面接でこの質問をされたのですが、うまく答えられませんでした。
具体的には、代入演算子が属するクラスは次のようになります。
class A {
private:
B* pb;
C* pc;
....
public:
....
}
このクラスにアトミック(スレッドセーフ)および例外セーフのディープコピー代入演算子を実装するにはどうすればよいですか?
面接でこの質問をされたのですが、うまく答えられませんでした。
具体的には、代入演算子が属するクラスは次のようになります。
class A {
private:
B* pb;
C* pc;
....
public:
....
}
このクラスにアトミック(スレッドセーフ)および例外セーフのディープコピー代入演算子を実装するにはどうすればよいですか?
2つの別々の懸念(スレッドセーフと例外安全)があり、それらを別々に扱うのが最善のようです。メンバーの初期化中に別のオブジェクトを引数として取得するコンストラクターがロックを取得できるようにするには、とにかくデータメンバーを別のクラスに因数分解する必要があります。このようにして、サブオブジェクトが初期化され、クラスが実際のデータを維持している間にロックを取得できます。同時実行の問題は無視できます。したがって、クラスは2つの部分に分割されます。class A
並行性の問題に対処するためとclass A_unlocked
、データを維持するためです。のメンバー関数にA_unlocked
は同時実行性の保護がないため、インターフェイスで直接公開しないでください。したがって、A_unlocked
のプライベートメンバーになりA
ます。
コピーコンストラクターを利用して、例外セーフな代入演算子を作成するのは簡単です。引数がコピーされ、メンバーが交換されます。
A_unlocked& A_unlocked::operator= (A_unlocked const& other) {
A_unlocked(other).swap(*this);
return *this;
}
もちろん、これは適切なコピーコンストラクターとswap()
メンバーが実装されていることを意味します。複数のリソースの割り当て、たとえばヒープに割り当てられた複数のオブジェクトの処理は、オブジェクトごとに適切なリソースハンドラーを用意することで最も簡単に実行できます。リソースハンドラーを使用しないと、例外がスローされた場合にすべてのリソースを正しくクリーンアップすることがすぐに非常に面倒になります。ヒープに割り当てられたメモリを維持するためにstd::unique_ptr<T>
(またはstd::auto_ptr<T>
C ++ 2011を使用できない場合)、適切な選択です。以下のコードは、オブジェクトをメンバーにするのではなく、ヒープ上にオブジェクトを割り当てることにはあまり意味がありませんが、ポイントされたオブジェクトをコピーするだけです。実際の例では、オブジェクトはおそらくclone()
、正しいタイプのオブジェクトを作成するためのメソッドまたはその他のメカニズムを実装します。
class A_unlocked {
private:
std::unique_ptr<B> pb;
std::unique_ptr<C> pc;
// ...
public:
A_unlocked(/*...*/);
A_unlocked(A_unlocked const& other);
A_unlocked& operator= (A_unlocked const& other);
void swap(A_unlocked& other);
// ...
};
A_unlocked::A_unlocked(A_unlocked const& other)
: pb(new B(*other.pb))
, pc(new C(*other.pc))
{
}
void A_unlocked::swap(A_unlocked& other) {
using std::swap;
swap(this->pb, other.pb);
swap(this->pc, other.pc);
}
スレッドセーフビットについては、コピーされたオブジェクトを他のスレッドが混乱させていないことを知っておく必要があります。これを行う方法は、ミューテックスを使用することです。つまり、class A
次のようになります。
class A {
private:
mutable std::mutex d_mutex;
A_unlocked d_data;
public:
A(/*...*/);
A(A const& other);
A& operator= (A const& other);
// ...
};
タイプのオブジェクトが外部ロックなしで使用されることを意図している場合は、のすべてのメンバーがA
並行性保護を行う必要があることに注意してください。A
同時アクセスを防ぐために使用されるミューテックスは、実際にはオブジェクトの状態の一部ではありませんが、オブジェクトの状態を読み取る場合でも変更する必要があるため、作成されmutable
ます。これが適切な場所にあると、コピーコンストラクターの作成は簡単です。
A::A(A const& other)
: d_data((std::unique_lock<std::mutex>(other.d_mutex), other.d_data)) {
}
これにより、引数のミューテックスがロックされ、メンバーのコピーコンストラクターに委任されます。コピーが成功したか例外をスローしたかに関係なく、式の最後でロックが自動的に解放されます。構築中のオブジェクトは、別のスレッドがこのオブジェクトについて知る方法がまだないため、ロックする必要はありません。
代入演算子のコアロジックも、代入演算子を使用してベースに委任するだけです。トリッキーな点は、ロックする必要のあるミューテックスが2つあることです。1つは割り当てられているオブジェクト用で、もう1つは引数用です。別のスレッドが2つのオブジェクトをまったく逆の方法で割り当てる可能性があるため、デッドロックが発生する可能性があります。便利なことに、標準C ++ライブラリは、std::lock()
デッドロックを回避する適切な方法でロックを取得するアルゴリズムを提供します。このアルゴリズムを使用する1つの方法は、std::unique_lock<std::mutex>
取得する必要のあるミューテックスごとに1つずつ、ロック解除されたオブジェクトを渡すことです。
A& A::operator= (A const& other) {
if (this != &other) {
std::unique_lock<std::mutex> guard_this(this->d_mutex, std::defer_lock);
std::unique_lock<std::mutex> guard_other(other.d_mutex, std::defer_lock);
std::lock(guard_this, guard_other);
*this->d_data = other.d_data;
}
return *this;
}
割り当て中のいずれかの時点で例外がスローされた場合、ロックガードはミューテックスを解放し、リソースハンドラーは新しく割り当てられたリソースを解放します。したがって、上記のアプローチは強力な例外保証を実装します。興味深いことに、コピー割り当てでは、同じミューテックスが2回ロックされないように、自己割り当てチェックを実行する必要があります。通常、必要な自己代入チェックは、代入演算子が例外安全ではないことを示していると私は主張しますが、上記のコードは例外安全だと思います。
これは答えの主要な書き直しです。この回答の以前のバージョンでは、更新が失われるか、デッドロックが発生する傾向がありました。問題を指摘してくれたYakkに感謝します。問題に対処した結果、より多くのコードが必要になりますが、コードの個々の部分は実際には単純であり、正確性を調査できると思います。
まず、スレッドセーフな操作はないことを理解する必要がありますが、特定のリソースに対するすべての操作は相互にスレッドセーフになる可能性があります。したがって、非代入演算子コードの動作について説明する必要があります。
最も簡単な解決策は、データを不変にし、pImplクラスを使用してAをカウントする不変の参照を格納するArefクラスを記述し、Arefの変更メソッドで新しいAを作成することです。Aの不変の参照カウントコンポーネント(BやCなど)を同様のパターンに従うことで、粒度を実現できます。基本的に、ArefはAのCOW(コピーオンライト)pImplラッパーになります(冗長コピーをなくすために、単一参照の場合を処理するための最適化を含めることができます)。
2番目のルートは、Aとそのすべてのデータにモノリシックロック(ミューテックスまたはリーダーライター)を作成することです。その場合、競合のない演算子=を作成するために、Aのインスタンスのロックでミューテックスの順序付け(または同様の手法)が必要になるか、おそらく驚くべき競合状態の可能性を受け入れて、前述のコピースワップイディオムDietmarを実行します。(Copy-moveも受け入れられます)(lock-copyconstruct、lock-swap割り当て演算子の明示的な競合状態=:Thread1はX=Yを実行します。Thread2はY.flag=true、X.flag =trueを実行します。その後の状態:X .flagはfalseです。Thread2が割り当て全体でXとYの両方をロックしている場合でも、これが発生する可能性があります。これは多くのプログラマーを驚かせるでしょう。)
最初のケースでは、非割り当てコードはコピーオンライトのセマンティクスに従わなければなりません。2番目のケースでは、非割り当てコードはモノリシックロックに従う必要があります。
例外安全性に関しては、コピーコンストラクターが例外安全であると想定する場合、ロックコードと同様に、lock-copy-lock-swap 1(2番目)は例外安全です。最初の例では、参照カウント、クローンのロック、およびデータ変更コードが例外安全である限り、問題はありません。いずれの場合も、operator=コードはかなり脳死しています。(ロックがRAIIであることを確認し、割り当てられたすべてのメモリをstd RAIIポインタホルダーに保存します(最終的に手渡しした場合に解放する機能を備えています)など)。
例外安全?プリミティブの操作はスローされないため、無料で入手できます。
アトミック?最も単純なのは2xのアトミックスワップですsizeof(void*)
-ほとんどのプラットフォームがこれを提供していると思います。そうでない場合は、ロックを使用するか、機能するロックレスアルゴリズムを使用する必要があります。
編集:ディープコピーですね AとBを新しい一時的なスマートポインターにコピーしてから、それらをアトミックに交換する必要があります。