Disruptor 全解析(7):解密内存屏障(Memory Barrier)

本文涉及的产品
云解析 DNS,旗舰版 1个月
全局流量管理 GTM,标准版 1个月
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
简介: 原文地址:http://mechanitis.blogspot.com/2011/08/dissecting-disruptor-why-its-so-fast.html​​, 作者是 Trisha Gee, LMAX 公司的一位女工程师。
原文地址: http://mechanitis.blogspot.com/2011/08/dissecting-disruptor-why-its-so-fast.html​​, 作者是 Trisha Gee, LMAX 公司的一位女工程师。
 

我最近写文章的速度变慢了,是因为我一直在尝试写一篇博客解释内存屏障(Memory Barrier)以及它在 Disruptor​ 的应用。问题是,无论我阅读了多少次,无论我向永远耐心的 Martin​ 和 Mike​ 提出多少个问题试图弄明白一些重点,我还是没办法直观的把握主题。我猜我没有完全理解它所需要的深厚背景知识。

 
因此,与其让一个像我这样的傻瓜去解释一些连自己都没有真正了解的东西,我准备尝试和覆盖的,是在一个抽象和大量简化的层面、我确实已经在这个领域内弄懂的东西。Martin 写过一篇详细的 走进内存屏障(Memory Barrier),这太有助于我回避和忽略本文的主题了。
 
免责申明:在这篇解答里的任何错误全都是我自己的事,并不反映 Disruptor 的实现或者真正理解这个东西的 LMAX 同事水平。
 
重点是?
 
在这一系列的博客中,我的主要目标是解释 Disruptor 是如何工作的——并且,稍微小小的扩展到,它为什么这样工作。理论上,从想要使用它的开发者视角描述 Disruptor,我可以在代码与 技术文献​ 间建立一座桥梁。
 
本文提到 Memory Barrier(内存屏障),我打算理解它是什么,并且如何使用。
 
什么是 Memory Barrier(内存屏障)?
 
这是一条 CPU 指令。是的,再一次,我们去思考 CPU 层面的特性以获得我们需要的性能(请参考 Martin 著名的 Mechanical Sympathy 理论)。基本上它是一条这样的指令:a) 保证特定操作的执行顺序,以及 b) 影响某些数据(也许是某些指令的执行结果)的可见性
 
编译器和 CPU 能够重排序指令,保证最终相同的结果,并尝试优化性能。插入一条 Memory Barrier 会告诉 CPU 和编译器:在这条命令之前发生的必须待在这条命令之面,在这条命令之后的必须待在命令之后。所有这一切都像一趟拉斯维加斯之旅完全占据了你的脑子一样。
 
Disruptor 全解析(7) - 解密内存屏障(Memory Barrier)
 
Memory Barrier(内存屏障)所做的另一件事是强制刷出各种 CPU cache(高速缓存)——比如,一个 Write-Barrier(写入屏障)将刷出所有在 Barrier 之前写入 cache 的数据,因此,任何尝试去读这些数据的线程将会读到它们的最新版本,而不管线程在哪个 CPU 核心或者哪个 CPU 插槽(Socket)上执行。
 
这与 JAVA 有什么关系?
 
我知道你在想什么——这不是汇编,是 Java。
 
这里的魔法咒语是关键字 "volatile"(我觉得这是 Java 认证里从来没有解释清楚过的东西)。如果你的字段是 volatile 的,Java 内存模型(Java Memory Model)会在你写入字段之后插进一个 Write-Barrier 指令,并且在你读这个字段之前插入一个 Read-Barrier 指令。
 
Disruptor 全解析(7) - 解密内存屏障(Memory Barrier)
 
这意味着,如果写入一个 volatile 字段,你知道这些事会发生:
 
1. 从写入字段这个时间点开始,任何线程访问该字段都会拿到更新后的数据。
 
2. 在写入字段之前的操作都确实执行过了,并且它们更新的数据也是可见的,
    因为 Memory Barrier 会刷出 cache 中所有先前的写入。
 
请举例!
 
很高兴你提出这个要求。这是我又要开始画“甜甜圈”的时间了。
 
RingBuffer​ 游标字段(cursor)是这些神奇的“volatile ”变量之一,它也是我们可以不用锁而实现 Disruptor 的一个原因。
 
Disruptor 全解析(7) - 解密内存屏障(Memory Barrier)
 
生产者(Producer)先拿到下一个(或者下一批)Entry 对象,​然后随意访问这些对象,用各种想写入的值更新它们。与 你知道的 一样​,在全部更新结束后,生产者会调用 RingBuffer 的 commit 方法,由它去更新序号。这个对“volatile”字段(cursor)的写入产生了一个 Memory Barrier,最终它会刷出所有的 cache(或者至少让它们失效)。
 
在这个时间点之后,消费者会拿到最新的序号(8),因为 Memory Barrier 也同样保证先前发生的 CPU 指令顺序,因此消费者可以确信生产者对序号 7 之上的 Entry 对象所做的全部修改也都生效了。
 
... 在消费者那边呢?
 
消费者上的序号也是“volatile”变量,它被一系列外部对象读取——其他的 下游​ 消费者可能在追踪这个消费者,而且  ProducerBarrier/RingBuffer(取决于你看的是老代码还是新代码)也会追踪它以保证环不会重叠(wrap)。
 
Disruptor 全解析(7) - 解密内存屏障(Memory Barrier)
 
因此,如果下游消费者(C2)看到前面的消费者(C1)读到了序号 12,它接着从 RingBuffer 读序号 12 之前的节点时,就可以读到消费者(C1)更新序号前对节点做的全部更改。
 
基本上,消费者(C2)拿到更新序号后做的一切操作(在上图用蓝色表示)都必须发生在消费者(C1)在更新序号前对 RingBuffer 所做的一切操作(用黑色表示)之后。
 
对性能的影响
 
内存屏障(Memory Barrier)作为另一种 CPU 级别的指令,不像 加锁的开销​ 那么大 —— 操作系统内核并不需要在多个线程间调度和协调。但是任何东西都不是免费的。内存屏障的确有一些开销 —— 编译器/CPU 不能重排序指令,会潜在的导致代码没有尽可能高效的利用 CPU,而且刷新 CPU cache 会对性能有明显的影响。因此,不要以为用“volatile”字段代替锁就能让你永远逍遥法外。
 
你会注意到 Disruptor 在实现里尝试尽可能少的读写序号。每次读写“volatile”字段都是一次相对开销较大的操作。认识到这一点可以很好的进行批处理 —— 如果你知道不应该太频繁的读写序号,那么先抓取一批节点进行处理,然后再更新序号 —— 这样无论对于生产端和消费端都是有意义的。下面是一个来自 BatchConsumer 的例子:
 
    long nextSequence = sequence + 1;
    while (running)
    {
        try
         {
            final long availableSequence = consumerBarrier.waitFor(nextSequence);
            while (nextSequence <= availableSequence)
            {
                entry = consumerBarrier.getEntry(nextSequence);
                handler.onAvailable(entry);
                nextSequence++;
            }
            handler.onEndOfBatch();
            sequence = entry.getSequence();
        }
        ...
        catch (final Exception ex)
        {
            exceptionHandler.handle(ex, entry);
            sequence = entry.getSequence();
            nextSequence = entry.getSequence() + 1;
        }
    }
 
(你会注意到这儿还是“老”代码和命名规则,因为这篇文章紧接着我的上一篇博客,我想这样比直接切换到新命名规则会稍微减少一些混乱)
 
在上面的代码中,我们在消费者处理节点的循环中递增的是一个局部变量。这意味着我们读写 sequence 这个 volatile 字段(用粗体表示)的次数尽可能的降到了最低。
 
总结
 
内存屏障(Memory Barrier)是一个 CPU 指令,它允许你对数据在什么时候被其他进程可见作出确定的假设。在 Java 中,你可以用 volatile 关键字来实现它们。使用 volatile 关键字意味着你不需要被迫的、别无选择的加锁,而且使用还会让你获得性能提升。但是,这需要更加小心的思考你的设计,特别是你对 volatile 字段的使用有多频繁,以及读写它们有多频繁。
 
PS: 鉴于 Disruptor 当前的“世界新秩序​”与我至今为止博客里提到的一切相比,用的是完全不同的命名规则,我想下一篇文章是该把“旧世界”映射到“新世界”了。
 
译注
 
这是 Trisha Gee 博客里有关 Disruptor 原理介绍的最后一篇,其他的几篇介绍大家可以阅读我博客中的译文:
 
 
目录
相关文章
|
25天前
|
存储 缓存 安全
Java内存模型深度解析:从理论到实践####
【10月更文挑战第21天】 本文深入探讨了Java内存模型(JMM)的核心概念与底层机制,通过剖析其设计原理、内存可见性问题及其解决方案,结合具体代码示例,帮助读者构建对JMM的全面理解。不同于传统的摘要概述,我们将直接以故事化手法引入,让读者在轻松的情境中领略JMM的精髓。 ####
33 6
|
1月前
|
存储 Java 编译器
Java内存模型(JMM)深度解析####
本文深入探讨了Java内存模型(JMM)的工作原理,旨在帮助开发者理解多线程环境下并发编程的挑战与解决方案。通过剖析JVM如何管理线程间的数据可见性、原子性和有序性问题,本文将揭示synchronized关键字背后的机制,并介绍volatile关键字和final关键字在保证变量同步与不可变性方面的作用。同时,文章还将讨论现代Java并发工具类如java.util.concurrent包中的核心组件,以及它们如何简化高效并发程序的设计。无论你是初学者还是有经验的开发者,本文都将为你提供宝贵的见解,助你在Java并发编程领域更进一步。 ####
|
2月前
|
C++
【C++】深入解析C/C++内存管理:new与delete的使用及原理(二)
【C++】深入解析C/C++内存管理:new与delete的使用及原理
|
2月前
|
编译器 C++ 开发者
【C++】深入解析C/C++内存管理:new与delete的使用及原理(三)
【C++】深入解析C/C++内存管理:new与delete的使用及原理
|
18天前
|
存储 算法 Java
Java内存管理深度解析####
本文深入探讨了Java虚拟机(JVM)中的内存分配与垃圾回收机制,揭示了其高效管理内存的奥秘。文章首先概述了JVM内存模型,随后详细阐述了堆、栈、方法区等关键区域的作用及管理策略。在垃圾回收部分,重点介绍了标记-清除、复制算法、标记-整理等多种回收算法的工作原理及其适用场景,并通过实际案例分析了不同GC策略对应用性能的影响。对于开发者而言,理解这些原理有助于编写出更加高效、稳定的Java应用程序。 ####
|
4天前
|
存储 缓存 数据安全/隐私保护
DMA(Direct Memory Access):直接内存访问
DMA(Direct Memory Access)是一种允许外设直接与内存进行数据传输的技术,无需 CPU 干预。它通过减轻 CPU 负担、提高数据传输效率来提升系统性能。DMA 的工作模式包括直接模式和 FIFO 模式,数据传输方式有单字传送和块传送,寻址模式有增量寻址和非增量寻址。通过缓存一致性协议、同步机制、数据校验和合理的内存管理,DMA 确保了数据在内存中的一致性和完整性。
21 0
|
2月前
|
Rust 编译器
|
2月前
|
存储 监控 算法
Java中的内存管理与垃圾回收机制解析
本文深入探讨了Java编程语言中的内存管理方式,特别是垃圾回收机制。我们将了解Java的自动内存管理是如何工作的,它如何帮助开发者避免常见的内存泄漏问题。通过分析不同垃圾回收算法(如标记-清除、复制和标记-整理)以及JVM如何选择合适的垃圾回收策略,本文旨在帮助Java开发者更好地理解和优化应用程序的性能。
|
2月前
|
存储 安全 Java
JVM锁的膨胀过程与锁内存变化解析
在Java虚拟机(JVM)中,锁机制是确保多线程环境下数据一致性和线程安全的重要手段。随着线程对共享资源的竞争程度不同,JVM中的锁会经历从低级到高级的膨胀过程,以适应不同的并发场景。本文将深入探讨JVM锁的膨胀过程,以及锁在内存中的变化。
46 1
|
2月前
|
Java C语言 iOS开发
MacOS环境-手写操作系统-16-内存管理 解析内存状态
MacOS环境-手写操作系统-16-内存管理 解析内存状态
47 0

推荐镜像

更多
下一篇
DataWorks