运营研发团队程序媛 张晶晶
背景
1. 最近研究 redis 关于主从复制的功能实现,发现客户端实时响应 slaveof 的命令后,把主从复制添加到 epoll 的时间事件中再进行操作。因此有疑问,redis 是如何进行文件和时间事件的调度
2.go 的一大特点就是从语言方面支持协程,提供系统的并发性,那么 go 语言中是否还需要 epoll 这种事件驱动模型
基于以上两个疑问,我进行了事件驱动模型的研究和分析
分析
先明确一点:事件驱动模型的本质是单线程的,因为想要同时处理多个请求,我们需要换成事件模型的方式重构代码
1. 最简单的模型是单线程
bind()
listen()
while(1) {
accept() // 接收新连接
handle() // 处理消息
}
当多个客户端请求时只能一个个处理,只要等到当前连接结束,才能处理下一个连接。这种方式处理效率低下,怎么提高处理效率呢,有两种方法:多线程处理,或者一个线程处理多个连接
2. 多线程
bind()
listen()
while(1) {
accept()
pthread_create()
}
这样就可以同时处理多个请求。但是这种方式有几个不好的地方,
cpu 和内存消耗,上下文切换
线程安全问题
问题定位难等
对于第一个问题,在程序中进行速率的限制,防止客户端无限链接 -> 线程池
3. 事件驱动模型
除了线程池,还可以采用非阻塞式 IO,通过 fcntl 设置套接字。这种方式只能通过不断的轮询来检查是否有请求数据到来。
操作系统应该是知道哪个套接字是准备好了数据的,因此没必要逐个扫描。
select
想想一个线程,有一堆的任务要处理,应该监视哪些东西呢,两种类型的套接字活动:
1.accept
2. 读写事件
尽管这两种活动在本质上有所区别,我们还是要把它们放在一个循环里,因为只能有一个主循环。循环会包含 select 的调用。这个 select 的调用会监视上述的两种活动。
while(1) {
int nready = select(fdset_max + 1, &readfds, &writefds, NULL, NULL); // 获取事件的个数
// 遍历 fd
for (fd = 0; fd <= fdset_max && nready > 0; fd++) {
// Check if this fd became readable.
if (FD_ISSET(fd, &readfds)) {// 是否为读事件
nready–;
if (fd == listener_sockfd) {// 是否为请请求到来
fd_status_t status =
on_peer_connected(newsockfd, &peer_addr, peer_addr_len);
if (status.want_read) {// 是否有读事件
FD_SET(newsockfd, &readfds_master);
} else {
FD_CLR(newsockfd, &readfds_master);
}
if (status.want_write) {// 是否有写事件
FD_SET(newsockfd, &writefds_master);
} else {
FD_CLR(newsockfd, &writefds_master);
}
}
} else {
fd_status_t status = on_peer_ready_recv(fd);// 接收数据
if (status.want_read) {
FD_SET(fd, &readfds_master);
} else {
FD_CLR(fd, &readfds_master);
}
if (status.want_write) {
FD_SET(fd, &writefds_master);
} else {
FD_CLR(fd, &writefds_master);
}
}
}
if (FD_ISSET(fd, &writefds)){// 是否有写事件
//
write()// 发送消息
…
}
}
流程图的处理从图一变成了图二
select 方法会返回需要处理的事件的个数,然后遍历所有的 fd 去处理。
但是这种方法依然是有缺陷,第一既然知道了事件的个数,可不可以知道事件是发生在哪个 fd 上。不然每次都需要遍历所有的 fd,限制性能。
此外,FD_SETSIZE 是一个编译期常数,在如今的操作系统中,它的值通常是 1024。它被硬编码在 glibc 的头文件里,并且不容易修改。它把 select 能够监视的文件描述符的数量限制在 1024 以内。曾有些人想要写出能够处理上万个并发访问的客户端请求的服务器,所以这个问题很有现实意义。
epoll
epoll 高效的关键之处在于它与内核更好的协作。epoll_wait 用当前准备好的事件填满一个缓冲区。只有准备好的事件添加到了缓冲区,因此没有必要遍历客户端中当前所有 监视的文件描述符。这简化了查找就绪的描述符的过程,把空间复杂度从 select 中的 O(N) 变为了 O(1)。
while(1){
int nready = epoll_wait(epollfd, events, MAXFDS, -1);
for (int i = 0; i < nready; i++) {
…
}
}
要在 select 里面重新遍历,有明显的差异:如果在监视着 1000 个描述符,只有两个就绪,epoll_waits 返回的是 nready=2,然后修改 events 缓冲区最前面的两个元素,因此我们只需要“遍历”两个描述符。用 select 我们就需要遍历 1000 个描述符,找出哪个是就绪的。因此,在繁忙的服务器上,有许多活跃的套接字时 epoll 比 select 更加容易扩展。
redis 为什么要实现自己的事件库?
Redis 并没有使用 libuv,或者任何类似的事件库,而是它去实现自己的事件库 —— ae,用 ae 来封装 epoll、kqueue 和 select。事实上,Antirez(Redis 的创建者)恰好在 2011 年的一篇文章 http://oldblog.antirez.com/po… 中回答了这个问题。他的回答的要点是:ae 只有大约 770 行他理解的非常透彻的代码;而 libuv 代码量非常巨大,也没有提供 Redis 所需的额外功能。
epoll 实现
https://www.cnblogs.com/appre… 这篇文章分析了 epoll 的实现
一颗红黑树,一张准备就绪 fd 链表,少量的内核 cache,就帮我们解决了大并发下的 fd(socket)处理问题。
1. 执行 epoll_create 时,创建了红黑树和就绪 list 链表。
2. 执行 epoll_ctl 时,如果增加 fd(socket),则检查在红黑树中是否存在,存在立即返回,不存在则添加到红黑树上,然后向内核注册回调函数,用于当中断事件来临时向准备就绪 list 链表中插入数据。
3. 执行 epoll_wait 时立刻返回准备就绪链表里的数据即可
又来一个问题,为什么 epoll 选择红黑树问不是其它 avl 树,mysql 选择 b + 树
为什么不用 AVL 树作为底层实现, 那是因为 AVL 树是高度平衡的树, 而每一次对树的修改, 都要 rebalance, 这里的开销会比红黑树大. 红黑树插入只要两次旋转, 删除至多三次旋转. 但不可否认的是, AVL 树搜索的效率是非常稳定的. 选取红黑树, 是一种折中的方案
B+ 树是为磁盘或其他直接存取的辅助存储设备而设计的一种数据结构。mysql 为什么选取 B + 树,本质上是因为 mysql 数据是存放在外部存储的
go 是否需要 epoll
因为协程的高效,在 go 中处理多客户端请求,只需要如下这样写即可
for{
accept()
go handle()
}
是否需要 epoll 的编程模型呢,在一篇帖子中写到
go 中怎么找不到像 epoll 或者 iocp 这种编程模型答案很简单:goroutine 底层用的非阻塞 +epoll,所以你可以用同步的方式写出异步的程序。
链接:https://grokbase.com/p/gg/gol…
验证需要查看 goroutine 的底层实现。(大概率是正确的,这就是 go 语言的优势所在)