给定多个进程,从具有共享文件描述符(表示 STDOUT/STDERR)的父进程分叉出来,如果其中一个进程写入 STDOUT 并超过 ~64K 缓冲区,它将阻塞(如预期)。在关闭其他进程上的所有共享文件描述符时,该进程将解除阻塞并继续写入 STDOUT。
关闭共享文件描述符的行为如何导致写阻塞进程解除阻塞? (我假设缓冲区正在被刷新,但我找不到这方面的证据)
为了重现,这里有两个设置状态的脚本。我的目标不是解决问题,而是了解关闭这些描述符的行为如何导致被阻止的进程继续。 (即意图是不是解决 Python 或子进程问题)
文件:A.py
#!/usr/bin/env python2.6
import subprocess
if __name__ == "__main__":
subprocess.Popen("./B.sh 70000", shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
subprocess.Popen("./B.sh 100", shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
subprocess.Popen("./B.sh 100", shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
文件:B.sh
#!/usr/bin/env bash
for i in `seq 1 $1`; do
echo -n "#"
done
echo ""
while true; do
echo > /dev/null
done
Python 2.6 的 subprocess.Popen 被用作建立状态的手段。在它的下面,管道和分叉(可能不是按这个顺序)当前进程为每个分叉进程复制其文件描述符,创建具有共享文件描述符的进程链。
shell 脚本B.sh
只是将数据输出到 STDOUT,然后循环(故意不进行睡眠,以便您可以在 htop 之类的东西中区分运行和睡眠状态)。
将两个脚本放在同一工作目录中并执行 A.py 来复制行为(CentOS 6.7,但我怀疑任何 CentOS 6.X 版本都可以复制)。
以下是演示共享状态的进程文件描述符的目录列表,以供参考:
# Process 1: ./B.sh 70000
ls -la /proc/4144/fd
total 0
lrwx------ 1 root root 64 Sep 28 14:18 0 -> /dev/pts/0
l-wx------ 1 root root 64 Sep 28 14:18 1 -> pipe:[53061]
l-wx------ 1 root root 64 Sep 28 14:18 2 -> pipe:[53061]
lr-x------ 1 root root 64 Sep 28 14:18 255 -> /root/B.sh
# Process 2: ./B.sh 100
ls -la /proc/4145/fd
total 0
lrwx------ 1 root root 64 Sep 28 14:18 0 -> /dev/pts/0
l-wx------ 1 root root 64 Sep 28 14:18 1 -> pipe:[53063]
l-wx------ 1 root root 64 Sep 28 14:18 2 -> pipe:[53063]
lr-x------ 1 root root 64 Sep 28 14:18 255 -> /root/B.sh
lr-x------ 1 root root 64 Sep 28 14:18 3 -> pipe:[53061]
# Process 3: ./B.sh 100
ls -la /proc/4146/fd
total 0
lrwx------ 1 root root 64 Sep 28 14:18 0 -> /dev/pts/0
l-wx------ 1 root root 64 Sep 28 14:18 1 -> pipe:[53065]
l-wx------ 1 root root 64 Sep 28 14:24 10 -> pipe:[53065]
l-wx------ 1 root root 64 Sep 28 14:18 2 -> pipe:[53065]
lr-x------ 1 root root 64 Sep 28 14:18 255 -> /root/B.sh
lr-x------ 1 root root 64 Sep 28 14:18 3 -> pipe:[53061]
lr-x------ 1 root root 64 Sep 28 14:18 4 -> pipe:[53063]
生成的第一个进程(进程 1)向 STDOUT 输出 ~>64K 数据,导致它进入睡眠状态(通过 htop 并将 strace 附加到 pid 可以看出),因为它在写入时被阻止。
第二个和第三个进程(分别为进程 2 和进程 3)将保持运行状态,并具有重复的文件描述符,这些文件描述符引用作为进程 1 的一部分建立的管道。
杀死进程 2 或进程 3 之一,进程 1 仍处于睡眠状态,杀死两者,进程 1 解锁(为什么?)并进入运行状态。
重新启动测试并使用gdb
附加到进程 2 或进程 3,然后关闭p close(#)
与进程 1 共享的文件描述符,进程 1 仍处于睡眠状态。附加到另一个进程并关闭共享描述符,进程 1 解除阻塞并进入运行状态。
因此,关闭所有与阻塞进程共享的描述符的行为会导致它解除阻塞。是什么原因导致这个先前被写阻塞的进程在这种情况下被释放?
答案1
一旦管道的读取端关闭,尝试写入就会出错。它将用 SIGPIPE 终止该进程。或者,如果该信号被阻止,写入将立即返回,并带有errno == EPIPE
。这应该可以解释你的行为。它是 UNIX 管道的原始特性之一。
当对管道读取端的最后一个剩余引用关闭时,就会发生这种情况。可以有其他参考文献,例如来自dup()
。
在您的情况下,您已经fork()
创建了一个新进程,因此子进程以所有相同的文件描述符开始。要关闭管道,必须关闭父级和子级的文件描述符。请注意,close()
父级中的文件描述符不会影响子级的文件描述符(反之亦然)。
这是引用计数一般概念的示例。内核记录有多少文件描述符引用管道的读取端。每次调用都会将计数减一close()
。如果计数降至零,内核将运行适当的清理函数。在 Linux 内核中,这是一个称为 的函数指针.release
,因为它释放所有关联的资源。
引用计数系统对于 UNIX 文件描述符至关重要。例如我可以找到dup() 和 fork()研究中使用UNIX V5。
如果你想知道为什么 SIGPIPE 在从 python2.6 开始的子进程中被阻止,请参见https://bugs.python.org/issue1652。
如果您对管道 FD 泄漏到 P2 和 P3 感到惊讶,请参阅https://bugs.python.org/issue7213。即要从中获得更明智的行为Popen()
,您可以通过close_fds=True
。
否则,如果你想要将特定的额外 FD 传递到 P2 和 P3,我真的想通过使用pass_fds
参数来明确这一点。
我假设您确实想要这样做,否则我真的不明白这个示例程序应该做什么。您正在丢弃子进程对象,然后退出。所以父进程正在关闭它的管道 FD,至少在它退出时是这样。
我们可以在 shell 中重现这一点,而不依赖于似乎可能取决于 python 特定版本的细节。
$ strace -f sh -c 'cat </dev/zero | { sleep 1& sleep 2& }'
...
[pid 26477] read(0, "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0"..., 131072) = 131072
[pid 26477] write(1, "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0"..., 131072 <unfinished ...>
...
[pid 26480] nanosleep({tv_sec=2, tv_nsec=0}, <unfinished ...>
...
[pid 26479] nanosleep({tv_sec=1, tv_nsec=0}, NULL) = 0
...
[pid 26479] +++ exited with 0 +++
[pid 26480] <... nanosleep resumed> NULL) = 0
...
[pid 26480] +++ exited with 0 +++
[pid 26477] <... write resumed> ) = 65536
[pid 26477] --- SIGPIPE {si_signo=SIGPIPE, si_code=SI_USER, si_pid=26477, si_uid=1001} ---
[pid 26477] +++ killed by SIGPIPE +++
我在这里注意到一个我以前没有想到的细节。write()
返回它仅成功将 64K 写入管道缓冲区。如果调用者禁用了 SIGPIPE 的默认终止操作,会发生什么情况? “短写入”是您经常必须通过重试在管道或套接字上容忍的事情。例如,如果进程接收到不相关的信号,并且为该信号设置了处理程序函数,则可能会发生这种情况。因此,调用者应该继续重试write()
剩余的数据,并且那 write()
调用将立即返回errno == EPIPE
。