はじめに
マルチスレッドアプリケーションは、コーディングもテストもデバッグも難しいことで知られています。しかし、マルチコアのデスクトップシステムとラップトップシステムに秘められた高いパフォーマンスを最大限に利用するために、開発者たちはアプリケーションをスレッド化するという難題に取り組んでいます。マルチスレッドアプリケーション開発の困難な問題に効く万能薬はありませんが、既存のライブラリとツールを活用すれば、この過渡期の負担を大幅に軽減することができます。
本稿では、C++アプリケーションをスレッド化するときに遭遇する正確さとパフォーマンスの問題の主な原因の1つ、すなわちスレッドセーフでないコンテナクラスの使用について考察することにします。まず、この問題がなぜ起きるのかを例で示し、それからIntel Threading Building Blocks(Intel TBB)ライブラリの並列コンテナクラスについて説明します。このライブラリはマルチスレッドアプリケーションの開発を支援すべく設計されたC++テンプレートライブラリです。TBBの並列コンテナクラスを利用すると、アプリケーションにスケーラブルな並列処理を安全に追加することができます。
あなたのコンテナはスレッドセーフか?
多くの開発者は、C++の標準テンプレートライブラリ(STL)の実装に含まれているコンテナクラスか、自作のコンテナクラスに頼っています。しかし困ったことに、こうしたライブラリはスレッドセーフでないことが多いのです。STLの仕様では、スレッドやマルチスレッドコードで使用する際のコンテナクラスの必須動作やスレッドについて何も言及していません。そのため、これらのSTLコンテナクラスの実装がスレッドセーフでないという事態が一般化しているのです。
例えば、STLのmap<string, MyClass>
を使用する場合を考えてみましょう。
たとえ異なる2つのキーに関連付けられた異なる2つの値を上記のコードで修正するとしても、大部分のSTL実装は正しい動作を保証しません。これらの操作を同期なしに並列的に実行すると、マップが破損する可能性があります。スレッドセーフに対する要件の指定がないと、異なる2つのマップにアクセスすることでデータ破損を招く可能性さえあります。
もちろん、上記のコードをスレッドセーフにするようなやり方で、STLテンプレートクラスのmapを実装することは可能です。残念ながら、よく使われるmap操作シーケンスの中には、スレッドセーフなやり方で実装できないものがあります。それぞれの操作を単独で実行すればスレッドセーフになるかもしれませんが、シリアルコードの中でよく使われるシーケンスは予期せぬ結果を引き起こすことがあります。例えば、2つのスレッドが次のコードでマップ内の同じ要素を操作したらどうなるでしょう。
Thread 0によって実行されるコードは2つの操作を実行します。まずoperator []
を呼び出して、"Key1"
に関連付けられたオブジェクトへの参照を取得します。このキーがマップ内になければ、operator []
はMyClass
型のオブジェクトを格納するためのスペースを割り当て、このキーと関連付けます。次にoperator =
が呼び出され、取得した参照が指し示すオブジェクトにMyClass
の一時的なインスタンスがコピーされます。
望ましい結果は、"Key1"
がマップ内に現れないか、MyClass()
のインスタンスと対になることです。しかし、ユーザーによって挿入される同期がないと、たとえ各操作が単独ではスレッドセーフであっても、これ以外の結果も起こり得ます。Thread 1によって呼び出されるメソッドerase
が、Thread 0によるoperator []
呼び出しとoperator =
呼び出しの間に生じる能性があります。その場合、Thread 0は削除されたオブジェクトに対してoperator =
を呼び出そうとし、これは誤った動作になります。このようなマルチスレッド化のバグは「競合状態」として知られています。この動作はどちらのスレッドが先に操作を実行するかにかかっています。
この例のような競合の難しい点の1つは、動作が予測不能(非決定論的)だということです。このコードをテストで実行しても、Thread 1からのerase
呼び出しが、いつもThread 0のフェッチと更新の間に入ることもあります。そのため、このようなバグはテストをすり抜け、検証済みのリリースコードの中に潜伏し、ユーザーのシステムでいつでも表に現れる可能性があります。
スレッドフレンドリでないコンテナクラスを使用する際に、こうしたバグを避け、正確さを確保するため、開発者たちは各コンテナの使用時に必ずロックをかけ、一度に1つのスレッドしかアクセスできないようにしています。このような粒度の粗い同期アプローチでは、アプリケーションで使用できる並列処理が制限され、またアクセスポイントごとにコードを追加することになるため、複雑さが増します。しかし、こうした既存のライブラリを利用する以上、これは支払わなければならない代価です。
代替策:Intel TBBのコンテナクラス
スレッドセーフでないコンテナを粒度の粗い同期でラップする以外にも、別の方法があります。Intel TBBライブラリはスレッドを使用するC++用のランタイムベース並列プログラミングモデルです。ライブラリが提供するスケーラブルな並列アルゴリズムに加え、Intel TBBはマップ、キュー、ベクタのコンテナクラスの安全でスケーラブルな実装を提供します。これらのテンプレートは、ユーザースレッド化コードで直接使用することも、ライブラリに含まれている並列アルゴリズムと共に使用することもできます。
上述したように、一部のコンテナクラスの標準インターフェイスは本質的にスレッドフレンドリではありません。そのため、Intel TBBの並列コンテナは対応するSTLコンテナの単純な代替品にはなりません。そこで、Intel TBBはSTLの精神に従いながらも、スレッドセーフを保証する必要があるところでは修正されたインターフェイスを提供するのです。
Intel TBBライブラリのすべての並列コンテナは「粒度の細かいロック」を使って実装されています。コンテナに対してメソッドが呼び出されたときは、データ構造の中で、そのメソッドが扱う部分だけがロックされるので、他の部分には複数のスレッドが同時にアクセスできます。
以下では、concurrent_hash_map
、concurrent_queue
、concurrent_vector
の各テンプレートクラスについて説明し、安全でないコンテナの使用を検知して置き換える方法を示します。