第一章:Go代码翻译为C的总体挑战与安全边界
将Go代码翻译为C并非简单的语法映射,而是一场涉及内存模型、运行时语义与安全契约的深度重构。Go语言内置垃圾回收、goroutine调度、defer/panic/recover异常机制以及丰富的标准库抽象(如sync.Map、net/http),这些在C中均无直接对应物,必须显式模拟或彻底重构。
内存管理范式冲突
Go的堆分配与自动回收与C的手动malloc/free存在根本性不兼容。例如,以下Go片段:
func NewBuffer() []byte {
return make([]byte, 1024) // GC托管内存
}
翻译为C时,不能简单替换为malloc(1024),还需引入引用计数、arena分配器或调用方责任协议——否则易引发use-after-free或内存泄漏。建议采用RAII式封装:
// buffer.h
typedef struct { uint8_t *data; size_t len; } Buffer;
Buffer buffer_new(size_t len); // 分配并初始化
void buffer_free(Buffer *b); // 显式释放,置data为NULL
并发与同步语义鸿沟
Go的channel和select无法直译为POSIX线程原语。go f()需转为pthread_create+状态机,且runtime.Gosched()等调度提示在C中无等价物。必须用互斥锁+条件变量重实现通信逻辑,并严格校验竞态点。
安全边界必须显式声明
| Go特性 | C中等效方案 | 安全风险示例 |
|---|---|---|
nil指针检查 |
手动if (p == NULL) |
未检查导致段错误 |
range越界保护 |
循环前校验len > 0 && i < len |
缓冲区溢出 |
defer清理逻辑 |
goto cleanup或RAII结构体 |
资源泄漏(文件句柄、锁) |
所有翻译必须通过静态分析(如clang --analyze)与动态检测(valgrind --tool=memcheck)双重验证,禁止绕过空指针/越界检查以“追求性能”。安全边界不是可选项,而是翻译过程的强制前置约束。
第二章:内存管理的三重幻象:从Go堆到C手动管理的致命跃迁
2.1 Go逃逸分析与C栈/堆分配策略的映射实践
Go编译器通过逃逸分析决定变量分配位置(栈 or 堆),其决策逻辑可映射至C语言中显式的 alloca()(栈)与 malloc()(堆)语义。
逃逸判定关键信号
- 变量地址被返回(如函数返回局部变量指针)
- 被闭包捕获且生命周期超出当前栈帧
- 大小在编译期未知(如切片动态扩容)
示例:逃逸行为对比
func stackAlloc() *int {
x := 42 // 逃逸:返回局部变量地址
return &x
}
&x导致x必须分配在堆上——等价于C中malloc(sizeof(int));若改为return x,则对应int x = 42;(栈分配)。
显式控制建议
- 使用
-gcflags="-m -l"查看逃逸详情 - 避免无谓取址,优先值传递小结构体
| Go语义 | C等效操作 | 分配位置 |
|---|---|---|
var x int |
int x; |
栈 |
&x(逃逸) |
malloc(sizeof(int)) |
堆 |
make([]byte, 1024) |
malloc(1024) |
堆 |
2.2 slice与string到C数组/char*的零拷贝转换陷阱与加固方案
Go 中 unsafe.Slice 和 (*reflect.StringHeader)(unsafe.Pointer(&s)).Data 可实现零拷贝获取底层字节地址,但存在严重生命周期风险:若 Go 字符串/slice 被 GC 回收或内存重用,C 侧指针即成悬垂指针。
常见误用模式
- 直接传递局部
[]byte地址给 C 函数,未延长其栈帧生命周期 - 忽略
runtime.KeepAlive()导致编译器提前释放 backing array - 对
string使用unsafe.String()逆向构造时未保证底层内存不可迁移(如来自make([]byte)的切片转 string)
安全加固三原则
- ✅ 使用
C.CString()+ 手动C.free()(有拷贝,但安全) - ✅ 若必须零拷贝,需配合
runtime.KeepAlive(x)锁定变量生命周期 - ❌ 禁止对
string字面量或 map value 直接取Data——其内存可能只读或不可靠
// 安全零拷贝示例(需确保 s 生命周期覆盖 C 调用全程)
func safeStringToC(s string) *C.char {
hdr := (*reflect.StringHeader)(unsafe.Pointer(&s))
// 注意:s 必须在调用方作用域中保持活跃!
runtime.KeepAlive(s) // 防止 GC 提前回收
return (*C.char)(unsafe.Pointer(uintptr(hdr.Data)))
}
逻辑分析:
hdr.Data是uintptr类型的原始地址;unsafe.Pointer转换为*C.char后可被 C 使用;runtime.KeepAlive(s)告知编译器s在此之后仍被依赖,阻止优化移除。参数s必须是逃逸到堆上的变量(非短生命周期栈对象),否则地址无效。
| 方案 | 零拷贝 | 安全性 | 适用场景 |
|---|---|---|---|
C.CString() |
❌ | ✅ | 小数据、短生命周期 C 调用 |
unsafe.Slice + KeepAlive |
✅ | ⚠️(需严格生命周期管理) | 高频、大数据量、可控内存上下文 |
sync.Pool 缓存 C 字符串 |
❌ | ✅ | 中等频率、避免频繁 malloc/free |
graph TD
A[Go string/slice] --> B{是否逃逸到堆?}
B -->|否| C[栈地址→C后悬垂→崩溃]
B -->|是| D[取 Data 字段]
D --> E[插入 runtime.KeepAlive]
E --> F[C 函数安全使用]
2.3 defer清理逻辑在C中等效实现:资源生命周期建模与RAII模拟
C语言虽无原生defer,但可通过作用域绑定+函数指针注册模拟其“后置执行”语义。
资源封装与清理注册
typedef struct {
void *ptr;
void (*cleanup)(void*);
struct cleanup_node *next;
} cleanup_node;
static cleanup_node *cleanup_stack = NULL;
#define DEFER(cleanup_fn, ptr) \
do { \
cleanup_node *n = malloc(sizeof(*n)); \
n->ptr = (ptr); \
n->cleanup = (cleanup_fn); \
n->next = cleanup_stack; \
cleanup_stack = n; \
} while(0)
DEFER宏将清理函数与资源指针压入栈链表;cleanup_fn需接收void*参数(如fclose需封装为void close_file(void *f) { fclose((FILE*)f); })。
生命周期终结触发
| 阶段 | 行为 |
|---|---|
| 函数入口 | cleanup_stack = NULL |
| 资源分配后 | DEFER(close_file, fp) |
| 函数返回前 | run_cleanup()遍历调用 |
graph TD
A[资源分配] --> B[DEFER注册清理器]
B --> C[函数执行任意路径]
C --> D{函数返回}
D --> E[自动遍历cleanup_stack]
E --> F[顺序调用cleanup函数]
关键约束:必须显式调用run_cleanup()(通常置于return前),或结合setjmp/longjmp实现异常安全。
2.4 CGO桥接层中malloc/free与Go runtime.MemStats的协同监控实践
CGO调用C代码时,malloc/free分配的内存不被Go GC感知,易引发隐性内存泄漏。需建立跨运行时的内存观测闭环。
数据同步机制
通过runtime.ReadMemStats定期采集Go堆指标,同时在CGO入口/出口钩住malloc/free调用,记录C堆分配量:
// cgo_bridge.c(简化示意)
#include <stdlib.h>
#include <stdatomic.h>
static _Atomic uint64_t c_heap_alloc = ATOMIC_VAR_INIT(0);
void* tracked_malloc(size_t size) {
void* p = malloc(size);
if (p) atomic_fetch_add(&c_heap_alloc, size);
return p;
}
atomic_fetch_add保证多goroutine并发安全;c_heap_alloc作为C堆总量原子计数器,供Go侧读取。
协同视图对齐
| 指标来源 | 关键字段 | 同步周期 |
|---|---|---|
| Go runtime | MemStats.Alloc, TotalAlloc |
每5s |
| C bridge layer | c_heap_alloc(原子变量) |
同步读取 |
// Go侧聚合逻辑
var cHeap uint64
atomic.LoadUint64(&cHeap) // 无锁读取C堆总量
atomic.LoadUint64避免竞态,确保与runtime.ReadMemStats在同一采样窗口内获取一致快照。
内存归因流程
graph TD
A[CGO调用] –> B[tracked_malloc/free]
B –> C[更新c_heap_alloc原子计数]
C –> D[Go定时ReadMemStats]
D –> E[聚合总内存:Go堆 + C堆]
E –> F[告警阈值判定]
2.5 内存泄漏检测:结合AddressSanitizer与Go pprof交叉验证方法论
工具协同逻辑
AddressSanitizer(ASan)捕获堆内存越界与泄漏的实时行为,而Go pprof通过runtime.MemStats与pprof.WriteHeapProfile提供采样式堆快照。二者互补:ASan定位非法访问源头,pprof确认长期驻留对象。
验证流程
# 启用ASan编译(需Clang)
clang -fsanitize=address -g -O1 main.c -o main-asan
# Go服务启用pprof HTTP端点
go run -gcflags="-m -l" main.go &
curl http://localhost:6060/debug/pprof/heap > heap.pprof
clang参数说明:-fsanitize=address启用ASan运行时检查;-g保留调试符号便于栈回溯;-O1避免优化干扰地址报告。
交叉分析表
| 维度 | AddressSanitizer | Go pprof |
|---|---|---|
| 检测粒度 | 单字节级非法访问 | 对象级分配/释放统计 |
| 时间覆盖 | 全生命周期实时监控 | 快照式周期采样 |
| 泄漏判定依据 | LeakSanitizer报告未释放指针 |
inuse_objects持续增长 |
验证路径
graph TD
A[启动服务] --> B{ASan检测到泄漏}
B -->|是| C[提取调用栈+地址]
B -->|否| D[pprof采样对比]
C --> E[匹配pprof中对应alloc_space]
D --> F[观察inuse_bytes趋势]
E & F --> G[确认泄漏对象类型与生命周期]
第三章:垃圾回收器缺席后的生存法则:C侧内存所有权契约设计
3.1 Go对象生命周期终结信号捕获与C端析构注册机制
Go运行时通过runtime.SetFinalizer为对象注册终结器,但该机制仅保证“可能执行”,不保证时机与顺序。为实现确定性资源清理,需桥接C端析构逻辑。
终结器注册与C函数绑定
// 注册Go对象终结时触发的C析构函数
func RegisterCDeallocator(obj interface{}, cDestructor unsafe.Pointer) {
runtime.SetFinalizer(obj, func(_ interface{}) {
C.call_c_destructor(cDestructor)
})
}
cDestructor为C函数指针(如void (*destructor)(void*)),由CGO导出;obj需保持强引用直至析构完成,否则可能被提前回收。
关键约束对比
| 特性 | Go Finalizer | C端显式析构注册 |
|---|---|---|
| 执行确定性 | 弱(GC触发,无序) | 强(可手动调用) |
| 资源泄漏风险 | 高(延迟不可控) | 低(可控生命周期) |
析构流程示意
graph TD
A[Go对象被GC标记] --> B{Finalizer存在?}
B -->|是| C[调用runtime.finalizer]
C --> D[执行C.call_c_destructor]
D --> E[C端释放内存/句柄]
3.2 引用计数与弱引用在C接口层的轻量级实现(基于atomic_int)
核心设计思想
使用 atomic_int 替代锁保护的整型计数器,避免线程竞争开销;弱引用不参与强生命周期管理,仅通过原子读取校验对象是否存活。
关键数据结构
typedef struct {
atomic_int ref_count; // 强引用计数(初始为1)
atomic_int weak_count; // 弱引用计数(含自身)
void *data;
} obj_t;
ref_count控制资源释放时机:减至0时释放data;weak_count保证对象元信息(如计数器内存)在所有弱引用析构后才回收。两者均需独立原子操作,不可合并。
生命周期协同逻辑
- 强引用增/减:
atomic_fetch_add(&obj->ref_count, 1)/atomic_fetch_sub(&obj->ref_count, 1) - 弱引用增:
atomic_fetch_add(&obj->weak_count, 1) - 弱引用访问前必须检查:
atomic_load(&obj->ref_count) > 0
状态迁移表
| 操作 | ref_count | weak_count | 后续动作 |
|---|---|---|---|
| 强引用释放 | →0 | ≥1 | 释放 data,不释放结构体 |
| 最后弱引用释放 | ≥0 | →0 | 释放整个 obj_t |
graph TD
A[创建对象] --> B[ref_count=1, weak_count=1]
B --> C[强引用+1]
B --> D[弱引用+1]
C --> E[ref_count减至0?]
E -->|是| F[free data]
D --> G[weak_count减至0?]
G -->|是| H[free obj_t]
3.3 GC屏障缺失场景下的悬空指针防御:基于guard page与影子内存的运行时防护
当垃圾回收器未插入写屏障(如某些保守式GC或跨语言嵌入场景),对象被回收后其内存仍可能被误读——悬空指针悄然滋生。
防御核心思想
利用操作系统级保护机制与轻量元数据协同拦截非法访问:
- Guard Page:在堆块尾部映射不可访问页,捕获越界解引用
- 影子内存(Shadow Memory):为每8字节主存维护1字节状态位,实时标记
ALIVE/FREED/POISONED
状态映射表
| 主存地址范围 | 影子值 | 含义 |
|---|---|---|
[0x1000,0x1007] |
0x01 |
已分配活跃 |
[0x1008,0x100f] |
0xff |
已释放(毒化) |
访问检查伪代码
bool is_dereference_safe(void* ptr) {
uintptr_t shadow_addr = (uintptr_t)ptr >> 3; // 8:1 影子映射
uint8_t status = *(uint8_t*)(shadow_addr + SHADOW_BASE);
return status == 0x01; // 仅 ALIVE 允许访问
}
该函数在每次指针解引用前插入(通过编译器插桩或LD_PRELOAD劫持),开销恒定 O(1),不依赖GC屏障。影子基址 SHADOW_BASE 通常设为固定虚拟地址(如 0x7fff00000000),避免动态查表。
运行时拦截流程
graph TD
A[程序尝试解引用ptr] --> B{计算影子地址}
B --> C[读取影子字节]
C --> D{状态==0x01?}
D -->|是| E[允许访问]
D -->|否| F[触发SIGSEGV<br>或跳转至安全处理例程]
第四章:指针语义的坍缩与重建:unsafe.Pointer、uintptr与C指针的类型安全转换
4.1 Go uintptr转C指针的“不可寻址性”破局:runtime.Pinner与持久化句柄实践
Go 中将 uintptr 转为 C 指针时,若底层 Go 对象被 GC 移动或回收,会导致悬垂指针——根源在于 uintptr 是纯数值,不具备 GC 可寻址性。
runtime.Pinner:让对象“钉住”在内存中
var pinner runtime.Pinner
data := make([]byte, 1024)
pinner.Pin(&data[0]) // 防止底层数组被移动
defer pinner.Unpin()
cPtr := (*C.char)(unsafe.Pointer(&data[0]))
Pin() 将对象首地址注册进运行时 pinned set,阻止 GC 重定位;Unpin() 必须配对调用,否则内存泄漏。参数为 *unsafe.Pointer 或切片首元素地址,仅支持堆分配对象。
持久化句柄替代方案
| 方案 | 生命周期管理 | 安全性 | 适用场景 |
|---|---|---|---|
uintptr 直接转换 |
手动管理 | ❌(易悬垂) | 短期、栈上临时调用 |
runtime.Pinner |
显式配对 | ✅(GC 友好) | 中长期 C 回调持有 |
reflect.Value + unsafe |
不推荐 | ⚠️(绕过类型安全) | 已废弃 |
graph TD
A[Go slice] --> B[runtime.Pinner.Pin]
B --> C[GC 不移动该对象]
C --> D[C 函数持有有效指针]
D --> E[回调完成 → Unpin]
4.2 C函数回调中Go闭包指针的跨语言存活保障(避免栈帧销毁)
Go 闭包在传递给 C 回调时,若未显式保活,其底层 runtime._funcval 所绑定的栈帧可能在 Go 调度器切换时被回收,导致悬垂指针。
核心机制:runtime.SetFinalizer + C.malloc 双重防护
- 将闭包转换为
*C.void前,用C.malloc分配堆内存并拷贝闭包数据; - 同时对 Go 端持有该闭包的
interface{}变量注册SetFinalizer,确保 GC 不提前回收; - C 层回调结束前调用
C.free显式释放。
// C 侧回调原型(需与 Go 导出签名严格一致)
void on_event(void* ctx, int code) {
struct go_closure* cb = (struct go_closure*)ctx;
cb->fn(cb->data, code); // 安全调用 Go 函数指针
}
此处
ctx指向C.malloc分配的持久化闭包结构体,cb->fn是runtime·goPanic安全封装后的 Go 函数指针,cb->data为闭包捕获变量副本。
生命周期对照表
| 阶段 | Go 栈帧状态 | C ctx 内存来源 | 是否安全 |
|---|---|---|---|
C.register(cb) 调用后 |
可能被 GC 回收 | C.malloc 堆区 |
✅ |
| Go goroutine 调度切换 | 已销毁 | C.malloc 堆区 |
✅ |
C.free(ctx) 调用前 |
无关 | 仍有效 | ✅ |
graph TD
A[Go 创建闭包] --> B[memcpy 到 C.malloc 块]
B --> C[SetFinalizer 阻止 GC]
C --> D[C 层异步回调]
D --> E[回调内解引用 cb->fn]
E --> F[C.free 释放 ctx]
4.3 *C.struct_X 与 Go struct 内存布局对齐的ABI验证与自动化校验工具链
核心挑战:跨语言内存视图一致性
C 结构体默认按字段自然对齐(如 int64 对齐到 8 字节边界),而 Go 编译器可能因 //go:align 或字段重排引入隐式填充差异,导致 unsafe.Pointer 转换后字段偏移错位。
自动化校验流程
$ go run abicheck.go --c-header=x.h --go-pkg=github.com/example/x
→ 解析 C 头文件生成 C.struct_X 偏移映射 → 反射提取 Go struct 字段 unsafe.Offsetof → 比对各字段 Offset、Size、Align 三元组。
关键校验维度对比
| 字段 | C 偏移 | Go 偏移 | 是否一致 | 原因 |
|---|---|---|---|---|
flags |
0 | 0 | ✅ | 首字段无填充 |
count |
8 | 12 | ❌ | Go 在 uint32 后插入 4 字节 padding |
校验失败时的修复建议
- 使用
//go:packed(慎用,影响性能) - 显式添加
_ [4]byte填充字段保持对齐 - 通过
#pragma pack(1)统一 C 端对齐策略(需同步修改构建参数)
// abicheck.go 核心校验逻辑片段
func verifyFieldAlignment(cFields, goFields []fieldInfo) error {
for i := range cFields {
if cFields[i].Offset != goFields[i].Offset {
return fmt.Errorf("offset mismatch at %s: C=%d, Go=%d",
cFields[i].Name, cFields[i].Offset, goFields[i].Offset)
}
}
return nil
}
该函数逐字段比对偏移值;fieldInfo 包含 Name(字段名)、Offset(字节偏移)、Size(字节长度)和 Align(对齐要求),确保 ABI 层面二进制兼容性可被机器判定。
4.4 cgo -gcflags=-G=3 下的指针逃逸抑制与C API暴露面最小化实践
Go 1.22 引入 -G=3 新逃逸分析器,显著提升 cgo 边界处的指针生命周期判定精度。
逃逸分析对比效果
| 场景 | -G=2(旧) |
-G=3(新) |
|---|---|---|
C.CString(s) 转换后立即传入 C 函数 |
逃逸至堆 | 保留在栈(若无跨函数引用) |
&x 传入 C.func(&x) 且 C 不存储该指针 |
保守逃逸 | 精确判定为不逃逸 |
关键实践:最小化 C API 暴露面
- 仅导出纯 C 兼容结构体(无 Go 指针字段)
- 使用
//export前置声明时,参数全部为 C 类型(*C.char,C.int等) - 避免在 C 回调中持有 Go 指针,改用
runtime.SetFinalizer或显式C.free
// ✅ 安全:栈分配 + 显式生命周期控制
func ProcessData(data string) C.int {
cstr := C.CString(data) // -G=3 下:若 data 为局部变量且未被 C 持有,则 cstr 栈分配
defer C.free(unsafe.Pointer(cstr))
return C.process_data(cstr)
}
C.CString 返回 *C.char,-G=3 能识别 cstr 仅用于本次 C.process_data 调用,避免无谓堆分配;defer C.free 确保及时释放,消除内存泄漏风险。
第五章:通往生产级C互操作的终局思考
在构建 Rust 与 C 混合部署的微服务网关时,某金融支付平台曾遭遇核心交易链路 127ms 的不可预测延迟抖动。根因并非 Rust 代码逻辑,而是 extern "C" 函数调用中未对齐的 #[repr(C)] 结构体导致 x86-64 ABI 栈帧错位,触发 CPU 频繁重填微指令缓存(uop cache miss)。这一案例揭示:生产级互操作的“终局”,不在语法兼容,而在内存布局、调用约定与生命周期契约的毫米级协同。
内存对齐的硬性约束
C ABI 要求结构体字段按最大成员对齐,而 Rust 默认使用 #[repr(Rust)]。以下对比直接决定二进制兼容性:
| 字段定义 | C 声明 | Rust #[repr(C)] |
实际大小(x86-64) |
|---|---|---|---|
struct { uint32_t a; uint64_t b; } |
sizeof=16 |
#[repr(C)] struct { a: u32, b: u64 } |
✅ 16 bytes |
同上但 Rust 忘加 #[repr(C)] |
sizeof=16 |
struct { a: u32, b: u64 } |
❌ 通常 12 bytes(Rust 自优化) |
// 生产环境必须显式声明
#[repr(C)]
#[derive(Debug, Clone)]
pub struct PaymentRequest {
pub amount_cents: u64,
pub currency_code: [u8; 3], // ISO 4217, null-padded
pub timestamp_ns: i64,
}
跨语言错误传播的零拷贝方案
该平台弃用传统 errno 全局变量,改用内联错误码嵌入返回结构体:
// C header
typedef struct {
int32_t status; // 0=OK, -1=INVALID_AMOUNT, -2=TIMEOUT
uint64_t trace_id;
char error_msg[64]; // 可变长尾部需手动 strncpy
} payment_result_t;
Rust 端通过 std::mem::transmute_copy 零成本转换,避免堆分配:
unsafe fn c_to_rust_result(c_res: *const payment_result_t) -> Result<(), &'static str> {
let res = std::ptr::read_unaligned(c_res);
if res.status == 0 { Ok(()) } else { Err(std::ffi::CStr::from_ptr(res.error_msg.as_ptr()).to_str().unwrap_or("unknown")) }
}
ABI 稳定性保障流程
团队引入自动化验证流水线:
- 使用
bindgen从 C 头文件生成 Rust 绑定; - 运行
cargo test -- --nocapture执行 ABI 对齐断言; - 在 CI 中注入
gcc -dumpmachine与rustc --print target-list交叉校验目标三元组; - 每次发布前执行
llvm-objdump -t libpayment.so | grep "T _Z.*"确保符号导出无 C++ name mangling。
flowchart LR
A[修改C头文件] --> B[bindgen生成rust-bindings.rs]
B --> C[编译Rust crate]
C --> D[运行abi_compatibility_test]
D --> E{所有字段offset==C sizeof?}
E -->|Yes| F[合并PR]
E -->|No| G[失败并打印diff]
生产环境信号安全实践
当 C 库调用 sigaction() 注册 SIGUSR1 用于热重载配置时,Rust 的 std::sync::atomic::AtomicUsize 必须声明为 #[repr(C)] 并使用 Relaxed 内存序——因为信号处理函数运行在独立栈帧,无法保证 SeqCst 的全局可见性。实际部署中,团队将原子计数器封装为 static mut CONFIG_VERSION: AtomicUsize = AtomicUsize::new(0);,并在信号处理函数内以 unsafe { CONFIG_VERSION.load(Ordering::Relaxed) } 读取。
调试工具链深度集成
在 Kubernetes Pod 中,通过 perf record -e 'syscalls:sys_enter_ioctl' -p $(pgrep rust_gateway) 捕获 C 库 ioctl 调用耗时;再用 rust-gdb --args ./target/release/gateway 加载 libbacktrace.so 符号,定位到 libssl.so 中 SSL_read() 的 TLS 握手阻塞点——最终发现是 OpenSSL 1.1.1k 与 Rust openssl crate 0.10.x 的 BIO 方法签名不匹配导致循环等待。
内存所有权移交的确定性边界
C 回调函数接收 void* user_data 参数时,Rust 端严格遵循:若回调可能异步执行(如 libuv 的 uv_timer_start),则 Box::leak(Box::new(...)) 创建 'static 引用,并在回调完成时由 C 层调用 free() 释放;若回调同步返回,则使用 std::mem::transmute 将 &T 转为 *const c_void,禁止在回调内解引用已 drop 的栈变量。某次线上事故即源于误将 String::as_ptr() 传入异步回调,导致 use-after-free。
