この記事では、std :: threadの問題に対処し、「pthread_cancel、boolean flagまたはboost :: thread :: interrupt?」というトピックに関する古代の議論を同時に解決します。
問題
C ++ 11で追加されたstd :: threadクラスには、1つの不快な機能があります。これは、 RAIIイディオム(Resource Acquisition Is Initialization)に対応していません。 標準からの抜粋:
30.3.1.3スレッドデストラクタ
〜スレッド();
joinable()の場合はterminate() 、それ以外の場合は効果はありません。
このようなデストラクタで私たちを脅かすものは何ですか? プログラマは、 std::thread
オブジェクトを破棄する際に非常に注意する必要があります。
void dangerous_thread() { std::thread t([] { do_something(); }); do_another_thing(); // may throw - can cause termination! t.join(); }
do_another_thing
関数から例外がスローされた場合、 std::thread
destructorはstd::terminate
呼び出してプログラム全体をstd::terminate
。 これで何ができますか? std::thread
周りにRAIIラッパーを書いてみて、この試みが私たちをどこに連れて行くか見てみましょう。
RAIIをstdに追加::スレッド
class thread_wrapper { public: // Constructors ~thread_wrapper() { reset(); } void reset() { if (joinable()) { // ??? } } // Other methods private: std::thread _impl; };
thread_wrapper
は、 std::thread
インターフェースをコピーし、別の追加の関数reset
を実装します。 この関数は、スレッドを結合不可能な状態にする必要があります。 デストラクタはこの関数を呼び出すため、その後_impl
std::terminate
を呼び出さず_impl
折りたたみstd::terminate
。
_impl
を非結合状態にするために、 reset
はdetach
またはjoin
2つのオプションがあります。 detach
の問題は、スレッドが引き続き実行され、混乱を引き起こし、RAIIイディオムに違反することです。 だから私たちの選択はjoin
です:
thread_wrapper::reset() { if (joinable()) join(); }
深刻な問題
残念ながら、 thread_wrapper
このような実装は、通常のstd::thread
よりも優れていstd::thread
。 なんで? 次の使用例を見てみましょう。
void use_thread() { std::atomic<bool> alive{true}; thread_wrapper t([&alive] { while(alive) do_something(); }); do_another_thing(); alive = false; }
do_another_thing
から例外がdo_another_thing
れた場合、異常終了は発生しません。 ただし、 alive
がfalse
になることはなく、スレッドは終了しないため、 thread_wrapper
デストラクターからのjoin
呼び出しは永久にthread_wrapper
ます 。
問題は、 thread_wrapper
オブジェクトthread_wrapper
、実行中の関数に影響を与えて完了を「求める」方法thread_wrapper
ないことです。 do_something
関数では、実行スレッドが条件変数またはオペレーティングシステムのブロック呼び出しで「スリープ」状態になる可能性があるため、状況はさらに複雑になります。
したがって、 std::thread
デストラクタで問題を解決するには、より深刻な問題を解決する必要があります。
長い関数の実行を中断する方法、特にこの関数で実行のスレッドが条件変数またはOSのブロッキング呼び出しで「スリープ」できる場合はどうすればよいですか?
この問題の特殊なケースは、実行スレッド全体の中断です。 実行スレッドに割り込むための3つの既存のメソッドを見てみましょう: pthread_cancel
、 boost::thread::interrupt
およびbooleanフラグ。
既存のソリューション
pthread_cancel
選択したスレッドに割り込み要求を送信します。 POSIX仕様には、割り込み可能な関数の特別なリスト ( read
、 write
など)が含まれています。 いくつかのスレッドに対してpthread_cancel
を呼び出した後、このスレッドのこれらの関数は特別なタイプの例外をスローし始めます。 この例外は無視できません。このような例外をキャッチしたcatchブロックはさらに例外をスローする必要があるため、この例外はスレッドスタックを完全に巻き戻して終了します。 スレッドは、 pthread_setcancelstate
関数を使用して、呼び出しの中断を一時的に防ぐことができます(可能な使用法の1つは、デストラクタ、ロギング関数などからの例外を回避するためです)。
長所:
- 条件変数の待機を中止できます
- OSブロッキング呼び出しを中断できます
- 割り込み要求を無視するのが難しい
短所:
- 大きな移植性の問題: Windowsでの
pthread_cancel
の明らかな欠如に加えて、一部のlibc実装( Androidで使用されるbionicなど)でも欠落しています -
std::condition_variable::wait
C ++ 14以降の標準でstd::condition_variable::wait
するstd::condition_variable::wait
問題 - 割り込み可能な関数を使用するCコードで問題を引き起こす可能性があります(特殊効果のリスト:リソースリーク、ミューテックスが時間通りにロック解除されないなど)。
- デストラクタの割り込み関数には特別な注意が必要です(たとえば、
close
は割り込み関数です) - 例外のない環境では使用できません
- 個々の機能またはタスクを中断するために使用することはできません。
std::condition_variable::wait
問題は、C ++ 14 std::condition_variable::wait
にnoexcept
仕様が指定されているためにnoexcept
ます。 pthread_setcancelstate
で割り込みを有効にすると、条件変数の待機を中断する機能が失われます。割り込みが許可されると、この特定の例外を「飲み込む」ことができないため、 noexcept
仕様を満たすことができません。
ブースト::スレッド::割り込み
Boost.Threadライブラリは、 pthread_cancel
にいくらか似たオプションのスレッド割り込みメカニズムを提供します。 実行のフローを中断するには、対応するboost::thread
オブジェクトでinterrupt
メソッドを呼び出すだけで十分です。 boost::this_thread::interruption_point
関数を使用して、現在のスレッドのステータスを確認できます。割り込みスレッドでは、この関数はタイプboost::thread_interrupted
例外をスローします。 BOOST_NO_EXCEPTIONSを使用して例外の使用が禁止されている場合、 boost::this_thread::interruption_requested
を使用してステータスを確認できます。 Boost.Threadでは、 boost::condition_variable::wait
待機を中断することもできます。 これを実装するには、スレッドローカルストレージと条件変数内の追加のミューテックスを使用します。
長所:
- 移植性
-
boost::condition_variable::wait
を中止できますboost::condition_variable::wait
- 例外なく環境で使用できます。
短所:
- Boost.Threadへのバインド-この割り込みメカニズムは、標準の条件変数またはスレッドでは使用できません
-
condition_variable
内に追加のmutexが必要condition_variable
- オーバーヘッド:各
condition_variable::wait
2つの余分なロック/ロック解除ミューテックスを追加しcondition_variable::wait
- OSブロックコールを中断できません
- 個々の機能またはタスクを中断するために使用するのは問題です(コードで判断すると、これは例外を使用してのみ実行できます)
- 例外の哲学に対する軽微な違反-フローの中断は、プログラムのライフサイクルにおいて例外的な状況ではありません
ブールフラグ
StackOverflowでpthread_cancel
( 1、2、3、4 )に関する質問を読んだ場合、最も一般的な回答の1つは「 pthread_cancel
代わりにブールフラグを使用する」です。
例外のあるこの例でalive
アトミック変数は、ブールフラグです。
void use_thread() { std::atomic<bool> alive{true}; thread_wrapper t([&alive] { while(alive) do_something(); }); do_another_thing(); // may throw alive = false; }
長所:
- プラットフォームに依存しない
- ストリームのブレークポイントは明らかです
短所:
- コードの複製
- 分解が邪魔です-ブロック関数を書く簡単で効率的な方法はありません
- 条件付き変数の待機を中断しないでください(特に、ブール型フラグを使用してクラスの外にある場合)
- OSブロックコールを中断できません
キャンセルトークン
どうする ブールフラグを基にして、それに関連する問題の解決を始めましょう。 コードの重複? すばらしい-ブールフラグを別のクラスにラップしましょう。 それをcancellation_token
と呼びましょう。
class cancellation_token { public: explicit operator bool() const { return !_cancelled; } void cancel() { _cancelled = true; } private: std::atomic<bool> _cancelled; };
thread_wrapper
できます。
class thread_wrapper { public: // Constructors ~thread_wrapper() { reset(); } void reset() { if (joinable()) { _token.cancel(); _impl.join(); } } // Other methods private: std::thread _impl; cancellation_token _token; };
さて、トークンへのリンクを別のスレッドで実行される関数に転送するだけです:
template<class Function, class... Args> thread_wrapper(Function&& f, Args&&... args) { _impl = std::thread(f, args..., std::ref(_token)); }
説明のためにthread_wrapper
を書いているので、まだstd::forward
を使用することはできず、同時に、移動コンストラクターとswap
関数で発生する問題を無視できます。
use_thread
と例外をuse_thread
た例を思い出してuse_thread
。
void use_thread() { std::atomic<bool> alive{true}; thread_wrapper t([&alive] { while(alive) do_something(); }); do_another_thing(); alive = false; }
cancel_tokenのサポートを追加するには、ラムダに正しい引数を追加しalive
を削除するだけです。
void use_thread() { thread_wrapper t([] (cancellation_token& token) { while(token) do_something(); }); do_another_thing(); }
いいね! do_another_thing
から例外がdo_another_thing
れた場合でもdo_another_thing
デストラクタthread_wrapper
まだdo_another_thing
cancellation_token::cancel
を呼び出し、スレッドは実行を完了します。 さらに、cancel_tokenのブールフラグのコードを削除すると、この例ではコードの量が大幅に削減されました。
待機の中断
条件付き変数で待機するなど、ブロッキングコールを中断するようにトークンを教えるときです。 特定の割り込みメカニズムから抽象化するには、 cancellation_handler
インターフェイスが必要です。
struct cancellation_handler { virtual void cancel() = 0; };
条件変数の待機を中止するハンドラーは次のようになります。
class cv_handler : public cancellation_handler { public: cv_handler(std::condition_variable& condition, std::unique_lock<mutex>& lock) : _condition(condition), _lock(lock) { } virtual void cancel() { unique_lock l(_lock.get_mutex()); _condition.notify_all(); } private: std::condition_variable& _condition; std::unique_lock<mutex>& _lock; };
ここで、cancelation_tokenにcancel_handlerへのポインターを置き、cancelation_handler cancellation_handler::cancel
からcancellation_handler::cancel
を呼び出します。
class cancellation_token { std::mutex _mutex; std::atomic<bool> _cancelled; cancellation_handler* _handler; public: explicit operator bool() const { return !_cancelled; } void cancel() { std::unique_lock<mutex> l(_mutex); if (_handler) _handler->cancel(); _cancelled = true; } void set_handler(cancellation_handler* handler) { std::unique_lock<mutex> l(_mutex); _handler = handler; } };
条件変数の待機の中断バージョンは、次のようになります。
void cancellable_wait(std::condition_variable& cv, std::unique_lock<mutex>& l, cancellation_token& t) { cv_handler handler(cv, l); // implements cancel() t.set_handler(&handler); cv.wait(l); t.set_handler(nullptr); }
注意! 指定された実装は、例外とスレッドセーフの両方の観点から安全ではありません。 彼女はここで、anceration_handlerの動作メカニズムを説明するためにのみここにいます。 正しい実装へのリンクは、記事の最後にあります。
対応するcancellation_handler
実装することにより、OSへのブロッキング呼び出しと他のライブラリからのブロッキング関数を中断するようにトークンに教えることができます(これらの関数が待機を中断するメカニズムを少なくとも持っている場合)。
再スレッドライブラリ
説明されているトークン、ハンドラー、およびスレッドは、オープンソースライブラリとして実装されています: https : //github.com/bo-on-software/rethread 、 ドキュメント (英語)、 テスト、およびベンチマーク 。
以下は、コードとライブラリに実装されているものとの主な違いのリストです。
-
cancellation_token
は、いくつかの実装を持つインターフェースです。 中断された関数は、 定数リンクによってcancelal_tokenを受け取ります - トークンは一般的に使用される操作にミューテックスの代わりにアトミックを使用します
- スレッドのラッパーは
rethread::thread
と呼ばれrethread::thread
ライブラリにあるもの:
- トークン
- RAII互換ストリーム
-
std::condition_variable
互換性のある条件変数での割り込み可能な待機std::condition_variable
-
poll
待機の中断-これにより、多くのブロッキングPOSIX呼び出し(read
、write
など)の中断バージョンを実装できます。
性能
測定は、Intel Core i7-3630QM @ 2.4GHzプロセッサを搭載したラップトップで実行されました。
以下は、再rethread
トークンベンチマークの結果です。
次の操作のパフォーマンスが測定されました。
- 状態の確認は、
cancellation_token::is_cancelled()
関数(または同等のブール型へのコンテキスト変換cancellation_token::is_cancelled()
を呼び出すことの代償です - 中断された関数の呼び出しは、中断されたブロッキング関数のオーバーヘッドです。呼び出しの前にトークンにハンドラーを登録し、呼び出しの終了後に「登録解除」します
-
standalone_cancellation_token
1つ作成します
Ubuntu 16.04
CPU時間、ns | |
---|---|
トークンステータスの確認 | 1.7 |
割り込み機能を呼び出す | 15.0 |
トークン作成 | 21.3 |
Windows 10
CPU時間、ns | |
---|---|
トークンステータスの確認 | 2.8 |
割り込み機能を呼び出す | 17.0 |
トークン作成 | 33.0 |
マイナスのオーバーヘッド
このような低い割り込みオーバーヘッドは、興味深い効果を生み出します。
状況によっては、割り込み関数は「通常の」アプローチよりも高速です。
トークンを使用しないコードでは、ブロック機能を永久にブロックすることはできません-その後、「正常な」アプリケーションの終了を達成することはできませんexit(1);
ような異常。正常と見なすことはできません)。 永続的なブロックを回避し、ステータスを定期的に確認するには、タイムアウトが必要です。 たとえば、これ:
while (alive) { _condition.wait_for(lock, std::chrono::milliseconds(100)); // ... }
まず、このようなコードは、フラグを確認するために100ミリ秒ごとに起動します(タイムアウト値は増やすことができますが、「合理的な」アプリケーション終了時間によって上から制限されます)。
第二に、このコードはそのような無意味な覚醒がなくても最適ではありません。 事実は、 condition_variable::wait_for(...)
の呼び出しはcondition_variable::wait_for(...)
よりも効率がcondition_variable::wait(...)
少なくとも、現在の時刻を取得し、ウェイクアップ時間を計算する必要があります。
このステートメントを証明するために、rethread_testingで2つの合成ベンチマークを作成し、マルチスレッドキューの2つの基本的な実装を比較しました。「通常」(タイムアウト付き)と割り込み(トークン付き)です。 プロセッサ時間は、キュー内の1つのオブジェクトの出現を待つために測定されました。
CPU時間、ns | |
---|---|
Ubuntu 16.04&G ++ 5.3.1(「通常の」キュー) | 5913 |
Ubuntu 16.04&G ++ 5.3.1(中断されたキュー) | 5824 |
Windows 10およびMSVS 2015(通常のキュー) | 2467 |
Windows 10およびMSVS 2015(割り込みキュー) | 1729 |
そのため、MSVS 2015では、割り込み可能なバージョンはタイムアウトのある「通常の」バージョンよりも1.4速く実行されます。 Ubuntu 16.04では、違いはそれほど顕著ではありませんが、中断されたバージョンは明らかに「通常の」バージョンよりも優れています。
おわりに
これが問題の唯一の可能な解決策ではありません。 最も魅力的な代替方法は、トークンをスレッドローカルストレージに配置し、中断時に例外をスローすることです。 動作はboost::thread::interrupt
に似ていboost::thread::interrupt
が、各条件変数に追加のmutexがなく、オーバーヘッドが大幅に少なくなります。 このアプローチの主な欠点は、既に述べた例外の哲学の違反とブレークポイントの非自明性です。
トークンを使用したアプローチの重要な利点は、フロー全体ではなく、個別のタスクを中断できることです。ライブラリに実装されているcancellation_token_source
ライブラリを使用すると、同時に複数のタスクを中断できます。
ほとんどすべてのウィッシュリストをライブラリに実装しました。 私の意見では、ファイルやソケットを操作するようなブロックシステムコールとの統合が不足しています。 read
、 write
、 connect
、 accept
などの割り込み可能なバージョンを書き込みます。 難しいことではありません。主な問題は、トークンを標準のiostreamに貼り付けたがらないことと、一般に受け入れられている代替手段がないことです。