C# 5の非同期、パート6: asyncはいずこへ?
2018-01-19 10:24:18
await
式を含むメソッドの前には、コンテキストキーワードである async
を置く必要があるというデザインなのは、どういう理由があって決定したことなのか、何人かの人々が私に尋ねた。
どんなデザイン上の決定でも同じことだが、そこにはメリットとデメリットがあり、多くの異なる原則は競合するので共存できないという文脈で評価されなければならない。すべての基準を満たすとか、誰もを喜ばせるといったスラムダンク・ソリューションはなさそうだ。我々は、達成できる妥協点を常に探しているのであり、到達不能な完全性を求めているのではない。このデザイン決定はその良い例である。
我々の鍵となる主義の1つは、「可能な場合はいつでも、破壊的な変化を避けること」である。理想的には、C# 1、2、3、4で動作していたあらゆるプログラムがC# 5で同様に動作するならば、それは素晴らしいだろう1。私が数個前のエピソードで言及したように2、接頭辞演算子を追加する時は、曖昧さが残る可能性がある点が多くあり、我々はそれら全てを除きたい。我々は付与された await
がキーワードではなく識別子を意図しているかどうかをうまく推測することができる多くのヒューリスティックスを考えたが、それらのどれもが気に入らなかった。
var
と dynamic
のためのヒューリスティックスはもっとずっと簡単だった。なぜなら、 var
はローカル変数宣言において特別なだけだし、 dynamic
は型が合法的な文脈で特別なだけだからである。キーワードとしての await
は、式または型が合法的であるメソッド本体の中のほとんど至る所で合法的である。そしてそれは、理にかなったヒューリスティックスを設計・実装・テストしなければならない点の数を大いに増やす。議論されたヒューリスティックスは 微妙 で 複雑 だった。たとえば、 var x = y + await;
は明らかに await
を識別子として扱わなければならないが、 var x = await + y;
は同じようにしなければならないのか、それとも、 y
に適用された単項プラス演算子の await
なのか? var x = await t;
はキーワードとして扱わなければならない。 var x = await(t);
は同じようにしなければならないのか、それとも、 await
という名前のメソッド呼び出しなのか?
async
を必要とすることは、我々がすぐにすべての後方互換性問題を除くことができることを意味する; await
式を含むどんなメソッドも、旧作のコードではなく新築のコードに違いない。なぜなら、旧作のコードには決して非同期修飾子がなかったからだ。
破壊的な変化を同じように避けるための他のアプローチは、 await
式に2語キーワードを使用することである。それは、我々が yield return
でしたことである。我々は多くの2語パターンを考慮した; 私のお気に入りは、 wait for
であった。我々は yield with
や yield wait
といった形の選択肢を拒絶した。なぜなら、反復子ブロックの微妙に異なる継続の振る舞いと混同される恐れが高すぎると我々が感じたからだ。我々は、 yield
の論理的な意味は「値を差し出す」であり、「制御の流れを呼び出し元に譲る」ではないというように人々を効果的に訓練したが、もちろんそれは両方の意味を持っている!我々は return
と continue
を含んでいる選択肢を拒絶した。それは、制御の流れに関するそれらの形とあまりに簡単に混同されるからだ。 while
を含んでいる選択肢も問題を含む; 初心者プログラマーは、 while
ループは状態が偽になった瞬間に出られるのか、それともループの最後まで進行し続けるのか、と時折尋ねる。非同期に while
を使うことで、類似した混乱がどのように生じる可能性があるか、あなたは理解できるだろう。
もちろん、 await
も同様に問題を含む。これの基本的な問題は、2種類の待機があるということである。あなたが病院の待合室にいるならば、あなたは医者の手が空くまで寝入ることによって待つかもしれない。あるいは、あなたは雑誌を読んだり、口座の残高計算をしたり、あなたの母を呼び出したり、クロスワードパズルをしたり、そういったことによって待つかもしれない。そして、タスクベース非同期のポイントは、後者の待機モデルを受け入れることである; あなたは、タスクが完了するのを待つ間に、眠るのではなくて何かをこのスレッドの上でしてもらい続けたい。よってあなたは、していたことを覚えておくことによって待ち、それから、あなたが待つ間他に何かをする。私が期待しているのは、我々がどの種類の待機について話しているかをはっきりさせるようにユーザーを教育するという課題を克服できることだ。
最後に、それが await
であるかどうかにかかわらず、デザイナーたちはそれが一語機能であることを強く望んだ。我々は、この機能が一つのメソッドの中で数えきれない回数使われる可能性があることを予期している。多くの反復子ブロックは yield return
を1つか2つだけしか含まないが、複雑な非同期処理を組織化するコードの中には何十もの await
が存在する可能性がある。簡潔な演算子を持つことは重要である。
もちろん、それが簡潔すぎることも望まれない。F#は、彼らの非同期ワークフロー処理のために do!
や let!
などを使う。その!せいで!コードが!刺激的に!見える!しかし、それは理解するために知っていなければならない「秘密のコード」でもある; この秘密は解明しやすいとは言えない。 async
と await
を見た場合ならば、少なくとも、キーワードが意味するものについて若干の手掛かりを持つ。
もう一つの原則は、「他の言語機能と一致すること」である。我々は、ここで2つの方向から引っ張られている。一方では、あなたは反復子ブロックを含むメソッドの前で iterator
と言う必要はない。(我々がそうしたならば、 yield return x;
は単に yield x;
であることもできた。)これは、反復子ブロックと矛盾するようである。他方では……あとでこの点に戻ってこよう。
我々が考慮するもう一つの原則は、「驚き最少の原則」である。より詳しくは、小さな変化には、局所的ではない驚くべき結果があってはならない。以下を考慮しよう:
void Frob<X>(Func<X> f) { ... }
...
Frob(() => {
if (whatever)
{
await something;
return 123;
}
return 345;
} );
await something;
をコメントアウトすることで、 X
のために推論される型が Task<int>
から int
に変わることは、奇怪で混乱させるようである; 我々は、ラムダに戻り値の型の注釈を加えたくはない。したがって、我々には多分、 await
を含むラムダに async
を必要とすることが伴うだろう:
Frob(async () => {
if (whatever)
{
await something;
return 123;
}
return 345;
} );
これなら、たとえ await
がコメントアウトされるとしても、 X
のために推論される型は Task<int>
である。
ラムダに async
を必要とする強い圧力がある。我々は、言語機能には首尾一貫していて欲しいし、匿名関数には async
が必要だが名前のあるメソッドでは不要だとすることは矛盾しているように思えるので、つまりそれが、メソッドでも同様にそれを必要とすることへの間接的な圧力である。
小さな変更が大きな違いを引き起こす別の例:
Task<object> Foo()
{
await blah;
return null;
}
async
が必要とされないならば、 await
を持つこのメソッドは結果が null
にセットされた非nullのタスクを作り出す。もし我々がテスト目的で await
をコメントアウトしたとするならば、それはnullタスクを作り出す――完全に別である。我々が async
を必要とするならば、メソッドは両方の方法で同じものを返す。
もう一つのデザイン原則は、メソッドのような宣言されたエンティティの本体に先行するものは、すべてエンティティのメタデータを表現するものであるということである。名前、戻り値の型、型パラメータ、正式なパラメータ、属性、アクセシビリティ、static
/ instance
/ virtual
/ override
/ abstract
/ sealed
といったものは、すべてメソッドのメタデータの一部分である。 async
と partial
はそうではない。そして、それは矛盾しているように思える。言い換えよう: async
は、単にメソッドの実施詳細を記述するだけのものである; それは、メソッドがどのように使われるかに影響を及ぼさない。与えられたメソッドが async
であるとマークされているかどうかは、呼び出し元は少しも気にしない。であればなぜ、それをコードの中の、呼び出し元を書いている人が読みそうな場所に入れなければならないのか?これは、 async
に反対する論点である。
他方、別の重要なデザイン原則は、興味深いコードはそれ自体に対する注意を呼び出さなければならないということである。コードは書かれるよりももっとたくさん読まれる。非同期メソッドには、通常のメソッドと非常に異なる制御の流れがある; コードの保守者が即座にそれを読むであろう先頭でそれを呼び立てることには意味がある。反復子ブロックは短い傾向がある; 私は、1画面に収まらない反復子ブロックをこれまでに書いたとは思わない。反復子ブロックをちらっと見て、 yield
を見つけることは、かなり簡単である。非同期メソッドが長くなりえることと、 await
が即座に明らかにならないようなどこかに埋められているかもしれないことが想像できる。あなたがメソッドを先頭から眺めたときに、このメソッドがコルーチンのように振舞うのを一目で分かることができたら、それは素晴らしい。
別の重要なデザイン原則は、「言語は、リッチ・ツールに従わなければならない」である。我々が async
を必要とするとしよう。ユーザーはどんなエラーをするだろうか?ユーザーは、 await
を含まずに async
修飾子を持つメソッドが、別のスレッドの上で動作すると信じるかもしれない。あるいは、ユーザーは await
をいくつか含むメソッドを書くが、 async
修飾子を付与し忘れるかもしれない。我々は、両方のケースで問題を特定して、機能を使う方法を開発者に教えることができるリッチな診断を生じるコード・アナライザーを書くことができる。診断は、たとえば、 await
のない async
メソッドが別のスレッドの上で動作しないことをあなたに思い出させることができ、それが本当にあなたが望むものであるならば、平行動作を達成する方法の提案をすることができる。あるいは、診断は、 await
を含んでいて int
を返すメソッドは(おそらく自動的に!) Task<int>
を返す非同期メソッドにリファクタリングする必要があるとあなたに話すことができるかもしれない。診断用エンジンは、このメソッドのすべての呼び出し側を捜して、それらを非同期にしなければならないかどうかについてのアドバイスを順番にすることもできるだろう。 async
が必要とされないならば、我々はこれらの種類の問題を簡単に見つけることも診断することもできない。
これらが、たくさんあるメリットとデメリットである; それらの全てを評価して、それがどのように感じられるかを知るためにプロトタイプ・コンパイラを何度もいじった後で、C#デザイナーは await
を含むメソッドが async
を必要とすることにした。私は、それが理にかなった選択であると思う。
謝辞: 私の同僚ルシアンに多謝。このエピソードの基礎となった、仔細なデザイン・メモについての彼の洞察と優れた要約に対して。
次回: 私は例外について少し話して、それから async
/ await
のことはしばらく休みたい。同じ話題に関する1ダースの投稿をたった2、3週間で行ったのは、多かった。
-
我々は多数の出来事でこの原則に違反した。(1)偶然に、そして(2)利益が本当に非常に魅力的で、破損の確率が低そうだった時は故意に。後者の有名な例は、
F(G<A,B>(7))
である。C# 1では、F
には2つの引数があり、その両方が比較演算子であることを意味する。C# 2では、F
には1つの引数があり、G
は型引数が2個のジェネリック・メソッドであることを意味する。 ↩︎ -
私がその記事を書いたとき、我々が接頭辞演算子として
await
を追加しようとしていたことを私は知っていた。我々は、曖昧さの可能性がある点を見つけるために仕様を改良するプロセスを最近完了していたので、それは書くのが簡単な記事であった。もちろん、私は9月の時点では例としてawait
を使うことができなかった。我々はC# 5の新しい機能を漏らしたくなかったからだ。それで私は、うまく意味がないようにfrob
を選択したのだ。 ↩︎