T-CREATOR

WebAssembly の仕組みを図解:モジュール・メモリ・テーブル・インポート/エクスポート

WebAssembly の仕組みを図解:モジュール・メモリ・テーブル・インポート/エクスポート

WebAssembly(wasm)を実際に使おうとすると、「モジュールって何?」「メモリはどう管理するの?」「JavaScript との連携はどうなってるの?」といった疑問に直面することがあります。公式ドキュメントを読んでも、抽象的な説明が多く、具体的なイメージが掴みにくいですよね。

本記事では、WebAssembly の内部構造を モジュールメモリテーブルインポート/エクスポート の 4 つの観点から図解し、それぞれがどのように連携するかを丁寧に解説します。実際のコード例も交えながら、初心者の方でも WebAssembly の仕組みを直感的に理解できるようになりますよ。

背景

WebAssembly とは

WebAssembly(wasm)は、ブラウザやサーバ上で高速に実行できるバイナリフォーマットです。C・C++・Rust・Go などの言語でコンパイルされたコードを、JavaScript と同じ環境で動かせるため、パフォーマンスが求められる処理を効率的に実行できます。

しかし、WebAssembly は JavaScript とは異なる設計思想を持っています。JavaScript がオブジェクト指向や動的型付けを前提とするのに対し、WebAssembly は 線形メモリテーブル といった低レベルな概念を扱います。

なぜ内部構造を知る必要があるのか

WebAssembly を使う上で、以下のような場面で内部構造の理解が役立ちます。

  • JavaScript との間でデータをやり取りする
  • メモリ不足やクラッシュをデバッグする
  • 複数の WebAssembly モジュールを組み合わせる
  • パフォーマンスを最大限に引き出す

内部構造を知っていれば、エラーメッセージの意味や最適な設計パターンが見えてくるでしょう。

下記の図は、WebAssembly が JavaScript と連携しながら動作する基本的な流れを示しています。

mermaidflowchart TB
  wasmFile[".wasm ファイル"]
  jsLoader["JavaScript<br/>ローダー"]
  wasmModule["WebAssembly<br/>モジュール"]
  wasmInstance["WebAssembly<br/>インスタンス"]
  jsEnv["JavaScript 環境"]

  wasmFile -->|fetch + compile| jsLoader
  jsLoader -->|WebAssembly.compile| wasmModule
  wasmModule -->|instantiate| wasmInstance
  wasmInstance <-->|関数呼び出し<br/>データ交換| jsEnv

図で理解できる要点

  • .wasm ファイルはコンパイル後にモジュールとして扱われる
  • インスタンス化することで実行可能になる
  • JavaScript とは相互に関数呼び出しやデータ交換が可能

課題

WebAssembly を使う際につまずくポイント

WebAssembly を学び始めると、以下のような課題に直面します。

モジュールとインスタンスの違いが分かりにくい

「モジュール」と「インスタンス」という用語が出てきますが、両者の違いや役割が直感的に理解しづらいことがあります。特に、複数のインスタンスを生成する場合や、モジュールを再利用する場合に混乱しやすいでしょう。

メモリ管理が JavaScript と大きく異なる

JavaScript はガベージコレクション(GC)によってメモリを自動管理しますが、WebAssembly では 線形メモリ という連続したバイト配列を手動で管理します。メモリのサイズ指定や、メモリ成長の仕組みを理解しないと、Out of Memory エラーに遭遇しやすくなります。

テーブルの役割が不明瞭

WebAssembly には「テーブル」という概念がありますが、これが何のために存在し、どのように使うのかが分かりにくいです。関数ポインタや動的ディスパッチと関連がありますが、具体的なユースケースが見えにくいことがあります。

インポート/エクスポートの仕組みが複雑

JavaScript と WebAssembly の間で関数やメモリをやり取りする際、インポート/エクスポートという仕組みを使います。しかし、どちらが何をインポートし、どちらが何をエクスポートするのか、記述方法が煩雑で混乱しやすいです。

下記の図は、初学者が直面しやすい課題の関係性を示しています。

mermaidflowchart LR
  conceptGap["概念の理解困難"]
  memoryIssue["メモリ管理の違い"]
  tableConfusion["テーブルの役割不明"]
  importExport["インポート/エクスポートの複雑さ"]

  conceptGap -->|原因| memoryIssue
  conceptGap -->|原因| tableConfusion
  conceptGap -->|原因| importExport

  memoryIssue -->|結果| errors["Out of Memory<br/>エラー"]
  tableConfusion -->|結果| unused["機能を活用できない"]
  importExport -->|結果| linkError["LinkError<br/>TypeError"]

図で理解できる要点

  • 概念の理解不足が様々な課題を引き起こす
  • それぞれの課題が具体的なエラーに繋がる
  • 内部構造の理解が課題解決の鍵

解決策

WebAssembly の仕組みを理解するために、モジュールメモリテーブルインポート/エクスポート の 4 つの要素を順に見ていきましょう。

モジュールとインスタンス

WebAssembly の モジュール は、コンパイルされたバイナリコード(.wasm ファイル)を表します。モジュールは設計図のようなもので、それ自体は実行できません。

インスタンス は、モジュールから生成される実行可能なオブジェクトです。同じモジュールから複数のインスタンスを生成できます。

mermaidflowchart LR
  module["WebAssembly<br/>モジュール<br/>(設計図)"]
  instance1["インスタンス 1<br/>(実行可能)"]
  instance2["インスタンス 2<br/>(実行可能)"]
  instance3["インスタンス 3<br/>(実行可能)"]

  module -->|instantiate| instance1
  module -->|instantiate| instance2
  module -->|instantiate| instance3

図で理解できる要点

  • モジュールは 1 つの設計図として再利用可能
  • インスタンスは独立した実行環境を持つ
  • 同じモジュールから複数インスタンスを生成できる

モジュールの構造

WebAssembly モジュールは、以下のセクションで構成されています。

#セクション名説明
1Type関数シグネチャの定義
2Import外部からインポートする関数・メモリ・テーブル
3Functionモジュール内の関数定義
4Table関数参照を格納するテーブル
5Memory線形メモリの定義
6Export外部に公開する関数・メモリ・テーブル
7Code関数の実装コード
8Dataメモリの初期データ

これらのセクションが組み合わさって、1 つの WebAssembly モジュールを構成しているのです。

メモリ(線形メモリ)

WebAssembly の メモリ は、連続したバイト配列として表現されます。JavaScript の ArrayBuffer と似ていますが、WebAssembly 専用の管理機構を持ちます。

メモリの特徴

メモリには以下の特徴があります。

  • ページ単位で管理: 1 ページ = 64KB(65536 バイト)
  • 初期サイズと最大サイズ: モジュール作成時に指定可能
  • 成長可能: memory.grow 命令で動的に拡張できる
  • JavaScript と共有: JavaScript 側から WebAssembly.Memory オブジェクトとしてアクセス可能

メモリの宣言例(JavaScript 側)

JavaScript から WebAssembly のメモリを作成する例を見てみましょう。

javascript// WebAssembly のメモリを作成
const memory = new WebAssembly.Memory({
  initial: 10, // 初期サイズ 10 ページ(640KB)
  maximum: 100, // 最大サイズ 100 ページ(6.4MB)
});

上記のコードでは、初期サイズ 10 ページ、最大サイズ 100 ページのメモリを作成しています。

メモリへのアクセス(JavaScript 側)

JavaScript からメモリにアクセスするには、ArrayBuffer を介します。

javascript// メモリの ArrayBuffer を取得
const buffer = memory.buffer;

// Int32Array として操作(4 バイトずつアクセス)
const int32View = new Int32Array(buffer);

// 最初の要素に値を書き込む
int32View[0] = 42;

// 読み取り
console.log(int32View[0]); // 42

WebAssembly 側からも、同じメモリ領域にアクセスできます。

下記の図は、メモリの構造と JavaScript との関係を示しています。

mermaidflowchart TB
  wasmMemory["WebAssembly Memory<br/>(線形メモリ)"]
  arrayBuffer["ArrayBuffer<br/>(生のバイト列)"]
  typedArray["Typed Array<br/>(Int32Array, Uint8Array など)"]
  jsAccess["JavaScript 側<br/>からのアクセス"]
  wasmAccess["WebAssembly 側<br/>からのアクセス"]

  wasmMemory -->|buffer プロパティ| arrayBuffer
  arrayBuffer -->|ビューとして操作| typedArray
  typedArray --> jsAccess
  wasmMemory --> wasmAccess

図で理解できる要点

  • WebAssembly メモリと JavaScript の ArrayBuffer は同一の領域
  • Typed Array を介して JavaScript から読み書き可能
  • WebAssembly 側も同じメモリ領域を直接操作できる

テーブル(関数参照テーブル)

WebAssembly の テーブル は、関数参照を格納するための配列です。主に、動的な関数呼び出しや関数ポインタを実現するために使われます。

テーブルの役割

WebAssembly では、関数を直接変数に代入したり、動的に呼び出したりすることができません。そこで、テーブルを使って関数参照を管理します。

テーブルには以下の特徴があります。

  • 要素型: funcref(関数参照)が主流
  • サイズ: 初期サイズと最大サイズを指定可能
  • 成長可能: table.grow 命令で拡張できる
  • JavaScript と共有: JavaScript 側から WebAssembly.Table オブジェクトとしてアクセス可能

テーブルの宣言例(JavaScript 側)

JavaScript から WebAssembly のテーブルを作成する例を見てみましょう。

javascript// WebAssembly のテーブルを作成
const table = new WebAssembly.Table({
  element: 'anyfunc', // 関数参照を格納
  initial: 10, // 初期サイズ 10 要素
  maximum: 50, // 最大サイズ 50 要素
});

上記のコードでは、関数参照を格納するテーブルを作成しています。

テーブルへのアクセス(JavaScript 側)

JavaScript からテーブルに関数を設定する例です。

javascript// JavaScript の関数を定義
function add(a, b) {
  return a + b;
}

// テーブルの 0 番目に関数を設定
table.set(0, add);

// テーブルから関数を取得して呼び出し
const fn = table.get(0);
console.log(fn(10, 20)); // 30

WebAssembly 側からも、テーブルを介して関数を動的に呼び出せます。

下記の図は、テーブルの構造と使われ方を示しています。

mermaidflowchart LR
  table["WebAssembly Table<br/>(関数参照配列)"]
  func0["関数 0<br/>add()"]
  func1["関数 1<br/>multiply()"]
  func2["関数 2<br/>subtract()"]
  callSite["呼び出し元<br/>(WebAssembly or JS)"]

  func0 -.->|参照| table
  func1 -.->|参照| table
  func2 -.->|参照| table
  callSite -->|インデックス指定<br/>で呼び出し| table

図で理解できる要点

  • テーブルは関数参照の配列として機能
  • インデックスを指定して動的に関数を呼び出せる
  • JavaScript と WebAssembly の両方から利用可能

インポート/エクスポート

WebAssembly では、JavaScript と相互に関数・メモリ・テーブルをやり取りするために インポート/エクスポート の仕組みを使います。

インポート

WebAssembly モジュールが外部から関数・メモリ・テーブルを受け取ることを「インポート」と呼びます。JavaScript 側でこれらを用意し、WebAssembly モジュールに渡します。

エクスポート

WebAssembly モジュールが外部に関数・メモリ・テーブルを公開することを「エクスポート」と呼びます。JavaScript 側から WebAssembly の関数を呼び出す際に使います。

下記の図は、インポート/エクスポートの流れを示しています。

mermaidflowchart TB
  subgraph jsEnv["JavaScript 環境"]
    jsFunc["JavaScript<br/>関数"]
    jsMem["JavaScript が作成した<br/>Memory"]
    jsTable["JavaScript が作成した<br/>Table"]
  end

  subgraph wasmMod["WebAssembly モジュール"]
    importFunc["インポートした<br/>関数"]
    importMem["インポートした<br/>Memory"]
    importTable["インポートした<br/>Table"]
    exportFunc["エクスポートする<br/>関数"]
    exportMem["エクスポートする<br/>Memory"]
    exportTable["エクスポートする<br/>Table"]
  end

  jsFunc -->|インポート| importFunc
  jsMem -->|インポート| importMem
  jsTable -->|インポート| importTable

  exportFunc -->|エクスポート| jsEnv
  exportMem -->|エクスポート| jsEnv
  exportTable -->|エクスポート| jsEnv

図で理解できる要点

  • JavaScript から WebAssembly にリソースを渡すのがインポート
  • WebAssembly から JavaScript にリソースを公開するのがエクスポート
  • 双方向のやり取りが可能

インポートの具体例

JavaScript 側で関数を定義し、WebAssembly にインポートする例を見てみましょう。

javascript// JavaScript 側で関数を定義
const importObject = {
  env: {
    // WebAssembly 側から呼び出せる関数
    log: function (arg) {
      console.log('WebAssembly から呼ばれました:', arg);
    },
  },
};

上記の importObject を WebAssembly のインスタンス化時に渡します。

javascript// WebAssembly モジュールをインスタンス化
WebAssembly.instantiateStreaming(
  fetch('module.wasm'),
  importObject
).then((result) => {
  // WebAssembly のインスタンスを取得
  const instance = result.instance;

  // エクスポートされた関数を呼び出す
  instance.exports.run();
});

WebAssembly 側では、env.log という名前でインポートした関数を呼び出せます。

エクスポートの具体例

WebAssembly 側で関数をエクスポートする例です(WAT 形式で記述)。

wasm(module
  ;; 関数をエクスポート
  (func $add (param $a i32) (param $b i32) (result i32)
    local.get $a
    local.get $b
    i32.add
  )
  (export "add" (func $add))
)

上記のコードでは、add という名前で関数をエクスポートしています。

JavaScript 側からは、以下のように呼び出せます。

javascript// エクスポートされた関数を呼び出し
const result = instance.exports.add(10, 20);
console.log(result); // 30

このように、インポート/エクスポートを使うことで、JavaScript と WebAssembly がシームレスに連携できます。

メモリとテーブルのインポート/エクスポート

関数だけでなく、メモリやテーブルもインポート/エクスポートできます。

javascript// JavaScript 側でメモリを作成
const memory = new WebAssembly.Memory({
  initial: 10,
  maximum: 100,
});

// JavaScript 側でテーブルを作成
const table = new WebAssembly.Table({
  element: 'anyfunc',
  initial: 10,
  maximum: 50,
});

// インポートオブジェクトに含める
const importObject = {
  env: {
    memory: memory,
    table: table,
  },
};

WebAssembly 側では、インポートしたメモリとテーブルを利用できます。

下記の図は、インポート/エクスポートの全体像を示しています。

mermaidflowchart TB
  subgraph jsCode["JavaScript コード"]
    jsFuncDef["関数定義<br/>log()"]
    jsMemDef["Memory 作成"]
    jsTableDef["Table 作成"]
    jsImportObj["importObject<br/>組み立て"]
    jsInstantiate["instantiate<br/>呼び出し"]
    jsCallExport["エクスポートされた<br/>関数呼び出し"]
  end

  subgraph wasmModule["WebAssembly モジュール"]
    wasmImport["インポート宣言<br/>(env.log, memory, table)"]
    wasmFunc["内部関数<br/>処理"]
    wasmExport["エクスポート宣言<br/>(add, memory, table)"]
  end

  jsFuncDef --> jsImportObj
  jsMemDef --> jsImportObj
  jsTableDef --> jsImportObj
  jsImportObj --> jsInstantiate
  jsInstantiate -->|渡す| wasmImport
  wasmImport --> wasmFunc
  wasmFunc --> wasmExport
  wasmExport -->|返す| jsCallExport

図で理解できる要点

  • JavaScript で定義したリソースを importObject にまとめる
  • instantiate 時に WebAssembly にリソースを渡す
  • WebAssembly はエクスポートで JavaScript にリソースを返す

具体例

ここでは、実際に WebAssembly モジュールを作成し、JavaScript から利用する具体例を見ていきましょう。

WAT(WebAssembly Text Format)で書くシンプルなモジュール

WebAssembly のテキスト形式(WAT)を使って、シンプルなモジュールを作成します。

モジュールの全体構造

wasm(module
  ;; メモリを定義(1 ページ = 64KB)
  (memory $mem 1)

  ;; メモリをエクスポート
  (export "memory" (memory $mem))

  ;; 加算関数を定義
  (func $add (param $a i32) (param $b i32) (result i32)
    local.get $a
    local.get $b
    i32.add
  )

  ;; 加算関数をエクスポート
  (export "add" (func $add))
)

上記のコードでは、1 ページ分のメモリと、2 つの整数を加算する関数を定義しています。

WAT をバイナリ(wasm)に変換

WAT をバイナリにコンパイルするには、wat2wasm ツールを使います。

bash# wat2wasm のインストール(Homebrew の場合)
brew install wabt

# WAT をバイナリに変換
wat2wasm simple.wat -o simple.wasm

これで、simple.wasm というバイナリファイルが生成されます。

JavaScript から WebAssembly を読み込む

生成した WebAssembly モジュールを JavaScript から読み込んで実行します。

モジュールの読み込みとインスタンス化

javascript// WebAssembly モジュールを読み込む
fetch('simple.wasm')
  .then((response) => response.arrayBuffer())
  .then((bytes) => WebAssembly.instantiate(bytes))
  .then((result) => {
    // インスタンスを取得
    const instance = result.instance;

    console.log(
      'WebAssembly モジュールの読み込みが完了しました'
    );
  });

上記のコードでは、fetch で .wasm ファイルを取得し、WebAssembly.instantiate でインスタンス化しています。

エクスポートされた関数を呼び出す

javascript// エクスポートされた add 関数を呼び出す
const result = instance.exports.add(100, 200);
console.log('100 + 200 =', result); // 100 + 200 = 300

このように、JavaScript から WebAssembly の関数を簡単に呼び出せます。

メモリにデータを書き込む・読み取る

WebAssembly のメモリにアクセスしてみましょう。

javascript// エクスポートされたメモリを取得
const memory = instance.exports.memory;

// メモリの ArrayBuffer を取得
const buffer = memory.buffer;

// Uint8Array としてメモリにアクセス
const uint8View = new Uint8Array(buffer);

// メモリの先頭に文字列 "Hello" を書き込む
const text = 'Hello';
for (let i = 0; i < text.length; i++) {
  uint8View[i] = text.charCodeAt(i);
}

console.log('メモリに書き込みました:', text);

上記のコードでは、メモリの先頭に "Hello" という文字列をバイト列として書き込んでいます。

javascript// メモリから読み取る
let readText = '';
for (let i = 0; i < 5; i++) {
  readText += String.fromCharCode(uint8View[i]);
}

console.log('メモリから読み取り:', readText); // "Hello"

WebAssembly 側からも、同じメモリ領域にアクセスできるため、JavaScript と WebAssembly でデータを共有できます。

JavaScript 関数をインポートして使う

次に、JavaScript の関数を WebAssembly にインポートして使う例を見てみましょう。

WAT でインポートを宣言

wasm(module
  ;; JavaScript から log 関数をインポート
  (import "env" "log" (func $log (param i32)))

  ;; 計算結果をログ出力する関数
  (func $compute (param $a i32) (param $b i32)
    local.get $a
    local.get $b
    i32.add
    call $log
  )

  ;; compute 関数をエクスポート
  (export "compute" (func $compute))
)

上記のコードでは、env.log という名前で JavaScript の関数をインポートしています。

JavaScript 側でインポートオブジェクトを用意

javascript// JavaScript 側で log 関数を定義
const importObject = {
  env: {
    log: function (value) {
      console.log('WebAssembly からの出力:', value);
    },
  },
};

// WebAssembly モジュールをインスタンス化
fetch('import_example.wasm')
  .then((response) => response.arrayBuffer())
  .then((bytes) =>
    WebAssembly.instantiate(bytes, importObject)
  )
  .then((result) => {
    const instance = result.instance;

    // compute 関数を呼び出す(10 + 20 の結果がログ出力される)
    instance.exports.compute(10, 20);
    // 出力: "WebAssembly からの出力: 30"
  });

このように、JavaScript の関数を WebAssembly から呼び出すことができます。

テーブルを使った動的な関数呼び出し

最後に、テーブルを使って動的に関数を呼び出す例を見てみましょう。

WAT でテーブルを定義

wasm(module
  ;; テーブルを定義(関数参照を 3 つ格納)
  (table $funcTable 3 funcref)

  ;; 3 つの関数を定義
  (func $funcA (result i32)
    i32.const 10
  )

  (func $funcB (result i32)
    i32.const 20
  )

  (func $funcC (result i32)
    i32.const 30
  )

  ;; テーブルに関数参照を登録
  (elem (i32.const 0) $funcA $funcB $funcC)

  ;; インデックスを指定して関数を呼び出す
  (func $callByIndex (param $index i32) (result i32)
    local.get $index
    call_indirect (type 0)
  )

  ;; 型定義
  (type (func (result i32)))

  ;; エクスポート
  (export "callByIndex" (func $callByIndex))
  (export "funcTable" (table $funcTable))
)

上記のコードでは、3 つの関数をテーブルに登録し、インデックスを指定して動的に呼び出せるようにしています。

JavaScript 側から動的に関数を呼び出す

javascriptfetch('table_example.wasm')
  .then((response) => response.arrayBuffer())
  .then((bytes) => WebAssembly.instantiate(bytes))
  .then((result) => {
    const instance = result.instance;

    // インデックス 0 の関数を呼び出す(funcA)
    console.log(instance.exports.callByIndex(0)); // 10

    // インデックス 1 の関数を呼び出す(funcB)
    console.log(instance.exports.callByIndex(1)); // 20

    // インデックス 2 の関数を呼び出す(funcC)
    console.log(instance.exports.callByIndex(2)); // 30
  });

このように、テーブルを使うことで、実行時に呼び出す関数を動的に切り替えられます。

下記の図は、今回の具体例で扱った要素の関係を示しています。

mermaidflowchart TB
  watCode["WAT コード<br/>(テキスト形式)"]
  wasmBinary[".wasm ファイル<br/>(バイナリ)"]
  jsFetch["JavaScript<br/>fetch()"]
  wasmInst["WebAssembly<br/>インスタンス"]

  subgraph exports["エクスポート"]
    expFunc["関数<br/>(add, compute)"]
    expMem["メモリ"]
    expTable["テーブル"]
  end

  subgraph imports["インポート"]
    impFunc["JavaScript 関数<br/>(log)"]
  end

  watCode -->|wat2wasm| wasmBinary
  wasmBinary -->|fetch| jsFetch
  jsFetch -->|instantiate| wasmInst
  impFunc -->|渡す| wasmInst
  wasmInst --> exports

図で理解できる要点

  • WAT をバイナリにコンパイルして .wasm ファイルを生成
  • JavaScript で fetch してインスタンス化
  • インポート/エクスポートで相互連携

まとめ

本記事では、WebAssembly の仕組みを モジュールメモリテーブルインポート/エクスポート の 4 つの観点から解説しました。

モジュールとインスタンス では、モジュールが設計図であり、インスタンスが実行可能なオブジェクトであることを理解しました。同じモジュールから複数のインスタンスを生成できるため、効率的な再利用が可能です。

メモリ では、WebAssembly が線形メモリという連続したバイト配列を使い、JavaScript の ArrayBuffer と連携してデータをやり取りすることを学びました。ページ単位で管理され、動的に拡張できる点も重要でしたね。

テーブル では、関数参照を格納する配列として機能し、動的な関数呼び出しを実現できることを確認しました。JavaScript と WebAssembly の両方から利用でき、柔軟な設計が可能になります。

インポート/エクスポート では、JavaScript と WebAssembly が相互にリソースをやり取りする仕組みを理解しました。関数だけでなく、メモリやテーブルもインポート/エクスポートできるため、密接な連携が実現できます。

これらの仕組みを理解することで、WebAssembly のエラーメッセージの意味や、最適な設計パターンが見えてくるでしょう。実際にコードを書きながら、WebAssembly の世界をさらに深く探求してみてください。

関連リンク