返回所有文章

学习 2026

啊鸡学习 Node FFI 之《线程与回调篇》

区分 JavaScript event loop、Worker、共享 libuv 线程池与 SDK 自建线程,理解 native callback 如何经 trampoline 进入 binding,再由 TSFN,或线程安全队列配合 uv_async_t,回到 JavaScript。

Node.jsFFINode-API
本文目录8 节
  1. 1. 先给三条线程命名
  2. 2. “异步”至少有三个不同问题
  3. 3. Trampoline 改 ABI,不改线程
  4. 4. 在 B42 失效前复制成 P42
  5. 5. 把 P42 送回 JavaScript:队列加门铃
  6. 6. Deadlock 看等待环,不看“页面卡住”
  7. 7. 收尾
  8. References

「啊鸡学习 Node FFI」系列(二):上一篇是啊鸡学习 Node FFI 之《调用原理篇》,下一篇是啊鸡学习 Node FFI 之《内存与关闭篇》。

先看一个简化的设备 SDK:

c
typedef void (*event_cb)(
    const uint8_t* data,
    size_t length,
    void* user_data
);

int sdk_start(event_cb callback, void* user_data);
void sdk_stop(void);

为了只讨论线程,这里省略 handle、open、unregister 和 close;sdk_stop() 先当作一个合并的教学 barrier,下一篇再拆开。本篇依赖四条假设(真实 SDK 必须查文档、源码或实验验证):

  • sdk_start() 保存 callback 与 user_data,很快返回;
  • SDK 创建一条线程 S0,由它在 sdk_start() 返回后串行调用 callback;
  • callback 的 data 只在本次 callback 返回前有效;
  • sdk_stop() 阻止新事件,等待 S0 与已开始的 callback 退出,成功后不再调用或持有 callback 与 user_data。

现在只跟踪一个事件:

text
t0  Tjs 调 sdk_start(callback, user_data)
t1  sdk_start 返回,Tjs 继续跑 event loop
t2  S0 调 callback(B42, 3, user_data)

B42 是 SDK 借出的三字节缓冲区:0x10 0x20 0x30。问题是:t2 的 callback handler 运行在 Tjs,还是 S0?

答案是 S0。后面的线程切换,必须由 binding 另外完成。

1. 先给三条线程命名

一个 Node.js 进程里可能同时有 JavaScript thread、Worker、libuv worker pool 和 SDK 自建线程。先把 thread、Environment 和 loop 分开:本例的 JavaScript 在 Tjs 上执行,binding 实例属于 Environment E0,E0 使用 event loop L0——有关联,但不是同一个对象的三个别名。

执行者本例中的工作能否直接操作 E0 的 JavaScript 值
Tjs执行 E0 的 JavaScript 与 L0 上的 callback可以
P0仅在使用异步 wrapper 时执行正向 C 调用不可以
S0SDK 产生事件并调用 native function pointer不可以

Node.js 进程中的 JavaScript、Worker、libuv pool 与 SDK 线程拓扑

图中把初始 JavaScript 线程标为 T0、SDK 线程标为 Tsdk,对应本文的 Tjs / S0。Worker 和 pool task 也不是一回事:Worker 有自己的 OS thread、V8 Isolate、Environment 和 event loop;uv_queue_work() 一类任务用进程共享的 libuv pool。把 FFI 放进 Worker 可以不占初始 loop,但隔离不了 native 越界写或崩溃——它们仍在同一进程。若 binding 在 Worker 的 E1 中创建,日后的 JavaScript dispatch 也回到 E1/L1,不是回到进程最早启动的 E0/L0。

2. “异步”至少有三个不同问题

讨论 FFI callback 时,“异步”经常混着三件事:

  1. sdk_start() 由 Tjs 直接执行,还是排给 P0?
  2. 日后的 callback 由哪条线程发起?
  3. S0 调完 native adapter 后立即返回,还是等待 JavaScript?

它们互不决定。同步注册的时间线是:

text
t0  Tjs → sdk_start(F, user_data)
t1  sdk_start 保存 F、启动 S0,然后返回
t2  S0 → F(B42, 3, user_data)

sdk_start() 执行多久,Tjs 就被占用多久;本例它很快返回,同步调用最简单。异步 wrapper 把执行者换成 P0:

text
t0  Tjs 把 sdk_start(F, user_data) 排给 P0
t1  P0 → sdk_start(F, user_data) → 返回

此后没有固定先后:
A   completion 被投回 E0 / L0,等待 Tjs 执行
B   S0 → F(B42, 3, user_data)

同步 FFI 与共享 libuv pool 异步 wrapper 的执行路径对比

.async 改变的只是正向调用的执行者:它没有把未来的 callback 从 S0 变成 Tjs,也不保证 completion 先于首个 callback 执行。图底部的 blocking / starvation 是长调用时的风险标签,不是两种 wrapper 的必然结果——目标 C 函数若本身很慢,只是从占住 Tjs 改为长期占住共享 pool 的一个 slot,可能挤压文件系统、dns.lookup() 这类同样用 pool 的 work。

3. Trampoline 改 ABI,不改线程

Binding 要把 JavaScript callback 暴露成 C 能调用的 function pointer,靠的是 trampoline(如 libffi closure):SDK 调 F,F 先按 libffi 的通用接口还原参数,再找到真正的 JavaScript callback。以 closure 为例,准备时把 CIF、通用 handler 和 binding 自己的 closure_ctx 绑在一起——closure_ctx 是固定的 binding context,SDK 调用时传的第三个参数 user_data 则是业务数据,两者来自不同通道,即使碰巧存着相同地址也不是一回事:

c
static void binding_handler(
    ffi_cif* cif,
    void* ret,
    void** args,
    void* closure_ctx) {
  const uint8_t* data = *(const uint8_t**)args[0];
  size_t length = *(size_t*)args[1];
  void* sdk_user_data = *(void**)args[2];
  // 用 closure_ctx 找到 binding state,再处理这三个 SDK 参数。
}

Callback trampoline 在同一 native thread 上转换机器 ABI

这条路径从头到尾都在 S0 上:调用栈挂在 SDK 的 callback 调用下面,ABI 转换不是线程调度,不会自动找到“Node 主线程”。void 返回也不代表 fire-and-forget——S0 仍要等整个 native callback 返回才能继续。

还有一层默认行为容易被忽略:本文核对的 Koffi 3.2.1 与 ffi-napi 4.0.3,都会把 foreign-thread 的 JavaScript callback relay 到 owning loop,并等 JavaScript dispatch 完成后才让 S0 返回,void callback 也一样。下一节改用专用 Node-API Addon:让 SDK 调编译好的 native adapter,由它复制、投递、立即返回。

4. 在 B42 失效前复制成 P42

从这里开始,F 不再是通用 FFI 生成的 trampoline,而是专用 Addon 编译出的 on_sdk_event——对 SDK 暴露相同的 C prototype,但在 S0 上只做 bounded copy 与 native enqueue。

t2 时,B42 仍属于 SDK:bytes 是 10 20 30,只活到本次 callback 返回。把 B42 的 pointer 直接排进队列没有用——adapter 一返回,SDK 就可以覆盖或复用那段内存。S0 必须先复制出 bridge 自己拥有的 P42:

c
typedef struct {
  size_t length;
  uint8_t bytes[];
} Payload;

static void on_sdk_event(
    const uint8_t* data,
    size_t length,
    void* user_data) {
  Bridge* bridge = user_data;  // 教学契约:仍存活的 registration context

  if (length > MAX_EVENT_BYTES) {  // 真实实现还要检查 data 与分配溢出
    record_invalid_event(bridge);
    return;
  }

  Payload* payload = malloc(sizeof(*payload) + length);
  if (payload == NULL) {
    record_dropped_event(bridge);
    return;
  }

  payload->length = length;
  memcpy(payload->bytes, data, length);  // B42 仍有效

  if (!enqueue_owned_payload(bridge, payload)) {
    free(payload);  // 未接纳,owner 仍是 producer
  }
}

Ownership 的交接点在 enqueue:

时刻P42 owner释放者
刚复制完成S0 上的 producerproducer
enqueue 失败仍是 producerproducer
enqueue 成功queue / consumer正常消费或 teardown cleanup
owning loop 取出loop consumerloop consumer,或明确转给 JS finalizer

成功 enqueue 代表 ownership transfer,不代表用户 JavaScript 一定会执行——Environment teardown 时,consumer 可能只能清理 native payload,不能再创建 JS 值。

Foreign-thread callback 复制 payload 并回到 owning Environment 的安全路径

5. 把 P42 送回 JavaScript:队列加门铃

P42 已不依赖 SDK 的借用期,下一步是送到 E0/L0。Node-API 自带 Thread-Safe Function(TSFN):producer 调 napi_call_threadsafe_function() 把 payload 排队,loop 一侧的 call_js_cb 创建 JavaScript 值;有界队列满时,nonblocking 模式返回 napi_queue_full(producer 丢弃 payload),blocking 模式等的是空位,不是 JavaScript 执行完。更底层的做法是自己保存 payload,用 uv_async_t 当门铃唤醒 loop:

text
S0:
  lock(queue)
  queue.push(P42); queue.push(P43); queue.push(P44)
  unlock(queue)
  uv_async_send(notify)   ×3

E0 / L0:
  async callback 可能只执行一次
  但从 queue 取出 P42、P43、P44 三条

三次 send 可以被合并成一次 wakeup;被合并的是门铃,不是消息。uv_async_send() 可以由 producer thread 调用;handle 的初始化、loop callback 和 uv_close() 则属于 owning loop;业务队列仍需要自己的 mutex。TSFN 和“队列 + 门铃”是替代方案,不是叠加的两层——直接使用 libuv API 也意味着 Addon 不再只依赖 Node-API 的公开 ABI。

TSFN 与 uv_async_t 的投递、合并唤醒和 payload ownership 对比

Loop 一侧取货时,先在锁内 swap 出 batch、解锁,再创建 JavaScript Buffer、调 onEvent(P42)——不要持 queue 或 bridge mutex 进入用户代码。假设 onEvent 里立即调用 sdk_stop():这首先是 reentrancy(外层 dispatch 尚未返回,bridge 又被进入一次),不自动等于 deadlock;只有外层仍持同一把锁时才可能自锁。

6. Deadlock 看等待环,不看“页面卡住”

在专用 Addon 的 notification 路径中,S0 复制、投递后立即返回,不等 JavaScript,等待图没有反向边。换成同步 relay 就不一样了。

情形一:callback relay 等待 JavaScript。 Koffi / ffi-napi 的 foreign-thread callback 会等 owning loop 完成 JavaScript dispatch。若被 relay 的 JavaScript callback 又调用会 join S0 的 sdk_stop():

text
S0
  → relay callback 到 owning loop
  → 等待 JavaScript dispatch 完成

Tjs
  → 执行 onEvent(P42)
  → 调 sdk_stop()
  → 等待 / join S0
text
Tjs ── sdk_stop / join ──▶ S0
S0  ── 等 JavaScript dispatch ─▶ Tjs

这就是 deadlock。

**情形二:notification 队列满了。**即使不需要业务 reply,blocking admission 也能成环:S0 blocking enqueue 到已满队列、等 Tjs 腾 slot;Tjs 却在 sdk_stop() 里等 / join S0。仍是 wait-for cycle。类似地,若 SDK 持内部锁调用 callback,同步 relay 到 JavaScript 后又重入 SDK 取同一把锁,锁也会进环。

Notification、request-reply 与 wait-for deadlock 的关系

由此把几个词分清:嵌套进入还没返回的调用是 reentrancy;单向等待是 blocking;ready work 长期拿不到 loop、pool slot 或锁是 starvation;资源已释放却仍被访问是 use-after-free。只有 wait-for graph 成环才是 deadlock——本例两条边:Tjs → S0、S0 → Tjs。

7. 收尾

B42 的完整路径:SDK 在 S0 上调 function pointer,入口不切线程;adapter 在借用期内把 B42 复制成 bridge 拥有的 P42;enqueue 成功才交出 ownership;owning loop 解锁后创建 Buffer、调 onEvent。两条死锁线索都来自反向等待边:通用 binding 默认的“等 JS dispatch”,或 blocking 队列的“等腾 slot”。

还没回答的最后一件事:关闭时怎样证明 SDK 不会再调 F,registration shell、ref 和 async handle 什么时候才能释放。下一篇沿同一个 bridge 给出答案。

References

返回首页
上一篇啊鸡学习 Node FFI 之《调用原理篇》下一篇啊鸡学习 Node FFI 之《内存与关闭篇》

Discussion

留言与讨论

想法、补充和不同意见都欢迎。