2021/06/19

[js]非同期とコールバックとPromise

JavaScript で検索するとたくさん出てくる「コールバック Promise」。
この記事もその1つになるのだろう。自分の考えを整理するのに、どうしても文章に書かざるを得ない人たちの一人なので仕方がない。
ちなみに、ブラウザでの動作は考えておらず、Node.js から使うものとして書いている。


JavaScript はシングルスレッドだと言われている。仕様ではここになるのかな?

8.6 Agents
https://262.ecma-international.org/11.0/#sec-agents

An agent comprises a set of ECMAScript execution contexts,
an execution context stack,
a running execution context,
an Agent Record,
and an executing thread.
Except for the executing thread, the constituents of an agent belong exclusively to that agent.

【DeepL翻訳】
エージェントは、ECMAScript の実行コンテキストのセット、実行コンテキストスタック、実行中の実行コンテキスト、エージェントレコード、および実行スレッドから構成されます。実行スレッドを除き、エージェントの構成要素はそのエージェントにのみ属します。

実行しているコンテキストではスタックとかスレッドとかで構成されるけど、全部 a や an が付いているので1つしかないということなんじゃなかろうか。
「single thread」という単語は見つけられなかったのだよ。

解釈は間違ったかもしれんが、とりあえずシングルスレッドであることは信用しよう。


シングルスレッドなのに非同期ってどういうことじゃ、と思ったが、ここは割込みができるシステムと考えればよいと思う。

 

組み込み開発ではよくあることだが、CPU は実行状態として「通常モード」と「特権モード」のようなモードというか状態というかを持つことがある。電源を入れて起動直後は特権モードで、その状態じゃないとできない設定をいろいろやってから通常モードになって動き始める、みたいな感じだ。

通常モードで動いているけれども、例えば外部のセンサからINPUTピンに対して信号が来た場合には割り込んで良いようにする、という設定にしておくと、信号が来たら現在の状態をスタックに保存してから特権モードに切り替わり、特権モードで処理をして終わったらまた通常モードに戻る、というようなことができる。

こういう特権モードになるときは CPUとしてまるまる切り替わるので、スレッドとかそういうのはない。そして特権モードでの動作は通常モードとは異なるのでコンテキストが異なる。すなわち非同期だ。

 

私の場合は JavaScript の非同期をそんな世界観で眺めている。


非同期なところまではよいのだが、非同期で処理を何かしたいことがほとんどだろう。 setTimeout() みたいな遅延動作なんかはわかりやすい例で、何か動作を遅延させてから実行したいのだ。

しかし、実行し始めたコンテキストとは別のコンテキストで動作するので、素直に続きを行うわけにはいかない。コンソールにログを出す、みたいな影響を与えないものであれば気にしなくてよいのだが、変数に値を代入したりファイルに書き込んだりしたいことが多かろう。

グローバル変数みたいにスコープが広い変数であれば代入できると思うが、ローカル変数に戻したいという場合もあろう。そういうときは実行したい単位を関数にしてしまい、Promise<戻り値>を戻すようにして、asyncを付けておくと、呼び出し元はその関数を await つけて呼び出せば

 

例えば、このコードをそのまま実行すると、setTimeout()したログは一番最後に出力される。

01: console.log('hello');
02: setTimeout(() => { console.log('meme'); }, 1000);
03: console.log('world');

hello
world
meme

 

setTimeout()の後にworldを出したいだけなら、こんな感じか。
ちなみに TypeScript で書いていて、 tsconfig.json の "target" は "es2015" にしている。 tsc --init で作られたそのままだと怒られてダメだったのだよね。
vscode で実行していたのでデバッグ設定をしていたのだが、"Launch Program" で実行しても console.log の出力がどこにも出てこなくて悩んだ。実行するとタブが "TERMINAL" になるので、急いで "DEBUG CONSOLE"にすると出てきた。

01: async function setTimeoutPromise(msec: number): Promise<void> {
02:     return new Promise<void>((resolve: any, reject: any) => {
03:         setTimeout(() => { resolve();}, msec);
04:     });
05: }
06: 
07: const fn = async () => {
08:     console.log('hello');
09:     await setTimeoutPromise(1000);
10:     console.log('world');
11: };
12: fn();

なんかめんどくさいね。
await を使いたいがために async の関数が2つ必要になってしまった。

 

それはともかく、setTimeout() の第1引数は関数を受け取るタイプで、指定した時間後にこの関数を呼んでくれる。つまりコールバックだ。
これを呼び出し元で同期に見せかけたいので、Promise を使ってコールバックで resolve() を呼び出すようにしている。

 

自分で作った関数だけで非同期が発生することはなくて、そこからsetTimeout() なり npm でインストールしたライブラリなり、なんらかの外部に依存する場合しか非同期になることはないだろう。そして JavaScript で提供された関数を呼ばないで済むことはまずないので(たぶん変数の参照や代入だけか?)、前提条件はあまり考えなくて良いだろう。

 

非同期の基本的な処理完了通知はコールバックのはず。ただ、組み込み CPUでの割り込み処理で特権モードになったままになるのではなく、Node.js のコンテキストに戻されるはずだから、pthread の join で待って、戻ってきたらコールバック関数を呼び出す、みたいなことをやっているのか。

Promiseはその先の話で、いろいろ書き方はあるのだろうが、

  1. Promise を new するときにコールバックで結果を返す関数を最後に呼び出す関数を登録する
    1. 登録する関数の引数は (resolve, reject) にするのが一般的。
    2. コールバックが呼ばれるときに resolve() を呼び出す(失敗だったら reject() を呼び出す)。
    3. コールバックで結果を得てそれを次も使いたい場合は resolve() の引数にする。
  2. promise.then() を呼び出して、引数は resolve() の中身を実装するようなイメージの関数にする。
01: function setTimeoutPromise(message: string, msec: number): Promise<string> {
02:     return new Promise<string>((resolve: any, reject: any) => {
03:         setTimeout(() => { resolve(message);}, msec);
04:     });
05: }
06: 
07: console.log('hello');
08: setTimeoutPromise('world', 1000).then((msg: string) => {
09:     console.log(msg);
10: });

setTimeoutPromise()の引数をコールバック関数内の resolve()に渡せているのが面白いところだ。全然コンテキストが異なるならアクセスできないはずなので、うまいことやってるのだろう。深く探る必要もあるまい。

そして setTimeoutPromise() に async をつけて、呼び出すときに await をつけると、resolve() の引数がそのまま戻り値として使えるようになる。つまり同期になる。

 

なので、もうあまり難しいことは覚えずに async - await だけ覚えておけばいいかな、と思っていたのだ。


しかし、パフォーマンスを出さないといけないようになると、全部同期で済ませるのはもったいないこともある。非同期になる理由が「忙しいから」であれば待っていても良いのだが、「待ち時間が発生するため」の場合には、その間に他のことをやってしまいたいからだ。

 

まず、Promise.all()というものがある。

Promise.all() - JavaScript | MDN
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Promise/all

Promiseのインスタンスを配列で受け取って、then でも配列で結果を返している。面白い。
これを async - await で書くとこんな感じか。

01: const promise1 = Promise.resolve(3);
02: const promise2 = 42;
03: const promise3 = new Promise((resolve, reject) => {
04:     setTimeout(resolve, 100, 'foo');
05: });
06: 
07: const fn = async () => {
08:     const result = await Promise.all([promise1, promise2, promise3]);
09:     console.log(result);
10: }
11: 
12: fn();
13: 

all()以外にもいろいろある

順番にやっていかないといけないものは async - await でつなげれば良いし、特に順番はどうでもよいなら Promise.all() でまとめてしまえばよい。本当にどうでもよいならコールバック関数が呼ばれておしまい、でよいのだが、そうもいかんことが多かろう。

 

これでなんとかなるかと思っているのだが、どうなんだろうね。苦労してみないと分からんのだろう。

0 件のコメント:

コメントを投稿

コメントありがとうございます。
スパムかもしれない、と私が思ったら、
申し訳ないですが勝手に削除することもあります。

注: コメントを投稿できるのは、このブログのメンバーだけです。