mingw: 发送 STDIN 时挂起的解决方法
签字人:Alexandr Miloslavskiy
说明
这里的问题是有缺陷的poll() 实现。
当它试图查看是否可以在不阻塞的情况下写入管道时,它最终会调用NtQueryInformationFile() 并测试WriteQuotaAvailable。
但是,配额的含义被误解了。
当某些数据被写入管道时,配额的值会减少,或管道上有一个挂起的读取。
因此,如果等待读取的大小 >= 大于管道的缓冲区大小,poll() 会认为管道不可写并将永远挂起,这通常意味着两个管道用户都死锁了。
我研究了这个问题,发现 Windows 管道跟踪两个值:QuotaUsed 和 BytesInQueue。
poll() 中的代码显然想知道BytesInQueue 而不是配额。
不幸的是,BytesInQueue 只能从管道的读取端请求,而poll() 接收写入端。
poll() 的 git 实现是从 gnulib 复制的,迄今为止,它还包含一个有缺陷的实现。
我还查看了 cygwin 中的实现,它也以一种微妙的方式被破坏。它在pipe_data_available() 中使用此代码:
fpli.WriteQuotaAvailable = (fpli.OutboundQuota - fpli.ReadDataAvailable)
但是,ReadDataAvailable 总是为管道的写入端返回 0,将代码转换为返回管道总缓冲区大小的混淆版本,我猜反过来 poll() 总是说管道是可写的。
引入代码的提交并没有说明此更改,因此可能是一些调试代码滑入。
这些是 git 中使用的典型尺寸:
- 0x2000 -
strbuf_read() 中的默认读取大小
- 0x1000 - CRT 中的默认读取大小,由
strbuf_getwholeline() 使用
- 0x2000 -
compat\mingw.c 中的管道缓冲区大小
因此,一旦子进程使用strbuf_read(),父进程中的poll() 将永远挂起,导致两个进程死锁。
这会导致两个可观察的行为:
- 如果父进程开始快速发送 STDIN(通常是这种情况),那么第一个
poll() 将成功并且第一个块将通过。
MAX_IO_SIZE_DEFAULT 是 8MB,所以如果 STDIN 超过 8MB,那么它将死锁。
- 如果父进程因任何原因(包括 OS 调度程序)稍等片刻,而子进程首先发出
strbuf_read(),那么即使在小型 STDIN 上也会立即死锁。
git stash push 说明了这个问题,它当前会将整个补丁读入内存,然后通过 STDIN 将其发送到git apply。
如果补丁超过 8MB,则 git 在 Windows 上挂起。
可能的解决方案
-
以某种方式获得
BytesInQueue 而不是QuotaUsed
我进行了非常彻底的搜索,但没有找到任何从管道写入端获取值的方法。
.
-
还将管道的读取端发送给
poll()
可以这样做,但它可能会引入一些脏代码,因为poll()
- 可以一次接受多个管道
- 可以接受不是管道的东西
- 应该有一个众所周知的签名。
.
-
使
poll() 始终回复“可写”以表示管道的写入结束
毕竟,cygwin(偶然?)似乎多年来一直这样做。
另外需要注意的是,pump_io_round() 写入了 8MB 块,完全忽略了管道的缓冲区大小只有 8KB 的事实,这意味着管道在单次写入期间被多次阻塞。
如果孩子的 STDERR/STDOUT 在尝试处理 8MB 的 STDIN 时被阻塞,这可能会导致死锁。
这种死锁可以通过每轮写入小于管道的缓冲区大小来解决,并且在开始下一轮之前总是从 STDOUT/STDERR 读取所有内容。
因此,让poll() 始终回复“可写”不应导致任何新问题或阻止任何未来的解决方案。
.
-
增加管道缓冲区的大小
BytesInQueue 和 QuotaUsed 之间的区别在于挂起读取的大小。因此,如果缓冲区大于读取大小,poll() 就不会那么容易挂起。但是,我发现例如 strbuf_read() 在读取大量输入时会变得越来越饥饿,最终超过任何合理的管道缓冲区大小。
选择的解决方案
使poll() 始终回复“可写”以表示管道的写入端。
希望有一天有人会找到一种正确实施它的方法。
复制
printf "%8388608s" X >large_file.txt
git stash push --include-untracked -- large_file.txt
我决定不将此作为测试,以避免减慢测试套件的速度。
我不认为具体问题会再次出现,而且git stash push 可能会被重新设计以避免通过 STDIN 发送整个补丁。