#fork #once-cell #process #data #child-process #primitive

wipe-on-fork

Unix 系统中进程被 fork 时自动清理自身的 OnceCell, LazyCell, Once, OnceLock, LazyLock 的替代品

5 个版本

0.2.3 2024 年 1 月 25 日
0.2.2 2024 年 1 月 25 日
0.2.1 2024 年 1 月 25 日
0.2.0 2024 年 1 月 25 日
0.1.0 2024 年 1 月 24 日

#239数据结构

每月 35 次下载

MIT/Apache

315KB
1.5K SLoC

Wipe-on-fork 为 Rust 提供的 OnceCell, LazyCell, Once, OnceLock, LazyLock

a group of people cleaning the room

关于金字塔的创造者是谁,埃及人还是外星人,一直有阴谋论。同样,几千年后,我们可以预期未来的人会问:互联网是人类还是外星人发明的?

当时的史学家可以引用 HTTP 状态码 418 I'm a teapot,这个状态码最初是一个愚人节玩笑,用来证明 HTTP 是由喝茶、制作茶壶并具有幽默感的人类发明的。创建 save418.com 网站的 Shane Brunswick,这个网站对于保留 418 状态码至关重要,他说:“这是提醒我们,计算机背后的过程仍然是由人类制造的。”

类似的事情也发生在计算机科学的其他领域。其中之一就是 fork()。它是一个进程创建另一个进程的方式。在 HotOS 2019 上,四位享有盛誉的计算机系统研究人员——Andrew Baumann、Jonathan Appavoo、Orran Krieger 和 Timothy Roscoe——在他们题为 A fork() in the road 的论文中,讨论了为什么人们应该避免使用 fork(),尽管它在 POSIX 和操作系统设计中是核心,并且被广泛使用。平行宇宙可能没有 fork()

由于 fork() 是一种低级操作系统原语,它以使应用程序几乎透明的这种方式进行更改,因此它始终存在兼容性问题。对于 Rust 来说,这一直是个头疼的问题(见 https://github.com/rust-lang/rust/issues/6930https://github.com/rust-lang/rust/issues/9373https://github.com/rust-lang/rust/issues/9568https://github.com/rust-lang/rust/issues/16799)。

这就需要替换上述的 lazy_static!,以便子进程可以运行自己的,而不是从父进程中继承。这与 Linux 中的“在 fork 期间擦除”(在 Linux madvise 函数中称为“wipe-on-fork”的概念密切相关),该函数允许程序在 fork 时通知操作系统擦除页面。

MADV_WIPEONFORK(自 Linux 4.14 以来)

在 fork(2) 之后,向子进程提供该范围内已填充零的内存。这在为服务器进行 fork 以确保敏感的进程数据(例如,PRNG 种子、加密机密等)不会传递给子进程时很有用。

因此,我们采用了这种命名约定,并创建了许多数据结构。

此存储库重新实现了(复制粘贴,但进行了修改)以下来自 Rust 的 std 库的结构

Rust std 此库
std::cell::OnceCell wipe_on_fork::WipeOnForkOnceCell
std::cell::LazyCell wipe_on_fork::WipeOnForkLazyCell
std::sync::Once wipe_on_fork::WipeOnForkOnce
std::sync::OnceLock wipe_on_fork::WipeOnForkOnceLock
std::sync::LazyLock wipe_on_fork::WipeOnForkLazyLock

大部分代码,包括文档测试,都是从rust-lang/rust中的 Rust std 库中复制粘贴的。我们之所以这样做,而不是以黑盒方式使用现有的原语——这始终是首选选择——是因为(1)其中一些仍在等待稳定,(2)一些必要的类型或函数仅可在 std 包中使用,(3)我们的一些更改需要更多底层操作。

使用 OnceCellLazyCellOnceOnceLockLazyLock 的擦除-on-fork 版本类似于它们的 keep-on-fork 对应版本。需要注意的是,这些擦除-on-fork 版本并不是“更好”或“更通用”的实现。某些应用程序会 特别 需要擦除-on-fork,而其他应用程序会 特别 需要 keep-on-fork。这就是为什么我们在前缀 WipeOnFork* 中包含,以提醒它们虽然相关但基本不同,因为它们都涉及 fork()

请注意,fork() 并不是创建子进程的唯一解决方案。实际上,一个更受欢迎的解决方案(尽管不那么方便)是使用 posix_spawn() 创建新进程。这已经在 Dask 中使用,但在 Ray 中没有使用(见 https://github.com/ray-project/ray/issues/13568 中的讨论)。使用擦除-on-fork 原语是为了在可组合性上提供兼容性,因为任何进程的任何线程都可以调用 fork(),而更少破坏性的解决方案就像 线程安全 一样,编写具有 fork 安全性 的代码。

fork 检测

检测后台分叉有两大方法。

  • 检查代码是否在另一个进程ID (PID)下运行,该进程ID可以从以下代码获取:std::process::id()
  • 通过pthread_atfork()注册分叉处理程序

我们最终没有采用PID方法,因为它有一个固有的限制。在Unix中,无法保证PID不会重复。事实上,假设整个操作系统已经拥有pid_max - 3个进程(注意:由于64位系统中的pid_max是2^22,这非常不可能)

  • 父亲的PID为a
  • 父亲分叉并创建了一个子进程,其PID为b
  • 子进程分叉并创建了一个孙子进程,其PID为c
  • 父亲去世,留下a可供操作系统重用
  • 孙子进程分叉,可以期待为曾孙进程获得PID a
  • 如果父亲创建并初始化了一个Once,并且这个Once被儿子和孙子保持不变,当曾孙进程第一次使用它时,它无法区分这个Once是否应该被清除。

尽管这非常罕见,因为大多数消费级内存不太可能达到pid_max进程的数量,但我们选择采用更稳健的方法。

我们引入了“代”的概念。当一个分叉时清除对象首次初始化时,它将是第一代(即,具有代ID 0)。这个代ID存储在一个全局变量中。

pub struct GenerationCounter {
    pub(crate) gen: Mutex<Option<u64>>,
}

// implementations of `GenerationCounter`

pub(crate) static GENERATION: GenerationCounter = GenerationCounter::new();

我们使用pthread_atfork()注册分叉处理程序。每次分叉时,我们都要求增加这个计数器。因为未来的代将继承这个分叉处理程序。

unsafe extern "C" fn update_generations() {
    let mut lock = GENERATION.gen.lock().unwrap();
    if lock.is_some() {
        *lock = Some(lock.unwrap() + 1);
    } else {
        panic!("The generation counter is expected to have started.");
    }
}

impl GenerationCounter {
    pub fn get(&self) -> u64 {
        // code before pthread_atfork
        unsafe {
            libc::pthread_atfork(None, None, Some(update_generations));
        }
        // code after pthread_atfork
    }
}

这解决了问题,因为这里的曾孙进程保证具有代ID为3

实现细节

现在我们突出显示每个是如何实现的。

OnceCell

回忆一下,std::cell::OnceCell的实现如下。

pub struct OnceCell<T> {
    inner: UnsafeCell<Option<T>>,
}

我们的实现如下。

pub struct WipeOnForkOnceCell<T> {
    generation_id: Cell<Option<u64>>,
    inner: UnsafeCell<Option<T>>,
    _not_send_sync: PhantomData<*const ()>,
}

我们使用Cell来托管Option<u64>,这样我们就可以在只有对WipeOnForkOnceCell<T>的非可变引用的情况下修改代ID。使用_not_send_sync是一种黑客手段,用于实现不支持在标准库之外的负特征!Sync。有关此黑客手段的讨论,请参阅此处

我们在 getget_mutsettry_insertinto_inner 上运行检查 wipe_if_should_wipe。如果需要擦除,它会清除单元格。

#[inline]
fn wipe_if_should_wipe(&self) {
    if self.check_if_should_wipe() {
        self.generation_id.set(None);
        unsafe {
            *self.inner.get() = None;
        }
    }
}

LazyCell

回顾一下,std::cell::LazyCell 的实现如下。

enum State<T, F> {
    Uninit(F),
    Init(T),
    Poisoned,
}

pub struct LazyCell<T, F = fn() -> T> {
    state: UnsafeCell<State<T, F>>,
}

我们的实现如下。

enum State<T, F> {
    Uninit(F),
    Init(T, F),
    Poisoned,
}

pub struct WipeOnForkLazyCell<T, F = fn() -> T> {
    generation_id: Cell<Option<u64>>,
    state: UnsafeCell<State<T, F>>,
    _not_send_sync: PhantomData<*const ()>,
}

大部分设计考虑与 OnceCell 相同。我们修改状态以在 Init 中保持 F,因为在子项中可能需要重新初始化。我们还更改了实现中函数的类型,从 FnOnce 更改为 FnMut,以允许函数被多次调用。

以下是一个更细致的 wipe_if_should_wipe,在 getinto_innerforce 之前运行。

#[inline]
fn wipe_if_should_wipe(&self) {
    if self.check_if_should_wipe() {
        self.generation_id.set(None);
        
        let is_state_init = unsafe {
            match *self.state.get() {
                State::Init(_, _) => true,
                _ => false,
            }
        };
        
        if is_state_init {
            let state = unsafe { &mut *self.state.get() };
            let State::Init(_, f) = core::mem::replace(state, State::Poisoned) else {
                unreachable!()
            };
            
            unsafe { self.state.get().write(State::Uninit(f)) };
        }
    }
}

Once

现在我们将注意力转向线程安全的原语。核心之一是 std::sync::Once,它是 std::sync::OnceLockstd::sync::LazyLock 的启动器,我们将在下面讨论。

std::sync::Once 的实现如下。

pub struct Once {
    inner: sys::Once,
}

其中,sys::Once 可解析为

pub struct Once {
    state: AtomicU32,
}

我们的实现采取了不同的方法,如下。

#[derive(Clone, Copy, PartialEq, Eq)]
pub enum State {
    Incomplete,
    Poisoned,
    Running,
    Complete,
}

pub struct WipeOnForkOnce {
    generation_id: Mutex<Option<u64>>,
    state: Mutex<State>,
}

它基于 https://github.com/rust-lang/rust/blob/master/library/std/src/sys/pal/unsupported/once.rs 中的非线程安全实现,如下所示

pub struct Once {
    state: Cell<State>,
}

因为 Linux 或 Unix 中 std::sync::Once 的默认实现会涉及许多底层原语。为了使上述构造线程安全,我们利用了 Mutex。幸运的是,Mutex::new() 是一个 const 函数(见 https://github.com/rust-lang/rust/pull/97791)。

与我们在 OnceCellLazyCell 中使用的 Cell<Option<u64>> 相比,这里我们使用 Mutex<Option<u64>> 来保证线程安全。

wipe_if_should_wipe() 函数在 call_once()call_once_force()is_completed()state() 函数之前运行。

#[inline]
fn wipe_if_should_wipe(&self) {
    let mut lock = self.generation_id.lock().unwrap();

    let res = match *lock {
        None => false,
        Some(generation_id) => generation_id != crate::utils::GENERATION.get(),
    };

    if res {
        *lock = None;
        *self.state.lock().unwrap() = State::Incomplete;
    }
}

OnceLock

std::sync::OnceLock 的实现如下。

pub struct OnceLock<T> {
    once: Once,
    value: UnsafeCell<MaybeUninit<T>>,
    _marker: PhantomData<T>,
}

我们的实现有一些不同之处。

pub struct WipeOnForkOnceLock<T> {
    once: WipeOnForkOnce,
    value: UnsafeCell<Option<T>>,
    _marker: PhantomData<T>,
}

我们将 Once 替换为 WipeOnForkOnce。这似乎对大多数功能来说已经足够了。然而,我们使用 UnsafeCell<Option<T>> 而不是 UnsafeCell<MaybeUninit<T>>,因为我们不能在我们的代码中使用不稳定的 #[may_dangle] 属性。然而,没有这个属性,程序的行为会改变。

unsafe impl<#[may_dangle] T> Drop for OnceLock<T>  {
    // the implementation 
}

我们更愿意将处理释放者的任务委托给 Rust。因此,我们将其改为 Option<T>,并让 Rust 枚举实现来处理细节,而不是使用更难的 MaybeUninit

LazyLock

std::sync::LazyLock 的实现如下,使用了 union

union Data<T, F> {
    value: ManuallyDrop<T>,
    f: ManuallyDrop<F>,
}

pub struct LazyLock<T, F = fn() -> T> {
    once: Once,
    data: UnsafeCell<Data<T, F>>,
}

但是,由于我们需要保留函数(在子进程中的初始化),我们确实需要偏离这种实现。我们的实现如下。

pub struct WipeOnForkLazyLock<T, F = fn() -> T> {
    once: WipeOnForkOnce,
    func: UnsafeCell<ManuallyDrop<F>>,
    data: UnsafeCell<ManuallyDrop<Option<T>>>,
}

我们也让数据成为 Option<T>,因为数据在延迟初始化之前不存在。因此,代码的其余部分也相应修改,包括释放者。对一些较小的更改进行了处理,以处理获取(即 take())。

非 Unix 的行为

我们还没有在纯 Windows(不是 WSL,也不是 Cygwin)中使用我们的实现进行广泛测试,但我们预计它将正确工作。我们基本上禁用了擦除-on-fork 检查,所以它们始终假设没有发生分叉(这是 Windows 没有分叉的情况)。

许可证

Rust 在 MITApache 2 许可证(联合使用)下。这个存储库复制粘贴了大部分代码,继承了相同的许可证。

依赖项