🦊

JavaScriptとWasm間でデータ受け渡し

2023/08/04に公開

前回の記事はこちら。

https://zenn.dev/itte/articles/dc7471ffc2f76f

JavaScript側からWebAssembly(Wasm)の関数を呼び出すときに、引数も戻り値もJavaScriptのnumberしか使うことができませんでした。
今回の記事では、それ以外のデータ、例えば文字列や画像、バイナリを受け渡す方法を説明します。

DenoとZigに限った話ではありませんので、タイトルもJavaScriptとWasmにしました。

この記事はネイティブ言語の経験があり、メモリとポインタについて理解していることを前提とします。改めて、以降の説明のためにそれぞれ一言で振り返ってみます。

メモリとは、プログラムとプログラムが扱うデータを一時的に格納する空間です。

ポインタとは、あるデータをメモリのどこに格納したかを示す一意のインデックスです。

メモリを共有している

前の記事で、JavaScriptで次のようにメモリを確保しました。

const memory = new WebAssembly.Memory({ initial: 20, maximum: 100 })

実は、このメモリはJavaScriptからもWasmからもアクセスできる共有のメモリになっています。

JavaScriptとWasmの共有メモリ

メモリを共有しているということは、ポインタもJavaScriptとWasmで一致しているということになります。

例えば、Wasm側でポインタ100番のデータが64だったとします。すると、JavaScript側で100番のデータを参照してみると同じ64が入っています。

引数と戻り値にポインタを使う

JavaScriptとWasmでポインタが一致しているということは、受け渡したいデータがある場合は、メモリにデータを書き込んで、書き込んだ位置のポインタを渡せば良いのです。

JavaScriptからWasmにデータを渡すときは、あらかじめメモリにデータを書き込んでおき、その位置のポインタを引数に渡す。
WasmからJavaScriptにデータを返すときは、メモリにデータを書き込んで、その位置のポインタを戻り値として返す。

これだけです。

JavaScriptで動かせるWasmのポインタサイズは現在32ビットのみで、JavaScriptのnumberに収まります。そのため、引数と戻り値にポインタを使っても問題ありません。

さて、メモリというのは8ビットの記憶領域の連続で成っています。0番のポインタの位置には8ビット(1バイト)のデータが書き込め、1番のポインタの位置にも8ビットのデータが入っています。もし16ビット(2バイト)のデータを使いたいときはポインタ2つ分のメモリを確保して、先頭のポインタを受け渡します。
これにより、文字列や画像のような大きいデータも受け渡しできるようになります。

Wasm側でメモリを確保する

共有しているメモリはJavaScriptで確保しました。しかし、そのメモリの中でプログラムやデータをどの位置に置くかなど、細かい使用方法はWasmが管理しています。そのため、JavaScript側で好き勝手にメモリを書き換えるとWasmのプログラムが壊れます。

なので、JavaScriptはWasmが指定したメモリ領域にだけしかデータを書き込んではいけません。

よって次の手順でデータを渡すことになります。

  1. JavaScriptがWasmに対して「データを渡したいからデータを書き込める場所を作って」と依頼
  2. Wasmがメモリの中から領域を確保
  3. Wasmが「ここにだったら書き込んでいいですよ」と、その位置のポインタを返す
  4. JavaScriptがそのポインタの位置にデータを書き込む
  5. JavaScriptが「ここに書き込んだデータで関数を実行して」とポインタを渡す

JavaScriptでメモリの読み書き

Wasmがメモリの中から使用可能な領域を確保する方法は、使用している言語によって違いますのでそれぞれの言語を参照してください。

ここではJavaScriptでメモリを読み書きする方法を説明します。

共有しているメモリはこれでした。

const memory = new WebAssembly.Memory({ initial: 20, maximum: 100 })

このWebAssembly.Memoryにはbufferというプロパティがあります。これは、ArrayBufferもしくはSharedArrayBufferになっています。どちらも使い方はほぼ同じです。なので、あとはArrayBufferの使い方でメモリを読み書きできます。

ArrayBufferの使い方が分からない場合は「JavaScriptの型付き配列」でググってみてください。

文字列の読み書き

文字列やバイナリを扱う場合はUint8Arrayを使うと簡単です。

(例)pointer番の位置にサイズcapacityで確保されているメモリにUTF-8文字列を書き込む方法

const uint8view = new Uint8Array(memory.buffer, pointer, capacity)
uint8view.set(new TextEncoder().encode(text))

(例)pointer番の位置に長さlengthで記憶されているUTF-8文字列を読み込む方法

let uint8view = new Uint8Array(memory.buffer, pointer, length)
let text = new TextDecoder().decode(uint8view)

ポインタだけでは足りない

これまでの説明でデータ受け渡しはとても簡単のように思われるかもしれません。しかし実はデータを受け渡すときに、ポインタ以外で最低限共有しなければならない情報が2つあります。
これらのデータをどのように共有するかは、扱うデータの種類やプログラミングのやり方によって変わります。

確保するメモリのサイズ(キャパシティ)

データを格納するためにWasm側でメモリを確保するとします。そのとき確保するサイズ(以降キャパシティと呼ぶことにします)を共有しなければなりません。

例えば1MBのデータをJavaScriptからWasmに渡したい場合、Wasmに1MB以上のメモリを確保してもらう必要があります。JavaScriptがWasmに対して確保するキャパシティを指定するのか、Wasmが確保したキャパシティをJavaScriptに伝えるのか、どちらかが必要です。

渡したいデータに対してキャパシティが小さいということも想定できますので、そうならないように工夫するか、そうなったときにどうするか決めておく必要もあります。

なお、言語やプログラミングのやり方によっては、メモリの解放のために確保したキャパシティをどこかに保持しておく必要があります。

データのサイズ

動的にサイズが変わるデータを扱う場合は、確保したメモリに対して、何サイズ使ったのか(以降サイズと呼ぶことにします)という情報が必要になります。

例えばWasmで100バイトのデータを確保して、JavaScriptが「Hello」と5バイトしか書き込まなかったとします。そのポインタだけWasmに渡してしまうと、Wasmは100バイトすべてが文字列だと判断するしか無くなってしまいます。

ポインタ、キャパシティ、サイズの3点セット

というわけで、JavaScriptとWasmはお互いに、ポインタ、キャパシティ、サイズの3点セットの情報を共有しておく必要があります。

共有の方法はデータとして渡しても良いですし、この関数を使うときは〇バイトまでとあらかじめ両者で決めておいても良いです。

JavaScriptとZigの間でデータ受け渡し

次の記事で、JavaScript(Deno)とZigの間でデータを受け渡す具体的なコードを書きます。説明するのは、私が使っている一つの方法に過ぎません。本記事の説明だけで、ネイティブ言語の経験がある人はプログラミングできると思います。

https://zenn.dev/itte/articles/d7bc723edb0fce

Discussion