关于后端:并发刺客False-Sharing并发程序的隐藏杀手

46次阅读

共计 7114 个字符,预计需要花费 18 分钟才能阅读完成。

并发刺客(False Sharing)——并发程序的暗藏杀手

前言

前段时间在各种社交平台“雪糕刺客”这个词比拟火,简略的来说就是雪糕的价格十分高!其实在并发程序当中也有一个刺客,如果在写并发程序的时候不留神不小心,这个刺客很可能会连累咱们的并发程序,让咱们并发程序执行的效率变低,让并发程序付出很大的代价,这和“雪糕刺客”当中的“刺客”的含意是统一的。这个并发程序当中的刺客就是——假共享(False Sharing)。

假共享(False Sharing)

缓存行

当 CPU 从更慢级别的缓存读取数据的时候(三级 Cache 会从内存当中读取数据,二级缓存会从三级缓存当中读取数据,一级缓存会从二级缓存当中读取数据,缓存级别越低执行速度越快),CPU 并不是一个字节一个字节的读取的,而是一次会读取一块数据,而后将这个数据缓存到 CPU 当中,而这一块数据就叫做 缓存行 。有一种缓存行的大小就是 64 字节,那么咱们为什么会做这种优化呢?这是因为 局部性原理,所谓局部性原理简略说来就是,过后应用一个数据的时候,它左近的数据在将来的一段时间你也很可能用到,比如说咱们遍历数组,咱们通常从前往后进行遍历,比方咱们数组当中的数据大小是 8 个字节,如果咱们的缓存行是 64 个字节的话,那么一个缓存行就能够缓存 8 个数据,那么咱们在遍历第一个数据的时候将这 8 个数据加载进入缓存行,那么咱们在遍历将来 7 个数据的时候都不须要再从内存当中拿数据,间接从缓存当中拿就行,这就能够节约程序执行的工夫。

假共享

当两个线程在 CPU 上两个不同的外围上执行代码的时候,如果这两个线程应用了同一个缓存行 C,而且对这个缓存行当中两个不同的变量进行写操作,比方线程 A 对变量 a 进行写操作,线程 B 对变量 b 进行写操作。而因为缓存一致性(Cache coherence)协定的存在,如果其中 A 线程对缓存行 C 中变量 a 进行了写操作的话,为了保障各个 CPU 外围的数据统一(也就是说两个 CPU 外围看到了 a 的值是一样的,因为 a 的值曾经发生变化了,须要让另外的 CPU 外围晓得,不然另外的 CPU 外围应用的就是旧的值,那么程序后果就不对了),其余外围的这个缓存行就会生效,如果他还想应用这个缓存行的话就须要从新三级 Cache 加载,如果数据不存在三级 Cache 当中的话,就会从内存当中加载,而这个从新加载的过程就会很连累程序的执行效率,而事实上线程 A 写的是变量 a,线程 B 写的是变量 b,他们并没有真正的有共享的数据,只是他们须要的数据在同一个缓存行当中,因而称这种景象叫做 假共享(False Sharing)

下面咱们谈到了,当缓存行生效的时候会从三级 Cache 或者内存当中加载,而多个不同的 CPU 外围是共享三级 Cache 的(上图当中曾经显示进去了),其中一个 CPU 外围更新了数据,会把数据刷新到三级 Cache 或者内存当中,因而这个时候其余的 CPU 外围去加载数据的时候就是新值了。

下面谈到的对于 CPU 的缓存一致性(Cache coherence)的内容还是比拟少的,如果你想深刻理解缓存一致性(Cache coherence)和缓存一致性协定能够认真去看这篇文章。

咱们再来举一个更加具体的例子:

假如在内存当中,变量 a 和变量 b 都占四个字节,而且他们的内存地址是间断且相邻的,当初有两个线程 A 和 B,线程 A 要一直的对变量 a 进行 + 1 操作,线程 B 须要一直的对变量进行 + 1 操作,当初这个两个数据所在的缓存行曾经被缓存到三级缓存了。

  • 线程 A 从三级缓存当中将数据加载到二级缓存和一级缓存而后在 CPU- Core0 当中执行代码,线程 B 从三级缓存将数据加载到二级缓存和一级缓存而后在 CPU- Core1 当中执行代码。
  • 线程 A 一直的执行 a += 1,因为线程 B 缓存的缓存行当中蕴含数据 a,线程 A 在批改 a 的值之后,就会在总线上发送音讯,让其余处理器当中含有变量 a 的缓存行生效,在处理器将缓存行生效之后,就会在总线上发送音讯,示意缓存行曾经生效,线程 A 所在的 CPU- Core0 收到音讯之后将更新后的数据刷新到三级 Cache。
  • 这个时候线程 B 所在的 CPU-Core1 当中含有 a 的缓存行曾经生效,因为变量 b 和变量 a 在同一个缓存行,当初线程 B 想对变量 b 进行加一操作,然而在一级和二级缓存当中曾经没有了,它须要三级缓存当中加载这个缓存行,如果三级缓存当中没有就须要去内存当中加载。
  • 仔细分析下面的过程你就会发现线程 B 并没有对变量 a 有什么操作,然而它须要的缓存行就生效了,尽管和线程 B 共享须要同一个内容的缓存行,然而他们之间并没有真正共享数据,所以这种景象叫做假共享。

Java 代码复现假共享

复现假共享

上面是两个线程一直对两个变量执行 ++ 操作的代码:

class Data {
  public volatile long a;
  public volatile long b;
}

public class FalseSharing {public static void main(String[] args) throws InterruptedException {Data data = new Data();
    long start = System.currentTimeMillis();
    Thread A = new Thread(() -> {for (int i = 0;  i < 500_000_000; i++) {data.a += 1;}
    }, "A");

    Thread B = new Thread(() -> {for (int i = 0;  i < 500_000_000; i++) {data.b += 1;}
    }, "B");
    A.start();
    B.start();
    A.join();
    B.join();
    long end = System.currentTimeMillis();
    System.out.println("破费工夫为:" + (end - start));
    System.out.println(data.a);
    System.out.println(data.b);
  }
}

下面的代码比较简单,这里就不进行阐明了,下面的代码在我的笔记本上的执行工夫大概是17 秒

下面的代码变量 a 和变量 b 在内存当中的地位是相邻的,他们在被 CPU 加载之后会在同一个缓存行当中,因而会存在假共享的问题,程序的执行工夫会变长。

上面的代码是优化过后的代码,在变量 a 后面和前面别离退出 56 个字节的数据,再加上 a 的 8 个字节(long 类型是 8 个字节),这样 a 前后加上 a 的数据有 64 个字节,而当初支流的缓存行是 64 个字节,够一个缓存行的大小,因为数据 a 和数据 b 就不会在同一个缓存行当中,因而就不会存在 假共享 的问题了。而上面的代码在我笔记本当中执行的工夫大概为 5 秒。这就足以看出 假共享 会对程序的执行带来多大影响了。

class Data {
  public volatile long a1, a2, a3, a4, a5, a6, a7;
  public volatile long a;
  public volatile long b1, b2, b3, b4, b5, b6, b7;
  public volatile long b;
}

public class FalseSharing {public static void main(String[] args) throws InterruptedException {Data data = new Data();
    long start = System.currentTimeMillis();
    Thread A = new Thread(() -> {for (int i = 0;  i < 500_000_000; i++) {data.a += 1;}
    }, "A");

    Thread B = new Thread(() -> {for (int i = 0;  i < 500_000_000; i++) {data.b += 1;}
    }, "B");
    A.start();
    B.start();
    A.join();
    B.join();
    long end = System.currentTimeMillis();
    System.out.println("破费工夫为:" + (end - start));
    System.out.println(data.a);
    System.out.println(data.b);
  }
}

JDK 解决假共享

为了解决 假共享 的问题,JDK 为咱们提供了一个注解 @Contened 解决假共享的问题。

import sun.misc.Contended;

class Data {
//  public volatile long a1, a2, a3, a4, a5, a6, a7;
  @Contended
  public volatile long a;
//  public volatile long b1, b2, b3, b4, b5, b6, b7;
  @Contended
  public volatile long b;
}

public class FalseSharing {public static void main(String[] args) throws InterruptedException {Data data = new Data();

    long start = System.currentTimeMillis();
    Thread A = new Thread(() -> {for (long i = 0;  i < 500_000_000; i++) {data.a += 1;}
    }, "A");

    Thread B = new Thread(() -> {for (long i = 0;  i < 500_000_000; i++) {data.b += 1;}
    }, "B");
    A.start();
    B.start();
    A.join();
    B.join();
    long end = System.currentTimeMillis();
    System.out.println("破费工夫为:" + (end - start));
    System.out.println(data.a);
    System.out.println(data.b);
  }
}

下面代码的执行工夫也是 5 秒左右,和之前咱们本人在变量的左右两边插入变量的成果是一样的,然而 JDK 提供的这个接口和咱们本人实现的还是有所区别的。(留神:下面的代码是在 JDK1.8 下执行的,如果要想 @Contended 注解失效,你还须要在 JVM 参数上退出-XX:-RestrictContended,这样下面的代码能力失效否则是不可能失效的)

  • 在咱们本人解决假共享的代码当中,是在变量 a 的左右两边退出 56 个字节的其余变量,让他和变量 b 不在同一个缓存行当中。
  • 在 JDK 给咱们提供的注解 @Contended,是在被加注解的字段的左边退出肯定数量的空字节,默认退出 128 空字节,那么变量a 和变量 b 之间的内存地址大一点,最终不在同一个缓存行当中。这个字节数量能够应用 JVM 参数-XX:ContendedPaddingWidth=64,进行管制,比方这个是 64 个字节。
  • 除此之外 @Contended 注解还可能将变量进行分组:
class Data {@Contended("a")
  public volatile long a;
  
  @Contended("bc")
  public volatile long b;
  @Contended("bc")
  public volatile long c;
}

在解析注解的时候会让同一组的变量在内存当中的地位相邻,不同的组之间会有肯定数量的空字节,配置形式还是跟下面一样,默认每组之间空字节的数量为 128。

比方下面的变量在内存当中的逻辑布局具体布局如下:

 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)                           20 0a 06 00 (00100000 00001010 00000110 00000000) (395808)
     12   132        (alignment/padding gap)                  
    144     8   long Data.a                                    0
    152   128        (alignment/padding gap)                  
    280     8   long Data.b                                    0
    288     8   long Data.c                                    0
    296   128        (loss due to the next object alignment)
Instance size: 424 bytes
Space losses: 260 bytes internal + 128 bytes external = 388 bytes total

下面的内容是通过上面代码打印的,你只有在 pom 文件当中引入包 jol 即可:

import org.openjdk.jol.info.ClassLayout;
import sun.misc.Contended;


class Data {@Contended("a")
  public volatile long a;
  
  @Contended("bc")
  public volatile long b;
  @Contended("bc")
  public volatile long c;
}

public class FalseSharing {public static void main(String[] args) throws InterruptedException {Data data = new Data();

    System.out.println(ClassLayout.parseInstance(data).toPrintable());
  }
}

从更低层次 C 语言看假共享

后面咱们是应用 Java 语言去验证 假共享 ,在本大节当中咱们通过一个 C 语言的多线程程序(应用 pthread)去验证 假共享。(上面的代码在类 Unix 零碎都能够执行)

#include <stdio.h>
#include <pthread.h>
#include <time.h>

#define CHOOSE // 这里定义了 CHOOSE 如果不想定义 CHOOSE 则将这一行正文掉即可

// 定义一个全局变量
int data[1000];

void* add(void* flag) {
  // 这个函数的作用就是一直的往 data 当中的某个数据进行加一操作
  int idx = *((int *)flag);
  for (long i = 0; i < 10000000000; ++i) {data[idx]++;
  }
}

int main() {
  pthread_t a, b;
#ifdef CHOOSE // 如果定义了 CHOOSE 则执行上面的代码 让两个线程操作的变量隔得远一点 让他们不在同一个缓存行当中
  int flag_a = 0;
  int flag_b = 100;
  printf("远离 \n");
#else // 如果没有定义 让他们隔得近一点 也就是说让他们在同一个缓存行当中
  int flag_a = 0;
  int flag_b = 1;
  printf("邻近 \n");
#endif
  pthread_create(&a, NULL, add, &flag_a); // 创立线程 a 执行函数 add 传递参数 flag_a 并且启动
  pthread_create(&b, NULL, add, &flag_b); // 创立线程 b 执行函数 add 传递参数 flag_b 并且启动
  long start = time(NULL);
  pthread_join(a, NULL); // 主线程期待线程 a 执行实现
  pthread_join(b, NULL); // 主线程期待线程 b 执行实现
  long end = time(NULL);
  printf("data[0] = %d\t data[1] = %d\n", data[0], data[1]);
  printf("cost time = %ld\n", (end - start));
  return 0;
}

下面代码的输入后果如下图所示:

咱们首先来解释一下下面 time 命令的输入:

  • readl:这个示意真实世界当中的墙钟工夫,就是示意这个程序执行所破费的工夫,这个秒单位和咱们平时说的秒是一样的。
  • user:这个示意程序在用户态执行的 CPU 工夫,CPU 工夫和实在工夫是不一样的,这里须要留神辨别,这里的秒和咱们平时的秒是不一样的。
  • sys:这个示意程序在内核态执行所破费的 CPU 工夫。

从下面程序的输入后果咱们能够很显著的看进去当操作的两个整型变量相隔距离远的时候,也就是不在同一个缓存行的时候,程序执行的速度是比数据隔得近在同一个缓存行的时候快得多,这也从侧面显示了 假共享 很大水平的升高了程序执行的效率。

总结

在本篇文章当中次要探讨了以下内容:

  • 当多个线程操作同一个缓存行当中的多个不同的变量时,尽管他们事实上没有对数据进行共享,然而他们对同一个缓存行当中的数据进行批改,而因为缓存一致性协定的存在会导致程序执行的效率升高,这种景象叫做 假共享
  • 在 Java 程序当中咱们如果想让多个变量不在同一个缓存行当中的话,咱们能够在变量的旁边通过减少其余变量的形式让多个不同的变量不在同一个缓存行。
  • JDK 也为咱们提供了 Contended 注解能够在字段的前面通过减少空字节的形式让多个数据不在同一个缓存行,而且你须要在 JVM 参数当中退出 -XX:-RestrictContended,同时你能够通过 JVM 参数-XX:ContendedPaddingWidth=64 调整空字节的数目。JDK8 之后注解 Contended 在 JDK 当中的地位有所变动,大家能够查问一下。
  • 咱们也是用了 C 语言的 API 去测试了假共享,事实上在 Java 虚拟机当中底层的线程也是通过调用 pthread_create 进行创立的。

更多精彩内容合集可拜访我的项目:https://github.com/Chang-LeHu…

关注公众号:一无是处的钻研僧,理解更多计算机(Java、Python、计算机系统根底、算法与数据结构)常识。

正文完
 0