LoginSignup
402
443

More than 5 years have passed since last update.

C# 今更ですが、await / async

Last updated at Posted at 2017-11-30

はじめに

今更すぎて、この記事自体存在価値がなさそう…

C#のスレッド(非同期処理)は、他の言語では見ない類稀な構文であるため、かなり難しいと思います。さらにawait / asyncを使うと、プログラムに書かれていない部分にも意識を回す必要があり、初心者には厳しい関門でしょう。スレッド自体の考え方やできることは言語によらずあまり変わらないのですが、C#での表現方法(文法)が独特であるのと、GUIプログラミングという鬼門の分野があるからでしょう。

スレッドを理解するために、押さえておきたいポイント

文法やプログラムの例から始めようと思ったのですが、スレッドを理解するには、その考え方や注意点などプログラムの裏側にあるものを知らなければならないと思うので、まず理解する上で押さえておくべきポイントをを上げたいと思います。

ポイント1:スレッドをどう起動するのか?
ポイント2:スレッドの終了をどう知るのか?
ポイント3:スレッドの実行結果をどう受け取るのか?

この3つはいいと思います。ただ私は、次の4つ目のポイントがスレッドを使えるようになる上で一番重要であるように思います。

ポイント4:どのスレッドで処理が実行されているのか?

どのスレッドで実行しているのかが良く分からないので、とりあえずスレッドの処理が終わるまで待機しておけばいいとか(スレッドを分かっている人から怒られる)、不用意なミスをするとか(デッドロック)、してしまうように思います。この体験が「スレッドには近寄らない方がいい」という気持ちが発生し、ますますスレッドが苦手、分からないという悪循環になっているように思います。

ポイント1:スレッドをどう起動するのか?

まず、なぜスレッドを使いたいのでしょうか? おそらく時間が掛かる処理を別スレッドで実行して、メインスレッドの処理をじゃましないようにするためでしょう。たとえば、ボタンを押したらアプリケーションがフリーズしないように、子スレッドに処理をさせてメインスレッドは即リターンする、などでしょうか。つまりスレッドで実行したいのは処理であり、そしてプログラムで ~処理を見たら、ほぼメソッドで表現されます。ですから、スレッドで処理をするメソッドをまず作りましょう。例として 1 から 100 まで足した結果(5050)を返すメソッドにします。

int Calculate()
{
    int total = 0;
    for (int i=1; i<=100; ++i)
        total += i;
    Thread.Sleep(4560); // 何か重い処理をしている...
    return total
}

100回足し算をするだけでは今のコンピューターは一瞬で処理が終わってしまうので、何か時間が掛かる処理として Thread.Sleep() を入れておきます。スレッドの説明であるあるです。

これを子スレッドで実行したい訳ですから、こんな風にメソッドを引数にとる書き方ができるとよさそうです。(「メソッドを引数にとる」という言い方は本当は正しくなく、実際には ActionFunc<> が引数です。)


    子スレッドで実行する(Calculate);

C#ではこんな風に1行で書ける構文があるのでしょうか? それがあるんですね。それがTask.Run()です。

    Task<int> task = Task.Run<int>(new Func<int>(Calculate));

残念ながらC#はメソッド名をそのまま書けないので、Func<int>で包んであげます(追記:多分一番良いのは、 Task.Run(() => Calculate())とラムダ式で書くことだと思います)。また、今は説明のため型を明記していますが、実際のプログラムでは型推論が働き、型を省略(Run<int><int>の部分)できます。戻り値の Task<int> task = の部分はすぐ後で説明します。

C#の場合、メソッドをインライン化(=ラムダ式)できるので、こう書くこともできます。よく見る例はこっちかもしれません。

    Task<int> task = Task.Run<int>(() => {
        int total = 0;
        for (int i=1; i<=100; ++i)
            total += i;
        Thread.Sleep(4560); // 何か重い処理をしている...
        return total
    });

いずれにせよ、これでスレッドを起動し、処理を開始させることができました。

ポイント2:スレッドの終了をどう知るのか?、ポイント3:スレッドの実行結果をどう受け取るのか?

C#ではこの2つはほぼ同時にプログラムで表現されるので、1つにまとめました。

さて、ポイント1でスレッドを起動したのですから、次はスレッドの処理の結果をもらうやり方です。Run()の戻り値がスレッドの処理の結果 …ではないです。スレッドの処理の戻り値は、スレッドに投入したメソッドの戻り値のことですから、intです。しかしintがもらえるのはスレッドの処理が完了した後なので、すぐに int がもらえるとおかしいのです。だから Run() の戻り値は、スレッドの処理が完了したときにもらえる予定の Task<int> になるのです。いわゆる引換券みたいなものです。

では、実際のスレッドの戻り値の取り方はどうやるのでしょうか。それは TaskResultプロパティで取得できます。Resultプロパティを使うと、スレッドの処理が「完了」するまで待って、結果を取得できます。

    Task<int> task = Task.Run<int>(new Func<int>(Calculate));
    int result = task.Result; // スレッドの終了まで「待つ」

ただしこう書くと、スレッドが終了するまで待つことになります。Run()で子スレッドに処理を投入したのに、すぐ次の行でスレッドの処理が終了するまで待ってしまっては、非同期処理の利点を完全に殺していることになります。つまり、まったくの無駄です。非同期処理なのですから、メインスレッドはRun()で処理を子スレッドに投入した後、スレッドの完了を待たずさっさと自分の処理に戻りたいのです。そんな都合の良い構文がC#にはあるのでしょうか? まあこう書いている地点であるんですけどね。

   Task<int> task = Task.Run<int>(new Func<int>(Calculate));
   int result = await task; // スレッドの処理の結果を「待ち受け」する

Taskawaitを付けると、メインスレッドは処理を即リターンして、子スレッドの処理が終了すると、int result以降の処理が、おもむろに再開します。何かすごくご都合主義を感じますが、こう書けてしまうのです。awaitの動作は、他の記事でも書かれている通り、yield returnに似ています。また、awaitについて1点注意しておきたいのは、awaitはスレッドの処理が終わるまで待たないで待ち受けをしている、ということです。

ちなみにこの記述は2行に分けなければならない訳ではなく、1行にできます。

    int result = await Task.Run<int>(new Func<int>(Calculate));

これでスレッドの終了通知(awaitを書いておけば勝手にやってくれる)とスレッドの実行結果(awaitTaskから取り出せる)は出来た訳ですが、今までコード断片だったので、もう少しちゃんと書きましょう。このスレッドへの投入を、ボタンクリックで実行するとしましょう。つまりボタンのイベントハンドラで呼び出すとします。スレッドの結果は TextBox コントロールの text へ設定することにします。

    button1.Click += button1_ClickedAsync;
public async void button1_ClickedAsync(object sender, EventArgs e)
{
    Task<int> task = Task.Run<int>(() => {
        int total = 0;
        for (int i=1; i<=100; ++i)
            total += i;
        Thread.Sleep(4560); // 何か重い処理をしている...
        return total
    });
    int result = await task;
    this.text.Text = $"{result}"; // 雑なstring変換
}

メソッド内にawaitがあるということは、すなわちスレッド実行があるということであり、すなわち非同期処理をするということになります。C#では非同期処理をするメソッドに、asyncキーワードを付けるというルールがあります。asyncがあると、呼び出し側はawaitを付けなければならないという、暗黙ルールを伝えることになります。今回の例は、イベントハンドラにしちゃったので await が不要ですが… 正直例としてはイマイチでした。またメソッド名は末尾に Async を付けるという慣習があります(ルールじゃない)。

ポイント4:どのスレッドで処理が実行されているのか?

await / async の文法だけならポイント3までで完了ですが、ポイント4もちゃんと見ておきましょう。

例として、GUIアプリケーションでボタンを押したらスレッドを起動するプログラムにしてみます。単にスレッドを起動して、結果をawaitで待ち受けてテキストボックスに設定するだけです。どのスレッドで実行しているかは、Thread.CurrentThread.ManagedThreadIdでスレッドIDが取れるので、これで分かります。

Form1.cs
public partial class Form1 : Form
{
    public Form1()
    {
        InitializeComponent();
    }

    private async void button1_Click(object sender, EventArgs e)
    {
        Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId}: button1_Click START");
        Task<int> task = Task.Run<int>(new Func<int>(Calculate));
        Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId}: button1_Click Before");
        int result = await task;
        Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId}: button1_Click After");
        this.textBox1.Text = $"{result}";
        Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId}: button1_Click END");
    }

    private int Calculate()
    {
        Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId}: Calculate START");
        int total = 0;
        for (int i=0; i<=100; ++i)
            total += i;
        Thread.Sleep(4560);
        Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId}: Calculate END");
        return total;
    }
}

実行結果

9: button1_Click START
9: button1_Click Before
10: Calculate START
10: Calculate END
9: button1_Click After
9: button1_Click END

await後の処理は、親スレッド(ID=9)で実行されているのが分かると思います。絵で表すと次のようになります。(オレンジが親スレッド、が子スレッド)

2017-11-28_151331.png

await後の処理をするスレッドは、そのメソッドを実行したスレッド、すなわちこの例の場合親スレッドで実行されるというルールがあります。子スレッドのままではありません。一般的な説明では、await後の処理は、メソッドを実行した同期コンテキスト(synchronization context)で実行される、とか同期コンテキストが切り替わる、とか書かれていると思います。

この「親スレッドに切り替わって処理される」というは、GUIプログラムでは重要で、それはUIの更新はメインスレッド(UIスレッド)でしか出来ない、という縛りがあるからです。理由は割愛しますが(長いので)、まぁそういうのがあるんです。

参考サイト:async/awaitと同時実行制御 (++C++; // 未確認飛行 C ブログ)
説明があっさりしていますが、信頼と実績の定番サイトです。

  • デッドロックについて

さて、ポイント4は何が重要なのでしょうか? 試しに、Taskawaitで待ち受けするのではなく、Resultでスレッドが終了するまで待ってみましょう。

Form1.cs
public partial class Form1 : Form
{
    public Form1()
    {
        InitializeComponent();
    }

    private async void button1_Click(object sender, EventArgs e)
    {
        Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId}: button1_Click START");
        Task<int> task = Task.Run<int>(new Func<int>(Calculate));
        Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId}: button1_Click Before");
        int result = task.Result; // ← await ⇒ Resultにしてみた
        Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId}: button1_Click After");
        this.textBox1.Text = $"{result}";
        Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId}: button1_Click END");
    }

    private int Calculate()
    {
        Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId}: Calculate START");
        int total = 0;
        for (int i=0; i<=100; ++i)
            total += i;
        Thread.Sleep(4560);
        Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId}: Calculate END");
        return total;
    }
}

こうすると、メインスレッドではResultで子スレッドの終了を待ち、子スレッドは処理が終了後メインスレッドに処理を復帰させようとするが、メインスレッドの処理が(Resultで待ち続けているため)終わらないので、処理がストップしてしまいます。この状態をデッドロックと呼びます。絵で書くと次のような状態になっています。

2017-11-28_161048.png

これが、よく他のサイトで書かれている TaskResultWait()で「待って」はいけない理由です。

その他、説明できなかったこと

スレッドに引数を渡す

ポイント1でスレッドを実行する方法を書きましたが、スレッドに引数を渡す方法は説明していませんでした。一番簡単なのは、Run()の引数をラムダ式でインライン化してしまえば、直接渡せます。

private async void button1_ClickedAsync()
{
    int m = 999;
    Task<int> task = Task.Run(() => {
        Thread.Sleep(3000);
        return m * 2;
    });

    int result = await task; // -> 1998
    this.textBox1.Text = $"{result}";
}

ラムダ式の内部の値は、いつ評価されるのでしょうか。具体的には、次の場合、taskの結果は 1998 でしょうか、200 でしょうか。

private async void button1_ClickedAsync()
{
    int m = 999;
    Task<int> task = Task.Run(() => {
        Thread.Sleep(3000);
        return m * 2;
    });
    m = 100;

    int result = await task; // -> いくつ?
    this.textBox1.Text = $"{result}";
}

C#の場合、200 になります。ラムダ式の中で、変数が評価されるタイミングで、そのときの値を参照します。ちなみにこの動作は当たり前ではなく、(ラムダ式がある)言語によってマチマチです。C#は変数を評価するタイミングで、値を参照する、というルールになっているという話です。

それでは、ラムダ式を定義したときの値を使いたい、文字通りスレッドに引数を渡したい場合はどうすればいいでしょうか。それはTaskコンストラクタの第2引数で値を渡せます。残念ながら、Run()メソッドには引数を渡すオーバーロードがありませんでした。Tasknewした場合は、Start()でスレッドを開始できます。

private async void button1_ClickedAsync()
{
    int m = 999;
    Task<int> task = new Task(x => {
        Thread.Sleep(3000);
        return x * 2;
    }, m);
    task.Start();

    m = 100;

    int result = await task; // -> 1998
    this.textBox1.Text = $"{result}";
}

非同期メソッドの戻り型(async void?、 async Task?)

非同期にしたいメソッドの戻り値がvoidであるとき、スレッドの実行結果をもらう必要がありません。この場合、async Task HogeAsync()ではなくasync void HogeAsync()ではいけないのでしょうか?

async voidTaskを返さないので、async Taskと比べて次のことが出来なくなります。

  • awaitで待ち受けできなくなり、そのためスレッドの終了を知ることが出来ない、投げっぱなし(fire and forget)になる。
  • 例外を捕獲できない

async voidにすること自体には問題ありません(例外を処理できないことを除いて)。 しかし、async Taskで出来ることがasync voidで出来なくなり、その逆は無いため、async voidを積極的に使う理由がありません。そのため、基本的には指向停止でasync Taskを使えば良いでしょう。async voidを使いたいときは、もともと非同期にしたいメソッドがvoidでなければならない、つまりイベントハンドラ―ということになります。

参考サイト:Async/Await - Best Practices in Asynchronous Programming (英語です)

追記:ただしASP.NETの場合は、async voidの場合は常に誤りになります。私はASP.NETは詳しくないので理由がよく分かりませんが、次のページを読んでくれたら、と思います。

参考サイト:asyncの落とし穴Part3, async voidを避けるべき100億の理由 
ASP.NETでaysnc voidを使っていはいけない話

コンソールアプリケーションでの同期コンテキスト

ポイント4の話の続き。今までわざとらしくGUIアプリケーションにしていましたが、それはコンソールアプリケーションでは await 後で実行されるスレッドがGUIと異なっているからです。

Program.cs
class Program
{
    static void Main(string[] args)
    {
        var task = CalculateAsync();
        var result = task.Result;

        Console.ReadLine();
    }

    static async Task<int> CalculateAsync()
    {
        Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId}: CalculateAsync START");
        var task = Task.Run(new Func<int>(Calculate));
        Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId}: CalculateAsync Before");
        var result = await task;
        Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId}: CalculateAsync After");
        return result;
    }

    static int Calculate()
    {
        Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId}: Calculate START");
        int total = 0;
        for (int i = 1; i <= 100; ++i)
            total += i;
        Thread.Sleep(2345);
        Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId}: Calculate END");
        return total;
    }
}

9: CalculateAsync START
9: CalculateAsync Before
10: Calculate START
10: Calculate END
10: CalculateAsync After

子スレッドのまま(ID=10)です。これは、コンソールアプリケーションの場合、(規定の)同期コンテキストがないため、戻れないからそのまま子スレッドで実行されます。これは、同じプログラムでも実行されるアプリケーションによって動作が異なることを意味しています。ライブラリ作成者はどうするんでしょうか。ASP.NET でも動作が異なるようなのですが、私はASP.NETは全く変わらないので、すみませんがこの記事では書きません(書けません)。

まとめ

結構いろいろ端折ったのですが、それでもやはりスレッドの話は長くなってしまいました。await / asyncについてはこの位でいいのですが、スレッド全体としてはまだまだ不十分です。スレッドプールやスレッドローカル(変数)、スレッドの中断なんていうのもありますが、もう一つ(2つ)大きなテーマとして、スレッド間の同期を取る同期オブジェクトとシグナルの話があります。同期オブジェクトとシグナルの話は、また別の機会に書きたいと思います。

最後にポイントをまとめておきます。

  • Task.Run()でスレッド実行。
  • Run()の戻り型は Task<T>で、これはスレッドの終了結果をもらう予約(まだもらえてない)。
  • Taskawait で「待ち受け」する。待ち受けしている間は、メソッドを即リターンして別の処理を行っている。
  • スレッドが終了すると、勝手にawaitの後ろから処理が再開する。そのとき実行するスレッドは親スレッドになる。
  • スレッドが終了すると処理は親スレッドに移るので、TaskResultWait()で「待って」はいけない。
  • 非同期処理をするメソッドには async をつける。これは呼び出し側が await がいる、という目印になる。(厳密には、asyncが付いているメソッドは戻り型がTask<T>(C# 6.0以前)で、Task<T>awaitで「待ち受け」するのでawaitがいる)
  • 追記:戻り値が Task<T> であれば、awaitを付けて「待ち受け」をする。安易にResultWait()で「待って」しまうと、デッドロックする場合がある。
402
443
7

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
402
443