通过 3.1 章节的“互联网系统规模发展史”我们可以看出,编程语言的性能从来就不排名第一的因素,不同的业务类型有不同的最佳语言。下面我们逐个分析前面出现过的主流编程语言的优缺点,感受产品形态、业务需求和技术方案的相互追逐,相爱相杀。
PHP 是一种单线程全阻塞语言:在每个 HTTP/FastCGI 请求中,PHP 解释器会启动一个 进程/线程 来运行一段 PHP 代码,在运行的时候,无论是读写磁盘(磁盘 I/O)还是读写数据库(网络 I/O),PHP 线程都会停下来等待:此时并不消耗 CPU 资源,但是 TCP 连接和线程都还在持续等待,所以如果这个请求不结束,那该线程将会一直保持运行,持续消耗着 TCP 连接数资源和内存资源。
由于 PHP 拥有单线程阻塞特性,所以 php-fpm 模式和 Apache 的 mod_php 模式在解释执行 PHP 代码时的性能是一模一样的。在 2 vCore 4G 内存的情况下,200 QPS 的性能极限是无法通过把 Apache 换成 php-fpm 来解决的。它们的主要区别还是在“海量 HTTP 连接的处理能力”上,我们将在后面第 4 章“至关重要的 Web Server 软件”中做详细的阐述。
那 PHP 这种单线程阻塞语言的性能瓶颈应该怎么突破呢?Node.js 登场了。
在 PHP 这样的阻塞式语言中,所有的 I/O 操作都是需要停下来等待的,例如磁盘 I/O,数据库网络 I/O 等,而真正用于计算的 CPU 资源反而大多数时候都在浪费:大部分 API 都不存在复杂的数据转换,时间其实主要花在了和各种数据库的通信上。这个世界上绝大多数语言都是阻塞式运行的,因为这样做虽然性能不高,但却最符合人类大脑的习惯,编码也更加容易。在 Go 语言出现前的时代,高并发问题大多是用多核+多进程/多线程来解决的。
Ryan Dahl 敏锐地发现了 I/O 浪费时间这个问题,并且挑选了一个为浏览器创造的单线程语言 JavaScript 来实现他的抱负:将所有 I/O 操作全部异步化,并利用 js 的单线程排队特性,创造了一种高性能且稳定的后端技术——Node.js。
不过,计算机的世界没有银弹,Node.js 虽然 I/O 性能强,但是代码编写起来却更加地困难:开发者需要额外付出一些异步编程的思考时间,Debug 也更加麻烦。
Node.js 是一种非常神奇的单线程异步非阻塞架构,以 Google V8 引擎作为 JavaScript 解释器,利用事件驱动加非阻塞 I/O 技术,大幅提升了单机能够处理的 QPS 极限,而它“只是完整利用了单核 CPU”而已。
此外,Node.js 还具备一个 Nginx 的优势:可以单机处理海量用户的 TCP 连接。
Node.js 可以完整利用单核 CPU 了,那现在的服务器 CPU 已经做到了单颗 192 核 384 线程,该如何利用这么多的 CPU 核心呢?该 Go 语言登场了。
为了更好地“直接利用全部 CPU”,Java 诞生了线程池技术,至今还在发光发热。而 Go 选择釜底抽薪:在语言层面打造一个完善的“超并发”工具:Goroutine(协程)。
笔者之所以将 Goroutine 称为“超并发”工具,是因为它是语言层面提供的一个线程池+协程
的综合解决方案,并使用 Channel 管道思想来传递数据,为使用者提供了一个无需手动管理的高性能“并发控制运行时”(Concurrency Control Runtime),可以保证榨干所有 CPU 核心的每一个时间片。
Go 的协程从技术原理上讲就是“在一个线程内不断地 goto”,就像 DPDK 通过完全在用户态运行而避免了上下文切换从而大幅提升了网络性能一样,Go 在线程内主动 goto 也可以轻松将 CPU 利用率顶到 100%,实现硬件资源利用的最大化。
当然,“不断地 goto”只是一种形象的类比方法,实际上 Golang 的协程技术经历了好几次迭代,具体实现大家可以看“灯塔” draveness 的书:《Go 语言设计与实现》。
此外,“吃完多核服务器上的每一个 CPU 核心”也是各种新形态 MySQL 兼容数据库的主要价值,这个我们在后面章节中讨论数据库架构时再进行详细地分析。
就像性能优化的核心是空间换时间、时间换空间一样,Goroutine 也不是银弹,也是牺牲了一些东西的。根据笔者的实践,这个东西就是“极其昂贵的内存同步开销”,而且 Goroutine 引发的这个问题比 Java 的线程池内存同步问题严重的多。
一旦你想在单个 Go 进程内部的海量协程之间做“数据同步”,那你面临的就不只是 CPU 资源浪费那么简单了,你会发现,CPU 依然吃完了,但是并发量还是好低:多线程的内存同步难题已经摧毁了无数 Java 程序员的头发,而 Goroutine “线程 x 协程”数量的内存同步堪称灾难,如果你用过sync.Map
,笔者相信你一定有切身的体会。
那如果我们就是需要在海量协程之间做实时数据同步该怎么办呢?这个时候,高并发哲学思维又要出动了:找出单点,进行拆分!
等等,好像除了唯一的这个 Go 进程找不出单点啊?
没错,这个唯一的 go 进程申请的这段内存就是单点,想解决这个问题需要出大招:找外援。
就像 MySQL 之于 PHP,MongoDB 之于 Node.js,Redis 就是 Go 协程最亲密的伙伴,是 Go 的最佳拍档。
网络栈是一种贯彻了 Linux 一切皆文件思维的优秀工具,此时可以帮上大忙:找另一个单线程性能之王 Redis 打辅助,就可以帮助海量协程通过排队的方法解决问题:此时一旦某个协程进入网络 I/O 状态,则会立即让出 CPU 时间片,将当前 CPU 核心的指令指针 goto 到下一个协程,不浪费 CPU 资源。
当然,理论上说你也可以选择自己用 Go 写一个类似 Redis 的单线程内存数据库,和你的业务进程进行网络通信,一样可以解决这个问题。
而一旦我们解决了协程之间内存同步的大问题,Go 就可以胡吃海塞,大杀四方,分分钟榨干 192 颗 CPU 核心。
一些使用 Node.js 的读者可能不同意前面“Node.js 是单线程”的观点,确实,自 V12 开始支持的 worker_threads 让 Node.js 拥有了一种事实上的“多线程”能力,其运行架构如图 3-2 所示。
但是,需要明确的是,在逻辑上,整个 Node.js 依然是一个线程安全的单线程逻辑处理器,worker_threads 的出现是为了在 CPU 密集型的业务场景中,利用多核 CPU 来进行并行计算,但不适用于 I/O 密集领域。Node.js 在 I/O 领域的单线程、线程安全及事件驱动特性从未发生任何改变。如果你在 I/O 密集型的业务中使用 worker_threads,可能会得到总执行时间增加的反向效果。
Java 在软件工程层面的优势我们前面已经说过,其实,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 语言开发的软件。
这个思想怎么看起来有点眼熟呢?这不就是容器技术吗!
JVM,全称 Java Virtual Machine,中文名为 Java 虚拟机,是 Java 平台的核心组件。它负责执行 Java 字节码,将字节码翻译成底层操作系统可以识别的机器指令。
我们平时说的 Java 语言的特点,绝大多数在本质上都是 JVM 技术的特点。
正是因为 JVM 屏蔽了具体操作系统平台相关的信息,抹平了各种操作系统之间的差异,Java 程序才拥有了“一次编写,到处运行”的能力。
JVM 的设计哲学主要体现在以下几个方面:
📙 高并发的哲学原理 《Philosophical Principles of High Concurrency》
Copyright © 2023 吕文翰