Melchior

バンクーバーで働くエンジニアの備忘録

Async HooksとAsync Resourcesの導入

概要

この記事はNode.js Advent Calendar 2017 15日目の記事です。

カナダから非同期で失礼します。

Async hooksとはNode.jsの非同期イベントをトレースすることができるネイティブライブラリです。まだ試験段階なのでAPIが変わる可能性がありますが、Async Hooksに入門したので、Async HooksとAsync Resouresの使い方、実際の使用例の考察をまとめました。

Node.jsのバージョンはv9.3.0を使用しています。

Async Hooksの基本的な使い方

まずは下記のコードを見てみます。このコードはAsync Resourcesが1秒ごとに生成され、破棄される様子を見ることができます。

const fs = require('fs');
const util = require('util');
const asyncHooks = require('async_hooks');

const hooks = {
  // Resourceが生成されるときに呼ばれる
  init(asyncId, type, triggerAsyncId, resource) {
    const obj = {
      asyncId,
      type,
      triggerAsyncId,
      resource
    };
    fs.writeSync(1, `init\n${util.format(obj)}\n`);
  },
  // Resourceのcallbackが呼ばれる直前に呼ばれる
  before(asyncId) {
    fs.writeSync(1, `before\t${util.format({ asyncId })}\n`);
  },
  // Resourceのcallbackが終了したときに呼ばれる
  after(asyncId) {
    fs.writeSync(1, `after\t${util.format({ asyncId })}\n`);
  },
  // Resourceが破棄されるときに呼ばれる
  destroy(asyncId) {
    fs.writeSync(1, `destroy\t${util.format({ asyncId })}\n`);
  },
  // Promiseのresolveが呼ばれるタイミングで呼ばれる
  promiseResolve(asyncId) {
    fs.writeSync(1, `promiseResolve\t${util.format({ asyncId })}\n`);
  }
};

// Hookの生成
const asyncHook = asyncHooks.createHook(hooks);

// Hookを有効にする
asyncHook.enable();

// 1秒おきにResourceの生成
(function start() {
  // 実行中のasyncId
  const eid = asyncHooks.executionAsyncId();
  // 呼び出し元のasyncId
  const tid = asyncHooks.triggerAsyncId();
  console.log(`start\teid: ${eid} tid: ${tid}`);
  setTimeout(start, 1000);
})();

// 10秒後にhookを無効にする
setTimeout(() => asyncHook.disable(), 10000);

createHookに任意の関数を渡すことで、非同期イベントの検知が簡単にできるようになります。特別難しい機能は無いので、それぞれの用語については下記の表にまとめました。

async_hooks

用語 説明
createHook Function Hookの作成
executionAsyncId Function 実行中のasyncIdの取得
triggerAsyncId Function 呼び出し元のasyncIdの取得

createHook

用語 説明
init Function Resoureが生成される時に呼ばれる
before Function Resourceがcallbackを呼ぶ直前で呼ばれる
after Function Resoureがcallbackを呼び終えた直後に呼ばれる
destroy Function Resoureが破棄される時に呼ばれる
promiseResolve Function PromiseのResourceでresolveが呼ばれる時に呼ばれる
asyncId number Async Resourceに割り当てられたid
triggetAsyncId number 呼び出し元のasyncId
enable Function Hookの有効化
disable Function Hookの無効化

⚠使用上の注意⚠

createHookの関数内でエラーが発生した際はuncaughtExceptionでキャッチすることはできず、スタックトレースを残してプロセスが終了されます。 またconsole.log, process.stdout.writeは非同期オペレーションなためAsync Resourceが生成されます。Hook内の各関数ではfs.writeSyncを使用してください。

スタックトレースを遡る

何か面白いことできないかなぁと探っていたところ、興味深いコードを見つけたので、これをベースにAsync Resouresの誕生から終焉までの軌跡を表示するようにしてみました。 実際のログはこちらです。コードは以下のとおりです。

const delay = util.promisify(setTimeout);
const DELAY = 1000;
const map = new Map();

function init(asyncId, type, triggerAsyncId, resource) {
  const obj = {
    asyncId,
    type,
    triggerAsyncId,
    resource
  };
  Error.captureStackTrace(obj, init);
  map.set(asyncId, [obj.stack, triggerAsyncId]);
  fs.writeSync(1, `init\n${util.format(obj)}\n`);
}

function destroy(asyncId) {
  fs.writeSync(1, `destroy\tasyncId: ${asyncId}\n`);
  showStackTrace(asyncId);
  map.delete(asyncId);
}

function showStackTrace(asyncId) {
  const array = map.get(asyncId);
  if (!array) {
    return;
  }
  const [stack, tid] = array;
  fs.writeSync(1, `stack: \tasyncId: ${asyncId} triggetAsyncId: ${tid}\n${stack.replace(/(.*)\n/, '')}\n`);
  showStackTrace(tid);
}

asyncHooks.createHook({ init, destroy }).enable();

let promise = delay(DELAY);
for (let i = 0; i < 10; i++) {
  promise = promise.then(() => delay(DELAY));
}

スタックトレースを遡ることができるので、デバッグに使えるかもしれません。

メモリリークの検知

以下のコードはAsync Resousesが生成されたものの、リソースが破棄されない例です。mapのサイズが次第に大きくなっていくことが一目でわかるので、メモリリークの検知の早期発見に使えるかもしれません。

const map = new Map();

function init(asyncId) {
  map.set(asyncId, 1);
  fs.writeSync(1, `init\tasyncId: ${asyncId} mapSize: ${map.size}\n`);
}

function destroy(asyncId) {
  map.delete(asyncId);
  fs.writeSync(1, `destroy\tasyncId: ${asyncId} mapSize: ${map.size}\n`);
}

asyncHooks.createHook({ init, destroy }).enable();

const queue = [];
(function start() {
  new Promise(resolve => queue.push(resolve));
  setTimeout(start, 100);
})();

Async Resourcesの基本的な使い方

AsyncResourceクラスを使用することで、任意の非同期イベントを作成することが可能です。emitBeforeemitAfterはセットなので発火する際には必ず両方を呼ぶ必要があります。

const { AsyncResource } = require('async_hooks');

// 自動的にinitが呼ばれる
const asyncResource = new AsyncResource('Async');

// Callbackを呼ぶ直前に呼ぶ
asyncResource.emitBefore();

// Callbackが終了した直後に呼ぶ
asyncResource.emitAfter();

// AsyncResourceが破棄される時に呼ぶ
asyncResource.emitDestroy();

// AsyncResourceのインスタンスに割り当てられたasyncIdを返す
asyncResource.asyncId();

// 呼び出し元のasyncIdを返す
asyncResource.triggerAsyncId();

Async Resourcesの導入

こちらのBluebirdのPRによると、メモリリークの問題も解決し安定してきたようなので、実際にAigleのライブラリに適用してみようと思います。コミットはこちら

まず、Nodeのバージョンがv9.3.0以上推奨とのことでv9.3.0以上のみを有効にしました。

const node = typeof process !== 'undefined' && process.toString() === '[object process]';
const supportAsyncHook = node && (() => {
  const support = '9.3.0';
  const [smajor, sminor] = support.match(/\d+/g);
  const version = process.versions.node;
  const [vmajor, vminor] = version.match(/\d+/g);
  return vmajor > smajor || vmajor === smajor && vminor >= sminor;
})();

次にconstructorAsyncResourceを生成します。

class Aigle {
  constructor(executor) {
    ...
    this._resource = supportAsyncHook && new AsyncResource('PROMISE');
    ...
  }
}

今回はPromiseライブラリなので、Native Promiseと同じtypeを使用しました。 これでAigleインスタンスが生成されるときにinitが呼ばれるようになります。

次に、onFulfilledまたはonRejectedが呼ばれる直前にemitBeforeを、直後にemitAfterを追加します。 簡単な方法としては、

const original = onFulfilled;
onFulfilled = value => {
  this._resource.emitBefore();
  try {
    return original(value);
  } catch (e) {
    throw e;
  } finally {
    this._resource.emitBefore();
  }
};

以上のように追加するのが簡単ですが、パフォーマンスを考慮して別の関数にしました。callbackが呼ばれる直前・直後に追加すれば問題ありません。

AsyncResourceの問題点としてパフォーマンスが著しく低下するため、デフォルトではAsync Resourcesを無効にしています。有効にする方法は、

Aigle.config({ asyncResource: true });

で使用することができます。

パフォーマンスについては後日調査してまた記事を書こうと思います。

まとめ

Async Hooksを使って非同期イベントのトレースを簡単にすることができるようになりました、デバッグにとても便利そうなライブラリです。 またAsync Resourcesを使うことでカスタムな非同期イベントを生成できますが、パフォーマンスに難がありそうです。Immediateイベントは自動的に発行されるので特別Async Resourcesを使う必要はないのかなぁとも思いましたが、あまり詳しくないのでわかりません。もし詳しい方いたらぜひシェアしていただけたらうれしいです。

今後も最新の情報収集とパフォーマンスの調査を行っていきたいと思います。

ソース