#今回の目的
例えば 他のデバイスが非同期でフラグを変えるのを待つ場合に
コンパイラの最適化によって、望んだ処理が消える可能性がある
その場合に volatileを使うが、そのあたりを調査します
#問題コード
まず簡単な例。flagが初期値0で、0以外の値に変わると
worker!!
と出力し、終了する。
別スレッドで 5秒後にflag=1 に設定するので5秒後に終了するはずのプログラム
(下記に理由を書きますが 本来はスレッドの同期にvolatileを使うのは間違いで、処理系によっては正しく動作できません。今回はそのあたりは無視しています)
#include <iostream>
#include <thread>
int flag = 0;
void worker() {
while( flag == 0){
}
std::cout << "worker!! \n";
}
auto main(int argc, const char * argv[]) -> int {
std::cout << "start\n";
std::thread th(worker);
std::this_thread::sleep_for(std::chrono::seconds(5));
flag = 1;
th.join();
return 0;
};
今回 Windowsマシンが調子悪いので Xcode6.1 で実行してみました。
debugビルドだと 5秒後に worker!! が出力されプログラムが終了しますが
releaseビルドだと無限ループになりました
コンパイラの最適化次第では releaseでも正しく動く事もありますが
Xcode6.1の 逆アセンブラを比較します
わかりやすく該当部分のみ抜粋
0x100001170: pushq %rbp
0x100001171: movq %rsp, %rbp
0x100001174: subq $0x10, %rsp
0x100001178: jmp 0x10000117d ; worker() + 13 at main.cpp:16
0x10000117d: cmpl $0x0, 0x1fa9(%rip) ; (void *)0x0000000000007fff
0x100001187: jne 0x100001192 ; worker() + 34 at main.cpp:20
0x10000118d: jmp 0x10000117d ; worker() + 13 at main.cpp:16
0x100001192: movq 0x1e7f(%rip), %rdi ; (void *)0x00007fff7a3302f8: std::__1::cout
0x100001199: leaq 0x1d44(%rip), %rsi ; "worker!! \n"
0x1000011a0: callq 0x100002bd8 ; symbol stub for: std::__1::basic_ostream<char, std::__1::char_traits<char> >& std::__1::operator<<<std::__1::char_traits<char> >(std::__1::basic_ostream<char, std::__1::char_traits<char> >&, char const*)
0x1000011a5: movq %rax, -0x8(%rbp)
0x1000011a9: addq $0x10, %rsp
0x1000011ad: popq %rbp
0x1000011ae: retq
AT&Tシンタックスで読むの苦手ですが・・
0x10000117d: cmpl $0x0, 0x1fa9(%rip) ; (void *)0x0000000000007fff
0x100001187: jne 0x100001192 ; worker() + 34 at main.cpp:20
で、flagの値が0かどうかチェックし 0でなければループを抜けています
0の場合は
0x10000118d: jmp 0x10000117d ; worker() + 13 at main.cpp:16
で、再びflagの値をチェックしています
ので、別スレッドでflagの値が変更された場合 上手く動きますね
(スレッド間のキャッシュ同期問題は 今回考えてません 大事な事なのでry)
問題の release版
0x100000770: pushq %rbp
0x100000771: movq %rsp, %rbp
0x100000774: cmpl $0x0, 0x975(%rip) ; (void *)0x0000000000000000
0x10000077b: sete %al
0x10000077e: testb $0x1, %al
0x100000780: movb $0x1, %al
0x100000782: jne 0x10000077e ; worker() + 14 at main.cpp:27
0x100000784: movq 0x885(%rip), %rdi ; (void *)0x00007fff7a3302f8: std::__1::cout
0x10000078b: leaq 0x766(%rip), %rsi ; "worker!! \n"
0x100000792: movl $0xa, %edx
0x100000797: popq %rbp
0x100000798: jmp 0x10000097e ; std::__1::basic_ostream<char, std::__1::char_traits<char> >& std::__1::__put_character_sequence<char, std::__1::char_traits<char> >(std::__1::basic_ostream<char, std::__1::char_traits<char> >&, char const*, unsigned long) at ostream:734
ちょっと読みにくいですが
0x100000774: cmpl $0x0, 0x975(%rip) ; (void *)0x0000000000000000
0x10000077b: sete %al
flag == 0ならば alレジスタに1を設定、それ以外は0を設定します
flag == 0なので alレジスタには1が設定されました
0x10000077e: testb $0x1, %al
0x100000780: movb $0x1, %al
0x100000782: jne 0x10000077e ; worker() + 14 at main.cpp:27
alレジスタと 0x1を比較(AND)します。alレジスタは0x1なので 0x1となります
alレジスタに 0x1を設定します
上記の testの値が 非ゼロの場合 再び testb にジャンプします
今回は 0x1だったのでジャンプします
そして何度たっても alの値は0x1のため無限ループをします
##なぜ コンパイラ最適化で無限ループになるのか?
コードのこの部分に注目
void worker() {
while( flag == 0){
}
std::cout << "worker!! \n";
}
局所的に見ると、このflagの値はどこからも設定されませんので
コンパイラにとっては、関数内では変化しない値と認識されます
関数に入った時はflag==0のはずなので、当然無限ループになります
なので、コンパイラに対して、flagという変数が、スレッドやI/O待ち等で変更される可能性がある事を教え
最適化を禁止させる必要があります
#最適化禁止にはvolatile
そこで今回のテーマのvolatileの登場
volatileは変数に付ける修飾子で、コンパイラによる最適化を禁止させる働きがあります
さっそく volatileを加えたコード
#include <iostream>
#include <thread>
volatile int flag = 0;
void worker() {
while( flag == 0){
}
std::cout << "worker!! \n";
}
auto main(int argc, const char * argv[]) -> int {
std::cout << "start\n";
std::thread th(worker);
std::this_thread::sleep_for(std::chrono::seconds(5));
flag = 1;
th.join();
return 0;
};
flagの変数にvolatile属性を加えただけですが、これだけで正しく動作します
アセンブラをみてみましょう
0x100000780: pushq %rbp
0x100000781: movq %rsp, %rbp
0x100000784: cmpl $0x0, 0x965(%rip) ; (void *)0x0000000000000000
0x10000078b: je 0x100000784 ; worker() + 4 at main.cpp:27
0x10000078d: movq 0x87c(%rip), %rdi ; (void *)0x00007fff7a3302f8: std::__1::cout
0x100000794: leaq 0x75d(%rip), %rsi ; "worker!! \n"
0x10000079b: movl $0xa, %edx
0x1000007a0: popq %rbp
0x1000007a1: jmp 0x10000097e ; std::__1::basic_ostream<char, std::__1::char_traits<char> >& std::__1::__put_character_sequence<char, std::__1::char_traits<char> >(std::__1::basic_ostream<char, std::__1::char_traits<char> >&, char const*, unsigned long) at ostream:734
0x100000784: cmpl $0x0, 0x965(%rip) ; (void *)0x0000000000000000
0x10000078b: je 0x100000784 ; worker() + 4 at main.cpp:27
flagの値と 0x0を比較し 同じであれば再び cmpに飛ぶ
非常にシンプルかつ、正しい動きになりました
このように volatileは 他のスレッド、I/O待により値を書き換えられる可能性がある変数に対して
コンパイラの最適化を抑制する 修飾子です
#しかし最適化を全て禁止したくない
上記の対策で まず問題ありませんが
せっかくコンパイラが最適化をしてくれているのに
flag に関わる場所を全部最適化禁止するのは もったいない
そう感じるのが Cゲンガーでしょう
この1カ所のみ最適化を阻止する方法は色々考えられます
例えば
- この関数を別ファイルにし、そのファイルには最適化を行わない
- インラインアセンブラで書く
- キャストを使い局所的にvolatileにする
- メンバ関数をvolatile指定する
1 については、makefileをいじってください
参考までに他の方法を
##インラインアセンブラで最適化阻止
#include <iostream>
#include <thread>
int flag = 0;
void worker() {
asm volatile(
"loop: \n\t"
"movl (%0), %%eax \n\t"
"cmp $0x0, %%eax \n\t"
"je loop \n\t"
: :"r" (&flag) : "%eax"
);
std::cout << "worker!! \n";
}
auto main(int argc, const char * argv[]) -> int {
std::cout << "start\n";
std::thread th(worker);
std::this_thread::sleep_for(std::chrono::seconds(5));
flag = 1;
th.join();
return 0;
};
あまりアセンブラ得意ではないので適当ですが
(AT&T記法で flagのアドレス値を直接参照する方法誰か知りませんかね・・)
flagに volatile修飾していないので、flagは最適化対象です
が、最適化して欲しくないループの部分を インラインアセンブラで書きます。
asmのあとのvolatileは 念のため入れておいたほうが良いでしょう
flagのアドレスをレジスタ変数に入れ、その値をチェックしています
アセンブラなので アドレスで取って下さい! flagの値ではダメですよ!
一応、暗黒魔術ですが これで問題なく動きます
念のためのアセンブリ
0x100000770: pushq %rbp
0x100000771: movq %rsp, %rbp
0x100000774: leaq 0x975(%rip), %rcx
0x10000077b: movl (%rcx), %eax
0x10000077d: cmpl $0x0, %eax
0x100000780: je 0x10000077b ; worker() + 11 at main.cpp:30
0x100000782: movq 0x887(%rip), %rdi ; (void *)0x00007fff7a3302f8: std::__1::cout
0x100000789: leaq 0x758(%rip), %rsi ; "worker!! \n"
0x100000790: movl $0xa, %edx
0x100000795: popq %rbp
0x100000796: jmp 0x10000096e ; std::__1::basic_ostream<char, std::__1::char_traits<char> >& std::__1::__put_character_sequence<char, std::__1::char_traits<char> >(std::__1::basic_ostream<char, std::__1::char_traits<char> >&, char const*, unsigned long) at ostream:734
0x100000774: leaq 0x975(%rip), %rcx
0x10000077b: movl (%rcx), %eax
0x10000077d: cmpl $0x0, %eax
0x100000780: je 0x10000077b ; worker() + 11 at
&flagを取得し、その値が0以外になるのを待ってます。正しい動きですね!
(本来は 上3行は 1つのニーモニックに落とせるのだが AT&T記法がわからぬため、無駄な処理になってます)
##キャストを使い局所的にvolatileにする
インラインアセンブラによる最適化阻止は黒魔術ですね、ちょっと強引だし
なにより、CPUやコンパイラ依存なので移植時に問題が出ます
ので、おおよそ正しくは このように処理をするのではないかと。
#include <iostream>
#include <thread>
int flag = 0;
void worker() {
volatile int *p = &flag;
while( *p == 0){
}
std::cout << "worker!! \n";
}
auto main(int argc, const char * argv[]) -> int {
std::cout << "start\n";
std::thread th(worker);
std::this_thread::sleep_for(std::chrono::seconds(5));
flag = 1;
th.join();
return 0;
};
flagはvolatile修飾してないので最適化対象です
volatile int *p = &flag;
にて、flagのアドレスを取得し volatile修飾しています
これが今回の ポイントです。
このため、 ポインタpは 最適化の対象外となり
局所的に最適化を止める事ができました。
アセンブリです
0x100000780: pushq %rbp
0x100000781: movq %rsp, %rbp
0x100000784: cmpl $0x0, 0x965(%rip) ; (void *)0x0000000000000000
0x10000078b: je 0x100000784 ; worker() + 4 at main.cpp:20
0x10000078d: movq 0x87c(%rip), %rdi ; (void *)0x00007fff7a3302f8: std::__1::cout
0x100000794: leaq 0x75d(%rip), %rsi ; "worker!! \n"
0x10000079b: movl $0xa, %edx
0x1000007a0: popq %rbp
0x1000007a1: jmp 0x10000097e ; std::__1::basic_ostream<char, std::__1::char_traits<char> >& std::__1::__put_character_sequence<char, std::__1::char_traits<char> >(std::__1::basic_ostream<char, std::__1::char_traits<char> >&, char const*, unsigned long) at ostream:734
0x100000784: cmpl $0x0, 0x965(%rip) ; (void *)0x0000000000000000
0x10000078b: je 0x100000784 ; worker() + 4 at main.cpp:20
はい、完璧ですね!
ただし、ポインタを使い間接参照を行っているので
環境により、ポインタ演算が最適化されず、必ず速くなる保証はありません!
##default volatileで部分的に最適化対象にする
上記の反対で
デフォルトは最適化阻止、局所的に最適化を行いたいという場合
(volatile必要な変数は 不具合考え volatileで宣言すべきなので、上記よりこちらが良いとおもいます)
volatile int flag = 0;
と volatile修飾をします。
最適化してよい箇所は 上記の逆で
int *p = const_cast<int*>(&flag);
と、volatileを落として使うと、最適化対象になります。
ただし、ポインタを使い間接参照を行っているので
環境により、ポインタ演算が最適化されず、必ず速くなる保証はありません!
##メンバ関数でvolatile指定
メンバ関数であればvolatileを指定し、関数自体の最適化を排除できます
オブジェクト指向で開発している場合は、volatileなgetterを作る
ベストだとおもいます
#include <iostream>
#include <thread>
class test{
public:
void worker(){
while( flag_v() == 0){
}
std::cout << "worker!! \n";
}
int flag_v() volatile {return(flag_);};
int flag() {return(flag_);};
void setFlag( int flag ) { flag_ = flag; };
private:
int flag_ = 0;
};
auto main(int argc, const char * argv[]) -> int {
std::cout << "start\n";
test t;
std::thread th( &test::worker, &t );
std::this_thread::sleep_for(std::chrono::seconds(5));
t.setFlag(1);
th.join();
return 0;
};
このように、classにし
int flag_v() volatile {return(flag_);};
と、getterにvolatileを指定すると、最適化が抑制され
正常に動きます
普段は最適化して欲しいので volatile有無で2つ関数作ると良いと思います
ただし、メンバ変数でしかvolatileは指定出来ないため
グローバルの関数では不可能です
一般的な用途としては クラスになっていると思うのでほとんどこれで対応出来るでしょう
ただし、現状では ラムダ関数には volatileを修飾できませんので
ラムダ関数で行う場合には、上記の volatile *にキャストする必要があるでしょう
一応 ラムダに volatileやconst修飾が出来るような提案は出ているので
そのうち ラムダでもvolatile修飾出来るようになるんじゃないかな。
http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2008/n2651.pdf
#マルチスレッドでvolatileが同期すると思うな
すこしC言語に詳しい方ならよく知っていると思いますが
意外と面倒な話
メモリバリアの問題です
上記のvolatile修飾すれば、一見他のスレッドでflagが書き換えられた場合
即座に反映されるように見えますが
マルチスレッドの場合は、異なるCPUコアで動いている場合があります
CPUには一次キャッシュ、二次キャッシュ、三次キャッシュ
あるいはメモリキャッシュ等、色々なキャッシュが存在して
最適化の為に、CPUコアはそれぞれ独自にメモリをキャッシュしているかもしれません
例えば 他スレッドが別のコアで動作している場合、他スレッドが キャッシュしているflagに1を設定した場合
その書き込みがwrite backされ、自分のコアが 再びキャッシュ更新するまでは
flagの値の更新がわかりません!!
プロセッサによっては、メモリがwriteされた場合は即座にキャッシュを更新する作りになっているものもありますが
全てとは限らないので
機種依存の不具合になります
ので、他スレッドから値が更新されるのを期待する場合には
mutexや、lock free等を使い、データの同期処理を行って下さい
C++11だと std::atomic を使えばベストだと思います
(異論もとむ)
あくまでvolatileで同期出来るのは、シングルスレッドでの処理
I/Oのwait 程度だと思います
#おまけ
boost.coroutine を使うと、シングルスレッドで 非同期処理を行う事が出来ます
とっても便利なので みんな使ってね!
で、その場合 シングルスレッドだが他のコンテキスト(Fiberとか呼ばれたりする)
により値が変更される事があるが
その際は 恐らく、シングルスレッドで同じコアで動作しているので
mutexなどが不要で、volatileのみで同期が出来ると 考えているが
それが本当に正しいか・・・・ 調査中です。
知っている人いたら教えて下さい。
原子性、順序性には絶対に問題はないと思いますし
可視性にたいして、coroutineのcontextで問題になる可能性も低いと思っています。。