The art of multipropcessor programming 读书笔记-3. 自旋锁与争用(1)

简介: The art of multipropcessor programming 读书笔记-3. 自旋锁与争用(1)
本系列是 The art of multipropcessor programming 的读书笔记,在原版图书的基础上,结合 OpenJDK 11 以上的版本的代码进行理解和实现。并根据个人的查资料以及理解的经历,给各位想更深入理解的人分享一些个人的资料


自旋锁与争用


1. 再论 TAS 与 TTAS 的自旋锁

在前面的章节我们实现了 TASLock 与 TTASLock 自旋锁,由于 compareAndSet 都会导致互连线上的广播,这样会导致所有线程的延迟,包括没有等待锁的线程。更糟糕的一点是,compareAndSet 调用会让其他的处理器丢弃自己高速缓存中的所副本,这样每一个正在自旋的线程几乎每次都会遇到一个缓存缺失cache miss,需要通过总线获取新的值。还有更糟糕的是,当持有锁的线程,尝试释放锁的时候,由于互连线可能被自旋的线程所独占,所以释放可能被延迟。以上就是 TASLock 为何性能如此之差的原因。

下面分析当锁被线程 A 持有时,TTASLock 锁的行为。线程 B 第一次读锁时发生 cache 确实,从而阻塞等待值被载入它的 cache 中。只要 A 持有锁,B 就会不断读取该值,且每次都命中 cache。这样,在 A 持有锁时,不会产生总线流量,而且也不会降低其他线程的访问速度。此外,A 释放锁也不会被正在自旋的线程所延迟。

但是,在锁释放的时候,会引起一场总线风暴:A 线程将 false 值写入锁变量来释放锁,该操作将会使自旋线程的 cache 副本立刻失效。


2. Exponential Backoff(指数回退,或者称为指数补偿)


我们在微服务系统设计中,可能会经常看到 Backoff 这个名词。他经常出现在微服务调用失败,重试的时候,经常不会是直接重试,而是有一定间隔的重试。这个重试间隔也一般不是固定的,对于同一个请求,重试间隔和重试次数是有一定关系的。最常用的就是指数函数关系。

这个设计其实源于底层适应硬件的软件设计。首先我们来明确一个概念,争用(contention):多线程争用同一资源,这里指的是锁。高争用指的是大量线程竞争同一个锁,低争用则指的是相反的情况。

在我们之前实现的 TTASLock 中,lock 主要分为两步:不断读取锁状态,读取到空闲时,尝试获取锁。如果一个线程通过这个完整过程但是获取锁失败,其他线程获取到了这个锁,那么很可能这个锁面临着高争用的情况。试图获取一个高争用的资源,是应该避免的操作。因为这样线程获取资源的概率非常小,但是造成的总线流量非常大。相反,如果让线程后退一段时间,不去争用锁,这样效率会更高。

线程再次重试之前应该后退多久呢?一种比较好的方式就是让后退的时间与重试的次数成正比,因为重试次数越多,高争用的可能性越高。下面是一个简单的方法:

  1. 读取锁状态
  2. 读取到空闲时,尝试获取锁
  3. 如果获取锁失败,随机后退一段时间
  4. 重复步骤 1 ~ 3,如果获取锁失败,则将步骤 3 的后退时间加倍,直到一个固定的最大值 maxDelay 为止。

我们来实现下这个锁:

public class Backoff {
  private final long minDelay;
  private final long maxDelay;
  private long current;
  public Backoff(long minDelay, long maxDelay) {
    this.minDelay = minDelay;
    this.maxDelay = maxDelay;
    //初始随机最大为 minDelay
    this.current = minDelay;
  }
  public void backoff() {
    //使用 ThreadLocalRandom 防止并发影响随机
    long delay = ThreadLocalRandom.current().nextLong(1, current);
    //随着次数翻倍,直到 maxDelay
    current = Math.min(current * 2L, maxDelay);
    try {
      Thread.sleep(delay);
    } catch (InterruptedException e) {
      //ignore
    }
  }
}
public class TTASWithBackoffLock implements Lock {
  private boolean locked = false;
  private final Backoff backoff = new Backoff(10L, 100L);
  //操作 locked 的句柄
  private static final VarHandle LOCKED;
  static {
    try {
      //初始化句柄
      LOCKED = MethodHandles.lookup().findVarHandle(TTASWithBackoffLock.class, "locked", boolean.class);
    } catch (Exception e) {
      throw new Error(e);
    }
  }
  @Override
  public void lock() {
    while (true) {
      //普通读取 locked,如果被占用,则一直 SPIN
      while ((boolean) LOCKED.get(this)) {
        //让出 CPU 资源,这是目前实现 SPIN 效果最好的让出 CPU 的方式,当线程数量远大于 CPU 数量时,效果比 Thread.yield 好,从及时性角度效果远好于 Thread.sleep
        Thread.onSpinWait();
      }
      //成功代表获取了锁
      if (LOCKED.compareAndSet(this, false, true)) {
        return;
      } else {
        //失败则回退
        backoff.backoff();
      }
    }
  }
  @Override
  public void unlock() {
    LOCKED.setVolatile(this, false);
  }
}

之后,我们使用 JMH 测试 TTASWithBackoffLock 与之前实现的 TTASLock 锁的性能差异:

//测试指标为单次调用时间
@BenchmarkMode(Mode.SingleShotTime)
//需要预热,排除 jit 即时编译以及 JVM 采集各种指标带来的影响,由于我们单次循环很多次,所以预热一次就行
@Warmup(iterations = 1)
//单线程即可
@Fork(1)
//测试次数,我们测试10次
@Measurement(iterations = 10)
//定义了一个类实例的生命周期,所有测试线程共享一个实例
@State(value = Scope.Benchmark)
public class LockTest {
  private static class ValueHolder {
    int count = 0;
  }
  //测试不同线程数量
  @Param(value = {"1", "2", "5", "10", "20", "50", "100"})
  private int threadsCount;
  @Benchmark
  public void testTTASWithBackoffLock(Blackhole blackhole) throws InterruptedException {
    test(new TTASWithBackoffLock());
  }
  @Benchmark
  public void testTTASLock(Blackhole blackhole) throws InterruptedException {
    test(new TTASLock());
  }
  private void test(Lock lock) throws InterruptedException {
    ValueHolder valueHolder = new ValueHolder();
    Thread[] threads = new Thread[threadsCount];
    //测试累加 5000000 次
    for (int i = 0; i < threads.length; i++) {
      threads[i] = new Thread(() -> {
        for (int j = 0; j < 5000000 / threads.length; j++) {
          lock.lock();
          try {
            valueHolder.count++;
          } finally {
            lock.unlock();
          }
        }
      });
      threads[i].start();
    }
    for (int i = 0; i < threads.length; i++) {
      threads[i].join();
    }
    if (valueHolder.count != 5000000) {
      throw new RuntimeException("something wrong in lock implementation");
    }
  }
  public static void main(String[] args) throws RunnerException {
    Options opt = new OptionsBuilder().include(LockTest.class.getSimpleName()).build();
    new Runner(opt).run();
  }
}

其结果是:

Benchmark                         (threadsCount)  Mode  Cnt  Score   Error  Units
LockTest.testTTASLock                          1    ss   10  0.064 ± 0.005   s/op
LockTest.testTTASLock                          2    ss   10  0.138 ± 0.044   s/op
LockTest.testTTASLock                          5    ss   10  0.426 ± 0.100   s/op
LockTest.testTTASLock                         10    ss   10  0.699 ± 0.128   s/op
LockTest.testTTASLock                         20    ss   10  0.932 ± 0.241   s/op
LockTest.testTTASLock                         50    ss   10  1.162 ± 0.542   s/op
LockTest.testTTASLock                        100    ss   10  1.379 ± 0.939   s/op
LockTest.testTTASWithBackoffLock               1    ss   10  0.068 ± 0.008   s/op
LockTest.testTTASWithBackoffLock               2    ss   10  0.080 ± 0.023   s/op
LockTest.testTTASWithBackoffLock               5    ss   10  0.135 ± 0.037   s/op
LockTest.testTTASWithBackoffLock              10    ss   10  0.187 ± 0.072   s/op
LockTest.testTTASWithBackoffLock              20    ss   10  0.200 ± 0.063   s/op
LockTest.testTTASWithBackoffLock              50    ss   10  0.239 ± 0.052   s/op
LockTest.testTTASWithBackoffLock             100    ss   10  0.261 ± 0.042   s/op
Process finished with exit code 0

从结果上可以看出,性能变好了很多。

虽然基于回退的锁实现很简单,也提升了性能。但是针对不同的机器,以及不同的配置,很难找出通用的最合适的 minDelay 以及 maxDelay

相关文章
|
11月前
|
缓存 安全 Java
Java Concurrencyin Practice 并发编程实践系列 第二章 线程安全 Thread Safety 下
Java Concurrencyin Practice 并发编程实践系列 第二章 线程安全 Thread Safety 下
|
11月前
|
存储 缓存 安全
Java Concurrencyin Practice 并发编程实践系列 第二章 线程安全 Thread Safety 上
Java Concurrencyin Practice 并发编程实践系列 第二章 线程安全 Thread Safety 上
|
存储 缓存 Java
The art of multipropcessor programming 读书笔记-3. 自旋锁与争用(2)
The art of multipropcessor programming 读书笔记-3. 自旋锁与争用(2)
|
存储 缓存 算法
The art of multipropcessor programming 读书笔记-硬件基础1
The art of multipropcessor programming 读书笔记-硬件基础1
The art of multipropcessor programming 读书笔记-硬件基础1
|
存储 缓存 Java
The art of multipropcessor programming 读书笔记-硬件基础2
The art of multipropcessor programming 读书笔记-硬件基础2
The art of multipropcessor programming 读书笔记-硬件基础2
|
存储 前端开发 rax
bthread源码剖析(三): 汇编语言实现的上下文切换
上回书说道,TaskGroup的run_main_task()有三大关键函数,剩余一个sched_to()没有展开详谈。那在今天的sched_to()源码探秘之旅开始之前呢,首先高能预警,本文会涉及到汇编语言,所以请大家坐稳扶好!
345 0
|
Android开发 Java C++
带你读《深入理解Android:Java虚拟机ART》之一:本书必读
本书将关注Android系统中至关重要的部分——Java虚拟机ART。随着Android设备的大规模普及,ART虚拟机已经成为当今使用最为广泛的JVM之一。所以,对ART虚拟机进行研究有着非同寻常的意义。本书的出现在一定程度上填补了这方面的空白。
|
存储 安全 Java
深入理解JVM虚拟机读书笔记——锁优化
注:本文参考自周志明老师的著作《深入理解Java虚拟机(第3版)》,相关电子书可以关注WX公众号:兴趣使然的草帽路飞,回复 001 获取。
深入理解JVM虚拟机读书笔记——锁优化
|
缓存 Java Unix
Java并发编程实战系列11之性能与可伸缩性Performance and Scalability
线程可以充分发挥系统的处理能力,提高资源利用率。同时现有的线程可以提升系统响应性。 但是在安全性与极限性能上,我们首先需要保证的是安全性。 11.1 对性能的思考 提升性能=用更少的资源做更多的事情(太对了,这才是问题的本质)。
1587 0
|
算法 Java 调度
Java并发编程实战系列15之原子遍历与非阻塞同步机制(Atomic Variables and Non-blocking Synchronization)
近年来,在并发算法领域的大多数研究都侧重于非阻塞算法,这种算法用底层的原子机器指令来代替锁来确保数据在并发访问中的一致性,非阻塞算法被广泛应用于OS和JVM中实现线程/进程调度机制和GC以及锁,并发数据结构中。
1288 0