Webサーバーからのファイルダウンロード処理を、C#, .NET, 非同期処理で高速化してみます。

ソースコード

ダウンロード後、zipファイルを展開し、以下を行ってください。

  • サーバー側 → PHPファイルをPHP導入済みのWebサーバーにアップロードしてください。
    • このサイトにもアップロード済みですので、そちらを使ってもテストできます。
  • クライアント側 → slnx ファイルを Visual Studio 2026 以降で開いてください。

実行結果

サーバー側

https://www.north-innovations.com/wp-content/scripts/async-demo.php?id=A

https://www.north-innovations.com/wp-content/scripts/async-demo.php?id=B

https://www.north-innovations.com/wp-content/scripts/async-demo.php?id=C

5秒かかるダウンロード処理を再現しています。実際に上記リンクをクリックすると様子が分かると思います。

クライアント側

手持ちの環境で実行したときの様子です。非同期処理によって、約3倍の高速化が図られているのが分かると思います。

サーバー側

async-demo.php
<?php
header('Content-Type: text/html; charset=utf-8');
header('Cache-Control: no-cache');
$id = $_GET['id'];
if (!isset($id))
    $id = 'unknown';
$id = htmlspecialchars($id, ENT_QUOTES, 'UTF-8');
for ($rate = 20; $rate <= 100; $rate += 20) {
    sleep(1);
    echo "Id={$id} {$rate}%<br>\n";
    ob_flush();
    flush();
}

1行ずつ1秒間隔で5回出力しています。

共通部分

Program.cs の一部
using System.Diagnostics;

namespace Asynchronization;

internal static class Program
{
    private static readonly Stopwatch _stopwatch = new();

    // 計測
    private static async Task MeasureAsync(string description, Func<Task> asyncTask)
    {
        Console.WriteLine($"{description}を開始します...");
        _stopwatch.Restart();
        await asyncTask();
        Console.WriteLine($"全ての処理が完了しました。所要時間: {_stopwatch.Elapsed.TotalSeconds:N2}秒\n");
    }

    // 進捗をコンソールに書き込む
    private static void WriteProgress(string message)
    {
        Console.WriteLine($"Time={_stopwatch.Elapsed.TotalSeconds:N2} ThreadId={Environment.CurrentManagedThreadId} {message}");
    }
}

計測、進捗表示などの共通機能です。

GuiLikeEventLoop.cs
namespace Asynchronization;

// GUIライクなイベントループ。
// スレッドIDが固定され、非同期処理が並行処理で実行されます。
internal class GuiLikeEventLoop : SynchronizationContext
{
    private readonly object _lock = new();
    private readonly Queue<(SendOrPostCallback, object?)> _queue = new();

    public static void Run(Func<Task> asyncFunc)
    {
        var context = new GuiLikeEventLoop();
        SetSynchronizationContext(context);
        try
        {
            var isTerminating = false;
            asyncFunc().ContinueWith(task =>
            {
                lock (context._lock)
                {
                    isTerminating = true;
                    Monitor.Pulse(context._lock);
                }
            });
            lock (context._lock)
            {
                while (!isTerminating)
                {
                    if (context._queue.TryDequeue(out var item))
                    {
                        Monitor.Exit(context._lock);
                        try { item.Item1.Invoke(item.Item2); }
                        finally { Monitor.Enter(context._lock); }
                    }
                    else
                        Monitor.Wait(context._lock);
                }
            }
        }
        finally
        {
            SetSynchronizationContext(null);
        }
    }

    public override void Post(SendOrPostCallback d, object? state)
    {
        lock (_lock)
        {
            _queue.Enqueue((d, state));
            Monitor.Pulse(_lock);
        }
    }
}

GUI (Windows Forms や WPF) アプリ環境を再現するための同期コンテキストクラスです。中身は理解しなくて構いません。

同期関数

Program.cs の一部
    private const string BaseUrl = "https://www.north-innovations.com/wp-content/scripts/async-demo.php";
    private static readonly HttpClient _httpClient = new();

    // 同期ダウンロード
    private static void Download(string id)
    {
        try
        {
            var url = $"{BaseUrl}?id={id}";
            WriteProgress($"接続 Id={id} Url={url}");
            using var stream = _httpClient.GetStreamAsync(url).Result;
            using var reader = new StreamReader(stream);
            while (true)
            {
                var line = reader.ReadLine();
                if (line == null)
                    break;
                WriteProgress($"受信 {line.Replace("<br>", "")}");
            }
            WriteProgress($"切断 Id={id}");
        }
        catch (Exception ex)
        {
            Console.WriteLine(ex.Message);
        }
    }

普通の(同期処理の)ダウンロード関数です。指定したURLからデータをダウンロードし、受信する度に画面に内容を表示します。

ライブラリの仕様で、同期版の GetStream メソッドが用意されていないため、非同期版を同期的な動作に変換して利用しています。

  • 本来の同期メソッド  :using var stream = _httpClient.GetStream(url);
  • 非同期版を同期的に利用:using var stream = _httpClient.GetStreamAsync(url).Result;

非同期関数

Program.cs の一部
    // 非同期ダウンロード
    private static async Task DownloadAsync(string id)
    {
        try
        {
            var url = $"{BaseUrl}?id={id}";
            WriteProgress($"接続 Id={id} Url={url}");
            using var stream = await _httpClient.GetStreamAsync(url);
            using var reader = new StreamReader(stream);
            while (true)
            {
                var line = await reader.ReadLineAsync();
                if (line == null)
                    break;
                WriteProgress($"受信 {line.Replace("<br>", "")}");
            }
            WriteProgress($"切断 Id={id}");
        }
        catch (Exception ex)
        {
            Console.WriteLine(ex.Message);
        }
    }

非同期処理のダウンロード関数です。

同期版との違い:

  • 関数宣言の前に「async」があり、戻り値が「Task」、関数名の最後が「Async」。
    • 同期: void Download(string id) { }
      非同期:async Task DownloadAsync(string id) { }
    • (参考)戻り値がある場合
      • 同期: int Download(string id) { }
        非同期:async Task<int> DownloadAsync(string id) { }
  • 関数呼び出しの前に「await」があり、関数名の最後が「Async」。
    • 同期: using var stream = _httpClient.GetStream(url);
      非同期:using var stream = await _httpClient.GetStreamAsync(url);
    • 同期: var line = reader.ReadLine();
      非同期:var line = await reader.ReadLineAsync();
    • (参考)戻り値がない場合
      • 同期: stream.Flush();
        非同期:await stream.FlushAsync();

同期関数を呼ぶ

Program.cs の一部
    public static async Task Main()
    {
        // 同期
        await MeasureAsync("同期ダウンロード", async () =>
        {
            Download("A");
            Download("B");
            Download("C");
        });

実行結果(改行を追加しています)

実行イメージ

普通の関数(同期関数)を3回呼び出しています。5秒のタスク3個が逐次処理されており、約15秒で処理が完了しています。データ受信待ちの間はスレッドがスリープしています。

非同期関数を呼ぶ

Program.cs の一部
        // 非同期
        await MeasureAsync("非同期ダウンロード", async () =>
        {
            var taskA = DownloadAsync("A");
            var taskB = DownloadAsync("B");
            var taskC = DownloadAsync("C");
            await Task.WhenAll(taskA, taskB, taskC);
        });

実行結果(改行を追加しています)

実行イメージ

非同期関数を3回呼び出した後、処理が完了するのを待っています。5秒のタスク3個が並行処理されており、約5秒で処理が完了しています。最初の接続処理以外はマルチスレッドによる同時実行(並列処理)も行われています。

GUIアプリ環境で非同期関数を呼ぶ

Program.cs の一部
        // 同期コンテキストあり、非同期
        GuiLikeEventLoop.Run(() => MeasureAsync("同期コンテキストあり(GUI環境を再現)で非同期ダウンロード", async () =>
        {
            var taskA = DownloadAsync("A");
            var taskB = DownloadAsync("B");
            var taskC = DownloadAsync("C");
            await Task.WhenAll(taskA, taskB, taskC);
        }));

実行結果(改行を追加しています)

実行イメージ

特殊なクラスを使ってGUIアプリ環境(Windows Forms、WPF)を再現してみました。GUIアプリ環境で非同期関数を呼ぶと、スレッドが1個に限定され、並列処理が行われなくなります。

同期関数をTask.Run経由で呼ぶ

Program.cs の一部
        // 別スレッド、同期
        await MeasureAsync("別スレッドで同期ダウンロード", async () =>
        {
            var taskA = Task.Run(() => Download("A"));
            var taskB = Task.Run(() => Download("B"));
            var taskC = Task.Run(() => Download("C"));
            await Task.WhenAll(taskA, taskB, taskC);
        });

実行結果(改行を追加しています)

実行イメージ

同期関数を Task.Run 経由で呼び出すと、非同期的に扱うことができます。マルチスレッドによる同時実行(並列処理)が行われます。

以下の場合に適した書き方です。

  • 計算処理中心、CPUバウンドの処理を行う場合。
  • ディレクトリの一覧取得等、ライブラリ側で非同期I/O関数が提供されていない場合。
  • GUIアプリで、処理中に画面がフリーズする(操作不能になる)のを避けたい場合。

非同期関数をTask.Run経由で呼ぶ

Program.cs の一部
        // 別スレッド、非同期
        await MeasureAsync("別スレッドで非同期ダウンロード", async () =>
        {
            var taskA = Task.Run(() => DownloadAsync("A"));
            var taskB = Task.Run(() => DownloadAsync("B"));
            var taskC = Task.Run(() => DownloadAsync("C"));
            await Task.WhenAll(taskA, taskB, taskC);
        });
    }

実行結果(改行を追加しています)

実行イメージ

非同期関数を Task.Run 経由で呼び出した場合も非同期的に扱うことができます。

普通に非同期関数を呼び出した場合と比べて以下の点が異なります。

  • 最初の処理(接続処理)からマルチスレッドで同時実行(並列処理)される。
  • GUIアプリ環境で実行した場合にスレッドが制限されなくなる。マルチスレッド処理(並列処理)が可能になる。

以下の場合に適した書き方です。

  • 最初の処理(非同期関数内で最初のawaitに達するまでの処理)の実行に時間がかかる場合。
  • GUIアプリ環境で、CPU処理が多めの非同期関数を呼び出す場合。