首页 > 解决方案 > WebAssembly:线程安全和 C/C++ 局部变量

问题描述

我试图理解 WebAssembly 内存模型,特别是从以下角度:在 WebAssembly 实例之间共享线性内存时我会面临什么样的风险?所有 C/C++ => wasm 教程给我们的基本内存模型如下(堆栈开始__heap_base - 1并向下增长):

+-----------------------------------------------+
| ? | static data |     stack     |     heap    |
+-----------------------------------------------+
^   ^             ^               ^             ^
|   |             |               |             |
0 __global_base  __data_end     __heap_base  MAX_MEMORY

但是下面的事实让我很吃惊。来自https://webassembly.org/docs/security/

具有不明确静态范围的局部变量(例如,由地址运算符使用,或者是结构类型并按值返回)在编译时存储在线性内存中单独的用户可寻址堆栈中。这是一个独立的内存区域,具有固定的最大大小,默认情况下初始化为零。

并来自https://github.com/WebAssembly/design/blob/main/Rationale.md#locals

C/C++ 使得获取函数本地值的地址并将这个指针传递给被调用者或其他线程成为可能。由于 WebAssembly 的局部变量位于地址空间之外,因此 C/C++ 编译器通过在线性内存中创建单独的堆栈数据结构来实现地址获取变量。此堆栈有时称为“别名”堆栈,因为它用于可能由指针指向的变量。

换句话说,从__heap_base - 1to定义的堆栈__data_end是 C/C++ 编译模块的实现工件。“WASM 堆栈”位于线性内存之外。碰巧的是,当您获取本地地址(例如)时,编译器将其存储在“别名堆栈”中,因此有一个地址可以获取。

在使用共享内存的情况下,这种行为不会为新的非常危险的数据竞争打开大门吗?

想象一段这样的代码:

int calculation(int param1, int param2)
{
    if (param1 == param2 * 2)
        ++param1;
    else
        ++param2;

    return param1 / 3 + param2;
}

在这里,calculation是线程安全的。但是,如果我用calculation这种等效形式替换:

int calculation(int param1, int param2)
{
    int* param = param1 == param2 * 2 ? &param1 : &param2;

    ++*param;

    return param1 / 3 + param2;
}

根据编译器的输出,如果共享内存由标志启用,则calculation可能不再是线程安全的param1和/或param2存储在别名堆栈上,该别名堆栈位于线性内存上,如果共享内存由--features=atomics,bulk-memory --shared-memory标志启用,则可以在其他实例之间共享。

那么,编译器在哪些具体情况下可以决定将局部变量存储在别名堆栈上?

编辑:我做了一些测试来验证,我想知道我是否正确。我在堆上存储了使用 16 个无符号局部变量的函数的第一个、一半和最后一个局部变量的内存地址,然后我从 javascript 中将它们打印出来,最低存储地址之间的差异__heap_base32*3 bytes + padding,并不是32*16 + padding,这意味着只有其内存地址被占用的三个变量存储在别名堆栈中。当然,这些测试不是线程安全的,因为我将本地人的地址存储在函数之外,但它说明了一点:如果在可重入函数上,我暂时获取本地人的地址来实现方便,并且由于其复杂性,编译器不确定我要做什么,它最终可以决定将本地存储在堆栈上而不是更改其实现,从而使函数线程不安全。

标签: c++multithreadingwebassembly

解决方案


在多线程设置中,每个线程都会将自己的堆栈放入共享内存中。堆栈指针(它的创建似乎是由 完成的LLVM createSyntheticSymbols)被放置到 WebAssembly全局变量中。目前这些全局变量被用作线程本地存储。这意味着每个线程都有自己的全局变量。

在 WebAssembly 实例开始时,主线程将有自己的全局变量指向共享内存中的主线程堆栈。如果您启动另一个线程,在其启动期间,它的全局变量将指向共享内存中的另一个位置,该线程的堆栈所在的位置。

如果调用者不提供自己的指针,堆栈的分配似乎就完成了。Emscripten __pthread_create_js将变量分配到当前堆栈是在这里stackAlloc完成的,其中:

global.get __stack_pointer

正在获取当前线程堆栈指针,减去所需的字节(堆栈向下增长),将其对齐为 16 个字节,然后将新值记住回全局。这都是线程安全的,因为全局只能从线程本身访问。

关于指针,是的,编译器会将指针访问的变量放入显式堆栈中。目前 WebAssembly 堆栈不是“可步行的”,但有一个提议可以做到这一点。许多实现还使用显式堆栈,以获得对堆栈使用(变量、结构等)的更细粒度的控制。

所有这些“东西”都应该(RFC 2119)对开发人员透明。意思是,它似乎只是工作。


根据您的评论:此时的 WebAssembly 标准通过使用原子指令来处理数据竞争。它们的访问顺序是顺序一致的。在多线程的情况下,显然内存分配器必须是线程安全的。不必单独使用显式线程专用堆栈(使用全局变量就足够了,如前所述),因为堆栈内存仅由线程本身管理。检查线程提案以获取原子指令和实现状态。也允许在非共享内存中使用原子指令。


一些实现在进行非原子访问以及原子访问时可能会锁定整个内存。这至少是因为规范不禁止更高的内存访问保证。这意味着即使您在某个内存地址创建竞争,您也无法读取不一致/撕裂的值。然而,这只是一种不应该依赖的可能性。


推荐阅读