7

两百行Rust代码解析绿色线程原理(三)栈

 3 years ago
source link: https://zhuanlan.zhihu.com/p/100964432
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.

两百行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)
翻译: 耿腾


栈只不过是一块连续的内存。

这一点很重要。计算机只有内存,它没有特殊的“栈”内存和“堆”内存,它们都是同一个内存的某一部分。

它们不同之处在于如何访问和使用该部分内存。栈支持在内存的连续部分上使用简单的入栈/弹栈指令,这使得它使用起来很快。堆内存由内存分配器按需分配,并且可以分散在不同的位置。

我们不会在这里讨论栈和堆之间的差异,因为有很多文章详细解释它们,包括 Rust 编程语言 中的一章。

栈是什么样的

v2-d330ba49f6e8fd9dde5ad579a16442ea_720w.jpg

让我们从这张简化的栈示意图开始。64 位 CPU 一次读取 8 个字节,尽管我们看到栈的自然方式是一长行的 u8 ;所以当我们传递指针时,我们需要确保传入的指针指向 0016,0008 或上例中的 0000

栈向下增长,因此我们从顶部开始向下工作。

当我们将栈指针设置为 16 字节对齐 的栈时,我们需要确保栈指针指向那些地址值为 16 的倍数的位置。在上面的示例中,满足此要求的唯一地址是 0008(记住栈从顶部开始)。

如果我们在上一章中添加以下代码行,就在我们在 main 函数中进行切换之前,我们可以有效地打印出我们的栈并查看它:

for i in (0..SSIZE).rev() {
    println!("mem: {}, val: {}",
    stack_ptr.offset(i as isize) as usize,
    *stack_ptr.offset(i as isize))
}

我们得到的输出是:

mem: 94846750517871, val: 0
mem: 94846750517870, val: 0
mem: 94846750517869, val: 0
mem: 94846750517868, val: 0
mem: 94846750517867, val: 0
mem: 94846750517866, val: 0
mem: 94846750517865, val: 0
mem: 94846750517864, val: 0
mem: 94846750517863, val: 0
mem: 94846750517862, val: 0
mem: 94846750517861, val: 86
mem: 94846750517860, val: 67
mem: 94846750517859, val: 56
mem: 94846750517858, val: 252
mem: 94846750517857, val: 205
mem: 94846750517856, val: 240
mem: 94846750517855, val: 0
mem: 94846750517854, val: 0
mem: 94846750517853, val: 0
mem: 94846750517852, val: 0
mem: 94846750517851, val: 0
mem: 94846750517850, val: 0
mem: 94846750517849, val: 0
mem: 94846750517848, val: 0
mem: 94846750517847, val: 0
mem: 94846750517846, val: 0
mem: 94846750517845, val: 0
mem: 94846750517844, val: 0
mem: 94846750517843, val: 0
mem: 94846750517842, val: 0
mem: 94846750517841, val: 0
mem: 94846750517840, val: 0
mem: 94846750517839, val: 0
mem: 94846750517838, val: 0
mem: 94846750517837, val: 0
mem: 94846750517836, val: 0
mem: 94846750517835, val: 0
mem: 94846750517834, val: 0
mem: 94846750517833, val: 0
mem: 94846750517832, val: 0
mem: 94846750517831, val: 0
mem: 94846750517830, val: 0
mem: 94846750517829, val: 0
mem: 94846750517828, val: 0
mem: 94846750517827, val: 0
mem: 94846750517826, val: 0
mem: 94846750517825, val: 0
mem: 94846750517824, val: 0
I LOVE WAKING UP ON A NEW STACK!

我已经在这里把内存地址打印成 u64 类型,这样如果你不熟悉十六进制也容易肉眼解析。

首先要注意的是,这只是一块连续的内存,从地址 94846750517824 开始,到 94846750517871 结束。

地址 94846750517856 到 94846750517863 应该需要我们特别注意。第一个地址是我们的“栈指针”的地址,我们写入 CPU 的 %rsp 寄存器的值。范围表示在我们进行切换之前写入栈的值。

换句话说,值 240,205,252,56,67,86,0,0 是指向我们的 hello() 函数的指针,只是写成了多个 u8 类型的值。

这里有一个有趣的注意事项是 CPU 将 u64 写为 u8 字节的顺序取决于它的字节顺序。我将简单地参考维基百科的文章,但如果你试图手动解析这些数字,你必须牢记这一点。

当我们编写更复杂的函数时,我们极小的 48 字节栈将很快耗尽空间,你看,当我们运行我们在 Rust 中编写的函数时,我们的代码将指示 CPU 在我们的栈上入栈和弹出值来执行我们的程序。

当你在大多数现代操作系统中启动进程时,标准栈大小通常为 8 MB,但可以进行不同的配置,这对于大多数程序来说已经足够了,但是需要由我们开发者保证使用的时候不会超出这个大小。这就是我们大多数人经历过的可怕的 “栈溢出” 的原因。

但是,当我们自己控制栈时,我们可以选择我们想要的大小。例如,在 Web 服务器中运行简单函数时,每个上下文都用 8 MB 是超出我们的需要的,因此通过减少栈大小,我们可以在一台机器上运行数百万个绿色线程,而如果使用操作系统提供的栈,我们会更快把内存用光。

可增长的栈

某些实现使用可增长的栈。这让我们可以只分配一小部分内存就足够为大多数任务使用,但是当我们用光这个栈时它不会导致栈溢出,而是分配一个新的更大的栈并将所有内容从当前栈中移到这个新的更大的栈上,并可以恢复程序继续执行。

Go 语言就是一个这样的例子。它从一个 8 KB 的栈开始,当它的空间用完时,它会重新分配到一个更大的栈。但是正如编程中的每一件事都是有代价的,所有指针都需要正确地被更新,这不是一件容易的事。如果你对 Go 如何处理它的栈更感兴趣(这是可增长栈的使用和权衡的一个很好的例子)可以参看这篇文章: https://blog.cloudflare.com/how-stacks-are-handled-in-go/

请注意稍后会很重要的一件事:我们使用 Rust 标准库中普通的 Vec<u8>。对我们来说非常方便,但也有一些问题。除了其它之外,我们无法保证它会留在内存中的同一位置。 你可能会想到,如果栈移动到不同的地址空间,我们的程序会崩溃,因为我们的所有指针都将变为无效。比如对我们的栈执行 push() 这样简单的操作可能会触发一次增长,当 Vec 扩展它时会请求一个新的、更大的内存块并将值移动到新位置。

好了,现在我们已经了解了栈的外观和工作原理,我们已准备好继续实现绿色线程。你已经完成了很多艰苦的工作,所以我答应你开始写代码。

如何设置栈

Windows x64-86 的栈设置与 x64-86 psABI 调用约定略有不同。我将在 附录:支持Windows 的章节中花更多时间介绍 Windows 栈,但重要的是要知道如果用那些并不接受多个参数的简单函数设置栈,两者的差异不是很大,就像我们目前做的这样。

psABI 的栈布局如下:

如你所知,%rsp 是我们的栈指针。你可以看到,我们需要将栈指针放在距离我们的基地址为 16 的倍数位置。返回的地址位于相邻的 8 个字节中,如你所见,上面有一个内存参数的空间。当我们想要做比迄今为止更复杂的事情时,我们需要牢记这一点。

如果你足够好奇,你可能想知道切换到栈后它发生了什么?

答案是我们用 Rust 编写的代码被编译成 CPU 的指令,然后就像使用任何其他的栈一样,接管并使用我们的栈。

遗憾的是,为了清楚地展示这一点,我得将栈大小增加到 1024 字节,才能为打印出栈本身获得足够的空间,所以目前这样我们无法打印。

不过,我制作了一个示例的更改版本,在运行时它会打印出两个文本文件,一个是 BEFORE.txt,在我们切换到栈之前打印出我们的栈,一个 AFTER.txt 打印出我们切换后的栈。然后,你可以自己查看栈现在是如何存活并由我们的代码使用的。

如果你在此代码中看到任何你无法识别的内容,请稍作休息,我们会尽快搞清楚它们。

#![feature(asm)]
#![feature(naked_functions)]
use std::io::Write;

const SSIZE: isize = 1024;
static mut S_PTR: *const u8 = 0 as *const u8;

#[derive(Debug, Default)]
#[repr(C)]
struct ThreadContext {
    rsp: u64,
    r15: u64,
    r14: u64,
    r13: u64,
    r12: u64,
    rbx: u64,
    rbp: u64,
}

fn print_stack(filename: &str) {
    let mut f = std::fs::File::create(filename).unwrap();
    unsafe {
        for i in (0..SSIZE).rev() {
            writeln!(
                f,
                "mem: {}, val: {}",
                S_PTR.offset(i as isize) as usize,
                *S_PTR.offset(i as isize)
            )
                .expect("Error writing to file.");
        }
    }
}

fn hello() {
    println!("I LOVE WAKING UP ON A NEW STACK!");
    print_stack("AFTER.txt");

    loop {}
}

unsafe fn gt_switch(new: *const ThreadContext) {
    asm!("
        mov 0x00($0), %rsp
        ret
        "
    :
    : "r"(new)
    :
    : "alignstack"
    );
}

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

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

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK