今更言うまでもありませんが、C/C++とJava/C#ではキーワードvolatileの意味が若干異なります。
C/C++
C/C++言語のvolatile
修飾子は、コンパイラに副作用を示唆し、メモリアクセスの最適化を抑制するために存在します。volatile
は典型的な処理系依存機能のうちのひとつであり、解釈は各コンパイラの実装に委ねられています。
C/C++のvolatile
は、ときどきマルチスレッド間の簡易的な同期・通信用として使われていることがあります。たとえばサブスレッドでの処理完了を待機するために、グローバル変数などを用いて定義した処理完了フラグをメインスレッドにて監視・ポーリングする、といった状況です。
#include <cstdio> #include <windows.h> #include <process.h> #include <conio.h> volatile bool g_completed; UINT CALLBACK MyThreadFunction(void*) { printf("Begin of sub-thread.\n"); // ヘビーな処理を実行。 for (int i = 0; i < 3; ++i) { printf("Executing task #[%d]...\n", i); ::Sleep(1000); } g_completed = true; printf("End of sub-thread.\n"); return 0; } int main() { printf("Main thread waiting for completion of sub-thread...\n"); auto hThread = reinterpret_cast<HANDLE>(_beginthreadex(nullptr, 0, MyThreadFunction, nullptr, 0, nullptr)); while (!g_completed) { // メッセージ処理などを行ないながら待機。 ::Sleep(1); } ::WaitForSingleObject(hThread, INFINITE); ::CloseHandle(hThread); hThread = nullptr; puts("Press any..."); _getch(); }
※本来はC++11で標準化されたstd::thread
を使ってもよいのですが、VC10.0 (VC++2010) などの古い処理系でもコンパイルできるよう、あえて古典的なCRTを使ってみました。
#include <cstdio> #include <thread> #include <chrono> volatile bool g_completed; void Sleep(int ms) { std::this_thread::sleep_for(std::chrono::milliseconds(ms)); } void MyThreadFunction() { printf("Begin of sub-thread.\n"); // ヘビーな処理を実行。 for (int i = 0; i < 3; ++i) { printf("Executing task #[%d]...\n", i); ::Sleep(1000); } g_completed = true; printf("End of sub-thread.\n"); } int main() { printf("Main thread waiting for completion of sub-thread...\n"); std::thread thread(MyThreadFunction); while (!g_completed) { // メッセージ処理などを行ないながら待機。 ::Sleep(1); } thread.join(); puts("Finished."); }
処理完了フラグはサブスレッドで書き換えますが、上記のようなケースにおいてフラグ変数をvolatile
で修飾しない場合、コンパイル時の最適化により、フラグ変数へのアクセスを(メインスレッド-サブスレッド間で共有する)メインメモリではなく、(スレッドローカルな)レジスタにて実行するようなコードを出力してしまうことがあります。そうなると上記のポーリング用while
ループの継続条件式は常に真となってしまい、メインスレッドはループを永遠に脱出できなくなります(無限ループ)。
ただし、こういったスレッド間の同期・通信用途はC/C++本来のvolatileの守備範囲ではなく、避けるべきです。そもそも、C++03規格およびそれ以前では、スレッドという概念そのものが標準化されていません。このようにvolatile
がマルチスレッド同期に乱用されるようになった背景として、MSVCにおける独自の勝手な言語拡張*1があります。
VC++のvolatile
拡張仕様では、なんとメモリバリアまで張ってくれるらしいです。つまり読み書き操作が暗黙的にアトミック処理になります。ただしx86/x64アーキテクチャとARMアーキテクチャとでは既定の動作(コンパイラオプション)が異なるらしいので注意が必要です。移植性を考えると、VC++のvolatile
拡張仕様に依存するべきではなく、メモリバリアはスレッドライブラリに用意されている同期オブジェクトを使って明示的に実装するべきです。
なお、一般的なアトミック操作の実現手段としては、C++11規格以降は基本的に標準ライブラリのstd::atomic
を使うべきです。ただし、C++/CLI (/clr) では残念ながらstd::atomic
が使えないそうで、代わりにWin32 Interlocked APIを直接使用するか、.NET標準クラスライブラリで用意されているアトミック処理用のSystem.Threading.Interlockedクラスなどを使います。C++/CLIにはC#のlock
ステートメントに相当する組み込み構文は存在せず、またstd::mutex
も使えないので、複雑な排他制御にはCRITICAL_SECTIONのようなWin32の同期オブジェクトを直接使用するか、System.Threading.MonitorやSystem.Threading.SemaphoreSlimといった.NETの同期オブジェクトを直接使用するか、あるいはラッパークラスmsclr::lockを使用します*2。処理系依存のvolatile
を使うのは最後の手段にしましょう。
ちなみに前述の例は、WaitForSingleObject()の第2引数に1を指定して(タイムアウト時間1ミリ秒の待機とする)、ポーリングループ内で呼び出して戻り値をチェックする方法に変更すれば、volatile
グローバルフラグ変数を取り除くことができます。
while (::WaitForSingleObject(hThread, 1) == WAIT_TIMEOUT) { // メッセージ処理などを行ないながら待機。 }
Java/C#
Java/C#のvolatile
は、VC++拡張仕様のようなメモリバリアまではなされないものの、最適化を抑制する効果があります。C/C++と違い、処理系依存ではなくれっきとした言語仕様となっています。C#/Javaは当初からマルチスレッドを考慮した言語設計がなされており、volatile
仕様に関してもマルチスレッドが考慮されているため、限定的ながらvolatile
をスレッド間の同期・通信に使うこともできます。
Javaのvolatile
変数に対するread/write自体はアトミックで、long
やdouble
にも指定することができます(ただし64bitのデータ型に対してアトミック命令が使われるとは限らない)。
C#でもvolatile
変数に対するread/write自体はアトミックですが、long
やdouble
に指定することはできません(64bitのデータ型に対して、32bit環境ではアトミック命令が使えるとは限らないため)。
Java/C#のvolatile
フィールドに対するインクリメント・デクリメントなどはアトミック操作にならないので、当然そういった用途にはjava.util.concurrent.atomic
パッケージやSystem.Threading.Interlocked
クラスなどを使うべきですが、一度だけ発生するフラグの単純な代入 (write) と参照 (read) による疑似的なシグナル用途など、volatile
でもOKな場面もあります。運用制限を設けて賢く使いましょう。
*1:うわさによると、VC7.1 (VC++ .NET 2003) では、volatile関連で /Oa というさらにぶっ飛んだ最適化オプションがあったそうですが、このオプションはVC8.0 (VC++2005) で削除されたそうです。Visual Studio 2003 から 2015 の Visual C++ の新機能 | Microsoft Learn
*2:Windowsでの開発経験が浅く、Pthreadsや標準C++のスレッドライブラリしか使ったことがない人は勘違いしていることが非常に多いのですが、Win32のミューテックスオブジェクトや.NETのSystem.Threading.Mutexはプロセス間の排他制御に使います。スレッド間の排他制御に使うこともできますが、オーバーヘッドが大きいので基本的に使いません。