Setenv 并非线程安全,而 C 也不想修复它

Unix 的 `setenv`/`getenv` 环境变量 API 从根本上就不是线程安全的,而 POSIX 明确允许这一点,这会在现代多线程程序(或 Go、Rust 这类语言)依赖它时偶尔导致非常棘手的崩溃。评论者争论真正的 bug 是否是可变的、进程全局的环境本身;许多人认为环境变量在启动后就应被视为不可变,库也应提供显式配置 API。另一些人则指出,已有一些线程安全的实现(如 Solaris/Illumos 和类似 Windows 的复制到调用方缓冲区的调用),并呼吁要么提供更安全的 C 库新函数,要么干脆推出一个“后 C”标准库,移除这些历史包袱,同时保留兼容性。

核心问题:setenv/getenv 与线程安全

  • POSIX 明确指出,setenv/unsetenv 并不要求线程安全;getenv 返回的指针可能会在后续调用中失效。
  • 在多线程程序中,setenvgetenv(或任何使用环境变量的 libc 调用)并发竞争,可能导致崩溃或产生错误数据。
  • 有人认为这使得该 API “坏掉且无法修复”,因为它暴露了全局可变状态,却没有安全的替代版本。

为什么难以修复

  • 程序可以通过多种渠道修改环境:setenv/putenv、直接写 environ,以及修改字符串本身。
  • 如果代码被允许直接触碰 environ,那么只给 setenv/getenv 加锁并不够。
  • getenv 的 API 返回内部指针;你无法安全地重排或释放底层存储,而不可能破坏现有代码。
  • 向后兼容是主要反对意见:大量遗留代码依赖当前语义。

系统和语言之间的差异

  • 一些 libc(Solaris/Illumos、部分 BSD、Apple)会加锁,并且常常“泄漏”旧的环境存储以避免 use-after-free;其他实现(musl、部分 BSD)则完全不加锁。
  • glibc 历史上存在不安全竞争;较新的版本在 setenv 周围加了锁,但当环境被扩容时,getenv 仍然会有竞争。
  • Windows API(GetEnvironmentVariableGetEnvironmentStrings)会复制到调用方缓冲区,因此实际上是线程安全的。
  • Rust 的标准库用锁包装环境访问并返回拥有所有权的副本,但通过 FFI 调用 C 仍然可能破坏不变量;这曾在时区处理上引发过真实的 CVE。
  • Go 也曾遇到类似问题,因为它的 DNS/时间代码会调用读取环境变量的 libc。

使用场景以及环境变量是否应当可变

  • 许多人认为进程环境应被视为不可变:在启动时或 forkexec 之间设置,而不是作为运行时配置总线使用。
  • 也有人指出现实中的用例:shell、测试框架、调试器、进程启动器,以及只通过环境变量暴露配置的库。

提议的修复与变通方案

  • 新 API:可重入/线程安全的 getenv_r/getenv_s/tgetenv,将结果复制到调用方缓冲区;也可以考虑逐步弃用旧 API。
  • 实现技巧:泄漏旧的环境块(Solaris/Illumos、Eyra)以避免 UAF;添加内部互斥锁;或者在多线程代码中对 setenv 直接崩溃/ panic,以暴露 bug。
  • 更高层的建议:线程启动后不要修改环境;避免使用会这么做的库;改用显式配置 API,或者在 execve/posix_spawn 中传递 envp

元讨论:责任与标准

  • 一方认为:C/POSIX 按文档来说是“正确”的;程序员必须阅读手册页,并在多线程代码中避免使用非可重入 API。
  • 另一方认为:把危险边角写进文档,并不意味着应该保留它;标准应当演进以减少踩坑,即使这意味着需要新的 API 和更高的复杂度。