参照カウントの仕組みと循環参照
メモリがいつ解放されるか読める仕組みが参照カウント。即時解放の原理とコスト、循環参照リークの正体、弱参照やサイクル検出で穴を塞ぐ方法をARCとCPythonで正確に押さえる。
- 1.参照カウントは各オブジェクトに参照数を持たせ、加算(retain)・減算(release)して0になった瞬間に解放する決定的(deterministic)な回収方式。
- 2.互いを指し合う循環参照ではカウントが0に落ちずリークするため、弱参照(weak)で輪を断つか、別途サイクル検出器で補う必要がある。
- 3.ARC(Swift/Obj-C)はカウント操作をコンパイル時に挿入し循環は開発者が弱参照で防ぐ。CPythonは参照カウント+世代別サイクルGCを併用する。
参照カウントという回収方式
参照カウント(reference counting)は、各オブジェクトに「今いくつの参照から指されているか」を表す整数カウンタを持たせ、参照が増えたら加算(retain)、参照が消えたら減算(release)し、0になった瞬間にそのオブジェクトを解放するメモリ回収方式です。トレース型GCがルートからの到達可能性を周期的に調べるのに対し、参照カウントは参照の増減という局所的なイベントだけで生死を判定します。
最大の特徴は**決定的(deterministic)**であること。最後の参照が外れたその場で解放されるため、「いつ回収されるか」がコード上で読め、ファイルハンドルやロックの解放(RAII/deinit)を確実に結び付けられます。停止(stop-the-world)も原理上は生じません。
let a = Node() // count = 1
let b = a // retain → count = 2
b = nil // release → count = 1
a = nil // release → count = 0 → 即解放(deinit実行)
カウント操作のコストと正確さ
この方式は単純に見えて、いくつかの実コストを抱えます。
- 更新オーバーヘッド: ポインタ代入のたびに retain/release が走るため、読み書きの多いコードでカウンタ更新が積み重なります。トレース型GCが「死んだものに触れない」のと対照的に、参照カウントは生きている参照の付け替えすべてに課金されます。
- 解放の連鎖(cascade): 長いリストの先頭を解放すると、release が次々と0を生み、一気に多数の解放が走って局所的なポーズになり得ます。
- キャッシュとアトミック性: カウンタはオブジェクトと同じヒープ領域に置かれることが多く、更新が頻繁だとキャッシュを汚します。さらに複数スレッドが同じオブジェクトを共有する場合、カウンタ更新はアトミック命令(
fetch_add等)が必要で、これは通常のメモリ書き込みより数倍重くなります。
複数スレッドから同時に retain/release されると、count++ の読み・加算・書き戻しが競合し、解放漏れや二重解放を招きます。これを防ぐためアトミックなインクリメント/デクリメントを使いますが、CPUコア間でキャッシュラインの所有権を奪い合う(cache-line contention)ため、競合の激しい共有オブジェクトでは深刻なボトルネックになります。Rustが単一スレッド用の Rc とスレッド安全な Arc を分けているのは、このコストを払うかを型で選ばせるためです。
循環参照 — カウントが0に落ちない欠陥
参照カウント最大の弱点が循環参照(reference cycle)です。オブジェクトAがBを、BがAを互いに参照すると、外部からの参照がすべて消えても両者のカウントは互いの参照分(1)だけ残り続け、決して0になりません。到達不能なのに解放されない、典型的なメモリリークです。
A.next = B // Bのcount = 2
B.prev = A // Aのcount = 2
(外部参照を全て破棄)
→ A.count = 1, B.count = 1 ← 0にならずリーク
これは参照カウントが局所的な情報しか持たないことの必然的な帰結です。「外から到達できるか」というグローバルな性質は、各オブジェクトのカウンタを見るだけでは判定できません。双方向リスト、親子を相互に指すツリー、observer同士の相互登録、クロージャが自分を含むオブジェクトを捕捉する場合などで容易に発生します。
弱参照 — 輪を意図的に断つ
第一の対策は弱参照(weak reference)です。弱参照は対象を指しますがカウントを増やしません。よって所有関係の輪のうち「戻り側」を弱参照にすれば、強参照のカウントだけで生死が決まり、循環が成立しません。
| 参照の種類 | カウント | 対象が消えたとき | 用途 |
|---|---|---|---|
| 強参照(strong) | 加算する | 自分が握る限り生存 | 所有を表す主たる参照 |
| 弱参照(weak) | 加算しない | 自動でnilになる | 親への戻り・キャッシュ・delegate |
| 無所有(unowned) | 加算しない | 解放後アクセスでクラッシュ | 寿命が必ず対象以上と保証できる場合 |
弱参照は対象が解放されたら自動的に無効(nil)になるよう実装されるのが要点です。これにより、解放済みメモリを指し続けるダングリング参照を避けつつ、循環だけを断てます。設計上は「所有方向を強、逆方向を弱」と決めるのが定石で、親→子は強、子→親は弱、delegate は弱、というパターンに落ちます。
ARC — コンパイル時にカウントを挿入する
Swift と Objective-C の ARC(Automatic Reference Counting)は、参照カウントをコンパイラが静的に解析して retain/release を挿入する方式です。実行時にカウンタを操作する点はランタイムGCと同じですが、いつ操作するかをコンパイル時に確定させるため、別スレッドで動く回収器も周期的な走査も存在しません。
ARCで重要なのは、循環の解決は自動ではないという点です。コンパイラは所有グラフに輪があるかを一般には判定できないため、循環を断つ責任は開発者にあり、weak/unowned を明示します。代表例がクロージャのキャプチャリスト [weak self] で、これを忘れると self とクロージャが相互参照してリークします。
weak は対象解放後に安全に nil となりますが、参照のたびにオプショナルのアンラップが要ります。unowned はオプショナルでなく速い反面、対象がすでに解放された後にアクセスすると未定義動作やクラッシュになります。「自分の寿命が必ず対象以下」と論理的に保証できる場合だけ unowned を使い、少しでも疑わしければ weak を選ぶのが安全です。
CPython — 参照カウント+サイクル検出の併用
CPython は実用言語で参照カウントを主軸に据えた代表例です。すべての PyObject が ob_refcnt を持ち、Py_INCREF/Py_DECREF で増減して0で即解放します。ただし循環は参照カウントだけでは回収できないため、**世代別のサイクル検出GC(gc モジュール)**を併用して補います。
サイクル検出器は、コンテナ型(list, dict, クラスインスタンス等、相互参照を作り得るオブジェクト)だけを追跡対象とし、各オブジェクトの参照カウントから**「コンテナ内部だけで消費されている参照数」を差し引く**ことで、外部から到達可能な要素を選り分けます。この「差し引いても正のカウントが残るか」という判定で、輪の中だけで生きている到達不能なサイクルを特定し回収します。さらに世代仮説に従い、生き延びたオブジェクトを上の世代へ移して走査頻度を下げ、検出コストを抑えます。
CPythonの大半のオブジェクトは循環を持たず、参照カウントが0になった時点でサイクルGCを待たず即座に解放されます。サイクル検出器が走るのは循環を作り得るコンテナに限られ、それも周期的です。つまり「決定的な即時解放」という参照カウントの利点を保ったまま、循環という穴だけをトレース型の仕組みで塞いでいるわけです。
全体像 — どの欠点を何で埋めるか
参照カウントは、決定的解放という強い長所と、循環参照・更新コスト・アトミック性という弱点をセットで持ちます。各処理系はその弱点を別の道具で補っています。
| 処理系 | 主方式 | 循環への対処 | カウント操作 |
|---|---|---|---|
| ARC (Swift/ObjC) | 参照カウント | 開発者が weak/unowned で手動回避 | コンパイル時に静的挿入 |
| CPython | 参照カウント | 世代別サイクル検出GCで自動回収 | 実行時マクロ |
| Rust Rc/Arc | 参照カウント(明示型) | Weak<T> で手動回避(自動回収なし) | 代入・Drop時に実行時操作 |
Rustの所有権モデルは基本的にコンパイル時の単一所有でメモリを管理し、共有が必要な場面だけ Rc/Arc で参照カウントを選択的に使います。この場合も循環は Weak 型で開発者が断つ必要があり、ARCと同じく「自動回収はしない」立場です。一方CPythonは検出器を持つため循環も最終的に回収されます。
まとめ
参照カウントは、参照の増減という局所イベントだけで生死を判定し、最後の参照が外れた瞬間に決定的に即時解放する方式です。停止が原理上生じず解放タイミングが読める一方、参照付け替えごとの更新コスト、共有時のアトミック操作の重さ、そして循環参照でカウントが0に落ちずリークするという構造的欠陥を抱えます。循環は所有グラフの戻り側を弱参照にして断つのが基本で、ARCはこれを開発者の責任とし、CPythonは世代別サイクル検出GCで自動回収して補います。即時性という長所を保ちつつ、循環という穴をどう塞ぐか——その設計判断こそが各処理系のメモリ管理の個性です。
プログラミング Article
参照カウントの仕組みと循環参照を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
参照カウント
比較で見る軸
難易度: advanced / カテゴリ: プログラミング / タグ数: 5
導入後に効く点
互いを指し合う循環参照ではカウントが0に落ちずリークするため、弱参照(weak)で輪を断つか、別途サイクル検出器で補う必要がある。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- プログラミング
- タグ数
- 5
判断チェックリスト
- 自社の用途が「参照カウント / メモリ管理」に近いか確認する。
- 強みである「参照カウントは各オブジェクトに参照数を持たせ、加算(retain)・減算(release)して0になった瞬間に解放する決定的(deterministic)な回収方式。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。