DartでFutureをキャンセルする

Flutter 製の自作アプリで `Future` のキャンセル失敗によるバグが生じました。
それを改善したので、メモがてら記事にしておきます。

# お急ぎの方

やり方をすぐに知りたい方は「[直したコード](#直したコード)」以降をお読みください。
その前の「[await for](#await-for)」の項目は別解なので、余裕があるときにでもどうぞ。

# 題材

アプリには数秒の待ちを入れながらループする処理があります。
一回あたりの中身は次の通りです。

1. 数秒待つ
2. ある処理をする
3. わずかに待つ
4. 別の処理をする
5. 1 へ戻る

ここでは、二度の待ちを一度に減らした簡略な例にします。

1. 一秒待つ
2. 情報（秒数）を表示
3. 1 へ戻る

アプリでは、途中でキャンセルしてボタンを押すことで新たに始めることができます。
中断したところから再開するのではなく一からやり直しです。

本記事では Flutter を使わないので、ボタン押下の代わりに開始 2.5 秒後に自動キャンセルし、その 0.2 秒後にやり直すことにします。

```dart
Future<void> main() async {
  final count = Count();
  count.start(tag: 'count1');  // この中身がループ

  await Future<void>.delayed(const Duration(milliseconds: 2500));
  count.cancel();              // ループを止めたい

  await Future<void>.delayed(const Duration(milliseconds: 200));
  count.start(tag: 'count2');  // 新たにループを開始
}
```

`tag` は二つのループを区別しやすくするために付けているだけです。
問題になるのは `Count` クラスの `start()` をどう実装するかです。

# 考えられる方法

待つ方法をベースに考えていきます。

- Timer.periodic()
- await for
- for + Future.delayed()

## Timer.periodic()

一定の時間間隔で繰り返すのは [Timer.periodic()](https://api.dart.dev/stable/dart-async/Timer/Timer.periodic.html) が使えますし、キャンセルもできますね。
しかし、繰り返すごとに二度待つ場合には使えません。
単純な定期実行に向いています。

## await for

[await for](https://dart.dev/tutorials/language/streams) は [Stream](https://api.dart.dev/stable/dart-async/Stream-class.html) でデータが来るたびに捌くものなので、これも二度待てません。

その他に、キャンセルが難しいという問題があります。
`Stream` を [listen()](https://api.dart.dev/stable/dart-async/Stream/listen.html) で待ち受けている場合には戻り値（[StreamSubscription](https://api.dart.dev/stable/dart-async/StreamSubscription-class.html)）を使ってキャンセルできますが、`await for` ではどうにかして `break` するしかないようです。

これについては、以前に悩んでツイートしたときに [ntaoo さん](https://twitter.com/ntaoo) が良い方法を提案してくださいました。
https://gist.github.com/ntaoo/3fda225a9175ec802d24d11cd5ff4086
（Stream に変換するリストに持たせておく関数は Future でなくてもいいと思います。）

1. イテレーションごとの実行対象の関数をリストに入れて `Stream` に変換
2. `await for` で回して関数を一つずつ取り出す
3. その関数を [Future.delayed()](https://api.dart.dev/stable/dart-async/Future/Future.delayed.html) のコールバックとして使う
4. それを try で囲っておく
5. 関数の中でエラーを throw すると catch ブロックに実行が移る
6. catch にて for ブロックから break する

キャンセルのフラグを用意しておき、`true` になったときに関数の中で throw させることができれば、この方法も使えそうです（二度待つ必要がない場合）。

なお、Gist は下記ツイートの流れの途中に貼られたものです。
スレッドの続きのやり取りも参考にしていただけるかもしれません。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">複数のFutureをawaitして同期的に次々と捌きつつ、途中でキャンセルしたら続きを実行せずに終わるようにしたくて、package:asyncで簡単にできるかと思ったらそうでもなかった。</p>&mdash; Kabo (@kabochapo) <a href="https://twitter.com/kabochapo/status/1283306030764417026?ref_src=twsrc%5Etfw">July 15, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

## Future.delayed()

待つ処理と言えば、よく使うのはこれですよね。
ループの中で使うことでイテレーションごとに待ちを入れることもできます。

でもこれ単体では中断できません。
単体ではなく、次に紹介する [package:async](https://pub.dev/packages/async) の [CancelableOperation](https://pub.dev/documentation/async/latest/async/CancelableOperation-class.html) と組み合わせれば中断できます。[^completer]

[^completer]: [CancelableCompleter](https://pub.dev/documentation/async/latest/async/CancelableCompleter-class.html) でもできるようですが、より複雑になるので試していません。

# package:async

紛らわしいですが、`Timer` 等が含まれる `dart:async` とは別物です。
Dart 公式のパッケージであり、様々な機能を多数提供してくれます。

- [Package:async (Package of the Week) - YouTube](https://www.youtube.com/watch?v=r0tHiCjW2w0)

しかし、ドキュメントがペラッペラで不親切です。
使いこなせれば非常に便利そうなのに残念です。

先ほどのツイートの際にこのパッケージの `CancelableOperation` を使おうとしていましたが、うまく使えなくてやめてしまい、そのせいでバグが発生しました。
今回もドキュメントの情報不足によって動作させるまでに悩まされました。
それがこの記事を書こうと思った理由でもあります。

では、`CancelableOperation` を見る前に、それを使わなかったことによる失敗を見ておきましょう。
何をしたら失敗するのかを知っておくのも大事ですよね。

# 失敗コード

`CancelableOperation` より楽な方法にしようと思って手を抜いた結果が下記です。
キャンセルのフラグを持っておき、`Future.delayed` を await し終えたときに `true` になっていればループを抜けるという原始的なやり方です。

```dart
class Count {
  bool _isCancelled;

  void cancel() {
    print('キャンセルします');
    _isCancelled = true;
  }

  Future<void> start({String tag}) async {
    _isCancelled = false;

    for (var i = 1; i <= 5; i++) {
      await Future<void>.delayed(const Duration(seconds: 1));

      if (_isCancelled) {
        print('キャンセルされました');
        break;
      }

      print('$tag: $i');
    }
  }
}
```

先ほどの main 関数から `start()` を呼び出して 1 ～ 5 を出力する途中の 2.5 秒目でキャンセルし、その 0.2 秒後に新たに始めます。
最初のループは 3 回目のイテレーションでキャンセルされて 2 までの出力で止まると考えていました。

ところが、実際は次のとおりでした。

```
count1: 1
count1: 2
キャンセルします
count1: 3
count2: 1
count1: 4
count2: 2
count1: 5
count2: 3
count2: 4
count2: 5
```

キャンセル後も count1 の 3 ～ 5 が出力されてしまっています。
`Future.delayed()` が終わらないうちに同じインスタンスで `start()` が呼ばれると `_isCancelled` が `false` に戻ってしまうので当然です。
キャンセルしなかったことになってしまい、「キャンセルされました」の出力もなされません。

アプリでは、キャンセルして前の画面に戻ったときにこのクラスのオブジェクトが破棄されるように以前はしていたのですが、その後にオブジェクトのスコープを変えて破棄されなくなり、この問題が生じました。

# 直したコード

最初から `CancelableOperation` を避けずに使っていればバグは発生しませんでした。
今回はちゃんと使うように変えました。

キャンセル対象の `Future` を `CancelableOperation` に変換すると、その [cancel()](https://pub.dev/documentation/async/latest/async/CancelableOperation/cancel.html) メソッドによって `Future` のキャンセルが可能になります。
また、キャンセルされたかどうかを [isCanceled](https://pub.dev/documentation/async/latest/async/CancelableOperation/isCanceled.html) で確認できます。[^isCompleted]

[^isCompleted]: [isCompleted](https://pub.dev/documentation/async/latest/async/CancelableOperation/isCompleted.html) もありますが、await 後はキャンセル状態でもそうでなくても `true` になるので、今回のようなコードでは役立ちません。

```dart
class Count {
  CancelableOperation<void> _co;

  void cancel() {
    print('キャンセルします');
    _co?.cancel();
  }

  Future<void> start({String tag}) async {
    for (var i = 1; i <= 5; i++) {
      _co = CancelableOperation<void>.fromFuture(
        Future<void>.delayed(const Duration(seconds: 1)),
      );

      await _co.valueOrCancellation();
      if (_co.isCanceled) {
        print('キャンセルされました');
        break;
      }

      print('$tag: $i');
    }
  }
}
```

```
count1: 1
count1: 2
キャンセルします
キャンセルされました
count2: 1
count2: 2
count2: 3
count2: 4
count2: 5
```

`Future.delayed()` を `CancelableOperation` に変換してループの中で await できるので、二度待つ必要がある場合にも使えます（二度の await それぞれの後でキャンセル確認して break）。

なお、[value](https://pub.dev/documentation/async/latest/async/CancelableOperation/value.html) や [valueOrCancelation()](https://pub.dev/documentation/async/latest/async/CancelableOperation-class.html) は成功時には元の `Future` を返します。
上の例では `void` なので戻り値を使っていませんが、必要なケースではそれを await して結果を取り出せます。

## はまりどころ

コードを見て簡単に思われたでしょうが、はまりどころがありました。

### .fromFuture()に渡すもの

`.fromFuture()` の第一引数は `Future` です。
下記のように `await` してしまうと期待した動作になりません。

~~警告が出るわけでもなくて気づきにくいところなので要注意です。~~
`Future<void>.delayed()` のようにジェネリック型を指定した状態で `await` を付けると静的解析でもコンパイルでもエラーになりますが、`<void>` を省くと何も警告されなくて気づきにくいのでご注意ください。

```dart
_co = CancelableOperation<void>.fromFuture(
  // ここでawaitしてはダメ！
  await Future<void>.delayed(const Duration(seconds: 1)),
);
```

### value / valueOrCancellation()

```dart
await _co.value;
```

Stack Overflow 等を見るとたいてい `CancelableOperation` の `value` が使われていました。
しかし上のように `value` を `await` している間にキャンセルされると Future が解決されず、その後の処理に到達しなくなるようです。

そうしないためには `valueOrCancellation()` を使う必要があります。

### 非同期処理自体は止まらない

キャンセルしても非同期処理はキャンセルされません。
その確認にはループを使う必要はないのでシンプルな例にします。

```dart
Future<void> wait2s() async {
  await Future<void>.delayed(const Duration(seconds: 2), () {
    print('2秒経過');
  });
}
```

2 秒経ったときに「2秒経過」と出力するだけの関数です。
それより早い 1 秒目でキャンセルしてみます。

```dart
final _co = CancelableOperation<void>.fromFuture(wait2s());

await Future<void>.delayed(const Duration(seconds: 1));
print('1秒経過 - キャンセル');

await _co.cancel();
print('isCanceled: ${_co.isCanceled}');
```

```
1秒経過 - キャンセル
isCanceled: true
2秒経過
```

`isCanceled` は確かに `true` になりました。
しかしコールバック関数はその後に「2秒経過」を出力しています。
既に動き始めている非同期処理は止まらないのです。

「えっ、それのどこがキャンセルなの？」と思いますよね。
おそらく `await` が中断されてすぐに次行に移ることを意味しているのだろうと思います。

これはタイムアウトによる中断でも同じです。

```dart
try {
  await wait2s().timeout(const Duration(seconds: 1));
} on TimeoutException catch (_) {
  print('1秒経過 - タイムアウト');
}
```

```
1秒経過 - タイムアウト
2秒経過
```

これを誤って理解していると危険な場合もあるので気を付けましょう。
例えば、ファイルへの大量のデータ書き込みを一つの非同期処理として行う場合、それをキャンセルしたつもりでも動き続けてしまうことになります。

### 別解 - Isolate

[Isolate による並行処理](https://dart.dev/guides/language/concurrency) では、その Isolate を切断することで中断できます。
Isolate で for ループなどで動いている処理は止まるので、CancelableOperation でキャンセルしても動き続ける問題の解決策として使えます。

:::note warn
ただし注意が必要です。

- バックエンドなどに既に投げ終えている処理は、それを止める手段がなければ止まりません
- Isolate は Web では使えません
    - 代わりとして Web worker で同様に止められるかどうかは把握していません
- Isolate には僅かながらオーバーヘッドがあります
    - それを考慮しつつ、キャンセルに用いることが妥当か判断しましょう
:::

# さいごに

- `CancelableOperation` を避けずに使いましょう。
- はまりどころに注意しましょう。
- 確認しやすいようにテストコードを書いておきましょう。

誤りがあればご指摘ください。
他の方法がある場合もぜひ！