Following system colour scheme - Python 增强提案 Selected dark colour scheme - Python 增强提案 Selected light colour scheme - Python 增强提案

Python 增强提案

PEP 433 – 更易于抑制文件描述符继承

作者:
Victor Stinner <vstinner at python.org>
状态:
已取代
类型:
标准跟踪
创建日期:
2013年1月10日
Python 版本:
3.4
取代者:
446

目录

摘要

为创建文件描述符的函数添加一个新的可选 cloexec 参数,添加不同的方法来更改此参数的默认值,并添加四个新函数

  • os.get_cloexec(fd)
  • os.set_cloexec(fd, cloexec=True)
  • sys.getdefaultcloexec()
  • sys.setdefaultcloexec(cloexec)

基本原理

文件描述符有一个“在 exec 时关闭”标志,该标志指示文件描述符是否会被继承。

在 UNIX 上,如果设置了“在 exec 时关闭”标志,则文件描述符不会被继承:它将在子进程执行时关闭;否则,文件描述符将被子进程继承。

在 Windows 上,如果设置了“在 exec 时关闭”标志,则文件描述符不会被继承;如果“在 exec 时关闭”标志被清除,并且在调用 CreateProcess() 时将 bInheritHandles 参数设置为 TRUE(例如,当 subprocess.Popenclose_fds=False 创建时),则文件描述符会被子进程继承。Windows 没有“在 exec 时关闭”标志,而是有一个继承标志,其值正好相反。例如,设置“在 exec 时关闭”标志意味着清除句柄的 HANDLE_FLAG_INHERIT 标志。

Python 3.3 中的状态

在 UNIX 上,自 Python 3.2 起,subprocess 模块默认关闭大于 2 的文件描述符 [1]。父进程创建的所有文件描述符都会在子进程中自动关闭。

xmlrpc.server.SimpleXMLRPCServer 设置了监听套接字的“在 exec 时关闭”标志,而父类 socketserver.TCPServer 没有设置此标志。

在创建子进程或执行新程序时,文件描述符未关闭的其他情况包括:os.spawn*()os.exec*() 系列的函数,以及调用 exec()fork() + exec() 的第三方模块。在这种情况下,文件描述符会在父进程和子进程之间共享,这通常是出乎意料的,并会导致各种问题。

此 PEP 提议继续 Python 3.2 中所做的更改,将此问题修复到任何代码中,而不仅仅是使用 subprocess 的代码。

继承文件描述符的问题

在父进程中关闭文件描述符不会关闭相关资源(文件、套接字等),因为它在子进程中仍然打开。

TCPServer 的监听套接字在 exec() 时不会关闭:子进程能够接收来自新客户端的连接;如果父进程关闭监听套接字并在同一地址上创建新的监听套接字,它将收到“地址已在使用中”错误。

不关闭文件描述符可能导致资源耗尽:即使父进程关闭了所有文件,由于子进程中仍有文件打开,创建新的文件描述符可能会因“文件过多”而失败。

另请参阅以下问题

安全

泄露文件描述符是重大的安全漏洞。不受信任的子进程可以通过泄露的文件描述符读取敏感数据(如密码)并控制父进程。例如,这是从 chroot 逃逸的已知漏洞。

另请参阅 CERT 建议:FIO42-C. 确保不再需要文件时正确关闭文件

漏洞示例

原子性

在多线程应用程序中使用 fcntl() 设置“在 exec 时关闭”标志是不安全的。如果一个线程在创建文件描述符和调用 fcntl(fd, F_SETFD, new_flags) 之间调用了 fork()exec():文件描述符将被子进程继承。现代操作系统提供了在创建文件描述符期间设置该标志的函数,从而避免了竞态条件。

可移植性

Python 3.2 添加了 socket.SOCK_CLOEXEC 标志,Python 3.3 添加了 os.O_CLOEXEC 标志和 os.pipe2() 函数。在 Python 3.3 中,在打开文件和创建管道或套接字时,已经可以原子地设置“在 exec 时关闭”标志。

问题在于这些标志和函数不可移植:只有较新版本的操作系统才支持它们。O_CLOEXECSOCK_CLOEXEC 标志会被旧版 Linux 忽略,因此必须使用 fcntl(fd, F_GETFD) 检查 FD_CLOEXEC 标志。如果内核忽略 O_CLOEXECSOCK_CLOEXEC 标志,则需要调用 fcntl(fd, F_SETFD, flags) 来设置“在 exec 时关闭”标志。

注意

OpenBSD 5.2 之前的版本在使用 fork() 后再调用 exec() 时,不会关闭带有“在 exec 时关闭”标志的文件描述符,但如果 exec() 在不调用 fork() 的情况下调用,则可以正常工作。请尝试 openbsd_bug.py

范围

应用程序仍然需要在 fork() 后显式关闭文件描述符。“在 exec 时关闭”标志仅在 exec() 之后关闭文件描述符,因此是在 fork() + exec() 之后。

此 PEP 仅更改由 Python 标准库或使用标准库的模块创建的文件描述符的“在 exec 时关闭”标志。不使用标准库的第三方模块应进行修改以符合此 PEP。例如,可以使用新的 os.set_cloexec() 函数。

注意

有关 fork() 而非 exec() 的可能解决方案,请参阅 fork 后关闭文件描述符

提案

为创建文件描述符的函数添加一个新的可选 cloexec 参数,并提供不同的方法来更改此参数的默认值。

添加新函数

  • os.get_cloexec(fd:int) -> bool:获取文件描述符的“在 exec 时关闭”标志。并非在所有平台上都可用。
  • os.set_cloexec(fd:int, cloexec:bool=True):在文件描述符上设置或清除“在 exec 时关闭”标志。并非在所有平台上都可用。
  • sys.getdefaultcloexec() -> bool:获取 cloexec 参数的当前默认值
  • sys.setdefaultcloexec(cloexec: bool):设置 cloexec 参数的默认值

为...添加一个新的可选 cloexec 参数

  • asyncore.dispatcher.create_socket()
  • io.FileIO
  • io.open()
  • open()
  • os.dup()
  • os.dup2()
  • os.fdopen()
  • os.open()
  • os.openpty()
  • os.pipe()
  • select.devpoll()
  • select.epoll()
  • select.kqueue()
  • socket.socket()
  • socket.socket.accept()
  • socket.socket.dup()
  • socket.socket.fromfd
  • socket.socketpair()

cloexec 参数的默认值是 sys.getdefaultcloexec()

为默认设置“在 exec 时关闭”标志添加了一个新的命令行选项 -e 和环境变量 PYTHONCLOEXEC

subprocess 清除 pass_fds 参数的文件描述符的“在 exec 时关闭”标志。

标准库中所有创建文件描述符的函数都必须遵守 cloexec 参数的默认值:sys.getdefaultcloexec()

文件描述符 0 (stdin)、1 (stdout) 和 2 (stderr) 预期会被继承,但 Python 不会区别对待它们。当使用 os.dup2() 替换标准流时,必须显式指定 cloexec=False

提案的缺点

  • 通过阅读源代码,已无法知道新创建的文件描述符是否会设置“在 exec 时关闭”标志。
  • 如果文件描述符的继承很重要,现在必须显式指定 cloexec 参数,否则库或应用程序将根据 cloexec 参数的默认值而失败。

备选方案

默认启用继承,默认不可配置

为创建文件描述符的函数添加一个新的可选参数 cloexeccloexec 参数的默认值为 False,并且此默认值无法更改。文件描述符默认启用继承也是 POSIX 和 Windows 上的默认行为。此替代方案是最保守的选择。

此选项并未解决 理由 部分列出的问题,它只提供了一个修复它们的辅助工具。所有创建文件描述符的函数都必须进行修改,在应用程序使用的每个模块中设置 cloexec=True 以修复所有这些问题。

默认启用继承,默认只能设置为 True

此替代方案基于该提案:唯一区别是 sys.setdefaultcloexec() 不接受任何参数,它只能用于将 cloexec 参数的默认值设置为 True

默认禁用继承

此替代方案基于该提案:唯一区别是 cloexec 参数的默认值是 True(而不是 False)。

如果文件必须被子进程继承,可以使用 cloexec=False 参数。

默认设置“在 exec 时关闭”标志的优点

默认设置“在 exec 时关闭”标志的缺点

  • 这违反了最小惊讶原则。使用 os 模块的开发人员可能会期望 Python 遵守 POSIX 标准,因此“在 exec 时关闭”标志默认情况下不设置。
  • os 模块被编写为系统调用的(C 标准库函数)薄包装器。如果不支持原子标志来设置“在 exec 时关闭”标志(参见 附录:操作系统支持),单个 Python 函数调用可能会调用 2 或 3 个系统调用(参见 性能 部分)。
  • 额外的系统调用(如果有)可能会降低 Python 的速度:参见 性能

向后兼容性:只有少数程序依赖于文件描述符的继承,并且它们只传递少数文件描述符,通常只有一个。这些程序会立即因 EBADF 错误而失败,修复它们很简单:添加 cloexec=False 参数或使用 os.set_cloexec(fd, False)

无论如何,subprocess 模块都将被更改,以清除 Popen 构造函数的 pass_fds 参数中列出的文件描述符的“在 exec 时关闭”标志。因此,如果这些程序使用 subprocess 模块,它们可能不需要任何修复。

fork 后关闭文件描述符

此 PEP 无法解决使用 fork() 而非 exec() 的应用程序的问题。Python 需要一个通用的进程来注册回调函数,这些函数将在 fork 后调用,参见 #16500:添加 atfork 模块。可以使用这样的注册表在 fork() 之后关闭文件描述符。

缺点

  • 它无法解决 Windows 上的问题:Windows 上不存在 fork()
  • 此替代方案无法解决使用 exec() 而非 fork() 的程序的问题。
  • 第三方模块可能会直接调用 C 函数 fork(),而这不会调用“atfork”回调。
  • 所有创建文件描述符的函数都必须被修改为注册一个回调,然后在文件关闭时注销回调。或者必须维护一个所有打开文件描述符的列表。
  • 操作系统是比 Python 更好的关闭文件描述符的地方。例如,很难避免在关闭文件和注销关闭文件的回调之间发生竞态条件。

open(): 为 mode 添加“e”标志

一个新的“e”模式将设置“在 exec 时关闭”标志(尽力而为)。

此替代方案仅解决了 open() 的问题。例如,socket.socket() 和 os.pipe() 没有 mode 参数。

自 2.7 版本起,GNU libc 支持 fopen()"e" 标志。它使用 O_CLOEXEC(如果可用),或者使用 fcntl(fd, F_SETFD, FD_CLOEXEC)。使用 Visual Studio 时,fopen() 接受一个“N”标志,该标志使用 O_NOINHERIT

为新参数名称进行的“鲱鱼赛跑”

  • inherit, inherited: 更接近 Windows 的定义
  • sensitive (敏感)
  • sterile: “不产生后代。”

使用文件描述符继承的应用程序

大多数开发人员不知道文件描述符默认会被继承。大多数程序不依赖于文件描述符的继承。例如,subprocess.Popen 在 Python 3.2 中被修改为默认关闭子进程中大于 2 的所有文件描述符。到目前为止还没有用户抱怨此行为更改。

使用 fork 的网络服务器可能希望将客户端套接字传递给子进程。例如,在 UNIX 上,CGI 服务器使用 dup2() 通过文件描述符 0(stdin)和 1(stdout)传递客户端套接字。

要访问受限制的资源,例如在低于 1024 的 TCP 端口上创建监听套接字或读取包含密码等敏感数据的文件的操作,一种常见做法是:以 root 用户身份启动,创建文件描述符,创建子进程,放弃特权(例如更改当前用户),将文件描述符传递给子进程,然后退出父进程。

安全性在此类用例中非常重要:泄露其他文件描述符将是一个严重的安全漏洞(参见 安全性)。root 进程可能不会退出,而是监控子进程,并在前一个子进程崩溃时重新启动新的子进程并传递相同的文件描述符。

通过命令行选项从父进程获取文件描述符的程序示例

  • gpg: --status-fd <fd>, --logger-fd <fd>
  • openssl: -pass fd:<fd>
  • qemu: -add-fd <fd>
  • valgrind: --log-fd=<fd>, --input-fd=<fd>
  • xterm: -S <fd>

在 Linux 上,可以使用 "/dev/fd/<fd>" 文件名将文件描述符传递给期望文件名的程序。

性能

设置“在 exec 时关闭”标志可能需要为每个新文件描述符的创建进行额外的系统调用。额外系统调用的数量取决于设置标志的方法

  • O_NOINHERIT: 无额外系统调用
  • O_CLOEXEC: 一次额外系统调用,但仅在创建第一个文件描述符时,用于检查是否支持该标志。如果标志不受支持,Python 将回退到下一种方法。
  • ioctl(fd, FIOCLEX): 每个文件描述符一次额外系统调用
  • fcntl(fd, F_SETFD, flags): 每个文件描述符两次额外系统调用,一次用于获取旧标志,一次用于设置新标志

在 Linux 上,设置“在 exec 时关闭”标志对性能影响很小。Linux 3.6 上 bench_cloexec.py 的结果

  • 未设置“在 exec 时关闭”标志:7.8 us
  • O_CLOEXEC: 慢 1% (7.9 us)
  • ioctl(): 慢 3% (8.0 us)
  • fcntl(): 慢 3% (8.0 us)

实施

os.get_cloexec(fd)

获取文件描述符的“在 exec 时关闭”标志。

伪代码

if os.name == 'nt':
    def get_cloexec(fd):
        handle = _winapi._get_osfhandle(fd);
        flags = _winapi.GetHandleInformation(handle)
        return not(flags & _winapi.HANDLE_FLAG_INHERIT)
else:
    try:
        import fcntl
    except ImportError:
        pass
    else:
        def get_cloexec(fd):
            flags = fcntl.fcntl(fd, fcntl.F_GETFD)
            return bool(flags & fcntl.FD_CLOEXEC)

os.set_cloexec(fd, cloexec=True)

在文件描述符上设置或清除“在 exec 时关闭”标志。该标志在文件描述符创建后设置,因此不是原子的。

伪代码

if os.name == 'nt':
    def set_cloexec(fd, cloexec=True):
        handle = _winapi._get_osfhandle(fd);
        mask = _winapi.HANDLE_FLAG_INHERIT
        if cloexec:
            flags = 0
        else:
            flags = mask
        _winapi.SetHandleInformation(handle, mask, flags)
else:
    fnctl = None
    ioctl = None
    try:
        import ioctl
    except ImportError:
        try:
            import fcntl
        except ImportError:
            pass
    if ioctl is not None and hasattr('FIOCLEX', ioctl):
        def set_cloexec(fd, cloexec=True):
            if cloexec:
                ioctl.ioctl(fd, ioctl.FIOCLEX)
            else:
                ioctl.ioctl(fd, ioctl.FIONCLEX)
    elif fnctl is not None:
        def set_cloexec(fd, cloexec=True):
            flags = fcntl.fcntl(fd, fcntl.F_GETFD)
            if cloexec:
                flags |= FD_CLOEXEC
            else:
                flags &= ~FD_CLOEXEC
            fcntl.fcntl(fd, fcntl.F_SETFD, flags)

相比于 fcntl,ioctl 更受青睐,因为它只需要一次系统调用,而 fcntl 需要两次。

注意

fcntl(fd, F_SETFD, flags) 只支持一个标志(FD_CLOEXEC),因此可以避免 fcntl(fd, F_GETFD)。但它将来可能会丢弃其他标志,因此保持两个函数调用更安全。

注意

fopen() 函数的 GNU libc 在 fcntl(fd, F_SETFD, flags) 失败时会忽略错误。

open()

  • Windows: open(),带有 O_NOINHERIT 标志 [原子]
  • open(),带有 O_CLOEXEC 标志 [原子]
  • open() + os.set_cloexec(fd, True) [尽力而为]

os.dup()

  • Windows: DuplicateHandle() [原子]
  • fcntl(fd, F_DUPFD_CLOEXEC) [原子]
  • dup() + os.set_cloexec(fd, True) [尽力而为]

os.dup2()

  • fcntl(fd, F_DUP2FD_CLOEXEC, fd2) [原子]
  • dup3(),带有 O_CLOEXEC 标志 [原子]
  • dup2() + os.set_cloexec(fd, True) [尽力而为]

os.pipe()

  • Windows: CreatePipe(),带有 SECURITY_ATTRIBUTES.bInheritHandle=TRUE,或 _pipe(),带有 O_NOINHERIT 标志 [原子]
  • pipe2(),带有 O_CLOEXEC 标志 [原子]
  • pipe() + os.set_cloexec(fd, True) [尽力而为]

socket.socket()

  • Windows: WSASocket(),带有 WSA_FLAG_NO_HANDLE_INHERIT 标志 [原子]
  • socket(),带有 SOCK_CLOEXEC 标志 [原子]
  • socket() + os.set_cloexec(fd, True) [尽力而为]

socket.socketpair()

  • socketpair(),带有 SOCK_CLOEXEC 标志 [原子]
  • socketpair() + os.set_cloexec(fd, True) [尽力而为]

socket.socket.accept()

  • accept4(),带有 SOCK_CLOEXEC 标志 [原子]
  • accept() + os.set_cloexec(fd, True) [尽力而为]

向后兼容性

没有向后不兼容的更改。默认行为未改变:默认情况下不设置“在 exec 时关闭”标志。

附录:操作系统支持

Windows

Windows 有一个 O_NOINHERIT 标志:“在子进程中不要继承”。

例如,它支持 open()_pipe()

可以使用 SetHandleInformation(fd, HANDLE_FLAG_INHERIT, 0) 来清除该标志。

CreateProcess() 有一个 bInheritHandles 参数:如果为 FALSE,则不继承句柄。如果为 TRUE,则继承设置为 HANDLE_FLAG_INHERIT 标志的句柄。subprocess.Popen 使用 close_fds 选项来定义 bInheritHandles

ioctl

函数

  • ioctl(fd, FIOCLEX, 0): 设置“在 exec 时关闭”标志
  • ioctl(fd, FIONCLEX, 0): 清除“在 exec 时关闭”标志

可用性:Linux、Mac OS X、QNX、NetBSD、OpenBSD、FreeBSD。

fcntl

函数

  • flags = fcntl(fd, F_GETFD); fcntl(fd, F_SETFD, flags | FD_CLOEXEC): 设置“在 exec 时关闭”标志
  • flags = fcntl(fd, F_GETFD); fcntl(fd, F_SETFD, flags & ~FD_CLOEXEC): 清除“在 exec 时关闭”标志

可用性:AIX、Digital UNIX、FreeBSD、HP-UX、IRIX、Linux、Mac OS X、OpenBSD、Solaris、SunOS、Unicos。

原子标志

新标志

  • O_CLOEXEC: 可在 Linux (2.6.23)、FreeBSD (8.3)、OpenBSD 5.0、Solaris 11、QNX、BeOS、下一个 NetBSD 版本 (6.1?) 上使用。此标志是 POSIX.1-2008 的一部分。
  • SOCK_CLOEXEC 标志,用于 socket()socketpair(),可在 Linux 2.6.27、OpenBSD 5.2、NetBSD 6.0 上使用。
  • WSA_FLAG_NO_HANDLE_INHERIT 标志,用于 WSASocket():在 Windows 7 SP1、Windows Server 2008 R2 SP1 及更高版本上受支持。
  • fcntl(): F_DUPFD_CLOEXEC 标志,可在 Linux 2.6.24、OpenBSD 5.0、FreeBSD 9.1、NetBSD 6.0、Solaris 11 上使用。此标志是 POSIX.1-2008 的一部分。
  • fcntl(): F_DUP2FD_CLOEXEC 标志,可在 FreeBSD 9.1 和 Solaris 11 上使用。
  • recvmsg(): MSG_CMSG_CLOEXEC,可在 Linux 2.6.23、NetBSD 6.0 上使用。

在早于 2.6.23 的 Linux 版本上,O_CLOEXEC 标志会被简单地忽略。因此,我们必须通过调用 fcntl() 来检查该标志是否受支持。如果无效,我们必须使用 ioctl()fcntl() 来设置该标志。

在早于 2.6.27 的 Linux 版本上,如果在套接字类型中设置了 SOCK_CLOEXEC 标志,则 socket()socketpair() 会失败,并且 errno 设置为 EINVAL

在 Windows XPS3 上,使用 WSA_FLAG_NO_HANDLE_INHERIT 标志调用 WSASocket() 时出现 WSAEPROTOTYPE

新函数

  • dup3(): 可在 Linux 2.6.27 (和 glibc 2.9) 上使用
  • pipe2(): 可在 Linux 2.6.27 (和 glibc 2.9) 上使用
  • accept4(): 可在 Linux 2.6.28 (和 glibc 2.10) 上使用

如果在早于 2.6.28 的 Linux 版本上调用 accept4()accept4() 将返回 -1 (失败),并且 errno 设置为 ENOSYS

脚注


来源:https://github.com/python/peps/blob/main/peps/pep-0433.rst

最后修改:2025-02-01 08:59:27 GMT