新しもの好きプログラマの耳より情報ブログ

仕事でもあるプログラミングについて役に立ちそうな情報を発信していこうというブログです。役に立たなそうな情報はfacebookで。

C#のEventWaitHandle待ちをTaskに変換して混在を解決する方法(Taskの中で単に待つと問題が起きます)

概要

TaskとWaitEventHandleが混在している場合に、Taskに揃えようとして手軽に「Task.Run()の中でWaitEventHandle.Wait()」という実装にしてしまうと、問題が起きます。ぱっと見ではそんなに問題がありそうなコードには見えないので、意外と引っかかりがちです。

この問題に引っかからずにTaskとWaitEventHandleの混在を解決する方法を紹介します。単に解決方法のコードを貼るだけだと中身が分からなくて不安だと思うので、コードの中身の説明もします。

最初に結論まとめ

次の共通メソッドを作って、これを使ってEventWaitHandleをTaskへ変換すればOKです。

public static Task<bool> WaitHandleAsync(WaitHandle waitHandle, CancellationToken cancelToken, TimeSpan? timeout = null)
{
    var tcs = new TaskCompletionSource<bool>();
    cancelToken.Register(() => tcs.TrySetCanceled(), false);
    var hWait = ThreadPool.RegisterWaitForSingleObject(
        waitHandle,
        (_, timedOut) => tcs.TrySetResult(!timedOut),
        null, 
        timeout ?? Timeout.InfiniteTimeSpan, 
        true);
    tcs.Task.ContinueWith(_ => hWait.Unregister(null), CancellationToken.None);
    return tcs.Task;
}

説明

async/awaitとTask、便利ですよね。全部Taskで処理できると楽ですが、APIなどの都合でイベント(EventWaitHandle)を待つことも有ると思います。EventWaitHandleはTaskへ変換できないので、「イベントとTaskの両方(どちらか)の完了を待ちたい」という時にどうするかが悩みどころです。

最も手軽な感じで書くと、多分こうなるのではないでしょうか。

async Task func1(){}

AutoResetEvent event1 = new (false);

//func1の完了とevent1のシグナルの両方を待ちたい場合
await Task.WhenAll(
    func1(),
    Task.Run(() => event1.Wait())
    );

しかし、これは危ない書き方です。すぐに問題を起こすわけではありませんが、余計なリソースを握ったままになっているため、同じような処理が多数併走すると問題を起こし始めます。

どこが問題か、分かるでしょうか?一見しただけだとまともな処理に見えるので、私は引っかかってしまいました。

問題はここです。

Task.Run(() => event1.Wait())

このWait()はTaskのスレッド上で待っているため、Waitしている間はこのTaskが動いたまま、スレッドプールへ戻されなくなります。ということは、これを大量に作成してしまうと、アクティブなスレッド数が増えていき、スレッドプールの負荷が上がっていきます。状況によっては、実質的に処理が止まってしまうかもしれません。

これを避けるにはどうしたら良いかというと、イベント待ちをTaskに変換する必要があります。これは少々面倒な処理ですが、汎用的に使えるメソッドにまとめることができます。次のようになります。

public static Task<bool> WaitHandleAsync(WaitHandle waitHandle, CancellationToken cancelToken, TimeSpan? timeout = null)
{
    var tcs = new TaskCompletionSource<bool>();
    cancelToken.Register(() => tcs.TrySetCanceled(), false);
    var hWait = ThreadPool.RegisterWaitForSingleObject(
        waitHandle,
        (_, timedOut) => tcs.TrySetResult(!timedOut),
        null, 
        timeout ?? Timeout.InfiniteTimeSpan, 
        true);
    tcs.Task.ContinueWith(_ => hWait.Unregister(null), CancellationToken.None);
    return tcs.Task;
}

長いですね。順に説明していきます。

まず、その用途では定番の、TaskCompletionSourceを作ります。

var tcs = new TaskCompletionSource<bool>();

次に、メソッドの引数に渡されたキャンセルトークンがキャンセルされた時に、そのTaskCompletionSourceもキャンセルされるように、キャンセル処理を登録します。特にUIスレッドなどのコンテキストの一致が必要な処理ではないので、第2引数はfalseです。

cancelToken.Register(() => tcs.TrySetCanceled(), false);

次に、ここが今回の最大のポイントです。

    var hWait = ThreadPool.RegisterWaitForSingleObject(
        waitHandle,
        (_, timedOut) => tcs.TrySetResult(!timedOut),
        null, 
        timeout ?? Timeout.InfiniteTimeSpan, 
        true);

これはWaitHandleのイベント発生時(もしくはタイムアウト時)にデリゲートを実行するように、スレッドプールに対して登録をします。イベントがシグナルになった時にスレッドプールのスレッドを使う形になるので、最初に書いた悪い例のように実行中のスレッドを握ったままにならないのが大きな違いです。

引数のタイムアウトを受け取って、タイムアウトしたかどうかをTaskのResultへ返す処理も行っています。これはタイムアウト付きのEventWaitHandle.Wait(timeout)と同じ動作なので、扱いやすいはずです。

登録したものは解除する必要があります。次のように、ContinueWithで解除処理を行います。

tcs.Task.ContinueWith(_ => rwh.Unregister(null), CancellationToken.None);

これによって、イベントが発生した場合、タイムアウトした場合、キャンセルされた場合のいずれであっても、RegisterWaitForSingleObjectで行った登録を解除できます。

以上で、スレッドプールに負荷をかけずにイベント待ちをするTaskが出来上がります。

最初の悪い例は、これを使って次のように書き直せば問題なくなります。

async Task func1(){}

AutoResetEvent event1 = new (false);

//func1の完了とevent1のシグナルの両方を待ちたい場合
await Task.WhenAll(
    func1(),
    WaitHandleAsync(event1, CancellationToken.None)
    );

問題の処理だけをそのまま置き換えられるので、導入しやすいですね。

まとめ

思いつきでコーディングするとやってしまいがちな「Taskの中でEventWaitHandle.Wait()を呼ぶ」というのは実は問題が起きるコードだという話をして、その解決策をまとめました。実装がちょっと複雑ですが、共通メソッドを一度書けばあとは使い回せるので、問題なく導入できると思います。同じようなコードを書く必要がある場合は、ぜひこれを思い出してみてください。