Redis:6.0多线程无锁(lock-free)设计和多线程Reactor模式助力Redis QPS更上一层楼

本文涉及的产品
Redis 开源版,标准版 2GB
推荐场景:
搭建游戏排行榜
云数据库 Tair(兼容Redis),内存型 2GB
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
简介: Redis:6.0多线程无锁(lock-free)设计和多线程Reactor模式助力Redis QPS更上一层楼

640.png

Redis6.0 多线程无锁I/O设计精髓



干货:

  1. 单线程模式-----并非CPU瓶颈
  2. 多线程网络模型-----多线程Reactor模式
  3. 多线程I/O-----lock-free无锁模式


因为我们的主题是多线程,所以不会过多涉及单线程。


1. 单线程模式-并非CPU瓶颈


咱们都知道单线程的程序是没法利用服务器的多核CPU的,那么早期的Redis为何还要使用单线程呢?咱们不妨先看一下Redis官方给出的回答:

640.png

核心意思是:CPU并非制约Redis性能表现的瓶颈所在,更多状况下是受到内存大小和网络I/O的限制,因此Redis核心网络模型使用单线程并无什么问题,若是你想要使用服务的多核CPU,能够在一台服务器上启动多个实例或者采用分片集群的方式。


咱们知道Redis的I/O线程除了在等待事件,其它的事件都是非阻塞的,没有浪费任何的CPU时间,这也是Redis可以提供高性能服务的缘由。


2. 多线程网络模型-多线程Reactor模式


Redis在 6.0 版本以后正式在核心网络模型中引入了多线程,它的工做模式是这样的:

640.png


区别于单 Reactor 模式,这种模式再也不是单线程的事件循环,而是有多个线程(Sub Reactors)各自维护一个独立的事件循环,由 Main Reactor 负责接收新链接并分发给 Sub Reactors 去独立处理,最后 Sub Reactors 回写响应给客户端。


Multiple Reactors 模式一般也能够等同于 Master-Workers 模式,好比Nginx(前期文章有分享哈,可以回头去看)等就是采用这种多线程模型,虽然不一样的项目实现细节略有区别,但整体来讲模式是一致的。


2.1 多线程工作流程

640.png


  1. Redis 服务器启动,开启主线程事件循环(Event Loop),注册 acceptTcpHandler 链接应答处理器到用户配置的监听端口对应的文件描述符,等待新链接到来;
  2. 客户端和服务端创建网络链接;
  3. acceptTcpHandler 被调用,主线程使用 AE 的 API 将 readQueryFromClient 命令读取处理器绑定到新链接对应的文件描述符上,并初始化一个 client 绑定这个客户端链接;
  4. 客户端发送请求命令,触发读就绪事件,服务端主线程不会经过 socket 去读取客户端的请求命令,而是先将 client 放入一个 LIFO 队列 clients_pending_read;
  5. 在事件循环(Event Loop)中,主线程执行 beforeSleep -->handleClientsWithPendingReadsUsingThreads,利用 Round-Robin 轮询负载均衡策略,把 clients_pending_read队列中的链接均匀地分配给 I/O 线程各自的本地 FIFO 任务队列 io_threads_list[id] 和主线程本身,并且用io_threads_pending[id]来记录每个线程的分配任务数量,因为线程需要读取这个io_threads_pending[id]这个数量来消费任务,消费完成会初始化为0。I/O 线程经过 socket 读取客户端的请求命令(是通过io_threads_op这个变量来判断是读(IO_THREADS_OP_READ)还是写(IO_THREADS_OP_WRITE), 这里是io_threads_op == IO_THREADS_OP_READ),存入 client->querybuf 并解析第一个命令,但不执行命令,主线程忙轮询,等待全部 I/O 线程完成读取任务;
  6. 主线程和全部 I/O 线程都完成了读取任务(通过遍历io_threads_pending[id],把每个线程的分配任务数量累加起来如果和等于0代表多线程已经消费完了任务),主线程结束忙轮询,遍历 clients_pending_read 队列,执行全部客户端链接的请求命令,先调用 processCommandAndResetClient 执行第一条已经解析好的命令,而后调用 processInputBuffer 解析并执行客户端链接的全部命令,在其中使用 processInlineBuffer 或者 processMultibulkBuffer 根据 Redis 协议解析命令,最后调用 processCommand 执行命令;
  7. 根据请求命令的类型(SET, GET, DEL, EXEC 等),分配相应的命令执行器去执行,最后调用 addReply 函数族的一系列函数将响应数据写入到对应 client 的写出缓冲区:client->buf 或者 client->reply ,client->buf 是首选的写出缓冲区,固定大小 16KB,通常来讲能够缓冲足够多的响应数据,可是若是客户端在时间窗口内须要响应的数据很是大,那么则会自动切换到 client->reply 链表上去,使用链表理论上可以保存无限大的数据(受限于机器的物理内存),最后把 client 添加进一个 LIFO 队列 clients_pending_write;
  8. 在事件循环(Event Loop)中,主线程执行 beforeSleep --> handleClientsWithPendingWritesUsingThreads,利用 Round-Robin 轮询负载均衡策略,把 clients_pending_write 队列中的链接均匀地分配给 I/O 线程各自的本地 FIFO 任务队列 io_threads_list[id] 和主线程本身,并且用io_threads_pending[id]来记录每个线程的分配任务数量,因为线程需要读取这个io_threads_pending[id]这个数量来消费任务,消费完成会置为0。I/O 线程经过调用 writeToClient(io_threads_op == IO_THREADS_OP_WRITE)把 client 的写出缓冲区里的数据回写到客户端,主线程忙轮询,等待全部 I/O 线程完成写出任务;
  9. 主线程和全部 I/O 线程都完成了写出任务(通过遍历io_threads_pending[id],把每个线程的分配任务数量累加起来如果和等于0代表多线程已经消费完了任务), 主线程结束忙轮询,遍历 clients_pending_write 队列,若是 client 的写出缓冲区还有数据遗留,则注册 sendReplyToClient 到该链接的写就绪事件,等待客户端可写时在事件循环中再继续回写残余的响应数据。

这里大部分逻辑和以前的单线程模型是一致的,变更的地方仅仅是把读取客户端请求命令和回写响应数据的逻辑异步化了,交给 I/O 线程去完成,这里须要特别注意的一点是:I/O 线程仅仅是读取和解析客户端命令而不会真正去执行命令,客户端命令的执行最终仍是要回到主线程上完成。


2.2 图解详细流程

640.png


3. I/O多线程lock-free(无锁设计)


主线程和 I/O 线程之间共享的变量有三个:io_threads_pending 计数器、io_threads_op I/O 标识符和 io_threads_list 线程本地任务队列。


  • io_threads_pending
  • 统计每个线程分配任务的数量,主线程在分配的时候,子线程是被换新的,它一直在执行100w次的CPU空转即自旋操作;当主线程把任务分配好之后,子线程会判断io_threads_pending[id]不为0就去消费任务,消费完成会置为0。
  • io_threads_op
  • IO_THREADS_OP_WRITE socket可写
  • IO_THREADS_OP_READ socket可读
  • io_threads_list
  • 线程本地任务队列(链表)


lock-free无锁设计核心:


1. 原子变量,不需要加锁保护

  • io_threads_pending变量在声明的时候加上了_Atomic限定符:_Atomic unsigned long; _Atomic是C11标准中引入的原子操作:被_Atomic修饰的变量被认为是原子变量,对原子变量的操作是不可分割的(Atomicity),且操作结果对其他线程可见,执行的顺序也不能被重排。所以io_threads_pending是属于线程安全的变量。

2. 交错访问来规避共享数据竞争

  • io_threads_op 和 io_threads_list 这两个变量则是经过控制主线程和 I/O 线程交错访问来规避共享数据竞争问题。
  • I/O 线程启动以后会经过忙轮询和锁休眠等待主线程的信号,在这以前它不会去访问本身的本地任务队列 io_threads_list[id],而主线程会在分配完全部任务到各个 I/O 线程的本地队列以后才去唤醒 I/O 线程开始工做,而且主线程以后在 I/O 线程运行期间只会访问本身的本地任务队列 io_threads_list[0] 而不会再去访问 I/O 线程的本地队列,这也就保证了主线程永远会在 I/O 线程以前访问 io_threads_list 而且以后再也不访问,保证了交错访问。
  • io_threads_op 同理,主线程会在唤醒 I/O 线程以前先设置好 io_threads_op 的值,而且在 I/O 线程运行期间不会再去访问这个变量,这也就变相保证了原子性。在源码src/server.h中 : externintio_threads_op;


4. 源码


源码真的太多了,拷贝到这里实在影响阅读,因此为了大家能迅速定位,我这里贴出地址哈。


  • 子线程入口Main函数 处理read,write和解析操作
  • 主线程执行回复入口函数
  • handleClientsWithPendingWritesUsingThreads:3810行
  • 地址同上
  • 主线程执行读取数据入口函数
  • handleClientsWithPendingReadsUsingThreads:3935行
  • 地址同上
  • 主线程初始化多线程入口函数
  • initThreadedIO: 3712行
  • 地址同上
  • 其他源码
  • readQueryFromClient:2266行
  • postponeClientRead:3908行
  • 地址同上


参考:

https://draveness.me/redis-io-multiplexing/ 

https://ruby-china.org/topics/39925


相关实践学习
基于Redis实现在线游戏积分排行榜
本场景将介绍如何基于Redis数据库实现在线游戏中的游戏玩家积分排行榜功能。
云数据库 Redis 版使用教程
云数据库Redis版是兼容Redis协议标准的、提供持久化的内存数据库服务,基于高可靠双机热备架构及可无缝扩展的集群架构,满足高读写性能场景及容量需弹性变配的业务需求。 产品详情:https://www.aliyun.com/product/kvstore     ------------------------------------------------------------------------- 阿里云数据库体验:数据库上云实战 开发者云会免费提供一台带自建MySQL的源数据库 ECS 实例和一台目标数据库 RDS实例。跟着指引,您可以一步步实现将ECS自建数据库迁移到目标数据库RDS。 点击下方链接,领取免费ECS&RDS资源,30分钟完成数据库上云实战!https://developer.aliyun.com/adc/scenario/51eefbd1894e42f6bb9acacadd3f9121?spm=a2c6h.13788135.J_3257954370.9.4ba85f24utseFl
相关文章
|
23天前
|
Java 开发者
在Java多线程编程中,创建线程的方法有两种:继承Thread类和实现Runnable接口
【10月更文挑战第20天】在Java多线程编程中,创建线程的方法有两种:继承Thread类和实现Runnable接口。本文揭示了这两种方式的微妙差异和潜在陷阱,帮助你更好地理解和选择适合项目需求的线程创建方式。
17 3
|
23天前
|
Java 开发者
在Java多线程编程中,选择合适的线程创建方法至关重要
【10月更文挑战第20天】在Java多线程编程中,选择合适的线程创建方法至关重要。本文通过案例分析,探讨了继承Thread类和实现Runnable接口两种方法的优缺点及适用场景,帮助开发者做出明智的选择。
16 2
|
23天前
|
Java
Java中多线程编程的基本概念和创建线程的两种主要方式:继承Thread类和实现Runnable接口
【10月更文挑战第20天】《JAVA多线程深度解析:线程的创建之路》介绍了Java中多线程编程的基本概念和创建线程的两种主要方式:继承Thread类和实现Runnable接口。文章详细讲解了每种方式的实现方法、优缺点及适用场景,帮助读者更好地理解和掌握多线程编程技术,为复杂任务的高效处理奠定基础。
28 2
|
23天前
|
Java 开发者
Java多线程初学者指南:介绍通过继承Thread类与实现Runnable接口两种方式创建线程的方法及其优缺点
【10月更文挑战第20天】Java多线程初学者指南:介绍通过继承Thread类与实现Runnable接口两种方式创建线程的方法及其优缺点,重点解析为何实现Runnable接口更具灵活性、资源共享及易于管理的优势。
28 1
|
23天前
|
安全 Java 开发者
Java多线程中的`wait()`、`notify()`和`notifyAll()`方法,探讨了它们在实现线程间通信和同步中的关键作用
本文深入解析了Java多线程中的`wait()`、`notify()`和`notifyAll()`方法,探讨了它们在实现线程间通信和同步中的关键作用。通过示例代码展示了如何正确使用这些方法,并分享了最佳实践,帮助开发者避免常见陷阱,提高多线程程序的稳定性和效率。
33 1
|
1月前
|
存储 消息中间件 资源调度
C++ 多线程之初识多线程
这篇文章介绍了C++多线程的基本概念,包括进程和线程的定义、并发的实现方式,以及如何在C++中创建和管理线程,包括使用`std::thread`库、线程的join和detach方法,并通过示例代码展示了如何创建和使用多线程。
43 1
C++ 多线程之初识多线程
|
23天前
|
Java
在Java多线程编程中,`wait()` 和 `notify()/notifyAll()` 方法是线程间通信的核心机制。
在Java多线程编程中,`wait()` 和 `notify()/notifyAll()` 方法是线程间通信的核心机制。它们通过基于锁的方式,使线程在条件不满足时进入休眠状态,并在条件成立时被唤醒,从而有效解决数据一致性和同步问题。本文通过对比其他通信机制,展示了 `wait()` 和 `notify()` 的优势,并通过生产者-消费者模型的示例代码,详细说明了其使用方法和重要性。
24 1
|
2月前
|
数据采集 负载均衡 安全
LeetCode刷题 多线程编程九则 | 1188. 设计有限阻塞队列 1242. 多线程网页爬虫 1279. 红绿灯路口
本文提供了多个多线程编程问题的解决方案,包括设计有限阻塞队列、多线程网页爬虫、红绿灯路口等,每个问题都给出了至少一种实现方法,涵盖了互斥锁、条件变量、信号量等线程同步机制的使用。
LeetCode刷题 多线程编程九则 | 1188. 设计有限阻塞队列 1242. 多线程网页爬虫 1279. 红绿灯路口
|
1月前
|
存储 前端开发 C++
C++ 多线程之带返回值的线程处理函数
这篇文章介绍了在C++中使用`async`函数、`packaged_task`和`promise`三种方法来创建带返回值的线程处理函数。
45 6
|
1月前
|
存储 运维 NoSQL
Redis为什么最开始被设计成单线程而不是多线程
总之,Redis采用单线程设计是基于对系统特性的深刻洞察和权衡的结果。这种设计不仅保持了Redis的高性能,还确保了其代码的简洁性、可维护性以及部署的便捷性,使之成为众多应用场景下的首选数据存储解决方案。
40 1