0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

JavaScriptの非同期関数(async function)について part1

Last updated at Posted at 2022-10-08

初めに

もともとSymbol.iteratorSymbol.asyncIteratorの勉強メモをまとめながら調べる資料からgeneratorの非同期関数のキーワードが出てきて、それで初めてThunkcoを触れました。読めば読むほどasync functionの概念をたどり着く前にまずcallbackgeneratorThunk&coasync functionの流れでメモをまとめていきたいと思います。

結論として、

  • callbackは非同期操作のための関数内関数。
  • generatoryieldによりコールバック地獄回避のための手動ジェネレータ。
  • Thunkはコールバック地獄回避のための自動ジェネレータ。
  • coは自動ジェネレータでありながら返り値がPromiseの保証を取り入れた関数。
  • async functionは非同期操作や可読性をさらに向上させるためasyncawaitが導入され、返り値がPromiseの保証をする関数です。

(ブラウザEvent Loopでは非同期関数への処理様々です。例えばChromiumではMacrotaskMicrotaskに分けられて優先度づけられている。ここの非同期関数はあくまでもMicrotaskで処理されるPromiseに指し、Macrotaskでの非同期APIsetTimeout/setIntervalajaxなどとは無関係です。)

coの段階からPromiseの使用が強調されるというのは、ES6Promiseの出現とブラウザ側の処理と関わっているのですが。そしてこのような分け方では大事な中身の書き方が決して決まっていません。callbackではとうぜんPromiseを取り入れても問題ないです。

Thunkcoは外部モジュールとして長い間使われてきましたが、generatorasync functionはJavaScript自体のものとして実装されています。callbackは技法です。

...というわけでこれらの勉強メモをまとめるのが今回のメインです。そこから学んだ概念や知識を自分の言葉でまとめることによって、正しく理解したのを検証したいと思います。

generator function vs. async function

ここのgenerator関数は普通のgeneratorではなく、Promiseを返す非同期generatorを指しています。

以下はfsを例にして非同期generatorasync function両者の書き方を比べたのですが、
function* ()async function ()yieldawaitと置き換えても何もおかしくなく、同じく非同期に動作してくれるのです。

ただasyncawaitのほうが可読性が高くなり、awaityieldより受け入れられるデータタイプが幅広く、Promise以外のStringNumberBooleanは自動的にresolved状態のPromiseとして返すおかげで、async関数は最終的にPromiseを返すのが保証される。

const fs = require('fs');
// note: require() is synchronous

const readFile = function (fileName) {
  return new Promise((resolve, reject) => {
    fs.readFile(fileName, (error, data) => {
      if (error) return reject(error);
      resolve(data);
    });
  });
};

// generator function
const gen = function* () {
  const f1 = yield readFile('/directory/file1');
  const f2 = yield readFile('/directory/file2');
  console.log(f1.toString());
  console.log(f2.toString());
};
// note: generator's pointer always comes one after another,
// so it works synchronously

// async function
const asyncReadFile = async function () {
  const f1 = await readFile('/directory/file1');
  const f2 = await readFile('/directory/file2');
  console.log(f1.toString());
  console.log(f2.toString());
};

requireはモジュール導入の同期操作の一つで、後ではawaitでモジュール導入の非同期操作ではまとめて説明します。)

callbackgenerator

なぜgenerator関数で非同期操作が求められるのか、というのが今回のトピックに触れるとき一番の素朴な疑問です。

それまでにgeneratorはイテレータによりyieldから値を生成させるコルーチン(coroutine)だと認識して、ほかの勉強メモでは何度も練習してきましたが。

JavaScriptの非同期操作や処理だとcallbackPromiseに基づいたasync functionに認識していたので、同期だと思ったジェネレータでは非同期に操作できること、そしてasyncawaitキーワードが生まれるまで一連の発展がわかりませんでした。

なのでまずコールバック地獄を改善するためのgenerator関数までまとめたいです。

// callback ⇒ generator
// callback hell
const request = require('request');
const url = 'https://www.google.com';

request(url, function (error, response) {
  if (!error && response.statusCode === 200) {
    console.log('got it 1');
    request(url, function (error, response) {
      if (!error && response.statusCode === 200) {
        console.log('got it 2');
        request(url, function (error, response) {
          if (!error && response.statusCode === 200) {
            console.log('got it 3');
          }
        })
      }
    })
  }
});

// improve callback hell => generator
const request = require('request');

function* requestGen() {
  function sendRequest(url) {
    request(url, function (error, response) {
      if (!error && response.statusCode === 200) {
        console.log(response.body);

        iterator.next(response.body); // always for the next time
      }
    })
  }
  const url = 'https://www.google.com';

  const r1 = yield sendRequest(url);
  console.log('r1', r1);
  const r2 = yield sendRequest(url);
  console.log('r2', r2);
  const r3 = yield sendRequest(url);
  console.log('r3', r3);
}

const iterator = requestGen();
iterator.next(); // for the first time
// note: tight coupling and low reusability

関数内のcallbackは非同期関数を実現する技法のひとつですが、長くなるとコールバック地獄になりがちです。

generatorのように一番の外側にはgenerator関数、urlのラッパー関数sendRequest(url)request(url, callback)を包んで、generatornext()を通してresponse.bodyを今指しているyieldへ値をアサインする。読み方としては行ったり来たりして理解しづらいかもしれませんが、
function* ()async function ()yieldawaitに置き換えれば読みやすくなると思います。

callback vs. Promise

そしてここで一つの疑問が生まれた。generator関数でのcallbackと、async関数でPromiseとはどこか違いますか?

まず一番の違いはgeneratorcallbackは非同期操作だけど、実質上同期操作です。ちょっと言い方が変かもしれないが。。

例えば上の例ではrequest(url, callback(error, response))自体はurlのリスポンスをゲットしてから第二引数のcallbackが動作する。そしてcallbackの中身ではiterator.next(response.body)、自分の再帰呼び出ししてからゲットしたresponse.bodyr1へ戻す。それからconsole.log('r1', r1);r1を出力、const r2 = yield sendRequest(url);で再びrequest()を行う。
このように順番に動作するというのは、同期関数とはどこが違う?(以前からcallbackを非同期操作と呼ばれる違和感を感じたのはこれです。)あえて同期関数のように書き換えても同じことができるのでは?

// callback
function demo(str, callback) {
  console.log(str);
  callback();
}
demo('I am str', () => {
  console.log('I am callback');
});
// I am str
// I am callback

Promiseは違いますね。前にも少し触れたがEvent Loopでは解決されたPromiseを別のキューMicrotaskに送り、Call stackがクリアしてから順番通りに結果を実行していくんです。(new Promise(function(resolve, reject))が解決状態になるまで同期に動いています。)

// Promise
function PromiseDemo() {
  return new Promise((resolve, reject) => {
    console.log('number in Promise')
    resolve('I am Promise');
  })
}

PromiseDemo().then((result) => {
  console.log(result);
});
console.log('1');
PromiseDemo().then((result) => {
  console.log(result);
});
console.log('2');
// number in Promise
// 1
// number in Promise
// 2
// I am Promise
// I am Promise

JavaScriptはsingle-threadedなので、細かく言えばcallbackPromiseも一つのスレッドで優先度が違うだけで同期に動作するしか見えないかもしれないけれど、解決されたPromiseはほかのキューに移すというのはスレッドをブロッキングしない遮断しない非同期操作に近い動きを実現してくれました。

ブラウザ側ではほかのタスクキューまたスレッドも存在する、JavaScriptエンジンはその中の一つのスレッドしか過ぎませんが、JavaScriptエンジンを通してスレッドたちへ指示を出したり結果(タスク)を処理したりするMulti-threadではないけれどMultifunctionalです。

generatorThunk

Thunk関数の仕組みを勉強してみました。簡潔に言うと(上の例のような)generatorの結合度を下げ再利用を図るための解決策です。Thunk関数は三つの関数から組み立てています。requestの例にしてみれば、

Thunk:第一階層引数は関数、第二階層はURL、第三階層はコールバック、返り値は第一の関数がcallメソッドを通して第二と第三の引数を利用した結果を返す。

run:ジェネレータ関数を受け入れ、カスタムしたnextメソッドが二回目からの呼び出しresult.value(next)ではThunkcallback関数(=request(url, callback)callback)として作用し、requestresponsenext(error, data)dataになり、gen.next(data)の引数dataとして、ジェネレータ関数のyieldを通じて結果を転送する。
done: trueになるまで何度もカスタムnextの再帰呼び出しと、カスタムnext内のgen.next(data)を経由してrequestresponseを回送する。

generatorThunk関数の応用をし結果を受け取ったり、それからの操作をしたりもする。

(以下はデモコードです。最新バージョンではない。)

// improve generator tight coupling problem => Thunk function
function Thunk(fn) { // fn => request
  return function (...args) {
    return function (callback) {
      // fn.call => request.call(), args => url
      // callback => (first time)request's callback => (second time)custom next()
      return fn.call(this, ...args, callback);
    }
  }
}

function run(fn) {
  let gen = fn(); // run generator

  // custom next method === request's callback, data => response
  function next(error, data) {
    // in the first time, result => gen.next()
    // when we call gen.next second time forward, data => request's response
    let result = gen.next(data);

    if (result.done) return;
    
    // result.value => return function(callback) { return request.call(this, url, callback) }
    // result.value(callback) => custom next()
    result.value(next);
  }

  next();
}

const request = require('request');
const requestThunk = Thunk(request);

// generator
function* requestGen() {
  const url = 'https://www.google.com';
  let r1 = yield requestThunk(url);
  console.log(r1.body);
  let r2 = yield requestThunk(url);
  console.log(r2.body);
  let r3 = yield requestThunk(url);
  console.log(r3.body);
}

run(requestGen);
/**
 * generator.next() always send value back to
 * yield first, then execute method.
 */

co

coの引数はジェネレータ、返り値はPromiseオブジェクトです。
内部コードyieldThunk関数をサポートするけどcallbackをいれるThunkしか受け入れられないので、fnargsがすでにラッピングしておかねばならない。

function Thunk(fn) {
  return function (...args) {
    return function (callback) {
      return fn.call(this, ...args, callback);
    }
  }
}

const request = require('request');
const requestThunk = Thunk(request); // fn => request

const googleRequest = requestThunk('https://www.google.com'); // args => url
const co = require('co');
co(function* () {
  const r1 = yield googleRequest; // run => googleRequest(callback)
  const r2 = yield googleRequest;
  const r3 = yield googleRequest;

  return {
    r1,
    r2,
    r3
  }
}).then((response) => {
  console.log(response);
}).catch((error) => {
  console.log(error);
})

coではyieldThunkcallback)かPromiseを受け入れるが、未来サポートしない可能性があるのでPromiseのほうがおすすめです。

//
const co = require('co');
co(function* () {
  const url = 'https://www.google.com';
  const r1 = yield fetch(url);
  const r2 = yield fetch(url);
  const r3 = yield fetch(url);

  return {
    r1,
    r2,
    r3
  }
}).then((response) => {
  console.log(response);
}).catch((error) => {
  console.log(error);
});

about co module

  • 引数はgenerator
  • yieldにはThunkPromiseが受け入れられる。
  • 返り値がPromiseを保証する。

// co module simplifies generator without calling next(),
// and it returns Promise object which can use callback in Promise chain
let co = require('co');
co(gen).then(function () {
  console.log('Generator is completed')
});

下は参考文章から取ったcoの内部を再現するデモコードです。(Promiseに基づいたデモコードです。本番のソースコードは下のco module source codeにあります。)

coは最終的にPromiseを返すのがポイントです。Thunkcallbackから貰った返り値、Promiseは解決されたPromiseです。yieldにはPromiseともかく、なぜThunkも受け入れられるでしょうか。

このデモコードではfscallbackからもらった結果dataresolve()へ、つまりThunkの返り値でも解決されたPromiseにすれば最終的にPromiseを返すのが保証される。

// internal implementation
const fs = require('fs');

const readFile = function (fileName) {
  return new Promise((resolve, reject) => {
    fs.readFile(fileName, (error, data) => {
      if (error) return reject(error);
      resolve(data);
    });
  });
};

const gen = function* () {
  const f1 = yield readFile('/directory/file1');
  const f2 = yield readFile('/directory/file2');
  console.log(f1.toString());
  console.log(f2.toString());
};
// note: yield is a two-way syntax

coにはもうひとつのポイントがあります。それはgeneratornext()を自動的に繰り返すことです。今のcoPromiseを保証するのだからPromise chainが利用できます。

手動なら、コールバック地獄のようにならないけれど長くなると読みづらくなります。Thunkのようにcallbackdone: trueになるまで自分(custom next)を呼び出しすれば自動的に繰り返してくれます。

// manually run
let g = gen();
// value was Promise object
// first, get file1 and put value into callback
g.next().value.then(function (data) {
  // second, send file1 back to f1, then get file2 and put into callback
  g.next(data).value.then(function (data) {
    // finally, send file2 back to f2
    g.next(data);
  });
});

// automatically run
function run(gen) {
  const g = gen();

  function next(data) {
    // for the first time we call next(), there doesn't have "data", that means we runs "g.next()"
    let result = g.next(data);
    // if all the file loading were done, it returned value back finally,
    if (result.done) return result.value;
    // if not, we use Promise chain to send value back and  call next() again until all the files were done
    result.value.then(function (data) {
      next(data);
    });
  }
  next();
}
// note: we can use next() to point to where yield is,
// and send value back to yield by next() at the same time
run(gen);

(自分の感覚ではcoThunk関数の改善バージョン、Thunkは手動generatorの改善バージョンです。)

generatorを核心概念として、
Thunkcallback返り値のためのラッパー、
cocallback返り値がPromiseを保証するラッパー、
runcustom nextgeneratorを自動的に動くための再帰呼び出しメソッドです。

co module source code

勉強のために一部のコードを取って概念を説明したいと思います。
(以下はデモコードです。ネーミング変更あり。)

// co module source code
function co(gen) {
  let contextOfCo = this;
  return new Promise((resolve, reject) => {
    // check gen if generator(function)
    // if it is, change generator's context with co function
    if (typeof gen === 'function') gen = gen.call(contextOfCo);

    // avoiding gen is null/undefined or it didn't have next function(not generator), return and suspend
    if (!gen || typeof gen.next !== 'function') return resolve(gen);

    onFulfilled();
    function onFulfilled(resolve) {
      let result;
      // let generator run automatically
      try {
        result = gen.next(resolve);
      } catch (e) {
        return reject(e);
      }
      next(result);
    }
  });
}

function next(result) {
  // first: check generator state
  if (result.done) return resolve(result.value);
  // second: ensure return value is Promise object
  // invoke external function: toPromise()
  let value = toPromise.call(contextOfCo, result.value);
  // third: if value is Promise object, use Promise chain to call onFulfilled function again
  // invoke external function: isPromise()
  if (value && isPromise(value)) return value.then(onFulfilled, onRejected);
  // forth: if value is not a function, Promise, generator, array, or object
  return onRejected(
    new TypeError(
      'You may only yield a function, promise, generator, array, or object, '
      + 'but the following object was passed: "'
      + String(result.value)
      + '"'
    )
  );
}

Thunk関数の概念が分かったらcoはそれに基づいてPromiseの検証や保証をいれた関数です。coではデータタイプ幅広く受け入れるためには返り値Promiseの保証を細かく施しています。

ソースコードを覗いてみればtoPromise()では、thunkToPromise()arrayToPromise()objectToPromise()などで転換作業して、とてもよく考えられている工夫されているモジュールだと分かります。

下は使い方のデモコードです。

// usage
// let actions execute asynchronously
// when all of them are completed, it will continue to next step
// array
co(function* () {
  let resolve = yield [
    Promise.resolve(1),
    Promise.resolve(2)
  ];
  console.log(resolve);
}).catch(onerror);
// object
co(function* () {
  let resolve = yield {
    1: Promise.resolve(1),
    2: Promise.resolve(2)
  };
  console.log(resolve);
}).catch(onerror);

co(function* () {
  let values = [n1, n2, n3];
  yield values.map(somethingAsync);
});

function* somethingAsync(x) {
  // do something async
  return y
}
// note: the argument in map() works simultaneously

ここからはasync functionに入りますが、後から読めば文章が長すぎて読みづらくなるのでasync functionの部分はpart2に続きます。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?