++C++; // 未確認飛行 C ブログ

http://ufcpp.net/

async/awaitと同時実行制御

with 3 comments

C# 5.0のasync/awaitを使うと、多くの場面ではシングル スレッド的な動作になるし、多くの場面ではlock不要(結果的に、デッドロックが起こりようなくなる)になったりします。

ただし、「多くの場面で」。「必ず」ではないのがはまりどころ。いくつかの場面では、同時実行制御が必要です(普通にマルチスレッドの平行実行になるので、同時に同じデータにアクセスされる可能性を考慮しないとバグります)。

前提知識

いくつか、C# 5.0世代の非同期処理についての前提知識は、以下のスライド(先月末の.NETラボでの発表)を参考にしてください。

An other world awaits you from 信之 岩永
  • 5~12ページ: async/awaitの書き方
  • 17~22ページ: スレッドとそのコスト
  • 24~26ページ: スレッド プール
  • 29~32ページ: I/O完了待ちと非同期API
  • 36~40ページ: UIスレッドとディスパッチャー
  • 41~45ページ: 同期コンテキスト

await演算子と同期コンテキスト

今日の主題となるのは以下の2点。

  • 文脈なんて普段から意識するものではなく、同期コンテキスト(synchronization context: 同時実行における文脈)も、できるだけ意識させたくない
  • await演算子は同期コンテキストを自動的に拾う

awaitは同期コンテキストを拾うというのは、例えば、以下のようなコードを書いたとするとしたときに、

static async void Run()
{
await Run1Async();
// 
続きの処理

}

以下のようなコードと同じような処理手順になるということです。

static async void Run()
{
var context = SynchronizationContext.Current;
Run1Async().ContinueWith(t =>
{
context.Post(state =>
{
// 
続きの処理
}, null);
});
}

(実際にawait演算子がやっていることはもう少し複雑ですが、「同期コンテキストを拾う」という点に焦点を当てるとこんな感じになるという例です。)

Fire & Forget

非同期メソッドには、Task(もしくはTask<T>)を返す、await演算子かWait()メソッドで「待てる」(awaitable)メソッドと、voidの「待てない」(fire & forget)メソッドの2種類定義できます。

awaitableは「待てる」という名前通り、awaitする前提です。一方のfire & forgetは「呼ぶだけ呼んであとは放置」という意味で、以下のような使い方になります。

さて、この図の例でいうと、await someTask; した後、次の行がどう実行されるかは、実行環境(の持つ同期コンテキストの種類)次第となります。

GUI上で

async void、fire & forget型の非同期メソッドの用途はイベント ハンドラーです。というよりむしろ、それ以外の状況での使い道は乏しいし、はまりどころになるだけなので非推奨です。

例えば、以下のようなコードを考えてみましょう。ThreadUnsafeの部分には、スレッド安全でない(マルチスレッド実行するならlockが必要な)処理が入っているものとしましょう。

HttpClient _client = new HttpClient();

private async void Button_Click_1(object sender, RoutedEventArgs e)
{
await _client.GetAsync(_url1);
ThreadUnsafe();
await _client.GetAsync(_url2);
ThreadUnsafe();
await _client.GetAsync(_url3);
ThreadUnsafe();
}

このButton_Click_1を、ボタンのClickイベントのハンドラーにしましょう(というか、このメソッド名はVisual Studio上でClickイベントのハンドラーを自動生成した結果得られるものです)。

<Window x:Class=”WpfApplication1.MainWindow”
 xmlns=”http://schemas.microsoft.com/winfx/2006/xaml/presentation&#8221;
 xmlns:x=”http://schemas.microsoft.com/winfx/2006/xaml&#8221;
 Title=”MainWindow” Height=”350″ Width=”525″>
<Grid>
<Button Content=”click here” Click=”Button_Click_1″ />
</Grid>
</Window>

この状況で、そのボタンを連打しても(lockしなくて)平気なの?というのが疑問として浮かびます。(答えだけ先に言ってしまうなら不要です。)

まず、ボタンを1回だけ押したときの挙動。GUIアプリ(WPFでもSilverlightでもWindowsストア アプリでも)の同期コンテキストは、制御フローをUIスレッドに返すようになっています。その結果、先ほどのコードは以下のように実行されます。

ThreadUnsafeの部分が実行されているのは常に同じスレッドで、同時に実行される可能性はありません。これは、ボタンを連打しても変わらず、連打時の挙動は以下のようになります。

非同期処理なので実行される順序やタイミングまでは保証できませんが、ThreadUnsafeの部分がすべてUIスレッド上で実行されることは保証されます。

ちなみに、UIスレッドに処理を戻す際には、一度メッセージがキューに溜められるので、「処理中に帰ってきた応答が処理されない」というようなこともありません。

つまり、以下のような場合には常にlock不要。

  • UIスレッド上から非同期処理開始
  • I/O待ち
  • awaitする

GUIのイベント ハンドラーとして使う限りにおいては、fire & forgetでも何の問題もなかったり。

スレッド プール実行

さて、I/O待ちでなく、「重たい処理をUIスレッドから逃がしたい」みたいな場合にはスレッド プール上での実行(Task.Run)が必要になります。

private async void Button_Click_1(object sender, RoutedEventArgs e)
{
PreWork();

    await Task.Run(() =>
{
HeavyWork();
});

PostWork();
}

ここで覚えておくべきことは、Task.Runの内側と外側で同期コンテキストが変わること。

Task.Runの外側はUIのコンテキストになります。awaitした後にはUIスレッドに処理が戻されているので、後半もUIスレッド上です。

一方、内側は、スレッド プール上での実行になりますが、スレッド プール上のコンテキストではawaitしてもスレッド プール上のどこかで実行されるだけで、決してUIスレッドには戻らなくなります。

ということで、問題となる書き方の例。Task.Runでスレッド プール実行に移った後のawait。

private async void Button_Click_1(object sender, RoutedEventArgs e)
{
await Task.Run(async () =>
{
await someTask;
await someTask;
});
}

Task.Runの中のawaitでは、単一スレッド実行にはならないので、lockが必要になることがあります。

コンソール アプリ

前述の通り、GUI上でI/O待ちする分には割と問題がでないんですが。一方、コンソール アプリだとどうでしょう。

コンソール アプリは同期コンテキストを持っていません。SynchronizationContext.Currentはnullです。

using System;
using System.Threading;

class Program
{
static void Main()
{
Console.WriteLine(
SynchronizationContext.Current == null); // true
}
}

この場合、「awaitは既定の同期コンテキストを使う」ということになっていて、その既定の同期コンテキストはスレッド プールを使います。

GUIの場合と同じようなコードをコンソール アプリでも書いてみましょう。

static HttpClient _client = new HttpClient();

static async Task RunAsync()
{
await _client.GetAsync(_url1);
ThreadUnsafe();
await _client.GetAsync(_url2);
ThreadUnsafe();
await _client.GetAsync(_url3);
ThreadUnsafe();
}

以下のように、どこのスレッドでThreadUnsafeが実行されているかの保証がなくなります。

ただし、この時点では、まあ、複数のThreadUnsafeが同時に実行されることはありません。

問題が出るのは、これをfire & forgetで複数呼んだときや、Task.WhenAllでfork/joinした場合。

static async Task RunDoubeAsync()
{
await Task.WhenAll(
RunAsync(),
RunAsync());
}

以下のように、どのスレッドで実行されるかも、タイミングも保証がないので、ThreadUnsafeの部分が複数のスレッドで同時に実行される可能性があります。

当然、lockが必要です。この状況下でlockが必要なのは当然なんですが、問題は、全く同じコードなのに、状況によってlockの必要性が変わること。

ASP.NET

コンソール アプリの場合「同期コンテキストを持っていない」というので、ある意味わかりやすいのもの、むしろ問題となるのはASP.NETだったり。

ASP.NETは同期コンテキストを持っています。

var t = System.Threading.SynchronizationContext.Current.GetType();
var name = t.Name;

とか書くと、AspNetSynchronizationContextという名前が帰ってきます。

ここで勘違いしてはいけないのは、GUIの同期コンテキストとは目的からして全く違うということ。

GUIでは、UIスレッド上で実行しないといけないことが多いので、UIスレッドに処理を戻す必要がありました。これがGUIにおける文脈であり、同期コンテキストがやっていることです。

が、こういう制約はWebにはありません。Webにあるのは、どのWebリクエストと紐づいた処理かという文脈です。なので、AspNetSynchronizationContextは、

  • やらない: 単一スレッド実行
  • やる:  HttpContext等の保持
  • やる: リクエストが来てから開始した非同期処理が全部終わるまで、レスポンスを返さないよう、処理の監視

というものです。単一スレッド実行しない。awaitの挙動的には、コンソール アプリの場合と同じ注意が必要です。

オーバーヘッド

まあ、同期コンテキスト越しの処理というのはそれなりにオーバーヘッドが生じます。特に、GUIの場合、UIスレッドに制御フローを戻すのが、結構な負担。なので、勝手にUIスレッドに戻っているというのも、問題になることあり。

例えばということで、以下のような一連の流れを考えてみましょう。

  1. IOAsync: 非同期I/O待ち
  2. HeavyWork: 1.の結果を使って、何かしらの重たい処理を行う(スレッド プール上で実行したい)
  3. UpdateUI: 2.の結果をUIに反映(UIスレッド上での実行必須)

もちろん、いくつかの書き方があります。何パターンか示していきましょう。ダメなものから順に(なので、最後のやつ推奨)。

まずはわかりやすいダメな例。全部同期。UIスレッドが止まるので、いわゆるフリーズ。

PreWork();
IOAsync().Wait();
HeavyWork();
UpdateUI();

次、非同期I/Oのところだけawait。HeavyWorkのところでフリーズ。

PreWork();
await IOAsync();
HeavyWork();
UpdateUI();

なのでまあ、HeavyWorkをTask.Run。ただ、単純にそこをawaitで書こうとすると、一度UIスレッドを経由しちゃうので非効率。

PreWork();
await IOAsync();
await Task.Run(() => HeavyWork());
UpdateUI();

そこで実際はどうするべきかというと、ContinueWithでつなぐこと。同期コンテキストを拾いたいならawaitに頼る、そうでないならawaitに頼らない。

PreWork();
await IOAsync()
.ContinueWith(_ => HeavyWork());
UpdateUI();

ConfigureAwait

さて。awaitの挙動は、Awaitableパターンというものを満たすクラスを作ることで、自由にカスタマイズできます。

「同期コンテキストを拾う」という挙動も、単にTaskに対する既定の実装がそうしているというだけで、拾わないようにすることも可能です。

それをやっているのがConfigureAwait拡張メソッド。ConfigureAwaitの引数にtrueを渡すと同期コンテキストを拾う、falseを渡すと拾わないという仕様になっています。

います、が…。またダメな例。以下のようなコードを書いたとします。

async void RunAsync()
{
PreWork();

    await HeavyWorkAsync()
.ConfigureAwait(false);

await AnotherWorkAsync()
.ConfigureAwait(true);

PostWork();
}

2個目のConfigureAwaitはtrueなので、同期コンテキストを拾ってUIスレッドに戻ってほしい。けども、実際はそうなりません。

Task.Runの中でawaitしてもUIスレッドには戻れないのと同じ理屈。一度ConfigureAwait(false)してしまうとUIスレッドに戻れなくなる。

なので、前述の例を、こいつを使って書いてしまうとバグります。スレッド プール上でUIを更新しようとして、実行時例外で落ちます。

PreWork();
await IOAsync().ConfigureAwait(false);
HeavyWork();
UpdateUI();

解消法は簡単というか、もうすでに例を示している通り、ContinueWithを使えばOK。

まとめ

特定の状況下、例えば、

  • UIスレッド上から非同期処理開始
  • I/O待ち
  • awaitする

であれば、非同期処理といえども、挙動はシングル スレッド的で、同時実行制御(lockとか)は不要です。

一方で、

  • コンソール アプリは同期コンテキストを持っていない
  • ASP.NETの同期コンテキストはマルチスレッド動作
  • Task.Runの中でさらにawaitしても、スレッド プールの同期コンテキストになる
  • ConfigureAwaitにも注意
  • 同期コンテキストを介するのはそれなりのオーバーヘッド

などの注意が必要です。

Written by ufcpp

2012年11月12日 @ 19:06

カテゴリー: .NET

3件のフィードバック

Subscribe to comments with RSS.

  1. […] についての詳細な資料は、C# といえばこのサイトの、async/awaitと同時実行制御 がわかりやすいと思います。 […]

  2. […] 参照 非同期プログラミングのパターン Async/Await – 非同期プログラミングのベスト プラクティス (MSDN マガジン) async/awaitと同時実行制御 […]

  3. […] = i; は UI スレッド上で実行されます。このあたりの話は ufcpp さんの説明(文章中の「スレッド […]


コメントを残す