JavaScript
Node.js
非同期処理

非同期処理ってどういうこと?JavaScriptで一から学ぶ

More than 1 year has passed since last update.

非同期処理ってよく聞くけどどういうことなのかいまいち分かっていなかったので、JavaScriptを題材に勉強がてら基本的なところをまとめます。

非同期処理ってなに?

プログラムって実行すると、コードを上から順に1行ずつ実行していきますね。
その処理の1つに時間のかかる処理があると、その実行が完了するまで、次の行には進みません。

例えば次のようなコード。実行環境はNode.js (v5.3.0) です。

処理の途中で5秒間sleepします。

sync_sleep.js
console.log("start");

function sleep(milliSeconds) {
  var startTime = new Date().getTime();
  while (new Date().getTime() < startTime + milliSeconds);

  console.log("sleepが完了しました。");
}

sleep(5000); // 実行するのに5秒かかる

console.log("end");

実行してみます。

$ node sync_sleep.js
start
sleepが完了しました。
end

上から下に向かって、順に実行されました。まー特に違和感はないというか、なにを当たり前のことを言っているんだという感じ。

非同期処理

ですが、例えばサーバーと通信を行った際に、リクエストが返ってくるまでに数秒以上もかかると困ります。

そこで、「ある関数が呼び出されたとき、戻り値として本来渡したい結果を返すのではなく、一度関数としては終了し(=呼び出し元に戻る)、後で『本来渡したかった値』を返せる状態になったときに、呼び出し元にその値を通知する」という仕組みが考え出されました。
このような仕組みを非同期処理といい、なにかしらの処理が完了したら渡したfunctionを実行させるという関数をコールバック関数といいます。

たとえば、ファイルを読み取って、その内容を出力する処理を行います。
まずテキストファイルを用意します。

number.txt
1
2

次にそのファイル内容を読み取って出力してみます。

async_readfile.js
console.log("start");

var fs = require('fs');
fs.readFile("number.txt", "utf-8", function(err, data){
  if (err) throw err;
  console.log("ファイルの読み取り準備ができました。");
  console.log(data);
});

console.log("end");

では実行。

$ node async_readfile.js
start
end
ファイルの読み取り準備ができました。
1
2

なんと、endが先に出力されました。
まずasync_readfile.jsに記載されたコードが実行されます。その際、fs.readFileは非同期型関数として実装されており、呼び出されている関数は一度終了し、中の処理が完了した段階で呼び出し元に通知します。
結果、endが先に出力されたのです。
これが非同期処理です。

Node.jsについて

ちょっと脱線しますけど、非同期処理ということでNode.jsについて軽くご紹介を。

  • サーバーサイドJavaScript実行環境である。
  • コマンドラインツールである。
  • ターミナルで'node my_app.js'と打つことで、JavaScriptプログラムを実行できる。
  • JavaScriptは「V8 javascript engine」(Google Chromeで使用されているもの)で実行される。
  • NodeはネットワークとファイルシステムにアクセスするためのJavaScript APIを提供する。

Wikiによると

Node.jsはサーバーサイドJavaScript実行環境です。Node.jsはイベント駆動の非同期I/Oモデルを採用することで、高いパフォーマンスを発揮します。

どういうことか、もうちょっとかみ砕いてみます。

アパッチなどで採用されているマルチスレッドモデルの難点
  • スレッド毎のメモリ領域の圧迫
  • I/O待ち
上記問題を解決するためのNode.jsのアプローチ
  • コードをひとつのメインスレッドで実行する → スレッド毎のメモリ領域の圧迫の回避
  • I/O処理を非同期に実装し、イベントトリガーのコールバックで処理の順序性を保つ → I/O待ちの低減

ということをすることで、高いパフォーマンスを発揮できるようにしています(ただし適しているケース、不向きなケースというのはもちろんあります)。

上のasync_readfile.jsの例だと、async_readfile.jsに記載されたコードはメインスレッドで実行されます。で、ファイルの読み出しが可能になったというイベントの発生をトリガーに、fs.readFileの第三引数に指定された関数(コールバック関数ですね)が実行されます。
「ファイルを読みに行く」という処理をI/O専門のワーカースレッド(バックグラウンドでタスクを処理するスレッド)に任せているわけですね。

ポイントは 「メインスレッドがI/O待ちを行っていない事」。処理コストの高いI/O処理をワーカーに任せている間、メインスレッドは別の仕事を行う事ができます。これにより、リソースを効率的に使う事ができます。

コールバックの問題点

Node.jsに話がそれてしまったので、そろそろ話を戻します。

さて、コールバックを使って非同期処理を書くのは、単純なケースであればシンプルで分かりやすいのですが、複雑な処理になると、とたんに分かりにくくなるという問題があります。

一般的に書くと下記のようになります。コールバックの第1引数にErrorオブジェクト、第2引数に得られた結果を渡すのはnodeの慣習なので、今回はスキップします。

nest_callback.js
function sleep(callback) {
  setTimeout(function() {
    callback(null, '現在時刻:' + new Date());
  }, 1000);
}

sleep(function(err, res) {
  console.log(res);
  sleep(function(err, res) {
    console.log(res);
    sleep(function(err, res) {
      console.log(res);
    });
  });
});

このように何の工夫もせずに書くと、関数の入れ子が積み重なっていき、コードがどんどん読みにくくなっていきます。

同期的に書こう

これを読みやすくするために、同期的に書こうよというのがこれからのお話です。

同期的に書くとは

  • ネストが入れ子にならない。
  • 上から下に読んで実行フローが把握できる。
  • 非同期処理の結果得られた値を次の処理に引き回せる。
  • try/catchで素直に例外を捕捉できる。

代表的な方法としては、下記が挙げられます。

  • Promiseを使う方法
  • Generatorを使う方法

個人的には、最近のjs非同期処理 PromiseとGeneratorの共存にあるように、

  1. 基本は全てPromiseでくるむ。
  2. 並列処理はPromise.all。
  3. 直列処理は1をさらにGeneratorで包む。

の方針で実装するのが良いのではないかと。この辺どうなんでしょうね。

とりあえず順に見ていきます。

Promise

Promiseの基本的な考え方は「非同期的に動作する関数は、本来返したい戻り値の代わりに『プロミスオブジェクト』という特別なオブジェクトを返しておき、(本来返したい)値を渡せる状態になったら、そのプロミスオブジェクトを通して呼び出し元に値を渡す」というものです。
イメージとしては、食堂に行った時に、先にお金を払って食券をもらい、できあがったら食券と引き換えにご飯を受けとる感じ。

基本の使い方

まずは雰囲気を掴んでいきましょう。

  1. Promiseオブジェクトを作成し、返す。Promiseオブジェクトを作成するには、new Promise(fn)
  2. fnには何らかの処理を書く。
    • 処理結果が正常なら、resolve(結果の値)を呼ぶ。
    • 処理結果がエラーなら、reject(Errorオブジェクト)を呼ぶ。
  3. 1のpromiseオブジェクトに対して.thenで値が返ってきた時のコールバックを設定する。

1秒たったら文字列asyncを返す非同期処理をPromiseで実装します。

promise.js
console.log('start');

function puts(str) {
  // ① Promiseコンストラクタを new して、promiseオブジェクトを返す
  return new Promise(function(resolve) {
    setTimeout(function() {
      resolve(str);
    }, 1000);
  });
}

// ② ①のpromiseオブジェクトに対して .then で値が返ってきた時のコールバックを設定する
puts('async').then(function(result) {
  console.log(result)
});

console.log('end');

実行結果。

$ time node promise.js
start
end
async

node promise.js  0.11s user 0.06s system 14% cpu 1.194 total

最後に文字列asyncが返ってきて、だいたい1秒で処理全体が完了しています。

Promiseの仕組み

ではもう少し深くPromiseの仕組みを見ていきます。

Promiseオブジェクトには状態があります。それは以下の3種類です。

  • Fulfilled: resolve(成功)した時。この時onFulfilledが呼ばれる
  • Rejected: reject(失敗)した時。この時onRejectedが呼ばれる
  • Pending: FulfilledまたはRejectedではない時。つまりpromiseオブジェクトが作成された初期状態等が該当する

最初はPendingになっていますが、完了するとFullfilled、失敗するとRejectedになります。

コンストラクタであるPromiseには、newする時、functionを引数として渡します。

このfunctionには、2つの引数を指定することができます。

  1. 第一引数として指定しているresolve(必須)。このresolveはfunctionで、処理結果が正常だった場合に実行させる。resolveを実行すると、Promiseオブジェクトの状態がfulfilledになる。
  2. 第二引数であるreject(省略可)。同じくfunctionだが、resolveとは逆で、処理結果がエラーであった場合に実行させる。rejectを実行すると、Promiseオブジェクトの状態がrejectedになる。

このようにして、Promiseコンストラクタに渡したfunction内で、Promsieオブジェクトの状態をpendingからfulfilled及びrejectedに変化させます。

今回はgetURLという、GETをするための関数を作ります。
このgetURLでは、取得結果のステータスコードが200の場合のみ取得に成功、それ以外はエラーにしています。

https-promise.js
function getURL(url) {
  // ①Promiseコンストラクタを new して、promiseオブジェクトを返す
  return new Promise(function (resolve, reject) {
    const https = require('https');

    https.get(url, function(res) {
      if (res.statusCode === 200) {
        res.on('data', function(chunk) {
          // ②うまくいった場合の処理を記載する
          resolve(chunk);
        });
      } else {
        // ②うまくいかなかった場合の処理を記載する
        reject(new Error(res.statusCode));
      }
    }).on('error', function(e) {
      reject(new Error(e.message));
    });
  })
}

const URL = "https://www.google.co.jp";
// ③promiseオブジェクトに対して .then で値が返ってきた時のコールバックを設定する
getURL(URL).then(function onFulfilled(value){
  console.log(value);
}).catch(function onRejected(error){
  console.error(error);
});

同期的な処理も混ぜる

Promiseに同期的な処理を混ぜることもできます。

非同期処理trebleで、渡された数値を3倍にし、Promiseオブジェクトを返します。
同期処理dumpで、渡された数値をログに表示し、そのままその数値を返します。

この2つの関数が混ざった処理を見てみます。

treble.js
// 非同期処理: 3倍にする
const treble = function(number) {
  return new Promise(function(resolve) {
    resolve(number * 3);
  });
};

// 同期処理: ログに表示する
const dump = function(number) {
  console.log(number);
  return number;
};

treble(10) // 10 * 3 -> 30
  .then(treble) // 30 * 3 -> 90
  .then(dump)
  .then(treble) // 90 * 3 -> 270
  .then(dump);

実行結果。

$ node treble.js
90
270

dumpのように、onFulfilledハンドラ内でPromiseオブジェクト以外が返された場合、新しくPromiseオブジェクトが作成され、返した値ですぐにresolveされます。
この仕組みのおかげで、Promiseを用いて同期処理と非同期処理を混ぜて記載することができます。

並列処理

非同期処理を扱う時に、1つずつ順番に扱うのではなく、複数の処理を並列に走らせたい場合があります。
その場合はpromise.allを使います。

promise_all.js
console.log('start');

function puts(str) {
  return new Promise(function(resolve) {
    setTimeout(function() {
      resolve(str);
    }, 1000);
  });
}


var promises = [
  puts(1),
  puts(2),
  puts(3)
];

// 並列処理に対してPromise.allを使う
Promise.all(promises).then(function(results) {
  console.log(results);
});

console.log('end');

実行結果。

$ time node promise_all.js
start
end
[ 1, 2, 3 ]

node promise_all.js  0.12s user 0.07s system 15% cpu 1.196 total

並列処理なので約1秒で完了しています。

Generator

直列処理を記述するのにGeneratorを使います(並列処理を書くこともできます)。

Generatorは任意の時点で処理を止めてそこから再開することができます。そのため、非同期の処理をいったん止めて、コールバックが終了したら再開するという処理をすることで、直列処理を実現できます。

直列処理

直列処理は色々な書き方があるのですけど、Generatorとcoを使ったやり方がすっきりと書けるので、今回はそのやり方を紹介します。

generator.js
// co をインストールしておく
// $ npm install co
console.log('start');

function puts(str) {
  return new Promise(function(resolve) {
    setTimeout(function() {
      resolve(str);
    }, 1000);
  });
}

const co = require('co')
co(function* () {
  const res1 = yield puts('1');
  console.log(res1);

  const res2 = yield puts('2');
  console.log(res2);

  const res3 = yield puts('3');
  console.log(res3);
});

console.log('end');

実行結果。

time node generator.js
start
end
1
2
3

node generator.js  0.12s user 0.07s system 5% cpu 3.227 total

yieldにPromiseオブジェクトを渡し、コールバックが完了すると、次の処理に進みます。
直列で処理しているので、順番通りに実行され、約3秒で処理が完了しています。

まとめ

  • 非同期処理とは「ある関数が呼び出されたとき、戻り値として本来渡したい結果を返すのではなく、一度関数としては終了し(=呼び出し元に戻る)、後で『本来渡したかった値』を返せる状態になったときに、呼び出し元にその値を通知する」という仕組み。
  • Node.jsではI/O処理を非同期にすることで、I/O待ちを防ぎ、パフォーマンスを向上させている。
  • JavaScriptでの非同期処理を行う際は、下記方針が良さそう。
    1. 基本は全てPromiseでくるむ。
    2. 並列処理はPromise.allでまとめて処理できる。
    3. 直列処理はPromiseをさらにGeneratorとcoで包むと、すっきり書ける。
  • Promiseは「非同期的に動作する関数は、本来返したい戻り値の代わりに『プロミスオブジェクト』という特別なオブジェクトを返しておき、(本来返したい)値を渡せる状態になったら、そのプロミスオブジェクトを通して呼び出し元に値を渡す」という処理を行うことで非同期処理を実現させている。
  • Generatorは任意の時点で処理を止めてそこから再開することができるため、非同期の処理をいったん止めて、コールバックが終了したら再開するという処理をすることで、直列処理を実現できる。

参考