KODAMA LAB
Home
Lecture notes

Concurrent Programming in JavaScript

From prototypes to the event loop, async/await and Web Workers.

15 sections 7 live demos 4 exercises

→ Java edition → Python edition

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: 外からは触れない
JavaJavaScript備考
private int x;#x = 0;私有フィールド
extendsextends同じ綴り
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().

Square ← 実行
Polygon ← 実行
Shape ← 実行
Object .prototype(終点) ← 実行

top to bottom = the direction lookup walks

04JavaScript の並行性

ここからが本題である。Java 版では「オブジェクトごとに計算主体があり、同時に動く」ことを前提に、 割り込み・同期・セマフォを扱った。JavaScript の既定の実行環境には、その前提が無い。

JavaJavaScript(既定)
計算の主体スレッド(複数)1 本だけ
切り替わり方いつでも割り込まれる(プリエンプティブ)自分が譲ったときだけ(協調的)
切り替わる場所どこでもawait と関数の終わりだけ
共有データの競合起きる1 命令の途中では起きない。ただし await をまたぐと起きる
本当の並列スレッドWeb Worker

つまり JavaScript は「並行(concurrent) だが並列(parallel) ではない」。この差が、 以降で見るイベントループの挙動と、競合状態の起き方の違いを生む。

05イベントループ

JavaScript の処理系は、次の 3 つを見ながら動いている。

  1. コールスタック — いま実行している関数の積み重ね
  2. タスクキュー(マクロタスク)— setTimeout、クリックなどのイベント
  3. マイクロタスクキューPromisethenawait の続き、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.

start
console

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.

count = 0

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 isolatedCOOP/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 版と同じ生産者・消費者を、非同期セマフォで書く。emptyfull の 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.

empty = 6 full = 0

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 editionPython edition