Build Insiderオピニオン:岩永信之(9)

Build Insiderオピニオン:岩永信之(9)

C# 7、そしてその先へ: 非同期処理(前編) - Task-like

2016年8月31日

C#の進化の中でも「非同期メソッド」はコーディング方法を大きく変えるほど革新的だったが、そこにはまだ課題もある。C# 7~将来のC#で、非同期処理はどう進化するのか、前後編で見ていこう。

岩永 信之
  • このエントリーをはてなブックマークに追加

 C# 5.0/VB 11で追加された非同期メソッドは、プログラミング作業に大きなインパクトを与えた革新的な機能である。C#開発での非同期処理は劇的に楽に書けるようになった。また、C#の非同期メソッドを参考にして、ECMAScriptやC++にも同様の構文が提案されている。

 ただ、要望はあるのに現状の非同期メソッドではできないことも残されている。大きなものは以下の2つである。

  • (1)戻り値の型にvoidTaskTask<T>しか認めていない
  • (2)非同期シーケンス(awaityield returnの混在)に対応していない

 どちらも、C# 5.0の直後からずっと要望が挙がっているものである。それが未解決なのはそれなりに難しい課題を抱えているからだ。今回は、これらの課題とその解決策について説明していく。

 前編では(1)非同期メソッドの戻り値の問題について、後編では(2)非同期シーケンスについて話していこう。

非同期メソッドの戻り値(Task-like)

 現在の非同期メソッドでは、戻り値の型はTaskTask<T>(いずれもSystem.Threading.Tasks名前空間)、もしくはvoidでなければならい。この仕様によって、非同期メソッドは、Taskクラスという特定の型に依存している状態である。

 これに対して、「Taskクラスに近い条件を持った型であれば、任意の型を戻り値にできるようにしたい」という案が出ている。このTaskクラスに近い条件を持った型を指して、「Task-like」(Task風)と呼んでいる。

課題: 特定のクラスへの依存

 一般論でいうと、言語機能が特定のクラスに依存するのはリスクでしかない。そのクラス(今回の場合、Taskクラス)が時代遅れになったときに、言語機能(同、非同期メソッド)も一緒に時代遅れになりかねないからだ。そこで通常は、「所定のメソッドさえ実装していれば具体的な型は問わない」というような、型を明示しない仕様にするものである。実際、C#でも、foreachステートメントやクエリ式などはそのような仕様になっている(GetEnumeratorなどのメソッドだけ持っていれば何でもよく、C#コンパイラーが特定の型に依存することはない)。

課題を許容した理由

 C#チームは最初からこの機能に一定の需要があることを把握していた。しかし、実現するには課題があり、C# 5.0のときはトレードオフの検討の結果、Taskクラスへの依存という妥協を選択した。その課題を要約したコードをリスト1に示そう。リスト1では、Taskクラス以外に、MyTask1MyTask2というクラスも非同期メソッドの戻り値にできるものと仮定する。

C#
using System;
using System.Collections.Generic;
using System.Threading.Tasks;

class MyTask1<T> { public T MyResult { get; } } // 1
class MyTask2<T> { public IEnumerable<T> MyResult { get; } } // 2

class Program
{
  static async Task<int> A() => 1;
  static async MyTask1<int> B() => 1;
  static async MyTask2<int> C() => new[] { 1, 2, 3 }; // 2

  static int X(Func<Task<int>> f) => f().Result;
  static int X(Func<MyTask1<int>> f) => f().MyResult; // 1
  static IEnumerable<int> X(Func<MyTask2<int>> f) => f().MyResult; // 2

  static void Main()
  {
    X(async () => 1); // 3
    X(async () => Enumerable.Empty<int>()); // 4
  }
}
リスト1: 非同期メソッドの戻り値に任意の型を返せるようにした場合

 課題というのは大まかにいうと、「どこまでTaskクラスと似ていれば、それをTask-likeといえるのか」と「オーバーロード解決が型推論で候補が複数ある場合にどれを選択するか」である。リスト1中のコメントに番号を振っているが、以下のような検討項目がある。

  • 結果のプロパティ:
    Task<T>クラスの場合はResultプロパティで結果を返す
    MyTask1<T>クラスのように、別の名前(例えばMyResult)は認められるか(リスト1中の1
  • T以外の結果:
    Task<T>クラスの場合、結果(Resultプロパティ)の型はT
    MyTask2<T>クラスのように、T以外の型(この場合はIEnumerable<T>型)の結果は返せるか(リスト1中の2
    -また、リスト1中の4のように、列挙子を返すラムダ式の場合、Task<IEnumerable<T>>MyTask1<IEnumerable<T>>MyTask2<T>のどちらが選ばれるべきか
  • ラムダ式の型推論:
    - リスト1中の3のように、型推論の候補が複数(この場合はTask<int>型とMyTask1<int>型)ある場合、どちらが選ばれるべきか

 このうち解決が簡単なのは「結果のプロパティ」くらいで、残りはなかなかの難問である。特に、型推論がうまくいかないようでは、非同期メソッドの利便性が大きく損なわれることになる。

 一方で、TaskTask<T>クラス以外の型を使いたいことがどのくらいあるのかという話になる。特に、Task-likeな型を非同期メソッドの戻り値にするには、「method builder」と呼ばれるそれなりに複雑なコード(後述)を書く必要がある。そうまでして使いたい機能かというと、当時、あまり需要がないと想像された。

 そして仕様の複雑化を避けた結果、C# 5.0では戻り値の型をTaskクラスに制限する妥協案を選択した。需要が低いものに時間を割いて、リリースが遅れてはいけない。「done is better than perfect」という言葉もあるように、必要なタイミングで必要な機能を提供できることが大切である。非同期メソッドの時代背景としては、Windows 8向けの新API(=現在のUWPの基盤になっているWindows Runtime)が非同期メソッドを必要としており、Windows 8のリリースとタイミングをそろえる必要もあった。

新しい需要: ValueTask

 非同期メソッドの戻り値に対する制限はトレードオフの選択結果であり、意図して行った妥協なので、よほどのメリットがない限り緩和する意味はない。しかし、そのよほどのメリットが見つかってしまった。前々回説明したValueTask構造体である。

 前々回の説明の通り、ValueTask構造体はパフォーマンス向上のために追加される構造体である。補足として、大幅なパフォーマンス改善が見込めるケースを1つ紹介しておこう。リスト2のような例を考える。

C#
using System.Threading.Tasks;

class Program
{
  static async Task<string> GetNameAsync()
  {
    // 実際にはネットワークアクセスなどがあるものとする
    await Task.Delay(1000);
    return "name";
  }

  // 本当に非同期処理を必要とするのは最初だけ
  // あとはキャッシュした値を返す
  static Task<string> Name => _name ?? (_name = GetNameAsync());
  static Task<string> _name;

  // 何段か処理が挟まっている
  // asyncは連鎖する。awaitしたければ呼び出し階層の全部のメソッドをasyncに
  static async Task<string> A() => await Name;
  static async Task<string> B() => await A();

  static async void MainAsync()
  {
    // 何回も呼び出し
    for (int i = 0; i < 100; i++)
      await B();
  }
}
リスト2: 非同期処理の連鎖とキャッシュ

 本当に非同期処理が必要になるのは最初の1回きりで、以降はキャッシュを使いまわせる状況である。この例は単純化したものだが、似たような処理が必要になることは多いだろう。

 ここで問題となるのは、非同期メソッドの連鎖だ。細かくメソッドを分割して、それらの呼び出しが階層的になる場合、1カ所でも非同期メソッドがあるなら呼び出し階層に含まれる全てのメソッドが非同期メソッドになる。そして、階層の全てでTaskクラスのインスタンスが作られるとそれなりの負担が発生する。最初の1回は本当にTaskクラスが必要とされるが、キャッシュが効いている状況でまでインスタンスが作られるのは完全に無駄である。これが全てValueTask構造体に置き換わるとかなりのパフォーマンス改善につながるだろう。

 実際、corefxチームやASP.NETチームなどによって、構造体への置き換えが10倍近い高速化につながったケースも報告されているそうだ。とはいえ、これらの高速化は、非同期メソッドを使わず、手作業で最適化を行ったものである。マイクロソフト内であっても、多くのチームが「手作業で最適化はしない。非同期メソッドが対応してくれれば同種の高速化作業をする」という態度を示しているらしい。

 そこで、「非同期メソッドのValueTask構造体への対応」が高い優先度を持つこととなった。とはいえ、ここでまたValueTask構造体だけを特別扱いする必要はないだろう。この際、任意のTask-likeを認めることについてあらためて検討すべきである。

課題に対する検討結果

 前述の通り、非同期メソッドの戻り値をTaskクラスかTask<T>クラスに限定するに至った課題があった。任意のTask-likeを認めるに当たって、これらの課題に対する再検討が必要になる。C# 7では以下のような方針になるようだ。

  • 結果のプロパティ:
    - 名前は問わない
    - リスト1のMyTask1<T>クラスのように、Result以外の名前のプロパティを持っていても構わない
  • T以外の結果:
    T以外は認めない
    - リスト1のMyTask1<T>.MyResultプロパティはT型なので認められる
    - リスト1のMyTask2<T>.MyResultプロパティはIEnumerable<T>型なので認められない
    -リスト1の4では結果的に、Task<IEnumearble<T>>MyTask1<IEnumearble<T>>MyTask2<T>のどちらになるかで不明瞭になることはない
  • ラムダ式の型推論:
    - 互換性を維持するため、既存のTask<T>型を優先する。この意味では、今後もTaskTask<T>クラスに依存した構文になる
    Task<T>型が候補になく、他の候補が複数ある場合、コンパイルエラーにする

どう実現するか: method builder

 Task-likeと認められる(非同期メソッドの戻り値として使える)ためには、リスト3に示すようなメソッドを持つ必要がある。これらは同じ名前/同じ引数リストであれば何でもよく、インターフェースの実装などは不要である。C#コンパイラーが特定の型に依存することはない。

C#
[AsyncBuilder(typeof(MyTaskMethodBuilder))]
class MyTask<TResult>
{
}

class MyTaskMethodBuilder<TResult>
{
  public void Start<TStateMachine>(ref TStateMachine stateMachine) { }
  public void SetStateMachine(IAsyncStateMachine stateMachine) { }
  public void SetResult(TResult result) { }
  public void SetException(Exception exception) { }
  public MyTask<TResult> Task { get; }
  public void AwaitOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine) { }
  public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine) { }
}
リスト3: Task-likeと認められるために必要なメソッド

 Task-likeな型(この例ではMyTaskクラス)自身に必要なのは、method builderと呼ばれる別の型(同、MyTaskMethodBuilderクラス)を探す機能だけである。method builder側のメソッドがどう使われるかは本稿のはんちゅうを超えるので省略する。

 ちなみに、Task-like型から対応するmethod builderを探す仕組みに関しては、以下のような複数の案が検討された。

  1. Task-likeに属性を付ける(リスト3はこの方式)
  2. インスタンスメソッド/拡張メソッド使う
  3. 静的メソッドを使う

 1の属性を付ける方法の場合、既存の型への拡張ができないという問題がある。例えば、クラスAという既存の型があったとすると、Aの作者以外がAに対するmethod builderを追加することができない。また、このAsyncBuilder属性をどこで定義するかという問題がある。C# 7と同時に.NETの標準ライブラリに入るだろうが、それよりも古いバージョンの.NET向けのプログラムでも使えるように、この属性を配布する手段が必要になる。

 その点、2の方式であれば、拡張メソッドを使えば既存の型の拡張もできる。しかしmethod builderの場合、Task-likeのインスタンスを作るより前にmethod builderのインスタンスが必要で、素直に拡張メソッドを呼ぶ手段がない。

 3の静的メソッドを使う方法では、属性の場合と同じく拡張性に問題がある。しかし、Task-likeの話とは別に、C#に静的拡張メソッドのようなものを追加する案も出ていて、これが入れば拡張性の問題は解決する。

 最終的に1の方式が採用されそうなのだが、その理由としては以下の通りだ。既存の型に対する拡張ができないという問題に対しては、そもそもその需要は少ないないだろうことと、Task-like側に属性を付ける以外に非同期メソッド側に属性を付ける手法も認めることで問題が緩和するあてがあることがある。

 AsyncBuilder属性の配布方法については、(名前空間まで含めて)同名の属性であれば、どこで定義されたものでもよく、internalであってもよいことにする。つまり、Task-like型と同じライブラリ内に自前で属性を用意できるようにして、そもそも配布の必要性をなくすことで対処する。

 将来の話をするなら静的拡張メソッドと同様に拡張属性もあり得るだろう。

 ちなみに、どの方式でも、属性の有無やメソッドの有無だけでオーバーロード解決の仕方が変わるという、C#の他の機能には見られない珍しい挙動をしてしまうという問題はある。この問題に対して、C#チームは当初強い懸念を抱いていたが、どの方式でも結局珍しい挙動になること、利用頻度が極端に低い(=Task-like型を作る側だけの問題。おそらく、Task-like型が作られる機会はめったにない)機能に対してこだわるべきでないということから、妥協を選択することにした。

その他の応用例

 主役はValueTask構造体だとはいえ、せっかく任意のTask-likeを使えるようになるわけである。他の用途もいろいろと考えらえる。

相互運用

 Windows RuntimeのIAsyncOperation<TResult>インターフェース(Windows.Foundation名前空間)など、Taskクラス以外を使う非同期ライブラリとの相互運用に使える。現状では、リスト4のように、1段階の変換処理が必要になる。

C#
public IAsyncOperation<int> F() => FInternal().AsAsyncOperation();

private async Task<int> FInternal()
{
  await Task.Delay(1);
  return 1;
}
リスト4: 現状の非同期メソッドの相互運用コード

 同様に、Reactive Extensionsとの連携で、IObservable<T>インターフェースを返す非同期メソッドなども考えられる。

戻り値の共変性

 戻り値の型がクラスではなくインターフェースであれば、型引数を共変にできる。つまり、リスト5に示すように、Task<T>クラスのインターフェ-ス版ITask<T>があれば、ITask<string>ITask<object>に代入できる。

C#
Task<string> t1 = Task.Run(() => "abc");
Task<object> t2 = t1; // 代入できない

string s1 = t1.Result;
object s2 = s1; // 代入できる

// もしインターフェースであれば……
ITask<string> i1 = ITask.Run(() => "abc");
ITask<object> i2 = i1; // 代入できるはず
リスト5: 共変な型引数
その他

 その他、例えば、await演算のタイミングでログを記録しつつ非同期処理をするようなmethod builderも作れるだろう。

 また、かなりの乱用・悪用ではあるが、非同期処理以外のこともできる。null許容型に対して、null条件演算子と似たような処理を実現する例も挙がっている。

未解決の課題・新たな課題:

 言語機能がTaskクラスに依存しているという問題は解消しそうだが、未解決の課題や新たな課題も残されている。

既存の型に対する拡張

 前述の通り、Task-likeからmethod builderを探すのに属性を使うことになる。その結果、現状では、他人が書いた型をTask-likeにできない(他人が書いた型には属性を付けられない)という課題が残されている。この課題の解決には何らかの新しい文法が必要になる。インスタンス メソッドだけでなくなんでも拡張する機能が提案されてはいるが、既存の型に属性を追加するのは特に難しく、今のところ提案に挙がっていない。

 ちなみに、既存の型に対する拡張でTask-likeを作れないということは、おそらく、Reactive Extensionsの一環としてIObservable<T>をTask-like化することが難しくなる。

言語レベルでのobsoleteサポート

 非同期メソッドの戻り値をTaskクラスからValueTask構造体に変更することで、場合によっては10倍近いパフォーマンス向上につながることがある。自分が作っていたライブラリがまさにその場合に該当するとしたらどうするだろうか。今すぐにでもValueTask化したいと思うのも自然だろう。

 しかし、ライブラリの互換性を考えると単純に戻り値を変更するわけにはいかない。オーバーロードを追加したいところである。そこで問題になるのが、リスト6に示すような、戻り値違いのオーバーロードやラムダ式の型推論である。

C#
class MyLibrary
{
  // 引数違いのオーバーロードは追加できる
  public async void F(Task t) { await t; }
  public async void F(ValueTask t) { await t; }

  // オーバーロード追加できるものの、互換性のためTask優先
  // ラムダ式F(async () => { })では前者しか呼ばれない
  public async void F(Func<Task> f) { await f(); }
  public async void F(Func<ValueTask> f) { await f(); }

  // 戻り値違いのオーバーロードは追加できない
  public async Task F() { }
  public async ValueTask F() { } // コンパイルエラー
}
リスト6: ValueTaskオーバーロードの追加

 一般論で言うと、この問題の解決策はない。戻り値違いのオーバーロードもラムダ式の型推論の解決もできない。一方で、今回の場合に限って言うなら、本当にやりたいことは置き換えであって、古いメソッドを残す理由は互換性の維持である。古い方の優先度を下げるような仕組みがあれば問題を解決できるかもしれない。

 そこで検討に挙がっているのが、言語レベルでのobsolete(廃止機能)サポートである。リスト7に示すように、古い方のメソッドにobsoleteキーワードを付けることで、そちらのオーバーロードが選ばれないようにしたいというものだ。このような特殊なオーバーロード解決は、既存のObsolete属性(System名前空間)を使った注釈では実現できない。

C#
class MyLibrary
{
  // 既存のコードだけが前者を呼ぶ
  // 新規コードでは後者を優先的に呼ばれる
  public obsolete async void F(Func<Task> f) { await f(); }
  public async void F(Func<ValueTask> f) { await f(); }

  public obsolete async Task F() { }
  public async ValueTask F() { } // これもコンパイルできる
}
リスト7: 言語レベルでのobsoleteサポートの例

まとめ

 C# 7では、非同期メソッドの戻り値として任意の「Task-like」(Task風の型)が使えるようになる予定である。主な動機としてはValueTask構造体を用いることによるパフォーマンス改善となる。

 C# 5の時点でも検討はされていたが、課題もあり、最終的にはTaskクラスに限られることになっていた。そして、ValueTaskという新しい動機が見つかったことで、C# 7での拡張が再検討された。

 Task-likeを認めることで新たな課題も出ている。それらの課題に対して、さらに、言語的なobsoleteサポートや、既存の型の拡張などが検討されている。

岩永 信之(いわなが のぶゆき)

岩永 信之(いわなが のぶゆき)

 

 

 ++C++; の中の人。C# 1.0がプレビュー版だった頃からC#によるプログラミング入門を公開していて、C#とともに今年で15年になる。最近の自己紹介は「C#でググれ」。

 

 

 

 

※以下では、本稿の前後を合わせて5回分(第7回~第11回)のみ表示しています。
 連載の全タイトルを参照するには、[この記事の連載 INDEX]を参照してください。

7. 次期C#とパフォーマンス向上(後編)―― 予定・検討中の5つの新機構

前編に続き、次期C#のパフォーマンス向上について解説。C# 7以降での採用が予定もしくは検討されているパフォーマンス向上関連の新機能の内容を具体的に見ていこう。

8. 見えてきたC# 7: C#の短期リリースサイクル化

C# 7にはどんな新機能が含まれるのかが見えてきた。これまでと比べて、C# 7はかなり速いペースでのリリースとなる。その背景にはどんな事情があるのだろうか。

9. 【現在、表示中】≫ C# 7、そしてその先へ: 非同期処理(前編) - Task-like

C#の進化の中でも「非同期メソッド」はコーディング方法を大きく変えるほど革新的だったが、そこにはまだ課題もある。C# 7~将来のC#で、非同期処理はどう進化するのか、前後編で見ていこう。

10. C# 7、そしてその先へ: 非同期処理(後編)- 非同期シーケンス

C#(とVisual Basic)が切り開いた非同期処理の新たな世界。そこにはまだ課題もある。これを克服する方法として、前後編の後編となる今回は「非同期シーケンス」がC# 7でどうなるかを見てみよう。

11. nullが生まれた背景と現在のnullの問題点 ― null参照問題(前編)

Cの系譜を継ぐC#ではnullが長らく使い続けられてきたが、最近ではその存在が大きな問題だと認識されている。前後編でこの問題を取り上げ、今回(前編)はnullを取り巻く事情について考察する。

サイトからのお知らせ

Twitterでつぶやこう!


Build Insider賛同企業・団体

Build Insiderは、以下の企業・団体の支援を受けて活動しています(募集概要)。

ゴールドレベル

  • 日本マイクロソフト株式会社
  • グレープシティ株式会社