同期プリミティブの概要-mutexおよびcond

マルチスレッドプログラムでは同期が必要です。 (もちろん、100%loclessアルゴリズムで構成されていない限り、これはほとんどありません)。 それは、最新のオペレーティングシステムのアプリケーションまたはカーネルコンポーネントです。



もちろん、以下のすべては、OSカーネルの開発の観点から私をより興奮させます。 しかし、ほとんどすべてがユーザーコードに適用されます。



ちなみに、古き良き時代にはカーネル内でマルチタスクを再受け入れることはなかったため、古いOSのカーネルは同期プリミティブを必要としませんでした。 (私はUnix 7thバージョンを担当しました。そうではありませんでした。)より正確には、唯一の同期方法は割り込み禁止でした。 しかし、それについては後で。



最初に、ヒーローをリストします。 次の同期プリミティブを知っています。



ユーザー/カーネルモード:mutex + cond、sema、クリティカルセクションの開始/終了。

カーネルのみ:スピンロック、割り込み管理。



なぜこれがすべて必要なのか、読者はおそらく知っているだろうが、それでも明らかにする。



いくつかのデータ構造が2つの並列作業スレッド(またはスレッドと割り込み)によってアクセスでき、アトミックアクセスを提供できないエンティティである場合、一度に1つのスレッドのみが構造の状態で複雑な操作を実行するように、そのような構造を操作する必要があります。



簡単な例。 リスト。



struct list { list *next; list *prev };
      
      







リストにアイテムを貼り付けます。



 new_el->next = curr_el->next; new_el->prev = curr_el; curr_el->next->prev = new_el; // 3 curr_el->next = new_el;
      
      







すべてが原始的です。 しかし、このコードが2つのスレッドによって並行して実行される場合、リンクリストの代わりにパスタファクトリーで爆発が発生します。 たとえば、最初のスレッドが3行目を終了した時点で2番目のスレッドが参加し、リストを左から右に巡回すると、同じ場所で右から左に別のオブジェクトに出会うことになります。



不快です。



相互排他ロック-相互排他ロックを適用します。 このロックは、それによってロックされたコードの並列実行を禁止します-1つのスレッドが実行を開始した場合、2番目のスレッドは最初のスレッドが終了するまで入り口で待機します。



 mutex_lock( &list->mutex); new_el->next = curr_el->next; new_el->prev = curr_el; curr_el->next->prev = new_el; // 3 curr_el->next = new_el; mutex_unlock( &list->mutex);
      
      







いいですね (まあ、100を超えるプロセッサがあり、それらに人気のあるコードがある場合はあまり良くありませんが、これはまったく別の問題です。)



何が起こっているの? スレッドAは、mutex list-> mutexのmutex_lockを呼び出します。 これは、明らかに、変更したいリストに属し、アクセスを保護します。 彼はロックされておらず、スレッドAはミューテックスをロックし(彼はロックされていることを知っており、誰がそれをロックしたかを知っています)、作業を続けます。 スレッドBがコードの同じ領域(またはリスト項目を削除する機能などで同じミューテックスで保護されている別の領域)に入ろうとすると、ロックされたミューテックスをもう一度ロックすることはできません。 スレッドBは、スレッドAがmutex_unlockを呼び出すまで待機します。



ところで、あなたと私が核開発者である場合、アプリケーションプログラマー(およびすべての「ヘビーウェイト」同期プリミティブ)のmutexのもう1つの興味深い特性を理解することが重要です-既に別のスレッドによってロックされているmutexをロックしようとすると、ただ待っているだけではありません-スレッドは「プロセッサから削除」され、コンテキストの切り替えが発生します。 これにより、プロセッサをはるかに効率的にロードできますが、問題があります。 たとえば、割り込みハンドラー内では、割り込み内でコンテキストを切り替えることは禁止されており、システムがかなり破損する恐れがあるため、このようなプリミティブはまったく使用できません。 しかし、おそらく、これについては別に記述する必要があります。



これにより、複雑なデータ構造で作業する必要がある場合に問題が完全に解決されます。 しかし、他のタスクがあります。 たとえば、他のスレッドが待機しているイベントについて別のスレッドに通知します。



関数alloc_memおよびfree_memについて考えてみましょう。



 // NB!   ! alloc_mem() { while(total_free_mem <= 0) { wait_cond(&got_free_mem); } // actually allocate } free_mem() { // actually free mem total_free_mem++; signal_cond(&got_free_mem); }
      
      







ここで何が起こっていますか? すべてが些細です。 メモリ割り当て関数では、グローバルな空きメモリカウンタを調べます。 空の場合、空きメモリはありません。誰かがメモリを解放するまで待機します-wait_condを呼び出し、誰かが信号を送るまで一時停止します-準備ができて、メモリが解放されました。



これは、もちろんfree_mem()関数です。メモリをヒープに戻し、空きメモリカウンタを増やし、signal_condを呼び出します-メモリがあることを被害者に伝えます。 wait_cond内で眠った人は誰でも、そのようなシグナルの後に目覚め、はい、メモリがあることを確認し、それを割り当てます。 大丈夫ですか?



ええ、もちろんです。 alloc_mem関数が一度に2つのスレッドによって呼び出された場合、それは災害になります-そのうちの1つが最初にシグナルを受信し、ウェイクアップし、空きメモリがあることを確認してから、突然スケジューラを取り出してプロセッサから削除します。 そして、同じウェイクアップの2番目のスレッドを許可します。 2番目のスレッドが起動し、メモリがあることを確認し、それを取得して終了します。 マフィアは最初のスレッドを起動し、それはすべて悪いです。 彼女は変数free_memをチェックし、すべてがそこにあることを確認しました。そして今-プールに空きメモリがありません。 トラブル。



この場合、問題は致命的ではありません-関数の最初に戻って、海辺の天気を再び待つことができます。 もちろん、これは悪いことですが、空のスローではCPU時間を失います。



しかし、答えはわかっているようです。 ミューテックスを追加してください!



 // NB!    ! alloc_mem() { mutex_lock( &allocator_mutex ); while(total_free_mem <= 0) { wait_cond(&got_free_mem); } // actually allocate mutex_unlock( &allocator_mutex ); } free_mem() { mutex_lock( &allocator_mutex ); // actually free mem total_free_mem++; signal_cond(&got_free_mem); mutex_unlock( &allocator_mutex ); }
      
      







とても良い いや メモリは解放されません-alloc_mem()関数はそれをロックし、condを待っている間スリープ状態になり、誰もミューテックスに入ることができず、誰もメモリとシグナルを解放しません。



トラブル。 しかし、申し分なく、私たちは何をすべきかを知っています! condを待っている間に眠りにつく前に、ミューテックスを加熱し、他の人が自由に入って記憶を戻すようにします。 このように:



 // NB!     ! alloc_mem() { mutex_lock( &allocator_mutex ); while(total_free_mem <= 0) { mutex_unlock( &allocator_mutex ); wait_cond(&got_free_mem); mutex_lock( &allocator_mutex ); } // actually allocate mutex_unlock( &allocator_mutex ); }
      
      







解説では、神に感謝するのではなく、すでにそれを見ています。 今何 そして今、クリックがあり、目覚めてからwait_cond関数を離れ、free_memからシグナルを受信して​​メモリを解放し、ミューテックスをキャプチャする瞬間の間に細い線があります。 この時点では、ミューテックスは使用されず、他のスレッドが再び先に進み、姿を消す可能性があります。 このため、wait_cond関数は少し異なります。



 wait_cond( cond *c, mutex *m );
      
      







次のように機能します。この関数は、「ウェイクアップ」するシグナルとロックされたミューテックスを提供する条件変数を受け入れます。



 alloc_mem() { mutex_lock( &allocator_mutex ); while(total_free_mem <= 0) { wait_cond(&got_free_mem,&allocator_mutex); } // actually allocate mutex_unlock( &allocator_mutex ); }
      
      







wait_cond関数は、ミューテックスを加熱します。まず、それ自体で、そして次に、スリープ状態への移行に関してアトミックに行います。 つまり、wait_condに含まれるスレッドが最初にスリープ状態になり、その後、スリープを中断せずにミューテックスを加熱します。 逆に、目を覚ますと、まずミューテックスをキャプチャし、次に目を覚まして動作を続けます。 (これには、スレッド切り替えコードからかなりの量のトリックが必要です。これについては、次のいずれかのメモで説明します。)



このようなセマンティクスのみが100%の一貫性と「レース」の欠如-競合状態を提供します。



free関数のコードは非常に正しいことが判明したことに注意してください。



 free_mem() { mutex_lock( &allocator_mutex ); // actually free mem total_free_mem++; signal_cond(&got_free_mem); // 4 mutex_unlock( &allocator_mutex ); // 5 }
      
      







上記に照らしてのみ、ライン4でアロケーターを正式にウェイクアップしますが、ライン5の実行後に実際にウェイクアップするのは、この時点までミューテックスをキャプチャできないためです。



上記に加えて、本当のsignal_cond関数がすべての待機スレッドではなく、1つ(通常は最高の優先順位)でウェイクアップすることを追加するのはおそらく理にかなっているので、上記の例の状況はやや単純で複雑です。 アラームシステム内に既に1つのallocを解放するために1つのallocのみを起動する組み込みメカニズムがあるため、より単純ですが、実際には何も解決しないため、より複雑です-割り当てられたセクションがこのallocに適しているかどうかはわかりません。したがって、signal_condではなくbroadcast_cond苦しんでいるすべての人を目覚めさせ、誰に資源を手に入れるかを決める正直な戦いの機会を与えます。



ここでこれらのプリミティブの実際の実装を見ることができます:



mutex.ccond.c



次のエピソードはセマセマフォです。これは、mutexとcondの両方を単独で置き換えます。 実質的にアンサンブルはありません。



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




All Articles