本章講解的並發服務器是使用fork實施的每客戶單進程模型。
以下是基本TCP客戶/服務器程序的套接字函數(發生的一些典型事件的時間表):
TCP狀態轉換圖:
1、socket函數:
#include <sys/socket.h> int socket(int family, int type, int protocol); // 返回非負描述符 或 -1(出錯)
family:協議族 (AF_INET, AF_INET6, AF_LOCAL, AF_ROUTE, AF_KEY)
type:套接字類型 (SOCK_STREAM, SOCK_DGRAM, SOCK_SEQPACKET, SOCK_RAW)
protocol:協議,可以為0(選擇所給定family和type組合的系統默認值) (IPPROTO_TCP, IPPROTO_UDP, IPPROTO_SCTP)
AF_XXX和PF_XXX分別代表地址族和協議族,然而現在其實是相等的。(address family && protocol family)
2、connect函數:
#include <sys/socket.h> int connect(int sockfd, const struct sockaddr *servaddr, socklen_t addrlen); // 成功返回0,出錯返回-1
sockfd:套接字描述符,socket函數返回的
servaddr:要連接的服務器套接字地址結構,通常需要強制轉換(sockaddr是通用套接字地址結構)
addrlen:servaddr的結構大小,sizeof(servaddr)
客戶端調用函數connect之前不必一定調用bind函數,如果調用的話,內核會確定源IP地址並選擇一個臨時端口作為源端口。
TCP套接字調用connect將激發TCP的三次握手過程,僅在連接建立成功或出錯時才返回。
1) TCP客戶沒有收到SYN分節的響應,返回ETIMEDOUT錯誤。(會多次發送SYN,都無響應的話會返回這個錯誤)
2) 相應是RST(復位),表明該服務器主機在指定的端口上沒有進程在等待與之連接(可能進程沒有在運行),客戶一接收到RST就馬上返回ECONNREFUSED錯誤。
RST產生的條件:端口上沒有正在監聽的服務器;TCP想取消一個已有連接;TCP接收到一個根本不存在的連接上的分節。
3) SYN在中間的某個路由器上引發一個“destination unreachable”(目的地不可達)ICMP錯誤,則認為是一種軟錯誤(soft error)。客戶主機內核保存該消息,並按第一種情況中的時間間隔繼續發送SYN。如果在規定的時間內仍然未收到響應,則把保存的消息(ICMP錯誤)作為EHOSTUNREACH或ENETUNREACH錯誤返回給進程。(導致的原因:按照本地系統的轉發表,根本沒有到達遠程系統的路徑;connect調用根本不等待就返回。)
以下是我做的測試:(ip:192.168.1.100)
第一個例子顯示No route to host,表示是一個因特網不可到達的IP地址
第二個例子顯示Connection timed out(等待很久才返回這個結果),表示可以連接到路由,但是不存在這樣的一個IP地址
第三個例子正常顯示,然後關閉服務器程序
最後一個例子顯示Connection refused,是因為主機存在,但是端口已經沒有被占用,服務器會立刻響應一個RST分節
connect函數導致當前套接字從CLOSED狀態轉移到SYN_SENT狀態,若成功則再轉移到ESTABLISHED狀態。
若connect失敗則該套接字不再可用,必須關閉,故當循環調用函數connect,每當失敗,都必須close當前的套接字描述符並重新調用socket。
3、bind函數:
#include <sys/socket.h> int bind(int sockfd, const struct sockaddr *myaddr, socklen_t addrlen); // 成功返回0,出錯返回-1
第二個參數是指向特定於協議的地址結構指針,這個結構體可以指定IP和端口,也可以不指定。(IP必須屬於其所在主機的網絡接口之一)
IP地址置為通配地址,或端口置為0,都是由內核自動選擇IP和臨時端口。
如果是內核選擇的臨時端口,由於第二個參數是const的,所以要想返回端口值,必須調用getsockname來返回協議地址。
在為多個組織提供Web服務器的主機上,需要捆綁非通配IP地址。
調用bind函數常見錯誤是EADDRINUSE(地址已使用)
4、listen函數:
#include <sys/socket.h> int listen(int sockfd, int backlog); // 成功返回0,出錯返回-1
TCP服務器調用,要做的兩件事情:
1) socket函數創建一個套接字時,默認是一個主動套接字,listen函數把一個未調用connect的未連接的套接字轉換成一個被動套接字,指示內核應接收指向該套接字的連接請求。(主動/客戶 -> 被動/服務器)
2) backlog參數指定套接字排隊的最大連接個數
listen函數導致當前套接字從CLOSED狀態轉移到LISTEN狀態。
本函數在socket和bind之後,在accept之前。
內核為任何一個給定的監聽套接字維護兩個隊列:
1) 未完成連接隊列:處於SYN_RCVD狀態的客戶套接字
2) 已完成連接隊列:處於ESTABLISHED狀態的客戶套接字
這兩個隊列之和不超過backlog(有些不是backlog這個值,而是對應backlog的一個值),服務器每次accept是從已完成隊列隊頭取得一個返回,而完成三次握手的套接字轉移到已完成隊列,如果該隊列為空,進程將被投入睡眠,直到TCP在該隊列中放入一項才喚醒它。
每個系統的backlog參數與已排隊連接的實際數對應關系都不相同,但是不要把它置為0,如果不想讓任何客戶連接上監聽套接字,則關掉它。
為了動態改變backlog參數,一種方法是通過命令行選項或環境變量覆寫默認值。
void Listen(int fd, int backlog) { char *ptr; /*4can override 2nd argument with environment variable */ if ( (ptr = getenv("LISTENQ")) != NULL) backlog = atoi(ptr); if (listen(fd, backlog) < 0) err_sys("listen error"); }
當一個客戶SYN到達時,若隊列已滿,TCP就忽略該分節(不發RST,即不會馬上報錯,而等待重發),期望不久就能在這些隊列中找到可用空間。目的是為了讓客戶區分“該端口沒有服務器在監聽”和“該端口有服務器在監聽,但是隊列滿”
5、accept函數:
#include <sys/socket.h> int accept(int sockfd, struct sockaddr *cliaddr, socklen_t *addrlen); // 成功返回非負描述符,出錯返回-1
第一個參數表示原來(socket創建)的監聽套接字描述符
第二個參數返回已連接的對端進程(客戶)的協議地址
第三個參數是值-結果參數,調用前置為cliaddr所指的套接字地址結構的長度,返回的是內核存放該套接字地址結構的確切字節數
返回值是已連接套接字描述符,注意區分已連接套接字和監聽套接字:監聽套接字在服務器的生命期內一直存在,內核為每個由服務器進程接受的客戶連接創建一個已連接套接字,當服務器完成對某個給定客戶的服務時,相應的已連接套接字就被關閉。
第2、3個參數,如果對返回客戶協議地址沒有興趣,可以將後兩個參數置為空指針。
#include "unp.h" #include <time.h> int main(int argc, char **argv) { int listenfd, connfd; socklen_t len; struct sockaddr_in servaddr, cliaddr; char buff[MAXLINE]; time_t ticks; listenfd = Socket(AF_INET, SOCK_STREAM, 0); bzero(&servaddr, sizeof(servaddr)); servaddr.sin_family = AF_INET; servaddr.sin_addr.s_addr = htonl(INADDR_ANY); servaddr.sin_port = htons(13); /* daytime server */ Bind(listenfd, (SA *) &servaddr, sizeof(servaddr)); Listen(listenfd, LISTENQ); for ( ; ; ) { len = sizeof(cliaddr); connfd = Accept(listenfd, (SA *) &cliaddr, &len); printf("connection from %s, port %d\n", Inet_ntop(AF_INET, &cliaddr.sin_addr, buff, sizeof(buff)), ntohs(cliaddr.sin_port)); ticks = time(NULL); snprintf(buff, sizeof(buff), "%.24s\r\n", ctime(&ticks)); Write(connfd, buff, strlen(buff)); Close(connfd); } }
這個程序是每次有連接的時候,在服務端輸出來自哪個IP和端口的連接。
可以知道,沒有調用bind的客戶端程序會綁定一個臨時端口用於連接。
----- 並發服務器 -----
6、fork函數:
#include <unistd.h> pid_t fork(void); // 返回兩次,子進程中返回0,父進程中為子進程id,出錯返回-1
fork函數是unix中派生新進程的唯一方法。
調用這個函數,會派生一個新進程,於是在父進程中返回了子進程的id,在子進程中返回0。(成功的情況下)
所以,返回值可以判斷目前是在子進程還是父進程中。
父進程在調用fork之前打開的所有描述符在fork返回之後由子進程分享,於是通常情況下,父進程調用accept之後調用fork,子進程接著讀寫這個已連接套接字,父進程則關閉這個已連接套接字,達到並發。
fork的典型用法:
1) 一個進程創建一個自身的副本,這樣每個副本都可以在另一個副本執行其他任務的同時處理各自的某個操作,這就是網絡服務器的典型用法。
2) 一個進程想要執行另一個程序,通常是父進程創建一個副本(子進程),子進程調用exec把自身替換成新的程序,如shell之類的程序的典型用法。
7、exec函數:
#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[]); // 成功不返回,出錯返回-1
這幾組函數區別在於:
1) 待執行的程序文件是由文件名(filename)還是由路徑名(pathname)指定。
2) 新程序的參數是一一列出還是由一個指針數組來引用
3) 把調用進程的環境傳遞給新程序還是給新程序指定新的環境
這些函數在失敗的時候才返回-1給調用者,否則不返回,控制將被傳遞給新程序的起始點。
execve是內核中的系統調用,其他5個都是調用execve的庫函數。
上面行的3個函數把新程序的每個參數字符串指定成exec的一個獨立參數,並以一個空指針結束可變數量的參數。下面行的3個函數則有一個作為exec參數的argv數組,其中含有指向新程序各個參數字符串的所有指針(argv數組必須含有一個用於指定其末尾的空指針)。
左列2個函數,指定了一個filename參數,exec將使用當前的PATH環境變量把該文件名參數轉換為一個路徑名,但filename參數中如果含有斜槓(/),就不再使用環境變量 (我的理解這個是相對路徑(?)),而右兩列4個函數指定一個全限定的pathname參數(所以這個是絕對路徑(?) )。
左兩列4個函數不顯式指定一個環境指針,它們使用外部變量environ的當前值來構造一個傳遞給新程序的環境列表。右列2個函數顯式指定,envp指針數組必須以一個空指針結束。
進程在調用exec之前打開的描述符通常跨exec繼續保持打開,但是這個默認行為可以使用fcntl設置FD_CLOEXEC描述符標志禁止掉。(後面才有講的)
----- 分割線 -----
並發服務器:
Unix中編寫並發服務器程序最簡單的辦法就是fork一個子進程來服務每個客戶。
// 並發服務器輪廓
pid_t pid; int listenfd,connfd; listenfd = socket( ... ); bind(listen, ... ); listen(listenfd,LISTENQ); for(;;) { connfd = accept(listenfd, ... ); if((pid = fork()) == 0) {
// 子進程 關閉 監聽套接字,並 處理 事務 close(listenfd); doit(connfd); close(connfd); exit(0); }
// 父進程 關閉 已連接套接字,繼續accept close(connfd); }
在父進程close(connfd)的時候,子進程可能仍然在doit(connfd),此時TCP套接字不會發送FIN並終止與客戶連接,原因是:
每個文件或套接字都有一個引用計數(ls -al時候回顯的連接數),它是當前打開著的引用該文件或套接字的描述符的個數。
所以,父進程關閉connfd時,只是把相應的引用計數值從2減為1,該套接字真正的清理和資源釋放要等到其引用計數值到達0時才發生。(子進程也關閉connfd的時候)
8、close函數:
#include <unistd.h> int close(int sockfd); // 成功返回0,出錯返回-1
close函數也用來關閉套接字並終止TCP連接,close一個TCP套接字的默認行為是把該套接字標記成已關閉,然後立即返回到調用進程。該套接字描述符不能再由調用進程使用,不能再作為read或write的第一個參數。調用之後,TCP將嘗試發送已排隊等待發送到對端的任何數據,發送完畢後發生的是正常的TCP終止序列。後面章節的SO_LINGER選項可以改變默認行為(可以確信對端進程已收到所有未處理數據)
引用計數大於0的時候不會引發TCP的四次揮手,但是如果確實想讓TCP發送一個FIN,可以使用shutdown函數代替close。
很重要的一點:如果父進程對每個由accept返回的已連接套接字都不調用close,那麼父進程最終將耗盡可用描述符,並且沒有一個客戶連接會被終止(子進程退出之後,引用計數減為1,因為父進程永不關閉已連接套接字,所以不會發送FIN)
9、getsockname和getpeername函數:
#include <sys/socket.h> int getsockname(int sockfd, const struct sockaddr *localaddr, socklen_t addrlen); int getpeername(int sockfd, const struct sockaddr *peeraddr, socklen_t addrlen); // 成功返回0,出錯返回-1
注意:調用這兩個函數返回的是IP地址和端口的組合,並不是域名。
這兩個函數的用處:
1) 沒有bind的TCP客戶上,connect成功返回後,getsockname用於返回由內核賦予該連接的本地IP地址和本地端口號
2) 在以端口號0調用bind後,getsockname用於返回由內核賦予的本地端口號
3) getsockname可用於獲取某個套接字的地址族,如下面的代碼所示
int sockfd_to_family(int sockfd) { struct sockaddr_storage ss; socklen_t len; len = sizeof(ss); if (getsockname(sockfd, (SA *) &ss, &len) < 0) return(-1); return(ss.ss_family); }
4) 在通配IP地址調用bind的TCP服務器,一旦建立連接(accept成功返回),getsockname就可以用於返回由內核賦予該連接的本地IP地址。(sockfd必須賦已連接套接字描述符)
5) 當一個服務器是由調用過accept的某個進程通過調用exec執行程序時,它能夠獲取客戶身份的唯一途徑便是調用getpeername。(無論是父進程還是子進程,都可以使用accept返回的地址結構,但是調用exec之後,子進程的內存映像被替換成新的文件,此時,只有套接字描述符仍然開放 -> (可以使用,但是需要當做參數或者別的方法傳遞過去),所以只有getpeername可以使用)
exec新程序如何獲取已連接套接字描述符:1) 調用exec的進程可以把這個描述符號格式化成一個字符串,再把它作為一個命令行參數傳遞給新程序。 2) 約定在調用exec之前,總是把某個特定描述符置為所接受的已連接套接字描述符(?) -> inetd采用的方法
總結:
客戶端:socket -> connect -> close
服務器:socket -> bind -> listen -> accept -> close
大多數TCP服務器是並發的,大多數UDP服務器是迭代的。