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

Posted 干货满满张哈希

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了The art of multipropcessor programming 读书笔记-3. 自旋锁与争用相关的知识,希望对你有一定的参考价值。

本系列是 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

以上是关于The art of multipropcessor programming 读书笔记-3. 自旋锁与争用的主要内容,如果未能解决你的问题,请参考以下文章

the art of seo(chapter five)

the art of seo(chapter six)

The Art of Java 全网收集最全版本在此!

State of the Art in Microservices

学习 The Art And Science Of Java

简洁代码的艺术The Art of Clean Code