開發(fā)一個Unix服務(wù)器程序時,我們本書做過的進程控制:
1.迭代服務(wù)器(iterative server),它的適用情形極為有限,因為這樣的服務(wù)器在完成對當前客戶的服務(wù)前無法處理已等待服務(wù)的新客戶。
2.并發(fā)服務(wù)器(concurrent server),為每個客戶調(diào)用fork派生一個子進程。傳統(tǒng)上大多Unix服務(wù)器程序?qū)儆谶@種類型。
3.使用select函數(shù)處理多個客戶的單個TCP服務(wù)器進程。
4.并發(fā)服務(wù)器的修改,為每個客戶創(chuàng)建一個線程,取代一個進程。
我們本章探究并發(fā)服務(wù)器的另兩類變體:
1.預(yù)先派生子進程,讓服務(wù)器在啟動階段調(diào)用fork創(chuàng)建一個子進程池,每個客戶請求由當前可用子進程池中的某個閑置子進程處理。
2.預(yù)先創(chuàng)建線程,讓服務(wù)器在啟動階段創(chuàng)建一個線程池,每個客戶由當前可用線程池中的某個閑置線程處理。
我們本章審視預(yù)先派生子進程和預(yù)先創(chuàng)建線程這兩種類型的細節(jié):如果池中進程和線程不夠多怎么辦?如果池中進程和線程過多怎么辦?父進程與子進程之間以及各個線程之間怎樣彼此同步?
客戶程序的編寫比服務(wù)器程序容易些,因為客戶中進程控制要少得多。
本章會介紹9個不同的服務(wù)器程序設(shè)計范式,并用同一個客戶程序訪問這些服務(wù)器以便相互比較。我們的客戶/服務(wù)器交互情形在web應(yīng)用中是典型的,客戶向服務(wù)器發(fā)送一個小請求,然后服務(wù)器響應(yīng)以返回給客戶的數(shù)據(jù)。
我們將針對每個服務(wù)器運行同一客戶程序的多個實例,以測量服務(wù)某個固定數(shù)目的客戶所需的CPU時間,以下是結(jié)果:
上圖中測量的僅僅是用于進程控制所需的CPU時間,迭代服務(wù)器是基準,從其他服務(wù)器的實際CPU時間中減去迭代服務(wù)器的實際CPU時間就得到相應(yīng)服務(wù)器用于進程控制所需的CPU時間,因為迭代服務(wù)器沒有控制進程開銷。本章我們使用進程控制CPU時間來稱謂某個給定系統(tǒng)與基準的CPU時間之差。
以上測時數(shù)據(jù)都是與服務(wù)器運行在同一子網(wǎng)的兩個不同主機上運行客戶程序獲得的,對于每個測試,這兩個客戶都派生5個子進程以建立到服務(wù)器的5個同時存在的連接,因此服務(wù)器在任意時刻最多有10個同時存在的連接。每個客戶跨每個連接請求服務(wù)器返回4000字節(jié)的數(shù)據(jù)。對于涉及預(yù)先派生子進程或預(yù)先創(chuàng)建線程這兩種服務(wù)器的測試,服務(wù)器在啟動階段派生15個子進程或15個線程。
有些服務(wù)器設(shè)計涉及創(chuàng)建一個子進程池或一個線程池,我們需要考慮閑置子進程或線程過多有什么影響,下圖匯總了這些數(shù)據(jù):
下圖匯總了不同服務(wù)器情況下,客戶請求在15個子進程或線程中的分布:
上圖中第一列的含義是子進程或線程的編號。
以下客戶程序用于測試我們的服務(wù)器的各個變體:
#include "unp.h"
#define MAXN 16384 /* max # bytes to request from server */
int main(int argc, char **argv) {
int i, j, fd, nchildren, nloops, nbytes;
pid_t pid;
ssize_t n;
char request[MAXLINE], reply[MAXN];
if (argc != 6) {
// 運行本客戶需要指定服務(wù)器主機名或IP地址、服務(wù)器端口、
// 由客戶fork的子進程數(shù)(以允許客戶并發(fā)地向同一服務(wù)器發(fā)起多個連接)、
// 每個子進程發(fā)送給服務(wù)器的請求數(shù)、每個請求要求服務(wù)器返送的數(shù)據(jù)字節(jié)數(shù)
err_quit("usage: client <hostname or IPaddr> <port> <#children>"
"<#loops/child> <#bytes/request>");
}
nchildren = atoi(argv[3]);
nloops = atoi(argv[4]);
nbytes = atoi(argv[5]);
snprintf(request, sizeof(request), "%d\n", nbytes); /* newline at end */
for (i = 0; i < nchildren; ++i) {
if ((pid = Fork()) == 0) { /* child */
for (j = 0; j < nloops; ++j) {
// 每個子進程每次請求時,都要重新連接服務(wù)器
fd = Tcp_connect(argv[1], argv[2]);
// 向服務(wù)器發(fā)送一行文本,指出需由服務(wù)器返送的字節(jié)數(shù)
Write(fd, request, strlen(request));
// 在這個連接上讀入所請求數(shù)據(jù)量(nbytes字節(jié))的數(shù)據(jù)
if ((n = Readn(fd, reply, nbytes)) != nbytes) {
err_quit("server returned %d bytes", n);
}
// 客戶主動關(guān)閉連接,客戶會進入TIME_WAIT狀態(tài),這是與通常的HTTP連接的差別之一
Close(fd); /* TIME_WAIT on client, not server */
}
printf("child %d done\n", i);
exit(0);
}
/* parent loops around to fork() again */
}
// 父進程等待所有子進程終止
while (wait(NULL) > 0); /* now parent waits for all children */
if (errno != ECHILD) {
err_sys("wait error");
}
exit(0);
}
在測試本章中各個服務(wù)器程序時,用于執(zhí)行本客戶程序的命令如下:
這將建立2500個與服務(wù)器的TCP連接(5個子進程各自發(fā)起500次連接)。在每個連接上,客戶向服務(wù)器發(fā)送5字節(jié)數(shù)據(jù)(4000\n
),服務(wù)器于是向客戶返送4000字節(jié)數(shù)據(jù)。我們在兩個不同主機上針對同一個服務(wù)器執(zhí)行本客戶程序,于是總共有5000個TCP連接,且任意時刻服務(wù)器端最多同時存在10個連接。
迭代TCP服務(wù)器總是在完全處理某個客戶的請求后才轉(zhuǎn)向下一個客戶,這樣的服務(wù)器程序比較少見,一個例子是簡單的時間獲取服務(wù)器程序。
比較各個范式服務(wù)器程序時,迭代服務(wù)器程序很重要,如果我們針對迭代服務(wù)器程序執(zhí)行如下客戶程序:
同樣會有5000個連接,跨每個連接傳送的數(shù)據(jù)量也相同。由于服務(wù)器是迭代的,它沒有執(zhí)行任何進程控制,這就讓我們測量出服務(wù)器處理這些數(shù)目的客戶所需的CPU時間,改時間作為一個基準值,從其他服務(wù)器的實測CPU時間中減去該基準值就能得到它們的進程控制時間。從進程控制角度看迭代服務(wù)器是最快的,因為它不執(zhí)行進程控制。
我們不給出本迭代服務(wù)器程序,它只是對下面介紹的并發(fā)服務(wù)器程序的少許修改。
傳統(tǒng)上并發(fā)服務(wù)器調(diào)用fork派生一個子進程來處理每個客戶,這使得服務(wù)器能同時為多個客戶服務(wù),每個進程一個客戶??蛻魯?shù)目的唯一限制就是操作系統(tǒng)對服務(wù)器父進程所屬用戶ID能同時擁有的子進程數(shù)量的限制。
并發(fā)服務(wù)器的問題在于為每個客戶現(xiàn)場fork一個子進程比較耗費CPU時間,多年前(20世紀80年代后期),一個繁忙的服務(wù)器每天也就處理幾百或幾千個客戶時,這點CPU時間是可以接受的,但隨著web應(yīng)用的爆發(fā)式增長,繁忙的web服務(wù)器每天的TCP連接數(shù)以百萬計,這還是就單個主機而言,更繁忙的站點往往運行多個主機來分攤負荷。常用的負載散布方法是DNS輪詢(DNS round robin)。
以下是我們的并發(fā)服務(wù)器的main函數(shù):
#include "unp.h"
int main(int argc, char **argv) {
int listenfd, connfd;
pid_t childpid;
void sig_chld(int), sig_int(int), web_child(int);
socklen_t clilen, addrlen;
struct sockaddr *cliaddr;
// 調(diào)用tcp_listen使本函數(shù)變得協(xié)議無關(guān)
if (argc == 2) {
listenfd = Tcp_listen(NULL, argv[1], &addrlen);
} else if (argc == 3) {
listenfd = Tcp_listen(argv[1], argv[2], &addrlen);
} else {
err_quit("usage: serv01 [ <host> | <port#> ]");
}
cliaddr = Malloc(addrlen);
// sig_chld信號處理函數(shù)簡單地wait子進程,我們不再給出此函數(shù)
Signal(SIGCHLD, sig_chld);
Signal(SIGINT, sig_int);
for (; ; ) {
clilen = addrlen;
if ((connfd = accept(listenfd, cliaddr, &clilen)) < 0) {
if (errno == EINTR) {
continue; /* back to for() */
} else {
err_sys("accept error");
}
}
if ((childpid = Fork()) == 0) { /* child process */
Close(listenfd); /* close listening socket */
web_child(connfd); /* process request */
exit(0);
}
Close(connfd); /* parent closes connected socket */
}
}
以上函數(shù)為每個客戶連接fork一個子進程并處理來自垂死的子進程的SIGCHLD信號。
以上函數(shù)還捕獲由終端中斷鍵產(chǎn)生的SIGINT信號,在客戶運行完畢后我們鍵入該鍵以顯示服務(wù)器程序運行所需的CPU時間,以下是SIGINT的信號處理函數(shù),它不返回而直接終止進程:
void sig_int(int signo) {
void pr_cpu_time(void);
pr_cpu_time();
exit(0);
}
以下是pr_cpu_time函數(shù):
#include "unp.h"
#include <sys/resource.h>
#ifndef HAVE_GETRUSAGE_PRPTO
int getrusage(int, struct rusage *);
#endif
void pr_cpu_time(void) {
double user, sys;
struct rusage myusage, childusage;
// 獲取調(diào)用進程的資源利用統(tǒng)計
if (getrusage(RUSAGE_SELF, &myusage) < 0) {
err_sys("getrusage error");
}
// 獲取調(diào)用進程的所有已終止子進程的資源利用統(tǒng)計
if (getrusage(RUSAGE_CHILDREN, &childusage) < 0) {
err_sys("getrusage error");
}
// 獲取耗費在執(zhí)行用戶進程上的CPU時間
user = (double)myusage.ru_utime.tv_sec + myusage.ru_utime.tv_usec / 1000000.0;
user += (double)childusage.ru_utime.tv_sec + childusage.ru_utime.tv_usec / 1000000.0;
// 獲取耗費在執(zhí)行系統(tǒng)調(diào)用上的CPU時間
sys = (double)myusage.ru_stime.tv_sec + myusage.ru_stime.tv_usec / 1000000.0;
sys += (double)childusage.ru_stime.tv_sec + childusage.ru_stime.tv_usec / 1000000.0;
printf("\nuser time = %g, sys time = %g\n", user, sys);
}
web_child函數(shù)處理每個客戶請求:
#include "unp.h"
#define MAXN 16384 /* max # bytes client can request */
void web_child(int sockfd) {
int ntowrite;
ssize_t nread;
char line[MAXLINE], result[MAXN];
for (; ; ) {
if ((nread = Readline(sockfd, line, MAXLINE)) == 0) {
return; /* connection closed by other end */
}
/* line from client specifies #bytes to write back */
ntowrite = atol(line);
if ((ntowrite <= 0) || (ntowrite > MAXN)) {
err_quit("client request for %d bytes", ntowrite);
}
Writen(sockfd, result, ntowrite);
}
}
以上函數(shù)中,客戶會發(fā)來一行文本,指出需由服務(wù)器返送多少字節(jié)的數(shù)據(jù)給客戶,這與HTTP有些類似:客戶發(fā)送一個小請求,服務(wù)器響應(yīng)以所期望的信息(如一個HTML文件或一幅GIF圖像)。在HTTP應(yīng)用系統(tǒng)中,服務(wù)器通常在發(fā)送回所請求的數(shù)據(jù)后就關(guān)閉連接,但較新版本允許使用持續(xù)連接(persistent connection),打開連接后會為后續(xù)客戶的額外請求繼續(xù)保持連接打開。以上函數(shù)中,服務(wù)器允許來自客戶的額外請求,但客戶每次建立連接后只發(fā)送一個請求,然后就自己關(guān)閉該連接。
以上這種傳統(tǒng)的并發(fā)服務(wù)器所需CPU時間最多,與它為每個客戶現(xiàn)場fork的做法相吻合。
本章中沒有測量的服務(wù)器程序設(shè)計范式是由inetd激活的服務(wù)器,從進程控制角度看,由inetd激活的處理單個客戶連接的每個服務(wù)器涉及一個fork和一個exec,因此所需CPU時間只會比以上傳統(tǒng)并發(fā)服務(wù)器更多。
我們的第一個增強型服務(wù)器程序使用稱為預(yù)先派生子進程的技術(shù),使用該技術(shù)的服務(wù)器不像傳統(tǒng)意義的并發(fā)服務(wù)器那樣為每個客戶現(xiàn)場派生一個子進程,而是在啟動階段預(yù)先派生一定數(shù)量的子進程,當各個客戶連接到達時,這些子進程立即就能為它們服務(wù)。下圖是服務(wù)器父進程預(yù)先派生出N個子進程且正有2個客戶連接著的情形:
這種技術(shù)的優(yōu)點在于無須父進程調(diào)用fork的開銷就能處理新到的客戶,缺點是父進程必須在服務(wù)器啟動階段猜測需要預(yù)先派生多少子進程。如果某個時刻客戶數(shù)恰好等于子進程總數(shù),那么新到的客戶將被忽略,直到至少有一個子進程重新可用(內(nèi)核會為每個新到的客戶完成三路握手,直到達到相應(yīng)套接字上listen函數(shù)的backlog參數(shù)為止,然后服務(wù)器調(diào)用accept時會把這些已完成的連接傳遞給服務(wù)器進程),這樣客戶就能察覺到服務(wù)器響應(yīng)時間變慢,因為客戶的connect函數(shù)可能立即返回,但它的第一個請求卻在一段時間后才被服務(wù)器處理。
通過增加一些代碼,服務(wù)器能應(yīng)對客戶負載的變動,即父進程要持續(xù)監(jiān)視可用(即閑置)子進程數(shù),一旦該值降到低于某個閾值就派生額外的子進程,同樣,一旦該值超過另一個閾值就終止一些過剩的子進程,因為過多可用的子進程也會降低性能。
先查看預(yù)先派生子進程這類服務(wù)器程序的基本結(jié)構(gòu),以下是預(yù)先派生子進程服務(wù)器的第一個版本的main函數(shù):
#include "unp.h"
static int nchildren;
static pid_t *pids;
int main(int argc, char **argv) {
int listenfd, i;
socklen_t addrlen;
void sig_int(int);
pid_t child_make(int, int, int);
if (argc == 3) {
listenfd = Tcp_listen(NULL, argv[1], &addrlen);
} else if (argc == 4) {
listenfd = Tcp_listen(argv[1], argv[2], &addrlen);
} else {
err_quit("usage: serv02 [ <host> ] <port#> <#children>");
}
// 最后一個參數(shù)是用戶指定的預(yù)先派生的子進程個數(shù)
nchildren = atoi(argv[argc - 1]);
// 分配一個存放各個子進程ID的數(shù)組,用于在父進程即將終止時終止所有子進程
pids = Calloc(nchildren, sizeof(pid_t));
for (i = 0; i < nchildren; ++i) {
pids[i] = child_make(i, listenfd, addrlen); /* parent returns */
}
Signal(SIGINT, sig_int);
for (; ; ) {
pause(); /* everything done by children */
}
}
以下是SIGINT信號處理函數(shù)sig_int:
void sig_int(int signo) {
int i;
void pr_cpu_time(void);
/* terminate all children */
for (i = 0; i < nchildren; ++i) {
kill(pids[i], SIGTERM);
}
while (wait(NULL) > 0); /* wait for all children */
if (errno != ECHILD) {
err_sys("wait error");
}
// getrusage函數(shù)匯報的是已終止子進程的資源利用統(tǒng)計,因此調(diào)用pr_cpu_time前需要終止所有子進程
pr_cpu_time();
exit(0);
}
child_make函數(shù)由main調(diào)用以派生各個子進程:
#include "unp.h"
pid_t child_make(int i, int listenfd, int addrlen) {
pid_t pid;
void child_main(int, int, int);
if ((pid = Fork()) > 0) {
return pid; /* parent */
}
child_main(i, listenfd, addrlen); /* never returns */
}
以下是child_main函數(shù):
void child_main(int i, int listenfd, int addrlen) {
int connfd;
void web_child(int);
socklen_t clilen;
struct sockaddr *cliaddr;
cliaddr = Malloc(addrlen);
printf("child %ld starting\n", (long)getpid());
// 子進程一直循環(huán),直到被父進程終止
for (; ; ) {
clilen = addrlen;
// 每個子進程調(diào)用accept返回一個已連接套接字
connfd = Accept(listenfd, cliaddr, &clilen);
// 有客戶連接到來后,調(diào)用web_child處理客戶請求,最后關(guān)閉連接
web_child(connfd); /* process the request */
Close(connfd);
}
}
下面介紹一下在源自Berkeley的內(nèi)核中,如何實現(xiàn)多個進程在同一監(jiān)聽描述符上調(diào)用accept
父進程在派生子進程前創(chuàng)建監(jiān)聽套接字,每次調(diào)用fork時,所有描述符也被復(fù)制。下圖是proc結(jié)構(gòu)(每個進程一個)、監(jiān)聽描述符的單個file結(jié)構(gòu)、單個socket結(jié)構(gòu)之間的關(guān)系:
上圖中,父進程在fork完所有子進程后還不關(guān)閉listenfd,這是可以直接關(guān)閉的,但打開著是為了以后需要fork額外的子進程而做準備,這是一種對現(xiàn)有代碼的改進。
描述符只是本進程的proc結(jié)構(gòu)中的一個數(shù)組下標,其對應(yīng)數(shù)組元素引用一個file結(jié)構(gòu)。fork函數(shù)執(zhí)行期間會復(fù)制描述符:子進程中一個給定描述符所引用的file結(jié)構(gòu)與父進程中同一個描述符所引用的file結(jié)構(gòu)是同一個。每個file結(jié)構(gòu)都有一個引用計數(shù),當打開一個文件或套接字時,內(nèi)核將為之構(gòu)造一個file結(jié)構(gòu),并由打開操作返回的描述符引用,它的引用計數(shù)初始為1,以后每當調(diào)用fork派生子進程,或調(diào)用dup復(fù)制描述符時,該file結(jié)構(gòu)的引用計數(shù)就遞增1。在我們的N個子進程的例子中,file結(jié)構(gòu)的引用計數(shù)為N+1。
服務(wù)器進程在程序啟動階段派生N個子進程,它們各自調(diào)用accept并因此被內(nèi)核投入睡眠。當?shù)谝粋€客戶連接到達時,所有N個子進程均被喚醒,這是因為所有N個子進程的監(jiān)聽描述符指向同一個socket結(jié)構(gòu),因此它們都在同一個等待通道(wait channel),即在這個socket結(jié)構(gòu)的so_timeo成員上進入睡眠。盡管N個子進程都被喚醒,但只有最先運行的子進程會獲得那個客戶連接,其余N-1個子進程繼續(xù)睡眠,因為它們發(fā)現(xiàn)隊列長度為0(最先運行的子進程取走了隊列中的連接)。
這是有時稱為驚群效應(yīng)(thundering herd)的問題,盡管只有一個子進程將獲得連接,但所有N個子進程都被喚醒了。盡管以上代碼依然正常工作,但每當僅有一個連接準備好被接受時卻喚醒太多進程的做法會導(dǎo)致性能受損。
圖30-1中第二行里BSD/OS服務(wù)值為1.8的CPU時間的測試條件是:預(yù)先派生15個子進程且同時存在最多10個客戶,為了測量驚群問題的影響,我們保持同時存在的最大客戶數(shù)不變,只增加預(yù)先派生的子進程個數(shù)。
某些Unix內(nèi)核有一個往往命名為wakeup_one的函數(shù),它只是喚醒等待某個事件的多個進程中的一個,而非喚醒所有等待該事件的進程,BSD/OS內(nèi)核沒有這樣的函數(shù)。
接著我們查看客戶連接在進程池中的分配,為了采集這些信息,我們把main函數(shù)改為在共享內(nèi)存區(qū)中分配一個長整數(shù)計數(shù)器數(shù)組,每個子進程一個計數(shù)器,main函數(shù)所增加代碼如下:
long *cptr, *meter(int); /* for counting #clients/child */
cptr = meter(nchildren); /* before spawning children */
在分配共享內(nèi)存區(qū)時,如果系統(tǒng)支持(如4.4 BSD),我們就使用匿名內(nèi)存映射,否則使用/dev/zero映射(如SVR 4)。既然該數(shù)組是本進程在尚未派生各個子進程前調(diào)用mmap創(chuàng)建的,它將由本進程(父進程)和后來fork的所有子進程所共享。
meter函數(shù)在共享內(nèi)存區(qū)中分配一個數(shù)組:
#include "unp.h"
#include <sys/mman.h>
/*
* Allocate an array of "nchildren" longs in shared memory that can
* be used as a counter by each child of how many clients it services.
* See pp. 467-470 of "Advanced Programming in the Unix Environment."
*/
long *meter(int nchildren) {
int fd;
long *ptr;
#ifdef MAP_ANON
ptr = Mmap(0, nchildren * sizeof(long), PROT_READ | PROT_WRITE, MAP_ANON | MAP_SHARED, -1, 0);
#else
fd = Open("/dev/zero", O_RDWR, 0);
ptr = Mmap(0, nchildren * sizeof(long), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
Close(fd);
#endif
return ptr;
}
然后我們把child_main函數(shù)改為讓每個子進程在accept函數(shù)返回后遞增各自的計數(shù)器,把SIGINT信號處理函數(shù)改為在所有子進程終止后顯示這個計數(shù)器數(shù)組。
圖30-2給出這個分布,當可用子進程阻塞在accept函數(shù)上時,內(nèi)核調(diào)度算法把各個連接均勻地散布到各個子進程。
TCPv2中提到過select函數(shù)的沖突(collision)現(xiàn)象,當多個進程在引用同一個套接字的描述符上調(diào)用select時會發(fā)生沖突,因為socket結(jié)構(gòu)中為存放本套接字就緒時應(yīng)喚醒哪些進程而分配的僅僅是一個進程id的空間,如果有多個進程在等待同一個套接字,那么內(nèi)核必須喚醒阻塞在select函數(shù)上的所有進程,因為內(nèi)核不知道哪些進程受到剛就緒的套接字的影響。
我們可以迫使本服務(wù)器程序發(fā)生select沖突,方法是在調(diào)用accept前加一個select調(diào)用,等待監(jiān)聽套接字變?yōu)榭勺x,各個子進程將阻塞在select函數(shù)而非accept函數(shù)上,以下是對child_main函數(shù)的改動:
printf("child %ld starting\n", (long)getpid());
+ FD_ZERO(&rset);
for (; ; ) {
+ FD_SET(listenfd, &rset);
+ Select(listenfd + 1, &rset, NULL, NULL, NULL);
+ if (FD_ISSET(listenfd, &rset) == 0) {
+ err_quit("listenfd readable");
+ }
+
clilen = addrlen;
connfd = Accept(listenfd, cliaddr, &clilen);
web_child(connfd); /* process the request */
Close(connfd);
}
修改好后,通過檢查BSD/OS內(nèi)核的nselcoll計數(shù)器在服務(wù)器運行前后的變化,我們發(fā)現(xiàn)某次運行本服務(wù)器出現(xiàn)1814個沖突,下一次運行出現(xiàn)2045個沖突,而兩個客戶為每次運行本服務(wù)器總共產(chǎn)生5000個連接,相當于有約35%~40%的select調(diào)用引起沖突。
比較BSD/OS服務(wù)器的CPU時間,加上select調(diào)用后其值由圖30-1中的1.8增長到2.9,增長的原因一部分可能是新加了系統(tǒng)調(diào)用(由accept函數(shù)改為select+accept函數(shù)),另一部分可能是內(nèi)核為處理select沖突而引入的額外開銷。
以上是4.4 BSD實現(xiàn),它允許多個進程在引用同一個監(jiān)聽套接字的描述符上調(diào)用accept,但這僅適用于在內(nèi)核中實現(xiàn)accept函數(shù)的源自Berkeley的內(nèi)核。作為一個庫函數(shù)實現(xiàn)accept函數(shù)的System V內(nèi)核可能不允許這么做,事實上如果我們在基于SVR 4的Solaris 2.5內(nèi)核上運行以上服務(wù)器程序,那么客戶開始連接到該服務(wù)器后不久,某個子進程的accept函數(shù)就會返回EPROTO錯誤(協(xié)議有錯)。
造成本問題的原因在于SVR 4的流實現(xiàn)機制和庫函數(shù)版本的accept函數(shù)并非一個原子操作,Solaris 2.6修復(fù)了這個問題,但大多其他SVR 4實現(xiàn)仍存在這個問題。
解決辦法是讓應(yīng)用進程在調(diào)用accept前后安置某種形式的鎖,這樣任意時刻只有一個子進程阻塞在accept函數(shù)中,其他子進程則阻塞在試圖獲取用于保護accept函數(shù)的鎖上。
我們使用fcntl函數(shù)的POSIX文件上鎖功能保護accept函數(shù)。
main函數(shù)的唯一改動是在派生子進程的循環(huán)前增加一個對my_lock_init函數(shù)的調(diào)用:
+ my_lock_init("/tmp/lcok.XXXXXX"); /* one lock file for all children */
for (i = 0; i < nchildren; ++i) {
pids[i] = child_make(i, listenfd, addrlen); /* parent returns */
}
child_main函數(shù)的唯一改動是再調(diào)用accept前獲取文件鎖,在accept函數(shù)返回后釋放文件鎖:
for (; ; ) {
clilen = addrlen;
+ my_lock_wait();
connfd = Accept(listenfd, cliaddr, &clilen);
+ my_lock_release();
web_child(connfd); /* process request */
Close(connfd);
}
以下是使用POSIX文件上鎖的my_lock_init函數(shù):
#include "unp.h"
static struct flock lock_it, unlock_it;
static int lock_fd = -1; /* fcntl() will fail if my_lock_init() not called */
// 調(diào)用者將要上鎖的文件的路徑名模板傳給my_lock_init函數(shù)
void my_lock_init(char *pathname) {
char lock_file[1024];
/* must copy caller's string, in case it's a constant */
strncpy(lock_file, pathname, sizeof(lock_file));
// mkstemp函數(shù)的參數(shù)是以6個字節(jié)的“XXXXXX”結(jié)尾的路徑名,函數(shù)會將“XXXXXX“替換為一個串,該串使文件名唯一
// 然后該函數(shù)創(chuàng)建此文件,并打開它,文件的權(quán)限為0600
lock_fd = Mkstemp(lock_file);
// 立即從文件系統(tǒng)目錄中刪除該路徑名,之后如果程序崩潰,這個臨時文件也將消失
// 但還有進程打開著此文件(引用計數(shù)非0),因此文件本身不會被刪除
Unlink(lock_file); /* but lock_fd remains open */
// 初始化用于上鎖的flock結(jié)構(gòu)
// 上鎖范圍為自字節(jié)偏移量0開始(l_whence為SEEK_SET,l_start為0),跨越整個文件(l_len為0)
// 我們不往該文件中寫任何東西,它的長度總為0,內(nèi)核將正常處理該建議性鎖
lock_it.l_type = F_WRLCK;
lock_it.l_whence = SEEK_SET;
lock_it.l_start = 0;
lock_it.l_len = 0;
// 初始化用于解鎖的flock結(jié)構(gòu)
unlock_it.l_type = F_UNLCK;
unlock_it.l_whence = SEEK_SET;
unlock_it.l_start = 0;
unlock_it.l_len = 0;
}
以上函數(shù)中,作者(Stevens先生)聲明兩個flock結(jié)構(gòu)時一開始使用以下方式:
static struct flock lock_it = { F_WRLCK, 0, 0, 0, 0 };
static struct flock unlock_it = { F_UNLCK, 0, 0, 0, 0 };
這樣做有兩個問題,首先,常值SEEK_SET為0并無保證;更重要的是,POSIX不保證flock結(jié)構(gòu)中各成員的順序,在Solaris和Digital Unix上l_type是第一個成員,在BSD/OS上它卻不是。POSIX只是保證該結(jié)構(gòu)中存在POSIX必需的成員,卻不保證它們的先后順序,更何況POSIX還允許flock結(jié)構(gòu)中出現(xiàn)非POSIX的額外成員。因此,除非要把flock結(jié)構(gòu)初始化為全0,否則總應(yīng)該逐個成員賦值,而不應(yīng)該在分配該結(jié)構(gòu)時以初始化列表(initializer)初始化它。
這個規(guī)則的例外是初始化列表由實現(xiàn)具體提供的情形,如初始化Pthread互斥鎖時:
pthread_mutex_t mlock = PTHREAD_MUTEX_INITIALIZER;
其中pthread_mutex_t通常是一個結(jié)構(gòu)類型,而該初始化列表是由實現(xiàn)提供的,來自不同實現(xiàn)的該初始化列表可以不同。
以下是用于上鎖和解鎖的兩個函數(shù),它們使用我們在my_lock_init函數(shù)中初始化過的flock結(jié)構(gòu)調(diào)用fcntl:
void my_lock_wait() {
int rc;
while ((rc = fcntl(lock_fd, F_SETLKW, &lock_it)) < 0) {
if (errno == EINTR) {
continue;
} else {
err_sys("fcntl error for my_lock_wait");
}
}
}
void my_lock_release() {
if (fcntl(lock_fd, F_SETLKW, &unlock_it) < 0) {
err_sys("fcntl error for my_lock_release");
}
}
現(xiàn)在使用文件鎖包圍accept函數(shù)的預(yù)先派生子進程服務(wù)器程序在SVR 4系統(tǒng)上照樣可以工作,因為它保證每次只有一個子進程阻塞在accept調(diào)用中。對比圖30-1中Digital Unix和BSD/OS服務(wù)器的行2和行3,我們看到這種圍繞accept函數(shù)的上鎖增加了服務(wù)器的控制進程CPU時間。
Apache Web服務(wù)器程序的1.1版本在預(yù)先派生子進程后,如果實現(xiàn)允許所有子進程都阻塞在accept調(diào)用中,就不再對accept調(diào)用加鎖,否則就使用以上包圍accept函數(shù)的文件上鎖技術(shù)。
我們可以查看圍繞accept函數(shù)加文件鎖的版本是否存在不加鎖版本的驚群現(xiàn)象,圖30-1A給出了增加子進程數(shù)時的CPU時間,在使用文件上鎖保護accept函數(shù)的Solaris一欄中,我們看到子進程數(shù)超過75時,會引起CPU時間劇增,一個可能的原因是系統(tǒng)因進程過多而耗盡內(nèi)存,導(dǎo)致開始對換。
我們可用上面的meter函數(shù)查看全體客戶連接在可用子進程池上的分布,圖30-2給出結(jié)果顯示所有3個系統(tǒng)都均勻地把文件鎖散布到等待進程中。
有多種方法可用于實現(xiàn)進程之間的上鎖,以上使用的POSIX文件鎖可移植到所有兼容POSIX的系統(tǒng),但它涉及文件系統(tǒng)操作,比較耗時,下面我們使用線程上鎖保護accept耗時,這種方法不僅用于同一進程內(nèi)各線程之間的上鎖,也適用于不同進程之間的上鎖。
為了使用線程上鎖,main、child_make、child_main函數(shù)都保持不變,唯一需要改動的是那3個上鎖函數(shù)。在不同進程之間使用線程上鎖要求:
1.互斥鎖變量必須存放在由所有進程共享的內(nèi)存區(qū)中。
2.告知線程庫這是在不同進程間共享的互斥鎖。
將線程上鎖用于多個進程要求線程庫支持PTHREAD_PROCESS_SHARED屬性。
有多種方法在不同進程間共享內(nèi)存空間,下例使用mmap函數(shù)和/dev/zero設(shè)備,它在Solaris和其他SVR 4內(nèi)核上均可運行。以下是新版my_lock_init函數(shù):
#include "unpthread.h"
#include <sys/mman.h>
static pthread_mutex_t *mptr; /* actual muutex will be in shared memory */
void my_lock_init(char *pathname) {
int fd;
pthread_mutexattr_t mattr;
fd = Open("/dev/zero", O_RDWR, 0);
mptr = Mmap(0, sizeof(pthread_mutex_t), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
// 內(nèi)存映射后,就可以關(guān)閉描述符了
Close(fd);
// 以默認屬性初始化互斥量屬性結(jié)構(gòu)pthread_mutexattr_t
Pthread_mutexattr_init(&mattr);
// 賦予互斥量屬性結(jié)構(gòu)PTHREAD_PROCESS_SHARED屬性,該屬性默認值為PTHREAD_PROCESS_PRIVATE,只能用于單個進程內(nèi)
Pthread_mutex_attr_setpshared(&mattr, PTHREAD_PROCESS_SHARED);
// 以剛設(shè)置的屬性初始化共享內(nèi)存中的互斥鎖
Pthread_mutex_init(mptr, &mattr);
}
以下是線程上鎖版本的my_lock_wait和my_lock_release函數(shù):
void my_lock_wait() {
Pthread_mutex_lock(mptr);
}
void my_lock_release() {
Pthread_mutex_unlock(mptr);
}
比較圖30-1中Solaris服務(wù)器的行3和行4,可見線程互斥鎖上鎖快于文件上鎖。
對預(yù)先派生子進程服務(wù)器程序的另一個修改版本是只讓父進程調(diào)用accept,然后把所接受的已連接套接字傳遞給某個子進程,這么做繞過了為所有子進程的accept調(diào)用上鎖保護的可能需求,但需要從父進程到子進程的某種形式的描述符傳遞。這種技術(shù)會使代碼變得復(fù)雜,因為父進程必須跟蹤子進程的忙閑狀態(tài),以便給空閑子進程傳遞新套接字。
之前的預(yù)先派生子進程的例子中,父進程無需關(guān)心由哪個子進程接收客戶連接,操作系統(tǒng)處理這個細節(jié)(給予某個子進程首先調(diào)用accept的機會,或給予某個子進程所需的文件鎖或互斥鎖)。圖30-2的前5列表明我們測量的這3個系統(tǒng)以公平的輪詢方式將客戶連接分配給子進程。
對于傳遞描述符的預(yù)先派生子進程例子,我們需要為每個子進程維護一個信息結(jié)構(gòu)以便管理,以下是child.h頭文件中定義的Child結(jié)構(gòu):
typedef struct {
pid_t child_pid; /* process ID */
int child_pipefd; /* parent's stream pipe to/from child */
int child_status; /* 0 = ready */
long child_count; /* # connections handled */
} Child;
Child *cptr; /* array of Child structures; calloc'ed */
我們在該結(jié)構(gòu)中存放相應(yīng)子進程的進程ID、父進程中連接到子進程的Unix域字節(jié)流管道描述符、子進程狀態(tài)、該子進程已處理客戶的計數(shù)。我們的SIGINT信號處理函數(shù)將在終止程序前顯示各個子進程已處理的客戶計數(shù),以便觀察全體客戶請求在各個子進程之間的分布。
以下是傳遞描述符的child_make函數(shù),它在fork前先創(chuàng)建一個字節(jié)流管道,它是一對Unix域字節(jié)流套接字,派生出子進程后,父進程關(guān)閉其中一個描述符(sockfd[1]),子進程關(guān)閉另一個描述符(sockfd[0]):
#include "unp.h"
#include "child.h"
pid_t child_make(int i, int listenfd, int addrlen) {
int sockfd[2];
pid_t pid;
void child_main(int, int, int);
Socketpair(AF_LOCAL, SOCK_STREAM, 0, sockfd);
if ((pid = Fork()) > 0) {
Close(sockfd[1]);
cptr[i].child_pid = pid;
cptr[i].child_pipefd = sockfd[0];
cptr[i].child_status = 0;
return pid; /* parent */
}
// 子進程把流管道的自身擁有端復(fù)制到標準錯誤輸出,這樣每個子進程就能通過讀寫標準錯誤與父進程通信
Dup2(sockfd[1], STDERR_FILENO); /* child's stream pipe to parent */
Close(sockfd[0]);
Close(sockfd[1]);
Close(listenfd); /* child does not need this open */
child_main(i, listenfd, addrlen); /* never returns */
}
所有子進程均派生之后的進程關(guān)系:
我們關(guān)閉每個子進程中的監(jiān)聽套接字,因為只有父進程才調(diào)用accept。父進程必須處理監(jiān)聽套接字和所有字節(jié)流套接字,父進程使用select函數(shù)多路選擇它的所有描述符。
以下是傳遞描述符版本的main函數(shù),變動在于,分配描述符集、打開監(jiān)聽套接字和到各個子進程的字節(jié)流管道對應(yīng)的位、計算最大描述符值、分配Child結(jié)構(gòu)數(shù)組的內(nèi)存空間、主循環(huán)由一個select函數(shù)驅(qū)動:
#include "unp.h"
#include "child.h"
static int nchildren;
int main(int argc, char **argv) {
// 計數(shù)器navail用于跟蹤當前可用的子進程數(shù)
int listenfd, i, navail, maxfd, nsel, connfd, rc;
void sig_int(int);
pid_t child_make(int, int, int);
ssize_t n;
fd_set rset, masterset;
socklen_t addrlen, clilen;
struct sockaddr *cliaddr;
if (argc == 3) {
listenfd = Tcp_listen(NULL, argv[1], &addrlen);
} else if (argc == 4) {
listenfd = Tcp_listen(argv[1], argv[2], &addrlen);
} else {
err_quit("usage: serv05 [ <host> ] <port#> <#children>");
}
FD_ZERO(&masterset);
FD_SET(listenfd, &masterset);
maxfd = listenfd;
cliaddr = Malloc(addrlen);
nchildren = atoi(argv[argc - 1]);
navail = nchildren;
cptr = Calloc(nchildren, sizeof(Child));
/* prefork all the children */
for (i = 0; i < nchildren; ++i) {
child_make(i, listenfd, addrlen); /* parent returns */
FD_SET(cptr[i].child_pipefd, &masterset);
maxfd = max(maxfd, cptr[i].child_pipefd);
}
Signal(SIGINT, sig_int);
for (; ; ) {
rset = masterset;
// 如果沒有可用子進程,就從讀描述符集中關(guān)掉監(jiān)聽套接字對應(yīng)的位
// 這樣可防止父進程在無子進程可用的情況下accept新連接
// 內(nèi)核會將這些多余連接排入隊列,直到達到listen函數(shù)的backlog參數(shù)指示的值為止
if (navail <= 0) {
FD_CLR(listenfd, &rset); /* turn off if no available children */
}
nsel = Select(maxfd + 1, &rset, NULL, NULL, NULL);
/* check for new connections */
// 監(jiān)聽套接字變?yōu)榭勺x,一個新連接準備好accept
if (FD_ISSET(listenfd, &rset)) {
clilen = addrlen;
connfd = Accept(listenfd, cliaddr, &clilen);
// 找出第一個可用子進程
// 我們總是從Child結(jié)構(gòu)數(shù)組的第一個元素開始搜索可用子進程
// 這意味著該數(shù)組中靠前的子進程比靠后的子進程更優(yōu)先接收新連接
// 如果不希望偏向于較早的子進程,我們可以記住最近一次接收新連接的子進程在Child結(jié)構(gòu)數(shù)組中的位置
// 下一次搜索就從該位置后面開始,如果到達數(shù)組末端就繞回第一個元素
// 但這么做沒什么優(yōu)勢,如果有多個子進程可用,由哪個子進程處理一個客戶請求無關(guān)緊要
// 除非操作系統(tǒng)進程調(diào)度算法懲罰(降低其優(yōu)先級)總CPU時間較長的進程
// 如果在各個子進程間更均勻地分攤負荷,那么每個子進程會在各自的總CPU時間上更為一致
for (i = 0; i < nchildren; ++i) {
if (cptr[i].child_status == 0) {
break; /* available */
}
}
if (i == nchildren) {
err_quit("no available children");
}
cptr[i].child_status = 1; /* mark child as busy */
++cptr[i].child_count;
--navail;
// 將新的已連接套接字傳遞給找到的空閑子進程
// 我們隨作為輔助數(shù)據(jù)傳遞的描述符寫一個單字節(jié)普通數(shù)據(jù),但接收進程不查看該字節(jié)內(nèi)容
n = Write_fd(cptr[i].child_pipefd, "", 1, connfd);
// 父進程關(guān)閉新的已連接套接字
Close(connfd);
if (--nsel == 0) {
continue; /* all done with select() results */
}
}
/* find any newly-available children */
for (i = 0; i < nchildren; ++i) {
if (FD_ISSET(cptr[i].child_pipefd, &rset)) {
// 如果子進程處理完一個客戶,會通過該子進程的字節(jié)流管道擁有端向父進程寫回單個字節(jié)
// 這使得該字節(jié)流管道的父進程擁有端變?yōu)榭勺x
// 父進程會讀入這個單字節(jié),忽略其值,然后把該子進程標記為可用,并遞增navail計數(shù)器
// 如果子進程意外終止,它的字節(jié)流管道擁有端將被關(guān)閉,read函數(shù)會返回0
// 之后父進程會終止運行,但更好的做法是登記這個錯誤,并重新派生一個子進程取代意外終止的那個子進程
// 本程序使用的Unix域套接字是字節(jié)流套接字,當子進程終止時,父進程會從Unix域套接字中讀到EOF
// 這個Unix域套接字也可換為數(shù)據(jù)報套接字,但這樣就需要通過SIGCHLD信號來判斷子進程是否終止
// 當Unix域套接字兩端進程非父子關(guān)系時,如想知道對端進程是否終止,只能用字節(jié)流套接字
if ((n = Read(cptr[i].child_pipefd, &rc, 1)) == 0) {
err_quit("child %d terminated unexpectedly", i);
}
cptr[i].child_status = 0;
++navail;
if (--nsel == 0) {
break; /* all done with select() results */
}
}
}
}
}
以下是傳遞描述符式預(yù)先派生子進程服務(wù)器程序的child_main函數(shù):
void child_main(int i, int listenfd, int addrlen) {
char c;
int connfd;
ssize_t n;
void web_child(int);
printf("child %ld starting\n", (long)getpid());
for (; ; ) {
if ((n = Read_fd(STDERR_FILENO, &c, 1, &connfd)) == 0) {
err_quit("read_fd returned 0");
}
if (connfd < 0) {
err_quit("no descriptor from read_fd");
}
web_child(connfd); /* process request */
Close(connfd);
// 完成客戶處理后,子進程通過它的字節(jié)流管道擁有端寫出1個字節(jié)到父進程,告知父進程本子進程已可用
Write(STDERR_FILENO, "", 1); /* tell parent we're ready again */
}
}
以上child_main函數(shù)不再調(diào)用accept,而是阻塞在read_fd函數(shù)中,等待父進程傳遞來一個已連接套接字描述符。
比較圖30-1中Solaris服務(wù)器的行4和行5,可見傳遞描述符的預(yù)先派生子進程的版本慢于使用線程上鎖的預(yù)先派生子進程版本;再比較Digital Unix和BSD/OS服務(wù)器的行3和行5,我們能得出類似結(jié)論:父進程通過字節(jié)流管道把描述符傳遞到各個子進程,且各個子進程通過字節(jié)流管道寫回單個字節(jié),無論是與使用共享內(nèi)存區(qū)中的互斥鎖相比,還是與使用文件鎖相比,都更費時。
圖30-2給出了Child結(jié)構(gòu)中child_count計數(shù)器值的分布,它是在終止服務(wù)器時由SIGINT信號處理函數(shù)顯示的,正如以上討論,越早派生(在Child結(jié)構(gòu)數(shù)組中排名越前)的子進程所處理的客戶數(shù)越多。
如果服務(wù)器主機支持線程,我們就可以將以上討論中的子進程換成線程。
以下是第一個創(chuàng)建線程的服務(wù)器版本:
#include "unpthread.h"
int main(int argc, char **argv) {
int listenfd, connfd;
void sig_int(int);
void *doit(void *);
pthread_t tid;
socklen_t clilen, addrlen;
struct sockaddr *cliaddr;
if (argc == 2) {
listenfd = Tcp_listen(NULL, argv[1], &addrlen);
} else if (argc == 3) {
listenfd = Tcp_listen(argv[1], argv[2], &addrlen);
} else {
err_quit("usage: serv06 [ <host> ] <port#>");
}
cliaddr = Malloc(addrlen);
Signal(SIGINT, sig_int);
for (; ; ) {
clilen = addrlen;
// 主線程大多時間阻塞在accept函數(shù)中,每當它返回一個客戶連接,就調(diào)用pthread_create創(chuàng)建一個新線程
connfd = Accept(listenfd, cliaddr, &clilen);
// 新線程執(zhí)行的函數(shù)是doit,參數(shù)是新的已連接套接字
Pthread_create(&tid, NULL, &doit, (void *)connfd);
}
}
void *doit(void *arg) {
void web_child(int);
// 先讓自己脫離,這樣主線程就不用等待它
Pthread_detach(pthread_self());
web_child((int)arg);
Close((int)(arg));
return NULL;
}
圖30-1表明這個簡單的創(chuàng)建線程版本在Solaris和Digital Unix上都快于所有預(yù)先派生子進程的版本。這個為每個客戶現(xiàn)場創(chuàng)建一個線程的版本比為每個客戶現(xiàn)場派生一個子進程版本快很多倍。
我們的web_child函數(shù)調(diào)用readline,而readline函數(shù)是非線程安全的,本例中,readline函數(shù)僅僅用于讀入來自客戶的5字符的帶換行符的數(shù)字,為了簡單起見,我們使用第三章中給出的效率稍低的readline函數(shù)版本。
在支持線程的系統(tǒng)上,我們預(yù)期在服務(wù)器啟動階段預(yù)先創(chuàng)建一個線程池以取代為每個客戶現(xiàn)場創(chuàng)建一個線程的做法會有性能提升。以下服務(wù)器的基本設(shè)計是預(yù)先創(chuàng)建一個線程池,并讓每個線程各自調(diào)用accept,并且用互斥鎖保證任何時刻只有一個線程在調(diào)用accept。這里我們沒有理由使用文件鎖保護各個線程中的accept函數(shù),因為進程共享這個互斥鎖。
以下給出pthread07.h頭文件,其中定義了用于維護關(guān)于每個線程信息的Tthread結(jié)構(gòu):
typedef struct {
pthread_t thread_tid; /* thread ID */
long thread_count; /* # connections handled */
} Thread;
Thread *tptr; /* array of Thread structures; calloc'ed */
int listenfd, nthreads;
socklen_t addrlen;
pthread_mutex_t mlock;
以下是預(yù)先創(chuàng)建線程版本的main函數(shù):
#include "unpthread.h"
#include "pthread07.h"
pthread_mutex_t mlock = PTHREAD_MUTEX_INITIALIZER;
int main(int argc, char **argv) {
int i;
void sig_int(int), thread_make(int);
if (argc == 3) {
listenfd = Tcp_listen(NULL, argv[1], &addrlen);
} else if (argc == 4) {
listenfd = Tcp_listen(argv[1], argv[2], &addrlen);
} else {
err_quit("usage: serv07 [ <host> ] <port #> <#threads>");
}
nthreads = atoi(argv[argc - 1]);
tptr = Calloc(nthreads, sizeof(Thread));
for (i = 0; i < nthreads; ++i) {
thread_make(i); /* only main thread returns */
}
Signal(SIGINT, sig_int);
for (; ; ) {
pause(); /* everything done by threads */
}
}
以下是thread_make和thread_main函數(shù):
#include "unpthread.h"
#include "pthread07.h"
void thread_make(int i) {
void *thread_main(void *);
// 創(chuàng)建線程并使其執(zhí)行thread_main函數(shù),該函數(shù)的唯一參數(shù)是本線程在Thread結(jié)構(gòu)數(shù)組中的下標
Pthread_create(&tptr[i].thread_tid, NULL, &thread_main, (void *)i);
return; /* main thread returns */
}
void *thread_main(void *arg) {
int connfd;
void web_child(int);
socklen_t clilen;
struct sockaddr *cliaddr;
cliaddr = Malloc(addrlen);
printf("thread %d starting\n", (int)arg);
for (; ; ) {
clilen = addrlen;
// 調(diào)用accept前后用互斥量保護
Pthread_mutex_lock(&mlock);
connfd = Accept(listenfd, cliaddr, &clilen);
Pthread_mutex_unlock(&mlock);
++tptr[(int)arg].thread_count;
web_child(connfd); /* process request */
Close(connfd);
}
}
比較圖30-1中Solaris和Digital Unix服務(wù)器的行6和行7,我們看到互斥量保護的預(yù)先創(chuàng)建子進程版本快于為每個客戶現(xiàn)場創(chuàng)建一個線程的版本,且在這兩個主機上,是所有版本中最快的。
圖30-2給出了Thread結(jié)構(gòu)中thread_count計數(shù)器值的分布,它們由SIGINT信號處理函數(shù)在服務(wù)器終止前顯示輸出。這個分布的均勻性是由線程調(diào)度算法帶來的,該算法在選擇由哪個線程接收互斥鎖上表現(xiàn)為按順序輪詢所有線程。
在諸如Digital Unix等源自Berkeley的內(nèi)核上,我們不必為調(diào)用accept上鎖,因此可將使用互斥量保護accept函數(shù)的預(yù)先創(chuàng)建線程版本改為沒有互斥鎖的版本,但這么做會導(dǎo)致圖30-1中行7的3.5秒增長到3.9秒,如果繼續(xù)查看CPU時間的兩個構(gòu)成部分(用戶時間和系統(tǒng)時間),我們發(fā)現(xiàn)沒有上鎖的用戶時間有所減少(因為上鎖是由在用戶空間中執(zhí)行的線程函數(shù)庫完成的),系統(tǒng)時間卻增長較多(因為一個連接到達時所有阻塞在accept函數(shù)中的線程都被喚醒,引發(fā)內(nèi)核的驚群問題)。不管是否加鎖都需要某種形式的互斥來把每個連接分配到線程池中某個線程,因此讓內(nèi)核執(zhí)行分配不如讓線程自行通過線程函數(shù)庫執(zhí)行分配來得快。
最后一個使用線程的服務(wù)器程序設(shè)計范式是在程序啟動階段創(chuàng)建一個線程池后,只讓主線程調(diào)用accept,并把每個客戶連接傳遞給池中某個可用線程。這里有多個實現(xiàn)手段,我們可以使用前面的描述符傳遞,但既然所有線程和所有描述符都在同一進程內(nèi),我們就沒有必要使用描述符傳遞。接收線程只需知道這個已連接套接字描述符的值,而上面的描述符傳遞的實際并非這個值,而是對這個套接字的一個引用,因此可能返回一個不同于原值的描述符(該套接字的引用計數(shù)也會被遞增)。以下是主線程統(tǒng)一accept的預(yù)先創(chuàng)建線程版本的頭文件pthread08.h,其中也定義了Thread結(jié)構(gòu):
typedef struct {
pthread_t thread_tid; /* thread ID */
long thread_count; /* # connections handled */
} Thread;
Thread *tptr;
#define MAXNCLI 32
// clifd數(shù)組中是由主線程存入已accept的已連接套接字描述符,會由線程池中的可用線程從中取出一個以服務(wù)相應(yīng)客戶
// iput是主線程將往該數(shù)組中存入元素的下標,iget是線程池中某個線程將從該數(shù)組中取元素的下標
int clifd[MAXNCLI], iget, iput;
// 以上3個由所有線程共享的數(shù)據(jù)結(jié)構(gòu)使用以下互斥鎖和條件變量來保護
pthread_mutex_t clifd_mutex;
pthread_cond_t clifd_cond;
以下是主線程accept的預(yù)先創(chuàng)建線程版本的main函數(shù):
#include "unpthread.h"
#include "pthread08.h"
static int nthreads;
pthread_mutex_t clifd_mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t clifd_cond = PTHREAD_COND_INITIALIZER;
int main(int argc, char **argv) {
int i, listenfd, connfd;
void sig_int(int), thread_make(int);
socklen_t addrlen, clilen;
struct sockaddr *cliaddr;
if (argc == 3) {
listenfd = Tcp_listen(NULL, argv[1], &addrlen);
} else if (argc == 4) {
listenfd = Tcp_listen(argv[1], argv[2], &addrlen);
} else {
err_quit("usage: serv08 [ <host> ] <port#> <#threads>");
}
cliaddr = Malloc(addrlen);
nthreads = atoi(argv[argc - 1]);
tptr = Calloc(nthreads, sizeof(Thread));
iget = iput = 0;
/* create all the threads */
for (i = 0; i < nthreads; ++i) {
thread_make(i); /* only main thread returns */
}
Signal(SIGINT, sig_int);
for (; ; ) {
clilen = addrlen;
connfd = Accept(listenfd, cliaddr, &clilen);
// 一旦某個客戶連接到達,把新的已連接套接字描述符加入clifd數(shù)組
Pthread_mutex_lock(&clifd_mutex);
clifd[iput] = connfd;
if (++iput == MAXNCLI) {
iput = 0;
}
// 如果iput趕上了iget,說明該數(shù)組不夠大
if (iput == iget) {
err_quit("iput = iget = %d", iput);
}
// 發(fā)送信號到條件變量,然后釋放互斥鎖,以允許線程池中某個線程為這個客戶服務(wù)
Pthread_cond_signal(&clifd_cond);
Pthread_mutex_unlock(&clifd_mutex);
}
}
以下是主線程accept的預(yù)先創(chuàng)建線程版本的thread_make和thread_main函數(shù):
#include "unpthread.h"
#include "pthread08.h"
void thread_make(int i) {
void *thread_main(void *);
Pthread_create(&tptr[i].thread_tid, NULL, &thread_main, (void *)i);
return; /* main thread returns */
}
void *thread_main(void *arg) {
int connfd;
void web_child(int);
printf("thread %d starting\n", (int)arg);
for (; ; ) {
// 線程池中每個線程都試圖獲取保護clifd數(shù)組的互斥鎖,獲得后就測試iput與iget是否相等
// 如果相等,說明無事可做,于是通過調(diào)用pthread_cond_wait睡眠在條件變量上
// 主線程接受一個連接后將調(diào)用pthread_cond_signal向條件變量發(fā)送信號,以喚醒睡眠在其上的線程
// 若測得iput與iget不等則從clifd數(shù)組中取出一個連接,然后調(diào)用web_child
Pthread_mutex_lock(&clifd_mutex);
while (iget == iput) {
Pthread_cond_wait(&clifd_cond, &clifd_mutex);
}
connfd = clifd[iget]; /* connected socket to service */
if (++iget == MAXNCLI) {
iget = 0;
}
Pthread_mutex_unlock(&clifd_mutex);
++tptr[(int)arg].thread_count;
web_child(connfd); /* process request */
Close(connfd);
}
}
圖30-1中的測時數(shù)據(jù)表明這個版本的服務(wù)器慢于互斥鎖保護accept的預(yù)先創(chuàng)建線程的版本,原因在于此版本同時需要互斥鎖和條件變量,而互斥鎖保護accept的預(yù)先創(chuàng)建線程的版本只需要互斥鎖。
檢查線程池中各個線程所服務(wù)客戶數(shù)的分布直方圖,我們發(fā)現(xiàn)它類似圖30-2中的最后一列,這意味著當主線程調(diào)用pthread_cond_signal引起線程函數(shù)庫基于條件變量執(zhí)行喚醒工作時,該函數(shù)庫在所有可用線程中輪詢喚醒其中一個。
經(jīng)過比較各個版本的服務(wù)器程序,我們得出以下總結(jié)性意見:
1.當系統(tǒng)負載較輕時,每來一個客戶連接現(xiàn)場派生一個子進程為之服務(wù)的傳統(tǒng)并發(fā)服務(wù)器程序模型就足夠了,這個模型甚至可以和inetd結(jié)合使用,即inetd處理每個連接的接受。下面的意見是就重負荷運行的服務(wù)器而言的,如web服務(wù)器。
2.相比傳統(tǒng)的每個客戶fork一次設(shè)計范式,預(yù)先創(chuàng)建一個子進程池或一個線程池的設(shè)計范式能把進程控制CPU時間降低10倍以上,編寫這些范式的程序并不復(fù)雜,但超越本章所給例子的是要監(jiān)視子進程個數(shù),隨著所服務(wù)客戶數(shù)的動態(tài)變化而增加或減少。
3.某些實現(xiàn)允許多個子進程或線程阻塞在同一個accept調(diào)用中,另一些實現(xiàn)卻要求包繞accept調(diào)用安置某種類型的鎖加以保護,可用文件鎖或Pthread互斥鎖。
4.讓所有子進程或線程自行調(diào)用accept通常比讓父進程或主線程獨自調(diào)用accept并把描述符傳遞給子進程或線程來得簡單而快速。
5.由于潛在select沖突,讓所有子進程或線程阻塞在同一個accept調(diào)用中比讓它們阻塞在同一個select調(diào)用中更可取。文章來源:http://www.zghlxwxcb.cn/news/detail-700901.html
6.使用線程通常遠快于使用進程,但選擇進程還是線程要取決于操作系統(tǒng)提供什么支持,還可能取決于是否要為客戶執(zhí)行exec。文章來源地址http://www.zghlxwxcb.cn/news/detail-700901.html
到了這里,關(guān)于UNIX網(wǎng)絡(luò)編程卷一 學習筆記 第三十章 客戶/服務(wù)器程序設(shè)計范式的文章就介紹完了。如果您還想了解更多內(nèi)容,請在右上角搜索TOY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!