用数字找出 GC 暂停与泄漏的代码行
目标
打印这台 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"}}
步骤
- 在
/root/svccs/memory/gcinfo.json中写入python(platform.python_version())、threshold(把gc.get_threshold()转成列表)、gc_enabled(gc.isenabled())。 - 对
svc.load_requests()的前 200 条,按gc.collect()→gc.disable()→svc.handle_cyclic200 次 →gc.collect()(返回值是回收的不可达对象数)→gc.enable()的顺序运行,把requests、unreachable、per_request(unreachable ÷ requests,保留两位小数)写入/root/svccs/memory/cycles.json。 - 在
/root/svccs/memory/mem.py中编写handle_acyclic(req)。它要返回与svc.handle_cyclic(req)相同的值,但不产生循环(子节点 → 父节点用weakref)。评分器会在关闭 GC 的情况下多次调用它,并检查gc.collect()是否为 0。 - 在同一个文件里,按下面的“测量规则”编写
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。 - 在导入了
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。 - 在同一个文件里编写:
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。 - 在导入
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。 - 在同一个文件里编写
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)] 번째, 소수 넷째 자리)
参考
- 评分器会导入
mem.py。生成结果 JSON 的代码,请放在if __name__ == "__main__":之下或单独的脚本里。 - GC 和 tracemalloc 的数字,取决于该进程已经导入了什么。请在每一步的全新
python3进程中,按指示的顺序测量。 - 常见错误:只用 getsizeof 就写下整个容器的大小;把单个快照里最大的那一行指认为元凶;把整个请求时间写成 GC 暂停;上限定得太大,修好之后增长依然存在。
- 产出会在会话结束后消失。需要的话请另行保存。
打印这台 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 条。