mcpField
P
python-memory-safe-scripts

python-memory-safe-scripts

github.com/terrylica開発264 回インストール2 日前に更新
リポジトリ

python-memory-safe-scripts について

systemd のメモリ制限内で動く Python スクリプトを書くための実践的なスキルです。データ処理スクリプトをもとに、アロケーターの解放、オブジェクトの寿命、リソースの後処理を扱います。

主な機能

  1. 01アロケーターのメモリを明示的に解放
  2. 02HTTP レスポンスをすぐに閉じる
  3. 03不要になった大きなオブジェクトを削除
  4. 04ガベージコレクションを定期実行
  5. 05スレッドプールを後処理し接続を再利用
  6. 06systemd のメモリ制限を設定

活用シーン

  1. 01バッチスクリプトを systemd のメモリ上限内で実行
  2. 02大量データ処理中の RSS 増加を抑制
  3. 03ワーカープールでのファイルディスクリプター枯渇を回避

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/terrylica/cc-skills --skill python-memory-safe-scripts

インストール後、Claude Code で/python-memory-safe-scriptsを入力して読み込まれたか確認します。

詳細

メモリ安全性を高める Python スクリプトのパターン

systemd の MemoryMax 制約下で Python スクリプトを稼働させ続けるための、実戦で検証済みのパターンです。62 GB の GPU ワークステーションで、5 回の OOM 最適化サイクルを経て、repair_direct_parquet.py(24 ワーカーによる並列修復)と exness_tick_cache_seeder.py(10 銘柄の日次シーダー)から抽出しました。

重要なポイント:Python のガベージコレクターはオブジェクトを解放しますが、C アロケーター(glibc ptmalloc2)は解放済みページを OS に返しません。malloc_trim(0) を明示的に呼び出さない限り、del と gc.collect() の後でも RSS は増加し続けます。mimalloc と MIMALLOC_PURGE_DELAY の併用も役立ちますが、明示的なパージの方が高速です。

進化し続けるスキル:このスキルは使用を通じて改善されます。手順に誤りがある、パラメーターがずれている、または回避策が必要だった場合は、先延ばしせず、すぐにこのファイルを修正してください。実際に再現可能な問題がある場合に限り更新してください。

7 つのパターン

1. キャッシュされたアロケーターのパージ

最も重要なパターンです。初回の呼び出し時に ctypes ライブラリハンドルをキャッシュすれば、以降のパージは割り当てオーバーヘッドなしの単一 FFI 呼び出しで済みます。

import ctypes
import gc
import sys

_purge_lib = None
_purge_method = None  # "mimalloc" | "glibc" | "none"

def _force_allocator_purge():
    """Force mimalloc/glibc to return freed pages to the OS."""
    global _purge_lib, _purge_method

    if sys.platform != "linux":
        return

    if _purge_method is None:
        try:
            _purge_lib = ctypes.CDLL("libmimalloc.so.2")
            _purge_method = "mimalloc"
        except OSError:
            try:
                _purge_lib = ctypes.CDLL("libc.so.6")
                _purge_method = "glibc"
            except OSError:
                _purge_method = "none"

    if _purge_method == "mimalloc":
        _purge_lib.mi_collect(ctypes.c_bool(True))
    elif _purge_method == "glibc":
        _purge_lib.malloc_trim(0)

def _force_gc():
    """Python GC + allocator purge. Call every 50 iterations + between work units."""
    gc.collect()
    _force_allocator_purge()

ハンドルのキャッシュが重要な理由:ctypes.CDLL("libc.so.6") は dlopen() を呼び出し、それ自体がメモリを割り当てます。ループ内で 1400 回呼び出すのは逆効果です。一度だけキャッシュしてください。

mimalloc を優先する理由:LD_PRELOAD=libmimalloc.so.2 が有効な場合、mimalloc がすべての割り当てを横取りしているため、glibc の malloc_trim は何もしません。mimalloc では mi_collect(True) が適切なパージ方法です。

2. HTTP レスポンスのライフサイクル

必要な内容を取り出したら、すぐにレスポンスを閉じてください。requests ライブラリはレスポンス本文、コネクションプールへの参照、urllib3 の内部状態を保持します。

# CORRECT: extract content, close, delete
resp = requests.get(url, timeout=60)
if resp.status_code != 200:
    resp.close()
    return None

content = resp.content  # Extract what you need
resp.close()            # Release connection pool reference
del resp                # Drop the Python object

# Process content...
del content             # Release after processing
# WRONG: response lives until end of function scope
resp = requests.get(url, timeout=60)
data = parse(resp.content)  # resp still alive, holding ~18 MB
return data                 # resp GC'd eventually... maybe

重要な理由:各 requests.Response は content(本文全体)、urllib3.HTTPResponse への参照、およびコネクションプールの PoolManager を保持します。4 つのワーカーが同時に 1400 個の URL を処理する場合、閉じていないレスポンスが蓄積し、数百 MB に達します。

3. オブジェクトの明示的な削除

大きなオブジェクトについて、Python の GC に頼らないでください。オブジェクトが不要になったら、すぐに del を使用してください。

# After writing a DataFrame to Parquet
_atomic_write_parquet(df, path)
del df  # Only reference gone → immediate refcount GC

# After extracting data from a ZIP
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
    df = pl.read_csv(zf.open(zf.namelist()[0]), ...)
del zip_bytes  # Release raw ZIP content after parsing

# After processing a list of work items
results = process_all(missing_days)
del missing_days  # Release the 1400-element date list

del を使うタイミング:不要になった約 1 MB を超えるオブジェクトです。DataFrame、HTTP レスポンスのバイト文字列、ZIP の内容、大きなリストが該当します。

4. GC の定期的な実行間隔

次の 2 つのレベルで _force_gc() を呼び出してください。

# Level 1: Every 50 iterations within a work unit
for i, item in enumerate(items):
    process(item)
    if (i + 1) % 50 == 0:
        _force_gc()

# Level 2: Between major work units
for symbol in symbols:
    seed_symbol(symbol)
    _force_gc()  # Release all per-symbol state before next symbol

50 が適切な理由:32 コアのワークステーションで実証されています。100 ではパージ前に RSS が増えすぎます。25 ではパージのオーバーヘッドが測定可能になります(スループットが約 2% 低下)。repair_direct_parquet.py で 50 が最適な値でした。

5. ThreadPoolExecutor のクリーンアップ

エグゼキューターの終了後、残存する状態を明示的にクリーンアップしてください。

with concurrent.futures.ThreadPoolExecutor(max_workers=4) as ex:
    pending = {}
    # ... bounded future submission pattern ...

# After pool exits:
del pending     # Future objects hold references to results
del missing     # Work item list
_force_gc()     # Release worker thread memory + allocator pages

より高度なケース(ワーカー内の DB 接続など)では、スレッドローカルリソースを明示的に閉じてください。

pool.shutdown(wait=False, cancel_futures=True)
for t in threading.enumerate():
    if t.name.startswith("ThreadPoolExecutor"):
        _close_worker_cache()  # Close DB connections
gc.collect()
_force_allocator_purge()
6. スレッドローカルな接続の再利用

ループ内でデータベース接続や HTTP セッションを作成しないでください。threading.local() を使って、ワーカースレッドごとに 1 つの接続を取得してください。

import threading

_thread_local = threading.local()

def _get_worker_cache():
    """One DB connection per worker thread, reused across all iterations."""
    cache = getattr(_thread_local, "cache", None)
    if cache is None:
        cache = DatabaseClient()
        _thread_local.cache = cache
    return cache

def _close_worker_cache():
    """Explicit cleanup at shutdown."""
    cache = getattr(_thread_local, "cache", None)
    if cache is not None:
        cache.close()
        _thread_local.cache = None

ファイルディスクリプターの枯渇を防げる理由:各 urllib3.PoolManager(maxsize=20) は最大 20 個のファイルディスクリプターを保持します。24 ワーカーのプール内で反復ごとに新しいものを作成すると、数分で ulimit -n 1024 を使い切ります。スレッドローカルな再利用により、ワーカー数を N とした場合のファイルディスクリプター数を約 4N+50 に抑えられます。

7. systemd サービスの設定
[Service]
# Memory limits — hard kill prevents runaway RSS
MemoryHigh=2G        # Soft limit: triggers reclaim pressure
MemoryMax=4G         # Hard limit: SIGKILL on breach
MemorySwapMax=0      # No swap escape — fail fast, don't thrash

# mimalloc: replaces glibc ptmalloc2, returns freed pages faster
Environment=LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libmimalloc.so.2
Environment=MIMALLOC_PURGE_DELAY=1000

# OOM priority (lower = more likely to survive)
OOMScoreAdjust=-200
ManagedOOMMemoryPressure=kill

MemoryHigh と MemoryMax の違い:MemoryHigh はカーネルによるメモリ回収(cgroup のメモリー圧力)を引き起こします。プロセスの動作は遅くなりますが、終了しません。MemoryMax は強制終了を伴う上限です。カーネルが強制終了前にメモリを回収できるように、MemoryHigh は MemoryMax の 50〜66% に設定してください。


アンチパターン

アンチパターン失敗する理由修正方法
ループ内での ctypes.CDLL("libc.so.6")dlopen() はメモリを割り当てます。1000 回の呼び出しで約 50 MB を浪費しますハンドルをモジュールのグローバル変数にキャッシュする
resp.close() を呼び出さない requests.get()レスポンスボディと接続プールが GC まで保持されますコンテンツを抽出した直後に resp.close() と del resp を実行する
作業単位の間に gc.collect() を呼び出さない循環参照がシンボル/バッチをまたいで蓄積します主要な作業単位ごとに _force_gc() を実行する
ループの反復ごとに新しい DB 接続を作成する各接続で urllib3 PoolManager 経由の fd を 20 個消費しますスレッドごとに接続を再利用するため threading.local() を使う
OOM の修正に MemoryMax を引き上げるメモリリークを隠すだけです。RSS はどんな制限も埋めるまで増加しますまずメモリリークを修正してください。修正方法は常にパターン 1~6 のいずれかです
gc.collect() を呼び出さずに del df する参照カウントでオブジェクトは解放されますが、glibc はページを保持しますdel + gc.collect() + _force_allocator_purge() を実行する
MemorySwapMax が設定されていないプロセスは終了せずにディスクへスワップし、何時間もスラッシングしますMemorySwapMax=0 を設定して、スラッシングさせずに速やかに失敗させる

診断チェックリスト

systemd の下でスクリプトが SIGKILL(status=9)を受けた場合:

  1. OOM であることを確認する:journalctl --user -u service.service | grep -E "killed|signal|KILL"
  2. ピーク RSS を確認する:systemctl --user status service.service | grep Memory(ピーク値が表示されます)
  3. 定常状態の RSS をプロファイルする:スクリプトを手動で実行し、30 秒間隔で 3 回、/proc/PID/status の VmRSS を確認します
  4. fd 数を確認する:ls /proc/PID/fd | wc -l — 500 を超える場合は、接続の頻繁な生成を疑います(パターン 6)
  5. アロケーターを確認する:サービスファイルに LD_PRELOAD=libmimalloc.so.2 がありますか?glibc の場合は、malloc_trim が呼び出されているか確認します
  6. 定期的なログを追加する:50 回の反復ごとに logger.info("RSS=%d MB", psutil.Process().memory_info().rss // 1048576) を実行します

参考実装

スクリプト使用パターンRSS プロファイル
scripts/repair_direct_parquet.py7 つすべてのパターン3 GB で開始し、24 ワーカーで約 13 GB で頭打ちになります
scripts/exness_tick_cache_seeder.pyパターン 1~5、710 シンボル × 1400 日間、163 MB で一定です
scripts/tick_cache_seeder.pyパターン 3、7mimalloc ではピーク 2.5 GB(glibc では 4.47 GB)

実行後の振り返り

このスキルの実行が完了したら、終了する前に確認してください:

  1. コマンドは成功しましたか? — 成功しなかった場合は、失敗の原因となった指示またはエラーテーブルを修正してください。
  2. パラメーターまたは出力に変更はありましたか? — 基盤となるツールのインターフェースに変更があった場合は、使用例とパラメーター表を更新して、それに合わせてください。
  3. 回避策が必要でしたか? — 別のフラグや追加手順などの工夫が必要だった場合は、次回同じ回避策が必要にならないよう、この SKILL.md を更新してください。

問題が実在し、再現可能な場合にのみ更新してください。推測に基づいて更新しないでください。

コメント