【问题标题】:WebAssembly: thread-safety and C/C++ local variablesWebAssembly:线程安全和 C/C++ 局部变量
【发布时间】:2021-12-15 18:00:15
【问题描述】:

我试图理解 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 - 1__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 中将它们打印出来,以及最低存储地址与 @987654335 之间的差异@ 是32*3 bytes + padding,而不是32*16 + padding,这意味着只有内存地址被占用的三个变量存储在别名堆栈中。当然,这些测试不是线程安全的,因为我将本地人的地址存储在函数之外,但它说明了一点:如果在可重入函数上,我暂时获取本地人的地址来实现方便,并且由于其复杂性,编译器不确定我要做什么,它最终可以决定将本地存储在堆栈上而不是更改其实现,从而使函数线程不安全。

【问题讨论】:

  • 我对 WASM 了解不多,但我认为 WASM 堆栈毕竟是一个堆栈。对calculation 的每次调用都会为param1param2 分配自己的内存,因此无论是来自不同线程还是递归调用,都应该获得独立的内存用于其param1param2
  • 我不知道 Web Assembly 内存是如何工作的,但您当然可以假设他们不是白痴,并且从多个线程调用相同的简单函数不会导致数据竞争。只需尝试通过共享线性内存来更好地了解它们的含义。
  • 我还假设“别名堆栈”及其堆栈指针是线程本地的,即每个线程都有自己的。这样就解决了线程安全方面的问题,并且线程内的递归调用仍然有效,因为它是一个堆栈。
  • @NateEldredge 据我所知,WebAssembly 还不支持多线程,所以我无法从 C++ 启动线程。必须在 Javascript 级别实现并行性:我创建一个共享线性内存并启动不同的 WebWorker,每个 WebWorker 都有自己的 wasm 实例,但都共享相同的导入内存。别名堆栈将在所有实例之间共享,没有一个实例可以知道它与其他多少兄弟共享内存。
  • @ALX23z 我编辑了我的问题以进一步澄清这一点。

标签: c++ multithreading webassembly


【解决方案1】:

在多线程设置中,每个线程都会将自己的堆栈放入共享内存中。堆栈指针(它的创建 seems to be done by LLVM createSyntheticSymbols)被放置到 WebAssembly global 变量中。目前这些全局变量用作thread-local storage。这意味着每个线程都有自己的全局变量。

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

如果调用者不提供自己的指针,则分配堆栈seems to be done by Emscripten __pthread_create_js。将变量分配到当前堆栈中完成here with stackAlloc 其中:

global.get __stack_pointer

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

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

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


基于您的 cmets:此时的 WebAssembly 标准通过使用原子指令来处理数据竞争。他们的访问顺序是sequentially consistent。在多线程的情况下,显然内存分配器必须是线程安全的。不必单独使用显式线程专用堆栈(使用全局变量就足够了,如前所述),因为堆栈内存仅由线程本身管理。检查the threads proposal 的原子指令和implementation status。也允许在非共享内存中使用原子指令。


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

【讨论】:

  • 加一个用于链接到定义“应该”的 RFC
  • 关于你的答案的三个问题。评论 1:您似乎向我解释了 pthread_create 是如何实现的,这是 WASM 的 libc 实现的一部分。 pthread_create 仅在我想从 WASM 模块中生成线程时调用。我更专注于从 WebWorkers 外部启动 WASM 模块,因为 libc 增加了很多样板文件,我试图避免它(至少,直到 clang 直接支持标准库)。
  • 评论 2:我没有使用 emscripten,而是 clang++(使用 wasi-sdk;我在使用当前的 clang 安装时遇到了 sysroot 问题,而 wasi-sdk 是唯一的方法)。您是否知道与__stack_pointer 等效的clang,以及以任何方式指定我自己的每个实例的指针? clang 似乎没有定义__stack_pointer,或者至少没有定义为可导出的符号(extern unsigned char __stack_pointer 不起作用)。我自己可以很好地管理内存(见评论 3)。
  • 这些是一些新要求,但与共享内存的关系保持不变。数据竞争通过使用原子变量来覆盖。如果两个线程不是原子地读写同一个地址,就会发生数据竞争。检查内存模型:github.com/WebAssembly/design/issues/1397。如果您有多个线程使用您必须以一种或另一种方式使用的共享内存,请确保显式堆栈不重叠。正如我所写的那样,emscripten 会照顾你。
  • WebAssembly 本身目前没有创建线程的指令。该功能留给主机。 LLVM 和 Emscripten 只是为这个任务相互完成。在浏览器中,要创建线程,请使用 WebWorker。目前没有办法直接在 worker 中使用 WebAssembly 文件,只有 JS 必须加载 WebAssembly。这意味着在某种程度上,您需要胶水 JS 代码来为您提供线程支持。然而,所有这些都与内存模型本身无关。
【解决方案2】:

WASM 做出的选择并不少见。拆分堆栈和多堆栈设计并不新鲜,并且一直与 C 和 C++ 兼容。这是 C 规范不足的故意结果,它始终允许“堆栈”变量存在于不可寻址的寄存器中。 C 堆栈是抽象的,与底层执行环境的关系有限。

当 C++ 采用 C++11 的 Java 内存模型(C 紧随其后)时,线程安全并不是“自动的”,而是仅适用于 C++ 对象。从这个意义上说,“堆”不是一个对象,而是一个概念,实现安全是实现的责任。请注意,C++ 标准不需要性能。技术上允许使用全局锁来保护堆。

在这种情况下,这意味着 WASM 应该将单独的堆栈分开(正如 @Nikolay 指出的那样)。这些堆栈占用的内存区域无关紧要,只要各个堆栈的各个片段在任何特定时刻不重叠即可。

【讨论】:

  • 是的,这很好,我从没想过 WASM 的“受保护堆栈”是一组无限数量的寄存器,正如你所说,它们也是不可寻址的。
猜你喜欢
  • 2015-07-07
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2017-03-07
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多