JavaScript
callback
promise
非同期処理
es6

JavaScriptの同期、非同期、コールバック、プロミス辺りを整理してみる

More than 1 year has passed since last update.

対象

このあたりの理解があいまいな方:

  • JavaScriptは非同期で処理できる!?
  • 並行処理と非同期処理って同じ!?
  • そもそも同期と非同期って何が違う!?
  • 非同期の結果はコールバックで受け取る!?
  • コールバックを多用するとコールバック地獄に陥る!?
  • Promiseを使うとコールバック地獄を回避できる!?

JavaScriptの基礎

大前提として、JavaScriptはシングルスレッドで動いています。
これはつまり、JavaScriptは並行処理はできないということです。
同期であろうと非同期であろうと2つ以上の処理を同時に行なうことはできません
JavaScriptでは、キューに登録された関数が順番にひとつずつ実行されていきます。
でもキューに登録される順番が同期であったり非同期であったりします。
JavaScript以外(例えばデータベース等)に仕事を任せてる間、その処理を待たないで次へ進めるという意味で非同期なのです。
まとめると、

  • JavaScriptは並列処理できない:1度に1つの仕事しかできない。
  • JavaScriptは非同期処理できる:誰か(DB等)に仕事を任せている間に自分の他の仕事を進めることができる

同期処理の基礎

簡単な同期処理をひとつ見てみましょう。

console.log(1);
console.log(2);
console.log(3);

ログを3つ表示するだけのシンプルな処理です。
3つの関数はlog(1)、log(2)、log(3)の順にキューに登録されます。
そしてこの順番に実行されていくので、ログはこんな感じになります。

1
2
3

このように上から順にキューに登録していく処理を同期処理といいます。
プログラムの基本です。

非同期処理の基礎

次は簡単な非同期処理を3つ見てみましょう。
1つ目はこれです。

console.log(1);
setTimeout(function(){console.log(2)}, 1000);
console.log(3);

この場合、log(1)、setTimeout(〜略〜)、log(3)の順にキューに登録されます。
なので最初にlog(1)が実行され、ログに1が表示されます。
次にsetTimeout(〜略〜)が実行され、タイマーにlog(2)が登録されます。
続いてlog(3)が実行され、ログに3が表示されます。
ここまでは同期処理です。
そしてタイマーにlog(2)が登録されてから1000ミリ秒後にlog(2)がキューに登録されます。
log(2)は最初の3つの関数とは独立してキューに登録されるので、これは非同期処理です。
そしてlog(2)が実行されるので、ログはこんな感じになります。

1
3
2

今回、関数setTimeoutに無名関数function(){console.log(2)}を渡しました。
このように、ある関数Aの引数に別の関数Bを渡し、AからBを呼び出すことをコールバックといいます。
少し脱線しますが念のため補足すると、コールバックは無名関数である必要はありません。
次のコードでも全く同じ結果が得られます:

//コールバック関数を別に定義
var logresult = function(){
  console.log(2);
}

console.log(1);
setTimeout(logresult, 1000);
console.log(3);

非同期処理の理解を深めるために、先ほどの例をちょっとだけ変えた次の例を考えてみましょう。

console.log(1);
setTimeout(function(){console.log(2)}, 0);
console.log(3);

先ほどの例と違う部分は、タイマーが0ミリ秒に設定されていることです。
待ち時間がゼロなので、console.log(1)、console.log(2)、console.log(3)の順に実行されると勘違いしやすいです。
しかしあくまでlog(1)、setTimeout(〜略〜)、log(3)が順に、つまり同期的にキューに登録された後、setTimeout(〜略〜)が実行されたタイミングで、つまり非同期でlog(2)がキューに登録されるので、ログはこうなります。

1
3
2

つまり、JavaScriptはsetTimeoutを使ってタイマーに仕事を任せている間に、次の自分の仕事であるconsole.log(3)を進めているということです。
さらに理解を深めるためにsetTimeout以外の例も見てみましょう。

var xhr = new XMLHttpRequest();
xhr.open('GET','何かのAPI');
xhr.send();
xhr.addEventListener('load', function(result){
  console.log(1);
});
console.log(2);

今度はXMLHttpRequest()を使って通信しています。
この場合、まずはopen()、send()、addEventListener()、log(2)が順に(同期的に)キューに登録されます。
そしてaddEventListener()でloadイベントが発生した時点でlog(1)がキューに登録されます。
これはその前の一連の関数とは関係ないタイミングでキューに登録されるので非同期です。
関数はキューに登録された順番にひとつずつ実行されていくので、ログはこうなります。

2
1

つまり、JavaScriptはXMLHttpRequestを使って外部と通信している間に、次の自分の仕事であるconsole.log(2)を進めているということです。
非同期のメリットは、JavaScriptのスレッドでやらなくても良い仕事を外部にやらせてる間にJavaScriptのスレッドが自分の仕事を進められることです。
JavaScriptのスレッドでやらなくても良い仕事というのはたくさんあります。
今回の1つめや2つめのようなタイマー処理だったり、3つめのようなHTTP通信であったり、他にはDB周りの処理だったりです。

同期処理を行なう関数を作る

先ほどはconsole.log()という与えられた関数を使いました。
今度は同期処理を行なうオリジナルの関数を作ってみましょう。
150円のリンゴを買ったときのおつりを計算して返すだけのシンプルな関数です:

var syncBuyApple = function(payment){
  if(payment >= 150){
    return {change:payment-150, error:null};
  }else{
    return {change:null, error:150-payment + '円足りません。'};
  }
}

引数paymentに金額を渡すとchangeにおつりの金額、errorにnullが入ったオブジェクトを返します。
金額が足りない時はchangeにnull、errorにエラー文が入ったオブジェクトを返します。
この関数を使って500円でりんごをひとつ買ってみましょう:

var result = syncBuyApple(500);
console.log('500円払いました。');
if(result.change !== null){
  console.log('おつりは' + result.change + '円です。');
}
if(result.error !== null){
  console.log('エラーが発生しました:' + result.error);
}

おつりがあればおつりを、エラーがあればエラーをログに表示します。
次の非同期の場合と比較するためにエラー処理までちゃんと書いておきます。
500円払った場合はのログはこうなります。

500円払いました。
おつりは350円です。

500円支払っておつりを計算して結果を表示するまですべて同期処理です。

同期処理を複数行なう

同期処理でりんごを買う関数syncBuyAppleを使って、今度は500円でりんごを4つ続けて買ってみましょう:

var result1 = syncBuyApple(500);
if(result1.change !== null){
  console.log('1つ目のおつりは' + result1.change + '円です。');
}
if(result1.error !== null){
  console.log('1つ目でエラーが発生しました:' + result1.error);
}
var result2 = syncBuyApple(result1.change);
if(result2.change !== null){
  console.log('2つ目のおつりは' + result2.change + '円です。');
}
if(result2.error !== null){
  console.log('2つ目でエラーが発生しました:' + result2.error);
}
var result3 = syncBuyApple(result2.change);
if(result3.change !== null){
  console.log('3つ目のおつりは' + result3.change + '円です。');
}
if(result3.error !== null){
  console.log('3つ目でエラーが発生しました:' + result3.error);
}
var result4 = syncBuyApple(result3.change);
if(result4.change !== null){
  console.log('4つ目のおつりは' + result4.change + '円です。');
}
if(result4.error !== null){
  console.log('4つ目でエラーが発生しました:' + result4.error);
}

単調ですが、ネストが浅い(インデントが高々1段)ので読みやすいということを覚えておいて下さい。
ちなみにログはこうなります。

1つ目のおつりは350円です。
2つ目のおつりは200円です。
3つ目のおつりは50円です。
4つ目でエラーが発生しました:金額が足りません。

ここまでは問題ないと思います。
問題は次の非同期の場合です。

非同期処理を行なう関数を作る(コールバック版)

同じく、150円のリンゴを買う関数を作りましょう。
こんどはreturnではなく1秒後にコールバックでおつりを受け取ります:

//150円のりんごを1つ買う関数
//第一引数に支払い金額
//第二引数にコールバック関数
//おつりを計算してコールバック関数に渡す
var asyncBuyApple = function(payment, callback){
  setTimeout(function(){
    if(payment >= 150){
      callback(payment-150, null);
    }else{
      callback(null, '金額が足りません。');
    }
  }, 1000);
}

念のため補足しておくと、callbackは引数なので好きな名前をつけて構いません。
では、この関数を使って500円でりんごをひとつ買ってみましょう。

//りんごをひとつだけ買う場合
asyncBuyApple(500, function(change, error){
  if(change !== null){
    console.log('おつりは' + change + '円です。');
  }
  if(error !== null){
    console.log('エラーが発生しました:' + error);
  }
});
console.log('500円払いました。');

コールバックには、おつりやエラーをログに表示する無名関数を渡しています。
するとログはこうなります:

500円払いました。
おつりは350円です。

console.log('500円払いました。')を記述している場所に注意して下さい。

非同期処理を複数

コールバックを使った処理は、複数連なると途端に煩雑になります。
先ほどのコールバックでおつりを受け取る関数を使って、りんごを4つ続けて買ってみましょう:

//りんごをたくさん買う場合(コールバック地獄)
asyncBuyApple(500, function(change, error){
  if(change !== null){
    console.log('1回目のおつりは' + change + '円です。');
    asyncBuyApple(change, function(change, error){
      if(change !== null){
        console.log('2回目のおつりは' + change + '円です。');

        asyncBuyApple(change, function(change, error){
          if(change !== null){
            console.log('3回目のおつりは' + change + '円です。');
          }
          if(error !== null){
            console.log('3回目でエラーが発生しました:' + error);
          }
        });
      }
      if(error !== null){
        console.log('2回目でエラーが発生しました:' + error);
      }
    });
  }
  if(error !== null){
    console.log('1回目でエラーが発生しました:' + error);
  }
});

このように、コールバックを続けて行なうとネストがかなり深くなってしまいます。
これはコールバック地獄と呼ばれ、一般には嫌われています。

非同期処理を行なう関数を作る(Promise版)

コールバック地獄を回避する方法として登場したのがPromiseという仕様です。
PromiseはES6に対応している環境ではデフォルトで使うことができます。
(ES5までしか対応していない環境で使う場合はbluebirdなどのライブラリが必要になります。)
ではPromiseを使っておつりを受け取る関数を作るとこうなります:

var promiseBuyApple = function(payment){
  return new Promise(function(resolve, reject){
    if(payment >= 150){
      resolve(payment-150);
    }else{
      reject('金額が足りません。');
    }
  });
}

この関数はPromiseオブジェクトを返します。
一般に、Promiseオブジェクトのコンストラクタに渡す関数には、成功した場合に実行する関数と、失敗した場合に実行する関数を渡します。
この例の場合は、成功した場合にはresolve、失敗した場合にはrejectが実行されます。
ではこの関数を使ってりんごを買ってみましょう:

//りんごをひとつ買う
promiseBuyApple(400).then(function(change){
  console.log('おつりは' + change + '円です');
}).catch(function(error){
  console.log('エラーが発生しました:' + error);
});

成功した場合の処理はthenメソッド、失敗した場合の処理はcatchメソッドに渡します。
これを使ってりんごを4つ続けて買ってみましょう:

//りんごをたくさん買う
promiseBuyApple(400).then(function(change){
  console.log('おつりは' + change + '円です');
  return promiseBuyApple(change);
}).then(function(change){
  console.log('おつりは' + change + '円です');
  return promiseBuyApple(change);
}).then(function(change){
  console.log('おつりは' + change + '円です');
}).catch(function(error){
  console.log('エラーが発生しました:' + error);
});

コールバックを使った場合と比べてかなり完結になったのが分かると思います。
thenメソッドが順に実行され、thenメソッドのコールバック中でreturnされた結果が次のthenメソッドのコールバックに引数として渡されます。
もしおつりが足りなくなればその時点でcatchメソッドが実行されます。
Promiseオブジェクトにはthenとcatch以外にもメソッドがあります。
例えばallメソッドを使えば全て成功したら次の処理へ進むとか、raceメソッドを使えばどれかひとつでも成功したら次の処理とか、そういった処理が可能になります。
詳しくは今更だけどPromise入門が分かりやすいです。

ES6で書き直す(おまけ)

PromiseはES6の仕様なので、ES6でも書いておきます。
アロー演算子が使えるのでシンプルに書けます:

//りんごをひとつ買う
promiseBuyApple(400)
.then(change=>console.log('おつりは' + change + '円です'))
.catch(error=>console.log('エラーが発生しました:' + error));

////りんごをたくさん買う
promiseBuyApple(400).then(change=>{
  console.log('おつりは' + change + '円です');
  return promiseBuyApple(change);
}).then(change=>{
  console.log('おつりは' + change + '円です');
  return promiseBuyApple(change);
}).then(change=>{
  console.log('おつりは' + change + '円です');
}).catch(error=>{
  console.log('エラーが発生しました:' + error);
});

さいごに

誤りがあれば教えて頂けると嬉しいですmm