主要介紹一個完整的TCP客戶/服務器程序需要的基本套接字函數。
在整個TCP客戶/服務程序中,用到的函數就那麼幾個,其整體框圖如下:
<喎?http://www.2cto.com/kf/ware/vc/" target="_blank" class="keylink">vcD4NCjxociAvPg0KPGgyIGlkPQ=="2socket函數">2.socket函數
為了執行網絡I/O,一個進程必須要做的事情就是調用socket函數。其函數聲明如下:
#include
int socket(int family ,int type, int protocol);
其中:
family:指定協議族
type:指定套接字類型
protocol:指定某個協議,設為0,以選擇所給定family和type組合的系統默認值。
這些參數有一些特定的常值定義如下:
socket函數調用成功的時候將返回一個小的非負整數值,成為套接字描述符,簡稱sockfd。為了得到這個描述符,我們制定了協議族和套接字類型,並未指定本地與遠程協議地址。
另外,書中還提到一個AF_XXX(表示地址族)和PF_XXX(表示協議族)的區別,一般情況下都使用AF,知道這個就可以了。
函數聲明如下:
#include
int connect(int sockfd, const struct sockaddr *servaddr,socklen_t addrlen);
sockfd:由socket返回的套接字描述符。
servaddr:套接字地址結構,包含了服務器IP和端口號。
addrlen:套接字地址結構大小,防止讀越界。
客戶端調用connect時,將向服務器主動發起三路握手連接,直到連接建立和連接出錯時才會返回,這裡出錯返回的可能有一下幾種情況:
1)TCP客戶沒有收到SYN分節的響應。(內核發一個SYN若無響應則等待6s再發一個,若仍無響應則等待24s後再發送一個。總共等待75s仍未收到則返回錯誤ETIMEDOUT)
2)若對客戶的SYN的響應是RST,表明服務器主機在我們指定的端口上沒有進程在等待與之連接,客戶端收到RST就會返回ECONNREFUSED錯誤。
產生RST的三個條件是:目的地SYN到達卻沒有監聽的服務器;TCP想取消一個已有連接;TCP接收到一個根本不存在連接上的分節。
3)若客戶發出的SYN在中間的某個路由器上引發了一個“destination unreachable”(目的地不可達)ICMP錯誤,則認為是一種軟錯誤,在某個規定時間(比如上述75s)沒有收到回應,內核則會把保存的信息作為EHOSTUNREACH或ENETUNREACH錯誤返回給進程。
若connect失敗則該套接字不再可用,必須關閉,我們不能對這樣的套接字再次調用connect函數,當循環調用函數connect為給定主機嘗試各個ip地址直到有一個成功時,在每次connect失敗後,都必須close當前的套接字描述符並從新調用socket。
bind函數把一個本地協議地址賦予了一個套接字。協議地址時32位IPv4地址或128位的IPv6地址與16位的TCP/UDP端口號的組合。
在調用bind函數可以制定一個特定的端口號,或者制定一個IP地址,或者兩個都指定,後者兩者都不指定。
函數聲明如下:
#include
int bind(int sockfd, const struct sockaddr * myaddr,socklen_t addrlen);
sockfd:套接字描述符
myaddr:套接字地址結構的指針
addrlen:上述結構的長度,防止內核越界
服務器在啟動時捆綁它們的眾所周知的端口,例如時間獲取服務的端口13。如果不調用bind函數,當調用connect或listen的時候,TCP會創建一個臨時的端口,這對於客戶端來說很常見(畢竟我們從來沒見過客戶端程序調用過bind函數),而對於TCP服務器來說就比較少見了,因為TCP服務器就是通過其眾所周知的端口被大家認識。
進程可以把一個特定的IP地址綁定到它的套接字上:對於客戶端來說,這沒有必要,因為內核將根據所外出網絡接口來選擇源IP地址。對於服務器來說,這將限定服務器只接收目的地為該IP地址的客戶連接。
對於IPv4來說,通配地址由常值INADDR_ANY
來指定,其值一般為0,它告知內核去選擇IP地址,因此我們經常看到如下語句:
struct sockaddr_in servaddr;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
同理,端口號指定為0時,內核就在bind被調用的時候選擇一個臨時端口,不過bind函數不返回內核選擇的值,第二個參數有const限定。如果想要直到內核所選擇的臨時端口值,必須調用getsockname
來返回協議地址。
最後需要注意的是:bind綁定保留端口號時需要超級用戶權限。這就是為什麼我們在linux下執行服務器程序的時候要加sudo
,如果沒有超級用戶權限,綁定將會失敗。
listen函數由TCP服務器調用,其函數聲明如下:
#include
int listen (int sockdfd , int backlog);
listen函數主要有兩個作用:
1.對於參數sockfd來說:當socket函數創建一個套接字時,它被假設為一個主動套接字。listen函數把該套接字轉換成一個被動套接字,指示內核應接受指向該套接字的連接請求。
2.對於參數backlog:規定了內核應該為相應套接字排隊的最大連接數=未完成連接隊列+已完成連接隊列
其中:
未完成連接隊列:表示服務器接收到客戶端的SYN但還未建立三路握手連接的套接字(SYN_RCVD狀態)
已完成連接隊列:表示已完成三路握手連接過程的套接字(ESTABLISHED狀態)
結合三路握手的過程:
1.客戶調用connect發送SYN分節
2.服務器收到SYN分節在未完成隊列建立條目
3.直到三鹿握手的第三個分節(客戶對服務器SYN的ACK)到達,此時該項目從未完成隊列移動到已完成隊列的隊尾。
4.當進程調用accept時,已完成隊列出隊,當已完成隊列為空時,accept函數阻塞,進程睡眠,直到已完成隊列入隊。
所以說,如果三路握手正常完成,未完成連接隊列中的任何一項在其中存留的時間就是服務器在收到客戶端的SYN和收到客戶端的ACK這段時間(RTT)。
如圖所示:
對於一個WEB服務器來說,RTT是187ms。
關於這兩個隊列還有需要注意的地方:當客戶端發送SYN分節到達服務器時,如果此時服務器的未完成連接隊列是滿的,服務器將忽略這個SYN分節,服務器不會立即給客戶端回應一個RST,因為客戶端有自己的重傳機制,如果服務器發送RST,那麼客戶度端的connect就會返回錯誤了。另外客戶端無法區別RST究竟意味著“該端口沒有服務器在監聽”還是意味著“該端口有服務器在監聽不過它的隊列滿了。”
TCP服務器調用accept函數,函數聲明如下:
#include
int accept (int sockfd, struct sockaddr *cliaddr ,socklen_t * addrlen);
sockfd:套接字描述符
cliaddr:對端(客戶)的協議地址
addr:大小
當accept調用成功,將返回一個新的套接字描述符,例如:
int connfd = Accept(listenfd,(SA*)NULL,NULL);
其中我們稱listenfd
為監聽套接字描述符,稱connfd
為已連接套接字描述符。,區分這兩個套接字十分重要,一個服務器進程通常只需要一個監聽套接字,但是卻能夠有很多已連接套接字(比如通過fork創建子進程),也就是說每有一個客戶端建立連接時就會創建一個connectfd,當連接結束時,相應的已連接套接字就會被關閉。
通過指針我們可以得到客戶端的套接字信息,但是如果我們對這些不感興趣就可以另他們為NULL,書中給出一個示例,服務器相應連接後,打印客戶端的IP地址和端口號。
部分代碼如下:
#include "unp.h"
#include
int
main(int argc, char **argv)
{
//...
for ( ; ; ) {
len = sizeof(cliaddr);
connfd = Accept(listenfd, (SA *) &cliaddr, &len);//已連接套接字
//cliaddr獲取客戶端協議地址信息。
printf("connection from %s, port %d\n",
Inet_ntop(AF_INET, &cliaddr.sin_addr, buff, sizeof(buff)),
ntohs(cliaddr.sin_port));
//...
Close(connfd);
}
}
用fork創建子進程的方法並不陌生,這裡將用fork編寫並發服務器程序。
#include
pid_t fork(void);
在子進程中返回0,在父進程返回返回子進程ID,因為有這個父子的概念,所以fork函數調用一次卻要返回兩次。
關於fork函數的一些特性:
1.任何子進程只能有一個父進程,並且子進程可以通過getppid獲取父進程ID
2.父進程中調用fork之前打開的描述符,在fork之後與子進程分享,在網絡通信中也正是應用到這個特性。
說到上述第2個特性,我們知道服務器進程往往在死循環中等待客戶端連接,利用特性2,當accept函數調用返回一個connfd時,子進程可以利用其進行讀寫,而父進程直接關閉即可。
簡而言之:父進程創建描述符,子進程對其實際操作。
exec函數實際上是6個函數,他們的區別主要在於:
(a)待執行的程序文件是由文件名還是路徑名指定。
(b)新程序的參數是一一列出來還是由一個指針數組來引用
(c)把調用進程的環境傳遞給新程序還是給新程序指定新的環境。
首先一個概念叫做“迭代服務器”
,例如:
for(;;)
{
connfd = Accept(listenfd,(SA*)NULL,NULL);
ticks=time(NULL);
snprintf(buff,sizeof(buff),"%.24s\r\n",ctime(&ticks));
Write(connfd,buff,strlen(buff));
Close(connfd);
}
當一個客戶端連接過來時,服務器向客戶端寫入時間信息後,關閉已連接套接字,回到for循環頂部阻塞等待連接的到來,這樣每次連接到來的時候,必須完成該次服務,因為它占用了服務器進程。但是由於簡單的獲取時間服務本身就很快,單次服務馬上就完成了,所以也就影響不大,不過如果是十分耗時的服務就不一定了,我們並不希望服務器被單個客戶長時間占用,而是希望服務器同時服務多個用戶,於是在Unix中編寫並發服務器程序最簡單的辦法就是fork一個子進程來服務每個客戶。
pid_t pid;
int listenfd;
int connfd;
listenfd=Socket(/*...*/);
Bind(listenfd,/*...*/);
Listen(listenfd,/*...*/);
for(;;)
{
connfd = Accept(listenfd,(SA*)NULL,NULL);
if((pid=fork())==0)//子進程
{
close(listenfd);//關閉監聽套接字
doit(connfd);//服務
close(connfd);
exit(0);
}
close(connfd);
}
這裡我一直不理解的是,為什麼在子進程裡面要關閉監聽套接字(listenfd)呢?
這就跟fork的相關知識有關:
1.首先fork並不是把父進程從頭到尾執行一遍,否則這樣不就無窮盡了。
2.父進程在調用fork處,整個父進程空間會原模原樣地復制到子進程中,包括指令,變量值,程序調用棧,環境變量,緩沖區,等等。
3.在並發服務器的示例中,子進程會將已連接的套接字(connfd)和監聽套接字(listenfd)拷貝到自己的進程空間。
4.對於套接字描述符來說,他們都有一個引用計數,fork之後由於描述符被復制,其引用計數都變成了2。
5.因此,我們在父進程中關閉connfd(因為我們在子進程中使用connfd),在子進程中關閉listenfd(因為我們在父進程中進行監聽),此時他們的引用計數都變成了1。
6.然後,我們所期望的狀態就是父進程中保留一個監聽套接字繼續等待客戶端連接,在子進程中通過已連接的套接字對客戶端請求進行服務。
7.最後在子進程中關閉connfd,或exit(0)
,使得connfd真正完成清理和資源釋放。
close函數可以用來關閉套接字並終止TCP連接。
#include
int close(int sockfd);
從上節的並發服務器可以看到,close函數是對套接字描述符的引用計數減1,也就是說,如果調用close後,引用計數不為0,將不會引起TCP的四分組連接終止序列,這正是父進程與子進程共享已連接套接字的並發服務器所期望的。不過如果我們確實想在TCP連接上發送一個FIN,那麼調用shutdown
函數。
#include
int getsockname(int sockfd,struct sockaddr*localaddr,socklen_t *addrlen);
int getpeername(int sockfd,struct sockaddr*peeraddr,socklen_t *addrlen);
//若成功則為0,失敗則為-1
這兩個函數的作用:
1.首先我們知道在TCP客戶端一般不使用bind函數,當connect返回後,getsockname
可以返回客戶端本地IP地址和本地端口號。
2.如果bind綁定了端口號0(內核選擇),由於bind的參數是const型的,因此必須通過getsockname
去得到內核賦予的本地端口號。
3.獲取某個套接字的地址族
4.以通配IP地址bind的服務器上,accept成功返回之後,getsockname
可以用於返回內核賦予該連接的本地IP地址。其中套接字描述符參數必須是已連接的套接字描述符。
本章介紹了套接字編程的函數,客戶端和服務器端都從socket
開始,客戶端隨後調用connect
,而服務器端先後調用bind
,listen
和accept
,最後使用close
來關閉描述符。