14

两百行Rust代码解析绿色线程原理(二)一个能跑通的例子

 3 years ago
source link: https://zhuanlan.zhihu.com/p/100846626
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

两百行Rust代码解析绿色线程原理(二)一个能跑通的例子

原文: Green threads explained in 200 lines of rust language
地址: https://cfsamson.gitbook.io/green-threads-explained-in-200-lines-of-rust/
作者: Carl Fredrik Samson(cfsamson@Github)
翻译: 耿腾


在这个例子中,我们将创建自己的栈,并使 CPU 从它当前执行的上下文切换到到我们创建的栈。我们将在后面的章节中基于这些概念进行构建(不过我们不会在这些代码的基础上构建)。
译者注:阅读本章时建议读者自己也动手写代码运行一下,很多问题就容易理解了。

创建我们的项目

首先,让我们在名为 green_threads 的文件夹中启动一个新项目。命令行执行:

cargo init

我们需要使用 nightly 版本的 Rust,因为我们将使用一些尚未稳定的功能:

rustup override set nightly

在我们的 main.rs 文件中,我们首先启用一个功能,它允许我们使用 asm! 宏:

#![feature(asm)]

我们在这里设置一个较小的栈尺寸,只有 48 个字节,这样我们可以在切换上下文之前打印并查看这个栈:

const SSIZE: isize = 48;

在 OSX 中使用这么小的栈的好像有些问题。此代码运行的最小值是 624 字节的栈大小。如果你想要遵循这个确切的例子,代码可以在 Rust Playground 上运行(但是由于最终代码中的循环,你需要等待大概 30 秒的超时时间)。

然后,我们添加一个表示 CPU 状态的结构。我们现在只关注存储 “栈指针” 的寄存器,所以只需要添加下面的代码:

#[derive(Debug, Default)]
#[repr(C)]
struct ThreadContext {
    rsp: u64,
}

在后面的示例中,我们将使用之前链接中的规范文档中标记为 “callee saved”(由被调用者保存的) 的所有寄存器。这些就是 x86-64 ABI 描述的寄存器中那些用来保存上下文的寄存器,但是现在我们只需要一个寄存器来使 CPU 跳转到我们的栈。

需要注意的是,这个结构定义需要加上 #[repr(C)],因为我们需要按照汇编代码的方式去访问数据。 Rust 没有稳定的 ABI,因此我们无法确保它会在内存中以 rsp 作为前 8 个字节来表示。 C 具有稳定的 ABI,这一属性正是在告诉编译器使用兼容 C-ABI 的内存布局。当然,我们的结构现在只有一个字段,但我们稍后会添加更多字段。

fn hello() -> ! {
    println!("I LOVE WAKING UP ON A NEW STACK!");

    loop {}
}

对于这个非常简单的例子,我们将定义一个函数,它只打印一条消息,然后永远循环:

接下来是我们的内联汇编,我们切换到自己的栈。

unsafe fn gt_switch(new: *const ThreadContext) {
    asm!("
        mov 0x00($0), %rsp
        ret
        "
    :
    : "r"(new)
    :
    : "alignstack" // 目前没有这句也可以工作,不过后面会用到
    );
}

我们在这里使用了一个技巧。我们写入要在新栈上运行的函数的地址。然后我们将存储此地址的第一个字节的地址传递给 rsp 寄存器(我们设置给 new.rsp 的地址值将指向 位于我们自己的栈上的地址,该地址将导致上述函数被调用)。我讲清楚了吗?

ret 关键字将程序控制转移到位于栈顶部的返回地址。由于我们将地址推送到 %rsp 寄存器,因此CPU会认为它是当前运行的函数的返回地址,因此当我们传递 ret 指令时,它会直接返回到我们自己的栈中。

CPU 做的第一件事就是读取函数的地址并运行它。

Rust 内联汇编宏的快速入门

如果您之前没有使用内联汇编,可能会看起来很陌生,但我们稍后会使用扩展版本来切换上下文,所以我将逐行解释我们正在做什么:

unsafe 是一个关键字,表示 Rust 无法在我们编写的函数中强制执行安全保证。由于我们直接操作 CPU,这绝对是不安全的。

gt_switch(new: *const ThreadContext)

在这里我们获取一个指向 ThreadContext 实例的指针,我们只读取一个字段。

asm!("

这是 Rust 标准库中的 asm! 宏。它将检查我们的语法,在遇到看起来不像 AT&T(默认情况下)汇编语法的情况时会产生一个错误消息。

这个宏里第一个输入是汇编模板:

mov 0x00($0), %rsp

这是一个简单的指令,它将存储在基地址为 $0 偏移量为 0x00 处的值(这意味着在十六进制中完全没有偏移)移动到 rsp 寄存器。由于 rsp 寄存器存储指向栈上下一个值的指针,因此我们有效地将我们提供的地址压到当前的栈上,覆盖了当前已有的值。

在普通的汇编代码中,你不会看到这样使用的 $0。这是汇编模板的一部分,是第一个参数的占位符。参数编号为 0,1,2 ...... 从输出参数开始,然后继续输入参数。我们这里只有一个输入参数,对应于 $0

如果在普通汇编中遇到 $,它很可能意味着一个立即值(一个整数常量),但也要看具体情况(是的,$可以代表方言之间以及 x86 汇编和 x86-64 汇编之间的不同之处)。

ret

ret 关键字指示 CPU 从栈顶部弹出一个内存位置,然后无条件跳转到该位置。实际上我们已经劫持了我们的 CPU 并使其返回到我们的栈。

:

内联 ASM 与普通 ASM 略有不同。我们在汇编模板后传递了四个附加参数。这是第一个被称为 output(输出) 的,它是我们传递输出参数的地方,这些参数是我们想要在Rust函数中用作返回值的参数。

: "r"(new)

第二个是我们的输入参数。在编写内联汇编时,"r" 被称为一个 constraint(约束)。您可以使用这些约束来有效地指导编译器决定放置输入的位置(例如,在一个寄存器中作为值或将其用作“内存”位置)。 "r" 仅表示将其放入编译器选择的通用寄存器中。内联汇编中的约束本身是一个很大的课题,幸运的是我们的需求很简单。

:

下一个选项是 clobber 列表,您可以在其中指定编译器不应触及的寄存器,并让它知道我们要在汇编代码中管理这些寄存器。如果我们弹出栈的任何值,我们需要在这里指定哪些寄存器并让编译器知道,因此它知道它不能自由地使用这些寄存器。我们不需要它,因为我们返回了一个全新的栈。

: "alignstack"

最后一个是我们的 options(选项)。这些对于 Rust 来说是独一无二的,我们可以设置的选项由三种:alignstackvolatileintel。我会向你介绍文档以了解它们,在这里有具体解释。值得注意的是,我们需要为代码指定 “对齐栈(alignstack)” 才能在 Windows 上运行。

运行我们的例子

fn main(){
    let mut ctx = ThreadContext::default();
    let mut stack = vec![0_u8; SSIZE as usize];
    let stack_ptr = stack.as_mut_ptr();

    unsafe {
        std::ptr::write(stack_ptr.offset(SSIZE - 16) as * mut u64, hello as u64);
        ctx.rsp = stack_ptr.offset(SSIZE - 16) as u64;
        gt_switch(&mut ctx);
    }
}

所以这实际上是在设计我们的新栈。 hello 已经是一个指针了(一个函数指针),所以我们可以直接把它转换为一个 u64,因为 64 位系统上的所有指针都是 64 位,然后我们将这个指针写入我们的新栈。

我们将在下一章中详细讨论栈,但现在我们需要知道的一件事是栈向下增长。如果我们的 48 字节栈在索引 0 处开始,并在索引 47 处结束,则索引 32 将是从栈末尾开始的 16 字节偏移量的第一个索引。

请注意,我们将指针写入距离栈底部16字节的偏移量(还记得我写的关于16字节对齐的内容吗?)。

我们把它作为指向 u64 的指针而不是指向 u8 的指针。我们想要写入位置 32、33、34、35、36、37、38、39,这是我们存储 u64 所需的 8 字节空间。如果我们不进行这个类型转换,我们实际上是在尝试将 u64 写入位置 32(译者注:即将一个 u64 写入到一个 u8 中,显然存不下),这不是我们想要的。

译者注:stack_ptr 的类型为 * mut u8stack_ptr.offset(SSIZE - 16) 也是 * mut u8

我们将 rsp(栈指针)设置为 栈中索引为 32 的内存地址,我们传递的不是存储在该位置的 u64 值而是首字节的地址。

译者注:如果传递的是存储在该位置的值,代码就应该是 (stack_ptr.offset(SSIZE - 16) as * mut u64).read(),即 hello 函数指针的值;如果是首字节地址,就是上面那段代码中的 stack_ptr.offset(SSIZE - 16) as u64(这个地址以 u64 类型存储,因为我们要把它赋值给 u64 类型的 ctx.rsp)。

当我们执行 cargo run 命令时,我们将看到如下输出:

Finished dev [unoptimized + debuginfo] target(s) in 0.58s
Running `target\debug\green_thread_start.exe`
I LOVE WAKING UP ON A NEW STACK!

好的,究竟发生了什么? 我们在任何时候都没有调用函数 hello,但它仍然运行了。发生的事情是我们实际上让 CPU 跳转到我们自己的栈并在那里执行代码。我们迈出了实现上下文切换的第一步。

在接下来的章节中,我们会在实现绿色线程之前先探讨一点栈相关的内容,这个过程会更加容易,因为目前我们已经涵盖了很多基础知识。

如果要运行它,可以在这里查看完整代码。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK