以下の内容で投稿
はじめに
この記事は、インドリ氏による『スレッドセーフとインテルTBBのコンテナ』に記載されている誤りを訂正することを目的としています。インドリ氏の記事では、TBBコンテナの紹介に注意するあまり、マルチスレッドプログラミングに潜む危険、その危険を取り除く方法についての記述が正しくありません。本記事では、マルチスレッドプログラミングを安全に設計する方法を説明することを目的とします。
本記事で用いるコードは、C言語に類似していますが、C言語ではありません。振る舞いを理解していただきやすくするための仮想言語です。実行できる環境はありません。
「競合」という問題
ここに、ひとつのリンゴがあります。そして、二人の人が、そのリンゴの前にいます。ここで二人に向かって「リンゴを食べて良いですよ」とだけ言うと、どうなるでしょうか。お互いに譲り合うか、もしくは取り合いをするでしょう。ここで二人が仲良くリンゴにありつくためには、調停者がいて、どの様に分けるかを決めることが必要です。もちろん、二人のうちどちらかが調停者を兼ねてもかまいませんが。
並列プログラミングを行うということは、この様に、二人以上の人に、何らかの作業を依頼することに喩えられます。これら、作業を依頼された人が、それぞれ別個の作業を行うなら、何の問題はありません。しかし、複数の人が同じ資源を操作するようなことがあると、問題が発生します。
List1.マルチスレッドで問題がある仮想コード:
#define ARRAY_MAX 1000
static char array[ARRAY_MAX];
static int index = 0;
int push(char v) {
if (index < ARRAY_MAX) {
array[index] = v;
++index;
}
return index;
}
char pop(void) {
if (index > 0) {
return array[index];
--index;
}
return NULL;
}
int howmany(void) {
return index;
}
List1は、1000個の文字を格納できるスタックです。このコードは、シングルスレッドで実行している限り、安全に実行できます。しかし、マルチスレッドでは、安全ではありません。マルチスレッドでは、コードが同時に実行されます。次のように実行がなされたとき、どの様になるでしょうか。なお、これらの実行直前で、index
は999、つまりあと1つなら追加できる状態で、2つ追加しようとしている状況です。
実行前の |
スレッド1 |
スレッド2 |
実行後の |
index値 |
実行行 |
実行行 |
index値 |
999 |
int push(char v) { |
|
999 |
999 |
if (index < ARRAY_MAX) { |
|
999 |
999 |
array[index] = v; |
int push(char v) { |
999 |
999 |
++index; |
if (index < ARRAY_MAX) { |
1000 |
1000 |
return index; |
array[index] = v; |
1000 |
1000 |
|
++index; |
1001 |
1001 |
|
return index; |
1001 |
スレッド2のif
文でindex
値を参照した後、スレッド1のインクリメントが実行され、index
の値が1000となりました。するとスレッド2では、array
配列の1001番目の要素にアクセスすることになります。これを実行した結果どうなるかは、実行環境により異なりますが、アプリケーションのどこかで、期待しないエラーが出る可能性が高くなります。
さて、このコードの問題は、何でしょうか。それは、index
変数の1つのインスタンスが複数のスレッドで共有されることです。index
変数がどの様なアクセススコープを持っていても、関係はありません。複数のスレッドが同じインスタンスを共有すると、問題が発生します。
逐次処理で発生する並列化と同じ問題
(「並列処理とコンテナ:スレッドセーフとは何か」より)
並列プログラミングを行う際には、従来の逐次プログラミングでは考えなかったことを考えなくてはなりません。そのうちの1つが、並列処理で同時にコンテナを操作する時に起こる問題についてです。これはよく「マルチスレッドプログラミングではスレッドセーフなコンテナを使う必要がある」と表現されます。
インドリ氏のこの書き方は、誤解を招く表現を多く含んでいます。
- 「並列プログラミング」に対する誤解を与える。
「並列プログラミングを行う際には」という表記と、「マルチスレッドプログラミングでは」という表記が混在することにより、並列プログラミングをマルチスレッドプログラミングに限定するような表現がされています。しかし、「並列プログラミング」には「マルチスレッド」以外にも「マルチタスク」があります。もっとも、「マルチタスク」の場合は、「プログラミング」という小さな単位ではなく、システムという大きな単位になります。
- 従来の逐次プログラミングというほど、並列プログラミングは新しいわけではない。
「並列プログラミング」には、「マルチスレッド」だけでなく、「マルチタスク」が含まれます。マルチタスクはMS-DOSからWindowsに移行したとき、すなわちWindows2.0から発生しており、これは1987年に誕生しています。ただし、完全なマルチタスクOSには1993年のWindows NT 3.1まで待たなければなりません。しかし、UNIXについては、生まれたときから完全なマルチタスクOSでした。
- 並列プログラミングに限った問題ではない。
スレッドクリティカルの根本的な問題は、複数の処理が同じインスタンスを操作することです。これは、並列プログラミングに限った問題ではありません。データベースを扱う場合や、非同期実行を行う場合、一時ファイルやセマフォといった共有メモリなどを扱う場合など、逐次プログラミングであっても同様に発生します。
「競合問題」の解決
「1つのインスタンスが複数のスレッドで共有される」ことで問題が発生するなら、スレッドごとにインスタンスを分ければ、問題は解決します。最初からマルチスレッド化することがわかっていて、設計から行うならば、設計の段階でマルチスレッド化する箇所を洗い出し、スレッド間で共通して使用するインスタンスがないように設計を行います。
しかし、今回取り上げたような、オブジェクトを保存しておくためのアルゴリズム、「コンテナ」では、データを蓄えている変数や、どこまでデータを蓄えているかを示すインデックスなどのインスタンスを、複数のスレッドで共有しなければ意味をなしません。そこで、共有しながら解決できる方法を探します。
では、もう一度問題に戻ります。先ほど、「複数のスレッドで、インスタンスを共有することが問題」と申しました。「インスタンスを共有すること」が、なぜ問題になるのでしょうか。共有しなければならないなら、共有することで起こる問題を解決する方法を探そう、ということです。すなわち、「共有しても問題のないアルゴリズム」が、解決方法となるわけです。
表1を見ます。スレッド2がindex
の範囲を確認した後に、スレッド1でindex
の値を変更しています。こうすると、せっかく範囲を確認したことが無駄になってしまいます。つまりここは、index
の範囲を確認してからインクリメントするまでの間、他のスレッドがindex
の値を参照してはいけないのです。そこで、この範囲を「複数のスレッドで同時に実行すること」を禁止します。
禁止する方法は、処理系によって用意されています。ここでは「lock (オブジェクト) 処理
」とすることで、同じ「オブジェクト
」を参照するスレッドからは「処理
」が実行できない、という実装であると仮定し、List1を修正します。Windows では、ミューテックスを使ってロックを行う場合が多いです。OpenMPでは、クリティカルディレクティブによって指定します。お使いの処理系による禁止の方法を調べて使用して下さい。
List2.マルチスレッドでの競合問題を修正した仮想コード:
#define ARRAY_MAX 1000
static char array[ARRAY_MAX];
static int index = 0;
static int lockObject = 0;
int push(char v) {
lock (&lockObject) {
if (index < ARRAY_MAX) { // 参照して、
array[index] = v;
++index; // 変更する
}
}
return index;
}
char pop(void) {
lock (&lockObject) {
if (index > 0) { // 参照して、
return array[index];
--index; // 変更する
}
}
return NULL;
}
int howmany(void) {
lock (&lockObject) {
return index;
}
}
実行前の |
スレッド1 |
スレッド2 |
実行後の |
index値 |
実行行 |
実行行 |
index値 |
999 |
int push(char v) { |
|
999 |
999 |
lock (&lockObject) { |
int push(char v) { |
999 |
999 |
if (index < ARRAY_MAX) { |
lock (&lockObject) { |
999 |
999 |
array[index] = v; |
ロック待ち |
999 |
999 |
++index = v; |
ロック待ち |
1000 |
1000 |
} // lock 終了 |
if (index < ARRAY_MAX) { |
1000 |
1000 |
return index; |
} // lock 終了 |
1000 |
1000 |
|
return index; |
1000 |
「1行」なら安全か
(コメント インドリ (2010/03/03 09:12)より)
>スケジューラーの初期化について
並列処理では実行順序が保障されません。しかも、アセンブラレベルで命令が実行されております。その状態なのですから、初心者がTBBとネイティブスレッド・OpenMPなどの処理を併用させたときに、どのようなプログラムでも正しく実行できると100%の自信を持って言えるでしょうか?私はそんな危険を冒すよりも1行のプログラムを書く方を勧めます。
(コメント インドリ (2010/03/03 15:55)より)
ですから普通の人は・・・
cout << "foo" << .....
(なんらかの処理をする)
cout << "bar" << ...
とプログラミングするでしょう。
また必ず一行で書くという行為はcout
という標準出力オブジェクトの不変項と事後条件を満たしていると言えるでしょうか?
本文中に、「cout
はスレッドセーフではない」と書かれている事についてです。ここに引用したように、「一行で書けば安全だ」と理解できる論調なので、これについて間違っている事を解説します。
まず、「cout
はスレッドクリティカルか」という問題についてです。私が調べた範囲、Microsoft、SUNによる実装は、スレッドセーフであるとリファレンスに記載されています。GNUについては、POSIXがスレッドセーフであることを要求するので、準拠したライブラリを使用するならばスレッドセーフであると、記載されています。参考資料に挙げておきます。よほどユニークなライブラリでない限り、「スレッドセーフである」と言って大丈夫でしょう。
次に、「一行で書く」と言うことについてです。本記事では、「変数を参照する」「変数を更新する」という“2行”に渡る処理を取り上げました。これが“1行”だったら問題が発生しないかというと、そうではありません。インドリ氏もコメントでは触れられていますが、人が書く1行のコードは、複数のアセンブリコードに翻訳(コンパイル)されます。アセンブリコードが複数の命令になる場合、問題が発生する可能性があります。アセンブリコードが想像できなくても大丈夫です。for
文を思い浮かべてください。標準的なfor
文は、1行の中に3つの実行文があります。同じように、List3のように書けば、複数行でありながら1実行文となります。ソースコードの行数と実行される命令数、アセンブリレベルでの命令数には、何の関係もありませんし、関係を持たせるべきではありません。
List3.1命令文を複数行に書く:
int
c
=
1;
最後に、最も重要なことです。インドリ氏の一連の発言では「関数がスレッドセーフであること」と、「アプリケーションがスレッドセーフであること」を混同しています。この混同のために、コメント投稿者との会話に齟齬が発生しています。インドリ氏以外は、このふたつを混同していません。このふたつは同じではなく、分けて考えなければなりません。例えば、インドリ氏が「並列処理で使用して安全である」として取り上げている「concurrent_bounded_queue
」であっても、複数のスレッドから同時にpush
した場合、どの順番でコンテナに挿入されるかはわかりません。これは、cout
に対して「並列処理時にcout
に出力を指定するとコンソール画面の表示が滅茶苦茶になります」と書いてあるのと同じことですので、concurrent_bounded_queue
も「スレッドセーフではない」事になってしまいます。インドリ氏の記事では、他の記事を見ても、1命令を多数のスレッドで同時に呼び出すということをしていないため、この問題に気がつかなかったと思われます。
繰り返しますが、スレッドセーフな命令だけで作ったからといって、アプリケーションが「スレッドセーフ」、この場合は設計意図通りに実行結果が現れるわけではありません。アプリケーションがスレッドセーフになるためには、開発者がスレッドクリティカルな部分について適切に処理を組む必要があります。
まとめ
私が初めて「マルチスレッド」によるプログラミングを行ったのは、1999年です。それ以前は、「マルチタスク」によるプログラミングを行っていました。その、初めてのマルチスレッドプログラミングを行った後、部ではじめてマルチスレッドプログラミングを扱った事例であったため、レポートを提出しています。そのレポートから編集して引用し、まとめとします。
1.スレッドセーフとスレッドクリティカル
新たに製造する関数をスレッドセーフにする方法は2つある。1つはその関数が共有変数領域を参照する場合に実行中のスレッドが必ず1つになるようにロックしてしまう方法、もう1つは他のスレッドから独立した領域を参照するようにする方法である。しかし、前者の方法を使うと、これからロックを行おうとしている他のスレッドは、ロックが解放されるまで待たなければならない。これではせっかく複数の処理が同時に実行されるマルチスレッドを用いている意味が無くなってしまう。また、後者のように全ての変数をスレッドごとに独立させるとメモリ資源を浪費することになる。よって、まずスレッド間で共有しなければならないかどうかを検討する。共有しなくても良い変数であれば、スレッドごとに独立させる。ただし、大きな領域を必要とする場合は、共有することを考える。ここの見極めが、マルチスレッドプログラミングにおけるポイントの1つとなる。
2.共有変数の保護
ロックする命令とロックを解除する命令の間では同時に実行されるスレッドはひとつに限定される。マルチスレッドとしてのパフォーマンスは損なわれるが、共有変数の安全性が確保される。もちろん、ロックを使用しないスレッドは、ロックによる遅延の影響を受けない。共有変数を使用しないスレッドはロックを使う必要はない。
3.ロックとパフォーマンス
ロックをしてスレッドの実行をシーケンシャルにすると、その分全体的なパフォーマンスが低下する。では、実行スピードを優先するには、ロックする範囲を小分けにした方がよいのか、それともなるべく少ないロックで大きな範囲をカバーする方がよいのか。これについては、ひとつのロックで多くの範囲をカバーする方がよい。しかし、その範囲に時間のかかる処理が含まれると、マルチスレッドの利点が死んでしまう。したがって、少ないロックで多くの、ただし時間のかからない処理をするのがよいと言える。処理フローを見直し、時間がかかる処理はマルチスレッドの領域へ、時間がかからない処理をシーケンシャルな領域へ移動させる。必要なら、アルゴリズムの変更も検討する。
4.並行処理能力の向上
実行中、多数の処理が参照しており、限られた処理だけが更新を行う場合があるという共有変数が存在する。頻繁に書き換えられるわけではないが、参照途中に変更が行われる可能性がある以上、参照中に変更されること、変更中に参照されることは禁止しなければならない。しかし、複数のスレッドが同時に参照することは許されるべきである。オラクルなどのデータベースでは、普通に使われているロック処理である。このようなロックが用意されているなら、積極的に用いる。用意されていないなら、実装する。
参考資料
- Thread Safety in the Standard C++ Library … .NET Framework 4 の C++ における、標準ライブラリのスレッド安全性について
The iostream classes follow the same rules as the other classes, with one exception. It is safe to write to an object from multiple threads. For example, thread 1 can write to cout at the same time as thread 2. However, this can result in the output from the two threads being intermixed.
- Thread Safety in the Standard C++ Library … .NET Framework 1.1 の C++ における、標準ライブラリのスレッド安全性について
For writes to the same object, , the object is thread safe for writing:
- Sun Studio 12: C++ ユーザーズ ガイド(PDFファイル)
11.1 マルチスレッドプログラムの構築
C++ コンパイラに付属しているライブラリは、すべてマルチスレッドで使用しても安全です。
11.1.2 C++ サポートライブラリの使用
C++ サポートライブラリ(libCrun、libiostream、libCstd、libC) は、マルチスレッドで使用しても安全ですが、非同期安全(非同期例外で使用しても安全) ではありません。
- Using Concurrency
So, for 3.0, the question of "is multithreading safe for I/O" must be answered with, "is your platform's C library threadsafe for I/O?"
(As an example, the POSIX standard requires that C stdio FILE*
operations are atomic. POSIX-conforming C libraries (e.g, on Solaris and GNU/Linux) have an internal mutex to serialize operations on FILE*
s. However, you still need to not do stupid things like calling fclose(fs)
in one thread followed by an access of fs
in another.)
- 『実践マルチスレッドプログラミング』(ISBN:4-7561-1784-8)
編集履歴
3/31 |
- Sodaさんの意見に従い、index のインクリメント方法を修正。
- 「R/W-ロック」の説明を入れようかと思い、howmany 関数を追加。でも、どうやって使うよ?
- コラム「逐次処理で発生する並列化と同じ問題」を、「誤解を招く表現」と「どの様な誤解か」がわかりやすいように再構成。
- 「マルチスレッドプログラミングで発生する問題」→「「競合」という問題」
- 「マルチスレッドで発生する問題の解決」→「「競合」問題の解決」
- 「参考資料」追加
|
4/2 |
- 「まとめ」内の「共通変数」を「共有変数」に修正。
- 「1.スレッドセーフとスレッドクリティカル」と「2.共有変数の保護」に違いがないように思えたので、「1.スレッドセーフとスレッドクリティカル」の方を、「変数を共有しなければならないか見極める」に変更。
- コラム「1行なら安全?」で、「2つ目の誤りは」を「2点目に、一連の発言では」に変更。coutは、C++の規約ではスレッドセーフであることを求められていないので、誤りではない。
- コラム「1行なら安全?」で、「C言語、あるいはC#やJavaの様な言語であっても同じですが」を「インドリ氏もコメントでは触れられていますが」に変更。その他、言葉遣いを修正。皮肉は削除しておく。(いや、全体に皮肉に充ち満ちているが)
- コラム「「1行」なら安全」は長いので、節に昇格。
|
投稿日時 : 2010年3月30日 23:46