为什么 parent 中的 printf() 几乎总是在 fork() 之后赢得竞争条件?
Why does printf() in the parent almost always win the race condition after fork()?
Unix有个比较有名的brain-teaser:写一个if
表达式,让下面的程序在屏幕上打印Hello, world!
。 if
中的expr
必须是合法的C表达式,不能包含其他程序结构。
if (expr)
printf("Hello, ");
else
printf("world!\n");
答案是fork()
。
小时候只是一笑而忘。但是重新考虑一下,我发现我无法理解为什么这个程序比它应该的更可靠。 fork()
之后的执行顺序不是 gua运行teed 并且存在竞争条件,但在实践中,您几乎总是看到 Hello, world!\n
,而不是 world!\nHello,
.
为了演示,我运行程序进行了100,000轮。
for i in {0..100000}; do
./fork >> log
done
在Linux 5.9(Fedora 32,gcc 10.2.1,-O2
)上,执行100001次后,child只赢了146次,parent有获胜概率为 99.9985%。
$ uname -a
Linux openwork 5.9.14-1.qubes.x86_64 #1 SMP Tue Dec 15 17:29:47 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux
$ wc -l log
100001 log
$ grep ^world log | wc -l
146
FreeBSD 12.2(clang 10.0.1,-O2
)上的结果类似。 child 只赢了 68 次,占 0.00067%,而 parent 赢了 99.993%。
一个有趣的side-note是ktrace ./fork
立即将显性结果变为world\nHello,
(因为只跟踪parent),证明了Heisenbug 问题的本质。然而,通过 ktrace -i ./fork
跟踪这两个进程会恢复行为,因为这两个进程都被跟踪并且同样慢。
$ uname -a
FreeBSD freebsd 12.2-RELEASE-p1 FreeBSD 12.2-RELEASE-p1 GENERIC amd64
$ wc -l log
100001 log
$ grep ^world log | wc -l
68
独立于缓冲?
一个答案表明缓冲会影响这种竞争条件的行为。但是从 printf().
中删除 \n
后,该行为仍然存在
if (expr)
printf("Hello");
else
printf("World");
并在 FreeBSD 上通过 stdbuf
关闭标准输出的缓冲。
for i in {0..10000}; do
stdbuf -i0 -o0 -e0 ./fork >> log
echo > log
done
$ wc -l log
10001 log
$ grep -v "^HelloWorld" log | wc -l
30
为什么 parent 中的 printf()
在实践中几乎总是在 fork()
之后赢得比赛条件?是不是和C标准库中printf()
的内部实现细节有关? write()
系统调用?还是 Unix 内核中的进程调度?
当fork
执行时,执行它的进程(新的父进程)正在执行(当然),而新创建的子进程没有。对于 运行 的子进程,要么必须停止父进程并为子进程提供处理器,要么必须在另一个处理器上启动子进程,这需要时间。同时,父级继续执行。
除非发生一些不相关的事件,例如父级耗尽了共享处理器的时间片,否则它会赢得比赛。
当你执行 printf(3)
输出一个字符串到终端(到任何 tty 设备,这是在 stdio
包中检查的,通过 isatty(3)
调用), stdio
包在 行缓冲模式下工作 ,这意味着在将输出写入终端之前累积输出的内部缓冲区会刷新缓冲区:
- 如果缓冲区完全填满(这不会发生,因为字符串太短,而缓冲区通常是最佳性能大小或大约 16kb ---这是 BSD 中 ufs2 文件系统的值unix), 或...
- 如果输出包含
\n
行分隔符(这只发生在 parent 代码中,见下文)刷新发生在 \n
.[= 的位置51=]
因为您的 parent 代码(收到 child 的 pid_t
进程 ID 的代码)是执行 printf(3)
的代码,其中包含 \n
字符,它的缓冲区在执行 printf()
调用时被刷新,而 child 的缓冲区将在 exit(3)
系统调用时被刷新,作为 atexit(3)
处理的一部分。您可以通过在 parent 和 child 中调用 _exit(2)
(不调用 at-exit 处理程序的 exit(3)
版本)来测试它,以及您会看到屏幕上只显示 parent 输出。
如您所说,存在竞争条件,因此如果 child 执行到最后,在 parent 有时间执行其 printf(3)
之前您可以在最后获得 parent 的输出(只需在 parent 代码中 printf(3)
之前调用 sleep(3)
,您将看到正确的顺序。最重要的是,第一个启动它的 write(2)
系统调用的进程将成为赢家(因为在 write(2)
系统调用执行期间 inode 被锁定,并且输出是有序的)。但是parent 进程只执行它的代码,中间没有任何系统调用,而 child 进程的顺序是将字符串存储在缓冲区中,并在 atexit(3)
函数列表时刷新它在 returning 从 main()
之后调用。这可能同时涉及多个系统调用,甚至可能会暂时阻塞进程。
您还可以在 child 代码中放置一个 \n
,您可能会看到 child 进程被调度并在 write()
之前启动parent,尽管 parent 仍然有可能继续获胜,因为它很有可能在 child 被允许开始之前被安排(这是因为 parent 启动 fork(2)
只执行它的第一部分,例如检查创建 child 的权限并创建新进程 table 条目,赋予它 child's pid 号需要从 fork return,允许 parent 的 fork(2)
到 return 一旦 child 进程 ID 已知,同时分配将内存段添加到新进程并准备执行是在 child 的 fork()
后半部分完成的。这意味着 child 很可能会从 return fork()
当 parent 已经 运行 以最高速度调用 printf()
时调用。但是你无法控制它。
Unix有个比较有名的brain-teaser:写一个if
表达式,让下面的程序在屏幕上打印Hello, world!
。 if
中的expr
必须是合法的C表达式,不能包含其他程序结构。
if (expr)
printf("Hello, ");
else
printf("world!\n");
答案是fork()
。
小时候只是一笑而忘。但是重新考虑一下,我发现我无法理解为什么这个程序比它应该的更可靠。 fork()
之后的执行顺序不是 gua运行teed 并且存在竞争条件,但在实践中,您几乎总是看到 Hello, world!\n
,而不是 world!\nHello,
.
为了演示,我运行程序进行了100,000轮。
for i in {0..100000}; do
./fork >> log
done
在Linux 5.9(Fedora 32,gcc 10.2.1,-O2
)上,执行100001次后,child只赢了146次,parent有获胜概率为 99.9985%。
$ uname -a
Linux openwork 5.9.14-1.qubes.x86_64 #1 SMP Tue Dec 15 17:29:47 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux
$ wc -l log
100001 log
$ grep ^world log | wc -l
146
FreeBSD 12.2(clang 10.0.1,-O2
)上的结果类似。 child 只赢了 68 次,占 0.00067%,而 parent 赢了 99.993%。
一个有趣的side-note是ktrace ./fork
立即将显性结果变为world\nHello,
(因为只跟踪parent),证明了Heisenbug 问题的本质。然而,通过 ktrace -i ./fork
跟踪这两个进程会恢复行为,因为这两个进程都被跟踪并且同样慢。
$ uname -a
FreeBSD freebsd 12.2-RELEASE-p1 FreeBSD 12.2-RELEASE-p1 GENERIC amd64
$ wc -l log
100001 log
$ grep ^world log | wc -l
68
独立于缓冲?
一个答案表明缓冲会影响这种竞争条件的行为。但是从 printf().
中删除\n
后,该行为仍然存在
if (expr)
printf("Hello");
else
printf("World");
并在 FreeBSD 上通过 stdbuf
关闭标准输出的缓冲。
for i in {0..10000}; do
stdbuf -i0 -o0 -e0 ./fork >> log
echo > log
done
$ wc -l log
10001 log
$ grep -v "^HelloWorld" log | wc -l
30
为什么 parent 中的 printf()
在实践中几乎总是在 fork()
之后赢得比赛条件?是不是和C标准库中printf()
的内部实现细节有关? write()
系统调用?还是 Unix 内核中的进程调度?
当fork
执行时,执行它的进程(新的父进程)正在执行(当然),而新创建的子进程没有。对于 运行 的子进程,要么必须停止父进程并为子进程提供处理器,要么必须在另一个处理器上启动子进程,这需要时间。同时,父级继续执行。
除非发生一些不相关的事件,例如父级耗尽了共享处理器的时间片,否则它会赢得比赛。
当你执行 printf(3)
输出一个字符串到终端(到任何 tty 设备,这是在 stdio
包中检查的,通过 isatty(3)
调用), stdio
包在 行缓冲模式下工作 ,这意味着在将输出写入终端之前累积输出的内部缓冲区会刷新缓冲区:
- 如果缓冲区完全填满(这不会发生,因为字符串太短,而缓冲区通常是最佳性能大小或大约 16kb ---这是 BSD 中 ufs2 文件系统的值unix), 或...
- 如果输出包含
\n
行分隔符(这只发生在 parent 代码中,见下文)刷新发生在\n
.[= 的位置51=]
因为您的 parent 代码(收到 child 的 pid_t
进程 ID 的代码)是执行 printf(3)
的代码,其中包含 \n
字符,它的缓冲区在执行 printf()
调用时被刷新,而 child 的缓冲区将在 exit(3)
系统调用时被刷新,作为 atexit(3)
处理的一部分。您可以通过在 parent 和 child 中调用 _exit(2)
(不调用 at-exit 处理程序的 exit(3)
版本)来测试它,以及您会看到屏幕上只显示 parent 输出。
如您所说,存在竞争条件,因此如果 child 执行到最后,在 parent 有时间执行其 printf(3)
之前您可以在最后获得 parent 的输出(只需在 parent 代码中 printf(3)
之前调用 sleep(3)
,您将看到正确的顺序。最重要的是,第一个启动它的 write(2)
系统调用的进程将成为赢家(因为在 write(2)
系统调用执行期间 inode 被锁定,并且输出是有序的)。但是parent 进程只执行它的代码,中间没有任何系统调用,而 child 进程的顺序是将字符串存储在缓冲区中,并在 atexit(3)
函数列表时刷新它在 returning 从 main()
之后调用。这可能同时涉及多个系统调用,甚至可能会暂时阻塞进程。
您还可以在 child 代码中放置一个 \n
,您可能会看到 child 进程被调度并在 write()
之前启动parent,尽管 parent 仍然有可能继续获胜,因为它很有可能在 child 被允许开始之前被安排(这是因为 parent 启动 fork(2)
只执行它的第一部分,例如检查创建 child 的权限并创建新进程 table 条目,赋予它 child's pid 号需要从 fork return,允许 parent 的 fork(2)
到 return 一旦 child 进程 ID 已知,同时分配将内存段添加到新进程并准备执行是在 child 的 fork()
后半部分完成的。这意味着 child 很可能会从 return fork()
当 parent 已经 运行 以最高速度调用 printf()
时调用。但是你无法控制它。