【C++】shared_ptrの循環参照でデストラクタが呼ばれない?|メモリが増え続ける様子を実測して、weak_ptrで解放できる形に直す

【C++】shared_ptrの循環参照でデストラクタが呼ばれない?|メモリが増え続ける様子を実測して、weak_ptrで解放できる形に直す C++

スマートポインタを使えば、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.4
weak_ptr版
作った組数 プロセスのメモリ(MB)
0            0.7
25000        0.7
50000        0.7
75000        0.7
100000       0.7
作って捨てたペアの数と、プロセスのメモリ使用量。shared_ptrの循環では増え続け、weak_ptrにすると増えない
循環のままだと、作った分だけ、そっくりメモリが残る。weak_ptrにすると、ずっと0.7MBのまま

循環のほうは、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 = 1

use_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 = 0

use_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の使い分けです。

あわせて読みたい