专题实验:基于Socket和epoll的网络聊天室
1. 实验环境
操作系统:Ubuntu 20.04.6 LTS
内核版本:5.15.0-89-generic
编译器:gcc (Ubuntu 9.4.0-1ubuntu1~20.04.2) 9.4.0
2. 背景介绍
2.1 socket编程
2.1.1 socket()
该函数用于创建一个套接字。
int socket(int domain, int type, int protocol)
返回值
:成功返回文件描述符,失败返回-1domain
:协议域,AF_INET
代表使用IPv4type
:socket类型,SOCK_STREAM
代表使用TCP传输protocol
:协议,0
表示使用默认协议
2.1.2 bind()
该函数用于将一个特定地址绑定到一个套接字。
int bind (int sockfd, const struct sockaddr *addr, socklen_t addrlen)
返回值
:成功返回0,失败返回-1sockfd
:socket文件描述符addr
:绑定给该sockfd的协议地址addrlen
:对应结构体长度,即sizeof(addr)
2.1.3 listen()
服务器中的进程调用该函数监听客户端的连接。
int listen (int sockfd, int backlog)
返回值
:成功返回0,失败返回-1sockfd
:socket文件描述符backlog
:等待建立连接的队列长度
2.1.4 accept()
服务器用于接受客户端的连接请求。
int accept (int sockfd, struct sockaddr *addr, socklen_t *addrlen)
返回值
:成功返回socket文件描述符,失败返回-1sockfd
:socket文件描述符addr
:绑定给该sockfd的协议地址addrlen
:对应结构体长度,即sizeof(addr)
注意,这里的addr
是传出参数,addrlen
是传入传出参数。
2.1.6 connect()
客户端用于发起对服务器的连接请求。
int connect (int sockfd, const struct sockaddr *addr, socklen_t addrlen)
返回值
:成功返回0,失败返回-1sockfd
:socket文件描述符addr
:请求连接的服务器的地址信息addrlen
:对应结构体长度,即sizeof(addr)
2.1.6 recv()
接收端用于接收数据。
ssize_t recv(int socket, void *buf, size_t len, int flags)
返回值
:失败返回-1,成功返回接收到的字节数。返回值为0时一般代表超时或对应端主动关闭。sockfd
:socket文件描述符buf
:缓冲区,用来存放收到的数据len
:缓冲区的长度flags
:0表示默认
2.1.7 send()
发送端用于发送数据。
ssize_t send(int sockfd, const void *buf, size_t len, int flags)
返回值
:失败返回-1,成功返回发送出的字节数。返回值为0时一般代表超时或对应端主动关闭。sockfd
:socket文件描述符buf
:缓冲区,用来存放发送的数据len
:缓冲区的长度flags
:0表示默认
2.2 epoll
首先需要了解epoll
的相关函数,这里不对底层知识作过多介绍,只需要了解epoll是利用红黑树实现的。
主要使用的epoll函数有3种,<sys/epoll>中只定义了5个函数 。
epoll.h的定义可以在/usr/include/x86_64-linux-gnu/sys/epoll.h
中找到,关于它的实现需要在linux内核源代码编译后获得,本机中有4.9.291
版本的内核源代码的编译后的文件,可以在/fs/eventpoll.c
中找到实现。
2.2.1 epoll结构体
epoll的结构体主要有两个数据结构
epoll事件
struct epoll_event
{
uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
} __EPOLL_PACKED;
和存有用户数据变量的data
typedef union epoll_data
{
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
在后续的实验中,我们会经常对其中的fd进行操作。
2.2.2 epoll_create和epoll_create1
这两个函数用于创建epoll实例
int epoll_create(int size)
,参数size用于指定 epoll 实例能处理的最大文件描述符数,事实上,这个参数并不生效,因此在使用时常设置为0。
int epoll_create1(int flags)
,flags是额外的标志,当它为0时,效果与使用前一个函数相同。常见的值是EPOLL_CLOEXEC
,它会在执行 exec
时关闭文件描述符,这可以防止文件描述符的泄露。
当创建epoll实例成功时,将会返回epoll文件描述符,失败则会返回-1
。与想象中不同,系统调用是利用create1
实现了create
。
2.2.3 epoll_ctl
epoll_ctl
用于在 epoll 实例中注册或修改文件描述符上的事件
int epoll_ctl(int epfd, int op, int fd, struct *epoll_event event)
返回值
:成功返回0,失败返回-1。
epfd
: epoll实例 的文件描述符。op
:EPOLL_CTL_ADD
(注册事件)、EPOLL_CTL_MOD
(修改事件)或EPOLL_CTL_DEL
(删除事件)。fd
:事件绑定的文件描述符。event
:struct epoll_event
结构体,用于指定关注的事件类型和关联的用户数据。
epoll中的触发方式分为水平触发(LT)和边缘触发(ET),epoll默认的触发方式为水平触发。它们在监听读事件时的区别主要在于
- LT时,若读缓冲区有数据,则会一直触发事件,直到数据被读完。
- ET时,读缓冲区数据从无到有将会触发事件,需要一次性将数据读完,否则将会丢失。
由于我们实现程序的特殊性,因此将使用边缘触发的方式。
2.2.4 epoll_wait和epoll_pwait
这两个函数用于等待epoll实例上的事件发生
int epoll_wait(int epfd, struct *epoll_event events, int maxevents, int timeout)
返回值
:就绪事件的个数,-1表示失败,0表示超时
epfd
: epoll 文件描述符。events
:事件数组,用于存储发生的事件maxevents
:指定events
数组的最大长度timeout
: 指定等待事件发生的超时时间,单位是毫秒。如果设置为-1
,则表示阻塞直到有事件发生。
pwait函数相较于wait函数只多了一个参数。
int epoll_pwait(int epfd, struct *epoll_event events, int maxevents, int timeout, const sigset_t *sigmask)
sigmask
:允许在等待期间阻塞特定的信号。
2.3 需求分析
实验文档中并没有给出具体的实现方式,因此我们将在此节中明确需求。
针对客户端:
-
接受三个参数,后两个参数分别为服务器IP和服务器端口号。
-
在输入消息前打印提示符
>
,该提示符不会被读取到消息中。 -
按下回车键时将会向服务器发送消息,该消息会向其他在线客户端广播。
-
可以接受来自服务器的广播消息并在终端输出,输出内容包含发送者的IP和端口号。
-
用户可以输入exit退出程序。
针对服务器:
- 接受三个参数,后两个参数分别为服务器IP和服务器端口号。
- 可以从客户端接收消息,并将该消息向其他在线客户端广播。
- 可以监听客户端的连接与断开。
2.4 epoll相较于select的优势
尝试使用其他多路复用IO如select实现了相关的客户端和服务器代码,但是由于不符合该专题实验的内容主题因此没有在此处展示。
epoll相比select是高效的,原因既有select的固有缺点,也有epoll的改进优点。
select存在的问题:
- fd_set的本质是数组,默认大小为1024,因此即使可以手动更改,这个空间也是有限的。
- select在内核态和用户态切换时需要复制fd_set,这造成了大量的开销。
- select需要持续遍历fd_set来检查是否有事件发生,这一过程的时间开销随监听文件描述符的增加而线性增加。
epoll的改进优点:
- epoll使用红黑树存储监听的文件描述符,红黑树可以在 O ( l o g ( n ) ) O(log(n)) O(log(n))时间内插入和删除
- epoll不需要在内核态和用户态间拷贝数组,红黑树的维护在内核态中实现。
- epoll不需要轮询,通过事件回调会将就绪的事件数组发送到用户态处理。
3.客户端实现
客户端主要需要实现两个功能
- 接收并打印来自服务器的广播消息
- 接收用户终端输入并向服务器发送消息
3.1 监听广播消息
可以考虑使用fgets()
函数获得用户在终端的输入,但是需要注意的是,这一过程将会阻塞进程,因此需要开辟子进程实现对来自服务器广播消息的监听。
// 处理来自服务器的广播消息
void handle_server_messages(int sockfd)
{
char buf[MAX_BUF_LENGTH] = {0};
// 创建 epoll实例
int epollfd = epoll_create1(0);
if (epollfd == -1)
{
perror("epoll_create1");
exit(-1);
}
// 添加epoll事件
struct epoll_event ev, events[1];
ev.events = EPOLLIN;
ev.data.fd = sockfd;
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, sockfd, &ev) == -1)
{
perror("epoll_ctl: sockfd");
exit(-1);
}
while (1)
{
// 出错返回-1,超时返回0,完成返回就绪事件个数
int nfds = epoll_wait(epollfd, events, 1, -1);
if (nfds == -1)
{
perror("epoll_wait");
exit(-1);
}
else if (nfds == 0)
{
perror("timeout");
exit(-1);
}
for (int i = 0; i < nfds; ++i)
{
if (events[i].data.fd == sockfd)
{
recv(sockfd, buf, MAX_BUF_LENGTH, 0);
printf("\nBroadcast From %s >", buf);
memset(buf, 0, sizeof(buf));
}
}
}
}
事实上,随后在测试中发现了如下问题,考虑到发送的消息中一定含有换行符,因此在打印新的消息提示符前无需添加换行符,但是该换行符经常不能在正确的时间被打印,经过研究发现,是printf的缓冲机制,将会等待换行符,因此在收到下一条广播消息时,才会在终端显示>
,选择在打印后手动清除缓冲区。
即修改上面代码块的第43行为
printf("\nBroadcast From %s", buf);
printf(">");
fflush(stdout);
这样就解决了命令提示符>
错位的问题。因为没有对buf
进行任何处理,因此buf
在将近结束位置将会带有换行符\n
。printf
函数会将该换行符之前的内容打印在标准输出上,而后面的>
就留在了缓冲区中,直到下一条广播消息到达,随下一条广播消息而被打印出来。在打印>
后强制刷新缓冲区就能解决以上问题。
随后还发现没有准备该子进程的退出函数,因此当客户端使用exit
退出时,或者服务器停止时,该子进程不会随主进程退出而终止,而是循环打印printf
语句,因此将recv
修改为
int buf_len = recv(sockfd, buf, MAX_BUF_LENGTH, 0);
if (buf_len <= 0)
{
if (buf_len < 0)
{
perror("recv");
}
else
{
perror("exit");
}
exit(-1);
}
recv
返回值小于0表示出错,等于0一般表示超时或主动断开连接,这样就解决了退出问题。
3.2 发送广播消息
主函数中如下编写,主函数主要进行建立客户端和服务器之间的套接字连接,创建监听子进程,和接收用户输入并向服务器发送消息。由于在本实验中,只允许用户通过终端输入消息,因此在发送时无需采用epoll实现并发。
值得注意的是,由于允许用户输入exit
退出聊天,因此需要使用strcmp
比较用户输入与"exit\n"
是否相等,若相等,则向服务器发送消息后退出,需要在服务器端添加相同的检测机制来手动处理用户的退出。
int main(int argc, char *argv[])
{
int sockfd;
struct sockaddr_in servaddr;
char buf[N] = {0};
if (argc < 3)
{
printf("Usage: %s ip port\n", argv[0]);
return 0;
}
// 创建TCP套接字连接
if ((sockfd = socket(AF_INET, SOCK_STREAM, 0)) == -1)
{
perror("socket");
exit(-1);
}
// 初始化服务器结构
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(atoi(argv[2]));
servaddr.sin_addr.s_addr = inet_addr(argv[1]);
if (connect(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) == -1)
{
perror("connect");
exit(-1);
}
printf("Connected to the server\n");
printf(">");
// 考虑到主进程在等待用户输入时会被阻塞,因此新开一个子进程监听来自服务器的消息
pid_t pid = fork();
if (pid == -1)
{
perror("fork");
exit(-1);
}
else if (pid == 0) // 子进程
{
// 子进程负责监听服务器消息
handle_server_messages(sockfd);
exit(0);
}
else // 父进程
{
while (1)
{
if (fgets(buf, N, stdin) != NULL)
{
if (strcmp(buf, "exit\n") == 0)
{
send(sockfd, buf, strlen(buf), 0);
close(sockfd);
exit(0);
}
printf(">");
send(sockfd, buf, strlen(buf), 0);
memset(buf, 0, sizeof(buf));
}
}
}
close(sockfd);
return 0;
}
主进程方面并没有什么值得注意的问题,注意使用while(1)
嵌套保证循环输入,并且fgets
会阻塞并读入最后的换行符\n
。
4. 服务器实现
server.c
的程序较长,根据功能来将代码切片后查看,首先是服务器的创建工作。
4.1 服务器初始化
下面是server.c
用到的一些数据结构,还有其他数据结构在具体应用时给出。
int listenfd, connfd, epollfd; //文件标识符
struct sockaddr_in myaddr, peeraddr; //服务器地址结构
socklen_t len = sizeof(peeraddr);
char buf[N] = {0}; //消息缓冲区
ssize_t n;
与客户端类似,开始阶段需要初始化服务器结构,创建相应的套接字,使用bind
绑定自身地址,在bind
和accept
间需要调用listen
监听连接请求,listen
会将listenfd
对应的文件描述符转为被动状态,等待其他套接字主动发起连接。
// 用法为 ./可执行文件名 IP port
if (argc < 3)
{
printf("Usage: %s ip port\n", argv[0]);
return 0;
}
//创建使用IPv4地址的TCP连接,0表示使用默认协议
if ((listenfd = socket(AF_INET, SOCK_STREAM, 0)) == -1)
{
perror("socket");
exit(-1);
}
// 初始化服务器地址
memset(&myaddr, 0, sizeof(myaddr));
myaddr.sin_family = AF_INET;
myaddr.sin_port = htons(atoi(argv[2]));
myaddr.sin_addr.s_addr = inet_addr(argv[1]);
//绑定服务器的地址结构
if (bind(listenfd, (struct sockaddr *)&myaddr, sizeof(myaddr)) == -1)
{
perror("bind");
exit(-1);
}
//监听请求,在bind()之后,accept()之前调用
if (listen(listenfd, MAX_CLIENTS/2) == -1)
{
perror("listen");
exit(-1);
}
printf("Server is running on %s:%d\n", inet_ntoa(myaddr.sin_addr), ntohs(myaddr.sin_port));
4.2 epoll实例与事件
接下来是epoll的创建,并初始化用来记录连接用户及其IP地址和端口号的数组。由于以Broadcast From ${IP}:${port}>
的形式打印广播消息,因此需要在向客户端广播消息时,将IP和port写入缓冲数组中,这里将在后面介绍。
epollfd = epoll_create1(0);
if (epollfd == -1)
{
perror("epoll_create1");
exit(-1);
}
struct epoll_event ev, events[MAX_EVENTS];
ev.events = EPOLLIN;
ev.data.fd = listenfd;
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, listenfd, &ev) == -1)
{
perror("epoll_ctl: listen_sock");
exit(-1);
}
int client_sockets[MAX_CLIENTS] = {0};
struct sockaddr_in client_addrs[MAX_CLIENTS];
4.3 服务器主循环
下面内容将被写在while(1)
循环中,以确保服务器能够在运行时重复进行相关操作。epoll_wait
函数将会在成功时返回就绪的事件数,使用for循环遍历数组对每一个事件进行处理。
while (1)
{
int nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1);
if (nfds == -1)
{
perror("epoll_wait");
exit(EXIT_FAILURE);
}
for (int i = 0; i < nfds; ++i)
{
处理events[i]
... // 余下内容
}
// 退出操作
close(listenfd);
close(epollfd);
return 0;
}
4.3.1 处理连接请求
由于只为epoll添加了两种事件,因此只需要使用一个if-else
即可。
下面代码片段中有一个不该出现的for
循环,这是用来寻找一个空闲的地址存放新连接用户的地址和文件描述符等信息,虽然C语言没有提供哈希表的库函数,但是可以考虑后续使用经验证的uthash
库替换。
accept
函数用于服务器接受来自客户端的连接,当接受来自新客户端的连接后,打印该客户端的IP地址和端口号。同时为该客户端添加epoll事件,并将该客户端的文件描述符和相关信息存储在之前开辟的数组空间中。
// 处理连接请求
if (events[i].data.fd == listenfd)
{
if ((connfd = accept(listenfd, (struct sockaddr *)&peeraddr, &len)) == -1)
{
perror("accept");
exit(-1);
}
printf("New connection from %s:%d\n", inet_ntoa(peeraddr.sin_addr), ntohs(peeraddr.sin_port));
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = connfd;
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, connfd, &ev) == -1)
{
perror("epoll_ctl:conn_sock");
exit(-1);
}
for (int i = 0; i < MAX_CLIENTS; ++i)
{
if (client_sockets[i] == 0)
{
client_sockets[i] = connfd;
memcpy(&client_addrs[i], &peeraddr, sizeof(struct sockaddr_in));
break;
}
}
}
4.3.2 接收消息
除了连接请求外的请求,代表有客户端向服务器发送了消息,在服务器使用recv
接收send
来的数据。
else
{
int sockfd = events[i].data.fd;
memset(buf, 0, sizeof(buf));
n = recv(sockfd, buf, N, 0);
... // 其余代码
// 1. 处理退出事件
// 2. 处理广播事件
}
根据recv
函数的返回值,需要使用一个if-else语句块来进行处理。当出错、超时或客户端关闭连接时,清除对应的事件和相关数据结构。
在客户端中,使用输入exit
作为退出命令,因此可以在服务器端进行判断,如果发送的消息为exit则删除该客户端对应的epoll事件,可以将相关判断写在epoll_wait()
失败或超时的条件语句中,因为它们执行相同的退出操作。这里使用for循环的原因和之前相同。
I 出错、超时或客户端断开连接
// 出错、超时或客户端手动发送退出指令
if (n <= 0 || strcmp(buf, "exit\n") == 0)
{
int i;
for (i = 0; i < MAX_CLIENTS; ++i)
{
if (client_sockets[i] == sockfd)
{
client_sockets[i] = 0;
break;
}
}
printf("Client %d disconnected\n", i);
close(sockfd);
epoll_ctl(epollfd, EPOLL_CTL_DEL, sockfd, NULL);
}
由于使用数组维护已经建立连接的客户端,因此需要找到该客户端对应的数组下标,并将其置为0,允许新连接使用该位置。关闭相关套接字并删除对应的epoll事件。
II 广播消息
如果没有进入前面的if判断,就说明正确接收到了广播消息,首先使用for循环找到发送消息的端口号,取出对应的IP地址和端口号,并使用sprintf
函数重新格式化消息,将扩展后的消息发送给每一个当前在线的客户端。
else // 广播消息
{
// 寻找发送消息的进程的端口号
int index = 0;
for (int j = 0; j < MAX_CLIENTS; ++j)
{
if (client_sockets[j] == sockfd)
{
index = j;
break;
}
}
printf("Received from %s:%d: %s", inet_ntoa(client_addrs[index].sin_addr), ntohs(client_addrs[index].sin_port), buf);
char extended_buf[MAX_BUF_LENGTH];
sprintf(extended_buf, "%s:%d: %s", inet_ntoa(client_addrs[index].sin_addr), ntohs(client_addrs[index].sin_port), buf);
// 广播消息
for (int j = 0; j < MAX_CLIENTS; ++j)
{
if (client_sockets[j] > 0 && client_sockets[j] != sockfd)
{
send(client_sockets[j], extended_buf, strlen(extended_buf), 0);
}
}
}
这样,就完成了客户端和服务器端的编写。
5. 存在问题和优化方向
由于我们期望能够向所有客户端发送消息的同时,附带发送者的IP和端口号,因此我们需要一个数据结构来存放所有已连接的客户端的IP和端口号,这里我们简单地采用了数组存放,寻找数组的下标需要
O
(
n
)
O(n)
O(n)时间。在后续优化中,可以考虑借助哈希表实现文件描述符到下标的映射,在
O
(
1
)
O(1)
O(1)时间内获得结果,避免冗余的遍历。但是C语言中没有哈希表的实现,可以考虑手动实现或者采用经过验证的开源库如uthash
。
此外在实验中由于主函数会被阻塞,本实验中选择使用fork()
创建子进程来实现监听。后续中可以考虑采用pthread
,使用更加轻量级的线程替换掉进程。
6. 效果演示
首先编译两个程序,获得可执行文件。
运行服务器
新开两个终端窗口,分别运行客户端,在服务器的终端中看到打印消息。
我们这里使用第二个窗口中的客户端发送一条消息Hello World
在服务器和另一个客户端中都看到了这一条消息,并且端口号正确。
验证该服务器-客户端程序能够正常工作。
7. 实验总结
本实验中采用Socket API和epoll实现了可并发的聊天服务器,虽然使用epoll相较于使用select来说代码更加复杂,但是提高了处理的效率。
在本学期的网络程序设计中,我对于网络编程有了更深入的认识。如javascript编程中,我在回顾了vue及其使用的ajax库axios如何与后端服务器进行通信的同时,学习使用了更轻量级的后端服务器和数据库实现。express.js相比于Spring Boot更加方便部署,对于实现本章实验的小应用来说使用Spring有些过于复杂,并且也学习了怎么直接对json文件进行读写和mongodb的使用方法。
本学期同时还学习了嵌入式Linux课程,所以后面的socket编程和内核网络协议栈来说是两门课互有补充,相较于原来的认识有了更深入的了解,同时将知识串联在一起,对于Ubuntu系统的使用也更熟练,gRPC则是在已有知识外的一些扩展。总之网络程序设计绝对是一门好课。