C#, .NET, 非同期処理を、Web サーバーで利用したときの効果を書いてみます。
ソースコード
ダウンロード後、zipファイルを展開し、slnx ファイルを Visual Studio 2026 以降で開いてください。
プロジェクト DbServer, WebServer, WebClient をマルチスタートアップに設定して、リリースビルドで実行してください。
DBモックサーバー
// DBモックサーバー
using System.Net;
using System.Net.Sockets;
using var listener = new TcpListener(IPAddress.Any, 9000);
listener.Start();
Console.WriteLine("DB Mock Server started on port 9000.");
for (var index = 0; ; index++)
{
var client = listener.AcceptTcpClient();
var id = index;
_ = Task.Run(async () =>
{
try
{
using var client2 = client;
using var stream = client2.GetStream();
using var reader = new StreamReader(stream);
using var writer = new StreamWriter(stream) { AutoFlush = true };
while (true)
{
var command = await reader.ReadLineAsync();
await Task.Delay(10);
await writer.WriteLineAsync("100");
//Console.WriteLine($"{id}: Database request finished.");
}
}
catch { }
});
}TCPで待ち受けし、テキストで1行コマンドを受け取って、少し時間をおいて、テキストで1行結果を返します。待機時間は、コード上は 10 ミリ秒と指定していますが、OS のタイムスライスの影響を受けるので、指定した通りの時間にはなりません。Windows 11 Pro 環境では 16 ミリ秒位の時間になるようです。
Webサーバー
// Webサーバー
using Microsoft.AspNetCore.Builder; // パッケージ:ServiceStack.Kestrel
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Net;
using System.Net.Sockets;
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.UseKestrel().UseUrls("http://localhost:5000");
builder.Logging.SetMinimumLevel(LogLevel.None);
var app = builder.Build();
// 同期処理
app.MapGet("/sync", async () =>
{
using var connection = DbConnection.Open();
connection.Writer.WriteLine("SELECT COUNT(*) FROM Table1");
var result = connection.Reader.ReadLine();
return $"Result: {result}";
});
// 非同期処理
app.MapGet("/async", async () =>
{
using var connection = DbConnection.Open();
await connection.Writer.WriteLineAsync("SELECT COUNT(*) FROM Table1");
var result = await connection.Reader.ReadLineAsync();
return $"Result: {result}";
});
// スレッド数を取得
app.MapGet("/thread", async () =>
{
return ThreadPool.ThreadCount.ToString();
});
Console.WriteLine("Web Server started on port 5000.");
ThreadPool.GetMinThreads(out var minWorkers, out _);
Console.WriteLine($"Minimum worker threads: {minWorkers}");
await app.RunAsync();
// DBモックサーバーへの接続
class DbConnection : IDisposable
{
private static readonly ConcurrentQueue<DbConnection> _pool = new();
private readonly TcpClient _client = new();
public readonly StreamWriter Writer;
public readonly StreamReader Reader;
public static DbConnection Open()
{
if (!_pool.TryDequeue(out var connection))
connection = new DbConnection();
return connection;
}
public DbConnection()
{
_client.Connect(IPAddress.Loopback, 9000);
var stream = _client.GetStream();
Writer = new StreamWriter(stream) { AutoFlush = true };
Reader = new StreamReader(stream);
}
public void Dispose()
{
_pool.Enqueue(this);
}
}DBサーバーへクエリを投げて、結果を返します。DBサーバーへのアクセスに同期メソッドを使う版と、非同期メソッドを使う版の2つがあります。
Webクライアント
// Webクライアント
using System.Diagnostics;
using var client = new HttpClient();
var requestCount = 0;
// 統計情報を3秒毎に出力
var statTask = Task.Run(async () =>
{
while (true)
{
var stopwatch = Stopwatch.StartNew();
await Task.Delay(3000);
var thread = await client.GetStringAsync($"http://localhost:5000/thread");
var request = Interlocked.Exchange(ref requestCount, 0);
Console.WriteLine($"{thread} threads, {request / stopwatch.Elapsed.TotalSeconds:0} req/sec");
}
});
Console.WriteLine("Web Client started.");
await Task.Delay(10_000);
await Test("sync", 10, 30_000);
await Test("sync", 100, 70_000);
await Test("async", 100, 30_000);
async Task Test(string type, int worker, int delay)
{
Console.WriteLine($"\n--- {type} method, {worker} workers ---\nWeb access started.");
var url = $"http://localhost:5000/{type}";
using var cancellation = new CancellationTokenSource(delay);
await Task.WhenAll(Enumerable.Range(0, worker).Select(_ => Task.Run(async () =>
{
while (!cancellation.IsCancellationRequested)
{
await client.GetStringAsync(url);
Interlocked.Increment(ref requestCount);
}
})));
Console.WriteLine($"Web access stopped.");
await Task.Delay(27_000);
}Webサーバーにアクセスし、負荷試験をします。統計値(Webサーバーのスレッド数、処理スループット)が3秒毎に表示されます。
実行結果
手持ちの環境で実行したときの様子です。CPU は Ryzen 9 7900、12 コア 24 スレッドです。
初期状態
Web Client started.
2 threads, 0 req/sec
3 threads, 0 req/sec
3 threads, 0 req/sec
初期状態では、Webサーバーのスレッド数は 3 位になっています。
同期メソッド 10 同時接続
--- sync method, 10 workers ---
Web access started.
16 threads, 439 req/sec
16 threads, 625 req/sec
16 threads, 623 req/sec
16 threads, 620 req/sec
16 threads, 624 req/sec
16 threads, 622 req/sec
15 threads, 621 req/sec
15 threads, 622 req/sec
15 threads, 623 req/sec
15 threads, 622 req/sec
Web access stopped.
15 threads, 173 req/sec
15 threads, 0 req/sec
15 threads, 0 req/sec
15 threads, 0 req/sec
15 threads, 0 req/sec
15 threads, 0 req/sec
3 threads, 0 req/sec
3 threads, 0 req/sec
3 threads, 0 req/sec
10 個のワーカーでWebサーバーに同時アクセスしたときの様子です。
アクセスを開始するとWebサーバーのスレッド数がすぐに 16 に跳ね上がり、アクセスを終了した後しばらくすると 3 に戻っています。.NETのスレッドプールが自動的にスレッド数の調整を行ってくれているのが分かると思います。
スループットは安定して 622 req/sec 位出ています。DBサーバーの処理時間が 16ms 位、10同時接続なので、理論値は 1000ms / 16ms * 10 = 625 req/sec 位。きちんと理論値の性能が出せているのが分かると思います。
同期メソッド 100 同時接続
--- sync method, 100 workers ---
Web access started.
30 threads, 1215 req/sec
31 threads, 1723 req/sec
37 threads, 1947 req/sec
43 threads, 2356 req/sec
45 threads, 2502 req/sec
49 threads, 2720 req/sec
53 threads, 2987 req/sec
58 threads, 3315 req/sec
61 threads, 3513 req/sec
62 threads, 3592 req/sec
66 threads, 3822 req/sec
69 threads, 4074 req/sec
75 threads, 4378 req/sec
81 threads, 4748 req/sec
88 threads, 5127 req/sec
92 threads, 5524 req/sec
98 threads, 5813 req/sec
102 threads, 6106 req/sec
103 threads, 6175 req/sec
103 threads, 6231 req/sec
104 threads, 6211 req/sec
104 threads, 6227 req/sec
104 threads, 6176 req/sec
Web access stopped.
104 threads, 3078 req/sec
104 threads, 0 req/sec
104 threads, 0 req/sec
104 threads, 0 req/sec
104 threads, 0 req/sec
103 threads, 0 req/sec
102 threads, 0 req/sec
3 threads, 0 req/sec
3 threads, 0 req/sec
同時アクセス数を10 倍にしたときの様子です。
スレッド数は 30 から始まり、時間とともに増え、最終的に 104 になっています。CPU の論理コア数が 24 なので、24 付近でいったん制限がかかり、負荷状況を見ながら少しずつ増やされ、100(ワーカー数、同時接続数)を超えたところで落ち着いているのが分かると思います。
スループットは 1700 位から始まり、時間とともに増え、最終的に 6220 位で安定しています。スレッド数が増えるまで時間がかかり、その間は性能が十分に出せていないのが分かると思います。
非同期メソッド 100 同時接続
--- async method, 100 workers ---
Web access started.
25 threads, 3323 req/sec
26 threads, 6250 req/sec
26 threads, 6185 req/sec
26 threads, 6230 req/sec
26 threads, 6239 req/sec
26 threads, 6155 req/sec
26 threads, 6235 req/sec
26 threads, 6190 req/sec
26 threads, 6242 req/sec
26 threads, 6237 req/sec
Web access stopped.
26 threads, 2740 req/sec
26 threads, 0 req/sec
26 threads, 0 req/sec
26 threads, 0 req/sec
25 threads, 0 req/sec
24 threads, 0 req/sec
24 threads, 0 req/sec
4 threads, 0 req/sec
4 threads, 0 req/sec
DBアクセスのコードを非同期メソッドに変更したときの様子です。
スレッド数は 26 (CPU の論理コア数とほぼ同じ)で始まり、そのまま変化せず終了しています。スループットは最初から理論値が出て、安定しているのが分かると思います。
まとめ
- Webサーバー内のコードは可能なら非同期メソッドで書いた方が良い。
- 同期メソッドで書いた場合、アクセスが集中したときに最初のうちは本来の性能を出せない場合がある。
- 非同期メソッドで書けばアクセスが集中しても最初から本来の性能を出すことができる。
実用面の補足
実運用で同期メソッドを利用したときの影響を改めて考えてみます。
- ハードウェア:CPU 4仮想コア
- 最大同時利用ユーザー数1000人、平均アクセス間隔30秒、平均リクエスト処理時間 0.5秒。
- 必要スループット:(1/30)*1000≒33.3リクエスト/秒
- 必要スレッド数:33.3*0.5≒16.7個
- スレッドが増えるまで(性能が安定するまで)の遅延時間:(16.7-4)/2≒6.4秒
6.4秒だとほぼ気づかないレベルだと思いますが、気になる場合はWebサーバーの.NETスレッドプールの設定で、最小ワーカースレッド数を変更します。(上記の場合で16~24位に変更)。
必要スレッド数が100を超える場合はデータベースの接続設定(最大プールサイズや最大接続数)の変更も行います。(SQL Server の場合は接続文字列に max pool size=n を追加)