[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),还有可能夹杂私货、更改内容(敏感词屏蔽(未实现))。
四、总结
总的来说,最近这次编程让我意识到一个很大的问题。以前总是在做之前想太多,总是希望能够一次性设计好整个架构,然而这是建立在一定的项目经验上的。就我目前而言暂不具备这样的水平,所以在编写程序时就很容易导致代码臃肿、逻辑混乱,从而导致难以调试。
所以这次编程更改了思路:先将项目拆分成一个个小的功能模块,底层搭好后再按照功能层级逐个实现,一个一个拼接、一块砖一块砖的搭建,边搭建边微调框架,最终完成程序的编写。这种编程思路在这次练习中起到了很大的作用。整个编程过程思路清晰、编写流畅,代码的调试也轻松许多。
以上便是我这次练习的总结,发布博客以供记录、总结,博客中如有纰漏欢迎指出,欢迎讨论、共同进步。