Posts 非同期はなかなか手強い(特にループが絡むと!)
Post
Cancel

非同期はなかなか手強い(特にループが絡むと!)

前提

  • JavaScriptの場合、ブラウザで簡単に確認できますが、それはそれで結構面倒だったりもします。なので、今回はNodeを利用して確認を取っています。
    $ node -v
    v4.2.0
    
  • Angularjs等ではpromiseやdeferredを返すメソッドがあったりしますが、より一般的にするために、Promiseを利用しています。ECMAScript 6なので、まだまだブラウザベースでは時期尚早かもしれません。https://kangax.github.io/compat-table/es6/あたりを見ると、Edgeは使えそうですが、IE11はまだのようです(2016年6月現在)。
  • 非同期を扱ったものになっていますが、特に、ループにからんだものとなっています。
  • 筆者もPromiseについては苦労しているので、もしかしたら、時々ウソを書いているかもしれません。そんな場合はごめんなさい
  • Promiseを少しは知らないと、意味が良くわからないかもしれません。→JavaScript Promiseの本あたりは勉強になります!

これは想定外!?

もちろん、非同期のプログラムをがんがんやっている人には常識的なんでしょうが、JavaやRuby等では非同期なんて出会わなかったので、次のプログラムを実行したとき(実際には似ているプログラム)筆者には「???」でした。

これは、ファイルの内容を読み取る「fs.readFile」という非同期なメソッドを100回実行しているわけですが、その際のcallbackconsole.log(i)を仕込んでいます。1

1
2
3
4
5
6
7
8
var fs = require('fs');
for(var i = 1; i <= 100; i++) {
    // 非同期のreadFileを実行(読み取った内容は捨てているけど...)
    fs.readFile("tmp.js", "utf-8", function(err, data) {
        if(err) throw err;
        console.log(i);
    });
}

実際には100回、全て同じ「101」がコンソールに表示されます

筆者的には「ええっ〜」って感じでした。

1
2
3
4
5
6
7
101
101
101
101
101
101
...

さて、どうしてそんなことになるのかというと、以下のようなことです。

  1. fs.readFileは非同期なメソッドなので(nodejsに標準にあるメソッド)、実行したら終わるのを待たずに次のループになり、まだ実行したら(待たずに)・・・と続きます。
  2. 1の結果、fs.readFileが1つも終わらないうちに、ループが終わってしまいます。
  3. そして、iはこのcallbackの中からも参照できるので、ループが終わった後にようやく終了したfs.readFileメソッドのcallbackの中でiを参照したときには既に101になっているので101が出力されます(100でないのは、forループを抜けるときには101になっているからです)。

即実行関数

しかーし、もちろん、このiを使って何かしたい場合もあります(この例でのiということじゃありませんが、forEachなどを使って、そのキーを利用するというような場合です)。

さて、どうすれば良いでしょうか?これもJavaScript特有の即実行関数を使うと可能になります。

1
2
3
4
5
6
7
8
9
10
var fs = require('fs');
for(var i = 1; i <= 100; i++) {
    (function(i) {
        // 非同期のreadFileを実行(読み取った内容は捨てているけど...)
        fs.readFile("tmp.js", "utf-8", function(err, data) {
            if(err) throw err;
            console.log(i);
        });
    })(i);
}

これは3行目から無名関数の定義を行い、9行目で実行しています。その際iを実引数にして、メソッドの仮引数iとして受け取っています。

この場合、fs.readFileの実行時間にバラツキがあるため、1〜100まできれいには並びませんが、先ほどのようにずっと101というのはなくなります。結果は以下のようです。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
2
4
1
3
5
6
7
8
9
10
11
12
13
14
15
...

ちなみに、仮引数(↑の無名関数内)のiは、関数の中なので外側のiとは無関係です(名前は同じでも)。さすがのJavaScriptも関数スコープだけはあるんですが、これにより、ループ内で実行される即実行関数毎に別のスコープなので、渡されたときのiがずっと生きている、というわけです。

しかし、これを100%順番通りやりたいだとか、順番通りじゃなくても、全てが終わった後で、別の作業をしたいような場合はどうすると良いでしょうか?

then句でつなぐのは面倒

ここで、ファイルを読み込むのではなく、書き込むものにメソッドを用意し、これらのメソッドが実行したら、最後に、書き込んだファイルサイズのトータルを出力するものです。。

まとめると、ポイントは次のようです。

  1. writeFileというメソッドを作成する。
    1. このメソッドはPromiseをnewして返す。
    2. このメソッドの中で、非同期のfs.writeFilefs.statを使って、ファイルを書き出し、そのファイルサイズを読み取ります。
    3. Bはfs.writeFilefs.statという順番に行われないとダメなので、fs.writeFileのcallbackの中でfs.statを実行しています。
    4. ファイルのサイズの合計をしたら、その合計値を引数にfulfill(ここではresolve)しています。
  2. 1の関数をthenのチェーンで繋げていきます。
  3. 最後にトータルを出力します。

実際のコードは以下のようです。

>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
var fs = require('fs');

function writeFile(num, total) {
    return new Promise(function(resolve, reject) {
        var data = "";
        // 引数の数字分のaを内容として書き出す(つまりその数分のバイトのファイルができる)
        for(var i = 0; i < num; i++) {
            data += "a";
        }
        fs.writeFile(num + ".txt", data, function(err) {
            fs.stat(num + ".txt", function(err, fstat) {
                resolve(fstat.size + total);
            });
        });
    });
}

writeFile(1, 0).then(function(size) {
    return writeFile(2, size);
}).then(function(size) {
    return writeFile(3, size);
}).then(function(size) {
    return writeFile(4, size);
}).then(function(size) {
    return writeFile(5, size);
}).then(function(size) {
    return writeFile(6, size);
}).then(function(size) {
    return writeFile(7, size);
}).then(function(size) {
    return writeFile(8, size);
}).then(function(size) {
    return writeFile(9, size);
}).then(function(size) {
    return writeFile(10, size);
}).then(function(size) {
    console.log(size);
});

この方法は、実行する数が少ないとか、或いは数が一定である場合は有効だと思います。ただ、実際には実行回数が特定じゃないこともあり、この方法がとれないことがあるわけです(というか、私の場合そうでした)。

余談ですが、上記のthenの中はwriteFileを実行しながらreturnしていますが、この変が意外にポイントです。意味がよくわからないような場合は、このページの最後の「ちょっとした書き方で結果が違うので注意が必要」で簡単に説明してありますが、もともとのhttps://pouchdb.com/2015/05/18/we-have-a-problem-with-promises.htmlあたりもよく読んでおく必要がありそうです。

ちなみに、上記のソースは次のように書き換えることができます。

>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
var fs = require('fs');

function writeFile(result) {
    if(!result) {
        result = {
            size: 0,
            num: 1
        };
    }
    return new Promise(function(resolve, reject) {
        var data = "";
        // 引数の数字分のaを内容として書き出す(つまりその数分のバイトのファイルができる)
        for(var i = 0; i < result["num"]; i++) {
            data += "a";
        }
        fs.writeFile(result["num"] + ".txt", data, function(err) {
            fs.stat(result["num"] + ".txt", function(err, fstat) {
                resolve({
                    size: fstat.size + result["size"],
                    num: result["num"] + 1
                });
            });
        });
    });
}
var promise = Promise.resolve();
promise.then(writeFile)
   .then(writeFile)
   .then(writeFile)
   .then(writeFile)
   .then(writeFile)
   .then(writeFile)
   .then(writeFile)
   .then(writeFile)
   .then(writeFile)
   .then(writeFile)
   .then(function(result) {
    console.log(result["size"]);
   });

Promise.allを使ってみる

メソッドの実行の終わりが、特に次のメソッドの終わりとで、順番的にかならず前のが終わってから次のが実行されないとダメだという分けではない場合、Promise.allが簡単です。

次に示すプログラムのポイントは次のようです。

  1. writeFileメソッドは、基本あまり変わっていませがん、引数が100までインクリメントする数字になっています。
    1. その数のファイル名(1.txtのようなもの)にその数分の「a」を書き込みます。
    2. 書き出したファイルのサイズでresolveします(この値を利用していません)。
  2. 1を実行するとPromiseを返すので、それをpromisesという配列に入れていきます。
  3. そして、ループを抜けたら、そのpromisesという配列に対して、Promise.allを実行します。
  4. 3で1つにまとめられたPromiseオブジェクトに対して、thenを行い(つまり、全てが終わったら)、このプログラム内でどこからでも参照できるsizeTotalを出力します。出力するときに、まだファイルの書き出しが終了していないと、おかしくなりますが、今回はそのようなことはありません。
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
var fs = require('fs');
var sizeTotal = 0;

function writeFile(num) {
    return new Promise(function(resolve, reject) {
        var data = "";
        for(var i = 0; i < num; i++) {
            data += "a";
        }
        fs.writeFile(num + ".txt", data, function(err) {
            fs.stat(num + ".txt", function(err, fstat) {
                sizeTotal += fstat.size;
                resolve(fstat.size);
            });
        });
    });
}

var promises = [];
for(var i = 1; i <= 100; i++) {
    (function(i) {
        promises.push(writeFile(i));
    })(i);
}

Promise.all(promises).then(function(arr) {
    console.log(sizeTotal);
});

順番までコントロールしようとすると

ループを使いながら、しかも、順番までコントロールしようとすると(私には)難しかったですが、次のような方法で実現しました(突っ込みどころ満載かもしれませんが・汗)。

以下のコードのポイントは次のようです。

  1. 書き込んだファイルのトータルは、前にやったsizeTotalに値を入れていく方法で実装しています。
  2. まずは、var promise = Promise.resolve();で、すでに解決済みのpromiseを作成してから、ループを回します。
  3. writeFileは、promiseを返すので、最初だけ2で用意されたpromiseでthenが実行されますが、その後はwriteFileが返すpromiseのthenを行うことになります。
  4. ループを抜けると、promiseが参照できるので、これを使って、ファイルサイズの合計を出力しています。
  5. これらにより、writeFile内で出力されているconsole.log(to)が順番通りになり、最後のファイルサイズの合計が5050になっています。

(ふう、これにたどり着くのにどれくらいかかったか・・・涙)

>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
var fs = require('fs');
var sizeTotal = 0;

function writeFile(to) {
    return new Promise(function(fulfill, reject) {
        var data = "";
        for(var i = 0; i < to; i++) {
            data += "a";
        }
        fs.writeFile(to + ".txt", data, function(err) {
            if(err) {
                reject(err);
            } else {
                fs.stat(to + ".txt", function(err, fstat) {
                    console.log(to);
                    sizeTotal += fstat.size;
                    fulfill("OK");
                });
            }
        });
    });
}

var promise = Promise.resolve();
for(var i = 1; i <= 100; i++) {
    (function(i) {
        promise = promise.then(function() {
            return writeFile(i);
        });
    })(i);
}

promise.then(function() {
    console.log(sizeTotal);
});

ちょっとした書き方で結果が違うので注意が必要

さて、若干↑の方でちょっぴり触れましたが、そもそもthenの中にどんなものを書けば良いのか、という問題を最後に書きます。

https://pouchdb.com/2015/05/18/we-have-a-problem-with-promises.htmlの最後にある、パズルの答えですが、これが参考になるので、僕なりの解釈を書いておきます。

登場人物は次の3つのメソッドです。最初の2つは、promiseを返すメソッドで、最後のfinalHandlerは何でもOKな感じです。

  • doSomething
  • doSomethingElse
  • finalHandler

僕なりの解釈では、次のルールを想定すると理解できる感じがしています。ただ、これはEcmaScriptの仕様を読んだ結果じゃなく、結果からそのように解釈すると間違っていない、という程度のものです。

どんなルールか?

  1. thenの引数に指定するのは、通常は関数オブジェクトです。
  2. thenの引数に指定するのは、関数オブジェクトですが、通常能動的にそれを実行するのは関数を引数にしたとは言いません。
  3. thenの引数に指定するのが、関数オブジェクトの能動的に実行しちゃっていても、関数を返す場合は、もちろん問題ない。
  4. thenの引数にした関数オブジェトクトは、自動で実行されるが、その実行される関数はPromiseを返すようにプログラミングしておく必要があります。
  5. 1〜4に反して、thenの引数にしたものが関数でその関数自体がPromiseを返す場合、1〜4と同じ挙動になります。
  6. 1〜4に反して、thenの引数にしたものが関数でその関数自体は何も返さない場合、そのままthenが新しくPromiseをnewして返します(なので、その次の中身は待ちません)。
  7. thenメソッドの引数が関数でない場合(関数を実行しているものも含む)、そもそもその中のプログラムをすぐに実行してしまう。thenがチェーンになっている場合、最初のメソッドでPromiseを返し、次のthenの引数が関数でもなく、関数の実行がPromiseを返さない場合は、前のpromiseがそのまま返される。なので次のthenでは最初のpromiseが使われますが、最初のthenは(ちょっと自信がないのですが)、1つめの実行を待つことなく実行されてしまうことになります。

そもそも「promise.then(ここ)」のここに入れるのは関数オブジェクトなので、doSomethingElseやfinalHandlerを

Puzzle #1

1
2
3
doSomething().then(function () {
  return doSomethingElse();
}).then(finalHandler);
1
2
3
4
5
6
doSomethinge
|-----------------|
                  doSomethingElse(undefined)
                  |------------------|
                                     finalHandler(resultOfDoSomethingElse)
                                     |------------------| これは最初のthenの引数が無名関数で、その中で`doSomethingElse()`が実行され、Promiseが返ってきます。つまり↑のルールのの、*5にあたります*。

なので、一応doSomethingElse()doSomethingElsefinalHandlerという順番に実行されます。

Puzzle #2

1
2
3
doSomething().then(function () {
  doSomethingElse();
}).then(finalHandler);
1
2
3
4
5
6
doSomething
|-----------------|
                  doSomethingElse(undefined)
                  |------------------|
                  finalHandler(undefined)
                  |------------------|

最初のthenの中の引数は無名関数で、その中で実行されているdoSomethingElse()はPromiseを返しますが、無名関数自体はなにも返しませんので、最終的には何も返ってきません。

つまり、上記のルールの6で、新しいPromiseが作成されて返されます。つまり、doSomethingElseの終わりを待たず、finalHandlerが実行されます。

Puzzle #3

1
2
doSomething().then(doSomethingElse())
  .then(finalHandler);
1
2
3
4
5
6
doSomething
|-----------------|
doSomethingElse(undefined)
|---------------------------------|
                  finalHandler(resultOfDoSomething)
                  |------------------|

最初のthenの引数は関数じゃなく、doSomethingElse()が返すPromiseになり、上記の7にあたり、doSomethingdoSomethingElseは同時に実行され、finalHandlerは、最初のpromiseに対してthenをするので、

doSomethingElse()finalHandler

doSomethingElse

という感じになります。

Puzzle #4

1
2
doSomething().then(doSomethingElse)
  .then(finalHandler);
1
2
3
4
5
6
doSomething
|-----------------|
                  doSomethingElse(resultOfDoSomething)
                  |------------------|
                                     finalHandler(resultOfDoSomethingElse)
                                     |------------------|

これが上記の1〜4にあたり、これば一番ノーマルな感じがします。

以上。

  1. コールバックとは非同期なメソッドが終わったら、実行されるFunctionのことです。JavaScriptではFunctionもオブジェクトであり、引数に指定することも可能です。ちなみに、サンプルの例では「function(err, data) {…}」の部分がcallbackになっています。 

シェア
#内容発言者

Object.defineProperty〜TypeScriptのDecoratorまで

WindowsでRails、ついでにHeroku

坂井和郎