🧵 进程资源四件套:协程/线程/FD/子进程(内存盘 4 图)

RSS 只是总账,真正漏起来的是四本隐藏账本:协程便签墙 / 线程手指头 / FD 手环 / 外包车间——再加一辆垃圾车(GC 速率)当实锤证人。覆盖内存盘 🧵 进程资源组 4 张面板(m103 m104 m105 m126)· 仪表盘 siliang-memory。

await 让位调度 领手环 / 开线程 重活外包 垃圾还库 一个 worker 的四大隐藏资源 —— 便签墙 / 手指头 / 手环 / 外包车间 + 垃圾车 m103 · asyncio 任务数(便签墙) 协程 = 一张待办便签:创建即贴上 await 让位 → 干完 / cancel 撕掉 忘 await/cancel:只贴不撕 = 协程泄漏 只涨不落 = 泄漏(对照后台任务数 c7) m104 · 线程数 / 打开 FD 线程 = 手指头:抓着活干(GIL) FD = 手环:文件/连接/管道各发一个 手环总数有限(ulimit),用完要 close 还 涨到上限 → Too many open files backend worker 进程 一家独立分店:独立内存/连接池/GC RSS 只是总账 —— 资源要拆开看 四张图都是 per-worker 一条线 一条线异常 = 定位到具体分店 m105 · 进程池在途任务(外包车间) compute / batch 两个车间(子进程) CPU 重活外包出去,前台绝不亲自干 (GIL:CPU 活只有独立进程能并行) 在途高位 = 活堆在车间(CPU 排队) max_over_time(…[1m]) by (pool) 事故现场 · FD 耗尽 Too many open files 手环发完:新连接建不了 表现为莫名的一批量 5xx 排查:m104 的 open_file_descriptors 是否只开不关(斜着涨) m126 · GC 各代回收速率(垃圾车) 垃圾车每秒出车几次(按代拆) gen0 最忙,gen2 应最闲——闲过头是病 gen2 停滞 + gen2 待回收爬升 = 大对象驻留实锤(两张图互证) sum by (generation) (rate(…[5m])) 四大资源都是隐藏账本:RSS 正常也可能某一项在漏——内存排查永远四张一起看,谁斜着涨谁是嫌疑人 口径:m103/m104/m105 都是 max_over_time(…[1m]),每 worker 一条线;m126 是 Counter 配 rate——持续上涨不回落 = 泄漏旁证

💡 一句话理解

上一课的 RSS 是"总账",它只告诉你这家店一共占了多少地方,不告诉你哪本账在漏。这家分店(worker 进程)里有四本隐藏账本:协程是服务员脑里的待办便签墙(m103,忘 cancel 越贴越多);线程是店员的手指头、FD 是游乐场手环(m104,手环发完就报 Too many open files);重活外包给车间(m105 进程池);垃圾车(m126 GC 速率)负责把不要的内存运走——它不出车,垃圾就堆成实锤。

这四本账平时都被 RSS 的总和掩盖:RSS 看着正常,FD 可能已经斜着涨了半小时。所以内存排查的规矩是:总账异常来这四张图找凶手,四张一起看。

🧩 费曼拆解 讲给完全没接触过的小白

先看便签墙。backend 的服务员是个"聪明服务员"(asyncio):给 A 桌点完单,趁等上菜的空档立刻去服务 B 桌——每接一单就往墙上贴一张便签(创建协程),菜上齐、客人买单就撕掉(await 完成或 cancel)。正常营业时墙上几十张便签起起落落。有一天出了 bug:有一类单子贴上去之后没人去撕(创建了协程却忘了 await/cancel)——便签越贴越多,墙贴满,服务员脑子(事件循环内存)被撑爆。这就是协程泄漏,m103 上是一条只涨不落的斜线。

再看手指头和手环。线程是店员的手指头:抓着活干活(Python 有 GIL,同一时刻只有一根手指能执行 Python 代码,所以 CPU 活线程多也没用)。FD 是游乐场手环:每打开一个文件、一条网络连接、一个管道,场馆就发一个手环;场馆手环总数有限(ulimit),用完必须 close 还牌。哪天有代码只领牌不还牌,手环迟早发光——届时新连接建不了,报出经典的 Too many open files,表现为莫名其妙的一批量 5xx。m104 就是数"现在手上有几根手指、几个手环"。

最后是车间和垃圾车。老板(事件循环)有条铁律:绝不亲自干重活——大文件压缩、图像处理全外包给几个专职工人(compute/batch 子进程池),不然全场客人干等。m105 数的是"车间里正在干几个活"。而垃圾车(GC)负责把没人要的对象运走还库:gen0 新人区一天扫八遍、gen2 档案室一年开一次门。m126 数"每秒出车几次"——gen2 长期不出车,加上 gen2 待回收爬升(上一课 m102),两条证据一合,大对象驻留就是实锤。

类比里的东西系统里对应的东西
待办便签墙m103 asyncio 任务数:每协程一张便签;忘 await/cancel = 只贴不撕,只涨不落就是协程泄漏
店员的手指头m104 线程数:抓着活干;Python 的 GIL 让同一时刻只有一根"手指"在跑 Python 代码
游乐场手环m104 打开 FD:文件/连接/管道各发一个,总数有限;发光 = Too many open files
外包车间m105 进程池在途任务:compute / batch 两个池,CPU 重活外包,前台(事件循环)不亲自干
垃圾车出车频率m126 GC 回收速率按代拆:gen0 最忙、gen2 应最闲——停滞 + 待回收爬升 = 大对象驻留实锤
便签墙对总账协程泄漏要对照核心盘后台任务数(c7)与聊天监听数,判断"多出来的便签是谁贴的"

🛠 动手验证(4 步,在 Grafana 里亲手做一遍)

  1. m103 拉 6h——健康是围绕业务量波动的锯齿;一条只涨不落的斜线 = 便签墙在失控。
  2. m104 看 FD 线——应长期平稳或小幅波动;斜着涨不回落 = 有东西只开不关,涨到上限就是 Too many open files 事故。
  3. m105 按 by (pool) 看——compute / batch 两个车间各自忙闲;持续高位 = 活堆在车间(CPU 排队),这不是内存泄漏,别抓错凶手。
  4. m126 看 gen2 线——对照上一课 m102 的 gen2 待回收:一条停滞一条爬升 = 大对象驻留实锤,立即转入大对象在途排查。

🧠 必知必会 看懂本课全部面板的地基

协程 = 便签
asyncio 的任务创建后要么 await 到完成、要么显式 cancel——两条路都会"撕便签";只创建不回收就是泄漏。
# m103 口径:每 worker 一条线,看 6h 形态
max_over_time(siliang_asyncio_tasks_active[1m])
事件循环铁律
事件循环是单线程调度器,重活(大文件压缩/图像处理)绝不能在它里面跑——否则全部在线用户卡住。重活一律丢进程池。
# 所以外包车间(m105)的存在感 = 前台是否轻快
max_over_time(siliang_process_pool_active[1m]) by (pool)
线程与 GIL
线程数异常增多通常是:连接库起线程、子进程、卡死的任务;GIL 决定 CPU 活加线程不加速,IO 活仍有用。
# m104 双线之一:线程数(手指头)
max_over_time(siliang_process_threads[1m])
FD = 手环
每开一个文件/Socket/管道占一个 FD,用完必须 close;总数有限(ulimit),耗尽报 Too many open files——新连接建不了,批量 5xx。
# m104 双线之二:打开 FD(手环),斜着涨 = 只开不关
max_over_time(siliang_process_open_file_descriptors[1m])
进程池为何用"进程"
GIL 让 CPU 密集活在 Python 线程里并行不起来,只有独立子进程才能真正用满多核——所以外包车间是进程池。
# m105 口径:按 pool 拆(compute / batch)
max_over_time(siliang_process_pool_active[1m]) by (pool)
GC 速率读法
Counter(_total)配 rate:每秒出车几次。gen0 忙、gen2 闲是正常分工;gen2 速率≈0 单独不算病,要和待回收爬升互证。
# m126 口径(照抄主图谱)
sum by (generation) (rate(siliang_python_gc_collections_total[5m]))
max_over_time 抹抖动
本课三张 Gauge 面板都用 [1m] 窗口取最大值,抹掉 30s 抓取的瞬时抖动。
# 统一口径:Gauge 面板 = max_over_time(…[1m])
m103 / m104 / m105 全一样,m126 例外用 rate
per-worker 一条线
每 worker 一条线,线与线不均衡 = 某个分店出了问题;协程泄漏要对照后台任务数(c7)判断来源。
# 找出最忙的那个分店
topk(3, siliang_asyncio_tasks_active)  # 前三名便签墙

📋 逐面板精讲 4 张图一张不落

🧵 m103 · asyncio 任务数

它是什么:事件循环里的"待办便签"数量,每 worker 一条线。协程也会泄漏——创建后忘了 await/cancel,便签越贴越多:每个泄掉的协程都拽着自己引用的上下文内存不放,RSS 跟着被抬起来。

回答什么问题:协程有没有只创建不回收?内存爬升的凶手是不是"贴不完的便签"?

✅ 围绕业务量上下波动(锯齿):接单贴便签、干完撕便签,进出平衡

🚨 只涨不落 = 协程泄漏。动作:对照核心盘后台任务数(c7)与聊天监听数判断来源,再去代码里找忘了 await/cancel 的 create_task

# 面板 PromQL(照抄主图谱口径,每 worker 一条线)
max_over_time(siliang_asyncio_tasks_active[1m])

联动:主图谱 m103 ↗ · 相关课程:后台生成任务(c7 对照来源) · Python GC 分代回收

🔧 m104 · 线程数 / 打开 FD

它是什么:进程的"手指头"(线程)和"拿着的文件句柄"(FD)两本账。子进程、网络连接、临时文件都要占 FD;手环总数有限(ulimit),这是资源的硬天花板。

回答什么问题:有没有什么东西在"只开不关"?——它是子进程 / 连接 / 临时文件泄漏最灵敏的旁证,比 RSS 反应更早。

✅ 两条线长期平稳或小幅波动;线程数与常驻连接数成稳定比例

🚨 持续上涨不回落 = 泄漏旁证;涨到上限直接报 Too many open files——新连接建不了,表现为莫名的一批量 5xx。动作:按 instance 定位分店,查未 close 的连接/文件

# 面板 PromQL(照抄主图谱口径:线程 / FD 两条线)
max_over_time(siliang_process_threads[1m])
max_over_time(siliang_process_open_file_descriptors[1m])

联动:主图谱 m104 ↗ · 相关课程:HTTP 健康(FD 耗尽 = 批量 5xx) · 连接池:图书馆借书

🏭 m105 · 进程池在途任务

它是什么:重活"外包车间"(compute / batch 两个子进程池)当前正在干的活数。它是 CPU 密集型工作不阻塞主事件循环的关键——老板绝不当着客人的面杀鱼。

回答什么问题:外包车间忙不忙?活是不是堆在车间里没人收(CPU 排队)?

✅ 忙时高、闲时落,与业务节奏同步

🚨 持续高位不落 = 活堆在车间 = CPU 排队。注意:这不是内存泄漏——先分流(对应容器 CPU 挤占/接口变慢),别抓错凶手

# 面板 PromQL(照抄主图谱口径,按车间拆)
max_over_time(siliang_process_pool_active[1m]) by (pool)

联动:主图谱 m105 ↗ · 相关课程:RSS / VMS / working_set · 容器层:盒子视角(CPU 挤占)

🚛 m126 · GC 各代回收速率

它是什么:垃圾车每秒出车几次,按代(generation)拆开的速率图——Counter 配 rate。gen0/1 应该频繁出车(新人区天天扫),gen2 应该最闲(档案室一年一开)。

回答什么问题:垃圾车还在正常收垃圾吗?——它是"大对象驻留"的实锤证人。

✅ gen0 出车最勤、gen1 次之、gen2 低频但偶有出车

🚨 gen2 长期不出车 + gen2 待回收爬升(上一组 m102)= 大对象驻留实锤,两张图互证。动作:转入"大对象在途"面板找谁攥着不放

# 面板 PromQL(照抄主图谱口径)
sum by (generation) (rate(siliang_python_gc_collections_total[5m]))

联动:主图谱 m126 ↗ · 相关课程:Python GC 分代回收 · 内存全局水位(m102 互证)

🏭 生产实战 real world

场景 1 · Too many open files 事故复盘 SOP

半夜一批莫名 5xx、日志只有一句手环发完——按这个顺序回放监控。

# 第 1 步:FD 是不是在事发前就斜着涨(m104 口径)
max_over_time(siliang_process_open_file_descriptors{job="siliang_backend_worker"}[1m])
# 第 2 步:按 instance 定位是哪家分店发的疯
max_over_time(siliang_process_open_file_descriptors[1m]) by (instance)
# 第 3 步:确认报错时间点与 FD 顶格时刻对齐(旁证闭环)
sum(rate(siliang_http_requests_total{status=~"5.."}[5m])) by (handler)
# 判读:FD 爬升 → 顶格 → 5xx 爆发 = 只开不关的连接/文件;修 close,不是调 ulimit

场景 2 · 协程泄漏:便签墙失控的来源追踪

m103 斜着涨,先别翻代码——先对照几张"贴便签的业务"定位是谁贴的。

# 第 1 步:确认形态(m103 口径)
max_over_time(siliang_asyncio_tasks_active{job="siliang_backend_worker"}[1m])
# 第 2 步:对照后台生成任务数(c7 口径)——同步涨 = 生成任务在贴
sum(siliang_chat_resume_active_generation_tasks{job="siliang_backend_worker"})
# 第 3 步:对照聊天 SSE 监听(连接数持续涨不回落 = 监听泄漏)
sum(siliang_chat_resume_active_listeners)
# 判读:谁跟 m103 同步爬升,谁就是贴便签的人;代码侧找忘 cancel 的 create_task

场景 3 · 大对象驻留:垃圾车停滞实锤互证

单独一条证据不算数,两条合在一起才是实锤。

# 证据 A:gen2 回收速率停滞(m126 口径)
sum by (generation) (rate(siliang_python_gc_collections_total[5m]))
# 证据 B:gen2 待回收持续爬升(m102 口径)
max_over_time(siliang_python_gc_pending_objects{generation="2"}[1m])
# 收尾:RSS 是否已被抬起来(m101 口径)
max_over_time(siliang_process_resident_memory_bytes[1m])
# 判读:A 停滞 + B 爬升 = 实锤;RSS 尚平 = 早期窗口,立即查在途与哨兵

场景 4 · 外包车间积压:CPU 排队与内存泄漏分流

接口变慢 + 在途任务高——先分清是"车间排队"还是"内存泄漏",处置完全不同。

# 车间在途(m105 口径,按池拆)
max_over_time(siliang_process_pool_active[1m]) by (pool)
# 对照 RSS:车间排队时 RSS 平稳 = CPU 问题,不是内存问题
deriv(siliang_process_resident_memory_bytes[1h])
# 判读:pool_active 高 + deriv≈0 → 查 CPU/并发;pool_active 平 + deriv>5MB/h → 回内存线

场景 5 · 四张图一起看:每日 2 分钟资源巡检

把四本账钉在一张看板里,谁斜谁出局。

# 便签墙(协程)
max_over_time(siliang_asyncio_tasks_active[1m])
# 手指头 + 手环(线程 / FD)
max_over_time(siliang_process_threads[1m])
max_over_time(siliang_process_open_file_descriptors[1m])
# 车间(进程池)
max_over_time(siliang_process_pool_active[1m]) by (pool)
# 垃圾车(GC)
sum by (generation) (rate(siliang_python_gc_collections_total[5m]))
# 巡检标准:四条都"锯齿或平稳" = 过关;任一条只涨不落 = 当天跟进

⚠️ 常见坑 pitfalls

坑 1 · 把任务数当吞吐量 — 症状:"m103 高 = 服务忙"。原因:active 是存量(贴在墙上的便签),不是速率;忙不忙要看 rate 类的图。正解:存量图看形态(锯齿/斜线),吞吐看 QPS 图。
# 错: tasks_active 高 → 结论"流量大了"
# 对: 看 6h 形态:只涨不落 = 泄漏,与流量无关
坑 2 · FD 涨一点不在意 — 症状:FD 从 80 缓慢爬到 300,"离上限还远"。原因:只开不关的泄漏是单调的,按这个斜率迟早顶格,且顶格即事故。正解:FD 只认形态:斜着涨就当天查。
# 错: FD < ulimit 就不管
# 对: deriv(open_file_descriptors[1h]) > 0 且持续 → 排查
坑 3 · "线程越多越快" — 症状:见线程数低就想加线程提性能。原因:Python 有 GIL,CPU 活加线程不加速,真正的外包通道是进程池。正解:CPU 重活进进程池(m105),线程数只当旁证。
# 错: 加线程提速 CPU 密集任务
# 对: max_over_time(siliang_process_pool_active[1m]) by (pool)
坑 4 · gen2 速率 0 直接定罪 — 症状:看到 gen2 出车少就喊泄漏。原因:gen2 本来就该闲,"闲"是分工不是病。正解:必须与 gen2 待回收爬升(m102)互证。
# 错: gen2 rate ≈ 0 → 泄漏实锤
# 对: gen2 rate≈0 + gen2 pending 爬升 = 实锤
坑 5 · 把车间在途高当内存泄漏 — 症状:m105 高位 + 接口慢,按泄漏排查半天。原因:在途高是 CPU 排队,内存未必有任何问题。正解:先对照 deriv(RSS),分流到 CPU/并发线。
# 错: pool_active 高 → 查内存泄漏
# 对: pool_active 高 + RSS 平 → 查 CPU 节流/慢查询
坑 6 · 只看总和,不按 worker 拆 — 症状:四张图全画 sum(),一条线掩盖问题。原因:每 worker 一本账,一家分店泄漏会被四家平摊稀释。正解:本课所有图 per-worker 一条线,异常再下钻 instance。
# 错: sum(siliang_asyncio_tasks_active)
# 对: max_over_time(siliang_asyncio_tasks_active[1m]) by (instance)
坑 7 · 对这些 Gauge 求 rate — 症状:想看"便签增长速度"就写 rate(tasks_active)。原因:Gauge 不配 rate;m126 的 rate 是对 Counter(_total)用的,别混。正解:Gauge 用 deriv 或直接看形态。
# 错: rate(siliang_asyncio_tasks_active[5m])
# 对: deriv(siliang_asyncio_tasks_active[1h])
坑 8 · RSS 正常就宣布没事 — 症状:m101 平稳,结论"内存健康"并收工。原因:FD/协程可以单独漏很久才把 RSS 抬起来,总账最迟报警。正解:四张隐藏账本与总账一起巡检。
# 错: 只看 m101 定健康
# 对: m101 + m103/m104/m105/m126 四本账合审

🎓 费曼自测 合上书,能讲出来才算懂

Q1 · 协程泄漏和线程/FD 泄漏,监控上分别长什么样?

参考答案:协程泄漏是 m103 的便签墙只涨不落——协程创建后忘了 await/cancel,每个泄掉的协程还拽着上下文内存。线程/FD 泄漏是 m104 里 FD 那条线斜着涨不回落——有东西只开不关(连接/临时文件/子进程),涨到 ulimit 上限就报 Too many open files,新连接建不了,表现为一批量 5xx。

Q2 · 为什么外包车间用"进程池"而不是"线程池"?

参考答案:Python 的 GIL 让同一时刻只有一个线程能执行 Python 字节码,CPU 密集活在线程里并行不起来;只有独立子进程才能用满多核。代价是进程间传数据要序列化,所以大进大出的数据要掂量。这也解释了事件循环铁律:前台(事件循环)绝不亲自干重活,重活一律丢进程池(m105 看它在途几个活)。

Q3 · "gen2 停滞 + gen2 待回收爬升 = 实锤"为什么要两张图互证?

参考答案:因为单独一条都有无辜解释:gen2 出车少是它的正常分工(档案室一年开一次门,闲过头单看不算病);而待回收数也可能只是正常波动。只有"垃圾车长期不去 gen2 收"加上"gen2 存量还在持续爬升"同时成立,才能断定有一批大对象赖在老年代、GC 收不走——这就是大对象驻留,下一步去"大对象在途"面板找谁攥着不放。

Q4 · m103 斜着涨了,你的前三步排查动作是什么?

参考答案:第一步确认形态(拉 6h,排除正常锯齿);第二步对照核心盘后台任务数 c7 和聊天 SSE 监听数——谁与 m103 同步爬升,谁就是贴便签的业务(来源定位);第三步进代码找忘了 await/cancel 的 create_task(fire-and-forget)。顺手用 deriv 量化每小时涨几个任务,给修复验收定基线。

← 上一课:📊 内存全局水位:先量体温 📚 课程目录 下一课:📦 大对象在途:谁攥着大文件 →