Redis 线程IO模型

8/18/2021 Redis

# 一:前言

Redis单线程为什么这么快?

  1. 纯内存访问,Redis 将所有数据放在内存中,内存的响应时长大约为100纳秒,这是 Redis 达到每秒万级访问的重要基础。
  2. 非阻塞I/O,Redis 使用 epoll 作为I/O多路复用技术的实现,再加上 Redis 自身的事件处理模型将 epoll 中的连接、读写、关闭都转换为事件,不在网络I/O上浪费过多的时间。
  3. 单线程避免了线程切换和竞态产生的消耗。
    1. 单线程可以简化数据结构和算法的实现。
    2. 单线程避免了线程切换和竞态产生的消耗,对于服务端开发来说,锁和线程切换通常是性能杀手。

# 二:非阻塞IO

阻塞式IO

当调用套接字的读写方法,默认它们是阻塞的。

比如 read 方法要传递进去一个参数 n,表示读取这么多字节后再返回,如果没有读够线程就会卡在那里,直到新的数据到来或者连接关闭了,read 方法才可以返回,线程才能继续处理。

write 方法一般来说不会阻塞,除非内核为套接字分配的写缓冲区已经满了,write 方法就会阻塞,直到缓存区中有空闲空间挪出来了。

IO

非阻塞式IO

非阻塞 IO 在套接字对象上提供了一个选项 Non_Blocking,当这个选项打开时,读写方法不会阻塞,而是能读多少读多少,能写多少写多少。能读多少取决于内核为套接字分配的读缓冲区内部的数据字节数,能写多少取决于内核为套接字分配的写缓冲区的空闲空间字节数。读方法和写方法都会通过返回值来告知程序实际读写了多少字节。

有了非阻塞 IO 意味着线程在读写 IO 时可以不必再阻塞了,读写可以瞬间完成然后线程可以继续干别的事了。

# 三:事件轮询 (多路复用)

非阻塞 IO 有个问题,那就是线程要读数据,结果读了一部分就返回了,线程如何知道何时才应该继续读。

也就是当数据到来时,线程如何得到通知。写也是一样,如果缓冲区满了,写不完,剩下的数据何时才应该继续写,线程也应该得到通知。

select

事件轮询 API 就是用来解决这个问题的,最简单的事件轮询 API 是 select 函数,它是操作系统提供给用户程序的 API。输入是读写描述符列表 read_fds & write_fds,输出是与之对应的可读可写事件。同时还提供了一个 timeout 参数,如果没有任何事件到来,那么就最多等待 timeout 时间,线程处于阻塞状态。一旦期间有任何事件到来,就可以立即返回。时间过了之后还是没有任何事件到来,也会立即返回。拿到事件后,线程就可以继续挨个处理相应的事件。处理完了继续过来轮询。于是线程就进入了一个死循环,我们把这个死循环称为事件循环,一个循环为一个周期。

每个客户端套接字 socket 都有对应的读写文件描述符。

read_events, write_events = select(read_fds, write_fds, timeout)
for event in read_events:
 handle_read(event.fd)
for event in write_events:
 handle_write(event.fd)
handle_others() # 处理其它事情,如定时任务等
1
2
3
4
5
6

因为我们通过 select 系统调用同时处理多个通道描述符的读写事件,因此我们将这类系统调用称为多路复用API现代操作系统的多路复用 API 已经不再使用 select 系统调用,而改用 epoll(linux)kqueue(freebsd & macosx),因为 select 系统调用的性能在描述符特别多时性能会非常差。它们使用起来可能在形式上略有差异,但是本质上都是差不多的,都可以使用上面的伪代码逻辑进行理解。

服务器套接字 serversocket 对象的读操作是指调用 accept 接受客户端新连接。何时有新连接到来,也是通过 select 系统调用的读事件来得到通知的。

事件轮询 API 就是 Java 语言里面的 NIO 技术。Java 的 NIO 并不是 Java 特有的技术,其它计算机语言都有这个技术,只不过换了一个词汇,不叫 NIO 而已。

# 四:快速理解

  • 多进程
  • 多线程
  • 基于单进程的 IO 多路复用(select/poll/epoll)

1. 多进程

对于并发情况,假如一个进程不行,那搞多个进程不就可以同时处理多个客户端连接了么?

多进程这种方式的确可以解决了服务器在同一时间能处理多个客户端连接请求的问题,但是仍存在一些缺点:

  • fork() 等系统调用会使得进程上下文进行切换,效率较低
  • 进程创建的数量随着连接请求的增加而增加。比如 10w 个请求,就要 fork 10w 个进程,开销太大
  • 进程与进程之间的地址空间是私有、独立的,使得进程之间的数据共享变得困难

2. 多线程

线程是运行在进程上下文的逻辑流,一个进程可以包含多个线程,多个线程运行在同一进程上下文中,因此可共享这个进程地址空间的所有内容,解决了进程与进程之间通信难的问题。

同时,由于一个线程的上下文要比一个进程的上下文小得多,所以线程的上下文切换,要比进程的上下文切换效率高得多。

3. IO 多路复用

简单理解就是:一个服务端进程可以同时处理多个套接字描述符。

  • 多路:多个客户端连接(连接就是套接字描述符)
  • 复用:使用单进程就能够实现同时处理多个客户端的连接

以上是通过增加进程和线程的数量来并发处理多个套接字,免不了上下文切换的开销,而 IO 多路复用只需要一个进程就能够处理多个套接字,从而解决了上下文切换的问题。

基本原理就是不再由应用程序自己监视连接,而是由内核替应用程序监视文件描述符。客户端在操作的时候,会产生具有不同事件类型的 socket。在服务端,I/O 多路复用程序(I/O Multiplexing Module)会把消息放入队列中,然后通过文件事件分派器(Fileevent Dispatcher),转发到不同的事件处理器中。可以想象一条高速公路,车辆在不同的车道上行驶,但它们共用同一条路。

其发展可以分 select->poll→epoll 三个阶段来描述。

# 五:select/poll/epoll

举例1:领导分配员工开发任务,有些员工还没完成。如果领导对每个员工的工作都要验收检查,那在未完成的员工那里,只能阻塞等待,等待他完成之后,再去检查下一位员工的任务,造成性能问题。

那如何解决这个问题呢?

# 5.1 select

举例2:领导找个 Team Leader(后文简称 TL),负责代替自己检查每位员工的开发任务。

TL 的做法是:遍历问各个员工 "完成了么?",完成的待 CR 检查无误后合并到 Git 分支,对于其他未完成的,休息一会儿后再去遍历.... ...

这样存在什么问题呢?

  • 这个 TL 存在能力短板问题,最多只能管理 1024 个员工
  • 很多员工的任务没有完成,而且短时间内也完不成的话,TL 还是会不停的去遍历问询,影响效率。

select 函数:

int select(int maxfdp1, fd_set *readset, fd_set *writeset, fd_set *exceptset, const struct timeval *timeout);
1

select 函数监视的文件描述符分 3 类,分别是 writefdsreadfds、和 exceptfds。调用后 select 函数会阻塞,直到有描述符就绪(有数据可读、可写、或者有 except),或者超时(timeout 指定等待时间,如果立即返回设为 null 即可),函数返回。当 select 函数返回后,可以通过遍历 fdset,来找到就绪的描述符。

select 具有良好的跨平台支持,其缺点在于单个进程能够监视的文件描述符的数量存在最大限制,在 Linux 上一般为 1024。

# 5.2 poll

举例3:换一个能力更强的 New Team Leader(后文简称 NTL),可以管理更多的员工,这个 NTL 可以理解为 poll。

poll 函数:

intpoll(struct pollfd *fds, nfds_t nfds, int timeout);
typedef struct pollfd { 
	int fd; // 需要被检测或选择的文件描述符 
	short events; // 对文件描述符fd上感兴趣的事件 
	short revents; // 文件描述符fd上当前实际发生的事件
} pollfd_t;
1
2
3
4
5
6

poll 改变了文件描述符集合的描述方式,使用了 pollfd 结构而不是 select 的 fd_set 结构,使得 poll 支持的文件描述符集合限制远大于 select 的 1024。

# 5.3 epoll

举例4:在上一步 poll 方式的 NTL 基础上,改进一下 NTL 的办事方法:遍历一次所有员工,如果任务没有完成,告诉员工待完成之后,其应该做 xx 操作(制定一些列的流程规范)。这样 NTL 只需要定期检查指定的关键节点就好了。这就是 epoll。

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);	// 等待IO事件的发生
1
2
3

epoll 是 Linux 内核为处理大批量文件描述符而作了改进的 poll,是 Linux 下多路复用 IO 接口 select/poll 的增强版本,它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统 CPU 利用率。

# 5.4 小结

  • select 就是轮询,在 Linux 上限制个数一般为 1024 个
  • poll 解决了 select 的个数限制,但是依然是轮询
  • epoll 解决了个数的限制,同时解决了轮询的方式

I/O多路复用的本质是通过一种机制(系统内核缓冲I/O数据),让单个进程可以监视多个文件描述符, 一旦某个描述符就绪(一般是读就绪或写就绪),能够通知程序进行相应的读写操作

select poll epoll
操作方式 遍历 遍历 回调
数据结构 bitmap 数组 红黑树
最大连接数 1024(x86)或者2048(x64) 无上限 无上限
最大支持文件描述符数 有最大值限制 65535 65535
fd拷贝 每次调用,都需要把fd结合从用户态拷贝到内核态 每次调用,都需要把fd结合从用户态拷贝到内核态 只有首次调用的时候拷贝
工作效率 每次都要遍历所有文件描述符,时间复杂度O(n) 每次都要遍历所有文件描述符,时间复杂度O(n) 每次只用遍历需要遍历的文件描述符,时间复杂度O(1)

# 六:IO 多路复用在 Redis 中的应用

Redis 服务器是一个事件驱动程序,服务器处理的事件分为 文件事件时间事件两类。

  • 文件事件:Redis 主进程中,主要处理客户端的连接请求与响应。
  • 时间事件:fork 出的子进程中,处理如 AOF 持久化任务等。

由于 Redis 的文件事件是单进程,单线程模型,但是确保持着优秀的吞吐量,IO 多路复用起到了主要作用。

文件事件是对套接字操作的抽象,每当一个套接字准备好执行连接应答、写入、读取、关闭等操作时,就会产生一个文件事件。因为一个服务器通常会连接多个套接字,所以多个文件事件有可能会并发地出现。

IO 多路复用程序负责监听多个套接字并向文件事件分派器传送那些产生了事件的套接字。文件事件分派器接收 IO 多路复用程序传来的套接字,并根据套接字产生的事件的类型,调用相应的事件处理器。如图所示:

Redis 的 IO 多路复用程序的所有功能都是通过包装常见的 select、poll、evport 和 kqueue 这些 IO 多路复用函数库来实现的,每个 IO 多路复用函数库在 Redis 源码中都有对应的一个单独的文件。

Redis 为每个 IO 多路复用函数库都实现了相同的 API,所以 IO 多路复用程序的底层实现是可以互换的。如图:

Redis 把所有连接与读写事件、还有时间事件一起集中管理,并对底层 IO 多路复用机制进行了封装,最终实现了单进程能够处理多个连接以及读写事件。这就是 IO 多路复用在 redis 中的应用。

# 七:指令队列

Redis 会将每个客户端套接字都关联一个指令队列。客户端的指令通过队列来排队进行顺序处理,先到先服务。

# 八:响应队列

Redis 同样也会为每个客户端套接字关联一个响应队列。Redis 服务器通过响应队列来将指令的返回结果回复给客户端。 如果队列为空,那么意味着连接暂时处于空闲状态,不需要去获取写事件,也就是可以将当前的客户端描述符从 write_fds 里面移出来。等到队列有数据了,再将描述符放进去。避免 select 系统调用立即返回写事件,结果发现没什么数据可以写。出这种情况的线程会飙高 CPU。

# 九:定时任务

服务器处理要响应 IO 事件外,还要处理其它事情。比如定时任务就是非常重要的一件事。如果线程阻塞在 select 系统调用上,定时任务将无法得到准时调度。那 Redis 是如何解决这个问题的呢?

Redis 的定时任务会记录在一个称为最小堆的数据结构中。这个堆中,最快要执行的任务排在堆的最上方。在每个循环周期,Redis 都会将最小堆里面已经到点的任务立即进行处理。处理完毕后,将最快要执行的任务还需要的时间记录下来,这个时间就是 select 系统调用的 timeout 参数。因为 Redis 知道未来 timeout 时间内,没有其它定时任务需要处理,所以可以安心睡眠 timeout 的时间。

Nginx 和 Node 的事件处理原理和 Redis 也是类似的。

# 十:总结

Redis中的IO多路复用模式:

  1. 一个 socket 客户端与服务端连接时,会生成对应一个套接字描述符(套接字描述符是文件描述符的一种),每一个 socket 网络连接其实都对应一个文件描述符。
  2. 多个客户端与服务端连接时,Redis 使用 「I/O 多路复用程序」 将客户端 socket 对应的 FD 注册到监听列表(一个队列)中。当客服端执行 read、write 等操作命令时,I/O 多路复用程序会将命令封装成一个事件,并绑定到对应的 FD 上。
  3. 「文件事件处理器」 使用 I/O 多路复用模块同时监控多个文件描述符(fd)的读写情况,当 acceptreadwriteclose 文件事件产生时,文件事件处理器就会回调 FD 绑定的事件处理器进行处理相关命令操作。
  4. 整个文件事件处理器是在单线程上运行的,但是通过 I/O 多路复用模块的引入,实现了同时对多个 FD 读写的监控,当其中一个 client 端达到写或读的状态,文件事件处理器就马上执行,从而就不会出现 I/O 堵塞的问题,提高了网络通信的性能。

Redis单线程?多线程?

Redis 在处理客户端的请求时,包括获取 (socket 读)、解析、执⾏、内容返回 (socket 写) 等都由⼀个顺序串⾏的主线程处理,这就是所谓的 「单线程」

Redis是单线程来执行命令的,每一条到达读服务端的命令并不会立即执行,所有的命令都会进入一个 socket 任务队列中,当 socket 可读则交给单线程事件分发器逐个被执行,即一个线程处理所有网络请求

Redis 采⽤多个 IO 线程来处理⽹络请求,提⾼⽹络请求处理的并⾏度。Redis 多 IO 线程模型只⽤来处理处理网络数据的读写和协议解析,对于 Redis 的读写命令,依然是单线程处理。

因为网络 I/O 在 Redis 执行期间占用了大部分 CPU 时间,所以把网络 I/O 部分单独抽离出来,做成多线程的方式。这里所说的多线程,其实就是将 Redis 单线程中做的这两件事情"从客户端读取数据、回写数据给客户端"(也可以称为网络 I/O),处理成多线程的方式,但是"执行 Redis 命令"还是在主线程中串行执行, 这个逻辑保持不变

# 十一:参考文献

最后更新: 12/18/2023, 3:49:59 PM