本章講解的并發伺服器是使用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伺服器是疊代的。
轉載于:https://www.cnblogs.com/tyrus/p/unp_ch4.html