假设我有以下脚本:
#!/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
在关键行上,我读取和写入同一个文件tmp
,有时会失败。
(我读到这是因为竞争条件,因为管道中的进程是并行执行的,我不明白为什么 - 每个进程都head
需要从前一个进程中获取数据,不是吗?这不是我的主要问题,但你也可以回答。)
当我运行该脚本时,它输出大约 200 行。有什么方法可以强制此脚本始终输出 0 行(因此 I/O 重定向tmp
始终首先准备好,因此数据始终被破坏)?需要明确的是,我的意思是更改系统设置,而不是此脚本。
感谢您的想法。
答案1
为什么存在竞争条件
管道的两侧是并行执行的,而不是一个接一个。有一个非常简单的方法来演示这一点:运行
time sleep 1 | sleep 1
这需要一秒钟,而不是两秒钟。
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; }'
尝试各种变化,直到您对所观察到的结果感到满意为止。
给定复合命令
cat tmp | head -1 > tmp
左侧进程执行以下操作(我只列出了与我的解释相关的步骤):
cat
使用参数执行外部程序tmp
。- 开放
tmp
阅读。 - 当它尚未到达文件末尾时,从文件中读取一个块并将其写入标准输出。
右侧流程执行以下操作:
- 将标准输出重定向到
tmp
,并截断进程中的文件。 head
使用参数执行外部程序-1
。- 从标准输入读取一行并将其写入标准输出。
唯一的同步点是 right-3 等待 left-3 处理完一整行。 left-2 和 right-1 之间没有同步,因此它们可以按任意顺序发生。它们发生的顺序是不可预测的:它取决于 CPU 架构、shell、内核、进程恰好被调度在哪个内核上、CPU 在该时间接收到的中断等。
如何改变行为
您无法通过更改系统设置来更改行为。计算机会按照您的指示执行操作。您告诉它并行截断tmp
和读取tmp
,因此它并行执行这两件事。
好吧,有一个“系统设置”你可以改变:你可以/bin/bash
用一个不是 bash 的不同程序来替换。我希望不用说这不是一个好主意。
如果您希望截断发生在管道左侧之前,则需要将其放在管道之外,例如:
{ cat tmp | head -1; } >tmp
或者
( exec >tmp; cat tmp | head -1 )
我不知道你为什么想要这个。从一个你知道是空的文件中读取有什么意义呢?
相反,如果您希望在完成读取后发生输出重定向(包括截断)cat
,那么您需要将数据完全缓冲在内存中,例如
line=$(cat tmp | head -1)
printf %s "$line" >tmp
或写入不同的文件,然后将其移动到位。这通常是在脚本中执行操作的可靠方法,并且具有以下优点:文件在通过原始名称可见之前已被完整写入。
cat tmp | head -1 >new && mv new tmp
这更多实用程序Collection 包含一个专门执行此操作的程序,称为sponge
.
cat tmp | head -1 | sponge tmp
如何自动检测问题
如果您的目标是处理写得不好的脚本并自动找出它们的问题所在,那么抱歉,生活并没有那么简单。运行时分析无法可靠地找到问题,因为有时cat
会在截断发生之前完成读取。静态分析原则上可以做到;你问题中的简化示例被捕获外壳检查,但它可能无法在更复杂的脚本中捕获类似的问题。
答案2
吉尔斯的回答解释了竞争条件。我只想回答这部分:
有什么方法可以强制此脚本始终输出 0 行(因此始终先准备好到 tmp 的 I/O 重定向,因此数据始终会被破坏)?明确地说,我的意思是更改系统设置
我不知道是否已经存在这样的工具,但我知道如何实现该工具。 (但请注意,这不会是总是0行,只是一个有用的测试器,可以轻松捕捉这样的简单比赛,并且一些更复杂的比赛。看@Gilles 的评论.) 它不能保证脚本是安全的,但在测试中可能是一个有用的工具,类似于在不同 CPU 上测试多线程程序,包括弱排序的非 x86 CPU,如 ARM。
你可以将其运行为racechecker bash foo.sh
strace -f
使用与ltrace -f
附加到每个子进程相同的系统调用跟踪/拦截工具。 (在 Linux 上,这是相同的ptrace
GDB 和其他调试器使用的系统调用设置断点、单步执行以及修改另一个进程的内存/寄存器。)
检测open
和openat
系统调用:当在此工具下运行的任何进程进行系统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()
函数添加检查,使其在只读打开时休眠,但让截断立即发生。