7
6

More than 5 years have passed since last update.

Koaへの道: JavaScriptのジェネレータを使って非同期処理をコールバックを(あまり)使わずに実現する - 理論編

Posted at

Koaを勉強するに当たり、そもそもなんでジェネレータで非同期処理が同期処理みたいにかけるのかをきちんと理解していないと、始まらないような気がしたので、勉強がてら書いてみようかなと思います。
駄文が多いので、時間がないときは「ジェネレータを使った非同期処理」から読んでしまうといいと思います。

非同期処理の必要性

Node.jsとノンブロッキングI/O

Node.jsでWebアプリを作ろうとするとき、やはり意識すべきはそのアーキテクチャだと思います。
WEBサーバとしてのNode.jsは、シングルプロセスで動作し、リクエストを順番に処理します。それゆえ、リクエストごとにプロセスを作るApacheに比べるとメモリ消費量が少ないことと、プロセス作成のコストを節約できるという利点があります。
一方で、シングルプロセスであるが故に、一つの時間のかかる処理が発生すると、後ろのリクエストが使えてしまい、パフォーマンスが劇的に悪くなるという欠点もあります。
最も時間のかかる処理はI/O、特に通信が関わるとCPUでの処理に比べ、圧倒的に待機時間がかかることになります。
それなら、その待機時間中に後ろのリクエストを処理してあげれば、時間の節約にもなるし、CPUを遊ばせずに済みます
このように、時間のかかるI/O処理の最中に別の処理を走らせることで、効率よくリクエストを処理しちゃおうというのがノンブロッキングI/Oです。

特に時間がかかり、かつCPUが遊びそうな処理といえば、データベースアクセスでしょう。
なにせ、クエリを投げたら、後は結果が帰ってくるまでひたすら待っているわけです。
是非ともノンブロッキングI/Oを活用したいところです。

非同期処理とノンブロッキングI/O

ノンブロッキングI/OはJavaScriptの非同期処理によって実現することができます。

'use strict'

//適当なコールバックを使うオブジェクト
const conv = {
  one: (first_callable, second_callable) => {
    console.log('おはようございます');
    first_callable(second_callable);
  },
  two: (callable) => {
    console.log('昨日は');
    callable();
  },
  three: () => {
    setTimeout(() => console.log('お楽しみでしたね'), 100);
  }
};

conv.one(conv.two, conv.three);
console.log('そんなことないよ');// お楽しみでしたね、の前に出る

このコードは無意味にコールバックをつなげまくったものですが、結果はこのようになります。

おはようございます
昨日は
そんなことないよ
お楽しみでしたね

setTimeoutによって、一瞬待ち時間が発生したため、conv.threeの処理が走る前に、最後の処理が動いてしまいました。
こんな感じでJSでは、待ち時間が発生すると、その処理の実行を待たず次の処理を実行することが容易に実現できるのです。

ところで、この機能は一見素晴らしい機能なのですが、一方で普通の関数の中で、非同期処理が発生すると、非同期処理で取得した値を呼び出し元の関数内で使うことができないという副作用を持ちます。
先に述べたとおり、非同期処理によって待ち時間が発生した場合、その後ろの処理が先に行われてしまうため、そもそも非同期処理の結果を受け取ること自体ができません。
その代わり、非同期処理の場合はその処理結果もしくは取得した値を受け取り、処理を行うための関数を事前に登録し、処理終了時にその処理を実行する機能(コールバック)を持つことが多いです。

コールバック地獄

というわけで、非同期処理を使用し処理を続けていくためには、コールバック機構を使う必要があります。
ところが、非同期処理が連発すると、コールバックがやたらと増えるため、可読性が劇的に落ちます。

'use strict'

//適当なコールバック
function hell(n) {
  setTimeout(() => {
    console.log(n);
    const x = n;
    setTimeout(() => {
      const y = x * x;
      console.log(y);
      setTimeout(() => {
        console.log(y * y * y);
      }, 1000)
    }, 1000)
  }, 1000)
}

hell(2);
console.log('どう動くかな');

3発非同期処理を打つと、もうこんな感じです。
非同期処理一つにつき、コールバック処理が一つ増える上に、各コールバックに対し、変数を渡して処理を続きの処理を行ってもらわなければなりません。
DBアクセス10回も20回も繰り返すようなアプリでこれは少々やりづらいものです。
そんなアクセスせんやろって思っても、実際に100回以上やっちゃっているところもありますので、なんとも。。。

もちろん、無名関数を使いまくると煩雑になるので、関数を外出ししようと思うと、これも厄介です。

'use strict'

//適当なコールバック群
function hell(n) {
  hell2(n, hell3);
}

//async
function hell2(n, callable) {
  console.log(n);
  setTimeout(callable, 1000, n, hell4);
}

//async
function hell3(n, callable) {
  const x = n * n;
  console.log(x);
  setTimeout(callable, 1000, x);
}

//sync
function hell4(n) {
  const x = n * n * n;
  console.log(x);
}

hell(2);
console.log('どう動くかな');

hell2, hell3を非同期関数に見立てています。
無名関数が数珠つなぎに連なる状況は避けられましたが、今度は処理する関数が飛んでいます。
PHPerの私にとっては、ある処理に対しては、メインとなる関数やメソッドが一つ存在して、基本的にはその中ですべての処理を行い、共通化できる処理などを細々としたメソッドやら関数やらに追い出す、という書き方が慣れているのですが、上述したコードはそうはなっていません。
hell関数はhell2が呼ばれた時点で終了し、以降の処理はhell2が主体となります。しかし、hell2setTimeoutが呼ばれるとその役目を終えます。その後hell3がコールバックとして呼ばれますが、またもsetTimeoutを実行後、その動作は終了します。結局最後に動作したのはhell4でした。
このように、メインとなる関数は存在せず、一連の関数群が連鎖的に処理を行うというなかなかエキセントリックな状況になっているわけです。
更に、これらの関数にどのような変数を渡さなければならないかも、考慮する必要があります。

こうして、地獄が完成するわけです。

ジェネレータを使った非同期処理

ジェネレータを使うと、このコールバック地獄から開放されるらしいです。
しかし、それはなぜなのでしょうか?
詳しく考えてみました

理論背景

ジェネレータの動きをおさらいしましょう。ジェネレータは、「中断 → 再開」を繰り返すオブジェクトです。
nextメソッドを打つと

  1. 再開時であれば中断時のyieldから再開する
  2. 再開の時、引数がyieldに渡される。
  3. 次のyieldが来ると、その右にある式を返却して処理を中断する

この挙動を次のように使うとどうでしょうか

  1. yieldの右側にある式として、非同期処理の関数を置く
  2. 非同期処理が実行されるが、yieldがあるので、そこでジェネレータ自体の処理は中断されている。
  3. 非同期処理が終了し、その結果をコールバックに渡すのだが、そのコールバックを元のジェネレータのnextメソッドにし、その引数に処理結果を渡す
  4. nextが呼ばれたので、ジェネレータの処理が再開され、yieldには先の非同期処理の結果が渡されている

つまり、以下の様なコードにすることはできないかということです。

const y = yield asyncFunc(x);

これの最大の利点は、非同期処理を書いたにもかかわらず、その結果を変数yに代入し、同期処理と変わらない感覚でコードを書くことができるというところにります。

実装例

これを実証するために、早速コードを書いてみましょう。

'use strict'

//無理やり順次処理
function* hell(n) {
  console.log(n);
  const x = yield setTimeout(() => gen.next(n * n), 1000);
  console.log(x);
  const y = yield setTimeout(() => gen.next(x * x), 1000);
  console.log(y);
  const z = yield setTimeout(() => gen.next(y * y * y), 1000);
  console.log(z);
}

const gen = hell(2);

console.log('どう動くかな');
gen.next();

gen.next()を打つことで、ジェネレータ内の処理が始まり、非同期処理の部分で一旦中断します。しかし、非同期処理完了後はその結果をgen.next()に渡すことで、再びジェネレータが動き出します。
非同期処理の結果はyieldの左の変数に格納され、以降、ジェネレータ内でいつでも使用できるようになります。

こうして、ジェネレータを使うことでコールバック地獄の悪夢から開放されることができるのです。

ジェネレータを使った非同期処理の利点

さて、ジェネレータを使った非同期処理がどれだけ嬉しいものであるか、まとめてみましょう

コールバックを連鎖させる必要が無い

こうなることを目論んで作っているので当然なのですが、やはり嬉しいものです。

メイン関数みたいに使える

私のようなC噛りのPHPerは、どうも「メイン関数」という考え方がしっくり来るのですが、メインのジェネレータ一つで、処理を完結させられるのは見やすいし、概念を理解しやすいので、嬉しいのですよ。

変数を溜め込める

あまりいいことではないのですが、私がアプリを作ると、メインの関数内にたくさんの変数ができてしまいます。
従来のコールバック地獄でさらにこれらの変数を扱う必要があったらと思うと、とてもnode.jsでアプリ作る気になりません。
しかし、ジェネレータを使うことで、非同期処理が取得する値をあたかも普通の同期処理が返す値と同じように、変数に代入することができます。

個人的にはこれが最高です。

まとめ

今回はやたらと煩雑な内容になってしまいました。
とりあえずジェネレータを検証していたら、「yield」にどのような値が来るかをという話があって、これの検証コードを書いている最中に、「これ、コールバックでジェネレータのnext蹴ったらどうなるんだ?」と思いつき、実際に調べてみたところ、やはりこの仕組を使って非同期処理を同期処理にしているということがわかりました。

ただ、今回の内容はやや実用性に書ける部分があるのですが、長くなるので、次回に回します。

7
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
6