Edited at

async/await で書いたコードと実行されるスレッド


TL;DR

await前
await
awai`後

動作スレッド
asyncメソッドの呼び出し元のスレッド
別スレッド

SynchronizationContext.Currentのスレッド

呼び出し時点から見た同期状態
同期
非同期
非同期

一般的なユースケースであろう、FormやUnityのUIスレッドではSynchronizationContext.currentにUIスレッドが設定されているので、UIスレッドからの呼び出しについては

await前
await
await後

動作スレッド
UIスレッド
別スレッド
UIスレッド

呼び出し時点から見た同期状態
同期
非同期
非同期(後回し)

public void OnClick (object sender, System.EventArgs e)

{
// Thread#0
HeavyAsync();
// Thread#0
Console.ReadKey(); // Thread#0
}

static async Task HeavyAsync()
{
// Thread#0
await Task.Delay(1000); // Thread#1
// Thread#0
await Task.Delay(1000); // Thread#1
}


awaitすると何が起こるか

await Task.Delay(300);

Console.WriteLine("After");

というように書いたとします。すると、意味としては次のようになるようです

var context = SynchronizationContext.Current; //Thread#a

Task.Delay(300).ContinueWith(
_=>context.Post(_=> // contextのThread、Thread#aにタスクをさせる
Console.WriteLine("After")
)
);

つまり、awaitもその後もその場で実行されることはありません。最初のawaitが出てきた時点でasyncメソッドの呼び出し元に戻ります。

しかし、SynchronizationContext.Currentに呼び出だし元のスレッドが入っている場合、awaitの後は呼び出し元に戻ります。戻るといっても即時実行されるわけではなく、タスクとして後回しにされます。


使い道

どうせ後回しにするなら、「別スレッドでそのまま処理した方がいいじゃない」と思いそうですが、UIスレッドでしかアクセスできないクラスが結構あります。(スレッドセーフでないため)そう考えると、言語仕様は次のような使われ方を想定してるんだなと合点がいきます。

Label label; // UIのコントロールで、UIスレッドでしかアクセス不可

public aysnc Task OnClick (object sender, System.EventArgs e)
{
var content = await LoadFromFileAsync();
label.text = content; // ここは後回しとは言えどUIスレッドなのでOK
}

これをawait使わずに書こうとすると…

Label label; // UIのコントロールで、UIスレッドでしかアクセス不可

public void OnClick (object sender, System.EventArgs e)
{
var context = SynchronizationContext.Current;
LoadFromFileAsync().ContinueWith(
content=>context.Post(content=>
label.text = content;
// ポイントは、「LoadFromFileの直後に回される」のではなく、
// 「LoadFromFileの直後に『UIスレッドが暇なときに回される』という処理を回す」
// ということで、コードの雰囲気とは若干タイミングがずれている
));
}

awaitの方が処理の一見みやすい気がしますが、ここまでのことをちゃんと理解していないと、実行順序が思わぬものになってしまいますね。この例のような単純なものでは全く意識せずともうまくいくと思いますが、メンバの呼び出し順序が厳しいクラスを使っていたり、なまじ昔ながらのスレッドをかじっていると混乱しそうです。(しました)


コンソールアプリなどの場合

自分で設定しない限りSynchronizationContext.Currentnullなので、呼び出し元スレッドに後回しで戻そうにも戻れません。他のスレッドに回されます。

コンソールアプリでは、メインスレッドでないと出来ないようなことはGUIアプリほどないので、awaitの旨味もあまりないですね。


検証


Formアプリ

void OnClic(EventArgs e){

Console.WriteLine("1) "+Thread.CurrentThread.ManagedThreadId);
HeavyAsync();
Console.WriteLine("Hello World!");
Console.ReadKey();
}

static async Task HeavyAsync()
{
int c = 2;
for(int i=0;i<3;i++){
Console.WriteLine(c++ +") "+Thread.CurrentThread.ManagedThreadId);
long time = DateTime.Now.Ticks;
while(DateTime.Now.Ticks < time + 3000000) { }
Console.WriteLine(c++ +") "+Thread.CurrentThread.ManagedThreadId);
await Task.Delay(300);
}
Console.WriteLine("Heavy end!");
}


console

1) 1

2) 1
3) 1
Hello World!
<- ReadKeyの入力のEnter(押すまでブロックされてる)
<- OnClickは抜けた
4) 1 <- 後回しにされてる
5) 1 <- 後回しにされてる
6) 1 <- 後回しにされてる
7) 1 <- 後回しにされてる
Heavy end! <- 後回しにされてる


コンソールアプリ

static void Main(string[] args)

{
Console.WriteLine("1) "+Thread.CurrentThread.ManagedThreadId);
HeavyAsync();
Console.WriteLine("Hello World!");
Console.ReadKey();
}

static async Task HeavyAsync()
{
int c = 2;
for(int i=0;i<3;i++){
Console.WriteLine(c++ +") "+Thread.CurrentThread.ManagedThreadId);
long time = DateTime.Now.Ticks;
while(DateTime.Now.Ticks < time + 3000000) { }
Console.WriteLine(c++ +") "+Thread.CurrentThread.ManagedThreadId);
await Task.Delay(300);
}
Console.WriteLine("Heavy end!");
}


console

1) 1

2) 1
3) 1
Hello World!
4) 4 <- Mainはブロックされたままだが、別スレッドで実行されてる
5) 4
6) 5
7) 5
Heavy end!
<- ReadKeyはまだブロッキングを続けている

FormアプリではUIスレッドがブロックされている間、awaitの後の処理がされていないのに対し、コンソールアプリではされています。SynchronizationContext.CurrentがUIスレッドになっているか、nullになっているかの違いです。


asyncメソッド呼び出し時、すぐにスレッドが切り替わって欲しい場合

Task.Run(HeavyAsync);


console

1) 1

Hello World!
2) 3 <- ReadKeyしてるけど関係ない
3) 3
4) 4
5) 4
6) 4
7) 4
Heav end!
<- まだReadKeyがブロックしてる

即座に切り替わっています。最早、呼び出しスレッドは関係なくなります。