[CSAPP筆記][第八章異常控制流][嘔心瀝血千行筆記]


異常控制流

  • 控制轉移
  • 控制流

系統必須能對系統狀態的變化做出反應,這些系統狀態不是被內部程序變量捕獲,也不一定和程序的執行相關。

現代系統通過使控制流 發生突變對這些情況做出反應。我們稱這種突變異常控制流( Exceptional Control Flow,ECF)

異常控制流發生在系統的各個層次。

理解ECF很重要

  • 理解ECF將幫助你理解重要的系統概念。
  • 理解ECF將幫助你理解應用程序如何與操作系統交互
    • 通過陷阱(trap)或者系統調用(system call)的ECF形式,向操作系統請求服務。
  • 理解ECF將幫助你編寫有趣的應用程序
  • 理解ECF將幫助你理解並發
  • 理解ECF將幫助你理解軟件異常如何工作。

這一章你將理解如何與操作系統交互,這些交互都圍繞ECF

8.1 異常

異常異常控制流的一種,一部分由硬件實現,一部分由操作系統實現。

  • 異常(exception)就是控制流的突變,用來響應處理器狀態的某些變化。

  • 狀態變化又叫做事件(event)

    • 事件可能與當前執行指令有關
      • 存儲器缺頁,算數溢出
      • 除0
    • 也可能與當前執行指令無關
      • I/O請求
      • 定時器產生信號
  • 通過異常表(exception table)的跳轉表,進行一個間接過程調用,到專門設計處理這種事件的操作系統子程序(異常處理程序(exception handler))

  • 異常處理完成后,根據事件類型,會有三種情況

    • 返回當前指令,即發生事件時的指令。
    • 返回沒有異常,所執行的下一條指令
    • 終止被中斷的程序

8.1.1 異常處理

  • 為每個異常分配了一個非負的異常號(exception number)

    • 一些號碼由處理器設計者分配
    • 其他號碼由操作系統內核的設計者分配。
  • 系統啟動時,操作系統分配和初始化一張稱為異常表的跳轉表。

    • 條目k包含異常k的處理程序的地址。
  • 異常表的地址放在叫異常表基址寄存器的特殊CPU寄存器中。)

  • 異常類似過程調用,不過有以下不同

    • 過程調用,跳轉到處理程序前,處理器將返回地址壓入棧中。對於異常,返回地址是當前,或下一跳指令。
    • 處理器會把額外的處理器狀態壓入棧中。
    • 如果控制一個用戶程序到內核,那么所有這些項目會被壓入內核棧中,而是用戶棧。
    • 異常處理程序運行在內核模式下,這意味他們對所有系統資源有完整訪問權限。

8.1.2 異常的類別

異常分為一下四類:中斷(interrupt),陷阱(trap),故障(fault)和終止(abort)。

前者可以叫異步中斷/異常外中斷 ,后三個可以叫同步中斷/異常

  1. 中斷

    • 中斷異步發生,是來自處理器外部的I/O設備的信號的結果。硬件中斷不是由任何一條專門的指令造成,從這個意義上它是異步的。
    • 硬件中斷的異常處理程序通常稱為中斷處理程序(interrupt handle)
      • I/O設備通過向處理器芯片的一個引腳發信號,並將異常號放到系統總線上,以觸發中斷。
      • 在當前指令執行完后,處理器注意到中斷引腳的電壓變化,從系統總線讀取異常號,調用適當的中斷處理程序。
      • 當處理程序完成后,它將控制返回給下一條本來要執行的指令。

      • 剩下的異常類型(陷阱,故障,終止)是同步發生,執行當前指令的結果。我們把這類指令叫做故障指令(faulting instruction).
  2. 陷阱和系統調用

    • 陷阱有意的異常,是執行一個指令的結果。也會返回到下一跳本來要執行的指令。
    • 陷阱最重要的用途是在用戶程序和內核之間提供一個像過程一樣的接口,叫做系統調用
      • 用戶程序經常需要向內核請求服務。
        • 讀文件(read)
        • 創建進程(fork)
        • 新的程序(execve)
        • 終止當前進程(exit)
      • 為了運行對這些內核服務的受控訪問,處理器提供了一條特殊的syscall n的指令
      • 系統調用是運行在內核模式下,而普通調用是用戶模式下。
  1. 故障

    • 故障由錯誤引起,可能被故障處理程序修正。

      • 如果能被修正,返回引起故障的指令。
      • 否則返回abort例程,進行終結。
  2. 終止

    • 終止是不可恢復的致命錯誤造成的結果,通常是一些硬件錯誤,比如DRAM和SRAM被損壞。
    • 終止處理程序從不將控制返回給應用程序。返回一個abort例程。

8.1.3 Linux/IA32 系統中的異常

  • 有高達256種不同的異常
    • 0~31 由Intel架構師定義的異常,對任何IA32系統都一樣。
    • 23~255 對應操作系統定義的中斷和陷阱。
  1. Linux/IA32 故障和終止

    • 除法錯誤
    • 一般保護故障
    • 缺頁
    • 機器檢查
  2. Linux/IA32 系統調用

  • 在IA32系統中,系統調用是通過一條稱為int n的陷阱指令完成,其中n可能是IA32異常表256個條目中任何一個索引,歷史中,系統調用是通過異常128(0x80)提供的。

  • C程序可用syscall函數來直接調用任何系統調用

    • 實際上沒必要這么做
    • C庫提供了一套方便的包裝函數。這些包裝函數將參數打包到一起,以適當的系統調用號陷入內核,然后將系統調用的返回狀態傳遞回調用函數。
    • 我們將系統調用與他們相關聯的包裝函數稱為系統級函數

研究程序如何使用int指令直接調用Linux 系統調用是很有趣的。所有到Linux系統調用的參數都是通過通用寄存器而不是棧傳遞。

慣例

  • %eax 包含系統調用號
  • %ebx,%ecx,%edx,%esi,%edi,%ebp包含六個任意的參數。
  • %esp不能使用,進入內核模式后,內核會覆蓋它。
  • 系統級函數寫的hello world

      int main()
      {
          write(1,"hello,world\n",13);
          exit(0);
      }
    
  • 匯編寫的hello world

      string:
              "hello world\n"
      main:
              movl $4,%eax
              movl $1,%ebx
              movl $String,%ecx
              movl $len,%edx
              int $0x80
    
              movl $1,%eax
              movl $0,%ebx
              int $0x80
    

8.2 進程

  • 異常是允許操作系統提供進程的概念的基本構造快,進程是計算機科學中最深刻,最成功的概念之一。
    • 假象,覺得我們的程序是系統中唯一運行着的程序。我們的程序好像獨占處理器和存儲器。
    • 這些假象都是通過進程概念提供給我們的。
  • 進程經典定義:一個執行中的程序實例.
    • 系統中每個程序都是運行某個進程的上下文中的。
      • 上下文是由程序正確運行所需的狀態組成。
      • 這個狀態包括存儲器中的代碼和數據,它的棧,通用目的寄存器,程序計數器,環境變量等。
  • 進程提供的假象
    • 一個獨立的邏輯控制流
    • 一個私有的地址空間

8.2.1 邏輯控制流

  • PC值的序列叫做邏輯控制流,或者簡稱邏輯流

8.2.2 並發流

  • 邏輯流也有不同的形式。

    • 異常處理程序,進程,信號處理程序,線程和Java進程都是邏輯流的例子。
  • 一個邏輯流的執行在執行上與另一個流重疊,稱為並發流,這兩個流被稱為並發地運行

    • 更准確地說,流X和Y互相並發。
  • 多個流並發執行的一般現象稱為並發

    • 一個進程和其他進程輪流執行的概念稱為多任務
    • 一個進程執行它的控制流的一部分的每一時間段叫做時間片
    • 因此,多任務 又叫時間分片
  • 並發的思想與流運行的處理器核數與計算機數無關。

    • 如果兩個流在時間上重疊,即使運行在同一處理器,也是並發。
    • 並行流是並發流的一個真子集。
      • 兩個流並發地運行在不同的處理器核或者計算機上,我們稱為並行流
      • 它們並行地運行,且並行地執行

你吃飯吃到一半,電話來了,你一直到吃完了以后才去接,這就說明你不支持並發也不支持並行

你吃飯吃到一半,電話來了,你停了下來接了電話,接完后繼續吃飯,這說明你支持並發

你吃飯吃到一半,電話來了,你一邊打電話一邊吃飯,這說明你支持並行

並發的關鍵是你有處理多個任務的能力,不一定要同時。

並行的關鍵是你有同時處理多個任務的能力。

8.2.3 私有地址空間

進程 為個程序好像獨占了系統地址空間。

  • 一個進程為每個程序提供它自己的私有地址空間
  • 不同系統一般都用相同的結構。

8.2.4 用戶模式和內核模式

處理器提供一種機制,限制一個應用程序可以執行的指令以及它可以訪問的地址空間范圍。這就是用戶模式內核模式

  • 處理器通過控制寄存器中的一個模式位來提供這個功能。

    • 該寄存器描述了進程當前享有的特權。
      • 設置了模式位后,進程就運行在內核模式中(有時也叫超級用戶模式)
        • 內核模式下的進程可以執行指令集的任何指令,訪問系統所有存儲器的位置。
      • 沒有設置模式位時,進程運行在用戶模式。
        • 用戶模式不允許程序執行特權指令。
          • 比如停止處理器,改變模式位,發起一個I/O操作。
        • 不允許用戶模式的進程直接引用地址空間的內核區代碼和數據。
        • 任何嘗試都會導致保護故障
        • 用戶通過系統調用間接訪問內核代碼和數據。
    • 進程從用戶模式轉變位內核模式的方法
      • 通過中斷,故障,陷入系統調用這樣的異常。
      • 在異常處理程序中會進入內核模式。退出后,又返回用戶模式。
  • Linux提供一種聰明的機制,叫/proc文件系統。

    • 允許用戶模式訪問內核數據結構的內容。
    • /proc文件將許多內核數據結構輸出為一個用戶程序可以讀的文本文件的層次結構。
      • 如CPU類型(/proc/cpuinfo)
      • 特殊進程使用的存儲器段('/proc/ /maps')
    • 2.6 版本引入Linux內核引入/sys文件系統。
      • 輸出關於系統總線和設備的額外的底層信息。

8.2.5 上下文切換

操作系統內核使用一種稱為上下文切換的 較高層次 的異常控制流來實現多任務。
  • 上下文切換機制建立在之前討論的較低層次異常機制上的。
內核為每個進程維護一個上下文
  • 上下文就是重新啟動一個被搶占的進程所需的狀態。

    • 由一些對象的值組成
      • 通用目的寄存器
      • 浮點寄存器
      • 程序計數器(PC)
      • 用戶棧
      • 狀態寄存器
      • 內核棧
      • 各種內核數據結構
        • 描繪地址空間的頁表
        • 包含當前進城信息的進程表
        • 進程已打開文件信息的文件表
  • 在進程執行的某些時刻,內核可以決定搶占當前進程,並重新開始一個先前被搶占的進程。這種決定叫做調度(shedule),由內核中稱為調度器(scheduler)的代碼處理的。

    • 當內核選擇一個新的進程運行時,我們就說內核調度了這個進程。
  • 當調度進程時,使用一種上下文切換的機制來控制轉移到新的進程

    • 保存當前進程的上下文
    • 恢復某個先前被搶占的進程被保存的上下文
    • 將控制傳遞給這個新恢復的進程
  • 什么時候會發生上下文切換

    • 內核代表用戶執行系統調用
      • 如果系統調用因為某個事件阻塞,那么內核可以讓當前進程休眠,切換另一個進程。
      • 或者可以用sleep系統調用,顯式請求讓調用進程休眠。
      • 即使系統調用沒有阻塞,內核可以決定執行上下文切換
    • 中斷也可能引發上下文切換。
      • 所有系統都有某種產生周期性定時器中斷的機制,典型為1ms,或10ms。
      • 每次定時器中斷,內核就能判斷當前進程運行了足夠長的時間,切換新的進程

高速緩存污染和異常控制流

一般而言,硬件高速緩存存儲器不能和諸如中斷和上下文切換這樣的異常控制流很好地交互,如果當前進程被一個中斷暫時中斷,那么對於中斷處理程序來說高速緩存器是冷的。如果處理程序從主存訪問足夠多的表項,被中斷的進程繼續的時候,高速緩存對於它來說也是冷的,我們稱中斷處理程序污染了高速緩存。使用 上下文切換也會發生類似的現象。

8.3 系統調用錯誤處理

  • 當Unix系統級函數遇到錯誤時,他們典型地返回-1,並設置全局變量errno來表示什么出錯了。

      if((pid=fork()<0){
              fprintf(stderr,"fork error: %s\n", strerror(errno));
              exit(0);
      }
    
  • strerror 函數返回一個文本串,描述了個某個errno值相關聯的錯誤。

8.4 進程控制

8.4.1 獲取進程ID

#include<sys/types.h>
#include<unistd.h>

pid_t getpid(void);
pid_t getppid(void);
  • PID是每個進程唯一的正數。
  • getpid()返回調用進程的PID,getppid()返回它的父進程的PID。
  • 返回一個類型pid_t的值,在Linux系統下在type.h被定義為int

8.4.2 創建和終止進程

進程總是處於下面三種狀態

  • 運行。進程要么在CPU中執行,要么等待執行,最終被內核調度。

  • 停止。進程的執行被掛起,且不會被調度。

    • 收到SIGSTOP,SIGTSTP,SIDTTIN或者SIGTTOU信號,進程就會停止。
    • 直到收到一個SIGCONT信號,在這個時刻,進程再次開始運行。
    • 信號是一種軟件中斷的形式。
  • 終止。進程永遠停止。

    • 收到一個信號。信號默認行為是終止進程。
    • 從主程序返回
    • 調用exit函數
      • exit函數以status退出狀態來終止進程(另一種設置方式在main中return )

子進程

父進程通過調用fork函數創建一個新的運行子進程

#include<sys/types.h>
#include<unistd.h>

pid_t fork(void);
返回:子進程返回0,父進程返回子進程的PID,如果出錯,返回-1;

新創建的子進程幾乎但不完全與父進程相同。

  • 子進程得到與父進程用戶級虛擬地址空間相同的(但是獨立的)一份拷貝。

    • 包括文本,數據和bss段,堆以及用戶棧。子進程還獲得與父進程任何打開文件描述符相同的拷貝。
    • 意味着當父進程調用fork時,子進程可以讀寫父進程中打開的任何文件。
    • 父進程和新創建的子進程之間最大的區別在於有不同的PID 。
  • fork()函數會第一次調用,返回兩次,一次在父進程,一次在子進程。

    • 返回值用來明確是在父進程還是在子進程中執行。

  • 調用一次,返回兩次

    • 對於具有多個fork實例的需要仔細推敲了
  • 並發執行

    • 父進程和子進程是並發運行的獨立進程。
    • 內核可能以任意方式覺得執行他們的順序。
    • 不能對不同進程中指令的交替執行做任何假設。
  • 相同但是獨立的地址空間

    • 在剛調用時,幾乎什么都是相同的。
    • 但是它們都有自己的私人空間,之后對x的改變是相互獨立的。
  • 共享文件

    • 父進程和子進程都把他們的輸出顯示在屏幕上。
    • 子進程繼承了父進程所有打開的文件。

畫進程圖會有幫助。

8.4.3 回收子進程

當一個進程由於某種原因終止時,內核並不是立即把它從系統中清除。相反,進程被保持在一種已終結的狀態,知道被它的父進程 回收(reap)。

當父進程回收已終止的子進程時,內核將子進程的退出狀態傳遞給父進程,然后拋棄已終止的進程。

一個終止了但還未被回收的進程叫做僵死進程

如果父進程沒有回收,而終止了,那么內核安排init進程來回收它們。

  • init進程的的PID位1,在系統初始化時由內核創建的。
  • 長時間運行的程序,如shell,服務器,總是應該回收他們的僵死子進程

一個進程可以通過調用waitpid函數來等待它的子進程終止或停止

#include<sys/types.h>
#include<sys/wait.h>

pid_t waitpid(pid_t pid ,int *status, int options);
返回:如果成功,則為子進程的PID,如果WNOHANG,則為0,如果其他錯誤,則為-1.

waitpid函數有點復雜。默認(option=0)時,waitpid掛起調用進程的執行,知道它的等待集合中的一個子進程終止,如果等待集合的一個子進程在調用時刻就已經終止,那么waitpid立即返回。在這兩種情況下,waitpid返回導致waitpid返回的已終止子進程的PID,並且將這個已終止的子進程從系統中去除。

  • 判斷等待集合的成員

    等待集合的成員通過參數pid確定

    • 如果pid>0,那么等待集合就是一個獨立的子進程,它的進程ID等於PID
    • 如果pid=-1,那么等待集合就是由父進程所有的子進程組成的。
    • waitpid函數還支持其他類型的等待集合,包括UNIX進程組等,不做討論。
  • 修改默認行為(此處書中有問題,作用寫反了)

    可以通過將options設置為常量WHOHANGWUNTRACED的各種組合,修改默認行為。

    • WHOHANG: 如果等待集合中的任何子進程都還沒有終止,那么立即返回(返回值為0)
      • 默認的行為返回已終止的子進程。
      • 當你要檢查已終止和被停止的子進程,這個選項會有用。
    • WUNTRACED:掛起調用進程的執行,知道等待集合中的一個進程變為已終結或被停止。
      • 返回的PID為導致的已終止或被停止的子進程PID·
      • 默認的行為是掛起調用進程,直到有子進程終止。
    • WHOHANG|WUNTRACED: 立即返回,如果等待集合中沒有任何子進程被停止或已終止,那么
  • 檢查已回收子進程的退出狀態

    如果status 參數是非空的,那么waitpid就會在status參數中放上關於導致返回的子進程的狀態信息。wait.h 頭文件定義解釋status參數的幾個宏(函數宏):

    • WIFEXITED(status) : 如果子進程通過調用exit或者一個返回(return)正常終止,就返回真。
    • WEXITSATUS(status): 返回一個正常終止的子進程的退出狀態。只有在WIFEXITED定義為真是,才會定義這個狀態。
    • WIFSIGNALED(status): 如果子進程是因為一個未被捕獲的信號終止的,那么就返回真
    • WTERMSIG(status): 返回導致子進程終止的信號的數目,只有在WIFSIGNALED返回真時,才會定義這個狀態。
    • WIFSTOPPED(status): 如果引起返回的子進程當前是被停止的,那么就返回真。
    • WSTOPSIG(status): 取得引發子進程暫停的信號代碼,只有在WIFSTOPPED為真,才定義這個狀態。
  • 錯誤條件

    • 調用進程沒有子進程,那么waitpid返回-1,並且設置errnoECHILD
    • 如果waitpid函數被一個信號中斷,那么它返回-1,並且設置errnoEINTR

Q:憑什么輸出bcac序列

A:?
  • wait 函數

    wait函數是waitpid函數的簡單版本:

      #include<sys/types.h>
      #include<sys/wait.h>
      
      pid_t wait(int *status);
    

    調用wait(&status)等價於調用waitpid(-1,&status,0)

  • waitpid實例,按順序回收僵死進程

8.4.4 讓進程休眠

  • sleep函數將一個進程掛起一段指定時間

      #include <unistd.h>
    
      unsigned int sleep (unsigned int secs);
      返回:還要休眠的描述
    
  • pause 讓調用進程休眠,知道該進程收到一個信號

      #include<unistd.h>
      
      int pause(void);
    

8.4.5 加載並運行一個程序

execve函數在當前進程的上下文中加載並運行了一個新程序。

#include <unistd.h>

int execve(const char *filename,const char *argv[],const char *envp[]);

execve函數加載並運行可執行目標文件filename,且帶參數argv和環境變量列表envp

只有當出現錯誤時,execve才會返回到調用程序

  • *argv[]參數列表數據結構表示

    • 指向一個以null結尾的指針數組
    • 每個指針指向一個參數串
      • 一般來說,argv[0]是可執行目標文件的名字。
  • *envp[]環境列表數據結構表示類似

    • 指向一個以null結尾的指針數組
    • 每個指針指向一個環境變量串
      • 每個串都是形如KEY=VALUE的 鍵值對

execve加載filename以后,調用7.9節的啟動代碼,啟動代碼設置用戶棧。並將控制傳遞給新程序的主函數。

  • 主函數有如下原型

    int main(int argc,char **argv,char **envp);
    int main(int argc,char *argv[],char *envp[]);

  • 當開始執行時,用戶棧如圖。

    • argc: 命令行參數個數
    • argv: 命令行指針數組的地址
    • envp: 環境變量指正數組的地址

Unix提供一下幾個函數來操作環境數組。

  • getenv

      #include<stdlib.h>
      char *getenv(const char *name);
      //getenv函數在環境變量搜索字符串“name=value"。如果找到了,它就返回一個指向value的指針,否則返回NULL。
    
  • setenvunsetenv

      #include<stdlib.h>
      int setenv(const char *name,const char *newvalue,int overwrite);
      //成功返回0,錯誤返回-1
      void unsetenv(const char *name);
      
      //如果環境數組包含一個形如"name=oldvalue"的字符串,那么unsetenv會刪除它
      //,而setenv會用newvalue代替oldvalue,但是只有在overwirte非零時才會這樣。
      //如果name不存在,那么setenv就把”name=newvalue"添加進指針數組。
    

forkexecve區別

  • fork:在新的子進程運行相同的程序。
    • 新進程是父進程的復制品。
  • execve:在當前進程的上下文加載並運行一個新的程序。
    • 覆蓋當前進程的地址空間。
    • 但沒有創建新進程。
    • 新的程序仍然有相同的PID,並且繼承了調用execve函數時已打開的所有文件描述。

8.4.6 利用fork和execve運行程序

Unix shellWeb服務器 這樣的程序大量使用forkexecve函數。

shell是一種交互型的應用級程序,代表用戶運行其他程序。

  • 最早的shellsh程序。
  • 后面出現了csh,tcsh,ksh,bash
  • shell執行一系列 read/evaluate
    • read:讀取來自用戶的命令。
    • evaluate:解析命令,並代表用戶執行程序。

其實shell也就是一個ACM中很簡單的模擬題而已。

  • 對字符串的處理。考慮各種trick
  • 通過判斷命令結尾是否有& 來決定shell是否waitpid。即是否后台運行。

  • 輸出一個>,等待接收命令。
  • 調用eval對命令運算。

  • parseline解析以空格分割的命令行參數,並將分割后的值丟入argv中。

    • 如果末尾是&,則返回1。表示后台運行
  • builtin_command 判斷一下是否存在這樣的指令。

  • 如果bg=0,那么等待程序結束,shell才會繼續執行。

  • parseline具體代碼就不貼了。

注意這個簡單的shell是有缺陷的,因為它並不回收它的后台子進程。修改這個缺陷,就必須使用信號

8.5 信號

研究一種更高層次的軟件形式的異常, 也是一種軟件中斷,稱為Unix信號,它允許進程中斷其他進程。

一個信號就是一條小消息,它通知進程系統中發生一個某種類型的事件。

Linux系統支持30多種信號。

每種信號類型對應於某種系統事件

  • 底層的信號。
    • 當底層發生硬件異常,信號通知 用戶進程 發生了這些異常

      • 除以0:發送SIGILL信號。
      • 非法存儲器引用:發送SIGSEGV信號
    • 較高層次的軟件事件

      • 鍵入ctrl+c:發送SIGINT信號
      • 一個進程可以發送給另一個進程SIGKILL信號強制終止它。
      • 子進程終止或者停止,內核會發送一個SIGCHLD信號給父進程。

8.5.1 信號術語

傳送一個信號到目的進程有兩個步驟。

  • 發送信號: 內核通過更新目的進程上下文的某個狀態,就說發送一個信號給目的進程。

    發送信號有兩個原因

    • 內核檢測到一個系統事件。比如被零除錯誤,或者子進程終止。
    • 一個進程調用了kill函數。顯示要求進程發送信號給目的進程。
      • 一個進程可以發信號給它自己。
  • 接收信號: 當目的進程 被內核強迫以某種方式對信號的發送做出反應。目的進程就接收了信號

    • 進程可以忽略這個信號,終止。
    • 或者通過一個稱為信號處理程序(signal handler)的用戶層函數捕獲這個信號。

一個只發出而沒有被接收的信號叫做待處理信號(pending signal)

  • 一種類型至多只有一個待處理信號
    • 如果一個進程有一個類型為k待處理信號
    • 那么接下來發送到這個進程類型為k的信號都會被簡單的丟棄

一個進程可以有選擇性地阻塞接收某種信號

  • 它任然可以被發送。但是產生的待處理信號不會被接收。

一個待處理信號最多被接收一次。內核為每個進程在pending位向量維護着待處理信號的集合,而在blocked位向量維護着被阻塞的信號集合。只要傳送一個類型為k的信號,內核就會設置pending中的第k位,而只要接收了一個類型為k的信號,內核就會清除pending中的第k位。

8.5.2 發送信號

Unix系統 提供大量向進程發送信號的機制。所有這些機制都是基於進程組(process group)。

  1. 進程組

    • 每個進程都屬於一個進程組
      • 由一個正整數進程組ID來標示

        • getpgrp()函數返回當前進程的進程組ID:

            #include<unistd.h>
            pid_t getpgrp(void);
          
      • 默認,一個子進程和它的父進程同屬於一個進程組

        • 一個進程可以通過setpgid()來改變自己或者其他進程的進程組。

            #include<unistd.h>
            int setpgid(pid_t pid,pid_t pgid);
            如果pid是0 ,那么使用當前進程的pid。
            如果pgid是0,那么使用指定的pid作為pgid(即pgid=pid)。
            
            例如:進程15213調用setpgid(0,0)
            那么進程15213會 創建/加入進程組15213.
          
  2. /bin/kill 程序發送信號

    • /bin/kill可以向另外的進程發送任意的信號

      • 比如

          unix>/bin/kill -9 15213
        

        發送信號9(SIGKILL)給進程15213。

      • 一個為負的PID會導致信號被發送到進程組PID中的每個進程。

         unix>/bin/kill -9 -15213
        
      發送信號9(`SIGKILL`)給進程組15213中的每個進程。
      
    • /bin/kill的原因是,有些Unix shell 有自己的kill命令

  3. 鍵盤發送信號

    作業(job) :對一個命令行求值而創建的進程

    • 在任何時候至多只有一個前台作業和0個或多個后台作業

      • 前台作業就是需要等待的
      • 后台作業就是不需要等待的
    • 鍵入unix>ls|sort

      • 創建一個兩個進程組成的前台作業
      • 兩個進程通過Unix管道鏈接。
    • shell為每個作業創建了一個獨立的進程組。

      • 進程組ID取自作業中父進程中的一個。

    鍵盤輸入ctrl-c 會發送一個SIGINT信號到外殼。外殼捕獲該信號。然后發送SIGINT信號到這個前台進程組的每個進程。在默認情況下,結果是終止前台作業

    類似,輸入`ctrl-z`會發送一個`SIGTSTP`信號到外殼,外殼捕獲這個信號,並發送`SIGTSTP`信號給前台進程組的每個進程,在默認情況,結果是**停止(掛起)**前台作業(還是僵死的)
    
  4. kill函數發送信號

    • 進程通過調用kill函數發送信號給其他進程,類似於bin/kill

        int kill(pid_t pid, int sig);
      
    • pid>0,發送信號sig給進程pid

    • pid<0,發送信號sig給進程組abs(pid)

    • 事例:kill(pid,SIGKILL)

  5. alarm函數發送信號

    進程可以通過調用alarm函數向它自己SIGALRM信號。

     #include<unistd.h>
     
     unsigned int alarm(unsigned int secs);
     
     返回:前一次鬧鍾剩余的秒數。
    

    alarm函數安排內核在secs秒內發送一個SIGALRM信號給調用進程

    • 如果secs=0 那么不會調度鬧鍾,當然不會發送SIGALRM信號。

    • 在任何情況,對alarm的調用會取消待處理(pending)的鬧鍾,並且會返回被取消的鬧鍾還剩余多少秒結束。如果沒有pending的話,返回0

    一個例子:

    輸出

     unix> ./alarm
     BEEP
     BEEP
     BEEP
     BEEP
     BEEP
     BOOM!
     //handler是一個自己定義的信號處理程序,通過signal函數捆綁。
    

8.5.3 接收信號

信號的處理時機是在從內核態切換到用戶態時,會執行do_signal()函數來處理信號

當內核從一個異常處理程序返回,准備將控制傳遞給進程p時,它會檢查進程p未被阻塞的待處理信號的集合(pening&~blocked)。

  • 如果這個集合為空,內核將控制傳遞到p的邏輯控制流的下一條指令。
  • 如果非空,內核選擇集合中某個信號k(通常是最小的k),並且強制p接收k。收到這個信號會觸發進程某些行為。一旦進程完成行為,傳遞到p的邏輯控制流的下一條指令。
    • 每個信號類型都有一個預定義的默認類型,以下幾種.

      • 進程終止
      • 進程終止並轉儲存器(dump core)
      • 進程停止直到被SIGCONT信號重啟
      • 進程忽略該信號
    • 進程可以通過使用signal函數修改和信號相關聯的默認行為。

      • SIGSTOP,SIGKILL是不能被修改的例外。

          #include<signal.h>
          typedef void (*sighandler_t)(int);
        
          sighandler_t signal(int signum,sighandler_t handler);
        
      • signal函數通過下列三種方式之一改變和信號signum相關聯的行為。

        • 如果handlerSIG_IGN,那么忽略類型為signum的信號
        • 如果handlerSIG_DFL,那么類型為signum的信號恢復為默認行為。
        • 否則,handler就是用戶定義的函數地址,這個函數稱為信號處理程序
          • 只要進程接收到一個類型為signum的信號,就會調用handler。
          • 設置信號處理程序:把函數傳遞給signal改變信號的默認行為。
          • 調用信號處理程序,叫捕獲信號
          • 執行信號處理程序,叫處理信號
    • 當處理程序執行它的return語句后,控制通常傳遞回控制流中進程被信號接收中斷位置處的指令。

信號處理程序是計算機並發的又一個示例。信號處理程序的執行中斷,類似於底層異常處理程序中斷當前應用程序的控制流的方式。因為信號處理程序的邏輯控制流與主函數的邏輯控制流重疊,信號處理程序和主函數並發地運行。


自我思考:信號是一種異常/中斷,當接收到信號的時候,會停下當前進程所做的事,立馬去執行信號處理程序。並不是多線程/並行,但確是並發的。從下面這張圖,可見一斑。

8.5.4 信號處理問題

當一個程序要捕獲多個信號時,一些細微的問題就產生了。

  • 待處理信號被阻塞
    • Unix 信號處理程序通常會阻塞 當前處理程序正在處理 的類型的待處理信號
  • 待處理信號(被拋棄了)不會排隊等待
    • 當有兩個同類型信號都是待處理信號時,有一個會被拋棄。
    • 關鍵思想:存在一個待處理的信號k僅僅表明至少一個一個信號k到達過。
  • 系統調用可以被中斷(在某些unix系統會出現)
    • read,waitaccept這樣的系統調用潛在的阻塞一段較長的時間,稱為慢速系統調用
      • 當處理程序捕獲一個信號,被中斷的慢速系統調用在信號處理程序返回后將不在繼續,而是立即返回給用戶一個錯誤條件,並將errno設置為EINTR

用一個后台回收僵死子進程的程序,前台讀入做例子

  • 1.初始簡單利用接收SIGCHLD信號回收,一次調用只回收一個。

    • 在調用的過程中,又有信號發送過來,但是被阻塞了。之后又被直接拋棄。
    • 如果不處理被阻塞和不會排隊等待的問題。會有信號被拋棄。
    • 重要教訓:不可以用信號對其他進程中發送的事件計數
    • handle1-code

  • 2.一次調用盡可能的多回收,保證在回收過程中,沒有遺漏的信號。

    • handle2-code
  • 3.還存在一個問題,在前台中,某些unix系統(Solaris系統)的read被中斷后不會自動重啟,需要手動重啟,Linux一般會自動重啟。

    • 之前 read模塊 code

    • 現在改為如果是errno==EINTR手動重啟。

    • 或者使用Signal包裝函數標准。8.5.5會提到。

8.5.5 可移植的信號處理

不同系統之間,信號處理語義的差異(比如一個被中斷的慢速系統調用是重啟,還是永久放棄)是Unix信號系統的一個缺陷。

為了處理這個問題,Posix標准定義了sigaction函數,它允許與LinuxSolaris這樣與Posix兼容的系統上的用戶,明確指明他們想要的信號處理語義。

#include<signal.h>

int sigaction(int signum,stuct sigaction *act,struct sigaction *oldcat);
//若成功則為1,出錯則為-1。

sigaction函數應用不廣泛,它要求用戶設置多個結構條目


一個更簡潔的方式,是定義一個包裝函數,稱為Signal,它調用sigaction

  • 它的調用方式與signal函數的調用方式一樣。
  • Signal包裝函數設置了一個信號處理程序,其信號處理語義如下(設置標准):
    • 只有這個處理程序當前正在處理的那種類型被阻塞。
    • 和所有信號實現一樣,信號不會排隊等待。
    • 只要可能,被中斷的系統調用會自動重啟
    • 一旦設置了信號處理程序,它就會一直保持,直到Signal帶着handler參數為SIG_IGN或者SIG_DFL被調用。
      • 在某些比較老的Unix系統,信號處理程序被使用一次后,又回到默認行為。

8.5.6 顯示地阻塞和取消阻塞信號

通過sigprocmask函數來操作。

#include<signal.h>

int sigprocmask(int how,const sigset_t *set,sigset_t *oldset);
  • sigprocmask函數改變當前已阻塞信號的集合(8.5.1節描述的blocked位向量)。
    • 具體行為依賴how

      • SIG_BLOCK:添加set中的信號到blocked中。
      • SIG_UNBLOCK: 從blocked刪除set中的信號。
      • SIG_SETMASK: blocked=set
    • 如果oldset非空,block位向量以前的值會保存到oldset中。

還有以下函數操作set集合

#include<signal.h>

int sigemptyset(sigset_t *set);
//置空
int sigfillset(sigset_t *set);
//每個信號全部填入
int sigaddset(sigset_t *set,int signum);
//添加
int sigdelset(sigset_t *set,int signum);
//刪除
//成功輸出0,出錯輸出-1
int sigismember(const sigset_t *set,int signum);
//判斷
//若signum是set的成員,輸出1,不是輸出0,出錯輸出-1。

8.5.7 同步流以避免討厭的並發錯誤

如何編寫讀寫相同存儲位置的並發流程序的問題,困擾着數代計算機科學家。

  • 流可能交錯 的數量是與指令數 量呈指數關系
    • 有些交錯會產生正確結果,有些可能不會。

所謂同步流就是。以某種方式同步並發流,從而得到 最大的可行交錯的集合 ,每個交錯集合都能得到正確的結果。

並發編程是一個很深奧,很重要的問題。在第12章詳細討論。

現在我們只考慮一個並發相關的智力挑戰。

code

如果發生以下情況,會出現同步錯誤

  • 父進程執行fork函數,內核調度新創建的子進程運行,而不是父進程。
  • 在父進程再次運行前,子進程已經終止,變成僵死進程,需要內核一個SIGCHLD信號給父進程
  • 父進程處理信號,調用deletejob.
  • 調用addjob

顯然deletejob必須在addjob之后,不然添加進去的job永久存在。這就是同步錯誤


這是一個稱為競爭(race)的經典同步錯誤的示例。

  • main中的addjob和處理程序中調用deletejob之間存在競爭。
  • 必須addjob贏得進展,結果才是正確的,否則就是錯誤的。但是addjob不一定能贏,所以有可能錯誤。即為同步錯誤。
  • 因為內核的調度問題,這種錯誤十分難以被發現。難以調試。

Q:如何消除競爭?

A:可以在fork之前,阻塞SIGCHLD信號,在調用addjob后取消阻塞。

  • 注意,子進程繼承了阻塞,我們要小心地接觸子進程中的阻塞。
  • 消除競爭的原則就是,讓該贏得競爭的對象在任何情況下都能贏。

一個暴露你的代碼中競爭的簡便技巧

制造一個fork的包裝函數Fork,通過隨機+休眠,在fork的那一瞬間,讓子進程,父進程都有50%機會先運行

8.6 非本地跳轉

C語言提供一種用戶級異常控制流形式,稱為非本地跳轉(nonlocal jump)

  • 它將控制直接從一個函數轉移到另一個當前正在執行的函數。不需要經過正常的調用-返回序列。

  • 非本地跳轉是通過setjmplongjmp函數來提供。

      #include<setjmp.h>
      
      int setjmp(jmp_buf env);
      int sigsetjmp(sigjmp_buf env,int savesigs);//信號處理程序使用
      //參數savesigs若為非0則代表擱置的信號集合也會一塊保存 
    
    • setjmp函數在env緩沖區保存當前調用環境,以供后面longjmp使用,並返回0

      • 調用環境包括程序計數器棧指針通用目的寄存器
      #include
            
            
            
                     
                    

    void longjmp(jmp_buf env,int retval);
    void siglongjmp(sigjmp_buf env,int retval);//信號處理程序使用

    • longjmp函數從env緩沖區中恢復調用環境,然后觸發一個從最近一次初始化env的setjmp調用的返回。然后setjmp返回,並帶有非零的返回值retval(看清楚從setjmp返回)
      • setjmp返回多次,第一次是0,第二次是retval.
      • longjmp從不返回。
  • 非本地跳轉的重要應用是允許從一個深層嵌套的函數調用立即返回。一般是發現了錯誤

    • 不用費力解開棧。
    • 直接返回到一個普通的本地化的錯誤處理程序。

例子代碼

  • 非本地跳轉另一個重要應用是使一個信號處理程序分支到一個特殊的代碼位置,而不是返回到被信號中斷的指令的位置。

代碼

程序分析

C++和Java 中的軟件異常

C++和Java提供的異常機制是較高層次的,是C語言setjmp和longjmp函數的更加結構化的版本。你可以把try語句中的catch字句看作setjmp函數。相似地,throw語句就類似與longjmp函數。

8.7 操作進程的工具

  • STRACE(痕跡):打印一個正在運行的程序和它的子進程調用的每個系統調用的軌跡。

    • -static編譯,能得到一個更干凈,不帶有大量共享庫相關的輸出的軌跡。
  • PS(Processes Status): 列出當前系統的進程(包括僵死進程)

  • TOP(因為我們關注峰值的幾個程序,所以叫TOP):打印當前進程使用的信息。

  • PMAP(rePort Memory map of A Process): 查看進程的內存映像信息

  • /proc:一個虛擬文件系統,以ASCII文本格式輸出大量內核數據結構。

    • 用戶程序可以讀取這些內容。
    • 比如,輸入"cat /proc/loadavg,觀察Linux系統上當前的平均負載。

8.8 小結

  • 異常控制流(ECF)發生在計算機系統的各個層次,是計算機系統中提供並發的基本機制。

  • 硬件層異常是處理器中的事件出發的控制流中的突變。控制流傳遞給一個異常處理程序,該處理程序進行一些處理,然后返回控制被中斷的控制流。

    • 有四種不同類型的異常:中斷,故障,終止和陷阱。
      • 定時器芯片或磁盤控制器,設置了處理器芯片上的中斷引腳時,中斷異步發生。返回到Inext

      • 一條指令的執行可能導致故障終止同時出現。

        • 故障可能返回調用指令。
        • 終止不將控制返回。
      • 陷阱用於系統調用。結束后,返回Inext

  • 操作系統層,內核用ECF提供進程的基本概念。進程給應用兩個重要抽象:

    • 邏輯控制流
    • 私有地址空間
  • 操作系統和應用程序接口處,有子進程,和信號

  • 最后,C語言的非本地跳轉 完成應用程序層面的異常處理。

至此,異常貫穿了從底層硬件,到抽象的軟件層次。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM