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_func
にfoo
というメンバーが存在しないというコンパイルエラーになりますが、そのエラーで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
はスレッドを作って、指定されたラムダ式関数を参照で渡し、作ったスレッドを返します。
startThread
はstartThread2
をコールし、メンバー関数をコールするだけのラムダ式関数を渡します。
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(); });
}
常にコピーが良いか?はいろいろ場合もあるので絶対ではないですけどね。
Discussion