10个稳定版本
3.4.0 | 2024年8月2日 |
---|---|
3.3.5 | 2024年8月1日 |
3.3.4 | 2024年7月30日 |
2.0.0 | 2024年7月16日 |
1.0.1 | 2024年7月12日 |
#20 in FFI
1,007每月下载量
48KB
418 行
rubicon
rubicon通过cdylib包和严格保证的不变量,在Rust中启用一种危险形式的动态链接。
名称
韦伯斯特词典将“rubicon”定义为
一个界限或限制线。特别是:一旦越过,就会使一个人无法回头。
在这种情况下,我将其视为同一地址空间内几个共享对象之间的限制线,每个对象都包含相同的Rust代码副本。
命名法
不同平台上的动态链接概念有不同的名称
概念 | Linux | macOS | Windows |
---|---|---|---|
共享库 | 共享对象 | 动态库 | 动态链接库 (DLL) |
库文件名 | libfoo.so |
libfoo.dylib |
foo.dll |
库搜索路径 | LD_LIBRARY_PATH |
DYLD_LIBRARY_PATH |
PATH |
预加载机制 | LD_PRELOAD |
DYLD_INSERT_LIBRARIES |
很复杂 |
在本文档中,首选macOS命名约定。
动机
Rust的动态链接模型
(本节内容截至Rust 1.79 / 2024-07-18更新)
cargo和rustc通过编译器标志-C prefer-dynamic支持某种形式的动态链接。
此标志将
- 链接到通过rustup分发的预构建的
libstd-HASH.dylib
,假设您没有使用-Z build-std
- 尝试链接到
libfoobar.dylib
,对于任何包含dylib
在其crate-type
中的cratefoobar
rustc 有一个内部算法来决定对于哪些依赖使用哪种链接方式。这个算法是尽力而为的,可能会失败。
无论如何,它假设在链接时 rustc 已知整个依赖图。
rubicon 的动态链接模型(xgraph
)
然而,有人可能故意要分割依赖图
策略 | 1graph(一个依赖图) | xgraph(多个依赖图) |
---|---|---|
模块 crate 类型 | dylib | cdylib |
地址空间中的重复 | 没有(rlib/dylib 在链接时解析) | 有(设计如此) |
谁加载模块? | 运行时链接器 | 应用程序 |
何时加载模块? | 在 main 之前,无条件 | 任何时间(但不要卸载) |
如何加载模块? | DT_NEEDED / LC_LOAD_DYLIB 等。 |
libdl,很可能是通过 libloading |
我们将 Rust 的“支持”动态链接模型称为“1graph”。
rubicon 允许(自行承担风险),一个不同的模型,我们将其称为“xgraph”。
在“xgraph”模型中,你的应用程序的每个“模块”——任何可以单独构建的内容,如“一堆 tree-sitter 语法”,或“整个 JavaScript 运行时”,都是其自己的依赖图,以具有 crate-type
为 cdylib
的 crate 为根。
在“xgraph”模型中,你的应用程序的“共享对象”(Linux 可执行文件、macOS 可执行文件等只是共享对象——除了它们有一个入口点,与其他库没有太大区别)对其模块没有任何引用——在 main()
执行之前,没有任何模块被加载。
相反,模块是通过类似 libloading 的 crate 明确加载的,它底层使用平台动态链接器加载器提供的任何设施。这使得你可以选择加载哪些模块以及何时加载。
链接和纪律
“xgraph”模型很危险——我们必须使用纪律才能使其正常工作。
特别是,我们将维护以下不变性
- A. 模块永远不会卸载,只会加载。
- B. 应用程序和所有模块都使用完全相同的 rustc 版本来构建
- C. 为应用程序和某些模块所依赖的 crate 启用了完全相同的 cargo 功能。
卸载模块(“A”)会破坏所有 Rust 程序的一个重大假设:即 'static
在程序执行期间始终存在。当卸载模块时,我们可以使某些 'static
消失。
虽然没有人可以阻止你卸载模块,但你现在编写的代码已经不再是安全的 Rust。
混合 rustc 版本(“B”)可能会导致结构体布局的差异,例如。
struct Blah {
a: u64,
b: u32,
}
...无法保证哪个字段将是第一个,是否会有填充,字段将按什么顺序排列。我们希望结构体布局与同一编译器版本匹配,但这可能也无法保证?(引用所需)
混合 cargo 功能集(“C”)也可能导致结构体布局的差异
struct Blah {
#[cfg(feature = "foo")]
a: u64,
b: u32
}
// if the app has `foo` enabled, and we pass a &Blah` to
// a module that doesn't have `foo` enabled, then the
// layout won't match.
或函数签名。或任何时间运行(重复)的代码。
在 xgraph
中无法避免重复
在 1graph
模型中,rustc 能够看到整个依赖图——因此,它能够避免依赖的重复:如果应用程序和其某些模块依赖 tokio
,则它们将依赖于单个 libtokio.dylib
,没有任何重复。
在
应用程序及其模块可以动态链接到tokio:对于每个目标(应用程序是一个目标,每个模块都是一个目标),都会有一个libtokio.dylib文件。
然而,该文件的内容将因目标而异,因为tokio公开了泛型函数。
此代码
tokio::spawn(async move {
println!("Hello, world!");
});
将导致spawn函数被单态化,从这样
pub fn spawn<F>(future: F) -> JoinHandle<F::Output> ⓘ
where
F: Future + Send + 'static,
F::Output: Send + 'static,
变为类似这样(这里的混淆并不真实)
pub fn spawn__OpaqueType__FOO(future: OpaqueType__FOO) -> JoinHandle<()> ⓘ
如果在另一个模块中,我们有以下代码
let jh = tokio::spawn(async move {
// make yourself wanted
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
println!("Oh hey, you're early!");
42
});
let answer = jh.await.unwrap();
那么它将导致另一个单态化tokio的spawn函数,可能看起来像这样
pub fn spawn__OpaqueType__BAR(future: OpaqueType__BAR) -> JoinHandle<i32> ⓘ
现在,你将得到
bin/
app/
executable
libtokio.dylib
(exports spawn__OpaqueType__FOO)
mod_a/
libmod_a.dylib
libtokio.dylib
(export spawn__OpaqueType__BAR)
此时,executable引用其自己的libtokio.dylib(通过绝对路径),以及libmod_a.dylib,到其自己的、独立的、libtokio.dylib
即使您编辑了DT_NEEDED
/ LC_LOAD_DYLIB
信息,使模块指向executable
的动态库版本,您也会在运行时遇到“缺少符号”错误!
从libtokio.dylib | 有__FOO | 有__BAR |
---|---|---|
可执行文件 | ✅ | ❌ |
mod_a | ❌ | ✅ |
您拥有的所有libtokio.dylib文件都没有包含所需的所有符号。
要创建一个包含所有必需符号的libtokio.dylib文件,您需要rustc知道整个依赖图:因此,您将回到1graph模型。
因此,当使用
目标 | 非泛型代码 | 应用程序泛型 | mod_a 泛型 | mod_b 泛型 |
---|---|---|---|---|
应用程序 | ✅ | ✅ | ❌ | ❌ |
mod_a | ✅ | ❌ | ✅ | ❌ |
mod_b | ✅ | ❌ | ❌ | ✅ |
第一列对应于所有非泛型函数、类型等,或者在每个独立的depgraph中以完全相同的方式实例化。
这些的副本将在应用程序可执行文件中,并且在每个libmod_etc.dylib文件中。目前这是不可避免的。
复制全局变量永远不行
既然我们已经接受了将存在代码复制的现实,并且只要代码在不同副本中完全匹配,这是可以的,我们就需要解决复制全局变量永远不行的问题。
特别是,我们所说的全局变量包括
- 线程局部(通过std::thread_local!宏声明)
- 进程局部(更常称为“静态”,通过static关键字声明)
static sample_process_local: AtomicU64 = AtomicU64::new(0);
std::thread_local! {
static sample_thread_local: u64 = 42;
}
fn blah() {
let sample_local = 42;
}
类型 | 进程局部 | 线程局部 | 局部 |
---|---|---|---|
每个作用域唯一 | ❌ | ❌ | ✅ |
每个线程唯一 | ❌ | ✅ | ✅ |
每个进程唯一 | ✅ | ✅ | ✅ |
以tracing为例:它允许您发出“事件”,供“订阅者”处理。它用于结构化日志记录:事件可以是INFO级别,并包含有关某些HTTP请求的信息等。
tracing允许通过tracing::dispatcher::set_global_default注册一个“全局”分发器。这将设置进程全局
static mut GLOBAL_DISPATCH: Dispatch = Dispatch {
subscriber: Kind::Global(&NO_SUBSCRIBER),
};
问题在于,由于所有目标(应用程序及其所有模块)都有自己的 tracing
复制,它们也都有自己的 GLOBAL_DISPATCH
进程局部变量。
对于 mod_a
来说,无论我们是否从应用程序注册了全局分发器都没有关系:根据 mod_a
的 GLOBAL_DISPATCH
复制,没有订阅者!
对此只有一个解决办法:每个人都必须共享相同的 GLOBAL_DISPATCH
:它必须从 app
导出,并且从所有其模块导入。
Rust 导出和导入动态符号的方式
在一个完美的世界里,会有一个类似 -C globals-linkage=[import,export]
的 rustc 标志:我们会将其设置为 export
,以便我们的应用程序声明这些为导出符号,你可以用 dlsym 查找,而后续加载的动态库可以使用,因为它们是动态链接器加载器搜索的符号集的一部分。
然而,我们面临两个障碍。
第一个是动态符号不会导出给可执行文件。幸运的是,有一个链接器标志可以做到这一点:-rdynamic
(也称为 --export-dynamic
)。
第二个是根本就没有这样的 rustc 标志。
导出静态项很容易。
static MERCHANDISE: u64 = 42;
我们可以这样做
#[used]
static MERCHANDISE: u64 = 42;
我们将得到一个混淆的符号
❯ cargo build --quiet
❯ nm -gp ./target/debug/librubicon.dylib | grep MERCHANDISE
00000000000099f0 S __ZN7rubicon11MERCHANDISE17h03e39e78778de1fdE
#[no_mangle]
属性意味着 #[used]
,并且禁用名称混淆
#[no_mangle]
static MERCHANDISE: u64 = 42;
❯ cargo build --quiet
❯ nm -gp ./target/debug/librubicon.dylib | grep MERCHANDISE
00000000000099f0 S _MERCHANDISE
(只需忽略 _
前缀——链接器就是这样可爱的。)
实际上,如果我们想的话,甚至可以指定自己的导出名称
#[export_name = "STILL_MERCHANDISE"]
static PINK_UNICORN: u64 = 42;
❯ cargo build --quiet
❯ nm -gp ./target/debug/librubicon.dylib | grep MERCHANDISE
00000000000099f0 S _STILL_MERCHANDISE
然而,在导入时,没有方法可以选择混淆。
我们可以直接导入它,不进行混淆
extern "C" {
static MERCHANDISE: u64;
}
// (only here to force the linker to import MERCHANDISE)
#[used]
static MERCHANDISE_ADDR: &u64 = unsafe { &MERCHANDISE };
# needed to avoid link errors: `MERCHANDISE` is not present at link time, it's
# only expected to be present at load time.
❯ export RUSTFLAGS="-Clink-arg=-undefined -Clink-arg=dynamic_lookup"
❯ cargo build --quiet
❯ nm -gp ./target/debug/librubicon.dylib | grep MERCHANDISE
00000000000e0210 S __ZN7rubicon16MERCHANDISE_ADDR17h2755f244419dcf79E
U _MERCHANDISE
或者我们可以显式指定一个 link_name
extern "C" {
#[link_name = "STILL_MERCHANDISE"]
static MERCHANDISE: u64;
}
// (only here to force the linker to import MERCHANDISE)
#[used]
static MERCHANDISE_ADDR: &u64 = unsafe { &MERCHANDISE };
00000000000e0210 S __ZN7rubicon16MERCHANDISE_ADDR17h2755f244419dcf79E
U _STILL_MERCHANDISE
坦白说,所有这些替代方案都很糟糕。
如果我们选择混淆,我们可以避免名称冲突,但 不能 再次导入该符号(除非手动将混淆名称复制到 Rust 源代码中)。
如果我们选择不混淆,两个导出 CURRENT_STATE
的 crate 之间可能会发生冲突。
实际上,我们别无选择,只能选择不混淆,并确保依赖图中的各种 crate 的未混淆全局变量之间没有冲突——这意味着,是的,我们又回到了在 C 中手动前缀的境地。
我们已经介绍了进程局部变量。对于线程局部变量的情况,情况类似,但我们必须做一些更复杂的操作,因为 LocalKey
的内部实现是,嗯,内部的,不能从稳定 Rust 中访问。
让所有这些都恰到好处是很棘手的——这就是为什么 rubicon
会有宏,这些宏旨在由任何具有全局状态的 crate 使用,例如 tokio
、tracing
、parking_lot
等。
这并不像 rustc 标志那样好,但这是我们目前能做的。在将来,希望 rubicon
会消失。
制作与 rubicon 兼容的 crate
如果你维护一个具有全局状态的 crate,你可能希望使其与 rubicon 兼容。
依赖 rubicon
你需要添加一个非可选依赖项到它中
cargo add rubicon
在不添加任何功能的情况下,它没有依赖项。
当启用 rubicon/import-globals
或 rubicon/export-globals
时,它将引入 paste,这是一个 proc-macro:我不太喜欢这个想法,但我已经探索了替代方案,并且我认为现在的标记粘贴是最好的。
同时启用 两个 功能会导致编译错误,而禁用两个功能则相当于你的 crate 没有使用 rubicon 的宏(因此大多数使用你的 crate 的用户应该完全不受影响)。
用户负责为 rubicon
添加他们 自己的 依赖并启用任一功能——这避免了功能冗余。只要整个 depgraph 中只有一份 rubicon
的副本(例如,每个人都使用 3.x),那么这个方案就可以工作。
宏化线程局部变量
rubicon::thread_local!
是 std::thread_local!
的直接替代品。
之前
std::thread_local! {
static BUF: RefCell<String> = RefCell::new(String::new());
}
之后
rubicon::thread_local! {
static BUF: RefCell<String> = RefCell::new(String::new());
}
然而,请注意,每当启用导入/导出时,静态链接将禁用你的静态链接。因此,预先添加前缀可能是一个好主意
rubicon::thread_local! {
static MY_CRATE_BUF: RefCell<String> = RefCell::new(String::new());
}
宏化静态变量
之前
static DISPATCHERS: Dispatchers = Dispatchers::new();
static CALLSITES: Callsites = Callsites {
list_head: AtomicPtr::new(ptr::null_mut()),
has_locked_callsites: AtomicBool::new(false),
};
static DISPATCHERS: Dispatchers = Dispatchers::new();
static LOCKED_CALLSITES: Lazy<Mutex<Vec<&'static dyn Callsite>>> = Lazy::new(Default::default);
之后
rubicon::process_local! {
static DISPATCHERS: Dispatchers = Dispatchers::new();
static CALLSITES: Callsites = Callsites {
list_head: AtomicPtr::new(ptr::null_mut()),
has_locked_callsites: AtomicBool::new(false),
};
static DISPATCHERS: Dispatchers = Dispatchers::new();
static LOCKED_CALLSITES: Lazy<Mutex<Vec<&'static dyn Callsite>>> = Lazy::new(Default::default);
}
两者 thread_local!
和 process_local!
都支持多重定义。
此外,如果确实需要(看着你 tracing-core),process_local!
支持 static mut
。
注意你的依赖项
有时线程局部变量和静态变量会隐藏在最意想不到的地方。
例如,tokio
依赖于 parking_lot
,它具有全局状态(你知道吗?)
/// Holds the pointer to the currently active `HashTable`.
///
/// # Safety
///
/// Except for the initial value of null, it must always point to a valid `HashTable` instance.
/// Any `HashTable` this global static has ever pointed to must never be freed.
static PARKING_LOT_HASHTABLE: AtomicPtr<HashTable> = AtomicPtr::new(ptr::null_mut());
实现 xgraph
模型
假设所有依赖项都与 rubicon 兼容,您可以实现 xgraph
模型!
就 crate 而言,您需要
bin
,一个二进制 crate,依赖于exports
和libloading
exports
,一个库 crate,crate-type=["dylib"]
(这只是“dye lib”)- 依赖于 所有 您的 rubicon 兼容依赖项
- 依赖于已启用
export-globals
功能的rubicon
mod_a
,一个库 crate,crate-type=["cdylib"]
(这是“see dye lib”)- 依赖于已启用
import-globals
功能的rubicon
- 依赖于已启用
mod_b
,与mod_a
相同mod_c
,与mod_a
相同- 等等。
需要
exports
crate 来以动态链接器可以理解的方式将地址空间中的所有全局变量引入。技术上
-rdynamic
应该有帮助,但我无法让它工作。
这就是全部了。不要忘记不变量!
- A. 模块永远不会卸载,只会加载。
- B. 应用程序和所有模块都使用完全相同的 rustc 版本来构建
- C. 为应用程序和某些模块所依赖的 crate 启用了完全相同的 cargo 功能。
您可以在 rubicon 仓库 中的 test-crates/
找到完整的示例。