剖析高性能队列Disruptor背后的数据结构和算法

2023-11-15

------ 本文是学习算法的笔记,《数据结构与算法之美》,极客时间的课程 ------

Disruptor 是一种内存消息队列。它经Java 中另外一个非常常用的内存消息队列 ArrayBlockQueue(ABS)的性能,要高出一个数量级,可以算得上是最快的内存消息队列了。今天来看下,Disruptor是如何做到如此高性能的?其底层依赖了哪些数据结构和算法?

基于循环队列的“生产者-消费者模型”

什么是内存消息队列?如果我说“生产者-消费者模型”,估计在部分人都知道。在这个模型中,“生产者”生产数据,并且将数据放到一个足以存储容器中。之后,“消费者”从中心存储容器中,取出数据消费。

这个模型非常简单、好理解,那你有没有思考过,这里面存储数据的中心存储容器,是用什么数据结构来实现的呢?

实际上,实现中心存储容器最常用的一种数据结构,就是队列。队列支持数据先进先出。下是这个特性,使得数据被消费的顺序性可以得到保证,也就是说,早被生产的数据就会早被消费。

队列的实现思路。一种是基于链表实现的链式队列,另一种是基于数组实现的顺序队列。不同的需求背景下,我们会选择不同的实现方式。

如果我们要实现一个无界队列,也就是说,队列的大小事先不确定,理论上可以支持无限大。这种情况下,我们适合选用链表来实现队列。因为链表支持快速地动态扩容。如果我们要实现一个德莱文队列,也就是说,队列的大小事先确定,当队列中数据满了之后,生产者就需要等待。直到消费者消费了数据,队列有空闲位置的时候,生产者才能癣数据放入。

实际上,相较于无界队列,有界队列的应用场景更加广泛。毕竟,我们的机器内存是有限的。而无界队列占用的内存数量是不可控的。对于实际的软件开发来说,这种不可控的因素,就会有潜在的风险。在某些极端情况下,无界队列就有能因为内存持续增长,而导致OOM( Out of Memory)错误。

还有一种循环队列,我们讲过,非循环队列在添加、删除数据的工程中,会涉及的搬移操作,导致性能变差。而循环队列可以解决这个数据搬移问题,所以,性能更加好。所以,大部分用到顺序队列的场景中,我们都选择用顺序队列中的循环队列。

实际上,循环队列这种数据结构,就是我们今天要讲的内存消息队列的雏形。我们借助循环队列,实现了一个最简单的“生产者-消费者模型”。对应的代码如下。为了方便理解,对于生产者和消费者之间操作的同步,我并没有用到线程相关的操作。而是采用了“当队列满了之后,生产者就轮训等待;当队列空了之后,消费者就轮训等待”这样的措施。

	public class Queue {
		private Long[] data;
		private int size = 0, head = 0, tail = 0;

		public Queue(int size) {
			this.data = new Long[size];
			this.size = size;
		}

		public boolean add(Long element) {
			if ((tail + 1) % size == head) {
				return false;
			}
			data[tail] = element;
			tail = (tail + 1) % size;
			return true;
		}

		public Long poll() {
			if (head == tail) {
				return null;
			}
			long ret = data[head];
			head = (head + 1) % size;
			return ret;
		}
	}
	
	public class Producer{
		private Queue queue;
		public Producer(Queue queue){
			this.queue = queue;
		}
		
		public void produce(Long data) throws InterruptedException{
			while (!queue.add(data)) {
				Thread.sleep(100);
			}
		}
	}
	
	public class Consumer{
		private Queue queue;
		public Consumer(Queue queue){
			this.queue = queue;
		}
		
		public void consume() throws InterruptedException{
			while(true){
				Long data = queue.poll();
				if (data == null) {
					Thread.sleep(100);
				} else {
					// TODO: ... 消费数据的业务逻辑
				}
			}
		}

基于加锁的并发“生产者-消费者模型”

实际上,刚刚的“生产者-消费者”实现的代码,是不完善的。为什么这么说呢?

如果我们只有一个生产者往队列中写数据,一个消费者从队列中读取数据,那上面的代码是没有问题的。但是,如果有多个生产者在并发地往队伍中写入数据,或者多个消费者并发地从队列中消费数据,那上面的代码就不能正确工作了。

在多个生产者或者多个消费者并发操作的情况下,刚刚的代码主要会有正面两个问题:

  • 多个生产者写入的数据可能会互相覆盖;
  • 多个消费者可能会读取重复的数据;

这两个问题是类似的,这里判重讲第一个问题是如何产生的,以及该如何解决。

两个线程同时往队列中添加数据,也就相当于两个线程同时执行类 Queue 中的 add() 函数。我们假设队列的大小 size 是10,当前 tail 指向下标7,head 指向下标 3,也就是说,队列中还有空闲空间。这个时候,线程1 调用 add() 函数,往队列中添加一个值为 12 的数据; 线程 2 调用 add() 函数,往队列中添加一个值为15的数据。在极端情况下,本来是往队列中添加两个数据(12 和 15),最终可能只有一个数据添加成功,另一个数据会被覆盖,为什么呢?在这里插入图片描述

		public boolean add(Long element) {
			if ((tail + 1) % size == head)    return false;
			data[tail] = element;
			tail = (tail + 1) % size;
			return true;
		}

从这段代码中,我们可以看出,每 3 行给 data[tail] 赋值,然后第 4 行才给 tail 的值加一。赋值和 tail 加一两个操作,并非原子操作。这就会导致这种的情况发生:当线程 1 和线程 2 现时执行 add() 函数的时候,线程 1 先执行完了第 3 行语句,将 data[7] (tail = 7)的值设置为12。在线程 1 还未执行到第 4 行语句之前,也就是未将 tail 加一之前,线程 2 执行了第 3 行语句,又将data[7] 的值设置为 15,也就是说,线程 2 插入的数据覆盖了线程 1 插入的数据。原来应该插入两个数据(12 和 15)的,现在只插入了一个数据(15)。在这里插入图片描述
那如何解决这种线程并发往队列中添加数据时,导致的数据覆盖、运行不正确问题呢?

最简单的处理方法就是给这段代码加锁,同一时间只允许一个线程执行 add() 函数。这相当于将这段代码的执行,由并行改成了串行。也就不存在我们刚刚说的问题了。

不过,加锁将并行改成串行,必然导致多个生产者同时生产数据的时候,执行效率下降。当然,我们可以继续优化。 我们看看 Disruptor 的处理方法。

基于无锁的并发“生产者-消费者模型”

尽管 Disruptor 的源码读越来很复杂,但是基本思想非常简单。实际上,它是换了一种队列和“生产者-消费者模型”的实现思路。

之前的实现中,队列只支持两个操作,添加数据和读取并移除数据,分别对应代码中 add() 函数和 poll() 函数,而Disruptor 采用了另一种实现思路。

对于生产者来说,它往队列中添加数据之前,先申请可用空闲存储单元,并且是批量地申请连续 n 个(n >= 1) 存储单元。当申请到这组连续的存储单元之后,后续往队列中添加元素,就可以不用加锁了,因为这组存储单元是这个线程独享的。不过,从刚刚的描述中,我们可以看出,申请存储单元的过程是需要加锁的。

对于消费者来说,处理的过程跟生产者是类似的。它先去申请一批连续可读的存储单元(这个申请过程也是需要加锁的),当申请到这批存储单元之后,后续的读取操作就可以不用加锁了。

不过,还有一个需要特别注意的地方,就是,如果生产者A 申请到了一组团结的存储单元,假设下标为 3 到 6 的存储单元,生产者 B 紧跟着申请到了下标是 7 到 9 的存储单元,那在 3 到 6 没有完全写入之前,7 到 9 的数据是无法读取的。这个也是 Disruptor 实现的一个弊端。在这里插入图片描述
实际上,Disruptor 采用的是RingBuffer 和 AvailableBuffer 这两个结构,来实现我刚刚讲的功能。不过,因为我们主要聚集在数据结构和算法上,所以我对这两种结构做了简化,但是基本思想是一致的。

总结引申

今天,讲了如何实现一个高性能的并发队列。这里的“并发”两个字,实际上就是多线程安全的意思。

常见的内存队列往往采用循环队列来实现。这种实现方法,对于只有一个生产者和消费者的场景,已经足够了。但是,当存在多个生产者或者多个消费者的时候,单纯的循环队列的实现,就无法正常工作了。

这主要是因为,多个生产者在同时往队列中写入数据的时候,在某些情况下,会存在数据覆盖的问题。而多个消费者同时消费数据,在某些情况下,会存在消费重复数据的问题。

针对这个问题,最简单、最暴力的解决方法,就是对写入和读取的过程加锁。这种处理方法,相当于将原来可以并行执行的操作,强制串行执行,相应地就会导致操作性能的下降。

为了在保证逻辑正确的前提下,尽可能地提高队列在并发情况下性能,Disruptor 采用了“两阶段写入”的方法。在写入数据之前,先加锁申请批量的空闲存储单元,之后往队列中写入数据的操作就不需要加锁了,写入的性能因此就提高了。Disruptor 对消费过程的改造,跟对生产过程的改造是类似的。它先加锁申请批量的可读取的存储单元,之后从队列中读取数据的操作也就不需要加锁了,读取的性能因此也就提高了。

你可能觉得这个优化思路非常简单。实际上,不管架构设计还是产品设计,往往越简单的设计思路,越能更好的解决问题。下所谓“大道至简”,就是这个意思。

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

剖析高性能队列Disruptor背后的数据结构和算法 的相关文章

  • 广度优先搜索(1)之树的层序遍历

    文章目录 零 导言 一 例子引入 1 题目描述 2 题目分析 3 算法实现与解释 二 概念定义 1 定义 2 深入理解 3 相关知识 三 相关习题 零 导言 这一系列博客的创作初衷是为了记录自己在刷题过程中对于一些比较经典的并且很哇塞的题型
  • 排序算法总结(Python版)

    经典排序算法总结与实现 经典排序算法在面试中占有很大的比重 也是基础 为了未雨绸缪 这次收集整理并用Python实现了八大经典排序算法 包括冒泡排序 插入排序 选择排序 希尔排序 归并排序 快速排序 堆排序以及基数排序 希望能帮助到有需要的
  • 【剑指offer】数据结构——字符串

    目录 数据结构 字符串 直接解 剑指offer 05 替换空格 剑指offer 17 打印从1到最大的n位数 剑指offer 20 表示数值的字符串 剑指offer 37 序列化二叉树 剑指offer 50 第一个只出现一次的字符 剑指of
  • Lecture 9

    绪论 这一章节介绍的是divide and conquer multiplication divide的意思是分开 conquer的意思是占据 控制 divide and conquer直译下来就是分开后控制 其实就是分而治之的意思 mul
  • 算法基础——大O表示法

    本期主题 算法的大O表示法 目录 1 什么是大O表示法 2 时间复杂度 2 1 时间复杂度定义 2 2 常见算法的时间复杂度 3 数组与链表对比 1 什么是大O表示法 大O表示法是一种特殊的表示方式 指出了算法的速度 指出了最糟情况下的运行
  • 【Leetcode】61. 旋转链表

    题目描述 给你一个链表的头节点 head 旋转链表 将链表每个节点向右移动 k 个位置 题解 旋转链表 找倒数第k个节点 翻转前后链表 执行用时 0 ms 在所有 Java 提交中击败了100 00 的用户 内存消耗 37 8 MB 在所有
  • 递归实现栈的翻转

    递归实现栈的翻转 主要考察对于递归的理解 其实这个问题最简单的方法当然是设计一个空的栈来存储这些元素 一次达成逆序 但是题目要求使用递归的方式实现逆序 因此需要借助函数返回栈来充当这个这个栈的作用 实际上依然是借助了一个栈 但是这个栈是函数
  • 算法与数据结构-堆

    前言 大四重拾算法与数据结构 所有内容为自己的阶段小结所以并不是技术性文章 如有兴趣阅读 遇到问题不妨给我留个言 万分感谢 堆 堆是一颗完全二叉树 最大堆 根结点的键值是所有堆结点键值中最大者 且每个结点的值都比其孩子的值大 最小堆 根结点
  • 判断无向图G是否连通。若连通返回1,否则返回0

    判断无向图G是否连通 若连通返回1 否则返回0 CODE 判断无向图G是否连通 若连通返回1 否则返回0 define N 1 gt gt 8 代替无穷大 默认 邻接矩阵 define size 6 include
  • 【剑指offer】数据结构——队列 栈 堆

    目录 数据结构 树 剑指offer 09 用两个栈实现队列 剑指offer 30 包含min函数的栈 剑指offer 31 栈的压入 弹出序列 剑指offer 41 数据流中的中位数 剑指offer 59 2 队列的最大值 数据结构 树 剑
  • 【C++后台开发面试】STL相关

    此部分较为精简 只供面试前联想记忆使用 需要先熟读相关的内容知识才能发挥其作用 推荐书籍 STL源码剖析 侯捷 六大组件及其关系 空间配置器 容器 迭代器 算法 仿函数 适配器 内存管理 内存配置和对象构造 析构分开 使用双层级配置器 第一
  • 【经典排序算法】3. 插入排序

    对顺序性强的数据 插入排序就比较快 最好情况O n 代码如下 public class Main public static void main String args int arr 3 3 5 6 2 1 arrPrint arr In
  • vscode——debugger

    提示 本文适用于vscode编译java代码调试初学者 文章目录 debugger图标介绍 左侧工具栏 调试代码 debugger图标介绍 在进行调试之前我们应先在代码前打断点 调试程序时 代码就会运行至断点位置然后停下 断点即为行数前小红
  • 【Leetcode】111. 二叉树的最小深度

    题目描述 题解 递归遍历 记录深度 然后贪心地去更新结果 取min 考虑到这里还不够 需要加一层叶节点的判断 必须当前节点是叶子结点才能够做res的更新 否则可能会碰到这种情况 根结点左边没有子树 根结点右边有子树 结果递归下去发现深度是1
  • 算法训练day43

    文章目录 1049 最后一块石头的重量 II 求最大重量 思路分析 代码实现 494 目标和 求组合方法数 思路分析 动规方法 代码实现 总结思考 474 一和零 求二维背包的最大物品数 思路分析 代码实现 思考总结 1049 最后一块石头
  • 力扣刷题-面试题 17.13. 恢复空格、字典树、前缀树的应用

    基本概念 Trie 树 又称单词查找树 前缀树 是一种树形结构 典型应用是用于统计 排序和保存大量的字符串 但不仅限于字符串 它的优点是 利用字符串的公共前缀来减少查询时间 最大限度地减少无谓的字符串比较 比哈希表更快 基本性质 根节点不包
  • Disruptor(一)Disruptor概念和RingBuffer数据结构

    Disruptor是LMAX公司开源的一个高效的内存无锁队列 谈到并发程序设计 有几个概念是避免不了的 1 锁 锁是用来做并发最简单的方式 当然其代价也是最高的 内核态的锁的时候需要操作系统进行一次上下文切换 等待锁的线程会被挂起直至锁释放
  • 2020年全国高校计算机能力挑战赛初赛java语言解答

    题目1 统计从1到N的整数中 所有立方值的平方根为整数的数的格式 输入说明 整数N N lt 10000 输出说明 符合条件的数的个数 如4 3 64 8 2 输入样例 10 输出样例 3 说明 样例中符合条件的3个数是1 4 9 publ
  • 完全二叉树与满二叉树

    去笔试了很多次 每次都有有关于二叉树的题目 而且其中最多的是关于完全二叉树 然而完全二叉树在哥心中的形态一直很模糊 究其原因是我把完全二叉树和满二叉树搞混了 其实满二叉树是完全二叉树的特例 因为满二叉树已经满了 而完全并不代表满 所以形态你
  • 【LeetCode刷题笔记】位运算

    231 2 的幂 解题思路 1 除法 不断循环判断 如果能被 2 整除 就不断除以 2 直到不能被 2 整除为止 最后结果如果是 1

随机推荐