XV6操做系統代碼閱讀心得(二):進程

1. 進程的基本概念

從抽象的意義來講,進程是指一個正在運行的程序的實例,而線程是一個CPU指令執行流的最小單位。進程是操做系統資源分配的最小單位,線程是操做系統中調度的最小單位。從實現的角度上講,XV6系統中只實現了進程, 並無提供對線程的額外支持,一個用戶進程永遠只會有一個用戶可見的執行流。html

2. 進程管理的數據結構

根據[1],進程管理的數據結構被叫作進程控制塊(Process Control Block, PCB)。一個進程的PCB必須存儲如下兩類信息:node

  1. 操做系統管理運行的進程所須要信息,好比優先級、進程ID、進程上下文等
  2. 一個應用程序運行所須要的所有環境,好比虛擬內存的信息、打開的文件和IO設備的信息等。

XV6中進程相關的數據結構

在XV6中,與進程有關的數據結構以下linux

// Per-process state
struct proc {
  uint sz;                     // Size of process memory (bytes)
  pde_t* pgdir;                // Page table
  char *kstack;                // Bottom of kernel stack for this process
  enum procstate state;        // Process state
  int pid;                     // Process ID
  struct proc *parent;         // Parent process
  struct trapframe *tf;        // Trap frame for current syscall
  struct context *context;     // swtch() here to run process
  void *chan;                  // If non-zero, sleeping on chan
  int killed;                  // If non-zero, have been killed
  struct file *ofile[NOFILE];  // Open files
  struct inode *cwd;           // Current directory
  char name[16];               // Process name (debugging)
};

與前述的兩類信息的對應關係以下算法

  1. 操做系統管理進程有關的信息:內核棧kstack,進程的狀態state,進程的pid,進程的父進程parent,進程的中斷幀tf,進程的上下文context,與sleepkill有關的chankilled變量。shell

  2. 進程自己運行所須要的所有環境:虛擬內存信息szpgdir,打開的文件ofile和當前目錄cwdwindows

額外地,proc中還有一條用於調試的進程名字name數組

在操做系統中,全部的進程信息struct proc都存儲在ptable中,ptable的定義以下緩存

struct {
  struct spinlock lock;
  struct proc proc[NPROC];
} ptable;

除了互斥鎖lock以外,一個值得注意的一點是XV6系統中容許同時存在的進程數量是有上限的。在這裏NPROC爲64,因此XV6最多隻容許同時存在64個進程。安全

proc.c中,userinit()用於建立第一個用戶進程,allocproc()則被用於在ptable中尋找空位並在空位上建立一個新的進程。當操做系統初始化時,經過userinit()調用allocproc建立第一個進程init。絕大多數進程相關信息都會在這裏初始化。因爲XV6系統只容許中斷返回一種從內核態進入用戶態的方式,所以allocproc()會建立中斷調用的棧結構,而userinit會設置其中的值,彷彿是從一次真正的中斷裏返回進程同樣。最後,在mpmain()中,系統調用schedule()函數,開始用戶進程的調度。在init進程被調度啓動後,會建立shell進程,用於和用戶交互。數據結構

Linux中進程相關的數據結構

Linux系統的實現中並不刻意區分進程和線程,而是將其一律存儲在被稱做task_struct的數據結構中。當兩個task_struct共享同一個虛擬地址空間時,它們就是同一個進程的兩個線程。與Linux進程有關的數據結構定義大多數都在/include/linux/sched.h中。task_struct數據結構至關複雜,在32位機器上一條能佔據1.7KiB的空間。task_struct中主要包含的數據結構有管理處理器底層信息的thread_struct、管理虛擬內存的mm_struct、管理文件描述符的file_struct、管理信號的signal_struct等等。Linux中的進程與XV6同樣都有獨立的內核棧,內核模式下的代碼是在內核棧中運行的。

操做系統維護多個task_struct隊列來實現不一樣的功能。全部的隊列都是用雙向鏈表實現的。有一個隊列存放了全部的進程;另外一個隊列存放了全部正在運行的進程(kernel/sched.c中的struct runqueue );此外,對於每個會致使進程掛起的等待事件,都有一個隊列存放由於等待此事件而掛起的進程(include/linux/wait.h中的wait_queue_t)。

Linux會將task_struct數據結構分配到這個進程的內核棧的頂部,將thread_info數據結構分配到這個進程的內核棧的底部。thread_info的名稱有些誤導,它存儲的實際上是一個task中更加底層和更加體系結構相關的屬性。進程數據結構的分配方法被稱爲Slab Allocator,經過精心優化的虛擬內存機制來提高進程管理的效率、實現對象重用。

struct thread_info {
    struct task_struct *task;
    struct exec_domain *exec_domain;
    __u32 flags;
    __u32 status;
    __u32 cpu;
    int preempt_count;
    mm_segment_t addr_limit;
    struct restart_block restart_block;
    void *sysenter_return;
    int uaccess_err;
};

Windows中進程相關的數據結構

在Windows NT之後的Windows系統中,進程用EPROCESS對象表示,線程用ETHREAD對象表示。在一個EPROCESS對象中,包含了進程的資源相關信息,好比句柄表、虛擬內存、安全、調試、異常、建立信息、I/O轉移統計以及進程計時等。每一個EPROCESS對象都包含一個指向ETHREAD結構體的鏈表。值得一提的是Windows系統中EPROCESSETHREAD的設計都是分層的,KPROCESSKTHREAD成員對象專門用來處理體系結構有關的細節,而Process Environment Block和Thead Environment Block對象則暴露給應用程序來訪問。

3. 進程的狀態

在大多數教科書使用的標準五狀態進程模型中,進程分爲New、Ready、Running、Waiting和Terminated五個狀態,狀態轉換圖如圖所示(圖出自Operating System Concepts, 7th Edition)

除去標記進程塊未被使用的UNUSED狀態,XV6操做系統中的狀態與上述的五狀態模型徹底對應。在XV6中這五個狀態的定義爲

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

XV6實現中源代碼中具體的轉換關係以下

這個轉換關係圖中,標識出了在XV6系統中發生狀態轉換所須要的函數或者事件。

Linux中的進程轉換圖與Xv6大體相同,可是有以下區別:

  1. Linux中的Waiting有兩個狀態,分別是Interruptible Waiting和Uninterruptible Waiting。
  2. Linux中額外有兩個調試用狀態TASK_TRACEDTASK_STOPPED

關於Windows的進程狀態,網上並無關於實現細節的特別詳細的解釋。一份關於Windows進程的文檔[6]使用了以下的進程轉換圖,但並無顯式地說明其與Windows進程實現之間的關係。

從中能夠看出,Windows進程可能額外多出了Suspend狀態,用於如下狀況的一種:

  1. 當內存不足使得此進程被移入硬盤時
  2. 當操做系統決定掛起某個後臺進程時
  3. 當用戶出於調試或者其餘緣由手動掛起了某個進程,或者一個進程的父進程使用系統調用掛起一個進程時
  4. 對於定時啓動的系統,在指定的運行時間之外會被掛起

我認爲操做系統設計這些狀態,是出於在有限的計算機系統資源上,對管理和調度多個進程的需求。若是一個CPU核同一時間只會有一個進程運行,那就徹底不須要設置進程的狀態。可是一個實用的現代操做系統必須支持大量進程共享一個CPU,也必須支持進程的不斷建立與終止,從而實現資源利用效率的最大化和系統功能的多樣化。所以,操做系統選取了如今的五狀態進程設計,而且出於不一樣系統的需求不一樣,也會有更加細化的設計。

4. 進程的調度算法

Xv6系統中的進程可使用fork()系統調用建立新進程。爲了建立一個進程,操做系統必須爲這個進程分配相應的資源,包括內存、CPU時間、文件等,與此同時,操做系統必須對此進程作出相應的管理,包括設置它的進程ID、調度優先級、虛擬內存結構、運行資源限制等等。爲了可以維持多個進程在一個CPU上運行,必須對此作出相應的調度。調度算法有不少種,由簡到難以下

  1. First Come, First Served. 這個調度算法的思想是讓操做系統維護一個能夠運行的進程的等待隊列,讓先進入等待隊列的進程先運行。這個調度算法的優點在於實現簡單,只要是能實現鏈表的系統就能實現這個調度算法。可是這個算法的問題在於,算法在不少量度下都是次優的,其優劣的程度徹底取決於進程進入隊列的前後順序。
  2. Shortest Task First. 這個調度算法的思想是讓指望運行時間最短的進程先執行。能夠從理論上證實,給定一組執行時間已知的進程,這個調度算法能讓全部進程的等待時間之和最小。可是,預估一個進程的執行時間是極爲困難的,妨礙了這個算法的實踐有效性。此外,這個算法還存在飢餓(Starvation)的問題,也就是大量短時進程的不斷進入會使得一個長時進程沒法執行。
  3. Round-Robin. 這個算法的思想在於對全部運行的進程分配一個時間片,讓全部進程輪流執行。這個調度算法雖然效率低下,可是公平性是最好的,而且不會存在飢餓的問題。
  4. Priority Based Multilevel Queue. 這個算法的思想是對不一樣進程分配不一樣的優先級,每一個優先級會有一個進程隊列,優先級高的先執行,同一優先級內使用某種前述調度算法分配。爲了解決低優先級進程飢餓的問題,經常會採用某種動態優先級調整機制。

一個現代操做系統所使用的調度算法一般是Priority Based Multilevel Queue的一種變體。具體地說,根據操做系統的具體需求,將不一樣類別的進程賦予不一樣的優先級。好比,Windows系統中,用戶當前使用的窗體進程具備很是高的優先級。對於每個優先級內的進程都會維護一個獨自的隊列,每一個優先級可使用不一樣的調度算法。高優先級的前臺進程可使用Round-Robin,後臺進程可使用First Come First Served。若是一個進程好久沒有獲得執行,那麼能夠提高它的優先級,從低優先級隊列進入高優先級隊列,從而避免飢餓的問題。

通常而言,出於CPU資源的限制和操做系統內核空間的內存限制,操做系統會指定容許同時存在的最大進程數。在Xv6系統中,最多同時存在64個進程。操做系統會維護一個大小爲64的struct proc數組,並在其中分配新的進程。

進程的上下文包含了這個進程執行時所須要的所有信息,主要是寄存器的值和運行時棧。在Xv6系統中,執行進程的上下文切換就意味着要保存原進程的調用保存寄存器 %ebp %ebx %esi %ebp,棧指針%esp和程序指針eip,並載入新的進程的上述寄存器。特別地,Xv6中的進程切換隻會切換到內核調度器進程,並經過內核調度器切換到新的進程。

關於進程調度的具體細節,官方文檔具備精彩的描述,在此再也不贅述。

多進程和多CPU之間的關係在於,在操做系統面前,每一個進程都好似佔用了一個獨立的虛擬CPU,但事實上操做系統會將多個進程分配在一個或多個CPU上運行,進程的數量與CPU的數量之間並無直接的關係。

5. 內核態進程與用戶態進程

內核態進程,顧名思義,是在操做系統內核態下執行的進程。在內核態下運行的進程通常用於完成操做系統最底層,最爲核心,沒法在用戶態下完成的功能。好比,調度器進程是Xv6中的一個內核態進程,由於在用戶態下是沒法進行進程調度的。相比較而言,用戶態進程用於完成的功能能夠多種多樣,而且其功能只依賴於操做系統提供的系統調用,不須要深刻操做內核的數據結構。好比init進程和shell進程就是xv6中的用戶態進程。

6. 進程的內存佈局

Xv6進程在虛擬內存中的佈局如上圖。固然,其中的每一頁在物理內存中大機率並非這樣排列的,可是虛擬內存系統爲每一個進程提供了統一的內存抽象。進程的棧用於存放運行時數據和運行時軌跡,包含了函數調用的嵌套,函數調用的參數和臨時變量的存儲等。棧一般較小,不會在運行時增加,不適合存儲大量數據。相比較而言,堆提供了一個存放全局變量和動態增加的數據的機制。堆的大小一般能夠動態增加,而且通常用於存儲較大的數據和程序執行過程當中始終會被訪問的全局變量。

7. fork、wait、exit系統調用的實現。

fork()函數

// Create a new process copying p as the parent.
// Sets up stack to return as if from system call.
// Caller must set state of returned proc to RUNNABLE.
int
fork(void)
{
  int i, pid;
  struct proc *np;
  struct proc *curproc = myproc();

  // Allocate process.
  if((np = allocproc()) == 0){
    return -1;
  }

  // Copy process state from proc.
  if((np->pgdir = copyuvm(curproc->pgdir, curproc->sz)) == 0){
    kfree(np->kstack);
    np->kstack = 0;
    np->state = UNUSED;
    return -1;
  }
  np->sz = curproc->sz;
  np->parent = curproc;
  *np->tf = *curproc->tf;

  // Clear %eax so that fork returns 0 in the child.
  np->tf->eax = 0;

  for(i = 0; i < NOFILE; i++)
    if(curproc->ofile[i])
      np->ofile[i] = filedup(curproc->ofile[i]);
  np->cwd = idup(curproc->cwd);

  safestrcpy(np->name, curproc->name, sizeof(curproc->name));

  pid = np->pid;

  acquire(&ptable.lock);

  np->state = RUNNABLE;

  release(&ptable.lock);

  return pid;
}

fork()函數的代碼如上。fork()函數首先調用allocproc()函數得到並初始化一個進程控制塊struct proc(12-14行)。此外,在allocproc()函數中還會對進程的內核棧進行初始化,在內核棧裏設置一個Trap Frame,把Trap Frame的上下文部分都置爲0。而後,fork()函數使用copyuvm()函數複製原進程的虛擬內存結構(17-24行)。爲了能讓子進程返回時處在和父進程如出一轍的狀態,Trap Frame也會被拷貝(25行,須要注意這裏的運算符優先級)。爲了讓子進程系統調用的返回值爲0,子進程的eax寄存器會被置爲0(28行)。而後,父進程打開的文件描述符會被所有拷貝給子進程(30-32行),還有父進程所處於的目錄(33行)。這些操做都會增長文件描述符和目錄的被引用數。最後,fork()函數拷貝了父進程的名字,設置了子進程的狀態爲RUNNABLE,而後返回子進程pid給父進程。子進程被建立後,在某個時刻調度子進程運行時,fork()函數會第二次返回給子進程,此時返回值爲0。

wait()函數

// Wait for a child process to exit and return its pid.
// Return -1 if this process has no children.
int
wait(void)
{
  struct proc *p;
  int havekids, pid;
  struct proc *curproc = myproc();
  
  acquire(&ptable.lock);
  for(;;){
    // Scan through table looking for exited children.
    havekids = 0;
    for(p = ptable.proc; p < &ptable.proc[NPROC]; p++){
      if(p->parent != curproc)
        continue;
      havekids = 1;
      if(p->state == ZOMBIE){
        // Found one.
        pid = p->pid;
        kfree(p->kstack);
        p->kstack = 0;
        freevm(p->pgdir);
        p->pid = 0;
        p->parent = 0;
        p->name[0] = 0;
        p->killed = 0;
        p->state = UNUSED;
        release(&ptable.lock);
        return pid;
      }
    }

    // No point waiting if we don't have any children.
    if(!havekids || curproc->killed){
      release(&ptable.lock);
      return -1;
    }

    // Wait for children to exit.  (See wakeup1 call in proc_exit.)
    sleep(curproc, &ptable.lock);  //DOC: wait-sleep
  }
}

wait()函數的代碼如上。wait()函數首先必需要得到ptable的鎖(10行),由於它有可能會對ptable作出修改。而後它會遍歷ptable,從中尋找本身的子進程(14-32行)。若是發現殭屍子進程,就把殭屍子進程回收,具體地說要回收它的虛擬內存,內核棧,並設置狀態爲UNUSED(18-30行),有趣的是,在這裏wait()函數根本沒有回收這個子進程打開的文件描述符,由於在exit()函數內這個進程打開的文件描述符已經所有被關閉了,並且只有exit()以後的進程纔多是ZOMBIE狀態。對於沒有子進程的狀況,wait()會直接返回,不然他會調用sleep(),並傳入ptable的鎖做爲參數。之因此要在sleep函數中傳入ptable鎖,是爲了不在wait()把進程設置爲SLEEP狀態以前,子進程就已經成爲僵死進程並在exit()函數中調用了wakeup(),這會使得父進程接收不到wakeup從而進入死鎖狀態。

exit()函數

// Exit the current process.  Does not return.
// An exited process remains in the zombie state
// until its parent calls wait() to find out it exited.
void
exit(void)
{
  struct proc *curproc = myproc();
  struct proc *p;
  int fd;

  if(curproc == initproc)
    panic("init exiting");

  // Close all open files.
  for(fd = 0; fd < NOFILE; fd++){
    if(curproc->ofile[fd]){
      fileclose(curproc->ofile[fd]);
      curproc->ofile[fd] = 0;
    }
  }

  begin_op();
  iput(curproc->cwd);
  end_op();
  curproc->cwd = 0;

  acquire(&ptable.lock);

  // Parent might be sleeping in wait().
  wakeup1(curproc->parent);

  // Pass abandoned children to init.
  for(p = ptable.proc; p < &ptable.proc[NPROC]; p++){
    if(p->parent == curproc){
      p->parent = initproc;
      if(p->state == ZOMBIE)
        wakeup1(initproc);
    }
  }

  // Jump into the scheduler, never to return.
  curproc->state = ZOMBIE;
  sched();
  panic("zombie exit");
}

exit()函數首先關閉這個進程打開的全部文件描述符(15-20行),而後除去本身對所處的文件目錄的引用(22-25行),對文件管理相關數據結構的訪問必需要得到和釋放相關的鎖(begin_op()end_op())。清除這些引用能夠容許文件系統管理當前的緩存。若是這個進程的父進程正在等待子進程結束,那麼這個進程必須喚醒父進程(30行),只有這樣父進程纔可以在某個時刻回收殭屍子進程。若是這個進程有子進程的話,就把這個進程的子進程都傳給init進程,並由init進程來負責回收殭屍子進程(33-39行)。最後,這個進程的狀態會被設置爲ZOMBIE,調度器調度其餘進程運行(42-44行)。

參考資料

  1. Operating System Concepts, 7th Edition

  2. Computer Systems: a Programmer's Perspective, 3rd Edition

  3. Process in Linux, https://www.cs.columbia.edu/~junfeng/10sp-w4118/lectures/l07-proc-linux.pdf

  4. 10 Things Every Linux Programmer Should Know, http://www.mulix.org/lectures/kernel_workshop_mar_2004/things.pdf

  5. Introduction to Linux Kernel, Chapter 3 Process Management, https://notes.shichao.io/lkd/ch3/#chapter-3-process-management

  6. A Complete Introduction to Windows Processes, Threads and Related Resources, https://www.tenouk.com/ModuleT.html

  7. Windows進程數據結構及建立流程,https://blog.csdn.net/cuit/article/details/9200097

相關文章
相關標籤/搜索