<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>管道缓冲 on Zero Day Notes</title>
        <link>https://blog.5772447.xyz/tags/%E7%AE%A1%E9%81%93%E7%BC%93%E5%86%B2/</link>
        <description>Recent content in 管道缓冲 on Zero Day Notes</description>
        <generator>Hugo -- gohugo.io</generator>
        <language>zh</language>
        <lastBuildDate>Mon, 26 Oct 2026 09:00:00 +0800</lastBuildDate><atom:link href="https://blog.5772447.xyz/tags/%E7%AE%A1%E9%81%93%E7%BC%93%E5%86%B2/index.xml" rel="self" type="application/rss+xml" /><item>
            <title>Popen 未读管道：备份脚本卡在 64KB</title>
            <link>https://blog.5772447.xyz/posts/15f31637/</link>
            <pubDate>Mon, 26 Oct 2026 09:00:00 +0800</pubDate>
            <guid>https://blog.5772447.xyz/posts/15f31637/</guid>
            <description>&lt;h2 id=&#34;问题背景&#34;&gt;&lt;a href=&#34;#%e9%97%ae%e9%a2%98%e8%83%8c%e6%99%af&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;问题背景&#xD;&#xA;&lt;/h2&gt;&lt;p&gt;运维组有一台文件服务器，每天凌晨 1 点半跑一个 Python 备份脚本：用 &lt;code&gt;subprocess.Popen&lt;/code&gt; 调用 &lt;code&gt;rsync&lt;/code&gt;，把业务目录同步到备份盘，脚本负责记录耗时、输出和退出码，最后把结果写进日志并发通知。这套脚本跑了半年多，一直很稳。&lt;/p&gt;&#xA;&lt;p&gt;上个月新项目上线，业务目录从 200GB 涨到 1.2TB，文件数量也翻了几倍。数据量变大后，备份偶尔会“卡住”：脚本进程还在，rsync 也在，但两边都不动，CPU 占用接近零，日志里没有任何报错，直到早上有人发现备份没完成。&lt;/p&gt;&#xA;&lt;h2 id=&#34;故障现象&#34;&gt;&lt;a href=&#34;#%e6%95%85%e9%9a%9c%e7%8e%b0%e8%b1%a1&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;故障现象&#xD;&#xA;&lt;/h2&gt;&lt;ul&gt;&#xA;&lt;li&gt;脚本每周有两三次整夜不结束，第二天上班还挂在那里；手动 Ctrl+C 后重跑，有时能成功，有时又卡住。&lt;/li&gt;&#xA;&lt;li&gt;卡住时 &lt;code&gt;ps&lt;/code&gt; 能看到 Python 主进程和 &lt;code&gt;rsync&lt;/code&gt; 子进程都活着，状态都是睡眠，CPU 几乎为零。&lt;/li&gt;&#xA;&lt;li&gt;小目录备份从不复现；大目录、文件多、输出量大的任务更容易卡。&lt;/li&gt;&#xA;&lt;li&gt;杀掉 Python 进程后，&lt;code&gt;rsync&lt;/code&gt; 有时变成孤儿进程继续跑，下一次定时任务又启动一份，出现重叠备份。&lt;/li&gt;&#xA;&lt;li&gt;日志里没有超时、没有错误码，因为脚本要等 &lt;code&gt;wait()&lt;/code&gt; 返回之后才去读输出——可它永远没返回。&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;h2 id=&#34;排查过程&#34;&gt;&lt;a href=&#34;#%e6%8e%92%e6%9f%a5%e8%bf%87%e7%a8%8b&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;排查过程&#xD;&#xA;&lt;/h2&gt;&lt;p&gt;第一步复现。手动对大目录执行脚本，观察输出和进程状态；再用一个小目录做对照。小目录几十秒完成，大目录跑到某个点后完全静止，说明和数据量、输出量相关，不是 rsync 本身报错。&lt;/p&gt;&#xA;&lt;p&gt;第二步看进程在等什么。&lt;code&gt;py-spy dump&lt;/code&gt; 打到 Python 主进程，主线程停在 &lt;code&gt;waitpid&lt;/code&gt;；&lt;code&gt;ps -o pid,stat,wchan,cmd&lt;/code&gt; 看 rsync 子进程，状态是 &lt;code&gt;S&lt;/code&gt;，&lt;code&gt;wchan&lt;/code&gt; 显示 &lt;code&gt;pipe_write&lt;/code&gt;。也就是说，子进程卡在“往管道写”的系统调用上，父进程卡在“等子进程退出”上，两边互相等。&lt;/p&gt;&#xA;&lt;p&gt;第三步确认管道状态。&lt;code&gt;ls -l /proc/&amp;lt;python&amp;gt;/fd&lt;/code&gt; 能看到指向 &lt;code&gt;pipe:[...]&lt;/code&gt; 的文件描述符；用 &lt;code&gt;fcntl&lt;/code&gt; 查管道容量，返回 65536 字节，正是 Linux 默认的 64KB。再抓子进程的 &lt;code&gt;strace&lt;/code&gt;，最后一条系统调用停在 &lt;code&gt;write(1, ...)&lt;/code&gt; 没有返回——管道满了，写不进去。&lt;/p&gt;&#xA;&lt;p&gt;第四步回到代码。脚本的写法是：&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;div style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&#xA;&lt;table style=&#34;border-spacing:0;padding:0;margin:0;border:0;&#34;&gt;&lt;tr&gt;&lt;td style=&#34;vertical-align:top;padding:0;margin:0;border:0;&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code&gt;&lt;span style=&#34;white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f&#34;&gt;1&#xA;&lt;/span&gt;&lt;span style=&#34;white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f&#34;&gt;2&#xA;&lt;/span&gt;&lt;span style=&#34;white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f&#34;&gt;3&#xA;&lt;/span&gt;&lt;span style=&#34;white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f&#34;&gt;4&#xA;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#xA;&lt;td style=&#34;vertical-align:top;padding:0;margin:0;border:0;;width:100%&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-python&#34; data-lang=&#34;python&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;p &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; subprocess&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;Popen(cmd, stdout&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;subprocess&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;PIPE, stderr&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;subprocess&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;PIPE)&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;p&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;wait()&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;out &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; p&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;stdout&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;read()&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;err &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; p&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;stderr&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;read()&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#xA;&lt;/div&gt;&#xA;&lt;/div&gt;&lt;p&gt;&lt;code&gt;Popen&lt;/code&gt; 创建了两根管道，但父进程在 &lt;code&gt;wait()&lt;/code&gt; 期间并不读它们。子进程的输出一旦超过 64KB，管道写满，子进程阻塞在 &lt;code&gt;write&lt;/code&gt;；父进程等子进程退出，子进程等父进程腾空管道——标准的生产者消费者死锁。之前数据量小，rsync 输出不到 64KB，问题被完美掩盖。&lt;/p&gt;&#xA;&lt;p&gt;还有一种更隐蔽的变体：只把 stdout 接到 PIPE、stderr 继承终端。此时 stderr 不会阻塞，但 stdout 超过 64KB 一样会卡死；判断依据不是“接了几根管道”，而是“有没有任何一根管道没人读”。&lt;/p&gt;&#xA;&lt;p&gt;第五步最小复现。写一个子进程连续往 stdout 写 100KB，父进程用同样的 &lt;code&gt;Popen + wait()&lt;/code&gt; 接法，跑到 64KB 左右必然卡住；把父进程改成 &lt;code&gt;communicate()&lt;/code&gt;，同样的子进程顺利结束。变量唯一，结论确认。&lt;/p&gt;&#xA;&lt;h2 id=&#34;解决方案&#34;&gt;&lt;a href=&#34;#%e8%a7%a3%e5%86%b3%e6%96%b9%e6%a1%88&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;解决方案&#xD;&#xA;&lt;/h2&gt;&lt;ol&gt;&#xA;&lt;li&gt;&lt;strong&gt;改用 &lt;code&gt;subprocess.run&lt;/code&gt;&lt;/strong&gt;：&lt;code&gt;subprocess.run(cmd, capture_output=True, text=True, timeout=3600, check=True)&lt;/code&gt;。它内部用 &lt;code&gt;communicate()&lt;/code&gt; 并发读取 stdout 和 stderr，不会出现管道写满无人读的情况；超时会杀掉子进程并抛出 &lt;code&gt;TimeoutExpired&lt;/code&gt;。&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;需要流式处理时用 &lt;code&gt;communicate()&lt;/code&gt;&lt;/strong&gt;：&lt;code&gt;p.communicate(timeout=3600)&lt;/code&gt; 同样会持续排空两根管道。注意 &lt;code&gt;communicate&lt;/code&gt; 超时后不会自动杀进程，要显式 &lt;code&gt;p.kill()&lt;/code&gt; 或杀掉整个进程组，再调用一次 &lt;code&gt;communicate()&lt;/code&gt; 收回残留输出。&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;超时后杀进程组&lt;/strong&gt;：启动时加 &lt;code&gt;start_new_session=True&lt;/code&gt;，超时后 &lt;code&gt;os.killpg(os.getpgid(p.pid), signal.SIGKILL)&lt;/code&gt;，避免 rsync 变成孤儿继续占用备份盘。&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;大输出不要进内存&lt;/strong&gt;：如果命令输出可能很大，直接把 &lt;code&gt;stdout&lt;/code&gt; 重定向到日志文件（&lt;code&gt;stdout=log_fp, stderr=subprocess.STDOUT&lt;/code&gt;），或者用线程持续读管道；同时给 rsync 加 &lt;code&gt;-q&lt;/code&gt; 或降低日志级别，从源头控制输出量。&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;加锁防重叠&lt;/strong&gt;：cron 命令前加 &lt;code&gt;flock -n /var/lock/backup.lock&lt;/code&gt;，上一次没结束时直接跳过并告警，避免多份备份同时写盘。&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;验证&lt;/strong&gt;：用一个会产生 100MB 输出的测试命令跑完整流程，确认脚本能正常结束、超时能触发、退出码和输出都写进日志。&lt;/li&gt;&#xA;&lt;/ol&gt;&#xA;&lt;h2 id=&#34;根因分析&#34;&gt;&lt;a href=&#34;#%e6%a0%b9%e5%9b%a0%e5%88%86%e6%9e%90&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;根因分析&#xD;&#xA;&lt;/h2&gt;&lt;p&gt;直接根因是 &lt;strong&gt;&lt;code&gt;Popen&lt;/code&gt; 把 stdout 和 stderr 接到管道后直接 &lt;code&gt;wait()&lt;/code&gt;，父进程不读管道；子进程输出超过 64KB 的管道缓冲后阻塞在 &lt;code&gt;write&lt;/code&gt;，父进程又在等子进程退出，形成死锁&lt;/strong&gt;。&lt;/p&gt;&#xA;&lt;p&gt;更深一层，是脚本只在“小数据量”下验证过。200GB 时 rsync 的输出不到 64KB，&lt;code&gt;wait()&lt;/code&gt; 接法看起来完全正确；数据涨到 1.2TB 后，输出量和文件数一起增长，越过了那个隐形的 64KB 阈值。代码里没有超时、没有输出上限、没有防重叠，于是“偶发卡住”变成了“整夜不结束”。再加上日志要等 &lt;code&gt;wait()&lt;/code&gt; 返回才写，故障期间没有任何可观测信息，排查只能靠进程状态反推。&lt;/p&gt;&#xA;&lt;h2 id=&#34;预防措施&#34;&gt;&lt;a href=&#34;#%e9%a2%84%e9%98%b2%e6%8e%aa%e6%96%bd&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;预防措施&#xD;&#xA;&lt;/h2&gt;&lt;ul&gt;&#xA;&lt;li&gt;代码规范明确禁止 &lt;code&gt;Popen + PIPE + wait()&lt;/code&gt; 组合；需要收集输出一律用 &lt;code&gt;run(capture_output=True)&lt;/code&gt; 或 &lt;code&gt;communicate()&lt;/code&gt;。&lt;/li&gt;&#xA;&lt;li&gt;所有外部命令必须设置超时，并明确超时后的清理动作（杀进程组、释放锁、告警）。&lt;/li&gt;&#xA;&lt;li&gt;定时任务统一加 &lt;code&gt;flock&lt;/code&gt;，把“重叠执行”变成显式失败，而不是悄悄堆积。&lt;/li&gt;&#xA;&lt;li&gt;大输出场景默认落文件而不是进内存，避免管道和内存双重风险。&lt;/li&gt;&#xA;&lt;li&gt;外部命令的输出量要有上限：日志级别、进度输出、错误详情都应有界，避免一次异常把管道和磁盘同时打满。&lt;/li&gt;&#xA;&lt;li&gt;监控任务耗时和退出码：超过历史基线就告警，不要等早上人工发现。&lt;/li&gt;&#xA;&lt;li&gt;压测用接近真实峰值的输入，不要只拿样例目录验证。&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;h2 id=&#34;总结&#34;&gt;&lt;a href=&#34;#%e6%80%bb%e7%bb%93&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;总结&#xD;&#xA;&lt;/h2&gt;&lt;p&gt;管道是一个只有 64KB 的邮箱：有人往里投信，就得有人及时取走。&lt;code&gt;wait()&lt;/code&gt; 只负责等子进程结束，不会帮你读信；邮箱一满，子进程就停在 &lt;code&gt;write&lt;/code&gt; 上，父进程还在等它退出，两边一起等到天荒地老。这个脚本并不是“突然坏了”，而是数据量终于越过了那个一直存在、却从未被测试覆盖的阈值。超时、排空管道、加锁，做的都是同一件事：让挂起变成可见的失败，而不是无声的停机。&lt;/p&gt;&#xA;&lt;h2 id=&#34;延伸阅读&#34;&gt;&lt;a href=&#34;#%e5%bb%b6%e4%bc%b8%e9%98%85%e8%af%bb&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;延伸阅读&#xD;&#xA;&lt;/h2&gt;&lt;ul&gt;&#xA;&lt;li&gt;&lt;a class=&#34;link&#34; href=&#34;https://blog.5772447.xyz/posts/5255de1b/&#34; &gt;asyncio 接口集体超时：同步调用阻塞事件循环&lt;/a&gt;&lt;/li&gt;&#xA;&lt;li&gt;&lt;a class=&#34;link&#34; href=&#34;https://blog.5772447.xyz/posts/d5be3b26/&#34; &gt;requests 连接池耗尽致间歇性 ConnectionError&lt;/a&gt;&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;</description>
        </item></channel>
</rss>
