イベントの待機
スレッド間でデータを共有する場合には、一方のスレッドが何らかの処理を行う間、もう一方が待機するという状況が多々あります。そのとき、CPU時間を無駄に使うことは避けたいものです。共有データにアクセスする順番が来るのを各スレッドが待機するだけなら、ミューテックスのロックでも事足ります。しかしそのやり方では、必ずしも理にかなっているとは言えません。
待機の方法で一番簡単なのは、スレッドを短時間スリープさせるやり方です。スレッドがスリープから復帰するごとに、目的の処理が済んだかどうかをチェックさせます。大事なのは、イベント発生を示すデータの保護に使用するミューテックスを、スレッドのスリープ中はロック解除しておくことです。
std::mutex m; bool data_ready; void process_data(); void foo() { std::unique_lock<std::mutex> lk(m); while(!data_ready) { lk.unlock(); std::this_thread::sleep_for(std::chrono::milliseconds(10)); lk.lock(); } process_data(); }
この方法はとても簡単ですが、2つの理由で理想的とは言えません。1つは、データ処理が完了した後、待機スレッドがスリープから復帰してチェックするまでに、平均で5ミリ秒(10ミリ秒の半分)の間隔が空くという点です。場合によっては、明らかな遅れが生じてしまう可能性もあります。
この問題自体は、待機時間を短くすれば改善できますが、次の2番目の問題を悪化させることになります。すなわち、データの処理が完了するまでの間も、待機スレッドは10ミリ秒ごとにスリープから復帰して、ミューテックスを取得し、フラグをチェックするという処理を繰り返さなくてはならないという問題です。CPU時間の浪費になりますし、ミューテックスの競合が増えることになります。待機スレッドからすれば早く終わってほしいはずの処理スレッドの動作を、逆に遅くすることになりかねないのです。
こうした処理方法は避けて、条件変数を使いましょう。スレッドを一定時間スリープさせるのではなく、別のスレッドから通知があるまでスリープさせるというやり方です。そうすれば、通知を受けてからからスレッドが復帰するまでの時間差を、OSの処理次第で最短に抑えられますし、待機スレッドによるCPUの浪費を全体でほぼゼロにできます。先ほどのfoo()
を条件変数で書き換えると次のようになります。
std::mutex m; std::condition_variable cond; bool data_ready; void process_data(); void foo() { std::unique_lock<std::mutex> lk(m); while(!data_ready) { cond.wait(lk); } process_data(); }
このコードでは、ロックオブジェクトlk
をwait()
へのパラメータとして渡しています。条件変数の実装では、wait()
に入る時点でミューテックスのロックを解放し、抜ける時点で再度ロックします。これにより、このスレッドの待機中に他のスレッドが保護対象のデータを変更できます。data_ready
フラグを設定するコードは次のようになります。
void set_data_ready() { std::lock_guard<std::mutex> lk(m); data_ready=true; cond.notify_one(); }
ただし、データの準備が本当に完了したかどうかのチェックはやはり必要です。条件変数で偽の復帰が発生してしまうことがあるからです。すなわち、他のスレッドからの通知はないのに、wait()
の呼び出しから制御が戻ってしまうことがあるのです。こうした誤動作が心配なら、その処理を標準ライブラリに任せてしまうこともできます。その場合、待機の対象をプレディケートで指定します。C++0xでは、新しいラムダ式の機能を使って簡単に記述できます。
void foo() { std::unique_lock<std::mutex> lk(m); cond.wait(lk,[]{return data_ready;}); process_data(); }
ここまでは、スレッド間でデータを共有する場合を見てきました。では、その逆が必要な場合、つまり、各スレッドにデータのコピーをそれぞれ別個に持たせたい場合にはどうすればよいのでしょうか。それには、thread_local
という新しいストレージ期間キーワードを使用します。
スレッドローカルのデータ
thread_local
というキーワードは、ローカルスコープの名前空間スコープの任意のオブジェクトの宣言で使用でき、変数がスレッドローカルであることを示します。このキーワードを指定した変数は、各スレッドがそれぞれ別個のコピーを持ち、そのスレッドの生存期間内は保持されます。簡単に言えば、スレッドごとの静的変数です。各スレッドが持つコピー変数は、そのスレッドが初めてローカルスコープの変数宣言部を通過するときに初期化され、そのスレッドが終了するまで値が保持されます。
std::string foo(std::string const& s2) { thread_local std::string s="hello"; s+=s2; return s; }
この関数では、変数s
の各スレッド用のコピーを"hello"という値で初期化します。そして、関数が呼び出されるごとに、引数で渡された文字列をそのスレッドの変数s
に付加していきます。この例から分かるように、コンストラクタとデストラクタを持つstd::string
のようなクラス型の変数でも問題はありません。この点は、C++0x以前のコンパイラの拡張機能よりも改良されています。
並行処理のサポートに関して言語のコア部分に追加された新機能はスレッドローカル記憶域だけではありません。マルチスレッド対応でアトミック処理をサポートする新しいメモリモデルもあります。
新しいメモリモデルとアトミック処理
データの保護にロックや条件変数を使用しているぶんには、メモリモデルを意識する必要はありません。ロックを正しく利用していれば、競合状態からデータが保護されることがメモリモデルによって保証されます。ロックが誤っていれば、動作は未定義です。
一方、非常に低レベルで処理を行う場合や、高パフォーマンスなライブラリ機能を提供する場合は、詳細を押さえておくことが重要になります。非常に細かな話になりますので、ここでは取り上げません。簡単に言えば、C++0xには内蔵の整数型やvoidポインタに対応するアトミック型とテンプレートstd::atomic<>
があり、基本的なユーザー定義型のアトミック版を作成できるということです。詳細については、関連ドキュメントを参照ください。
まとめ
以上、C++0xの新しいマルチスレッド機能について駆け足で見てきました。今回取り上げたのはごく基本的な部分に過ぎません。他にも、スレッドIDや非同期のfuture値など、さまざまな機能が追加されています。