非同期WebRequestとTimeout処理の今昔

最近はTypeScriptにお熱ですが、とはいえ、C#も大好きな私です。むしろC#は大好きです。今日はすっかり飽き飽きな非同期のTimeout処理について、おさらいすることにしましょう!題材はいつもどーりWebRequestでいいですよね。

まず、都合のいいTimeoutをシミュレートできるAPIは探せば多分あるでしょうが、面倒なので自分で作りましょう。いえ、簡単です。「空のASP.NET WebApplication」を立ち上げてジェネリックハンドラを追加。とりあえずレスポンスを返すのに3秒かかるということにしときましょう。

public class Timeout : HttpTaskAsyncHandler
{
    public override async Task ProcessRequestAsync(HttpContext context)
    {
        await Task.Delay(TimeSpan.FromSeconds(3)); // 3秒かかるってことにする
        context.Response.ContentType = "text/plain";
        context.Response.Write("Hello World");
    }
}

で、そのまま実行してIIS Expressで動いてもらってれば準備できあがり。

同期の場合

さて、そしてConsoleApplicationを立ちあげて、まずは同期でやる場合でも見ましょうか。

var req = WebRequest.Create("http://localhost:18018/Timeout.ashx");
req.Timeout = 1000; // 1秒でタイムアウト

req.GetResponse();

これはちゃんとタイムアウトでWebExceptionを返してくれます。そりゃそーだ。

古き良き非同期の場合

じゃあBegin-Endパターンの非同期でやってみましょうか。

var req = WebRequest.Create("http://localhost:18018/Timeout.ashx");
req.Timeout = 1000; // 1秒でタイムアウトのつもり

req.BeginGetResponse(ar =>
{
    var res = req.EndGetResponse(ar);
    Console.WriteLine(new StreamReader(res.GetResponseStream()).ReadLine());
}, null);

Console.ReadLine(); // 適当に待つ

この結果はなんと、普通にHello Worldと表示されてしまいます。はい、Timeout機能していません、全く。そう、WebRequestのTimeoutプロパティによる設定は同期限定なのだよ、なんだってー。このことはMSDNのBeginGetResponseのとこにも書いてあって、「非同期要求の場合は、クライアント側のアプリケーションが独自のタイムアウト機構を実装する必要があります」ということになっています。

ThreadPool.RegisterWaitForSingleObject

なので、そこに書いてあるとおり、ThreadPool.RegisterWaitForSingleObjectで実装してみましょう。

var req = WebRequest.Create("http://localhost:18018/Timeout.ashx");
// req.Timeout = 1000; このTimeoutはイミナイのでイラナイ

var result = req.BeginGetResponse(ar =>
{
    var res = req.EndGetResponse(ar);
    Console.WriteLine(new StreamReader(res.GetResponseStream()).ReadLine());
}, null);

ThreadPool.RegisterWaitForSingleObject(result.AsyncWaitHandle, (state, timeout) =>
{
    // 引数で指定した時間の後にここの部分が発火する。
    // そのとき非同期処理が完了していなければ(Timeoutしていれば)timeoutがtrue, 普通に終了してればfalse
    if (timeout)
    {
        Console.WriteLine("TIMEOUT!!");

        var _req = (WebRequest)state;
        if (_req != null) _req.Abort(); // あぼーんでキャンセルというか打ち切る
    }
}, req, timeout: TimeSpan.FromSeconds(1), executeOnlyOnce: true);

Console.ReadLine(); // 適当に待つ

うん、ややこしいですね。ウンザリです。しかし昔はこれぐらいしか手段がなかったのだからShoganai!

C# 5.0で救われよう

そんなこんなで一気に時代が進んで、C# 5.0です。GetResponseAsyncですね!GetResponseAsyncなら、GetResponseAsync先生ならやってくれる、と思っていた時がありました。

static async Task Run()
{
    var req = WebRequest.Create("http://localhost:18018/Timeout.ashx");
    req.Timeout = 1000; // ま、このTimeoutはイミナイですよ

    var res = await req.GetResponseAsync();

    Console.WriteLine(new StreamReader(res.GetResponseStream()).ReadLine());
}

static void Main(string[] args)
{
    Run().Wait();
}

結果はBegin-Endの時と同じでTimeout指定は無視されます。はい残念残念。所詮別にBegin-Endと何も変わってはいないわけです。とはいえ、Taskならば、この辺、柔軟に処理を仕込めます。

Timeoutという拡張メソッドを作る

async/awaitやTaskといった道具立てはあるのですが、細かい色々なものはない(Cancelを足すとかTimeoutを足すとかRetryを足すとか、この辺はよく使うであろうシチュエーションだと思うので、自分の道具箱に仕込んでおくと幸せになれます)ので、作りましょう。

public static async Task Timeout(this Task task, TimeSpan timeout)
{
    var delay = Task.Delay(timeout);
    if (await Task.WhenAny(task, delay) == delay)
    {
        throw new TimeoutException();
    }
}

public static async Task<T> Timeout<T>(this Task<T> task, TimeSpan timeout)
{
    await ((Task)task).Timeout(timeout);
    return await task;
}

単純ですね。ポイントはTask.WhenAnyで、これは特殊なやり方ではなくて、イディオムです。C# 5.0を使っていくなら覚えておきましょう、絶対に。

さて、これを使えば

static async Task Run()
{
    var req = WebRequest.Create("http://localhost:18018/Timeout.ashx");
    var res = await req.GetResponseAsync().Timeout(TimeSpan.FromSeconds(1));

    Console.WriteLine(new StreamReader(res.GetResponseStream()).ReadLine());
}

static void Main(string[] args)
{
    Run().Wait();
}

超シンプルになりました。やったね!

HttpClient

ちなみに.NET 4.5から入ったHttpClientは、非同期操作しか提供していない、だけに、ちゃんとTimeoutプロパティが非同期でも対応していますので、フツーはこっちを使うと良いでしょう。

static async Task Run()
{
    var client = new System.Net.Http.HttpClient() { Timeout = TimeSpan.FromSeconds(1) };
    var s = await client.GetStringAsync("http://localhost:18018/Timeout.ashx");

    Console.WriteLine(s);
}

static void Main(string[] args)
{
    Run().Wait();
}

吐いてくる例外はSystem.Threading.Tasks.TaskCanceledExceptionです。これは、中でCancellationTokenSource.CreateLinkedTokenSourceとリンクさせた上で、CancellationTokenSourceのCancelAfterによってTimeoutを処理しているからです。HttpClientも、最終的にネットワークとやり取りしている部分はWebRequestですから。

まとめ

まあ、今までは細かい罠があってクソが、となる局面も少なからず多かったわけですが、ようやく整理された、感があります。しょっぱいことは考えないで、甘受していきたいですねー。

Profile

Yoshifumi Kawai

Cysharp, Inc
CEO/CTO

Microsoft MVP for Developer Technologies(C#)
April 2011
|
July 2024

Twitter:@neuecc GitHub:neuecc

Archive