iT邦幫忙

2022 iThome 鐵人賽

DAY 26
0

前言

在這一篇文章中,我們將會介紹何謂 Thread,和 Process 之間有什麼樣的區別,前面介紹的 Thread 和 Process 的差別是理論上的差別 (根據恐龍書,和實際上 xv6 有不小的差別),以及在 xv6 中 Thread 和 Process 之間的關係該如何理解。以及了解 Thread 是如何完成切換的。最後我們將介紹 compute bound thread,以及背後的相關處理。

Thread

先回看到前面 Process 的概念,Process 存在的目的是為了要讓電腦進行多工處理,如一邊執行 VSCode 一邊使用 git 等等。而 Thread 存在的目的,是為了讓在某一些場合下,讓程式變得更容易組織,且更容易實現並行執行的想法。

Thread 又被稱為 Lightweight Process,是作業系統在分配 CPU 占用時間 (也就是排程的概念),CPU 使用時的一個基本單位,一個 Process 中,可能會有許多個 Thread,如下圖所示

< source >

可以理解成 Thread 是一個 Process 實際在執行的單位。Thread 會共享 Process 內許多的資源,包含

  • Memory space
    • code section
    • data section
  • open file

而以下是屬於 Thread 本身的私有資源 (無法被共享)

  • Program Counter: 目前 Thread 執行到的指令位置。
  • CPU Register
  • Stack: 每一個 Thread 擁有自己的 Stack,記錄一些函式呼叫的資訊。
  • State, Thread ID: 目前 Thread 處於的 State (用於排班,像是 Ready, Blocked, Running 等等),以及和 Process 一樣,擁有標示每一個 Thread 的 Thread ID。

而一隻程式如果想要實現並行運算,常見的方法是將一隻程式拆分成多個子部分,假設我們目前是一部 4 核心處理器的電腦,則理想上我們可以將任務拆分成 4 個部分,並且在 4 個核心上執行,達到原先單一核心的 4 倍效率 (理論上)。

根據上面的概念往下延伸,我們可以簡單的認為基本上 Thread 就是一個串行執行程式的一個單位 (程式的片段),假設一隻程式使用串行的方法進行撰寫,那麼執行他時就是一個 Thread 進行處理。而如果使用並行的方式進行撰寫,那麼我們就可以使用多 Thread 的方式進行加速。這一段話也呼應到 Day-01 中並行的概念,並行是一種程式設計的架構。

而相比於 Process,從上面的圖中我們可以發現到一個事實,Thread 彼此之間並不是獨立的 (原因為他們共享 Process 的 code section 和記憶體內容),而這麼做的好處是可以更高效的去實現並行執行,相比於 Process,Process 需要進行 context switch,而前面我們看到了 context switch 的操作,是一項需要大量時間成本的操作,包含儲存暫存器內容等等,使用 Thread 可以讓我們相對更加高效率的完成這樣的操作。

Process 與 Thread,單一 Thread 與多 Thread

前面我們看到的 Process 實際上為單一 Thread 的程式,例如如果 Process 為文書處理程式,如 Word,則有單一個 Thread 正在被執行,單一 Thread 這邊表達的是串行的概念,也就是一個 Process 一次執行一項任務,無法一邊打字一邊使用拼字檢查法等等。而假設我們的 Process 裡面不只有一個 Thread,而是有多個 Thread,也就是多個串行的指令同時執行,則概念上這個 Process 是在進行並行處理,一個 Process 一次完成多個任務,如一邊打字一邊執行拼字檢查法。

上面有提到 Thread 本質上來說也是為了多工處理,前面我們知道 Process 也可以達到同樣的效果,那為何需要 Thread 的存在?

假設下面情況,網頁伺服器接收客戶端要求網頁,影像,聲音等等資料,如果我們使用單一 Thread 的 Process 進行處理,則當我們收到請求時,我們會產生出新的 Process 進行處理該服務請求,而產生 Process 實際上是一個十分耗費資源與時間成本的做法 (在 Solaris 上,產生 Process 的速度比產生 Thread 慢了 30 倍),一個 Process 的產生會涉及到許多系統呼叫,記憶體分頁分配等等操作。

而如果使用多 Thread 的 Process,則伺服器可以產生出多個 Thread 去監聽客戶端的請求,產生 Thread 的成本低於 Process,且 Thread 共享 Process 中的資源,如果客戶端要求了一些相同的資源,則 Thread 之間可以直接存取 Process 的記憶體區塊達到共享資源的目的。如果是單一 Thread 的 Process,則只能使用共享記憶體或是訊息交換的方式去分享資源。

而 Thread 因為共享資源的緣故,所以會涉及到一些同步處理所發生的問題,諸如 Thread 相互搶資源或是 deadlock 等等問題需要處理。

Scheduling

在 Thread 處理中我們需要考量到以下議題 :

  • 如同 Process,我們也需要在多個 Thread 之間完成切換,而停止一個 Thread 並啟動另外一個 Thread 的行為稱為 Scheduling。在 xv6 中每一個 hart 都有一個 Scheduler。
  • 在 Process 中我們在完成切換時,我們會將一些訊息保存在 Process 的 Trapframe 中,而對於 Thread,我們也需要考慮 Thread 哪一些訊息需要儲存,並且儲存在哪裡。
  • 我們在 Process 切換時,會涉及到一些中斷處理的議題,而在 Thread 中,Thread 會在自己的任務結束之後,保存自己的狀態並且讓出自己所持有的資源,但是假設 Thread 目前執行的任務可能需要大量的時間進行處理,我們就需要一些機制讓 Thread 非自願的釋放出自己所持有的資源,例如使用 Timer Interrupt 進行處理,而這種長期占用資源的 Thread 也稱為 compute bound thread。

在 CPU 的每一個核心上面,都會有一個特定的硬體,用來產生 Timer Interrupt (在 Day-01 有稍微提及),Timer Interrupt 會定期的去發起中斷,而這樣就可以用來解決上面提及的 compute bound thread 的問題了。回顧前面提及 Trap 的部分,在 usertrap() 中,我們會通過 scause CSR 去判斷我們發生 Trap 的原因,我們可以發現到其中有對於 Timer Interrupt 的處理,而處理 Timer Interrupt 的方式是通過 yield() 去釋放資源,之後我們會對於這個部分進行討論。

Timer Interrupt 發起中斷,將 CPU 的控制權轉交給 Kernel,User space 並非自願讓出 CPU 資源,而是通過 Timer Interrupt 將資源強制出讓給 CPU,這樣的現象稱為 pre-emptive scheduling,pre-emptive 中文可以理解成先發制人,也就是直接通過 Timer Interrupt 去搶走資源的感覺。在 xv6 的 Scheduling 是通過 pre-emptive scheduling 所達成的。

Thread 在 Scheduling 時,也會涉及到一些 Thread state 的切換,而下面我們介紹 Thread 的三種狀態,這三種狀態在 proc.h 中我們可以看到

enum procstate { UNUSED, USED, SLEEPING, RUNNABLE, RUNNING, ZOMBIE };

Thread state 大概可以分成下面這三種 (可以看到在 xv6 中並沒有特別為 thread 建立一個類似 threadstate 的 enum,在 xv6 中每一個 Process 只有 2 個 Thread,一個為在 User space 上的 Thread,另外一個為在 Kernel space 上的 Thread,下面會對這個部分進行更多的描述)

  • RUNNING: Thread 在目前的 CPU (hart) 上執行
  • RUNABLE: Thread 還沒有在某一個 CPU (hart) 上執行,Thread 已經做好一切準備進入 RUNNING 的準備,只要一有閒置的 CPU 資源,Thread 就可以執行,並且進入 RUNNING
  • SLEEPING: Thread 等待一些 I/O 事件,只有在 I/O 事件發生時才會執行

而在上面我們說 Timer Interrupt 觸發 yield() 讓 Thread 去釋放 CPU 的資源,意思就是將某一個 Thread State 由 RUNNING 轉換成 RUNABLE。pre-emptive scheduling 將一個 RUNNING 的 Thread 變成 RUNNABLE 的 Thread,而這部份我們可能會直觀的想到後面我們還需要保存 Thread 的狀態,包含暫存器等等資訊,以便之後等待 CPU 處於閒置時,Thread 能夠進入到 RUNNING。而下面我們就通過 Scheduling 來了解相關的操作。

Switch Thread

Thread 在 xv6 中我們可以直接想像成 CPU 分配資源的實體,所以我們下面會從 Thread 的角度重新看待 trap 的流程,下面會使用到 Kernel Thread 和 User Thread 這兩個詞來描述 Process 在不同空間下執行的實體 (User space 上的 Process 以 User Thread 代稱,Kernel space 上的 Process 以 kernel Thread 代稱,但實際上在 xv6 中一個 Process 實作上只有一個 Thread)。

回顧一下在 Process 的 Trap,詳細部分可以參考在 Trap 篇章中提及的部分,大致上如果 Process 呼叫了一個 System call,Process 的狀態以及暫存器會被保存在 Trapframe 中,接著 CPU 會走到 Process 的 Trapoline page 中執行對應的 Trap handler,處理完成後,通過 Trapframe 恢復 User space 底下的狀態與暫存器,接著回到 User mode。

上面是因為 System call 而發生 Trap 的流程,而 Timer Interrupt 同樣也會觸發 Trap,前面提到我們會通過 pre-emptive scheduling 的方式,觸發 Timer Interrupt,接著通過 yield() 強迫 Thread 或是 Process 讓出目前持有的 CPU 資源,如果 xv6 kernel 決定從一個 Process 切換到另外一個 Process,那麼在 kernel 中的 Process 就會從 kernel 中的 Thread 切換到另外一個 Process 的 kernel 中的 Thread。概念上為以下說明

  • 如果我們想要從 Process A 切換到 Process B,則我們需要從 Process A 進入到 Kernel space,並且將 Process A 暫存器的值儲存在 Process A 的 Trapframe 中,接著在 Kernel space 底下執行 Process 的 Kernel thread。
  • 接著我們從 Process A 的 Kernel Thread 切換到 Process B 的 Kernel Thread。
  • Process B 暫停自己的 Kernel Thread,並且通過 Process B 的 Trapframe 恢復暫存器內容。
  • 接著回到 Process B 繼續執行,可以理解成 Process B 繼續在 User space 執行或者 Process B 的 User Thread 在 User space 底下繼續執行。

上面這是大致上 Thread Switch 的概念,如果我們要更詳細的去描述 Thread Switch 的概念,我們可以表示成以下,並且引入一個 yield(), swtch() 等概念

  • 在 User space 底下的 Process A 因為 Timer Interrupt,我們從 User mode 進入到 Supervisor mode 中,也就是我們目前處在 Kernel space 中。在 trampoline.s 中 uservec 會將 User mode 底下的 Process 的暫存器儲存到 Process A 結構中的 Trapframe 區域中
  • 之後我們在 Supervisor mode,也就是 Kernel space 底下執行 uservec(),這時候 Process A 的 Kernel Thread 和使用 Kernel Stack 去執行 uservec()
  • 假設這時候 Process A 的 kernel Thread 對應到的 CPU 資源這時候需要被釋出,這時候會通過 swtch() 來切換到其他 Process 的 kernel Thread。
  • swtch() 會將 Process A 對應到的 kernel Thread 的暫存器內容儲存到 context。

所以到這裡,我們對於 proc 這個結構有了更多的理解,前面在 Process 的介紹時,context 的理解是儲存 kernel space 底下的 Process 暫存器內容,trapframe 是儲存 user space 底下的 Process 暫存器內容。
而現在,我們的理解為 context 為儲存 kernel Thread 的暫存器內容,trapframe 為儲存 user Thread 的暫存器內容。

而可以看到一個 Process 不會同時出現 User Thread 和 Kernel Thread,因為 Process 只會在一個空間中執行,要就是在 User space 中,不然就是在 Kernel space 中。

在上面我們大致上描述完 Thread switch 了,以及引入了 swtch(),我們將在後面對這個函式進行追蹤,以及從 swtch() 的情為去理解 Scheduler 的概念。

實驗: 通過 Timer Interrupt 去打斷 Compute Bound Thread (pre-emptive scheduling)

要製造出 compute bound thread,我們需要讓一個 Thread 長時間持有 CPU 資源,我們可以最簡單的使用無限迴圈辦到這一件事情,測試程式碼如下:

//TThread
#include "kernel/types.h"
#include "user/user.h"

int main(void)
{
    int pid;
    char c;

    pid = fork();
    if(pid == 0)
    {
        c = '/';
    }
    else
    {
        printf("parent pid is %d, child pid is %d\n", getpid(), pid);
        c = '\\';
    }
    for(int i = 0;;i++)
    {
        if((i % 1000000) == 0)
            write(2, &c, 1);
    }
    exit(0);
}

這邊我們要測試一件事情,在只有一個 CPU 的情況下,存在兩個 compute bound thread,可以看到我們通過 fork() 建立出子 Process,且不論是親代 Process 或是子代 Process 皆會陷入一個無限迴圈,因此不會主動讓出 CPU 的資源 (compute bound thread),我們要測試是否 compute bound thread 會因為 Timer Interrupt 而被打斷,以下測試

make qemu

接著執行程式碼,出現以下結果

子 Process 會印出 \,親代 Process 會印出 /,可以看到在子代 Process 占用 CPU 資源時,會偶爾的切換到親代 Process,照道理說子 Process 是會無限的佔用 CPU 資源,而這裡讓出資源的原因便是 pre-emptive scheduling 的策略,我們可以通過追蹤這邊發生的 trap,進而去驗證這個是否為 Timer Interrupt 所導致的 trap。

首先,我們在 trap 中知道,會進到 usertrap() 中判斷這是什麼原因或是類型所引發的 trap,下面回顧一下 usertrap()

void
usertrap(void)
{
  int which_dev = 0;

  if((r_sstatus() & SSTATUS_SPP) != 0)
    panic("usertrap: not from user mode");

  // send interrupts and exceptions to kerneltrap(),
  // since we're now in the kernel.
  w_stvec((uint64)kernelvec);

  struct proc *p = myproc();
  
  // save user program counter.
  p->trapframe->epc = r_sepc();
  
  if(r_scause() == 8){
    // system call

    if(killed(p))
      exit(-1);

    // sepc points to the ecall instruction,
    // but we want to return to the next instruction.
    p->trapframe->epc += 4;

    // an interrupt will change sepc, scause, and sstatus,
    // so enable only now that we're done with those registers.
    intr_on();

    syscall();
  } else if((which_dev = devintr()) != 0){
    // ok
  } else {
    printf("usertrap(): unexpected scause %p pid=%d\n", r_scause(), p->pid);
    printf("            sepc=%p stval=%p\n", r_sepc(), r_stval());
    setkilled(p);
  }

  if(killed(p))
    exit(-1);

  // give up the CPU if this is a timer interrupt.
  if(which_dev == 2)
    yield();

  usertrapret();
}

可以預期,我們會在 which_dev = devintr() 執行 devintr(),而我們可以追蹤進入 devintr(),我們預期在執行完 devintr() 後,會回傳 2 到 usertrap() 中。

int
devintr()
{
  uint64 scause = r_scause();

  if((scause & 0x8000000000000000L) &&
     (scause & 0xff) == 9){
    // this is a supervisor external interrupt, via PLIC.

    // irq indicates which device interrupted.
    int irq = plic_claim();

    if(irq == UART0_IRQ){
      uartintr();
    } else if(irq == VIRTIO0_IRQ){
      virtio_disk_intr();
    } else if(irq){
      printf("unexpected interrupt irq=%d\n", irq);
    }

    // the PLIC allows each device to raise at most one
    // interrupt at a time; tell the PLIC the device is
    // now allowed to interrupt again.
    if(irq)
      plic_complete(irq);

    return 1;
  } else if(scause == 0x8000000000000001L){
    // software interrupt from a machine-mode timer interrupt,
    // forwarded by timervec in kernelvec.S.

    if(cpuid() == 0){
      clockintr();
    }
    
    // acknowledge the software interrupt by clearing
    // the SSIP bit in sip.
    w_sip(r_sip() & ~2);

    return 2;
  } else {
    return 0;
  }
}

所以我們預期每一段時間如果 xv6 都會發起 Timer Interrupt,則理論上每一次都會在固定的時間進入到 scause == 0x8000000000000001L 當中,並且將 2 回傳到 usertrap(),因此,我們可以在 scause == 0x8000000000000001L 這個地方下一個中斷點,以下實驗。

我們使用 make qemu-gdb 來執行 xv6 的 debug 模式,接著我們在另外一個 terminal 執行 gdb-multiarch,我們知道 xv6 隨時都會發起 Timer Interrupt,因此這邊實驗的想法為我們先讓 xv6 執行起來,接著在 gdb 中由鍵盤發起中斷,接著讓 xv6 執行 TThread 這個 spin 的程式,我們在這個程式執行期間,使用鍵盤發起 Interrupt,同時設下斷點,理論上在我們繼續執行時,TThread 應該會進入到 Timer Interrupt 的部分,而因為我們在這裡設下了斷點,因此 TThread 應該會停下來,以下實驗

我們在 gdb 的監看視窗中,使用 layout next 來監看目前程式碼的狀態,並選擇 continue 繼續執行 xv6,直到 xv6 啟動流程結束,並且成功開啟 shell。

接著我們會發現這時候 gdb 並無法控制到 xv6,我們這時候在 xv6 執行 TThread,並在執行到一半時,我們在 gdb 中使用 Ctrl + C 對 xv6 發起中斷,獲得控制權

可以看到我們成功執行了 TThread,並且發起了中斷,可以由右邊的 gdb 視窗看到,接著我們在 trap.c 的 224 行,也就是 scause == 0x8000000000000001L 的地方下一個斷點 (Timer Interrupt 進入點),接著我們繼續執行 TThread,理論上 TThread 會執行到一半便觸碰到我們下的中斷點並且停止執行

可以看到這邊 TThread 成功觸發到了 Timer Interrupt,接著我們讓這一段程式碼繼續執行完成,並且在 usertrap() 中檢視 devintr() 的回傳值

可以看到這邊 devintr() 的回傳值為 2,也就是 Timer Interrupt 發生,接著便會發生 yield() 讓我們從目前的 Thread 切換到另外一個 Thread,到這裡我們便驗證完成了,在 compute bound thread 存在的情況下,會被 Timer Interrupt 給 Interrupt。

reference

xv6-riscv
Operating System Concepts, 9/e
RISC-V xv6 Book
wiki: Thread
Thread in Operating System


上一篇
Day-24 xv6 Process, Init Process
下一篇
Day-26 xv6 Switch Thread, yield, sched
系列文
與作業系統的第一類接觸 : 探索 xv631
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言