Inside CARLA Architecture: Client, Server, and Every Tick, Part 2
PyPI 上有两个包:carla==0.9.15 和 carla-ue5-api==0.10.0,装完都叫 import carla。但 0.9 的客户端连 0.10 的 server,握手就对不上——不是参数不匹配,是应用层协议在跨代际时被整套重写,消息格式、调用语义都变了。
CARLA 0.9 → 0.10 的迁移不是换渲染器那么简单:底层引擎从 UE4 换到 UE5(物理从 PhysX 换成 Chaos,光照换 Lumen),服务端对象模型、传感器定义、世界设置语义都跟着变。客户端协议是服务端对象模型的镜像,镜像源头重写了,协议只能重写。这和 HTTP/1 到 HTTP/2 的分帧层重写是同一类事件:版本号差一位,背后是协议代际。
carla-ue5-api==0.10.0 客户端脚本正常跑完,数据齐全,进程退出阶段却崩了:C++ 异常从原生层逃逸,触发 std::terminate → SIGABRT,退出码 134。触发点固定在”恢复异步设置”的 apply_settings 这一步(坑录 T0.5;UE4 侧 0.9.15 客户端退出时有同款,见 T0.6)。
Python 的异常机制和 C++ 的异常机制是两套系统。except 能捕获的是 Python 解释器层的异常对象;而这里的异常发生在 rpclib 的 C++ 栈里,没有被绑定层翻译成 Python 异常就一路传播到顶层,std::terminate 直接终止整个进程——进程都死了,catch 块没有机会执行。这不是”异常太特殊”,而是”根本没走到 Python 层”。
项目判定该 abort 无害,依据三条可核查的证据:
最后一条值得展开:把崩溃登记成”已知签名”,之后的每次发生都用签名比对。已知崩溃换了形态,就是新 bug 伪装成老 bug。
shell 里进程的退出状态是 8 位无符号数。约定:进程被信号杀死时,退出码为
其中 是信号编号。正常 exit(m) 则退出码就是 (0–255)。
| 退出码 | 信号 | 编号 | 典型场景 |
|---|---|---|---|
| 134 | SIGABRT | 6 | C++ std::terminate / abort(),如 CARLA 客户端退出崩溃 |
| 137 | SIGKILL | 9 | kill -9、超时被强杀、OOM killer |
| 139 | SIGSEGV | 11 | 段错误,原生代码访问非法内存 |
| 143 | SIGTERM | 15 | kill 默认信号,优雅终止请求 |
查看上一条命令的退出码:
python eval_route.py; echo $? # $? 保存上一条命令的退出状态
cmd | tee log 的 $? 是 tee 的;conda run 包装也可能改写。长跑任务要拿真实退出码,需 set -o pipefail 或绕开包装。TCP 连接的关闭靠四次挥手:一端发 FIN,对端回 ACK,再反向重复一次。客户端收到 FIN 后,内核把连接标记为关闭,应用层的 recv 立刻返回 0(EOF)——正常死亡是有讣告的。
横死没有:对端进程被 kill -9、宿主机断电、虚拟机崩溃、网络中间设备重启——FIN 永远发不出来。你的内核认为连接还 Established,应用层一无所知。这种一端认为连接存在、另一端已经不存在的状态,就是半开连接。
阻塞式 recv 的语义是”等到有数据为止”,它等待的不是对端的应答,而是本机内核缓冲区里有字节到达。对端死了就没有字节会到达,recv 就一直睡。TCP 协议本身没有应用数据超时:它有重传超时,但那只管”我发的包对方没确认”,管不了”我在等对方主动说话”。
理论上的兜底是 TCP keepalive,但 Linux 默认 tcp_keepalive_time = 7200 秒——空闲两小时才发第一个探测包,对评测链来说等于没有。
本项目的链路是 WSL2 → 虚拟交换机 → Windows 宿主 → CARLA server,跨了两层虚拟网络。server 进程在 Windows 侧崩溃时,WSL 侧的内核只是”对端不再发包”,NAT/虚拟网卡不会好心替你发 RST。同机直连回环的场景反而容易立刻拿到连接重置。半开连接两次实测各白停数小时(坑录 T3.0),探活线程挂在 C 层 recv 里,自愈逻辑永远轮不到执行。
client.set_timeout(8.0) 的逻辑在 Python 侧:发起调用、启动计时、调用返回后检查耗时是否超限、超了就抛异常。注意这个顺序——超时检查发生在原生调用返回之后。如果原生调用永远不返回,检查代码永远执行不到。
调用栈长这样:
你的代码: client.get_world()
→ carla Python 绑定(薄封装,计时在这里)
→ rpclib C++ 客户端
→ 阻塞 recv() ← 卡死点在这里,控制权在内核/原生栈
Python 代码要执行,前提是控制权回到解释器。线程阻塞在 C++ 的 recv 系统调用里时,整条线程在内核里睡眠,解释器的任何字节码——包括超时判断——都轮不到运行。这不是 GIL 问题(阻塞调用一般已释放 GIL,别的线程照常跑),而是发起调用的那个线程本身再也回不来。
CARLA 的 Python 包是 rpclib 的薄绑定,网络 I/O 全部在原生层完成。绑定层没有、也无法给阻塞 recv 加 SO_RCVTIMEO 之类的套接字级超时——那需要改 C++ 源码重新编译。
这条规律适用于一切”Python 调用原生库”的场景:解释器层的超时、重试、熔断,管辖范围都到原生边界为止。系统调用级阻塞(recv、connect 到不回 SYN-ACK 的地址、读不关闭的管道)必须用进程外部的手段解决——这也是解法最终落到”子进程隔离 + SIGKILL”的原因。
“进程内掐表”的第二反应是 Unix 经典方案:SIGALRM 定时器 + 信号处理函数抛异常,把阻塞调用”炸”出来。在纯 C 程序里这经常可行;在 Python 里,对 C 层阻塞无效。
信号到达时,内核先打断进程,但 Python 的做法是:C 层信号处理器只把一个标志位/回调登记进解释器状态就返回,真正的 Python handler 要等主线程回到解释器主循环、在字节码指令之间检查时才执行。设计动机是保证 handler 执行时解释器状态一致(不会在任意 C 扩展的中途跑 Python 代码)。
于是死锁闭环形成:
recv 里,不回解释器;SIGALRM 按时到达,内核已投递,Python handler 已登记;门铃在响,人在地下室搬货。alarm 设了,handler 写了,一秒钟都不会提前触发。
有人会说”recv 收到信号应该返回 EINTR”——这对 C 程序成立,但 Python 会按 PEP 475 自动重试被信号打断的系统调用,除非 handler 抛异常;而 handler 又执行不了。两个机制叠在一起,结果就是你观察到的:什么都不会发生。
进程内的牌到此打完:set_timeout 管不到、SIGALRM 打不断、线程杀不死(Python 没有安全的杀线程 API)。剩下的手段只剩一条——把这个调用放进一个可以被操作系统整体杀掉的进程里。
tools/server_watchdog.py 的 server_alive 探活,就是半开挂死的最终解法:
def server_alive(port):
# 探活代码整体塞进子进程:挂死也只挂子进程
code = (
"import carla;"
"c=carla.Client('%s',%d);c.set_timeout(8.0);" # 内层超时照旧设,双保险
"c.get_world().get_actors()" % (host_ip(), port)
)
try:
r = subprocess.run([sys.executable, "-c", code], timeout=25, # 外层 25s 硬超时
capture_output=True)
return r.returncode == 0
except subprocess.TimeoutExpired:
return False # 超时即杀子进程,父进程拿到确定答案
subprocess.run(timeout=25) 的超时不在 Python 解释器层兑现:超时到达后,subprocess 模块对子进程发 SIGKILL。SIGKILL 由内核直接处理,不经过目标进程的任何代码——卡在 C 层 recv 也好、死循环也好,谁也躲不掉。这是操作系统级的终止,和进程内一切超时机制都不在同一个层面上。
父进程因此总能拿到二元结果:returncode == 0(活着)或超时/非零(判死)。爆炸半径被压缩到”一个探活子进程”,主评测链不受影响。
sys.executable子进程用 sys.executable 启动,保证继承当前 conda 环境的解释器——环境里有对应代数的 carla 包。如果图省事写 python3,在错误环境里 import carla 直接失败,探活恒返回 False,会被误判成”server 死了”,进而触发重启甚至误杀。这个教训后来真的以事故形式兑现了(见 B16)。
WSL2 里可以直接调 Windows 的 powershell.exe(WSL 互操作,interop)。项目用它读宿主 GPU 显存:
# tools/server_watchdog.py: gpu_used_mb()
try:
out = subprocess.run(
["powershell.exe", "nvidia-smi.exe",
"--query-gpu=memory.used", "--format=csv,noheader"],
text=True, capture_output=True, timeout=60).stdout.strip() # 关键:timeout=60
return int(out.replace("MiB", "").strip())
except Exception:
return -1 # 探测失败返回哨兵值,由调用方决定怎么办
坑录 T3.0 记录:powershell interop 偶发管道残留挂死——子进程不退、管道不关,不带 timeout 的 check_output 会永远等下去,整条评测链被拖死。和 CARLA RPC 半开挂死是同一个病根:跨进程边界的阻塞调用,对方可能永远不回话。
显存探测失败的正确语义不是”判错卡”也不是”崩溃”,而是本轮校验放弃:调用方(launch_once)看到 -1 就跳过这一轮显存增量校验,等下一轮再探。把三种结局严格分开:
“探测失败 ≠ 结论错误”这条区分在坑录里被反复点名——把”不知道”误当”失败”,是监控系统误杀的第一大来源。
tools/server_watchdog.py 把前面所有防御零件拼成一个完整的自愈循环。拆开看四步:
每 15 秒 server_alive(port) 一次:探活隔离在子进程 + 25 s 硬超时(见 B12)。连续两次失联(间隔 30 s 确认窗)才判死,避免单次抖动误触发重启。
server 起没起对,不看进程名看显存:
GPU_DELTA_MB = 4000 # 起完后 5090 显存须比基线多 4GB 以上
根因是 -graphicsadapter=N 的索引会漂移:宿主挂着 ToDesk/向日葵等虚拟显示适配器,且枚举顺序随重启/会话变化(实测 UE5 侧 =2 选中 5090,WSL 重启后同参数选到 Microsoft Basic Render Driver 直接崩)。所以”RPC 通了”不算就绪,必须 nvidia-smi 看到显存真的涨了 4 GB 才算——探测失败(-1)则跳过本轮(见 B13 的语义)。
ADAPTER_CANDIDATES = [0, 2, 1, 3]
一次启动选错卡就杀掉换下一个索引重试,直到显存校验通过。把”易漂移的配置项”变成”启动时探测的运行时参数”。
双实例同机时两个 UE4 server 进程同名(CarlaUE4-Win64-Shipping.exe),按名杀会团灭另一侧。清理改为按命令行匹配:
Get-CimInstance Win32_Process |
Where-Object { $_.CommandLine.Contains('-carla-rpc-port=2171') } |
ForEach-Object { Stop-Process -Id $_.ProcessId -Force }
杀干净、重启、评测从断点路线续跑。四步全自动,人才敢让 220 条路线连跑十几个小时。
有人用裸 python3 调 watchdog 的探活/清理函数。裸解释器不在任何项目 conda 环境里,import carla 抛 ImportError,探活子进程非零退出——server_alive 恒返回 False。
兜底逻辑一看”server 死了”,进入清理流程;按进程名扫描时把另一侧活着的 CARLA 实例也杀了(双实例同名进程,旧版按名杀)。损失:误杀 2031 端口实例,约 40 分钟 + 在跑路线作废重跑。
裸 python3 → import carla 失败(环境问题)
→ 探活恒 False(把"探测坏了"读成"server 死了")
→ 兜底按名杀进程(清理粒度过粗)
→ 活着的邻居陪葬
每一环单独看都”情有可原”,串起来就是事故。三处修复分别断掉链条的一环:
conda run -n mysim-simlingo(或对应环境)执行;探活子进程用 sys.executable 继承正确解释器,从机制上杜绝裸 python3。-carla-rpc-port= 精确匹配,即使误判也不会扩大杀伤面(见 B15 ④)。监控/自愈系统的可信度必须高于被监控对象,否则它就是新的故障源。这条事故的直接产物是硬规则:“watchdog 一切操作必须 conda run 进正确环境”,以及更深一层的方法论——探测失败、环境错误、目标死亡是三件事,混为一谈的系统会杀人。