処理内容は変えていないのに、応答時間が不定期に跳ねる。CPU にもディスクにも余裕がある。こういうとき、原因が にあることがあります。
言語ごとに仕組みは違いますが、考え方は共通しています。この回はその共通部分を扱います。
1. なぜ遅さとして現れるのか
GC は、使われなくなったメモリを自動で回収する仕組みです。開発者がメモリ管理から解放される代わりに、回収のタイミングを制御できません。
回収中は、処理が止まるか遅くなります。この停止が短く頻繁なら問題になりませんが、長い停止が時々起きると、その瞬間のリクエストだけが極端に遅くなります。
| 症状 | 示唆 |
|---|---|
| 平均は良いのに p99 だけ極端に悪い | 長い停止が時々起きている |
| メモリ使用量がのこぎり状に増減 | 確保と回収を繰り返している。正常な形 |
| メモリ使用量が右肩上がり | の疑い |
| 時間が経つほど遅くなる | 回収の対象が増え続けている |
1行目が典型です。EP.1 で を見るべきだと書きましたが、GC が原因の遅さは平均にはほとんど現れません。平均だけを見ていると、この問題は存在しないことになります。しかも停止はリクエストの内容と無関係に起きるため、どの処理が遅いのかを調べても原因にたどり着けません。「特定の処理ではなく、時々どれかが遅い」という症状が出たら、この線を疑ってください。
メモリ使用量が増えては落ち、また増えては落ちる形は健全な状態です。問題なのは、落ちる位置が少しずつ上がっている場合。回収しきれていないものが蓄積しています。
2. 設定より先に、ゴミを減らす
GC が原因だと分かると、まず設定の調整に向かいがちです。しかし効果が大きいのは、そもそも回収対象を作らないことです。
- 全件をメモリに載せない — 区切って流す(EP.7 と同じ話)
- 繰り返しの中で大きなものを作らない — ループの外へ出せないか
- 不要な複製を減らす — 変換のたびに新しい入れ物を作っていないか
- 使い終わったら参照を切る — 特に長生きする入れ物に溜め込まない
1番目が最も効きます。件数に比例してメモリを使う処理は、件数が増えた日に必ず問題になります。区切って処理する形にしておけば、件数が増えても使用量は一定に保てます。
# 悪い例: 件数に比例してメモリを使うdef process_bad(path): rows = open(path).readlines() # 全行をメモリに載せる parsed = [parse(r) for r in rows] # さらにもう一式作る return [transform(p) for p in parsed] # さらにもう一式
# 良い例: 1行ずつ流す。件数が増えても使用量は一定def process_good(path): with open(path) as f: for row in f: # 1行ずつ読む yield transform(parse(row)) # 都度返す(溜めない)悪い例は、同じデータの複製を3つ同時に保持しています。1万行なら問題なくても、1000万行では停止します。良い例は1行ずつ処理して都度返すため、行数が増えても使用量は変わりません。
この違いは、開発中には一切現れません。手元のテストデータが数千行なら、どちらも一瞬で終わります。EP.10 で扱う で本番相当の件数を流して初めて差が出るため、少量データでの確認だけでは見つけられない類の問題です。
3. リークを見つける
メモリリーク は、不要になったのに解放されないメモリが蓄積し続ける状態です。GC がある言語でも、参照が残っていれば回収されないため発生します。
見つけ方は単純で、時間経過に対する使用量の推移を見ます。処理が落ち着いたあとも右肩上がりなら、蓄積しています。
import gcfrom collections import Counter
def snapshot_by_type(): """いま生きているオブジェクトを型ごとに数える。""" gc.collect() return Counter(type(o).__name__ for o in gc.get_objects())
before = snapshot_by_type()for _ in range(1000): handle_request() # 疑わしい処理を繰り返すafter = snapshot_by_type()
# 増えた数が多い順に並べるdiff = [(after[k] - before.get(k, 0), k) for k in after]for delta, name in sorted(diff, reverse=True)[:10]: if delta > 0: print(f" +{delta:6d} {name}")繰り返し実行して増え続ける型が見つかれば、そこが手がかりになります。よくある原因は次のとおりで、いずれも長生きする入れ物に溜め込んでいるという共通点があります。
| 原因 | 典型例 |
|---|---|
| 上限のないキャッシュ | 辞書に入れ続け、消さない |
| 登録したものを解除していない | イベントの購読、コールバック |
| 閉じていない資源 | 接続、ファイル、一時領域 |
| 溜め続けるログや履歴 | 配列に追加し続ける |
| 循環参照+独自の後処理 | 回収されず残る場合がある |
EP.6 で扱った は、上限を決めていないとリークそのものになります。件数の上限か有効期限、少なくともどちらかは必ず入れてください。「めったに増えないから」という前提は、たいてい時間とともに崩れます。
4. 設定を触るとき
ゴミを減らしてなお問題が残る場合、設定の調整に進みます。ここで押さえておくべき交換関係があります。
| 変更 | 得られるもの | 失うもの |
|---|---|---|
| 使用可能なメモリを増やす | 回収の頻度が下がる | 1回の停止が長くなりうる |
| 回収を頻繁にする | 1回の停止が短い | 全体としての処理量が減る |
| 停止時間を優先する方式に変える | 跳ねが減る | 処理量が犠牲になる |
| 処理量を優先する方式に変える | 総処理量が増える | 跳ねが大きくなる |
1行目は直感に反するかもしれません。メモリを増やせば安全と考えがちですが、回収時に走査する量も増えるため、1回の停止が長くなることがあります。利用者が待つ経路では、跳ねの大きさのほうが問題になります。
何を優先するかは、用途で決まります。利用者が待つ処理なら停止時間を、バッチなら総処理量を優先する。同じシステムでも役割によって答えが違うため、用途ごとに分けて設定するという判断もあり得ます。
5. 判断の順序
メモリまわりは深入りしやすい領域なので、順序を決めておきます。
- 1分位点を見る — 平均では現れない。p99 と最大値
- 2使用量の推移を見る — のこぎり状か、右肩上がりか
- 3右肩上がりならリークを疑う — 増えている型を特定する
- 4のこぎり状なら量を減らす — 全件を載せていないか
- 5最後に設定を触る — 交換関係を理解したうえで
3番と4番で対処がまったく違うため、2番の判別を飛ばさないことが重要です。リークなのにゴミの量を減らそうとしても、蓄積は止まりません。逆に、リークではないのにリークを探しても見つかりません。推移のグラフを1枚見るだけで判別できるので、ここは飛ばさないでください。
GC 由来の遅さは平均に現れず、分位点に出る。のこぎり状は正常、右肩上がりが メモリリーク。設定より先にゴミの量を減らす(全件を載せない)。リークの典型は上限のないキャッシュと解除し忘れた登録。設定変更は停止時間と処理量の交換だと理解して触る。
この記事の感想を教えてください
あなたの 1 クリックで、本当にこの記事は更新されます。「もっと詳しく」「続編希望」が一定数集まった記事は、 ふくふくが 実際に内容を拡充したり続編記事を公開 します。 送信したリアクションはお使いのブラウザに記録され、再カウントされません。