1. 程式人生 > >【TCP/IP網路程式設計】:06基於UDP的伺服器端/客戶端

【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網路程式設計》等相關書