Posted at

Callback, Promise, Co&Generator, async/awaitの4種で見る非同期処理

More than 3 years have passed since last update.

時代と共にJavaScriptで非同期処理を扱う方法が追加され、様々な方法が使えるようになっています。

それぞれの特徴、使い方、そして今後どうしていけば良いのかをまとめてみました。

それぞれの方法の詳細な解説については秀逸な記事が既に何本もあるので、そちらを見て頂くとして、この記事では細かい落穂拾いをしていこうと思います。

JavaScriptは如何にしてAsync/Awaitを獲得したのか Qiita版 (Callback, Promise, generator, async/await)

最近のjs非同期処理 PromiseとGeneratorの共存 (Promise, co&generator)

ES.next async/await + Promise で解決できる事、とES6 generators (yield) + Promise + npm aa (async-await) で解決できる事 (async/await, co&generator的なaaライブラリ)

コールバック……駆逐してやる…この世から…一匹…残らず!! (Callback, Promise, co&generator, 他)

なお、この記事はnode.js v6.2.1を用いて動作確認を行っています。


TL;DR

対応状況やasync-awaitへの移行を考えると、2016年6月時点ではco & generatorの組み合わせがよさそうです。


現状の対応状況

手法
仕様
node.js
Chrome
Edge
Firefox
IE
Opera
Safari

Callback
最初から1

Yes
Yes
Yes
Yes
Yes
Yes
Yes

Promise
ES2015(ES6)
0.12
32.0
Yes
29.0
N/A
19
7.1

co & generator
ES2015(ES6)
4.02

39.0
13
26.0
N/A
26
N/A

async/await
ES.next(stage33)
実装中
実装中
13?
実装中
N/A
N/A
N/A

環境によっては使える機能も多いものの、全ブラウザが対応しているわけではないため、クライアントサイドで動かす場合はPolyfill(es6-promiseなど)の併用が必要そうです。

node.jsであれば4.0以上でco & generatorまで使えるため、サーバサイドや、AWS Lambdaなどで動作させる分には問題なく使用できます。

async/awaitは、現状ではBabelなどのトランスパイラを使用する必要がありますが、各ブラウザベンダで実装が進んでいるので、しばらくしたら不要になる環境も出てきそうです。


Callback

まずは非同期関数の引数としてCallback関数を渡し、処理が終了したら呼んでもらう、一番基本的な書き方から見てみようと思います。


callback.js

function asyncPrint(str, time, callback) {

setTimeout(() => {
console.log(`${new Date()}: ${str}`);
callback(`value ${str}`);
}, time);
}

console.log('Start some processes.');
asyncPrint('process A', 1000, (result1) => {
console.log(`-- result1: ${result1}\n`);
asyncPrint('process B', 1000, (result2) => {
console.log(`-- result2: ${result2}\n`);
asyncPrint('process C', 1000, (result3) => {
console.log(`-- result3: ${result3}\n`);
console.log('All processes have been finished.');
});
});
});



result

% node callback.js

Start some processes.
Processes have not finished yet.
Wed Jun 15 2016 09:32:50 GMT+0900 (JST): process A
-- result1: value process A

Wed Jun 15 2016 09:32:51 GMT+0900 (JST): process B
-- result2: value process B

Wed Jun 15 2016 09:32:52 GMT+0900 (JST): process C
-- result3: value process C

All processes have been finished.


nodejsの標準ライブラリに含まれる非同期関数は最後の引数としてcallbackを取る仕様で統一されているため、node.js上で動作するJavaScriptを書いていると良く見かける書き方かと思います。

手軽に実装できる反面、非同期処理を逐次的に実行していくとネストが深くなってしまい、通称Callback地獄に苦労することになります。


メリット:


  • 手軽に書ける

  • どの環境でも動く


デメリット:


  • ネストが深くなりがち

  • 失敗時のエラー処理が分散・重複し、書きづらい

  • 非同期処理の並列実行・待ち合わせなどがつらい


Promise

続いて、jQuery.deferredを使っていた人にはおなじみの書き方、Promiseです。

各処理が実行状態を保持するPromiseを返すことで、ネストを抑えた書き方やエラー処理ができるようになっています。


promise.js

function asyncPrint(str, time) {

return new Promise((resolve, reject) => {
setTimeout(() => {
console.log(`${new Date()}: ${str}`);
resolve(`value ${str}`);
}, time);
});
}

console.log('Start some processes.');
asyncPrint('process A', 1000)
.then((result1) => {
console.log(`-- result1: ${result1}\n`);
return Promise.all([
asyncPrint('process B', 2000),
asyncPrint('process C', 1000),
asyncPrint('process D', 2000)
]);
})
.then((result2) => {
console.log(`-- result2: ${JSON.stringify(result2)}\n`);
return asyncPrint('process E', 1000)
})
.then((result3) => {
console.log(`-- result3: ${result3}\n`);
console.log('All processes have been finished');
})
.catch((err) => {
console.log('Some error occurred while processing');
console.log(err);
});

console.log('Processes have not finished yet.\n');



result

% node promise.js

Start some processes.
Processes have not finished yet.

Wed Jun 15 2016 09:34:23 GMT+0900 (JST): process A
-- result1: value process A

Wed Jun 15 2016 09:34:24 GMT+0900 (JST): process C
Wed Jun 15 2016 09:34:25 GMT+0900 (JST): process B
Wed Jun 15 2016 09:34:25 GMT+0900 (JST): process D
-- result2: ["value process B","value process C","value process D"]

Wed Jun 15 2016 09:34:26 GMT+0900 (JST): process E
-- result3: value process E

All processes have been finished



メリット:


  • ネストの数を抑えられる

  • エラー処理をまとめて記述できる


  • Promise.allに配列を渡すことで並列実行ができる


デメリット:


  • 非同期処理を逐次的に実行しようとすると、そのたびにthenや関数定義が必要になる(参考

  • 各非同期処理の結果が次のthenにしか渡されない

  • 複数回繰り返される非同期処理を書くのがつらい(参考


  • Promise.allで並列実行した場合、結果は配列なので、どの結果なのかが分かりづらい


Co & generator

ES2015(ES6)で追加された4generator functionと、良い感じにnext()を呼んでくれるcoを組み合わせると、あたかも同期型の関数を呼んでいるかのように非同期処理を逐次実行できます。

また、yieldの引数にはPromise, Object, Array, generatorなど様々な型を取れるため、並列実行や処理の分割が書きやすいのも良い所です。


co-generator.js

var co = require('co');

function asyncPrint(str, time) {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log(`${new Date()}: ${str}`);
resolve(`value ${str}`);
}, time);
});
}

console.log('Start some processes.');
co(function*() {
var result1 = yield asyncPrint('process A', 1000);
console.log(`-- result1: ${result1}\n`);

var result2 = yield {
B: asyncPrint('process B', 2000),
C: asyncPrint('process C', 1000),
D: asyncPrint('process D', 2000)
};
console.log(`-- result2: ${JSON.stringify(result2)}\n`);

var result3 = yield asyncPrint('process E', 1000);
console.log(`-- result3: ${result3}\n`);
}).then(function() {
console.log('All processes have been finished');
}).catch(function(err) {
console.log('Some error occurred while processing');
console.log(err);
})

console.log('Processes have not finished yet.\n');



result

% node co-generator.js

Start some processes.
Processes have not finished yet.

Wed Jun 15 2016 09:35:01 GMT+0900 (JST): process A
-- result1: value process A

Wed Jun 15 2016 09:35:02 GMT+0900 (JST): process C
Wed Jun 15 2016 09:35:03 GMT+0900 (JST): process B
Wed Jun 15 2016 09:35:03 GMT+0900 (JST): process D
-- result2: {"B":"value process B","C":"value process C","D":"value process D"}

Wed Jun 15 2016 09:35:04 GMT+0900 (JST): process E
-- result3: value process E

All processes have been finished



メリット:


  • 非同期処理を同期型の関数のように呼べる

  • 逐次実行、並列実行を同じ表記で書ける

  • 並列実行の結果をObjectにできるので対応がわかりやすい

  • async/awaitとほぼ同じ書き方なので、のちのち移行しやすい


デメリット:


  • 今までのJavaScriptと見た目がかなり違うので慣れが必要


async/await

ES.nextで追加予定のasync/awaitを使った書き方です。前の段落と比較すると分かりやすいかと思いますが、co & generatorの形式とほぼ同じ書き方ができるようになっています。

現在stage3なので仕様はほぼ確定。前述したように実装も進行中なので、(環境次第ですが)近いうちに使えるようになりそうです。


async-await.js

function asyncPrint(str, time) {

return new Promise((resolve, reject) => {
setTimeout(() => {
console.log(`${new Date()}: ${str}`);
resolve(`value ${str}`);
}, time);
});
}

console.log('Start some processes.');
(async () => {
var result1 = await asyncPrint('process A', 1000);
console.log(`-- result1: ${result1}\n`);

var result2 = await Promise.all([
asyncPrint('process B', 2000),
asyncPrint('process C', 1000),
asyncPrint('process D', 2000)
]);
console.log(`-- result2: ${JSON.stringify(result2)}\n`);

var result3 = await asyncPrint('process E', 1000);
console.log(`-- result3: ${result3}\n`);
})().then(() => {
console.log('All processes have been finished');
}).catch((err) => {
console.log('Some error occurred while processing');
console.log(err);
})

console.log('Processes have not finished yet.\n');



package.json

{

"name": "async-await",
"version": "1.0.0",
"dependencies": {},
"scripts": {
"babel": "./node_modules/.bin/babel",
"babel-node": "./node_modules/.bin/babel-node"
},
"devDependencies": {
"babel-cli": "^6.10.1",
"babel-preset-es2017": "^1.4.0"
}
}


result

% npm run babel-node async-await.js

> async-await@1.0.0 babel-node /home/developer/lambda/promise
> babel-node "async-await.js"

Start some processes.
Processes have not finished yet.

Wed Jun 15 2016 10:05:36 GMT+0900 (JST): process A
-- result1: value process A

Wed Jun 15 2016 10:05:37 GMT+0900 (JST): process C
Wed Jun 15 2016 10:05:38 GMT+0900 (JST): process B
Wed Jun 15 2016 10:05:38 GMT+0900 (JST): process D
-- result2: ["value process B","value process C","value process D"]

Wed Jun 15 2016 10:05:39 GMT+0900 (JST): process E
-- result3: value process E

All processes have been finished



メリット:


  • 非同期処理を同期型の関数のように呼べる

  • 外部ライブラリを使用せず言語仕様だけで記述できる


デメリット:


  • 対応している環境が少なく、現状ではトランスパイラ必須

  • 並列実行したい場合にPromise.allが必要5


  • Promise.allの返り値は配列なので、どの結果なのかが分かりづらい

  • 定義した関数を式として評価するために(async function() {})().then……という書き方になる(書き方自体は慣れっこだが、前節の例と比べると……)


co & generatorとasync/awaitの制限

co & generatorはyield、async/awaitはawaitで関数の実行を中断することによって非同期処理の終了を待つようになっています。

そのため、yieldawaitfunction*async functionの中で無いと呼ぶことができず、配列の各要素について直列に処理を実行する場合に問題になることがあります。

以下にco & generatorの例を示しますが、async/awaitも同様の制限を持っています。


問題無く実行できる例


correct1.js(co-generator)

co(function*() {

var list = ['A', 'B', 'C'];
for(var i = 0; i < list.length; i++) {
yield asyncPrint(list[i], 1000);
}
})


correct2.js(co-generator)

co(function*() {

var list = ['A', 'B', 'C'];
for(var c of list) {
yield asyncPrint(c, 1000);
}
})


エラーになる例

co(function*() {

var list = ['A', 'B', 'C'];
list.forEach(function(c) {
yield asyncPrint(c, 1000);
});
})


まとめ

非同期処理の量や、実行環境にもよりますが、個人的には以下のような方法が良いように思いました。


  • node.js標準の関数はbluebird辺りでPromise化(promisify)

  • 対応状況やasync-awaitへの移行を考えて、co & generator





  1. JavaScriptは第一級関数を採用した言語なので最初から可能。非同期処理という意味ではsetTimeoutの仕様が決まったタイミングともいえるか 



  2. harmonyフラグを付ければ0.12でも一部は動作するかも(未確認) 



  3. 2種類以上の実装が無いのでES2016には入らなかったが、Edge(実装済), Firefox(実装中), Chrome(実装中)のため、これらがリリースされたらstage4になる 



  4. 実は2006年のJavaScript1.7で既に定義されていたり 



  5. 仕様策定中はawait*という並列実行用のSyntax sugarがありましたが、廃止されました。