1. 程式人生 > >網路程式設計------TCP協議網路程式

網路程式設計------TCP協議網路程式

        在網路程式設計------UDP協議網路程式一文中編寫了根據傳輸層的UDP協議來進行伺服器與客戶端通訊的程式碼,並介紹了相關的概念。在本文中將編寫基於TCP協議的伺服器與客戶端通訊程式碼。並對比TCP與UDP協議之間的差別。下面先介紹TCP協議。

TCP協議

        TCP協議與UDP一樣,都是基於傳輸層的協議。兩個網路程序根據TCP協議在進行通訊時,首先要相互建立連線。待雙方確認連線建立成功之後,才可進行通訊(UDP協議不用建立連線,直接進行通訊)。這樣做可以確保資料傳送的可靠性,但同時因為建立連線等需要花費時間和資源,因此速度相對UDP會相對較慢。與UDP面向資料報傳送方式的不同,TCP是面向位元組流進行傳送的,即傳送方傳送一定位元組的資料,接收方可以以任意的長度接收。比如傳送方一次傳送了20位元組的資料,接收方可以一次接受1個,2個位元組等。而UDP協議要求傳送方一次傳送多少,接收方一次就必須接收多少。

        因此,TCP協議具有以下特點:

(1)是傳輸層的協議

(2)面向連線,速度相對TCP會較慢,成本會相對較高

(3)保證可靠性

(4)面向位元組流

        下面基於TCP協議的簡單網路程式。

        在編寫程式碼之前還要再認識一些介面函式。(部分介面函式已在網路程式設計------UDP協議網路程式中給出)

介面函式

1. 地址轉換函式

 基於TCP協議的網路程式設計,也要通過網路來進行通訊,因此與UDP相同也要對IP地址進行相應的轉換。

在UDP中介紹了IPv4型別的IP地址格式轉換的兩個函式,下面在介紹幾個相關函式:

“點分十進位制”轉換為整型地址

 int inet_aton(const char *cp, struct in_addr *inp);//標頭檔案:<sys/socket.h>  <netinet/in.h>  <arpa/inet.h>

        該函式與inet_addr函式一樣,都只適用於IPv4型別的IP地址。

        引數:

cp:需轉換的“點分十進位制”字串型別的IP地址

inp:轉換後的整型地址,該整型地址被封裝在結構體struct in_addr中。所以inp是一輸出型引數

        返回值:成功返回0,失敗返回-1。

#include <arpa/inet.h>
int inet_pton(int af, const char *src, void *dst);

        該函式具有通用性,它適用於任意套接字型別的IP地址,具體是轉換哪種地址,由引數給出。

引數:

af:套接字型別的地址標識,即struct sockaddr結構體的第一個成員。如對於IPv4型別的IP地址,該引數為AF_INET。

src:需轉換的“點分十進位制”字串型別的IP地址

dst:該引數指向轉換後的整型地址。因此該引數也是一輸出引數。

        返回值:成功返回0,失敗返回-1。

整型地址轉換為“點分十進位制”字串

    在UDP中介紹過的inet_ntoa函式為:

char *inet_ntoa(struct in_addr in);

        在轉換時該函式內部為我們在靜態儲存區申請了一片記憶體存放轉換後的字串,然後將這片記憶體的地址以返回值的形式帶回。所以,不需要我們手動釋放。

        但是,當多次呼叫該函式時,後面呼叫的結果會覆蓋前面的結果。也就是說每次呼叫轉換後的結果都放在同一記憶體中。因此,在多執行緒環境中呼叫該函式時,這片區域就相當於臨界資源,多個執行緒都可進行訪問。因此可能會出現異常。所以該函式不是執行緒安全函式。

        所以,可以呼叫以下函式,來由我們自己提供存放字串的記憶體,同時該函式適用於任意型別的IP地址轉換:

#include <arpa/inet.h>
const char *inet_ntop(int af, const void *src,char *dst, socklen_t size);

        引數:

af:16位的IP地址型別標識,如AF_INET

src:指向要轉換的變數

dst:存放轉換後的“點分十進位制”字串,因此它是一個輸出型引數

size:dst的位元組長度

        返回值:成功返回0,失敗返回-1

2. listen函式

#include <sys/types.h>          
#include <sys/socket.h>
int listen(int sockfd, int backlog);

        函式功能:該函式用於客戶端,使sockfd處於監聽狀態,處於監聽狀態的網絡卡檔案才能接受客戶端發來的連線請求。

        引數:

sockfd:socket函式返回的檔案描述符

backlog:等待佇列的中等待連線的個數。

        返回值:成功返回0,失敗返回-1.

        注意:

        當系統中的資源不足以支援與客戶端進行連線並提供服務時,此時就要是請求的連線處於等待佇列中。待系統中有多餘的資源時,在進行連線。

        為保證伺服器一直處於忙碌狀態,就必須維護一個等待佇列。因為如果不維護等待佇列,當伺服器資源不足時時,客戶端發來的連線請求就會被忽略,當伺服器空閒下來時,可能沒有連線請求發來,此時,伺服器就可能處於空閒狀態,而使資源得不到利用。所以,必須維護一個等待佇列。

        同時,這個等待佇列不能太長。因為等待佇列的維護是需要消耗資源的。應將更多的資源用於服務上,所以一般將等待佇列的長度設定為5,當等待佇列中的請求連線數超過5時,就直接忽略多餘的連線。

3. connect函式

 #include <sys/types.h>         
 #include <sys/socket.h>
 int connect(int sockfd, const struct sockaddr *addr,socklen_t addrlen);

        函式功能:客戶端通過呼叫該函式向伺服器端發起連線請求

        引數:

sockfd:客戶端程式中由socket函式返回的檔案描述符

addr:客戶端要連線的伺服器端的存放套接字相關資訊的結構體

addrlen:上述結構體的長度。

        返回值:成功返回0,失敗返回-1。該函式呼叫成功,即表示三次握手建立成功。

4. accept函式

 #include <sys/types.h>          
 #include <sys/socket.h>
 int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

        函式功能:當connect函式呼叫成功客戶端與伺服器端成功連線後,即三次握手完成後。伺服器端呼叫該函式接受連線。

        引數:

sockfd:伺服器程式中由socket函式返回的檔案描述符

addr:該變數指向接收的客戶端的有關套接字的結構體,如果設定為NULL,就表示伺服器端不關心客戶端的地址所以是一輸出型引數。

addrlen:上述結構體的長度。它是一個輸入輸出型引數。傳入的是呼叫者提供的緩衝區addr的長度,以避免緩衝區溢位。輸出的是實際客戶端結構體變數addr的長度,此時,有可能沒有佔滿呼叫者提供的緩衝區的大小。

        返回值:當呼叫該函式時,還沒有客戶端發來的連線請求,就阻塞等待。當連線建立成功,伺服器端成功接收後,返回客戶端的網絡卡檔案描述符,失敗返回-1。

        下面編寫一個基於TCP協議的簡單的網路程式。實現客戶端與伺服器端的相互通訊。

基於單程序的伺服器

(1)首先,在伺服器程式中要呼叫socket開啟一個網絡卡檔案用於網路通訊

(2)呼叫bind將伺服器程式與特定的IP地址和埠號進行繫結,以便客戶端能找到該伺服器與之進行連線通訊

(3)因為該伺服器是基於TCP協議的,所以要使上述的網絡卡檔案處於監聽狀態才能接受客戶端發來的連線請求。

(4)當客戶端呼叫connect與伺服器端建立連線成功後,伺服器需要呼叫accept來接收連線

(5)然後雙方開始進行通訊

(6)因為可能有多個客戶端需要與伺服器建立連線請求,因此伺服器需要不斷的呼叫accept來接受連線請求,所以應使(4)~(5)處於一個無限迴圈中。

        因為伺服器端需要繫結一個眾所周知的IP地址和埠號才能使客戶端找到它,因此將IP地址和埠號以命令列引數的形式傳入。

伺服器程式程式碼如下:

#include<stdio.h>                                                                                                                     
#include<sys/types.h>
#include<sys/socket.h>
#include<stdlib.h>
#include<netinet/in.h>
#include<arpa/inet.h>
#include<unistd.h>
#include<string.h>
//命令列輸入的格式為:./a.out 192.168.3.95 8080
int main(int argc,char* argv[])
{
    //如果命令列傳入的引數個數不為3,彈出用法說明
    if(argc != 3)
    {
        printf("Usage:%s [ip][port]\n",argv[0]);
        return 1;
    }
    
    //開啟網絡卡檔案,將其繫結到指定的埠號和IP地址,並使其處於監聽狀態
    int listen_sock = server_sock(argv[1],atoi(argv[2]));//得到監聽套接字
    printf("bind and listen success,wait accept...\n");

    //繫結並監聽成功後,雙方開始通訊
    struct sockaddr_in client;//定義存放客戶端套接字資訊的結構體
    while(1)
    {
        socklen_t len = sizeof(client);//呼叫者指定存放結構體的緩衝區的大小
        //伺服器端呼叫該函式阻塞等待客戶端發來連線請求
        //如果連線建立成功之後,該函式接受客戶端的連結請求
        int client_sock = accept(listen_sock,(struct sockaddr*)&client,&len);
        if(client_sock < 0)//接受失敗
        {
            printf("accept error\n");                                                                                                 
            continue;
        }
        char ip_buf[24];
        ip_buf[0] = 0;
        //轉換整型IP地址為字串格式
        inet_ntop(AF_INET,&client.sin_addr,ip_buf,sizeof(ip_buf));

        //將從網路中接受的埠號轉換為主機序列
        int port = ntohs(client.sin_port);
        printf("connect success, ip:[%s],port:[%d]\n",ip_buf,port);

        //此時,雙方開始互發訊息進行通訊
        server_work(client_sock,ip_buf,port);
    }
    return 0;
}             
//得到監聽套接字函式
int server_sock(char* ip,int port)
{
    //開啟網絡卡檔案,得到檔案描述符
    int sock = socket(AF_INET,SOCK_STREAM,0);
    if(sock < 0)
    {
        printf("socker error\n");
        exit(1);
    }

    struct sockaddr_in server;
    bzero(&server,sizeof(server));//使結構體server清零
    server.sin_family = AF_INET;
    server.sin_addr.s_addr = inet_addr(ip);
    server.sin_port = htons(port);
    socklen_t len = sizeof(server);

//    //一個伺服器可能有多個網絡卡,一個網絡卡也可能繫結多個IP地址
//    //INADDR_ANY可以設定在所有IP地址上進行監聽,
//    //直到客戶端發來與指定的IP地址進行連線時,才確定使用哪個IP地址
//    server.sin_addr.s_addr = htonl(INADDR_ANY);
    //伺服器需繫結固定的IP地址和埠號才能使客戶端正確找到
    if(bind(sock,(struct sockaddr*)&server,len) < 0)                                                                                  
    {
        printf("bind error\n");
        exit(2);
    }

    //使sock處於監聽狀態,並且最多允許5個客戶端處於連線等待狀態,多於5的連結請求直接忽略
    if(listen(sock,5) < 0)
    {
        printf("listen error\n");
        exit(3);
    }

    return sock;//得到監聽套接字
}

        在上述程式碼中,以下語句:

server.sin_addr.s_addr = inet_addr(ip);

        可以替換為:

server.sin_addr.s_addr = htonl(INADDR_ANY);

        INADDR_ANY是一個巨集,表示本地的任意IP地址。因為伺服器可能有多個網絡卡,一個網絡卡可能對應多個IP地址,所以可以繫結多個IP地址,在所有的IP地址上進行監聽,當客戶端指定與某個IP地址進行連線時才確定使用哪個IP地址。

//客戶端與伺服器端建立連線之後的通訊函式
void server_work(int client_sock,char* ip,int port)
{
    char buf[128];
    while(1)
    {
        buf[0] = 0;//清空字串
        //伺服器從客戶端接受資訊,如果客戶端沒有發來資訊就阻塞等待
        ssize_t s = read(client_sock,buf,sizeof(buf) - 1);
        if(s > 0)
        {
            buf[s] = 0;
            printf("ip:%s,port:%d say# %s\n",ip,port,buf);
        }
        //如果讀到的為0,說明此時客戶端關閉了檔案描述符,與之斷開了連線
        //所以此時伺服器應直接退出通訊函式。
        else if(s == 0)
        {
            printf("ip:%s,port:%d quit\n",ip,port);
            break;
        }
        else
        {
            printf("read error\n");
            break;
        }

        //伺服器端向客戶端傳送資訊
        printf("please enter#");
        fflush(stdout);
        buf[0] = 0;
        int ss = read(0,buf,sizeof(buf) - 1);
        if(ss > 0)
        {
            buf[ss - 1] = 0; 
        }

        //將從鍵盤讀到的資訊寫入客戶端的網絡卡檔案向其傳送資訊                                                                          
        write(client_sock,buf,strlen(buf));
        printf("waiting ...\n");
    }
}

TCP客戶端

(1)首先,在客戶端程式中開啟網絡卡檔案,得到檔案描述符

(2)客戶端不需要繫結固定的埠號,它的埠號是由核心自動進行分配。所以直接呼叫connect向伺服器端傳送連線請求

(3)當連線成功,伺服器端呼叫accept接收客戶端的連線請求後,雙方便開始進行通訊,規定客戶端傳送“quit”時,表明此時客戶端斷開連線,此時直接關閉網絡卡檔案即可。

        客戶端要根據伺服器端繫結的固定的IP地址和埠號找到伺服器,並向其傳送請求連線,所以應將伺服器端固定的IP地址和埠號以命令列引數的形式傳入:./a.out 192.168.3.95 8080

        客戶端程式碼如下:

#include<stdio.h>                                                                                                                     
#include<sys/types.h>
#include<sys/socket.h>
#include<stdlib.h>
#include<netinet/in.h>
#include<arpa/inet.h>
#include<unistd.h>
#include<string.h>

int main(int argc,char* argv[])
{
    //用法說明
    if(argc != 3)
    {
        printf("Usage:%s [ip][port]\n",argv[0]);
        return 1;
    }

    //開啟網絡卡檔案
    int sock = socket(AF_INET,SOCK_STREAM,0);
    if(sock < 0)
    {
        printf("socket error\n");
        return 2;
    }

    struct sockaddr_in server;
    server.sin_family = AF_INET;
    server.sin_addr.s_addr = inet_addr(argv[1]);
    server.sin_port = htons(atoi(argv[2]));
    socklen_t len = sizeof(server);
    //向伺服器端傳送連線請求
    if(connect(sock,(struct sockaddr*)&server,len) < 0)
    {

        printf("connect failed\n");
        return 3;
    }

    printf("connect success\n");

    //連線成功後雙方開始相互通訊
    char buf[128];
    while(1)
    {
        buf[0] = 0;
        printf("please enter#");
        fflush(stdout);
        ssize_t s = read(0,buf,sizeof(buf) - 1);
        if(s > 0)
        {
            buf[s - 1] = 0;//去掉\n,如果不去掉,在與quit比較時,quit需加上\n
            //當客戶端傳送quit時表明要與伺服器端斷開連結
            if(strcmp(buf,"quit") == 0)
            {
                printf("client quit\n");
                break;
            }
            //向伺服器端傳送訊息
            write(sock,buf,strlen(buf));                                                                                              
            printf("waiting ...\n");
        }
        //從伺服器端接受訊息
        buf[0] = 0;
        ssize_t ss = read(sock,buf,sizeof(buf) - 1);
        if(ss > 0)
        {
            buf[ss] = 0; 
            printf("server say:%s\n",buf);
        }
    }

    //當客戶端斷開連線後,關閉客戶端的檔案描述符
    close(sock);
    return 0;
}         

        執行結果:

        伺服器端先執行起來,等待接收客戶端的連線請求:


        此時,再執行客戶端程式碼向伺服器端請求連線:


伺服器端顯示如下:


        此時,二者便建立連線成功,接下來互發訊息進行通訊,以下為客戶端介面:


        以下為伺服器端介面:


        注意:上述是用本地環回IP地址進行測試。本地環回可以測試網路程式,在有無網路的情況下都可正常測試,因為它會經過協議棧,但不經過網路。在實際應用時,應使用伺服器端的IP地址進行測試。

        在上述程式中,一個客戶端在與伺服器端建立連線進行通訊時,其他的客戶端再向伺服器端傳送連線請求時,會連線不上。因為,在上述伺服器端的程式中,當程序接收到第一個客戶端發來的請求時,會進入與該客戶端進行通訊的死迴圈中,而無法再呼叫accept接受來自其它客戶端的請求。當第一個客戶端與之斷開連線之後,伺服器程序才會跳出死迴圈接收來自其它客戶端的連線請求,此時也是,一次只能與一個客戶端進行連線通訊。

        在實際應用中,伺服器應同時接收處理多個客戶端的請求,所以需要有多個執行流。因此,上述的伺服器端程式碼還需要進行修改。

基於多程序的TCP伺服器

        在上述單程序的伺服器中,因為程序要與客戶端進行通訊,所以無法再accept接受新的客戶端請求,此時,可以利用建立子程序的方式來提供多個執行流,當一個客戶端發來連線請求時,主程序建立一個子程序與客戶端進行通訊,而父程序的任務是不斷接受新的客戶端的請求,建立子程序。當子程序退出時要父程序要通過等待來回收子程序的資源。所以大致程式碼如下:

while(1)
{
    accept();//父程序接收連線
    pid_t pid = fork();
    if(pid == 0)//child
    {
        server_work();
    }
    waitpid();//父程序等待
}

        在上述程式碼中,當一個客戶端發來請求時,父程序建立子程序與之通訊。如果父程序阻塞式等待,則當客戶端連線斷開時,父程序一直在等待也不能接受來自其它客戶端的連線請求,此時,與上述的單程序伺服器缺陷相同。如果父程序非阻塞式等待,父程序也要一直輪詢式的檢視子程序有沒有退出,這樣會使資源得到浪費。當然也可以採用忽略子程序退出時的SIGCHLD訊號來使子程序自己回收資源,當時這樣做也比較麻煩。因此,上述的處理方法也不可取。

        所以,採用如下的處理方法:

while(1)
{
    accept();//父程序接收連線
    pid_t pid = fork();
    if(pid == 0)//child
    {
        pid_t pid1 = fork();
        if(pid1 == 0)//孫子程序
        {
            server_work();
        }
        exit(0);
    }
    waitpid();//父程序等待
}

        首先父程序建立子程序,為避免父程序阻塞等待子程序,使子程序建立好之後直接退出,此時父程序就可以立即回收子程序的資源了。而與客戶端的通訊工作則交給孫子程序來完成,所以在子程序建立好之後,子程序在建立孫子程序,由孫子程序與客戶端進行通訊,子程序直接退出。此時孫子程序會變成孤兒程序被1號程序領養,當孫子程序退出時由1號程序回收資源。而父程序在孫子程序建立完成之後會立即回收子程序避免了等待的工作,同時也可以不斷地接收來自客戶端的連線請求。

        因此將伺服器端的部分程式碼修改如下:

        在main函式中,修改如下:

   while(1)
    {   
        socklen_t len = sizeof(client);
        int client_sock = accept(listen_sock,(struct sockaddr*)&client,&len);
        if(client_sock < 0)
        {   
            printf("accept error\n");
            continue;
        }   

        char ip_buf[24];
        ip_buf[0] = 0;
        inet_ntop(AF_INET,&client.sin_addr,ip_buf,sizeof(ip_buf));
        int port = ntohs(client.sin_port);
        printf("connect success, ip:[%s],port:[%d]\n",ip_buf,port);

         process_work(client_sock,listen_sock,ip_buf,port);//父程序建立子程序
        close(client_sock);
    }   

        父程序建立子程序,子程序再建立孫子程序:

void process_work(int client_sock,int listen_sock,char* ip_buf,int port)
{
    pid_t pid = fork();
    if(pid < 0)
    {   
        printf("fork error\n");
        return;
    }   
    else if(pid == 0)//child
    {   
        pid_t pid1 = fork();
        if(pid1 < 0)
        {   
            printf("fork error\n");
            return;
        }   
        else if(pid1 == 0)//grandchild
        {   
            close(listen_sock);//關閉不用的檔案描述符                                                                                 
            server_work(client_sock,ip_buf,port);
        }   
        close(client_sock);//關閉不用的檔案描述符
        exit(0);
    }   
    else//father
    {   
        close(client_sock);//關閉不用的檔案描述符
        waitpid(pid,NULL,0);
    }   
}

        此時,在處理多個客戶端連線請求時,就可以正常運行了。

        同時需要注意的是:在上述程式中,子程序和孫子程序的檔案描述表都是從父程序那裡繼承過來的,所以三者的檔案描述符表相同。

        而父程序的工作是接收來自客戶端的連線請求,並不與客戶端進行通訊,所以需要關閉來自客戶端的檔案描述符(檔案描述符有上限)。同理,子程序也要關閉該檔案描述符。而對於孫子程序來說,它的工作是與客戶端進行通訊,而不接受連線請求,所以需要關閉伺服器端開啟的檔案描述符。

基於多執行緒的TCP伺服器

        伺服器也可以通過建立多執行緒的方法來提供多個執行流,主執行緒接收客戶端發來的請求並建立新執行緒,新執行緒與客戶端進行通訊。在上述多程序的程式中主要考慮的問題是父程序在阻塞式等待時不能接收連線請求。多執行緒環境中主執行緒理應對新執行緒進行回收。但是pthread庫提供一個個系統呼叫可以分離新執行緒,當新執行緒退出時自己回收資源,不必主執行緒來回收,所以主執行緒在建立完新執行緒之後,直接對其進行分離,就可以繼續不斷接受連線請求了。

        對單程序的伺服器程式碼修改如下:

        main函式中的程式碼修改:

 while(1)
    {                                                                                                                                 
        socklen_t len = sizeof(client);
        int client_sock = accept(listen_sock,(struct sockaddr*)&client,&len);
        if(client_sock < 0)
        {   
            printf("accept error\n");
            continue;
        }   

        char ip_buf[24];
        ip_buf[0] = 0;
        inet_ntop(AF_INET,&client.sin_addr,ip_buf,sizeof(ip_buf));
        int port = ntohs(client.sin_port);
        printf("connect success, ip:[%s],port:[%d]\n",ip_buf,port);

        pthread_t tid;
        Sock* Arg = (Sock*)malloc(sizeof(Arg));
        Arg->sock = client_sock;
        Arg->ip = ip_buf;
        Arg->port = port;

        int ret = pthread_create(&tid,NULL,&pthread_run,Arg);
        if(ret < 0)
        {   
            printf("pthread_create error\n");
            continue;
        }   

        pthread_detach(tid);

    }   

        新執行緒建立完成之後去執行pthread_run函式,引數為Arg,因此在與客戶端進行通訊時,需要用到檔案描述符,IP地址和埠號等,所以將它們封裝在一個結構體中,作為該函式的引數。然後在該函式中與客戶端進行通訊:

typedef struct Sock
{
    int sock;
    char* ip;
    int port;
}Sock;
void* pthread_run(void* Arg1)
{
    Sock* Arg = (Sock*)Arg1;
    server_work(Arg->sock,Arg->ip,Arg->port);//該函式與單程序中的函式相同
}

        此時,在測試時也可達到與多程序相同的作用:可以處理多個客戶端的連線請求並與之進行通訊

簡述多程序和多執行緒的TCP伺服器的優缺點

        缺點:

(1)在多程序和多執行緒環境中,都是當客戶端發來連線請求時,伺服器端才開始建立子程序和新執行緒。此時會使客戶端進行等待,浪費客戶端的時間,使效能受損。因為程序的建立比執行緒的建立花費的時間多,所以多程序的伺服器相對多執行緒在時間這點上來說會受損嚴重。

(2)程序和執行緒的建立都需要消耗資源,也會使效能受損。但程序比執行緒需要更多的資源,所以多執行緒在資源消耗上會受損較輕。

(3)多程序多執行緒環境中CPU都要進行排程,此時也會使客戶端進行等待,會影響效能。但程序的切換成本會相對執行緒高,所以影響較大。

(4)因為多執行緒是在程序的同一地址空間內執行,當一個執行緒出現問題時,整個程序即該程序中的其他執行緒也會受到影響。所以,多執行緒伺服器的穩定性較差。

        優點:

(1)多執行緒和多程序都可以處理多個客戶端的請求

(2)上述多執行緒和多程序的程式編寫都比較簡單,週期短

(3)多程序中一個程序出現問題,其他程序不會受影響。所以多程序伺服器穩定性較好。

UDP協議與TCP協議對比

        TCP是面向連線,而UDP是無連線的。

(1)TCP花費的時間會相對UDP長

(2)TCP消耗的資源也會相對UDP多

(3)UDP的速度會相對TCP較快

(4)同時TCP比UDP更可靠。

        TCP 是面向位元組流的,而UDP是面向資料報的,因此

(5)TCP在訊息的接收上會更加靈活。