Normal view

There are new articles available, click to refresh the page.
Before yesterdayMain stream

Emscripten Fetch 接口的一个潜在内存泄漏问题

7 May 2025 at 18:04

近日发现了一个非常刁钻的可能引起基于 Emscripten 编译的 WASM 程序内存泄漏的问题。Emscripten 工具链提供了 Fetch 功能模块,这个模块允许我们调用浏览器的 fetch 接口来进行网络访问。

一个使用 fetch 接口的简单例子是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <stdio.h>
#include <string.h>
#include <emscripten/fetch.h>

void downloadSucceeded(emscripten_fetch_t *fetch) {
printf("Finished downloading %llu bytes from URL %s.\n", fetch->numBytes, fetch->url);
// The data is now available at fetch->data[0] through fetch->data[fetch->numBytes-1];
emscripten_fetch_close(fetch); // Free data associated with the fetch.
}

void downloadFailed(emscripten_fetch_t *fetch) {
printf("Downloading %s failed, HTTP failure status code: %d.\n", fetch->url, fetch->status);
emscripten_fetch_close(fetch); // Also free data on failure.
}

int main() {
emscripten_fetch_attr_t attr;
emscripten_fetch_attr_init(&attr);
strcpy(attr.requestMethod, "GET");
attr.attributes = EMSCRIPTEN_FETCH_LOAD_TO_MEMORY;
attr.onsuccess = downloadSucceeded;
attr.onerror = downloadFailed;
emscripten_fetch(&attr, "myfile.dat");
}

Fetch API 提供了一些比较高阶的功能,一种一个比较重要的功能是,他可以将下载的内容缓存到 IndexDB 中,这个缓存机制能够突破浏览器自身的缓存大小的限制(一般超过 50MB 的文件浏览器的自动缓存机制会拒绝缓存)。但是这个缓存机制会导致内存泄漏。

1 泄漏产生的过程

在开头的例子中,我们需要再 onerror 和 onsuccess 回调中调用 emscripten_fetch_close 接口来关闭 fetch 指针代表的请求。在关闭过程中,fetch 使用的数据缓存区将会被回收。这个过程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
EMSCRIPTEN_RESULT emscripten_fetch_close(emscripten_fetch_t* fetch) {
if (!fetch) {
return EMSCRIPTEN_RESULT_SUCCESS; // Closing null pointer is ok, same as with free().
}

// This function frees the fetch pointer so that it is invalid to access it anymore.
// Use a few key fields as an integrity check that we are being passed a good pointer to a valid
// fetch structure, which has not been yet closed. (double close is an error)
if (fetch->id == 0 || fetch->readyState > STATE_MAX) {
return EMSCRIPTEN_RESULT_INVALID_PARAM;
}

// This fetch is aborted. Call the error handler if the fetch was still in progress and was
// canceled in flight.
if (fetch->readyState != STATE_DONE && fetch->__attributes.onerror) {
fetch->status = (unsigned short)-1;
strcpy(fetch->statusText, "aborted with emscripten_fetch_close()");
fetch->__attributes.onerror(fetch);
}

fetch_free(fetch);
return EMSCRIPTEN_RESULT_SUCCESS;
}

可以看到,回收并非总会发生, emscripten_fetch_close 函数会对 fetch 的部分状态进行检查,如果检查失败,则会返回一个 EMSCRIPTEN_RESULT_INVALID_PARAM 的错误码,并且不会执行后续的清理过程(`fetch_free)。被检查的两属性中,fetch->id 是我们需要关注的对象。fetch->id 这个属性作为 fetch 的唯一标识符,是用来建立起 C++ 端的请求对象和 JS 端的请求对象的映射的。id 的值在 JS 端分配。查看源码中的 Fetch.js 文件,

1
2
3
4
5
6
7
8
9
10
11
12
function fetchXHR(fetch, onsuccess, onerror, onprogress, onreadystatechange) {
// ...

var id = Fetch.xhrs.allocate(xhr);
#if FETCH_DEBUG
dbg(`fetch: id=${id}`);
#endif
{{{ makeSetValue('fetch', C_STRUCTS.emscripten_fetch_t.id, 'id', 'u32') }}};

// ...

}

这是唯一的一处 id 复制。这段代码位于 fetchXHR 函数中,这意味着只有发起了 XHR 请求时,id 才会被分配。那么,如果缓存存在呢?这时不会调用 fetchXHR 函数(而是调用 fetchLoadCachedData 函数)。这意味着回调函数中我们试图调用 emscripten_fetch_close 函数来关闭请求并回收资源时,这个回收过程无法进行,这导致了内存泄漏。

2 怎么解决这个问题

要解决这个问题我们只需要强行让 fetch->id == 0 的检查无法通过即可,我们可以在 emscripten_fetch_close 调用前,强行设置 fetch->id 为一个非零值。那么什么值合适呢?如果我们取值和已有的请求的 id 相同,那么 emscripten_fetch_close 可能将那个请求关闭。研究 id 分配的过程(即 Fetch.xhrs.allocate 的实现)

1
2
3
4
5
6
// libcore.js
allocate(handle) {
var id = this.freelist.pop() || this.allocated.length;
this.allocated[id] = handle;
return id;
}

可以看到,id 是顺序分配的,且使用过的 id 会被回收使用(freelist)。因此我们可以设置一个较大的值,只要同一时间最大的并发请求数量不超过这个值,那就是安全的。我一般选择设置为 0xffff。 那么,正确的关闭请求的方式是:

1
2
3
4
if (fetch->id == 0) {
fetch->id = 0xffff;
}
emscripten_fetch_close(fetch);

Unraid 上 Nextcloud 的部署问题

3 September 2023 at 14:56

之前我写过一篇文章来吐槽 Nextcloud 的性能问题。当时我是尝试在群晖上试用 Nextcloud。考虑到我使用的群晖服务器的 CPU 性能比价差,因此我尝试在 Unraid 上部署 Nextcloud。但是性能差的问题仍然没有改观。不过近期在 Unraid 发布了 6.12.0 版本以后这个问题得到了很大的改观。

1 问题分析

问题出在 shfs 这个进程上。在 Unraid 中,这个进程负责将分散在各个 Disk 上的文件夹内容聚合成一个统一的目录库,这意味着如果我们要访问任何文件都需要通过 shfs 来查找文件,这也使得 shfs 成文高吞吐率场景下的一个性能瓶颈。之前我经常可以看到 shfs 的 CPU 占用飙升到 100% 甚至 200%。在新版的 Unraid 中提供了 exclusive access 的特性。 Exclusive 的是排他性,独占的意思。Exclusive Access 是指固定将一个目录放在一个 Disk 中,这样我们在访问这个目录下的文件时,就不需要通过 shfs 来检索目录,而是可以直接访问磁盘上的目录系统,从而降低开销。

另一方面,Exclusive 也意味着这个目录无法有效的利用磁盘阵列提供的存储池,而只能利用单磁盘空间。因此,Nextcloud 的数据文件夹是不能使用 Exclusive Access 的。不过好在数据文件的访问其实并不是主要瓶颈,造成 Nextcluod 卡顿的主要原因在对容器挂载的配置文件(也就是 appdata)里面的众多小文件的高频读写。

另一个需要指出的是,在 Unraid 中,尽管缓存有多个 SSD,但是这些 SSD 会预先通过 Raid 机制聚合成一个统一的缓存磁盘,对于 Shfs 来说这个缓存池就是一个单一的磁盘,因此对缓存池的访问可以启用 Exclusive Access

2 如何使用

要是用 Exclusive Access,首先需要再 Settings --> Global Share Settings 下打开(如下图,将 Permit exclusive shares 设置为 yes)。

但是在共享目录使用时,Exclusive Access 的含义是比较模糊的。因为 Share 的管理界面并不会提供一个可以由用户可以直接控制的选项来为具体的 Share 开启 Exclusive Access。事实上,Exclusive Access 是否会起作用是系统根据目录的状态来自动确定的。如前文所述,Exclusive Access 作用的前提是数据只位于一个磁盘中。因此,在 Permit exclusive shares 启用的前提下,如果某个 Share 的文件只可能位于一个磁盘中,那么 Exclusive Access 将自动启用。

要实现 Share 的文件只属于一个磁盘中,需要满足一下条件:

  1. Share 只启用了一个 Storage,没有 Secondary Storage
  2. 如果 Share 是存储在 Array 中的,在设置 Include disks 时只能选择一个磁盘;
  3. 如果是事先已经存在的目录,那么在满足上两条的情况下,还需要通过适当的 Mover 动作,将数据迁移到单一磁盘中。(注意如果之前 Share 是存在多个磁盘中时,做前两条修改,并不会自动将数据迁移到目标磁盘)

在本文的场景中,我们需要在 appdata 这个共享目录上启用 Exclusive Access,就可能需要进行必要的 Mover 操作。如果你看到 appdata 的 Share settings 页面中显示了如下 Exclusive access: no 的文字,进行如下操作:

  1. 出现这个状态,说明你的 appdata 并不完全位于缓存中,有一部分是位于阵列中的,此时我们需要让 Mover 将数据从阵列中移动到缓存中。注意:
    1. 如果在现在的状态下,你的 appdata 是 Cache only 的,但是你仍然看到 Exclusive access: no,这是因为曾经某个时刻你为 appdata 设置了阵列存储的选项(以老版本的术语来说,就是 use cache 不是 only,而是 yes 和prefer 的选项),那么在那个时间段内,shfs 可能会将数据文件调度到阵列上。此时即便你讲 appdata 设置为 cache only,Mover 也不会自动将文件从阵列迁移到缓存。修改共享目录的这些相关设置只会影响新文件,而不会直接影响已有文件。
    2. 在上面这种情况下,你需要首先恢复缓存-阵列两级缓存架构,然后将 Mover 行为设置为 prefer cache(在 6.12,0 的属于下,是将 Mover action 设置为 Array -> Cache。然后运行 Mover 完毕。这时 Mover 会将还停留在阵列上文件移动到缓存(当然前提是的缓存的空间要够)。
    3. 完成上面的操作之后,我们再将 appdata 设置为 cache only 的形式(Primiary Storage 为缓存,无 Secondary Storage),这时我们应该能够看到 Exclusive access 变成了 yes。

3 结语

上面的这套操作,能够极大改善 Nextcloud 在 Unraid 上的表现。其他的应用也会有很大的改观(例如 Jellyfin 的打开和搜索速度会有质的提升)。

❌
❌