TT Lab
开始
学习 学习路径 课程

打造好服务的计算机科学 — 用测量重新学习教科书概念

用数字找出 GC 暂停与泄漏的代码行

在 TT Lab 中继续学习

目标

打印这台 Pod 的 GC 设置,数出制造循环引用的 handler 留下的垃圾,再用 weakref 断开循环。用 gc.callbacks 测量 GC 暂停 并将其与请求延迟的 p99 联系起来,观察 gc.freeze 的效果。用 tracemalloc 确认 sys.getsizeof 的陷阱和 slots,在泄漏的服务中找出增长的那一行(文件:行),用有上限的缓存修好,再测量增长是否消失。

为什么重要

CPython 对大部分对象用引用计数立刻清理,而循环则由分代收集器根据分配数集中清理。如果这种收集落在超过 1% 的请求上,平均值不变,p99 却会飙升。内存问题要到“增长最多的那一行”而不是“最大的那一行”去找,而 getsizeof 不统计所指向的对象,所以会把大小说小。评分器不只看你写下的数字,而是把你的函数放到新的解释器里,在不在材料中的输入上重新运行,与基准对照。

材料

位于 /opt/fixtures/svccs/memory/ 之下。只读取,不要修改。在 Python 中先执行 sys.path.insert(0, "/opt/fixtures/svccs/memory"),再用 import svc 来调用。

svc.py          가짜 주문 서비스
                CATALOG         불러올 때 한 번 만드는 상품 2만 개(자라지 않는 큰 정적 데이터)
                load_requests() requests.jsonl 의 요청 목록
                handle_cyclic   요청마다 부모↔자식이 서로 가리키는 트리를 만든다 → {"id", "items", "path"}
                render(req)     요청을 문자열로 렌더링
                handle_leaky    렌더링 결과를 전역 사전에 넣고 지우지 않는다 → {"id", "bytes"}
requests.jsonl  요청 3,000줄 {"id", "user", "items"} — id 는 다시 오지 않는다
sessions.json   세션 400개 {세션 id: {"user", "roles", "cart", "flags"}}

步骤

  1. 在 /root/svccs/memory/gcinfo.json 中写入 python(platform.python_version())、threshold(把 gc.get_threshold() 转成列表)、gc_enabled(gc.isenabled())。
  2. 对 svc.load_requests() 的前 200 条,按 gc.collect() → gc.disable() → svc.handle_cyclic 200 次 → gc.collect()(返回值是回收的不可达对象数)→ gc.enable() 的顺序运行,把 requests、unreachable、per_request(unreachable ÷ requests,保留两位小数)写入 /root/svccs/memory/cycles.json。
  3. 在 /root/svccs/memory/mem.py 中编写 handle_acyclic(req)。它要返回与 svc.handle_cyclic(req) 相同的值,但不产生循环(子节点 → 父节点用 weakref)。评分器会在关闭 GC 的情况下多次调用它,并检查 gc.collect() 是否为 0。
  4. 在同一个文件里,按下面的“测量规则”编写 measure(handler, reqs)。把请求列表扩展为 [base[i % len(base)] for i in range(20000)](base = svc.load_requests()),并把 {"cyclic": measure(svc.handle_cyclic, …), "acyclic": measure(mem.handle_acyclic, …)} 写入 /root/svccs/memory/pause.json。
  5. 在导入了 svc 的进程里,测量 gc.collect() 三次中最短的耗时(ms)、gc.freeze() 之后紧接着的 gc.get_freeze_count(),以及再次 gc.collect() 三次中最短的耗时,然后 gc.unfreeze()。把它们以 collect_ms_before、freeze_count、collect_ms_after 写入 /root/svccs/memory/freeze.json。
  6. 在同一个文件里编写:deep_size(obj)(顺着 dict 的键和值以及 list、tuple、set、frozenset 的元素往下走,对每个互不相同的对象(以 id 为准)累加 sys.getsizeof 的值);有 a、b、c、d 四个属性的 PlainPoint(a, b, c, d),以及用 __slots__ 声明相同属性的 SlotPoint(a, b, c, d);per_object_bytes(cls, n=100000)(开启 tracemalloc,把 cls(0, 0, 0, 0) 创建 n 个并放进列表,前后 get_traced_memory()[0] 之差 ÷ n,保留一位小数)。针对用 json.load 读出的 sessions.json,把 shallow(getsizeof)、deep、plain_getsizeof、slots_getsizeof(各自是一个 (0, 0, 0, 0) 实例的 getsizeof)、plain_per_object、slots_per_object 写入 /root/svccs/memory/sizes.json。
  7. 在导入 svc 之前先 tracemalloc.start(),用 handle_leaky 放入前 500 条请求之后拍一次快照,再放入后面的 2,000 条(第 500–2,499 条)之后再拍一次快照。把 compare_to(앞 스냅숏, "lineno")(占位符为前一个快照)的第一项,以 file(只写文件名)、line、size_diff_kb(size_diff ÷ 1024,保留一位小数)、count_diff 写入 /root/svccs/memory/leak.json。
  8. 在同一个文件里编写 handle_fixed(req)。它返回与 svc.handle_leaky 相同的值,但缓存最多限制在 1,000 个(超出就从最旧的开始丢弃)。开启 tracemalloc,对两个 handler 各自先用前 1,500 条请求预热,再测量接下来 1,500 条请求期间 get_traced_memory()[0] 增加的量(KB,保留一位小数),把 requests_measured(1500)、leaky_growth_kb、fixed_growth_kb、leak_line(第 7 步的“文件:行”)写入 /root/svccs/memory/fix.json。

测量规则

시작 전에 gc.collect() 한 번. gc.callbacks 에 콜백을 넣어 phase "start" 에서 시각을 적고
"stop" 에서 그 차이를 일시정지 하나로 기록한다. 요청마다 handler(req) 앞뒤를 perf_counter 로 잰다.
끝나면 콜백을 뺀다(예외가 나도 — try/finally).
돌려줄 것: requests · collections(일시정지 개수) · gc_pause_ms_total · gc_pause_ms_max(소수 셋째 자리)
          total_ms(요청 지연의 합, 소수 셋째 자리)
          p50_ms · p99_ms(지연을 정렬한 목록의 [int(n×0.5)] · [int(n×0.99)] 번째, 소수 넷째 자리)

参考

打印这台 Pod 的 GC 设置

用这台 Pod 的 python3 打印 python、threshold、gc_enabled,写入 /root/svccs/memory/gcinfo.json。

gc.get_threshold() 返回的是元组,所以转成列表再写入。阈值随版本而不同,所以基准不是文档里的数字,而是在这个解释器里打印出来的值。

数出引用计数清理不掉的垃圾

在关闭 GC 的情况下调用 svc.handle_cyclic 200 次,把 gc.collect() 回收的数量以 requests、unreachable、per_request 写入 /root/svccs/memory/cycles.json。

没有循环的对象在请求结束的瞬间引用计数归零就消失了,所以关掉 GC 之后,留下来的只有循环。开始之前要先用 gc.collect() 把之前的垃圾清掉,才能只数出这些请求留下的东西。如果开着 GC 运行,中途的自动收集会拿走一部分。

用 weakref 断开循环

在 /root/svccs/memory/mem.py 中编写 handle_acyclic(req)——返回值与 svc.handle_cyclic 相同,但没有循环。评分器会用别的请求对照返回值,并在关闭 GC 的情况下运行之后,检查 gc.collect() 是否为 0。

父节点 → 子节点(children 列表)保持强引用,只让子节点 → 父节点用 weakref.ref(parent) 持有,循环就消失了。沿路径向上回溯时,调用弱引用(ref())来得到父节点。根节点在函数结束之前一直被局部变量抓着,所以不会中途消失。

GC 暂停造成了 p99

在 mem.py 中按测量规则编写 measure(handler, reqs),用 2 万个请求测量两个 handler,把 cyclic、acyclic 写入 /root/svccs/memory/pause.json。评分器会用自己的 handler(没有收集的,和调用 gc.collect 的)重新调用你的 measure。

暂停是回调中 start 与 stop 之间的时间,而不是整个请求的时间。收集由分配数触发,所以只有制造循环的一侧会频繁运行。如果收集落在超过 1% 的请求上,p99 就要承担这笔开销。

用 gc.freeze 把长寿对象摘出去

在导入了 svc 的进程中,测量 freeze 前后的完整收集耗时和 freeze_count,以 collect_ms_before、freeze_count、collect_ms_after 写入 /root/svccs/memory/freeze.json。

freeze 会把“此刻”正在跟踪的对象移入永久代。所以必须在创建大型静态数据(svc 的 CATALOG)之后调用才有效。时间会波动,所以取三次中最短的值。

getsizeof 是浅层的——深层大小与 slots

在 mem.py 中编写 deep_size、PlainPoint、SlotPoint、per_object_bytes,并写出 /root/svccs/memory/sizes.json。评分器会用混有共享与循环的其他对象来对照 deep_size。

getsizeof 不统计字典的键和值。要顺着往下走,同时用 id 跳过已经统计过的对象,这样才不会把共享的字符串算两次,对包含自身的列表也能结束。slots 实例用 getsizeof 看可能更大,所以请相信用 tracemalloc 创建 10 万个对象得到的值。

不是最大的那一行,而是增长最多的那一行

在导入 svc 之前开启 tracemalloc,用 handle_leaky 预热 500 条请求之后,与再放入 2,000 条之后的快照之差里取第一行,以 file、line、size_diff_kb、count_diff 写入 /root/svccs/memory/leak.json。

单个快照的 statistics 最上面是“占用最多”的那一行,会出现启动时创建一次的大数据。泄漏是随时间“增长”的,所以用 compare_to 看两个快照之差。filename 是完整路径,所以用 os.path.basename 只保留名称。

用有上限的缓存修复,并测量增长是否消失

在 mem.py 中编写把缓存限制为最多 1,000 个的 handle_fixed(req),测量两个 handler 的增长量,把 requests_measured、leaky_growth_kb、fixed_growth_kb、leak_line 写入 /root/svccs/memory/fix.json。评分器会用新的请求 id,在新进程中重新测量你的 handle_fixed。

用不会再出现的键来填充的缓存,没有上限就是泄漏。放进 OrderedDict,当长度超过上限时,用 popitem(last=False) 丢弃最旧的一个即可。如果预热的请求数少于上限,那么在测量区间内缓存还在增长,增长看上去依然存在。评分器会用新的请求 id 预热 2,000 条后再测 4,000 条。