2022年01月27日の日記です


node.jsの2つのイベント処理  2022-01-27 17:16:47  コンピュータ

仕事で node.js を使用しているのだが、思わぬところで引っかかったので記しておこう。


…と、いきなり話を始めてもわからないので、前提知識から。


node.js は、サーバ側で Javascript を使用するための仕組みだ。


そして、Javascript は「Webブラウザで使う」ことを前提に設計された言語だ。

設計当時の OS は、Windows3.1 と MacOS 8 …


どちらも、今のマルチタスクとは違って、「アプリケーションの善意」でマルチタスクを実現していた。

(OS が割り込みで CPU 時間を管理するのが今の方式だが、Win3.1 や MacOS 8 は、アプリケーションが「短い時間で OS に処理を返す」ことを前提にしていた。

 処理を返さないプログラムがいると、全体の動作が停止し、破綻した。)


いきなりすごい昔話が始まったが、その時代に設計された「アプリケーション内の組み込み言語」として、長時間の処理を「させるわけにはいかなかった」。


そこで、Javascript では「イベント駆動」という仕組みを使った。

画面がクリックされた、ボタンが押された、マウスカーソルが特定の領域に入った、など、あらかじめ設定した「イベント」が起きた場合に、指定したプログラムを呼び出してもらう。


イベント駆動でないなら、プログラムが動く条件を調べるところから自前で作らないといけない。

そして、条件を調べるためには、ユーザーがいつ押すかわからない「ボタン」が、押される瞬間を待ち続けないといけない。


…ここで「待ち続ける」というのがダメなのだ。

先に書いたように、短い時間で OS に処理を返さないと、全体が破綻するのだから。



そんなわけで Javascript はイベント駆動だし、なにかを「待つ」必要のある処理を「書けない」ように設計されている。

待つ必要がある場合には、その事象が起きた場合のイベントがあらかじめ用意されているから、そのイベントに対して処理を設定するのだ。


結果として、Javascript のプログラムはイベントを待って少しだけ処理する、というプログラムの断片だらけになる。




ブラウザ側なら、それでいいだろう。


ブラウザはユーザーインターフェイス(UI) の塊で、ユーザーが何かしたら、このプログラムを動かす、ということの連続でできている。


Javascript の組み込みイベントも、そうしたブラウザに合わせて設定されている。

この設定はブラウザメーカが勝手に作っているわけではなく、ECMA という団体で標準化されている。

だから、どこのブラウザでも同じ Javascript プログラムが動く、はずだ。


(実際には、すでにメンテナンスされていない IE とかは仕様が古すぎて最近のプログラムが動かなかったり、Chrome と FireFox で若干の仕様差があったりする。)


話しは戻って node.js なのだが、これはサーバ側で Javascript を動かす仕組みだ。

そして、ECMA の定めるイベントには、サーバで必要とするような事象が十分に考慮されていない。



先に書いたように、Javascript はイベント駆動だ。イベントなしにプログラムを書くことは、まぁできなくはないのだけど、やりづらい。

でも、サーバ向けのイベントは用意されていないし、そもそもサーバで作りたいプログラムは、ブラウザ側の UI と違って非常に多岐にわたる。


そこで、node.js では「ユーザが自由にイベントを拡張できるライブラリ」を作った。これは標準ライブラリとして、node.js を使える環境では必ず使える。

そして、多くの標準ライブラリで、このライブラリを使ったイベント処理を実現している。


なかなかうまい仕組みで、サーバ上でのプログラムを、違和感なくイベント処理で作ることができる。




さて、ここからが今日の本題。


Javascirpt に組み込みのイベントと、node.jsの標準ライブラリが提供するイベント。

同じ「イベント」の仕組みなので同じような動作をする、と思ってプログラムをしていたら、全くそうではなかったのだ。


気づかずに落とし穴にはまってしまった。

何か違う、と気づいてから情報を求めて探し回ったが、この違いに言及している日本語の記事には出会えなかった。


英語で探していてもほとんど情報がなく、「少し違うよ」程度に書かれている記事はあっても、具体的な情報がない。


最終的に、node.js のプログラムを読んで違いを理解した。



具体的には、次のようなプログラムが問題になったのだ。



const stream = fs.createReadStream("sample.text", {encoding:'utf8'})
const reader = readline.createInterface({input: stream})
const headline =  await new Promise((resolve) => {
    reader.on('line', (line) => {
        reader.close()
        return resolve(line.trim())
    }).on('close', () => {
         return resolve('')
    })
})


これはプログラムの断片で、ライブラリとして標準提供されている fs と readline を必要とする。


短いのに Javascript らしい、非常にわかりにくいイベントプログラムになっているので説明しよう。


まず、stream を作っている。

これは、ファイルを読み込んで、読み込みに成功すると、「読み込んだ」というイベントを起こしてデータを渡してくれる。

ここで、特に指定が無ければファイルの頭から終わりまで、読み込みバッファ(指定が無い場合は 64Kbyte)毎に勝手にデータを読み続けてくれる、というのがミソなのだが、ここではあまり意識する必要はない。


この stream を入力として、reader を作っている。

stream で流れてくるデータを、行ごとに分解して、できた行ごとにイベントを起こしてデータを渡してくれる、一種のフィルタだ。


このプログラムでは、この reader に対して2つのイベント処理プログラムを設定している。


line は読み取った行を渡してもらうイベントで、行を受け取ると reader を終了し、行を結果として返す。

(resolve は結果を返す仕組みだが、詳細後述)


また、行が1つもないと…つまり、0バイトのファイルだと、line が来ないで close が来る。

この場合は「空文字列」を結果として返している。


つまりは、テキストファイルの最初の行を取り出すプログラムになっている。



さて、resolve という変わったやり方で結果を返しているのは、これらのプログラム全体が、

Promise という関数の中で呼び出されているためだ。


これもイベントを作り出す仕組みで、「何かを待つ」必要があるときに使う。

先に書いた通り Javascript では何かを待つことはできないのだが、Promise は最近作られた巧妙な仕組みだ。


Promise は、Promise オブジェクトと呼ばれるデータを返す。この時に待ち時間は発生しない。


そして、Promise オブジェクトは、渡された後でも値が変化する、という何とも奇妙なものだ。


最初は「未解決」という状態になっている。

その後、resolve を呼ばれると「解決済み」となり、そのとき resolve に渡された値を読み出すことができる。



await という制御命令は、Promise オブジェクトを引数とする。


そして、await があると、Javascript はその前後でプログラムを分割する。

(以降のプログラムを、勝手に別の関数にまとめると思って欲しい)

そして、await 命令でいったんプログラムの実行を終了してしまう。


await は「Promise が解決した」というイベントを待ち、イベントが発生すると以降のプログラムの処理を始める。

これにより、「待つ」という動作が、見事にイベント駆動に置き換えられ、短い時間で処理を終了する、という Javascript の理念を守ることができる。




ここで、もう一つの Javascript の特徴を説明しておこう。

このあとの話で必要になるからだ。


Javascript の大きな特徴は2つある。一つは、ここまでに書いた「イベント駆動」だ。

もう一つが「シングルスレッド」。


Windows などの OS は。複数のプログラムを同時に動かすことができる。

これを「マルチスレッド」と呼ぶ。


これに対して、Javascript はシングルスレッド。1つのプログラムしか動かせない、という意味だ。

Javascript はイベント駆動で、このイベントはブラウザの場合なら、ユーザーの操作などで引き起こされる。


マウスを動かした、ボタンをクリックした、などだ。


でも、「イベント」が起きても、すぐにイベントの処理プログラムが動くわけではない。

すでに動いているプログラムがあるなら、そのプログラムが最後まで実行終了するまで待たされる。


多数のイベントが有るときは、イベントは処理待ちの「キュー」に貯められる。

そして、動いているプログラムがないときに、順次処理されていく。


複数のプログラムを同時に動かす、というのは、コンピューター的には実は結構「無理している」処理で、無駄が多いのだ。

それに対して、1つのプログラムを動かすだけなら、その仕事に専念できるので効率よく動かすことができる。


シングルスレッドと、必要なときには仕事を「溜めて」おけるイベントキューの組み合わせで、効率よく仕事をこなせる。

これが Javascript の特徴で、node.js が高速だと言われる理由でもある。




さて、話を戻す。先程のプログラムでは、2種類のイベントが出てきた。

Promise によるイベント処理と、reader(readline) によるイベント処理だ。


このふたつが全然違うことで問題が起きる、というのが今日の話のテーマだ。



先程描いたように Promise はプログラムを小さな単位に自動的に区切り、1回のプログラム実行時間を短いものにする。


await new Promise の前と後ろでプログラムは区切られる。

後ろのプログラムは、resolve が実行された後で実行される。


ここで、resolve が「以降のプログラム」を動かすわけではない、ということにも言及しておこう。

resolve は、promise オブジェクトを「解決済み」に変更する役割しか持たない。


resolve はイベントをキューに積むだけで、「解決」したときの、await 以降のプログラムを動かすわけではない。

実際に await 以降のプログラムが動き始めるのは、また別のタイミングなのだ。


ところが、reader によるイベント処理はそうなっていない。

そのため、先に書いたプログラムは正しく動作しない。


具体的には、reader.close() に落とし穴がある。

これが resolve と同じように、close イベントを発生させるだけで実際の処理は後回し、であればよいのだが、実際には reader.close() 関数呼び出しの中で、reader.on('close', ~ に書かれているプログラムが呼び出されてしまうのだ。


その結果、イベント処理は「最小の処理時間」を実現するのではなく、実行時間を引き延ばすことになっている。

さらに、line イベントのプログラムの「途中で」close イベントのプログラムが始まってしまうことで、close イベントの resolve が先に動いてしまう。

Promise はそこで解決してしまうため、行のデータを渡す、という一番大切なことが実現されない。




なぜこんなことになるのか。


node.js の提供する「イベントを実現するライブラリ」は、実際にはイベント「風」のふるまいを行うだけで、Javascript のイベントとは全く別の動作をするためだ。


Javascript のイベントは、Promise の resolve のように、「イベントが起きた」という記録だけを行い、そのイベントに紐づいた処理は後で起動される。


しかし、node.js のイベントライブラリは、「イベントが起きた」ことを伝えると、そのことを伝える関数の中で、イベントに紐づいた処理を呼び出してしまう。


Javascript はシングルスレッド…プログラムの途中で別のプログラムが動き始めることは無い、と保証されている言語なのだが、この仕組みだと、その前提さえ崩れてしまう。


(イベントを起こすと、そのイベントによって割り込みが起こったような挙動になる)




node.js のイベント標準ライブラリの名前は EventEmitter 。


これ自体は非常に便利なものだし、批判したいわけではない。

言語の持つ仕組みではなく、その言語自身で書かれたライブラリとしてイベントを「疑似的に」実現しているので、挙動が違うのも仕方がないところ。


ただ、使う上でこの知識を持っていないと、思わぬところで謎の挙動に悩まされることになる。



最初に書いた通り、日本語でこのことを解説する記事を見かけなかったので、ここに記しておく次第。

(EventEmitter の使い方、というような記事は多数あるのだけど)




同じテーマの日記(最近の一覧)

コンピュータ

別年同日の日記

03年 おでん

17年 ザ・ハウス・オブ・ザ・デッド


申し訳ありませんが、現在意見投稿をできない状態にしています


戻る
トップページへ

-- share --

0000

-- follow --




- Reverse Link -