共有ポインターとマルチスレッド。 そして再び彼らについて、もう一度

「Modern C ++ Programming」の章は、「スマートポインターについて百と初めて」と呼ばれています。 すべては問題ありませんが、この本は2001年に出版されたので、このトピックに戻ることはもう一度価値がありますか? ちょうど今だと思います。 これらの15年の間に、視点そのもの、つまり問題を見る角度が変わりました。 当時、最初の事実上の標準実装であるboost :: shared_ptr <>がリリースされたばかりで、その前に誰もが必要に応じて実装を書き、少なくともコードの詳細、長所、短所を想像していました。 当時のC ++に関するすべての本は、必然的にスマートポインターのバリエーションの1つを非常に詳細に説明していました。







今では標準が与えられており、それは良いことです。 しかし一方で、中身を理解する必要はなくなり、代わりに「通常のポインターを使用する場所ならどこでもスマートポインターを使用する」というマントラを3回繰り返すだけで十分です 。これはあまり良くありません。 さまざまなベンダーの実装の違いは言うまでもなく、この標準が可能なインターフェイスオプションの1つにすぎないことを誰もが認識しているとは思わない。 標準を選択する際に、さまざまな要因を考慮してさまざまな可能性の中から選択が行われましたが、最適かどうかにかかわらず、この選択が明らかに唯一のものではありません。







たとえば、stackoverflowでは、「標準ライブラリのスマートポインターはスレッドセーフですか?」という質問が何度も聞かれます。 答えは通常カテゴリに分類されますが、あまり有益ではないものもあります。 たとえば、何が危機にatしているのかわからなければ、おそらく理解できなかっただろう。 ちなみに、新しいC ++標準について説明している比較的新しい本も、この問題にほとんど注意を払っていません。







それでは、カバーをはがして詳細を把握してみましょう。







すぐに用語を定義します。これは、ポインターが参照するデータを保護することではなく、任意の複雑さのオブジェクトである可能性があり、一般的にマルチスレッドアクセスには個別の同期が必要です。 スマートポインターのスレッドセーフとは、データポインター自体のセキュリティと内部参照カウンターの有効性を意味します。 比Fig的に言えば、ポインタがstd :: make_shared <>()を介して作成された場合、割り当て、関数または他のフローへの転送、スワップ、破壊は無効な状態になりません。 reset()またはget()を呼び出すまでは、ポインターが何らかの有効なオブジェクトを参照していることを期待する権利がありますが、必ずしも意味するものではありません。







タイトルの質問に対する一般的な答えの1つは、スレッドセーフであるのは制御ブロック自体だけです。 」です。 それで、具体的にコントロールブロックセーフが何を意味するのかを見ていきます。

実験には、 g ++-5.4.0を使用しました







例から始めましょう。



構造体にパックされ、ポインタを介してアクセス可能な共有メモリに情報があります。 原則として、アクセス速度が重要であることが判明しているため、このデータを変更せずに読み取って使用する必要がある1つ以上の独立したストリームがあります。 同時に、整合性に違反してこのデータを変更する1つまたは複数のストリームがある場合でも、実際には通常、変更の頻度ははるかに低く、アクセス速度はそれほど重要ではありません。 それでも、従来の( 排他ロック )同期のフレームワーク内にとどまるため、データの変更が発生していなくても読み取りアクセスのシリアル化が強制されます。 当然、これは効率の最も致命的な方法に反映され、この状況は非常に頻繁に、おそらくわずかに異なるバージョンで発生するので、マルチスレッドプログラミングの主な問題と呼ぶことに挑戦します。







もちろん、 boost :: shared_mutexとその若い子孫std :: shared_mutexという標準的なソリューションがあり、読み取り用に共有と書き込み専用の2つのレベルのアクセスを許可します。 ただし、 std :: shared_ptrコントロールユニットへのスレッドセーフアクセスを提供し(これが何を意味するのか実際には理解していない)、またその操作はロックフリーで実装されていると聞きました。私はエレガントなソリューションを提供したいと思います:







//   std::shared_ptr<SHARED_DATA> data; reading_thread { //       auto read_copy=data; //  ,   ... //  read_copy } writing thread { //     auto update=std::make_shared<SHARED_DATA>(...args); // (?)   data=update; //     ,     //          }
      
      





ここでは、更新のたびにデータを使用して構造を再作成する必要がありますが、これは実際にはかなり許容されるケースです。







だから、それは動作しますか? もちろん違います! しかし、なぜ正確に?







仕組み



共有ポインターの構造そのものを見ると

/usr/include/c++/5/bits/shared_ptr_base.h:1175







 template<typename _Tp, _Lock_policy _Lp> class __shared_ptr { ... private: _Tp* _M_ptr; // Contained pointer. __shared_count<_Lp> _M_refcount; // Reference counter. };
      
      





実際のデータへのポインタとその制御ブロックという2つのメンバーで構成されていることがわかります。 しかし、実際には、変更、割り当て、移動などをブロックせずにアトミックに行う方法はありません。 両方の要素。 つまり、共有ストリームポインターは安全ではありません(。?)ドットまたは疑問符? まあ、それはポイントのようですが、どういうわけか曖昧で、最終的なものではなく、どういうわけかささいで単純すぎます。 「コントロールユニットへのアクセスのみが安全である」と言われ、確認しませんでした。







それを理解し、より深く掘り下げましょう



 auto data=std::make_shared<int>(0); void read_data() { //  ,     for(;;) auto read_copy=data; } int main() { std::thread(read_data).detach(); //  ,      for(;;) data=std::make_shared<int>(0); return 0; }
      
      





提案されたアイデアを実装し、一般に期待どおりに機能するようなミニマリストの例を次に示します-ar音でクラッシュし、わずか数百サイクルを巻きます。 ただし、ここで説明することさえない実際のデータに注意してください。ポインタは逆参照されません。 つまり、コントロールユニットで何か問題が発生していますか? しかし、デバッガーとして動作できるコードができました。 しかし、最初に、他の可能なオプションを見てみましょう。







 auto data=std::make_shared<int>(0); void read_data() { for(;;) auto sp=std::atomic_load(&data); } int main(int argc, char**argv) { std::thread(read_data).detach(); for(;;) { std::atomic_exchange(&data, std::make_shared<int>(0)); assert(std::atomic_is_lock_free(&data)); } return 0; }
      
      





ここですべてが順調です em ただし、ループの本体でassert()を使用しない場合。 つまり、 std :: shared_ptrのアトミック操作定義されていますが、ブロックされています。 まあ、これは私たちのやり方ではありません、私自身ができるミューテックスでは。 別のオプション:







 std::shared_ptr<int> variant[]={ std::make_shared<int>(0), std::make_shared<int>(0) }; auto data=variant[0]; void read_data() { for(;;) auto sp=data; } int main() { std::thread(read_data).detach(); for(size_t n=0;; ++n) { data=variant[n%2]; } return 0; }
      
      





ほとんど同じですが、2つのコアを100%で完全にロードすることで正常に機能します。 違いは、ここではスレッドの1つがデストラクタを呼び出さないことです。 さて、標準のポインタデストラクタは安全ではありませんか? 信じられません 元のバージョンに戻りましょう







より深く掘る



より近い読み取りストリームを検討してください。







  auto sp=data;
      
      





ここでは、コピーコンストラクタとデストラクタがループで呼び出され、それだけです。







ここにソースコードからの抜粋があります
 //L1#shared_ptr_base.h : 662 __shared_count(const __shared_count& __r) noexcept : _M_pi(__r._M_pi) { if (_M_pi != 0) _M_pi->_M_add_ref_copy(); } //L2#shared_ptr_base.h : 134 void _M_add_ref_copy() { __gnu_cxx::__atomic_add_dispatch(&_M_use_count, 1); } //L1#shared_ptr_base.h : 658 ~__shared_count() noexcept { if (_M_pi != nullptr) _M_pi->_M_release(); } //L2#shared_ptr_base.h : 147 if (__gnu_cxx::__exchange_and_add_dispatch(&_M_use_count, -1) == 1) { _GLIBCXX_SYNCHRONIZATION_HAPPENS_AFTER(&_M_use_count); _M_dispose(); }
      
      







不要なものをすべて捨てると、







 // copy ctor __gnu_cxx::__atomic_add_dispatch(&_M_use_count, 1); // old instance dtor if (__gnu_cxx::__exchange_and_add_dispatch(&_M_use_count, -1) == 1) _M_dispose();
      
      





または、擬似コードに行く場合







 ++_M_pi->_M_use_count; if(--_M_pi->_M_use_count == 0) dispose();
      
      





ここでは、インクリメント演算子とデクリメント演算子はアトミックであると想定され、dispose()関数はメモリをクリアし、特に参照カウンター_M_piへのポインターを無効にします。 私は、マルチスレッドに慣れている場合、次の式を言わなければなりません。







if(-cnt == 0)

do_something();







小切手が引き出された手ren弾のように見えます。 そのような設計が確実に保護する唯一のものは、別のスレッドでの同様の呼び出しからです-アトミックデクリメント演算子が何度呼び出されても、そのうちの1つだけがカウンターがリセットされます。







しかし、この時点で別の書き込みストリームで何が起こりますか?









このようなどこか
 //L1#shared_ptr.h : 291 shared_ptr& operator=(shared_ptr&& __r) noexcept { this->__shared_ptr<_Tp>::operator=(std::move(__r)); //L2#shared_ptr_base.h : 997 __shared_ptr& operator=(__shared_ptr&& __r) noexcept { __shared_ptr(std::move(__r)).swap(*this); //L#3shared_ptr_base.h : 932 __shared_ptr(__shared_ptr&& __r) noexcept : _M_ptr(__r._M_ptr), _M_refcount() { _M_refcount._M_swap(__r._M_refcount); //L#4shared_ptr_base.h : 684 void _M_swap(__shared_count& __r) noexcept { _Sp_counted_base<_Lp>* __tmp = __r._M_pi; __r._M_pi = _M_pi; _M_pi = __tmp; } //L2#shared_ptr_base.h : 1073 void swap(__shared_ptr<_Tp, _Lp>& __other) noexcept { std::swap(_M_ptr, __other._M_ptr); _M_refcount._M_swap(__other._M_refcount); } //L3#shared_ptr_base.h : 684 void _M_swap(__shared_count& __r) noexcept { _Sp_counted_base<_Lp>* __tmp = __r._M_pi; __r._M_pi = _M_pi; _M_pi = __tmp; } //L2#shared_ptr_base.h : 658 ~__shared_count() noexcept { if (_M_pi != nullptr) _M_pi->_M_release(); } //L3#shared_ptr_base.h : 142 void _M_release() noexcept { // Be race-detector-friendly. For more info see bits/c++config. _GLIBCXX_SYNCHRONIZATION_HAPPENS_BEFORE(&_M_use_count); if (__gnu_cxx::__exchange_and_add_dispatch(&_M_use_count, -1) == 1) { _GLIBCXX_SYNCHRONIZATION_HAPPENS_AFTER(&_M_use_count); _M_dispose(); //L1#shared_ptr.h : 93 (destructor) //L2#shared_ptr_base.h : 658 ~__shared_count() noexcept { if (_M_pi != nullptr) //_M_pi == nullptr - true here _M_pi->_M_release(); }
      
      





不要なものをすべて破棄すると、およそ次のコードフラグメントが残ります。







  _Sp_counted_base<_Lp>* __tmp = __r._M_pi; __r._M_pi = _M_pi; _M_pi = __tmp; if (__gnu_cxx::__exchange_and_add_dispatch(&_M_use_count, -1) == 1) _M_dispose();
      
      





繰り返しますが、最初の3行( swap() )は非常に疑わしいように見えますが、注意深く分析すると、完全に安全であることがわかります(当然、このコンテキストでのみ)。







 if(--_M_pi->_M_use_count == 0) dispose();
      
      





少し高い安全性を発見したのと同じ表現。 ここに真実の瞬間が来ます:

// assert(_M_use_count == 1);







//書き込みスレッド //読み取りスレッド
if(--_ M_pi-> _ M_use_count == 0)// true
++ _ M_pi-> _ M_use_count; //カウント= 1
if(--_ M_pi-> _ M_use_count == 0)// true
処分(); //私が最初です! バン!! 処分(); //いや! バン!!


これは、アトミックなインクリメントとアトミックなデクリメントの組み合わせがスレッドとstd :: shared_ptr <>間の競合を引き起こす方法であり、制御ブロックレベルでもスレッドセーフではありません。 今本当にポイント。







少し後に、 boost :: shared_ptr <>のドキュメントでこの原則の比phor的に短い要約を見つけました。 「ポインタは書き込み専用または読み取り専用のどちらでも安全であり、競合する読み取り/書き込み中は安全ではありません。」 このアイデアを少し開発し、データを読み取らない裸のレコードは事実上無意味であることに気付いたので、標準のスマートポインターは安全に読み取れる、つまり、安全な通常のconstポインターが安全であることがわかります。 つまり、この複雑な内部エンジニアリングはすべて、通常のポインターのレベルに到達するために作成されましたが、それ以上ではありません。







結論の代わりに。 分析から合成まで



最後に、少なくとも概念的なレベルで、ブロッキングミューテックスを使用せず、異なるスレッドからの共有ポインターを使用した安全な操作を可能にするアルゴリズムを提案します。 しかし、私は対処することができず、さらに、既存の基本的な非ブロッキングプリミティブに基づいて、これは単に不可能であるという信念を開発しました。 もちろん、 スピンロックベースのバージョンを作成するのは非常に簡単ですが、スポーツマンらしくないので、このようなアルゴリズムを完全にノンブロッキングのものとは考えていません。 既存のマルチスレッドブロッキング実装をベースとして、各ミューテックスを対応するスピンロックに置き換えることができます 。つまり、アルゴリズムを使用して、より効率的なタイプのミューテックスを選択するタスクを減らすことができます。 明らかにこれは私たちのやり方ではありません。







完全な非ブロッキング実装には何が欠けていますか? 組み込み型でのみ機能するノンブロッキングプリミティブはほとんどありません。









非ブロッキングアルゴリズムのif()演算子の無条件の禁止を考えると、後者のみが分岐演算子の役割に適していますが、その最も深刻な制限は、同じ変数のみをチェックして割り当てることができることです。 一般に、3つのプリミティブすべてに共通するものに気付くのは簡単です。これらは、機械語のサイズの1つのメモリ領域のみでアトミックに動作します。その理由は明らかです。 共有ポインターの一般化された構造を見ると、共有データ( 制御ブロックを含む)へのポインターが少なくとも1つ含まれていなければならず 、このブロック内のどこか参照カウンターがあるはずです。 割り当てなどのポインター自体を使用する操作の場合、カウンターをアトミックにチェックすると同時に、ポインターを制御ブロックに変更する必要がありますが、これは既存のアトミックプリミティブでは不可能です。 したがって、完全な非ブロッキング共有ポインタの作成は原則として不可能です。







実際、私は間違いを犯してうれしいです。多分私が知らない、または誤解しているものがありますか? みんながコメントに挑戦するのを待っています。








All Articles