本篇文章簡單描述了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 }

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 }
示例代碼運行結果
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 */

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 */
示例代碼運行結果
調用connect函數的UDP套接字
TCP套接字需要手動注冊傳輸數據的目標IP和端口號,而UDP則是調用sendto函數時自動完成目標地址信息的注冊,該過程如下
- 第一階段:向UDP套接字注冊目標IP和端口號
- 第二階段:傳輸數據
- 第三階段:刪除UDP套接字中注冊的目標地址信息
每次調用sendto函數都會重復執行以上過程,這也是為什么同一個UDP套接字可和不同目標進行數據交換的原因。像UDP這種未注冊目標地址信息的套接字稱為未連接套接字,而TCP這種注冊了目標地址信息的套接字稱為已連接connected套接字。當需要和同一目標主機進行長時間通信時,UDP的這種無連接的特點則會非常低效。通過調用connect函數使UDP變為已連接套接字則會有效改善這一點,因為上述的第一階段和第三階段會占用整個通信過程近1/3的時間。
已連接的UDP套接字不僅可以使用之前的sendto和recvfrom函數,還可以使用沒有地址信息參數的write和read函數(需要注意的是,調用connect函數的已連接UDP套接字並非真的與目標UDP套接字建立連接,僅僅是向本端UDP套接字注冊了目標IP和端口號信息而已)。修改之前的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 }
關於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網絡編程》等相關書籍。