volatile:マルチスレッドプログラマーの親友
アンドレイ・アレキサンドレス、2001年2月1日
volatileキーワードは、特定の非同期イベントが存在する場合にコードが正しくなくなる可能性のあるコンパイラーの最適化を防ぐために考案されました。
私はあなたの気分を台無しにしたくありませんが、このコラムはマルチスレッドプログラミングの恐ろしいトピックに対処します。Genericの前回の記事で述べたように、例外安全プログラミングが難しい場合は、マルチスレッドプログラミングと比較して子供の遊びです。
複数のスレッドを使用するプログラムは、一般に、作成、正しいことの証明、デバッグ、保守、および飼いならしが難しいことで有名です。誤ったマルチスレッドプログラムは、グリッチなしで何年も実行される可能性がありますが、いくつかの重要なタイミング条件が満たされたために予期せず実行されます。
言うまでもなく、マルチスレッドコードを作成するプログラマーは、彼女が得ることができるすべての助けを必要としています。このコラムでは、マルチスレッドプログラムの一般的な問題の原因である競合状態に焦点を当て、それらを回避する方法に関する洞察とツールを提供します。驚くべきことに、コンパイラーはそれを支援するために一生懸命働きます。
ほんの少しのキーワード
C標準とC ++標準はどちらもスレッドに関しては目立って沈黙していますが、volatileキーワードの形で、マルチスレッドに少し譲歩します。
よく知られている対応するconstと同様に、volatileは型修飾子です。これは、さまざまなスレッドでアクセスおよび変更される変数と組み合わせて使用することを目的としています。基本的に、揮発性がないと、マルチスレッドプログラムの作成が不可能になるか、コンパイラが膨大な最適化の機会を浪費します。説明が整いました。
次のコードについて考えてみます。
class Gadget {
public:
void Wait() {
while (!flag_) {
Sleep(1000);
}
}
void Wakeup() {
flag_ = true;
}
...
private:
bool flag_;
};
上記のGadget :: Waitの目的は、flag_メンバー変数を毎秒チェックし、その変数が別のスレッドによってtrueに設定されたときに戻ることです。少なくともそれはプログラマーが意図したことですが、残念ながら、Waitは正しくありません。
Suppose the compiler figures out that Sleep(1000) is a call into an external library that cannot possibly modify the member variable flag_. Then the compiler concludes that it can cache flag_ in a register and use that register instead of accessing the slower on-board memory. This is an excellent optimization for single-threaded code, but in this case, it harms correctness: after you call Wait for some Gadget object, although another thread calls Wakeup, Wait will loop forever. This is because the change of flag_ will not be reflected in the register that caches flag_. The optimization is too ... optimistic.
レジスターに変数をキャッシュすることは、ほとんどの場合に適用される非常に価値のある最適化であるため、それを無駄にするのは残念です。CおよびC ++を使用すると、このようなキャッシュを明示的に無効にすることができます。変数にvolatile修飾子を使用すると、コンパイラはその変数をレジスタにキャッシュしません。アクセスするたびに、その変数の実際のメモリ位置に到達します。したがって、ガジェットの待機/ウェイクアップコンボを機能させるために必要なのは、flag_を適切に修飾することだけです。
class Gadget {
public:
... as above ...
private:
volatile bool flag_;
};
volatileの理論的根拠と使用法のほとんどの説明はここで止まり、複数のスレッドで使用するプリミティブ型をvolatile修飾することをお勧めします。ただし、volatileは、C ++のすばらしい型システムの一部であるため、さらに多くのことができます。
ユーザー定義タイプでのvolatileの使用
プリミティブ型だけでなく、ユーザー定義型も揮発性修飾できます。その場合、volatileはconstと同様の方法で型を変更します。(constとvolatileを同じタイプに同時に適用することもできます。)
constとは異なり、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();
regularGadget.Foo();
volatileGadget.Bar();
修飾されていない型からその揮発性の対応する型への変換は簡単です。ただし、constの場合と同様に、揮発性から非修飾に戻ることはできません。キャストを使用する必要があります:
Gadget& ref = const_cast<Gadget&>(volatileGadget);
ref.Bar();
揮発性修飾クラスは、そのインターフェースのサブセット、つまりクラス実装者の制御下にあるサブセットへのアクセスのみを許可します。ユーザーは、const_castを使用することによってのみ、そのタイプのインターフェースへのフルアクセスを取得できます。さらに、constnessと同様に、volatilenessはクラスからそのメンバーに伝播します(たとえば、volatileGadget.name_とvolatileGadget.state_はvolatile変数です)。
揮発性、クリティカルセクション、および競合状態
マルチスレッドプログラムで最も単純で最も頻繁に使用される同期デバイスはミューテックスです。ミューテックスは、取得プリミティブと解放プリミティブを公開します。あるスレッドでAcquireを呼び出すと、Acquireを呼び出す他のスレッドはブロックされます。後で、そのスレッドがReleaseを呼び出すと、Acquire呼び出しでブロックされたスレッドが1つだけ解放されます。つまり、特定のミューテックスに対して、Acquireの呼び出しとReleaseの呼び出しの間にプロセッサ時間を取得できるのは1つのスレッドだけです。Acquireの呼び出しとReleaseの呼び出しの間に実行されるコードは、クリティカルセクションと呼ばれます。(Windowsの用語は、ミューテックス自体をクリティカルセクションと呼ぶため、少し混乱しますが、「ミューテックス」は実際にはプロセス間ミューテックスです。スレッドミューテックスおよびプロセスミューテックスと呼ばれると便利です。)
ミューテックスは、競合状態からデータを保護するために使用されます。定義上、競合状態は、データに対するスレッドの増加の影響がスレッドのスケジュール方法に依存する場合に発生します。競合状態は、2つ以上のスレッドが同じデータの使用をめぐって競合する場合に発生します。スレッドは任意の時点で相互に割り込む可能性があるため、データが破損したり、誤って解釈されたりする可能性があります。したがって、データへの変更や場合によってはアクセスは、クリティカルセクションで慎重に保護する必要があります。オブジェクト指向プログラミングでは、これは通常、ミューテックスをメンバー変数としてクラスに格納し、そのクラスの状態にアクセスするたびにそれを使用することを意味します。
経験豊富なマルチスレッドプログラマーは、上記の2つの段落を読むことを諦めたかもしれませんが、揮発性の接続にリンクするため、彼らの目的は知的トレーニングを提供することです。これを行うには、C ++タイプの世界とスレッドセマンティクスの世界の間に類似点を描きます。
- クリティカルセクションの外では、スレッドはいつでも他のスレッドを中断する可能性があります。制御がないため、複数のスレッドからアクセスできる変数は揮発性です。これは、揮発性の本来の目的、つまりコンパイラーが複数のスレッドによって使用される値を一度に無意識にキャッシュすることを防ぐという意図と一致しています。
- ミューテックスによって定義されたクリティカルセクション内では、1つのスレッドのみがアクセスできます。その結果、クリティカルセクション内では、実行中のコードはシングルスレッドのセマンティクスを持ちます。制御変数は揮発性ではなくなりました—揮発性修飾子を削除できます。
つまり、スレッド間で共有されるデータは、概念的にはクリティカルセクションの外側では揮発性であり、クリティカルセクションの内側では不揮発性です。
ミューテックスをロックしてクリティカルセクションに入ります。const_castを適用して、型から揮発性修飾子を削除します。これらの2つの操作を組み合わせることができれば、C ++の型システムとアプリケーションのスレッドセマンティクスの間に接続を作成できます。コンパイラに競合状態をチェックさせることができます。
LockingPtr
ミューテックス取得とconst_castを収集するツールが必要です。揮発性オブジェクトobjとミューテックスmtxで初期化するLockingPtrクラステンプレートを開発しましょう。LockingPtrは、その存続期間中、mtxを取得し続けます。また、LockingPtrは、揮発性ストリップされたobjへのアクセスを提供します。アクセスは、operator->およびoperator *を介してスマートポインター方式で提供されます。const_castはLockingPtr内で実行されます。LockingPtrは、取得したミューテックスをその存続期間中保持するため、キャストは意味的に有効です。
まず、LockingPtrが機能するクラスMutexのスケルトンを定義しましょう。
class Mutex {
public:
void Acquire();
void Release();
...
};
LockingPtrを使用するには、オペレーティングシステムのネイティブデータ構造とプリミティブ関数を使用してミューテックスを実装します。
LockingPtrは、制御変数のタイプでテンプレート化されています。たとえば、ウィジェットを制御する場合は、volatileWidgetタイプの変数で初期化するLockingPtrを使用します。
LockingPtrの定義は非常に単純です。LockingPtrは、洗練されていないスマートポインターを実装します。const_castとクリティカルセクションの収集にのみ焦点を当てています。
template <typename T>
class LockingPtr {
public:
LockingPtr(volatile T& obj, Mutex& mtx)
: pObj_(const_cast<T*>(&obj)), pMtx_(&mtx) {
mtx.Lock();
}
~LockingPtr() {
pMtx_->Unlock();
}
T& operator*() {
return *pObj_;
}
T* operator->() {
return pObj_;
}
private:
T* pObj_;
Mutex* pMtx_;
LockingPtr(const LockingPtr&);
LockingPtr& operator=(const LockingPtr&);
};
LockingPtrは、その単純さにもかかわらず、正しいマルチスレッドコードを作成するのに非常に役立ちます。スレッド間で共有されるオブジェクトを揮発性として定義し、const_castを使用しないでください。常にLockingPtr自動オブジェクトを使用してください。これを例で説明しましょう。
ベクトルオブジェクトを共有する2つのスレッドがあるとします。
class SyncBuf {
public:
void Thread1();
void Thread2();
private:
typedef vector<char> BufT;
volatile BufT buffer_;
Mutex mtx_;
};
スレッド関数内では、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() {
BufT::iterator i = buffer_.begin();
for ( ; i != lpBuf->end(); ++i ) {
... use *i ...
}
}
const_castを適用するか、LockingPtrを使用するまで、buffer_の関数にアクセスすることはできません。違いは、LockingPtrがconst_castを揮発性変数に適用する順序付けられた方法を提供することです。
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_;
};
インクリメントとデクリメントが異なるスレッドから呼び出される場合、上記のフラグメントはバグがあります。まず、ctr_は揮発性でなければなりません。第二に、++ 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は無料のマルチスレッドコードに等しく、クリティカルセクションはありません。不揮発性は、シングルスレッドシナリオまたはクリティカルセクション内に相当します。
たとえば、スレッドセーフなものと高速で保護されていないものの2つのバリアントで操作を実装するクラスウィジェットを定義します。
class Widget {
public:
void Operation() volatile;
void Operation();
...
private:
Mutex mtx_;
};
オーバーロードの使用に注意してください。これで、ウィジェットのユーザーは、揮発性オブジェクトに対してスレッドセーフを取得するか、通常のオブジェクトに対して速度を取得するために、統一された構文を使用して操作を呼び出すことができます。ユーザーは、共有ウィジェットオブジェクトを揮発性として定義することに注意する必要があります。
揮発性メンバー関数を実装する場合、最初の操作は通常、これをLockingPtrでロックすることです。次に、非揮発性の兄弟を使用して作業を行います。
void Widget::Operation() volatile {
LockingPtr<Widget> lpThis(*this, mtx_);
lpThis->Operation();
}
概要
マルチスレッドプログラムを作成するときは、volatileを有利に使用できます。次のルールに従う必要があります。
- すべての共有オブジェクトを揮発性として定義します。
- プリミティブ型で直接volatileを使用しないでください。
- 共有クラスを定義するときは、揮発性メンバー関数を使用してスレッドセーフを表現します。
これを実行し、単純な汎用コンポーネントLockingPtrを使用すると、スレッドセーフなコードを記述でき、競合状態について心配する必要がなくなります。コンパイラーが心配し、間違っている箇所を熱心に指摘するからです。
私が関わったいくつかのプロジェクトでは、volatileとLockingPtrを使用して大きな効果を上げています。コードはクリーンで理解しやすいです。いくつかのデッドロックを思い出しますが、デバッグが非常に簡単なため、競合状態よりもデッドロックの方が好きです。競合状態に関連する問題は事実上ありませんでした。しかし、あなたは決して知りません。
謝辞
洞察に満ちたアイデアを手伝ってくれたJamesKanzeとSorinJianuに感謝します。
Andrei Alexandrescuは、ワシントン州シアトルを拠点とするRealNetworks Inc.(www.realnetworks.com)の開発マネージャーであり、高く評価されている本 『Modern C ++ Design』の著者です。彼はwww.moderncppdesign.comで連絡されるかもしれません。Andreiは、C ++セミナー(www.gotw.ca/cpp_seminar)の注目のインストラクターの1人でもあります。
この記事は少し古いかもしれませんが、コンパイラーに競合状態をチェックさせながらイベントを非同期に保つのに役立つマルチスレッドプログラミングの使用でvolatile修飾子を使用する優れた使用法についての良い洞察を提供します。これは、メモリフェンスの作成に関するOPの元の質問に直接答えることはできないかもしれませんが、マルチスレッドアプリケーションで作業するときのvolatileの適切な使用に関する優れたリファレンスとして、他の人への回答としてこれを投稿することにしました。