非同期処理を使うようになると、UIが固まりにくくなったり、待ち時間を効率よく扱えたりする一方で、新しい問題が見えてきます。その代表が競合です。特に、同じリソースを複数の処理がほぼ同時に触る可能性があると、予期しない状態不整合や重複実行が起きます。
ここでいうリソースとは、メモリ上の変数だけに限りません。ファイル、DBレコード、コレクション、外部API、ソケットなど、共有されるものはすべて競合の対象になります。
競合は async/await が起こすのではない
まず整理しておきたいのは、async / await 自体が競合を発生させるわけではない、ということです。問題を起こすのは、同じタイミングで複数の処理が走り得ることです。
競合が起きる典型例
- 同じ処理が重複起動される
await中に別の処理が割り込む- 複数スレッドや複数タスクが同じデータを触る
- 外部リソースに同時アクセスする
非同期コードでは待ち時間中に別の処理が進めるため、同期コードでは表に出にくかった競合が目に見えやすくなります。
対策の基本方針
競合対策にはいくつかの方向性がありますが、発想としては大きく次の4つに分けられます。
競合対策の考え方
- そもそも同時実行させない
- 排他制御する
- 順番待ちさせる
- 共有しない設計にする
実務では、この中から対象リソースや要求に合わせて選びます。
1. そもそも同時実行させない
最も単純な対策は、「前回処理中なら今回を始めない」という方針です。たとえばタイマー通知で、送信中に次のTickが来ても捨ててよいなら、この考え方が使えます。
private bool _sending;
private async Task NotifyInfluxDbAsync(long total)
{
if (_sending) return;
_sending = true;
try
{
await _influxNotifier.SendAsync(total);
}
finally
{
_sending = false;
}
}
これは考え方としては分かりやすいのですが、一般論としては完全には安全ではありません。if (_sending) のチェックと _sending = true の間に別スレッドが入る可能性があるからです。UIスレッドだけで完結するなら実用上問題にならないこともありますが、汎用的な排他には向きません。
2. 同期コードでは lock
同期処理における排他制御の基本は lock です。
private readonly object _sync = new();
public void Increment()
{
lock (_sync)
{
_count++;
}
}
lock を使うと、同じロック対象に対しては一度に1つのスレッドしか中に入れません。共有データの更新を守るには非常に分かりやすい方法です。
ただし、非同期処理ではここに大きな注意点があります。
重要な注意
awaitを含むコードにlockを使わない- ロック保持中に中断する設計は避ける
つまり、次のような書き方は基本的にしません。
lock (_sync)
{
await SomeAsync();
}
非同期処理では、lock より別の道具を使う方が自然です。
3. 非同期では SemaphoreSlim
非同期コードで排他制御を行う代表的な道具が SemaphoreSlim です。感覚としては「lock の async版」に近いと思うと理解しやすいです。
private readonly SemaphoreSlim _semaphore = new(1, 1);
private async Task NotifyInfluxDbAsync(long total)
{
await _semaphore.WaitAsync();
try
{
await _influxNotifier.SendAsync(total);
}
finally
{
_semaphore.Release();
}
}
これで、同時に1つの処理だけが中に入れるようになります。他の呼び出しは待ち、先行処理が終わってから順番に進みます。
さらに、「待たせる」のではなく「送信中なら今回を捨てる」設計にしたいなら、次のように書けます。
if (!await _semaphore.WaitAsync(0))
return;
この形は、タイマー駆動の通知処理のような「重複を避けたいが、全部を順番待ちさせる必要はない」ケースに向いています。
4. キューで直列化する
順番が重要で、しかも全部処理したい場合は、排他よりも構造的に同時実行できない形にする方が分かりやすいことがあります。その典型がキューです。
考え方は単純で、処理要求が来るたびにすぐ実行するのではなく、キューに積み、専用の1本の処理ループが順番に消化します。
private readonly Channel<long> _channel = Channel.CreateUnbounded<long>();
public async Task EnqueueAsync(long total)
{
await _channel.Writer.WriteAsync(total);
}
public async Task ProcessLoopAsync()
{
await foreach (var total in _channel.Reader.ReadAllAsync())
{
await _influxNotifier.SendAsync(total);
}
}
この方式の利点は、「ちゃんと全部処理する」「順序を守る」「同時実行が起こらない」をまとめて満たしやすいことです。ログ書き込み、ファイル更新、順番が意味を持つAPI送信などに向いています。
5. 共有しない設計にする
そもそも競合は「共有されたものを書き換える」から発生します。ならば、最初から共有しない、あるいは変更不能にする、という考え方もあります。
代表的な考え方
- 不変オブジェクトを使う
- 毎回新しいインスタンスを作る
- 共有コレクションを直接更新しない
- 読み取り専用の構造に寄せる
これは排他制御のような対症療法ではなく、設計そのものを安全側に寄せる方法です。多少のコストはありますが、根本的に事故が減ります。
6. スレッドセーフな型や原子的操作を使う
競合の対象が単純なカウンタや辞書であれば、より専用の手段が使えます。
代表例
ConcurrentDictionary<TKey, TValue>ConcurrentQueue<T>ConcurrentBag<T>Interlocked.Increment
単純なカウンタ更新なら Interlocked が非常に有効ですし、複数スレッドから辞書を更新するなら ConcurrentDictionary が向いています。すべてを lock で包むより、対象に合った道具を使う方が安全かつ明快です。
7. UIに関する競合は「UIスレッドだけで触る」
WPFのUIに関しては、排他制御の前にもっと強いルールがあります。UIはUIスレッドだけで触るという原則です。これは競合対策でもあります。
バックグラウンド側で処理した結果を画面へ反映したい場合は、Dispatcher を使ってUIスレッドへ戻します。こうすることで、複数スレッドからUI状態を触る事態を避けられます。
実務での選び分け
競合対策は万能の1パターンがあるわけではなく、目的に応じて選び分けます。
ざっくりした選び方
| 状況 | 向いている方法 |
|---|---|
| 同時実行されたら困る | SemaphoreSlim |
| 順番通りに全部処理したい | キュー + ワーカー |
| 単純なカウンタ | Interlocked |
| 同期処理の共有変数 | lock |
| UI状態 | UIスレッドだけで触る |
特に async / await を使うコードでは、まず lock より SemaphoreSlim を検討する、というのはかなり実践的な指針です。
まとめ
非同期処理では、待ち時間中に別の処理が進めるようになるため、共有リソースへのアクセス競合が起きやすくなります。ただし、原因は async / await そのものではなく、同じものを複数の流れが同時に触ることです。
その対策としては、
- 同時実行させない
- 排他制御する
- キューで直列化する
- 共有しない設計に寄せる
- 専用のスレッドセーフな型を使う
といった方法があります。
非同期コードでは特に、「lock ではなく SemaphoreSlim」「必要ならキューで直列化」という考え方を持っておくと、実務でかなり役に立ちます。競合対策は後から慌てて足すより、どのリソースを誰がいつ触るのかを先に設計することが大切です。