阻塞I/O的進程模型
fork 函數:
pid_t fork(void)
返回:在子進程中爲0,在父進程中爲子進程ID,若出錯則爲-1
程序調用 fork 一次,卻在父、子進程裏各返回一次。在調用該函數的進程(即爲父進程)中返回的是新派生的進程 ID 號,在子進程中返回的值爲 0。fork 函數實現的時候,實際上會把當前父進程的所有相關值都克隆一份,包括地址空間、打開的文件描述符、程序計數器等,就連執行代碼也會拷貝一份,新派生的進程的表現行爲和父進程近乎一樣,就好像是派生進程調用過 fork 函數一樣。
if(fork() == 0){
do_child_process(); //子進程執行代碼
}else{
do_parent_process(); //父進程執行代碼
}
當一個子進程退出時,系統內核還保留了該進程的若干信息,比如退出狀態。這樣的進程如果不回收,就會變成殭屍進程。在 Linux 下,這樣的“殭屍”進程會被掛到進程號爲 1 的 init 進程上。所以,由父進程派生出來的子進程,也必須由父進程負責回收,否則子進程就會變成殭屍進程。殭屍進程會佔用不必要的內存空間,如果量多到了一定數量級,就會耗盡我們的系統資源。
有兩種方式可以在子進程退出後回收資源,分別是調用 wait 和 waitpid 函數。
pid_t wait(int *statloc);
pid_t waitpid(pid_t pid, int *statloc, int options);
函數 wait 和 waitpid 都可以返回兩個值,一個是函數返回值,表示已終止子進程的進程 ID 號,另一個則是通過 statloc 指針返回子進程終止的實際狀態。這個狀態可能的值爲正常終止、被信號殺死、作業控制停止等。如果沒有已終止的子進程,而是有一個或多個子進程在正常運行,那麼 wait 將阻塞,直到第一個子進程終止。
waitpid 可以認爲是 wait 函數的升級版,它的參數更多,提供的控制權也更多。pid 參數允許我們指定任意想等待終止的進程 ID,值 -1 表示等待第一個終止的子進程。options 參數給了我們更多的控制選項。
處理子進程退出的方式一般是註冊一個信號處理函數,捕捉信號 SIGCHILD 信號,然後再在信號處理函數裏調用 waitpid 函數來完成子進程資源的回收。SIGCHLD 是子進程退出或者中斷時由內核向父進程發出的信號,默認這個信號是忽略的。所以,如果想在子進程退出時能回收它,需要像下面一樣,註冊一個 SIGCHILD 函數。
signal(SIGCHLD, sigchld_handler);
一張圖展示這種進程模型:
服務端程序舉個例子:
#define MAX_LINE 4096
char rot13_char(char c) {
if ((c >= 'a' && c <= 'm') || (c >= 'A' && c <= 'M'))
return c + 13;
else if ((c >= 'n' && c <= 'z') || (c >= 'N' && c <= 'Z'))
return c - 13;
else
return c;
}
void child_run(int fd) {
char outbuf[MAX_LINE + 1];
size_t outbuf_used = 0;
size_t result;
while (1) {
char ch;
result = recv(fd, &ch, 1, 0);
if (result == 0) {
break;
} else if (result == -1) {
printf("read");
break;
}
if (outbuf_used < sizeof(outbuf)) {
outbuf[outbuf_used++] = rot13_char(ch);
}
if (ch == '\n') {
send(fd, outbuf, outbuf_used, 0);
outbuf_used = 0;
continue;
}
}
}
void sigchld_handler(int sig) {
/*WNOHANG 用來告訴內核,即使還有未終止的子進程也不要阻塞在 waitpid 上
因爲 wait 函數在有未終止子進程的情況下,沒有辦法不阻塞*/
/*一個waitpid不足夠阻止殭屍進程,如果n個子進程同時停止,那麼會同時發出n個SIGCHILD信號給父進程,但是信號處理函數執行一次,因爲信號一般是不排隊的,多個SIGCHILD只會發送一次給父進程。所以需要用循環waitpid處理,獲取所有終止子進程狀態。*/
while (waitpid(-1, 0, WNOHANG) > 0);
return;
}
int main()
{
struct servaddr_in serv_addr;
int listener_fd = socket(PF_INET, SOCK_STREAM, 0);
bzero(&serv_addr, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(7878);
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
int on = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
bind(listenfd, (struct sockaddr*) &serv_addr, sizeof(serv_addr));
listen(listenfd, SOMAXCONN);
signal(SIGCHLD, sigchld_handler);
while (1) {
struct sockaddr_storage ss;
socklen_t slen = sizeof(ss);
int fd = accept(listener_fd, (struct sockaddr *) &ss, &slen);
if (fd < 0) {
printf("accept failed\n");
exit(0);
}
if (fork() == 0) {
//子進程不需要關心監聽套接字
close(listener_fd);
child_run(fd);
exit(0);
} else {
//父進程不需要關心連接套接字
close(fd);
}
}
return 0;
}
說說這裏的close函數,從父進程派生出的子進程,同時也會複製一份描述字,就是說,連接套接字和監聽套接字的引用計數都會被加 1,而調用 close 函數則會對引用計數進行減 1 操作,這樣在套接字引用計數到 0 時,纔可以將套接字資源回收。所以,這裏的 close 函數非常重要,缺少了它們,就會引起服務器端資源的泄露。
阻塞I/O的線程模型
在同一個進程下,線程上下文切換的開銷要比進程小得多。怎麼理解線程上下文呢?我們的代碼被 CPU 執行的時候,是需要一些數據支撐的,比如程序計數器告訴 CPU 代碼執行到哪裏了,寄存器裏存了當前計算的一些中間值,內存裏放置了一些當前用到的變量等,從一個計算場景,切換到另外一個計算場景,程序計數器、寄存器等這些值重新載入新場景的值,就是線程的上下文切換。
POSIX 線程是現代 UNIX 系統提供的處理線程的標準接口。POSIX 定義的線程函數大約有 60 多個,這些函數可以幫助我們創建線程、回收線程。
主要線程函數
- 線程創建
int pthread_create(pthread_t *tid, const pthread_attr_t *attr,
void *(*func)(void *), void *arg);
返回:若成功則爲0,若出錯則爲正的Exxx值
在新線程的入口函數內,可以執行 pthread_self 函數返回線程 tid。
- 線程終止
void pthread_exit(void *status)
當調用這個函數之後,父線程會等待其他所有的子線程終止,之後父線程自己終止。當然,如果一個子線程入口函數直接退出了,那麼子線程也就自然終止了。所以,絕大多數的子線程執行體都是一個無限循環。
也可以通過調用 pthread_cancel 來主動終止一個子線程,和 pthread_exit 不同的是,它可以指定某個子線程終止。
int pthread_cancel(pthread_t tid)
- 回收已終止線程的資源
int pthread_join(pthread_t tid, void ** thread_return)
當調用 pthread_join 時,主線程會阻塞,直到對應 tid 的子線程自然終止。和 pthread_cancel 不同的是,它不會強迫子線程終止。
- 線程分離
一個線程的重要屬性是可結合的,或者是分離的。一個可結合的線程是能夠被其他線程殺死和回收資源的;而一個分離的線程不能被其他線程殺死或回收資源。一般來說,默認的屬性是可結合的。
int pthread_detach(pthread_t tid)
在高併發的例子裏,每個連接都由一個線程單獨處理,在這種情況下,服務器程序並不需要對每個子線程進行終止,這樣的話,每個子線程可以在入口函數開始的地方,把自己設置爲分離的,這樣就能在它終止後自動回收相關的線程資源了,就不需要調用 pthread_join 函數了。
每個連接一個線程處理
服務端程序舉例:
extern void loop_echo(int);
void thread_run() {
pthread_detach(pthread_self());
int fd = (int) arg;
loop_echo(fd);
}
int main(int c, char **v) {
struct servaddr_in serv_addr;
int listener_fd = socket(PF_INET, SOCK_STREAM, 0);
bzero(&serv_addr, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(7878);
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
int on = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
bind(listenfd, (struct sockaddr*) &serv_addr, sizeof(serv_addr));
listen(listenfd, SOMAXCONN);
pthread_t tid;
while (1) {
struct sockaddr_storage ss;
socklen_t slen = sizeof(ss);
int fd = accept(listener_fd, (struct sockaddr *) &ss, &slen);
if (fd < 0) {
printf("accept failed\n");
} else {
//通過強制把描述字轉換爲 void * 指針的方式完成傳值,但是這個指針裏存放的並不是一個地址,而是連接描述符的數值。
pthread_create(&tid, NULL, &thread_run, (void *) fd);
}
}
return 0;
}
loop_echo 的程序如下,在接收客戶端的數據之後,再編碼回送出去。
char rot13_char(char c) {
if ((c >= 'a' && c <= 'm') || (c >= 'A' && c <= 'M'))
return c + 13;
else if ((c >= 'n' && c <= 'z') || (c >= 'N' && c <= 'Z'))
return c - 13;
else
return c;
}
void loop_echo(int fd) {
char outbuf[MAX_LINE + 1];
size_t outbuf_used = 0;
ssize_t result;
while (1) {
char ch;
result = recv(fd, &ch, 1, 0);
//斷開連接或者出錯
if (result == 0) {
break;
} else if (result == -1) {
error(1, errno, "read error");
break;
}
if (outbuf_used < sizeof(outbuf)) {
outbuf[outbuf_used++] = rot13_char(ch);
}
if (ch == '\n') {
send(fd, outbuf, outbuf_used, 0);
outbuf_used = 0;
continue;
}
}
}
構建線程池處理多個連接
上面的服務器端程序雖然可以正常工作,不過它有一個缺點,那就是如果併發連接過多,就會引起線程的頻繁創建和銷燬。雖然線程切換的上下文開銷不大,但是線程創建和銷燬的開銷卻是不小的。我們可以使用預創建線程池的方式來進行優化。在服務器端啓動時,可以先按照固定大小預創建出多個線程,當有新連接建立時,往連接字隊列裏放置這個新連接描述字,線程池裏的線程負責從連接字隊列裏取出連接描述字進行處理。
這個程序的關鍵是連接字隊列的設計,因爲這裏既有往這個隊列裏放置描述符的操作,也有從這個隊列裏取出描述符的操作。需要引入兩個重要的概念,一個是鎖 mutex,一個是條件變量 condition。鎖很好理解,加鎖的意思就是其他線程不能進入;條件變量則是在多個線程需要交互的情況下,用來線程間同步的原語。
//定義一個隊列
typedef struct {
int number; //隊列裏的描述字最大個數
int *fd; //這是一個數組指針,隊列本體
int front; //當前隊列的頭位置
int rear; //當前隊列的尾位置
pthread_mutex_t mutex; //鎖
pthread_cond_t cond; //條件變量
} block_queue;
//初始化隊列
void block_queue_init(block_queue *blockQueue, int number) {
blockQueue->number = number;
blockQueue->fd = calloc(number, sizeof(int));
blockQueue->front = blockQueue->rear = 0;
pthread_mutex_init(&blockQueue->mutex, NULL);
pthread_cond_init(&blockQueue->cond, NULL);
}
//往隊列裏放置一個描述字fd
void block_queue_push(block_queue *blockQueue, int fd) {
//一定要先加鎖,因爲有多個線程需要讀寫隊列
pthread_mutex_lock(&blockQueue->mutex);
//將描述字放到隊列尾的位置
blockQueue->fd[blockQueue->rear] = fd;
//如果已經到最後,重置尾的位置
if (++blockQueue->rear == blockQueue->number) {
blockQueue->rear = 0;
}
printf("push fd %d", fd);
//通知其他等待讀的線程,有新的連接字等待處理
pthread_cond_signal(&blockQueue->cond);
//解鎖
pthread_mutex_unlock(&blockQueue->mutex);
}
//從隊列裏讀出描述字進行處理
int block_queue_pop(block_queue *blockQueue) {
//加鎖
pthread_mutex_lock(&blockQueue->mutex);
//判斷隊列裏沒有新的連接字可以處理,就一直條件等待,直到有新的連接字入隊列
//這是爲了確保被pthread_cond_wait喚醒之後的線程,確實可以滿足繼續往下執行的條件。如果沒有while循環的再次確認,可能直接就往下執行了。
while (blockQueue->front == blockQueue->rear)
pthread_cond_wait(&blockQueue->cond, &blockQueue->mutex);
//取出隊列頭的連接字
int fd = blockQueue->fd[blockQueue->front];
//如果已經到最後,重置頭的位置
if (++blockQueue->front == blockQueue->number) {
blockQueue->front = 0;
}
printf("pop fd %d", fd);
//解鎖
pthread_mutex_unlock(&blockQueue->mutex);
//返回連接字
return fd;
}
服務端程序如下:
typedef struct {
pthread_t thread_tid; /* thread ID */
long thread_count; /* # connections handled */
} Thread;
void thread_run(void *arg) {
pthread_t tid = pthread_self();
pthread_detach(tid);
block_queue *blockQueue = (block_queue *) arg;
while (1) {
int fd = block_queue_pop(blockQueue);
printf("get fd in thread, fd==%d, tid == %d", fd, tid);
loop_echo(fd);
}
}
int main(int c, char **v) {
struct servaddr_in serv_addr;
int listener_fd = socket(PF_INET, SOCK_STREAM, 0);
bzero(&serv_addr, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(7878);
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
int on = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
bind(listen_fd, (struct sockaddr*) &serv_addr, sizeof(serv_addr));
listen(listen_fd, SOMAXCONN);
block_queue blockQueue;
block_queue_init(&blockQueue, BLOCK_QUEUE_SIZE);
thread_array = calloc(THREAD_NUMBER, sizeof(Thread));
int i;
for (i = 0; i < THREAD_NUMBER; i++) {
pthread_create(&(thread_array[i].thread_tid), NULL, &thread_run, (void *) &blockQueue);
}
while (1) {
struct sockaddr_storage ss;
socklen_t slen = sizeof(ss);
int fd = accept(listener_fd, (struct sockaddr *) &ss, &slen);
if (fd < 0) {
printf("accept failed\n");
continue;
} else {
block_queue_push(&blockQueue, fd);
}
}
return 0;
}
連接字隊列的實現裏,有一個重要情況沒有考慮,就是隊列滿了。不過和前面的程序相比,線程創建和銷燬的開銷大大降低,但因爲線程池大小固定,又因爲使用了阻塞套接字,肯定會出現有連接得不到及時服務的場景。這個問題的解決還是要回利用多路 I/O 複用加上線程來處理,僅僅使用阻塞 I/O 模型和線程是沒有辦法達到極致的高併發處理能力。
溫故而知新 !