C#のセマフォとは?Semaphore/SemaphoreSlimの違いと使い方をサンプルで徹底解説
はじめに
C#で並列処理や非同期処理を書くとき、「同時に実行してよい処理の数を制限したい」という場面はよくあります。たとえば、Web APIへのリクエストを一度に大量送信しないようにしたい、DB接続数を抑えたい、ファイル処理を数件ずつ実行したい、といったケースです。
このような場面で役立つ同期オブジェクトがセマフォです。
C#では主に Semaphore と SemaphoreSlim という2つのクラスが用意されています。どちらも「同時に入れる数」を制御するための仕組みですが、用途や性能、非同期処理への向き不向きが異なります。Microsoft公式ドキュメントでも、Semaphore は WaitOne で入場し Release で解放する同期オブジェクト、SemaphoreSlim は Wait / WaitAsync と Release を使う軽量なセマフォとして説明されています。Microsoft Learn+1
この記事では、C#のセマフォの基本から、Semaphore と SemaphoreSlim の違い、実践的なサンプル、よくあるバグ、非同期処理でのベストプラクティスまで詳しく解説します。
1. C#のセマフォとは?まず押さえる基本
1-1. セマフォは同時実行数を制限する同期オブジェクト
セマフォとは、簡単にいうと同時に処理へ入れる数を制限するための仕組みです。
たとえば、同時に3つまで処理を実行したい場合、セマフォのカウントを3に設定します。処理を開始するタスクは、まずセマフォに入れるか確認します。空きがあれば処理を開始し、空きがなければ待機します。処理が終わったらセマフォを解放し、次に待っている処理が実行できるようになります。
イメージとしては、駐車場の空き台数に近いです。駐車場に3台分の空きがあれば、車は最大3台まで入れます。満車になったら、どれか1台が出るまで次の車は待つ必要があります。
C#のセマフォも同じように、内部に「現在入れる残り数」を持っています。処理が入るとカウントが減り、処理が出るとカウントが増えます。
1-2. lock/Monitor/Mutexとの違い
C#にはセマフォ以外にも、排他制御に使える仕組みがあります。代表的なものが lock、Monitor、Mutex です。
lock は、1つの共有リソースに対して同時に1スレッドだけ入れるようにする構文です。内部的には Monitor を使っています。つまり、lock は「1人だけ通す」ための仕組みです。
一方、セマフォは「最大N人まで通す」ことができます。たとえば、同時実行数を1にすれば lock に近い使い方もできますが、セマフォの本来の強みは2以上の同時実行数を制御できることです。
Mutex は、lock より重めの同期オブジェクトで、プロセス間同期にも使えます。たとえば、アプリケーションを多重起動させたくない場合などに使われます。
違いを整理すると、次のようになります。
| 仕組み | 主な用途 | 同時に入れる数 | プロセス間同期 | async/awaitとの相性 |
|---|---|---|---|---|
lock | 共有変数の排他制御 | 1 | 不可 | 悪い |
Monitor | lock の詳細制御 | 1 | 不可 | 悪い |
Mutex | 所有権つきの排他制御 | 1 | 可能 | あまり良くない |
Semaphore | 同時実行数制限、プロセス間同期 | N | 可能 | あまり良くない |
SemaphoreSlim | 同一プロセス内の軽量な同時実行数制限 | N | 不可 | 良い |
非同期処理で同時実行数を制限したい場合は、基本的に SemaphoreSlim を使うのが一般的です。
1-3. セマフォが必要になる典型的な場面
セマフォが必要になるのは、主に「処理を並列化したいが、無制限には実行したくない」場面です。
たとえば、次のようなケースです。
Web APIへの同時リクエスト数を制限したい
DBへの同時アクセス数を抑えたい
ファイルの読み書きを同時に実行しすぎないようにしたい
外部サービスのレート制限に引っかからないようにしたい
大量のタスクを並列実行しつつ、CPUやメモリの負荷を抑えたい
バックグラウンド処理で一度に処理する件数を制限したい
たとえば、100件のURLへアクセスする処理があるとして、単純に Task.WhenAll で一斉に実行すると、100件のリクエストが同時に飛んでしまいます。これは相手サーバーへの負荷にもなりますし、自分のアプリケーション側でもソケットやメモリを消費します。
そこでセマフォを使えば、「最大5件ずつ処理する」といった制御ができます。
1-4. C#で使えるSemaphoreとSemaphoreSlimの概要
C#でセマフォを扱う代表的なクラスは、次の2つです。
1つ目は System.Threading.Semaphore です。これはOSの同期オブジェクトを利用するセマフォで、名前付きセマフォを使えばプロセス間同期もできます。WaitOne で待機し、Release で解放します。Microsoft Learn+1
2つ目は System.Threading.SemaphoreSlim です。これは同一プロセス内での利用を想定した軽量なセマフォです。Wait、WaitAsync、Release を使って制御します。特に WaitAsync が使えるため、async / await と組み合わせる場合に便利です。Microsoft Learn+1
現在のC#開発では、プロセス間同期が必要なければ、基本的には SemaphoreSlim を選ぶと考えてよいでしょう。
2. C#におけるSemaphoreの基本的な使い方
2-1. Semaphoreクラスの役割と特徴
Semaphore クラスは、複数のスレッドやプロセスから共有リソースへのアクセス数を制御するための同期オブジェクトです。
特徴は次のとおりです。
同時に入れるスレッド数を指定できる
WaitOneでセマフォに入るReleaseでセマフォを解放する名前付きセマフォを使うとプロセス間同期ができる
WaitHandleを継承しているSemaphoreSlimより重いが、OSレベルの同期に対応できる
Semaphore は、複数プロセスから同じリソースを扱う場合や、名前付き同期オブジェクトが必要な場合に使います。
2-2. Semaphoreのコンストラクタと初期カウント・最大カウント
Semaphore の基本的なコンストラクタは次の形です。
C#var semaphore = new Semaphore(initialCount: 3, maximumCount: 3);
initialCount は、最初に何個の空きがあるかを表します。maximumCount は、セマフォの最大カウントです。
たとえば、次のコードでは最大3つのスレッドが同時にセマフォへ入れます。
C#var semaphore = new Semaphore(3, 3);
最初のカウントは3です。1つのスレッドが WaitOne に成功すると、カウントは2になります。さらに2つのスレッドが入ると、カウントは0になります。この状態で次のスレッドが WaitOne を呼ぶと、どれかのスレッドが Release するまで待機します。
2-3. WaitOneで待機する仕組み
Semaphore では、セマフォに入るときに WaitOne を呼びます。
C#semaphore.WaitOne();
カウントが1以上であれば、WaitOne はすぐに成功し、カウントを1減らします。カウントが0であれば、空きが出るまで呼び出し元のスレッドはブロックされます。
タイムアウトを指定することもできます。
C#if (semaphore.WaitOne(TimeSpan.FromSeconds(5)))
{
// セマフォに入れた場合の処理
}
else
{
// 5秒以内に入れなかった場合の処理
}
ブロッキング処理なので、非同期メソッドの中で安易に使うとスレッドを塞いでしまいます。async / await を使う場合は、後述する SemaphoreSlim.WaitAsync を使うほうが適しています。
2-4. Releaseでスレッドを解放する仕組み
セマフォに入った処理が終わったら、必ず Release を呼びます。
C#semaphore.Release();
Release を呼ぶと、セマフォのカウントが1増えます。待機しているスレッドがあれば、そのうちの1つがセマフォに入れるようになります。
重要なのは、WaitOne に成功したら、必ず対応する Release を呼ぶことです。Release を忘れると、カウントが戻らず、待機中のスレッドがずっと進めない状態になる可能性があります。
そのため、通常は try-finally で次のように書きます。
C#semaphore.WaitOne();
try
{
// 保護したい処理
}
finally
{
semaphore.Release();
}
例外が発生しても finally が実行されるため、セマフォの解放漏れを防げます。
2-5. Semaphoreを使った基本サンプルコード
次のサンプルでは、同時に実行できる処理を2つまでに制限しています。
C#using System;
using System.Threading;
using System.Threading.Tasks;
class Program
{
private static readonly Semaphore Semaphore = new Semaphore(2, 2);
static async Task Main()
{
var tasks = new Task[5];
for (int i = 0; i < tasks.Length; i++)
{
int taskId = i + 1;
tasks[i] = Task.Run(() => DoWork(taskId));
}
await Task.WhenAll(tasks);
}
static void DoWork(int id)
{
Console.WriteLine($"Task {id}: 待機中");
Semaphore.WaitOne();
try
{
Console.WriteLine($"Task {id}: 開始");
Thread.Sleep(2000);
Console.WriteLine($"Task {id}: 終了");
}
finally
{
Semaphore.Release();
}
}
}
このコードでは、5つのタスクを開始していますが、同時に実行される DoWork の中核処理は最大2つまでです。
Semaphore(2, 2) によって、最初に2つのタスクだけがセマフォに入れます。残りのタスクは、先に入ったタスクが Release するまで待機します。
2-6. 名前付きセマフォでプロセス間同期を行う方法
Semaphore の大きな特徴は、名前付きセマフォを使ってプロセス間同期ができることです。
C#using System;
using System.Threading;
class Program
{
static void Main()
{
using var semaphore = new Semaphore(
initialCount: 1,
maximumCount: 1,
name: "Global\\MyNamedSemaphore");
Console.WriteLine("セマフォの取得を待っています...");
semaphore.WaitOne();
try
{
Console.WriteLine("セマフォを取得しました。Enterキーで解放します。");
Console.ReadLine();
}
finally
{
semaphore.Release();
}
}
}
このプログラムを複数起動すると、同じ名前のセマフォを共有できます。maximumCount が1なので、同時にクリティカルセクションへ入れるプロセスは1つだけです。
名前付きセマフォは、複数アプリケーション間で共有リソースを制御したい場合に使えます。ただし、セキュリティやアクセス権の考慮が必要です。Microsoftのドキュメントでも、名前付きセマフォは作成者以外のユーザーが開いて使える場合があるため、必要に応じてアクセス制御を設定することが説明されています。Microsoft Learn+1
3. C#におけるSemaphoreSlimの基本的な使い方
3-1. SemaphoreSlimクラスの役割と特徴
SemaphoreSlim は、同一プロセス内での利用を想定した軽量なセマフォです。Semaphore と同じように同時実行数を制限できますが、より軽量で、特に非同期処理と相性が良いのが特徴です。
SemaphoreSlim の主な特徴は次のとおりです。
同一プロセス内の同期に向いている
Waitで同期的に待機できるWaitAsyncで非同期に待機できるReleaseで解放する名前付きセマフォには対応していない
Semaphoreより軽量Web API呼び出しやバックグラウンド処理の同時実行数制限に向いている
SemaphoreSlim は、C#で非同期処理の同時実行数を制限したい場合の定番です。
3-2. Wait/Releaseを使った同期処理
同期処理で SemaphoreSlim を使う場合は、Wait と Release を使います。
C#var semaphore = new SemaphoreSlim(3, 3);
semaphore.Wait();
try
{
// 同時実行数を制限したい処理
}
finally
{
semaphore.Release();
}
Wait は、カウントが空くまで現在のスレッドをブロックします。同期メソッドの中では使えますが、非同期メソッドではなるべく WaitAsync を使うべきです。
3-3. WaitAsyncを使った非同期処理
SemaphoreSlim の大きな利点は、WaitAsync が使えることです。
C#await semaphore.WaitAsync();
WaitAsync は、セマフォに入れるまで非同期に待機します。Wait のようにスレッドをブロックし続けるのではなく、await によって待機できます。Microsoft公式ドキュメントでも、WaitAsync は SemaphoreSlim に非同期で入るためのメソッドとして説明されています。Microsoft Learn
非同期処理では、次のように書きます。
C#await semaphore.WaitAsync();
try
{
await DoSomethingAsync();
}
finally
{
semaphore.Release();
}
await する処理をセマフォで囲むことで、同時に実行される非同期処理の数を制限できます。
3-4. SemaphoreSlimを使った基本サンプルコード
次のサンプルでは、同時実行数を3つまでに制限しています。
C#using System;
using System.Threading;
using System.Threading.Tasks;
class Program
{
private static readonly SemaphoreSlim Semaphore = new SemaphoreSlim(3, 3);
static async Task Main()
{
var tasks = new Task[10];
for (int i = 0; i < tasks.Length; i++)
{
int id = i + 1;
tasks[i] = DoWorkAsync(id);
}
await Task.WhenAll(tasks);
}
static async Task DoWorkAsync(int id)
{
Console.WriteLine($"Task {id}: 待機中");
await Semaphore.WaitAsync();
try
{
Console.WriteLine($"Task {id}: 開始");
await Task.Delay(1000);
Console.WriteLine($"Task {id}: 終了");
}
finally
{
Semaphore.Release();
}
}
}
10個のタスクを作っていますが、同時に「開始」できるのは最大3つです。Task.Delay は非同期に待機するため、Thread.Sleep のようにスレッドを占有しません。
このように、SemaphoreSlim と async / await を組み合わせると、効率的に同時実行数を制限できます。
3-5. using/try-finallyで安全に解放する書き方
SemaphoreSlim 自体は IDisposable を実装しているため、不要になったら Dispose できます。ただし、セマフォの取得と解放には using ではなく、通常は try-finally を使います。
C#await semaphore.WaitAsync();
try
{
await ProcessAsync();
}
finally
{
semaphore.Release();
}
メソッド内だけで完結するセマフォであれば、次のように using でインスタンスの破棄を管理できます。
C#using var semaphore = new SemaphoreSlim(3, 3);
var tasks = items.Select(async item =>
{
await semaphore.WaitAsync();
try
{
await ProcessAsync(item);
}
finally
{
semaphore.Release();
}
});
await Task.WhenAll(tasks);
注意点として、using はセマフォオブジェクトの破棄を管理するものであり、WaitAsync で取得した権利を自動的に Release してくれるものではありません。取得したら必ず Release する、というルールは変わりません。
4. SemaphoreとSemaphoreSlimの違い
4-1. 最大の違いはプロセス間同期に対応しているか
Semaphore と SemaphoreSlim の最大の違いは、プロセス間同期に対応しているかどうかです。
Semaphore は名前付きセマフォを作成できるため、複数プロセス間で同じセマフォを共有できます。たとえば、複数のアプリケーションから同じファイルや外部リソースを扱う場合に利用できます。
一方、SemaphoreSlim は同一プロセス内での使用を想定した軽量なセマフォです。名前付きセマフォには対応していないため、プロセス間同期には使えません。
4-2. パフォーマンスと軽量性の違い
SemaphoreSlim は名前のとおり、Semaphore より軽量です。Microsoft公式ドキュメントでも、SemaphoreSlim は単一プロセス内で短時間の待機に使える軽量で高速なセマフォとして説明されています。Microsoft Learn
プロセス間同期が不要で、単にアプリケーション内のタスク数を制限したいだけなら、SemaphoreSlim のほうが適しています。
ただし、軽量だからといって万能ではありません。待機時間が長い処理や、プロセスをまたいだ制御が必要な処理では、用途に応じて Semaphore や別の仕組みを検討する必要があります。
4-3. 非同期処理への対応の違い
非同期処理との相性は、SemaphoreSlim のほうが圧倒的に優れています。
Semaphore は WaitOne を使いますが、これはスレッドをブロックします。非同期メソッド内で使うと、待機中のスレッドが占有されやすくなります。
一方、SemaphoreSlim には WaitAsync があり、await によって非同期に待機できます。
C#await semaphoreSlim.WaitAsync();
ASP.NET Core、Worker Service、バッチ処理、Web API呼び出しなど、現代的なC#アプリケーションでは非同期処理を使うことが多いため、同時実行数制限には SemaphoreSlim がよく使われます。
4-4. 名前付きセマフォを使えるかどうか
Semaphore は名前付きセマフォを作成できます。
C#var semaphore = new Semaphore(1, 1, "Global\\MySemaphore");
これにより、別プロセスから同じ名前のセマフォを開いて利用できます。
一方、SemaphoreSlim には名前を指定するコンストラクタがありません。そのため、プロセス間で共有することはできません。
4-5. 使い分け早見表
| 比較項目 | Semaphore | SemaphoreSlim |
|---|---|---|
| 主な用途 | OSレベルの同期、プロセス間同期 | 同一プロセス内の軽量な同時実行数制限 |
| プロセス間同期 | 可能 | 不可 |
| 名前付きセマフォ | 使える | 使えない |
| 非同期待機 | 基本的に不得意 | WaitAsync が使える |
| パフォーマンス | 比較的重い | 軽量 |
async / await との相性 | 低い | 高い |
| Web APIの同時実行数制限 | あまり向かない | 向いている |
| DBアクセス数の制御 | ケースによる | 向いている |
| ファイル処理の並列数制御 | ケースによる | 向いている |
4-6. 基本的にはSemaphoreSlimを選ぶべき理由
C#でセマフォを使う多くのケースでは、プロセス間同期ではなく、同一アプリケーション内の同時実行数制限が目的です。
たとえば、次のような用途です。
Task.WhenAllで大量の非同期処理を流すが、同時実行数を制限したいAPIリクエストを最大5件ずつにしたい
DBへのアクセスを最大10件までにしたい
バックグラウンド処理で処理数を制御したい
これらはすべて SemaphoreSlim が得意な領域です。
そのため、次のように考えるとわかりやすいです。
プロセス間同期が必要なら
Semaphore非同期処理の同時実行数制限なら
SemaphoreSlim迷ったらまず
SemaphoreSlim
特に async / await を使うコードでは、SemaphoreSlim.WaitAsync を使うのが基本です。
5. 実践サンプルで学ぶC#セマフォの活用例
5-1. 同時実行数を3つまでに制限するサンプル
まずは、最も基本的な「同時実行数を3つまでに制限する」サンプルです。
C#using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
using var semaphore = new SemaphoreSlim(3, 3);
var tasks = Enumerable.Range(1, 10)
.Select(async id =>
{
await semaphore.WaitAsync();
try
{
Console.WriteLine($"{id}: 開始");
await Task.Delay(1000);
Console.WriteLine($"{id}: 終了");
}
finally
{
semaphore.Release();
}
});
await Task.WhenAll(tasks);
}
}
このコードでは10個の処理を作っていますが、同時に実行されるのは最大3つです。
SemaphoreSlim(3, 3) の1つ目の 3 は初期カウント、2つ目の 3 は最大カウントです。基本的には、同時実行数制限では両方同じ値にしておくとわかりやすいです。
5-2. 複数タスクの並列処理を制御するサンプル
大量のデータを並列処理したい場合にも、SemaphoreSlim は便利です。
C#using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
var items = new List<string>
{
"A", "B", "C", "D", "E", "F", "G", "H"
};
using var semaphore = new SemaphoreSlim(2, 2);
var tasks = items.Select(item => ProcessWithLimitAsync(item, semaphore));
await Task.WhenAll(tasks);
}
static async Task ProcessWithLimitAsync(string item, SemaphoreSlim semaphore)
{
await semaphore.WaitAsync();
try
{
Console.WriteLine($"{item} の処理を開始");
await Task.Delay(1500);
Console.WriteLine($"{item} の処理を終了");
}
finally
{
semaphore.Release();
}
}
}
この例では、複数のアイテムを処理しますが、同時に処理されるのは最大2件です。
大量のタスクを作成しても、実際に重い処理へ入れる数を制限できるため、システム全体の負荷をコントロールしやすくなります。
5-3. Web APIへの同時リクエスト数を制限するサンプル
Web APIへ多数のリクエストを送る場合、同時実行数を制限しないと、相手サーバーに負荷をかけたり、レート制限に引っかかったりする可能性があります。
C#using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
class Program
{
private static readonly HttpClient HttpClient = new HttpClient();
static async Task Main()
{
var urls = new[]
{
"https://example.com/api/1",
"https://example.com/api/2",
"https://example.com/api/3",
"https://example.com/api/4",
"https://example.com/api/5"
};
using var semaphore = new SemaphoreSlim(2, 2);
var tasks = urls.Select(url => FetchAsync(url, semaphore));
await Task.WhenAll(tasks);
}
static async Task FetchAsync(string url, SemaphoreSlim semaphore)
{
await semaphore.WaitAsync();
try
{
Console.WriteLine($"取得開始: {url}");
var response = await HttpClient.GetAsync(url);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
Console.WriteLine($"取得完了: {url}, 文字数: {content.Length}");
}
finally
{
semaphore.Release();
}
}
}
このコードでは、APIリクエストの同時実行数を2件までに制限しています。
実務では、外部APIの利用規約やレート制限、サーバーの応答速度、自社システムのリソース状況に応じて同時実行数を調整します。
5-4. ファイル処理やDBアクセスの負荷を抑えるサンプル
ファイル処理やDBアクセスも、無制限に並列化するとかえって遅くなることがあります。I/O待ちが多い処理でも、接続数やファイルハンドル、ディスク負荷には限界があります。
C#using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
var files = Directory.GetFiles("logs", "*.txt");
using var semaphore = new SemaphoreSlim(4, 4);
var tasks = files.Select(file => ProcessFileAsync(file, semaphore));
await Task.WhenAll(tasks);
}
static async Task ProcessFileAsync(string filePath, SemaphoreSlim semaphore)
{
await semaphore.WaitAsync();
try
{
var text = await File.ReadAllTextAsync(filePath);
var lineCount = text.Split(Environment.NewLine).Length;
Console.WriteLine($"{Path.GetFileName(filePath)}: {lineCount} 行");
}
finally
{
semaphore.Release();
}
}
}
この例では、ファイル処理の同時実行数を4つに制限しています。
DBアクセスの場合も同じ考え方で使えます。
C#static async Task SaveWithLimitAsync(
DataItem item,
SemaphoreSlim semaphore,
CancellationToken cancellationToken)
{
await semaphore.WaitAsync(cancellationToken);
try
{
await SaveToDatabaseAsync(item, cancellationToken);
}
finally
{
semaphore.Release();
}
}
DB接続プールの上限や外部サービスの制限を考慮して、適切な同時実行数を設定することが大切です。
5-5. async/awaitとSemaphoreSlimを組み合わせた実践例
実務では、SemaphoreSlim、async / await、CancellationToken を組み合わせて使うことが多いです。
C#using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
public class ApiProcessor
{
private readonly HttpClient _httpClient;
private readonly SemaphoreSlim _semaphore;
public ApiProcessor(HttpClient httpClient, int maxConcurrency)
{
_httpClient = httpClient;
_semaphore = new SemaphoreSlim(maxConcurrency, maxConcurrency);
}
public async Task ProcessAsync(
IEnumerable<string> urls,
CancellationToken cancellationToken)
{
var tasks = urls.Select(url => ProcessOneAsync(url, cancellationToken));
await Task.WhenAll(tasks);
}
private async Task ProcessOneAsync(
string url,
CancellationToken cancellationToken)
{
await _semaphore.WaitAsync(cancellationToken);
try
{
using var response = await _httpClient.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync(cancellationToken);
Console.WriteLine($"{url}: {body.Length} bytes");
}
finally
{
_semaphore.Release();
}
}
}
この例では、コンストラクタで最大同時実行数を受け取り、その数を SemaphoreSlim に設定しています。
WaitAsync(cancellationToken) を使っているため、アプリケーション終了時やリクエスト中断時に待機をキャンセルできます。
6. C#セマフォでよくある注意点とバグ
6-1. Release忘れによるデッドロック
最も多いミスは、Release を呼び忘れることです。
C#await semaphore.WaitAsync();
// 例外が起きると Release されない
await DoSomethingAsync();
semaphore.Release();
この書き方では、DoSomethingAsync で例外が発生した場合、Release が実行されません。その結果、セマフォのカウントが戻らず、後続の処理が待ち続ける可能性があります。
正しくは、必ず try-finally を使います。
C#await semaphore.WaitAsync();
try
{
await DoSomethingAsync();
}
finally
{
semaphore.Release();
}
セマフォを使うときは、「取得したら必ず解放する」が鉄則です。
6-2. Releaseの呼びすぎによるSemaphoreFullException
Release を呼びすぎると、セマフォの最大カウントを超えてしまい、SemaphoreFullException が発生することがあります。
C#var semaphore = new SemaphoreSlim(1, 1);
await semaphore.WaitAsync();
semaphore.Release();
semaphore.Release(); // 呼びすぎ
SemaphoreSlim(1, 1) の最大カウントは1です。すでにカウントが1に戻っている状態でさらに Release すると、最大カウントを超えるため例外になります。
注意したいのは、new SemaphoreSlim(1) のように最大カウントを省略した場合です。この場合、意図しない Release の呼びすぎを検出しにくくなります。そのため、同時実行数制限では次のように最大カウントも指定することをおすすめします。
C#var semaphore = new SemaphoreSlim(1, 1);
6-3. 例外発生時にセマフォが解放されない問題
例外発生時にセマフォが解放されない問題は、実務で非常に厄介です。
たとえば、次のようなコードは危険です。
C#await semaphore.WaitAsync();
await CallApiAsync();
await SaveAsync();
semaphore.Release();
CallApiAsync または SaveAsync で例外が発生すると、Release が呼ばれません。
正しい書き方は次のとおりです。
C#await semaphore.WaitAsync();
try
{
await CallApiAsync();
await SaveAsync();
}
finally
{
semaphore.Release();
}
catch で例外を処理するかどうかに関係なく、セマフォの解放は finally に置くのが基本です。
6-4. CurrentCountを過信してはいけない理由
SemaphoreSlim には CurrentCount プロパティがあります。これは現在セマフォに入れる残り数を表します。Microsoft公式ドキュメントでも、CurrentCount は Wait / WaitAsync で減り、Release で増える値として説明されています。Microsoft Learn
ただし、CurrentCount を見てから処理を判断するコードは危険です。
C#if (semaphore.CurrentCount > 0)
{
await semaphore.WaitAsync();
}
このコードは、一見すると空きがある場合だけ待機するように見えます。しかし、CurrentCount を確認した直後に別のタスクがセマフォを取得する可能性があります。その場合、結局 WaitAsync で待機することになります。
並行処理では、CurrentCount はあくまで「その瞬間の目安」です。制御ロジックの中心に使うべきではありません。
空きがある場合だけ入りたいなら、タイムアウト0の WaitAsync などを検討します。
C#if (await semaphore.WaitAsync(0))
{
try
{
await DoSomethingAsync();
}
finally
{
semaphore.Release();
}
}
else
{
Console.WriteLine("現在は空きがありません。");
}
6-5. ConfigureAwaitやキャンセルトークンを使う際の注意点
ライブラリコードでは、ConfigureAwait(false) を使うことがあります。
C#await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
この書き方自体は問題ありません。ただし、WaitAsync に成功したあとの処理では、例外やキャンセルが起きても必ず Release されるようにする必要があります。
C#await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await DoSomethingAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
semaphore.Release();
}
重要なのは、WaitAsync がキャンセルされた場合です。WaitAsync がキャンセルされて例外が発生した場合、セマフォは取得できていません。そのため、WaitAsync の前から try-finally をかけて無条件に Release すると、取得していないのに解放してしまう危険があります。
避けるためには、次のように WaitAsync が成功した後に try-finally に入る書き方がわかりやすいです。
C#await semaphore.WaitAsync(cancellationToken);
try
{
await DoSomethingAsync(cancellationToken);
}
finally
{
semaphore.Release();
}
6-6. Disposeのタイミングとリソース解放
Semaphore や SemaphoreSlim は不要になったら Dispose できます。
ただし、まだ待機中のタスクや使用中の処理がある状態で Dispose してはいけません。使用中に破棄すると、後続の Wait、WaitAsync、Release などで例外が発生する可能性があります。
メソッド内で一時的に使う場合は、すべてのタスクが完了してからスコープを抜けるようにします。
C#using var semaphore = new SemaphoreSlim(3, 3);
var tasks = items.Select(item => ProcessAsync(item, semaphore));
await Task.WhenAll(tasks);
// ここまで完了してから Dispose される
クラスのフィールドとして保持する場合は、そのクラスのライフサイクルに合わせて破棄します。ASP.NET Coreのシングルトンサービスで使う場合は、アプリケーション終了時まで使われる可能性があるため、安易に途中で Dispose しないようにします。
7. SemaphoreSlimを使った非同期処理のベストプラクティス
7-1. WaitAsyncを優先して使う
非同期処理では、Wait ではなく WaitAsync を使います。
悪い例です。
C#semaphore.Wait();
await DoSomethingAsync();
semaphore.Release();
このコードでは、Wait がスレッドをブロックします。
良い例です。
C#await semaphore.WaitAsync();
try
{
await DoSomethingAsync();
}
finally
{
semaphore.Release();
}
async / await を使うなら、待機も非同期にするのが基本です。
7-2. try-finallyで必ずReleaseする
SemaphoreSlim を使うときの最重要ルールは、try-finally で必ず Release することです。
C#await semaphore.WaitAsync();
try
{
await ProcessAsync();
}
finally
{
semaphore.Release();
}
これは、API呼び出し、DB保存、ファイル処理など、どの用途でも同じです。例外が発生してもセマフォを解放できるようにしておくことで、デッドロックや処理停止を防げます。
7-3. タイムアウトを設定して待機しすぎを防ぐ
セマフォの待機が長くなりすぎると、処理全体が詰まってしまうことがあります。そのような場合は、タイムアウト付きの WaitAsync を使います。
C#if (await semaphore.WaitAsync(TimeSpan.FromSeconds(10)))
{
try
{
await ProcessAsync();
}
finally
{
semaphore.Release();
}
}
else
{
Console.WriteLine("セマフォの取得がタイムアウトしました。");
}
タイムアウト付きの WaitAsync は、一定時間内にセマフォを取得できたかどうかを bool で返します。
外部サービスへのアクセスやバックグラウンドジョブでは、無限に待つよりも、タイムアウトさせてリトライやスキップを検討するほうが安全な場合があります。
7-4. CancellationTokenでキャンセル可能にする
アプリケーション終了時やリクエスト中断時に処理を止めたい場合は、CancellationToken を使います。
C#await semaphore.WaitAsync(cancellationToken);
try
{
await ProcessAsync(cancellationToken);
}
finally
{
semaphore.Release();
}
タイムアウトとキャンセルを組み合わせることもできます。
C#if (await semaphore.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken))
{
try
{
await ProcessAsync(cancellationToken);
}
finally
{
semaphore.Release();
}
}
else
{
Console.WriteLine("待機がタイムアウトしました。");
}
WaitAsync にはタイムアウトや CancellationToken を受け取るオーバーロードがあります。待機しすぎを防ぎたい場合や、処理を中断可能にしたい場合に便利です。Microsoft Learn
7-5. ASP.NET Coreやバックグラウンド処理での使いどころ
ASP.NET Coreでは、SemaphoreSlim を使って特定処理の同時実行数を制限できます。
たとえば、外部APIへのアクセス数をアプリケーション全体で制限したい場合です。
C#public class ExternalApiClient
{
private readonly HttpClient _httpClient;
private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(5, 5);
public ExternalApiClient(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<string> GetAsync(string url, CancellationToken cancellationToken)
{
await _semaphore.WaitAsync(cancellationToken);
try
{
return await _httpClient.GetStringAsync(url, cancellationToken);
}
finally
{
_semaphore.Release();
}
}
}
このようなクラスをDIコンテナでシングルトンまたは適切なスコープで管理すれば、外部APIへの同時アクセス数を制御できます。
バックグラウンドサービスでも同様です。
C#public class Worker
{
private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(3, 3);
public async Task RunAsync(IEnumerable<Job> jobs, CancellationToken stoppingToken)
{
var tasks = jobs.Select(job => ExecuteJobAsync(job, stoppingToken));
await Task.WhenAll(tasks);
}
private async Task ExecuteJobAsync(Job job, CancellationToken cancellationToken)
{
await _semaphore.WaitAsync(cancellationToken);
try
{
await job.ExecuteAsync(cancellationToken);
}
finally
{
_semaphore.Release();
}
}
}
重い処理を無制限に並列実行しないことで、CPU、メモリ、DB、外部APIへの負荷を抑えられます。
7-6. Parallel.ForEachAsyncとの使い分け
.NETでは、Parallel.ForEachAsync を使って並列処理を書くこともできます。ParallelOptions.MaxDegreeOfParallelism を指定すれば、並列度を制御できます。Microsoft公式ドキュメントでも、MaxDegreeOfParallelism は Parallel メソッドで実行される同時操作数に影響し、正の値を指定するとその数に制限されると説明されています。Microsoft Learn+1
たとえば、次のように書けます。
C#await Parallel.ForEachAsync(
items,
new ParallelOptions
{
MaxDegreeOfParallelism = 3,
CancellationToken = cancellationToken
},
async (item, ct) =>
{
await ProcessAsync(item, ct);
});
では、SemaphoreSlim と Parallel.ForEachAsync はどう使い分ければよいのでしょうか。
単純に「コレクションを最大N並列で処理したい」だけなら、Parallel.ForEachAsync がシンプルです。
一方で、次のような場合は SemaphoreSlim が向いています。
複数のメソッドや複数の場所から同じ同時実行数制限を共有したい
APIクライアント単位で同時リクエスト数を制限したい
処理の一部だけを制限したい
Task.WhenAllや独自のキュー処理と組み合わせたいリソース単位で細かく制御したい
つまり、ループ全体の並列度制御なら Parallel.ForEachAsync、特定リソースへの入口制御なら SemaphoreSlim、と考えるとわかりやすいです。
8. C#のセマフォに関するFAQ
8-1. SemaphoreSlimはスレッドセーフ?
SemaphoreSlim は複数スレッドから Wait、WaitAsync、Release を呼び出して使うための同期オブジェクトです。通常の同時実行数制限では、複数タスクから共有して使えます。
ただし、スレッドセーフだからといって、Release を何度呼んでもよいわけではありません。Wait または WaitAsync で取得した回数に対応して Release する必要があります。
また、CurrentCount を使った判定に依存するコードは競合しやすいため避けるべきです。
8-2. SemaphoreSlimとlockはどちらを使うべき?
目的によって使い分けます。
共有変数やコレクションへのアクセスを一瞬だけ排他制御したいなら、lock が適しています。
C#lock (_syncObject)
{
counter++;
}
一方、非同期処理を含む処理や、同時実行数を2以上に制限したい場合は SemaphoreSlim が向いています。
C#await _semaphore.WaitAsync();
try
{
await ProcessAsync();
}
finally
{
_semaphore.Release();
}
特に、lock の中で await は使えません。非同期処理の同時実行数制限には SemaphoreSlim を使うのが基本です。
8-3. SemaphoreSlimでデッドロックは起きる?
起きる可能性があります。
代表的な原因は次のとおりです。
Releaseを呼び忘れる例外発生時に
Releaseされない同じ処理内でセマフォを二重取得する
同期ブロッキングと非同期処理を混ぜる
取得順序が複雑で待ち合いになる
たとえば、次のようなコードは危険です。
C#await semaphore.WaitAsync();
try
{
await SomeMethodAsync();
}
finally
{
semaphore.Release();
}
この SomeMethodAsync の内部でも同じセマフォを取得しようとすると、条件によっては待ち続ける可能性があります。
セマフォのスコープはできるだけ小さくし、どの処理が取得・解放するのかを明確にすることが重要です。
8-4. 同時実行数はいくつに設定すべき?
最適な同時実行数は、処理の内容によって変わります。
CPU負荷が高い処理であれば、CPUコア数を意識する必要があります。I/O待ちが多い処理であれば、CPUコア数より多めにしても効果が出る場合があります。
ただし、Web API、DB、ファイルI/Oなどは外部リソースの制限を受けるため、単純に大きくすればよいわけではありません。
目安としては、次のように考えます。
| 処理内容 | 同時実行数の考え方 |
|---|---|
| CPU負荷が高い処理 | CPUコア数前後から検討 |
| Web API呼び出し | 相手APIのレート制限を優先 |
| DBアクセス | 接続プールやDB負荷を考慮 |
| ファイルI/O | ディスク性能やファイル数を考慮 |
| バックグラウンドジョブ | サーバー全体の負荷を見て調整 |
最初は小さめの値から始め、ログやメトリクスを見ながら調整するのが安全です。
8-5. SemaphoreSlimはシングルトンで使ってよい?
用途によっては、シングルトンで使って問題ありません。
たとえば、アプリケーション全体で外部APIへの同時アクセス数を5件に制限したい場合、APIクライアント内に SemaphoreSlim を保持し、そのクライアントをシングルトンとして使う設計は自然です。
C#public class LimitedApiClient
{
private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(5, 5);
public async Task CallAsync(CancellationToken cancellationToken)
{
await _semaphore.WaitAsync(cancellationToken);
try
{
await SendRequestAsync(cancellationToken);
}
finally
{
_semaphore.Release();
}
}
}
ただし、何を制限したいのかを明確にする必要があります。
ユーザーごとに制限したいのか、アプリケーション全体で制限したいのか、処理種別ごとに制限したいのかによって、SemaphoreSlim を持つスコープは変わります。
8-6. C#で非同期処理の同時実行数を制限するなら何を使うべき?
多くの場合、SemaphoreSlim を使うのがよいです。
理由は次のとおりです。
WaitAsyncが使えるasync/awaitと相性が良い同一プロセス内の制御では軽量
Task.WhenAllと組み合わせやすいWeb API、DB、ファイル処理などの同時実行数制限に使いやすい
単純なコレクション処理なら Parallel.ForEachAsync も選択肢になります。一方で、特定リソースへのアクセス制御を複数箇所から共有したい場合は、SemaphoreSlim のほうが柔軟です。
プロセス間同期が必要な場合だけ、Semaphore を検討するとよいでしょう。
まとめ
C#のセマフォは、同時実行数を制限するための重要な同期オブジェクトです。
Semaphore と SemaphoreSlim は似ていますが、用途は異なります。Semaphore は名前付きセマフォによるプロセス間同期に対応しており、OSレベルの同期が必要な場合に使います。一方、SemaphoreSlim は同一プロセス内での軽量な同時実行数制限に向いており、WaitAsync が使えるため非同期処理と相性が良いです。
実務でC#のセマフォを使う場合は、基本的に次の方針で考えるとわかりやすいです。
非同期処理の同時実行数を制限したいなら
SemaphoreSlimプロセス間同期が必要なら
SemaphoreWaitAsync後は必ずtry-finallyでReleaseReleaseの呼び忘れと呼びすぎに注意CurrentCountを制御ロジックに使いすぎないタイムアウトや
CancellationTokenを活用する単純な並列ループなら
Parallel.ForEachAsyncも検討する
特に、Web API、DBアクセス、ファイル処理、バックグラウンドジョブなどでは、無制限な並列実行はトラブルの原因になります。SemaphoreSlim を使って適切に同時実行数を制限すれば、アプリケーションの安定性とパフォーマンスを両立しやすくなります。

