1. 程式人生 > 實用技巧 >ucore作業系統學習(四) ucore lab4核心執行緒管理

ucore作業系統學習(四) ucore lab4核心執行緒管理

1. ucore lab4介紹

什麼是程序?

  現代作業系統為了滿足人們對於多道程式設計的需求,希望在計算機系統上能併發的同時執行多個程式,且彼此間互相不干擾。當一個程式受制於等待I/O完成等事件時,可以讓出CPU給其它程式使用,令寶貴的CPU資源得到更充分的利用。

  作業系統作為大總管需要協調管理各個程式對CPU資源的使用,為此抽象出了程序(Process)的概念。程序顧名思義就是進行中、執行中的程式。

  物理層面上,一個CPU核心同一時間只能執行一個程式,或者說一個CPU核心某一時刻只能歸屬於一個特定程序。但邏輯層面上,作業系統可以進行程序排程,既可以為程序分配CPU資源,令其執行,也可以在發生等待外設I/O時,避免CPU空轉而暫時掛起當前程序,令其它程序獲得CPU。

  程序能夠隨時在執行與掛起中切換,且每次恢復執行時都能夠接著上次被打斷掛起的地方接著執行。這就需要作業系統有能力保留程序在被掛起時的CPU暫存器上下文快照,當CPU中的暫存器被另外的程序給覆蓋後,在恢復時能正確的還原之前被打斷時的執行現場。新老程序在CPU上交替時,新排程執行緒上下文的恢復和被排程執行緒上下文的儲存行為被稱作程序的上下文切換。

什麼是執行緒?

  程序是一個獨立的程式,與其它程序的記憶體空間是相互隔離的,也作為一個CPU排程的單元工作著,似乎很好的滿足了需求。但有時候也存在一些場景,比如一個檔案處理程式一方面需要監聽並接受來自使用者的輸入,另一方面也要對使用者的輸入內容進行復雜,耗費大量時間的資料處理工作。人們希望在一個程式中既能處理耗時的複雜操作(例如定時存檔等大量的磁碟I/O),同時不能阻塞避免其無法及時的響應使用者指令。

  由於響應使用者輸入指令的程式與批處理程式都需要訪問同樣的內容,雖然作業系統提供了各式各樣的程序間通訊手段,但依然效率不高,為此,電腦科學家提出了執行緒(Thread)的概念。

  執行緒是屬於程序的,同一程序下所有執行緒都共享程序擁有的同一片記憶體空間,沒有額外的訪問限制;但每個執行緒有著自己的執行流和排程狀態,包括程式計數器在內的CPU暫存器上下文是執行緒間獨立的。這樣上述的需求就能通過在檔案處理程序中開啟兩個執行緒分別提供使用者服務和後臺批處理服務來實現。通過作業系統合理的排程,既能實時的處理使用者指令,又不耽誤後臺的批處理任務。

lab4相對於lab3的主要改進

  1. 在/kern/process/proc.[ch]中實現了程序/執行緒的建立、初始化、退出以及控制執行緒的執行狀態等功能。

  2. 在/kern/process/switch.S中實現了執行緒的上下文切換功能。

  3. 在/kern/trap/trapentry.S中實現了forkrets,用於do_forks建立子執行緒後呼叫的返回處理。

  4. 在/kern/schedule/sched.[ch]中實現了一個最基本的FIFO的執行緒CPU排程演算法。

  5. 參考linux引入了slab分配器,修改了之前實驗中對於實體記憶體分配/回收的邏輯(如果不是學有餘力,可以暫時不用理會,當做黑盒子看待就行)。

  lab4是建立在之前實驗的基礎之上的,需要先理解之前的實驗內容才能順利理解lab4的內容。

可以參考一下我關於前面實驗的部落格:

  1.ucore作業系統學習(一) ucore lab1系統啟動流程分析

  2.ucore作業系統學習(二) ucore lab2實體記憶體管理分析

  3.ucore作業系統學習(三) ucore lab3虛擬記憶體管理分析

2. ucore lab4實驗細節分析

  得益於ucore在lab2、lab3中建立起了較為完善的物理、虛擬記憶體管理機制,得以在lab4實驗中建立起記憶體空間獨立的程序機制,以及執行流獨立的執行緒功能。

  在ucore中,並不顯式的區分程序與執行緒,都使用同樣的資料結構proc_struct程序/執行緒管理塊進行管理。當不同的執行緒控制塊對應的頁表(cr3)相同時,ucore認為是同一程序下的不同執行緒。

proc_struct結構:

// process's state in his life cycle
// 程序狀態
enum proc_state {
    // 未初始化
    PROC_UNINIT = 0,  // uninitialized
    // 休眠、阻塞狀態
    PROC_SLEEPING,    // sleeping
    // 可執行、就緒狀態
    PROC_RUNNABLE,    // runnable(maybe running)
    // 殭屍狀態(幾乎已經終止,等待父程序回收其所佔資源)
    PROC_ZOMBIE,      // almost dead, and wait parent proc to reclaim his resource
};

/**
 * 程序控制塊結構(ucore程序和執行緒都使用proc_struct進行管理)
 * */
struct proc_struct {
    // 程序狀態
    enum proc_state state;                      // Process state
    // 程序id
    int pid;                                    // Process ID
    // 被排程執行的總次數
    int runs;                                   // the running times of Proces
    // 當前程序核心棧地址
    uintptr_t kstack;                           // Process kernel stack
    // 是否需要被重新排程,以使當前執行緒讓出CPU
    volatile bool need_resched;                 // bool value: need to be rescheduled to release CPU?
    // 當前程序的父程序
    struct proc_struct *parent;                 // the parent process
    // 當前程序關聯的記憶體總管理器
    struct mm_struct *mm;                       // Process's memory management field
    // 切換程序時儲存的上下文快照
    struct context context;                     // Switch here to run process
    // 切換程序時的當前中斷棧幀
    struct trapframe *tf;                       // Trap frame for current interrupt
    // 當前程序頁表基地址暫存器cr3(指向當前程序的頁表實體地址)
    uintptr_t cr3;                              // CR3 register: the base addr of Page Directroy Table(PDT)
    // 當前程序的狀態標誌位
    uint32_t flags;                             // Process flag
    // 程序名
    char name[PROC_NAME_LEN + 1];               // Process name
    // 程序控制塊連結串列節點
    list_entry_t list_link;                     // Process link list 
    // 程序控制塊雜湊表節點
    list_entry_t hash_link;                     // Process hash list
};

2.1 執行緒的建立與初始化

  ucore在lab4中建立了程序/執行緒的機制,在總控函式kern_init中,通過pmm_init建立了常駐核心的第0號執行緒idle_proc和第1號執行緒init_proc

  整個ucore核心可以被視為一個程序(核心程序),而上述兩個執行緒的cr3指向核心頁表boot_cr3,且其程式碼段、資料段選擇子特權級都處於核心態,屬於核心執行緒。

proc_init函式:

// proc_init - set up the first kernel thread idleproc "idle" by itself and 
//           - create the second kernel thread init_main
// 初始化第一個核心執行緒 idle執行緒、第二個核心執行緒 init_main執行緒
void
proc_init(void) {
    int i;

    // 初始化全域性的執行緒控制塊雙向連結串列
    list_init(&proc_list);
    // 初始化全域性的執行緒控制塊hash表
    for (i = 0; i < HASH_LIST_SIZE; i ++) {
        list_init(hash_list + i);
    }

    // 分配idle執行緒結構
    if ((idleproc = alloc_proc()) == NULL) {
        panic("cannot alloc idleproc.\n");
    }

    // 為idle執行緒進行初始化
    idleproc->pid = 0; // idle執行緒pid作為第一個核心執行緒,其不會被銷燬,pid為0
    idleproc->state = PROC_RUNNABLE; // idle執行緒被初始化時是就緒狀態的
    idleproc->kstack = (uintptr_t)bootstack; // idle執行緒是第一個執行緒,其核心棧指向bootstack
    idleproc->need_resched = 1; // idle執行緒被初始化後,需要馬上被排程
    // 設定idle執行緒的名稱
    set_proc_name(idleproc, "idle");
    nr_process ++;

    // current當前執行執行緒指向idleproc
    current = idleproc;

    // 初始化第二個核心執行緒initproc, 用於執行init_main函式,引數為"Hello world!!"
    int pid = kernel_thread(init_main, "Hello world!!", 0);
    if (pid <= 0) {
        // 建立init_main執行緒失敗
        panic("create init_main failed.\n");
    }

    // 獲得initproc執行緒控制塊
    initproc = find_proc(pid);
    // 設定initproc執行緒的名稱
    set_proc_name(initproc, "init");

    assert(idleproc != NULL && idleproc->pid == 0);
    assert(initproc != NULL && initproc->pid == 1);
}

  在proc_init函式可以看到,ucore中要建立一個新的核心執行緒(init_proc),是通過kernel_thread實現的。建立核心執行緒時,新執行緒相當於是current當前執行緒fork出的一個子執行緒。

  呼叫kernel_thread函式時,需要指定執行緒的執行入口(例如:init_main),入口函式的引數(例如:"Hello world!"),以及指定是否需要採取寫時複製的機制進行fork時父子程序的記憶體對映。

kern_init函式:

// kernel_thread - create a kernel thread using "fn" function
// NOTE: the contents of temp trapframe tf will be copied to 
//       proc->tf in do_fork-->copy_thread function
// 建立一個核心執行緒,並執行引數fn函式,arg作為fn的引數
int
kernel_thread(int (*fn)(void *), void *arg, uint32_t clone_flags) {
    struct trapframe tf;
    // 構建一個臨時的中斷棧幀tf,用於do_fork中的copy_thread函式(因為執行緒的建立和切換是需要利用CPU中斷返回機制的)
    memset(&tf, 0, sizeof(struct trapframe));
    // 設定tf的值
    tf.tf_cs = KERNEL_CS; // 核心執行緒,設定中斷棧幀中的程式碼段暫存器CS指向核心程式碼段
    tf.tf_ds = tf.tf_es = tf.tf_ss = KERNEL_DS; // 核心執行緒,設定中斷棧幀中的資料段暫存器指向核心資料段
    tf.tf_regs.reg_ebx = (uint32_t)fn; // 設定中斷棧幀中的ebx指向fn的地址
    tf.tf_regs.reg_edx = (uint32_t)arg; // 設定中斷棧幀中的edx指向arg的起始地址
    tf.tf_eip = (uint32_t)kernel_thread_entry; // 設定tf.eip指向kernel_thread_entry這一統一的初始化的核心執行緒入口地址
    return do_fork(clone_flags | CLONE_VM, 0, &tf);
}

do_fork函式:

/* do_fork -     parent process for a new child process
 * @clone_flags: used to guide how to clone the child process
 * @stack:       the parent's user stack pointer. if stack==0, It means to fork a kernel thread.
 * @tf:          the trapframe info, which will be copied to child process's proc->tf
 */
int
do_fork(uint32_t clone_flags, uintptr_t stack, struct trapframe *tf) {
    int ret = -E_NO_FREE_PROC;
    struct proc_struct *proc;
    if (nr_process >= MAX_PROCESS) {
        goto fork_out;
    }
    ret = -E_NO_MEM;
    //LAB4:EXERCISE2 YOUR CODE
    /*
     * Some Useful MACROs, Functions and DEFINEs, you can use them in below implementation.
     * MACROs or Functions:
     *   alloc_proc:   create a proc struct and init fields (lab4:exercise1)
     *   setup_kstack: alloc pages with size KSTACKPAGE as process kernel stack
     *   copy_mm:      process "proc" duplicate OR share process "current"'s mm according clone_flags
     *                 if clone_flags & CLONE_VM, then "share" ; else "duplicate"
     *   copy_thread:  setup the trapframe on the  process's kernel stack top and
     *                 setup the kernel entry point and stack of process
     *   hash_proc:    add proc into proc hash_list
     *   get_pid:      alloc a unique pid for process
     *   wakeup_proc:  set proc->state = PROC_RUNNABLE
     * VARIABLES:
     *   proc_list:    the process set's list
     *   nr_process:   the number of process set
     */

    //    1. call alloc_proc to allocate a proc_struct
    //    2. call setup_kstack to allocate a kernel stack for child process
    //    3. call copy_mm to dup OR share mm according clone_flag
    //    4. call copy_thread to setup tf & context in proc_struct
    //    5. insert proc_struct into hash_list && proc_list
    //    6. call wakeup_proc to make the new child process RUNNABLE
    //    7. set ret vaule using child proc's pid

    // 分配一個未初始化的執行緒控制塊
    if ((proc = alloc_proc()) == NULL) {
        goto fork_out;
    }
    // 其父程序屬於current當前程序
    proc->parent = current;

    // 設定,分配新執行緒的核心棧
    if (setup_kstack(proc) != 0) {
        // 分配失敗,回滾釋放之前所分配的記憶體
        goto bad_fork_cleanup_proc;
    }
    // 由於是fork,因此fork的一瞬間父子執行緒的記憶體空間是一致的(clone_flags決定是否採用寫時複製)
    if (copy_mm(clone_flags, proc) != 0) {
        // 分配失敗,回滾釋放之前所分配的記憶體
        goto bad_fork_cleanup_kstack;
    }
    // 複製proc執行緒時,設定proc的上下文資訊
    copy_thread(proc, stack, tf);

    bool intr_flag;
    local_intr_save(intr_flag);
    {
        // 生成並設定新的pid
        proc->pid = get_pid();
        // 加入全域性執行緒控制塊雜湊表
        hash_proc(proc);
        // 加入全域性執行緒控制塊雙向連結串列
        list_add(&proc_list, &(proc->list_link));
        nr_process ++;
    }
    local_intr_restore(intr_flag);
    // 喚醒proc,令其處於就緒態PROC_RUNNABLE
    wakeup_proc(proc);

    ret = proc->pid;
fork_out:
    return ret;

bad_fork_cleanup_kstack:
    put_kstack(proc);
bad_fork_cleanup_proc:
    kfree(proc);
    goto fork_out;
}

copy_thread函式:

// copy_thread - setup the trapframe on the  process's kernel stack top and
//             - setup the kernel entry point and stack of process
static void
copy_thread(struct proc_struct *proc, uintptr_t esp, struct trapframe *tf) {
    // 令proc-tf 指向proc核心棧頂向下偏移一個struct trapframe大小的位置
    proc->tf = (struct trapframe *)(proc->kstack + KSTACKSIZE) - 1;
    // 將引數tf中的結構體資料複製填入上述proc->tf指向的位置(正好是上面struct trapframe指標-1騰出來的那部分空間)
    *(proc->tf) = *tf;
    proc->tf->tf_regs.reg_eax = 0;
    proc->tf->tf_esp = esp;
    proc->tf->tf_eflags |= FL_IF;

    // 令proc上下文中的eip指向forkret,切換恢復上下文後,新執行緒proc便會跳轉至forkret
    proc->context.eip = (uintptr_t)forkret;
    // 令proc上下文中的esp指向proc->tf,指向中斷返回時的中斷棧幀
    proc->context.esp = (uintptr_t)(proc->tf);
}

2.2 ucore執行緒排程時執行緒上下文的切換

  由於在proc_init中,令全域性變數current指向了idle_proc,代表當前佔用CPU的是執行緒idel_proc,設定idel_proc的need_resched為1。proc_init函式返回,總控函式kern_init完成了一系列初始化工作後,最終執行了cpu_idle函式。

  cpu_idle函式可以視為idle_proc的執行流,在其中進行了一個while(1)的無限迴圈,當發現自己需要被排程時,呼叫schedule函式進行一次執行緒的排程。

cpu_idle函式:

// cpu_idle - at the end of kern_init, the first kernel thread idleproc will do below works
void
cpu_idle(void) {
    while (1) {
        // idle執行緒執行邏輯就是不斷的自旋迴圈,當發現存在有其它執行緒可以被排程時
        // idle執行緒,即current.need_resched會被設定為真,之後便進行一次schedule執行緒排程
        if (current->need_resched) {
            schedule();
        }
    }
}

  schedule函式中,會先關閉中斷,避免排程的過程中被中斷再度打斷而出現併發問題。然後從ucore的就緒執行緒佇列中,按照某種排程演算法選擇出下一個需要獲得CPU的就緒執行緒。

  通過proc_run函式,令就緒執行緒的狀態從就緒態轉變為執行態,並切換執行緒的上下文,儲存current執行緒(例如:idle_proc)的上下文,並在CPU上恢復新排程執行緒(例如:init_proc)的上下文。

schedule函式:

/**
 * 進行CPU排程
 * */
void
schedule(void) {
    bool intr_flag;
    list_entry_t *le, *last;
    struct proc_struct *next = NULL;
    // 暫時關閉中斷,避免被中斷打斷,引起併發問題
    local_intr_save(intr_flag);
    {
        // 令current執行緒處於不需要排程的狀態
        current->need_resched = 0;
        // lab4中暫時沒有更多的執行緒,沒有引入執行緒排程框架,而是直接先進先出的獲取init_main執行緒進行排程
        last = (current == idleproc) ? &proc_list : &(current->list_link);
        le = last;
        do {
            if ((le = list_next(le)) != &proc_list) {
                next = le2proc(le, list_link);
                // 找到一個處於PROC_RUNNABLE就緒態的執行緒
                if (next->state == PROC_RUNNABLE) {
                    break;
                }
            }
        } while (le != last);
        if (next == NULL || next->state != PROC_RUNNABLE) {
            // 沒有找到,則next指向idleproc執行緒
            next = idleproc;
        }
        // 找到的需要被排程的next執行緒runs自增
        next->runs ++;
        if (next != current) {
            // next與current進行上下文切換,令next獲得CPU資源
            proc_run(next);
        }
    }
    // 恢復中斷
    local_intr_restore(intr_flag);
}

proc_run函式:

// proc_run - make process "proc" running on cpu
// NOTE: before call switch_to, should load  base addr of "proc"'s new PDT
// 進行執行緒排程,令當前佔有CPU的讓出CPU,並令引數proc指向的執行緒獲得CPU控制權
void
proc_run(struct proc_struct *proc) {
    if (proc != current) {
        // 只有當proc不是當前執行的執行緒時,才需要執行
        bool intr_flag;
        struct proc_struct *prev = current, *next = proc;

        // 切換時新執行緒任務時需要暫時關閉中斷,避免出現巢狀中斷
        local_intr_save(intr_flag);
        {
            current = proc;
            // 設定TSS任務狀態段的esp0的值,令其指向新執行緒的棧頂
            // ucore參考Linux的實現,不使用80386提供的TSS任務狀態段這一硬體機制實現任務上下文切換,ucore在啟動時初始化TSS後(init_gdt),便不再對其進行修改。
            // 但進行中斷等操作時,依然會用到當前TSS內的esp0屬性。發生使用者態到核心態中斷切換時,硬體會將中斷棧幀壓入TSS.esp0指向的核心棧中
            // 因此ucore中的每個執行緒,需要有自己的核心棧,在進行執行緒排程切換時,也需要及時的修改esp0的值,使之指向新執行緒的核心棧頂。
            load_esp0(next->kstack + KSTACKSIZE);
            // 設定cr3暫存器的值,令其指向新執行緒的頁表
            lcr3(next->cr3);
            // switch_to用於完整的程序上下文切換,定義在統一目錄下的switch.S中
            // 由於涉及到大量的暫存器的存取操作,因此使用匯編實現
            switch_to(&(prev->context), &(next->context));
        }
        local_intr_restore(intr_flag);
    }
}

2.3 什麼是執行緒的上下文?

  在proc_run中,呼叫了switch_to函式。switch_to是彙編實現的函式(子過程),其引數是兩個struct context結構體的指標。

  第一個引數from代表著當前執行緒的上下文,第二個引數to代表著新執行緒的上下文,switch_to的功能就是保留current執行緒的上下文至from上下文結構中,並將to上下文結構中的內容載入到CPU的各個暫存器中,恢復新執行緒的執行流上下文現場。

struct context:

// Saved registers for kernel context switches.
// Don't need to save all the %fs etc. segment registers,
// because they are constant across kernel contexts.
// Save all the regular registers so we don't need to care
// which are caller save, but not the return register %eax.
// (Not saving %eax just simplifies the switching code.)
// The layout of context must match code in switch.S.
// 當程序切換時儲存的當前暫存器上下文
struct context {
    uint32_t eip;
    uint32_t esp;
    uint32_t ebx;
    uint32_t ecx;
    uint32_t edx;
    uint32_t esi;
    uint32_t edi;
    uint32_t ebp;
};

switch_to函式定義:

void switch_to(struct context *from, struct context *to);

switch_to實現: 

.text
.globl switch_to
switch_to:                      # switch_to(from, to)

    # save from registers
    # 令eax儲存第一個引數from(context)的地址
    movl 4(%esp), %eax          # eax points to from
    # from.context 儲存eip、esp等等暫存器的當前快照值
    popl 0(%eax)                # save eip !popl
    movl %esp, 4(%eax)
    movl %ebx, 8(%eax)
    movl %ecx, 12(%eax)
    movl %edx, 16(%eax)
    movl %esi, 20(%eax)
    movl %edi, 24(%eax)
    movl %ebp, 28(%eax)

    # restore to registers
    # 令eax儲存第二個引數next(context)的地址,因為之前popl了一次,所以4(%esp)目前指向第二個引數
    movl 4(%esp), %eax          # not 8(%esp): popped return address already
                                # eax now points to to
    # 恢復next.context中的各個暫存器的值
    movl 28(%eax), %ebp
    movl 24(%eax), %edi
    movl 20(%eax), %esi
    movl 16(%eax), %edx
    movl 12(%eax), %ecx
    movl 8(%eax), %ebx
    movl 4(%eax), %esp
    pushl 0(%eax)               # push eip

    # ret時棧上的eip為next(context)中設定的值(fork時,eip指向 forkret,esp指向分配好的trap_frame)
    ret

  由於函式呼叫時是先呼叫後返回的,整個執行流程體現出一種先進後出的結構,因此普遍採用棧來實現函式呼叫,且不同執行流之間的棧是互相隔離的。ucore中,執行緒的上下文除了各個通用暫存器、段暫存器、指令指標暫存器等暫存器上下文之外,還需要額外的維護各自的棧結構。當然如果發生了程序間的切換,還需要切換頁表。

  80386由於引入了特權級機制,為了避免不同特權級之間棧上資料的互相干擾,要求一個程式(執行緒)在不同特權級下維護不同的棧。具體的各個特權級棧指標儲存在當前程式的TSS任務狀態段中,由TR暫存器控制。80386的設計者希望作業系統的設計者通過TSS任務狀態段機制,由硬體來處理不同任務(執行緒執行流)的上下文切換。

  也許是出於對作業系統與硬體耦合性以及效能的影響,Linux核心並沒有充分的利用80386提供的任務切換機制。

linux不使用任務門(轉載):

  Intel的這種設計確實很周到,也為任務切換提供了一個非常簡潔的機制。但是,由於i386的系統結構基本上是CISC的,通過JMP指令或CALL(或中斷)完成任務的過程實際上是“複雜指令”的執行過程,其執行過程長達300多個CPU週期(一個POP指令佔12個CPU週期),因此,Linux核心並不完全使用i386CPU提供的任務切換機制。

  由於i386CPU要求軟體設定TR及TSS,Linux核心只不過“走過場”地設定TR及TSS,以滿足CPU的要求。但是,核心並不使用任務門,也不使用JMP或CALL指令實施任務切換。核心只是在初始化階段設定TR,使之指向一個TSS,從此以後再不改變TR的內容了。也就是說,每個CPU(如果有多個CPU)在初始化以後的全部執行過程中永遠使用那個初始的TSS。同時,核心也不完全依靠TSS儲存每個程序切換時的暫存器副本,而是將這些暫存器副本儲存在各個程序自己的核心棧中。

  這樣一來,TSS中的絕大部分內容就失去了原來的意義。那麼,當進行任務切換時,怎樣自動更換堆疊?我們知道,新任務的核心棧指標(SS0和ESP0)應當取自當前任務的TSS,可是,Linux中並不是每個任務就有一個TSS,而是每個CPU只有一個TSS。Intel原來的意圖是讓TR的內容(即TSS)隨著任務的切換而走馬燈似地換,而在Linux核心中卻成了只更換TSS中的SS0和ESP0,而不更換TSS本身,也就是根本不更換TR的內容。這是因為,改變TSS中SS0和ESP0所花費的開銷比通過裝入TR以更換一個TSS要小得多。因此,在Linux核心中,TSS並不是屬於某個程序的資源,而是全域性性的公共資源。在多處理機的情況下,儘管核心中確實有多個TSS,但是每個CPU仍舊只有一個TSS。

為什麼執行緒切換時要修改esp0?

  ucore在設計上大量參考了早期32位linux核心的設計,因此和Linux一樣也沒有完全利用硬體提供的任務切換機制。整個OS週期只在核心初始化時設定了TR暫存器和TSS段的內容(gdt_init函式中),之後便不再對其進行大的修改,而是僅僅線上程上下文切換時,令TSS段中的esp0指向當前執行緒的核心棧頂(proc_run)。這麼做的原因一是ucore只使用了ring0和ring3兩個特權級,所有執行緒的ring0核心棧是由ucore全盤控制的,而在後續lab5之後的使用者態執行緒其ring3棧則是由應用程式自己控制的;二是由於在發生特權級切換的中斷時,80386CPU會將中斷引數壓入新特權級對應的棧上,如果發生使用者態->核心態的切換時,esp0必須指向當前執行緒自己的核心棧,否則將會出現不同執行緒核心棧資料的混亂,造成嚴重後果。

2.4 init_proc執行緒生命週期全過程分析

  分析到switch_to之後,init_proc執行緒似乎已經完成了從建立並初始化並進行上下文切換,獲得並佔用CPU的全過程。但實際上還剩下了關鍵的一環沒有分析。

  在idle_proc和init_proc上下文切換switch_to返回時,CPU中的各個暫存器已經被init_proc執行緒的context上下文覆蓋了,此時switch_to的ret返回將會返回到哪裡呢?

  答案就在copy_thread函式中通過語句proc->context.eip = (uintptr_t)forkret處,switch_to返回後將會跳轉到forkret這一所有執行緒完成初始化後統一跳轉的入口;在copy_thread中同時也設定了當前的棧頂指標esp指向proc->tf。

forkret函式:

// forkrets定義在/kern/trap/trapentry.S中的
void forkrets(struct trapframe *tf);

// forkret -- the first kernel entry point of a new thread/process
// NOTE: the addr of forkret is setted in copy_thread function
//       after switch_to, the current proc will execute here.
static void
forkret(void) {
    forkrets(current->tf);
}

  forkrets中令棧頂指標指向了前面設定好的trap_frame首地址後,便跳轉至__trapret,進行了中斷返回操作。

  在__trapret中,會依次將前面設定好的臨時trap_frame中斷棧幀中的各個資料依次還原,執行iret,完成中斷返回。

trapentry.S(部分):

.globl __trapret
__trapret:
    # restore registers from stack
    popal

    # restore %ds, %es, %fs and %gs
    popl %gs
    popl %fs
    popl %es
    popl %ds

    # get rid of the trap number and error code
    addl $0x8, %esp
    iret

.globl forkrets
forkrets:
    # set stack to this new process’s trapframe
    movl 4(%esp), %esp
    jmp __trapret

  中斷返回時,其cs、eip會依次從中斷棧幀中還原,中斷棧幀中eip是通過kern_thread中的語句(tf.tf_eip = (uint32_t)kernel_thread_entry;),指向了kernel_thread_entry。因此中斷返回後會跳轉到kernel_thread_entry函式入口處執行。

kernel_thread_entry定義:

// kernel_thread_entry定義在/kern/process/entry.S中
void kernel_thread_entry(void);

kernel_thread_entry實現:

.text
.globl kernel_thread_entry
kernel_thread_entry:        # void kernel_thread(void)

    pushl %edx              # push arg
    call *%ebx              # call fn

    pushl %eax              # save the return value of fn(arg)
    call do_exit            # call do_exit to terminate current thread

  kernel_thread_entry中,將暫存器edx中的資料壓入棧中,並跳轉至ebx指向的程式入口。那麼edx和ebx到底是什麼呢?edx和ebx都是在前面中斷返回時通過__traprets的popal指令,從init_proc建立時構造的臨時中斷棧幀中彈出的資料。

  回顧一下kern_thread,其中ebx儲存的就是傳入的fn,即init_main函式的地址,而edx則儲存了arg引數,即"Hello world!!"字串。

  因此當init_proc執行到kernel_thread_entry時,實際上就是將引數"Hello world!!"地址壓入了棧中,並且呼叫init_main函式,傳入棧上引數"Hello world"的地址並將其列印在標準輸出控制檯上。

  隨後,init_main函式執行完畢並返回,保留了返回值eax的值之後,kernel_thread_entry簡單的呼叫了do_exit函式,終止了init_proc當前執行緒。

kern_thread函式:

// kernel_thread - create a kernel thread using "fn" function
// NOTE: the contents of temp trapframe tf will be copied to 
//       proc->tf in do_fork-->copy_thread function
// 建立一個核心執行緒,並執行引數fn函式,arg作為fn的引數
int
kernel_thread(int (*fn)(void *), void *arg, uint32_t clone_flags) {
    struct trapframe tf;
    // 構建一個臨時的中斷棧幀tf,用於do_fork中的copy_thread函式(因為執行緒的建立和切換是需要利用CPU中斷返回機制的)
    memset(&tf, 0, sizeof(struct trapframe));
    // 設定tf的值
    tf.tf_cs = KERNEL_CS; // 核心執行緒,設定中斷棧幀中的程式碼段暫存器CS指向核心程式碼段
    tf.tf_ds = tf.tf_es = tf.tf_ss = KERNEL_DS; // 核心執行緒,設定中斷棧幀中的資料段暫存器指向核心資料段
    tf.tf_regs.reg_ebx = (uint32_t)fn; // 設定中斷棧幀中的ebx指向fn的地址
    tf.tf_regs.reg_edx = (uint32_t)arg; // 設定中斷棧幀中的edx指向arg的起始地址
    tf.tf_eip = (uint32_t)kernel_thread_entry; // 設定tf.eip指向kernel_thread_entry這一統一的初始化的核心執行緒入口地址
    return do_fork(clone_flags | CLONE_VM, 0, &tf);
}

init_proc執行緒的整個生命週期:

  1. 通過kernel_thread函式,構造一個臨時的trap_frame棧幀,其中設定了cs指向核心程式碼段選擇子、ds/es/ss等指向核心的資料段選擇子。令中斷棧幀中的tf_regs.ebx、tf_regs.edx儲存引數fn和arg,tf_eip指向kernel_thread_entry。

  2. 通過do_fork分配一個未初始化的執行緒控制塊proc_struct,設定並初始化其一系列狀態。將init_proc加入ucore的就緒佇列,等待CPU排程。

  3. 通過copy_thread中設定使用者態執行緒/核心態程序通用的中斷棧幀資料,設定執行緒上下文struct context中eip、esp的值,令上下文切換switch返回後跳轉到forkret處。

  4. idle_proc在cpu_idle中觸發schedule,將init_proc執行緒從就緒佇列中取出,執行switch_to進行idle_proc和init_proc的context執行緒上下文的切換。

  5. switch_to返回時,CPU開始執行init_proc的執行流,跳轉至之前構造好的forkret處。

  6. fork_ret中,進行中斷返回。將之前存放在核心棧中的中斷棧幀中的資料依次彈出,最後跳轉至kernel_thread_entry處。

  7.kernel_thread_entry中,利用之前在中斷棧中設定好的ebx(fn),edx(arg)執行真正的init_proc業務邏輯的處理(init_main函式),在init_main返回後,跳轉至do_exit終止退出。

為什麼在switch_to上下文切換後,還需要進行一次中斷返回?

  相信不少初學者和當初的我一樣,會產生一個問題:為什麼在init_proc執行緒上下文切換時,不直接控制流跳轉至init_main函式,而是繞了一個大彎,非要通過中斷間接實現?

  這是因為ucore在lab4中需要為後續的使用者態程序/執行緒的建立打好基礎。由於目前我們所有的程式邏輯都是位於核心中的,擁有ring0的最高優先順序,所以暫時感受不到通過中斷間接切換執行緒上下文的好處。但是在後面引入使用者態程序/執行緒概念後,這一機制將顯得十分重要。

  當應用程式申請建立一個使用者態程序時,需要ucore核心為其分配各種核心資料結構。由於特權級的限制,需要令應用程式通過一個呼叫門陷入核心(執行系統呼叫),令其CPL特權級從ring3提升到ring0。但是當用戶程序被初始化完畢後,進入排程執行狀態後,為了核心的安全就不能允許使用者程序繼續處於核心態了,否則作業系統的安全性將得不到保障。而要令一個ring0的程序回到ring3的唯一方法便是使用中斷返回機制,在使用者程序/執行緒建立過程中“偽造”一箇中斷棧幀,令其中斷返回到ring3的低特權級中,開始執行自己的業務邏輯。

  以上述init_proc的例子來說,如果init_proc不是一個核心執行緒,那麼在構造臨時的中斷棧幀時,其cs、ds/es/ss等段選擇子將指向使用者態特權級的段選擇子。這樣中斷返回時通過對棧上臨時中斷棧幀資料的彈出,進行各個暫存器的復原。當跳轉至使用者態執行緒入口時,應用程式已經進入ring3低特權級了。這樣既實現了使用者執行緒的建立,也使得應用程式無法隨意的訪問核心資料而破壞系統核心。

3. 總結

  ucore通過lab4、lab5建立起了程序/執行緒機制,能夠通過執行緒的上下文切換,交替的處理不同執行緒的工作流在一個CPU核心上併發的執行。

  通過ucore lab4實驗的學習,瞭解到作業系統建立執行緒,維護執行緒都存在一定的時間、空間上的開銷,執行緒的上下文切換也是一個較為繁瑣、耗時的操作。

  這也是為什麼在應用程式中都推薦使用執行緒池將所申請的核心執行緒快取起來,以減少反覆建立、銷燬核心級執行緒的額外開銷以提高效率。另一方面,也意識到為什麼即使記憶體空間足夠,像web伺服器這樣的I/O密集型應用程式也無法單純的依靠增加執行緒的數量來應對上萬甚至更多的併發請求。因為陷入核心的系統級執行緒上下文切換是如此的消耗CPU資源,以至於當併發連線過高時,幾乎所有的CPU資源都消耗在了核心執行緒上下文切換上,而無暇處理業務邏輯。這也是I/O多路複用技術被廣泛使用的原因。

  區別於最原始的一個執行緒阻塞式的負責處理一個I/O套接字,web伺服器通過維護一個執行緒池來併發處理請求的傳統阻塞式I/O模式。作業系統核心提供的I/O多路複用功能,使得一個執行緒可以同時維護、處理多個I/O套接字,通過事件通知機制處理業務邏輯。I/O多路複用允許一個執行緒同時處理成百上千的併發連線,極大的減少了核心級執行緒上下文切換的次數,極大的提高了web伺服器這樣I/O密集型的效能。這也是為什麼能夠像nginx、redis、netty、nodeJS等以高效能著稱的應用程式都普遍以I/O多路複用技術作為其核心。

  這篇部落格的完整程式碼註釋在我的github上:https://github.com/1399852153/ucore_os_lab (fork自官方倉庫)中的lab4_answer。

  希望我的部落格能幫助到對作業系統、ucore os感興趣的人。存在許多不足之處,還請多多指教。