この投稿の前提情報
await
を含むということはほとんどの場合時間的に長い処理であり、この間ずっと他のスレッドをブロックしっぱなしというものは決して褒められたコードではない。
できるのであれば、その非同期メソッド内の同期処理部分の必要な部分だけをlock
するのが最善である。
ここに記載されている情報は、嬉々として採用するべきものではなく、
- 非同期部分を同時に呼び出してしまうと困ったことがおきる
- 非同期メソッド呼び出し前後でデータの整合性を保つ必要がある
といった状況化で仕方なく、しぶしぶ採用するべきものになる。
lock
ブロック内ではawait
できない
通常、排他制御をおこなう場合、lock
ステートメントを使用する。
readonly object LockHandler = new object();
void Hoge()
{
lock (LockHandler)
{
DoSomething();
}
}
しかし、このlock
ブロック内にawait
が含まれるコードはコンパイルエラーが発生する。
というのも、ロックを解放するのはそれを獲得したスレッドでなければならず、await
演算子の前と後ろは違うスレッドで実行される可能性があるためだ。
// Compile Error
lock (LockHandler)
{
await DoSomethingAsync();
}
SempahoreSlim
を使う
そこで、ロックの獲得と解放を異なるスレッドで行うことのできる仕掛けが必要になる。
これに適したクラスがSemaphoreSlim
であり、これでawait
を含むコードの排他処理ができるようになる。
readonly SemaphoreSlim Semaphore = new SemaphoreSlim(1, 1);
public async Task AsynchronousMethodAsync()
{
await Semaphore.WaitAsync();
try
{
await DoSomethingAsync();
}
finally
{
Semaphore.Release();
}
}
もし、同じクラスの同期処理部分でも排他制御をおこないたければ、同じSemaphoreSlim
オブジェクトのWait
メソッドを使用すればよい。
public void SynchronousMethod()
{
Semaphore.Wait();
try
{
DoSomething();
}
finally
{
Semaphore.Release();
}
}
SemaphoreSlim
の注意点
SemaphoreSlim
を使用している場合、lock
ステートメントと異なり、すでに獲得しているロックを再取得できない。
以下のコードは1を出力して、そのまま停止する。2には到達しない。
readonly SemaphoreSlim Semaphore = new SemaphoreSlim(1, 1);
public async Task AsynchronousMethodAsync()
{
await Semaphore.WaitAsync();
try
{
Console.WriteLine("1"); // 1
await Semaphore.WaitAsync();
Console.WriteLine("2"); // 2
}
finally
{
Semaphore.Release();
}
}
これが意味するのは、あるSemaphoreSlim
オブジェクトでガードされているメソッドから、同じオブジェクトでガードされているメソッドを呼び出せないということだ。
以下のAsynchronousMethodAsync()
は、SynchronousMethod()
のSemaphore.Wait()
で処理が停止し、それ以上処理が継続されない。
public async Task AsynchronousMethodAsync()
{
await Semaphore.WaitAsync();
try
{
SynchronousMethod();
await ...
}
finally
{
Semaphore.Release();
}
}
public void SynchronousMethod()
{
Semaphore.Wait();
...
}
IDisposable
を実装するべきか
SemaphoreSlim
はIDisposable
を実装している。そして、この型をインスタンスフィールドに含むクラスは一般論で言えばIDisposable
を実装し、そこでSepamhoreSlim
オブジェクトをDispose
するべきだということになる。
つまり、排他制御しているオブジェクトを使いおわったらDispose()
する必要がある。lock
ステートメントで済んでいたクラスと比べて、とても面倒だ。
安心して欲しい。SemaphoreSlim
のソースを追えばわかることだが、AvailableWaitHandle
プロパティを使用しない場合、そのDispose()
は内部の変数にnullを代入する以外の仕事はおこなわれない。
そして、今回の要件ではこのプロパティは不要なので、SemaphoreSlim
オブジェクトをDispose
しなくても実質的な問題は生じない。
もちろん未来永劫そうであると保証があるわけではない。
どうするべきかという問いには、「IDisposable
を実装するべきだ」と回答するしかない。
だから、コードレビューで原理主義者に追求を受けそうだというのなら、IDisposable
を実装することになるだろう。
現実的なところでは、SemaphoreSlim
のラッパークラスを作り、AvailableWaitHandle
を呼び出せなくしてしまうという手がある。
うるさいコード分析ツールを黙らせるのも楽になるだろう。コンストラクタの引数(1, 1)
を隠蔽できるようになるのもいい。
AvailableWaitHandle
のないSemaphoreSlim
を再実装してしまう方法もある。
何かまちがっている気もするが、こわいコードレビュアーをかかえたチームでは歓迎されるのではないだろうか。
AsyncLock
とその注意点
https://www.atmarkit.co.jp/ait/articles/1411/18/news135.html にあるように、SemaphoreSlim
そのものを使うのではなく、それを使いやすくしたクラスを使用するという方法がある。
readonly AsyncLock LockHandler = new AsyncLock();
public async Task AsynchronousMethodAsync()
{
using (await LockHandler.LockAsync())
{
await DoSomethingAsync();
}
}
簡潔で美しい方法だが、await
を忘れusing (LockHandler.LockAsync())
としてしまうと、ロックされている場合に実行中のTask
をDispose
しようとして例外を投げるコードができあがってしまう。
困ったことに、非同期メソッドの呼び出しがawait
されてないという警告も出力されない。
若干一名の尊い犠牲のもとになりたっている情報なので、もし採用するのなら注意してほしい。
犠牲担当の意見としては、二度と利用する気はない。
参考にした情報
非同期:awaitを含むコードをロックするには?(SemaphoreSlim編)[C#、VB]
https://www.atmarkit.co.jp/ait/articles/1411/11/news117.html
非同期:awaitを含むコードをロックするには?(AsyncLock編)[C#、VB]
https://www.atmarkit.co.jp/ait/articles/1411/18/news135.html
SemaphoreSlim
のソース
https://source.dot.net/#System.Private.CoreLib/SemaphoreSlim.cs