[WebAssembly]初学笔记 Pthreads多线程

如果有看不明白的地方请先看前置说明文章

对于有高性能要求的可并发任务来说,在程序一开始设计的时候就要把多线程协作考虑进去,因此介绍完了一些emscripten的基本操作之后,我立刻就来到了多线程的章节。

Emscripten提供了两种实现多线程的方式:

  • POSIX Threads (Pthreads) API (以下简称pthreads)(此工具中标准库的std::thread也基于pthreads实现)
  • Wasm Workers API (以下简称wasmworkers)(在这里继续再批判一次官方这破API文档竟然不写每个函数的用法,其它东西扯了一堆)

我参阅了一下wasmworkers的文档,以上两种API基本能实现相同的功能,区别在于它的wasmworkers是直接根据js原生worker原本的API实现的,因此输出的编译结果会比较小,而pthreads实现了完整的pthreads api,所以输出的编译结果会比较大。由于考虑程序的可移植性,所以这里我只学习使用pthreads实现多线程,如果对pthreads不熟悉的话可以看一看这个Rookie-Note上的学习文档简单了解一下,还是挺简单的,我也刚学会才来写了这篇笔记。

本篇笔记不会对pthreads api如何使用进行全面的说明,因为内容比较多且不是专属于wasm的内容,在代码编写上也和普通的 C pthread程序没有什么区别,可以看我上面贴的菜鸟笔记链接去学习,这里主要就说明一下emscripten中pthread的实现方式和举个例子,顺便贴上emscripten的pthread指导链接

首先要注意

  • 使用以上两种多线程API的时候都不可以和 `-sSINGLE_FILE` 编译参数搭配使用,也就是使用多线程就无法把输出文件全部打包到一个js文件里了。
  • 要使用pthreads需要浏览器开启SharedArrayBuffer支持(在目前的浏览器中由于存在安全原因默认是禁用的),在nodejs中可以直接使用。wasmworkers我没有测试,但应该也是一样的。
  • 编译参数:要使用pthreads库,需要在编译时添加 `-pthread` 参数。
  • 由于标准库中的std::thread也是基于pthread实现的,因此如果你想要使用`std::thread`,也需要添加 `-pthread` 编译参数。

emscripten中的pthreads其实也是基于js原生的worker实现的,在官方的这个文档中提到在wasm中创建新的线程需要js线程先去创建worker,但对于pthreads api来说创建线程是一个阻塞操作,也就是说这个过程中wasm不会返回到js线程,就没法去调用api创建。然而我测试下来即使没有添加任何额外的参数,依然可以正常创建线程,不知是现在的版本中内部做了一些优化但文档没改,还是nodejs的实现不一样。

如果在创建线程时出现问题可以参考文档中的三个方法,推荐比较不影响源码的后两个方法,即使用`-sPTHREAD_POOL_SIZE=<表达式>`可以在进入wasm主入口之前先预创建指定数量的线程,或者使用`-sPROXY_TO_PTHREAD`参数让wasm主线程不跑在js主线程里,就不会卡着js的执行了。

以上 “<表达式>” 在文档中特别提醒,可以设置为 navigator.hardwareConcurrency ,即和处理器逻辑线程相同的数量。

示例:

这里我写了一个多线程示例,且故意不对计算资源竞争进行加锁,以此证明这些线程确实是并行执行的线程间共享一部分内存互斥锁可以正常工作

将test.cpp中的内容修改为下,其做的事情是开启4个线程来同时对一个sum变量做非原子性的自增一亿次操作,理论上最终sum的结果应该是一个不等于四亿的随机数,另外我定义了一个log函数,其中对输出操作进行加锁,防止不同的线程同时输出内容串在一起:

// emcc -sMODULARIZE=1 -sASSERTIONS -pthread -o dist/test.mjs test.cpp
#include <iostream>
#include <emscripten.h>
#include <pthread.h> // 引入线程库
using std::cout, std::endl;

// 将创建的线程数
constexpr size_t NUM_THREADS = 4;

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

// 创建一个互斥锁来保证每个线程的cout语句不会被其它线程的cout打断
// 尝试注释掉此锁以及下面的加/解锁,你将看到乱七八糟的输出
pthread_mutex_t cout_mutex = PTHREAD_MUTEX_INITIALIZER;

// 临时定义一个log函数,在实验加锁排他输出的同时,避免参数过多的cout调用看起来太混乱
template <typename... Args>
void log(Args... args) {
	pthread_mutex_lock(&cout_mutex); // 加锁
	((cout << args), ...) << endl;
	pthread_mutex_unlock(&cout_mutex); // 解锁
}

// 线程的入口函数,接受一个代表线程编号的参数
void *ThreadEntry(void *_idx) {
	const long idx = (long)_idx;
	const long pid = pthread_self(); // 可以获取到线程真实的pid
	log("Thread ", idx, "(pid:", pid, ") : starting...");
	const double startTime = emscripten_get_now();//获取当前的时间,用于之后计算线程执行时间
	// 这里执行一个简单的循环自增操作,把所有线程共享的sum变量自增一亿次
	for (size_t i = 0; i < 100000000; i++) {
		sum++;
	}
	log("Thread ", idx, " : done in ",emscripten_get_now()-startTime,"ms.");
	pthread_exit((void *)sum);
}

int main() {
	log("Hello, World!");
	pthread_t thread[NUM_THREADS]; // 创建线程数组,后面创建的线程将保存在这里
	for (size_t idx = 0; idx < NUM_THREADS; idx++) {
		log("Main: creating thread ", idx);
		// 创建线程,传入线程入口函数以及线程参数(线程编号)
		auto code = pthread_create(&thread[idx], NULL, ThreadEntry, (void *)idx);
		if (code) { // 创建成功时返回值应为0
			log("Main error: return code from pthread_create() is ", code);
			return -1;
		}
	}
	void *returnValue;
	for (size_t idx = 0; idx < NUM_THREADS; idx++) {
		// 依次等待线程结束,并获取线程的返回值
		auto code = pthread_join(thread[idx], &returnValue);
		if (code) {
			log("Main error: return code from pthread_join() is ", code);
			return -1;
		}
		log("Main: completed join with thread ", idx, " having a returnValue of ", (unsigned long)returnValue);
	}
	log("Main: sum = ", sum);
	log("Main: program completed. Exiting.");
	return 0;
}

然后执行命令编译:emcc -sMODULARIZE=1 -sASSERTIONS -pthread -o dist/test.mjs test.cpp,注意在这个示例中不要添加优化参数`-O`,因为它可能会在编译阶段把循环内的结果计算完,导致最终输出结果看起来不像并行执行的。

接着在start.js中直接加载运行即可,下面我把输出内容也放在里面:

(async () => {
	const { default: wasm } = await import('./dist/test.mjs');
	const instance = await wasm();
	/* 控制台中将输出以下内容:
		Hello, World!
		Main: creating thread 0
		Main: creating thread 1
		Main: creating thread 2
		Main: creating thread 3
		Thread 2(pid:229176) : starting...
		Thread 1(pid:161352) : starting...
		Thread 0(pid:93528) : starting...
		Thread 3(pid:297000) : starting...
		Thread 3 : done.
		Thread 2 : done.
		Thread 0 : done.
		Main: completed join with thread 0 having a returnValue of 108925498
		Thread 1 : done.
		Main: completed join with thread 1 having a returnValue of 148142535
		Main: completed join with thread 2 having a returnValue of 102042998
		Main: completed join with thread 3 having a returnValue of 101243148
		Main: sum = 148142535
		Main: program completed. Exiting.
	*/
})();

 

从上面的输出结果可以看出如我们所料,在多线程不对资源加锁的情况下进行竞争,每个线程会对sum变量各自进行读取和覆盖,所以最后得到的总和一定不是四亿,而是一个接近一亿的随机数字,每次执行都会不一样。

其它小提示:

  • 阻塞:由于当wasm运行在js线程中时,wasm 的一些操作可能会阻塞js线程,尤其是js的主线程被阻塞的时候可能导致网页无响应或者nodejs程序卡住,所以emscripten提供了一个方便的功能,可以帮我们把我们的wasm主入口单独分到一个线程,只要添加前文已经提到过的编译参数`-sPROXY_TO_PTHREAD`即可。文档里没有写这么做是否会有什么额外的开销,暂且认为没有。
  • 使用 #ifdef __EMSCRIPTEN_PTHREADS__ 宏可以在代码中判断编译阶段是否启用了pthreads,由于运行阶段在同一个wasm程序中不存在一种方式切换单线程或多线程逻辑,因此如果有从不支持多线程环境回退到单线程的需求的话,需要编译两个版本出来。


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

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

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