30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

Web Worker (Dedicated Worker) によるマルチスレッド処理

JavaScript は基本的にシングルスレッドであり、並列処理を行うことはできない。
そのため何か重たい処理があると、それによってメインスレッドが専有されてしまい、後続の処理が遅延してしまう。
その結果、ウェブアプリの速度や操作性に悪影響を与えてしまう可能性がある。
Web Worker を使うとマルチスレッドによる処理が可能になり、重たい処理をメインスレッドではなく他のスレッドに処理させることができる。

この記事では、Web Worker の基本的な使い方を見ていく。

動作確認は以下の環境で行った。

  • Deno 1.9.0
  • Google Chrome 90.0.4430.72

ワーカースレッドの作成

Web Worker には専用ワーカー(Dedicated Worker)と共有ワーカー(Shared Worker)がある。
この記事では Shared Worker については扱わず、Dedicated Worker に絞って話を進めていく。以降、単に Worker と書いた際は Dedicated Worker を指す。

早速 Worker を使っていきたいのだが、まずは動作確認のためのサーバを立てる。
以下の内容のserver.jsを書き、$ deno run --unstable --allow-net --allow-read server.jsを実行する。
このコードは重要ではなく、HTML ファイルと JavaScript ファイルを返すサーバでさえあればよいので、読む必要はない。

const html = `
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Web Worker</title>
</head>
<body>
  <script src="/main.js"></script>
</body>
</html>
`;

for await (const conn of Deno.listen({ port: 8080 })) {
  handleConn(conn);
}

async function handleConn(conn) {
  const httpConn = Deno.serveHttp(conn);
  for await (const { request, respondWith } of httpConn) {
    switch (true) {
      case /^\/$/.test(request.url): {
        respondWith(
          new Response(html, {
            status: 200,
            headers: { "Content-Type": "text/html" },
          })
        );
        break;
      }

      case /\.js$/.test(request.url): {
        const jsFile = await Deno.readFile(`.${request.url}`);
        respondWith(
          new Response(jsFile, {
            status: 200,
            headers: { "Content-Type": "text/javascript" },
          })
        );
        break;
      }

      default: {
        respondWith(
          new Response("Not Found\n", {
            status: 404,
            headers: { "Content-Type": "text/plain" },
          })
        );
        break;
      }
    }
  }
}

次に、main.jsを書く。このファイルが HTML ファイルから読み込まれることになる。

const worker = new Worker("worker.js");

console.log(worker);

そして、worker.jsというファイルを作成する。中身はまだ空でよい。

この状態でhttp://localhost:8080/にアクセスすると、ブラウザのログに以下の内容が流れる。

Worker {onmessage: null, onerror: null}

これで、ワーカースレッドを作成できたことになる。
このようにnew Worker(ファイル名)という形でWorkerのインスタンスを作成するところから、Dedicated Worker の利用は始まる。

この例の場合、main.jsに書かれた内容がメインスレッドで実行され、worker.jsで書かれた内容がワーカースレッドで実行される。

Worker として呼び出せるのは、ウェブページと同じオリジンのファイルのみ。
あくまでもウェブページのオリジンが基準であり、呼び出し元の JavaScript ファイル(この例だとmain.js)のオリジンと一致するかは、問われない。
例えば以下のケースではウェブページとworker.jsが同じオリジン(http://localhost:8080)であるため、エラーにならない。

<!-- http://localhost:8080/ -->
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Web Worker</title>
</head>
<body>
  <script src="http://localhost:8081/main.js"></script>
</body>
</html>
// http://localhost:8081/main.js
const worker = new Worker("http://localhost:8080/worker.js");

console.log(worker);

Worker のコンテキスト

ワーカースレッドで実行されるコードは、メインスレッドで実行される場合とは全く異なるコンテキストで動く。

// main.js
const worker = new Worker("worker.js");

console.log(self); // Window
console.log(Object.getPrototypeOf(self)); // Window
console.log(Object.getPrototypeOf(Object.getPrototypeOf(self))); // WindowProperties
// worker.js
console.log(self); // DedicatedWorkerGlobalScope
console.log(Object.getPrototypeOf(self)); // DedicatedWorkerGlobalScope
console.log(Object.getPrototypeOf(Object.getPrototypeOf(self))); // WorkerGlobalScope

ワーカースレッドのグローバルスコープにはWindowオブジェクトは存在せず、利用できるメソッドが異なる。例えば DOM API は利用できない。

主要なメソッドとイベント

メインスレッドとワーカースレッドはお互いにメッセージを送受信することによって、連携していく。

まずはメインスレッドからの送信。
これは簡単で、WorkerインスタンスのpostMessageメソッドを使えばよい。

// main.js
const worker = new Worker("worker.js");

worker.postMessage("First message.");

そしてメッセージを送信すると、ワーカースレッド側でmessageイベントが発生する。
そのため、messageイベントにイベントハンドラを設定しておくことで、受信時の処理を設定できる。
受信したメッセージはイベントオブジェクトのdataプロパティに入っている。

// worker.js
self.addEventListener(
  "message",
  (e) => {
    console.log(e.data); // First message.
  },
  false
);

逆にワーカースレッドからメインスレッドにメッセージを送る際も、postMessageメソッドとmessageイベントを使う。

// main.js
const worker = new Worker("worker.js");

worker.addEventListener(
  "message",
  (e) => {
    console.log(e.data);
  },
  false
);

worker.postMessage("First message.");
worker.postMessage("ABC");

// 以下のログが流れる
// 1 回目に受け取ったデータ => First message.
// 2 回目に受け取ったデータ => ABC
// worker.js
let counter = 0;

self.addEventListener(
  "message",
  (e) => {
    counter += 1;
    self.postMessage(`${counter} 回目に受け取ったデータ => ${e.data}`);
  },
  false
);

メインスレッドではWorkerインスタンス、ワーカースレッドではselfオブジェクト(グローバルオブジェクト)、という違いはあるが、どちらでもpostMessageメソッドとmessageイベントが存在するので、それを使ってメッセージの送受信を行うのが基本になる。

メッセージで渡される値はコピーされたものであり、値を共有しているわけではない。
オブジェクトを渡すときは一旦シリアライズして送信され、それをデシリアライズしている。
そのためだと思われるが、リテラルやオブジェクトは渡せるが、関数を渡そうとするとエラーになる。

const worker = new Worker("worker.js");

worker.postMessage(1); // ok
worker.postMessage(true); // ok
worker.postMessage({ a: "b" }); // ok
worker.postMessage(() => {}); // Uncaught DOMException: Failed to execute 'postMessage' on 'Worker': () => {} could not be cloned.

messageイベントの他にerrorイベントもあり、ワーカースレッドで例外が投げられると発生する。
これにイベントハンドラを設定しておくことで、発生したエラーの詳細を知ることができる。

const worker = new Worker("worker.js");

worker.addEventListener(
  "message",
  (e) => {
    console.log(e.data);
  },
  false
);

worker.addEventListener(
  "error",
  (e) => {
    console.log(e.filename); // http://localhost:8080/worker.js
    console.log(e.lineno); // 9
    console.log(e.colno); // 1
    console.log(e.message); // Uncaught ReferenceError: x is not defined
  },
  false
);

worker.postMessage("a");
self.addEventListener(
  "message",
  (e) => {
    self.postMessage(e.data);
  },
  false
);

x;

Workerインスタンスのterminateメソッドを使うと、ワーカースレッドを終了させることができる。
以下のコードでは、最初のメッセージが返ってきた時点でワーカースレッドが終了するので、ログにはaのみが表示される。

const worker = new Worker("worker.js");

worker.addEventListener(
  "message",
  (e) => {
    console.log(e.data);
    worker.terminate();
  },
  false
);

worker.postMessage("a");
worker.postMessage("b");
self.addEventListener(
  "message",
  (e) => {
    self.postMessage(e.data);
  },
  false
);

ウェブアプリケーションをマルチスレッドで処理する

基本的な使い方は分かったので次は、利用例を示していく。
ワーカースレッドを使うことで、メインスレッドが特定の処理に専有されてしまうことを回避できるようになる。

まず最初にメインスレッドが専有されてしまう例を紹介し、それをワーカースレッドで解決する。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Web Worker</title>
</head>
<body>
  <p>
    <span id="msg">ボタンを押すと計算を開始します</span><br>
    <button id="cal-button" type="button">計算開始</button>
  </p>
  <p>
    <span id="counter">0</span><br>
    <button id="count-up-button" type="button">count up</button>
  </p>
  <script src="/main.js"></script>
</body>
</html>

f:id:numb_86:20210419125553p:plain

ボタンが 2 つ用意されており、count upを押下すると数字が増えていく。
そして計算開始を押下すると何らかの演算が行われるのだが、この演算に5秒かかるとする。

// main.js
let counter = 0;

const msg = document.querySelector("#msg");
const calButton = document.querySelector("#cal-button");

function heavy(ms, message) {
  const startTime = performance.now();
  while (performance.now() - startTime < ms);
  return message;
}

calButton.addEventListener(
  "click",
  () => {
    msg.textContent = "計算中です";
    msg.textContent = heavy(5000, "計算が完了しました");
  },
  false
);

const counterText = document.querySelector("#counter");
const countUpButton = document.querySelector("#count-up-button");

countUpButton.addEventListener(
  "click",
  () => {
    counter += 1;
    counterText.textContent = counter;
  },
  false
);

この場合、計算開始を押下すると、UI が全く更新されなくなってしまう。
計算開始を押下してから5秒後にようやく、更新される。

f:id:numb_86:20210419125635g:plain

メインスレッドがheavy関数の実行に専有されてしまい、それが終わるまで後続の処理はブロックされてしまっている。
setTimeoutを使えば計算中ですをすぐに表示させることはできるが、それでも、count upボタンの押下にすぐに反応させることはできない。
シングルスレッドである以上、heavy関数の実行と UI の描画を両立させることはできない。

ワーカースレッドを使ってマルチスレッドにすれば、これを解決できる。

// main.js
let counter = 0;

const msg = document.querySelector("#msg");
const calButton = document.querySelector("#cal-button");

const worker = new Worker("worker.js");

calButton.addEventListener(
  "click",
  () => {
    msg.textContent = "計算中です";
    worker.postMessage("計算が完了しました");
  },
  false
);

worker.addEventListener(
  "message",
  (e) => {
    msg.textContent = e.data;
  },
  false
);

const counterText = document.querySelector("#counter");
const countUpButton = document.querySelector("#count-up-button");

countUpButton.addEventListener(
  "click",
  () => {
    counter += 1;
    counterText.textContent = counter;
  },
  false
);
// worker.js
function heavy(ms, message) {
  const startTime = performance.now();
  while (performance.now() - startTime < ms);
  return message;
}

self.addEventListener(
  "message",
  (e) => {
    const result = heavy(5000, e.data);
    self.postMessage(result);
  },
  false
);

計算開始を押下するとワーカースレッドを呼び出し、そちらでheavy関数を実行させている。
そしてそれと並行して、メインスレッドが UI を更新する。そしてheavy関数の実行が終わったタイミングでメインスレッドは結果を受け取り、それを UI に反映させる。

f:id:numb_86:20210419125720g:plain

これで、heavy関数を実行しつつ UI を更新できるようになった。

複数のワーカースレッドを作成する

ワーカースレッドは複数作成できるし、ワーカースレッドの中でさらにワーカースレッドを作成することもできる。

以下の例ではworker.jsのなかで、ワーカースレッドとしてsub-worker.jsを呼び出している。
そしてバケツリレーのように、データを流している。

// main.js
const worker = new Worker("worker.js");

worker.addEventListener(
  "message",
  (e) => {
    console.log(e.data);
  },
  false
);

worker.postMessage("First message."); // First message. worker sub-worker
// worker.js
const subWorker = new Worker("sub-worker.js");

// メインスレッドから受け取り、サブに渡す
self.addEventListener(
  "message",
  (e) => {
    subWorker.postMessage(e.data + " worker");
  },
  false
);

// サブから受け取り、メインスレッドに渡す
subWorker.addEventListener(
  "message",
  (e) => {
    self.postMessage(e.data);
  },
  false
);
// sub-worker.js
self.addEventListener(
  "message",
  (e) => {
    self.postMessage(e.data + " sub-worker");
  },
  false
);

HTML のなかに Worker を書く

ワーカースレッドで実行したいコードを、独立したファイルに書くのではなく HTML ファイルのなかに書くこともできる。

データブロックにコードを記述することで、ブラウザが実行してしまうことを防げる。
参考:
data block って?

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Web Worker</title>
</head>
<body>
  <script id="worker" type="text/worker">
    self.addEventListener(
      "message",
        (e) => {
          self.postMessage(e.data);
        },
      false
    );
  </script>
  <script src="/main.js"></script>
</body>
</html>

そしてメインスレッドのなかで、Blobオブジェクトを経由して URL を生成する。
そうすれば、Workerクラスに渡せるようになる。

// main.js
const code = document.querySelector("#worker").textContent;
const blob = new Blob([code], { type: "text/javascript" });
const url = URL.createObjectURL(blob);
const worker = new Worker(url);
URL.revokeObjectURL(url);

worker.addEventListener(
  "message",
  (e) => {
    console.log(e.data);
  },
  false
);

worker.postMessage("foo");

BlobオブジェクトやcreateObjectURLについては、以前書いた。

numb86-tech.hatenablog.com

ワーカースレッドでのスクリプトのインポートについて

最後に、ワーカースレッドで他のスクリプトをインポートする方法について見ていく。

この観点から見た時、Worker は 2 種類に分類できる。
Classic WorkerModule Workerである。このどちらであるかによって、できることが異なる。

どちらの Worker になるかは、Workerインスタンスの作成時に決まる。

const worker = new Worker("worker.js"); // Classic Worker
const worker = new Worker("worker.js", { type: "classic" }); // Classic Worker
const worker = new Worker("worker.js", { type: "module" }); // Module Worker

import文を使えるのはModule Workerのみで、Classic Workerで使うとエラーになる。
Dynamic Import については、どちらの Worker でも使うことができる。

importScriptsは、Classic Workerでのみ使える。
importScriptsにファイルを渡すと、その内容を読み込み、そこに定義されているグローバル変数が使えるようになる。

// main.js
const worker = new Worker("worker.js", { type: "classic" });
// worker.js
importScripts("./a.js");

// foo を使える
console.log(foo);
// a.js
const foo = "This is foo.";
console.log("Loaded");

なお、読み込むファイル(上記の例だとa.js)にexport文があると、エラーになる。

参考資料

Deno で学ぶ HTTP/2 の仕組み

先日 Deno のv1.9がリリースされ、HTTP/2 に対応したサーバを立てられるようになった。

deno.com

zenn.dev

この記事では Deno で実際にサーバを立てながら、HTTP/2 の特徴を見ていく。

動作確認は以下の環境で行った。

  • Deno1.9.0
  • Google Chrome90.0.4430.72
  • curl7.54.0

TLS の利用が必須

Deno で HTTP/2 対応のサーバを立てるためには、TLS の利用が必須である。
これは Deno に特有のことではなく、現在の主要なブラウザは全て、TLS 上でのみ HTTP/2 を利用できるようになっている。
仕様上では TLS を使わなくても HTTP/2 を利用できることになっているが、実務においては TLS の利用が前提になっていると考えていいと思う。

そのため、ローカル環境で HTTP/2 を利用したい場合はまず、TLS の準備をする必要がある。
その手順については以前ブログにまとめており、今回もこの方法で準備した。localhost.numb86.netというドメインで TLS を使える状態になっている。

numb86-tech.hatenablog.com

ALPN によるプロトコルのネゴシエーション

HTTP/2 で通信を行うには、サーバとクライアントの両方が HTTP/2 に対応している必要がある。
主要なブラウザは全て対応しているので、あとはサーバさえ対応していれば HTTP/2 での通信が行える。
サーバが HTTP/2 に対応しているのかの確認は、ALPN という方式で行われる。

HTTP 通信を行う際には、まず TCP のハンドシェイクが行われる。
そしてその後 TLS のハンドシェイクを行い、それが終わってから HTTP メッセージの送信が始まる。
ALPN では、TLS ハンドシェイクの際に、使用可能なプロトコルのリストをクライアントが渡す。そしてサーバはその中から、どのプロトコルを使って通信を行うのかを決め、クライアントに渡す。
このようにして、どのプロトコルで通信をするのかが決まるのである。
クライアントが渡すリストのなかに HTTP/2 が入っており、サーバがそれを選べば、HTTP/2 での通信が始まる。

ALPN で選択できるプロトコル一覧は、以下で確認できる。
Transport Layer Security (TLS) Extensions

Deno で指定できるのは、今のところhttp/1.1h2のようだ。
指定がなかった場合は HTTP/1.1 が採用される。
Deno 1.9 Release Notes | Deno Blog

実際にサーバを立てて、確認してみる。

server.tsという名前で以下のコードを書き、$ deno run --unstable --allow-net --allow-read server.tsで実行する。

const listener = Deno.listenTls({
  port: 8443,
  certFile: "./cert.pem",
  keyFile: "./privkey.pem",
  alpnProtocols: ["h2", "http/1.1"],
});

for await (const conn of listener) {
  handleConn(conn);
}

async function handleConn(conn: Deno.Conn) {
  const httpConn = Deno.serveHttp(conn);
  for await (const { respondWith } of httpConn) {
    respondWith(new Response("Hello!\n"));
  }
}

こうするとhttps://localhost.numb86.net:8443/で HTTP/2 に対応したサーバにアクセスできるはずなので、curl で確認してみる。

$ curl https://localhost.numb86.net:8443/
Hello!
$ curl -I https://localhost.numb86.net:8443/
HTTP/2 200
date: Fri, 16 Apr 2021 23:22:33 GMT
content-length: 7

HTTP/2 を使った通信になっているのが分かる。

-vフラグをつけると、ALPN の情報も得られる。

$ curl -v https://localhost.numb86.net:8443/
* ALPN, offering h2
* ALPN, offering http/1.1

* ALPN, server accepted to use h2

< HTTP/2 200

クライアントがh2http/1.1を提示して、サーバがh2を使うことにしているのが分かる。

コードのalpnProtocolsの部分を書き換えて、alpnProtocols: ["http/1.1", "h2"],にしてみる。

* ALPN, offering h2
* ALPN, offering http/1.1

* ALPN, server accepted to use http/1.1

< HTTP/1.1 200 OK

そうすると、http/1.1が選ばれる。

alpnProtocolsの行をコメントアウトすると、ネゴシエーションが上手くいかない。
やり取りは HTTP/1.1 で行うことになる。

* ALPN, offering h2
* ALPN, offering http/1.1

* ALPN, server did not agree to a protocol

< HTTP/1.1 200 OK

フレーム

HTTP/2 になっても、HTTP のシンタックスは変わらない。HTTP の利用者は違いを意識しなくても利用できる。
だがその中身は大幅に変わっており、HTTP/1.1 とは全くの別物になっている。
HTTP/2 の最大の特徴は、データをバイナリ形式にエンコーディングして送受信するということ。HTTP/1.1 ではプレーンテキストでデータを送受信していた。

Deno で TCP サーバを書いて、確認してみる。

const listener = Deno.listenTls({
  port: 8443,
  certFile: "./cert.pem",
  keyFile: "./privkey.pem",
});

for await (const conn of listener) {
  const buffer = new Uint8Array(128);
  await conn.read(buffer);
  const decode = new TextDecoder().decode(buffer);
  console.log(decode); // 受け取ったデータを表示する
  conn.write(buffer);
  conn.closeWrite();
}

alpnProtocolsを設定していないので、HTTP/1.1 でやり取りが行われる。

この状態で$ curl https://localhost.numb86.net:8443/を実行してリクエストを送ると、以下のログが流れる。

GET / HTTP/1.1
Host: localhost.numb86.net:8443
User-Agent: curl/7.54.0
Accept: */*

次にalpnProtocolsを設定して HTTP/2 を利用するようにして、同様の確認を行う。
すると、流れるログは以下のようになる。

PRI * HTTP/2.0

SM

d?�&���A����    ^�i������M3z�%�P��S*/*

HTTP/1.1 はテキスト、HTTP/2 はバイナリで、やり取りを行っている。

そして HTTP/2 は、HTTP メッセージをバイナリ形式に変換するだけでなく、小さく分割してから送信している。
このひとつひとつの小さなバイナリデータを、フレームと呼ぶ。
HTTP/2 に対応しているクライアントやサーバは、HTTP メッセージをフレームへと分解してから送信し、それを受け取った側はフレームを組み立て HTTP メッセージを再構成していることになる。
なぜこんな面倒なことをしているのかというと、これによって、ひとつの TCP 接続のなかに複数の HTTP メッセージを混在させることができるからである。フレームという単位でやり取りが行われるため、ひとつの HTTP メッセージが TCP 接続を専有してしまうということがなくなった。

ストリーム

ひとつの TCP 接続のなかに複数の HTTP メッセージを混在させることができるということは、複数の HTTP メッセージを並列的に処理できるということである。
この概念を、ストリームという。ストリームは概念であり、実体はない。TCP 接続のなかにあるひとつひとつの HTTP メッセージをストリームと読んでいるに過ぎない。

各フレームは、自分がどのストリームに所属するのかを示す情報を持っている。そのため、フレームを受け取ったクライアントやサーバが、ストリーム毎にフレームを組み立てて、HTTP メッセージを復元することができる。
この仕組みによって、ひとつの TCP 接続のなかで、あたかも複数の TCP 接続が生まれているかのような状況になる。

実際 HTTP/2 は、ストリーム単位でのフロー制御を行っている。これはまさに TCP におけるフロー制御と同じメカニズムである。
フロー制御とは、一度に送信するデータの量について送信側と受信側で調整を行っていくプロセスで、輻輳制御などのために行われる。
つまり HTTP/2 の通信では、ストリームと TCP の 2 つのレベルでフロー制御していることになる。
HTTP/2 におけるフロー制御については、以下の記事が分かりやすかった。

qiita.com

パフォーマンスの向上

ストリームによって、パフォーマンスの向上が期待できる。
具体的に言うと、同一オリジンに対してはひとつの TCP 接続しか発生しないので、オーバーヘッドを削減できる可能性がある。
HTTP/1.1 では同一オリジンへのアクセスでも複数の TCP 接続が発生する可能性があり、その場合はその度に TCP ハンドシェイクが行われる。TLS 通信なら、さらに TLS のハンドシェイクも行われる。
HTTP/2 なら、これらのハンドシェイクはオリジン毎に一度で済む。

実際にサーバを書いて確認してみる。
まずはalpnProtocolsをコメントアウトして、HTTP/1.1 での挙動を調べる。

const ORIGIN = "https://localhost.numb86.net:8443";

const html = `
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>HTTP/2</title>
</head>
<body>
  <img src="/deno1.jpeg" width="200">
  <img src="/deno2.jpeg" width="200">
  <img src="/deno3.jpeg" width="200">
  <img src="/deno4.jpeg" width="200">
  <img src="/deno5.jpeg" width="200">
  <img src="/deno6.jpeg" width="200">
  <br>
  <img src="/deno7.jpeg" importance="low" width="200">
  <img src="/deno8.jpeg" importance="low" width="200">
  <img src="/deno9.jpeg" importance="low" width="200">
</body>
</html>
`;

const listener = Deno.listenTls({
  port: 8443,
  certFile: "./cert.pem",
  keyFile: "./privkey.pem",
  // alpnProtocols: ["h2", "http/1.1"],
});

for await (const conn of listener) {
  console.log(conn.rid, conn.remoteAddr); // このログを結果を調べる
  handleConn(conn);
}

function getPath(url: string): string {
  return url.slice(ORIGIN.length);
}
async function handleConn(conn: Deno.Conn) {
  const httpConn = Deno.serveHttp(conn);
  for await (const { request, respondWith } of httpConn) {
    const path =
      request.url.indexOf("http") === 0 ? getPath(request.url) : request.url;

    switch (true) {
      case /^\/$/.test(path): {
        respondWith(
          new Response(html, {
            status: 200,
            headers: { "Content-Type": "text/html" },
          })
        );
        break;
      }

      case /\.jpeg$/.test(path): {
        const number = Number(path.slice(5, -5));
        const resource = number <= 6 ? "./deno1.jpeg" : "./deno2.jpeg";
        const data = await Deno.readFile(resource);
        respondWith(
          new Response(data, {
            status: 200,
            headers: { "Content-Type": "image/jpeg" },
          })
        );
        break;
      }

      default: {
        respondWith(
          new Response("Not Found\n", {
            status: 404,
            headers: { "Content-Type": "text/plain" },
          })
        );
        break;
      }
    }
  }
}

deno1.jpegdeno2.jpegという名前で適当な画像を用意した上で、ブラウザでhttps://localhost.numb86.net:8443/にアクセスする。
そうすると以下のログが流れ、複数の TCP 接続が行われていることが分かる。

4 { transport: "tcp", hostname: "127.0.0.1", port: 62489 }
9 { transport: "tcp", hostname: "127.0.0.1", port: 62494 }
13 { transport: "tcp", hostname: "127.0.0.1", port: 62495 }
17 { transport: "tcp", hostname: "127.0.0.1", port: 62496 }
19 { transport: "tcp", hostname: "127.0.0.1", port: 62497 }
25 { transport: "tcp", hostname: "127.0.0.1", port: 62498 }

次にalpnProtocolsをアンコメントして、HTTP/2 で通信を行ってみる。

すると今度は、TCP 接続はひとつしか存在しない。

4 { transport: "tcp", hostname: "127.0.0.1", port: 63630 }

さらに、HTTP メッセージを並列的に処理できるという特徴により、「処理の重いレスポンスのせいで後続のリクエストをいつまでも送信できない」という問題を回避できるようになる。

HTTP/1.1 ではひとつの TCP で同時にやり取りできるのは、ひとつの HTTP メッセージだけである。
そのため、ひとつのオリジンに対してひとつの TCP 接続しか行えないとすると、同一オリジンから複数のリソースを取得したい場合に相当な時間がかかることになる。ひとつひとつ逐次処理していくことになる。
そのためブラウザは、ひとつのオリジンに対して複数の TCP 接続を行うことで、並列的なやり取りを可能にしている。

しかし TCP 接続を増やし続けることには、コストがある。サーバとクライアント、それからネットワーク経路上の中間機器のCPU やメモリを、消費することになる。
しかも先程も書いたように、TCP 接続を増やせば増やすほどオーバーヘッドが増える。
そのため主要なブラウザは、TCP の同時接続数を 6 つまでに制限している。
これは、7 つ以上のリソースを同一オリジンから同時に取得する場合、後続のリソースの取得については待機状態が発生することを意味する。
HTTP/2 ではひとつの TCP 接続のなかでほぼ無制限に並列的に処理できるので、待機状態は発生しない。

先程のコードでこのことを確認できる。
今度はサーバのログではなく、ブラウザの挙動を確認する。

なお、素材は以下のページのものを使っている。
Artwork | Deno

まずは HTTP/1.1。
同時に 6 つの画像までしか、ダウンロードできない。

f:id:numb_86:20210417110021g:plain

続いて HTTP/2。
全ての画像を並列してダウンロードできていることが分かる。

f:id:numb_86:20210417110119g:plain

現代のウェブページやウェブアプリケーションは、画像やスクリプトファイルなど、多数のリソースによって構成されている。
巨大なひとつのリソースをダウンロードするのではなく、それほど大きくはないリソースを多数ダウンロードする、という傾向が強い。
そのため、余計なハンドシェイクを減らし、複数のリソースを並列的にダウンロードできる HTTP/2 を利用することで、パフォーマンスを大きく改善できる可能性がある。

ストリームの優先順位

全てのストリームを平等に並列処理するのではなく、特定のストリームを優先的に処理して速くレスポンスを返して欲しいケースもある。
例えばスタイルシートや JavaScript ファイルはブラウザの描画処理に影響を与えるので、早めに取得しておきたい。逆に、後回しにしても影響が少ないリソースもあるかもしれない。
HTTP/2 には、各ストリームに優先度を設定するため仕組みが用意されている。
これを使うことで、優先的にレスポンスを返して欲しいストリームを、サーバに伝えることができる。

優先順位の設定については以下の記事が分かりやすかった。

qiita.com

HTTP/2 が提供しているのは優先順位を伝えるための仕組みだけで、優先順位を決定するロジック等は定義されていない。
どのように優先順位を決めるのかは、実装によって異なる。
実際には、ブラウザが自動的に決めることになる。<img src="img/1.png" importance="high">のようにアプリケーションの開発者が指定できるようなのだが、それも参考にしつつ、最終的にはブラウザが決定する。
そしてサーバも、その優先順位に必ず従わなければならないわけではない。そのため、目安くらいに考えておいたほうがよいのかもしれない。

ドメインシャーディングはアンチパターンになる

前述の通り、HTTP/1.1 ではひとつのオリジンに対して 6 つしか TCP を同時接続できない。
この制限に対処するために、様々な工夫が用いられてきた。
例えば、CSS スプライトなどを使って複数のリソースをひとつのファイルにまとめることで、HTTP リクエストの数を減らすことができる。

他にも、ドメインシャーディングというテクニックが使われていた。
これは、意図的に複数のドメインからリソースを配信することで、TCP の接続数の制限を回避する手法。
ドメインが異なるということはオリジンが異なるということなので、6 つ以上の同時接続を実現できる。
例えばexample.comsub.example.comの 2 つのドメインからリソースを配信することで、合計で 12 個まで TCP を同時接続できるようになる。

だがこのドメインシャーディングは、HTTP/2 を利用できる環境においては不要になる。
既述したように、HTTP/2 ではひとつの TCP 接続のなかで並列的に HTTP メッセージを送受信できる。 わざわざドメインを分けて配信する必要はない。
むしろドメインを分けることで、せっかくひとつの TCP 接続で済んでいたものが、example.comsub.example.comのふたつの TCP 接続に増えてしまう。
こうするとオーバーヘッドが増えてしまう。手間を掛けて HTTP/2 のメリットを捨てていることになる。

このように、HTTP/2 の恩恵を最大限に引き出すためには、HTTP/2 の仕組みや原理をしっかり理解しておく必要がある。
そうしないと、間違ったテクニックや効果のないテクニックを使い続けることになってしまう。
これはどのような技術やプロトコルにも言える。利便性の高いインターフェイスのおかげで簡単に使えるとしても、互換性のおかげで違いを意識しないで済んだとしても、その中身や背景について詳しく理解していればしているほど、性能を引き出せるようになる。

HTTP ヘッダの圧縮

ウェブの用途が広がり、HTTP が高機能になるにつれ、HTTP ヘッダは肥大化していった。特に Cookie などの情報を持っている場合、サイズはかなり大きくなる。
しかも HTTP はステートレスな通信なので、ヘッダは毎回送信される。
このため、大きなオーバーヘッドが発生しやすい。
HTTP/2 ではHPACKという形式で HTTP ヘッダを圧縮することで、このオーバーヘッドの削減を図っている。

参考資料