单机100万连接,每秒10万次请求服务端的设计与实现(三) - 变量共享、超线程与高性能队列

简要构架

前文提到过一个框架性的服务器端架构思路,但没给出系统结构图,这里画个图吧,直观不少:

M
M
M
M
M
M
完成部分IO
IO对象争用
M
M
M
网络IO
数据包分析线程
I/O队列
数据IO请求
业务队列
业务流程处理线程
输出队列
*异步IO
IO完成队列
保存队列
*异步保存

图中所有圆角矩形,代表处理线程(带星号的表示可能有多个工作线程,其余在第一版中均为单一线程),圆形图案代表线程间传递任务对象用的队列,线条表示任务流转过程,其中线条上带M的标识了一个典型的业务处理流程。

最终实际的系统结构远这个图复杂的多,以后慢慢讲,但这个图画出了最核心的系统流水示意,一个业务请求,就是这样从网络输入开始,沿着流水线一步步处理,最后通过网络输出的。

其中,每个工作线程,都是从队列/网络读取数据,处理,放入下一个队列/网络的循环,由队列来完成进程间通讯。

队列

一开始,队列我并没有多想,随手拿了一个以前写的ArrayQueue来用,简单测试了一下,大概每次put/take各耗时50ns。那么,就算在实际环境中,每个请求平均要进出20次队列,每次进出实际要消耗200ns(put/take各100ns),那么,整个流水线上的搬运会消耗4000ns的时间。在核心处理线程上,预计每个请求画在队列进出上消耗600ns(少量请求可能会超过1次进出),按照之前10000纳秒可用时间的估计,还有9400纳秒可用,似乎耗的有点多,但也不是啥大事儿,本准备就先这么用着了,没想到,遇见了如下的灵异事件。

奇怪的性能差异

上篇文章介绍的测试环境,是我的主开发环境,自己攒的机器,CPU是
[email protected] Max 4.3GHz, 4.5GHz w/Turbo Boost Max 3.0, 1M L2 Cache, 13.5M L3 Cache(1.35M/Core), 10 Cores 20 Threads.

另外还有台T470的笔电,CPU是
[email protected] Max 3.1Ghz, 256K L2 Cache, 3M L3 Cache. 2 Cores 4 Threads.

看起来好乱,我列个表。

- I9-7900X I5-7200U
主频 3.3 2.5
最大主频 4.3 3.1
L1 缓存 32K指令32K数据 32K指令32K数据
L2 缓存 每核心1M 每核心128k
L3 缓存 13.5M(每核心 1.35M) 3M
核心数 10 2
线程数 20 4
配备的OS Ubuntu 18.04 Win10

这台笔电,自带Win10偶尔也用来敲敲代码。某天突然手贱,在看性能测试程序的时候,没事敲了下Alt-Shift-R X,然后,发现,之前在I9-7900X这种暴力cpu上测试出来,要100ns才能完成一次put + take的队列,在I5-7200U弱鸡上竟然只要32ns

32ns VS 100ns !?

检查代码,同步代码,两边再测试,真的是32ns vs 100ns,妥妥的虐杀。两边各点了几十次run按钮之后,总结如下:

- I9-7900X + Ubuntu18.04 I5-7200U + Win10
take & put Cost 100ns左右,上下波动基本在10ns以内,从未低于85ns过 大部分在30-35ns,偶尔会跳到100ns左右

作为一个喝着科学家故事的鸡汤长大的中年油腻大叔,听多了无数新发现都源于实验室那一点数据误差的传说。
又作为一个心理依然处于幼稚期的好奇巨婴。
这千万分之一秒数量级的差异,足以让我寝食难安。
这次跟这68纳秒,耗上了

初步的分析和排查

两台机器,Java版本都是1.8.0_171,代码很简单,应该不会是触发了JVM的什么底层bug导致的性能差异。 JIT编译器编译出来的代码,应该也和操作系统并没有什么大关系。

一般来说,碰见问题首先找自己的原因,不要老去怀疑OS和JVM这些被广泛使用的东西。那么,队列性能测试,会不会是队列经常过满或是过空的问题呢。在快的CPU上,每次都触发了调度的不合理,而在慢的CPU上触发的较少。所以,一个环境下是始终100ns,一个是大部分32ns,偶尔100ns。

有思路就先尝试,之前用的队列,offer和poll都是非阻塞的,但用了put和take两个方法来封装offer和poll,如果没有成功操作,就pack,然后反复尝试。大致代码如下:

public void put(T obj) {
	offer(obj, -1);
}

public boolean offer(T obj, final long nanoTimeout) {
	long w = 0;
	long packTime = MIN_PACKTIME_NS;
	while(!offer(obj)) {
		LockSupport.parkNanos(packTime);
		if(nanoTimeout > 0) {
			w += packTime;
			if(w > nanoTimeout) return false;
		}
		if(packTime < MAX_PACKTIME_NS) packTime <<= 1;
	}
	return true;
}
public boolean offer(T obj) {
	… …
}

然后,在测试代码中,使用了put/take来做性能测试, 并对pack时间进行统计。遗憾的是,测试结果表明,除非我把队列容量等参数设置调整的很不合理,并没有什么时间消耗在pack上。

继续深入

没办法,再试试操作系统吧,把公司的电脑,也装上了win10,Windows 10 有授权评估版,试用个几天不侵犯知识产权。继续测试,灵异的事情再次发生,详见下表:

- I9-7900X + Ubuntu18.04 I9-7900X + Win10 I5-7200U + Win10
take & put Cost 100ns左右,从未低于85ns过 大部分在100ns左右,偶尔会跳到30-35ns 大部分在30-35ns,偶尔会跳到100ns左右

又多了一种情况,我没有再去折腾家里的笔电装Ubuntu,茫然的一边点run一边思考,点了几十次之后,我觉得可以阶段性总结一下了。
1 Linux环境,性能始终在100ns的级别
2 Win10环境,某些时候,性能会到32ns的级别
3 I9-7900X 到 32ns量级的可能,比I5-7200U的概率更低。

显然,核心频率,缓存大小啥的,导致不了这个32ns 与 100ns之间的巨大差距。我隐约猜到了原因,还是从看十几年前自己写的ArrayQueue的源代码开始吧。

源码通常能说明一切

虽然觉得十几年来,自己也没啥长进,但那个意气风发的青涩年代写的代码,还是有点青涩的,这是一个单线程offer,单线程poll的队列,其核心代码如下:

	int tail = 0; //fetched
	int head = 1; //empty
	
	public boolean offer(T obj) {
		if(obj == null) throw new NullPointException("can't put null into this queue")
		if(head != tail) {
			array[head] = obj; 
			if(this.capacity == head + 1) {
				head = 0;
			} else {
				head++;
			}
			return true;
		}
		return false;
	}
	
	@SuppressWarnings("unchecked")
	public T poll(){
		Object r;
		if(tail + 1 < head 
				|| (head <= tail && tail != capacity -1) 
				|| (tail == capacity - 1 && head > 0)) {
			int t = tail + 1;
			if( t == this.capacity) t = 0;
			r = array[t];
			
			//编译优化或CPU导致的乱序执行,会导致head移动后array[head]尚未赋值完成,返回空下次再获取
			if(r == null) return null;
			//同上,因为乱序执行的问题,为了避免取到上次队列中的对象,取出对象后将array[i]设置为null,
			//这也导致了本队列不支持null对象
			array[t] = null;
			
			tail = t;
			return (T)r;
		}
		return null;
	}

简单说几句代码逻辑,一个数组,head指向队列头,tail指向队尾,超过队列长度时数值循环回来(这段代码对head/tail的循环维护和队列空/满的条件判断写的没错,但策略不当,不无聊就不用细看了),当offer/poll时,先根据head和tail计算队列是否满/空,然后执行相应的操作,无论是P线程(调用offer的Producer线程,下同)还是C线程(调用poll的Consumer线程,下同),都需要根据head和tail值来进行是否可以往队列放/取的判断,对于P线程来说,可能会没有读到被C线程最新更新的tail值,但这只会让head误以为队列已满(实际上刚有空位),并不会出错,反之亦然。

注意里面的注释部分。系统无法保证array[head] = obj 和 head++ 谁先执行。如果head++先执行,而array[head]尚未被赋值,poll函数读到这里会出错。因此多了一个poll时if(r == null) return null的检测和回写null的代码**(我记得这段代码是测试发现错误后加上的)**。注意,offer不需要这个过程,只要tail往前走了,tail原有的位置就必然可以被新对象替换掉,这个不是重点,有兴趣的读者自己看。

现在看来,这段代码有一个很严重的性能问题,每次offer/poll操作前,都需要同时读取head和tail的值,操作后,P线程更新head值,C线程更新tail值。这里,P线程更新Head值后必然会触发一次其他核心的缓存失效,C线程要读取Head值会发生一次L1/L2缓存不命中,需要去L3 Cache中重新读取更新后的Head值,这里通常会需要30-50ns的时间。

如果P C两个线程正好放一个/读一个/放一个/读一个(测试环境中,由于缓存失效的轮流等待,这个很可能是高概率事件),那么每次因为缓存失效带来的poll & offer(各一次)的延迟,正好是100ns左右。相对于缓存失效导致的L3 读取开销,那些几个时钟周期就能完成的运算耗时都可以忽略不计,这很好的解释了,poll & offer 100ns的时间消耗。

那么,32ns的时间消耗又从何而来呢?32ns意味着极高的缓存命中率,按照正常分析,只有在P线程offer一批,C线程poll一批这种情况下,才不会因为缓存失效而性能大幅降低,我加了段日志统计代码,发现并非如此。那么,到底是什么带来了这么高的缓存命中率。

谜底

数据被写了,线程切换了,而缓存依然命中,只能说明,这两个线程用了同样的L1/L2 缓存。而单核心上一次线程切换的开销,通常约需要 > 1000ns的消耗(关于线程切换性能,可参考How long does it take to make context switch),我再次确认了一下I5-7200U的参数 L2 Cache是每核心256k独立L2缓存,而不是共享L2(多年前好像有这种设计)。

两个线程,跑在同一个核心上,没有上下文切换。
真実はいつも一つ! (真相只有一个!)
Hyper-Threading (超线程)

在超线程的环境中,每个核心上可以同时存放两个线程的上下文,当一个线程因为某些原因需要挂起时,CPU可迅速切换到另一个线程运行,在本例中,A P两个测试线程均属于运算环节简单,时间主要消耗在内存访问上的情形。这时,如两个线程在同一核心下超线程执行,共用L1/L2缓存,消除了耗时最多的缓存失效下的 L3缓存读取等待,获得了更好的性能数据。

思考

一、为什么在Ubuntu + 10核心20线程的CPU下,始终不会有32ns的Cost?
可能的答案:Linux内核会优先将进程分配到独立核心上运行,或是其线程-CPU核心分布/调度策略正好导致了这一结果。而win10的分配更加随机,这也说明了,为什么在10核心20线程的CPU上,Win10将两个线程分到同一个Core上的概率比2核心4线程的CPU更低。

二、为什么多核心不共享L2缓存(以前也有核心较少的CPU共享L2 Cache)
缓存的大小和访问速度往往成正比(物理约束,距离和电信号速度),电路的空间分布也是问题。现在的CPU L3缓存通常是共享的,可部分解决该问题。关于缓存,缓存的访问速度/成本/空间分布的相关内容推荐阅读这篇问答

三、为什么多核心,不共享一小块高速缓存
这个其实是我想问的问题,而且这块高速缓存只需要很小的容量,甚至一个CPU只要1k(随进程整体切换)就可以大幅降低服务多线程加锁带来的巨大开销。将加锁的速度提高上百倍。当然,这会导致从CPU到OS到编译器以至开发语言的一个整体变化。这里随口一提,我完全不懂芯片设计,就不乱置喙了。

基础队列改进

原因已经找到,但在实际的业务处理中,显然不可能把两个工作线程放在一个核心上工作,这只会导致真实环境下的性能下降。对于这个队列来说,首先就是,分离两个线程需要的变量,让A/P两个线程,在队列不是极端的满/空的情况下,无需共享变量。

再来看看之前的ArrayQueue,首先试图使用head/tail来判断队列是否有空间/对象可以存/取,然后在测试中发现,指令乱序执行使得通过head/tail来判断队列情况有Bug,又加了一个是否不为空的判断。

慢着,这里就是关键了,head不靠谱,靠谱的是

if((r = array[tail + 1]) != null) ....

那么C线程还管head干嘛,反正不靠谱,同样的,P线程也不用管tail,只要

if(array[head] == null) .....

就行了,我们可以想象一下,array只有一个位置的极端情况,只要P线程读到是null,就可以array[0] = obj0往里加对象,C线程马上,或延迟发现array[0] = obj0,那么取走obj0 再让 array[0] = null; P线程只可能在C线程将array[0]置为null之后才会再次 array[0] = obj1,安全快速可靠。

那么就改吧,顺便把tail 和 head也改成持续增长的对象,这也是我这十几年的改变之一,以前追求代码本身的完美,现在觉得,业务上完美才是真的完美。一个自增的long,每纳秒增长一次,几十年才会溢出,完全没必要循环。

然后把队列长度强制成2的幂,一个位与操作即可获得数组下标。主要代码修改如下:

@sun.misc.Contended("g0")
final Object[] array;
@sun.misc.Contended("g0")
final int capacity;
@sun.misc.Contended("g0")
final int m;
@sun.misc.Contended("g1")
long tail = 0; 
@sun.misc.Contended("g2")
long head = 0;
	
public SimpleArrayQueue(int preferCapacity) {
	this.capacity = ComUtils.getPow2Value(preferCapacity, MIN_CAPACITY, MAX_CAPACITY); 
	//找一个大于等于preferCapacity并在MIN MAX 之间的2的幂
	array = new Object[this.capacity];
	this.m = this.capacity - 1;				
}

public boolean offer(T obj) {
	ProgramError.when(obj == null, "Can't put Null Object into this Queue!");
	int p = (int) (head & this.m);
	if(array[p] != null) return false;
	head ++;
	array[p] = obj;
	return true;
}
	
public T poll(){
	Object r;
	int p = (int) (tail & this.m);
	if((r=array[p]) == null)  return null;
	array[p] = null;
	tail++;
	return (T)r;
}

其中
@sun.misc.Contended 是为了解决伪共享问题,让head/tail不会和其他变量分别在不同的缓存行。这里用了Java 8新增的annotation,但padding方案可能会更通用。关于伪共享问题,推荐阅读从Java视角理解系统结构(三)伪共享
将队列内数组的容量,capacity,设置为2的幂,head和tail都只自增,通过和 capacity-1 位与 来获得数组下标。

写完了测试一下,I9-7900X + Ubuntu18.04,结果如下:

Consumer 0 has completed. Cost Per Take 9ns. 
Producer 0 has completed. Cost Per Put 9ns. 
Total 201M I/O, cost 1913ms, 105M/s

一次put + take 的时间从之前的100ns左右,降低到了20ns左右。单队列在1P1C的情况下,达到了105M也就是1.05亿次每秒的吞吐量。够了够了。

这次就到这里,下节,把这个队列改写出一个线程安全版本。

本文所涉及的部分代码,会随着文章进度逐步整理并放到 github上。
其中,高性能基础数据结构的代码见 https://github.com/Lofint/tachyon

你可能感兴趣的:(高性能JAVA服务)