俺たちはJavaScriptの非同期処理とどう付き合っていけば良いのだろうか

  • 191
    いいね
  • 1
    コメント
この記事は最終更新日から1年以上が経過しています。

おきまりのやつ

CYBIRDエンジニア Advent Calendar 2015 2日目担当の @keitarou です。
イケメンスタジオという謎の組織でWEBエンジニアをしております。
最近はNode.jsを使った大規模分散アプリケーションみたいなものを作ってます。
今回のCYBIRDエンジニア Advent Calendarの仕切り役みたいなのもやっています。みんな協力してくれるいい会社だなぁと

1日目は(@gotyoooo)さんの最近のCYBIRDゲームインフラ環境についてでした。うちのインフラエンジニアは超優秀だと思います。転職希望の方はご連絡ください。

はじめに

ES6, Promise, Generatorなどの登場によってJavaScriptの非同期処理の実行パターンのバリエーションが一気に増えた気がします。
今回は今現在、どういった手法が存在するのか、どういうメリット・デメリットがあるのかを自分の中で整理することが目的なので何も期待しないでください。
あと、何か間違ったこと書いていたらご指摘ください。

何故、非同期処理が必要なのか

平日の朝を例にしてみると

  • A. お風呂に入る(15min)
  • B. ドライヤーで髪を乾かす(5min)
  • C. 洗濯機を回す(20min)
  • D. 洗濯物を干す(5min)

ざっとやらないといけないことは↑ぐらいあるかと思います。
全て処理を行うには単純計算で45分かかる事がわかります。
しかしこれは全ての処理を直列に行なった場合の数字であって、実際は洗濯機を回している間に風呂に入ることができるので45分もかかりません。
この、『AをしながらBをする。』『AをしながらBとCをする。』など複数の処理を同時に実行することを『並列実行』・『並行実行』などと呼ぶことができるかと思います。
『並列』・『並行』と似た言葉がありますが、具体的には以下のようなルールに当てはめて使い分ければ良いかと思います。

「並行」は概念的に違うものを同時に処理、「並列」は同じ処理を分割して処理

この『並列処理』・『並行処理』を行うための手続きとして非同期処理があります。
JavaScriptでは基本的には『処理』と『処理が完了した際の処理』を非同期処理のインタフェースに渡すことで『並列処理』・『並行処理』を行うことができます。

話を戻しますが、上の朝の例ですと、直列処理と並行処理では以下の様に異なります。

直列処理

A: 9:00
↓
B: 9:15
↓
C: 9:20
↓
D: 9:40
↓
-  9:45

並行処理

A: 9:00  | C: 9:00
↓        | ↓
B: 9:15  |
↓        |
-: 9:20  | D: 9:20
         | ↓
         | -  9:25

となわけで、効率よく処理をさばくことができるので朝の時間も有効活用することができますね。

WEBアプリケーションにおける非同期処理の活用所

  • HTTPのリクエスト
  • タイマーによる処理
  • ブラウザ操作におけるイベント処理
  • DATABASEの操作

などなど色々なところで非同期処理を行うことで効率のいいアプリケーションを作成することができます。

非同期処理における課題

非同期処理とひとつに言っても良いことばかりではありません。
その一つにソースコードが読みづらくなる。という点が挙げれるかと思います。
今回の記事では、非同期処理のプログラミングの可読性という点で話ができればと思います。
以下は例です。


request('hoge.png', function(file){
  console.log(file);
  request('fuga.png', function(file){
    console.log(file);
    request('piyo.png', function(file){
      console.log(file);
    });
  });
});

これだと、いわいるコールバック地獄と呼ばれるものでソースコードが段々右へ右へ行ってしまい、非常に読みづらいです。
目にも悪いですし, スコープによってcontextが違うので余計に紛らわしいです。
今回はJavaScriptにおける非同期処理に対する4つのアプローチに関してサンプルコードを元に紹介します。

  • 1. Callback Hell
  • 2. Then Chains
  • 3. Generator
  • 4. Async/Await

仮に、それぞれに上記の様な命名をして紹介します。
サンプルコードは全て、冒頭で紹介していた朝のタスクを並行で捌くというものになっています。

お風呂に入った後に、ドライヤーで髪を乾かします。洗濯機を回した後に洗濯機を干します。
洗濯機を回しながらお風呂に入ることができます。

めっちゃ厳しく考えると成り立たないところもありますが、そこは目を瞑っていただければと思います。

Callback Hell

その名もコールバック地獄、課題で上げたような処理の書き方ですね。


const startTime = new Date();

const doneMessage = function()
{
  console.log('さぁ出社するよ!!');
  var endTime = new Date();
  console.log(endTime - startTime + 'ms');
};

const TASKS = {
  A: 15,
  B: 5,
  C: 20,
  D: 5,
};
var dones = {
  A: false,
  B: false,
  C: false,
  D: false,
};

const done = function(target)
{
  console.log(target + ' done');
  dones[target] = true;
  if(dones.A && dones.B && dones.C && dones.D) doneMessage();
}

const runTask = function(target, cb)
{
  setTimeout(cb, TASKS[target]* 1000);
};

runTask('A', function(){
  done('A');
  runTask('B', function(){
    done('B');
  })
});
runTask('C', function(){
  done('C');
  runTask('D', function(){
    done('D');
  })
});

そもそもこの書き方の欠点はcallback地獄とかそんなレベルではなく、どのタスクを完了したかを何らかの手法でメモしないといけないという点ではないでしょうか。
今回は単純にフラグを点滅させながら都度確認する方法を取りました。話になりませんね。いけてないです。

Then Chains

.then().then().then()みたいに繋いでいくやり方ですね。jQueryなどでも最近はできるようになっているようですが,
今回は簡単にES6, Promiseを使って実装しています。
Promiseは非同期処理を.thenなどで繋いでいけるようにするために抽象化して実装するためのものです。

JavaScript Promiseの本
http://azu.github.io/promises-book/


const startTime = new Date();

const doneMessage = function()
{
  console.log('さぁ出社するよ!!');
  var endTime = new Date();
  console.log(endTime - startTime + 'ms');
};

const TASKS = {
  A: 15,
  B: 5,
  C: 20,
  D: 5,
};

const runTask = function(target){
  return new Promise(function(resolve){
    setTimeout(function(){
      console.log(target + ' done');
      resolve(true);
    }, TASKS[target]* 1000);
  });
};

Promise.all([
  runTask('A')
    .then(function(){
      return runTask('B');
    }),
  runTask('C')
    .then(function(){
      return runTask('D');
    }),
])
  .then(doneMessage);

かなりすっきりしましたね。
処理したタスクの管理が不要になりました。
並行処理を実現しているのはPromise.allというPromiseで抽象化された非同期処理を並行実行するためのユーティリティによって実現されています。
Promiseに関してはES6のシンタックスなので生実行できるJavaScript実行エンジンは絞られてしまいますが、Polyfillと言われているような、ES6のシンタックスをレガシーなJavaScriptエンジンでも実行できるように作られたライブラリを使うことで実行エンジンの範囲を広げることができます。
PromiseのPolyfillとしては、『bluebird』などがあります。
弊社のブラウザアプリの一部でも利用されていたりします。
https://github.com/petkaantonov/bluebird

Generator

ES6からサポートされたGeneratorによって並行実行を手続き型チックに書くことができます。


const co = require('co');

const startTime = new Date();

const doneMessage = function()
{
  console.log('さぁ出社するよ!!');
  var endTime = new Date();
  console.log(endTime - startTime + 'ms');
};

const TASKS = {
  A: 15,
  B: 5,
  C: 20,
  D: 5,
};

const runTask = function(target){
  return new Promise(function(resolve){
    setTimeout(function(){
      console.log(target + ' done');
      resolve(true);
    }, TASKS[target]* 1000);
  });
};

co(function* (){
  yield [
    co(function* (){
      yield runTask('A');
      yield runTask('B');
    }),
    co(function* (){
      yield runTask('C');
      yield runTask('D');
    }),
  ];
  doneMessage();
});

『function*()』, 『yield』,『co(...)』と普段見覚えのない構文が多いかと思いますが、ザクっと一つづつ補足します。

  • function*(): ジェネレーター関数であることを宣言する
  • yield: ジェネレーター関数は、関数の実行を状態を保ったまま、中断/再開させる仕組みを提供する関数ですが、どこで中断するのかを宣言するためのキーワードです。
  • co: ジェネレーター関数を実行するためのライブラリであり、イテレーターを最後まで実行することができます。(説明が下手です。) coを使わないと、while文などを駆使してイテレーターを評価しながら最後まで実行する必要があります。

今回のサンプルではcoを使っておりますが、他にもジェネレーター関数をいい感じに実行するためのライブラリが色々あります。『vo』『aa』などがあります。


Promiseインタフェースの非同期処理を実行し、yieldでresolveの結果を取得することもできます。今回の例ではあまり旨味がわからないので特別にもう一つサンプルを用意しました。
宝箱Aの中に宝箱Bが入っていて、宝箱Bの中に宝箱Cが入っていて....というのをひたすら繰り返して、最終的に宝を獲得できるというサンプルコードです。


const co = require('co');

const BOXES = {
  A: {
    key: 'B',
    treasure: null
  },
  B: {
    key: 'C',
    treasure: null
  },
  C: {
    key: 'D',
    treasure: null
  },
  D: {
    key: 'E',
    treasure: null
  },
  E: {
    key: 'F',
    treasure: null
  },
  F: {
    key: 'G',
    treasure: null
  },
  G: {
    key: null,
    treasure: '100G'
  },
};

const open = function(target){
  return new Promise(function(resolve){
    setTimeout(function(){
      console.log('Open ' + target);
      resolve(BOXES[target]);
    }, 100);
  });
};

co(function* (){
  var a = yield open('A');
  var b = yield open(a.key);
  var c = yield open(b.key);
  var d = yield open(c.key);
  var e = yield open(d.key);
  var f = yield open(e.key);
  var g = yield open(f.key);
  console.log('Get ' + g.treasure);

  // この書き方もできる
  // var box = null;
  // while(box === null || box.key)
  // {
  //   box = yield open(box === null ? 'A' : box.key);
  // }
  // console.log('Get ' + box.treasure);
});

このように非同期処理の結果を元に次の非同期処理を行うような場合にとても相性が良いです。


Generatorによって、非同期処理の実行を縦に手続き型っぽく読みことができる時代がきました。
try/catchを使ったエラーの捕捉も可能な点もメリットとして挙げれるのではないでしょうか。
しかし欠点としては、サードパーティのライブラリを使わないといけない(言語レベルでサポートされていない)という点かと思われます。この点の解決案として『ES7 Async/Await』があります。

Async/Await

Async/AwaitはECMAScript2016(ES7)のドラフトに上がっている非同期処理を実行のためのシンタックスです。
元はC#から影響を受けているようです。


const startTime = new Date();

const doneMessage = function()
{
  console.log('さぁ出社するよ!!');
  var endTime = new Date();
  console.log(endTime - startTime + 'ms');
};

const TASKS = {
  A: 15,
  B: 5,
  C: 20,
  D: 5,
};

const runTask = function(target){
  return new Promise(function(resolve){
    setTimeout(function(){
      console.log(target + ' done');
      resolve(true);
    }, TASKS[target]* 1000);
  });
};

(async function(){
  await Promise.all([
    runTask('A')
      .then(function(){
        return runTask('B');
      }),
    runTask('C')
      .then(function(){
        return runTask('D');
      }),
  ]);
  doneMessage();
})();

先ほど紹介した『Generator』のパターンと似ている気がしませんか。
function*() がasyncキーワードになって、yieldがawaitキーワードになっているだけっちゃあなってるだけです。
しかし残念な事に並行実行としてのサポートが超弱いです。今回の例ではPromise.allを結局使ってしまっています。

まとめ

あと、これに関してはすべて個人的な感想なので、なんとかかんとかってことでよろしくお願いいたします。

Callback Hell

  • ↑環境によって動かない。とかがない。
  • ↓並列・並行実行の際に終了を検知する必要がある時が面倒
  • ↓ネストが深くなるので読みづらい

Then Chains

  • ↓.then()の記述が多くなるのでノイズに感じることがある
  • ↑ネストが深くならない
  • ↑Promiseのユーティリティによって様々な実行方法が提供されている

Generator

  • ↓動く環境のハードルが上がる(babeればいいんだろうけど)
  • ↑ネストが深くならない
  • ↓何らからのジェネレーター関数実行用のライブラリとセットで使うことが前提
  • ↑coのユーティリティによって様々な実行方法が提供されている
  • ↑callbackで評価したものを変数に代入できるので、手続き型っぽいコードが書ける
  • ↑try/catchができる

Async/Await

  • ↓動く環境のハードルが超上がる
  • ↓そもそもシンタックスがドラフトレベルなのでサポートされるとは限らない
  • ↑ネストが深くならない
  • ↓Promise.allのような仕組みがないので、並列・並行処理を独立して解決できない
  • ↑callbackで評価したものを変数に代入できるので、手続き型っぽいコードが書ける
  • ↑try/catchができる

個人的な結論ですが、今のところ、Generator×coでの非同期処理が一番ナウいかな。という見解です。

さいごに

モンハン発売数日後に担当するのはやめようと思います。


CYBIRDエンジニア Advent Calendar 2015 明日は、 @cy-kenta-takahashiの『storyboardのあれやこれ』です。
2015年度の新卒からの登場です。モンハン仲間でもある@cy-kenta-takahashi楽しみです。乞うご期待!

この投稿は CYBIRDエンジニア Advent Calendar 20152日目の記事です。