求html码源网站,建站行业新闻,免费com域名申请注册,做oa系统的网站文章目录 1 前置知识1.1 Socket编程基础Socket概述Socket通信模型Socket API一个简单的Socket编程实例 1.2 IO多路复用1.3 阻塞原理 2 epoll原理2.1 epoll概述2.2 epoll系统调用epoll_create()epoll_ctl()epoll_wait() 2.3 epoll工作原理 3 示例代码及演示 1 前置知识
1.1 Soc… 文章目录 1 前置知识1.1 Socket编程基础Socket概述Socket通信模型Socket API一个简单的Socket编程实例 1.2 IO多路复用1.3 阻塞原理 2 epoll原理2.1 epoll概述2.2 epoll系统调用epoll_create()epoll_ctl()epoll_wait() 2.3 epoll工作原理 3 示例代码及演示 1 前置知识
1.1 Socket编程基础
Socket概述 Socket是什么 Socket是一种进程之间通信的方法允许同一主机或(通过网络连接起来的)不同主机上的应用程序进行数据交换。由于Socket起源于UNIX继承自UNIX“一切皆文件”的思想因此Socket本身就一种特殊的文件。 操作Socket的核心——文件描述符 既然Socket本身就是文件那Socket函数(Socket API)对Socket的操作本质上就是对文件的操作。内核为了高效管理打开的文件会为每一个文件创建一个称为文件描述符(file descriptor)的索引。所有对文件进行I/O操作的系统调用都需要经过文件描述符。因此在编写Socket代码时操作一个创建好的Socket都需要经过文件描述符这一句柄。 和Epoll什么关系 对于高并发的服务型程序Socket连接需要处理大批量的文件描述符(连接数可达几十上百万)简单轮询如此大规模的文件描述符普通的方法是行不通的。而Epoll就是为此而生的能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率。关于Epoll的详细介绍会在之后的小节中展示
Socket通信模型 下图展示了Socket的通信模型(篇幅有限以使用TCP协议为例)。每一个过程对应一个Socket API。在编写Socket程序时需要遵循图中的顺序。
Socket API 下面对Socket API进行简单介绍。与上图是对应的可以对比学习。
socket() 函数在一个通信域(domain)中创建一个(未绑定的)Socket并返回一个文件描述符之后介绍的Socket API就可以使用该文件描述符对Socket进行操作啦 函数原型int socket(int domain, int type, int protocol); domain指明一个创建Socket的通信域类型。 PF_INET协议族指定协议时用 AF_INET地址族设置地址时用。 type指明待创建Socket的类型。 SOCK_STREAM流服务TCP协议 SOCK_DGRAM数据报服务UDP协议。 protocol指明待创建Socket使用的协议。 若为0则使用适合请求Socket类型的默认协议(一般取0) 返回值创建好的Socket的文件描述符应为非负整数若创建失败则返回-1。 bind() 为一个(由socket函数创建的)未命名Socket绑定一个Socket地址。 函数原型int bind(int socket, const struct sockaddr *address, socklen_t address_len); socketSocket的文件描述符。 address指明一个要绑定的Socket地址(指向sockaddr结构的指针)。 address_len指明Socket地址的长度(sockaddr结构体的长度) 若为0则使用适合请求Socket类型的默认协议(一般取0) 返回值成功绑定返回0否则返回-1。 注意Socket编程使用sockaddr结构体来管理Socket地址而sockaddr_in结构体是其对应的Internet风格。二者关系具有相同的长度并且可以相互强制转换指针。设计上类似基类和派生类的关系Socket API的参数通常为更通用的“基类”sockaddr指针。在你想使用sockaddr_in指针作为参数时需要先对其进行强制转换 sockaddr_in结构体字段
struct sockaddr_in {__uint8_t sin_len; // 结构体sin的长度sa_family_t sin_family; // 地址族必须设为AF_INET(表示IPv4协议)in_port_t sin_port; // 端口(2B)struct in_addr sin_addr; // IPv4地址(4B)char sin_zero[8]; // 未使用设置为0(8B)
};// in_addr结构体表示一个IPv4地址
struct in_addr {in_addr_t s_addr;
};listen() 监听Socket连接并且可以限制监听队列长度(连接的数量)。 函数原型int listen(int socket, int backlog); socketSocket描述符。 backlog指明监听队列长度(连接的数量)。 若小于0则设置为0 若大于Socket监听队列支持的最大长度则设置为最大长度。 返回值成功监听返回0否则返回-1。 accept() 顾名思义就是在Socket接受一个新的连接。具体来说是从监听队列中出队一个新的Socket连接然后创建与其具有相同Socket类型协议和地址族的新Socket并为之分配一个新的文件描述符。 函数原型int accept(int socket, struct sockaddr *address, socklen_t *address_len); socket正在监听的Socket描述符。 address连接对方的Socket地址(指向sockaddr结构的指针) address_len连接对方的Socket的地址长度(sockaddr结构体的长度) 返回值连接对方的Socket的文件描述符应为非负整数 若创建失败则返回-1。 send() 在Socket上向连接的对方发送一条消息。 函数原型ssize_t send(int socket, const void *buffer, size_t length, int flags); socketSocket描述符。 buffer指向包含所要发送消息的buffer数组。 length指明消息长度(字节)。 flags指明消息传输的类型设置为0或者0与以下flag相或(|) MSG_EOR终止一个记录 MSG_OOB在支持带外通信的Socket上发送带外数据。 返回值发送成功返回数据字节数否则返回-1。 recv() 从已连接的对方Socket接收信息 函数原型ssize_t recv(int socket, void *buffer, size_t length, int flags); socketSocket描述符。 buffer指向用于接收消息的buffer。 length指明消息长度(字节)。 flags指明消息传输的类型设置为0或者0与以下flag相或(|) MSG_EOR终止一个记录 MSG_OOB在支持带外通信的Socket上发送带外数据。 返回值接收成功返回数据字节数否则返回-1。 一个简单的Socket编程实例 在读者熟悉Socket API的使用后可以尝试理解如下源码(server.c、client.c)。该程序中Client需要向Server发起连接以从Server获取需要的信息(14字节长的Hello, World!\n)。Server则不断接受Client发起的请求并向其发送信息。 server.c
#include stdio.h
#include stdlib.h
#include sys/types.h
#include sys/wait.h
#include string.h
#include unistd.h
#include sys/socket.h
#include arpa/inet.h
#include netdb.h#define true 1
#define false 0#define MYPORT 3490 // Server监听的端口
#define BACKLOG 10 // listen的请求接收队列长度int main()
{int sfd; // 存放创建好的服务器监听端口(sfd - socket file descriptor)if ((sfd socket(PF_INET, SOCK_STREAM, 0)) -1) {perror(Socket创建失败);exit(1);}struct sockaddr_in sa; // 存放Server自身的Socket地址信息(sa - socket address)sa.sin_family AF_INET;sa.sin_port htons(MYPORT); // htons将主机字节序转换为网络字节序sa.sin_addr.s_addr INADDR_ANY; // 自动填本机IPmemset((sa.sin_zero), 0, 8); // 其余部分置0if (bind(sfd, (struct sockaddr *)sa, sizeof(sa)) -1) {perror(Bind失败);exit(1);}if (listen(sfd, BACKLOG) -1) {perror(Listen失败);exit(1);}struct sockaddr_in gas; // 存放连接对方(客户端)的Socket地址信息(gas - guest addresses)unsigned int sin_size sizeof(struct sockaddr_in); // sockaddr_in结构体的大小// 主循环while (true) {int new_fd accept(sfd, (struct sockaddr *)gas, sin_size);if (new_fd -1) {perror(Accept失败);continue;}printf(获得来自%s的连接\n, inet_ntoa(gas.sin_addr));if (fork() 0) { // 子进程fork返回0if (send(new_fd, Hello, World!\n, 14, 0) -1)perror(send);close(new_fd);exit(0);}close(new_fd);while (waitpid(-1, NULL, WNOHANG) 0); // 清除所有子进程}
}client.c
#include stdio.h
#include stdlib.h
#include sys/types.h
#include string.h
#include unistd.h
#include sys/socket.h
#include arpa/inet.h
#include netdb.h#define true 1
#define false 0#define PORT 3490 // Server监听的端口
#define MAXDATASIZE 100 // 客户端可读入的最大字节数int main(int argc, char *argv[]) {if (argc ! 2) { // 参数数量不对fprintf(stderr, 命令使用方法client hostname\n);exit(1);}struct hostent *he; // 主机(服务器)信息if ((he gethostbyname(argv[1])) NULL) {perror(Gethostbyname失败);exit(1);}int sfd; // 存放创建好的客户端监听端口(sfd - socket file descriptor)if ((sfd socket(PF_INET, SOCK_STREAM, 0)) -1) {perror(Socket创建失败);exit(1);}struct sockaddr_in sa; // 服务器地址信息sa.sin_family AF_INET;sa.sin_port htons(PORT); // htons将主机字节序转换为网络字节序sa.sin_addr *((struct in_addr *)he - h_addr_list[0]); // 指定服务器IPmemset((sa.sin_zero), 0, 8); // 其余部分置0if (connect(sfd, (struct sockaddr *)sa, sizeof(struct sockaddr)) -1) {perror(Connect失败);exit(1);}int numbytes;char buf[MAXDATASIZE];if ((numbytes recv(sfd, buf, MAXDATASIZE, 0)) -1) {perror(recv);exit(1);}buf[numbytes] \0;printf(接收到数据: %s, buf);close(sfd);return true;
}运行效果 先将源码编译为可执行文件
gcc client.c -o client
gcc server.c -o server开启一个终端执行server 开启另一个终端执行client 可以观察到Server端输出了成功获得Client端的连接信息而Client端也成功从Server端获取到需要的数据。
1.2 IO多路复用
1.3 阻塞原理 网卡是怎么接收到网络上的数据的 网卡从网线接收到传来的数据再经过硬件电路的传输最终将数据写入内存的某个地址上。 CPU怎么知道接收了数据 当网卡通过上面的过程将数据写入内存后网卡就会向CPU发送中断信号以告知有数据到来。 阻塞原理 从进程调度的角度来看若进程在等待某一事件(如等待接收网络数据)则会在事件发生之前进入阻塞状态(也叫等待状态)。Socket API中的recv函数和epoll本质上都是阻塞方法。 下面通过一个例子来理解阻塞过程。假设一个接收客户端消息的服务器的Socket代码具有以下结构
int sfd socket(AF_INET, SOCK_STREAM, 0);
bind(sfd, ...);
listen(sfd, ...);
int new_sfd accept(sfd, ...);
recv(sfd, ...); // 会发生阻塞在程序执行到recv时就会进入阻塞状态一直等待直到接收到数据才会往下执行。如下图所示假设进程1会执行上面这个Socket程序。 在进程1被操作系统调度后会为其创建一个由文件系统管理的Socket对象包含发送缓冲区、接受缓冲区和等待队列等成员。等待队列指向了所有需要等待该Socket事件的进程。当进程1执行到recv函数时会因为等待数据而被加入该Socket对象的等待队列中如下图所示 主机从网线接收数据到网卡、写入内存以后CPU接收到中断信号进行中断处理接收数据。直到数据接收完成才会唤醒进程1重新放回工作队列中。 从上面这个单个连接的例子来看Socket程序线性执行过程中的阻塞过程是简单明了的。但存在多个连接、多个Socket对象时又如何知道哪些数据到达、唤醒哪些进程呢这就需要使用到epoll技术了。
2 epoll原理
2.1 epoll概述
epoll是什么 epoll是一个Linux实现IO多路复用的一种(最佳)工具用于可扩展的I/O事件通知机制管理具有可读可写事件的文件描述符(即fd、句柄后文可能混用这三个术语)。在Linux内核的2.5.45版本中被首次引入。 它维护一个监视列表(兴趣列表)监视多个文件描述符查看是否可以在其中任何一个文件上进行I/O操作。相比旧的select和poll系统调用能在要求更高的应用程序中实现更好的性能。
应用场景 Epoll经常应用于Linux下高并发服务型程序。尤其适合大量并发连接中只有少部分连接处于活跃下的情况。在这种情况下Epoll能显著的提高程序的CPU利用率。
四个特点 多路复用 事件驱动 水平触发和边缘触发 高性能 2.2 epoll系统调用 epoll提供三种系统调用epoll_create、epoll_ctl和epoll_wait。简单粗暴地理解epoll_create负责创建一个epoll池用于监控和管理fd。epollctl负责对这个池子里的fd进行增删改。epoll_wait负责在没有事件时阻塞epoll以免占用CPU资源一旦有事件则会唤醒。
epoll_create() 创建一个epoll对象(epoll池)并返回一个文件描述符。这个epoll对象对用户而言是黑盒无需考虑其中的细节。 epoll_create1()是epoll_create()的新版本epoll_create()在Linux内核版本2.6.27和glibc版本2.9中被废除。 函数原型int epoll_create(int size);、int epoll_create1(int flags); flags用于改变epoll的行为不改变取0除此以外只有EPOLL_CLOEXEC一种特殊取值。 返回值创建成功返回一个非负整数的文件描述符否则返回-1。 使用示例
efd epoll_create1();
if (efd -1) {perror(epoll_create error);exit(-1);
}epoll_ctl() 用于控制(配置)epoll对象监视的文件描述符和事件。创建好epoll池就可以通过epoll_ctl系统调用来添加fd啦 函数原型int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); epfdepoll_create1()创建得到的文件描述符。 op执行在目标文件描述符fd上的操作。 EPOLL_CTL_ADD在epoll文件描述符epfd的兴趣列表中添加一条记录。记录包括文件描述符fd、对fd相应打开文件描述的引用以及通过event声明的设置。 EPOLL_CTL_MOD将兴趣列表中fd的设置改变为event中新指定的设置。 EPOLL_CTL_DEL从兴趣列表删除fd的记录。event中指定的设置将被忽略(可直接设置为NULL)。 fd被操作的文件描述符fd。 event指向fd所连接的对象(epoll_event结构体)。epoll_event结构体介绍见后文。 返回值若成功返回0否则返回-1。 epoll_event结构体 该结构体指明了内核应该存储的数据以及在数据准备好时应该返回的对应文件描述符。
struct epoll_event {uint32_t events; /* Epoll events */epoll_data_t data; /* User data variable */
};union epoll_data {void *ptr;int fd;uint32_t u32;uint64_t u64;
};typedef union epoll_data epoll_data_t;其中数据成员data指明了内核应该存储的数据以及在数据准备好时应该返回的对应文件描述符。数据成员events则是由0或者其他event类型相或(|)所得的值用于影响event的行为。 event类型详见 使用示例
// 将aFd句柄加入epoll池中
if (epoll_ctl(efd, EPOLL_CTL_ADD, aFd, aEvent) -1) {perror(epoll_ctl error);exit(-1);
}epoll_wait() 等待一个epoll文件描述符上的I/O事件。调用epoll_wait()会一直阻塞直到以下情况发生 (1) 一个文件描述符触发了一个事件 (2) 调用被信号处理程序中断 (3) 超过参数timeout指定的时间。 函数原型int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); epfdepoll_create1()创建得到的文件描述符。 events指向调用者可以使用的事件的内存区域。 maxevents告知内核events的数量必须大于0。 timeout指明阻塞的毫秒数时间是根据CLOCK_MONOTONIC时钟测量的。timeout -1函数会永远阻塞下去timeout 0立刻返回。 返回值若成功返回0否则返回-1。 2.3 epoll工作原理 熟悉epoll的系统调用后相信读者已经能对epoll的工作机制有点感觉了。总结来说当某个进程调用epoll_create方法时内核会创建一个eventpoll对象(即文件描述符epfd所代表的对象可以通俗地称之为epoll池)。之后就可以往池子里增、删、改需要监视(感兴趣)的fd这需要使用epoll_ctl调用。 epoll使用红黑树管理epoll池 为了高效地对eventpoll中的fd进行增、删、改自然就需要考虑高效的数据结构。为此Linux内核使用红黑树来实现管理epoll池中的fd。红黑树是一种平衡二叉树其增删改操作时间复杂度为 O ( log n ) O(\text{log}n) O(logn)能够保证稳定的查找性能。 poll回调机制 为了在数据准备后能让epoll发现还需要依靠poll回调机制。在Linux内核中文件的操作定义为结构体struct file_operation。其中的成员函数poll能够与底层交互fd一旦读写就绪底层硬件(网卡)会调时就会把fd对应的结构体放到就绪队列中唤醒进程。epoll池在通过epoll_ctl添加fd时就会调用poll函数把fd就绪之后的回调路径提前设置好。通过这种事件通知的方式实现高效运行。
3 示例代码及演示 utility.h
#ifndef UTILITY_H_INCLUDED
#define UTILITY_H_INCLUDED#include iostream
#include list
#include sys/types.h
#include sys/socket.h
#include netinet/in.h
#include arpa/inet.h
#include sys/epoll.h
#include fcntl.h
#include errno.h
#include unistd.h
#include stdio.h
#include stdlib.h
#include string.h#define true 1
#define false 0using namespace std;listint clientsList; // 存放所有用户的fd#define SERVER_IP 127.0.0.1 // 服务器IP
#define SERVER_PORT 8888 // 服务器端口号
#define EPOLL_SIZE 5000 // epoll大小
#define BUF_SIZE 0xFFFF // 缓冲区大小
#define SERVER_WELCOME 欢迎加入聊天室!\n您的ID是: #%d // 聊天室欢迎用户信息格式
#define SERVER_MESSAGE 用户 #%d %s // 用户发言信息格式
#define EXIT EXIT
#define CAUTION 当前只有您一名用户无法聊天!int setnonblocking(int sockfd) {fcntl(sockfd, F_SETFL, fcntl(sockfd, F_GETFD, 0) | O_NONBLOCK);return 0;
}void addfd(int epollfd, int fd, bool enable_et ) {struct epoll_event ev;ev.data.fd fd;ev.events EPOLLIN;if( enable_et )ev.events EPOLLIN | EPOLLET;epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, ev);setnonblocking(fd);
}int sendBroadcastmessage(int clientfd) {char buf[BUF_SIZE]; // 接收新的聊天信息char message[BUF_SIZE]; // 存放格式化的信息bzero(buf, BUF_SIZE);bzero(message, BUF_SIZE);// receive messageprintf(接收到来自用户#%d的消息\n, clientfd);int len recv(clientfd, buf, BUF_SIZE, 0);if (len 0) { // 用户关闭连接close(clientfd);clientsList.remove(clientfd); // 服务器移除用户printf(用户#%d关闭.\n 聊天室现有%d名用户\n, clientfd, (int)clientsList.size());}else { // 广播消息if (clientsList.size() 1) { // 只有1名用户在聊天室中send(clientfd, CAUTION, strlen(CAUTION), 0);return len;}sprintf(message, SERVER_MESSAGE, clientfd, buf);for (listint::iterator it clientsList.begin(); it ! clientsList.end(); it) {if(*it ! clientfd){if(send(*it, message, BUF_SIZE, 0) 0 ) {perror(发送失败);exit(-1);}}}}return len;
}
#endif // UTILITY_H_INCLUDEDepoll_server.cpp
#include utility.hint main(int argc, char *argv[]) {// 设置服务器Socket地址struct sockaddr_in sa;sa.sin_family PF_INET;sa.sin_port htons(SERVER_PORT);sa.sin_addr.s_addr inet_addr(SERVER_IP);// 创建监听的Socketint sfd socket(PF_INET, SOCK_STREAM, 0);if (sfd 0) {perror(Socket创建失败);exit(-1);}// 绑定Socket地址if(bind(sfd, (struct sockaddr *)sa, sizeof(sa)) 0) {perror(Bind失败);exit(-1);}// 监听int ret listen(sfd, 5);if (ret 0) {perror(Listen失败);exit(-1);}printf(开始监听: %s\n, SERVER_IP);// 在内核中创建事件表int epfd epoll_create(EPOLL_SIZE);if (epfd 0) {perror(epfd error);exit(-1);}printf(创建epoll, epfd %d\n, epfd);static struct epoll_event events[EPOLL_SIZE];// 向内核事件表中添加事件addfd(epfd, sfd, true);// 主循环while (true) {int cnt epoll_wait(epfd, events, EPOLL_SIZE, -1); // 记录就绪事件的数目if(cnt 0) {perror(epoll失败);break;}printf(就绪事件数目 %d\n, cnt);// 处理就绪事件(共cnt个)for (int i 0; i cnt; i) {int new_sfd events[i].data.fd;//新用户连接if (new_sfd sfd) {struct sockaddr_in ca;socklen_t client_addrLength sizeof(struct sockaddr_in);int clientfd accept(sfd, (struct sockaddr*)ca, client_addrLength );printf(用户连接: %s : %d(IP : port), 用户fd %d\n, inet_ntoa(ca.sin_addr),ntohs(ca.sin_port), clientfd);addfd(epfd, clientfd, true); // 把这个新的客户端添加到内核事件列表// 服务端用list保存用户连接clientsList.push_back(clientfd);printf(加入新的用户fd %d 至epoll中\n, clientfd);printf(当前有%d名用户在聊天室中\n, (int)clientsList.size());// 服务端发送欢迎信息printf(欢迎\n);char message[BUF_SIZE];bzero(message, BUF_SIZE);sprintf(message, SERVER_WELCOME, clientfd);int ret send(clientfd, message, BUF_SIZE, 0);if (ret 0) {perror(Send失败);exit(-1);}}//客户端唤醒//处理用户发来的消息并广播使其他用户收到信息else {int ret sendBroadcastmessage(new_sfd);if(ret 0) { perror(error);exit(-1); }}}}close(sfd);close(epfd);return 0;
}epoll_client.cpp
#include utility.hint main(int argc, char *argv[]) {// 设置服务器Socket地址struct sockaddr_in sa;sa.sin_family PF_INET;sa.sin_port htons(SERVER_PORT); // htons将主机字节序转换为网络字节序sa.sin_addr.s_addr inet_addr(SERVER_IP);// 创建socketint sfd socket(PF_INET, SOCK_STREAM, 0);if (sfd 0) {perror(Socket创建失败);exit(-1);}// 连接服务端if (connect(sfd, (struct sockaddr *)sa, sizeof(sa)) 0) {perror(Connect失败);exit(-1);}// 创建管道fd[0]用于父进程读fd[1]用于子进程写int pipe_fd[2];if (pipe(pipe_fd) 0) {perror(pipe error);exit(-1);}// 创建epollint epfd epoll_create(EPOLL_SIZE);if (epfd 0) { perror(epfd error); exit(-1); }static struct epoll_event events[2];//将sock和管道读端描述符都添加到内核事件表中addfd(epfd, sfd, true);addfd(epfd, pipe_fd[0], true);// 表示客户端是否正常工作bool clientSta true;// 聊天信息缓冲区char message[BUF_SIZE];// Forkint pid fork();if(pid 0) {perror(fork出错);exit(-1);}else if(pid 0) { // 子进程close(pipe_fd[0]); // 子进程负责写因此先关闭读端printf(请输入exit退出聊天室\n);while (clientSta) {bzero(message, BUF_SIZE);fgets(message, BUF_SIZE, stdin);// 客户输出exit,退出if(strncasecmp(message, EXIT, strlen(EXIT)) 0)clientSta 0;else { // 子进程将信息写入管道if (write(pipe_fd[1], message, strlen(message) - 1) 0) {perror(fork出错);exit(-1);}}}}else { // 父进程//父进程负责读因此先关闭写端close(pipe_fd[1]);// 主循环(epoll_wait)while (clientSta) {int epoll_events_count epoll_wait( epfd, events, 2, -1 );// 处理就绪事件for (int i 0; i epoll_events_count; i) {bzero(message, BUF_SIZE);// 服务端发来消息if (events[i].data.fd sfd) {//接受服务端消息int ret recv(sfd, message, BUF_SIZE, 0);// ret 0 服务端关闭if(ret 0) {printf(Server closed connection: %d\n, sfd);close(sfd);clientSta 0;}else printf(%s\n, message);}//子进程写入事件发生父进程处理并发送服务端else {int ret read(events[i].data.fd, message, BUF_SIZE); // 父进程从管道中读取数据if (ret 0)clientSta 0;else // 将信息发送给服务端send(sfd, message, BUF_SIZE, 0);}}}}if (pid) {// 关闭父进程和Socketclose(pipe_fd[0]);close(sfd);}else {// 关闭子进程close(pipe_fd[1]);}return 0;
}运行效果 先将源码编译为可执行文件
gcc -lstdc epoll_server.cpp -o server
gcc -lstdc epoll_client.cpp -o client开启一个终端运行服务端程序 开启另一个终端运行客户端程序 此时服务端也有用户加入的记录 由于只有一个用户因此无法聊天 再开启另一个终端运行客户端程序加入聊天室即可进行聊天