#thread-local #dynamic #linker #global #forms #shared

rubicon

通过cdylib包和严格保证的不变量,在Rust中启用一种危险形式的动态链接,以去重全局变量

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

Download history 111/week @ 2024-07-06 359/week @ 2024-07-13 338/week @ 2024-07-20 397/week @ 2024-07-27 217/week @ 2024-08-03 4/week @ 2024-08-10

1,007每月下载量

MIT/Apache

48KB
418

license: MIT/Apache-2.0 crates.io docs.rs cursed? yes

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中的crate foobar

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-typecdylib 的 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,没有任何重复。

模型中,我们无法实现这一点。按照设计,该应用程序及其所有模块都是完全隔离地构建和链接的。只要它们同意一个薄的FFI(外部函数接口)边界,这可能是由一个所有人都依赖的“通用”crate提供的,它们就可以构建。

应用程序及其模块可以动态链接到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();

那么它将导致另一个单态化tokiospawn函数,可能看起来像这样

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文件中。目前这是不可避免的。

复制全局变量永远不行

既然我们已经接受了存在代码复制的现实,并且只要代码在不同副本中完全匹配,这是可以的,我们就需要解决复制全局变量永远不行的问题。

特别是,我们所说的全局变量包括

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_aGLOBAL_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 使用,例如 tokiotracingparking_lot 等。

这并不像 rustc 标志那样好,但这是我们目前能做的。在将来,希望 rubicon 会消失。

制作与 rubicon 兼容的 crate

如果你维护一个具有全局状态的 crate,你可能希望使其与 rubicon 兼容。

依赖 rubicon

你需要添加一个非可选依赖项到它中

cargo add rubicon

在不添加任何功能的情况下,它没有依赖项。

当启用 rubicon/import-globalsrubicon/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,依赖于 exportslibloading
  • 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/ 找到完整的示例。

依赖项