OpenClaw 异步执行Python脚本问题复盘
这里其实复盘了两个问题fork引起的子进程修改FD导致Agent 失去对Python子进程的通讯fork 引起的辅助线程没有复制到子进程空间,导致原来的辅助线程成为幽灵。辅助线程很可能持有了底层的一个单例锁,导致该锁成为死锁,requests.iter_lines()的时候无法获取锁导致子线程阻塞其实1是一个代码问题造成OpenClaw感知错乱,这个bug真正的原因是2。但是问题1可以解释为什么A
重点复盘一下OpenClaw SKILL中遇到的一个经典问题: 操作系统底层进程管理与网络请求库发生冲突
背景
我的SKILL 在设计的时候就为了用户体验选择脚本异步执行,FIRE AND FORGET模式。但我不是Python背景,我把需求vibe coding的时候,脚本会call一个API 接口,这个API接口是LLM,结果是流式输出的:
response = requests.post(ENDPOINT, json=payload, headers=headers, stream=True, timeout=300)
if response.status_code == 200:
for line in response.iter_lines(): #注意这里
而run_generation脚本内部有这样的逻辑片:
if is_async:
# Fully decouple from parent terminal streams
sys.stdin = open(os.devnull, 'r')
sys.stdout = open(os.devnull, 'w')
sys.stderr = open(os.devnull, 'w')
脚本的main是:
if is_async_mode:
# Use a Process but ensure the parent exits immediately
p = multiprocessing.Process(target=run_generation, args=(user_prompt, existing_id, True))
p.daemon = False
p.start()
# Output the ID for the SKILL to track and exit parent instantly
print(f"SESSION_ID: {existing_id}")
os._exit(0)
else:
print(f"SESSION_ID: {existing_id}")
run_generation(user_prompt, existing_id, False)
问题
因为想达到异步效果,我让Agent 在terminal用 python some_script.py --async 指令执行脚本。测试了很久我发现是这个–async模式的问题。–async模式在OpenClaw环境下不稳定执行,会一直阻塞没有结果。而如果查API的调用日志,会发现API调用会在4s 左右关闭,且状态值为200。
同步模式可以稳定执行完成。
这个问题在我没有太多Python经验的时候很难发现,因为撇出OpenClaw而在本地直接异步运行脚本是没有任何问题的。而带着OpenClaw debug它只会解释为脚本内部的远程API异常关闭了,而很难发现是–async模式的问题。
另外这个问题并不是稳定复现的,因为Agent 有自修正能力,它如果发现–async模式失败,它会自动尝试同步执行,而我并不知道它做了这样的尝试。
解决
解决办法是对脚本的执行从–async 改为background exec with yieldMs=2000。注意background exec 是 OpenClaw 系统级别的功能,不是Python指令。Python脚本本身退回到同步模式。
结论
发现问题后让OpenClaw 给我整理了一下分析结论如下:
从测试结果可以看出:
✅ 基本的 multiprocessing fork - 正常工作
✅ Fork 进程 + 普通 HTTP 请求 (httpbin) - 正常工作
❌ Fork 进程 + API streaming response + iter_lines() - 子进程卡住
最后一个测试显示:After 32s: child is_alive = True, exitcode = None
子进程还活着但永远不会完成
根本原因:
问题不是 multiprocessing 本身,而是 requests.iter_lines() 在 forked 子进程中处理 API 的 streaming NDJSON 响应时卡住了。
这只在 OpenClaw 环境中发生,因为:
OpenClaw 的 exec 环境中,stdin/stdout/stderr 是 sockets 而不是普通文件描述符
Fork 时这些文件描述符被继承
当子进程尝试重定向到 /dev/null 并同时进行 HTTP streaming 时,可能会发生冲突或死锁
为什么本地可以工作:
你本地运行时,stdin/stdout/stderr 是正常的 TTY 或 pipe
文件描述符的状态不同,不会触发这个问题
此外,OpenClaw环境中,同步模式可以正常工作(我们测试过,4分钟完成)
为什么同步模式可以工作?
因为在同步模式下:
不使用 multiprocessing fork
stdin/stdout/stderr 不被重定向到 /dev/null
response.iter_lines() 可以正常读取数据
分析
我不是Python背景,所以说实话上面的解释我没有完全弄懂。所以这里再记录一下和G老师的精细复盘过程。
一、进程管理
- 首先上面main 方法中的multiprocessing.Process这是一个系统级别的指令,指的是multiprocessing这个库在Python解释运行的时候会调用到fork()系统指令。在我的MacOS或是云端的OpenClaw服务器上,都是Linux系统。
- 操作系统接到指令后,会把当前运行的 Python 进程(父进程)的内存状态、打开的文件、网络连接等“复制”一份,产生一个一模一样的“克隆体”(子进程)。在这里就是子进程p,然后os._exit(0)即运行python main的父进程就退出了。并且这个退出是底层的 C 语言级别的退出指令。父进程立刻原地死亡,不执行任何 Python 级别的清理动作,子进程p被指派去执行 run_generation 这个函数。
- 此时子进程继承(携带着复制的)了所有父进程的内存状态,文件描述符,网络连接等资源
二、通讯方式
- 再来看程序的通讯方式。程序和外界的沟通方式有三种,stdin, stdout, stderr。脚本中的print语句其实对应着系统的stdout。
- 在本地环境中,stdout是被TTY/pipe接管的。TTY粗略指的就是屏幕,pipe是程序和程序之间的通讯管道。
- 而在OpenClaw环境中,OpenClaw的服务器本身没有屏幕,所以肯定不是TTY, 同时agent和程序之间不是用pipe通讯的,而是用socket通讯的。OpenClaw 的沙盒管理器(Agent)会在操作系统底层创建一个进程间通讯机制,这里OpenClaw自己跟我说是socket。Agent 把这个底层通道的“一头”插在你的 Python 脚本的 stdout 上,自己拿着“另一头”监听。
- 注意,这里如果OpenClaw在云服务器上,云服务器和你的本地客户端之间也是有通讯连接的,这里是网络连接,一般就是我们常说的广域网通讯(通常是 WebSocket 或 HTTPS长连接)。这一段和bug 没有关系。因为如果我试过本地部署OpenClaw和会复现,说明这一块是通的。问题出现在Agent 和脚本程序之间的通讯上(IPC)。
三、文件描述符FD
- 文件描述符是一个整数ID,是操作系统发给程序IO的一个号码牌。对于Python这种比较上层的程序来说,它是没有权限直接调用操作系统级别的屏幕、显卡、网卡这些功能的。
- 所以它想把标准输出打在屏幕上,或者通过网络调用API,实际上是向OS发起申请,OS建立了链接资源并自己管理,把这个资源分配了一个ID给应用层程序。这个ID就是文件描述符。这样应用程序就可以通过ID向操作系统要资源建立连接和IO。
- Python启动时,OS分配的默认FD是stdin - 0, stdout - 1, stderr - 2
四、IPC FD异常
- Python 启动,手里拿着 FD 1 (stdout)。但在 OpenClaw 里,操作系统把 FD 1 指向了一个 IPC Socket 连着Agent沙盒
- 当执行到异步分支时,主进程暴力退出,没有对手里的FD1进行处理。而子进程直行到run_generation内部的异步分支时,又把这个FD1进行了重定向,
sys.stdout = open(os.devnull, 'w')这一句直接指向了黑洞。也就是说对于Agent 监听来说,它本来是想监听Python的标准输出的,但是子进程把标准输出打到黑洞里了。Agent 就啥也拿不到了 - 到这里,FD1的定向发生了异常,也就是说IPC通讯已经g了。但是程序执行的是网络通讯,不会占用FD1,而在系统级别上,文件描述符之间应该是隔离的,也就是说FD1不应该影响网络通讯的FD4。所以要么是这个FD1的重定向在OS层面影响了FD4,要么这个FD1不是根本原因。
五、发生死锁
其实这一块是个猜想,因为底层对我来说有点黑盒,只能从表现上去推论。我自己没有验证。
- 表象是此时在异步模式下,HTTP Streaming传回,脚本处理response.iter_lines()的时候卡死了。
- 在本地裸环境中,执行一个Python脚本是单线程在跑。但是在OpenClaw环境下,Python这种父进程其实并不是完全单进程在跑,OpenClaw可能启动了什么辅助线程,例如日志传输,后台守护等,这些个辅助线程是持锁的,可能会用锁进行一些操作。
- 而在fork的时候,操作系统只复制了Python父进程成为子进程,那些个辅助线程就不管了,仿佛幽灵。而那些线程可能当时是持有锁的。此时这个锁就变成了死锁,无法释放。而Python子进程在处理网络请求requests.iter_lines()结果的时候,是需要操作系统的锁的,好巧不巧这个锁和那个幽灵线程的锁是同一把锁,requests.iter_lines()底层无法获取锁,于是一直阻塞了
- 这里“同一把锁”原因是Python的用户态和操作系统C语言架构的内核态,采用了单例锁的模式,也就是说,用户态和内核态用的很可能是同一把锁。所以才会出现fork的锁影响了网络通讯。Python requests 在底层依赖 urllib3,而在处理 HTTPS 和流式数据时,它极大概率会触碰到以下两个东西:
- Python 的 logging 模块:requests 内部有很多隐藏的警告或调试信息。如果它尝试调用 logging,而 Python 的 logging 模块在内部是有一把全局锁(I/O 锁)的。如果这把锁刚好被幽灵线程锁死,requests 就会瞬间挂起。
- 底层 SSL/TLS 上下文锁:加密模块在生成随机数或处理证书时,底层 C 库也共用互斥锁。
- 这个死锁问题对于HTTP Streaming是更容易发生的,因为普通HTTP请求是一个one-shot,踩雷的几率比较小。而流式返回则需要不断地和系统的 I/O 调度器交互、处理复杂的网络分块协议、并且极易触发底层的隐式日志。在这样一个满是死锁(幽灵锁)的环境里,它 100% 会踩雷。
六、HTTP 请求关闭
到此,我们复盘了OpenClaw本地的问题。而在测试过程中,我发现,同时去看HTTP请求的服务器日志,会显示请求在4s 左右中断,status=200。也就是说它在流式处理的时候被关闭了请求。这一块连起来就是这个问题的整个End - to - End的解释
- 由于脚本作为HTTP请求的客户端,requests.iter_lines() 进程卡死了。此时服务端最开始不会认为这个卡死有问题,它会不断把响应块发送到客户端,也就是TCP本地缓冲区。
- 因为处理TCP缓冲区的进程阻塞,所以缓冲区很快就满了。此时OS会通过底层的 TCP 协议给服务器发送一个极其重要的信号:“TCP 零窗口 (TCP Zero Window)”。服务端接手后停止发送请求。
- 服务端一直停止发送,触发写超时,关闭链接。
总结
这里其实复盘了两个问题
- fork引起的子进程修改FD导致Agent 失去对Python子进程的通讯
- fork 引起的辅助线程没有复制到子进程空间,导致原来的辅助线程成为幽灵。辅助线程很可能持有了底层的一个单例锁,导致该锁成为死锁,requests.iter_lines()的时候无法获取锁导致子线程阻塞
其实1是一个代码问题造成OpenClaw感知错乱,这个bug真正的原因是2。 但是问题1可以解释为什么Agent 给我的报告是API Server 关闭了请求,误导了最初的调查方向。因为agent和Python的IPC中断了,它不知道具体发生了啥。而它看到HTTP 4s请求结束,给我解释为服务端或IP限制问题。
另外解决方案里用background exec with yield这个指令是OpenClaw自己的,这里没有过多探索了,不一定具有普适性。
TAKEAWAY
原始代码逻辑在正常的电脑上是完美的,这是一个典型的云端容器环境特有 Bug。在复杂的云端沙盒(如 OpenClaw)中,尽量不要让代码自己管理多进程或后台执行(不要自己当管家),而是写纯同步的代码,让沙盒平台提供的机制(如 Background / Cron)去帮你管理异步(让平台当管家)。
在OpenClaw的特定环境下(标准 I/O 是 Socket),不要用 Fork + 重定向 + HTTP Streaming。
我是真的没想到,第一次vibe Python就遇到这么经典的POSIX Fork 的线程安全问题和Python 生态里底层库的公共单例锁问题,vibe coding是省事,但还要边vibe边digest……
更多推荐


所有评论(0)