01オブジェクト指向とは
Java 版で述べたとおり、オブジェクト指向は Simula67 で生まれた考え方で、世界をもの(object) の 集まりとして捉え、もの同士のメッセージの受渡し(message passing) で現象が進むと考える。 オブジェクトは 状態・状態を変更するプログラム(メソッド)・計算を実行する主体の 3 つから成る、という見方も同じである。
違うのは「計算を実行する主体」の与えられ方である。Java では 1 つのオブジェクトに 1 本のスレッドを 割り当てて本当に同時に動かすことができる。JavaScript では、既定では計算の主体は1 本しかない。 それでも並行に見せるための仕組みが、この資料の主題であるイベントループと非同期である。
この資料は Java 版 と対になっています。概念の説明はそちらが詳しく、 ここでは JavaScript で書くとどうなるか、そしてどこが Java と違うかに重点を置きます。
02JavaScript のオブジェクト
JavaScript でオブジェクトを作るもっとも素朴な方法は、そのまま書くことである。 クラスを定義しなくてもオブジェクトが作れる点が Java と大きく違う。
const tree = {
status: '立っている',
fall() { this.status = '倒れた'; }
};
tree.fall();
console.log(tree.status); // 倒れた
Java 版で「オブジェクトベース」と「クラスベース」に分けた分類でいえば、JavaScript は もともとオブジェクトベース(プロトタイプベース)の言語である。オブジェクトは別のオブジェクトを プロトタイプとして参照し、自分が持っていない性質はそちらへ探しにいく。この鎖を プロトタイプチェーンという。
const animal = { cry() { return '……'; } };
const dog = Object.create(animal); // animal をプロトタイプにする
dog.name = 'ポチ';
console.log(dog.cry()); // …… ← animal から借りてきた
console.log(Object.getPrototypeOf(dog) === animal); // true
03class 構文
2015 年の ES2015 で class 構文が入り、クラスベース言語と同じ書き方ができるようになった。
ただしこれはプロトタイプの上に被せた書き方であって、内部の仕組みが変わったわけではない。
class Tree {
#status = '立っている'; // # は私有フィールド(Java の private に相当)
fall() { this.#status = '倒れた'; }
get status() { return this.#status; }
}
const t = new Tree();
t.fall();
console.log(t.status); // 倒れた
console.log(t.#status); // SyntaxError: 外からは触れない
| Java | JavaScript | 備考 |
private int x; | #x = 0; | 私有フィールド |
extends | extends | 同じ綴り |
super() | super() | 同じ |
| インタフェース | (無い) | ダックタイピングか Symbol で代用 |
| オーバーロード | (無い) | 引数の数・型で使い分けはできない |
継承
extends でプロトタイプチェーンがつながる。super はプロトタイプの側を指す。
class Shape {
constructor(name) { this.name = name; }
draw() { return this.name + ' を描く'; }
}
class Square extends Shape {
constructor(side) { super('正方形'); this.side = side; }
draw() { return super.draw() + `(一辺 ${this.side})`; }
}
console.log(new Square(3).draw()); // 正方形 を描く(一辺 3)
オーバーロードが無い
Java の多重定義(同じ名前で引数の型・数が違うメソッドを複数持つこと)は JavaScript には無い。 同じ名前で 2 回定義すると、後の定義が前を上書きするだけである。
class Calc {
add(a, b) { return a + b; }
add(a, b, c) { return a + b + c; } // ← 前の add を消してしまう
}
new Calc().add(1, 2); // NaN (c が undefined)
引数の数で使い分けたいときは、既定値・可変長引数・引数オブジェクトで書く。
class Calc {
add(...xs) { return xs.reduce((a, b) => a + b, 0); }
}
new Calc().add(1, 2); // 3
new Calc().add(1, 2, 3); // 6
オーバーライドと動的束縛
メソッドを探す道すじは、Java の継承と同じく実体の側から上へたどる。 最初に見つかった定義が呼ばれる。JavaScript ではこれがプロトタイプチェーンの探索そのものである。
class Shape { draw() { return 'Shape.draw'; } }
class Polygon extends Shape {} // draw を定義しない
class Square extends Polygon { draw() { return 'Square.draw'; } }
const s = new Square();
s.draw(); // Square.draw
Object.getPrototypeOf(Object.getPrototypeOf(s)) // Polygon.prototype
Java との違いは、実行時に鎖を書き換えられることである。次のデモでは、各クラスが
draw() を持つかどうかを切り替えると、呼ばれる定義がその場で変わる。
Try it: walking the prototype chain
Toggle which classes define draw().
top to bottom = the direction lookup walks
04JavaScript の並行性
ここからが本題である。Java 版では「オブジェクトごとに計算主体があり、同時に動く」ことを前提に、 割り込み・同期・セマフォを扱った。JavaScript の既定の実行環境には、その前提が無い。
| Java | JavaScript(既定) | |
| 計算の主体 | スレッド(複数) | 1 本だけ |
| 切り替わり方 | いつでも割り込まれる(プリエンプティブ) | 自分が譲ったときだけ(協調的) |
| 切り替わる場所 | どこでも | await と関数の終わりだけ |
| 共有データの競合 | 起きる | 1 命令の途中では起きない。ただし await をまたぐと起きる |
| 本当の並列 | スレッド | Web Worker |
つまり JavaScript は「並行(concurrent) だが並列(parallel) ではない」。この差が、 以降で見るイベントループの挙動と、競合状態の起き方の違いを生む。
05イベントループ
JavaScript の処理系は、次の 3 つを見ながら動いている。
- コールスタック — いま実行している関数の積み重ね
- タスクキュー(マクロタスク)—
setTimeout、クリックなどのイベント - マイクロタスクキュー —
Promiseのthen、awaitの続き、queueMicrotask
規則は単純である。スタックが空になったら、まずマイクロタスクを空になるまで全部実行し、 それからタスクキューを1 つだけ取り出して実行する。以後これを繰り返す。
console.log('1');
setTimeout(() => console.log('5'), 0); // タスクキュー
Promise.resolve().then(() => console.log('3')); // マイクロタスク
queueMicrotask(() => console.log('4')); // マイクロタスク
console.log('2');
// 出力は 1 2 3 4 5
setTimeout(..., 0) が最後になるのは、マイクロタスクが先に全部片づくからである。
ここが「0 ミリ秒なのに最後」の理由で、多くの人が最初につまずくところである。
Try it: step the event loop
Run the program one step at a time.
call stack
microtask queue
task queue
06非同期の書き方
「時間のかかる処理が終わるのを待つ」書き方は、コールバック → Promise → async/await と
移り変わってきた。同じことを 3 通りで書くと違いがはっきりする。
// 1. コールバック(入れ子が深くなる。いわゆるコールバック地獄)
readFile('a.txt', (err, a) => {
readFile('b.txt', (err, b) => {
console.log(a + b);
});
});
// 2. Promise(つながるが、まだ縦に伸びる)
readFile('a.txt')
.then(a => readFile('b.txt').then(b => console.log(a + b)));
// 3. async / await(同期の見た目で書ける)
const a = await readFile('a.txt');
const b = await readFile('b.txt');
console.log(a + b);
await はそこで関数を中断し、残りをマイクロタスクとして予約する命令である。
中断している間、処理系は他の仕事をする。だから次のように、await の前後で
他のコードが割り込む。
async function f() {
console.log('a');
await null; // ここでいったん抜ける
console.log('c');
}
f();
console.log('b');
// 出力は a b c
Your turn: predict the output order
Tap A–F in the order they are printed, then run it.
console.log('A');
setTimeout(() => console.log('B'), 0);
(async () => {
console.log('C');
await null;
console.log('D');
})();
Promise.resolve().then(() => console.log('E'));
console.log('F');
07競合状態は起きるのか
Java 版では count++ が「読む・足す・書く」に分かれるため、その途中で割り込まれると
更新が消えた。JavaScript では 1 本しか流れが無いので、この形の競合は起きない。
let count = 0;
function inc() { count++; } // 途中で割り込まれることはない
しかし await をまたぐと話が変わる。await は意図的に譲る点であり、
そこで他の非同期処理が割り込む。Java のプリエンプションと同じ結果が、場所を限定して起きる。
let count = 0;
async function inc() {
const v = count; // 読む
await sleep(10); // ← ここで他方に譲ってしまう
count = v + 1; // 古い v をもとに書く
}
await Promise.all([inc(), inc()]);
console.log(count); // 2 ではなく 1 になる
直し方も Java と同じ考え方で、読んでから書くまでを他に譲らないようにする。
JavaScript には synchronized が無いので、順番待ちの仕組み(ロック)を自分で作る。
function createLock() {
let last = Promise.resolve();
return fn => {
const run = last.then(() => fn()); // 前の処理の後ろにつなぐ
last = run.catch(() => {});
return run;
};
}
const withLock = createLock();
async function inc() {
await withLock(async () => {
const v = count;
await sleep(10);
count = v + 1;
});
}
Try it: an update lost across await
Two tasks read, await, then write.
08Web Worker
本当に同時に動かしたいときは Web Worker を使う。Worker は別のスレッドで動き、
メインの流れとはメモリを共有しない。やりとりは postMessage による
メッセージの受渡しだけである。これは Simula67 以来の「メッセージパッシング」の考え方そのものである。
// main.js
const w = new Worker('worker.js');
w.postMessage({ n: 40 });
w.onmessage = e => console.log('答えは', e.data);
// worker.js
onmessage = e => {
const fib = n => n < 2 ? n : fib(n - 1) + fib(n - 2);
postMessage(fib(e.data.n));
};
重い計算をメインの流れで回すと、その間画面が固まる。ボタンも押せず、アニメーションも止まる。 1 本しか流れが無いのだから当然である。Worker に出すと画面は動き続ける。次のデモで実際に確かめられる。
Try it: does the page freeze?
Naive recursive Fibonacci, on the main thread or in a Worker.
メモリを共有する場合
SharedArrayBuffer を使うと、Worker とメモリを共有できる。ここでは Java と同じ
本物のデータ競合が起きるので、Atomics で不可分な読み書きを行う。
const sab = new SharedArrayBuffer(4);
const a = new Int32Array(sab);
a[0]++; // 危険:読む・足す・書くに分かれる
Atomics.add(a, 0, 1); // 安全:不可分に足す
Atomics.wait / Atomics.notify を使えば、Java 版のセマフォと同じものが書ける。
// P に相当(資源が 0 なら眠って待つ)
function P(sem) {
while (true) {
const v = Atomics.load(sem, 0);
if (v > 0 && Atomics.compareExchange(sem, 0, v, v - 1) === v) return;
Atomics.wait(sem, 0, 0);
}
}
// V に相当(返して、待っている人を起こす)
function V(sem) { Atomics.add(sem, 0, 1); Atomics.notify(sem, 0, 1); }
SharedArrayBuffer は、サイトが cross-origin isolated(COOP/COEP ヘッダ付き)
で配信されていないと使えません。このページは通常配信のため利用できない見込みですが、
下のデモがご覧の環境で使えるかどうかを実際に判定します。
Check: is shared memory available here?
Result for your browser.
09同期をとる
Java 版ではセマフォを導入して「資源を数える」ことで排他と待ち合わせを行った。 JavaScript でも考え方は同じで、Promise を使って非同期セマフォが書ける。
class Semaphore {
#n; #waiters = [];
constructor(n) { this.#n = n; }
async P() { // 資源を 1 つ取る
if (this.#n > 0) { this.#n--; return; }
await new Promise(res => this.#waiters.push(res)); // 待ち行列に並ぶ
}
V() { // 返す
const w = this.#waiters.shift();
if (w) w(); // 待っている人がいれば起こす
else this.#n++;
}
}
資源が 1 個のセマフォはミューテックス(相互排除)になり、
synchronized と同じ役目を果たす。
有限バッファ問題
Java 版と同じ生産者・消費者を、非同期セマフォで書く。empty と full の
2 つのセマフォで空きと品物を数えるところまで同じである。
const N = 6;
const buf = [];
const empty = new Semaphore(N); // 空きの数
const full = new Semaphore(0); // 品物の数
const mutex = new Semaphore(1); // バッファへの出入り口
async function producer() {
for (let i = 1; ; i++) {
await empty.P(); // 空きが出るまで待つ
await mutex.P();
buf.push(i);
mutex.V();
full.V(); // 品物が 1 つ増えたと知らせる
await sleep(200);
}
}
async function consumer() {
while (true) {
await full.P(); // 品物が来るまで待つ
await mutex.P();
const x = buf.shift();
mutex.V();
empty.V(); // 空きが 1 つ増えたと知らせる
console.log('取り出した', x);
await sleep(300);
}
}
Java 版との違いは await が付くことだけである。待つ理由も、待たせる相手も同じである。
Try it: producer and consumer with async semaphores
The code above, running.
10課題
課題 1 次のコードの出力の順を、実行する前に予想せよ。予想できたら、 上のイベントループのデモで確かめよ。
console.log('A');
setTimeout(() => console.log('B'), 0);
(async () => { console.log('C'); await null; console.log('D'); })();
Promise.resolve().then(() => console.log('E'));
console.log('F');
課題 2 await をまたぐ競合のデモで、ロックを掛けずに 2 つの処理を同時に走らせると
数が合わなくなる。なぜ 1 になるのかを、どの時点で誰が何を読んだかを追って説明せよ。
課題 3 上の Semaphore を使って、同時に 3 本までしか走らない
「並列数を制限する」関数 limit(n, tasks) を書け。10 個の処理を渡したとき、
常に 3 個だけが動いていることを確かめられるようにせよ。
課題 4 Web Worker のデモを参考に、フィボナッチ数の計算を Worker に出す版と 出さない版を作り、画面が固まるかどうかを比べよ。何番目から体感できる差になるか。
The same material in the other two languages: Java edition / Python edition