フューチャー技術ブログ

Go 1.24連載始まります&os.Root、WASMの最新のまとめ

恒例のGo新リリース記念連載が始まります。今回の1.24連載では次のような記事を予定しています。今回もたくさんメンバーが手を上げてくれたのと、土日は記事は出さないので、もしかしたら途中で1.24が先にリリースされちゃうかもしれません。

Date Title Author
1/27 インデックス+os.Root+WASM 渋川
1/28 testing.Context 真野さん
1/29 encoding/jsonのomitzero 島ノ江さん
1/30 templateの新文法(イテレータ) 大江さん
1/31 testing/synctest 棚井さん
2/3 strings関数 + encoding.TextAppender 市川燿さん
2/4 ツール管理回りの進化 辻さん

この記事では概要とos.Root、GoのWASM出力の現在地について紹介します。

Go 1.24更新のオーバービュー

RC2の段階のリリースノートを元にしています。ここで取り上げていない細かい修正も多いので全量を見たい方はリンク先の一次情報を参照してください。コードに影響ありそうな修正は以下のような内容かなと思います。この連載でも触れない内容もありますがご容赦ください。

  • ジェネリックタイプエイリアス(1.25で正式。1.24ではオプションで有効)
  • go getでツールの取得のための-toolオプションが追加
  • go buildがmainパッケージのバージョンを埋め込むようになった
  • Cgo高速化
  • 2-3%高速化(mapのアルゴリズムが改善されて早くなった、小さいオブジェクトのメモリアロケーションが効率よくなった、Mutex改善など)
  • FS回りでルートを限定した型が追加された(本記事で触れます)
  • ファイナライザを追加。新しい runtime.AddCleanup()runtime.SetFinalizer() よりも効率的で循環参照でもリークしなくなったり複数個の関数を1つのオブジェクトに登録できるようになった
  • 弱い参照を実現するweakパッケージ
  • 非同期テストサポート追加
  • 暗号回りの強化たくさん
  • jsonのタグでomitzeroが追加
  • go/typesがシーケンス型がイテレータを返せるようになった
  • net/http、暗号化しないHTTP/2があつかいやすくなった?
  • strings関数強化
  • encodingのTextAppender回りがいろいろ強化
  • sync.Map高速化
  • testingでContextサポート強化
  • templateでイテレータ対応が追加
  • wasmでGoの関数を公開できるように。今までは終わらないアプリとして実行しなければならなかったが用途が広がる?(本記事で触れます)

なお、前回も少し紹介したHTTP/3ですが、まだ今回は入りません。golang.org/x/net/quicはちょこちょこ継続的に更新されています。

os.Rootの追加

1.16で追加されたfs.FSはフューチャーブログでも何度か取り上げています。ファイルシステムを抽象化するもので、go embedで取り込んだファイルも普通のファイルも同じようにアクセスしたり、サードパーティのfs.FS実装ライブラリを利用することでオンメモリでファイルを扱ったりいろいろできます。

今回追加されたのは、ディレクトリトラバーサルという攻撃に対応した os.Root という型が追加されました。ディレクトリトラバーサルというのは思いがけずに安全ではないフォルダにアクセスされてしまい、情報が流出するという攻撃です。基本的にはワークフォルダよりも上のフォルダへのアクセスのことを指します。上に辿れるということは、もしユーザー名がわかっているのであればルートまで遡って、 ../../../Users/ユーザー名/.ssh なんてアクセスをされたら大変なことになってしまうのがお分かりでしょう。

ただ、今までも .. を単純に指定することはできませんでした。 ... を含むパスや絶対パスfs.FSのメソッドの引数でには入れられないようになっていました。 FS.OpenFS.Sub でファイルを開いたり、別フォルダに遷移するときに親を単純にたどれません。

dir := os.DirFS(".")
_, err := fs.Sub(dir, "..") // 親フォルダを探索
// エラー: sub ..: invalid argument

しかし、これには抜け穴がありました。以下のように親フォルダを参照するシンボリックリンクがあった場合には親フォルダが参照できてしまいます。

+ secret.txt
+ work
+ sub (..へのシンボリックリンク)
dir, _ := os.DirFS(".")
c, _ := fs.ReadFile(dir, "sub/secret.txt")
fmt.Printf("Content: %s\n", string(c))
// Content: danger!!

新しいos.Rootはこのようなシンボリックリンクを使った抜け穴もエラーとなって塞ぎます。より安全なプログラムにできます。Linuxではバインドマウントしたフォルダや/procといった特別なフォルダへのアクセスも防ぐようです。

dir, _ := os.OpenRoot(".")
_, err := fs.ReadFile(dir.FS(), "sub/secret.txt")
fmt.Printf("Err: %v\n", err)
// Err: openat sub/secret.txt: path escapes from parent

ただ、個人的には fs.FS はGoの悪い部分が目についてしまうAPI設計だなと思います。 fs.FS オブジェクトのAPIリファレンスだけを見ても何ができるのかが静的にはわかりません。Goの作法にある程度慣れている人であってもです。fs.FSからfs.GlobFS, fs.ReadDirFS, fs.ReadFileFS, fs.StatFS, fs.SubFSにキャストするか、fsパッケージが提供するfs.Glob(), fs.ReadFile(), fs.WalkDir()といった関数にfs.FSインスタンスを渡して動的チェックしなければ、実際に操作できるかはわからないという。

いっそのこと、os.Fileみたいに、ファクトリー関数(os.DirFS()os.OpenRoot())から返すオブジェクトは全部のメソッドが見えており、その一部を表現するために io.Readerなどを提供しておく、みたいな感じの方が良かったのではと思います。os.DirFSfs.FSを返すのがちょっとな、と。

fs.FSインタフェースには書き込み用のAPIがない点もいまいちだなー、と思っていました。サードパーティー用のfs.FS実装のいくつかが書き込み用APIを独自に追加していますが、メソッド名や引数のコンセンサスが取れていないため、インタフェースで標準化するメリットが消えてしまっていました。今回のos.Rootfs.FSの外で Root.Create(), Root.Mkdir(), Root.Open(), Root.OpenFile(), Root.Remove()といった変更用メソッドを追加しました。どうせならこのあたりのメソッド群をまとめて扱うインタフェースも1.24で新たに登録すれば良かったのに、と思わないでもないです。これらのメソッドのインターフェイスの追加は今後に期待します。

WASM出力の現在地

WASM機能がいくつかアップデートされました。

  • WASIモードのときに//go:wasmexportコンパイラディレクティブがサポート
  • //go:wasmimportコンパイラディレクティブで使える型が増えた

Go 1.24ではWASMには1+2つのモードがサポートされています。今回、リアクターモジュール対応が追加されました。

モード From ランタイム GOOS GOARCH ビルドモード 起動
Vanilla 1.11 wasm_exec.js js wasm go.run()
WASIコマンドモジュール 1.21 WASI wasip1 wasm wasi.start()
WASIリアクターモジュール new!! 1.24 WASI wasip1 wasm -buildmode=c-shared wasi.initialize()

みなさんはすでにGoならわかるシステムプログラミングは本を購入されて読まれていて、耳タコだとは思いますが、プログラムがネットワークアクセスする、ファイル読み書き、メモリ確保などを行うには、ランタイムというアプリケーションコードを支えるライブラリのレイヤーからシステムコールを通じてOSに依頼をして実施してもらう必要があります。

WASMはセキュリティを重視しており、呼び出し側からJavaScriptの関数として明示的に与えたもの経由でしか外へのアクセスができません。WASIモードではないVanillaなGo製のWASMを使う場合に、必ず wasm_exec.js というコードをロードして、その中のクラスを new してWASMのインスタンス化をしていましたが、この関数に含まれるものが、Goの実行に必要な関数をJavaScript側の機能を使って実装してインターフェースしているコード群です。それを共通化し、言語やOSによる違いがない世界を作ろうというのがWASIです。

WASI自体はpreview2がすでに安定版となっていますが、Goはpreview1のみをサポートしています。preview2だと、コマンドモジュールとかリアクターとかはコンポーネントモデルとして再定義されていて、エントリーポイントの関数も変わっていたりします。

Vanilla版か、WASI版かによる違いは、ロード時に呼ぶ WebAssembly.instantiate() (ブラウザ上の実行の場合で fetch()でWASMをロードする場合はWebAssembly.instantiateStreaming())の2つめの引数が違うだけになります。また、Vanilla版、WASIコマンドモジュールは main()から実行されるプログラムとなります。WASIリアクターモジュールはランタイムの初期化だけを事前に行う必要がありますが、必要なロジックだけJavaScriptから呼び出して利用します。

ロードのコード

Vanilla版のWASMのロードのコード

Goは wasm_exec.js を使います。これはいつのころから、 $GOROOT/misc/wasm_exec.jsではなく$GOROOT/lib/wasm/wasm_exec.jsに変わったようです。
GoランタイムとJSのランタイムのラッパーはgo.importObjectで提供され、エントリーポイント(Goコンパイラがinstance.exports.run()として生成)の呼び出しはwasm_exec.jsが提供する関数の中で行われます。

import fs from "node:fs";
import "./wasm_exec.js";

const go = new Go();
const {module, instance} = await WebAssembly.instantiate(fs.readFileSync("./main.wasm"), go.importObject);
// ブラウザだと
// WebAssembly.instantiateStreaming(fetch("./main.wasm"), go.importObject);

go.run(instance);

WASIコマンドモジュールのWASMのロードのコード

Go固有のコードはなくなります。

import fs from "node:fs";
import { WASI } from 'node:wasi';

const wasi = new WASI({
version: 'preview1',
args: process.args,
env: process.env,
preopens: {
'/local': './',
},
});

const {module, instance} = await WebAssembly.instantiate(fs.readFileSync("./main.wasm"), wasi.getImportObject());

wasi.start(instance); // ここが違う

WASIリアクターモジュールのWASMのロードのコード

こちらもGo固有のコードはなくなります。

import fs from "node:fs";
import { WASI } from 'node:wasi';

const wasi = new WASI({
version: 'preview1',
args: process.args,
env: process.env,
preopens: {
'/local': './',
},
});

const {module, instance} = await WebAssembly.instantiate(fs.readFileSync("./main.wasm"), wasi.getImportObject());

wasi.initilize(instance); // ここが違う

JSとの連携

Vanilla/WASMコマンドモジュールの関数をJSから呼び出す

この2つはmain()は終わらない関数としてGo側で定義します。JS側から呼べるようにJSのグローバル空間に関数を登録して、これをJSに呼んでもらいます。以下のコードはグローバル名前空間に add 関数を登録しています。syscall/jsパッケージを駆使して、JSとの値の交換のためのグルーコードをいちいち作成しないといけないので不便でした。

import (
"syscall/js"
)

func _add(a, b int) int {
}

// js.Valueを駆使して情報をやり取りするグルーコード
func add(this js.Value, args []js.Value) any {
return js.ValueOf(_add(args[0].Int(), args[1].Int()))
}

func main() {
js.Global().Set("add", js.FuncOf(add)) // こういう関数を登録
c := make(chan struct{}, 0)
<-c
}

WASMリアクターモジュールをJSから呼び出す

お待ちかねの1.24から導入されたWASMリアクターモードですが、こちらでもmain()関数がないとビルドエラーになりますが、終わらない関数といった小細工を実装する必要はないですし、JavaScript側に何かを登録する必要もないので、空のmain()関数だけ書いておけばOKです。関数もsyscall/jsを駆使する必要はありません。だいぶクリーンなGoコードに見えますね。

以下のようにaddという名前を登録しておくと、ロードで帰ってくる instance.exportsにこの名前で関数が登録済みの状態になるため、 instance.exports.add()と呼べるようになります。グローバル名前空間を汚さなくて済みますね。ただし文字列などを扱うのはいろいろ厄介です。そのうち別記事で紹介しようかと思います。

//go:wasmexport add
func Add(a, b int32) int32 {
return a + b
}

func main() {
// 空で良い
}

JS側コードの呼び出し

//go:wasmimportディレクティブでJavaScriptの関数をGo側に取り込むことが可能です。下記のコードはJavaScriptでmyModuleというモジュールに登録されたcustomLogic関数をインポートしてきて、それを使って計算を行う関数をエクスポートしています。この例ではWASIリアクターモジュールの例になっていますが、インポート自体は他のモードでもいけます。

//go:wasmimport myModule customLogic
func CustomLogic(a, b int32) int32

//go:wasmexport execCalc
func ExecCalc() int32 {
return CustomLogic(1, 2)
}

このインポートするモジュールはWebAssembly.instantiate()の2つめの引数(オブジェクト)のキーです。

const myModule = {
// go:wasmimportの2つめのcustomLogicはこのオブジェクトのキー
customLogic: (a, b) => a + b,
}

// WASIやwasm_exec.jsの提供するランタイムと混ぜて1つのオブジェクトにしておく
const imports = {
// go:wasmimportの1つめのmyModuleはこのオブジェクトのキー
myModule,
...wasi.getImportObject()
}

const {module, instance} = await WebAssembly.instantiate(
fs.readFileSync("./main.wasm"),
imports);

で、実際WASMのコーディングにはどれを使えばいいの

GoでWASMを作成するとして、メインのアプリケーションコードはJavaScript側で実装するはずなのでライブラリ的なコードを作りたいという人が大半でしょう。

今回追加されたWASIのリアクターモードはボイラープレートコードがかなり少なくなるのでとても扱いやすくなります。Node.jsから扱う場合にはこちらを利用する方向で良いでしょう。

しかし、ブラウザはWASIをサポートしていないので、ブラウザだとVanilla版を使うというのがメインになると思われます。そうなると、JSからGoを呼ぶにはたくさんコードを書かないといけないので嬉しくないですし今回のアップデートも使えないということになってしまいます。WASI Polyfillで検索するといくつかページがヒットします。そこまでの検証はしていませんがそれらが実用的であれば今後リアクターモード一択になる可能性があります。

なお、Denoは2024/11の2.1バージョンのアップデートで、WASMを直接 import できるようになりました。ですが、これはWebAssembly.instantiate()の2つめの引数の追加のインポート情報を取れないので、残念ながらこれの恩恵には授かれなそうです。今後DenoのWASI対応が進めば便利な世界がやってくる可能性はありそうです。

// Deno 2.1から直接インポートできるようになったらしいがGoでは今の所使えなそう
import { add } from "./add.wasm";

console.log(add(1, ""));