Unix 環境高級編程(APUE) system函數和sleep函數簡單解析

前言

最近在看《Unix 高級環境編程》的第十章,內容主要是與信號相關的概述和API。在看到章末的時候,有兩個函數system()和sleep()的實現讓我感覺比較困惑,並且在函數的內部實現中也使用了很多前面與信號相關的API,所以我覺得有必要好好實現一下這兩個函數。於是我就照着書上的代碼重新實現了一遍,並在代碼中加入了相關的註釋,算是記錄一下我的個人理解吧。

system()函數

函數功能

system函數接受一個待執行的命令cmd_string作爲參數,在函數中執行這條命令,並返回shell的終止狀態(子進程的終止狀態)。在函數中使用fork()創建一個子進程,並且在子進程中使用execl()執行shell腳本解釋器程序,並將待執行命令cmd_string傳入。接着在shell腳本解釋器中fork()出一個子進程,在子進程中使用execl()執行這條命令。以下是執行system("/bin/ed")的流程圖:
在這裏插入圖片描述

疑惑點及解釋

其實整個函數的執行流程還是挺清晰的,就是其中的一些信號操作有點讓人迷惑:

1. 父進程需要屏蔽(阻塞)SIGCHLD信號。
2. 父進程需要忽略SIGINT信號和SIGQUIT信號。

接下來我就以我目前學到的知識,來解釋以上兩個比較迷惑的地方。

1. 爲什麼父進程需要屏蔽(阻塞)SIGCHLD信號?

首先講到這個,得先了解一下SIGCHLD信號的產生條件是什麼。SIGCHLD的產生條件爲:

子進程終止時,或者子進程接收到SIGSTOP信號停止時,或者子進程處在停止態,接受到SIGCONT後喚醒時。總的來說就是子進程的狀態發生變化時會向父進程發送SIGCHLD信號。所以子進程終止後,會向父進程發送SIGCHLD信號。

而父進程接收到SIGCHLD信號後,會發生什麼呢?默認情況下父進程會執行相應的信號處理函數。在SIGCHLD信號對應的信號處理函數中,它會執行wait()收集這個終止子進程的信息,並把它徹底銷燬後返回。在收集完這個子進程的信息後,內核中關於這個子進程的信息就被抹去了,也就無法再找到有關這個已經被回收子進程的信息。收集的信息保存在status中。

Ok,簡單介紹了一下與SIGCHLD信號相關的內容之後,現在來分析system()函數。假設在父進程中不對SIGCHLD進行阻塞,看看會發生什麼。假設不對SIGCHLD進行阻塞,那麼有可能在父進程還沒有執行到waitpid()這個函數時,子進程就已經執行完畢,並且發送SIGCHLD信號給父進程。這時父進程會執行相應的信號處理函數,也就是執行wait()對子進程進行回收。回收完後在內核中就找不到有關子進程的信息了。接着父進程繼續往下執行,執行waitpid()函數。因爲內核中已經沒有子進程的相關信息了,所以waitpid()無法阻塞等待子進程,出錯,會返回一個小於0的值,將出錯信息保存到status中。最後system()會將保存錯誤信息的status作爲返回值返回。這就明顯有問題了。本來子進程是正常執行,shell解釋器以及命令都是正常執行,但是在system()中卻無法獲得子進程中shell解釋器正常執行返回的正確信息(被SIGCHLD的信號處理函數的wait()截獲了),只能返回錯誤信息。這肯定是不合理的。所以阻塞SIGCHLD信號就是爲了確保在waitpid()處能夠真正獲得子進程的終止狀態以及相應的信息。

那如果不在函數中執行waitpid(),而是依靠信號處理程序中的wait()獲取子進程的信息,這樣看起來好像也可以。但是,缺少了waitpid()的阻塞等待,父進程有可能在子進程執行完畢前就已經結束了,那麼也無法獲取子進程的終止狀態和信息。
waitpid()就是一個主動獲取子進程結束狀態的函數。

2. 爲什麼父進程需要忽略SIGINT信號和SIGQUIT信號?

這裏需要重新回看一下上面那張流程圖:

在這裏插入圖片描述

鍵入中斷字符可將中斷信號SIGINT發送給前臺進程組中的所有進程。當有中斷信號SIGINT發送進來時,a.out(父進程)和ed進程(子進程的子進程)會捕捉到這個信號(shell進程忽略此信號)。在執行命令的過程中,由system執行的命令可能是交互命令,而父進程(也就是system()的調用者)在執行時放棄了控制,等待該執行程序的結束。那麼按正常邏輯來說,中斷信號和退出信號應該只傳送到當前正在交互(或者說正在控制)的子進程,而不應該傳送到所有前臺進程中。這也就是爲什麼父進程需要忽略中斷信號SIGINT和退出信號SIGQUIT

system()函數的返回值

這一部分我就直接引用某位大佬的博客了:

system函數對返回值的處理,涉及3個階段:

階段1:創建子進程等準備工作。如果失敗,返回-1。

階段2:調用/bin/sh拉起shell腳本,如果拉起失敗或者shell未正常執行結束(參見備註1),原因值被寫入到status的低8~15比特位中。system的man中只說明瞭會寫了127這個值,但實測發現還會寫126等值。

階段3:如果shell腳本正常執行結束,將shell返回值填到status的低8~15比特位中。

備註1:只要能夠調用到/bin/sh,並且執行shell過程中沒有被其他信號異常中斷,都算正常結束。比如:不管shell腳本中返回什麼原因值,是0還是非0,都算正常執行結束。即使shell腳本不存在或沒有執行權限,也都算正常執行結束。如果shell腳本執行過程中被強制kill掉等情況則算異常結束。

如何判斷階段2中,shell腳本是否正常執行結束呢?系統提供了宏:WIFEXITED(status)。如果WIFEXITED(status)爲真,則說明正常結束。

如何取得階段3中的shell返回值?你可以直接通過右移8bit來實現,但安全的做法是使用系統提供的宏:WEXITSTATUS(status)。

由於我們一般在shell腳本中會通過返回值判斷本腳本是否正常執行,如果成功返回0,失敗返回正數。

所以綜上,判斷一個system函數調用shell腳本是否正常結束的方法應該是如下3個條件同時成立:

(1)-1 != status

(2)WIFEXITED(status)爲真

(3)0 == WEXITSTATUS(status)

注意:根據以上分析,當shell腳本不存在、沒有執行權限等場景下時,以上前2個條件仍會成立,此時WEXITSTATUS(status)爲127,126等數值。所以,我們在shell腳本中不能將127,126等數值定義爲返回值,否則無法區分中是shell的返回值,還是調用shell腳本異常的原因值。shell腳本中的返回值最好多1開始遞增。
————————————————
版權聲明:本文爲CSDN博主「cheyo車油」的原創文章,遵循 CC 4.0 BY-SA 版權協議,轉載請附上原文出處鏈接及本聲明。
原文鏈接:https://blog.csdn.net/cheyo/article/details/6595955

實現代碼及過程解析

#include <errno.h>
#include <signal.h>
#include <unistd.h>

// 參數爲傳入的、需要執行的命令
int system(const char* cmd_string) {
    pid_t       pid;
    int         status;
    struct sigaction ignore, saveintr, savequit;	// 信號處理動作
    sigset_t    child_mask, save_mask;              // 信號集

    // 如果命令爲空,直接返回
    if (cmd_string == nullptr)
        return(1);

    // 將ignore信號動作中的信號處理函數改成忽略
    ignore.sa_handler = SIG_IGN;
    // 這個函數用於清除函數參數所指向的信號集中的所有信號
    // sa_mask是ignore中的屏蔽字
    sigemptyset(&ignore.sa_mask);
    // 不設置對信號處理的各個選項
    ignore.sa_flags = 0;
    
    
    // 將SIGINT信號的處理動作更改忽略,並且保存原來對應的信號處理動作到saveintr中
    if (sigaction(SIGINT, &ignore, &saveintr) < 0)
        return(-1);
    // 與上一個函數的功能類似,將原來的信號處理動作保存到savequit中
    if (sigaction(SIGQUIT, &ignore, &savequit) < 0)
        return(-1);

    // 清空child_mask信號集
    sigemptyset(child_mask);

    // 向child_mask信號集中添加SIGCHLD信號
    siaddset(&child_mask, SIGCHLD);
    // 爲當前進程設置信號屏蔽字,內容爲當前信號屏蔽字和child_mask中信號屏蔽字的並集,其實就是多加了一個SIGCHLD信號。
    // 這個信號會在子線程執行完畢後返回
    // 原來的信號屏蔽字保存到save_mask中
    if (sigprocmask(SIG_BLOCK, &child_mask, &save_mask) < 0)
        return(-1);

    if ((pid = fork()) < 0) {
        status = -1;
    } else if (pid == 0) {  // 子進程
        // 因爲子進程會繼承父進程中的信號屏蔽字以及信號處理動作等
        // 所以針對父進程做了特殊處理後,在子進程中需要恢復爲初始狀態
        // 將SIGINT信號和SIGQUIT信號的處理動作恢復爲初始動作
        sigaction(SIGINT, &saveintr, NULL);
        sigaction(SIGQUIT, &save_mask, NULL);
        // 將子進程的信號屏蔽字重新設爲原來的初始狀態,也就是不屏蔽SIGCHLD
        sigprocmask(SIG_SETMASK, &save_mask, NULL);

        // 在子線程中執行新的shell解釋器sh,在這個新的shell中會fork()之後使用exec()執行命令cmd_string
        execl("/bin/sh", "sh", "-c", cmd_string, (char*)0);
        // _exit()與exit()的區別爲,_exit()不會執行各種終止處理程序,所以也就不會對標準I/O流進行flush
        // 如果execl()執行成功,則不會返回到這裏,因爲子進程的代碼段已經被覆蓋爲execl()中指定的程序
        // 如果execl()執行失敗,纔會返回到這裏,接着往下執行_exit(127)
        _exit(127);
    } else {
        // 等待子線程執行完畢,對子線程進行回收,並將子進程的終止狀態保存到status中
        // waitpid()正常返回的是收集到的子線程的pid
        // 返回-1則說明調用出錯,錯誤信息保存到errno中
        // EINTR表示因爲被中斷而出錯
        // ECHILD 調用者沒有等待的子進程(wait),也就是沒有可以回收的子進程(原來的子進程已經被其他wait()給回收了)
        // 或是pid指定的進程或進程組不存在(waitpid)或者pid指定的進程組中沒有那個成員是調用者的子進程
        while (waitpid(pid, &status, 0) < 0) {
            if (errno != EINTR) {
                status = -1;
                break;
            }
        }
    }

    // 經過上面的wait()之後,子進程必然執行完畢,將父進程的信號處理動作和信號屏蔽字都恢復爲初始狀態
    if (sigaction(SIGINT, &saveintr, NULL) < 0)
        return(-1);
    if (sigaction(SIGQUIT, &savequit, NULL) < 0)
        return(-1);
    if (sigprocmask(SIG_SETMASK, &save_mask, NULL) < 0)
        return(-1);

    // system()返回子進程終止狀態,也就是shell程序的終止狀態
    return(status);
}

sleep()函數

函數功能及實現方式

使調用進程休眠,函數參數爲休眠的時間。函數內部使用alarm設定鬧鐘,接着調用sigsuspend()使線程阻塞。當鬧鐘到時間後,會發送SIGALRM信號喚醒進程。當然如果有其他信號發送進來,也會喚醒進程,此時函數會返回剩餘的休眠時間。

疑惑點及解釋

1. sigsuspend()的作用,以及爲什麼在執行alarm之前需要阻塞SIGALRM信號?

sigsuspend()在這個sleep()中主要起到阻塞的作用。在調用sigsuspend()時,可以傳入一個信號集參數,這個信號集將會被作爲暫時的信號屏蔽字。當sigsuspend()停止阻塞並返回後,信號屏蔽字會自動重置爲調用sigsuspend()的狀態。至於爲什麼在執行alarm之前需要阻塞SIGALRM,我的理解是:在使用alarm設定鬧鐘之後到sigsuspend()阻塞還有一定的距離,如果用戶設置的時間過短,導致在還未執行到sigsuspend()阻塞時就已經收到SIGALRM信號,那麼真正執行sigsuspend()阻塞時就沒有SIGALRM信號來中斷它,進程一直休眠。在調用sigsuspend()時,通過傳入設置好的信號屏蔽字,停止SIGALRM信號的阻塞。通過這樣操作來提供一個原子操作,確保在alarm設定鬧鐘之後、調用sigsuspend()阻塞之前進程會阻塞SIGALRM信號。

2. alarm(0)的作用?

首先根據alarm的定義,如果參數爲0,則取消之前設定的鬧鐘,之前鬧鐘剩餘的時間會作爲alarm的返回值。根據sleep()的定義,如果在休眠的過程中接收到其他的信號,那麼休眠會提前結束,並且返回剩餘的休眠時間。所以很明顯,這個alarm(0)就是爲了應對其他信號導致休眠提前結束的情況。當接收到其他的信號時,sigsuspend()會返回,接着往下執行。那麼這個時候肯定是要取消之前已經設置的鬧鐘,並且要獲取鬧鐘的剩餘時間,alarm(0)就派上用場了。

實現代碼及過程解析

#include <errno.h>
#include <signal.h>
#include <unistd.h>


// `SIGALRM`信號對應的處理程序
static void sig_alrm(int signo) {
    // 什麼事情都不做,就是準備喚醒sigsuspend()
}

// seconds爲線程休眠的時間長度
// 如果sleep()被其他信號中斷,則會停止休眠,返回剩餘的休眠時間;否則休眠結束後返回0
// 藉助alarm()函數實現
unsigned int sleep(unsigned int seconds) {
    struct sigaction    new_act, old_act;      // 信號處理動作
    sigset_t            new_mask, old_mask, susp_mask;      // 信號集,用於保存需要屏蔽的信號
    unsigned int        unslept;           // 被中斷後保存剩餘的休眠時間

    // 給SIGALRM信號設置對應的信號處理動作,並保存舊的信號處理動作
    new_act.sa_handler = sig_alrm;      // 指定信號處理程序
    sigemptyset(&new_act.sa_mask);      // 清空信號處理動作中的屏蔽字。在調用信號處理動作中的信號處理程序時,sa_mask指定的信號會被阻塞直到處理程序執行結束
    new_act.sa_flags = 0;               // 不設置對信號處理的各個選項
    sigaction(SIGALRM, &new_act, &old_act);     // 將新的信號處理動作與SIGALRM信號綁定

    // 在調用alarm()之前,阻塞SIGALRM信號
    // 阻塞之後可以防止alarm()設置的休眠時間過短,在還未執行到sigsuspend()阻塞時就已經收到SIGALRM信號
    // 從而導致真正執行sigsuspend()阻塞時沒有SIGALRM信號來中斷它
    sigempty(&new_mask);
    sigaddset(&new_mask, SIGALRM);
    sigprocmask(SIG_BLOCK, &new_mask, &old_mask);

    alarm(seconds);

    sigdelset(&susp_mask, SIGALRM); // 在阻塞信號集中刪除SIGALRM。因爲sigsuspend()需要SIGALRM信號來中斷
    sigsuspend(&susp_mask);         // 阻塞,等待信號。捕獲信號並且執行完信號處理程序後會返回,繼續往下執行。信號屏蔽字在執行完後會被重設爲調用前的狀態
                                    // 同時將susp_mask信號集作爲信號屏蔽字,也就是susp_mask信號集中的信號會被阻塞。暫時解除對SIGALRM信號的阻塞
    unslept = alarm(0);     // 如果sigsuspend()被外部信號所打斷(也就是上面設置的alarm()還沒有到時間)
                            // 清除上面alarm()定時,並且返回alarm()剩下的時間
    sigaction(SIGALRM, &old_act, NULL);     // 將SIGALRM的信號處理動作設置爲初始值

    sigprocmask(SIG_SETMASK, &old_mask, NULL);      // 恢復原來的信號屏蔽字,解除對SIGALRM信號的阻塞
    return unslept;     // 返回休眠被打斷後剩餘的休眠時間。如果休眠沒有被打斷,則返回0

}

總結

關於system()sleep()的解析就到這裏,如果大家對這兩個函數還有什麼疑問,可以直接在下面評論,我會在第一時間給出自己的見解。謝謝~~~

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