この投稿の前提情報
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