敢えて過去の過ちに言及して話を始めます
株式会社 ACCESS の三原と申します。
僕は C# については2024年10月8日に「C#でのTaskの標準的作法を解説するネット記事にケチをつけたらマサカリをいただきました (旧題:「C#でasync Task型関数をWait()/Resultで待つとデッドロックする」というイディオムが疑わしいことについて)」という記事を公開し、C# 言語仕様と世の開発者の技術水準を見誤っていることを示しました。そのような人物が再び C# について記すことには、あらかじめ、不審の目がつきまといます。それは身から出た錆で、当然です。マイナスからのスタートと自覚して、本記事を記させていただきます。
ギミックの構造と実装コード
C# で非同期関数(async キーワード付)を同期関数(async キーワード無し)から呼び出す方法については Task.Run() 関数などワーカースレッドを用いることが定石とされています。
そこに、日頃忌避される C# の機能を用いれば、別の方法を採り得ることに気づきました。
async void 型関数は同期関数から呼び出せますが、多くの理由により使用を敬遠するべきとされます。
- 内部で Task.ConfigureAwait(false) を呼び出さない場合、コンテキストを占有してしまう
- 戻り値を返さない
- 内部で発生した例外を外部に反映させない
- etc ...
ただ上記3つについては、一定の条件では回避あるいは好都合となります。
- 非同期関数を現在のコンテキストで実行したい場合には目的に適している(配下で Task.Wait()/Task.Result を参照してデッドロックする場合を除く)
- 戻り値を async void 型関数を呼び出した関数に引き渡す方法があれば、async void 型関数から戻ってきたところで戻り値を返せる
- 例外を async void 型関数を呼び出した関数に引き渡す方法があれば、async void 型関数から戻ってきたところで再スローできる
その3つから、このような手順を立てます。
- 非同期関数(async キーワード付)を呼び出すため、ユーティリティとして async void 型関数を定義する
- async void 型関数には、内部で呼び出した非同期関数の戻り値ならびに例外の双方への参照を呼び出し元に戻すための穴を開ける、具体的には長さ 1 の配列を戻り値用と例外用に生成して async void 型関数に引数として渡す
- async void 型関数の内部で非同期関数を await 演算子を用いて呼び出し、非同期関数の戻り値への参照を戻り値用の配列に格納する
- async void 型関数の内部で非同期関数が投げた例外を全て捕捉し、例外用の配列に格納する
- async void 型関数から、非同期関数の戻り値ならびに例外を受け取って処理するため、ユーティリティとして同期関数を定義する
- async void 型関数を呼び出した同期関数は、async void 型関数から処理が戻ってきた際に、例外への参照が残っていることを確認すると、例外を再スローする
- 6 にて例外への参照が残っていない場合は、戻り値を返す
- async void 型関数を呼び出す同期関数は、本来呼び出したい非同期関数による戻り値を返せるよう、戻り値に型パラメータを用いてコンパイル時に型推論により解決する
- 非同期関数の引数は個数と型の双方がまちまちであるところを、引数を含む環境を定義できるラムダ式をカプセルとして用いて、型推論を不要にする
この条件でユーティリティ関数を実装したのが以下です。
// class AsyncFuncWrapper
//
// ©2024 ACCESS CO., LTD.
// Released under the MIT license
// https://opensource.org/licenses/mit-license.php
using System.Runtime.ExceptionServices;
namespace AFW // The acronym of "AsyncFuncWrapper"
{
class AsyncFuncWrapper
{
/// <summary>
/// Call the async function on the current context and return a value.
/// </summary>
/// <typeparam name="TResult">Type of the return value.</typeparam>
/// <param name="func">async function</param>
/// <returns>The return value of the async function.</returns>
public static TResult? Call<TResult>(Func<Task<TResult>> func)
{
var box = new TResult?[1];
var exceptionBox = new Exception?[1];
CallAsyncOnCurrentContext(func, box, exceptionBox);
var exception = exceptionBox[0];
if (exception != null)
{
ExceptionDispatchInfo.Capture(exception).Throw();
}
return box[0];
}
private static async void CallAsyncOnCurrentContext<TResult>(
Func<Task<TResult>> func, TResult?[] box, Exception?[] exceptionBox)
{
exceptionBox[0] = null;
try {
box[0] = await func();
}
catch (Exception e)
{
exceptionBox[0] = e;
}
}
}
}
上記のコードを、次のように呼び出すとします。
using AFW;
static async Task<int> AsyncInt(int value)
{
await Task.CompletedTask; // Gimic to make this function async.
return value;
}
static async Task<int> AsyncIntThrowsException(int value)
{
await Task.CompletedTask; // Gimic to make this function async.
throw new Exception("Exception from AsyncIntThrowsException");
}
var result1 = AsyncFuncWrapper.Call(() => AsyncInt(42));
Console.WriteLine($"AsyncInt(42) returns {result1}");
try {
var result2 = AsyncFuncWrapper.Call(() => AsyncIntThrowsException(42));
}
catch (Exception e)
{
Console.WriteLine($"AsyncIntThrowsException(42) throws exception: {e.Message}");
}
すると次のように出力されます。
AsyncInt(42) returns 42
AsyncIntThrowsException(42) throws exception: Exception from AsyncIntThrowsException
以前に C# 言語仕様を誤解した記事を公開した人物が書いたコードですから、このコードが C# 言語仕様に照らし合わせて問題ないのか、他の人による言及があるものと想像します。
最初からパブリックドメインとするとむしろトラブルを招くため、トラブルを避ける目的で MIT ライセンスといたします。同様の機構を実装するには何人たりとも同様に記述する他なく、独創性が認められないため、ライセンス条件を限定することが認められないと判断されれば、定石を見つけたことを意味しますから、会社としては資産を失いますが名誉でもあります。
この記事が皆様のお役に立てば幸いです。
追記 - チキンハートなので言えなかった最終目的
この記事の最終目的は、同期関数から非同期関数を心配なく呼び出すことです。
同期関数と非同期関数を呼び出して戻り値ないしは例外を受け取ることは、Task.Wait、Task.Result、または GetAwaiter().GetResult メソッドがデッドロックを起こす恐れがあります。デッドロックは動作しているように見えていつ止まるか分からない改修困難なバグを埋め込みます。ですから予め回避することが必須です。非同期関数を使う場合は呼び出し元も非同期関数にする、つまりコールスタック上で async/await の列を作りシステム側に一番近い場所で async void 型関数を呼び出して async/await の列を締めくくることが推奨されます。
発想の出発点は、async void 型関数による締めくくりをコールスタックの末端に置きつつ非同期関数で生成した戻り値ないしは例外を外部に取り出すことで、async/await の列をプログラムの局所に封じ込められないかというものです。
async void 型関数をシステム側に一番近い箇所で呼び出すとしても、main 関数は同期関数ですから、async void 型関数を呼び出したところで非同期関数の列から async/await の列への切り替えを行なっています。また切り替えをコールスタックの末端側で行うことに問題はありません。それならば非同期関数が生成した戻り値ないしは例外を安全に外部に取り出す機構を実装できれば、コールスタックの任意の箇所で非同期関数を呼び出して戻り値ないしは例外を取り出せるはずだと考えました。
仕掛けは単純ですが、成功したら応用範囲は広いです。例えば I/O 処理が非同期 API で公開されている場合に、並列処理を望まず終了まで待つと判断すれば、async/await への切り替えを API の呼び出し箇所に置いて、呼び出す関数は同期関数として実装できます。コールスタックの上から下まで async/await で揃える必要がなくなります。
世界中の C# プログラマが「やってはいけない!」と釘を刺すところを、using ディレクティブと名前空間宣言からクラスの終了まで 43 行のコードで常識を覆せるか。ネットにある決め台詞「それはあなたの意見ですよね。エビデンスは在るのですか?」に向けて、自分の意見をエビデンスに押し上げようという無謀な挑戦をしました。しかし、いつマサカリをもらうか分かりません。恥をかくのが怖くて、最終目的を言い出せませんでした。
記事を公開して「いいね!」をつける人は少なく興味を持たれませんでしたが、技術的に間違っているという指摘もいただきませんでした。記事を書いてよかったです。ありがとうございました。