[Linux環境編程] TCP通訊與多線程編程實現「多人在線聊天室」

[linux環境編程] TCP通訊與多線程編程實現「多人在線聊天室」linux

 

1、基本概念

一、TCP通訊編程

  TCP(Transmission Control Protocol)就是傳輸控制通信協議,是TCP/IP體系結構中最主要的傳輸協議。其「三次握手」提供了可靠的傳送,高可靠性保證了數據傳輸不會出現丟失與亂序,再加之TCP鏈接兩端設有緩存用來臨時存放雙向通訊的數據,因此能夠支持全雙工傳輸。很是貼合「多人在線聊天室」對數據傳輸的需求。緩存

 

二、多線程編程服務器

  多線程是指從軟件或者硬件上實現多個線程併發執行的技術。簡單來講就是可以在同一時間執行多於一個線程,每一個線程處理各自獨立的任務,進而簡化處理異步事件的代碼,改善響應時間,提高總體處理性。多線程

  一個進程的全部信息對該進程的全部線程都是共享的,包括可執行代碼、程序的全局內存和堆內存、棧以及文件描述符。所以,相比進程與進程之間,線程之間的通訊免去了繁雜的規則,但同時也面臨着線程同步的問題,須要多加註意。架構

 

 

2、程序實現效果

一、功能介紹併發

  服務端:可限制聊天室在線最大人數與等待進入最大人數。當聊天室人數達到上限,申請進入聊天室的用戶將會排隊,聊天室內任意用戶退出時,排隊用戶會按照順序自動加入聊天室。框架

  客戶端:實時顯示聊天室內成員所發消息以及成員暱稱,可主動退出聊天室,當聊天室有成員變更時會有系統提示。異步

 

二、測試流程:socket

2.1  編譯

  -std=gnu99:以GNU99標準編譯代碼;

  -o output_filename:將輸出文件的名稱命名爲output_filename,同時這個名稱不能和源文件同名。若是不給出這個選項,就會生成系統默認的「a.out」可執行文件,易被覆蓋。

  -lpthread:在編譯的連接階段自動加載pthread庫。

        gcc -std=gnu99 server_tcp.c -o server -lpthread
        gcc -std=gnu99 client_tcp.c -o client -lpthread

  

2.2  開啓server服務端與三個客戶端(client1/2/3)

  Ubuntu可經過Ctrl+Shift+T開啓多個終端標籤頁,分別執行如下四條指令。其中第一個參數爲可執行文件的路徑;第二個參數爲通信的串口號,1~1024已被系統使用,通常狀況下大於1024便可;第三個參數爲通信的IP地址,「127.0.0.1」會自動轉化爲本機的IP地址。

        ./server 2333 127.0.0.1
        ./client 2333 127.0.0.1
        ./client 2333 127.0.0.1
        ./client 2333 127.0.0.1

 

三、測試效果

  服務端設置:最大在線人數2人,排隊最大人數1人(實際中至少要設置5個,排隊人數上限過低則無實際意義,這裏設置1人僅作演示)。開啓服務器後依次鏈接三個客戶端,輸入用戶名後申請進入聊天室,前兩個用戶進入聊天室,第三個用戶排隊等候。輸入字符‘q’退出聊天室。

server(服務器):

 

client1(用戶123):

 

client2(用戶456):

client3(用戶789):

 

 

3、代碼分析

一、客戶端(server_tcp.c)

1.1  main 主函數

 1 #include <stdio.h>
 2 #include <sys/socket.h>
 3 #include <stdbool.h>
 4 #include <arpa/inet.h>
 5 #include <sys/types.h>
 6 #include <unistd.h>
 7 #include <string.h>
 8 #include <stdlib.h>
 9 #include <netinet/in.h>
10 #include <pthread.h>
11 
12 // 定義消息結構體,用於信息的傳輸
13 typedef struct Msg
14 {
15     char m_name[31];
16     char m_buf[255];
17 }Msg;
18 
19 Msg msg = {};
20 
21 bool quit = false;  // 該程序中暫無實際意義,用於後期拓展
22 int  sockfd = 0; // socket標識符
23 int main(int argc,char** argv)
24 {
25     pthread_t ptid[2] = {};
26 
27     // 建立socket對象
28     sockfd = socket(AF_INET,SOCK_STREAM,0);
29     if(0 > sockfd)
30     {
31         perror("socket");
32         return -1;
33     }
34 
35     // 準備通訊地址
36     struct sockaddr_in addr = {AF_INET};
37     addr.sin_port = htons(atoi(argv[1]));
38     addr.sin_addr.s_addr = inet_addr(argv[2]);
39     
40     /*//等待鏈接
41     struct sockaddr_in src_addr = {};
42     socklen_t addr_len = sizeof(src_addr);*/
43 
44     // 鏈接
45     int ret = connect(sockfd,(struct sockaddr*)&addr,sizeof(addr));
46     if(0 > ret)
47     {
48         perror("connect");
49         return -1;
50     }
51 
52     printf("請輸入您的暱稱:");
53     scanf("%s",msg.m_name);
54 
55     // 建立讀數據進程
56     ret = pthread_create(&ptid[0],NULL,pthread_read,NULL);
57     if(0 > ret)
58     {
59         perror("pthread_create");
60         return -1;
61     }
62 
63     // 建立寫數據進程
64     ret = pthread_create(&ptid[1],NULL,pthread_write,NULL);
65     if(0 > ret)
66     {
67         perror("pthread_create");
68         return -1;
69     }
70 
71     while(!quit);
72     close(sockfd);
73 }

   客戶端主函數主要用於創建起客戶端和服務器的鏈接,客戶端在申請鏈接後可能出現「進入聊天室(num<nmax_chat)」、「等待進入聊天室(num<nmax_chat+nmax_wait)」和「鏈接被拒(num>=nmax_chat+nmax_wait)」三種狀況。客戶端在獲取用戶的用戶名後將會創建起讀、寫數據兩個線程,實時接收和發送數據。

 

1.2  pthread_read 讀線程

 1 void* pthread_read(void* arg)
 2 {
 3     while(1)
 4     {
 5         bzero(&msg.m_buf,sizeof(msg.m_buf));
 6         int ret = recv(sockfd,&msg,sizeof(msg),0);
 7         if (0 < strlen(msg.m_buf))
 8         {
 9             printf("%s:%s\n",msg.m_name,msg.m_buf);
10         }
11         /*if(!strcmp("q",msg))
12         {
13             break;
14         }*/
15     }
16     close(sockfd);
17 }

  讀線程實時接收數據並顯示數據發送者的用戶名及其所發送的數據。 

 

1.3  pthread_write 寫線程

 1 void* pthread_write(void* arg)
 2 {
 3     while(1)
 4     {
 5         gets(msg.m_buf);
 6         //sprintf(msg,"%s:%s",name,msg); // name貼進去時msg已經改變
 7         printf("\33[1A");
 8         printf("\r                                   \r");
 9         fflush(stdout);
10         send(sockfd,&msg,sizeof(msg),0);
11         if(!strcmp("q",msg.m_buf))
12         {
13             quit = true;
14             break;
15         }        
16     }
17 }

  寫線程與讀線程之間存在互相干擾,由於兩者都必須保證明時性,因此沒法採用互斥鎖來保護數據,這裏將全局變量msg改成局部變量便可解決問題。而主函數中的msg.m_name則能夠經過線程建立函數傳遞給寫線程,但願代碼更加嚴謹的話能夠自行更改。

  7~9行代碼組合實現了「消除己方殘留在終端顯示界面的所發送的數據」。

  第7行代碼:將光標上移一行,即殘留顯示數據的行列;

  第8行代碼:將光標移至該行行首,再輸出一段空格覆蓋原有數據,最後將光標移回行首;

  第9行代碼:刷新標準輸出緩衝區,把輸出緩衝區裏的東西打印到標準輸出設備上(顯示終端)。目的是使七、8行代碼當即生效。

 

二、服務端

2.1  main 主函數

 1 #include <stdio.h>
 2 #include <sys/socket.h>
 3 #include <stdbool.h>
 4 #include <arpa/inet.h>
 5 #include <sys/types.h>
 6 #include <unistd.h>
 7 #include <string.h>
 8 #include <stdlib.h>
 9 #include <netinet/in.h>
10 #include <pthread.h>
11 
12 typedef struct Msg
13 {
14     char m_name[31];
15     char m_buf[255];
16 }Msg;
17 
18 typedef struct Client
19 {
20     int  c_fd;
21     bool c_flag;
22 }Client;
23 
24 const int nmax_chat = 2;
25 const int nmax_wait = 1;
26 int num_chat = 0;
27 int num_wait = 0;
28 Client client[4] = {}; // nmax_wait + nmax_chat + 1;
29 
30 bool quit = false;
31 int  sockfd = 0;
32 pthread_t ptid[3] = {};
33 
34 int main(int argc,char** argv)
35 {
36     // 建立socket對象
37     sockfd = socket(AF_INET,SOCK_STREAM,0);
38     if(0 > sockfd)
39     {
40         perror("socket");
41         return -1;
42     }
43 
44     // 準備通訊地址
45     struct sockaddr_in addr = {AF_INET};
46     addr.sin_port = htons(atoi(argv[1]));
47     addr.sin_addr.s_addr = inet_addr(argv[2]);
48 
49     // 綁定對象與地址
50     int ret = bind(sockfd,(struct sockaddr*)&addr,sizeof(addr));
51     if(0 > ret)
52     {
53         perror("bind");
54         return -1;
55     }
56 
57     // 設置排隊數量
58     listen(sockfd,num_wait);
59     
60     // 建立線程
61     ret = pthread_create(&ptid[0],NULL,pthread_accept,NULL);
62     if(0 > ret)
63     {
64         perror("pthread_create");
65         return -1;
66     }
67 
68     while(!quit);
69     // 關閉鏈接
70     close(sockfd);
71 }

  功能與客戶端主函數相似,主要用於創建起客戶端和服務器的鏈接的準備工做。客戶端在設立監聽數量(等待鏈接最大數量nmax_wait)後將會創建起接收線程。

 

2.2  pthread_accep 接收線程

 1 void* pthread_accept(void* arg)
 2 {    
 3     // 等待鏈接
 4     struct sockaddr_in src_addr = {};
 5     socklen_t addr_len = sizeof(src_addr);
 6 
 7     //printf("聊天室已建立!\n");
 8     while(1)
 9     {
10         if (nmax_chat > num_chat)
11         {
12             int i = 0;
13             for ( i = 0; i < nmax_chat; i++)
14             {
15                 if (false == client[i].c_flag)
16                 {
17                     client[i].c_fd = accept(sockfd,(struct sockaddr*)&src_addr,&addr_len);
18                     client[i].c_flag = true;
19                     break;
20                 }
21             }    
22             num_chat++;
23             // 建立線程
24             //printf("chat[%d] fd:%d flag:%d num_chat:%d\n",i,client[i].c_fd,client[i].c_flag,num_chat);
25             int ret = pthread_create(&ptid[1],NULL,pthread_com,&client[i]);
26             if(0 > ret)
27             {
28                 perror("pthread_create");
29                 pthread_exit(NULL);
30             }
31         }
32     }
33     pthread_exit(NULL);
34 }

   接收線程能夠實時接收申請鏈接服務器的客戶端,創建起服務器和客戶端的鏈接,經過for循環與標識符c_flag來判斷是否鏈接申請的客戶端。打個比方,就好像你去飯店吃飯,當你想進入飯店時,門口店員會先環視店內(for循環)、確認是否有座(num_chat<nmax_chat?)、有無被預約(client[i].flag),有則安排進店,無則確認店外的等候座椅是否還有空位(num_wait<nmax_wait?),有則安排座位在店外等候(listen(nmax_wait)),無則沒法安排。對於能夠進店的用戶,店員會安排好座位(client[i].c_fd)並遞上菜單(client[i].c_flag),點好菜後準備上菜(pthread_com)。

 

2.3  pthread_com 信息傳輸線程

void* pthread_com(void* arg)
{ 
    Client* client2 = arg;
    Msg msg = {}; // 發送該線程對應用戶消息及進出聊天室狀況
    char name[31] = {}; // 記錄、發送該線程對應用戶暱稱

    int ret = recv(client2->c_fd,&msg,sizeof(msg),0);
    strcpy(name,msg.m_name);
    //printf("name:%s fd:%d flag:%d num_chat:%d\n",name,client2->c_fd,client2->c_flag,num_chat);

    char buf[34] = "***"; // sizeof(buf) = sizeof(name) + 3
    bzero(&msg,sizeof(msg));
    strcpy(msg.m_name,strcat(buf,name));
    sprintf(msg.m_buf,"已進入聊天室[%d人]***",num_chat);        
    for (int i = 0; i < nmax_chat; i++)
    {
        if(client[i].c_flag == true)
            send(client[i].c_fd,&msg,sizeof(msg),0);
    }

    //通訊
    while(1)
    {
        bzero(&msg,sizeof(msg));
        int ret = recv(client2->c_fd,&msg,sizeof(msg),0);
        if (0 < strlen(msg.m_buf))
        {
            if(!strcmp("q",msg.m_buf))
            {
                num_chat--;     
                client2->c_flag = false;
                //printf("name:%s fd:%d flag:%d num_chat:%d\n",name,client2->c_fd,client2->c_flag,num_chat);
                char buf[34] = "***"; // sizeof(buf) = sizeof(name) + 3
                bzero(&msg,sizeof(msg));
                strcpy(msg.m_name,strcat(buf,name));
                sprintf(msg.m_buf,"已退出聊天室[%d人]***",num_chat);        
                for (int i = 0; i < nmax_chat; i++)
                {
                    if(client[i].c_flag == true)
                        send(client[i].c_fd,&msg,sizeof(msg),0);
                }    
                break;
            }    
            else
            {
                for (int i = 0; i < nmax_chat; i++)
                {
                    //printf("返回了數據:%s\n",msg.m_buf);
                    strcpy(msg.m_name,name);
                    if(client[i].c_flag == true)
                        send(client[i].c_fd,&msg,sizeof(msg),0);
                }
            }
        }
    }
    pthread_exit(NULL);
}

   信息傳輸線程主要承擔起郵局的功能,將各個客戶端投遞到郵局的信件(msg)配送至收件人(client n)手中,同時將一些意外事件(人員變更)轉達給寄件人收件人(client n)。不過相比真正的郵局仍是有很大差異的,這家郵局不只會複製你的信件(strcpy),還有可能夾雜私貨、更改內容(敏感詞屏蔽(未實現))。

 

 

4、總結

  總的來講,最近此次編程讓我意識到一個很大的問題。之前老是在作以前想太多,老是但願可以一次性設計好整個架構,然而這是創建在必定的項目經驗上的。就我目前而言暫不具有這樣的水平,因此在編寫程序時就很容易致使代碼臃腫、邏輯混亂,從而致使難以調試。

  因此此次編程更改了思路:先將項目拆分紅一個個小的功能模塊,底層搭好後再按照功能層級逐個實現,一個一個拼接、一塊磚一塊磚的搭建,邊搭建邊微調框架,最終完成程序的編寫。這種編程思路在此次練習中起到了很大的做用。整個編程過程思路清晰、編寫流暢,代碼的調試也輕鬆許多。

  以上即是我此次練習的總結,發佈博客以供記錄、總結,博客中若有紕漏歡迎指出,歡迎討論、共同進步。

相關文章
相關標籤/搜索