[Linux环境编程] TCP通信与多线程编程实现“多人在线聊天室”

[linux环境编程] TCP通信与多线程编程实现“多人在线聊天室”

 

一、基本概念

1、TCP通信

  TCP(Transmission Control Protocol)就是传输控制通讯协议,是TCP/IP体系结构中最主要的传输协议。其“三次握手”提供了可靠的传送,高可靠性保证了数据传输不会出现丢失与乱序,再加之TCP连接两端设有缓存用来临时存放双向通信的数据,所以可以支持全双工传输。非常贴合“多人在线聊天室”对数据传输的需求。

 

2、多线程编程

  多线程是指从软件或者硬件上实现多个线程并发执行的技术。简单来说就是能够在同一时间执行多于一个线程,每个线程处理各自独立的任务,进而简化处理异步事件的代码,改善响应时间,提升整体处理性。

  一个进程的所有信息对该进程的所有线程都是共享的,包括可执行代码、程序的全局内存和堆内存、栈以及文件描述符。因此,相比进程与进程之间,线程之间的通信免去了繁杂的规则,但同时也面临着线程同步的问题,需要多加注意。

 

 

二、程序实现效果

1、功能介绍

  服务端:可限制聊天室在线最大人数与等待进入最大人数。当聊天室人数达到上限,申请进入聊天室的用户将会排队,聊天室内任意用户退出时,排队用户会按照顺序自动加入聊天室。

  客户端:实时显示聊天室内成员所发消息以及成员昵称,可主动退出聊天室,当聊天室有成员变动时会有系统提示。

 

2、测试流程:

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

 

3、测试效果

  服务端设置:最大在线人数2人,排队最大人数1人(实际中至少要设置5个,排队人数上限太低则无实际意义,这里设置1人仅做演示)。开启服务器后依次连接三个客户端,输入用户名后申请进入聊天室,前两个用户进入聊天室,第三个用户排队等候。输入字符‘q’退出聊天室。

server(服务器):

 

client1(用户123):

 

client2(用户456):

client3(用户789):

 

 

三、代码分析

1、客户端(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行代码:刷新标准输出缓冲区,把输出缓冲区里的东西打印到标准输出设备上(显示终端)。目的是使7、8行代码立即生效。

 

2、服务端

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),还有可能夹杂私货、更改内容(敏感词屏蔽(未实现))。

 

 

四、总结

  总的来说,最近这次编程让我意识到一个很大的问题。以前总是在做之前想太多,总是希望能够一次性设计好整个架构,然而这是建立在一定的项目经验上的。就我目前而言暂不具备这样的水平,所以在编写程序时就很容易导致代码臃肿、逻辑混乱,从而导致难以调试。

  所以这次编程更改了思路:先将项目拆分成一个个小的功能模块,底层搭好后再按照功能层级逐个实现,一个一个拼接、一块砖一块砖的搭建,边搭建边微调框架,最终完成程序的编写。这种编程思路在这次练习中起到了很大的作用。整个编程过程思路清晰、编写流畅,代码的调试也轻松许多。

  以上便是我这次练习的总结,发布博客以供记录、总结,博客中如有纰漏欢迎指出,欢迎讨论、共同进步。

posted @ 2018-08-03 00:51  caoliu  阅读(5604)  评论(0编辑  收藏  举报