[APUE] 程式控制

sinkinben發表於2021-02-08

? APUE 一書的第八章學習筆記。

程式標識

大家都知道使用 PID 來標識的。

系統中的一些特殊程式:

  • PID = 0: 排程程式,也稱為交換程式 (Swapper)
  • PID = 1: init 程式,自檢結束後由核心呼叫,讀取與系統初始化相關的檔案,如 /etc/init.d/*, /etc/rc*/ . init 程式是一個以 root 啟動的普通程式,而不是像 Swapper 是一個核心程式。init 是所有孤兒程式的父程式。
  • PID = 2: 頁守護程式 (Page Daemon), 為虛擬儲存器的分頁操作提供支援。

關於程式標識的 API :

#include <unistd.h>
pid_t getpid(void);   // Returns: process ID of calling process
pid_t getppid(void);  // Returns: parent process ID of calling process
uid_t getuid(void);   // Returns: real user ID of calling process
uid_t geteuid(void);  // Returns: effective user ID of calling process
gid_t getgid(void);   // Returns: real group ID of calling process
gid_t getegid(void);  // Returns: effective group ID of calling process

fork

#include <unistd.h>
pid_t fork(void); // Returns: 0 in child, process ID of child in parent, −1 on error

fork 的一些特點:

  • 呼叫 1 次,返回 2 次;
  • 為什麼將子程式的 ID 返回給父程式?一個程式可有多個子程式,但沒有函式可以獲得所有子程式的 ID 。
  • 為什麼 fork 返回給子程式的是 0 ?因為子程式的 PID 不可能為 0 ,它的父程式 PID 可以由 getppid() 獲取。

fork 返回後,父子程式都會在 fork 的呼叫點繼續執行。子程式會獲得父程式的資料空間、堆和棧的副本,但應當注意的是子程式擁有的是副本,而不是父子程式一同共享這些資料。父子程式共享的只有程式的 text 段。

由於 fork 之後經常會跟著 exec 函式,所以很多時候並不修改父程式的資料段和堆疊。為了針對這一特點進行優化,實現當中採用了寫時複製 (Copy On Write), 父子程式共享這些區域,但核心會將它們的許可權修改為只讀 (Read-Only). 如果父子程式中的一個試圖修改這些區域,則核心只會為被修改區域的那塊記憶體拷貝一份副本,通常是虛擬儲存器中的“一頁”。

一般來說,fork 之後,父子程式是併發執行的,為此還需要實現程式間的同步操作(例如訊號)。

fork 一般有 2 種常見用法:

  1. 父程式複製自己,父子程式同時執行不同的程式碼段。這種情況常見於網路服務程式:父程式等待客戶端的請求,當請求到達時,父程式呼叫 fork ,使子程式處理該請求,而父程式繼續等待下一請求。
  2. 一個程式需要執行不同的程式。例如 Shell 程式,子程式從 fork 返回之後呼叫 exec 系列函式。在某些系統中,會把 fork, exec 封裝為一種操作 spawn .

例子

#include "apue.h"
int globalvar = 123;
char buf[] = "a write to stdout\n";
int main()
{
    int var = 233;
    pid_t pid;
    if (write(STDOUT_FILENO, buf, sizeof(buf) - 1) != sizeof(buf) - 1) err_sys("write err\n");
    if ((pid = fork()) < 0) err_sys("fork err\n");
    else if (pid == 0) var++, globalvar++;
    else sleep(2);
    printf("pid=%d, globalvar=%d, var=%d\n", pid, globalvar, var);
    return 0;
}

輸出:

$ ./a.out 
a write to stdout
pid=0, globalvar=124, var=234
pid=15438, globalvar=123, var=233

檔案共享

fork 之後,子程式會擁有父程式的檔案描述符表的副本,如下圖所示。

[APUE] 程式控制

所以:

  • 父程式的重定向dup也會被子程式繼承。
  • 父子程式共享某一開啟檔案的偏移量。如果父子程式同時對該檔案進行寫操作(但沒有任何同步機制),那麼就會造成資料的混亂。

vfork

#include <sys/types.h>
#include <unistd.h>
pid_t vfork(void);

vfork 用於建立一個子程式,而該子程式的目的是執行 exec 系列函式。

vfork 並不會把父程式的地址空間完全複製到子程式中,因為考慮到子程式會馬上呼叫 exec (因而不會引用該地址空間的資料),不過在它呼叫 exec, exit 之前,它一直在父程式的地址空間中執行。但如果子程式後續沒有呼叫 exec 或者 exit,是一種未定義行為。

vforkfork 的另外一個重要區別是:vfork 保證子程式先執行,在它呼叫 exec, exit 之後父程式才可能被排程執行(如果這 2 個呼叫依賴於父程式的進一步動作,那麼會產生死鎖)。

例子

int globvar = 6;
int main()
{
    int var = 88;
    pid_t pid;
    printf("before vfork\n");
    if ((pid = vfork()) < 0) err_sys("vfork err");
    else if (pid == 0)
    {
        globvar++, var++;
        _exit(0);
    }
    printf("pid = %u, globvar = %d, var = %d\n", pid, globvar, var);
    return 0;
}

輸出:

before vfork
pid = 3449, globvar = 7, var = 89

結果表明子程式修改了父程式的資料。

wait and waitpid

當一個子程式結束(不論是正常終止還是異常中止),核心會向父程式傳送 SIGCHILD 訊號。因為子程式中止是一個非同步事件(這可以在父程式執行的任何時候發生),因此該訊號也是核心向父程式傳送的非同步訊號。

父程式接收到某一訊號時,採取的措施可以是忽略,也可以是呼叫訊號處理函式。對於 SIGCHILD 預設的措施是忽略。

#include <sys/wait.h>
pid_t wait(int *statloc);
pid_t waitpid(pid_t pid, int *statloc, int options);
// Both return: process ID if OK, 0 (see later), or −1 on error

作用:

  1. 如果所有子程式都在執行中,阻塞呼叫程式(即父程式)。
  2. 如果一個子程式已經結束,正等待父程式獲取它的結束狀態,則父程式取得子程式的中止狀態後立即返回。
  3. 如果沒有任何子程式,則返回 -1(通過 strerror(errno) 獲取的錯誤資訊為 No child processes)。

二者的區別:

  • 在一個子程式結束前,wait 使得父程式阻塞(只要有 1 個子程式結束,父程式就喚醒,返回值是剛剛結束的子程式的 pid );而 waitpid 可以通過引數設定,使得父程式不阻塞。
  • wait 可以選擇等待某一程式 pid
  • waitpid(-1, &status, 0) 等價於 wait(&status) .

下面解析 3 個引數 pid, statloc, options .

statloc 用於獲取子程式的結束狀態,不同的位元位表示不同的含義,可以通過以下巨集定義獲取相關資訊。

[APUE] 程式控制

waitpidpid 的解釋如下:

  • pid == -1: 等待任意一個子程式。
  • pid > 0 : 等待 pid 指定的程式。
  • pid == 0 : 等待 Group ID 等於呼叫程式組 ID 的任意一個子程式
  • pid < -1 : 等待 Group ID 等於 pid 絕對值的任意一個子程式。

options 可以為 0 ,或者以下常量的或運算 | 的結果:

[APUE] 程式控制

例子 1

#include <sys/wait.h>
#include "apue.h"
void pr_exit(int status)
{
    if (WIFEXITED(status))
        printf("normal termination, exit status = %d\n", WEXITSTATUS(status));
    else if (WIFSIGNALED(status))
        printf("abnormal termination, signal number = %d%s\n",
               WTERMSIG(status),
#ifdef WCOREDUMP
               WCOREDUMP(status) ? " (core file generated)" : "");
#else
               "");
#endif
    else if (WIFSTOPPED(status))
        printf("child stopped, signal number = %d\n", WSTOPSIG(status));
}

int main()
{
    pid_t pid;
    int status;
    // case-1: childs exits with 7
    if ((pid = fork()) < 0)   err_sys("fork err\n");
    else if (pid == 0)        exit(7);
    if (wait(&status) != pid) err_sys("wait err\n");
    pr_exit(status);

    // case-2: child aborts
    if ((pid = fork()) < 0)   err_sys("fork err\n");
    else if (pid == 0)        abort();
    if (wait(&status) != pid) err_sys("wait err\n");
    pr_exit(status);

    // case-3: 0 as the divider in child
    if ((pid = fork()) < 0)   err_sys("fork err\n");
    else if (pid == 0)        status /= 0;
    if (wait(&status) != pid) err_sys("wait err\n");
    pr_exit(status);
    return 0;
}

輸出:

normal termination, exit status = 7
abnormal termination, signal number = 6 (core file generated)
abnormal termination, signal number = 8 (core file generated)

例子 2 : 殭屍程式

#include "apue.h"
#include <sys/wait.h>
int main()
{
    pid_t pid;
    if ((pid = fork()) < 0)  err_sys("fork err");
    else if (pid == 0)
    {
        if ((pid = fork()) < 0) err_sys("fork err");
        else if (pid > 0)       exit(0);
        // child-2 continues when its parent exit
        // then child-2's parent will be init (pid=1)
        sleep(2);
        printf("second child, parent pid = %u\n", getppid());
        exit(0);
    }
    if (waitpid(pid, NULL, 0) != pid) err_sys("waitpid err");
    exit(0);
}
// Output: second child, parent pid = 1

waitid

#include <sys/wait.h>
int waitid(idtype_t idtype, id_t id, siginfo_t *infop, int options);
// Returns: 0 if OK, −1 on error

waitidwaitpid 相比,具有更多的靈活性。

waitid 允許等待指定的某一子程式,但它使用 2 個單獨的參數列示要等待的子程式的所屬型別。

idtype 的含義如下:

[APUE] 程式控制

options 是下列常量按位或運算的結果:

[APUE] 程式控制

Race Condition

fork 之後不能保證父程式與子程式哪一個先執行,因此容易發生 Race Condition,解決競爭問題需要同步機制。

顯然 wait 是一種同步操作,保證了父程式在子程式結束後才能執行。

反過來,如果子程式想等待父程式結束,可以通過輪詢 (Polling)的方式:

while (getppid() != 1)
	sleep(1);

子程式每隔 1 秒被喚醒,然後進行條件測試,滿足條件後才能繼續執行。但這種輪詢方式浪費 CPU 的時間片,效率是極其低下的。

因此,多程式之間需要有某種形式的訊號傳送與接收方法,來實現多程式的同步。這些內容將在後面繼續討論。

exec

終於看到本章的重點內容了。

當程式呼叫 exec 函式,該程式的內容就被完全替換為指定的新程式,新程式從它的 main 開始執行。應當注意的是:exec 不會建立新的程式,所以呼叫前後的程式 ID 不會變,exec 只是用磁碟上的某一程式替換了當前的 text 段,資料段,堆和棧。

#include <unistd.h>
int execl(const char *pathname, const char *arg0, ... /* (char *)0 */ );
int execv(const char *pathname, char *const argv[]);
int execle(const char *pathname, const char *arg0, ... /* (char *)0, char *const envp[] */ );
int execve(const char *pathname, char *const argv[], char *const envp[]); 
int execlp(const char *filename, const char *arg0, ... /* (char *)0 */ ); 
int execvp(const char *filename, char *const argv[]);
int fexecve(int fd, char *const argv[], char *const envp[]);
// All seven return: −1 on error, no return on success

先說 pathnamefilename 的區別:

  • pathname 是相對於當前工作目錄的路徑;
  • filename: 如果包含 / 符號,就將其視為路徑;否則在 PATH 環境變數包含的目錄中查詢。

如果 execlp, execvpfilename 指向的不是一個由 Linker 產生的二進位制可執行檔案,那麼會認為 filename 指向的是一個 Shell 指令碼,呼叫 /bin/sh 或者 /bin/bash 執行之。比如:

// Content of file 'echo3': echo $1 $2 $3
execlp("/home/sinkinben/workspace/apue/echo3", "echo3", "sin", "kin", "ben", NULL);
// or
char* argv[] = {"echo3", "sin", "kin", "ben", NULL};
execvp("/home/sinkinben/workspace/apue/echo3", argv);

fexecve 根據呼叫者提供的 fd 來尋找可執行檔案。呼叫者可以使用檔案描述符驗證所需要的的檔案存在,並且無競爭地執行該檔案。否則如果在呼叫 exec 前,pathname, filename 指向的可執行檔案的內容被惡意篡改,容易引發安全漏洞。

第二個區別是引數列表的傳遞方式(函式名字的 l 表示 list, v 表示 vector)。

  • l 表示將呼叫的命令列引數通過一個單獨的引數傳遞(如上面的 execlp ),最後帶一個 NULL
  • v 表示命令列引數需要組合成一個陣列的形式(如上面的 execvp)。

對於 execle, execve 允許通過 char *const envp[] 設定環境表(e表示 envp)。

此外,函式名還有一個 pexeclp, execvp ,其中 p 表示該函式以 filename 作為引數,可以在 PATH 中尋找可執行檔案。

下圖為 7 個 exec 函式的對比。

[APUE] 程式控制

下圖為 7 個 exec 的關係圖。

[APUE] 程式控制

對於 fexecve 而言,它會把 fd 引數轉換為形如 /proc/{pid}/fd/{x} 的路徑(該路徑「指向」某一可執行檔案)。

例子

char *env_init[] = {"USER=unknown", "PATH=/tmp", NULL};
int main()
{
    pid_t pid;
    if ((pid = fork()) < 0) err_sys("fork err");
    else if (pid == 0)
    {
        if (execle("/tmp/echoall", "echoall", "arg1", "arg2", NULL, env_init) < 0)
            err_sys("execle err");
    }
    waitpid(pid, NULL, 0);
    exit(0);
}

其中 echoall 是一個列印 argvenviron 的程式(編譯後放在 /tmp 下):

int main(int argc, char *argv[])
{
    int i;
    extern char **environ;
    for (i = 0; i < argc; i++) printf("argv[%d] = %s\n", i, argv[i]);
    for (i = 0; environ[i] != NULL; i++) puts(environ[i]);
}

執行結果:

argv[0] = echoall
argv[1] = arg1
argv[2] = arg2
USER=unknown
PATH=/tmp

例子

最後來看個例子,如何實現 Shell 中的管道 | 功能。

#include <unistd.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>
int main()
{
    // exec: lcmd | rcmd
    // e.g. cat pipe.c | wc -l

    char *lcmd[] = {"cat", "pipe.c", NULL};
    char *rcmd[] = {"head", "-n", "10", NULL};
    int fd[2];
    pipe(fd);
    pid_t pid;
    if ((pid = fork()) == 0)
    {
        dup2(fd[1], 1);
        close(fd[0]), close(fd[1]);
        execvp(lcmd[0], lcmd);
        // should not be here
        exit(-1);
    }
    else if (pid > 0)
    {
        waitpid(pid, NULL, 0);
        if ((pid = fork()) == 0)
        {
            dup2(fd[0], 0);
            close(fd[0]), close(fd[1]);
            execvp(rcmd[0], rcmd);
            // should not be here
            exit(-1);
        }
        else if (pid > 0)
        {
            close(fd[0]), close(fd[1]);
            waitpid(pid, NULL, 0);
        }
    }
}

相關文章