追記:
この記事で書いたような話は当然JSプログラマの人たちは認識していて、排他制御を行うためのサードのnpmモジュールも存在するようです。
ryo_gridです。
以下、常識だろ、という話かもしれませんが、個人的には新たな気づきだったので共有したいと思いました。
以下の記事を理解するための前提
- JSエンジンのおおまかな仕組みを知っている(イベントループうんぬんであるとか)
- JSはシングルスレッドである、ただし、ランタイム内では例外もある、ということを知っている
ここらへんが良く分からんという方はググって知識を仕入れてから再度読まれるとよいかと思います。
はじめに
まず、以下のような記事をつい昨日書きました、
JSでマルチスレッド(ユーザスレッド)してくれるライブラリConcurrent.Threadを試してみる
この記事にはConcurrent.Threadによるマルチスレッド化を試してみた話と、async/awaitを使っても(要はコルーチンを使えば)、なんちゃってマルチスレッド書けるよね、という話と、実装例を書きました。
で、前者については特殊な例なのでひとまず置いておこうと思いますが、後者のようなことができるってことは、マルチスレッドにおける排他制御みたいなものが必要になるのでは?とふと思って、以下のような内容を利用しているSNSに投稿してみました。
JSのasync/awaitなのだけど、awaitしている間に別の関数(イベントループに登録されている関数は、何かこれっていう呼び名があるんだっけ?)の処理が走ることによって、awaitを使った関数がトランザクション処理というかクリティカルセクションというか、とにかく、関数の頭から終わりまでの間に扱うオブジェクトをいじられるとまずいとかいう事情がある場合どうなるんだろう?
で、まあ、そんな時はawaitなんぞ使うなって話で、awaitかけてた非同期関数が返すPromiseのthenで後続の処理をするとかすればええんか、と思ったのだけど、果たしてそれで問題は回避できるのだろうか。
対象の非同期関数は普通に完了を待つと時間がかかってしまうから非同期関数になっているはずで、内部でI/Oとかしている場合、実行権を手放して、やっぱり他の関数が走っちゃうなんてことがあったりするんじゃないかなーとか。
I/Oやらで実行権を手放した場合に、実行権を得られるのは同様にI/Oなんかをしている関数だけ、とかそういう実装になってるのかな?
でも、他にI/Oしてる関数が無かった場合、イベントループ(スレッド)が遊んじゃうよなあ。
うーん。わからん。
結論: コルーチンを用いたコードベースで、非同期処理関数呼び出しを含むトランザクション処理を書きたければ、それが担保されるようにコードを書かなければならない
※正式な用語か自信がないですが、イベントループに登録されている処理(関数)を、以下ではイベントタスクと呼称することにします
ありがたいことに、SNSにいる(繋がっている)有識者の方に答えを教えて頂けたのですが、その要点は以下のような感じでした。
- ロック用の変数用意して自分で排他処理しないとダメ
- イベントループで実現されているJSの実行機構を想定した時に単純にロックしてうまくいくのだろうか(私)?
- => unlock時に1つ accept するような Promise を返す lock を実装すればよい
- もしくは、想定外のコード実行が行われないように、Arrayをタスクキューとして使って、そこに期待した順番でタスクを並べてごにょごにょすれば良い(タスクの実行される順序関係を明確化し、それが守られるようにすればよい)
ということでした。
で、まあ、ひとまず "はじめに" に書いた疑問については終わりです。
というか、そもそもyield とか async/await とかのコルーチンを実現する仕組みが入る前から同じ話だったのでは?
で、上記の回答を受けて、またいろいろ考えていたのですが、掲題のように思ったわけです。
なぜなら、非同期関数(I/Oするものを代表にその他もろもろ)は実行に長時間かかるから非同期実行になっているわけで、その関数が非同期に実行されているからといって、JSのスレッドを占有しているわけがない、つまり、その非同期関数内でI/Oが実行されている間は、スレッドの実行権を解放して、他のイベントタスクが実行されているはず。
というわけで、実際に試してみました。
node.js の場合
- setIntervalで定期的に処理を呼び出すようまず設定
- トランザクション処理として動かしたい関数(途中で他のイベントタスクに割り込まれることを想定しない)を定義する
- トランザクション処理に含む非同期関数はファイルI/O(ファイルの読み込み)をするものとする
- 定義する関数は、単純にコールバックを登録する版と、Promiseでラップした版の2つ
- 細かい説明は省きますが、割り込まれていなければコンソールに step1, step2, step3 が続けて出ます。setIntervalで登録してあったイベントタスクに割り込まれると、間にその出力が入ってしまいます
const fs = require('fs');
const util = require('util');
function transaction1(callback){
console.log("transaction1: step1")
console.log("transaction1. step2 (file read)")
fs.readFile("./50MB.bin", (err, data) => {
if (err) throw err;
console.log("transaction1: step3");
});
callback()
}
function transaction2(){
const readFile = util.promisify(fs.readFile);
console.log("transaction2: step1")
console.log("transaction2. step2 (file read)");
return readFile("./50MB.bin")
.then((data) => {
console.log("transaction2: step3");
})
.catch((err) => {
throw err;
});
}
// output message every 100ms
setInterval(()=>{console.log("he he he, I try warikomi!")}, 100)
// call transaction1 func and then call transaction2 func
transaction1((err)=>{transaction2()})
実行結果は以下です。
node.jsのバージョンはv10.13.0です。
PS F:\work\tmp\js_warikomi_testing> node --version
v10.13.0
PS F:\work\tmp\js_warikomi_testing> node .\nodejs_warikomi_experiment.js
transaction1: step1
transaction1. step2 (file read)
transaction2: step1
transaction2. step2 (file read)
he he he, I try warikomi!
he he he, I try warikomi!
he he he, I try warikomi!
he he he, I try warikomi!
he he he, I try warikomi!
transaction2: step3
transaction1: step3
he he he, I try warikomi!
he he he, I try warikomi!
he he he, I try warikomi!
he he he, I try warikomi!
he he he, I try warikomi!
(以降省略)
どちらの実装でも割り込まれました。
Web JS の場合 (Google Chrome)
- ローカルファイルの読み込みを、XMLHttpRequestによるサーバ上のファイルの読み込みに置き換えました
- 他は node.js の場合と基本的には同じです
var ajax_util = {
post: (url) => {
return new Promise((resolve, reject) => {
let xhr = new XMLHttpRequest();
xhr.open('POST', url, true);
xhr.onload = () => {
if (xhr.readyState === 4 && xhr.status === 200) {
resolve(xhr.response);
} else {
reject(new Error(xhr.statusText));
} };
xhr.onerror = () => {
reject(new Error(xhr.statusText));
};
xhr.send("");
});
}
}
function transaction1(callback){
console.log("transaction1: step1")
console.log("transaction1. step2 (file read)")
var req = new XMLHttpRequest();
req.onreadystatechange = function() {
if (req.readyState == 4) { // communication finished
if (req.status == 200) { // communication succeeded
console.log("transaction1: step3");
callback()
}
}
}
req.open('POST', '10MB.bin', true);
req.setRequestHeader('content-type',
'application/x-www-form-urlencoded;charset=UTF-8');
req.send("");
}
function transaction2(){
console.log("transaction2: step1")
console.log("transaction2. step2 (file read)");
ajax_util.post("10MB.bin") // read 10MB files from server
.then((data) => {
console.log("transaction2: step3");
})
.catch((err) => {
throw err;
});
}
function start_experiment(){
// output message every 100msec
setInterval(()=>{console.log("he he he, I try warikomi!")}, 100)
// call transaction1 func and then call transaction2 func
transaction1((err)=>{transaction2()})
}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<title>expment that whether transactional execution can be implemented with normal coding style</title>
<script type="text/javascript" src="webjs_warikomi_experiment.js"></script>
</head>
<body>
<h1>Please see web console with developer tools of chrome browser or something</h1>
<script type="text/javascript">
// call function on webjs_warikomi_experiment.js
start_experiment();
</script>
</body>
</html>
実行結果は以下です。
Chromeのバージョンは "73.0.3683.75(Official Build) (32 ビット)" です。
node.jsの場合と同様に割り込まれました。
なお、自分で試してみたいという方のために以下に上記の例を置いてあります。
デベロッパーツールのコンソールを見てみて下さい。
なお、setIntervalで100ms間隔でconsole.out("うんたら")としているので、確認が終わったらタブは閉じておいた方が良いと思います。
結論(再)
コルーチンとか使ってなくても非同期処理関数呼び出しを含むトランザクション処理を書きたければ、それが担保されるようにコードを書かなければならない!!!
以上です。