📒 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 種常見用法:
- 父進程複製自己,父子進程同時執行不同的代碼段。這種情況常見於網絡服務進程:父進程等待客戶端的請求,當請求到達時,父進程調用
fork
,使子進程處理該請求,而父進程繼續等待下一請求。 - 一個進程需要執行不同的程序。例如 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
之後,子進程會擁有父進程的文件描述符表的副本,如下圖所示。
所以:
- 父進程的重定向
dup
也會被子進程繼承。 - 父子進程共享某一打開文件的偏移量。如果父子進程同時對該文件進行寫操作(但沒有任何同步機制),那麼就會造成數據的混亂。
vfork
#include <sys/types.h>
#include <unistd.h>
pid_t vfork(void);
vfork
用於創建一個子進程,而該子進程的目的是執行 exec
系列函數。
vfork
並不會把父進程的地址空間完全複製到子進程中,因爲考慮到子進程會馬上調用 exec
(因而不會引用該地址空間的數據),不過在它調用 exec, exit
之前,它一直在父進程的地址空間中運行。但如果子進程後續沒有調用 exec
或者 exit
,是一種未定義行爲。
vfork
和 fork
的另外一個重要區別是: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(通過
strerror(errno)
獲取的錯誤信息爲No child processes
)。
二者的區別:
- 在一個子進程結束前,
wait
使得父進程阻塞(只要有 1 個子進程結束,父進程就喚醒,返回值是剛剛結束的子進程的pid
);而waitpid
可以通過參數設置,使得父進程不阻塞。 wait
可以選擇等待某一進程pid
。waitpid(-1, &status, 0)
等價於wait(&status)
.
下面解析 3 個參數 pid, statloc, options
.
statloc
用於獲取子進程的結束狀態,不同的比特位表示不同的含義,可以通過以下宏定義獲取相關信息。
在 waitpid
中 pid
的解釋如下:
pid == -1
: 等待任意一個子進程。pid > 0
: 等待pid
指定的進程。pid == 0
: 等待 Group ID 等於調用進程組 ID 的任意一個子進程。pid < -1
: 等待 Group ID 等於pid
絕對值的任意一個子進程。
options
可以爲 0 ,或者以下常量的或運算 |
的結果:
例子 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
waitid
與 waitpid
相比,具有更多的靈活性。
waitid
允許等待指定的某一子進程,但它使用 2 個單獨的參數表示要等待的子進程的所屬類型。
idtype
的含義如下:
options
是下列常量按位或運算的結果:
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
先說 pathname
與 filename
的區別:
pathname
是相對於當前工作目錄的路徑;filename
: 如果包含/
符號,就將其視爲路徑;否則在PATH
環境變量包含的目錄中查找。
如果 execlp, execvp
的 filename
指向的不是一個由 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
)。
此外,函數名還有一個 p
的 execlp, execvp
,其中 p
表示該函數以 filename
作爲參數,可以在 PATH
中尋找可執行文件。
下圖爲 7 個 exec
函數的對比。
下圖爲 7 個 exec
的關係圖。
對於 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
是一個打印 argv
和 environ
的程序(編譯後放在 /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);
}
}
}