「啊鸡学习 Node FFI」系列(二):上一篇是啊鸡学习 Node FFI 之《调用原理篇》,下一篇是啊鸡学习 Node FFI 之《内存与关闭篇》。
先看一个简化的设备 SDK:
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。
现在只跟踪一个事件:
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 调用 | 不可以 |
S0 | SDK 产生事件并调用 native function pointer | 不可以 |

图中把初始 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 时,“异步”经常混着三件事:
sdk_start()由Tjs直接执行,还是排给P0?- 日后的 callback 由哪条线程发起?
S0调完 native adapter 后立即返回,还是等待 JavaScript?
它们互不决定。同步注册的时间线是:
t0 Tjs → sdk_start(F, user_data)
t1 sdk_start 保存 F、启动 S0,然后返回
t2 S0 → F(B42, 3, user_data)
sdk_start() 执行多久,Tjs 就被占用多久;本例它很快返回,同步调用最简单。异步 wrapper 把执行者换成 P0:
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)

.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 则是业务数据,两者来自不同通道,即使碰巧存着相同地址也不是一回事:
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 参数。
}

这条路径从头到尾都在 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:
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 上的 producer | producer |
| enqueue 失败 | 仍是 producer | producer |
| enqueue 成功 | queue / consumer | 正常消费或 teardown cleanup |
| owning loop 取出 | loop consumer | loop consumer,或明确转给 JS finalizer |
成功 enqueue 代表 ownership transfer,不代表用户 JavaScript 一定会执行——Environment teardown 时,consumer 可能只能清理 native payload,不能再创建 JS 值。

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:
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。

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():
S0
→ relay callback 到 owning loop
→ 等待 JavaScript dispatch 完成
Tjs
→ 执行 onEvent(P42)
→ 调 sdk_stop()
→ 等待 / join S0
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 取同一把锁,锁也会进环。

由此把几个词分清:嵌套进入还没返回的调用是 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 给出答案。
Discussion
留言与讨论
想法、补充和不同意见都欢迎。