Redis作为一个Client-Server架构的数据库,其源码中不少都是用来实现网络通信的部分。最常见的网络通信方式是使用Socket编程模型(Socket套接字编程可参考文末链接1),包括创建Socket、监听端口、处理连接请求和读写请求。但Socket编程模型一次只能处理一个客户端的请求,所以当要处理高并发请求时,一种方案就是使用多线程,让每个线程负责处理一个客户端的请求。而Redis负责客户端请求解析和处理的线程只有一个,如果直接使用Socket模型则会影响Redis支持高并发的客户端访问。因此Redis选择epoll模型进行网络通信。
socket模型实现网络通信有下面三个步骤
完成上面三步后服务器端就可以收到客户端的连接请求。
服务器端的简易代码逻辑如下,
// 调用socket函数创建一个主动套接字
listenSocket = socket();
// 绑定IP和端口
bind(listenSocket);
// 主动套接字转换为被动套接字
listen(listenSocket);
// 循环监听是否有客户端连接请求
while(1){
// 接受客户端连接
connSocket = accept(listenSocket);
// 从客户端读取数据,只能处理一个客户端
recv(connSocket);
// 给客户端返回数据
send(connSocket);
}
从代码可以看出程序监听到客户端请求时,只能处理一个客户端请求,如果想要处理多个客户端请求,那么就需要使用多线程来进行处理并返回客户端请求。简易代码如下,
// 调用socket函数创建一个主动套接字
listenSocket = socket();
// 绑定IP和端口
bind(listenSocket);
// 主动套接字转换为被动套接字
listen(listenSocket);
// 循环监听是否有客户端连接请求
while(1){
// 接受客户端连接
connSocket = accept(listenSocket);
// 创建新线程对已连接套接字进行处理
pthred_create(processData, connSocket);
}
// 处理已连接套接字上的读写请求
processData(connSocket){
// 从客户端读取数据,只能处理一个客户端
recv(connSocket);
// 给客户端返回数据
send(connSocket);
}
但该方法虽然能提升服务器端的并发处理能力,但并不适用Redis。Redis的主执行流程是单线程执行。epoll模型使用了IO多路复用机制,在单线程的条件下可以进行多个任务处理。
在信号传输中,一个信道同时传输多路信号,这就是所谓的多路复用技术(Multiplexing),常见的有频分复用、时分复用、波分复用、码分复用等。IO多路复用则是多个客户端请求在请求到服务器端时,采用了类似的复用技术,如下图,
Linux针对每一个套接字都会有一个文件描述符,也就是一个非负整数,用来唯一标识该套接字。所以在IO多路复用机制的函数中,Linux通常就会用文件描述符作为参数。有了文件描述符,select函数就能找到对应的套接字,从而进行监听、读写等操作。select函数对每一个描述符集合,都可以监听1024个描述符。
select函数实现网络通信的基本流程如下图,
select函数的简易代码如下,
// 监听套接字和已连接套接字变量
int sock_fd,conn_fd;
// 创建套接字
sock_fd = socket();
// 绑定套接字
bind(sock_fd);
// 监听套接字
listen(sock_fd);
// 被监听的描述符集合
fd_set rset;
int max_fd = sock_fd;
// 初始化rset数组,使用FD_ZERO宏设置每个元素为0
FD_ZERO(&rset)
// 使用FD_SET宏设置rset数组中位置为sock_fd的文件描述符为1,表示需要监听该文件描述符
FD_SET(sock_fd, &rset)
// 设置超时事件
struct timeval timeout;
timeout.tv_sec = 3;
timeout.tv_usec = 0;
while(1){
// 调用select函数,检查rset数组保存的文件描述符是否已有读事件就绪,返回就绪的文件描述符个数
n = select(max_fd+1, &rset, NULL, NULL, &timeout);
// 调用FD_ISSET宏,在rset数组中检测sock_fd对应的文件描述符是否就绪
if(FD_ISSET(sock_fd, &rset)){
// 如果sock_fd已经就绪,表明已有客户端连接;调用accept函数建立连接
conn_fd = accept();
// 设置rset数组中位置为conn_fd的文件描述符为1,表示需要监听该文件描述符
FD_SET(conn_fd, &rset);
}
// 依次检查已连接套接字的文件描述符
for(i=0;i
从上可以看出使用select函数进行监听文件描述符的缺点在于监听数量有限,默认能监听的文件描述符是1024,即使修改了默认值,在大并发的情况下还是有数量限制。另外select函数返回后,最后还需要遍历所有描述符集合才能知道哪些文件描述符就绪。
poll函数完成网络通信的主要流程如下:
主要的简易代码逻辑如下,
// 监听套接字和已连接套接字变量
int sock_fd,conn_fd;
// 创建套接字
sock_fd = socket();
// 绑定套接字
bind(sock_fd);
// 监听套接字
listen(sock_fd);
// poll函数可以监听的文件描述符数量,可以大于1024
#define MAX_OPEN = 2048
// pollfd结构体数组,对应文件描述符
struct pollfd client[MAX_OPEN];
// 将创建的监听套接字加入pollfd数组,并监听其可读事件
client[0].fd = sock_fd;
client[0].events = POLLRDNORM;
maxfd = 0;
//初始化client数组其他元素为-1
for(i = 1;i < MAX_OPEN; i++){
client[i].fd = -1;
}
while(1){
// 调用poll函数,检测client数组里的文件描述符是否有就绪的,返回就绪的文件描述符个数
n = poll(client, maxfd+1, &timeout);
// 如果监听套接字的文件描述符有可读事件,则进行处理
if(client[0].revents & POLLRDNORM){
conn_fd = accept();
// 保存已建立连接套接字
for(i = 1;i< MAX_OEPN;i++){
if(client[i].fd < 0){
client[i].fd = conn_fd;
client[i].events = POLLRDNORM;
break;
}
}
maxfd = i;
}
// 依次检查已连接套接字的文件描述符
for(i=0;i
与select函数相比,poll函数的改进之处主要就在它允许一次监听超过1024个文件描述符。但是调用poll函数后,仍然需要遍历每个文件描述符,检测该描述符是否就绪,然后再进行处理。
epoll机制使用epoll_event结构体来记录待监听的文件描述符和监听事件类型。常见的事件类型有下面几种:
epoll_data联合体以及epoll_event结构体如下,
typedef union epoll_data
{
...
int fd;
...
} epoll_data_t;
struct epoll_event
{
// epoll监听的事件类型
uint32_t events;
// 应用程序数据
epoll_data_t data;
}
select和poll函数在创建好文件描述符集合或pollfd数组后,就可以往数组中添加需要监听的文件描述符了。而对于epoll首先需要调用epoll_create函数,创建一个epoll实例。这个实例里维护了待监听的文件描述符和已就绪的文件描述符。这样就不需要在后面进行遍历查询哪些是已经就绪的文件描述符了。epoll具体的进行网络通信的流程如下,
epoll简易代码流程如下,
// 监听套接字和已连接套接字变量
int sock_fd,conn_fd;
// 创建套接字
sock_fd = socket();
// 绑定套接字
bind(sock_fd);
// 监听套接字
listen(sock_fd);
// 创建epoll实例对象
epfd = epoll_create(EPOLL_SIZE);
// 创建epoll_event结构体数组,保存套接字对应文件
ep_events = (epoll_event*)malloc(sizeof(epoll_event) * EPOLL_SIZE);
// 创建epoll_event变量
struct epoll_event ee
// 监听读事件
ee.events = EPOLLIN;
// 监听的文件描述符是刚创建的监听套接字
ee.data.fd = sock_fd;
// 将监听套接字加入到监听列表中
epoll_ctl(epfd, EPOLL_CTL_ADD, sock_fd, &ee);
while(1){
n = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1);
for(i=0; i
这样Redis在实现网络通信框架时,基于epoll机制中的epoll_create、epoll_ctl和epoll_wait等函数和读写事件,实现了网络通信的事件驱动框架,从而使得Redis虽然是单线程运行,但仍然能高效应对高并发的客户端访问。
Linux三种IO多路复用机制的差异点可以用下面这张图来表示,
从上面了解了事件驱动框架的基础,那么下面来看看Redis源码中的实现,
找到ae_select.c文件,它就使用了select机制实现IO多路复用,
前面定义了fd_set 结构的读事件集合和写事件集合,_表示已就绪的文件描述符集合。
先看看fd_set的数据结构,
// long int类型的数组,数组一共32个元素(1024/32=32),每个元素是32位(long int类型的大小),每一位可以用来表示一个文件描述符的状态
typedef struct{
...
__fd_mask __fds_bits[__FD_SETSIZE / __NFDBITS];
...
} fd_set
#include
#include
typedef struct aeApiState {
fd_set rfds, wfds;
/* We need to have a copy of the fd sets as it's not safe to reuse
* FD sets after select(). */
fd_set _rfds, _wfds;
} aeApiState;
static int aeApiCreate(aeEventLoop *eventLoop) {
aeApiState *state = zmalloc(sizeof(aeApiState));
if (!state) return -1;
FD_ZERO(&state->rfds);
FD_ZERO(&state->wfds);
eventLoop->apidata = state;
return 0;
}
static int aeApiResize(aeEventLoop *eventLoop, int setsize) {
/* Just ensure we have enough room in the fd_set type. */
if (setsize >= FD_SETSIZE) return -1;
return 0;
}
static void aeApiFree(aeEventLoop *eventLoop) {
zfree(eventLoop->apidata);
}
static int aeApiAddEvent(aeEventLoop *eventLoop, int fd, int mask) {
aeApiState *state = eventLoop->apidata;
if (mask & AE_READABLE) FD_SET(fd,&state->rfds);
if (mask & AE_WRITABLE) FD_SET(fd,&state->wfds);
return 0;
}
static void aeApiDelEvent(aeEventLoop *eventLoop, int fd, int mask) {
aeApiState *state = eventLoop->apidata;
if (mask & AE_READABLE) FD_CLR(fd,&state->rfds);
if (mask & AE_WRITABLE) FD_CLR(fd,&state->wfds);
}
static int aeApiPoll(aeEventLoop *eventLoop, struct timeval *tvp) {
aeApiState *state = eventLoop->apidata;
int retval, j, numevents = 0;
memcpy(&state->_rfds,&state->rfds,sizeof(fd_set));
memcpy(&state->_wfds,&state->wfds,sizeof(fd_set));
retval = select(eventLoop->maxfd+1,
&state->_rfds,&state->_wfds,NULL,tvp);
if (retval > 0) {
for (j = 0; j <= eventLoop->maxfd; j++) {
int mask = 0;
aeFileEvent *fe = &eventLoop->events[j];
if (fe->mask == AE_NONE) continue;
if (fe->mask & AE_READABLE && FD_ISSET(j,&state->_rfds))
mask |= AE_READABLE;
if (fe->mask & AE_WRITABLE && FD_ISSET(j,&state->_wfds))
mask |= AE_WRITABLE;
eventLoop->fired[numevents].fd = j;
eventLoop->fired[numevents].mask = mask;
numevents++;
}
}
return numevents;
}
static char *aeApiName(void) {
return "select";
}
再看ae_epoll.c文件,它使用了epoll机制实现IO多路复用,
#include
typedef struct aeApiState {
int epfd;
struct epoll_event *events;
} aeApiState;
static int aeApiCreate(aeEventLoop *eventLoop) {
aeApiState *state = zmalloc(sizeof(aeApiState));
if (!state) return -1;
state->events = zmalloc(sizeof(struct epoll_event)*eventLoop->setsize);
if (!state->events) {
zfree(state);
return -1;
}
state->epfd = epoll_create(1024); /* 1024 is just a hint for the kernel */
if (state->epfd == -1) {
zfree(state->events);
zfree(state);
return -1;
}
eventLoop->apidata = state;
return 0;
}
static int aeApiResize(aeEventLoop *eventLoop, int setsize) {
aeApiState *state = eventLoop->apidata;
state->events = zrealloc(state->events, sizeof(struct epoll_event)*setsize);
return 0;
}
static void aeApiFree(aeEventLoop *eventLoop) {
aeApiState *state = eventLoop->apidata;
close(state->epfd);
zfree(state->events);
zfree(state);
}
static int aeApiAddEvent(aeEventLoop *eventLoop, int fd, int mask) {
aeApiState *state = eventLoop->apidata;
struct epoll_event ee = {0}; /* avoid valgrind warning */
/* If the fd was already monitored for some event, we need a MOD
* operation. Otherwise we need an ADD operation. */
int op = eventLoop->events[fd].mask == AE_NONE ?
EPOLL_CTL_ADD : EPOLL_CTL_MOD;
ee.events = 0;
mask |= eventLoop->events[fd].mask; /* Merge old events */
if (mask & AE_READABLE) ee.events |= EPOLLIN;
if (mask & AE_WRITABLE) ee.events |= EPOLLOUT;
ee.data.fd = fd;
if (epoll_ctl(state->epfd,op,fd,&ee) == -1) return -1;
return 0;
}
static void aeApiDelEvent(aeEventLoop *eventLoop, int fd, int delmask) {
aeApiState *state = eventLoop->apidata;
struct epoll_event ee = {0}; /* avoid valgrind warning */
int mask = eventLoop->events[fd].mask & (~delmask);
ee.events = 0;
if (mask & AE_READABLE) ee.events |= EPOLLIN;
if (mask & AE_WRITABLE) ee.events |= EPOLLOUT;
ee.data.fd = fd;
if (mask != AE_NONE) {
epoll_ctl(state->epfd,EPOLL_CTL_MOD,fd,&ee);
} else {
/* Note, Kernel < 2.6.9 requires a non null event pointer even for
* EPOLL_CTL_DEL. */
epoll_ctl(state->epfd,EPOLL_CTL_DEL,fd,&ee);
}
}
static int aeApiPoll(aeEventLoop *eventLoop, struct timeval *tvp) {
aeApiState *state = eventLoop->apidata;
int retval, numevents = 0;
retval = epoll_wait(state->epfd,state->events,eventLoop->setsize,
tvp ? (tvp->tv_sec*1000 + tvp->tv_usec/1000) : -1);
if (retval > 0) {
int j;
numevents = retval;
for (j = 0; j < numevents; j++) {
int mask = 0;
struct epoll_event *e = state->events+j;
if (e->events & EPOLLIN) mask |= AE_READABLE;
if (e->events & EPOLLOUT) mask |= AE_WRITABLE;
if (e->events & EPOLLERR) mask |= AE_WRITABLE;
if (e->events & EPOLLHUP) mask |= AE_WRITABLE;
eventLoop->fired[j].fd = e->data.fd;
eventLoop->fired[j].mask = mask;
}
}
return numevents;
}
static char *aeApiName(void) {
return "epoll";
}
Reactor模型是网络服务器端用来处理高并发网络IO请求的一种编程模型。
它基于三类事件和三个角色来处理高并发请求。
三类事件分别是连接事件、写事件、读事件;
三个角色分别是reactor、acceptor、handler。
如果了解过RBAC(Role-Based Access Control)基于角色的访问控制模型的人应该知道用户、角色、权限三者之间的关系。类似的,我们先来看看这三类事件和Reactor模型的关系。
Reactor模型处理的是客户端和服务器端交互过程中,不同类请求在服务器端引发的待处理事件。
这三类事件和三个角色的关系如下,
首先,连接事件由acceptor来处理,负责接收连接;
acceptor在接收连接后,会创建handler,用于网络连接上对后续读写事件的处理;
其次,读写事件由handler处理;
最后,在高并发场景中,连接事件、读写事件会同事发生,所以,我们需要有一个角色专门监听和分配事件,这个就是reactor角色。当有连接请求时,reactor将产生的连接事件交由acceptor处理;当有读写请求时,reactor将读写事件交由handler处理。
它们和事件的关系如下图,
事件驱动框架包括两部分:
事件初始化是在服务器程序启动时就执行的,它的作用主要是创建需要监听的事件类型,以及该类事件对应的handler。而一旦服务器完成初始化后,事件初始化也就相应完成了,服务器程序就需要进入到事件捕获、分发和处理的主循环中。
Reactor模式也叫反应堆模式,它是一种事件驱动机制,逆转了事件处理的流程,不再是主动地等事件就绪,而是提前注册好回调函数,当有对应事件发生就调用回调函数。在while循环中,我们需要捕获发生的事件、判断事件类型,并根据事件类型调用初始化创建好的事件的回调函数来处理事件。主要的处理流程如下,
如上图所示,Reactor模型的基本工作机制就是,客户端的不同类型的请求会在服务器端出发连接、读、写三类事件,这三类事件的监听、分发和处理又是由reactor、acceptor、handler三类角色来完成的,然后这三类角色会通过事件驱动框架来实现交互和事件处理。
Redis的网络框架实现了Reactor模型,并且自行开发实现了一个事件驱动框架(对应的文件为ae.c)。在ae.h头文件中Redis为了实现事件驱动框架,相应地定义了事件的数据结构、框架主循环函数、事件捕获分发函数、事件和handler注册函数。下面依次来看一下,
事件的数据结构是关联事件类型和事件处理函数的关键要素。而Redis的事件驱动框架定义了两类事件——IO事件和时间事件,分别对应了客户端发送的网络请求和Redis自身的周期性操作。
如下,IO事件aeFileEvent中,
mask:用来表示事件类型的掩码。对于网络通信的事件来说,主要有AE_READABLE、AE_WRITEABLE和AE_BARRIER三类事件。框架在分发事件时,依赖的就是结构体中的事件类型。
refileProc和wfileProc:分别指向AE_REABLE和AE_WRITEABLE这两类事件的处理函数,也就是Reactor模型中的handler。框架在分发事件后,就需要调用结构体中定义的函数进行事件处理。
clientData:用来指向客户端私有数据的指针。
/* File event structure */
typedef struct aeFileEvent {
int mask; /* one of AE_(READABLE|WRITABLE|BARRIER) */
aeFileProc *rfileProc;
aeFileProc *wfileProc;
void *clientData;
} aeFileEvent;
事件事件aeTimeEvent如下,
/* Time event structure */
typedef struct aeTimeEvent {
long long id; /* time event identifier. */
long when_sec; /* seconds */
long when_ms; /* milliseconds */
aeTimeProc *timeProc;
aeEventFinalizerProc *finalizerProc;
void *clientData;
struct aeTimeEvent *prev;
struct aeTimeEvent *next;
} aeTimeEvent;
除了事件的数据结构以外,在头文件中还定义了支撑框架运行的主要函数,有负责事件和handler注册的aeCreateFileEvent函数,有负责事件捕获和分发的aeProcessEvents函数,有框架主循环的aeMain函数,头文件中的原型定义如下,
/* Prototypes */
int aeCreateFileEvent(aeEventLoop *eventLoop, int fd, int mask,
aeFileProc *proc, void *clientData);
int aeProcessEvents(aeEventLoop *eventLoop, int flags);
void aeMain(aeEventLoop *eventLoop);
找到对应的循环主函数实现如下,循环判断停止标记,如果循环标记设置为true,那么针对事件捕获、分发和处理的整个主循环就停止了。
void aeMain(aeEventLoop *eventLoop) {
eventLoop->stop = 0;
while (!eventLoop->stop) {
if (eventLoop->beforesleep != NULL)
eventLoop->beforesleep(eventLoop);
aeProcessEvents(eventLoop, AE_ALL_EVENTS|AE_CALL_AFTER_SLEEP);
}
}
那么主循环函数是在哪呗调用的呢?找到server.c文件,在Redis服务器初始化时调用了aeMain函数开始执行事件驱动框架,如下,
int main(int argc, char **argv) {
... ...
aeMain(server.el);
aeDeleteEventLoop(server.el);
return 0;
}
找到aeProcessEvents函数,有三个if判断,分别对应了三种情况,
int aeProcessEvents(aeEventLoop *eventLoop, int flags)
{
int processed = 0, numevents;
// 若没有事件处理,则立刻返回
if (!(flags & AE_TIME_EVENTS) && !(flags & AE_FILE_EVENTS)) return 0;
// 如果有IO事件发生,或者紧急的时间事件发生,则开始处理
if (eventLoop->maxfd != -1 || ((flags & AE_TIME_EVENTS) && !(flags & AE_DONT_WAIT))) {
... ...
/* Call the multiplexing API, will return only on timeout or when
* some event fires. */
numevents = aeApiPoll(eventLoop, tvp);
... ...
}
// 检查是否有时间事件,若有,则调用processTimeEvents函数处理
if (flags & AE_TIME_EVENTS)
processed += processTimeEvents(eventLoop);
// 返回已经处理的IO事件或时间事件
return processed; /* return the number of processed file/time events */
}
在情况二时,Redis需要捕获发生的网络事件并进行相应的处理,在代码中是调用aeApiPoll函数来捕获事件。Redis依赖操作系统底层提供的IO多路复用机制来实现捕获,检查是否有新的连接、读写事件发生。为了适配不同的操作系统,Redis对不同操作系统实现的网络IO多路复用函数都进行了统一封装,封装的代码通过下面四个文件中的函数实现,
这样Redis在不同的操作系统上调用IO多路复用API时就可以通过统一的接口来进行调用。比如Linux系统的ae_epoll.c的代码如下,使用epoll_wait API检测内核中发生的网络IO事件,
static int aeApiPoll(aeEventLoop *eventLoop, struct timeval *tvp) {
aeApiState *state = eventLoop->apidata;
int retval, numevents = 0;
// 调用epoll_wait获取监听到的事件
retval = epoll_wait(state->epfd,state->events,eventLoop->setsize,
tvp ? (tvp->tv_sec*1000 + tvp->tv_usec/1000) : -1);
if (retval > 0) {
int j;
// 获取监听到的事件数量
numevents = retval;
// 针对每一个事件进行处理
for (j = 0; j < numevents; j++) {
int mask = 0;
struct epoll_event *e = state->events+j;
// 保存事件信息
if (e->events & EPOLLIN) mask |= AE_READABLE;
if (e->events & EPOLLOUT) mask |= AE_WRITABLE;
if (e->events & EPOLLERR) mask |= AE_WRITABLE;
if (e->events & EPOLLHUP) mask |= AE_WRITABLE;
eventLoop->fired[j].fd = e->data.fd;
eventLoop->fired[j].mask = mask;
}
}
return numevents;
}
事件驱动框架对epoll_wait的调用链可以用下图表示,
找到aeCreateFileEvent函数,Linux提供了epoll_ctl API,用于增加新的观察事件,而Redis在此基础上,封装了aeApiAddEvent函数,对epoll_ctl进行调用,
int aeCreateFileEvent(aeEventLoop *eventLoop, int fd, int mask,
aeFileProc *proc, void *clientData)
{
if (fd >= eventLoop->setsize) {
errno = ERANGE;
return AE_ERR;
}
aeFileEvent *fe = &eventLoop->events[fd];
if (aeApiAddEvent(eventLoop, fd, mask) == -1)
return AE_ERR;
fe->mask |= mask;
if (mask & AE_READABLE) fe->rfileProc = proc;
if (mask & AE_WRITABLE) fe->wfileProc = proc;
fe->clientData = clientData;
if (fd > eventLoop->maxfd)
eventLoop->maxfd = fd;
return AE_OK;
}
事件注册函数是Redis启动后,main函数调用initServer函数进行服务器初始化时,initServer函数在初始化过程中调用的,AE_READABLE事件就是客户端的网络连接事件,而对应的处理函数就是接收TCP连接请求。
void initServer(void) {
/* Open the TCP listening socket for the user commands. */
... ...
/* Open the listening Unix domain socket. */
... ...
/* Create the Redis databases, and initialize other internal state. */
... ...
/* Register a readable event for the pipe used to awake the event loop
* when a blocked client in a module needs attention. */
if (aeCreateFileEvent(server.el, server.module_blocked_pipe[0], AE_READABLE,
moduleBlockedClientPipeReadable,NULL) == AE_ERR) {
serverPanic(
"Error registering the readable event for the module "
"blocked clients subsystem.");
}
/* Open the AOF file if needed. */
... ...
/* 32 bit instances are limited to 4GB of address space, so if there is
* no explicit limit in the user provided configuration we set a limit
* at 3 GB using maxmemory with 'noeviction' policy'. This avoids
* useless crashes of the Redis instance for out of memory. */
... ...
}
具体的说initServer函数的执行过程中,它会根据启动的IP端口个数,为每个IP端口上的网络事件调用aeCreateFileEvent函数,创建对AE_READABLE事件的监听,并且注册AE_READABLE事件的处理handler,也就是acceptTcpHandler函数,过程如下,
参考链接:
1、Socket通信详解_四问四不知的博客-CSDN博客_socket通信