Redis是单线程还是多线程

要说Redis是单线程还是多线程实际要看实际版本

Redis的版本有很多,其架构也是不一样的。

  • Redis3的时候是单线程。
  • Redis4开始,严格意义上来说已经不是单线程了,而是负责处理客户端请求的线程是单线程,但是开始加了点多线程的东西。
  • Redis6Redis7彻底的用上了多线程。

Redis中有几个里程碑式的重要版本:

时间 版本 版本特性
2012年10月 Redis2.6 开始支持Lua脚本
2015年4月 Redis3.0 官方的集群方案
2017年7月 Redis4.0 混合持久化、多线程异步删除
2018年10月 Redis5.0 核心代码重构
2020年5月 Redis6.0 多线程IO

怎么理解Redis是单线程的呢?

Redis的单线程主要是指Redis的网络IO和键值对读写是由一个线程来完成的,Redis在处理客户端的请求时包括获取(Socket读)、解析、执行、内容返回(Socket写)等都是由一个顺序串行的主线程处理,这就是所谓的”单线程“。这也是Redis对外提供键值存储服务的主要流程。

  • Redis采用Reactor模式的网络模型,对于一个客户端请求,主线程负责一个完整的处理过程
主线程耗时的操作耗时的操作读取Socket解析请求执行操作写入Socket

但是Redis的其他功能,比如持久化RDB、AOF、异步删除、集群数据同步等等其实都是由额外的线程来完成的。Redis命令工作线程是单线程的,但是对于整个Redis来说,实际是多线程的。

为什么Redis使用单线程依然很快?

  • 基于内存操作:Redis的所有数据都在内存中,因此所有的运算都是内存级别的。
  • 数据结构简单:Redis的数据结构是专门设计的,这些简单的数据结构的查找和操作的时间复杂度大部分都是O(1)。
  • 多路IO复用和非阻塞IO:Redis使用IO多路复用功能来监听多个Socket连接客户端,这样就可以使用一个线程连接来处理多个请求,减少线程切换带来的开销。同时也避免了IO阻塞操作。
  • 避免上下文切换:因为是单线程模型,所以避免了不必要的上下文切换和多线程竞争。省去了多线程切换带来的时间和性能上的消耗,而且单线程不会导致死锁问题发生。

Redis如何利用多核CPU呢?

官网是这么回答的

Redis很少出现CPU成为瓶颈的情况,通常Redis要么是内存首先,要么是网络受限。

例如:使用在平均Linux系统上运行的流水线Redis每秒可以发送100万个请求,因此如果您的应用程序主要使用O(N)或O(log(N))命令,则几乎不会使用太多CPU。

但是,要最大化CPU使用率,您可以在同一台计算机上启动多个Redis实例,并将它们视为不同的服务器。在某些时候,单个计算机可能已经不够了,因此如果要使用多个CPU,则可以开始考虑更早地进行分片的某种方式。

从4.0版本开始,Redis开始具有更多线程。目前,这仅限于在后台删除对象和阻止通过Redis模块实现的命令。对于随后的版本,计划是使Redis越来越多线程化。

官网的大致意思就是说Redis是基于内存操作的,因此它的瓶颈应该是服务器的内存或者网络带宽而并非是CPU,既然CPU不是瓶颈,那么自然就采用单线程的方案了,况且使用多线程比较麻烦。但是在Redis4.0中开始支持多线程了,比如后台删除、备份等功能

Redis在4.0之前一直采用单线程的原因主要有3个

  1. 使用单线程模型让Redis的开发和维护更简单,因为单线程模型方便开发和调试。
  2. 即使使用单线程模型也能并发处理多客户端的请求,主要就是使用了IO多路复用和非阻塞IO。
  3. 对于Redis来说,主要性能瓶颈是内存或者网络带宽而并非CPU。

为什么Redis后面开始使用多线程呢?

正常情况下使用del指令可以很快的删除数据,但是当被删除的key是一个非常大的对象时(比如包含了上万个元素的hash集合),那么del指令就会造成Redis主线程阻塞。

那么如何解决Redis删除大key时的阻塞问题呢?使用惰性删除可以有效地避免这个问题:

比如当删除一个很大的key的时候,因为单线程导致阻塞卡顿,所以Redis4.0中新增了多线程模块(此版本的多线程主要是为了解决删除数据效率比较低的问题),主要有这么几个命令:

  1. UNLINK KEY
  2. FLUSHDB ASYNC
  3. FLUSHALL ASYNC

这里其实就是把删除工作交给了子线程异步来完成了。

因为Redis是使用单个线程处理请求,Redis作者一直强调“Lazy Redis is better Redis”,而Lazy Free的本质就是把某些Cost(主要时间复杂度,占用主线程CPU时间片)较高操作,从Redis主线程剥离出来让bio子线程来处理,极大地减少主线程阻塞地时间。从而减少删除大key导致地性能和稳定性问题。

Redis6.0之后真正地加入了多线程

Redis6.0之后,最受关注的特性第一个就是多线程。

因为Redis一直被熟知地就是单线程,虽然有些命令操作可以用后台线程或子进程执行(比如数据删除、快照生成、AOF重写)。但是真正从网络IO到实际读写命令地处理都是由单个线程完成的。

随着网络硬件的性能提升,Redis的性能瓶颈有时也会出现在网络IO上,也就是说单个主线程的处理请求的速度跟不上网络硬件的速度。

为了应对这个问题:Redis6.0之后采用了多个IO线程来处理网络请求,提高网络请求处理的并行度

但是Redis的多IO线程只是用来处理网络请求的,对于读写操作命令Redis仍然采用单线程处理,主要是因为:

  • Redis处理请求时,网络处理经常成为瓶颈,而通过多个IO线程并行处理网络请求,可以提升服务的整体处理性能。
  • 继续使用单线程处理读写命令操作,就不用为了保证Lua脚本、事务的原子性,额外开发多线程互斥加锁机制,这样一来就简化了Redis的实现。

主线程和IO线程是怎么协作完成请求处理的呢?

主要有四个阶段:

  1. 阶段一:服务端和客户端建立Socket连接,并分配处理线程

    首先主线程负责接收建立连接请求,当有客户端请求和服务建立Socket连接时,主线程会创建和客户端的连接,并把Socket放入全局等待队列中。紧接着,主线程通过轮询的方法把Socket连接分配给IO线程。

  2. 阶段二:IO线程读取并解析请求

    主线程一旦把Socket分配给IO线程,就会进入阻塞状态,等待IO线程完成客户端请求读取和解析。因为有多个IO线程在并行处理,所以这个过程很快就能完成。

  3. 阶段三:主线程执行请求的操作

    等到IO线程解析完毕,主线程还是会以单线程的方式执行这些读写命令。

  4. 阶段四:IO线程回写Socket和主线程清空全局队列

    当主线程执行完毕读写命令后,会把需要返回的结果写入缓冲区,然后主线程会阻塞并等待IO线程把这些结果回写到Socket中,并返回给客户端。和IO线程读取、解析请求一样,IO线程回写到SOcket时也是有多个线程在并发执行,所以回写Socket的速度也很快。等待IO线程回写完毕,主线程会清空全局队列,等待客户端的后续请求。

具体流程如下图所示:

阶段三:主线程等待IO线程完成数据回写SocketIO线程开始执行主线程开始执行清空等待队列,等待后续请求请求的命令操作执行完成将结果数据写入缓冲区主线程阻塞阶段四:IO线程Socket回写完成将结果数据写回Socket(并行化执行)阶段一:主线程等待IO线程完成请求读取和解析IO线程开始执行主线程开始执行执行请求的命令操作接收建立连接请求,获取Socket将Socket放入全局等待队列轮询方式将Socket连接分配给IO线程主线程阻塞阶段二:IO线程请求解析完成将Socket和线程绑定读取Socket中的请求并解析(并行化执行)

IO多路复用

Unix网络编程中的五种IO模型

Blocking IO NoneBlocking IO IO MuiltiPlexing Signal Driven IO Asynchronous IO
阻塞IO 非阻塞IO IO多路复用 信号驱动IO 异步IO

File Descriptor

Linux系统中一切皆文件,文件描述符(File Descriptor简称FD),句柄。

文件描述符(File Descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。文件描述符在形式上是一个非负整数。实际上它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,文件描述符这一概念往往只适用于UNIX、LINUX这样的操作系统。

IO多路复用简单理解

IO多路复用是一种同步的IO模型,实现一个线程监视多个文件句柄

  • 一旦某个文件句柄就绪就能够通知到对应的应用程序进行相应的读写操作。
  • 没有文件句柄就绪时就会阻塞应用程序,从而释放CPU资源。

IO多路复用字面意思可以理解为:

IO 多路 复用
网络IO,尤其在操作系统层面指数据在内核态和用户态之间的读写操作 多个客户端连接(连接就是套接字描述符,即Socket或者Channel) 复用一个或者多个线程

所以IO多路复用就是一个或者一组线程处理多个TCP连接,使用单进程就能够实现同时处理多个客户端的连接,无需创建或者维护过多的进程/线程

一句话就是一个服务端进程可以同时处理多个套接字描述符。

实现IO多路复用的模型有3种:可以分为select->poll->epoll三个阶段来描述。

IO多路复用简单流程

将用户Socket对应的文件描述符(File Descriptor)注册进epoll,然后epoll监听哪些Socket上有消息到达,这样就避免了大量的无用操作。此时的Socket应该采用非阻塞模式。这样整个过程只有在调用selectpollepoll的时候才会阻塞,收发客户端消息是不会阻塞的,整个进程或者线程就被充分利用起来,这就是事件驱动,所谓的reactor反应模式。

单个线程通过记录跟踪每一个Socket(IO流)的状态来同时管理多个IO流,一个服务端进程可以同时处理多个套接字描述符,目的是尽量多的提高服务器的吞吐能力。

总结

客户端请求服务端时,实际就是在服务端的Socket文件中写入客户端对应的文件描述符(File Descriptor),如果有多个客户端同时请求服务端,为每次请求都分配一个线程的话,这样就会比较耗费服务端资源。所以只使用一个线程来监听多个文件描述符,就是IO多路复用。采用IO多路复用技术可以让单个线程高效的处理多个连接请求

从Redis6.0开始,就新增了多线程的功能来提高IO的读写性能,它的主要思路就是将主线程的IO读写任务拆分给一组独立的线程去执行,这样就可以使多个Socket的读写并行化了,采用IO多路复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络IO的时间消耗),将最耗时的Socket读取、请求解析、写入单独地外包出去,剩下的命令执行仍然由主线程串行执行。

  • 网络IO操作变成多线程化,其他核心部分仍然是线程安全的,是个不错的折中办法
主线程多个IO线程多个IO线程执行操作写入Socket读取Socket解析请求

Redis是否默认开启了多线程

如果在实际应用中,发现Redis服务的CPU开销不大但是吞吐量却没有提升,可以考虑使用Redis的多线程机制,加速网络处理,从而提升吞吐量。

在Redis6.0之后多线程机制默认是关闭的,如果需要使用多线程功能,则需要在配置文件redis.conf中完成两个设置:

# Redis大部分是单线程的,但有一些线程操作,例如UNLINK、缓慢的I/O访问和其他一些事情是在子线程上执行的。
# 现在,可以将Redis客户端套接字的读写处理在不同的I/O线程中。由于写入是如此缓慢,通常Redis用户使用流水线技术来加速每个核心的Redis性能,并产生多个实例以进行更好的扩展。
# 使用I/O线程可以轻松地将Redis的速度提高两倍,而不需要使用流水线技术或实例分片。
# 默认情况下,IO线程是禁用的,我们建议仅在具有4个或更多核心的机器上启用IO线程,留下至少一个备用核心。使用超过8个线程不太可能有太大帮助。
# 我们还建议仅在实际存在性能问题的情况下使用线程化I/O,Redis实例能够使用相当大的CPU时间百分比,否则使用此功能没有意义。
# 因此,例如,如果您有一个四核心的CPU,尝试使用2或3个I/O线程,如果您有8个核心,则尝试使用6个线程。要启用I/O线程,请使用以下配置指令:

io-threads 4

# 将io-threads设置为1将像往常一样使用主线程。
# 启用I/O线程时,我们仅在写入时使用线程,即将write(2)系统调用线程化并将客户端缓冲区传输到套接字。但是也可以通过将以下配置指令设置为yes来启用读取和协议解析的多线程化:

io-threads-do-reads no

❗**注意:**通常情况下,线程读取没有太大帮助。

  1. 此配置指令不能通过CONFIG SET在运行时更改。另外,当启用SSL时,此功能不起作用。
  2. 如果要使用redis-benchmark测试Redis,请确保运行基准测试也是多线程模式,使用--threads选项匹配上面配置的Redis线程的数量,否则将无法体现提升。