【連載】兩百行Rust程式碼解析綠色執行緒原理(三)棧

  • 2020 年 2 月 12 日
  • 筆記

原文: 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 程式語言 中的一章。

棧是什麼樣的

讓我們從這張簡化的棧示意圖開始。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);      }  }