一百天前 Redis 作者 antirez 在博客上(antirez.com)发布了一条重磅消息,Redis6.0 正式发布了。其中最引人注目的改动就是,​Redis6.0 引入了多线程​。

本文主要分两部分。首先我们先聊一下 ​Redis6.0 之前为什么采用单线程模型​,然后再​详细解释 Redis6.0 的多线程​。

Redis6.0 之前为何采用单线程模型

严格地说,​从 Redis 4.0 之后并不是单线程。除了主线程外,还有一些后台线程处理一些较为缓慢的操作​,例如无用连接的释放、大 key 的删除等等。

单线程模型,为何性能那么高?

Redis 作者从设计之初,进行了多方面的考虑。最终选择使用单线程模型来处理命令。之所以选择单线程模型,主要有如下几个重要原因:

•Redis 操作基于内存,绝大多数操作的性能瓶颈不在 CPU•单线程模型,避免了线程间切换带来的性能开销•使用单线程模型也能并发的处理客户端的请求(多路复用 I/O)•使用单线程模型,可维护性更高,开发,调试和维护的成本更低

上述​第三个原因是 Redis 最终采用单线程模型的决定性因素​,其他的两个原因都是使用单线程模型额外带来的好处,在这里我们会按顺序介绍上述的几个原因。

性能瓶颈不在 CPU

下图是 Redis 官网对单线程模型的说明。大概意思是:Redis 的瓶颈并不在 CPU,它的主要瓶颈在于内存和网络。在 Linux 环境中,Redis 每秒甚至可以提交 100 万次请求。

Redis6.0 为何引入多线程?单线程它不香吗?_多线程

为什么说 Redis 的瓶颈不在 CPU?

首先,Redis 绝大部分操作是基于内存的,而且是纯 kv(key-value)操作,所以命令执行速度非常快。我们可以大概理解成,Redis 中的数据存储在一张大 HashMap 中,HashMap 的优势就是查找和写入的时间复杂度都是 O(1)。Redis 内部采用这种结构存储数据,就奠定了 Redis 高性能的基础。根据 Redis 官网描述,​在理想情况下 Redis 每秒可以提交一百万次请求,每次请求提交所需的时间在纳秒的时间量级​。既然每次的 Redis 操作都这么快,单线程就可以完全搞定了,那还何必要用多线程呢!

线程上下文切换问题

另外,​多线程场景下会发生线程上下文切换​。线程是由 CPU 调度的,CPU 的一个核在一个时间片内只能同时执行一个线程,在 CPU 由线程 A 切换到线程 B 的过程中会发生一系列的操作,主要过程包括保存线程 A 的执行现场,然后载入线程 B 的执行现场,这个过程就是“线程上下文切换”。其中涉及线程相关指令的保存和恢复。

频繁的线程上下文切换可能会导致性能急剧下降,这会导致我们不仅没有提升处理请求的速度,反而降低了性能,这也是 Redis 对于多线程技术持谨慎态度的原因之一。

在 Linux 系统中可以使用 vmstat 命令来查看上下文切换的次数,下面是 vmstat 查看上下文切换次数的示例:

Redis6.0 为何引入多线程?单线程它不香吗?_单线程_02

vmstat 1 表示每秒统计一次, 其中 cs 列就是指上下文切换的数目. 一般情况下, 空闲系统的上下文切换每秒在 1500 以下。

并行处理客户端的请求(I/O 多路复用)

如上所述:Redis 的瓶颈并不在 CPU,它的主要瓶颈在于内存和网络。所谓内存瓶颈很好理解,Redis 做为缓存使用时很多场景需要缓存大量数据,所以需要大量内存空间,这可以通过集群分片去解决,例如 Redis 自身的无中心集群分片方案以及 Codis 这种基于代理的集群分片方案。

对于网络瓶颈,Redis 在网络 I/O 模型上采用了多路复用技术,来减少网络瓶颈带来的影响​。很多场景中使用单线程模型并不意味着程序不能并发的处理任务。Redis 虽然使用单线程模型处理用户的请求,但是它却使用 I/O 多路复用技术“并行”处理来自客户端的多个连接,同时等待多个连接发送的请求。使用 I/O 多路复用技术能极大地减少系统的开销,系统不再需要为每个连接创建专门的监听线程,避免了由于大量的线程创建带来的巨大性能开销。

Redis6.0 为何引入多线程?单线程它不香吗?_redis_03

下面我们详细解释一下多路复用 I/O 模型。为了能更充分理解,我们先了解几个基本概念。

Socket(套接字)​:Socket 可以理解成,在两个应用程序进行网络通信时,分别在两个应用程序中的通信端点。通信时,一个应用程序将数据写入 Socket,然后通过网卡把数据发送到另外一个应用程序的 Socket 中。我们平常所说的 HTTP 和 TCP 协议的远程通信,底层都是基于 Socket 实现的。5 种网络 IO 模型也都要基于 Socket 实现网络通信。

阻塞与非阻塞​:所谓阻塞,就是发出一个请求不能立刻返回响应,要等所有的逻辑全处理完才能返回响应。非阻塞反之,发出一个请求立刻返回应答,不用等处理完所有逻辑。

内核空间与用户空间​:在 Linux 中,应用程序稳定性远远比不上操作系统程序,为了保证操作系统的稳定性,Linux 区分了内核空间和用户空间。可以这样理解,内核空间运行操作系统程序和驱动程序,用户空间运行应用程序。Linux 以这种方式隔离了操作系统程序和应用程序,避免了应用程序影响到操作系统自身的稳定性。这也是 Linux 系统超级稳定的主要原因。所有的系统资源操作都在内核空间进行,比如读写磁盘文件,内存分配和回收,网络接口调用等。所以在一次网络 IO 读取过程中,数据并不是直接从网卡读取到用户空间中的应用程序缓冲区,而是先从网卡拷贝到内核空间缓冲区,然后再从内核拷贝到用户空间中的应用程序缓冲区。对于网络 IO 写入过程,过程则相反,先将数据从用户空间中的应用程序缓冲区拷贝到内核缓冲区,再从内核缓冲区把数据通过网卡发送出去。

多路复用 I/O 模型​,建立在多路事件分离函数 select,poll,epoll 之上。以 Redis 采用的 epoll 为例,在发起 read 请求前,先更新 epoll 的 socket 监控列表,然后等待 epoll 函数返回(此过程是阻塞的,所以说多路复用 IO 本质上也是阻塞 IO 模型)。当某个 socket 有数据到达时,epoll 函数返回。此时用户线程才正式发起 read 请求,读取并处理数据。这种模式用一个专门的监视线程去检查多个 socket,如果某个 socket 有数据到达就交给工作线程处理。由于等待 Socket 数据到达过程非常耗时,所以这种方式解决了阻塞 IO 模型一个 Socket 连接就需要一个线程的问题,也不存在非阻塞 IO 模型忙轮询带来的 CPU 性能损耗的问题。​多路复用 IO 模型的实际应用场景很多,大家耳熟能详的 Redis,Java NIO,以及 Dubbo 采用的通信框架 Netty 都采用了这种模型。

Redis6.0 为何引入多线程?单线程它不香吗?_单线程_04

下图是基于 epoll 函数 Socket 编程的详细流程。

Redis6.0 为何引入多线程?单线程它不香吗?_单线程_05

可维护性

我们知道,多线程可以充分利用多核 CPU,在高并发场景下,能够减少因 I/O 等待带来的 CPU 损耗,带来很好的性能表现。不过多线程却是一把双刃剑,带来好处的同时,还会带来代码维护困难,线上问题难于定位和调试,死锁等问题。多线程模型中代码的执行过程不再是串行的,多个线程同时访问的共享变量如果处理不当也会带来诡异的问题。

Redis6.0 为何引入多线程?单线程它不香吗?_redis_06

我们通过一个例子,看一下多线程场景下发生的诡异现象。看下面的代码:

class MemoryReordering {
int num = 0;
boolean flag = false;
public void set() {
num = 1; //语句1
flag = true; //语句2
}
public int cal() {
if( flag == true) { //语句3
return num + num; //语句4
}
return -1;
}
}

flag 为 true 时,cal() 方法返回值是多少?很多人会说:这还用问吗!肯定返回 2

结果可能会让你大吃一惊!上面的这段代码,由于语句 1 和语句 2 没有数据依赖性,可能会发生指令重排序,有可能编译器会把 flag=true 放到 num=1 的前面。此时 set 和 cal 方法分别在不同线程中执行,没有先后关系。cal 方法,只要 flag 为 true,就会进入 if 的代码块执行相加的操作。可能的顺序是:

•语句 1 先于语句 2 执行,这时的执行顺序可能是:语句 1->语句 2->语句 3->语句 4。执行语句 4 前,num = 1,所以 cal 的返回值是 2•语句 2 先于语句 1 执行,这时的执行顺序可能是:语句 2->语句 3->语句 4->语句 1。执行语句 4 前,num = 0,所以 cal 的返回值是 0

我们可以看到,在多线程环境下如果发生了指令重排序,会对结果造成严重影响。

当然可以在第三行处,给 flag 加上关键字 volatile 来避免指令重排。即在 flag 处加上了内存栅栏,来阻隔 flag(栅栏)前后的代码的重排序。当然多线程还会带来可见性问题,死锁问题以及共享资源安全等问题。

boolean volatile flag = false;

Redis6.0 为何引入多线程?

Redis6.0 引入的多线程部分,实际上只是用来处理网络数据的读写和协议解析,执行命令仍然是单一工作线程。

Redis6.0 为何引入多线程?单线程它不香吗?_单线程_04

从上图我们可以看到 Redis 在处理网络数据时,调用 epoll 的过程是阻塞的,也就是说这个过程会阻塞线程,如果并发量很高,达到几万的 QPS,此处可能会成为瓶颈。一般我们遇到此类网络 IO 瓶颈的问题,可以增加线程数来解决。开启多线程除了可以减少由于网络 I/O 等待造成的影响,还可以充分利用 CPU 的多核优势。Redis6.0 也不例外,在此处增加了多线程来处理网络数据,以此来提高 Redis 的吞吐量。当然相关的命令处理还是单线程运行,不存在多线程下并发访问带来的种种问题。

性能对比

压测配置:

Redis Server: 阿里云 Ubuntu 18.04,8 CPU 2.5 GHZ, 8G 内存,主机型号 ecs.ic5.2xlarge
Redis Benchmark Client: 阿里云 Ubuntu 18.04,8 2.5 GHZ CPU, 8G 内存,主机型号 ecs.ic5.2xlarge

多线程版本 Redis 6.0,单线程版本是 Redis 5.0.5。多线程版本需要新增以下配置:

io-threads 4 # 开启 4 个 IO 线程
io-threads-do-reads yes # 请求解析也是用 IO 线程

压测命令:

redis-benchmark -h 192.168.0.49 -a foobared -t set,get -n 1000000 -r 100000000 --threads 4 -d \${datasize} -c 256

Redis6.0 为何引入多线程?单线程它不香吗?_单线程_08

Redis6.0 为何引入多线程?单线程它不香吗?_单线程_08图片来源于网络Redis6.0 为何引入多线程?单线程它不香吗?_多线程_10图片来源于网络

从上面可以看到 GET/SET 命令在多线程版本中性能相比单线程几乎翻了一倍。另外,这些数据只是为了简单验证多线程 I/O 是否真正带来性能优化,并没有针对具体的场景进行压测,数据仅供参考。本次性能测试基于 unstble 分支,不排除后续发布的正式版本的性能会更好。

最后

可见单线程有单线程的好处,多线程有多线程的优势,只有充分理解其中的本质原理,才能灵活运用于生产实践当中。

希望本文对大家有所帮助。​如果感觉本文有帮助,有劳发!让更多人收获知识!


长按识别下图二维码,关注公众号「​Doocs 开源社区​」,第一时间跟你们分享好玩、实用的技术文章与业内最新资讯。