🐾

なんでもasyncパターン

2021/02/17に公開

第1回ゆるふわ実装パターン(レベル: 初心者脱出)の記事です(たぶん続かない)。

内容

  • なんでも脳死asyncパターンについて
  • 言語の実行スレッドモデルなどに注意しないとはまる罠がある
  • リポジトリパターンを活用しましょう
  • 脳死asyncパターンは書きやすいといってもそんなに簡単ではないですね

脳死asyncパターンとは

クリーンアーキテクチャの UseCase なんかをトップダウンで書くことを想像してみてほしいのですが。

  • なにか必要なデータをとってきて
  • なにか処理して
  • 結果がある条件を満たしていたらなにかに保存

みたいなざっくりした処理の流れがあるとき、それぞれの処理がどうなるかもまだ未定なのでとりあえず非同期だとして、以下のような感じのコードを書くことができます。

async function f1() {
    let inputData = await inputFromAnywhere()
    let outputData = await someProcessing(inputData)
    if (await outputData.hasCondition()) {
        outputToSomewhere(outputData)
    }
}

このようなコードの利点は

  • コールバックなどより書きやすく全体構造を見えやすく書ける場合がある
  • それぞれの処理時間などの詳細がわからなくても書ける
  • async / await 構文によって呼び出し側(UIなど)をブロックしない非同期処理として書ける

などがあると思いますので、一定の利点はあるのです。

また、シングルスレッドモデルの言語(たとえば JavaScript)や、イベントループに処理を返さないと他の処理が止まってしまうタイプのライブラリ(たとえば PyQt)などを使っている場合には、同期処理で時間のかかる処理を呼び出すとほかの処理が止まってしまうため、このような構造を導入しなければならない場合というのもあります。

非同期処理における暗黙の前提条件

脳死asyncパターンは非同期処理なのでいくつかの前提条件があります。これを考えていないと見つけにくい不具合などを引き起こすことがあります。

再入(リエントラント, re-entrant)の問題

この関数 f1 自体が async 関数なので、複数回 f1 を呼び出した場合に同時並行で動作します。たとえばUIイベント(ボタンをタップしたときとか)に f1 を呼び出すように実装すると f1 の処理が二重に実行されることを考慮する必要があります。

並行(コンカレント, concurrent)動作の問題

関数自身が再入した場合、または他の関数と同時に実行されることがある場合、並行動作について考慮する必要があります。

たとえばよくある並行処理の問題として、ちょっと条件を追加して以下のような関数を作ったとします。

async function f2() {
    if (await existsInputData()) {
        let inputData = await inputFromAnywhere()

        ...
    }
}

existsInputData で入力データが存在するかチェックして、inputFromAnywhere で入力を読み取る、といった処理を行うようなことを想像してください。

existsInputData と inputFromAnywhere の間でほかの処理が実行できてしまうので、この関数を同時並行で実行してしまうと existsInputData は true を返したのに inputFromAnywhere の実行前に状態が変化してしまい、入力が受け取れなかったり想定外の状態になることが考えられます。

並列(パラレル, parallel)実行の問題

シングルスレッドモデルの言語の場合、ある処理の流れで await を使わなければ途中で他の処理が実行されることはありませんが、マルチスレッドモデルの言語では、await するかどうかとは無関係に、個々の詳細な処理についても同時並列で動作することを考慮しなければなりません。

await を用いて処理を呼び出した場合、一般にはなんらかのスレッドで実行されてそれを待機するような仕組みになっています。明示的にスレッド生成を行わないだけで内部的にはマルチスレッドプログラミングと同じ仕組みなのですから、スレッドセーフな処理で全てを構成する必要があります。

また、シングルスレッドモデルの言語であれば、await をやめて同期実行にすることで、existsInputData と inputFromAnywhere の間に他の処理が実行されることがなくなるため、f3 のような修正に意味がありうるのですが、マルチスレッドモデルで並列実行される場合にはこの修正では問題が解決しません。

async function f3() {
    if (existsInputData()) {
        let inputData = inputFromAnywhere()

        ...
    }
}

基本的な対策

ロックによる保護

async 自体が疑似的なマルチスレッドの仕組みとみなすことができるので、対策もマルチスレッドにおける排他処理と同じように考えることになります。再入を防ぐには実行全体をロックやセマフォなどで保護し、並列実行の問題を防ぐためには個々の構成要素の処理をスレッドセーフになるように実装する必要があります。

アルゴリズムによる保護

また、並行実行の問題を起こさないようにするためには、個々の構成要素だけではなく、全体の処理の流れについても同時に複数の処理が実行されることを許容できるような仕組みとする必要がでてきます。

アルゴリズムというとおおげさですが、たとえば

  • 副作用を局所化して、複数の処理の間での依存性をなくす
  • 判定と取得を別々に実行するような並行実行に問題がある実装を禁止する

といった基本的なことが大切になります。つまり、任意の処理を好きなように書き散らせるわけではなくて、並行実行の問題を回避できるようなコーディングができるひとにしか、脳死asyncパターンは活用できないわけです……

リポジトリ(repository)パターンの導入

極論すると複数の処理で依存関係がなかったら並行実行の問題は関係ないので、依存性を局所化するためにリポジトリパターンを導入することができます。

リポジトリパターンの定義はひとによって揺れがあると思いますが、

  • データの入出力をひとつのクラスにまとめて整合性を保つ

というゆるめの定義として考えます。

たとえば UserData クラスを取り扱うリポジトリとして UserDataManager のようなクラスを用意して、UserData オブジェクトの入出力はすべて UserDataManager を通すことを規約とします。

UserDataManager では、整合性を保つことのできないようなインタフェースを定義しない、ということが重要です。つまり、UserDataManager に get と set のインタフェースを用意したとしたら、get と set は任意のタイミングで実行できることを保証します。これを実現するためには内部ではロックなどで排他処理したり呼び出しキューで直列化したりといった工夫を閉じ込めます。

大事なのは Manager をリポジトリとして考えたらそこに詳細を閉じ込めることで、外部からは自由に呼べるように見せかけるということです。

また、exists のようなインタフェースを用意することもできなくはないですが、問題を起こしやすいので最初から用意しない、といった戦略も役に立ちます。安全な部品だけを用意してそれを組み立てることで全体を安全にする手法と考えることができます。

まとめ

ここまでの話で、

  • 脳死asyncパターンは詳細を考えずに全体の流れを書き始めることに役に立ちます
  • リポジトリパターンと併用して注意深く実装することで比較的安全になります

ということがわかりました。

つまり、結論としては、

です。安易に考えず地に足をつけて行きましょうね。

Discussion