Redis——Redis6.0多线程问题

论坛 期权论坛 脚本     
已经匿名di用户   2022-2-7 16:32   2853   0

摘要

Redis是目前广为人知的一个内存数据库,在各个场景中都有着非常丰富的应用,前段时间Redis推出了6.0的版本,在新版本中采用了多线程模型。Redis 6.0 只有在网络请求的接收和解析,以及请求后的数据通过网络返回给时,使用了多线程。而数据读写操作还是由单线程来完成的(所以不存在数据安全问题)

Redis为什么最开始被设计成单线程的?

Redis作为一个成熟的分布式缓存框架,它由很多个模块组成,如网络请求模块、索引模块、存储模块、高可用集群支撑模块、数据操作模块等。很多人说Redis是单线程的,就认为Redis中所有模块的操作都是单线程的,其实这是不对的。我们所说的Redis单线程,指的是"其网络IO和键值对读写是由一个线程完成的",也就是说,Redis中只有网络请求模块和数据操作模块是单线程的。而其他的如持久化存储模块、集群支撑模块等是多线程的。所以说,Redis中并不是没有多线程模型的,早在Redis 4.0的时候就已经针对部分命令做了多线程化。

多线程适用场景

一个计算机程序在执行的过程中,主要需要进行两种操作分别是读写操作和计算操作。其中读写操作主要是涉及到的就是I/O操作,其中包括网络I/O和磁盘I/O。计算操作主要涉及到CPU。而多线程的目的,就是通过并发的方式来提升I/O的利用率和CPU的利用率。

那么,Redis需不需要通过多线程的方式来提升提升I/O的利用率和CPU的利用率呢?首先,我们可以肯定的说,Redis不需要提升CPU利用率,因为Redis的操作基本都是基于内存的,CPU资源根本就不是Redis的性能瓶颈。所以,通过多线程技术来提升Redis的CPU利用率这一点是完全没必要的。Redis确实是一个I/O操作密集的框架,他的数据操作过程中,会有大量的网络I/O和磁盘I/O的发生。要想提升Redis的性能,是一定要提升Redis的I/O利用率的,这一点毋庸置疑。但是,提升I/O利用率,并不是只有采用多线程技术这一条路可以走!

多线程的有点:

而多线程的目的,就是通过并发的方式来提升I/O的利用率和CPU的利用率

多线程的弊端

Java中的多线程技术,如内存模型、锁、CAS等,这些都是Java中提供的一些在多线程情况下保证线程安全的技术。采用多线程可以帮助我们提升CPU和I/O的利用率,但是多线程带来的并发问题也给这些语言和框架带来了更多的复杂性。而且,多线程模型中,多个线程的互相切换也会带来一定的性能开销。所以,在提升I/O利用率这个方面上,Redis并没有采用多线程技术,而是选择了多路复用 I/O技术。

Redis的单线程驱动的IO处理过程

redis启动后会进入一个死循环aeMain,在这个循环里一直等待事件发生,事件分为IO事件和timer事件,timer事件是一些定时执行的任务,如expire key等,本文只聊IO事件。epoll处理的是socket的可读、可写事件,当事件发生后提供一种高效的通知方式, 当想要异步监听某个socket的读写事件时,需要去事件驱动框架中注册要监听事件的socket,以及对应事件的回调function。然后死循环中可以通过epoll_wait不断地去拿发生了可读写事件的socket,依次处理即可。

可读可以简单理解为,对应的socket中有新的tcp数据包到来。
可写可以简单理解为,对应的socket写缓冲区已经空了(数据通过网络已经发给了客户端)

  • aeMain() 内部是一个死循环,会在epoll_wait处短暂休眠
  • epoll_wait返回的是当前可读、可写的socket列表
  • beforeSleep是进入休眠前执行的逻辑,核心是回写数据到socket
  • 核心逻辑都是由IO事件触发,要么可读,要么可写,否则执行timer定时任务
  • 第一次的IO可读事件,是监听socket(如监听6379的socket),当有握手请求时,会执行accept调用,得到一个连接socket,注册可读回调createClient,往后客户端和redis的数据都通过这个socket进行
  • 一个完整的命令,可能会通过多次readQueryFromClient才能从socket读完,这意味这多次可读IO事件
  • 命令执行的结果会写,也是这样,大概率会通过多次可写回调才能写完
  • 当命令被执行完后,对应的连接会被追加到 clients_pending_write,beforeSleep会尝试回写到socket,写不完会注册可写事件,下次继续写
  • 整个过程IO全部都是同步非阻塞,没有浪费等待时间
  • 注册事件的函数叫aeCreateFileEven

单线程IO的瓶颈

上面详细梳理了单线程IO的处理过程,IO都是非阻塞,没有浪费一丁点时间,虽然是单线程,但动辄能上10W QPS。不过也就这水平了,难以提供更多的自行车。

同时这个模型有几个缺陷:

  • 只能用一个cpu核(忽略后台线程)
  • 如果value比较大,redis的QPS会下降得很厉害,有时一个大key就可以拖垮
  • QPS难以更上一层楼

redis主线程的时间消耗主要在两个方面:

  • 逻辑计算的消耗
  • 同步IO读写,拷贝数据导致的消耗

当value比较大时,瓶颈会先出现在同步IO上(假设带宽和内存足够),这部分消耗在于两部分:

  • 从socket中读取请求数据,会从内核态将数据拷贝到用户态 (read调用)
  • 将数据回写到socket,会将数据从用户态拷贝到内核态 (write调用)

这部分数据读写会占用大量的cpu时间,也直接导致了瓶颈。 如果能有多个线程来分担这部分消耗,那redis的吞吐量还能更上一层楼,这也是redis引入多线程IO的目的

Redis的多线程驱动的IO处理过程

上面已经梳理了单线程IO的处理流程,以及多线程IO要解决的问题,接下来将目光放到: 如何用多线程分担IO的负荷。其做法用简单的话来说就是:

  • 用一组单独的线程专门进行 read/write socket读写调用 (同步IO)
  • 读回调函数中不再读数据,而是将对应的连接追加到可读clients_pending_read的链表
  • 主线程在beforeSleep中将IO读任务分给IO线程组
  • 主线程自己也处理一个IO读任务,并自旋式等IO线程组处理完,再继续往下
  • 主线程在beforeSleep中将IO写任务分给IO线程组
  • 主线程自己也处理一个IO写任务,并自旋式等IO线程组处理完,再继续往下
  • IO线程组要么同时在读,要么同时在写
  • 命令的执行由主线程串行执行(保持单线程)
  • IO线程数量可配置

beforesleep中,先让IO线程读数据,然后再让IO线程写数据。 读写时,多线程能并发执行,利用多核。

  1. 将读任务均匀分发到各个IO线程的任务链表io_threads_list[i],将io_threads_pending[i] 设置为对应的任务数,此时IO线程将从死循环中被激活,开始执行任务,执行完毕后,会将 io_threads_pending[i]清零。 函数名为: handleClientsWithPendingReadsUsingThreads
  2. 将写任务均匀分发到各个IO线程的任务链表io_threads_list[i],将io_threads_pending[i] 设置为对应的任务数,此时IO线程将从死循环中被激活,开始执行任务,执行完毕后,会将 io_threads_pending[i]清零。 函数名为: handleClientsWithPendingWritesUsingThreads
  3. beforeSleep中主线程也会执行其中一个任务(图中忽略了),执行完后自旋等待IO线程处理完。
  4. 读任务要么在beforeSleep中被执行,要么在IO线程被执行,不会再在读回调中执行
  5. 写任务会分散到 beforeSleep、IO线程、写回调中执行
  6. 主线程和IO线程交互是无锁的,通过标志位设置进行,不会同时写任务链表

性能据测试提升了一倍以上(4个IO线程)。

Redis6.0的多路复用

这里先讲讲Linux多路复用技术,就是多个进程的IO可以注册到同一个管道上,这个管道会统一和内核进行交互。当管道中的某一个请求需要的数据准备好之后,进程再把对应的数据拷贝到用户空间中。

多看一遍上面这张图和上面那句话,后面可能还会用得到。也就是说,通过一个线程来处理多个IO流。IO多路复用在Linux下包括了三种,select、poll、epoll,抽象来看,他们功能是类似的,但具体细节各有不同。其实,Redis的IO多路复用程序的所有功能都是通过包装操作系统的IO多路复用函数库来实现的。每个IO多路复用函数库在Redis源码中都有对应的一个单独的文件。

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

一旦有请求到达,就会交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个 IO 流的效果。所以,Redis选择使用多路复用IO技术来提升I/O利用率。而之所以Redis能够有这么高的性能,不仅仅和采用多路复用技术和单线程有关,此外还有以下几个原因:

  • 1、完全基于内存,绝大部分请求是纯粹的内存操作,非常快速。
  • 2、数据结构简单,对数据操作也简单,如哈希表、跳表都有很高的性能。
  • 3、采用单线程,避免了不必要的上下文切换和竞争条件,也不存在多进程或者多线程导致的切换而消耗 CPU
  • 4、使用多路I/O复用模型

为什么Redis 6.0 引入多线程

但是,需要提醒大家的是,Redis 6.0中的多线程,也只是针对处理网络请求过程采用了多线程,而数据的读写命令,仍然是单线程处理的。主要是因为我们对Redis有着更高的要求。但随着越来越复杂的业务场景,有些公司动不动就上亿的交易量,因此需要更大的 QPS。为了提升QPS,很多公司的做法是部署Redis集群,并且尽可能提升Redis机器数。但是这种做法的资源消耗是巨大的。而经过分析,限制Redis的性能的主要瓶颈出现在网络IO的处理上,虽然之前采用了多路复用技术。但是我们前面也提到过,多路复用的IO模型本质上仍然是同步阻塞型IO模型

下面是多路复用IO中select函数的处理过程:

从上图我们可以看到,在多路复用的IO模型中,在处理网络请求时,调用 select (其他函数同理)的过程是阻塞的,也就是说这个过程会阻塞线程,如果并发量很高,此处可能会成为瓶颈。虽然现在很多服务器都是多个CPU核的,但是对于Redis来说,因为使用了单线程,在一次数据操作的过程中,有大量的CPU时间片是耗费在了网络IO的同步处理上的,并没有充分的发挥出多核的优势。如果能采用多线程,使得网络处理的请求并发进行,就可以大大的提升性能。多线程除了可以减少由于网络 I/O 等待造成的影响,还可以充分利用 CPU 的多核优势。所以,Redis 6.0采用多个IO线程来处理网络请求,网络请求的解析可以由其他线程完成,然后把解析后的请求交由主线程进行实际的内存读写。提升网络请求处理的并行度,进而提升整体性能。但是,Redis 的多 IO 线程只是用来处理网络请求的,对于读写命令,Redis 仍然使用单线程来处理。

那么,在引入多线程之后,如何解决并发带来的线程安全问题呢?

这就是为什么我们前面多次提到的"Redis 6.0的多线程只用来处理网络请求,而数据的读写还是单线程"的原因。Redis 6.0 只有在网络请求的接收和解析,以及请求后的数据通过网络返回给时,使用了多线程。而数据读写操作还是由单线程来完成的,所以,这样就不会出现并发问题了。

分享到 :
0 人收藏
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

积分:81
帖子:4969
精华:0
期权论坛 期权论坛
发布
内容

下载期权论坛手机APP