非同期版読み取り用メソッドを定義する (改)

この記事は、連載C#でCSVファイル操作ライブラリ の3回目の記事

非同期版読み取り用メソッドを定義する - pierre3のブログの訂正版です。

この記事に対して以下のご指摘を頂き、プログラムの実装を見直しました。

おさらい

前回は、XsvReaderにテキストを読み取るためのメソッドReadLine(), ReadXsvLine(), ReadXsvToEnd()を作成しました。
今回は、これらをawait可能にした「非同期版」を作成し、XsvReaderに追加したいと思います。

async/awaitを使用した非同期メソッド

async/awaitキーワードを使用した非同期メソッドを実装します。
基本的に.Net Framework4.5以降が対象となります。

ReadLine()の非同期版ReadLineAsync()メソッド

XsvReaderの内部リーダTextReaderの非同期メソッドReadLineAsync()をそのままラップします。

ポイントは

  • awaitせずTaskを直に返せるものはasyncにせずそのまま返したほうが良い
  • このケースでは asyncメソッドにする必要がありません。むしろ無駄です。
public Task<string> ReadLineAsync()
{
    return BaseReader.ReadLineAsync();
}

(メモ) 試しに簡単な計測をしてみたところ、僅かですが確実に、asyncを付けた場合の方が遅くなりました。

ReadXsvLine()の非同期版ReadXsvLineAsync()

訂正前の記事では、

前回作成したメソッドReadXsvLine()を、Task.Run()メソッドを使用して非同期処理に変換します。

public async Task<string[]> ReadXsvLineAsync(ICollection<string> delimiters)  
{  
    return await Task.Run(() => ReadXsvLine(delimiters).ToArray());  
} 

としていましたが、Task.Runでラップしただけのような偽Asyncメソッドは避けるべきとのことで、このメソッドは見直します。

◆ライブラリが非同期メソッドを提供する場合、以下を守るべきとされているようです。

  • スレッド、スレッドプールはアプリケーション開発者側が管理すべきリソースなので、ライブラリ内部で(Task.Run()等を使って、勝手に)消費したりしてはいけない。
  • 但し、ネットワークの応答待ち、ディスクへの読み書き等のI/O依存の処理については非同期メソッドとして提供できる (ライブラリが提供するasyncメソッドは、通常I/O依存のであることが期待される)。
  • 計算が主体のCPU依存の処理では、非同期で行う(スレッドプールを使う)か否かを決めるのは、ライブラリを使用する側の権限とすべき。

※上記を含め、非同期を扱う上で「大事なことは全部 Talk: Async best practices にある」(@neuecc) そうです。必読です。

Task.Run()でラップするのをやめる

単純にTask.Run()でラップするのをやめて、内部リーダ(TextReader)が持つ非同期メソッドReadLineAsync()のみをawaitして呼び出すように変更します。

  1. Parse()メソッドに渡す1行分の文字列を非同期メソッド ReadLineAsync()で読み取るように修正します。
  2. フィールドの途中に改行がある場合、第3引数に指定したデリゲートをParse()メソッド内で呼び出して追加の行を取得するのですが、そのデリゲートも非同期で呼ぶようにします。(await followingLineSelector().ConfigureAwait(false) の部分)
  3. メソッド内でawait しているため、Parse()メソッドは必然的にasyncとなります。元々Parse()メソッドはイテレータブロックとして実装していたのですが、yield returnasync/await共存できないため、一旦リストに追加して、そのリストを返すように変更しました。
  4. ついでに、戻り値のstring配列をIList<string> に変更しています。
public async Task<IList<string>> ReadXsvLineAsync(ICollection<string> delimiters)
{
    return await Parse(await ReadLineAsync().ConfigureAwait(false), delimiters, 
        () => ReadLineAsync());
}

public async static Task<IList<string>> Parse(string line, ICollection<string> delimiters, 
    Func<Task<string>> followingLineSelector)
{
    var IList<string> result = new List<string>();
    //引数 line に渡された1行分のテキストをパースする
    //...(中略)...

    // 次の行を続けて処理したい場合、もう1行読み込んでParse()を再帰
    var followingLine = await followingLineSelector().ConfigureAwait(false);
    var next = await Parse(token + Environment.NewLine + followingLine,
        delimiters, followingLineSelector);
    result.AddRange(next);
    //...(中略)... 

    return result;
}

(メモ) メソッドの動作は、変更前と(少なくとも見かけ上は)全く変わっていないので、この修正ではあまり意味が無いような気もします。Task.Run()でラップするだけの場合との違いは?など、もう少し調べてみる必要がありそうです。

ReadXsvToEnd()の非同期版

末尾まで読み取るReadXsvToEnd()の非同期版ReadXsvToEndAsync()は、内部リーダが末尾に達するまでReadXsvLineAsync()を繰り返します。

public async Task<IList<IList<string>>> ReadXsvToEndAsync(ICollection<string> delimiters)
{
    var result = new List<IList<string>>();
    while (!EndOfData)
    {
        var line = await ReadXsvLineAsync(delimiters).ConfigureAwait(false);
        result.Add(line);
    }
    return result;
}

(おまけ).Net4.0の場合は?

Task.Factory.StartNew()でラップしてTaskを返すメソッドを作っていたのですが、あえて非同期版を用意せずに利用者側にお任せする方が良いかもしれません。(.Net4.0ではTextReaderに非同期版のメソッドも用意されていないですし)