📚

【C#】AsyncConsoleReader - CancellationToken対応の標準入力読み取り

に公開

C#で標準入力を読み取りたいときにはConsole.ReadLine()などが利用できます。例えば入力と同じ文字列を出力するだけのコードなら

var input = Console.ReadLine();
Console.WriteLine(input);

と書けるでしょう。

基本的にはこれだけで十分なのですが、問題は入力待ちをキャンセルしたいとき。Console.ReadLine()はスレッドをブロックするため、入力待ちのキャンセルが不可能です。一応Console.ReadKey()の方ならConsole.KeyAvailableと合わせてキャンセル処理が書けないこともないのですが、ReadLine()に関してはそのような機構すらないのでお手上げです。うーん辛い。

というわけで、キャンセルに対応した実装を提供するライブラリを作りました。

https://github.com/nuskey8/AsyncConsoleReader

対応しているプラットフォームはWindows/macOS/Linuxです。ぶっちゃけWindows向けの実装はかなり雑なので環境依存で動かない可能性がなくもないんですが(一応動作の確認は取れたものの...)、Linux/macOSなどのUnix系OS向けの実装は問題なく動くはずです。

Unix系の実装がSyatem.Consoleの一部APIと競合して動作しなくなる問題があったため、v1.0.2からはmanagedなコードの実装に置き換えました。パフォーマンス的にはやや不利ですが、これでSyatem.Consoleが動く環境ならどこでも動作するようになったはずです。

使い方

Console.Read()/ReadKey()/ReadLine()をそのままAsyncConsoleに置き換える形で利用できます。違いはCancellationTokenを渡せるかどうかです。

using AsyncConsoleReader;

var cts = new CancellationTokenSource();
cts.CancelAfter(500);

try
{
    var line = AsyncConsole.ReadLine(cts.Token);
    Console.WriteLine(line);
}
catch (Exception ex)
{
    Console.WriteLine(ex);
}

これを使うことで入力待ちを行いつつ、途中でキャンセルされた場合はちゃんとOperationCanceledExceptionが返ってきます。

もちろんスレッドをブロックしない非同期APIも用意されています。

var line = await AsyncConsole.ReadKeyAsync(false, cts.Token);

が、一点注意としてこれらの非同期APIは読み取り自体を非同期で行うわけではありません。 あくまでメインスレッドをブロックせずにスレッドプールに処理を投げるだけなので、入力の読み取り自体は同期的に行われます。一応これは通常の同期APIをTask.Runでラップすることでも代替できますが、こちらはIValueTaskSourceIThreadPoolWorkItemで効率的に実装されているため、こちらを使ってもらえると良いでしょう。

標準入出力と非同期I/O

ConsoleクラスにはTextReader/TextWriterで抽象化されたIn/Outプロパティが存在し、Console.WriteLine()Console.ReadLine()などは全てIn/Outへのショートカットになっています。

https://github.com/dotnet/runtime/blob/main/src/libraries/System.Console/src/System/Console.cs

そしてTextReader/TextWriterReadLineAsync()/WriteLineAsync()を持っています。つまり、こんなコードが書けるわけです。

var line = await Console.In.ReadLineAsync(cancellationToken);
await Console.Out.WriteLineAsync(line, cancellationToken);

これらは一見(キャンセルも含めて)動作するように思えますが、実際には期待通りに動きません。

https://stackoverflow.com/questions/78705374/what-is-the-difference-between-console-writeline-and-console-out-writelineasync

詳しい話は上のStackOverflowの回答が参考になりますが、ConsoleクラスのIn/Outの実装は常に同期的に実行されるようにラップされています。これはConsole.SetIn()/SetOut()で独自のTextReader/TextWriterをセットした場合も同様です。

標準出力と非同期処理に関する話としては、Rustの非同期ランタイムtokioがなぜサンプルコードでブロッキングを行うprintln!マクロを使うのか、というテーマで書かれた以下の記事があります。

https://academy.fpblock.com/blog/different-levels-async-rust/

上の記事の要旨は「そもそも標準出力は非常に短い期間しかブロッキングしないことが想定されるため、非同期にできるからと言ってそれを行うメリットは全くない」というものです。

そもそもasync/awaitはシンプルな見た目とは裏腹に実装は複雑で、ステートマシンの生成と継続のコールバック呼び出しというパフォーマンス上のコストを払って行われているものになります。

処理に時間がかかる可能性があるものに対してasync/awaitは非常に有効な手段ですが、わざわざ同期で問題ないコードを非同期にラップすると、コードが複雑になるだけでなくパフォーマンスの低下にもつながります。C#のConsoleが常に同期実行であるという仕様はこの辺りが要因でもあるでしょう。(もちろんスレッドセーフであるからなど他の理由もあると思いますが)

同様に標準入力の読み取りも、それ自体を非同期に置き換えるメリットはあまりありません。どちらかというと重要なのは「外部からキャンセルが行えること」であり、それがAsyncConsoleReaderの意義だったりします。

そういう意味ではAsyncConsoleReaderというネーミングは微妙なんですが、まあCancellableConsoleReaderよりはこっちの方がC#erとしてはわかりやすいと思うので...

まとめ

以上、AsyncConsoleReaderの紹介でした。インタラクティブなCLIツールを作るときなどではReadLine()を手動でキャンセルしたいことは結構多いのではないかと思います。というか実際それで必要になって作ったライブラリだったり。

というわけで地味に便利なライブラリになってるので、是非使ってみてください〜!

Discussion