C++0x Memory Model 第0回 - メモリモデルとは何か

私は,スカートを履いた女の子に,スカートを履いたままぱんつを脱いで,脱ぎ終わったら右手を挙げるようにと,そう命令した.私は,その子が右手を挙げたのを確かに見た.だが,その子のスカートをめくってみたらぱんつはまだそこにあったのだ!

これは C++ advent calendar の参加記事です。

本ブログエントリは,以降のブログエントリと合わせて, C++0x のメモリモデルに関する規則を具体的な例や意味付けを交えた形で説明していくことを目的としています.

1998年に制定され,2003年に改訂された現行の C++ プログラミング言語標準規格 (以下, C++03) においては,プログラム中にただ1つの実行スレッドしか存在しない場合の規定しか記述されていませんでした*1.しかし, C++ プログラミング言語の次期標準規格 (以下, C++0x) においては,プログラム中に複数の 実行スレッド thread of execution が存在することが許されるようになりました.複数の実行スレッドが存在することが許されるようになることで,プログラム中にただ1つの実行スレッドしか存在しない場合には問題とならなかった細部が根本的な問題を生じるようになります.特に,メモリに対する読み書きに関する様々な問題は,メモリに対する読み書きがプログラムの実行における主要な操作であるだけに,ただちに大問題へと進展します.プログラム中にただ1つの実行スレッドしか存在しない場合しか考慮していなかった C++03 におけるメモリに対する読み書きの規則は,そのままでは複数の実行スレッドが存在する場合にただちに深刻な問題を露呈することになります.そこで, C++0x ではメモリに対する読み書きに関する規則が徹底的に見直され修正されることとなりました.本ブログエントリでは,この C++0x におけるメモリに対する読み書きの規則,すなわち メモリモデル memory model について説明していきます*2

まず,メモリモデルという言葉について説明しておきます. C++0x におけるメモリモデルとは, C++0x プログラミング言語の実装における,メモリに対する読み書きに関する抽象モデル,およびその構成要素としての規則の集合,と定義することにします.ここで C++0x プログラミング言語の実装 implementations of C++0x programming language とは, C++0x の範疇で書かれたプログラムコードを,特定のあるコンパイラ実装がどのような機械語の列に翻訳するか,およびその翻訳結果である機械語の列が特定のあるアーキテクチャ上でどのように実行されるか,を指す言葉とします.したがって, C++0x におけるメモリモデルは,各々のコンパイラ実装が C++0x の範疇で書かれたプログラムコードをどのような機械語の列に翻訳するか,およびその翻訳結果である機械語の列が各アーキテクチャ上でどのように実行されるか,この2つの局面においてある種の制限を各々課すことになります*3

ただ1つの実行スレッドしか考慮しない場合には,メモリモデルに関する細かい規定はほとんど意識する必要がありません.プログラム中に単一の実行スレッドしか存在しなかった C++03 でメモリモデルというものを意識しておられた方は恐らくそれほど多くないでしょう.例として次のプログラムについて考えてみましょう.

int a = 0;
bool b = false;

int main()
{
  a = 1;
  b = true;
  if (b) {
    // b == true ならば a == 1 が成り立つ
    int x = a;
    assert(x == 1);
  }
}

上のプログラムを実行した場合,プログラム中の assert 式は必ず成功します.この assert 式が失敗する可能性を危惧された経験のある方はあまり居ないかと思われます.しかし,今,あえてこれを危惧してみましょう.たとえば, a = 1if 文より後に実行されることは無いのでしょうか? もし仮に, a = 1if 文より後に実行されると仮定すると,当然,上のプログラム中の assert 式は失敗します.

これはただの杞憂でしょうか? 実際,プログラム中にただ1つの実行スレッドしか存在しない場合にはこれは杞憂でしかありません.しかし,なぜこれが杞憂でしかないのかを,直観からではなく,客観的でより正確な形で把握しておくことは重要です.特に,プログラム中に複数の実行スレッドが存在する場合には,先と非常に似たような状況であったとしても,杞憂が杞憂でなくなります.次のプログラムについて考えてみます.まず,先のプログラム中の大域変数と同等の大域変数 a, b が静的に初期化されるとします.

// 静的に初期化される大域変数定義
std::atomic<int> a(0);                       // int a = 0;
std::atomic<bool> b(false);                  // bool b = false;

ただの int, bool 変数だと複数の実行スレッドから同時に読み書きすると未定義動作を引き起こしますので, アトミック atomic な int, bool 変数に変更しておきます*4.この状況の下で,ある実行スレッド T1 が次のコード片を実行するとしましょう.

// 実行スレッド T1 が実行するコード片
                                             //           私は,スカートを履いた女の子に,
a.store(1, std::memory_order_relaxed);       // a = 1;    スカートを履いたままぱんつを脱いで,
b.store(true, std::memory_order_relaxed);    // b = true; 脱ぎ終わったら右手を挙げるように
                                             //           と,そう命令した.

store は値の書き込みを行うメンバ関数です. store メンバ関数の第2引数にある std::memory_order_relaxed についての詳しい説明はずっと後回しになりますが,おおよそ,「アトミックな操作である,以外のいかなる追加の効果も持たせない」という指定だと理解しておいてください.また, T1 と異なる別のある実行スレッド T2 が次のコード片を実行するとしましょう.

// 実行スレッド T2 が実行するコード片
if (b.load(std::memory_order_relaxed)) {     // if (b) {
                                             //                   私は,その子が右手を挙げたのを確かに見た.
  int x = a.load(std::memory_order_relaxed); //   int x = a;      だが,その子のスカートをめくってみたら
  assert(x == 1);                            //   assert(x == 1); ぱんつはまだそこにあったのだ! (この assert は失敗しうる)
}

load は値の読み込みを行うメンバ関数で,その引数の std::memory_order_relaxed については先に説明した通りです.実行スレッド T2 が実行する assert 式が失敗することがある,というのが C++0x のメモリモデルからの帰結となります. T1 が実行するプログラムコードから「b == true ならば a == 1」が明らかに成り立つように思われるにも関わらず,実際にはこれは必ずしも成り立ちません.

上の例から,メモリモデルを強く意識する理由が生じてくることがお分かりいただけるかと思います.プログラム中に単一の実行スレッドしか存在しなかった場合には,プログラマは暗黙にメモリの読み書きに関する様々な仮定を置いており,それは特に意識しなくても成り立つものでした.上の,プログラム中にただ1つの実行スレッドしか存在しない場合の例で言えば,例えば「b == true ならば a == 1」という仮定はプログラムコード中の記述の順序から明らかで,ほとんどのプログラマはこの仮定の成立を疑わないでしょう.一方で,プログラム中に複数の実行スレッドが存在する場合には,このような暗黙の仮定の多くが,そしてそのような仮定に基づいたプログラムのロジック全体が簡単に瓦解しえます.したがって,特にプログラム中に複数の実行スレッドが存在する場合に,メモリモデル,つまりメモリの読み書きについてどのような仮定が成り立ってどのような仮定が成り立たないのか,を強く意識することは大変重要になってきます.

一方,メモリモデルを意識するだけでは事態は進展しません.上の例から分かるとおり,プログラムのロジックが成立するために根本的に重要な仮定の多くは,プログラム中にただ1つの実行スレッドしか存在しないときには成り立っていたとしても,複数の実行スレッドが存在するときには成り立つとは限りません.プログラムのロジックが正しくあるためには,メモリの読み書きに関する様々な仮定が必要になります.それらの仮定が成立するようプログラムを制御するにはどうすれば良いのかも知る必要が出てきます.

C++0x におけるメモリモデルは,一義的にはメモリの読み書きに対する抽象的な規則の集合です.抽象的な規則の集合であることにより,特定のコンパイラアーキテクチャの動作の具体を離れて,汎用で客観的な理解と運用が可能になる利点があります.しかし一方で,無味乾燥で抽象的な規則の集合だけを羅列してもメモリモデルに対する理解を深めることには困難かとも思われます.そこで,本ブログエントリに続くブログエントリにおける説明では,メモリモデルを構成する抽象的な規則群の客観的な説明と並行して,それらがコンパイラの翻訳やアーキテクチャ上の機械語の実行においてどのような意味付けと役割を果たすのか,また,具体的な例で規則群がどのように適用されていくのか,そういった具体的な説明も交えていくことで読者の理解を促していきたいと思います.

それでは, C++0x のメモリモデルについて順次説明していくことにします.

次回の記事: C++0x Memory Model 第1回 - 1.9 Program execution

*1:C++03 ではそもそも実行スレッドという言葉自体が定義されていませんでした.

*2:ある程度普及したプログラミング言語において厳格なメモリモデルを制定することに関しては Java プログラミング言語の規格とコミュニティの経験が先行しています. Java のメモリモデルに関する情報ハブサイトからたどれる情報,特に Java において厳格なメモリモデルが創設される契機となった Double-Checked Locking Idiom に関する議論などは, C++0x のメモリモデルを理解する上で大変大きな助けになるかと思われます.書籍としては, Java に詳しくない私が知っている限りでは "Java Concurrency in Practice" が非常にお勧めできます.

*3:C++03 および C++0x においては, "memory model" という言葉は,オブジェクトの一意性と各オブジェクトのアドレスの一意性に関する規則,また異なるスレッドから同時に読み書きする際にそれらアドレスの一意性がどのように影響するか,を定めた規則を指す言葉です (1.8) .しかし,本ブログエントリおよび後続のブログエントリでは一貫して本文で定義する意味でメモリモデルという言葉を用いることにします.なお, C++03 および C++0x で定義される "memory model" は本文で定義する意味でのメモリモデルに包含されるものとなります.

*4:「同時に」「読み」「書き」「アトミック」などの言葉を説明なしに使いました. C++0x に定義された概念や用語を用いて,これらに対してより厳密に定義された意味を持たせることができます.しかし,今は説明の便宜上,その詳細はずっと後回しにさせてもらいます