syghの新フラグメント置き場

プログラミングTipsやコード断片の保管場所です。お絵描きもときどき載せます。

volatileに関してそろそろ一言いっておくか

今更言うまでもありませんが、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.MonitorSystem.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をスレッド間の同期・通信に使うこともできます。

Javavolatile変数に対するread/write自体はアトミックで、longdoubleにも指定することができます(ただし64bitのデータ型に対してアトミック命令が使われるとは限らない)。
C#でもvolatile変数に対するread/write自体はアトミックですが、longdoubleに指定することはできません(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はプロセス間の排他制御に使います。スレッド間の排他制御に使うこともできますが、オーバーヘッドが大きいので基本的に使いません。