radium.png
HOME | ARCHIVE | PRODUCTS | ABOUT | RSS

Concurrency

2005-08-02

新たなパラダイムシフト

タダ乗り時代の終わり

20 世紀末, Wintel 連合はこの世の春を謳歌していた。人々は,より快適な OS と,より快適な CPU を求めて,両社の製品を購入し続けた。両社はその期待の圧力に押されるようにして,より優れた製品を矢継ぎ早に市場へと放っていった。特に Intel 社の Pentium プロセッサの性能向上の勢いには目覚しいものがあり,正にムーアの法則1を体現したその展開に人々は絶大な信頼を寄せていた。

2000 年の 3 月のこと, AMD が Athlon の 1GHz 製品を発表すると,すぐさまそれを追いかけるようにして Intel が Pentium III の 1GHz 製品を発表した2。プロセッサ技術者にとってみれば,これはそれまでの延長線上の出来事に過ぎなかったかもしれない。しかし消費者にしてみれば,ギガという単位への移行は非常に象徴的なものであるに違いなかった。この頃はまだクロックスピード本位の時代であり,ますます激化していく速度競争の中で, CPU の演算性能は限りなく向上し続けるものと感じられていた。

それから約 1 年半後の 2001 年 8 月のこと, Intel は Pentium 4 の 2GHz 製品を発表する3。まだムーアの法則は健在だ。あと 4, 5 年もすれば,きっと 10GHz を越える製品が発表されるのだろうという漠然とした予感が,その頃は存在していた。

しかしそれが,実際にはどうだろうか。 2005 年も半期を過ぎた今, Intel は辛うじて 3.8GHz 製品を販売しているに過ぎない4。メインストリームはあくまでも 3GHz 前後の製品だ。 Intel にしても AMD にしても,もはやクロックスピードの追求に対する熱意は失われてしまっている。

Pentium (x86) プロセッサが驚異的な成長を遂げている間,ソフトウェア技術者はタダ乗りの高速バスに乗っているような感じだった。 x86 プロセッサの最大の武器は強力な上位互換性だ。ただ放っておくだけで,ソフトウェアは年々速く動作するようになっていった。作り方を根本から変えることなく,次々に複雑な処理をこなせるようになっていった。あるいは,速く動くようになる分,余裕のある組み方も許されるようになってきた5

思えば,これまでの十数年間はソフトウェア技術者にとって最も幸せな時代だったのかもしれない。しかし,何事もそうであるように,幸せが長く続くことは無い。タダ乗りの時代はもう終わろうとしている。これからは作り方を変えなければ,ソフトウェアは速く動くようにならない。作り方を根本から変えなければ,複雑な処理をこなせるようにならない。ソフトウェア産業は, 1990 年代に OOP (オブジェクト指向プログラミング)を迎え入れたとき以上のパラダイムシフトを,これから迎えようとしているのかもしれない。 C++ の巨匠 Herb Sutter 氏は,技術者たちにそう警告を投げかけている6

マルチコアと並列性

クロックスピード本位の速度競争が限界を迎えた今,プロセッサ技術のトレンドは明らかにマルチコア化へと向かっている7 8。それでは,プロセッサがマルチコア化すれば ― つまり,単一のプロセッサに複数のコアが積載されるようになれば,それによってソフトウェアは高速に動作するようになるのだろうか?

残念ながら,答えは「否」だ。従来のソフトウェアの多くは,マルチコア化の恩恵を十分に受けることができない。何故だろうか? それは,従来のソフトウェアの多くは並列性 (concurrency) を備えていないためだ。

これは, Herb Sutter 氏のアナロジーを引用するならば,次のように喩えることができる ― 「1人の女性が1人の赤ちゃんを産むには,約9ヶ月の期間が要されるが,たとえ9人の女性を揃えたとしても,1ヶ月で1人の赤ちゃんを産むことは出来ない」。これは滑稽な喩えのように思われるかもしれないけども,現在多くのソフトウェアが置かれている状況を的確に言い表すことができている。逐次的な工程は,コアが多重化したところで自動的に高速化することは有り得ない。

マルチコア化の恩恵を受けるためには,幾つかの問題に取り組まなければならない。まず最初の問題は,目的ないしは工程を並列実行に対して適応させることだ。たしかに目的が「単一の生成物を得ること」である限りは,並列化は意味の無い手段であるかもしれない。しかし,本来の目的が「多くの生成物を得ること」であれば,並列化は実行時間を大幅に短縮する手段となりうる。あるいは,生成の工程を逐次的なものから分割・並列化可能なものへと改善することができれば,単一の生成物を得るまでの時間をも短縮することができるかもしれない。いずれにせよ,並列化という手段のために本質を変えてしまう必要は無い。ただ,その本質の中に並列化の余地を見出すことができれば,それは製品としての大きな差別化の要因となりうる。

そして次の問題は,実際のプログラムに対して並列性を与えることだ。有り体に言えば,マルチプロセスやマルチスレッド等の機構を利用して,複数の処理を並列に実行させることを意味する。そうすれば,コア毎に処理の分担が行われることによって,処理速度の向上が実現されることになる。

しかし,ここに来て非常に大きな障壁が現れる。プログラムに対して並列性を与えることは,予想されるよりも容易なことではない。実の所,それは非常に困難なことであり,正確に遂行するには相当な熟練が要される。しかも,高度なマルチコア・マルチプロセッサシステム上において,不具合無くかつ効率的に動作するほどの拡張性 (scalability) を得るには,かつてのシングルコア・シングルプロセッサシステム上では遭遇することの無かった様々な問題を解決していかなければならない。

並列プログラミングへのいざない

一貫性モデル

プログラムの並列性を扱うにあたって重要となるのが,一貫性モデル (consistency model) の存在だ。一貫性モデルとは,複数のプロセス(スレッド)間において処理の一貫性がどのように保たれるかを定義するものだ9。プログラムに並列拡張性を与えるには,ここで保証される一貫性を基としてプロセス間の調停を行うようにしなくてはならない。さもなくば,一貫性の欠如から生じる未定義の状態によって,プログラムは予期せぬ振る舞いを見せるようになってしまう。

一貫性モデルは厳密なものになるほど並列性の扱いが単純になるが,実際には多くのシステムが弱一貫性 (weak consistency) か,あるいはそれに順ずるものを採用している。弱一貫性のシステムにおいては,適切な同期の機構を用いない限り,あらゆる一貫性を期待することができない。わざわざこのように扱い難いモデルを用いているのは,一貫性に関してできるだけ制約の緩い状態を作り出しておかなければ,コンパイラ側やプロセッサ側において各種の高速化技術を応用することができなくなってしまうためだ10

一貫性と並列性の関係について考える上で,例として扱いやすいのが Java における同期 (synchronization) の仕組みだ。 Java は「メモリモデル」 (memory model) として一貫性の保証範囲を言語仕様に組み込んでいるため,並列性の話題を比較的扱いやすいという利点がある(C や C++ は特定のメモリモデルを持たないため,並列性は処理系に依存する問題となってしまう)。 Doug Lea 氏の "Concurrent Programming in Java" からの抜粋 "Synchronization and the Java Memory Model"11 はこの話題を中心に扱っており,非常に参考になる。

上の記事において Doug Lea 氏は,以下のような例を挙げている。

final class SetCheck {
  private int  a = 0;
  private long b = 0;

  void set() {
    a =  1;
    b = -1;
  }

  boolean check() {
    return ((b ==  0) ||
            (b == -1 && a == 1)); 
  }
}

逐次プログラムにおいては,このクラスのとりうる状態は (a, b) = (0, 0) ないしは (a, b) = (1, -1) のいずれかであると断定することができる。よって,メソッド check は常に真を返すと仮定することができるはずだ。

しかし,並列プログラムにおいては,この仮定は成り立たなくなる。 Java のメモリモデルは非常に弱い一貫性しか保証していないため,適切な同期の機構を用いない限りは,メモリへの書き込みの状態を推測することはできない。例えば set メソッドを発行した後でも,共有メモリへの反映が遅延されることによって,他スレッドからは (a, b) = (0, 0) の状態が見えてしまうかもしれない。あるいは a = 1 を代入した直後の中途半端なタイミングであれば,他スレッドからは (a, b) = (1, 0) の状態が見えてしまうかもしれない。あるいはコンパイラ側の最適化処理によって a と b の代入順序が入れ替わり,更に b = -1 の代入だけが完了したタイミングであれば,他スレッドからは (a, b) = (0, -1) の状態が見えてしまうかもしれない。あるいはコンパイラが実行順序を入れ換えなくとも,プロセッサ側が out-of-order 実行によって順序を入れ換えてしまう可能性もある12 13

原子性

並列性を考える上でもうひとつ重要となる要素が「原子性」 (atomicity) だ。原子性とは,ある操作が不可分である性質を表す。言い換えるならば,ある操作を行うのに,その途中の過程を覗くことができなければ,その操作は「原子的 (atomic) である」 と言うことができる。

例えば Java においては long と double を除くすべての型の変数へのアクセスは原子的であると定義されている11。また .NET CLR においては,アクセスの対象となる変数のサイズが当該アーキテクチャにおけるネイティブな整数型のサイズ以下であり,更にその変数が適切な境界整合 (alignment) の上に配置されている場合,その変数に対するアクセスは原子的であると定義されている14

例えば,次のようなクラス Counter が定義されているとする。

class Counter
{
  private int count = 0;
 
  public void add()
  {
    count += 1;
  }
}

このメソッド add における加算操作 ("+=1") は原子的ではない。実際には以下のような単位に分割されると考えられる。

1. count の値をメモリからレジスタ r1 に読み込む
2. r1 に 1 を加算する
3. r1 の値を count に格納する

このため,メソッド add の発行が複数のスレッドから同時に行われた場合,意図通りの動作が行われない可能性がある。例えば以下のような状態だ。

        thread 1         thread 2     m[count]
T0:  m[count] => r1                      0
T1:  r1 + 1 => r1     m[count] => r2     0
T2:  r1 => m[count]   r2 + 1 => r2       1
T3:                   r2 => m[count]     1

このように,複数のスレッドにおいてメソッド add が同時に発行される可能性がある場合,その結果をあらかじめ予測することはできなくなってしまう(実際には原子性以前に一貫性の問題があるが,ここでは敢えて原子性に焦点を絞っている)。例えば,2つのスレッドからメソッド add をそれぞれ 10 回づつ発行した場合,最終的な count の値は 10 から 20 までの値をとりうることになる。

この原子性の問題は, C や C++ のように原子性の定義がなされていない言語においては,更に複雑な問題となりうる。例えば Intel の 32bit アーキテクチャ (IA-32) には「メモリ上の値に対する演算命令」が存在するため,変数に対する定数の加減乗算は原子的であると考えることができる。しかし,基本的にメモリへのアクセス手段がロード・ストアの2種類しか存在しないような RISC 型のアーキテクチャにおいては,変数に対する演算はすべて非原子的であると考えることができる。このような差異のために,一方のアーキテクチャ上では正常に動作しているように見えるプログラムも,他方のアーキテクチャ上では不具合を生じてしまう可能性がある。もちろん,これは IA-32 から IA-64 への移行においても例外では無い15

Double-Checked Locking

並列プログラミングの難しさを物語る一例として "Double-Checked Locking" というパターン("DCLP" と略される)を挙げることができる。これは Singleton パターンの実装において,インスタンスを生成する際に二重のチェックを行うことによって安全性を確保しようというものだ。しかし実際には,このパターンには破綻があることが広く知られている16 17 18 19

例として,以下のようなメソッド getInstance があるとする。

MySingleton& MySingleton::getInstance()
{
  // >> ここからロック
  if ( instance == 0 )
  {
    instance = new MySingleton;
  }
  return *instance;
  // << ここまで
}

このクラスを普通に実装した場合,並列動作時の安全性は保証されない。複数のスレッドから同時に呼ばれた場合にインスタンスが複数生成されてしまう危険性がある。これを解決する最も簡単な方法は,コメントに記述したような箇所に何らかの同期を設けることだ。しかし,これを正直に実装してしまうと,メソッド getInstance を呼ぶ度に同期が発生してしまい,パフォーマンスに悪影響を与える可能性がある。一般的に同期の実行には相応の負荷が要されるうえに,メソッド getInstance は頻繁に呼ばれることが予想されるためだ。

そこで,以下のように,まず同期せずに instance の生成確認を行い,その後に同期を行ってから再び instance の生成確認を行うという「二重のチェック」を設けることにする。この改良によって,インスタンスが生成されていない初期の状態のみ同期が発生することになり,負荷の問題は解消されると考えられる。これが Double-Checked Locking パターンだ。

MySingleton& MySingleton::getInstance()
{
  if ( instance == 0 )
  {
    // >> ここからロック
    if ( instance == 0 )
    {
      instance = new MySingleton;
    }
    // << ここまで
  }
  return *instance;
}

この方法は一見上手くいくように感じられるものの,実際にはごく稀な条件下において破綻する可能性がある。原因は "instance = new MySingleton" の操作が原子的でないためだ。この操作は以下のような操作に分解されると推測される。

1: sizeof(MySingleton) の大きさのメモリを確保
2: 確保したメモリのポインタを instance に代入
3: MySingleton のコンストラクタを呼ぶ

この一連の操作は原子的でないため,途中で他スレッドの処理が挿入される可能性がある。例えば,操作 2 を実行した直後に他スレッドから getInstance が呼ばれたらどうなるだろうか? 既に instance には有効なポインタが代入されているため,ここで呼ばれた getInstance はその参照先のインスタンスを即座に返す可能性がある。ところが参照先のインスタンスはメモリを確保しただけであり,またコンストラクタは呼ばれていない。結果として,未初期化状態のインスタンスへの不正なアクセスが生じてしまうことになる。

それでは,一体どのように実装するのが正解なのだろうか? 文献には様々な改良例が挙げられているものの,その殆どはどこかに破綻する危険性を抱えているか,処理系に大きく依存する形となっている。結局のところ,パフォーマンスが大きな問題とならない限り DCLP は用いず,最も単純な方法(メソッド全体での同期,等々)を採ることが勧められる。あるいは,スレッド間で Singleton クラスのインスタンスを共有しなくとも済むような設計にすることが求められるかもしれない。ともかく,複雑な問題を伴う DCLP を用いるのは,他の単純な解決法を用いることが難しい場合のみに限定すべきであると考えられる。

総括

これまでに記した以外にも,並列プログラミングを行う際に注意すべき点は多く存在する。自分も経験の浅い分野であるだけに,未だ分からない点も多い。ともかく,最も基本的な考えとなるのは,複数のスレッドから共有される資源に対してアクセスが生じる場合のみ,並列性の問題が浮上してくるということだ。そのため,共有される資源を注意深く特定し,あるいは共有される資源を意図的に限定し,集中的な対処を行っていくことが重要になるのではないかと思う。

Java のように言語側の仕様として並列性の問題が扱われている場合はまだしも, C や C++ においては,並列性の問題は処理系や OS, ハードウェアに大きく依存するものとなり,扱いが難しくなる。 OpenMP のように言語側で並列性を扱う方法も考案されてはいるものの20,このような方式が主流になる見込みは無い。結局のところ, Windows のように API 側に用意された同期の機構を用いる方式が,当分の間は主流になるのだろうと思う。

並列性の話題は,今後様々な場において扱われる機会が多くなるのではないかと思う。そこで得られるノウハウを積極的に取り入れていくことが肝要かと思う。例えば Douglas C. Schmidt 氏のページには並列・分散プログラミングに関連するデザインパターンが幾つか紹介されている21(ただし,ここには DCLP のように破綻が判明しているパターンも紹介されているので注意が必要だ)。結城浩氏の書籍も参考になるかもしれない22(残念ながら在庫が見つからず入手できていない)。Windows 上での並列プログラミングの要点については Larry Osterman 氏のブログ上の連載が参考になった23。今後も機会を見て,このような参考資料を集めてみたいと思う。

参考資料

  1. Insider's Computer Dictionary. ムーアの法則.
  2. Impress PC Watch. Intel も 1GHz 到達 - Pentium III 1GHz 発表. 8 March 2000.
  3. Impress PC Watch. Intel, Pentium 4 2GHz を正式発表. 27 August 2001.
  4. Impress PC Watch. Intel, デュアルコア CPU "Pentium D" シリーズ - 対応チップセット Intel 945 と Pentium 4 670 も同時発表. 27 May 2005.
  5. 増井俊之. 富豪的プログラミング. bit, Vol.29, No.1, pp.36-37, 1997.
  6. Herb Sutter. The Free Lunch Is Over: A Fundamental Turn Toward Concurrency in Software. Dr. Dobb's Journal, 30(3), March 2005.
  7. 大原雄介. PC テクノロジートレンド 2004 FALL: 見え始めたマルチコアプロセッサ. MYCOM PC WEB, 6 September 2004.
  8. 後藤弘茂. 後藤弘茂の Weekly 海外ニュース: なぜ Intel はマルチコア化を急ぐのか. Impress PC Watch, 12 August 2004.
  9. Consistency Models. CNE Tutorial Modules Central.
  10. Sarita V. Adve, Kourosh Gharachorloo. Shared Memory Consistency Models: A Tutorial. WRL Research Report 95/7.
  11. Doug Lea. Synchronization and the Java Memory Model. Concurrent Programming in Java™: Design Principles and Patterns. Addison-Wesley, November 1999.
  12. Out-of-order execution. Wikipedia.
  13. Daniel A. Jiménez. Out-of-order Execution.
  14. Jon Skeet. Volatility, Atomicity and Interlocking.
  15. Larry Osterman. Larry gets taken to task on concurrency.
  16. David Bacon et al. The "Double-Checked Locking is Broken" Declaration.
  17. Scott Meyers, Andrei Alexandrescu. C++ and the Perils of Double-Checked Locking. Dr Dobb's Journal, July and August 2004.
  18. Scott Meyers. Double-Checked Locking, Threads, Compiler Optimizations, and More. Northwest C++ Users Group, April 2004.
  19. Peter Haggar. double-checked locking と Singleton パターン. IBM developerWorks, May 2002.
  20. OpenMP. OpenMP Architecture Review Board.
  21. Douglas C. Schmidt. Patterns for Concurrent, Parallel, and Distributed Systems.
  22. 結城浩. Java 言語で学ぶデザインパターン入門 マルチスレッド編. ソフトバンクパブリッシング, 2002.
  23. Larry Osterman. Concurrency, Part 1-15.