Redis 源码阅读 ——— 网络模块
概述
redis 是cs架构,网络采用epoll 模型,单线程处理每个请求。
很多同学对单线程有些疑问,简单的解释一下 redis 单线程的意思,redis 服务端虽说是单线程,但是可以同时 持有很多connection,每个connection 都可以同时发请求,只不过在 redis 服务端,一个一个的处理每个connection 发过来的request, 通俗点说就是,很多请求都能发过来,redis 会存下来(其实是存在每个connection socket 内核缓冲区),一个一个处理。
为什么单线程处理效率如此之高?
- 几乎所有的操作全部是内存操作,内存操作非常快(如果有一些系统调用,磁盘操作,单线程不会快的)
- 单线程避免了使用锁(memcache 使用了多线程,因为多了锁之类的,也没比redis快多少)
EPOLL 介绍
如果想读懂 redis 网络相关的代码,必须先搞清楚 epoll 的使用,epoll 说白了就是监听 fd(file descriptor,操作 fd 其实就是操作socket),每当 fd 上面有消息的时候(比如 可读,可写 消息等),就会得到通知,这样就可以处理了。epoll 主要好处是可以同时监听多个 fd(可以持有多个 client 连接),epoll 只有在 持有很多连接,并且每个连接都不是特别活跃的时候 效率才高,其他的情况,不见得比 poll,select 高。
epoll 使用只需要三步:
- int epoll_create(int size);
创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大。这个参数不同于select()中的第一个参数,给出最大监听的fd+1的值。需要注意的是,当创建好epoll句柄后,它就是会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽 - int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epoll的事件注册函数,它不同与select()是在监听事件时告诉内核要监听什么类型的事件,而是在这里先注册要监听的事件类型。第一个参数是epoll_create()的返回值,第二个参数表示动作,用三个宏来表示:EPOLL_CTL_ADD:注册新的fd到epfd中;
EPOLL_CTL_MOD:修改已经注册的fd的监听事件
-
EPOLL_CTL_DEL:从epfd中删除一个fd;
第三个参数是需要监听的fd,第四个参数是告诉内核需要监听什么事,struct epoll_event结构如下:
typedef union epoll_data {
void *ptr;
int fd;
__uint32_t u32;
__uint64_t u64;
} epoll_data_t;struct epoll_event {
__uint32_t events; /* Epoll events /
epoll_data_t data; / User data variable */
};
events可以是以下几个宏的集合:
EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
EPOLLOUT:表示对应的文件描述符可以写; EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来); EPOLLERR:表示对应的文件描述符发生错误; EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里
- int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
等待事件的产生,类似于select()调用。参数events用来从内核得到事件的集合,maxevents告之内核这个events有多大,这个 maxevents的值不能大于创建epoll_create()时的size,参数timeout是超时时间(毫秒,0会立即返回,-1将不确定,也有说法说是永久阻塞)。该函数返回需要处理的事件数目,如返回0表示已超时。
Redis 中 epoll 的使用
Redis epoll 封装介绍
redis 跟 网络相关的代码写的比较简洁,主要就两处
- 不同操作系统的 epoll 代码,都在 ae_epoll.c ae_evport.c ae_kqueue.c ae_select.c 中, linux 使用 ae_epoll.c , mac 使用 ae_kqueue.c
- 对 epoll 代码的封装在 ae.c 中
- aeCreateEventLoop 是对 epoll_create 的封装
- aeCreateFileEvent 是对 epoll_ctl 的封装,同时会将rfileProc, wfileProc 两个处理消息的回调函数一起封装
- aeProcessEvents 是对 epoll_wait 的封装
- aeMain 是一个死循环,不停的调用 aeProcessEvents, redis 就是在这里不停的收到 client 的 request, 并且一个一个处理
aeCreateEventLoop:
创建 aeEventLoop 结构体
/* File event structure */
typedef struct aeFileEvent {
int mask; /* one of AE_(READABLE|WRITABLE|BARRIER) */
aeFileProc *rfileProc;
aeFileProc *wfileProc;
void *clientData;
} aeFileEvent;
/* State of an event based program */
typedef struct aeEventLoop {
int maxfd; /* highest file descriptor currently registered */
int setsize; /* max number of file descriptors tracked */
long long timeEventNextId;
time_t lastTime; /* Used to detect system clock skew */
aeFileEvent *events; /* Registered events */
aeFiredEvent *fired; /* Fired events */
aeTimeEvent *timeEventHead;
int stop;
void *apidata; /* This is used for polling API specific data */
aeBeforeSleepProc *beforesleep;
aeBeforeSleepProc *aftersleep;
} aeEventLoop;
这个结构体中主要就是 events, fired 两个aeFileEvent类型变量,aeFileEvent 中 rfileProc, wfileProc 是两个回调函数, 分别处理读时间, 写时间, events 是aeCreateFileEvent 函数调用时 为其赋值,fird 是 监听到有消息来的时候 为其赋值,在 ae_epoll.c 中 aeApiPoll 函数。
总结一下, 服务启动 aeCreateEventLoop 创建 aeEventLoop 类型的变量, 将需要监控的 fd, 通过 aeCreateFileEvent 监听(同时将 赋值 rfileProc, wfileProc 回调函数), aeProcessEvents 监听到有消息需要处理的时候, 会使用 rfileProc, wfileProc 回调函数处理消息。所以,读 Redis 网络相关代码 ,其实只是看 aeCreateFileEvent(监听fd,设置对fd的回调函数) 在哪些地方被调用就可以了。
Redis 关键代码
-
initServer(server.c )无关代码删除:
void initServer(void) { server.el = aeCreateEventLoop(server.maxclients+CONFIG_FDSET_INCR); listenToPort(server.port,server.ipfd,&server.ipfd_count); for (j = 0; j < server.ipfd_count; j++) { if (aeCreateFileEvent(server.el, server.ipfd[j], AE_READABLE, acceptTcpHandler,NULL) == AE_ERR) { serverPanic( "Unrecoverable error creating server.ipfd file event."); } } 这段代码在默认开启redis-server 的情况下,server.ipfd 代表的fd 是 6379 打开的socket, 在6379监听到的消息,都调用 acceptTcpHandler 函数
acceptTcpHandler(networking.c)无关代码删除
void acceptTcpHandler(aeEventLoop *el, int fd, void *privdata, int mask) {
while(max--) {
cfd = anetTcpAccept(server.neterr, fd, cip, sizeof(cip), &cport);
acceptCommonHandler(cfd,0,cip);
}
}
static void acceptCommonHandler(int fd, int flags, char *ip) {
client *c;
c = createClient(fd)
}
client *createClient(int fd) {
aeCreateFileEvent(server.el,fd,AE_READABLE,
readQueryFromClient, c) == AE_ERR)
}
这段代码很清晰的表明了, 对于6379 过来的请求,全部 使用acceptTcpHandler 函数生成一个新的fd, 在同时将这个fd 放在 eventloop 中监听,并且 使用 readQueryFromClient 来处理readQueryFromClient(networking.c) 无关代码删除
void readQueryFromClient(aeEventLoop *el, int fd, void *privdata, int mask) {
nread = read(fd, c->querybuf+qblen, readlen);
processInputBuffer(c);
}
readQueryFromClient 就是把请求内容读出来, 在调用 processInputBuffer 处理, processInputBuffer 就是 redis 里面各种业务逻辑了,不在介绍
Epoll 总结
redis 网络相关代码其实就是一句话 使用 epoll 处理每一个请求,也没什么好学习的。。。。。。。
Redis 如何处理TCP 粘包,拆包
粘包,拆包介绍
tcp是面向流的, 所以tcp对数据内容毫无感知,收到就放在缓冲区里面等待用户读取,所以从server端读出来的数据,可能是按照发送顺序(tcp 保证不乱不丢)的任何内容, 这样 server 端如果无法识别出来一个完整的数据就出错了。解决办法有两种
- 特定分隔符,比如 http 的一个 request 是以 \r\n\r\n 结尾的,在服务端就可以一直读到这个特定的 \r\n\r\n ,通过这种方式可以区分出来一个 request 的数据
- 指定长度, 比如在 前4个字节中 存放这条消息的长度,这样就知道就可以通过 read 函数正确的读出数据。
特定字符的办法优势是,不用浪费空间存长度,但是现在的计算机环境通常可以忽略这个浪费,劣势是 每个字符都需要判断才能保证正确。效率低。
指定长度的办法是浪费空间存长度,但是效率高,所以基本上可以说任何时候都采用第二种方法
Redis 如何处理
set a 1 这条指令,按照 redis 协议,会翻译成
*3
$3
set
$1
a
$1
1
*3 表示有3行数据, $3 表示 有3个字符
属于哪种方法读者自己感受下。
Reids 的聪明之处
在我没读redis代码的时候,我一直认为 从缓冲区 读出来自己需要的长度,处理好以后,在从缓冲区里继续读,看了redis 代码以后,我才发现自己 too yong, redis 是这样做的(networking.c readQueryFromClient 函数, 代码有删减)
readlen = PROTO_IOBUF_LEN;
qblen = sdslen(c->querybuf);
if (c->querybuf_peak < qblen) c->querybuf_peak = qblen;
c->querybuf = sdsMakeRoomFor(c->querybuf, readlen);
nread = read(fd, c->querybuf+qblen, readlen);
define PROTO_IOBUF_LEN (102416) / Generic I/O buffer size */
redis 每次都读缓冲区的大小,如果最后一条消息不完整,下次计算一下长度,继续读,因为这个骚操作(知道了其实就是常规操作),让效率大大提升了,不然每次使用 read 系统调用,非常影响性能,特别对于 redis 这种单线程模型程序影响就更大了。
Redis如何处理 half connection
half connection 介绍
client -> server, 虽然我们都说connection,其实 就是client 开着一个 fd, server 开着一个 fd,两个fd之间可以互相通信,关闭的时候 一个 fd 跟另外一个 fd 说我准备关闭了(tcp 四次挥手), 不过如果有的极端情况(在大规模server端是常规情况),比如拔网线,关机,网络异常等原因(具体我也没试验过), 可能发不出任何消息 就断了,另外一个 fd 就在那里傻傻的等着,这就出现了 half connection 的情况。
如何处理 half connection
- 一般的处理办法就是心跳检查,服务端会 定时的 ping 客户端,如果连续几次 都 ping 不通,那么就会主动断开链接
为什么不使用 keep_alive 处理
Host Requirements RFC罗列有不使用它的三个理由:
- 在短暂的故障期间,它们可能引起一个良好连接(good connection)被释放(dropped)
- 它们消费了不必要的宽带
- 在以数据包计费的互联网上它们(额外)花费金钱。然而,在许多的实现中提供了存活定时器。
这种说法有它的道理,但是并不能说服我不使用 keep alive,最能说服我的是在知乎上看过的一句话, keep_alive 只能保证 tcp 是正常的,但是不能保证 用户程序是正常的,自己感受一下这些话。
Redis 如何处理
server.c clientsCronHandleTimeout 函数
if (server.maxidletime &&
!(c->flags & CLIENT_SLAVE) && /* no timeout for slaves */
!(c->flags & CLIENT_MASTER) && /* no timeout for masters */
!(c->flags & CLIENT_BLOCKED) && /* no timeout for BLPOP */
!(c->flags & CLIENT_PUBSUB) && /* no timeout for Pub/Sub clients */
(now - c->lastinteraction > server.maxidletime))
{
serverLog(LL_VERBOSE,"Closing idle client");
freeClient(c);
return 1;
} else if (c->flags & CLIENT_BLOCKED) {
......
通过代码可以看出来,redis 根本就既没有用 keep_alive , 也没有用 ping, 而是简单粗暴的通过 client 最后一次访问server 的时间 条件来判断,不管 这条连接是不是正常的,这样同样可以解决 half connection 问题。
我知道 redis 肯定要处理 half connection 的问题,所以我开始找 ping 相关代码,但是没找到,后来就老老实实从定时相关代码里面看,才找到。
第一反应,觉得比较奇怪,为什么好的连接也给断开了呢,是不是redis比较傻,能不能给他提个优雅处理的pr, 后来仔细想想,果然还是我自己 too yong, 作为服务端,连接资源还是比较宝贵的,如果长时间不访问服务端断开本来就是很合理的,而且如果用我开始觉得优雅的心跳,简直就是灾难,因为 redis 是单线程的,心跳都是一次网络交互。。。。
我看到过很多写心跳处理 half connection 的代码,原来一直觉得这就是最好的方法,学习了 redis 我才发现别有洞天,而且我仔细思考了下,感觉大部分时候 redis 这种处理方法更科学。
感悟
都说 redis 代码简洁,易读,不过没想到竟简洁如斯!!! tcp 粘包 拆包的处理, half connection 的处理,都让我有一种别开生面的感觉。其实文章里只写了有代表性的东西,时间有限无法一一列举,代码组织,架构都让我觉得提升不少, 读 redis 真的是一种享受,建议看过这篇文章的朋友都看一看 redis 代码,不要觉得难,其实你发现比读你同事的垃圾代码 容易多了。。。。