Skip to content

Instantly share code, notes, and snippets.

@pocketberserker
Last active October 6, 2022 02:53
Show Gist options
  • Save pocketberserker/5565303 to your computer and use it in GitHub Desktop.
Save pocketberserker/5565303 to your computer and use it in GitHub Desktop.
Async in C# and F#: Asynchronous gotchas in C# (Japanese translation)

C# と F# の Async: C# の非同期の落とし穴

原文:Async in C# and F#: Asynchronous gotchas in C#
原文著者:Tomas Petricek (@tomaspetricek)
翻訳者:@pocketberserker

2月に、私は毎年恒例のMVPサミット ── Microsoft が MVP のために主催するイベント ── に出席しました。私はボストンとニューヨークを訪問する機会を利用して、二つの F# に関する講演と Channel9 lecture about type providers の収録を行いました。他のすべての活動(しばしばパブで他の F#er を議論に巻き込んだり、朝まで長い睡眠)にもかかわらず、私はいくつかの講演に参加し果せました。

一つの(NDA ではない)講演は 、 Async Clinic という、 C# 5.0 における新しい async と await キーワードに関する講演でした。Lucian と Stephen は C#er が非同期プログラムを書くときに直面する一般的な問題について講演しました。この記事では、 F# の観点から問題のいくつかを見てみましょう。この講演は非常に活発で、誰かが次のような、 F# 側の聴衆の反応を記録しました。

https://twitter.com/josefajardo/status/303998917027192832

これはなぜでしょう?一般的なエラーの多くは F# 非同期モデル(2007年にリリースされたF# 1.9.2.7から Visual Studio 2008に同梱されています)を使用するときには成り立たない(または非常に少ない)とわかりました。

落とし穴 #1: Async は非同期に動かない

C# 非同期プログラミングモデルの最初のトリッキーな側面を見ていきましょう。次の例を見て、どのような順序の文字列が出力されるか考えてください(私は講演で示された正確なコードを見つけることができませんでしたが、 Lucian が似たようなコードを提示していた覚えがあります)。

async Task WorkThenWait() {
  Thread.Sleep(1000);
  Console.WriteLine("work");
  await Task.Delay(1000);
}

void Demo() {
  var child = WorkThenWait();
  Console.WriteLine("started");
  child.Wait();
  Console.WriteLine("completed");
}

もし "started", "work", "completed" のように出力されると推測したのなら、間違っています。このコードは "work", "started", "completed" と出力します。試してみてください!コードを書いた人は( WorkThenWait を呼び出すことで)作業を開始し、その後でもっと遅いタスクを待つつもりでした。問題は、 WorkThenWait は何か重い計算(ここでは Thread.Sleep )を行って初めて、 await を使うことにあります。

C# では、async メソッドにおいてコードの最初の部分は(呼び出し側のスレッド上で)同期的に実行されます。最初に、例えば await Task.Yield() を追加することにより、この問題を修正することができます。

対応する F# のコード

F# ではこれは問題ではありません。 F# で非同期コードを書くとき、 async { ... } ブロック内のコード全体は、すべて遅延され、それ以降(あなたが明示的に起動した時)でのみ開始されます。上記の C# コードは次の F# コードに対応します。

let workThenWait() =
  Thread.Sleep(1000)
  printfn "work done"
  async { do! Async.Sleep(1000) }

let demo() =
  let work = workThenWait() |> Async.StartAsTask
  printfn "started"
  work.Wait()
  printfn "completed"

workThenWait 関数が非同期計算の部分として作業( Thread.Sleep )を行わず、関数が呼ばれた時(そして非同期ワークフローが開始された時ではない)に実行されるのは明白です。 F# でよくあるパターンは、 async で関数全体をラップすることです。 F# では、期待通りに動作させるには次のように記述します。

let workThenWait() = async {
  Thread.Sleep(1000)
  printfn "work done"
  do! Async.Sleep(1000) }

落とし穴 #2: 結果を無視する

ここでは C# 非同期プログラミングにおける別の落とし穴を取り上げます(これは Lucian のスライドから直接いただきました)。次の非同期メソッドを実行したときに何が起こるか推測してください。

async Task Handler() {
  Console.WriteLine("Before");
  Task.Delay(1000);
  Console.WriteLine("After");
}

"Before" を出力して、1秒待機した後に "After" を出力すると期待しました?間違っています!これはいずれかの間で待たず、すぐに両方のメッセージを出力します。問題は、 Task.Delay は Task を返すのですが、 await を使って完了するまで待つのを忘れていたことにあります。

対応する F# コード

繰り返しになりますが、 F# ではおそらくこの問題に遭遇しません。あなたはきっと Async.Sleep を呼び、返ってきた Async<unit> を無視するコードを書くことができます。

let handler() = async {
  printfn "Before"
  Async.Sleep(1000)
  printfn "After" }

このコードを Visual Studio、MonoDevelop、Try F# などに貼り付けたら、警告と共に直接フィードバックを得るでしょう。

warning FS0020: この式には型 'unit' が必要ですが、型 'Async<unit>' が指定されています。'ignore' を使用して式の結果を破棄するか、'let' を使用して結果を名前にバインドしてください。

コンパイルして実行することはできますが、警告を読むと、式が Async<unit> を返し do! を使って待つ必要があることがわかると思います。

let handler() = async {
  printfn "Before"
  do! Async.Sleep(1000)
  printfn "After" }

落とし穴 #3: Async void メソッド

講演時間のかなり多くは async void メソッドに捧げられました。あなたが async void Foo() { ... } を書く場合、そのとき C# コンパイラは void を返すメソッドを生成します。裏側でそれが作成され、タスクを開始します。これは、作業が実際に起こったときに伝える方法がないことを意味します。

ここでの講演から、 aysnc void パターンに関する勧告は次の通りです。

後生だから async void の使用をやめてください!

公平を期すために ── async void メソッドは、イベントハンドラを書いている場合に使用すると便利 かも しれません。イベントハンドラは void を返す必要があり、しばしばバックグラウンドで作業を開始したり継続したりします。しかし私は、 MVVM の世界ではこれは本当に便利だとは思いません ── が、これに関してはカンファレンスの講演で素敵なデモを行ことにします。

C# での非同期プログラミングについて、MSDNマガジンの記事 のスニペットを使用して問題を実証してみましょう。

async void ThrowExceptionAsync() {
  throw new InvalidOperationException();
}

public void CallThrowExceptionAsync() {
  try {
    ThrowExceptionAsync();
  } catch (Exception) {
    Console.WriteLine("Failed");
  }
}

あなたはこのコードが "Failed" と出力されると思いますか?あなたは既にこのブログ記事のスタイルを理解しているでしょう…。実際、 ThrowExceptionAsync は作業を開始し、直ちに戻る(そして例外はバックグランドスレッド上でどこかで発生する)ので、例外はハンドルされません。

対応する F# コード

このように、プログラミング言語機能を使うべきでないならば、それはおそらく第1級の機能に含めないほうが良かったのです。 F# では async void 関数を記述できません ── async { ... } ブロックで関数本体をラップするとき、戻り値の型は Async<T> になります。型注釈を使って unit を要求した場合は、 type mismatch になるでしょう。

あなたは Async.Start を使用して C# に対応するコードを書くことができます。

let throwExceptionAsync() = async {
  raise <| new InvalidOperationException() }

let callThrowExceptionAsync() =
  try
    throwExceptionAsync()
    |> Async.Start
  with e ->
    printfn "Failed"

これもまた例外をハンドルしません。しかし、明示的に Async.Start を記述しなければならなかったので、何が起こっているかがより明白です。 Async.Start を書かなかった場合、関数は Async<void> を返し、(前のセクション "結果を無視する" のように)結果を無視しているという警告を得るでしょう。

落とし穴 #4: Async void ラムダ関数

更にトリッキーなケースは、デリゲートのようないくつかのメソッドに非同期ラムダ関数を渡す場合です。このケースでは、 C# コンパイラはデリゲート型からメソッドの型を推測します。 Action デリゲート(またはそれに類似のもの)を使用する場合、コンパイラは(作業を開始して void を返す)async void 関数を生成します。 Func<Task> デリゲートを使用する場合、コンパイラは Task を返す関数を生成します。

ここでは Lucian のスライドから引用したサンプルを見ましょう。次の(完全に有効な)コードは(すべてのタスクの sleep が終了した後)1秒で完了するでしょうか、それともすぐに終了するでしょうか?

Parallel.For(0, 10, async i => {
  await Task.Delay(1000);
});

For は Action デリゲートを受け取るオーバーロードだけを持っていること ── ひいては、このラムダ関数が常に async void としてコンパイルされるという事を知らない限り、何が起こるかわからないでしょう。これはまた、このような(たぶん便利な?)オーバーロードを追加することは、破壊的変更であることも意味します。

対応する F# コード

F# は特別な "async ラムダ関数" を持っていませんが、非同期計算を返すラムダ関数を書くことができます。このような関数の戻り値の型は Aysnc<T> であり、戻り値の型が void であるデリゲートを期待するメソッドの引数に渡すことはできません。次の F# コードはコンパイルできません。

Parallel.For(0, 10, fun i -> async {
  do! Async.Sleep(1000)
})

エラーメッセージは単に、関数の型 int -> Async<unit> が Action<int> デリゲート( F# では int -> unit )と互換性が無いことを言っています。

error FS0041: メソッド 'For' に一致するオーバーロードはありません。使用できるオーバーロードを以下に示します (または [エラー一覧] ウィンドウを参照してください)。

上記の C# コードと同じ動作を得るためには、明示的に作業を開始する必要があります。バックグラウンドで非同期ワークフローを開始したい場合は、(戻り値の型が unit である非同期計算を受け取り、スケジュールして unit を返す)Async.Start を使うと簡単です。

Parallel.For(0, 10, fun i -> Async.Start(async {
  do! Async.Sleep(1000)
}))

確かにこれを書くことができますが、何が起こっているのかを見ることは非常に簡単です。 Parallel.For のポイントは(一般的に同期機能である)CPU集中型の計算を並行に実行することなので、資源を無駄にしていることを確認することも難しくはないです。

落とし穴 #5: タスクのネスト

私は、 Lucian が聴衆の中で人々の頭のなかでコンパイルするスキルをテストするために次のものを含めたと考えているのが、ここの内容です。質問は、「次のコードは2つの出力の間に1秒待つか?」というものです。

Console.WriteLine("Before");
await Task.Factory.StartNew(
  async () => { await Task.Delay(1000); });
Console.WriteLine("After");

繰り返しになりますが、かなり予想外に、これは実際には2つの書き込みの間で待機しません。どのようにすれば可能になるのでしょう? StartNew メソッドはデリゲートを受け取り、 Task<T> を返します。ここで T はデリゲートによって返される型です。上記のケースでは、デリゲートは Task を返し、 Task<Task> という結果を得ます。 await の使用は、(すぐに内側のタスクを返す)外側のタスクの完了を待ち、その後は内側のタスクは無視されます。

C# では、 StartNew の代わりに Task.Run を使用することによって(もしくはラムダ関数内で async と await をドロッピングすることで)、これを解決できます。

F# で類似した何かを書くことができるでしょうか? Task.Factory.StartNew と async ブロックを返すラムダ関数をつかって Async<unit> を返すタスクを作ることができます。タスクを待つために、 Async.AwaitTask を使用して非同期ワークフローに変換する必要があります。これは、 Async<Async<unit>> を得ることを意味します。

async {
  do! Task.Factory.StartNew(fun () -> async {
    do! Async.Sleep(1000) }) |> Async.AwaitTask }

繰り返しますが、このコードはコンパイルできません。問題は、 do! キーワードは右側に Async<unit> を要求しますが、実際には Async<Aysnc<unit>> を取得することです。言い換えると、単に結果を無視することはできません。我々は明示的にそれで何かをする必要があります( C# の動作を複製する Async.Ignore を使用することができます)。エラーメッセージは、前のメッセージのように明確ではないかもしれませんが、アイデアを得ることはできます。

error FS0001: この式に必要な型は Async<unit> ですが、ここでは unit 型が指定されています

落とし穴 #6:非同期に実行されない

ここでも Lucian のスライドから別の問題のコードスニペットを見ましょう。今回は、問題は非常に簡単です。次のコードは非同期メソッド FooAsync を定義し、 Handler からそれを呼び出しますが、コードが非同期で実行されません。

async Task FooAsync() {
  await Task.Delay(1000);
}
void Handler() {
  FooAsync().Wait();
}

問題を発見するのはあまり難しいことではありません。我々は FooAsync().Wait() を呼び出しています。これはタスクを作成した後、 Wait を使用し、それが完了するまでブロックすることを意味します。単にタスクを開始したいだけなので、単に Wait を取り除くことで問題を修正します。

同様の動作を行うコードを F# で書くことはできますが、非同期ワークフローは(もともと CPU 制約のある計算のために設計された) .NET Tasks を使わず、代わりに Wait が存在しない F# Aysnc<T> を使います。これは次のように書く必要があることを意味します。

let fooAsync() = async {
  do! Async.Sleep(1000) }
let handler() =
  fooAsync() |> Async.RunSynchronously

確かに、偶然このようなコードを書くこともできますが、非同期的に実行しないという問題に直面している場合は、コードは RunSyncronously を呼び出して ── 名前が示すように ── 同期的に作業を行わせることで簡単に見分けることができます。

まとめ

この記事では、 C# の非同期プログラミングが予期しない動作を行う6例を見ました。それらのほとんどはMVPサミットでの Lucian と Stephen による講演をベースにしたので、一般的な落とし穴の興味深いリストを共有できたのは両人のおかげです!

私は、F# の非同期ワークフローを利用して、同様の動作をするコードスニペットの発見を試みました。殆どの場合、F#コンパイラは警告かエラーを報告します ── またはプログラミングモデルが同じコードを表現するための(直接的な)方法がありません。私は、これは 以前のブログ記事 で書いた"F# のプログラミングモデルは、間違いなく関数(宣言)プログラミング言語に適していると感じます。何が起こっているか論証することも簡単になると考えます。"という主張をサポートすると考えます。

最後に、この記事は C# async の辛辣な批判として理解されるべきではありません:-) 私はなぜ C# のデザインが原理に従うかを十分に理解できます ── C# では、(独自の Async<T> の代わりに) Task<T> を使うことが理に適っていますが、この Task<T> にはいくつもの意味が含まれています。そして私は、他の意思決定の背後にある理由を理解できます ── それはおそらく C# で非同期プログラミングを統合するための最良の方法です。しかし同時に、私は F# は良い仕事をしていると思います ── ひとつには組み合わせやすさですが、もっと重要なのは F# agents のような優れた補強のためです。

また、F# の async も問題を持っています(最も一般的な落とし穴は、末尾再帰関数はリークを避けるために do! の代わりに return! を使うべきだということです)。しかしそれは独立したブログ記事のためのトピックです。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment