程式和執行緒模型

Koma_Wong發表於2018-08-25

程式執行緒模型

執行緒和程式的概念已經在作業系統書中被翻來覆去講了很多遍。很多概念雖然都是套話,但沒能理解透其中深意會導致很多內容理解不清晰。對於程式和執行緒的理解和把握可以說基本奠定了對系統的認知和把控能力。其核心意義絕不僅僅是“執行緒是排程的基本單位,程式是資源分配的基本單位”這麼簡單。

多執行緒

我們這裡討論的是使用者態的多執行緒模型,同一個程式內部有多個執行緒,所有的執行緒共享同一個程式的記憶體空間,程式中定義的全域性變數會被雖有的執行緒共享,比如有全域性變數int i = 10,這一程式中所有併發執行的執行緒都可以讀取和修改這個i的值,而多個執行緒被CPU排程的順序又是不可控的,所以對臨界資源的訪問尤其需要注意安全。我們必須知道,做一次簡單的i = i + 1在計算機中並不是原子操作,涉及記憶體取數,計算和寫入記憶體幾個環節,而執行緒的切換有可能發生在上述任何一個環節中間,所以不同的操作順序很有可能帶來意想不到的結果。

但是,雖然執行緒在安全性方面會引入許多新挑戰,但是執行緒帶來的好處也是有目共睹的。首先,原先順序執行的程式(暫時不考慮多程式)可以被拆分成幾個獨立的邏輯流,這些邏輯流可以獨立完成一些任務(最好這些任務是不相關的)。比如QQ可以一個執行緒處理聊天一個執行緒處理上傳檔案,兩個執行緒互不干涉,在使用者看來是同步在執行兩個任務,試想如果線性完成這個任務的話,在資料傳輸完成之前使用者聊天被一直阻塞會是多麼尷尬的情況。

對於執行緒,我認為弄清以下兩點非常重要:

  • 執行緒之間有無先後訪問順序(執行緒依賴關係)

  • 多個執行緒共享訪問同一變數(同步互斥問題)

另外,我們通常只會去說同一程式的多個執行緒共享程式的資源,但是每個執行緒特有的部分卻很少提及,除了標識執行緒的tid,每個執行緒還有自己獨立的棧空間,執行緒彼此之間是無法訪問其他執行緒棧上內容的。而作為處理機排程的最小單位,執行緒排程只需要儲存執行緒棧、暫存器資料和PC即可,相比程式切換開銷要小很多。

執行緒相關介面不少,主要需要了解各個引數意義和返回值意義。

  1. 執行緒建立和結束

    • 背景知識:

      在一個檔案內的多個函式通常都是按照main函式中出現的順序來執行,但是在分時系統下,我們可以讓每個函式都作為一個邏輯流併發執行,最簡單的方式就是採用多執行緒策略。在main函式中呼叫多執行緒介面建立執行緒,每個執行緒對應特定的函式(操作),這樣就可以不按照main函式中各個函式出現的順序來執行,避免了忙等的情況。執行緒基本操作的介面如下。

    • 相關介面:

      • 建立執行緒:int pthread_create(pthread_t *pthread, const pthread_attr_t *attr, void *(*start_routine)(void *), void *agr);

        建立一個新執行緒,pthread和start_routine不可或缺,分別用於標識執行緒和執行體入口,其他可以填NULL。

        • pthread:用來返回執行緒的tid,*pthread值即為tid,型別pthread_t == unsigned long int。

        • attr:指向執行緒屬性結構體的指標,用於改變所創執行緒的屬性,填NULL使用預設值。

        • start_routine:執行緒執行函式的首地址,傳入函式指標。

        • arg:通過地址傳遞來傳遞函式引數,這裡是無符號型別指標,可以傳任意型別變數的地址,在被傳入函式中先強制型別轉換成所需型別即可。

      • 獲得執行緒ID:pthread_t pthread_self();

        呼叫時,會列印執行緒ID。

      • 等待執行緒結束:int pthread_join(pthread_t tid, void** retval);

        主執行緒呼叫,等待子執行緒退出並回收其資源,類似於程式中wait/waitpid回收殭屍程式,呼叫pthread_join的執行緒會被阻塞。

        • tid:建立執行緒時通過指標得到tid值。

        • retval:指向返回值的指標。

      • 結束執行緒:pthread_exit(void *retval);

        子執行緒執行,用來結束當前執行緒並通過retval傳遞返回值,該返回值可通過pthread_join獲得。

        • retval:同上。
      • 分離執行緒:int pthread_detach(pthread_t tid);

        主執行緒、子執行緒均可呼叫。主執行緒中pthread_detach(tid),子執行緒中pthread_detach(pthread_self()),呼叫後和主執行緒分離,子執行緒結束時自己立即回收資源。

        • tid:同上。
  2. 執行緒屬性值修改

    • 背景知識:

      執行緒屬性物件型別為pthread_attr_t,結構體定義如下:

      typedef struct{
          int etachstate;    // 執行緒分離的狀態
          int schedpolicy;    // 執行緒排程策略
          struct sched_param schedparam;    // 執行緒的排程引數
          int inheritsched;    // 執行緒的繼承性
          int scope;    // 執行緒的作用域
          // 以下為執行緒棧的設定
          size_t guardsize;    // 執行緒棧末尾警戒緩衝大小
          int stackaddr_set;    // 執行緒的棧設定
          void *    stackaddr;    // 執行緒棧的位置
          size_t stacksize;    // 執行緒棧大小
      }pthread_arrt_t;
    • 相關介面:

      對上述結構體中各引數大多有:pthread_attr_get***()和pthread_attr_set***()系統呼叫函式來設定和獲取。這裡不一一羅列。

  3. 執行緒同步

多程式

每一個程式是資源分配的基本單位。程式結構由以下幾個部分組成:程式碼段、堆疊段、資料段。程式碼段是靜態的二進位制程式碼,多個程式可以共享。實際上在父程式建立子程式之後,父、子程式除了pid外,幾乎所有的部分幾乎一樣,子程式建立時拷貝父程式PCB中大部分內容,而PCB的內容實際上是各種資料、程式碼的地址或索引表地址,所以複製了PCB中這些指標實際就等於獲取了全部父程式可訪問資料。所以簡單來說,建立新程式需要複製整個PCB,之後作業系統將PCB新增到程式核心堆疊底部,這樣就可以被作業系統感知和排程了。

父、子程式共享全部資料,但並不是說他們就是對同一塊資料進行操作,子程式在讀寫資料時會通過寫時複製機制將公共的資料重新拷貝一份,之後在拷貝出的資料上進行操作。如果子程式想要執行自己的程式碼段,還可以通過呼叫execv()函式重新載入新的程式碼段,之後就和父程式獨立開了。我們在shell中執行程式就是通過shell程式先fork()一個子程式再通過execv()重新載入新的程式碼段的過程。

  1. 程式建立與結束

    • 背景知識:

      程式有兩種建立方式,一種是作業系統建立的一種是父程式建立的。從計算機啟動到終端執行程式的過程為:0號程式 -> 1號核心程式 -> 1號使用者程式(init程式) -> getty程式 -> shell程式 -> 命令列執行程式。所以我們在命令列中通過 ./program執行可執行檔案時,所有建立的程式都是shell程式的子程式,這也就是為什麼shell一關閉,在shell中執行的程式都自動被關閉的原因。從shell程式到建立其他子程式需要通過以下介面。

    • 相關介面:

      • 建立程式:pid_t fork(void);

        返回值:出錯返回-1;父程式中返回pid > 0;子程式中pid == 0

      • 結束程式:void exit(int status);

        • status是退出狀態,儲存在全域性變數中S?,通常0表示正常退出。
      • 獲得PID:pid_t getpid(void);

        返回撥用者pid。

      • 獲得父程式PID:pid_t getppid(void);

        返回父程式pid。

    • 其他補充:

      • 正常退出方式:exit()、_exit()、return(在main中)。

        exit()和_exit()區別:exit()是對_exit()的封裝,都會終止程式並做相關收尾工作,最主要的區別是_exit()函式關閉全部描述符和清理函式後不會重新整理流,但是exit()會在呼叫_exit()函式前重新整理資料流。

        return和exit()區別:exit()是函式,但有引數,執行完之後控制權交給系統。return若是在呼叫函式中,執行完之後控制權交給呼叫程式,若是在main函式中,控制權交給系統。

      • 異常退出方式:abort()、終止訊號。

  2. 殭屍程式、孤兒程式

    • 背景知識:

      父程式在呼叫fork介面之後和子程式已經可以獨立開,之後父程式和子程式就以未知的順序向下執行(非同步過程)。所以父程式和子程式都有可能先執行完。當父程式先結束,子程式此時就會變成孤兒程式,不過這種情況問題不大,孤兒程式會自動向上被init程式收養,init程式完成對狀態收集工作。而且這種過繼的方式也是守護程式能夠實現的因素。如果子程式先結束,父程式並未呼叫wait或者waitpid獲取程式狀態資訊,那麼子程式描述符就會一直儲存在系統中,這種程式稱為殭屍程式。

    • 相關介面:

      • 回收程式(1):pid_t wait(int *status);

        一旦呼叫wait(),就會立即阻塞自己,wait()自動分析某個子程式是否已經退出,如果找到殭屍程式就會負責收集和銷燬,如果沒有找到就一直阻塞在這裡。

        • status:指向子程式結束狀態值。
      • 回收程式(2):pid_t waitpid(pid_t pid, int *status, int options);

        返回值:返回pid:返回收集的子程式id。返回-1:出錯。返回0:沒有被手機的子程式。

        • pid:子程式識別碼,控制等待哪些子程式。

          1. pid < -1,等待程式組識別碼為pid絕對值的任何程式。

          2. pid = -1,等待任何子程式。

          3. pid = 0,等待程式組識別碼與目前程式相同的任何子程式。

          4. pid > 0,等待任何子程式識別碼為pid的子程式。

        • status:指向返回碼的指標。

        • options:選項決定父程式呼叫waitpid後的狀態。

          1. options = WNOHANG,即使沒有子程式退出也會立即返回。

          2. options = WUNYRACED,子程式進入暫停馬上返回,但結束狀態不予理會。

  3. 守護程式

  • 背景知識:

    守護程式是脫離終端並在後臺執行的程式,執行過程中資訊不會顯示在終端上並且也不會被終端發出的訊號打斷。

  • 操作步驟:

    • 建立子程式,父程式退出:fork() + if(pid > 0){exit(0);},使子程式稱為孤兒程式被init程式收養。

    • 在子程式中建立新會話:setsid()。

    • 改變當前目錄結構為根:chdir("/")。

    • 重設檔案掩碼:umask(0)。

    • 關閉檔案描述符:for(int i = 0; i < 65535; ++i){close(i);}。

  1. Linux程式控制
  • 程式地址空間(地址空間)

    虛擬儲存器為每個程式提供了獨佔系統地址空間的假象。儘管每個程式地址空間內容不盡相同,但是他們的都有相似的結構。X86 Linux程式的地址空間底部是保留給使用者程式的,包括文字、資料、堆、棧等,其中文字區和資料區是通過儲存器對映方式將磁碟中可執行檔案的相應段對映至虛擬儲存器地址空間中。有一些"敏感"的地址需要注意下,對於32位程式來說,程式碼段從0x08048000開始。從0xC0000000開始到0xFFFFFFFF是核心地址空間,通常情況下程式碼執行在使用者態(使用0x00000000 ~ 0xC00000000的使用者地址空間),當發生系統呼叫、程式切換等操作時CPU控制暫存器設定模式位,進入內和模式,在該狀態(超級使用者模式)下程式可以訪問全部儲存器位置和執行全部指令。也就說32位程式的地址空間都是4G,但使用者態下只能訪問低3G的地址空間,若要訪問3G ~ 4G的地址空間則只有進入核心態才行。

  • 程式控制塊(處理機)

    程式的排程實際就是核心選擇相應的程式控制塊,被選擇的程式控制塊中包含了一個程式基本的資訊。

  • 上下文切換

    核心管理所有程式控制塊,而程式控制塊記錄了程式全部狀態資訊。每一次程式排程就是一次上下文切換,所謂的上下文字質上就是當前執行狀態,主要包括通用暫存器、浮點暫存器、狀態暫存器、程式計數器、使用者棧和核心資料結構(頁表、程式表、檔案表)等。程式執行時刻,核心可以決定搶佔當前程式並開始新的程式,這個過程由核心排程器完成,當排程器選擇了某個程式時稱為該程式被排程,該過程通過上下文切換來改變當前狀態。一次完整的上下文切換通常是程式原先執行於使用者態,之後因系統呼叫或時間片到切換到核心態執行核心指令,完成上下文切換後回到使用者態,此時已經切換到程式B。

執行緒、程式比較

關於程式和執行緒的區別這裡就不一一羅列了,主要對比下執行緒和程式操作中主要的介面。

  • fork()和pthread_create()

    負責建立。呼叫fork()後返回兩次,一次標識主程式一次標識子程式;呼叫pthread_create()後得到一個可以獨立執行的執行緒。

  • wait()和pthread_join()

    負責回收。呼叫wait()後父程式阻塞;呼叫pthread_join()後主執行緒阻塞。

  • exit()和pthread_exit()

    負責退出。呼叫exit()後呼叫程式退出,控制權交給系統;呼叫pthread_exit()後執行緒退出,控制權交給主執行緒。

相關文章