重点复盘一下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老师的精细复盘过程。

一、进程管理

  1. 首先上面main 方法中的multiprocessing.Process这是一个系统级别的指令,指的是multiprocessing这个库在Python解释运行的时候会调用到fork()系统指令。在我的MacOS或是云端的OpenClaw服务器上,都是Linux系统。
  2. 操作系统接到指令后,会把当前运行的 Python 进程(父进程)的内存状态、打开的文件、网络连接等“复制”一份,产生一个一模一样的“克隆体”(子进程)。在这里就是子进程p,然后os._exit(0)即运行python main的父进程就退出了。并且这个退出是底层的 C 语言级别的退出指令。父进程立刻原地死亡,不执行任何 Python 级别的清理动作,子进程p被指派去执行 run_generation 这个函数。
  3. 此时子进程继承(携带着复制的)了所有父进程的内存状态,文件描述符,网络连接等资源

二、通讯方式

  1. 再来看程序的通讯方式。程序和外界的沟通方式有三种,stdin, stdout, stderr。脚本中的print语句其实对应着系统的stdout。
  2. 在本地环境中,stdout是被TTY/pipe接管的。TTY粗略指的就是屏幕,pipe是程序和程序之间的通讯管道。
  3. 而在OpenClaw环境中,OpenClaw的服务器本身没有屏幕,所以肯定不是TTY, 同时agent和程序之间不是用pipe通讯的,而是用socket通讯的。OpenClaw 的沙盒管理器(Agent)会在操作系统底层创建一个进程间通讯机制,这里OpenClaw自己跟我说是socket。Agent 把这个底层通道的“一头”插在你的 Python 脚本的 stdout 上,自己拿着“另一头”监听。
  4. 注意,这里如果OpenClaw在云服务器上,云服务器和你的本地客户端之间也是有通讯连接的,这里是网络连接,一般就是我们常说的广域网通讯(通常是 WebSocket 或 HTTPS长连接)。这一段和bug 没有关系。因为如果我试过本地部署OpenClaw和会复现,说明这一块是通的。问题出现在Agent 和脚本程序之间的通讯上(IPC)。

三、文件描述符FD

  1. 文件描述符是一个整数ID,是操作系统发给程序IO的一个号码牌。对于Python这种比较上层的程序来说,它是没有权限直接调用操作系统级别的屏幕、显卡、网卡这些功能的。
  2. 所以它想把标准输出打在屏幕上,或者通过网络调用API,实际上是向OS发起申请,OS建立了链接资源并自己管理,把这个资源分配了一个ID给应用层程序。这个ID就是文件描述符。这样应用程序就可以通过ID向操作系统要资源建立连接和IO。
  3. Python启动时,OS分配的默认FD是stdin - 0, stdout - 1, stderr - 2

四、IPC FD异常

  1. Python 启动,手里拿着 FD 1 (stdout)。但在 OpenClaw 里,操作系统把 FD 1 指向了一个 IPC Socket 连着Agent沙盒
  2. 当执行到异步分支时,主进程暴力退出,没有对手里的FD1进行处理。而子进程直行到run_generation内部的异步分支时,又把这个FD1进行了重定向,sys.stdout = open(os.devnull, 'w') 这一句直接指向了黑洞。也就是说对于Agent 监听来说,它本来是想监听Python的标准输出的,但是子进程把标准输出打到黑洞里了。Agent 就啥也拿不到了
  3. 到这里,FD1的定向发生了异常,也就是说IPC通讯已经g了。但是程序执行的是网络通讯,不会占用FD1,而在系统级别上,文件描述符之间应该是隔离的,也就是说FD1不应该影响网络通讯的FD4。所以要么是这个FD1的重定向在OS层面影响了FD4,要么这个FD1不是根本原因。

五、发生死锁

其实这一块是个猜想,因为底层对我来说有点黑盒,只能从表现上去推论。我自己没有验证。

  1. 表象是此时在异步模式下,HTTP Streaming传回,脚本处理response.iter_lines()的时候卡死了。
  2. 在本地裸环境中,执行一个Python脚本是单线程在跑。但是在OpenClaw环境下,Python这种父进程其实并不是完全单进程在跑,OpenClaw可能启动了什么辅助线程,例如日志传输,后台守护等,这些个辅助线程是持锁的,可能会用锁进行一些操作。
  3. 而在fork的时候,操作系统只复制了Python父进程成为子进程,那些个辅助线程就不管了,仿佛幽灵。而那些线程可能当时是持有锁的。此时这个锁就变成了死锁,无法释放。而Python子进程在处理网络请求requests.iter_lines()结果的时候,是需要操作系统的锁的,好巧不巧这个锁和那个幽灵线程的锁是同一把锁,requests.iter_lines()底层无法获取锁,于是一直阻塞了
  4. 这里“同一把锁”原因是Python的用户态和操作系统C语言架构的内核态,采用了单例锁的模式,也就是说,用户态和内核态用的很可能是同一把锁。所以才会出现fork的锁影响了网络通讯。Python requests 在底层依赖 urllib3,而在处理 HTTPS 和流式数据时,它极大概率会触碰到以下两个东西:
    • Python 的 logging 模块:requests 内部有很多隐藏的警告或调试信息。如果它尝试调用 logging,而 Python 的 logging 模块在内部是有一把全局锁(I/O 锁)的。如果这把锁刚好被幽灵线程锁死,requests 就会瞬间挂起。
    • 底层 SSL/TLS 上下文锁:加密模块在生成随机数或处理证书时,底层 C 库也共用互斥锁。
  5. 这个死锁问题对于HTTP Streaming是更容易发生的,因为普通HTTP请求是一个one-shot,踩雷的几率比较小。而流式返回则需要不断地和系统的 I/O 调度器交互、处理复杂的网络分块协议、并且极易触发底层的隐式日志。在这样一个满是死锁(幽灵锁)的环境里,它 100% 会踩雷。

六、HTTP 请求关闭

到此,我们复盘了OpenClaw本地的问题。而在测试过程中,我发现,同时去看HTTP请求的服务器日志,会显示请求在4s 左右中断,status=200。也就是说它在流式处理的时候被关闭了请求。这一块连起来就是这个问题的整个End - to - End的解释

  1. 由于脚本作为HTTP请求的客户端,requests.iter_lines() 进程卡死了。此时服务端最开始不会认为这个卡死有问题,它会不断把响应块发送到客户端,也就是TCP本地缓冲区。
  2. 因为处理TCP缓冲区的进程阻塞,所以缓冲区很快就满了。此时OS会通过底层的 TCP 协议给服务器发送一个极其重要的信号:“TCP 零窗口 (TCP Zero Window)”。服务端接手后停止发送请求。
  3. 服务端一直停止发送,触发写超时,关闭链接。

总结

这里其实复盘了两个问题

  1. fork引起的子进程修改FD导致Agent 失去对Python子进程的通讯
  2. 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……

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐