std::threadで意識する変数スコープ

に公開

std::threadを作成する時は、よく以下のようにラムダ式関数を使うことがあります。

std::thread t([&] {
    // 何かをする
});

簡単な作業を別のスレで行いたい時はかなり便利ですよね。まず以下のようにラムダ式関数を変数にしてから渡してもいいです。

auto thread_func([&] {
    // 何かをする
});
std::thread t(thread_func);

でもこのthread_funcを違う関数に指定してその中でスレッドを作成したい場合はどうしますか?

C++20 以降は以下のようにautoでそのまま指定できるようになりました。コンパイラーによって、C++11 以降で既に使える場合がありますが、標準規格に追加されたのは C++20 からです。

void startThread(auto& func) {
    std::thread t(func);
    t.join();
}

C++20 未満では、thread_funcを渡す時に型を明示的に指定しないといけないです。でもthread_funcの型は何なのでしょうか?

以下のコードをコンパイルすると、thread_funcfooというメンバーが存在しないというコンパイルエラーになりますが、そのエラーでthread_funcの型が分かります。

#include <iostream>
#include <typeinfo>

int main() {
    auto thread_func ([&] {
        // 何かをする
    });

    decltype(thread_func)::foo = 1;

    return 0;
}

私が使ったコンパイラーでは以下のエラーになりました。

<source>: In function 'int main()':
<source>:9:28: error: 'foo' is not a member of 'main()::<lambda()>'
    9 |     decltype(thread_func)::foo = 1;
      |                            ^~~
Compiler returned: 1

でもthread_funcの型はmain()::<lambda()>ではないです。これはただのプレースホルダーで、実はもっと複雑な型になっていて、コンパイラーにしか分からないです。
ラムダ式関数の型は引数と戻り値によって決まる関数オブジェクトなのですが、関数ポインタなどと違って、具体的な型を指定して使うような型ではないです。
C++11 で導入された時からautoとセットで使うようになっています。

でもイメージとしては、以下のようなオブジェクトだと思ってください。

class thread_func {
public:
    void operator()() const { // 何かをする }
};

ラムダ式関数が例えばint型の変数xをキャプチャすると、その変数が以下のようにメンバー変数として追加されていく、というようなイメージですね。

class thread_func
{
public:
    thread_func(int& x)
    : m_x{x}
    {}

    void operator()() const
    {
        // 何かをする
    }
    
private: 
    int& m_x;
  };

では、ラムダ式関数の型を直接指定できないなら、C++20 未満でラムダ式関数を上記のように関数に引数として渡す時にどうすればいいのでしょうか?

以下のようにstd::functionを使えば、ラムダ式関数を指定できる型の変数に保存してから他の関数に渡せます。

void startThread(std::function<void()>& func) {
    std::thread t(func);
    t.join();
}

int main() {
    std::function thread_func([&] {
        // 何かをする
    });

    startThread(thread_func);

    return 0;
}

でもラムダ式関数をstd::functionに保存する際に、注意しないといけないことがいくつかあります。

ラムダ式関数はスタックで作成されるオブジェクトですが、std::functionは保存する関数によってヒープでメモリをアロケートします。

ラムダ式関数の場合は、引数がない場合は本当はサイズが0で、普通の関数と同じですが、C++ 的にゼロサイズのオブジェクトは存在しないため、sizeof(thread_func)1を返します。
引数をキャプチャするラムダ式関数の場合は、その引数のサイズ分メモリをスタックでアロケートします。例えば、int型の引数を1個キャプチャするラムダ式関数のサイズはsizeof(int)になります。

それに対して、std::functionはどの型の関数でも保存できるようになっていて、事前にその型が分からないため、ヒープでメモリをアロケートします。
内部では関数へのポインターとメンバー関数の場合はそのオブジェクトへのポインターをアロケートしていて、コンパイラーによって、サイズが違いますが、大体32バイトぐらいだそうです。

この違いによって、生存期間の観点で注意しないといけないポイントも違ってきます。ラムダ式関数の場合は、キャプチャしたオブジェクトの生存期間が大事で、関数オブジェクト自体の生存期間は普通の関数と同じく気にしなくていいです。
std::functionの場合は、元々の関数が大事で、メンバー関数の場合はついでにそのクラスか構造体のインスタンスの生存期間が大事です。

この違いが実際に致命的になる例を紹介します。

以下のコードを見てみてください。

#include <functional>
#include <iostream>
#include <thread>

class Test {
public:
    Test() {
        std::thread t = startThread();
        t.join();
    }

    void doSomething() {
        std::cout << "still alive" << std::endl;
    }

    std::thread startThread2(const auto& func) {
        return std::thread([&] { func(); });
    }

    std::thread startThread() {
        return startThread2([&] { doSomething(); });
    }
};

int main() {
    Test test;
    return 0;
}

Testはスレッドを作って、そのスレッドにメンバ関数を実行させて、終わるのを待つだけの簡単なクラスです。
startThread2はスレッドを作って、指定されたラムダ式関数を参照で渡し、作ったスレッドを返します。
startThreadstartThread2をコールし、メンバー関数をコールするだけのラムダ式関数を渡します。
Testのコンストラクタの中でstartThreadをコールして、join()で返ってきたスレッドが終わるのを待ちます。
Testの作りが意味不明になっていますが、ただの例なので、そこはスルーでお願いします。

このプログラムを実行すると、"still alive" が無事出力され、プログラムが正常終了します。

最初のラムダ式関数のキャプチャでdoSomething()をコールできるようにするために、[&]thisをキャプチャし、その後は参照型で渡していきます。
C++ では参照の参照という概念はないため、参照型のオブジェクトを参照しようとすると、元の参照先のオブジェクトを参照します。
ってことは、this、つまりTestのインスタンスが生きている限り、参照で渡したラムダ式関数はスレッドで無事実行されます。

このプログラムを以下のようにラムダ式関数をautoではなく、std::functionで渡すように変えてみましょう。

#include <functional>
#include <iostream>
#include <thread>

class Test {
public:
    Test() {
        std::thread t = startThread();
        t.join();
    }

    void doSomething() {
        std::cout << "still alive" << std::endl;
    }

    std::thread startThread2(const std::function<void()>& func) {
        return std::thread([&] { func(); });
    }

    std::thread startThread() {
        return startThread2([&] { doSomething(); });
    }
};

int main() {
    Test test;
    return 0;
}

このプログラムを実行すると、どうなるのでしょうか?

$ g++ tmp.cpp && ./a.exe
Segmentation fault

クラッシュします。

std::functionを使うと、最初に渡したラムダ式関数がstd::functionの中で保存されます。
startThread2の処理が終わると、std::functionのインスタンスが破棄され、スレッドが実行される時点でアクセスできなくなっているため、クラッシュします。

std::functionを使う場合は、std::functionのインスタンスを参照ではなく、以下のようにコピーでキャプチャすると、クラッシュしないです。

std::thread startThread2(const std::function<void()>& func) {
    return std::thread([func] { func(); });
}

でもこれはstd::functionだけではなく、どの変数のキャプチャの場合も注意しないといけないことで、むしろラムダ式関数の方が特殊だと思った方がいいです。

ラムダ式関数をstd::functionに保存するコードをたくさん見かけるため「ラムダ式関数の型がstd::functionだ」と勘違いしがちだと思います。
ラムダ式関数はできればそのまま使った方がいいですが、C++20 未満は、ラムダ式関数をそのまま渡せないため、std::functionに保存してから渡すことができます。
でもその場合は、ラムダ式関数とstd::functionは型も扱い方も全然違うことを意識しないといけないです。

ちなみに「なぜautoだとクラッシュしないのか」ですが、これは正確に理解するにはコンパイラの式展開など把握しきらないといけないと思いますが、たとえば「インラインの様に埋め込まれてラムダ自体の生存期間は気にしなくてよくなっている」という様な理解をしています。
厳密なところはちょっと違うかもしれませんが(すみません)

ちなみにautoの場合は今回の使い方ではクラッシュはしないわけですが、コードとしては参照キャプチャよりもコピーキャプチャの方が読み手には優しいとは思います。

    std::thread startThread2(const auto& func) {
        return std::thread([=] { func(); }); // コピーキャプチャ
    }

    std::thread startThread() {
        return startThread2([&] { doSomething(); });
    }

常にコピーが良いか?はいろいろ場合もあるので絶対ではないですけどね。


|cpp記事一覧へのリンク|

Discussion