スマートポインタを使えば、deleteを書かなくても、メモリは勝手に解放される。そう思って、shared_ptrだけでオブジェクトを管理していたのに、デストラクタに仕込んだprintfが、一度も表示されない。エラーも、警告も、出ません。
shared_ptrには、ひとつだけ、自動では解放できない形があります。お互いをshared_ptrで持ち合う「循環参照」です。この記事では、それを実際に作って、デストラクタが呼ばれない様子、メモリが増え続ける量、そして直し方を、MSVC(19.44、x64)の実行結果で確かめました。
shared_ptrの仕組みと、循環参照とは?
shared_ptrは、
・同じオブジェクトを指しているshared_ptrの数(参照カウント)を数えて、0になったときに、自動で解放するスマートポインタ
です。この数は、use_count()で見られます。ここで、AとBというオブジェクトが、お互いをshared_ptrで持っていたとします。AはBを、BはAを指しているので、外側の変数がなくなっても、お互いに1つずつ、カウントが残ります。どちらも0にならず、解放されません。これが、循環参照です。
実験①:お互いをshared_ptrで持つ
Nodeに、nextとprevを持たせて、両方をshared_ptrにしました。コンストラクタとデストラクタには、表示を入れて、生き残っている数も数えます。
▼s1_cycle.cpp
#include <cstdio>
#include <memory>
static int g_alive = 0;
struct Node
{
const char* name;
std::shared_ptr<Node> next;
std::shared_ptr<Node> prev; // お互いを、shared_ptrで持ち合う
explicit Node(const char* n) : name(n) { ++g_alive; std::printf(" 生成: %s\n", name); }
~Node() { --g_alive; std::printf(" 破棄: %s\n", name); }
};
int main()
{
{
auto a = std::make_shared<Node>("A");
auto b = std::make_shared<Node>("B");
a->next = b;
b->prev = a;
std::printf("スコープを出る直前: A.use_count=%ld, B.use_count=%ld\n", a.use_count(), b.use_count());
}
std::printf("スコープを出たあと: 生き残っているNode = %d\n", g_alive);
return 0;
} 生成: A
生成: B
スコープを出る直前: A.use_count=2, B.use_count=2
スコープを出たあと: 生き残っているNode = 2デストラクタの「破棄」が、1行も出ませんでした。スコープを出たあとも、Nodeが2個、生き残っています。
数字で追うと、理由が分かります。スコープに入る直前のuse_countは、AもBも2でした。「変数a」と「Bのprev」が、Aを指しています(Bも同じ)。スコープを出ると、変数aとbが消えて、カウントは、それぞれ1に下がります。でも、0にはなりません。残りの1つ(nextとprev)は、お互いが持っているので、永遠に消えません。
直し方:片方をweak_ptrにする
weak_ptrは、「参照カウントを増やさずに、相手を指す」ポインタです。持ち合いの片方(今回は、戻る側のprev)を、weak_ptrにします。
▼s2_weak.cpp(prevだけをweak_ptrに変えた)
#include <cstdio>
#include <memory>
static int g_alive = 0;
struct Node
{
const char* name;
std::shared_ptr<Node> next;
std::weak_ptr<Node> prev; // 戻る側は、weak_ptrにする
explicit Node(const char* n) : name(n) { ++g_alive; std::printf(" 生成: %s\n", name); }
~Node() { --g_alive; std::printf(" 破棄: %s\n", name); }
};
int main()
{
{
auto a = std::make_shared<Node>("A");
auto b = std::make_shared<Node>("B");
a->next = b;
b->prev = a;
std::printf("スコープを出る直前: A.use_count=%ld, B.use_count=%ld\n", a.use_count(), b.use_count());
}
std::printf("スコープを出たあと: 生き残っているNode = %d\n", g_alive);
return 0;
} 生成: A
生成: B
スコープを出る直前: A.use_count=1, B.use_count=2
破棄: A
破棄: B
スコープを出たあと: 生き残っているNode = 0今度は、破棄: Aと破棄: Bが出て、生き残りは0になりました。use_countも、Aが1、Bが2と、実験①の2と2から変わっています。Bのprevは、Aのカウントを増やさないので、Aは変数aだけで数えられています。変数aが消えると、Aが先に壊れて、そのとき、Aが持っていたnextも外れるので、Bも続いて壊れました。
ルールは、1つだけです。「所有する側」はshared_ptr、「相手を覗くだけ」の側はweak_ptrにします。親から子はshared_ptr、子から親へ戻る参照はweak_ptr、という形が定番です。
実験②:解放されないと、メモリはどれだけ増えるのか
「デストラクタが呼ばれないだけなら、実害は小さいのでは?」という疑問を、確かめました。1個1KBのデータを持ったNodeを、2個ずつペアにして、お互いに指させます。それを作っては捨てる、という処理を、10万組分まで繰り返して、プロセスのメモリ使用量を測りました。
▼s4_mem.cpp
#include <windows.h>
#include <psapi.h>
#include <cstdio>
#include <cstring>
#include <memory>
#include <vector>
struct NodeStrong
{
std::shared_ptr<NodeStrong> other;
char payload[1024];
};
struct NodeWeak
{
std::weak_ptr<NodeWeak> other;
char payload[1024];
};
static double privateMB()
{
PROCESS_MEMORY_COUNTERS_EX pmc{};
GetProcessMemoryInfo(GetCurrentProcess(), (PROCESS_MEMORY_COUNTERS*)&pmc, sizeof(pmc));
return pmc.PrivateUsage / (1024.0 * 1024.0);
}
int main(int argc, char** argv)
{
const bool weak = argc > 1 && std::strcmp(argv[1], "weak") == 0;
std::printf("%s版\n", weak ? "weak_ptr" : "shared_ptr(循環)");
std::printf("%-12s %s\n", "作った組数", "プロセスのメモリ(MB)");
std::printf("%-12d %.1f\n", 0, privateMB());
for (int round = 1; round <= 4; ++round)
{
for (int i = 0; i < 25000; ++i)
{
if (weak)
{
auto a = std::make_shared<NodeWeak>();
auto b = std::make_shared<NodeWeak>();
a->other = b;
b->other = a;
}
else
{
auto a = std::make_shared<NodeStrong>();
auto b = std::make_shared<NodeStrong>();
a->other = b;
b->other = a;
}
}
std::printf("%-12d %.1f\n", round * 25000, privateMB());
}
return 0;
}shared_ptr(循環)版
作った組数 プロセスのメモリ(MB)
0 0.7
25000 55.3
50000 109.6
75000 164.1
100000 218.4weak_ptr版
作った組数 プロセスのメモリ(MB)
0 0.7
25000 0.7
50000 0.7
75000 0.7
100000 0.7
循環のほうは、25,000組ごとに、約55MBずつ増えて、10万組で218MBになりました。weak_ptrに直した側は、10万組作っても、0.7MBのままです。ゲームのように、敵や弾を何度も生成して捨てるプログラムでは、この差が、「長く遊ぶほど重くなる」という症状になります。
実験③:ラムダの「自己キャプチャ」でも起きる
相手のオブジェクトがなくても、循環は起きます。自分自身を、自分の持つラムダでキャプチャしたときです。コールバックを持つ敵を、例にしました。
▼s3_lambda.cpp
#include <cstdio>
#include <functional>
#include <memory>
static int g_alive = 0;
struct Enemy
{
const char* name;
std::function<void()> onDead; // 倒されたときに呼ぶ処理
explicit Enemy(const char* n) : name(n) { ++g_alive; }
~Enemy() { --g_alive; std::printf(" 破棄: %s\n", name); }
};
int main()
{
{
auto e = std::make_shared<Enemy>("スライム");
e->onDead = [e]() { std::printf("%sが倒された\n", e->name); }; // 自分自身をキャプチャ
std::printf("use_count = %ld\n", e.use_count());
}
std::printf("スコープを出たあと: 生き残っているEnemy = %d\n", g_alive);
return 0;
}use_count = 2
スコープを出たあと: 生き残っているEnemy = 1use_countは2で、スコープを出たあとも、Enemyが1個、生き残りました(「破棄」の表示も出ていません)。ラムダの[e]は、shared_ptrをコピーして持ちます。そのラムダを、e自身のonDeadが持っているので、自分で自分を、離せなくなりました。
直し方は、実験①と同じ考え方です。weak_ptrを作って、それを、ラムダにキャプチャします。
▼s3_lambda_fix.cpp
#include <cstdio>
#include <functional>
#include <memory>
static int g_alive = 0;
struct Enemy
{
const char* name;
std::function<void()> onDead;
explicit Enemy(const char* n) : name(n) { ++g_alive; }
~Enemy() { --g_alive; std::printf(" 破棄: %s\n", name); }
};
int main()
{
{
auto e = std::make_shared<Enemy>("スライム");
std::weak_ptr<Enemy> w = e; // 弱い参照を作って、それをキャプチャ
e->onDead = [w]()
{
if (auto self = w.lock()) // 生きているときだけ使う
{
std::printf("%sが倒された\n", self->name);
}
};
e->onDead();
std::printf("use_count = %ld\n", e.use_count());
}
std::printf("スコープを出たあと: 生き残っているEnemy = %d\n", g_alive);
return 0;
}スライムが倒された
use_count = 1
破棄: スライム
スコープを出たあと: 生き残っているEnemy = 0use_countは1のままで、スコープを出ると「破棄: スライム」が出て、生き残りは0になりました。ラムダの中で使うときは、w.lock()で、shared_ptrを取り出します。相手がすでに壊れていれば、lock()は空を返すので、ifで確かめてから、使います。
循環参照を、見つける方法
①生き残り数を、数える
実験で使った方法です。コンストラクタで+1、デストラクタで-1する変数を作っておき、終了時に、0になっているかを確かめます。仕組みが簡単で、すぐに使えます。
②デバッグビルドの、リークレポートを使う
Visual Studioのデバッグビルドには、プログラム終了時に、解放されていないメモリを、報告する機能があります。_CrtSetDbgFlagで有効にして、出力先を標準エラー出力にしました(/Od /MDdでビルド)。
▼s5_leakreport.cpp
#include <crtdbg.h>
#include <cstdio>
#include <memory>
struct Node
{
std::shared_ptr<Node> other;
int id = 0;
};
int main()
{
// 終了時にリークを報告させて、報告先を標準エラー出力にする
_CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF);
_CrtSetReportMode(_CRT_WARN, _CRTDBG_MODE_FILE);
_CrtSetReportFile(_CRT_WARN, _CRTDBG_FILE_STDERR);
auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();
a->id = 1;
b->id = 2;
a->other = b;
b->other = a; // 循環
std::printf("main終了\n");
return 0;
}main終了
Detected memory leaks!
Dumping objects ->
{120} normal block at 0x000002031FEA1110, 40 bytes long.
Data: <xB > 78 42 B0 82 F7 7F 00 00 01 00 00 00 01 00 00 00
{119} normal block at 0x000002031FEA11F0, 40 bytes long.
Data: <xB > 78 42 B0 82 F7 7F 00 00 01 00 00 00 01 00 00 00
Object dump complete.「Detected memory leaks!」と出て、2つのブロック(Nodeが2個分)が報告されました。どの変数が原因かまでは分かりませんが、「リークしている」ことが、確実に分かります。ブロックのサイズ(40 bytes)は、今回のNodeと参照カウントの管理領域をまとめて確保した大きさです。なお、アドレスやDataの中身は、実行ごとに変わります。
shared_ptrの循環参照の、よくある失敗例と対処法
①親子で、お互いにshared_ptrを持たせる
親が子を、子が親を持つ構造は、特に起きやすい形です。子から親への参照はweak_ptrにします。
②ラムダでthisやshared_ptrを、そのまま渡す
コールバックや、イベントの登録でよく起きます。実験③のように、自分自身を[e]でキャプチャすると、自己参照になります。weak_ptrを作って、渡します。
③weak_ptrを、lock()せずに使おうとする
weak_ptrは、そのままでは、中身にアクセスできません。lock()で、shared_ptrを取り出してから使います。取り出した結果が空かどうかを、確認するのも忘れないようにします。
④全部をshared_ptrにしてしまう
「迷ったらshared_ptr」にすると、所有者が曖昧になります。所有者が1人に決まっているなら、unique_ptrで足ります。shared_ptrは、本当に、共有が必要な場面だけにします。
注意点
- 循環参照は、エラーも警告も出ない。デストラクタが呼ばれないことで、初めて気づく
- 「所有する側」は
shared_ptr、「覗くだけ」の側はweak_ptrにする weak_ptrはuse_countを増やさない(実験②では、Aが1、Bが2になった)- ラムダで自分自身を
shared_ptrのままキャプチャすると、自己参照の循環になる - 今回の実験では、10万組で約218MBが解放されずに残った。作っては捨てる処理で、特に目立つ
weak_ptrを使うときは、lock()して、空でないか確認する- 今回の検証は、MSVC 19.44(Visual Studio 2022付属、x64、
/std:c++17)で行った。メモリの数値は、環境で変わる
結論
shared_ptr同士で持ち合うと、use_countが0にならず、デストラクタが呼ばれない(実験①)- 片方を
weak_ptrにすると、どちらも正しく破棄された(実験①の直し) - 解放されないと、10万組で218MB増えたが、
weak_ptrにすると0.7MBのままだった - ラムダの自己キャプチャでも、同じ現象が起きる。
weak_ptrをキャプチャして、使うときにlock()する
スマートポインタは「deleteを書かなくてよい」仕組みですが、「所有の関係を考えなくてよい」仕組みではありません。誰が持ち主で、誰が覗くだけなのか。その線引きを、型で書き分けるのが、shared_ptrとweak_ptrの使い分けです。


