编程

隐藏在语言背后的魔鬼:运行架构为何会成为性能瓶颈

1002 2023-03-27 09:15:19

编程语言的性能差异是程序员社区经久不衰的话题,但当你对各种技术的了解越深入,就越能感受到各种语言的本质区别:不同语言的设计方向不同,就像时间换空间、空间换时间,他们只是选择了一种优势找信息之神换成了另一种优势罢了。

没有任何编程语言是单纯的“语法集合”,每一种语言都是它背后“运行架构”的体现,语言之间的差异本质上就运行架构设计方向的差异。

Web 系统规模发展史

Web 1.0 时代,各种静态、动态、解释型、编译型、虚拟机型语言百花齐放,因为当时系统规模和系统用户都比较少,而资源则大多以静态 HTML 页面的形式展示,技术上大家都是熟悉哪个用哪个,这些后端技术也大多能满足需求。

Web 2.0 时代,真正的高并发系统出现了,一分钟内需要写入数据的用户数超过了百万,这个时代,最终锤炼出了 Java 和 PHP 两种技术:PHP 擅长简单 CURD,而 Java 擅长事务处理(例如电商下单)。这个时代发展到顶峰,就是手机 App 和大前端的时代:界面由前端完全掌控,后端团队提供稳定高性能的 API 即可。

在 Web 生态发展的过程中,也催生出了如 Python、Ruby、Scala、Node.js 这样的后端技术之花,但他们在冲到顶峰后也迅速落幕,现在连 PHP 也在逐步退场,只有 Java 由于拥有完善的微服务基础设施,暂时看起来还安全。

Web 2.0 后期,乔布斯将人类拉入了移动互联网时代。今天的互联网巨头们,同时在线用户量动辄上亿,对系统性能的需求发生了爆炸性的增长,也催生出了 PHP 的接班人:Go 语言。十多年前我第一次写 Go 的时候就认识到,它就是 C with net,自带网络库的万能底层语言,可以让普通开发者轻松写出超高性能的应用。

语言特性如何决定性能

PHP 是一种单线程全阻塞语言:在每个 HTTP/FastCGI 请求中,PHP 解释器会启动一个 进程/线程 来运行一段 PHP 代码,在运行的时候,无论是读写磁盘(磁盘 IO)还是读写数据库(网络 IO),PHP 线程都会停下来等待:此时并不消耗 CPU 资源,但是 TCP 连接和线程资源都还在持续等待,如果这个请求不结束,那该线程将会一直保持运行,持续消耗着 TCP 连接和内存资源。

由于语言本身的运行架构一致,所以 PHP-FPM 模式和 Apache 的 mod_php 模式执行 PHP 的性能是一致的。在 2 vCore 4G 内存的情况下,PHP 200QPS 的性能极限是无法通过把 Apache 换成 PHP-FPM 来解决的。

那么阻塞式运行架构的性能瓶颈应该怎么突破呢?轮到 Node.js 登场了。

Node.js 的非阻塞 IO

在阻塞式语言中,所有的 IO 操作都是需要停下来等待的,例如磁盘 IO,数据库网络 IO 等,而真正用于计算的 CPU 资源反而大多数时候在浪费:绝大多数 API 不需要多少复杂的数据转换,更多的时间花在了和各种数据库的通信上。而世界上绝大多数语言都是阻塞式运行的,因为这样做虽然性能不高,但却最符合人类大脑的习惯,编码也更加容易。在当时,高性能大多是用多核+多进程/线程来实现的。

Ryan Dahl 敏锐地发现了 IO 浪费时间这个问题,并且挑选了一个为浏览器创造的单线程语言 JavaScript 来实现他的抱负:将所有 IO 操作全部异步化,并利用 js 的单线程排队特性,创造了一种高性能且稳定的后端技术:Node.js。

不过,计算机的世界没有银弹,Node.js 虽然性能强,但是代码编写起来更加困难,需要多付出一些异步编程的思考时间,debug 也更加地困难。

Node.js 是一种非常神奇的单线程异步非阻塞架构,以 Google v8 引擎作为 JavaScript 解释器,利用事件驱动技术,大幅提升了单机能够处理的 QPS 极限,而它“只是完整利用了单核 CPU”而已。

此外,Node.js 还具备一个 Nginx 的优势:可以单机处理海量用户的 TCP 连接。

你看,背后的哲学原理都连上了吧。

Node.js 可以完整利用单核 CPU 了,那现在的服务器 CPU 已经做到了单颗 96 核 192 线程,该如何利用这么多的 CPU 核心呢?该 Golang 登场了。

Go 语言的协程

为了更好地“直接利用全部 CPU”,Java 诞生了线程池技术,至今还在发光发热;而 Golang 选择釜底抽薪:在语言层面打造一个完善的“超并发”工具:goroutine(协程)。

我之所以将 goroutine 称为“超并发”工具,是因为它是语言提供的一个 线程池+协程 的综合解决方案,并使用 channel 管道思想来传递数据,为使用者提供了一个无需手动管理的高性能并发控制 runtime,可以保证完全榨干每一个 CPU 时间片。

Golang 的协程本质上来讲就是 在一个线程内不断地 goto,就像 DPDK 完全在用户态运行由于避免了上下文切换而大幅提升了网络性能一样,Golang 在线程内主动 goto 也可以轻松将 CPU 利用率顶到 100%,实现硬件资源利用的最大化。

当然,不断地 goto只是一种形象的类比方法,实际上 Golang 的协程技术经历了好几次迭代,具体实现大家可以看灯塔 draveness 的书:《Go 语言设计与实现》¹。

实际上“吃完多核服务器上的每一个 CPU 核心”也是各种新形态 MySQL 兼容数据库的主要价值,这个我们在倒数第二篇文章讨论数据库架构时会进行详细分析。

goroutine 的弱点

就像性能优化的核心是空间换时间、时间换空间一样,goroutine 也不是银弹,也是牺牲了一些东西的。根据我的实践,这个东西就是“极其昂贵的内存同步开销”,而且 goroutine 引发的这个问题比 Java 的线程池内存同步问题严重的多。

一旦你想在单个 Golang 进程内部的海量协程之间做“数据同步”,那你面临的就不只是 CPU 资源浪费那么简单了,你会发现,CPU 依然吃完了,但是并发量还是好低:多线程的内存同步开销已经摧毁了无数根 Java 程序员的头发,goroutine 线程 * 协程 的内存同步性能堪称灾难:

`sync.Map`受害者请高举你们的双手!

那如果我们需要在海量协程之间做实时数据同步该怎么办呢?这个时候,高并发哲学思维又要出动了:找出单点,进行拆分!

等等,好像除了唯一的这个 Golang 进程找不出单点啊?

没错,这个唯一进程申请的这段内存就是单点,想解决这个问题需要出大招:找外援。

Redis 是 Golang 协程最亲密的伙伴,就像 MySQL 之于 PHP

网络栈是一种贯彻了 Linux 一切皆文件思维的优秀工具,此时可以帮上大忙:找另一个单线程性能之王 Redis 打辅助,即可帮助海量协程排队:此时一旦某个协程进入网络 IO 状态,则会立即让出 CPU 时间片:goto 到下一个协程,不浪费 CPU 资源。

当然,你也可以选择自己用 Golang 写一个类似 Redis 的单线程内存数据库,和你的业务进程进行网络通信,也可以解决这个问题。

一旦解决了协程之间内存同步的问题,Golang 就开始胡吃海塞,大杀四方,榨干 CPU 的全部性能潜力。

Java 技术的优势

Java 是一整套基于运行时虚拟机技术的解决方案。总体来看,它选择了“空间换时间”:Java 应用对内存的需求量显著超过其它技术,而经过了这么多年的优化,Java 的“时间性能”在绝大多数场景下都已经做到了无限接近 C++ 的水平。

Java 虽然是虚拟机技术,但它是常驻内存的,并且这个技术非常的灵活。对,你没有看错,Java 技术确实非常灵活。Spring 框架对写代码程序员的约束确实很强,但这是对使用者的繁琐,Java 本身是非常灵活的。

经过了这么多年的发展,Java 其实一直都能跟上时代:JDBC、RMI、反射、JIT、数字签名、JWS、断言、链式异常、泛型、注解、lambda、类型推断等等等等。我们知道,传统的 Java 大多采用线程做并行,但是在今年(2022)它甚至发展出了协程 Fiber!

21 世纪头十年,JVM 在很多公司内都变成了代替虚拟机技术的存在,成为了事实上的“标准服务端运行环境”,以至于诞生了 JPython、JRuby、JPHP 等颇具邪典气质的技术:把动态语言的解释器内置到 JVM 内,再把代码和解释器打包成一个 jar/war,让 JVM 可以直接运行 Python、Ruby、PHP 项目。

这是什么,这就是容器技术啊!

总结一下

PHP 的模型最原始,鲁棒性最强,对垃圾代码的兼容性更强,甚至可以看做一种“半微服务”技术(因为多文件),但是性能也最差。

Node.js 实现了非阻塞 IO,但是只能利用一个 CPU 核心,这导致它的高性能还需要依赖基础架构(进程管理器/虚拟机/k8s)才能够发挥出来。

而 Golang 自带“线程+协程”的超并发解决方案,让只拥有一台笔记本的大学生也能随时对一个网站发起 5000QPS 的 DOS 攻击。让一个没有完善运维团队的小公司程序员,在不依赖并行基础架构的情况下,能够在裸金属服务器上用一个进程直接吃完全部的 CPU 资源,支撑起可观的用户量。

还记得我们的目标吗?一百万 QPS

通过使用 Golang,我们依然使用前面那台双路 E5-2682 V4 64 vCore,在数据库性能足够的情况下,我们可以把单个系统的 QPS 从 5000 提升到 50000,并且可以在裸金属服务器上直接部署,不需要虚拟机/k8s 并发基础设施,甚至都不需要前置一个负载均衡器。

当然,现实中 50000 QPS 的系统几乎必然拥有负载均衡器,即便每个接口只返回 20KB,那网络带宽也已经达到了 976MB/S,即 7.8Gbit,单机带宽都快干到 10G 了,肯定是不会只用单台服务器硬抗的,即使单机性能能达到,那单机也无法保证这么大规模系统的稳定性。这个时候我们就需要负载均衡器的介入,接下来两篇文章我们会详细讨论。