Python多进程共享队列无报错僵死 120G Nginx访问日志清洗踩坑全记录 昨天熬到两点半线上跑了三天的日志清洗脚本突然僵死在72%的进度条上动都不动。 我是转码入行的第三年之前很少写这么重的多进程任务这次需求是把存了三个月的120G原始Nginx访问日志里属于指定IP段的请求全捞出来写入时序数据库做用户行为分析。机器是4核8G的内网云主机之前粗估的跑完全部数据最多24小时结果挂上去第三天还没跑完点进监控一看进程全是休眠状态CPU占用直接掉到0。我先SSH登上去敲了ps aux | grep python主进程和四个子进程的状态全是S我一开始以为是D状态的不可中断休眠敲了半天dmesg也没找到IO报错后来翻了ps的文档才反应过来S是可中断休眠状态既没有死在系统调用里也没有在跑计算所有人都在等什么资源释放。 一开始我以为是日志里混了什么特殊编码的行读文件的时候卡IO了加了一堆print打调试日志结果打出来的最后一条记录是生产者进程已经把最后一个日志分片读完了返回码0正常退出。队列里明明还有两万多条待处理的数据没消费子进程怎么就全歇了 我想当然觉得是不是队列满了往里面put的时候卡住了直接找个小脚本试了下往queue里循环塞元素塞到内存爆了都没卡住显然不是队列满的问题。之后我给所有q.get()和q.put()都加了5秒的超时参数跑测试数据的时候直接抛了EOFError说队列空了但是我打出来的计数器显示已经push到队列里的任务数还有两万多条没被消费凭空消失了这就邪门了。我当时折腾到晚上九点先后试了好几个方案先是把共享队列换成第三方的进程间队列库跑了不到一个小时又卡了速度还比原来慢了快20%。之后试过搭个本地的redis当任务队列把所有任务序列化之后塞redis里这下跨进程通信靠网络走不会有什么内核缓冲区的问题吧结果实测处理速度直接从原来的每秒1.2万条掉到了每秒3000条24小时的活要跑三四天完全赶不上产品要数的deadline。后来脑子抽了想改用多线程跑跑了两分钟才反应过来CPython的GIL锁卡着4核机器只能用满1核CPU利用率刚到25%就上不去了比多进程还慢。等等我说错了我当时还试过用mmap把整个日志文件映射到内存不用逐行读结果120G的大文件直接把机器的内存和swap全占满OOM杀得我连SSH都登不进去最后找运维重启的机器脸都丢尽了。 后来实在没招我直接gdb attach到僵死的主进程上打了bt命令看栈回溯发现在调用multiprocessing.managers.SyncManager.Queue.put的位置线程正卡在Unix Domain Socket的write调用上完全没返回。我当时突然反应过来是不是底层的UDS缓冲区满了写操作直接阻塞了 去翻Python的官方issue列表翻到一个好几年前的旧帖子里面有人提到Linux系统下默认的Unix Domain Socket的发送缓冲区大小是65536字节也就是64KB只要单次写入的总数据超过这个阈值写端就会直接阻塞直到读端把缓冲区里的内容全部读完。我这才反应过来我之前写的烂代码有多大问题贴下我当时的错误代码懂的都懂新手写多进程很容易踩这个坑from multiprocessing import Process, Manager with Manager() as manager: task_queue manager.Queue() res_list manager.list() # 启动2个生产者读大文件分片 producers [Process(targetread_and_push, args(f, task_queue)) for f in file_list] # 启动4个消费者处理清洗逻辑 consumers [Process(targetprocess_and_save, args(task_queue, res_list)) for _ in range(4)] for p in producers: p.start() for p in producers: p.join() # 等所有生产者跑完 # 给消费者发终止信号 for _ in range(4): task_queue.put(None) for c in consumers: c.join()我当时完全想当然觉得等所有生产者进程join完所有数据肯定都已经进到共享队列里了结果根本不是这么回事。Manager的Queue底层没有任何自动刷缓冲区的逻辑生产者进程往队列里put的序列化数据在跨进程传输的时候先攒在UDS的发送缓冲区里要是攒的数据总大小超过64KB生产者这边没等缓冲区的内容全部发完就直接执行完代码退出了剩下的残留在内核缓冲区里的半拉数据要等主进程这边的Manager服务进程慢慢发出去。这时候我直接在主进程里往队列里put终止用的None标记UDS的发送缓冲区满了write操作直接就卡住了。但四个消费者进程那边已经把之前收到的所有任务都处理完了正在队列上阻塞get()等新的任务进来。两边谁也碰不着谁主进程卡在写终止标记消费者卡在等新任务剩下的内核缓冲区里的那点残片永远没人读完完全全的死锁。 我当时为了实锤这个结论写了个十行不到的测试脚本用Manager起个共享队列循环往里面塞3KB大小的字符串我数了下塞到第22次的时候put操作直接卡住再也不返回。22乘3KB刚好是66KB妥妥超过64KB的默认缓冲区阈值直接就复现了线上的僵死问题。 找到根因之后解决思路就很明确了别往跨进程的IPC通道里塞大块数据队列里只传几个字节的索引值所有真实的日志数据全放到主进程预分配的共享内存里这样队列里的元素最大也就十几个字节永远不可能超过64KB的缓冲区上限。我当时改的核心代码是这样的from multiprocessing import Queue, RawArray # 预分配共享内存每条日志最大允许10KB一共预开10000条的空间 shared_buf RawArray(c, 10000 * 10240) index_queue Queue(maxsize10000) result_queue Queue(maxsize10000) # 生产者只需要把数据写到共享内存的对应偏移位置往索引队列塞偏移量和长度 def read_and_push(file_path, index_queue, shared_buf): offset 0 with open(file_path, r, encodingutf-8) as f: for line in f: line_bytes line.strip().encode(utf-8) len_bytes len(line_bytes) shared_buf[offset:offsetlen_bytes] line_bytes index_queue.put((offset, len_bytes)) offset len_bytes index_queue.put(None)这里我用的是multiprocessing自带的RawArray不需要序列化直接在物理内存里开一段连续的共享空间所有进程都能直接读写完全不涉及IPC的数据拷贝开销。生产者进程不用把整条日志序列化后往队列里塞只需要把日志写到共享内存的指定偏移位置往索引队列里塞个(偏移量, 数据长度)的二元组就行。消费者从索引队列拿到这个二元组直接去共享内存的对应位置把字节读出来解码根本不用走大体积数据的跨进程传输。 改完之后我先拿了10G的测试集跑连续跑了三遍没有出现任何一次僵死处理速度反而比之前用Manager.Queue的时候快了40%左右毕竟少了大量序列化和跨进程拷贝的开销。后来把脚本部署到生产机上完整跑完120G的日志总共用了19小时比之前粗估的时间还快了5小时最后全量入库的时候我盯着监控面板连大气都不敢喘就怕哪里又突然卡住。哦对了中间还有个小坑我之前没提RawArray如果开的大小太大比如我一开始想直接开120G的共享空间机器直接OOM后来我算了下单条日志最大长度不会超过10KB同时在内存里跑的任务最多1万条开100MB不到的共享内存就完全够用完全没必要把所有数据都塞进去。共享内存只是当一个中转的缓冲池索引队列满的时候生产者自动阻塞消费者处理完直接把共享内存的对应位置覆盖复用就行根本不会出现内存溢出的问题。 我后来查了下Python更高版本的更新日志好像官方针对multiprocessing的UDS缓冲区做了优化会自动分片大体积的IPC消息不会再让写端直接卡在系统调用上。但我没敢在生产环境的3.9版本上随便升级毕竟线上好多依赖的第三方库还没适配更高版本这块我还没找测试环境实测过不确定这个改动会不会带来其他的副作用暂时不敢随便更新线上的Python版本。还有个地方我到现在也没完全想通之前用原生的multiprocessing.Queue的时候测出来的上限缓冲区大小也是64KB但是同样塞22次3KB的字符串有的时候不会卡有的时候会卡我猜是和系统当时的CPU调度时序有关刚好读端把缓冲区里的内容读走了写端就能继续往里塞。之前我写的小脚本测了十次有三次没卡住直接跑完了所以这个死锁不是必现的是概率性的跑小数据集的时候根本触发不了只有在数据量足够大队列里攒了足够多的任务的时候才会炸这也是我之前写单元测试的时候没测出来的原因跑几千条日志的时候啥事没有一上生产环境跑几十G的大文件就直接僵死。 下班的时候我盯着监控面板看脚本跑完最后一条数据入库进度条走到100%手边点的冰美式放了三个小时已经完全温得像隔夜凉茶。我翻了下Python官方文档里关于Manager.Queue的说明从头到尾找了三遍根本没提这个64KB缓冲区的隐藏限制连个小字备注都没有。合着这些踩坑的经验全是靠熬大夜熬出来的谁要是没遇到过这事根本不可能知道还有这么个偏门的死锁坑。