【问题标题】:Why does printf() in the parent almost always win the race condition after fork()?为什么父级中的 printf() 几乎总是在 fork() 之后赢得竞争条件?
【发布时间】:2021-04-19 05:12:44
【问题描述】:

有一个有点著名的 Unix 脑筋急转弯:写一个if 表达式,让下面的程序在屏幕上打印Hello, world!if 中的 expr 必须是合法的 C 表达式,并且不应包含其他程序结构。

if (expr)
    printf("Hello, ");
else
    printf("world!\n");

答案是fork()

我年轻的时候,只是笑了笑就忘记了。但是重新考虑它,我发现我无法理解为什么这个程序比它应该的更可靠。 fork() 之后的执行顺序无法保证,并且存在竞争条件,但在实践中,您几乎总是会看到 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次后,孩子只赢了146次,父母赢的概率为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)。孩子只赢了 68 次,或 0.00067% 的时间,而父母赢了 99.993% 的处决。

一个有趣的旁注是ktrace ./fork 立即将主要结果更改为world\nHello, (因为只跟踪父级),这表明了问题的 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

为什么在实践中父级中的printf() 几乎总是在fork() 之后赢得比赛条件?是不是和C标准库中printf()的内部实现细节有关? write() 系统调用?还是 Unix 内核中的进程调度?

【问题讨论】:

  • 这能回答你的问题吗? printf anomaly after "fork()"
  • @UmairMubeen 这个问题不是关于缓冲问题,而是关于操作系统用来决定应该运行哪个进程的启发式方法。
  • fork 可能不是调度抢占点。因此,除非父进程恰好在 fork 之后用完其时间片,否则它很可能在子进程运行之前完成所有操作。
  • @kaylum 完全有道理。我发现运行一些 CPU 时间浪费 xz /dev/urandom -c > /dev/null(迫使调度更频繁地运行)足以显着增加孩子在比赛条件下获胜的可能性。现在孩子在 100001 次处决中获胜 16116 次,获胜概率从 0.001% 提升到 10%。
  • 注,这也适用:if (!printf("Hello, "))(我知道,这不是你的实际问题......)

标签: c unix fork race-condition


【解决方案1】:

fork被执行时,执行它的进程(新的父进程)正在执行(当然),而新创建的子进程不是。要让子进程运行,要么必须停止父进程并为子进程分配处理器,要么必须在另一个处理器上启动子进程,这需要时间。同时,父进程继续执行。

除非发生一些不相关的事件,例如父级耗尽了分配给共享处理器的时间片,否则它会赢得比赛。

【讨论】:

  • 在过去,情况正好相反。绝大多数机器只有一个核心,运行子进程首先允许它快速到达exec,这是它要做的非常常见的情况,避免需要复制父进程修改的每一页内存当它恢复时(以防孩子想访问以前的内容)。
  • @DavidSchwartz 我刚刚尝试了在 DEC VAX-780 模拟器上运行的原始 4.3BSD 中的程序,得到了相同的结果,父母总是赢,我一定是想多了。您所指的“过去”是几岁?
  • 子进程必须等待父进程被阻塞或取消调度才能让子进程开始运行是不正确的(但父进程在子进程之前准备好运行仍然是正确的,原因我在我的回答中解释)。在真正的多处理器或多核系统中,两个进程都可以在不同的处理器/内核中同时执行。
  • @LuisColorado:这个答案没有说明子进程必须等待父进程被阻止或取消调度。它明确指出子进程可能在另一个处理器上运行。
  • 即使子进程在另一个处理器中运行(这是多处理器系统中最可能发生的事情),只要系统有子进程的新进程 ID,父进程就会被解除阻塞,而子进程必须等待他的进程完全构建,使其不得不等待更多时间才能被释放运行。
【解决方案2】:

当您执行printf(3) 将字符串输出到终端(到任何tty 设备,这在stdio 包内通过isatty(3) 调用进行检查)时,stdio 包在 行模式缓冲,意思是在写入终端之前积累输出的内部缓冲区刷新缓冲区:

  • 如果缓冲区完全填满(这不会发生,因为字符串太短,而缓冲区通常是最佳性能大小或大约 16kb ---这是 BSD unix 中 ufs2 文件系统的值) , 或...
  • 如果输出包含\n 行分隔符(这只发生在父代码中,见下文)刷新发生在\n 的位置。

由于您的父代码(接收到子进程 ID 的 pid_t 的代码)是执行包含 \n 字符的 printf(3) 的代码,因此它的缓冲区在执行时被刷新printf() 调用,而子缓冲区将在exit(3) 系统调用时被刷新,作为atexit(3) 处理的一部分。您可以通过在父级和子级中调用_exit(2)(不调用退出处理程序的exit(3) 版本)来测试这一点,您将看到屏幕上只显示父级输出。

正如你所说,有一个竞争条件,所以如果孩子被执行到最后,在父母有时间执行其printf(3) 之前,你可以在最后得到父母的输出(只要把在父代码中调用sleep(3),在printf(3)之前,你会看到正确的顺序。最重要的是,第一个启动它的进程write(2)系统调用将是赢家(因为inode是在write(2) syscal 执行过程中被锁定,并且输出是有序的)。但是父进程只执行它的代码,中间没有任何系统调用,而子进程的序列是将字符串存储在缓冲区中并在从main() 返回后调用atexit(3) 函数列表时刷新它。这可能同时涉及多个系统调用,甚至会阻塞进程一段时间。

您也可以在子代码中放置\n,您可能会看到子进程正在调度并在父进程之前启动write(),尽管父进程仍然可能会继续获胜,因为很有可能它在允许子级启动之前被安排(这是因为启动fork(2) 的父级仅执行它的第一部分,例如检查创建子级并创建新进程表条目的权限给它从叉子返回所需的子进程 pid 号,允许父进程的 fork(2) 在子进程 id 已知后立即返回,而将内存段分配给新进程并准备执行是在孩子的fork() 后半部分。这意味着当父母已经以最快的速度运行时,孩子很可能会从fork() 调用返回到printf() 调用。但你无法控制这一点。

【讨论】:

  • Re“作为您的父代码(收到子进程 ID 的 pid_t 的那个)是执行带有包含的 \n 字符的 printf(3) 的代码”:@987654352带有换行符的@在else子句中,当fork返回零时执行,子句中。
  • @EricPostpischil,无论如何,我已经解释了所有可能导致子进程输掉比赛的原因。尽管我只是误解了第一个,但他们都做出了贡献。拥有\n 不是决定性因素,也可能是一个问题。你是对的,\n 在其他部分,对此我深表歉意。无论如何,正如 PO 所指出的,有一个竞争条件可以使子进程赢得比赛。但是我关于 fork() 调用的最后一段可以使孩子在父母之后很长一段时间内被解除阻塞。
猜你喜欢
  • 2010-09-25
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2012-01-01
  • 1970-01-01
  • 1970-01-01
  • 2016-11-16
相关资源
最近更新 更多