操作系統(一) —— 進程線程模型

進程線程模型

  • 線程是調度的基本單位,進程是資源分配的基本單位

多線程模型

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. 線程同步
  • Linux下提供了多種方式來處理線程同步,最常用的是互斥鎖、條件變量和信號量。

  • 互斥鎖(mutex)
      鎖機制是同一時刻只允許一個線程執行一個關鍵部分的代碼。

    • 初始化鎖:int pthread_mutex_init(pthread_mutex_t *mutex,const pthread_mutex_attr_t *mutexattr);

      其中參數mutex爲指向需要初始化的互斥鎖的指針,參數mutexattr用於指定鎖的屬性,如果爲NULL則使用缺省屬性。

    • 阻塞加鎖:int pthread_mutex_lock(pthread_mutex *mutex);

      其中參數mutex爲指向需要獲取的互斥鎖的指針

    • 非阻塞加鎖:int pthread_mutex_trylock( pthread_mutex_t *mutex);

      該函數語義與 pthread_mutex_lock() 類似,不同的是在鎖已經被佔據時返回 EBUSY 而不是掛起等待。

    • 解鎖(要求鎖是lock狀態,並且由加鎖線程解鎖):int pthread_mutex_unlock(pthread_mutex *mutex);

      其中參數mutex爲指向需要釋放的互斥鎖的指針

    • 銷燬鎖(此時鎖必需是unlock狀態,否則返回EBUSY):int pthread_mutex_destroy(pthread_mutex *mutex);

      其中參數mutex爲指向需要釋放的互斥鎖的指針

  • 條件變量(cond)
      條件變量是利用線程間共享全局變量進行同步的一種機制。條件變量上的基本操作有:觸發條件(當條件變爲 true 時);等待條件,掛起線程直到其他線程觸發條件。

    • 初始化條件變量:int pthread_cond_init(pthread_cond_t *cond,pthread_condattr_t *cond_attr);

    其中參數cond爲指向需要初始化的條件變量的指針,儘管POSIX標準中爲條件變量定義了屬性,但在Linux中沒有實現,因此cond_attr值通常爲NULL,且被忽略。

    • 有兩個等待函數:
      (1)無條件等待:int pthread_cond_wait(pthread_cond_t *cond,pthread_mutex_t *mutex);

      (2)計時等待:int pthread_cond_timewait(pthread_cond_t *cond,pthread_mutex *mutex,const timespec *abstime);

      • 如果在給定時刻前條件沒有滿足,則返回ETIMEOUT,結束等待,其中abstime以與time()系統調用相同意義的絕對時間形式出現,0表示格林尼治時間1970年1月1日0時0分0秒。
    • 無論哪種等待方式,都必須和一個互斥鎖配合,以防止多個線程同時請求(用 pthread_cond_wait() 或 pthread_cond_timedwait() 請求)競爭條件(Race Condition)。
      mutex互斥鎖必須是普通鎖(PTHREAD_MUTEX_TIMED_NP)或者適應鎖(PTHREAD_MUTEX_ADAPTIVE_NP),且在調用pthread_cond_wait()前必須由本線程加鎖(pthread_mutex_lock()),
      而在更新條件等待隊列以前,mutex保持鎖定狀態,並在線程掛起進入等待前解鎖。在條件滿足從而離開pthread_cond_wait()之前,mutex將被重新加鎖,
      以與進入pthread_cond_wait()前的加鎖動作對應。

    • 激發條件
      (1)激活一個等待該條件的線程(存在多個等待線程時按入隊順序激活其中一個):int pthread_cond_signal(pthread_cond_t *cond);

      其中參數cond爲指向需要激活的條件變量的指針

      (2)激活所有等待線程:int pthread_cond_broadcast(pthread_cond_t *cond);

    • 銷燬條件變量:int pthread_cond_destroy(pthread_cond_t *cond);

      只有在沒有線程在該條件變量上等待的時候才能銷燬這個條件變量,否則返回EBUSY

    • 說明:
        pthread_cond_wait 自動解鎖互斥量(如同執行了pthread_unlock_mutex),並等待條件變量觸發。這時線程掛起,不佔用CPU時間,直到條件變量被觸發(變量爲ture)。
      在調用 pthread_cond_wait之前,應用程序必須加鎖互斥量。pthread_cond_wait函數返回前,自動重新對互斥量加鎖(如同執行了pthread_lock_mutex)。

      互斥量的解鎖和在條件變量上掛起都是自動進行的。因此,在條件變量被觸發前,如果所有的線程都要對互斥量加鎖,這種機制可保證在線程加鎖互斥量和進入等待條件變量期間,
      條件變量不被觸發。條件變量要和互斥量相聯結,以避免出現條件競爭——個線程預備等待一個條件變量,當它在真正進入等待之前,另一個線程恰好觸發了該條件
      (條件滿足信號有可能在測試條件和調用pthread_cond_wait函數(block)之間被髮出,從而造成無限制的等待)。

      條件變量函數不是異步信號安全的,不應當在信號處理程序中進行調用。特別要注意,如果在信號處理程序中調用 pthread_cond_signal 或 pthread_cond_boardcast 函數,
      可能導致調用線程死鎖

  • 信號量
    如同進程一樣,線程也可以通過信號量來實現通信,雖然是輕量級的。線程使用的基本信號量函數有四個:
    #include <semaphore.h>

  • 初始化信號量:int sem_init (sem_t *sem , int pshared, unsigned int value);

    sem - 指定要初始化的信號量;
    pshared - 信號量 sem 的共享選項,linux只支持0,表示它是當前進程的局部信號量;
    value - 信號量 sem 的初始值。

    • 信號量值加1,給參數sem指定的信號量值加1:int sem_post(sem_t *sem);

      sem - 指定要加1的信號量;

    • 信號量值減1,給參數sem指定的信號量值減1:int sem_wait(sem_t *sem);

      如果sem所指的信號量的數值爲0,函數將會等待直到有其它線程使它不再是0爲止。

    • 銷燬信號量,銷燬指定的信號量:int sem_destroy(sem_t *sem);

  • 條件變量與互斥鎖、信號量的區別
    1.互斥鎖必須總是由給它上鎖的線程解鎖,信號量的掛出即不必由執行過它的等待操作的同一進程執行。一個線程可以等待某個給定信號燈,而另一個線程可以掛出該信號燈。

    2.互斥鎖要麼鎖住,要麼被解開(二值狀態,類型二值信號量)。

    3.由於信號量有一個與之關聯的狀態(它的計數值),信號量掛出操作總是被記住。然而當向一個條件變量發送信號時,如果沒有線程等待在該條件變量上,那麼該信號將丟失。

    4.互斥鎖是爲了上鎖而設計的,條件變量是爲了等待而設計的,信號燈即可用於上鎖,也可用於等待,因而可能導致更多的開銷和更高的複雜性。

    5.互斥鎖一般用於互斥,信號量一般用於同步,互斥量的加鎖和解鎖必須由同一線程分別對應使用,信號量可以由一個線程釋放,另一個線程得到

3. 線程互斥
  • 互斥鎖(mutex)
    看 線程同步 中的互斥鎖

  • 自旋鎖(spin)

    • 定義自旋鎖:pthread_spinlock_t spin;

    • 初始化自旋鎖:int pthread_spin_init(pthread_spinlock_t *lock, int pshared);

    • 上鎖
      (1)int pthread_spin_lock(pthread_spinlock_t *lock);

      (2)int pthread_spin_trylock(pthread_spinlock_t *lock);

    • 解鎖:int pthread_spin_unlock(pthread_spinlock_t *lock);

    • 銷燬鎖:int pthread_spin_destroy(pthread_spinlock_t *lock);

  • 自旋鎖和互斥鎖的區別
    互斥鎖是當阻塞在pthread_mutex_lock時,放棄CPU,好讓別人使用CPU。自旋鎖阻塞在pthread_spin_lock時,不會釋放CPU,不斷向cup詢問可以用了不。

  • 讀寫鎖

    • 讀寫鎖的規則

      • 無鎖時,允許寫操作和讀操作

      • 有讀鎖時,允許讀操作,不允許寫操作

      • 有寫鎖時,不允許寫操作和讀操作

    • 定義鎖:pthread_rwlock_t lock;

    • 初始化:pthread_rwlock_init(&lock, NULL);

    • 讀鎖:pthread_rwlock_rdlock(&lock);

    • 寫鎖:pthread_rwlock_wrlock(&lock);

    • 解鎖:pthread_rwlock_unlock(&lock);

    • 銷燬鎖:pthread_rwlock_destroy(&lock);

多進程模型

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

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

1. 進程創建與結束
  • 背景知識:
    進程有兩種創建方式,一種是操作系統創建的一種是父進程創建的。
    從計算機啓動到終端執行程序的過程爲:0號進程 -> 1號內核進程 -> 1號用戶進程(init進程) -> getty進程 -> shell進程 -> 命令行執行進程。
    所以我們在命令行中通過 ./program執行可執行文件時,所有創建的進程都是shell進程的子進程,這也就是爲什麼shell一關閉,在shell中執行的進程都自動被關閉的原因。
    從shell進程到創建其他子進程需要通過以下接口。

  • 相關接口:

    • 創建進程(1):pid_t fork(void);

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

    • 創建進程(2):pid_t vfork(void);//與fork的區別在於:父進程要等子進程運行完成後才能運行且父子進程的數據是共享的(當子進程調用exit或exec時)

      返回值:出錯返回-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);}。
    
4. Linux進程控制
  • 進程地址空間(地址空間)
    虛擬存儲器爲每個進程提供了獨佔系統地址空間的假象。儘管每個進程地址空間內容不盡相同,但是他們的都有相似的結構。X86 Linux進程的地址空間底部是保留給用戶程序的,包括文本、數據、堆、棧等,其中文本區和數據區是通過存儲器映射方式將磁盤中可執行文件的相應段映射至虛擬存儲器地址空間中。有一些"敏感"的地址需要注意下, 對於32位進程來說,代碼段從0x08048000開始。從0xC0000000開始到0xFFFFFFFF是內核地址空間,通常情況下代碼運行在用戶態(使用0x00000000 ~ 0xC00000000的用戶地址空間),當發生系統調用、進程切換等操作時CPU控制寄存器設置模式位,進入內核模式,在該狀態(超級用戶模式)下進程可以訪問全部存儲器位置和執行全部指令。也就說32位進程的地址空間都是4G,但用戶態下只能訪問低3G的地址空間,若要訪問3G ~ 4G的地址空間則只有進入內核態才行。

    • 進程控制塊(處理機)
      進程的調度實際就是內核選擇相應的進程控制塊,被選擇的進程控制塊中包含了一個進程基本的信息。

    • 上下文切換
      內核管理所有進程控制塊,而進程控制塊記錄了進程全部狀態信息。每一次進程調度就是一次上下文切換,所謂的上下文本質上就是當前運行狀態,
      主要包括通用寄存器、浮點寄存器、狀態寄存器、程序計數器、用戶棧和內核數據結構(頁表、進程表、文件表)等。進程執行時刻,
      內核可以決定搶佔當前進程並開始新的進程,這個過程由內核調度器完成,當調度器選擇了某個進程時稱爲該進程被調度,該過程通過上下文切換來改變當前狀態。
      一次完整的上下文切換通常是進程原先運行於用戶態,之後因系統調用或時間片到切換到內核態執行內核指令,完成上下文切換後回到用戶態,此時已經切換到進程B。

5. 進程的狀態
  • 進程的三種基本狀態
    進程在運行中不斷地改變其運行狀態。通常,一個運行進程必須具有以下三種基本狀態。
    1)就緒狀態:當進程已分配到除CPU以外的所有必要的資源,只有獲得處理機便可以執行,這時的進程狀態爲就緒狀態;

    2)執行狀態:當進程已獲得處理機,其程序正在處理機上執行。此時的進程狀態稱爲執行狀態;

    3)阻塞狀態:正在執行的進程,由於等待某個事件發生而無法執行時,便放棄處理機而處於阻塞狀態。

進程、線程、程序

  • 程序是指令和數據的有序集合,其本身沒有任何運行的含義,是一個靜態的概念。
    而進程則是在處理機上的一次執行過程,它是一個動態的概念。這個不難理解,其實進程是包含程序的,進程的執行離不開程序,進程中的文本區域就是代碼區,也就是程序。

  • 進程和線程的主要差別在於它們是不同的操作系統資源管理方式。
    進程有獨立的地址空間,一個進程崩潰後,在保護模式下不會對其它進程產生影響,
    而線程只是一個進程中的不同執行路徑。線程有自己的堆棧和局部變量,
    但線程之間沒有單獨的地址空間,一個線程死掉就等於整個進程死掉,所以多進程的程序要比多線程的程序健壯,
    但在進程切換時,耗費資源較大,效率要差一些。但對於一些要求同時進行並且又要共享某些變量的併發操作,只能用線程,不能用進程。

  • 總結
    1) 簡而言之,一個程序至少有一個進程,一個進程至少有一個線程。
    2) 線程的劃分尺度小於進程,使得多線程程序的併發性高。3) 另外,進程在執行過程中擁有獨立的內存單元,而多個線程共享內存,從而極大地提高了程序的運行效率。
    4) 線程在執行過程中與進程還是有區別的。每個獨立的線程有一個程序運行的入口、順序執行序列和程序的出口。但是線程不能夠獨立執行,必須依存在應用程序中,由應用程序提供多個線程執行控制。
    5) 從邏輯角度來看,多線程的意義在於一個應用程序中,有多個執行部分可以同時執行。但操作系統並沒有將多個線程看做多個獨立的應用,來實現進程的調度和管理以及資源分配。這就是進程和線程的重要區別。

  • 線程和進程在使用上各有優缺點:線程執行開銷小,但不利於資源的管理和保護;而進程正相反。同時,線程適合於在SMP(多核處理機)機器上運行,而進程則可以跨機器遷移。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章