uchan note

プログラミングや電子工作の話題を書きます

std::atomic の挙動がメモリオーダーによりどう異なるか

C++ の std::atomic がメモリオーダーの指定によってどう挙動が変わるかを調べました。 x86-64 アーキテクチャにおいて,アトミック変数の読み書きがどのような機械語になるかが主なテーマです。

(2020/06/15 筆者の知識がアップデートされましたので記事を大きく修正しました)

サンプルコード

実験に用いたコードを次に示します。 関数 f,g はそれぞれ異なるスレッド上で実行されることを仮定します。

// acq_rel.cpp
#include <atomic>

std::atomic_int A{42}, B{1};

int f() {
  int r1 = A.load(ORDER_ACQUIRE);
  if (B.load(ORDER_ACQUIRE)) {
    return r1;
  }
  return 1234;
}

void g() {
  B.store(0, ORDER_RELEASE);
  A.store(0, ORDER_RELEASE);
}

このコードが実際に何をしているかの解釈は重要ではないかもしれませんが念のため説明します。

変数 A はデータ,変数 B はそのデータが有効であることを示すフラグのつもりです。 B が真値のとき,A に格納されたデータが有効である,すなわち A のデータを使用しても安全だ,ということを表します。

関数 g は A の値を「削除」します。 フラグを 0 にして論理的に削除した後,A に無効な値を書き込みます。

関数 f ではフラグが真の場合に A の値を使用します。 フラグを読む前に A をあらかじめ読んでおくことが大切です。 この順序を守ることにより,r1 の値と(if 文の条件式で読み取ったときの)B の値が r1 == 0 かつ B == 1 にならないことを保証できます。 あり得る値の組み合わせは次の通りです。

B r1
1 42
0 42
0 0

最後の方で示しますが,A をアトミック変数にしなかった場合には,関数 f において A の読み取りと B の読み取り順が最適化によって逆転することがあります。 そうすると,スレッドの切り替えタイミングによっては r1 == 0 かつ B == 1 になることがあり,無効な値を使ってしまうバグが発生します。 もし A がポインタであれば,ヌルポインタ参照等の深刻なエラーとなるでしょう。

ビルドスクリプト

2 つのマクロ B_ORDER_ACQUIREB_ORDER_RELEASE に指定するメモリオーダーを変えてみて挙動の変化を観察します。 ビルドに用いた Makefile は次の通りです。

ORDER_ACQ_REL = -DORDER_ACQUIRE=std::memory_order::acquire -DORDER_RELEASE=std::memory_order::release
ORDER_SEQ_CST = -DORDER_ACQUIRE=std::memory_order::seq_cst -DORDER_RELEASE=std::memory_order::seq_cst

.PHONY: all
all: acq_rel.s seq_cst.s reorder.s

acq_rel.s: acq_rel.cpp
        clang++-10 -O3 -std=c++20 -S -masm=intel -o $@ $(ORDER_ACQ_REL) $^

seq_cst.s: acq_rel.cpp
        clang++-10 -O3 -std=c++20 -S -masm=intel -o $@ $(ORDER_SEQ_CST) $^

reorder.s: reorder.cpp
        clang++-10 -O3 -std=c++20 -S -masm=intel -o $@ $(ORDER_ACQ_REL) $^

機械語にするのではなく,アセンブリコードを出力させます。 そうすればコンパイル結果を調べる際に逆アセンブルする必要がありませんからね。

ビルドは make とするだけです。

$ make

3 つのアセンブリコード acq_rel.s,seq_cst.s,reorder.s が生成されます。

acquire/release

acq_rel.s から必要な部分を抜粋します。

_Z1fv:                                  # @_Z1fv
        mov     ecx, dword ptr [rip + A]
        mov     eax, dword ptr [rip + B]
        test    eax, eax
        mov     eax, 1234
        cmovne  eax, ecx
        ret

_Z1gv:                                  # @_Z1gv
        mov     dword ptr [rip + B], 0
        mov     dword ptr [rip + A], 0
        ret

C++ の名前マングルにより分かりにくいですが,Z1fv が関数 f,Z1gv が関数 g です。

アトミック変数の読み書きに std::memory_order::acquire および std::memory_order::release を指定した場合, 変数の読み書きには単なる mov 命令が使用されることが分かります。

命令のリオーダは C++ コンパイラおよび CPU 内部で発生する可能性があります。 C++ コンパイラによるリオーダの結果は .s ファイルに現れますが,CPU によるリオーダは .s ファイルを見ても分かりません。 CPU の動作の仕様を把握する必要があります。

まず,C++ コンパイラによるリオーダの有無を見てみると,関数 f における A,B の read 順,関数 g における B,A の write 順ともに, .cpp ファイルで記述した順序が守られていることが分かります。

A,B の読み書き順がリオーダされなかったのは C++ の仕様に基づいています。 cppreference.com の memory_order_acquire の説明 を読むと, スレッド内のいかなる read も write も,acquire 操作の前方にリオーダしてはいけないと書いてあります。 すなわち B の read が A.load(std::memory_order::acquire); より前にリオーダされないことが保証されます。 memory_order_release の説明欄を見てみると,スレッド内のいかなる read も write も,release 操作の後方にリオーダしてはいけないと書いてあります。 これによって B の write が A.store(0, std::memory_order::release); より後にリオーダされないことが保証されます。

コンパイラレベルでは A,B の読み書き順に関してのリオーダは無かったことが分かりました。 次に気にするのは CPU におけるリオーダです。

x86-64 は TSO(total store order)を採用していて,read 同士,write 同士の順序が入れ替わることはありません。 したがって,関数 f における A,B の読み込み順が逆転することはありませんし,関数 g における B,A への書き込みの順序は,他のプロセッサから見ても同じに見えます。 参考:x86/x64におけるメモリオーダーの話

sec_cst

sec_cst.s から必要な部分を抜粋します。

_Z1fv:                                  # @_Z1fv
        mov     ecx, dword ptr [rip + A]
        mov     eax, dword ptr [rip + B]
        test    eax, eax
        mov     eax, 1234
        cmovne  eax, ecx
        ret

_Z1gv:                                  # @_Z1gv
        xor     eax, eax
        xchg    dword ptr [rip + B], eax
        xor     eax, eax
        xchg    dword ptr [rip + A], eax
        ret

アトミック変数の読み書きに std::memory_order::seq_cst を指定した場合, 読み込みは mov 命令ですが,書き込みは xchg 命令が使用されることが分かりました。

xchg 命令は lock プレフィクスを内包していて,明示的に lock プレフィクスを付けずとも付けたのと同じ動作になります。 したがって B への書き込みが完全に完了してから A への書き込みが始まります。

seq_cst は acquire/release より強い制約ですから,acquire/release の時と同様に,A,B の読み書き順がリオーダされることはありません。

mov の代わりに xchg が使われるのは,x86-64 では異なる変数に対する write 後の read は順序が保証されないからです。 関数 g の後に変数 A や B の読み込み命令( mov )が来た時に,それが write を飛び越えてしまうと sequential consistency(逐次一貫性)が保証できなくなってしまうのです。 xchg を使うことで read が write を追い越してしまうのを防げます。

アトミック変数を使わない場合

A を普通の int 変数とした場合のコードでも実験してみます。

// reorder.cpp
#include <atomic>

int A{42};
std::atomic_int B{1};

int f() {
  int r1 = A;
  if (B.load(ORDER_ACQUIRE)) {
    return r1;
  }
  return 1234;
}

void g() {
  B.store(0, ORDER_RELEASE);
  A = 0;
}

A にアトミック変数を使っていない以外は,最初に示したサンプルコードと同じです。コンパイラオプションも共通です。 コンパイル結果は次の通りです。

_Z1fv:                                  # @_Z1fv
        mov     eax, dword ptr [rip + B]
        test    eax, eax
        mov     eax, 1234
        cmovne  eax, dword ptr [rip + A]
        ret

_Z1gv:                                  # @_Z1gv
        mov     dword ptr [rip + B], 0
        mov     dword ptr [rip + A], 0
        ret

関数 f において,A の読み取りが B の読み取りの後に来てしまっています。 C++ のコードで考えてみると次のように A の読み取りが if 文の中に来てしまっている状態です。

int f() {
  if (B.load(ORDER_ACQUIRE)) {
    return A;
  }
  return 1234;
}

コンパイラは,A の読み取りは B が真のときだけで十分だと判断し,if 文の中に A の読み取りを移動してしまうのです。 これは C++ コンパイラに許されている正当な最適化です。

GCC での実験

reorder.cpp を g++-10 を使ってコンパイルすると,関数 f の A,B の read はリオーダされません。 筆者はこれに騙され,B さえアトミック変数にしておけば大丈夫と思い込んでいました。 rui さんに Clang ではリオーダされることを教えていただきました。ありがとうございました。

そもそもですが,変数 A をアトミック変数にしなければ未定義動作になってしまうことも後から気付きました。 A は複数のスレッドから読み書きされるためです。 ですから,reorder.cpp がいかなる結果(リオーダの有無や実行時の挙動)になろうとも,文句は言えません。

read が write を飛び越える

「異なる変数に対する write 後の read は順序が保証されない」ことがどのように影響を及ぼすか,追加で検証してみます。 元ネタは メモリモデル?なにそれ?おいしいの? です。

上記の記事にあるサンプルコードを,メモリオーダーを指定できるように改造したコードを使って実験します。

// read_write_order.cpp
#include <atomic>
std::atomic<int> x = 0;
std::atomic<int> y = 0;
int r1, r2;

void th1() {
  y.store(1, ORDER_ACQUIRE);
  r1 = x.load(ORDER_ACQUIRE);
}
void th2() {
  x.store(1, ORDER_ACQUIRE);
  r2 = y.load(ORDER_ACQUIRE);
}

まず std::memory_order::seq_cst を指定した場合のコンパイル結果を示します。

_Z3th1v:                                # @_Z3th1v
        mov     eax, 1
        xchg    dword ptr [rip + y], eax
        mov     eax, dword ptr [rip + x]
        mov     dword ptr [rip + r1], eax
        ret

_Z3th2v:                                # @_Z3th2v
        mov     eax, 1
        xchg    dword ptr [rip + x], eax
        mov     eax, dword ptr [rip + y]
        mov     dword ptr [rip + r2], eax
        ret

ご覧の通り,書き込みに xchg が使用されるため,read が write を飛び越えることがありません。

次に std::memory_order::acquire および std::memory_order::release を指定した場合のコンパイル結果を示します。

_Z3th1v:                                # @_Z3th1v
        mov     dword ptr [rip + y], 1
        mov     eax, dword ptr [rip + x]
        mov     dword ptr [rip + r1], eax
        ret

_Z3th2v:                                # @_Z3th2v
        mov     dword ptr [rip + x], 1
        mov     eax, dword ptr [rip + y]
        mov     dword ptr [rip + r2], eax
        ret

異なる変数への読み書きを mov で行っているため,read が write を飛び越えて実行されることがあります。 変数 x,y の初期値は 0,0 ですので,seq_cst ではあり得なかった (r1,r2) = (0,0) という結果になり得ます。 (筆者が手元で実験しても発生させることはできませんでしたが…)

Clang バージョン

実験に用いた GCC のバージョンを示します。

$ clang++-10 -v
clang version 10.0.0-4ubuntu1
Target: x86_64-pc-linux-gnu
Thread model: posix
InstalledDir: /usr/bin
Found candidate GCC installation: /usr/bin/../lib/gcc/x86_64-linux-gnu/10
Found candidate GCC installation: /usr/bin/../lib/gcc/x86_64-linux-gnu/7
Found candidate GCC installation: /usr/bin/../lib/gcc/x86_64-linux-gnu/7.5.0
Found candidate GCC installation: /usr/bin/../lib/gcc/x86_64-linux-gnu/8
Found candidate GCC installation: /usr/bin/../lib/gcc/x86_64-linux-gnu/9
Found candidate GCC installation: /usr/lib/gcc/x86_64-linux-gnu/10
Found candidate GCC installation: /usr/lib/gcc/x86_64-linux-gnu/7
Found candidate GCC installation: /usr/lib/gcc/x86_64-linux-gnu/7.5.0
Found candidate GCC installation: /usr/lib/gcc/x86_64-linux-gnu/8
Found candidate GCC installation: /usr/lib/gcc/x86_64-linux-gnu/9
Selected GCC installation: /usr/bin/../lib/gcc/x86_64-linux-gnu/10
Candidate multilib: .;@m64
Selected multilib: .;@m64