如何在Rust FFI库中复用Tokio Runtime?——sn_api异步函数的FFI封装优化方案问询
我正在为包含异步函数的sn_api库编写FFI包装器,用于Red语言的单线程非异步代码中。目前我在每个导出函数里用Runtime::new().unwrap().block_on(...),但每次调用都创建新Runtime的开销太大。下面是我的代码:
use std::os::raw::c_char; use std::ffi::{CString, CStr}; use sn_api::{BootstrapConfig, Safe}; use tokio::runtime::Runtime; #[no_mangle] pub extern "C" _safe_connect(ptr: *const Safe, bootstrap_contact: *const c_char) { assert!(!ptr.is_null()); let _safe = unsafe { &*ptr }; let bootstrap_contact = unsafe { CStr::from_ptr(bootstrap_contact) }; let mut bootstrap_contacts = BootstrapConfig::default(); bootstrap_contacts.insert(bootstrap_contact.parse().expect("Invalid bootstrap address")); // 如何在其他函数中复用该Runtime? Runtime::new().unwrap().block_on(_safe.connect(None, None, Some(bootstrap_contacts))); }
我想让所有异步函数都运行在同一个共享的Tokio Runtime上,考虑过用单例/全局实例,但我的库是以crate-type = ["cdylib"]编译的,担心全局变量不合适。请问最优的实现方案是什么?
完全同意你的想法——复用同一个Tokio Runtime是解决这个开销问题的核心。针对你的场景,有两种成熟的方案,各有优劣,你可以根据Red端的需求选择:
方案1:全局单例Runtime(最适合单线程Red场景)
虽然编译为cdylib,全局变量其实是完全可行的,只要我们用线程安全的方式初始化它。Tokio的Runtime本身实现了Send和Sync,适合全局共享。我们可以用once_cell或者lazy_static crate来创建一个懒加载的全局Runtime实例,确保它只初始化一次。
示例代码
首先在Cargo.toml中添加依赖:
[dependencies] once_cell = "1.18"
然后修改你的FFI代码:
use std::os::raw::c_char; use std::ffi::{CStr}; use sn_api::{BootstrapConfig, Safe}; use tokio::runtime::Runtime; use once_cell::sync::Lazy; // 懒加载全局Runtime,第一次使用时初始化,后续复用 static GLOBAL_RUNTIME: Lazy<Runtime> = Lazy::new(|| { Runtime::new().expect("Failed to initialize Tokio Runtime") }); #[no_mangle] pub extern "C" _safe_connect(ptr: *const Safe, bootstrap_contact: *const c_char) -> i32 { // 指针检查不能少,避免未定义行为 if ptr.is_null() || bootstrap_contact.is_null() { return -1; // 返回错误码给Red端处理 } let safe = unsafe { &*ptr }; let bootstrap_contact = unsafe { CStr::from_ptr(bootstrap_contact) }; // 替换expect为错误处理,避免panic跨越FFI边界 let bootstrap_addr = match bootstrap_contact.parse() { Ok(addr) => addr, Err(_) => return -2, // 解析失败的错误码 }; let mut bootstrap_contacts = BootstrapConfig::default(); bootstrap_contacts.insert(bootstrap_addr); // 复用全局Runtime执行异步调用 match GLOBAL_RUNTIME.block_on(safe.connect(None, None, Some(bootstrap_contacts))) { Ok(_) => 0, // 成功返回0 Err(_) => -3, // 连接失败的错误码 } }
这种方案的优点是:
- Red端代码不需要额外管理Runtime,调用FFI函数即可
- 完全自动复用同一个Runtime,开销最小
- 实现简单,几乎不需要修改Red端逻辑
需要注意的点:
- 不要用
expect或panic!,因为FFI边界的panic会导致未定义行为,一定要返回错误码让Red处理 - 如果Red是单线程的,这个方案完美适配;如果Red后续可能引入多线程,只要确保全局Runtime的线程池配置合理即可
方案2:显式初始化+传递Runtime句柄(更灵活可控)
如果你希望避免全局状态,或者需要在Red端控制Runtime的生命周期,可以提供一个初始化函数创建Runtime,返回其指针给Red,后续所有FFI函数都需要传入这个指针,最后再调用销毁函数释放资源。
示例代码
use std::os::raw::c_char; use std::ffi::{CStr}; use std::ptr; use sn_api::{BootstrapConfig, Safe}; use tokio::runtime::Runtime; // 创建Runtime并返回其指针 #[no_mangle] pub extern "C" _safe_create_runtime() -> *mut Runtime { match Runtime::new() { Ok(runtime) => Box::into_raw(Box::new(runtime)), // 将Runtime装箱后转为裸指针 Err(_) => ptr::null_mut(), // 创建失败返回空指针 } } // 销毁Runtime,释放资源 #[no_mangle] pub extern "C" _safe_destroy_runtime(runtime_ptr: *mut Runtime) { if !runtime_ptr.is_null() { unsafe { Box::from_raw(runtime_ptr); } // 从裸指针恢复Box,自动drop } } #[no_mangle] pub extern "C" _safe_connect(runtime_ptr: *mut Runtime, ptr: *const Safe, bootstrap_contact: *const c_char) -> i32 { if runtime_ptr.is_null() || ptr.is_null() || bootstrap_contact.is_null() { return -1; } let runtime = unsafe { &*runtime_ptr }; let safe = unsafe { &*ptr }; let bootstrap_contact = unsafe { CStr::from_ptr(bootstrap_contact) }; let bootstrap_addr = match bootstrap_contact.parse() { Ok(addr) => addr, Err(_) => return -2, }; let mut bootstrap_contacts = BootstrapConfig::default(); bootstrap_contacts.insert(bootstrap_addr); match runtime.block_on(safe.connect(None, None, Some(bootstrap_contacts))) { Ok(_) => 0, Err(_) => -3, } }
这种方案的优点是:
- 完全无全局状态,更符合模块化设计
- Red端可以控制Runtime的创建和销毁,甚至可以创建多个Runtime(虽然你的场景可能不需要)
- 更灵活,适合复杂的集成场景
缺点是Red端需要额外管理Runtime指针,增加了一点复杂度。
总结建议
因为你的Red代码是单线程非异步的,**方案1(全局单例Runtime)**是最优选择——它实现简单,Red端无需额外工作,同时完全解决了重复创建Runtime的开销问题。只要确保错误处理得当(避免panic),这个方案非常稳定可靠。
内容的提问来源于stack exchange,提问作者Maciej Łoziński

