Redis6.0 为何引入多线程?单线程它不香吗?
一百天前 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 万次请求。
为什么说 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 查看上下文切换次数的示例:
vmstat 1 表示每秒统计一次, 其中 cs 列就是指上下文切换的数目. 一般情况下, 空闲系统的上下文切换每秒在 1500 以下。
并行处理客户端的请求(I/O 多路复用)
如上所述:Redis 的瓶颈并不在 CPU,它的主要瓶颈在于内存和网络。所谓内存瓶颈很好理解,Redis 做为缓存使用时很多场景需要缓存大量数据,所以需要大量内存空间,这可以通过集群分片去解决,例如 Redis 自身的无中心集群分片方案以及 Codis 这种基于代理的集群分片方案。
对于网络瓶颈,Redis 在网络 I/O 模型上采用了多路复用技术,来减少网络瓶颈带来的影响。很多场景中使用单线程模型并不意味着程序不能并发的处理任务。Redis 虽然使用单线程模型处理用户的请求,但是它却使用 I/O 多路复用技术“并行”处理来自客户端的多个连接,同时等待多个连接发送的请求。使用 I/O 多路复用技术能极大地减少系统的开销,系统不再需要为每个连接创建专门的监听线程,避免了由于大量的线程创建带来的巨大性能开销。
下面我们详细解释一下多路复用 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 都采用了这种模型。
下图是基于 epoll 函数 Socket 编程的详细流程。
可维护性
我们知道,多线程可以充分利用多核 CPU,在高并发场景下,能够减少因 I/O 等待带来的 CPU 损耗,带来很好的性能表现。不过多线程却是一把双刃剑,带来好处的同时,还会带来代码维护困难,线上问题难于定位和调试,死锁等问题。多线程模型中代码的执行过程不再是串行的,多个线程同时访问的共享变量如果处理不当也会带来诡异的问题。
我们通过一个例子,看一下多线程场景下发生的诡异现象。看下面的代码:
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 引入的多线程部分,实际上只是用来处理网络数据的读写和协议解析,执行命令仍然是单一工作线程。
从上图我们可以看到 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
从上面可以看到 GET/SET 命令在多线程版本中性能相比单线程几乎翻了一倍。另外,这些数据只是为了简单验证多线程 I/O 是否真正带来性能优化,并没有针对具体的场景进行压测,数据仅供参考。本次性能测试基于 unstble 分支,不排除后续发布的正式版本的性能会更好。
最后
可见单线程有单线程的好处,多线程有多线程的优势,只有充分理解其中的本质原理,才能灵活运用于生产实践当中。
希望本文对大家有所帮助。如果感觉本文有帮助,有劳转发或点一下“在看”!让更多人收获知识!
长按识别下图二维码,关注公众号「Doocs 开源社区」,第一时间跟你们分享好玩、实用的技术文章与业内最新资讯。