无声的性能杀手-伪共享(false-sharing)

前言

在并发编程过程中,我们大部分的焦点都放在如何控制共享变量的访问控制上(代码层面),但是很少人会关注系统硬件及 JVM 底层相关的影响因素。这段时间学习Log4j2解除到了Disruptor,它被誉为“最快的消息框架”,其LMAX架构能够在一个线程里每秒处理6百万订单!在讲到 Disruptor 为什么这么快时,接触到了一个概念——伪共享(false sharing),其中提到:缓存行上的写竞争是运行在 SMP 系统中并行线程实现可伸缩性最重要的限制因素。由于从代码中很难看出是否会出现伪共享,有人将其描述成无声的性能杀手。
这里给出伪共享的非标准定义:

缓存系统中是以缓存行(cache line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行,就会无意中影响彼此的性能,这就是伪共享。

前置知识

CPU缓存结构

要说起伪共享的产生原因,我们先来回顾一下当代计算机的cpu的缓存系统。

cpu缓存:在计算机系统中,CPU高速缓存(英语:CPU Cache,在本文中简称缓存)是用于减少处理器访问内存所需平均时间的部件。在金字塔式存储体系中它位于自顶向下的第二层,仅次于CPU寄存器。其容量远小于内存,但速度却可以接近处理器的频率。
高速缓存的出现主要是为了解决 CPU 运算速度与内存读写速度不匹配的矛盾,因为 CPU 运算速度要比内存读写速度快很多,这样会使 CPU 花费很长时间等待数据到来或把数据写入内存。

在缓存中的数据是内存中的一小部分,但这一小部分是短时间内 CPU 即将访问的,当 CPU 调用大量数据时,就可避开内存直接从缓存中调用,从而加快读取速度。

按照数据读取顺序和与 CPU 结合的紧密程度,CPU 缓存可以分为一级缓存,二级缓存,部分CPU 还具有三级缓存(目前基本都有三级缓存)。每一级缓存中所储存的全部数据都是下一级缓存的一部分,越靠近 CPU 的缓存越快也越小。所以 L1 缓存很小但很快(L1 表示一级缓存),并且紧靠着在使用它的 CPU 内核。L2 大一些,也慢一些,并且仍然只能被一个单独的 CPU 核使用。L3 在现代多核机器中更普遍,仍然更大,更慢,并且被单个插槽上的所有 CPU 核共享。最后,你拥有一块主存,由全部插槽上的所有 CPU 核共享。拥有三级缓存的的 CPU,到三级缓存时能够达到 95% 的命中率,只有不到 5% 的数据需要从内存中查询。
结构如下:
无声的性能杀手-伪共享(false-sharing)
当 CPU 执行运算的时候,它先去 L1 查找所需的数据,再去 L2,然后是 L3,最后如果这些缓存中都没有,所需的数据就要去主内存拿。走得越远,运算耗费的时间就越长。所以如果你在做一些很频繁的事,你要确保数据在 L1 缓存中。

Martin Thompson 给出了一些缓存未命中的消耗数据,如下表格所示:

从CPU到 大约需要CPU周期 大约需要时间
主存 约60-80ns
QPI总线传输 约20ns
L3 cache 约40-45 cycles 约15ns
L2 cache 约10 cycles 约3ns
L1 cache 约3-4 cycles 约1ns
寄存器 约1 cycles

缓存行

缓存系统中是以缓存行(cache line)为单位存储的。缓存行是2的整数幂个连续字节,一般为32-256个字节。最常见的缓存行大小是64个字节。一个 Java 的 long 类型是 8 字节,因此在一个缓存行中可以存 8 个 long 类型的变量。所以,如果你访问一个 long 数组,当数组中的一个值被加载到缓存中,它会额外加载另外 7 个,以致你能非常快地遍历这个数组。事实上,你可以非常快速的遍历在连续的内存块中分配的任意数据结构。而如果你在数据结构中的项在内存中不是彼此相邻的(如链表),你将得不到免费缓存加载所带来的优势,并且在这些数据结构中的每一个项都可能会出现缓存未命中。

MESI 协议及 RFO 请求

从上一节中我们知道,每个核都有自己私有的 L1,、L2 缓存。那么多线程编程的时候,必定会存在一种情况就是多个核的L1,L2cache中存有相同的一份数据,如果只是读取的话,并没有大问题,涉及到修改的时候,这个时候如何保持缓存中的数据一致性呢?
下面将详细地解答以上问题. 首先我们需要谈到一个协议—— MESI 协议。现在主流的处理器都是用它来保证缓存的相干性和内存的相干性。M、E、S 和 I 代表使用 MESI 协议时缓存行所处的四个状态:

  • M(Modified): 这行数据有效,数据被修改了,只存在于本cache中
  • E(Exclusive): 该行数据有效,且数据与内存中的数据一致,但数据值只存在于本Cache中。通俗来说,该数据只在Cache中独一份
  • S(Shared): 该行数据有效,且该数据与内存中的数据一致。同时,该数据存在与其它多个Cache中
  • I(Invalid): 该行数据无效

每个Cache的Cache控制器不仅知道自己的读写操作,而且也监听(snoop)其它Cache的读写操作
下面是4个状态转换的图示
无声的性能杀手-伪共享(false-sharing)
关于MESI协议的内容可以看这篇博文
当某一个core的cache中的数据需要修改的时候,别的cache控制器收到了这个行为的消息,会将本身的cache行置为I状态。这个称之为RFO(Request For Owner)请求,处理RFO请求以及设置I的过程将给写操作带来很大的性能消耗。

伪共享成因

无声的性能杀手-伪共享(false-sharing)
这个图来自Disruptor,很经典得解释了伪共享的原因:
图1说明了伪共享的问题。在核心1上运行的线程想更新变量X,同时核心2上的线程想要更新变量Y。不幸的是,这两个变量在同一个缓存行中。每个线程都要去竞争缓存行的所有权来更新变量。如果核心1获得了所有权,缓存子系统将会使核心2中对应的缓存行失效。当核心2获得了所有权然后执行更新操作,核心1就要使自己对应的缓存行失效。这会来来回回的经过L3缓存,大大影响了性能。如果互相竞争的核心位于不同的插槽,就要额外横跨插槽连接,问题可能更加严重。

探索伪共享

上文讲到了伪共享的成因,这里面我来模拟一下,上代码:

import java.util.concurrent.TimeUnit;
public class FalseShare implements Runnable {

    public static int NUM_THREADS = 4;

    public final static long ITERATION = 500L * 1000L * 1000L;

    private final int arrayIndex;

    private static VolatileLong[] longs;

    public static long SUM_TIME = 0L;

    public FalseShare(final int arrayIndex) {
        this.arrayIndex = arrayIndex;
    }

    public static void main(String[] args) throws Exception {
        TimeUnit.MILLISECONDS.sleep(1000L);
        for (int j = 0; j < 10; ++j) {
            System.out.println(j);
            longs = new VolatileLong[NUM_THREADS];
            for (int i = 0; i < longs.length; ++i) {
                longs[i] = new VolatileLong();
            }
            final long start = System.nanoTime();
            runTest();
            final long end = System.nanoTime();
            SUM_TIME += end - start;
        }
        System.out.println("consume time : " + SUM_TIME / 10);
    }

    public static void runTest() throws InterruptedException {
        Thread[] threads = new Thread[NUM_THREADS];
        for (int i = 0; i < threads.length; ++i) {
            threads[i] = new Thread(new FalseShare(i));
        }

        for (Thread t : threads) {
            t.start();
        }

        for (Thread t : threads) {
            t.join();
        }

    }

    @Override
    public void run() {
        long i = ITERATION + 1;
        while (0 != --i) {
            longs[arrayIndex].value = i;
        }
    }

    private final static class VolatileLong {
        public volatile long value = 0L;
        public long p1, p2, p3, p4, p5;// 注释
    }
}

代码的逻辑很简单,就是四个线程修改一数组不同元素的内容。元素的类型是 VolatileLong,只有一个长整型成员 value 和 6 个没用到的长整型成员。value 设为 volatile 是为了让 value 的修改对所有线程都可见。程序分两种情况执行,第一种情况为不注释(见"注释"字样),第二种情况为注释。为了保证数据的相对可靠性,程序取 10 次执行的平均时间。执行情况如下:
core i7 双核 16G 2线程:10395156790ns(注释)
core i7 双核 16G 2线程:15174351692ns(不注释)

core i7 双核 16G 4线程:10542391911ns(注释)
core i7 双核 16G 4线程:22531089733ns(不注释)

core i7 双核 16G 8线程:16742105148ns(注释)
core i7 双核 16G 8线程:24304564521ns(不注释)

讲道理,两次测试的唯一区别就是有没有注释那一行看似无用的代码,但是差距一眼便见。这是为什么呢?Disruptor的作者称这种做法为缓存行填充(cache line padding)现在分析上面的例子,我们知道一条缓存行有 64 字节,而 Java 程序的对象头固定占8字节(32位系统)或 16字节(64位系统默认开启压缩,压缩为12字节),所以我们只需要填5个无用的长整型补上就可以占满一个完整的缓存行。
伪共享在多核编程中很容易发生,而且非常隐蔽。例如,在 JDK 的 LinkedBlockingQueue 中,存在指向队列头的引用 head 和指向队列尾的引用 tail 。而这种队列经常在异步编程中使有,这两个引用的值经常的被不同的线程修改,但它们却很可能在同一个缓存行,于是就产生了伪共享。线程越多,核越多,对性能产生的负面效果就越大。

如何避免伪共享的情况?

解决方案有两种。

  1. 缓存行填充,如同Disruptor中一样,显示的填充缓存行,示例源码:
abstract class SingleProducerSequencerPad extends AbstractSequencer
{
    protected long p1, p2, p3, p4, p5, p6, p7;

    SingleProducerSequencerPad(int bufferSize, WaitStrategy waitStrategy)
    {
        super(bufferSize, waitStrategy);
    }
}

abstract class SingleProducerSequencerFields extends SingleProducerSequencerPad
{
    SingleProducerSequencerFields(int bufferSize, WaitStrategy waitStrategy)
    {
        super(bufferSize, waitStrategy);
    }

    /**
     * Set to -1 as sequence starting point
     */
    long nextValue = Sequence.INITIAL_VALUE;
    long cachedValue = Sequence.INITIAL_VALUE;
}

/**
 * <p>Coordinator for claiming sequences for access to a data structure while tracking dependent {@link Sequence}s.
 * Not safe for use from multiple threads as it does not implement any barriers.</p>
 *
 * <p>* Note on {@link Sequencer#getCursor()}:  With this sequencer the cursor value is updated after the call
 * to {@link Sequencer#publish(long)} is made.</p>
 */

public final class SingleProducerSequencer extends SingleProducerSequencerFields
{
    protected long p1, p2, p3, p4, p5, p6, p7;
}
  1. 使用@sun.misc.Contended注解
    在使用此注解的对象或字段的前后各增加128字节大小的padding,使用2倍于大多数硬件缓存行的大小来避免相邻扇区预取导致的伪共享冲突。

建议尽量采用此方式,因为在不同平台上的cache line 大小可能不一样,JVM 主动帮你接锅,岂不美哉?(需要注意的是,这个注解只有在开启了-XX:-RestrictContended 选项时才会生效)
关于padding更多内容可以看这篇ata

小结

是不是所有出现涉及多线程的地方都需要考虑伪共享的问题呢?这个问题的答案是肯定的,伪共享的存在肯定对程序的性能有一定的影响,但是我们一定要解决潜在的伪共享的问题吗?答案是不一定。

  1. 伪共享是隐蔽的,不借助专业的工具(如PERF-C2C)很难发现
  2. 其次,不同类型的计算机具有不同的微架构(如 32 位系统和 64 位系统的 java 对象所占自己数就不一样),如果设计到跨平台的设计,那就更难以把握了,一个确切的填充方案只适用于一个特定的操作系统。
  3. 还有,缓存的资源是有限的,如果填充会浪费珍贵的 cache 资源,并不适合大范围应用。
  4. 最后,目前主流的 Intel 微架构 CPU 的 L1 缓存,已能够达到 80% 以上的命中率。

参考文档

上一篇:简单的node爬虫练手,循环中的异步转同步


下一篇:Flex从服务器请求文件如何避免缓存