IO 多路复用
文件描述符(File Descriptor):简称 FD,是一个从 0
开始递增的无符号整数,用来关联 Linux
中的一个文件,在 Linux
中,一切皆文件,例如常规文件、视频、硬件设备等,当然也包括网络套接字(Socket
)
Select
select
是 linux
中最早的 I/O
复用实现方案
它的作用就是用来监听多个 fd
的集合
c
// 定义类型别名 __fd_mask,本质是 long int
// 占用 4 字节,32 位
typedef long int __fd_mask;
// fd_set 记录要监听的 fd 集合,及其对应状态
typedef struct {
// fds_bits 是 long 类型数组,长度为 1024/32 = 32
// 共 1024 个 bit 位,每个 bit 位代表一个 fd,0 代表为就绪,1 代表就绪
// 也就是说 fds_bits 有 32 个元素,每个元素有 32 bit 位,共 1024 个 bit 位
__fd_mask fds_bits[__FD_SETSIZE / __NFDBITS];
} fd_set
// select 函数,用于监听多个 fd 的集合
int select(
// 要监视的 fd_set 的最大 fd + 1
int nfds, // fd 是无符号整数,从 0 开始不断递增,最大 fd + 1 就是告诉内核已经到最大值了,不用在遍历了
// linux 把一个 IO 可能发生的事件分为三类,分别是读、写、异常
fd_set *readfds, // 要监听读事件的 fd 集合
fd_set *writefds, // 要监听写事件的 fd 集合
fd_set *exceptfds, // 要监听异常事件的 fd 集合
// 超时时间,null -> 永不超时,0 -> 不阻塞等待;大于 0 -> 固定时间等待
struct timeval *timeout
)
select
模式存在的问题:
- 需要将整个
fd_set
从用户空间拷贝到内核空间,select
结束还要再次拷贝会用户空间 select
无法得知具体是哪个fd
就绪,需要遍历整个fd_set
fd_set
监听的fd
数量不能超过1024
个
内存图:
poll
poll
模式对 select
模式做了简单改进,但性能提升不明显
c
// pollfd 中的事件类型
#define POLLIN // 可读事件
#define POLLOUT // 可写事件
#define POLLERR // 错误事件
#define POLLNVAL // fd 未打开
// pollfd 结构
struct pollfd {
int fd; // 要监听的 fd
short int events; // 要监听的事件类型:读、写、异常
short int revents; // 实际发生的事件类型
}
// poll 函数
struct poll {
struct pollfd *fds, // pollfd 数组,可以自定义大小,没有上限
nfds_t nfds, // 数组元素个数
int timeout // 超时时间
}
poll
函数相对 select
函数没有单独划分:读/写/异常事件,而是放在一个数组当中 pollfd
pollfd
结构体中,既包含了 fd
的值,也包含了事件的类型
我们在调用 poll
函数时,我们会创建多个 pollfd
结构体,只需要指定 fd
和 events
,然后我们把 pollfd
传递给内核后,内核在监听时,如果发现事件有就绪,内核就会把就绪的事件类型放在 revents
中,如果等到超时时间过了,还没有发现任何就绪的事件,就会把 revents
设置为 0
,代表没有发生任何事情,然后把 pollfd
返回给用户
所以整个 poll
的流程:
- 创建
pollfd
数组,向其中添加关注的fd
信息,数组大小自定义 - 调用
poll
函数,将pollfd
数组拷贝到内核空间,转链表存储,无上限 - 内核遍历
fd
,判断是否就绪 - 数据就绪或超时后,拷贝
pollfd
数组到用户空间,返回就绪fd
数量n
- 用户进程判断
n
是否大于0
- 大于
0
则遍历pollfd
数组,找到就绪的fd
与 select
对比:
select
模式中的fd_set
大小固定为1024
,而pollfd
在内核中采用链表,理论上无上限- 监听
fd
越多,每次遍历消耗时间也越久,性能反而会下降
内存图:
epoll
epoll
模式对 select
和 poll
的改进,它提供了三个函数:
c
struct eventpoll {
// ...
struct rb_root rbr; // 一颗红黑树,记录有监听的 fd
struct list_head rdlist; // 一个链表,记录就绪的 fd
// ...
}
// 1. 会在内核创建 eventpoll 结构体,返回对应的句柄 epfd(唯一的)
// 每一个 epfd 指向唯一的 eventpoll,每调一次 epoll_create 都会创建一个新的 eventpoll
int epoll_create(int size);
// 2. 将一个 fd 添加到 epoll 的红黑树中,并设置 ep_poll_callback
// callback 触发时,就把对应的 fd 加入到 rdlist 这个就绪列表中
int epoll_ctl(
int epfd, // epoll 实例的句柄
int op, // 要执行的操作,包括:ADD、MOD、DEL
int fd, // 要监听的 fd
struct epoll_event *event // 要监听的事件类型:读、写、异常等
);
// 3. 检查 rdlist 列表是否为空,不为空则返回就绪的 fd 数量
int epoll_wait(
int epfd, // eventpoll 实例句柄
struct epoll_event *events, // 空 event 数组,用于存储就绪的 fd
int maxevents, // event 数组的最大长度
int timeout // 超时时间,-1 不超时,0 不阻塞,>0 阻塞时间
)
epoll_ctl
的作用:添加 fd
到 eventpoll
,并做监听
epoll_wait
作用:等待 fd
就绪
内存图:
epoll
相比于 select
和 poll
的优势:
- 拷贝的处理:
- 减少拷贝
fd
的数量:select
和poll
是把内核中所有的fd
都拷贝到用户中;epoll
只需要把要就绪的fd
拷贝到内核中 - 减少拷贝
fd
的次数:select
和poll
每次循环都需要把要监听的fd
从用户拷贝到内核中,每一次还要把结果拷贝回用户空间;epoll
是把select
的功能拆分开了:- 把
fd
拷贝到内核(epoll_ctl
) - 等待
fd
就绪(epoll_wait
),循环处理只需要执行epoll_wait
,不需要再去执行epoll_ctl
执行拷贝了
- 把
- 减少拷贝
select
和poll
拷贝到用户空间的是所有的fd
,需要遍历才知道哪个已经就绪,而epoll
拷贝到用户空间的是已经就绪的fd
,不需要遍历poll
是假无限,用的是链表解决fd
上限问题,理论上可以监听无数多的fd
,但随着fd
的增多,遍历的性能会下降;epoll
是采用红黑树保存监听的fd
,理论上无上限,遍历性能不会下降
IO 多路复用事件通知机制
当 fd
有数据可读时,我们调用 epoll_wait
就可以得到通知,但是事件通知的模式有两种:
LevelTriggered
:简称LT
。当fd
有数据可读时,会重复通知多次,直至数据处理完成。是epoll
的默认模式EdgeTriggered
:简称ET
。当fd
有数据可读时,只会被通知一次,不管数据是否处理完成
例子:
- 假设一个客户端
socket
对应的fd
已经注册到epoll
实例中 - 客户端
socket
发送了2kb
的数据 - 服务端
epoll_wait
,得到通知说fd
就绪 - 服务端从
fd
读取了1kb
数据 - 回到步骤
3
(再次调用epoll_wait
形成循环)
EF
模式数据一次读不完的解决方式有两种:
- 手动添加回去:
EF
模式在1
次数据读完之后,会直接将数据移除掉,下次在调用epoll_wait
不会在得到通知- 所以在第
1
次读完之后,需要手动的将数据添加回去,这里是调用epoll_ctl
,epoll_ctl
作用就是对epoll
实例中的fd
做操作,包括:添加、修改、删除 - 当我们做修改时,
eventpoll
会检查对应的fd
是否就绪,如果会重新添加到list_head
中,下次调用epoll_wait
就会读到 - 这样重复的通知,类似于
LT
模式,对于性能影响比较大
- 在一次通知中,循环读取数据,直到读完全部数据
- 使用这种方式不能使用阻塞
IO
,如果使用阻塞IO
当数据读完后不会返回,它会一直在那里等,导致整个进程阻塞 - 所以如果需要再一次通知中读完所有数据,需要使用不阻塞
IO
,一次循环读完,如果有数据会有一个标识,没有数据也会有一个标识,这样就可以在知道没数据后跳出循环
- 使用这种方式不能使用阻塞
LT
模式存在两种问题:
- 重复通知对性能有影响
- 可能存在惊群现象,多个进程都监听到了已经就需要的
fd
,都在尝试调用epoll_wait
,然后没有读完的数据还在list_head
中,下一次又会是所有的进程调用epoll_wait
,这种就是惊群现象
多路复用服务流程图:
redis 网络模型
redis
通过 IO
多路复用来提高网络性能,并且支持不同的多路复用实现,并且将这些实现进行封装,提供了统一高性能事件处理接口
不同的系统,多路复用技术不同,redis
通过 ae.c
文件来封装不同的多路复用技术,ae.c
文件中包含了 ae_epoll.c
、ae_evport.c
、ae_kqueue.c
、ae_select.c
四个文件,每个文件对应一种多路复用技术
ae_select.c
是一个通用的模块ae_epoll.c
->Linux
平台设计的模块ae_evport.c
->Solaris
平台设计的模块ae_kqueue.c
->OS X
和FreeBSD
平台设计的模块
macox
用的是 ae_kqueue.c
,windows
使用了一个跨平台的异步 I/O
库 libuv
c++
#ifdef HAVE_EVPORT
#include "ae_evport.c"
#else
#ifdef HAVE_EPOLL
#include "ae_epoll.c"
#else
#ifdef HAVE_KQUEUE
#include "ae_kqueue.c"
#else
#include "ae_select.c"
#endif
#endif
#endif
api
说明:
aeApiCreate(aeEventLoop *eventLoop)
创建多路复用程序,比如epoll_create
aeApiAddEvent(aeEventLoop *eventLoop, int fd, int mask)
注册fd
,比如epoll_ctl
aeApiDelEvent(aeEventLoop *eventLoop, int fd, int mask)
删除fd
aeApiPoll(aeEventLoop *eventLoop, int fd, int mask)
等待fd
就绪,比如epoll_wait
、select
、poll
c
int main(int argc, char **argv) {
// 初始化服务
initServer();
// 开始监听事件循环
aeMain();
}
c
void initServer(void) {
// 内部会调用 aeApiCreate(eventLoop) 类似 epoll_create
server.el = aeCreateEventLoop(server.maxclients + CONFIG_FDSET_INCR);
// 监听 TCP 端口,创建 ServerSocket,并得到 FD
// port 默认 6379,ip 默认 127.0.0.1
listenToPort(server.port, &server.ipfd)
// 注册 连接处理器,内部会调用 aeApiAddEvent(&server.ipfd) 监听 fd
createSocketAcceptHandler(&server.ipfd, acceptTcpHandler)
// 注册 ae_api_poll 前的处理器
aeSetBeforeSleepProc(server.el, beforeSleep)
}
c
void aeMain(aeEventLoop *eventLoop) {
eventLoop->stop = 0;
// 循环监听事件
while(!eventLoop->stop){
aeProcessEvents(eventLoop, AE_ALL_EVENTS|AE_CALL_BEFORE_SLEEP|AE_CALL_AFTER_SLEEP)
}
}
c
int aeProcessEvents(aeEventLoop *eventLoop, int flags) {
// 调用前置处理器,beforesleep
eventLoop->beforesleep(eventLoop);
// 等待 fd 就绪,类似 epoll_wait
// numevents 是就绪 fd 的数量
numevents = aeApiPoll(eventLoop, tvp);
for(j = 0; j < numevents; j++){
// 遍历处理就绪的 fd,调用对应的处理器
}
}
c
void acceptTcpHandler(...) {
// 接收 socket 连接,获取 fd
fd = accept(s, sa, len)
// 创建 connection,关联 fd
connection *conn = connCreateSocket();
conn.fd = fd;
// 内部调用 aeApiAddEvent(fd, READABLE)
// 监听 socket 的 fd 读事件,并绑定读处理器 readQueryFromClient
connSetReadHandler(conn, readQueryFromClient);
}
c
void readQueryFromClient(...) {
// 获取当前客户端,客户端中有缓冲区用来读和写
client *c = connGetPrivateData(conn);
// 获取 c->querybuf 缓冲区大小
long int qblen = sdslen(c->querybuf);
// 读取请求数据到 c->querybuf 缓冲区
connRead(c->conn, c->querybuf+qblen, readlen);
// 解析缓冲区字符串,转为 Redis 命令参数存入 c=>argv 数组
processInputBuffer(c);
// 处理 c->argv 中的命令
processCommand(c);
}
int processCommand(client *c) {
// 根据命令名称,寻找命令对应的 command,例如 setCommand
c->cmd = c->lastcmd = lookupCommand(c->argv[0]->ptr);
// 执行 command,得到响应结果,例如 ping 命令,对应 pingCommand
c->cmd->proc(c);
// 把执行结果写出,例如 ping 命令,就返回 "pong" 给 client
// shared.pong 是字符串 "pong" 的 SDS 对象
adReply(c, shared.pong);
}
void addReply(client *c, robj *obj) {
// 尝试把结果写到 c->buf 客户端缓冲区
if(_addReplyToBuffer(c, obj->ptr, sdslen(obj->ptr)) != C_OK);
// 如果 c->buf 写不下,则写到 c->reply,这是一个链表,容量无限
_addReplyObjectToList(c, obj->ptr, sdslen(obj->ptr));
// 将客户端添加到 server.clients_pending_write 队列,等待被写出
listAddNodeHead(server.clients_pending_write, c);
}
c
void beforeSleep(struct aeEventLoop *eventLoop) {
// 定义迭代器,指向 server.clients_pending_write->head 队列
listIter li;
li->next = server.clients_pending_write->head;
li->direction = AL_START_HEAD;
// 循环遍历写出 client
while((ln = listNext(&li))){
// 内部调用 aeApiAddEvent(fd, WRITABLE),监听 socket 的 fd 读事件
// 并且绑定写处理器 sendReplyToClient,可以把响应写到客户端 socket
connSetWriteHandlerWithBarrier(c->conn, sendReplyToClient, ae_barrier);
}
}
redis
网络模型内存图:
通信协议
Redis
是一个 CS
架构的软件,通信一般分为两步:
- 客户端(
client
)向服务端(server
)发送一条命令 - 服务端解析并执行命令,返回响应结果给客户端
因此客户端发送命令的格式、服务端响应结果的格式必须有一个规范,这个规范就是通信协议
Redis
采用的是 RESP
协议,全称是 Redis Serialization Protocol
RESP 协议 - 数据类型
在 RESP
中,通过首字母的字符来区分不同数据类型,常用的数据类型包括 5
种:
- 单行字符串:首字母是
+
,后面跟上单行字符串,以CRLF
("\r\n"
) 结尾,例如返回"OK"
:"+OK\r\n"
- 错误(
Errors
):首字节是-
,与单行字符串格式一样,只是字符串是异常信息,例如:"-Error message\r\n"
- 数值:首字节是
:
,后面跟上数字格式的字符串,以CRLF
结尾,例如::10\r\n
- 多行字符串:首字节是
$
,表示二进制安全的字符串,最大支持512MB
:- 如果大小为
0
,则代表空字符串:"$0\r\n\r\n"
- 如果大小为
-1
,则代表不存在:$-1\r\n
- 如果大小为
- 数组:首字节是
*
,后面跟上数组元素个数,在跟上元素,元素数据类型不限
Redis 内存回收
通过 expire
命令给 reids
的 key
设置TTL
(存活时间)
当 key
的 TTL
到期后,这个 key
会被释放,从而内存也会被释放掉
redis
是一个 key/value
内存存储数据库,因此所有的 key
、value
都保存在 dict
结构中,在 database
结构体中,有两个 dict
:一个用来记录 key/value
,一个用来记录 key/TTL
c
typedef struct redisDb {
dict *dict; // 存放所有 key/value 的地方,也被称为 keyspace
dict *expires; // 存放每一个 key 及其对应的 TLL 存活时间,只包含设置了 TTL 的 key
// ...
}
过期策略
那么就会面临两个问题:
redis
是如何知道key
是否过期- 利用两个
dict
分别记录key/value
及key/TTL
- 利用两个
- 是不是
TTL
到期就立即删除呢-
惰性删除
- 在访问一个
key
时,检查该key
的存活时间,如果已经过期才执行删除
c// 查找一个 key 执行写操作 robj *lookupKeyWriteWithFlags(redisDb *db, robj *key, int flags){ // 检查 key 是否过期 expireIfNeeded(db, key); return lookupKey(db, key, flags); } // 查找一个 key 执行读操作 robj *lookupKeyReadWithFlags(redisDb *db, robj *key, int flags){ robj *val; // 检查 key 是否过期 if(expireIfNeeded(db, key) == 1) { // ... } return lookupKey(db, key, flags); }
cint expireIfNeeded(redisDb *db, robj *key){ // 判断是否过期,如果未过期直接结束并返回 0 if(!keyIsExpired(db, key)) return 0; // ... 略 // 删除过期的 key deleteExpiredKeyAndPropagate(db, key); return 1; }
- 在访问一个
-
周期删除
cvoid aeMain(aeEventLoop *eventLoop){ eventLoop->stop = 0; while(!eventLoop->stop){ // beforeSleep() --> Fast 模式清理 // while 每次执行时,beforeSleep 调用间隔不低于 2ms --> 每次执行耗时很短,几十微秒到1毫秒 // n = aeApiPoll() // 如果 n > 0,fd 就绪,处理 IO 事件 // 如果到了执行时间,则调用 serverCron() --> Slow 模式清理 // 每次执行 serverCron 后都会返回一个时间,下一次执行到这一步时,会先检查一下这个时间,如果时间到了在执行这个函数,调用时间 100ms --> 每次执行耗时很长,几十毫秒 } }
-
周期性的抽样部分过期
key
,然后执行删除,执行周期有两种:redis
会设置一个定时任务serverCron()
,按照server.hz
的频率来执行过期key
的清理,模式为SLOW
c// server.c void initServer(void) { // ... // 创建定时器,关联回调函数 serverCron,处理周期取决于 server.hz,默认 10 aeCreateTimeEvent(server.el, 1, serverCron, NULL, NULL); } int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) { // 更新 lruclock 到当前时间,为后期的 LRU 和 LFU 做准备 unsigned int lruclock = getLRUClock(); atomicSet(server.lruclock, lruclock); // 执行 database 的数据清理,例如过期 key 处理 databasesCron(); return 1000/server.hz; } void databaseCron(void){ // 尝试清理部分过期 key,清理模式为 SLOW activeExpireCycle(ACTIVE_EXPIRE_CYCLE_SLOW); }
-
redis
的每个事件循环前会调用beforeSleep()
函数,执行过期key
清理,模式为FAST
cvoid beforeSleep(struct aeEventLoop *eventLoop) { // 尝试清理部分过期 key,清理模式为 FAST activeExpireCycle(ACTIVE_EXPIRE_CYCLE_FAST); }
SLOW
模式规则:- 执行频率受
server.hz
影像,默认为10
,即每秒执行10
次,每个执行周期100ms
- 执行清理耗时不超过一次执行周期的
25%
,也就是说一个执行周期是100ms
,一次清理的时间不超过25ms
- 逐个遍历
db
,逐个遍历db
中的bucket
,抽取20
个key
判断是否过期 - 如果没达到时间上限(
25ms
),并且过期key
,比例大于10%
,再进行一次抽样,否则结束
- 执行频率受
FAST
模式规则(过期key
比例小于10%
不执行):- 执行频率受
beforeSleep()
调用频率影响,但两次FAST
模式间隔不低于2ms
- 执行清理耗时不超过
1ms
- 逐个遍历
db
,逐个遍历db
中的bucket
,抽取20
个key
判断是否过期 - 如果没达到时间上限(
1ms
),并且过期key
比例大于10%
,再进行一次抽样,否则结束
- 执行频率受
-
-
淘汰策略
内存淘汰:就是当 redis
内存使用达到设置的阈值时,redis
主动挑选部分 key
删除以释放更多内存的流程。
redis
会在处理客户端命令的方法 processCommand()
中尝试做内存淘汰
c
int processCommand(client *c){
// 如果设置了 server.maxmemory 属性,并且并未有执行 lua 脚本
if (server.maxmemory && !server.lua_timedout){
// 尝试进行内存淘汰 performEvictions
int out_of_memory = (performEvictions() == EVICT_FAIL);
// ...
if (out_of_memory && reject_cmd_on_oom) {
rejectCommand(c, shared.oomerr);
return C_OK;
}
}
}
redis
支持 8
种不同策略来选择要删除的 key
noeviction
:不淘汰任何key
,但是内存满时不允许写入新数据,默认就是这种volatile-ttl
:对设置了TTL
的key
,比较key
的剩余TTL
值,TTL
越小越先淘汰allkeys-random
:对全体key
,随机进行淘汰,也就是直接从db->dict
中随机挑选volatile-random
:对设置了TTL
的key
,随机进行淘汰,也就是从db-expires
中随机挑选allkeys-lru
:对全体key
,基于LRU
算法进行淘汰,也就是直接从db->dict
中挑选最少最近使用的key
volatile-lru
:对设置了TTL
的key
,基于LRU
算法进行淘汰,也就是直接从db->expires
中挑选最少最近使用的key
allkeys-lfu
:对全体key
,基于LFU
算法进行淘汰,也就是直接从db->dict
中挑选最少频率使用的key
volatile-lfu
:对设置了TTL
的key
,基于LFU
算法进行淘汰,也就是直接从db->expires
中挑选最少频率使用的key
其中 LRU
和 LFU
的区别是:
LRU
全称Least Recently Used
最少最近使用,用当前时间减去最后一次访问时间,这个值越大则淘汰优先级越高LFU
全称Least Frequently Used
最少频率使用,会统计每个key
访问的频率,值越小淘汰优先级越高
redis
的数据结构都会被封装为 RedisObject
结构:
c
typedef struct redisObject {
unsigned type:4; // 对象类型
unsigned encoding:4; // 编码方式
unsigned lru:LRU_BITS; // LRU:以秒为单位记录一次访问时间,长度为 24 bit
// LFU:高 16 位以分钟为单位记录最近一次访问时间,低 8 位记录逻辑访问次数
int refcount; // 引用计数,计数为 0 则可以回收
void *ptr; // 数据指针,指向真实数据
} robj;
LFU
的访问次数之所以叫做逻辑访问次数 ,是因为并不是每次 key
被访问都计数,而是通过运算:
- 生成
0 ~ 1
之间的随机数R
- 计算
1 / (旧次数 * lfu_log_factor + 1)
,记录为P
,lfu_log_factor
默认为10
- 如果
R < P
,则P
加1
,且最大不超过255
- 访问次数会随时间衰减,距离上一次访问时间每隔
lfu_decay_time
分钟(默认1
),P
减1
淘汰策略流程图