同期プリミティブの概要-セマフォとビットロックレス

前回の投稿で、スレッド同期ツールキャンプの最も有名なペアであるmutexとcondについて説明しました。 今日は、前の2つだけを置き換えることができるプリミティブであるセマに会います。

しかし、最初に、ランダムな目覚めに関するいくつかの言葉。 (これを思い出させてくれたxaizekに感謝します。)原則として、厳密に実装された同期メカニズムはこれに悩まされませんが、それにもかかわらず、経験豊富なプログラマはこれに依存しません。

コードスニペットを思い出させてください:

while(total_free_mem <= 0) { wait_cond(&got_free_mem, &allocator_mutex); } 


ここで、wait_condのループは、偶然または誤ってイベントを待ってから戻っても、悪いことは何も起こらないことを保証します-whileをチェックインすると、チェックされたオブジェクトの目的の状態に到達したという確信が得られます。 そうでない場合は、待機中にスリープします。

ロックされたミューテックスを使用してオブジェクトの状態(total_free_mem <= 0)をチェックすることに注意してください。つまり、誰も同時に変更できません。

また、スレッド間でデータを交換する変数を揮発性としてマークする必要があることを忘れないことをお勧めします。そうしないと、コンパイラーはコードのロジックを簡単に壊すような最適化を簡単に構築します。

また、IntelプロセッサでのSMPの実装は完全に寛容であり、すべてのプロセッサが別々のキャッシュを持っているにもかかわらず、すべてのプロセッサが同じように見えることをプログラマーに保証することにも触れます。 つまり、マルチプロセッサシステムでプロセッサキャッシュのいわゆる一貫性を提供します。

これはどこでもそうではありません。 プロセッサ間でデータの同期が保証されるように、特別な努力が必要なプロセッサがあります。 または、複数のプロセッサからアクセス可能なデータを含むメモリページのキャッシュを明示的にオフにします。 これは完全に独立した会話のトピックです。ここでは完全性について言及しました。

いいね セマフォに戻りましょう。

実際、セマフォの場合、定義される操作は2つだけです。

  sem_acquire( &sema ) sem_release( &sema ) 


彼らは非常に簡単に動作します。 各セマフォには整数値があります。

値がゼロ以下の場合(セマフォがロックされている場合)に取得操作がブロックされ、ロックの終了時に(または値がゼロより大きい場合は即座に)セマフォの値が1減少します。

リリース操作は、セマフォの値を1増やし、取得中にスリープ状態になったスレッドがあれば、それを起動します。

セマフォは、mutexとcondの両方として使用できます(同時には使用できません)。

実際、ここにミューテックスがあります:

  //    = 1 sem_acquire( &sema ); //    sem_release( &sema ); 


取得を実行すると、最初のスレッドはスリープ状態になりません(値はゼロより大きかった)が、値を1減らします。リリースを実行する前に他の誰かが取得しようとすると、値がゼロになるためスリープ状態になります。

そして、ここに条件があります:

 //    = 0 get_byte() { acquire(&got_data); ret = buf[read_pos++]; read_pos %= sizeof(buf); } put_byte(b) { buf[put_pos++] = b; release(&got_data); } 


ここで何が見えますか? バッファからバイトを削除しようとすると、まだ誰もバッファにバイトを入れていない場合、取得時にスリープ状態になります。 しかし、バイトを入れたら、目を覚まして続行し、バイトを取りに行きましょう。

condセマフォの代わりにこのようなコードは機能しません。 シナリオを考えてみましょう。まず、スレッドAがput_byteを呼び出し、次にスレッドBがget_byteを呼び出します。 condセマフォの代わりにget_byteを作成しようとしても終了しなかった場合、condを待ってスリープ状態になっていたでしょう。signal_condの呼び出し時にこのcondを待たなかったため、put_byteは誰もウェイクしませんでした。 そして、私たちは目を覚まして眠りに落ちました。

セマフォはすべて正常であり、状態があり、セマフォは「予備で開く」ことができます-誰かが閉じようとする前でも。 つまり、取得するリリースも可能です!

これは、ロジックを本当に単純化するので素晴らしいです。

しかし、上記のコードはまだ間違っています。 2つのput_byte get_byteが同時に発生した場合、read_posを操作する時点で「けんか」します。これは厄介なことがあります。2つのスレッドが最初にポインターをインクリメントし、次に両方が同じバイトを使用します。

コードと他の何かが間違っています
このコード例には、バッファオーバーフローチェックはありません。 また、セマフォを介して実行することもできますが、「逆に」-get_byteで解放し、put_byteで取得し、初期値はバッファのサイズと等しくなります。


物ではない。 ミューテックスの「モード」にある通常のミューテックスまたはセマフォを使用して、バッファの作業領域を「カバー」する必要があることがわかりました。 次に、通常のcondよりもセマフォの利点は何ですか?

そこにあります。

第一に、パーティの1つが1つのスレッドから同期して動作することが保証されている場合があります。 この場合、ミューテックスはこの側には必要ありません。 これは多くの場合、ドライバーで発生します-ドライバーバッファーは、割り込みハンドラーまたは単一ドライバースレッドからいっぱいになります(または反対方向に空になります)。この部分では、バッファーへの同時アクセスをブロックする必要はありません。

次に、非常に簡単なロックレスアルゴリズムを適用します。

  rpos = atomic_add( &read_pos, 1 ); ret = buf[rpos]; read_pos %= sizeof(buf); 


ここでatomic_addはrpos = read_pos ++をアトミックに実行します 。 これにより、2つのスレッドの並列実行が正しいことが保証されます。各スレッドは、独自のバイトを受け取りますが、どの順番であるかは不明です。

確かにread_pos%= sizeof(buf);には問題があります。 -厳密に言えば、この操作はatomic_addの「内部」でアトミックに実行する必要があります。 つまり、完全なアトミック読み取り-増分-制限操作が必要です。

詳細に対処します
アトミックでポータブルな読み取りインクリメント制限操作はそうではありません。 一部のプロセッサ(MIPSなど)では整理できますが、移植可能なコードを作成します。

どうすれば問題を修正できますか? そして、手始めに、それは何ですか? スレッドAおよびBの潜在的なエラーシーケンス:

  • 開始値read_pos = sizeof(buf)-1;
  • A:読み取りと増分
  • B:読み取り値と増分値。読み取り値= sizeof(buf)、つまり配列の境界を超えています。


次に、2つのread_pos%= sizeof(buf); しかし、手遅れです。

ここで幸運です。 簡単な調整で十分です。

 rpos = atomic_add( &read_pos, 1 ) % sizeof(buf); 


さらに、操作read_pos%= sizeof(buf);degsが正しく述べているように、それは非現実的であるアトミックである必要があります-gccは組み込みのアトミック関数間でこのような操作を提供しません( ここ )。

ただし、配列のサイズが2のべき乗の倍数である場合、この操作は単純に除外できます。 atomic_addで読み取った配列のサイズを値に制限しているため、read_pos自体は変更できません。0xFFFFFFFFをゼロまで拡大して、配列のサイズによる除算の残りは常にtrueになります。

(完全な脳の爆発のために、読み取りアドレスと書き込みアドレスの処理が同じように構成されていれば、すべてが複数のサイズで機能することを追加します。カウンターオーバーフローの時点で、コードはバッファーの一部を使用しません。)


これはおそらくセマフォに関するすべてです。

しかし、優先順位の逆転についてのいくつかの言葉。 このことはリアルタイムOSにとって典型的かつ重要ですが、リアルタイムの優先順位は今日ほとんどどこにでもあるので、おそらく誰もがそれについて知る必要があります。

スレッドの優先順位が原因で、理論的に正しいプログラムに古いデッドロックの問題があります。

スレッドA、B、Cがあり、それぞれの優先度がそれぞれ3、2、1である、つまりAが最高であるとします。 すべての優先順位はリアルタイムクラスです。つまり、Aが処理する必要がある場合、無条件でプロセッサを受け取り、BとCは待機しています。

AとBは共有リソースで動作します-たとえば、低優先度のBは、AからログへのAメッセージフローを取得し、それらをネットワークまたはファイルに書き込むロガーです。 作業は重要ではないため、Bの優先度は低くなります。 スレッドBは、Aよりも重要度の低い処理を行いますが、継続します。 また、原子炉を制御し、1 msごとに出力を確認してロッドを調整する必要があります。

通常の生活では、Aは周期的に回転します-1ミリ秒でスロットの終わりまでスリープし、読み取り値を取得してロッドを操縦します。 Bはレイヴン中性子を数え、彼女は次の5-6秒間仕事をします。 したがって、プロセッサスレッドBはまったく落ちません。 BとAはより重要です。

遅かれ早かれ、バッファはオーバーフローし、セマフォがバッファにバイトを入れるのを待つのをやめます。

永久に、または少なくとも5〜6秒間停止します。Bは、Bがカウントされるまでプロセッサを受け取りません。 その結果、スレッドAはそのタイムスロットをスキップし、リアクターは制御できなくなります。

これを防ぐために、リアルタイムOSでは、すべての同期プリミティブが、待機しているプロセスの優先度をすべての待機スレッドの中で最大の優先度レベルに自動的に上げます。

つまり、この例では、Bは優先度Aを受け取り、Aが過剰になるのを待たないように、迷惑なBをプロセッサから削除します。

もちろん、例は偏っていますが、私はそれが本質を反映していることを望みます。

次回は、スピンロックを検討してください。 コメントでは、夏はスピンロックの小さな人生であるとすでに書いています-それは小さなミューテックスであり、基礎がないわけではありません。

しかし、彼らが言うように、ニュアンスがあります。 :)

これは、「同期プリミティブの概要」という一連の記事です。

Source: https://habr.com/ru/post/J278661/


All Articles