正文
本文通过 Java 和 Golang 在底层原理上的差异,分析了 Java 为什么只能创建数千个线程,而 Golang 可以有数百万的 Goroutines,并在上下文切换、栈大小方面对两者的实现原理进行了剖析。
很多有经验的工程师在使用基于 JVM 的语言时,都会看到这样的错误:
[error] (run-main-0) java.lang.OutOfMemoryError: unable to create native thread:
[error] java.lang.OutOfMemoryError: unable to create native thread:
[error] at java.base/java.lang.Thread.start0(Native Method)
[error] at java.base/java.lang.Thread.start(Thread.java:813)
...
[error] at java.base/java.lang.Thread.run(Thread.java:844)
呃,这是由线程所造成的
OutOfMemory
。在我的笔记本电脑上运行 Linux 操作系统时,仅仅创建 11500 个线程之后,就会出现这个错误。
如果你在 Go 语言上做相同的事情,启动永远处于休眠状态的 Goroutines,那么你会看到非常不同的结果。在我的笔记本电脑上,在我觉得实在乏味无聊之前,我能够创建七千万个 Goroutines。那么,为什么 Goroutines 的数量能够远远超过线程呢?要揭示问题的答案,我们需要一直向下沿着操作系统进行一次往返旅行。这不仅仅是一个学术问题,它对你如何设计软件有现实的影响。在生产环境中,我曾经多次遇到 JVM 线程的限制,有些是因为糟糕的代码泄露线程,有的则是因为工程师没有意识到 JVM
的线程限制。
术语“线程”可以用来描述很多不同的事情。在本文中,我会使用它来代指一个逻辑线程。也就是:按照线性顺序的一系列操作;一个执行的逻辑路径。CPU 的每个核心只能真正并发同时执行一个逻辑线程 [1]。这就带来一个固有的问题:如果线程的数量多于内核的数量,那么有的线程必须要暂停以便于其他的线程来运行工作,当再次轮到自己的执行的时候,会将任务恢复。为了支持暂停和恢复,线程至少需要如下两件事情:
-
某种类型的指令指针。也就是,当我暂停的时候,我正在执行哪行代码?
-
一个栈。也就是,我当前的状态是什么?栈中包含了本地变量以及指向变量所分配的堆的指针。同一个进程中的所有线程共享相同的堆 [2]。
于以上两点,系统在将线程调度到 CPU 上时就有了足够的信息,能够暂停某个线程、允许其他的线程运行,随后再次恢复原来的线程。这种操作通常对线程来说是完全透明的。从线程的角度来说,它是连续运行的。线程能够感知到重新调度的唯一方式是测量连续操作之间的计时 [3]。
回到我们最原始的问题:我们为什么能有这么多的 Goroutines 呢?
尽管并非规范所要求,但是据我所知所有的现代、通用 JVM 都将线程委托给了平台的操作系统线程来处理。在接下来的内容中,我将会使用“用户空间线程(user space thread)”来代指由语言进行调度的线程,而不是内核 /OS 所调度的线程。操作系统实现的线程有两个属性,这两个属性极大地限制了它们可以存在的数量;任何将语言线程和操作系统线程进行 1:1 映射的解决方案都无法支持大规模的并发。
使用操作系统线程将会导致每个线程都有固定的、较大的内存成本
采用操作系统线程的另一个主要问题是每个 OS 线程都有大小固定的栈。尽管这个大小是可以配置的,但是在 64 位的环境中,JVM 会为每个线程分配 1M 的栈。你可以将默认的栈空间设置地更小一些,但是你需要权衡内存的使用,因为这会增加栈溢出的风险。代码中的递归越多,就越有可能出现栈溢出。如果你保持默认值的话,那么 1000 个线程就将使用 1GB 的 RAM。虽然现在 RAM 便宜了很多,但是几乎没有人会为了运行上百万个线程而准备 TB 级别的 RAM。
Golang 采取了一种很聪明的技巧,防止系统因为运行大量的(大多数是未使用的)栈而耗尽内存:Go 的栈是动态分配大小的,随着存储数据的数量而增长和收缩。这并不是一件简单的事情,它的设计经历了多轮的迭代 [4]。我并不打算讲解内部的细节(关于这方面的知识,有很多的博客文章和其他材料进行了详细的阐述),但结论就是每个新建的 Goroutine 只有大约 4KB 的栈。每个栈只有 4KB,那么在一个 1GB 的 RAM 上,我们就可以有 250 万个 Goroutine 了,相对于 Java
中每个线程的 1MB,这是巨大的提升。
从上下文切换的角度来说,使用操作系统线程只能有数万个线程
因为 JVM 使用了操作系统线程,所以依赖操作系统内核来调度它们。操作系统有一个所有正在运行的进程和线程的列表,并试图为它们分配“公平”的 CPU 运行时间 [5]。当内核从一个线程切换至另一个线程时,有很多的工作要做。新运行的线程和进程必须要将其他线程也在同一个 CPU 上运行的事实抽象出去。我不会在这里讨论细节问题,但是如果你对此感兴趣的话,可以阅读更多的材料。这里比较重要的就是,切换上下文要消耗 1 到 100 微秒。这看上去时间并不多,相对现实的情况是每次切换 10 微秒,如果你想要每秒钟内至少调度每个线程一次的话,那么每个核心上只能运行大约
10 万个线程。这实际上还没有给线程时间来执行有用的工作。