阅读完redis中的网络模型源码,该文章会基于源码是如何去实现的角度,去介绍epoll模型、事件处理。以及基于此,自己实现的多人聊天终端系统,并附带源码。
1、epoll模型介绍
epoll 是Linux内核为处理大量并发网络连接而提出的解决方案,能显著提升系统CPU利用率。epoll使用非常简单,总共只有3个API :
epoll_create
函数创建一个epoll专用的文件描述符,用于后续epoll相关API调用;epoll_ctl
函数向epoll注册、修改或删除需要监控的事件;epoll_wait
函数会阻塞进程,直到监控的若干网络连接有事件发生。
1.1 int epoll_create(int size)
输人参数size通知内核程序期望注册的网络连接数目,内核以此判断初始分配空间大小;注意在Linux 2.6.8版本以后,内核动态分配空间,此参数会被忽略。返回参数为epoll专用的文件描述符,不再使用时应该及时关闭此文件描述符。
1.2 int epoll_ctl(int epfd, int op,int fd,struct epoll_event *event )
函数执行成功时返回0,否则返回-1,错误码设置在变量errno,输入参数含义如下。
- epfd:函数
epoll_create
返回的epoll文件描述符。 - op :需要进行的操作,
EPOLL_CTL_ADD
表示注册事件,EPOLL_CTL_MOD
表示修改网络连接事件,EPOLL_CTL_DEL
表示删除事件。 - fd:网络连接的socket文件描述符。
- event:需要监控的事件,结构体
epoll_event
定义如下:
struct epoll_event {
___uint32_t events;
epoll_data_t data;
};
typedef union epoll_data {
void *ptr;
int fd;
_uint32_t u32;
__uint64_t u64 ;
} epoll_data_t ;
其中events表示需要监控的事件类型,比较常用的是EPOLLIN文件描述符可读事件,EPOLLOUT 文件描述符可写事件; data保存与文件描述符关联的数据。
1.3 int epoll_wait(int epfd,struct epoll_event * events,int maxevents,int timeout)
函数执行成功时返回0,否则返回-1,错误码设置在变量errno;输入参数含义如下:
- epfd:函数
epoll_create
返回的epoll文件描述符; - epoll_event:作为输出参数使用,用于回传已触发的事件数组;
- lmaxevents:每次能处理的最大事件数目;
- timeout :
epoll_wait
函数阻塞超时时间,如果超过timeout时间还没有事件发生,函数不再阻塞直接返回;当timeout等于0时函数立即返回,timeout等于-1时函数会一直阻塞直到有事件发生。
1.4 epoll模型剖析
在调用epoll_create
时,内核除了帮我们在epoll文件系统里建了个file结点,在内核cache里建了个红黑树用于存储以后epoll_ctl
传来的socket外,还会再建立一个rdllist双向链表,用于存储准备就绪的事件,当epoll_wait调用时,仅仅观察这个rdllist双向链表里有没有数据即可。有数据就返回,没有数据就sleep,等到timeou
t时间到后即使链表没数据也返回。
- 执行
epoll_create
时,创建了红黑树和就绪链表; - 执行
epoll_ctl
时,如果增加socket句柄,则检查在红黑树中是否存在,存在立即返回,不存在则添加到树干上,然后向内核注册回调函数,用于当中断事件来临时向准备就绪链表中插入数据; - 执行
epoll_wait
时立刻返回准备就绪链表里的数据即可。
以下看一个epoll模型实例:
(1)服务端epoll_create
创建红黑树
(2)服务端监听端口,文件描述符fd1
(3)在epoll_ctl
中注册fd1事件
(4)当客户端发起连接时,epoll_wait
获取该fd1的事件,创建文件描述符fd2,在epoll_ctl
中注册fd2事件(与当前客户端数据交互的通道);当客户端发送数据,epoll_wait
获取该fd2的事件,接收数据。
gcc -o client client.c 、gcc -o server server.c、./server 127.0.0.1 7777、./client127.0.0.1 7777,后即可发送数据
//client.c客户端代码
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/un.h>
#include <sys/time.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <fcntl.h>
#include <string.h>
#include <netdb.h>
#include <errno.h>
#include <stdarg.h>
#include <stdio.h>
int main(int argc, char *argv[])
{
int connfd;
struct sockaddr_in serveraddr;
char buf[1024];
connfd = socket(AF_INET, SOCK_STREAM, 0);
bzero(&serveraddr, sizeof(serveraddr));
serveraddr.sin_family = AF_INET;
serveraddr.sin_port = htons(atoi(argv[2]));
inet_pton(AF_INET, argv[1], &serveraddr.sin_addr);
connect(connfd, (struct sockaddr *)&serveraddr, sizeof(serveraddr));
while(fgets(buf, 1024, stdin) != NULL){
write(connfd, buf, strlen(buf));
}
close(connfd);
return 0;
}
//server.c服务端代码
#include <sys/epoll.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/un.h>
#include <fcntl.h>
#include <string.h>
#include <errno.h>
#include <stdio.h>
#define MAX_EVENT_NUM 1024
#define BUFFER_SIZE 10
#define true 1
#define false 0
int setnonblocking(int fd)
{
int old_opt = fcntl(fd, F_GETFD);
int new_opt = old_opt | O_NONBLOCK;
fcntl(fd, F_SETFD, new_opt);
return old_opt;
}//将文件描述符设置为非阻塞的
void addfd(int epollfd, int fd, int enable_et)
{
struct epoll_event event;
event.data.fd = fd;
event.events = EPOLLIN;
if(enable_et){
event.events |= EPOLLET;
}
epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &event);
// setnonblocking(fd);
}//将文件描述符fd的EPOLLIN注册到epollfd指示的epoll内核事件表中,enable_et表示是否对fd启用ET模式
void lt(struct epoll_event *events, int num, int epollfd, int listenfd)
{
char buf[BUFFER_SIZE];
for(int i = 0; i < num; i++){
int sockfd = events[i].data.fd;
if(sockfd == listenfd){
struct sockaddr_in clientaddr;
socklen_t clilen = sizeof(clientaddr);
int connfd = accept(listenfd, (struct sockaddr *)&clientaddr, &clilen);
addfd(epollfd, connfd, false);//对connfd使用默认的lt模式
}else if(events[i].events & EPOLLIN){//只要socket读缓存中还有未读的数据,这段代码就会触发
printf("event trigger once\n");
memset(buf, '\0', BUFFER_SIZE);
int ret = recv(sockfd, buf, BUFFER_SIZE-1, 0);
if(ret <= 0){
close(sockfd);
continue;
}
printf("get %d bytes of content:%s\n", ret, buf);
}else{
printf("something else happened\n");
}
}
}
void et(struct epoll_event *event, int num, int epollfd, int listenfd)
{
char buf[BUFFER_SIZE];
for(int i = 0; i < num; i++){
int sockfd = event[i].data.fd;
if(sockfd == listenfd){
struct sockaddr_in clientaddr;
int clilen = sizeof(clientaddr);
int connfd = accept(listenfd, (struct sockaddr *)&clientaddr, &clilen);
addfd(epollfd, connfd, true);//多connfd开启ET模式
}else if(event[i].events & EPOLLIN){
printf("event trigger once\n");
while(1){//这段代码不会重复触发,所以要循环读取数据
memset(buf, '\0', BUFFER_SIZE);
int ret = recv(sockfd, buf, BUFFER_SIZE-1, 0);
if(ret < 0){
if((errno == EAGAIN) || (errno == EWOULDBLOCK)){
printf("read later\n");
break;
}
close(sockfd);
break;
}else if(ret == 0){
close(sockfd);
}else{
printf("get %d bytes of content:%s\n", ret, buf);
}
}
}else{
printf("something else happened \n");
}
}
}
int start_ser(char *ipaddr, char *port)
{
int sock = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in serveraddr;
bzero(&serveraddr, sizeof(serveraddr));
serveraddr.sin_family = AF_INET;
serveraddr.sin_port = htons(atoi(port));
inet_pton(AF_INET, ipaddr, &serveraddr.sin_addr);
bind(sock, (struct sockaddr *)&serveraddr, sizeof(serveraddr));
listen(sock, 128);
return sock;
}
int main(int argc, char *argv[])
{
int listenfd = start_ser(argv[1], argv[2]);
struct epoll_event events[MAX_EVENT_NUM];
int epollfd = epoll_create(5);
addfd(epollfd, listenfd, true);
while(1){
int ret = epoll_wait(epollfd, events, MAX_EVENT_NUM, -1);
if(ret < 0){
printf("epoll failure\n");
break;
}
lt(events, ret, epollfd, listenfd);//lt模式
//et(events, ret, epollfd, listenfd);//et模式
}
close(listenfd);
return 0;
}
2、redis事件处理机制介绍
2.1 socket可读、写条件
通俗的讲,socket接收缓冲区有数据,就可读;socket发送缓冲区数据未溢出,就可写。
1、可读条件
- socket的接收缓冲区中的
【已用】
数据字节大于等于该socket的接收缓冲区低水位标记的当前大小。对这样的socket的读操作将不阻塞并返回一个大于0的值(也就是返回准备好读入的数据)。我们可以用SO_RCVLOWAT socket选项来设置该socket的低水位标记。对于TCP和UDP .socket而言,其缺省值为1. - 该连接的读这一半关闭(也就是接收了FIN的TCP连接)。对这样的socket的读操作将不阻塞并返回0
- socket是一个用于监听的socket,并且已经完成的连接数为非0.这样的soocket处于可读状态,是因为socket收到了对方的connect请求,执行了三次握手的第一步:对方发送SYN请求过来,使监听socket处于可读状态;正常情况下,这样的socket上的accept操作不会阻塞;
- 有一个socket有异常错误条件待处理.对于这样的socket的读操作将不会阻塞,并且返回一个错误(-1),errno则设置成明确的错误条件.这些待处理的错误也可通过指定socket选项SO_ERROR调用getsockopt来取得并清除;
2、可写条件
- socket的发送缓冲区中的【剩余】数据字节大于等于该socket的发送缓冲区低水位标记的当前大小。对这样的socket的写操作将不阻塞并返回一个大于0的值(也就是返回准备好写入的数据)。我们可以用SO_SNDLOWAT socket选项来设置该socket的低水位标记。对于TCP和UDP socket而言,其缺省值为2048
- 该连接的写这一半关闭。对这样的socket的写操作将产生SIGPIPE信号,该信号的缺省行为是终止进程。
- 有一个socket异常错误条件待处理.对于这样的socket的写操作将不会阻塞并且返回一个错误(-1),errno则设置成明确的错误条件.这些待处理的错误也可以通过指定socket选项SO_ERROR调用getsockopt函数来取得并清除;
2.2 事件处理
redis服务器是典型的事件驱动程序,而事件又分为文件事件(socket的可读可写事件)与时间事件(定时任务)两大类。无论是文件事件还是时间事件都封装在结构体aeEventLoop
中:
typedef struct aeEventLoop {
int stop ;
aeFileEvent *events;aeFiredEvent * fired;
aeTimeEvent *timeEventHead;
void *apidata
aeBeforesleepProc * beforesleep;
aeBeforesleepProc *aftersleep ;
}aeEventLoop;
redis 并没有直接使用epoll提供的API,而是同时支持4种I/O多路复用模型,并将这些模型的API进一步统一封装,由文件ae_evport.c、ae_epoll.c、ae_kqueue.c和 ae_select.c
实现。
而Redis在编译阶段,会检查操作系统支持的I/O多路复用模型,并按照一定规则决定使用哪种模型。
以epoll为例,aeApiCreate
函数是对epoll_create的封装;aeApiAddEvent
函数用于添加事件,是对epoll_ctl 的封装;aeApiDelEvent
函数用于删除事件,是对epoll_ctl
的封装;aeApiPoll
是对epoll_wait的封装。
2.3 文件事件
- redis服务器启动时需要创建socket并监听,等待客户端连接;
- 客户端与服务器建立socket连接之后,服务器会等待客户端的命令请求;
- 服务器处理完客户端的命令请求之后,命令回复会暂时缓存在client结构体的 buf缓冲区,待客户端文件描述符的可写事件发生时,才会真正往客户端发送命令回复。这些都需要创建对应的文件事件:
aeCreateFileEvent (server.el,server.ipfd[j],AE_READABLE,
acceptTcpHandler , NULL) ;
aeCreateFileEvent (server.el , fd,AE__READABLE,
readQueryFromClient , c);
aeCreateFileEvent (server.el, c->fd, ae_flags,
sendReplyToclient , c);
当socket可读可写时,通过回调函数处理对应流程。
- 在redis初始化时,就对监听端口创建了可读事件,回调函数为
acceptTcpHandler
,当客户端连接redis服务器,触发可读事件,执行回调函数,该流程主要创建对应client客户端对象,并为该连接创建可读事件。 - 客户端连接到redis之后,发送数据,触发可读事件,调函数为
readQueryFromClient
,该流程主要接收数据,处理逻辑,并创建一个可写事件,用于回复客户端 - 当前发送缓冲区为空,触发可写事件,回调函数为
sendReplyToClient
,向客户端回复数据
2.4. 时间事件
时间事件主要处理定时函数
- redis轮询时,会优先处理即将到来的时间事件
- 时间事件还未到,查找最早会发生的时间事件,根据该时间差阻塞文件事件。即文件事件未能有可读、可写处理时,到时会退出阻塞,下一个轮询时,去处理即将到来的时间事件
3、epoll多路复用-多人聊天终端实战
1、代码结构
github地址 基于epoll的事件处理实现多人聊天终端
关注myserver.c 、networking.c、myclient.c这几个就行
(1)ae_epoll.c #redis API,对epoll的封装
(2)ae.c/ae.h #redis API,对事件处理封装,包含时间/文件事件的创建、添加、删除
(3)anet.c/anet.h #redis API,对socket底层bind、accept、listen函数的封装
(4)myserver.c #入口
(5)networking.c #自己编写的处理逻辑
(6)zmalloc.c/zmalloc.h #redis API,对内存malloc等的封装
(7)Makefile #用于编译
(8)ip.sh #自己写的脚本,用于方便替换myclient.c中char ip[20]
2、运行
make之后,再执行ip.sh,即可出现myserver
、myclien
可执行文件,直接启动可执行文件就完成了。
3、功能讲解
(1)群发。默认情况下,每一个客户端连接上,都会有一个唯一name标识。当客户端发送数据时,当前所有连接上的客户端都会接收到。
(2)私聊。可以通过setname liliya
,先将客户端name标识更新为liliya
。其他用户通过pchat liliya Are you ok
,即可将消息私发消息给liliya
4、并发量测试
服务端运行在虚拟机上,利用windwos的jmeter测试
当QPS为2000时,性能完全ok,在2ms内便能完成响应
当QPS达到1w时,也能处理,但性能会急速下降,平均需要2.5s才能完成响应