C#, .NET, 非同期処理を、Web サーバーで利用したときの効果を書いてみます。

ソースコード

ダウンロード後、zipファイルを展開し、slnx ファイルを Visual Studio 2026 以降で開いてください。

プロジェクト DbServer, WebServer, WebClient をマルチスタートアップに設定して、リリースビルドで実行してください。

DBモックサーバー

DbServer.cs
// 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サーバー

WebServer.cs
// 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クライアント

WebClient.cs
// 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サーバーのスレッド数は 3 位になっています。

同期メソッド 10 同時接続

10 個のワーカーでWebサーバーに同時アクセスしたときの様子です。

アクセスを開始するとWebサーバーのスレッド数がすぐに 16 に跳ね上がり、アクセスを終了した後しばらくすると 3 に戻っています。.NETのスレッドプールが自動的にスレッド数の調整を行ってくれているのが分かると思います。

スループットは安定して 622 req/sec 位出ています。DBサーバーの処理時間が 16ms 位、10同時接続なので、理論値は 1000ms / 16ms * 10 = 625 req/sec 位。きちんと理論値の性能が出せているのが分かると思います。

同期メソッド 100 同時接続

同時アクセス数を10 倍にしたときの様子です。

スレッド数は 30 から始まり、時間とともに増え、最終的に 104 になっています。CPU の論理コア数が 24 なので、24 付近でいったん制限がかかり、負荷状況を見ながら少しずつ増やされ、100(ワーカー数、同時接続数)を超えたところで落ち着いているのが分かると思います。

スループットは 1700 位から始まり、時間とともに増え、最終的に 6220 位で安定しています。スレッド数が増えるまで時間がかかり、その間は性能が十分に出せていないのが分かると思います。

非同期メソッド 100 同時接続

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 を追加)