如何在同一个管道中读写同一个文件总是“失败”?

kar*_*sss 9 bash process pipe io-redirection

假设我有以下脚本:

#!/bin/bash
for i in $(seq 1000)
do
    cp /etc/passwd tmp
    cat tmp | head -1 | head -1 | head -1 > tmp  #this is the key line
    cat tmp
done
Run Code Online (Sandbox Code Playgroud)

在关键线上,我读取和写入tmp有时会失败的同一个文件。

(我读它是因为竞争条件,因为管道中的进程是并行执行的,我不明白为什么 - 每个人都head需要从前一个中获取数据,不是吗?这不是我的主要问题,但你也可以回答。)

当我运行脚本时,它输出大约 200 行。有什么办法可以强制这个脚本总是输出 0 行(所以 I/O 重定向tmp总是首先准备好,所以数据总是被破坏)?明确地说,我的意思是更改系统设置,而不是这个脚本。

谢谢你的想法。

Gil*_*il' 19

为什么存在竞争条件

管道的两侧是并行执行的,而不是一个接一个。有一个非常简单的方法来证明这一点:运行

time sleep 1 | sleep 1
Run Code Online (Sandbox Code Playgroud)

这需要一秒钟,而不是两秒钟。

shell 启动两个子进程并等待它们完成。这两个进程并行执行:其中一个与另一个同步的唯一原因是它需要等待另一个。最常见的同步点是当右侧阻塞等待数据在其标准输入上读取时,当左侧写入更多数据时变为非阻塞状态。反过来也可能发生,当右侧读取数据很慢而左侧阻止其写入操作直到右侧读取更多数据时(管道本身有一个缓冲区,由管道管理)内核,但它的最大大小很小)。

要观察同步点,请观察以下命令(sh -x在执行时打印每个命令):

time sh -x -c '{ sleep 1; echo a; } | { cat; }'
time sh -x -c '{ echo a; sleep 1; } | { cat; }'
time sh -x -c '{ echo a; sleep 1; } | { sleep 1; cat; }'
time sh -x -c '{ sleep 2; echo a; } | { cat; sleep 1; }'
Run Code Online (Sandbox Code Playgroud)

尝试各种变化,直到您对观察到的内容感到满意为止。

鉴于复合命令

cat tmp | head -1 > tmp
Run Code Online (Sandbox Code Playgroud)

左侧流程执行以下操作(我只列出了与我的解释相关的步骤):

  1. 执行cat带有参数的外部程序tmp
  2. 打开tmp阅读。
  3. 当它还没有到达文件末尾时,从文件中读取一个块并将其写入标准输出。

右侧过程执行以下操作:

  1. 将标准输出重定向到tmp,在此过程中截断文件。
  2. 执行head带有参数的外部程序-1
  3. 从标准输入读取一行并将其写入标准输出。

唯一的同步点是 right-3 等待 left-3 处理完一行。left-2 和 right-1 之间没有同步,因此它们可以按任一顺序发生。它们发生的顺序是不可预测的:它取决于 CPU 架构、外壳、内核、进程恰好在哪些内核上被调度、CPU 在那个时间收到的中断等。

如何改变行为

您无法通过更改系统设置来更改行为。计算机按照您的指示执行操作。你告诉它并行截断tmp和读取tmp,所以它并行做这两件事。

好的,您可以更改一个“系统设置”:您可以替换/bin/bash为非 bash 的其他程序。我希望不用说这不是一个好主意。

如果您希望截断发生在管道的左侧之前,则需要将其放在管道之外,例如:

{ cat tmp | head -1; } >tmp
Run Code Online (Sandbox Code Playgroud)

或者

( exec >tmp; cat tmp | head -1 )
Run Code Online (Sandbox Code Playgroud)

我不知道你为什么想要这个。从您知道为空的文件中读取有什么意义?

相反,如果您希望在cat完成读取后发生输出重定向(包括截断),那么您需要在内存中完全缓冲数据,例如

line=$(cat tmp | head -1)
printf %s "$line" >tmp
Run Code Online (Sandbox Code Playgroud)

或写入不同的文件,然后将其移动到位。这通常是在脚本中执行操作的稳健方式,并且具有在文件通过原始名称可见之前被完整写入的优点。

cat tmp | head -1 >new && mv new tmp
Run Code Online (Sandbox Code Playgroud)

moreutils集合包括一个程序,做到了这一点,所谓的sponge

cat tmp | head -1 | sponge tmp
Run Code Online (Sandbox Code Playgroud)

如何自动检测问题

如果您的目标是采用写得不好的脚本并自动找出它们在哪里出错,那么抱歉,生活并没有那么简单。运行时分析无法可靠地找到问题,因为有时会cat在截断发生之前完成读取。静态分析原则上可以做到;您问题中的简化示例已被Shellcheck捕获,但它可能无法在更复杂的脚本中捕获类似问题。


Pet*_*des 2

吉尔斯的回答解释了竞争条件。我只想回答这部分:

有什么方法可以强制此脚本始终输出 0 行(因此始终先准备好到 tmp 的 I/O 重定向,因此数据始终会被破坏)?明确地说,我的意思是更改系统设置

我不知道是否已经存在这样的工具,但我知道如何实现该工具。(但请注意,这并不总是0行,只是一个有用的测试器,可以轻松捕获像这样的简单竞赛,以及一些更复杂的竞赛。请参阅@Gilles 的评论。) 它不能保证脚本是安全的,但可能会是一个有用的测试工具,类似于在不同 CPU 上测试多线程程序,包括 ARM 等弱序非 x86 CPU。

你可以将其运行为racechecker bash foo.sh

strace -f使用与ltrace -f附加到每个子进程相同的系统调用跟踪/拦截工具。ptrace(在 Linux 上,这与 GDB 和其他调试器用于设置断点、单步执行和修改另一个进程的内存/寄存器的系统调用相同。)

检测openopenat系统调用:当在此工具下运行的任何进程使用 进行系统open(2)调用(或openat)时O_RDONLY,睡眠可能为 1/2 或 1 秒。让其他open系统调用(尤其是包括O_TRUNC)立即执行。

这应该允许编写者在几乎所有竞争条件下赢得比赛,除非系统负载也很高,或者这是一个复杂的竞争条件,直到其他读取之后才发生截断。因此,延迟open()s(可能还有read()s 或写入)的随机变化会增加该工具的检测能力,但当然,无需使用延迟模拟器进行无限时间的测试,该模拟器最终将涵盖您可能遇到的所有可能情况。在现实世界中,您无法确定您的脚本没有竞争,除非您仔细阅读并证明它们没有。


您可能需要它将open文件列入白名单(而不是延迟)/usr/bin/usr/lib因此进程启动不会永远持续下去。(运行时动态链接必须到open()多个文件(查看strace -eopen /bin/true/bin/ls有时),尽管如果父 shell 本身正在执行截断,那也可以。但对于这个工具来说,不使脚本变得不合理地慢仍然是有好处的)。

或者可能将调用进程首先无权截断的每个文件列入白名单。即跟踪进程可以access(2)在实际挂起需要open()文件的进程之前进行系统调用。


racechecker本身必须用 C 语言编写,而不是用 shell 编写,但可以使用strace的代码作为起点,并且可能不需要太多工作来实现。

您也许可以使用 FUSE 文件系统获得相同的功能。可能有一个纯直通文件系统的 FUSE 示例,因此您可以向open()其中的函数添加检查,使其在只读打开时休眠,但让截断立即发生。