web-dev-qa-db-ja.com

C ++ volatileキーワードはメモリフェンスを導入しますか?

volatileは値が変更される可能性があることをコンパイラに通知することを理解していますが、この機能を実現するために、コンパイラはメモリフェンスを導入して動作させる必要がありますか?

私の理解では、揮発性オブジェクトに対する操作の順序は並べ替えることができず、保存する必要があります。これは、いくつかのメモリフェンスが必要であり、実際にこれを回避する方法がないことを意味するようです。私はこれを言って正しいですか?


この関連する質問 で興味深い議論があります

ジョナサン・ウェイクリーが書いている

...別個の完全な式で発生する限り、別個の揮発性変数へのアクセスはコンパイラーによって再配列できません...揮発性はスレッド安全性には役に立たないが、彼が与えた理由ではない。コンパイラがvolatileオブジェクトへのアクセスを並べ替えるからではなく、CPUがそれらを並べ替えるからです。アトミック操作とメモリバリアにより、コンパイラとCPUの並べ替えが防止されます。

これに対して David Schwartz 返信 コメント内

... C++標準の観点からは、コンパイラが何かを行うことと、コンパイラがハードウェアに何かをさせる命令を発行することとの間に違いはありません。 CPUがvolatileへのアクセスを並べ替える場合、標準ではその順序を保持する必要はありません。 ...

... C++標準は、並べ替えの内容について区別しません。そして、CPUが観察可能な効果なしでそれらを並べ替えることができると主張することはできません。それで問題ありません。C++標準は、その順序を観察可能と定義しています。コンパイラは、プラットフォームが標準に必要なことを実行するコードを生成する場合、プラットフォーム上のC++標準に準拠します。規格が揮発性へのアクセスを並べ替える必要がない場合、それらを並べ替えるプラットフォームは準拠していません。 ...

私のポイントは、C++標準がコンパイラが異なる揮発性へのアクセスを並べ替えることを禁止している場合、そのようなアクセスの順序はプログラムの観察可能な動作の一部であるという理論に基づいており、コンパイラがCPUの実行を禁止するコードを発行する必要があるということですそう。この規格は、コンパイラーが行うことと、コンパイラーの生成コードがCPUに何をさせるかを区別しません。

どちらが2つの質問になりますか:どちらかが「正しい」ですか?実際の実装は実際に何をしますか?

80
Nathan Doromal

volatileが何をするのかを説明するのではなく、volatileをいつ使用すべきかを説明させてください。

  • シグナルハンドラー内。 volatile変数への書き込みは、シグナルハンドラ内から標準で許可されている唯一のものであるためです。 C++ 11以降では、std::atomicをその目的に使用できますが、アトミックがロックフリーの場合のみです。
  • setjmpを扱う場合 Intelによる
  • ハードウェアを直接処理する場合、コンパイラーが読み取りまたは書き込みを最適化しないようにする必要があります。

例えば:

volatile int *foo = some_memory_mapped_device;
while (*foo)
    ; // wait until *foo turns false

volatile指定子がない場合、コンパイラはループを完全に最適化できます。 volatile指定子は、後続の2回の読み取りが同じ値を返すと仮定できないことをコンパイラーに伝えます。

volatileはスレッドとは関係がないことに注意してください。上記の例は、取得操作が含まれていないため、*fooに書き込みを行う別のスレッドがある場合は機能しません。

他のすべての場合、volatileの使用は非移植性と見なされるべきであり、C++ 11より前のコンパイラーとコンパイラー拡張機能(msvcの/volatile:msスイッチなど) X86/I64ではデフォルトで有効になっています)。

52
Stefan

C++ volatileキーワードはメモリフェンスを導入しますか?

仕様に準拠するC++コンパイラは、メモリフェンスを導入する必要はありません。あなたの特定のコンパイラはそうかもしれません。コンパイラの作成者に質問を送信してください。

C++の「volatile」の機能は、スレッド化とは関係ありません。 「揮発性」の目的は、コンパイラの最適化を無効にして、外因性の条件により変化するレジスタからの読み取りが最適化されないようにすることです。異なるCPU上の異なるスレッドによって書き込まれているメモリアドレスは、外因性の条件により変化するレジスタですか?いいえ。繰り返しますが、一部のコンパイラ作成者がchosenを使用して、異なるCPU上の異なるスレッドによって書き込まれるメモリアドレスを、外因性の条件によりレジスタが変更されているかのように扱う場合、それは彼らのビジネスです。そうする必要はありません。また、メモリフェンスを導入する場合でも、たとえばeveryスレッドがconsistent揮発性の読み取りと書き込みの順序を確認するために必要ではありません。

実際、volatileはC/C++のスレッド化にはほとんど役に立たない。ベストプラクティスはそれを避けることです。

さらに、メモリフェンスは特定のプロセッサアーキテクチャの実装の詳細です。 C#では、明示的にvolatile isがマルチスレッド用に設計されていますが、最初の段階ではフェンスがないアーキテクチャでプログラムが実行される可能性があるため、仕様ではハーフフェンスが導入されるとは述べていません。むしろ、仕様は、コンパイラ、ランタイム、およびCPUがどのような最適化を回避するかについて特定の(非常に弱い)保証を行い、副作用の順序付け方法に特定の(非常に弱い)制約を設定します。実際には、これらの最適化はハーフフェンスの使用によって排除されますが、それは将来変更される可能性のある実装の詳細です。

マルチスレッドに関する揮発性のセマンティクスに関心があるという事実は、スレッド間でメモリを共有することを考えていることを示しています。単にそれをしないことを検討してください。プログラムを理解するのがはるかに難しくなり、微妙で再現不可能なバグが含まれる可能性が高くなります。

21
Eric Lippert

まず、C++標準では、アトミックでない読み取り/書き込みを適切に順序付けるために必要なメモリバリアが保証されていません。 volatile変数は、MMIO、信号処理などで使用するために推奨されます。ほとんどの実装では、volatileはマルチスレッドには有用ではなく、一般的に推奨されません。

揮発性アクセスの実装に関しては、これがコンパイラの選択です。

この記事gccの動作を示しています揮発性オブジェクトをメモリバリアとして使用して、揮発性メモリへの書き込みシーケンスを順序付けることはできません。

iccの動作について、私はこれを見つけましたsourceまた、volatileはメモリアクセスの順序を保証しないことを伝えます。

Microsoft VS2013コンパイラの動作は異なります。これはドキュメントvolatileがRelease/Acquireセマンティクスを実施し、マルチスレッドのロック/リリースでvolatileオブジェクトを使用できるようにする方法を説明していますアプリケーション。

考慮に入れる必要がある別の側面は、同じコンパイラーが異なる動作を持っている可能性があるということです。ターゲットハードウェアアーキテクチャに応じてvolatileに。 MSVS 2013コンパイラに関するこのpostは、ARM =プラットフォーム。

だから私の答え:

C++ volatileキーワードはメモリフェンスを導入しますか?

保証されていませんが、おそらくそうではありませんが、一部のコンパイラーはそれを行うかもしれません。あなたはそれがするという事実に頼るべきではありません。

12
VAndrei

Davidが見落としているのは、c ++標準が特定の状況でのみ相互作用する複数のスレッドの動作を指定し、他のすべてが未定義の動作をもたらすという事実です。アトミック変数を使用しない場合、少なくとも1つの書き込みを含む競合状態は未定義です。

その結果、CPUは同期の欠落により未定義の動作を示すプログラムの違いにしか気付かないため、コンパイラは同期命令を完全に放棄する権利があります。

12
Voo

私の知る限り、コンパイラはItaniumアーキテクチャにメモリフェンスを挿入するだけです。

volatileキーワードは、非同期の変更、たとえばシグナルハンドラやメモリマップレジスタに本当に最適に使用されます。通常、マルチスレッドプログラミングに使用するのは間違ったツールです。

7
Dietrich Epp

それは、どのコンパイラー「コンパイラー」かによって異なります。 2005年以降、Visual C++で必要になりました。しかし、標準では必須ではないため、他の一部のコンパイラでは必要ありません。

6
Ben Voigt

する必要はありません。揮発性は同期プリミティブではありません。最適化を無効にします。つまり、抽象マシンで規定されているのと同じ順序で、スレッド内で予測可能な読み取りおよび書き込みシーケンスを取得します。しかし、異なるスレッドでの読み取りと書き込みには、そもそも順序がありません。順序を維持するかどうかを言っても意味がありません。 thead間の順序は同期プリミティブによって確立でき、それらなしでUBを取得します。

メモリバリアに関する説明。一般的なCPUには、いくつかのレベルのメモリアクセスがあります。メモリパイプライン、いくつかのレベルのキャッシュがあり、RAMなど。

Membar命令はパイプラインをフラッシュします。読み取りと書き込みが実行される順序は変更されず、特定の瞬間に未処理の実行が強制されます。マルチスレッドプログラムには便利ですが、それ以外の場合はあまり役に立ちません。

キャッシュは通常、CPU間で自動的に一貫しています。キャッシュがRAMと同期していることを確認したい場合は、キャッシュのフラッシュが必要です。メンバーとは大きく異なります。

5
n.m.

これは主にメモリからのものであり、スレッドなしのpre-C++ 11に基づいています。しかし、委員会のスレッドに関する議論に参加したことで、volatileをスレッド間の同期に使用できるという委員会の意図はなかったと言えます。マイクロソフトはそれを提案しましたが、提案は実行されませんでした。

volatileの主要な仕様は、IOのように、volatileへのアクセスが「観測可能な動作」を表すことです。同様に、コンパイラは特定のIOを並べ替えたり削除したりできません。また、揮発性オブジェクトへのアクセス(より正確には、volatile修飾型の左辺値式を介したアクセス)を並べ替えまたは削除することはできません。 volatileの本来の目的は、実際、メモリマップIOをサポートすることでした。ただし、これに関する「問題」は、「揮発性アクセス」を構成するものが実装で定義されていることです。そして、多くのコンパイラは、定義が「メモリへの読み書きを実行する命令」であるかのように実装しています。役に立たない定義ではありますが、これは正当ですif実装で指定されています。 (コンパイラーの実際の仕様はまだ見つかっていません。)

ハードウェアがアドレスをメモリマップIOとして認識し、並べ替えなどを禁止しない限り、メモリマップIOにvolatileを使用することさえできないため、ほぼ間違いなく(これは私が受け入れる引数です)、これは標準の意図に違反します少なくともSparcまたはIntelアーキテクチャでは。それでもなお、私が見たコライダー(Sun CC、g ++、およびMSC)はいずれもフェンスまたはmembar命令を出力しません。 (Microsoftがvolatileのルールを拡張することを提案した頃、私は彼らのコンパイラのいくつかがそれらの提案を実装し、揮発性アクセスのためにフェンス命令を発行したと思います。コンパイラオプションに依存している場合は驚かされます。チェックしたバージョン(VS6.0だったと思いますが)はフェンスを発しませんでした。)

5
James Kanze

コンパイラは、標準作業で指定されたvolatileを使用するために必要な場合にのみ、volatileアクセスの周りにメモリフェンスを導入する必要があります(setjmp、signalその特定のプラットフォーム上のハンドラーなど)。

これらのプラットフォームでvolatileをより強力または便利にするために、一部のコンパイラはC++標準で必要なものをはるかに超えていることに注意してください。移植可能なコードは、C++標準で指定されている以上のことを行うためにvolatileに依存すべきではありません。

4
David Schwartz

割り込みサービスルーチンでは常にvolatileを使用しています。 ISR(多くの場合アセンブリコード)はメモリの場所を変更し、割り込みコンテキストの外部で実行される高レベルのコードは、volatileへのポインターを介してメモリの場所にアクセスします。

私はこれをRAMおよびメモリマップIOに対して行います。

ここでの議論に基づくと、これはまだvolatileの有効な使用方法のようですが、複数のスレッドやCPUとは何の関係もありません。マイクロコントローラー用のコンパイラーが他のアクセスができないことを「知っている」場合(たとえば、すべてがオンチップであり、キャッシュがなく、コアが1つしかない場合)、メモリーフェンスがまったく暗示されていないと思います特定の最適化を防ぐ必要があるだけです。

オブジェクトコードを実行する「システム」により多くのものを積み込むと、ほとんどすべての賭けがオフになります。少なくとも、この議論を読んでいます。コンパイラはどのようにしてすべてのベースをカバーできますか?

2
Andrew Queisser

キーワードvolatileは、基本的に、オブジェクトの読み取りと書き込みがプログラムによって書き込まれたとおりに実行され、どのような方法でも最適化されないであることを意味します。バイナリコードはCまたはC++コードに従う必要があります。これが読み込まれる場所、書き込みがある場所です。

また、読み取りが予測可能な値をもたらすと期待されるべきではないことを意味します。コンパイラは、同じvolatileオブジェクトへの書き込みの直後であっても、読み取りについて何も想定すべきではありません。

volatile int i;
i = 1;
int j = i; 
if (j == 1) // not assumed to be true

volatile「Cは高レベルのアセンブリ言語」ツールボックスで最も重要なツールです。

オブジェクトのvolatile宣言は、非同期の変更を処理するコードの動作を保証するのに十分であるかどうかはプラットフォームに依存します。CPUが異なれば、通常のメモリの読み取りと書き込みの同期の保証レベルが異なります。おそらく、この分野の専門家でない限り、このような低レベルのマルチスレッドコードを記述しようとすべきではありません。

アトミックプリミティブは、マルチスレッドのオブジェクトのニースの高レベルのビューを提供し、コードの推論を容易にします。ほとんどすべてのプログラマーは、アトミックプリミティブまたは相互排他、ミューテックス、読み取り/書き込みロック、セマフォ、またはその他のブロッキングプリミティブを提供するプリミティブを使用する必要があります。

0
curiousguy

Volatileと命令の並べ替えに関する混乱は、CPUが行う並べ替えの2つの概念に起因すると思います。

  1. 順不同の実行。
  2. 他のCPUから見たメモリの読み取り/書き込みのシーケンス(各CPUが異なるシーケンスを見る可能性があるという意味での並べ替え)。

揮発性は、シングルスレッド実行(これには割り込みが含まれる)を想定して、コンパイラがコードを生成する方法に影響します。メモリバリア命令については何の意味もありませんが、コンパイラがメモリアクセスに関連する特定の種類の最適化を実行できないようにします。
典型的な例は、レジスタにキャッシュされた値を使用する代わりに、メモリから値を再取得することです。

順不同の実行

CPUは、最終結果が元のコードで発生した可能性がある場合、命令を順不同/推測的に実行できます。コンパイラはすべての状況で正しい変換のみを実行できるため、CPUはコンパイラで許可されていない変換を実行できます。対照的に、CPUはこれらの最適化の有効性をチェックし、誤っていることが判明した場合はそれらを元に戻すことができます。

他のCPUから見たメモリの読み取り/書き込みのシーケンス

命令のシーケンスの最終結果である有効な順序は、コンパイラによって生成されたコードのセマンティクスと一致する必要があります。ただし、CPUによって選択される実際の実行順序は異なる場合があります。他のCPU(すべてのCPUが異なるビューを持つことができる)で見られる有効な順序は、メモリバリアによって制約される可能性があります。
メモリバリアがCPUのアウトオブオーダー実行を妨げる範囲がわからないため、どれだけ効果的で実際の順序が異なるかはわかりません。

ソース:

0
Paweł Batko

私は、最新のOpenGLで動作する3Dグラフィックスとゲームエンジンの開発に関するオンラインのダウンロード可能なビデオチュートリアルを進めていました。クラスの1つでvolatileを使用しました。チュートリアルのWebサイトは here にあり、volatileキーワードを使用したビデオはShader Engineシリーズビデオ98。これらの作品は私自身のものではありませんが、Marek A. Krzeminski, MAScこれは、ビデオのダウンロードページからの抜粋です。

「ゲームを複数のスレッドで実行できるようになったため、スレッド間でデータを適切に同期することが重要です。このビデオでは、揮発性ロッククラスを作成して揮発性変数が正しく同期されるようにする方法を示します...」

そして、あなたが彼のウェブサイトにサブスクライブしていて、このビデオ内で彼のビデオにアクセスできる場合、彼はこれを参照します 記事Volatilemultithreadingプログラミングでの使用に関して。

上記のリンクの記事を次に示します。 http://www.drdobbs.com/cpp/volatile-the-multithreaded-programmers-b/184403766

volatile:マルチスレッドプログラマの親友

Andrei Alexandrescu、2001年2月1日

volatileキーワードは、特定の非同期イベントが存在する場合にコードが正しくない可能性があるコンパイラーの最適化を防ぐために考案されました。

気分を害したくありませんが、このコラムではマルチスレッドプログラミングの恐ろしいトピックを取り上げます。ジェネリックの前回の記事で述べたように、例外セーフプログラミングが難しい場合は、マルチスレッドプログラミングと比較して子供の遊びです。

複数のスレッドを使用するプログラムは、一般に、作成、修正、デバッグ、保守、および飼い慣らしが難しいことで有名です。誤ったマルチスレッドプログラムは、グリッチなしで何年も実行される可能性がありますが、いくつかの重要なタイミング条件が満たされているため、予期せず実行されます。

言うまでもなく、マルチスレッドコードを作成するプログラマーは、できる限りの支援を必要とします。このコラムでは、競合状態(マルチスレッドプログラムのトラブルの一般的な原因)に焦点を当て、それらを回避する方法と、驚くほど十分にコンパイラーがそれを支援するために一生懸命働く方法に関する洞察とツールを提供します。

ちょっとしたキーワード

C標準とC++標準はどちらもスレッドに関しては目立たないように静かですが、volatileキーワードの形式でマルチスレッドを少し譲歩します。

よく知られている対応するconstと同様に、volatileは型修飾子です。異なるスレッドでアクセスおよび変更される変数と組み合わせて使用​​することを目的としています。基本的に、揮発性なしでは、マルチスレッドプログラムの作成が不可能になるか、コンパイラが膨大な最適化の機会を無駄にします。説明は整然としています。

次のコードを検討してください。

class Gadget {
public:
    void Wait() {
        while (!flag_) {
            Sleep(1000); // sleeps for 1000 milliseconds
        }
    }
    void Wakeup() {
        flag_ = true;
    }
    ...
private:
    bool flag_;
};

上記のGadget :: Waitの目的は、flag_メンバー変数を毎秒チェックし、その変数が別のスレッドによってtrueに設定されたときに戻ることです。少なくともそれはプログラマが意図したものですが、残念ながら、Waitは間違っています。

コンパイラが、Sleep(1000)が外部変数への呼び出しであり、おそらくメンバ変数flag_を変更できないと判断したとします。その後、コンパイラは、flag_をレジスタにキャッシュし、低速のオンボードメモリにアクセスする代わりにそのレジスタを使用できると判断します。これは、シングルスレッドコードの優れた最適化ですが、この場合、正確性を損ないます。ガジェットオブジェクトのWaitを呼び出した後、別のスレッドがWakeupを呼び出しても、Waitは永久にループします。これは、flag_の変更がflag_をキャッシュするレジスタに反映されないためです。最適化も楽観的です。

レジスター内の変数のキャッシュは、ほとんどの場合に適用される非常に価値のある最適化であるため、それを無駄にするのは残念です。 CおよびC++では、このようなキャッシュを明示的に無効にすることができます。変数にvolatile修飾子を使用すると、コンパイラはその変数をレジスタにキャッシュしません。各アクセスはその変数の実際のメモリ位置にヒットします。したがって、ガジェットの待機/ウェイクアップコンボを機能させるために必要なことは、flag_を適切に修飾することだけです。

class Gadget {
public:
    ... as above ...
private:
    volatile bool flag_;
};

Volatileの原理と使用法のほとんどの説明はここで終わり、複数のスレッドで使用するプリミティブ型をvolatile修飾することをお勧めします。ただし、volatileはC++の素晴らしい型システムの一部であるため、volatileでできることははるかに多くあります。

ユーザー定義型でvolatileを使用する

プリミティブ型だけでなく、ユーザー定義型もvolatile修飾できます。その場合、volatileはconstと同様の方法で型を変更します。 (constとvolatileを同じ型に同時に適用することもできます。)

Constとは異なり、volatileはプリミティブ型とユーザー定義型を区別します。つまり、クラスとは異なり、プリミティブ型は、volatile修飾されている場合、すべての操作(加算、乗算、割り当てなど)を引き続きサポートします。たとえば、不揮発性intを揮発性intに割り当てることはできますが、不揮発性オブジェクトを揮発性オブジェクトに割り当てることはできません。

例でユーザー定義型でvolatileがどのように機能するかを説明しましょう。

class Gadget {
public:
    void Foo() volatile;
    void Bar();
    ...
private:
    String name_;
    int state_;
};
...
Gadget regularGadget;
volatile Gadget volatileGadget;

Volatileがオブジェクトに対してそれほど役に立たないと思うなら、驚きに備えてください。

volatileGadget.Foo(); // ok, volatile fun called for
                  // volatile object
regularGadget.Foo();  // ok, volatile fun called for
                  // non-volatile object
volatileGadget.Bar(); // error! Non-volatile function called for
                  // volatile object!

非修飾型からその揮発性の対応する型への変換は簡単です。ただし、constと同様に、volatileから非修飾に戻ることはできません。キャストを使用する必要があります。

Gadget& ref = const_cast<Gadget&>(volatileGadget);
ref.Bar(); // ok

Volatileで修飾されたクラスは、そのインターフェイスのサブセット(クラスの実装者の制御下にあるサブセット)のみにアクセスできます。ユーザーは、const_castを使用することによってのみ、そのタイプのインターフェースにフルアクセスできます。さらに、constnessと同様に、volatilenessはクラスからそのメンバーに伝播します(たとえば、volatileGadget.name_とvolatileGadget.state_はvolatile変数です)。

volatile、クリティカルセクション、および競合状態

マルチスレッドプログラムで最も単純で最も頻繁に使用される同期デバイスは、ミューテックスです。ミューテックスは、取得および解放プリミティブを公開します。あるスレッドでAcquireを呼び出すと、Acquireを呼び出す他のスレッドはブロックされます。後で、そのスレッドがReleaseを呼び出すと、Acquire呼び出しでブロックされた1つのスレッドが解放されます。つまり、特定のミューテックスに対して、Acquireの呼び出しとReleaseの呼び出しの間にプロセッサ時間を取得できるスレッドは1つだけです。 Acquireの呼び出しとReleaseの呼び出しの間の実行コードは、クリティカルセクションと呼ばれます。 (Windowsの用語は、ミューテックス自体をクリティカルセクションと呼び、「ミューテックス」は実際にはプロセス間ミューテックスであるため、少し紛らわしいです。スレッドミューテックスとプロセスミューテックスと呼ばれていれば良かったでしょう。)

ミューテックスは、競合状態からデータを保護するために使用されます。定義上、データに対するスレッドの増加がスレッドのスケジュール方法に依存する場合、競合状態が発生します。競合状態は、2つ以上のスレッドが同じデータを使用して競合する場合に表示されます。スレッドは任意の瞬間に相互に割り込むことができるため、データが破損したり、誤って解釈されたりする可能性があります。そのため、変更やデータへのアクセスは、クリティカルセクションで慎重に保護する必要があります。オブジェクト指向プログラミングでは、これは通常、ミューテックスをメンバー変数としてクラスに格納し、そのクラスの状態にアクセスするたびに使用することを意味します。

経験豊富なマルチスレッドプログラマは、上記の2つの段落を読んであくびをしたかもしれませんが、彼らの目的は、揮発性の接続とリンクするため、知的トレーニングを提供することです。これを行うには、C++型の世界とスレッド化セマンティクスの世界の類似点を描きます。

  • クリティカルセクションの外では、スレッドはいつでも他のスレッドを中断する可能性があります。コントロールがないため、複数のスレッドからアクセス可能な変数は揮発性です。これは、volatileの元々の意図、つまり、コンパイラーが複数のスレッドが使用する値を意図せずに一度にキャッシュしないようにするという意図に沿ったものです。
  • ミューテックスによって定義されたクリティカルセクション内では、1つのスレッドのみがアクセスできます。したがって、クリティカルセクション内では、実行中のコードはシングルスレッドのセマンティクスを持ちます。制御変数はもはや揮発性ではありません—揮発性修飾子を削除できます。

要するに、スレッド間で共有されるデータは、概念的にはクリティカルセクションの外側では揮発性であり、クリティカルセクションの内側では不揮発性です。

ミューテックスをロックすることにより、クリティカルセクションに入ります。 const_castを適用して、タイプからvolatile修飾子を削除します。これら2つの操作をまとめると、C++の型システムとアプリケーションのスレッドセマンティクスの間に接続が作成されます。コンパイラに競合状態をチェックさせることができます。

LockingPtr

Mutexの取得とconst_castを収集するツールが必要です。 volatileオブジェクトobjとmutex mtxで初期化するLockingPtrクラステンプレートを開発しましょう。 LockingPtrは、その存続期間中、mtxを取得し続けます。また、LockingPtrはvolatileストリップされたobjへのアクセスを提供します。アクセスは、operator->およびoperator *を介して、スマートポインター形式で提供されます。 const_castはLockingPtr内で実行されます。 LockingPtrは、その存続期間中に取得されたミューテックスを保持するため、キャストは意味的に有効です。

まず、LockingPtrが機能するMutexクラスのスケルトンを定義しましょう。

class Mutex {
public:
    void Acquire();
    void Release();
    ...    
};

LockingPtrを使用するには、オペレーティングシステムのネイティブデータ構造とプリミティブ関数を使用してMutexを実装します。

LockingPtrは、制御変数のタイプを使用してテンプレート化されます。たとえば、ウィジェットを制御する場合、タイプがvolatile Widgetの変数で初期化するLockingPtrを使用します。

LockingPtrの定義は非常に簡単です。 LockingPtrは、洗練されていないスマートポインターを実装します。 const_castとクリティカルセクションの収集のみに焦点を当てています。

template <typename T>
class LockingPtr {
public:
    // Constructors/destructors
    LockingPtr(volatile T& obj, Mutex& mtx)
      : pObj_(const_cast<T*>(&obj)), pMtx_(&mtx) {    
        mtx.Lock();    
    }
    ~LockingPtr() {    
        pMtx_->Unlock();    
    }
    // Pointer behavior
    T& operator*() {    
        return *pObj_;    
    }
    T* operator->() {   
        return pObj_;   
    }
private:
    T* pObj_;
    Mutex* pMtx_;
    LockingPtr(const LockingPtr&);
    LockingPtr& operator=(const LockingPtr&);
};

LockingPtrは、単純であるにもかかわらず、正しいマルチスレッドコードを記述するのに非常に役立ちます。スレッド間で共有されるオブジェクトをvolatileとして定義し、const_castを使用しないでください。常にLockingPtr自動オブジェクトを使用してください。これを例で説明しましょう。

ベクトルオブジェクトを共有する2つのスレッドがあるとします。

class SyncBuf {
public:
    void Thread1();
    void Thread2();
private:
    typedef vector<char> BufT;
    volatile BufT buffer_;
    Mutex mtx_; // controls access to buffer_
};

スレッド関数内では、LockingPtrを使用して、buffer_メンバー変数への制御されたアクセスを取得します。

void SyncBuf::Thread1() {
    LockingPtr<BufT> lpBuf(buffer_, mtx_);
    BufT::iterator i = lpBuf->begin();
    for (; i != lpBuf->end(); ++i) {
        ... use *i ...
    }
}

コードの記述と理解は非常に簡単です。buffer_を使用する必要があるときはいつでも、それを指すLockingPtrを作成する必要があります。これを行うと、ベクターのインターフェイス全体にアクセスできます。

良い点は、間違えた場合、コンパイラがそれを指摘することです:

void SyncBuf::Thread2() {
    // Error! Cannot access 'begin' for a volatile object
    BufT::iterator i = buffer_.begin();
    // Error! Cannot access 'end' for a volatile object
    for ( ; i != lpBuf->end(); ++i ) {
        ... use *i ...
    }
}

Const_castを適用するか、LockingPtrを使用するまで、buffer_の関数にアクセスできません。違いは、LockingPtrがconst_castをvolatile変数に適用する順序付けられた方法を提供することです。

LockingPtrは非常に表現力豊かです。関数を1つだけ呼び出す必要がある場合は、名前のない一時的なLockingPtrオブジェクトを作成して直接使用できます。

unsigned int SyncBuf::Size() {
return LockingPtr<BufT>(buffer_, mtx_)->size();
}

プリミティブ型に戻る

揮発性の高いオブジェクトが制御されていないアクセスからオブジェクトを保護する方法と、LockingPtrがスレッドセーフコードを記述する簡単で効果的な方法を提供する方法を確認しました。プリミティブ型に戻りましょう。プリミティブ型はvolatileによって異なる方法で処理されます。

複数のスレッドがint型の変数を共有する例を考えてみましょう。

class Counter {
public:
    ...
    void Increment() { ++ctr_; }
    void Decrement() { —ctr_; }
private:
    int ctr_;
};

IncrementとDecrementを異なるスレッドから呼び出す場合、上記のフラグメントはバグです。まず、ctr_はvolatileでなければなりません。第二に、++ ctr_のような一見アトミックな操作でさえ、実際には3段階の操作です。メモリ自体には演算機能はありません。変数をインクリメントするとき、プロセッサは次のことを行います。

  • レジスタ内のその変数を読み取ります
  • レジスタの値をインクリメントします
  • 結果をメモリに書き戻します

この3段階の操作は、RMW(Read-Modify-Write)と呼ばれます。 RMW操作の変更部分では、ほとんどのプロセッサがメモリバスを解放して、他のプロセッサがメモリにアクセスできるようにします。

その時点で別のプロセッサが同じ変数に対してRMW操作を実行すると、競合状態になります。2番目の書き込みが最初の書き込みの効果を上書きします。

それを避けるために、再びLockingPtrに頼ることができます:

class Counter {
public:
    ...
    void Increment() { ++*LockingPtr<int>(ctr_, mtx_); }
    void Decrement() { —*LockingPtr<int>(ctr_, mtx_); }
private:
    volatile int ctr_;
    Mutex mtx_;
};

これでコードは正しくなりましたが、SyncBufのコードと比較すると品質は劣っています。どうして? Counterを使用すると、誤ってctr_に(ロックせずに)誤って直接アクセスしても、コンパイラは警告を表示しません。 ctr_が揮発性の場合、コンパイラーは++ ctr_をコンパイルしますが、生成されたコードは単純に正しくありません。コンパイラはもはやあなたの味方ではなく、注意を払うだけで競合状態を回避できます。

それならどうしますか?上位レベルの構造で使用するプリミティブデータを単純にカプセル化し、それらの構造でvolatileを使用します。逆説的に、最初はこれが揮発性の使用目的であったという事実にもかかわらず、組み込みでvolatileを直接使用するのは悪いことです!

volatileメンバー関数

これまで、揮発性データメンバーを集約するクラスがありました。次に、クラスを設計して、さらに大きなオブジェクトの一部となり、スレッド間で共有することを考えてみましょう。ここで、揮発性メンバー関数が非常に役立ちます。

クラスを設計するとき、スレッドセーフなメンバー関数のみをvolatile修飾します。外部からのコードはいつでも任意のコードからvolatile関数を呼び出すと想定する必要があります。忘れないでください:volatileは無料のマルチスレッドコードに等しく、クリティカルセクションはありません。不揮発性は、シングルスレッドシナリオまたはクリティカルセクション内に相当します。

たとえば、スレッドセーフと高速で保護されていない2つのバリアントで操作を実装するクラスウィジェットを定義します。

class Widget {
public:
    void Operation() volatile;
    void Operation();
    ...
private:
    Mutex mtx_;
};

オーバーロードの使用に注意してください。これで、ウィジェットのユーザーは、揮発性オブジェクトに対してスレッドの安全性を確保するため、または通常のオブジェクトに対して速度を確保するために、統一された構文を使用してオペレーションを呼び出すことができます。ユーザーは、共有Widgetオブジェクトをvolatileとして定義することに注意する必要があります。

揮発性メンバー関数を実装する場合、通常、最初の操作はこれをLockingPtrでロックすることです。次に、不揮発性の兄弟を使用して作業が行われます。

void Widget::Operation() volatile {
    LockingPtr<Widget> lpThis(*this, mtx_);
    lpThis->Operation(); // invokes the non-volatile function
}

概要

マルチスレッドプログラムを作成するときは、volatileを使用して利点を得ることができます。次の規則に従う必要があります。

  • すべての共有オブジェクトをvolatileとして定義します。
  • プリミティブ型でvolatileを直接使用しないでください。
  • 共有クラスを定義するときは、揮発性メンバー関数を使用してスレッドの安全性を表現します。

これを行う場合、および単純な汎用コンポーネントLockingPtrを使用する場合、スレッドセーフコードを記述し、競合状態についてあまり心配する必要はありません。コンパイラがあなたを心配し、あなたが間違っている箇所を熱心に指摘するからです。

私が携わったいくつかのプロジェクトでは、volatileとLockingPtrを使用して非常に効果的です。コードは簡潔で理解しやすいものです。デッドロックをいくつか思い出しますが、デバッグが非常に簡単なので、競合状態よりもデッドロックを好みます。競合状態に関連する問題はほとんどありませんでした。しかし、あなたは決して知りません。

謝辞

洞察に満ちたアイデアを手伝ってくれたジェームズ・カンゼとソリン・ジアンに感謝します。


Andrei Alexandrescuは、ワシントン州シアトルに本拠を置くRealNetworks Inc.(www.realnetworks.com)の開発マネージャーであり、著名な書籍Modern C++ Designの著者です。彼はwww.moderncppdesign.comで連絡を取ることができます。 Andreiは、C++セミナー(www.gotw.ca/cpp_seminar)の主任講師の一人でもあります。

この記事は少し古くなっているかもしれませんが、マルチスレッドプログラミングの使用でvolatile修飾子を使用して、コンパイラが競合状態をチェックしている間にイベントを非同期に保つのに役立つ優れた使用法についての良い洞察を提供します。これは、メモリフェンスの作成に関するOPの元の質問に直接答えない場合がありますが、マルチスレッドアプリケーションで作業するときのvolatileの適切な使用に関する優れたリファレンスとして、他の人への回答として投稿することを選択します。

0
Francis Cugler