Redis 单线程 VS 多线程

Redis 单线程 VS 多线程

面试题

  • Redis 到底是单线程还是多线程?
  • IO 多路复用听说过吗?
  • Redis 为什么快?

Redis 为什么选择单线程

Redis 的版本很多 3.x、4.x、6.x,版本不同架构也是不同的,不限定版本问是否单线程也不太严谨。

  • 版本 3.x,最早版本,也就是大家口口相传的 redis 是单线程。

  • 版本 4.x,严格意义来说也不是单线程,而是负责处理客户端请求的线程是单线程,但是开始加了点多线程的东西(异步删除)。

  • 2020 年 5 月版本的 6.0.x 后及 2022 年出的 7.0 版本后,告别了大家印象中的单线程,用一种全新的多线程来解决问题。

Redis-里程碑

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

Redis 采用 Reactor 模式的网络模型,对于一个客户端请求,主线程负责一个完整的处理过程:

Redis-网络模型

但 Redis 的其他功能,比如持久化 RDB、AOF、异步删除、集群数据同步等等,其实是由额外的线程执行的

Redis 命令工作线程是单线程的,但是,整个 Redis 来说,是多线程的

Redis 单线程的演进变化

  • 在 Redis3.x 单线程时代但性能依旧很快的主要原因

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

    https://redis.io/docs/getting-started/faq/ (opens in a new tab)

    • Redis 4.0 之前使用单线程的原因

      Redis is single threaded. How can I exploit multiple CPU / cores ?

      It's not very frequent that CPU becomes your bottleneck with Redis, as usually Redis is either memory or network bound. For instance, using pipelining Redis running on an average Linux system can deliver even 1 million requests per second, so if your application mainly uses O(N) or O(log(N)) commands, it is hardly going to use too much CPU.

      However, to maximize CPU usage you can start multiple instances of Redis in the same box and treat them as different servers. At some point a single box may not be enough anyway, so if you want to use multiple CPUs you can start thinking of some way to shard earlier.

      You can find more information ablout using multiple Redis instances in the Partitioning page.

      However with Redis 4.0 we started to make Redis more threaded. For now this is limited to deleting objects in the background, and to blocking commands implemented via Redis modules. For future releases, the plan is to make Redis more and more threaded.

    • Redis 4.0 以后

      https://redis.io/docs/getting-started/faq/#how-can-redis-use-multiple-cpus-or-cores (opens in a new tab)

      How can Redis use multiple CPUs or cores?

      It's not very frequent that CPU becomes your bottleneck with Redis, as usually Redis is either memory or network bound. For instance, when using pipelining a Redis instance running on an average Linux system can deliver 1 million requests per second, so if your application mainly uses O(N) or O(log(N)) commands, it is hardly going to use too much CPU.

      However, to maximize CPU usage you can start multiple instances of Redis in the same box and treat them as different servers. At some point a single box may not be enough anyway, so if you want to use multiple CPUs you can start thinking of some way to shard earlier.

      You can find more information about using multiple Redis instances in the Partitioning page (opens in a new tab).

      As of version 4.0, Redis has started implementing threaded actions. For now this is limited to deleting objects in the background and blocking commands implemented via Redis modules. For subsequent releases, the plan is to make Redis more and more threaded.

  • 总结

    • 使用单线程模型是 Redis 的开发和维护更简单,因为单线程模型方便开发和调试;

    • 即使使用单线程模型也并发的处理多客户端的请求,主要使用的是 IO 多路复用和非阻塞 IO;

    • 对于 Redis 系统来说,主要的性能瓶颈是内存或者网络带宽而并非 CPU。

为什么逐渐加入多线程特性

单线程的缺陷

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

这就是 redis3.x 单线程时代最经典的故障,大 key 删除的头疼问题,由于 redis 是单线程的,del bigKey ..... 等待很久这个线程才会释放,类似加了一个 synchronized 锁,高并发下程序堵塞。

解决方案

  • 使用惰性删除避免 Redis 卡顿

  • 案例

    当 Redis 需要删除一个很大的数据时,因为是单线程原子命令操作,这就会导致 Redis 服务卡顿,于是在 Redis 4.0 中新增了多线程模块,主要为了解决删除数据效率比较低的问题。

    unlink key
    flushdb async
    flushall async
    把删除工作交给后台子线程异步来删除数据

    因为 Redis 是单个主线程处理,redis 之父 antirez 一直强调 "Lazy Redis is better Redis"。

    而 lazy free 的本质就是把某些 cost(主要时间复杂度,占用主线程 cpu 时间片)较高删除操作,从 redis 主线程剥离让 bio 子线程来处理,极大地减少主线程阻塞时间。从而减少删除导致性能和稳定性问题。

  • 在 Redis 4.0 就引入了多个线程来实现数据的异步惰性删除等功能,但是其处理读写请求的仍然只有一个线程,所以仍然算是狭义上的单线程。

Redis6/7 的多线程特性和 IO 多路复用

Redis 瓶颈初步定为:网络 IO

  • redis6/7 的时候,真正多线程登场

    在 Redis6/7 中,非常受关注的第一个新特性就是多线程。

    这是因为,Redis 一直被大家熟知的就是它的单线程架构,虽然有些命令操作可以用后台线程或子进程执行(比如数据删除、快照生成、AOF 重写)。但是,从网络 IO 处理到实际的读写命令处理,都是由单个线程完成的。随着网络硬件的性能提升,Redis 的性能瓶颈有时会出现在网络IO的处理上,也就是说,单个主线程处理网络请求的速度跟不上底层网络硬件的速度。

    为了应对这个问题:采用多个 IO 线程来处理网络请求,提高网络请求处理的并行度,Redis6/7 就是采用的这种方法。但是,Redis 的多 IO 线程只是用来处理网络请求的,对于读写操作命令 Redis 仍然使用单线程来处理。这是因为,Redis 处理请求时,网络处理经常是瓶颈,通过多个 IO 线程并行处理网络操作,可以提升实例的整体处理性能。而继续使用单线程执行命令操作,就不用为了保证 Lua 脚本、事务的原子性,额外开发多线程互斥加锁机制了(不管加锁操作处理),这样一来,Redis 线程模型实现就简单了。

  • 主线程和 IO 线程协作完成请求处理过程

    Redis-主线程和IO线程协作完成请求处理

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

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

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

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

    • 阶段三:主线程执行请求操作

      等到 IO 线程解析完成请求,主线程还是会以单线程的方式执行这些命令操作。

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

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

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

  • Blocking IO - 阻塞 IO

  • NoneBlocking IO - 非阻塞 IO

  • IO multiplexing - IO 多路复用

  • signal driven IO - 信号驱动 IO

  • asynchronous IO - 异步 IO

IO 多路复用

Linux 世界一切皆文件

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

public final class FileDescriptor {
    private int fd;
    private long handle;
    
    private Closeable parent;
    private List<Closeable> OtherParents;
    private boolean closed;
}

IO 多路复用是什么

  • 一种同步的 IO 模型,实现一个线程监听多个文件句柄一旦某个文件句柄就绪就能够通知到对应应用程序进行相应的读写操作,没有文件句柄就绪时就会阻塞应用程序,从而释放 CPU 资源。

  • 概念

    • I/O:网络 I/O,尤其在操作系统层面指数据在内核态和用户态之间的读写操作
    • 多路:多个客户端连接(连接就是套接字描述符,即 socket 或者 channel)
    • 复用:复用一个或多个线程
    • IO 多路复用:也就是说一个或一组线程处理多个 TCP 连接,使用单进程就能实现同时处理多个客户端的连接,无需创建或者维护过多的进程/线程
    • 简述
      • 一个服务端进程可以同时处理多个套接字描述符。
      • 实现 IO 多路复用的模型有 3 种,可以分 select -> poll -> epoll 三个阶段来描述

⭐️ Epoll

场景解析

模拟一个 tcp 服务器处理 30 个客户 socket。

假设你是一个监考老师,让 30 个学生解答一道竞赛考题,然后负责验收学生答卷,你有下面几个选择:

第一种选择(轮询):按顺序逐个验收,先验收 A,然后是 B,之后是 C、D……这中间如果有一个学生卡住,全班都会被耽误,你用循环挨个处理 socket,根本不具有并发能力。

第二种选择(来一个 new 一个,一对一服务):你创建 30 个分身线程,每个分身线程检查一个学生的答案是否正确。 这种类似于为每一个用户创建一个进程或者线程处理连接。

第三种选择(响应式处理,一对多服务),你站在讲台上等,谁解答完谁举手。这时 C、D 举手,表示他们解答问题完毕,你下去依次检查 C、D 的答案,然后继续回到讲台上等。此时 E、A 又举手,然后去处理 E 和 A……这种就是 IO 复用模型。Linux 下的 select、poll 和 epoll 就是干这个的。

IO 多路复用模型

简明理解

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

Redis-时分复用

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

nginx 使用 epoll 接收请求,nginx 会有很多链接进来, epoll 会把他们都监视起来,然后像拨开关一样,谁有数据就拨向谁,然后调用相应的代码处理。redis 类似同理,这就是 IO 多路复用原理,有请求就响应,没请求不打扰。

总结

只使用一个服务端进程可以同时处理多个套接字描述符连接。

客户端请求服务端时,实际就是在服务端的 Socket 文件种写入客户端对应的文件描述符(FileDescriptor),如果有多个客户端同时请求服务端,为每次请求分配一个线程类似每次来都 new 一个,如此就会比较耗费服务端资源,因此,我们只是用一个线程来监听多个文件描述符,这就是 IO 多路复用。采用多路 I/O 复用技术可以让单个线程高效的处理多个连接请求,一个服务端进程可以同时处理多个套接字描述符

面试题:redis 为什么这么快

IO 多路复用 + epoll 函数使用,才是 redis 为什么这么快的直接原因,而不是仅仅单线程命令 + redis 安装在内存中。

总结

Redis 工作线程是单线程的。但对整个 Redis 来说,是多线程的。

I/O 的读和写本身是堵塞的,比如当 socket 中有数据时,Redis 会通过调用先将数据从内核态空间拷贝到用户态空间,再交给 Redis 调用,而这个拷贝的过程就是阻塞的,当数据量越大时拷贝所需要的时间就越多,而这些操作都是基于单线程完成的。

Redis-网络模型

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

Redis-网络模型2

网络IO操作就变成多线程化了,其他核心部分仍然是线程安全的。

结论:Redis6→7 将网络数据读写、请求协议解析通过多个 IO 线程的来处理 ,对于真正的命令执行来说,仍然使用主线程操作。

Redis7 默认是否开启了多线程

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

Redis7 将所有数据放在内存中,内存的响应时长大约为 100 纳秒,对于小数据包,Redis服务器可以处理 8W 到 10W 的 QPS,这也是 Redis 处理的极限了,对于 80% 的情况下来说,单线程的 Redis 已经足够使用了

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

################################ THREADED I/O #################################

# Redis is mostly single threaded, however there are certain threaded
# operations such as UNLINK, slow I/O accesses and other things that are
# performed on side threads.
#
# Now it is also possible to handle Redis clients socket reads and writes
# in different I/O threads. Since especially writing is so slow, normally
# Redis users use pipelining in order to speed up the Redis performances per
# core, and spawn multiple instances in order to scale more. Using I/O
# threads it is possible to easily speedup two times Redis without resorting
# to pipelining nor sharding of the instance.
#
# By default threading is disabled, we suggest enabling it only in machines
# that have at least 4 or more cores, leaving at least one spare core.
# Using more than 8 threads is unlikely to help much. We also recommend using
# threaded I/O only if you actually have performance problems, with Redis
# instances being able to use a quite big percentage of CPU time, otherwise
# there is no point in using this feature.
#
# So for instance if you have a four cores boxes, try to use 2 or 3 I/O
# threads, if you have a 8 cores, try to use 6 threads. In order to
# enable I/O threads use the following configuration directive:
#
# io-threads 4
#
# Setting io-threads to 1 will just use the main thread as usual.
# When I/O threads are enabled, we only use threads for writes, that is
# to thread the write(2) syscall and transfer the client buffers to the
# socket. However it is also possible to enable threading of reads and
# protocol parsing using the following configuration directive, by setting
# it to yes:
#
# io-threads-do-reads no
#
# Usually threading reads doesn't help much.
#
# NOTE 1: This configuration directive cannot be changed at runtime via
# CONFIG SET. Also, this feature currently does not work when SSL is
# enabled.
#
# NOTE 2: If you want to test the Redis speedup using redis-benchmark, make
# sure you also run the benchmark itself in threaded mode, using the
# --threads option to match the number of Redis threads, otherwise you'll not
# be able to notice the improvements.
  • 设置 io-thread-do-reads 配置项为 yes,表示启动多线程。

  • 设置线程个数。关于线程数的设置,官方的建议是如果为 4 核的 CPU,建议线程数设置为 2 或 3,如果为 8 核 CPU 建议线程数设置为 6,线程数一定要小于机器核数,线程数并不是越大越好。

总结

Redis 自身出道就是优秀,基于内存操作、数据结构简单、多路复用和非阻塞 I/O、避免了不必要的线程上下文切换等特性,在单线程的环境下依然很快;

但对于大数据的 key 删除还是卡顿厉害,因此在 Redis 4.0 引入了多线程 unlink key/flushall async 等命令,主要用于 Redis 数据的异步删除;

而在 Redis6/7 中引入了 I/O 多线程的读写,这样就可以更加高效的处理更多的任务了,Redis 只是将 I/O 读写变成了多线程,而命令的执行依旧是由主线程串行执行的,因此在多线程下操作 Redis 不会出现线程安全的问题。

Redis 无论是当初的单线程设计,还是如今与当初设计相背的多线程,目的只有一个:让 Redis 变得越来越快。