1. 程式人生 > >Linux C語言程式設計(十五)——程序、執行緒與訊號

Linux C語言程式設計(十五)——程序、執行緒與訊號

1、程序

1.1 基本概念

每個程序在核心中都有一個程序控制塊( PCB)來維護程序相關的資訊, Linux核心的程序控制塊是task_struct結構體。

程序ID:統中每個程序有唯一的id,在C語言中用pid_t型別表示,其實就是一個非負整數。

程序狀態:有執行、掛起、停止、殭屍等狀態。

當前工作目錄

1.2 fork

fork的作用是根據一個現有的程序複製出一個新程序,原來的程序稱為父程序( Parent Process) ,新程序稱為子程序( ChildProcess) 。

個程序在呼叫exec前後也可以分別執行兩個不同的程式,例如在Shell提示符下輸入命令ls,首先fork建立子程序,這時子程序仍在執行/bin/bash程式,然後子程序呼叫exec執行新的程式/bin/ls

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

fork呼叫失敗則返回-1,呼叫成功的返回值見下面的解釋。我們通過一個例子來理解fork是怎樣建立新程序的。

#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
	pid_t pid;
	char *message;
	int n;
	pid = fork();
	if (pid < 0) {
		perror("fork failed");
		exit(1);
	}
	if (pid == 0) {
		message = "This is the child\n";
		n = 6;
	} else {
		message = "This is the parent\n";
		n = 3;
	}
	for(; n > 0; n--) {
		printf(message);
		sleep(1);
	}
	return 0;
}

1.3 exec函式

用fork建立子程序後執行的是和父程序相同的程式(但有可能執行不同的程式碼分支),子程序往往要呼叫一種exec函式以執行另一個程式。當程序呼叫一種exec函式時,該程序的使用者空間程式碼和資料完全被新程式替換,從新程式的啟動例程開始執行。呼叫exec並不建立新程序,所以呼叫exec前後該程序的id並未改變。

#include <unistd.h>
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg, ..., char *const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execve(const char *path, char *const argv[], char *const envp[]);

這些函式如果呼叫成功則載入新的程式從啟動程式碼開始執行,不再返回,如果調用出錯則返回-1,所以exec函式只有出錯的返回值而沒有成功的返回值。

不帶字母p(表示path)的exec函式第一個引數必須是程式的相對路徑或絕對路徑帶有字母l(表示list)的exec函式要求將新程式的每個命令列引數都當作一個引數傳給它,命令列引數的個數是可變的,因此函式原型中有..., ...中的最後一個可變引數應該是NULL,起sentinel的作用。

對於以e(表示environment)結尾的exec函式,可以把一份新的環境變量表傳給它,其他exec函式仍使用當前的環境變量表執行新程式。

#include <unistd.h>
#include <stdlib.h>
int main(void)
{
	execlp("ps", "ps", "-o","pid,ppid,pgrp,session,tpgid,comm", NULL);
	perror("exec ps");
	exit(1);
}

1.4 程序間通訊

每個程序各自有不同的使用者地址空間,任何一個程序的全域性變數在另一個程序中都看不到,所以程序之間要交換資料必須通過核心,在核心中開闢一塊緩衝區,程序1把資料從使用者空間拷到核心緩衝區,程序2再從核心緩衝區把資料讀走,核心提供的這種機制稱為程序間通訊( IPC, InterProcess Communication)

管道是一種最基本的IPC機制,由pipe函式建立:

#include <unistd.h>
int pipe(int filedes[2]);

呼叫pipe函式時在核心中開闢一塊緩衝區(稱為管道)用於通訊,它有一個讀端一個寫端,然後通過filedes引數傳出給使用者程式兩個檔案描述符, filedes[0]指向管道的讀端, filedes[1]指向管道的寫端(很好記,就像0是標準輸入1是標準輸出一樣)。

所以管道在使用者程式看起來就像一個開啟的檔案,通過read(filedes[0]);或者write(filedes[1]);向這個檔案讀寫資料其實是在讀寫核心緩衝區。 pipe函式呼叫成功返回0,呼叫失敗返回-1。

2、執行緒

2.1 執行緒的概念

程序在各自獨立的地址空間中執行,程序之間共享資料需要用mmap或者程序間通訊機制,有些情況需要在一個程序中同時執行多個控制流程,這時候執行緒就派上了用場。比如我們在使用QQ聊天的時候也可以聽音樂,還可以辦公寫文件。

這些任務都需要一個“等待-處理”的迴圈,可以用多執行緒實現,一個執行緒專門負責與使用者互動,另外幾個執行緒每個執行緒負責和一個網路主機通訊。

2.2 執行緒控制

1)建立執行緒

#include <pthread.h>
int pthread_create(pthread_t *restrict thread,
const pthread_attr_t *restrict attr,
void *(*start_routine)(void*), void *restrict arg);

返回值:成功返回0,失敗返回錯誤號。以前學過的系統函式都是成功返回0,失敗返回-1,而錯誤號儲存在全域性變數errno中,而pthread庫的函式都是通過返回值返回錯誤號,雖然每個執行緒也都有一個errno,但這是為了相容其它函式介面而提供的, pthread庫本身並不使用它,通過返回值返回錯誤碼更加清晰。

2)終止執行緒

如果需要只終止某個執行緒而不終止整個程序,可以有三種方法:

從執行緒函式return(對於main執行緒除外)。
一個執行緒可以呼叫pthread_cancel終止同一程序中的另一個執行緒。
執行緒可以呼叫pthread_exit終止自己。

2.3 執行緒間同步

1)互斥鎖mutex

多個執行緒同時訪問共享資料時可能會衝突,對於多執行緒的程式,訪問衝突的問題是很普遍的,解決的辦法是引入互斥鎖。獲得鎖的執行緒可以完成“讀-修改-寫”的操作,然後釋放鎖給其它執行緒,沒有獲得鎖的執行緒只能等待而不能訪問共享資料,這樣“讀-修改-寫”三步操作組成一個原子操作,要麼都執行,要麼都不執行,不會執行到中間被打斷,也不會在其它處理器上並行做這個操作。

互斥鎖Mutex用pthread_mutex_t型別的變量表示,可以這樣初始化和銷燬:

#include <pthread.h>
int pthread_mutex_destroy(pthread_mutex_t *mutex);
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

返回值:成功返回0,失敗返回錯誤號。Mutex的加鎖和解鎖操作可以用下列函式:

#include <pthread.h>
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);

返回值:成功返回0,失敗返回錯誤號。

2)條件變數Condition Variable

        執行緒間的同步還有這樣一種情況:執行緒A需要等某個條件成立才能繼續往下執行,現在這個條件不成立,執行緒A就阻塞等待,而執行緒B在執行過程中使這個條件成立了,就喚醒執行緒A繼續執行。

        在pthread庫中通過條件變數( Condition Variable) 來阻塞等待一個條件,或者喚醒等待這個條件的執行緒。 Condition Variable用pthread_cond_t型別的變量表示,可以這樣初始化和銷燬:

#include <pthread.h>
int pthread_cond_destroy(pthread_cond_t *cond);
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict attr);
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

返回值:成功返回0,失敗返回錯誤號。Condition Variable的操作可以用下列函式:

#include <pthread.h>
int pthread_cond_timedwait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex,const struct timespec *restrict abstime);
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);

返回值:成功返回0,失敗返回錯誤號。一個Condition Variable總是和一個Mutex搭配使用的。一個執行緒可以呼叫pthread_cond_wait在一個Condition Variable上阻塞等待,這個函式做以下三步操作:1.釋放Mutex  2. 阻塞等待  3. 當被喚醒時,重新獲得Mutex並返回

3)訊號量Semaphore

        Mutex變數是非0即1的,可看作一種資源的可用數量,初始化時Mutex是1,表示有一個可用資源,加鎖時獲得該資源,將Mutex減到0,表示不再有可用資源,解鎖時釋放該資源,將Mutex重新加到1,表示又有了一個可用資源。訊號量( Semaphore) 和Mutex類似,表示可用資源的數量,和Mutex不同的是這個數量可以大於1。

POSIX semaphore庫函式:

#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);
int sem_wait(sem_t *sem);
int sem_trywait(sem_t *sem);
int sem_post(sem_t * sem);
int sem_destroy(sem_t * sem);

semaphore變數的型別為sem_t, sem_init()初始化一個semaphore變數, value引數表示可用資源的數量, pshared引數為0表示訊號量用於同一程序的執行緒間同步

2.4 其它執行緒間同步機制

        如果共享資料是隻讀的,那麼各執行緒讀到的資料應該總是一致的,不會出現訪問衝突。只要有一個執行緒可以改寫資料,就必須考慮執行緒間同步的問題。由此引出了讀者寫者鎖( ReaderWriter Lock)的概念, Reader之間並不互斥,可以同時讀共享資料,而Writer是獨佔的( exclusive),在Writer修改資料時其它Reader或Writer不能訪問資料,可見Reader-WriterLock比Mutex具有更好的併發性。

        用掛起等待的方式解決訪問衝突不見得是最好的辦法,因為這樣畢竟會影響系統的併發性,在某些情況下解決訪問衝突的問題可以儘量避免掛起某個執行緒,例如Linux核心的Seqlock、 RCU( read-copy-update)等機制。

3、訊號

3.1 訊號基本概念

1)場景匯入

為了理解訊號,先從我們最熟悉的場景說起:

1. 使用者輸入命令,在Shell下啟動一個前臺程序。
2. 使用者按下Ctrl-C,這個鍵盤輸入產生一個硬體中斷。
3. 如果CPU當前正在執行這個程序的程式碼,則該程序的使用者空間程式碼暫停執行, CPU從使用者態切換到核心態處理硬體中斷。
4. 終端驅動程式將Ctrl-C解釋成一個SIGINT訊號,記在該程序的PCB中(也可以說傳送了一個SIGINT訊號給該程序)。
5. 當某個時刻要從核心返回到該程序的使用者空間程式碼繼續執行之前,首先處理PCB中記錄的訊號,發現有一個SIGINT訊號待處理,而這個訊號的預設處理動作是終止程序,所以直接終止程序而不再返回它的使用者空間程式碼執行。

2)場景解析

        Shell可以同時執行一個前臺程序和任意多個後臺程序,只有前臺程序才能接到像Ctrl-C這種控制鍵產生的訊號。前臺程序在執行過程中使用者隨時可能按下Ctrl-C而產生一個訊號,也就是說該程序的使用者空間程式碼執行到任何地方都有可能收到SIGINT訊號而終止,所以訊號相對於程序的控制流程來說是非同步( Asynchronous) 的。

        每個訊號都有一個編號和一個巨集定義名稱,這些巨集定義可以在signal.h中找到,例如其中有定義#define SIGINT 2。編號34以上的是實時訊號,我們這裡不討論實時訊號。

        訊號一般有這樣的四個引數:Signal(巨集定義名稱) Value(訊號的編號) Action(預設處理動作) Comment(簡要介紹、描述)

        訊號的動作含義常見的:Term表示終止當前程序, Core表示終止當前程序,Ign表示忽略該訊號, Stop表示停止當前程序, Cont表示繼續執行先前停止的程序

3.2 產生訊號

1 使用者在終端按下某些鍵時,比如使用者按下Ctrl-C產生SIGINT訊號, Ctrl-\產生SIGQUIT訊號, Ctrl-Z產生SIGTSTP訊號等

2 硬體異常產生訊號,這些條件由硬體檢測到並通知核心,然後核心向當前程序傳送適當的訊號。

3 一個程序呼叫kill(2)函式可以傳送訊號給另一個程序。

4 當核心檢測到某種軟體條件發生時也可以通過訊號通知程序,例如鬧鐘超時產生SIGALRM訊號,向讀端已關閉的管道寫資料時產生SIGPIPE訊號。

對於產生的訊號,可以選擇性處理,比如讓它執行預設動作或者忽略訊號等操作都是可行的。

3.3 阻塞訊號

1)訊號核心表示

        以上我們討論了訊號產生( Generation)的各種原因,而實際執行訊號的處理動作稱為訊號遞達( Delivery),訊號從產生到遞達之間的狀態,稱為訊號未決( Pending)。程序可以選擇阻塞( Block)某個訊號。

        被阻塞的訊號產生時將保持在未決狀態,直到程序解除對此訊號的阻塞,才執行遞達的動作。注意,阻塞和忽略是不同的,只要訊號被阻塞就不會遞達,而忽略是在遞達之後可選的一種處理動作。

        每個訊號都有兩個標誌位分別表示阻塞和未決,還有一個函式指標表示處理動作。訊號產生時,核心在程序控制塊中設定該訊號的未決標誌,直到訊號遞達才清除該標誌。

        如果在程序解除對某訊號的阻塞之前這種訊號產生過多次,將如何處理? Linux是這樣實現的:常規訊號在遞達之前產生多次只計一次,而實時訊號在遞達之前產生多次可以依次放在一個佇列裡。本章不討論實時訊號。

2)訊號集操作函式

        sigset_t型別對於每種訊號用一個bit表示“有效”或“無效”狀態,至於這個型別內部如何儲存這些bit則依賴於系統實現,從使用者的角度是不必關心的,使用者只能呼叫以下函式來操作sigset_t變數,而不應該對它的內部資料做任何解釋,比如用printf直接列印sigset_t變數是沒有意義的。

#include <signal.h>
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
int sigismember(const sigset_t *set, int signo);

函式sigemptyset初始化set所指向的訊號集,使其中所有訊號的對應bit清零,表示該訊號集不包含任何有效訊號。函式sigfillset初始化set所指向的訊號集,使其中所有訊號的對應bit置位,表示該訊號集的有效訊號包括系統支援的所有訊號。

3)sigprocmask

呼叫函式sigprocmask可以讀取或更改程序的訊號遮蔽字。

#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);

返回值:若成功則為0,若出錯則為-1

如果oset是非空指標,則讀取程序的當前訊號遮蔽字通過oset引數傳出。如果set是非空指標,則更改程序的訊號遮蔽字,引數how指示如何更改。如果oset和set都是非空指標,則先將原來的訊號遮蔽字備份到oset裡,然後根據set和how引數更改訊號遮蔽字。假設當前的訊號遮蔽字為mask,下表說明了how引數的可選值。


4)sigpending

sigpending讀取當前程序的未決訊號集,通過set引數傳出。呼叫成功則返回0,出錯則返回-1。

#include <signal.h>
int sigpending(sigset_t *set);

3.4 捕捉訊號

如果訊號的處理動作是使用者自定義函式,在訊號遞達時就呼叫這個函式,這稱為捕捉訊號。

1)sigaction

#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);

sigaction函式可以讀取和修改與指定訊號相關聯的處理動作。呼叫成功則返回0,出錯則返回-1。

signo是指定訊號的編號。若act指標非空,則根據act修改該訊號的處理動作。若oact指標非空,則通過oact傳出該訊號原來的處理動作。 act和oact指向sigaction結構體:

struct sigaction {
	void (*sa_handler)(int); /* addr of signal handler, or SIG_IGN, or SIG_DFL */
	sigset_t sa_mask; /* additional signals to block*/
	int sa_flags; /* signal options, Figure 10.16 alternate handler */
	void (*sa_sigaction)(int, siginfo_t *, void *);
};

將sa_handler賦值為常數SIG_IGN傳給sigaction表示忽略訊號,賦值為常數SIG_DFL表示執行系統預設動作,賦值為一個函式指標表示用自定義函式捕捉訊號,或者說向核心註冊了一個訊號處理函式,該函式返回值為void,可以帶一個int引數,通過引數可以得知當前訊號的編號,這樣就可以用同一個函式處理多種訊號。

這是一個回撥函式,不被main呼叫,而是被系統所呼叫。

2)pause

        pause函式使呼叫程序掛起直到有訊號遞達。如果訊號的處理動作是終止程序,則程序終止, pause函式沒有機會返回;如果訊號的處理動作是忽略,則程序繼續處於掛起狀態, pause不返回;如果訊號的處理動作是捕捉,則呼叫了訊號處理函式之後pause返回-1, errno設定為EINTR,所以pause只有出錯的返回值

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

下面我們用alarm和pause實現sleep(3)函式,稱為mysleep。

#include <unistd.h>
#include <signal.h>
#include <stdio.h>
void sig_alrm(int signo)
{
	/* nothing to do */
}

unsigned int mysleep(unsigned int nsecs)
{
	struct sigaction newact, oldact;
	unsigned int unslept;
	newact.sa_handler = sig_alrm;
	sigemptyset(&newact.sa_mask);
	newact.sa_flags = 0;
	sigaction(SIGALRM, &newact, &oldact);
	alarm(nsecs);
	pause();
	unslept = alarm(0);
	sigaction(SIGALRM, &oldact, NULL);
	return unslept;
}
int main(void)
{
	while(1){
		mysleep(2);
		printf("Two seconds passed\n");
	}
	return 0;
}
1. main函式呼叫mysleep函式,後者呼叫sigaction註冊了SIGALRM訊號的處理函式sig_alrm。
2. 呼叫alarm(nsecs)設定鬧鐘。
3. 呼叫pause等待,核心切換到別的程序執行。
4. nsecs秒之後,鬧鐘超時,核心發SIGALRM給這個程序。
5. 從核心態返回這個程序的使用者態之前處理未決訊號,發現有SIGALRM訊號,其處理函式是sig_alrm。
6. 切換到使用者態執行sig_alrm函式,進入sig_alrm函式時SIGALRM訊號被自動遮蔽,從sig_alrm函式返回時SIGALRM訊號自動解除遮蔽。然後自動執行系統呼叫sigreturn再次進入核心,再返回使用者態繼續執行程序的主控制流程( main函式呼叫的mysleep函式)。
7. pause函式返回-1,然後呼叫alarm(0)取消鬧鐘,呼叫sigaction恢復SIGALRM訊號以前的處理動作。

3)競態條件

現在重新審視上面的案例“mysleep”,設想這樣的時序:

1. 註冊SIGALRM訊號的處理函式。
2. 呼叫alarm(nsecs)設定鬧鐘。
3. 核心排程優先順序更高的程序取代當前程序執行,並且優先順序更高的程序有很多個,每個都
要執行很長時間
4. nsecs秒鐘之後鬧鐘超時了,核心傳送SIGALRM訊號給這個程序,處於未決狀態。
5. 優先順序更高的程序執行完了,核心要排程回這個程序執行。 SIGALRM訊號遞達,執行處理
函式sig_alrm之後再次進入核心。
6. 返回這個程序的主控制流程, alarm(nsecs)返回,呼叫pause()掛起等待。
7. 可是SIGALRM訊號已經處理完了,還等待什麼呢?

        出現這個問題的根本原因是系統執行的時序( Timing) 並不像我們寫程式時所設想的那樣。雖然alarm(nsecs)緊接著的下一行就是pause(),但是無法保證pause()一定會在呼叫alarm(nsecs)之後的nsecs秒之內被呼叫。由於非同步事件在任何時候都有可能發生(這裡的非同步事件指出現更高優先順序的程序),如果我們寫程式時考慮不周密,就可能由於時序問題而導致錯誤,這叫做競態條件( Race Condition) 。

        要是“解除訊號遮蔽”和“掛起等待訊號”這兩步能合併成一個原子操作就好了,這正是sigsuspend函式的功能。 sigsuspend包含了pause的掛起等待功能,同時解決了競態條件的問題,在對時序要求嚴格的場合下都應該呼叫sigsuspend而不是pause。

#include <signal.h>
int sigsuspend(const sigset_t *sigmask);

        和pause一樣, sigsuspend沒有成功返回值,只有執行了一個訊號處理函式之後sigsuspend才返回,返回值為-1, errno設定為EINTR。

        呼叫sigsuspend時,程序的訊號遮蔽字由sigmask引數指定,可以通過指定sigmask來臨時解除對某個訊號的遮蔽,然後掛起等待,當sigsuspend返回時,程序的訊號遮蔽字恢復為原來的值,如果原來對該訊號是遮蔽的,從sigsuspend返回後仍然是遮蔽的。

unsigned int mysleep(unsigned int nsecs)
{
	struct sigaction newact, oldact;
	sigset_t newmask, oldmask, suspmask;
	unsigned int unslept;


	/* set our handler, save previous information */
	newact.sa_handler = sig_alrm;
	sigemptyset(&newact.sa_mask);
	newact.sa_flags = 0;
	sigaction(SIGALRM, &newact, &oldact);


	/* block SIGALRM and save current signal mask */
	sigemptyset(&newmask);
	sigaddset(&newmask, SIGALRM);
	sigprocmask(SIG_BLOCK, &newmask, &oldmask);
	alarm(nsecs);
	suspmask = oldmask;
	sigdelset(&suspmask, SIGALRM); /* make sure SIGALRM isn't blocked */
	sigsuspend(&suspmask); /* wait for any signal to be caught */


	/* some signal has been caught, SIGALRM is now blocked */
	unslept = alarm(0);
	sigaction(SIGALRM, &oldact, NULL); /* reset previous action */
	/* reset signal mask, which unblocks SIGALRM */
	sigprocmask(SIG_SETMASK, &oldmask, NULL);
	return(unslept);
}

如果在呼叫mysleep函式時SIGALRM訊號沒有遮蔽:
1. 呼叫sigprocmask(SIG_BLOCK, &newmask, &oldmask);時遮蔽SIGALRM。
2. 呼叫sigsuspend(&suspmask);時解除對SIGALRM的遮蔽,然後掛起等待待。
3. SIGALRM遞達後suspend返回,自動恢復原來的遮蔽字,也就是再次遮蔽SIGALRM。
4. 呼叫sigprocmask(SIG_SETMASK, &oldmask, NULL);時再次解除對SIGALRM的遮蔽。