简要构架

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

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是
I9-7900X@3.3GHz 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是
I5-7200U@2.5Ghz 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; //fetchedint head = 1; //emptypublic 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

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

  1. 单机100万连接,每秒10万次请求服务端的设计与实现(一) - 前传

    因起 大概两年前,半途接手了一个项目,一个Python写的游戏服务器.倒腾倒腾弄上线后,在一台4核16G的服务器上,TPS不满百,平均响应延迟超百毫秒,勉强抗个500-1000人在线.慢的令人发指.业 ...

  2. 1万属性,100亿数据,每秒10万吞吐,架构如何设计?

    有一类业务场景,没有固定的schema存储,却有着海量的数据行数,架构上如何来实现这类业务的存储与检索呢?58最核心的数据"帖子"的架构实现技术细节,今天和大家聊一聊. 一.背景描 ...

  3. 每秒 10 万并发的 BI 系统如何频繁发生 Young GC?

    作者 | 救火队队长 责编 | 伍杏玲 本文经授权转载自石杉的架构笔记(ID:shishan100) 本周我们的一个重点就是给大家再次强调JVM频繁GC对系统性能的危害性. 因此在分析完JVM发生GC ...

  4. 最有效的赚钱方法,只有100元如何赚到10万?

    亲民创业网之前分享过很多赚钱项目,但很多人看了,都会说这都需要一定的成本投入才能操作啊.那今天,亲民创业网就和大家探讨一个简单的问题:手上只有100元如何赚到10万? 事实上,亲民创业网认为无论你手里 ...

  5. 每秒10万并发 mysql_亿级流量系统架构之如何设计每秒十万查询的高并发架构

    一.前情回顾 上篇文章(亿级流量系统架构之如何设计承载百亿流量的高性能架构)聊了一下系统架构中,百亿流量级别高并发写入场景下,如何承载这种高并发写入,同时如何在高并发写入的背景下还能保证系统的超高性能 ...

  6. 女工程师独家揭秘:支撑双11每秒10万次交易背后的数据库团队故事

    摘要:据说,这个世界上有两类珍稀物种: 1. 大熊猫 2. 美女DBA DBA 即数据库管理员,需要广泛的数据库.业务.系统和网络知识:心细如发,善于沟通的性格:和7*24小时待命,火线解决问题的意志 ...

  7. 无锁缓存,每秒10万并发,究竟如何实现?

    有一类业务场景: (1)超高吞吐量,每秒要处理海量请求: (2)写多读少,大部分请求是对数据进行修改,少部分请求对数据进行读取: 这类业务,有什么实现技巧么? 接下来,一起听我从案例入手,娓娓道来. ...

  8. 爱米云网盘连接服务器失败,爱米云网盘服务端

    爱米云服务端是一个轻量级私有网盘服务器软件,可解决企业内部文件管理问题,为企业提供高效的文件管理服务,在保证稳定性和安全性的基础上提供文件统一存储.共享.搜索.筛选.文件文件夹续传.权限管理等必要功能 ...

  9. jvm性能调优实战 - 26一个每秒10万并发的系统如何频繁发生Young GC的

    文章目录 业务简介 系统初期 技术痛点:实时自动刷新报表 + 大数据量报表 没什么大影响的频繁Young GC 提升机器配置:运用大内存机器 用G1来优化大内存机器的Young GC性能 小结 思考 ...

最新文章

  1. memcached企业面试题
  2. java工程中的.classpathaaaaaaaaaaaaaaaa转载
  3. 【JAVA学习】09.创建BootstrapTale列表页
  4. python语言的单行注释以井号开头_【学习】Python语言入门
  5. Bug(四)——error LNK1112:模块计算机类型x86与目标计算机类型x64冲突
  6. 修改 MyEclipse 编辑区域背景颜色
  7. 易筋SpringBoot 2.1 | 第廿篇:SpringBoot的复杂JPA以及源码解析
  8. 奋斗在制造业----CAE行业感想
  9. 5.5 Go语言项目实战:多人聊天室
  10. [flink]各种大厂开源案例
  11. 建立一个复数类Complex,其私有数据成员mX和mY表示复数的实部和虚部,构造函数Complex用于对复数的实部和虚部初始化
  12. c/c++算法之“24点”经典问题
  13. mssql数据库管理的简单介绍 (转 :kyle)
  14. Hive常用窗口函数实战
  15. SQL语法 自然连接 外连接 内连接
  16. 【Spring Boot】21.集成elasticsearch
  17. 无刷直流电机的PWM调制方式介绍
  18. 互联网公司无线覆盖解决方案
  19. android前端开发工具,分享七个非常有用的Android开发工具和工具包
  20. 使用Spring Integration实现定时任务

热门文章

  1. 豆制品加工黄浆水污水处理设备工艺特色
  2. Testin云测荣获5G应用企业服务优秀平台奖
  3. 移动的宽带特别不好用,非常卡,怎么回事?
  4. <代码自动化>, 之c/c++代码扫描器
  5. 蝴蝶影视服务器响应异常,elasticsearch的服务器响应异常及解决策略(转)
  6. 存储过程常用开关(set命令解析)
  7. linux服务器安装字体库
  8. RPC 就好像是谈一场异地恋
  9. FristiLeaks_1.3#攻略
  10. vivo X9i的Usb调试模式在哪里,开启vivo X9iUsb调试模式的方法