【TCP/IP網路程式設計】:06基於UDP的伺服器端/客戶端
本篇文章簡單描述了UDP傳輸協議的工作原理及特點。
理解UDP
UDP和TCP一樣同屬於TCP/IP協議棧的第二層,即傳輸層。
UDP套接字的特點
UDP的工作方式類似於傳統的信件郵寄過程。寄信前應先在信封上填好寄信人和收信人的地址,之後貼上郵票放進郵筒即可。當然信件郵寄過程可能會發生丟失,我們也無法隨時知曉對方是否已收到信件。也就是說信件是一種不可靠的傳輸方式,同樣的,UDP所提供的也是一種不可靠的資料傳輸方式(以信件類比UDP只是通訊形式上一致性,之前也以電話通訊的方式類比了TCP的通訊方式,而實際上從通訊速度上來講UDP通常是要快於TCP的;每次交換的資料量越大,TCP的傳輸速率就越接近於UDP)。因此,如果僅考慮可靠性,TCP顯然由於UDP;但UDP在通訊結構上較TCP更為簡潔,通常效能也要優於TCP。
區分TCP和UDP最重要的標誌是流控制,流控制賦予了TCP可靠性的特點,也說TCP的生命在於流控制。
UDP內部工作原理
與TCP不同,UDP不會進行流控制,其在資料通訊中的作用如下圖所示。可以看出,IP的作用就是讓離開主機B的UDP資料包準確傳遞到主機A,而UDP則是把UDP包最終交給主機A的某一UDP套接字。UDP最重要的作用就是根據埠號將傳輸到主機的資料包交付給最終的UDP套接字。
資料包傳輸過程UDP和IP的作用
UDP的高效使用
TCP用於對可靠性要求較高的場景,比如要傳輸一個重要檔案或是壓縮包,這種情況往往丟失一個數據包就會引起嚴重的問題;而對於多媒體資料來說,丟失一部分資料包並沒有太大問題,因為實時性更為重要,速度就成為了重要考慮因素。TCP慢於UDP主要在於以下兩點:
- 收發資料前後進行的連線及清理過程
- 收發資料過程中為保證可靠性而新增的流控制
因此,如果收發的資料量小但需要頻繁的連線時,UDP比TCP更為高效。
基於UDP的伺服器端/客戶端
和TCP不同,UDP伺服器端/客戶端並不需要在連線狀態下交換資料,UDP的通訊只有建立套接字和資料交換的過程。TCP套接字是一對一的關係,且伺服器端還需要一個額外的TCP套接字用於監聽連線請求;而UDP通訊中,無論伺服器端還是客戶端都只需要一個套接字即可,且可以實現一對多的通訊關係。下圖展示了一個UDP套接字與兩臺主機進行資料交換的過程。
UDP套接字通訊模型
基於UDP的資料I/O函式
TCP套接字建立連線之後,資料傳輸過程便無需額外新增地址資訊,因為TCP套接字會保持與對端的連線狀態;而UDP則沒有這種連線狀態,因此每次資料交換過程都需要新增目標地址資訊。下面是UDP套接字資料傳輸函式,與TCP傳輸函式最大的區別在於,該函式需要額外新增傳遞目標的地址資訊。
#include <sys/socket.h> ssize_t sendto(int sock, void *buff, size_t nbytes, int flags, struct sockaddr *to, socklen_t addrlen); -> 成功時返回傳輸的位元組數,失敗時返回-1
由於UDP資料的傳送端並不固定,因此,UDP套接字的資料接收函式定義了儲存傳送端地址資訊的資料結構。
#include <sys/socket.h> ssize_t recvfrom(int sock, void *buff, size_t nbytes, int flags, struct sockaddr *from, socklen_t addrlen); -> 成功時返回接收的位元組數,失敗時返回-1
基於UDP的回聲伺服器端/客戶端
UDP通訊函式呼叫流程
UDP不同於TCP,不存在請求連線和受理連線的過程,因此某種意義上並沒有明確的伺服器端和客戶端之分。如下是示例原始碼。
1 #include <stdio.h> 2 #include <stdlib.h> 3 #include <string.h> 4 #include <unistd.h> 5 #include <arpa/inet.h> 6 #include <sys/socket.h> 7 8 #define BUF_SIZE 30 9 void error_handling(char *message); 10 11 int main(int argc, char *argv[]) 12 { 13 int serv_sock; 14 char message[BUF_SIZE]; 15 int str_len; 16 socklen_t clnt_adr_sz; 17 18 struct sockaddr_in serv_adr, clnt_adr; 19 if(argc!=2){ 20 printf("Usage : %s <port>\n", argv[0]); 21 exit(1); 22 } 23 24 serv_sock=socket(PF_INET, SOCK_DGRAM, 0); 25 if(serv_sock==-1) 26 error_handling("UDP socket creation error"); 27 28 memset(&serv_adr, 0, sizeof(serv_adr)); 29 serv_adr.sin_family=AF_INET; 30 serv_adr.sin_addr.s_addr=htonl(INADDR_ANY); 31 serv_adr.sin_port=htons(atoi(argv[1])); 32 33 if(bind(serv_sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr))==-1) 34 error_handling("bind() error"); 35 36 while(1) 37 { 38 clnt_adr_sz=sizeof(clnt_adr); 39 str_len=recvfrom(serv_sock, message, BUF_SIZE, 0, 40 (struct sockaddr*)&clnt_adr, &clnt_adr_sz); 41 sendto(serv_sock, message, str_len, 0, 42 (struct sockaddr*)&clnt_adr, clnt_adr_sz); 43 } 44 close(serv_sock); 45 return 0; 46 } 47 48 void error_handling(char *message) 49 { 50 fputs(message, stderr); 51 fputc('\n', stderr); 52 exit(1); 53 }uecho_server
1 #include <stdio.h> 2 #include <stdlib.h> 3 #include <string.h> 4 #include <unistd.h> 5 #include <arpa/inet.h> 6 #include <sys/socket.h> 7 8 #define BUF_SIZE 30 9 void error_handling(char *message); 10 11 int main(int argc, char *argv[]) 12 { 13 int sock; 14 char message[BUF_SIZE]; 15 int str_len; 16 socklen_t adr_sz; 17 18 struct sockaddr_in serv_adr, from_adr; 19 if(argc!=3){ 20 printf("Usage : %s <IP> <port>\n", argv[0]); 21 exit(1); 22 } 23 24 sock=socket(PF_INET, SOCK_DGRAM, 0); 25 if(sock==-1) 26 error_handling("socket() error"); 27 28 memset(&serv_adr, 0, sizeof(serv_adr)); 29 serv_adr.sin_family=AF_INET; 30 serv_adr.sin_addr.s_addr=inet_addr(argv[1]); 31 serv_adr.sin_port=htons(atoi(argv[2])); 32 33 while(1) 34 { 35 fputs("Insert message(q to quit): ", stdout); 36 fgets(message, sizeof(message), stdin); 37 if(!strcmp(message,"q\n") || !strcmp(message,"Q\n")) 38 break; 39 40 sendto(sock, message, strlen(message), 0, 41 (struct sockaddr*)&serv_adr, sizeof(serv_adr)); 42 adr_sz=sizeof(from_adr); 43 str_len=recvfrom(sock, message, BUF_SIZE, 0, 44 (struct sockaddr*)&from_adr, &adr_sz); 45 46 message[str_len]=0; 47 printf("Message from server: %s", message); 48 } 49 close(sock); 50 return 0; 51 } 52 53 void error_handling(char *message) 54 { 55 fputs(message, stderr); 56 fputc('\n', stderr); 57 exit(1); 58 }uecho_client
示例程式碼執行結果
UDP客戶端套接字的地址分配
從上述示例原始碼來看,伺服器端UDP套接字需要手動bind地址資訊,而客戶端UDP套接字則無此過程。我們已經知道,客戶端TCP套接字是在呼叫connect函式的時機,由作業系統為我們自動綁定了地址資訊;而客戶端UDP套接字同樣存在該過程,如果沒有手動bind地址資訊,則在首次呼叫sendto函式時自動分配IP和埠號等地址資訊。和TCP一樣,IP是主機IP,埠號則隨機分配(客戶的臨時埠是在第一次呼叫sendto
時一次性選定,不能改變;然而客戶的IP地址卻可以隨客戶傳送的每個UDP資料報而變動(如果客戶沒有繫結一個具體的IP地址到其套接字上)。其原因在於如果客戶主機是多宿的,客戶有可能在兩個目的地之間交替選擇)。
UDP資料傳輸特性和connect函式呼叫
之前我們介紹了TCP傳輸資料不存在資料邊界,下面將會驗證UDP資料傳輸存在資料邊界的特性,並介紹UDP傳輸呼叫connect函式的作用。
存在資料邊界的UDP套接字
UDP協議具有資料邊界,這就意味著資料交換的雙方輸入函式和輸出函式必須一一對應,這樣才能保證可完整接收資料。如下是驗證UDP存在資料邊界的示例原始碼。
1 #include <stdio.h> 2 #include <stdlib.h> 3 #include <string.h> 4 #include <unistd.h> 5 #include <arpa/inet.h> 6 #include <sys/socket.h> 7 8 #define BUF_SIZE 30 9 void error_handling(char *message); 10 11 int main(int argc, char *argv[]) 12 { 13 int sock; 14 char message[BUF_SIZE]; 15 struct sockaddr_in my_adr, your_adr; 16 socklen_t adr_sz; 17 int str_len, i; 18 19 if(argc!=2){ 20 printf("Usage : %s <port>\n", argv[0]); 21 exit(1); 22 } 23 24 sock=socket(PF_INET, SOCK_DGRAM, 0); 25 if(sock==-1) 26 error_handling("socket() error"); 27 28 memset(&my_adr, 0, sizeof(my_adr)); 29 my_adr.sin_family=AF_INET; 30 my_adr.sin_addr.s_addr=htonl(INADDR_ANY); 31 my_adr.sin_port=htons(atoi(argv[1])); 32 33 if(bind(sock, (struct sockaddr*)&my_adr, sizeof(my_adr))==-1) 34 error_handling("bind() error"); 35 36 for(i=0; i<3; i++) 37 { 38 sleep(5); // delay 5 sec. 39 adr_sz=sizeof(your_adr); 40 str_len=recvfrom(sock, message, BUF_SIZE, 0, 41 (struct sockaddr*)&your_adr, &adr_sz); 42 43 printf("Message %d: %s \n", i+1, message); 44 } 45 close(sock); 46 return 0; 47 } 48 49 void error_handling(char *message) 50 { 51 fputs(message, stderr); 52 fputc('\n', stderr); 53 exit(1); 54 } 55 56 /* 57 root@my_linux:/home/swyoon/tcpip# gcc bound_host1.c -o host1 58 root@my_linux:/home/swyoon/tcpip# ./host1 59 Usage : ./host1 <port> 60 root@my_linux:/home/swyoon/tcpip# ./host1 9190 61 Message 1: Hi! 62 Message 2: I'm another UDP host! 63 Message 3: Nice to meet you 64 root@my_linux:/home/swyoon/tcpip# 65 66 */bound_hostA
1 #include <stdio.h> 2 #include <stdlib.h> 3 #include <string.h> 4 #include <unistd.h> 5 #include <arpa/inet.h> 6 #include <sys/socket.h> 7 8 #define BUF_SIZE 30 9 void error_handling(char *message); 10 11 int main(int argc, char *argv[]) 12 { 13 int sock; 14 char message[BUF_SIZE]; 15 struct sockaddr_in my_adr, your_adr; 16 socklen_t adr_sz; 17 int str_len, i; 18 19 if(argc!=2){ 20 printf("Usage : %s <port>\n", argv[0]); 21 exit(1); 22 } 23 24 sock=socket(PF_INET, SOCK_DGRAM, 0); 25 if(sock==-1) 26 error_handling("socket() error"); 27 28 memset(&my_adr, 0, sizeof(my_adr)); 29 my_adr.sin_family=AF_INET; 30 my_adr.sin_addr.s_addr=htonl(INADDR_ANY); 31 my_adr.sin_port=htons(atoi(argv[1])); 32 33 if(bind(sock, (struct sockaddr*)&my_adr, sizeof(my_adr))==-1) 34 error_handling("bind() error"); 35 36 for(i=0; i<3; i++) 37 { 38 sleep(5); // delay 5 sec. 39 adr_sz=sizeof(your_adr); 40 str_len=recvfrom(sock, message, BUF_SIZE, 0, 41 (struct sockaddr*)&your_adr, &adr_sz); 42 43 printf("Message %d: %s \n", i+1, message); 44 } 45 close(sock); 46 return 0; 47 } 48 49 void error_handling(char *message) 50 { 51 fputs(message, stderr); 52 fputc('\n', stderr); 53 exit(1); 54 } 55 56 /* 57 root@my_linux:/home/swyoon/tcpip# gcc bound_host1.c -o host1 58 root@my_linux:/home/swyoon/tcpip# ./host1 59 Usage : ./host1 <port> 60 root@my_linux:/home/swyoon/tcpip# ./host1 9190 61 Message 1: Hi! 62 Message 2: I'm another UDP host! 63 Message 3: Nice to meet you 64 root@my_linux:/home/swyoon/tcpip# 65 66 */bound_hostB
示例程式碼執行結果
呼叫connect函式的UDP套接字
TCP套接字需要手動註冊傳輸資料的目標IP和埠號,而UDP則是呼叫sendto函式時自動完成目標地址資訊的註冊,該過程如下
- 第一階段:向UDP套接字註冊目標IP和埠號
- 第二階段:傳輸資料
- 第三階段:刪除UDP套接字中註冊的目標地址資訊
每次呼叫sendto函式都會重複執行以上過程,這也是為什麼同一個UDP套接字可和不同目標進行資料交換的原因。像UDP這種未註冊目標地址資訊的套接字稱為未連線套接字,而TCP這種註冊了目標地址資訊的套接字稱為已連線connected套接字。當需要和同一目標主機進行長時間通訊時,UDP的這種無連線的特點則會非常低效。通過呼叫connect函式使UDP變為已連線套接字則會有效改善這一點,因為上述的第一階段和第三階段會佔用整個通訊過程近1/3的時間。
針對UDP套接字呼叫connect函式並非真的與目標UDP套接字建立連線,僅僅是向本端UDP套接字註冊了目標IP和埠號資訊而已。已連線的UDP套接字不僅可以使用之前的sendto和recvfrom函式,還可以使用沒有地址資訊引數的write和read函式。修改之前的ucheo_client程式碼為已連線UDP套接字如下。
1 #include <stdio.h> 2 #include <stdlib.h> 3 #include <string.h> 4 #include <unistd.h> 5 #include <arpa/inet.h> 6 #include <sys/socket.h> 7 8 #define BUF_SIZE 30 9 void error_handling(char *message); 10 11 int main(int argc, char *argv[]) 12 { 13 int sock; 14 char message[BUF_SIZE]; 15 int str_len; 16 socklen_t adr_sz; 17 18 struct sockaddr_in serv_adr, from_adr; 19 if(argc!=3){ 20 printf("Usage : %s <IP> <port>\n", argv[0]); 21 exit(1); 22 } 23 24 sock=socket(PF_INET, SOCK_DGRAM, 0); 25 if(sock==-1) 26 error_handling("socket() error"); 27 28 memset(&serv_adr, 0, sizeof(serv_adr)); 29 serv_adr.sin_family=AF_INET; 30 serv_adr.sin_addr.s_addr=inet_addr(argv[1]); 31 serv_adr.sin_port=htons(atoi(argv[2])); 32 33 connect(sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)); 34 35 while(1) 36 { 37 fputs("Insert message(q to quit): ", stdout); 38 fgets(message, sizeof(message), stdin); 39 if(!strcmp(message,"q\n") || !strcmp(message,"Q\n")) 40 break; 41 /* 42 sendto(sock, message, strlen(message), 0, 43 (struct sockaddr*)&serv_adr, sizeof(serv_adr)); 44 */ 45 write(sock, message, strlen(message)); 46 47 /* 48 adr_sz=sizeof(from_adr); 49 str_len=recvfrom(sock, message, BUF_SIZE, 0, 50 (struct sockaddr*)&from_adr, &adr_sz); 51 */ 52 str_len=read(sock, message, sizeof(message)-1); 53 54 message[str_len]=0; 55 printf("Message from server: %s", message); 56 } 57 close(sock); 58 return 0; 59 } 60 61 void error_handling(char *message) 62 { 63 fputs(message, stderr); 64 fputc('\n', stderr); 65 exit(1); 66 }uecho_con_client
關於recvfrom函式的討論
recvfrom是一個阻塞函式,那麼該函式的返回時機是怎樣的?
顯然如果客戶端正常收到應答資料,recvfrom自然可以返回。但如果發生其他情況呢?
對端呼叫close函式關閉UDP套接字時是否會發送EOF資訊,本端recvfrom函式又會有什麼動作嗎?是否會像TCP套接字的read函式那樣收到EOF資訊而返回0?
由於UDP套接字無連線的特性,即使對端呼叫close函式關閉套接字,本端也不會有任何感知,recvfrom自然不會返回。那如果是呼叫了connect函式的已連線UDP套接字呼叫close函式呢,服務端recvfrom函式是否會返回?
仍然不會。因為呼叫connect函式的已連線UDP套接字並非真的像TCP套接字那樣建立了連線,僅僅是為了資料交換的便利性向本端UDP套接字註冊了目標地址資訊而已;而close函式並不能感知到這些,也不會向TCP那樣向對端傳送檔案結束標誌EOF。因此,正常情況下,只有接收到傳送端資料資訊的recvfrom函式才會退出阻塞狀態而返回。
如果一個客戶資料報丟失(譬如說,被客戶主機與伺服器主機之間的某個路由器丟棄),客戶將永遠阻塞於recvfrom
呼叫,等待一個永遠不會到達的伺服器應答。類似地,如果客戶資料報到達伺服器,但是伺服器的應答丟失了,客戶也將永遠阻塞於recvfrom
呼叫。防止這樣永久阻塞的一般方法是給客戶的recvfrom
呼叫設定一個超時。
關於recvfrom函式和UDP協議的進一步內容可以參考《UNIX網路程式設計》等相關書