非同期WebRequestとTimeout処理の今昔
- 2012-10-16
最近は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ですから。
まとめ
まあ、今までは細かい罠があってクソが、となる局面も少なからず多かったわけですが、ようやく整理された、感があります。しょっぱいことは考えないで、甘受していきたいですねー。