[WebAssembly]使用Zig语言制作wasm模块

“前几篇文章你不是还在写C++的wasm笔记,为什么突然开始使用Zig了?”

实际上由于C++古老的语言特性导致有的时候必须把声明和定义分开来写非常繁琐,而且在Emscripten环境下要添加第三方的库比较麻烦,所以我想改到一个自带包管理的语言。于是就首先捡起以前学到一半被劝退的Rust,这个国庆我花了几天时间把rust的教程看完了,但我又一次被这个反人类语言极致的复杂性劝退了,也就有了上一篇文章的内容。

在群友的推荐下我发现Zig这个类C的语言好像还行,于是抱着试一试的心态来学习一下,也就有了这篇笔记。不过不代表我之后就会用这个语言写项目,还有待观察。

这篇笔记的主要内容不是怎么编写Zig语言,而是如何把一个Zig项目编译成wasm模块,因此需要先有一点点的zig基础才能看懂。现在zig的文档和社区生态还不是很完善,且有的东西还在变化,有的东西是查不到或者很难查到的,我花了一些时间摸索才搞明白一些要点,这就是写这篇笔记的原因。

环境准备

如果还没有zig环境,可以用scoop快速部署,只需执行两个脚本即可

安装scoop

Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
Invoke-RestMethod -Uri https://get.scoop.sh | Invoke-Expression

用scoop安装最新版的zig,我写这篇笔记的时候zig版本是0.14.0-dev.1798,部分特性有可能在以后改变。

scoop bucket add versions
scoop install versions/zig-dev

以上即可完成zig环境的安装。顺带一提,使用scoop update zig-dev可以更新zig-dev的版本。

简单的示例

最简单快速的方式就是创建一个zig文件,然后使用命令把它编译成wasm文件。

main.zig 文件(由于代码高亮插件没有支持这个语言,而且以下示例看着挺像rust的,所以先用rust高亮来凑活一下)

const std = @import("std");

pub fn main() !void {
    const poi: u16 = 666;

    std.debug.print("poi: {}\n", .{poi});
}

然后使用命令编译

zig build-exe main.zig -target wasm32-wasi

此时目录下就会多出一个main.wasm和一个main.wasm.o文件(以后不同版本可能有所区别),和Emscripten不同的是它不会帮你生成一个胶水js文件供你直接加载。

这里我们使用的是wasi接口的wasm,如果你的运行环境是wasmtime的话,那么就可以直接执行这个wasm文件了。

生成的结果使用nodejs也可以运行,但要在nodejs中运行它,我们得自己为加载wasm写一些代码,还好也不多:

start.js 文件

const { readFile } = require('node:fs/promises');
const { WASI } = require('node:wasi');
const { argv, env } = require('node:process');
const { join } = require('node:path');

const wasi = new WASI({
	version: 'preview1',//该项必须定义,见文档:
	// https://nodejs.org/docs/latest-v20.x/api/wasi.html#new-wasioptions
	args: argv,//该项不是必须的,用于把node运行参数传给wasm程序
	env,//该项不是必须的,用传递环境变量
	
	//其它参数也见上面的文档链接
	//注意这是一个较新的特性,不同nodejs版本的接口可能会有区别,注意选择对应的文档版本
});

(async () => {
	const wasm = await WebAssembly.compile(
		await readFile(join(__dirname, 'main.wasm')),
	);
	const instance = await WebAssembly.instantiate(wasm, wasi.getImportObject());
	wasi.start(instance);
})();

使用nodejs执行以上js文件将得到输出结果:poi: 666

不简单的示例

不简单指的是编译一个完整Zig项目的情况,而非单个zig文件,还要根据需求进行一些必要的配置。Zig作为项目编译的时候,会存在一个build.zig文件(是的,编译脚本也是zig文件),我们需要编辑这个文件中的内容来改变wasm目标。

现在创建一个空的目录,然后在里面执行命令创建一个zig项目:zig init

zig将会自动帮你生成一个项目必须的几个文件,其中有详细的注释,你可以看看这些文件都是干什么用的,在本节我们只关注怎么把项目编译到wasm目标。

打开build.zig文件你会看到默认给你生成了一大堆东西,不过大部分东西都是无关的内容,主要关注的就是开头的target变量,它默认进行以下赋值b.standardTargetOptions(.{}),也就是会根据你传入的参数和你当前的操作系统生成对应的输出结果,如果你什么也不改,直接运行zig build的话,就会在zig-out/bin目录下面看到生成的exe程序(windows下)。

现在修改target变量的赋值,将编译目标指定为 wasm32-wasi

const target = b.resolveTargetQuery(std.Target.Query.parse(
   .{ .arch_os_abi = "wasm32-wasi" },
) catch unreachable);

这样再执行zig build就已经可以编译出wasm结果了,不过为了让示例看起来更清晰,我把build.zig中无关的代码全部删除,仅保留必要的部分,你可以先删除src/root.zig文件,这里我们不需要它。接下来把完整的build文件放出来:

build.zig

const std = @import("std");

pub fn build(b: *std.Build) void {
    const target = b.resolveTargetQuery(std.Target.Query.parse(
        .{ .arch_os_abi = "wasm32-wasi" },
    ) catch unreachable);

    const exe = b.addExecutable(.{
        .name = "main", //目标文件的名字
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = b.standardOptimizeOption(.{}),//这个也不是必要的,但是是常用选项,所以留在这里
    });
    b.installArtifact(exe);//把生成的结果执行安装步骤,默认会安装到zig-out目录中,如果没有这一行的话编译结果会飞向宇宙
}

看,这一下就把示例代码缩到了只有十几行,是不是清晰多了。然后运行zig build,此时就会生成 zig-out/bin/main.wasm 文件,使用方法和上一节一样。

在Zig中导出对象

我查到以前用wasm32-wasi时在实例的export对象上会找不到导出的对象,需要使用wasm32-freestanding才能看到导出的内容,我也复现了这个问题,查了两天之后更新了一下zig,发现现在以wasi导出的对象可以在exports对象上看到了,不知道是不是更新修好了什么bug。直接使用wasm32-freestanding编译前面的演示项目反而会报错,本来我都准备专门写一节来讲怎么在freestanding下输出内容,因为这个接口下io接口没有对应的实现,要自己在环境上映射一个输出方法,所以建议还是使用wasm32-wasi来编译。

要导出Zig中的方法或者全局变量只需要在前面加上export即可。

直接给示例就可以看明白,接下来把main.zig的内容修改为以下内容:

const std = @import("std");

///导出一个addPoi函数供wasm调用
export fn addPoi(a: i32, b: i32) i32 {
    return a + b;
}

///导出一个全局变量
export var lllaaa: i64 = 114514;

pub fn main() !void {
    const poi: u16 = 666;
    std.debug.print("main poi: {}\n", .{poi});
}

然后改动一下build.zig,添加一行

const std = @import("std");

pub fn build(b: *std.Build) void {
    const target = b.resolveTargetQuery(std.Target.Query.parse(
        .{ .arch_os_abi = "wasm32-wasi" },
    ) catch unreachable);
    const exe = b.addExecutable(.{
        .name = "main",
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = b.standardOptimizeOption(.{}),
    });

    //注意这个选项
    exe.rdynamic = true; //导出该可执行对象中标记了export的项目
    // 此项默认为false,如果你需要在js环境中调用导出的方法,需要设置为true

    b.installArtifact(exe); //保存生成的结果
}

然后执行zig build编译,随后就可以在js中调用了:

const { readFile } = require('node:fs/promises');
const { WASI } = require('node:wasi');
const { join } = require('node:path');

const wasi = new WASI({
	version: 'preview1',//该项必须定义,见文档:
	// https://nodejs.org/docs/latest-v20.x/api/wasi.html#new-wasioptions
});

(async () => {
	const wasm = await WebAssembly.compile(
		await readFile(join(__dirname, 'zig-out/bin/main.wasm')),
	);
	const instance = await WebAssembly.instantiate(wasm, wasi.getImportObject());
	//查看导出了些什么
	console.debug(instance.exports);
	/* 输出以下内容
	[Object: null prototype] {
		memory: Memory [WebAssembly.Memory] {},
		_start: [Function: 13],
		addPoi: [Function: 105],
		wasi_thread_start: [Function: 106],
		lllaaa: Global [WebAssembly.Global] {}
	}
	*/

	//执行start(zig项目中的main函数),如果不执行将会报错,提示start没有被执行
	wasi.start(instance);
	//输出 main poi: 666

	//获取导出的函数和变量以及内存对象
	const { addPoi, lllaaa, memory } = instance.exports;

	//调用导出的函数
	console.log("1+2=", addPoi(1, 2));
	//输出 1+2= 3

	//获取导出的全局变量,注意这个value获取到的实际上是变量在wasm中的内存地址,所以需要通过DataView来读取
	console.log(lllaaa.value);//输出 16779296 (该地址不同环境下可能不一样)
	const view = new DataView(memory.buffer);
	console.log(view.getBigInt64(lllaaa.value, true));//输出 114514n
	//↑注意wasm项目导出数据的大小端字节序,这里默认导出的为小端顺序,所以需要把第二个参数设置为true,我翻了翻源码没有找到哪里可以改字节序,如果有人知道请留个言
})();

使用多线程

这一节先空着,由于Zig目前取消了async特性,所以要以普通Native Zig程序的流程一样在wasm中创建多线程执行任务非常困难,因为js中的worker从创建到在线的过程是异步的,而Zig中spawn线程的过程是同步的,所以无法直接创建线程来使用(Zig等不到worker创建完成)。如果要像emscripten那样预先创建线程池然后再用各种通信来创建线程就太过繁琐了,我为了写这一节想尝试一些比较简单的方法,但都失败了,所以这一节暂时空着,直到我找到简单的实现方式,或者Zig的异步特性回归之后我再试试。

我花了几天摸Zig源码和文档以及相关的wasi提案,最终写了一个包来解决以上问题,这个包应该不止适用于zig,其它语言编译出的wasi接口wasm应该也可以用,地址在:https://www.npmjs.com/package/wasi-threads-wrapper,关于这个包的用法和原理可以看其介绍页,这里就不讲了,下面继续讲Zig方面需要注意的问题。

使用默认参数无法使用多线程特性,编译时会提示缺少一些CPU特性,当我们确定我们运行wasm的环境支持多线程时,可以手动添加支持特性选项。

首先修改我们的main.zig文件,我写了一个和之前emscripten笔记中类似的多线程数据竞争例子:

const std = @import("std");
const Thread = std.Thread;
const print = std.debug.print;
const NUM_THREADS = 8;

// 创建一个变量用来实验多线程的非原子性操作
var sum: u64 = 0;

// 线程的入口函数,这里我传入了一个代表线程编号的参数
pub fn threadEntry(idx: usize) void {
    const pid = Thread.getCurrentId(); // 获取线程id,此id根据不同的运行环境会有所区别,比如node中的worker就是从1开始的,而不是系统线程的id

    print("Thread {} (idx:{}) : starting...\n", .{ pid, idx });
    var timer = std.time.Timer.start() catch unreachable; //获取一个计时器,用于之后计算线程执行时间
    // 这里执行一个简单的循环自增操作,把所有线程共享的sum变量自增一亿次
    const y = ∑ //必须获取sum变量的内存地址才能对其地址上的值进行修改,直接对sum进行自增不会变化
    for (0..100000000) |_| {
        y.* += 1;
        // sum += sum;  //错误
    }
    print("Thread {} : done in {}ms.\n", .{ pid, timer.read() / 1000000 });
}
pub fn main() !void {}//由于wasi在退出时如果wasi.start没有执行过会触发报错,但thread和main的入口实际上是不一样的,所以只能把这个真正的main留空,在下面导出了另一个main
export fn main2() void{
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    var threads: [NUM_THREADS]Thread = undefined; // 创建一个线程数组,用于存放所有线程
    for (0..NUM_THREADS) |idx| {
        print("Main: creating thread idx:{}\n", .{idx});
        // 创建线程,传入线程入口函数以及线程参数(线程编号)
        threads[idx] = std.Thread.spawn(.{ .allocator = gpa.allocator() }, threadEntry, .{idx}) catch |err| {
            if (err == error.SpawnError) {
                print("Spawn failed idx:{}.\n", .{idx});
            }
            unreachable;
        };
    }
    for (0..NUM_THREADS) |idx| {
        threads[idx].join(); //注意,zig的线程join不像pthread的join那样可以返回线程函数的值,此join不返回值
        print("Main: completed join with thread idx:{}\n", .{idx});
    }
    print("Main: sum = {}\n", .{sum});
    print("Main: program completed. Exiting.\n", .{});
}

然后修改我们的build.zig文件:

const std = @import("std");

pub fn build(b: *std.Build) void {
    const target = b.resolveTargetQuery(
        .{
            .cpu_arch = .wasm32,
            .os_tag = .wasi,
            .cpu_features_add = std.Target.wasm.featureSet(&.{
                .atomics, //使用多线程必须支持此特性
                .bulk_memory, //使用多线程必须支持此特性
                .mutable_globals, //好像有用
                .simd128, //顺便提一下,如果需要使用simd指令,需要开启此项,不开启此特性在进行向量计算时可能会退化为顺序计算
                .exception_handling, //wasm专用,启用异常处理
                //其它还有不少特性,见源码,一般如果编译没有出错,就无需特意去添加
            }),
        },
    );
    const exe = b.addExecutable(.{
        .name = "main",
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = b.standardOptimizeOption(.{}),
        .single_threaded = false, //注意:必须设置此项,否则无法使用多线程
    });
    exe.rdynamic = true; //导出该可执行对象中标记了export的项目

    //注意这里!
    exe.shared_memory = true; //这个需要开启,用于在多线程中使用共享内存
    exe.import_memory = true; //这个需要开启,用于导入线程组共享的Memory
    exe.export_memory = true; //这个需要开启,导出内存对象
    exe.max_memory = 4294967296; //酌情设置,设置过小会报内存相关的错误,这里设置的是允许的最大值

    b.installArtifact(exe); //保存生成的结果
}

然后运行zig build编译,再使用我的包加载这个wasm:

test.js

(async () => {
	const { initWasiMain, initWasiWorker } = await import('wasi-threads-wrapper');
	const { isMainThread } = require('node:worker_threads');
	const { join } = require('node:path');

	if (isMainThread) {
		await initWasiMain({
			entryFile: __filename,//wasi-main和wasi-worker的入口也是这个文件,在下面的else分支进行初始化
			wasmFile: join(__dirname, 'zig-out/bin/main.wasm'),
			initMethod: 'main2',//这个就是我们在main.zig中导出的另一个主入口
		});
	} else {
		await initWasiWorker();
	}
})();

然后用node执行它,将会获得类似以下的输出:

Main: creating thread idx:0
Main: creating thread idx:1
Thread 2 (idx:0) : starting...
Thread 3 (idx:1) : starting...
Main: creating thread idx:2
Thread 4 (idx:2) : starting...
Main: creating thread idx:3
Main: creating thread idx:4
Thread 5 (idx:3) : starting...
Thread 6 (idx:4) : starting...
Main: creating thread idx:5
Main: creating thread idx:6
Thread 7 (idx:5) : starting...
Main: creating thread idx:7
Thread 8 (idx:6) : starting...
Thread 9 (idx:7) : starting...
Thread 3 : done in 1672ms.
Thread 4 : done in 1982ms.
Thread 5 : done in 2052ms.
Thread 6 : done in 2117ms.
Thread 7 : done in 2110ms.
Thread 8 : done in 2073ms.
Thread 9 : done in 1983ms.
Thread 2 : done in 2722ms.
Main: completed join with thread idx:0
Main: completed join with thread idx:1
Main: completed join with thread idx:2
Main: completed join with thread idx:3
Main: completed join with thread idx:4
Main: completed join with thread idx:5
Main: completed join with thread idx:6
Main: completed join with thread idx:7
Main: sum = 175461042
Main: program completed. Exiting.

可以看到线程正常创建,并且并行对sum进行不加锁的自增,结果当然是得到了一个随机的数字。



本文发布于 https://luojia.me

本站文章未经文下加注授权不得拷贝发布。

0 0 投票数
打分
订阅评论
提醒
guest
0 评论
内联反馈
查看所有评论