非同期処理におけるセマフォを用いた排他制御

第5章で書いたように lock 節の中では await の使用が禁止されています。

「ロックを取得する」とよく言いますが、これは「実行スレッドがロックを取得する」という意味です。スレッドプールを通じてスレッドを共有する可能性のある Task 処理の間では、「ロックを取得する」ことでは排他制御を行えないのです。lock 節中での await の使用は、そもそも期待通りの排他制御が行われないため禁止されているともいえます。このような理由のため、lock 節を用いずに Mutex のオブジェクトを直接使用したとしても、排他処理はうまく意図したとおりに機能しません。なぜなら、Mutex もスレッドが「取得する」ものだからです。

これでは排他制御ができないじゃないか、という話になってしまいます。アプリのアーキテクチャを設計する上で、ロックを使わないように努力するにしても、どこかで最低限の排他制御は必ず必要になりえます。

スレッドと紐づかないで排他制御をする仕組みはないものでしょうか。

そこで登場するのが「セマフォ」というものです。セマフォという名前は、黎明期の鉄道における交通整理に用いられた手旗信号に由来しています。この手旗信号がどのように使われたか、鉄道マニアをのぞけばピンと来る方は少ないと思われるので、このあたりの由来はあまり気にしないほうがよいでしょう。

ここでは、加減算のできる単なる「カウンター」がセマフォだと思ってください。ただし、このカウンターには以下のような性質があります。

  • カウンターの最大値を指定できる
  • カウンターの加算および減算を実行できるのは、ある一時点でひとつのスレッドに限られる
  • カウンターの値が最大値の時に加算をしようとすると、カウンターの値が減算されるまで待機させられる

たとえば、このカウンターの最大値が3に設定されていると仮定してください。このカウンターを4つのスレッドから同時に加算しようとした場合、何がおこるでしょうか。カウンターの加算を同時に実行できるのは、ひとつのスレッドのみです。なので、4つのスレッドは、あるランダムな順番で順にカウンターの加算を実行していきます。ただし、最初の3つのスレッドが加算を実行した時点で、カウンターは最大値の3に到達するので、4番目のスレッドが加算をしようとすると、その時点で待機することになります。なにがしかの事象によりカウンターが減算されてはじめて、4番目のスレッドは加算を実行し、処理を継続することができるようになります。

ここで、カウンターの最大値を1に設定したと考えてください。ある処理Aの前にカウンターを加算し、処理後にカウンターを減算するようにしておけば、この処理Aが同時に複数実行されることはなくなります。つまり、排他制御を実現できるわけです。

実際のコードでどのように使えるのか確認しておきましょう。

System.Threading.SemaphoreSlimクラスのオブジェクトがセマフォであり、以下のように用います。

  • コンストラクターの引数でカウンターの初期値および最大値を指定
  • カウンターの加算はWaitもしくはWaitAsyncで行う
  • カウンターの減算はReleaseで行う

上記のコードではコレクションに要素を追加すると同時にファイルに書き出していますが、この前後でセマフォを用いて排他制御を行っています。ここで、ファイルの書き出しが非同期処理である点に注意してください。また、セマフォのカウンターの加算を非同期に行っている点も重要です。

サンプルコードはgithubから入手可能です。

NOTE: ところで SemaphoreSlim クラスという名前が示すように、このクラスはSemaphoreクラスの「軽量版」です。SlimがつかないSemaphoreでも同等の排他制御は可能ですが、少し処理が重くなるので、通常は SemaphoreSlim クラスを用いるとよいでしょう。

スレッド間でなくプロセス間でセマフォを利用したい場合は名前付きセマフォを利用する必要があります。このような場合は、SemaphoreSlimでなくSemaphoreクラスを用いる必要がでてきます。ただし、単にプロセス間でのロックが必要なだけであれば、セマフォでなく名前付きミューテックスを利用すればよいでしょう。

参考資料

より詳しく調べてみたい方は以下の資料などを参考にしてみてください。

日本語資料ということであれば、山本康彦(@biac)さんの資料付きデモアプリがわかりやすいです。

Leave a Reply