我们经常听到 Reids 是单线程的,严格来说这是不准确的。Redis 是单线程,主要是指 Redis 的网络 IO 和键值对读写是由一个线程来完成的,这也是 Redis 对外提供键值存储服务的主要流程。但 Redis 的其他功能,比如持久化、异步删除、集群数据同步等是由额外的线程执行的。甚至从 Redis 6.0 开始网络 IO 处理模块也变成多线程了。
从另一个角度上,Reids 事件驱动模型中的使用的文件事件处理器是单线程的,所以 Reids 被叫做单线程的模型,这也是 Redis 可以支撑起高并发的缘由。
非阻塞IO
当我们调用套接字的读写方法,默认它们是阻塞的。当用户线程发起 IO 请求,进程会从用户态切换为内核态,当发现数据准备就绪,内核会将数据拷贝到用户线程,这个过程用户线程是阻塞的,用户线程会让出 CPU,直到将数据返回给用户线程。
非阻塞 IO 在套接字对象上提供 Non_Blocking 选项,它表示读写方法不会阻塞,而是能读多少读多少,能写多少写多少。能读/写多少取决于内核为套接字分配的读/写缓冲区内部的数据字节数,读方法和写方法都会通过返回值来告知程序实际读写了多少字节。用户线程需要不断地询问内核数据是否就绪,也就说非阻塞IO不会交出CPU,一直占用CPU。
有了非阻塞 IO 意味着线程在读写 IO 时不必阻塞线程,线程可以继续干别的事情。
多路复用
非阻塞 IO 存在一个问题,就是线程读取一部分数据就返回了,那么线程如何知道应该何时继续读写数据。
事件轮询 API 就是用来解决这个问题的,这是一种多路复用机制。常见的事件轮询 API 有 select、poll、epoll,它们是操作系统提供给用户线程的 API,用于取代用户线程轮询。如果是用户线程轮询就要涉及用户态和内核态的频繁切换,这部分开销是巨大的。
select
最简单事件轮询 API 是 select,下面是 select 函数:
int select(int maxfdp1,fd_set *readset,fd_set *writeset,fd_set *exceptset,const struct timeval *timeout);
select() 的机制中提供一种 fd_set 的数据结构,实际上是一个 long 类型的数组,每一个数组元素都能与一打开的文件句柄(不管是 Socket 句柄,还是其他文件或命名管道或设备句柄)建立联系,当调用 select() 时,由内核根据 IO 状态修改 fd_set 的内容,由此来通知执行了 select() 的进程哪一 socket 或文件可读写。
使用 select() 最大的优势是可以在一个线程内同时处理多个 socket 的 IO 请求。用户可以注册多个 socket,然后不断地调用 select() 读取被激活的 socket,即可达到在同一个线程内同时处理多个 IO 请求的目的。但 select 存在三个问题:一是为了减少数据拷贝带来的性能损坏,内核对被监控的 fd_set 集合大小做了限制,并且这个是通过宏控制的,大小不可改变(限制为 1024);二是每次调用 select(),都需要把 fd_set 集合从用户态拷贝到内核态,如果 fd_set 集合很大时,那这个开销很大;三是每次调用 select() 都需要在内核遍历传递进来的所有 fd_set,如果 fd_set 集合很大时,那这个开销也很大。
poll
poll 的机制与 select 类似,与 select 在本质上没有多大差别,管理多个描述符也是进行轮询,根据描述符的状态进行处理,只是 poll 没有最大文件描述符数量的限制。
epoll
epoll 在 Linux 2.6 内核正式提出,是基于事件驱动的 I/O 方式,相对于 select 来说,epoll 没有描述符个数限制,使用一个文件描述符管理多个描述符,将用户关心的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的 copy 只需一次。
Linux 提供的 epoll 相关函数如下:
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
epoll_create 函数创建一个epoll 的实例,返回一个表示当前 epoll 实例的文件描述符,后续相关操作都需要传入这个文件描述符。
epoll_ctl 函数讲将一个 fd 添加到一个 eventpoll 中,或从中删除,或如果此 fd 已经在 eventpoll 中,可以更改其监控事件。
epoll_wait 函数等待 epoll 事件从 epoll 实例中发生(rdlist 中存在或 timeout),并返回事件以及对应文件描述符。
当调用 epoll_create 函数时,内核会创建一个 eventpoll 结构体用于存储使用 epoll_ctl 函数向 epoll 对象中添加进来的事件。这些事件都存储在红黑树中,通过红黑树高效地识别重复添加地事件。所有添加到 epoll 中的事件都会与设备(网卡)驱动程序建立回调关系,当相应的事件发生时会调用这个回调方法,它会将发生的事件添加到 rdlist 链表中。调用 epoll_wait 函数检查是否有事件发生时,只需要检查 eventpoll 对象中的 rdlist 链表中是否存在 epitem 元素(每一个事件都对应一个 epitem 结构体)。

对于 fd 集合的拷贝问题,epoll 通过内核与用户空间 mmap(内存映射)将用户空间的一块地址和内核空间的一块地址同时映射到相同的一块物理内存地址,使得这块物理内存对内核和对用户均可见,减少用户态和内核态之间的数据交换。
综上:epoll 没有描述符个数限制,所以 epoll 不存在 select 中文件描述符数量限制问题;mmap 解决了 select 中用户态和内核态频繁切换的问题;通过 rdlist 解决了 select 中遍历所有事件的问题。
指令队列
Redis 会将每个客户端套接字都关联一个指令队列。客户端的指令通过队列来排队进行顺序处理,先到先服务。
响应队列
Redis 同样也会为每个客户端套接字关联一个响应队列,Redis 服务器通过响应队列来将指令的返回结果回复给客户端。如果队列为空,那么意味着连接暂时处于空闲状态,不需要去获取写事件,也就是可以将当前的客户端描述符从 write_fds 里面移出来。等到队列有数据了,再将描述符放进去。
为什么Redis采用单线程IO多路复用
虽然 IO 多路复用为单线程处理多线程任务提供了思路,但并不是所有程序的可以保持高效的。Redis 把数据存放在内存中,而且操作逻辑简单,CPU 运算速度比内存 IO 更是快了几个数量级,这让 Redis 的瓶颈不会出现在 CPU,在到达 CPU 瓶颈之前网络带宽将先成为瓶颈,因此提高 CPU 利用率并不能有效提升 Redis 效率。单线程的设计避免了线程的上下文切换,而且单线程设计的安全性也避免了锁,这些因素决定了 Redis 采用单线程 IO 多路复用。
文章同步 github。
地址:github.com/lazecoding/Note/blob/main/note/articles/redis/IO模型.md




