Carpe Diem

備忘録

混ぜるな危険 〜同期関数と非同期関数〜

概要

Node.jsといえば非同期やcallbackで有名ですが、1つの関数の中で非同期な処理と同期的な処理が混ざってしまうと期待しない挙動になることがあるので注意しましょう、という話です。

環境

  • Node.js 8.9.0

基本的な方針

  • 非同期な関数は全て非同期処理でまとめる
  • 同期的な関数は全て同期的な処理でまとめる

です。

非同期な関数とは

christina04.hatenablog.com

でまとめましたが、

  • Timer(setTimeout, setInterval)
  • I/O event
  • setImmediate
  • process.nextTick
  • Promise

などです。

具体的なコード

console.log('start');

setTimeout(() => {
  console.log('setTimeout')
}, 0);

console.log('end');

こういうやつですね。

結果

非同期処理なのでscriptが完了してから実行されます。

start
end
setTimeout

同期的な関数とは

上記のような非同期ではないケースのことです。

具体的なコード

console.log('start');

function showName(name) {
  console.log(name);
}

showName('foo');

console.log('end');

結果

同期処理なので順に実行されます。

start
foo
end

callbackで書いても同期的なケース

callbackを見ると反射的に非同期が浮かびますが、実際は中身を見てみないと分かりません。 例えば以下はcallbackを使っていますが、結局のところ同期処理なので実行順も書いた通りになります。

console.log('start');

function someCallback(name, callback) {
  callback(name);
}

someCallback('foo', (arg) => {
  console.log(arg);
});

console.log('end');

結果

同期処理なので順に実行されます。

start
foo
end

同期関数と非同期関数が混ざるというのは

例えば以下のようなキャッシュ機能を持つファイル読み込み関数があるとします。
if文でキャッシュの処理を分けています。

const cache = {};

function readFile(fileName, callback) {
    if (cache[filename]) {
        return callback(null, cache[filename])
    }

    fs.readFile(fileName, (err, fileContent) => {
        if (err) return callback(err);
        
        cache[fileName] = fileContent;
        callback(null, fileContent);
    });
}

呼び出す処理は以下の通りです。

function main(){
  readFile('myfile.txt', (err, result) => {
    console.log('file read complete');
  });

  console.log('file read initiated')
}

どんな問題が起きるのか?

先程のコードを実行すると以下の結果になります。

# 1回目
file read initiated
file read complete
# 2回目
file read complete
file read initiated 

つまり実行タイミングによって処理の順番が変わってしまう、という問題が起きます。

どこが原因か?

非同期処理である

fs.readFile(fileName, (err, fileContent) => {
  if (err) return callback(err);
  ...

と、同期処理である

if (cache[filename]) {
  return callback(null, cache[filename])
}

のcallbackが同じように実行されている点です。

どうすれば解決するのか

解決方法ですが、主に2通りあります。

同期関数で揃える

非同期関数であるfs.readFileの代わりにfs.readFileSyncを使うことで、全て同期処理で統一します。

これによって以下のように順番が保証されます。

# 1回目
file read initiated
file read complete
# 2回目
file read initiated
file read complete

非同期関数で揃える

同期関数である

return callback(null, cache[filename])

を以下のようにprocess.nextTick()を使うことで全て非同期にします。

return process.nextTick(() => callback(null, cache[filename]));

これによって以下のように順番が保証されます。

# 1回目
file read initiated
file read complete
# 2回目
file read initiated
file read complete

まとめ

1つの関数の中では非同期な処理と同期的な処理は混ぜないように、という話でした。
特にifの分岐で漏れることがあるので注意してください。

ソース