很多有过 JVM 相关语言工作经验的程序员或许都遇到过如下问题:
超出 thread 限制导致内存溢出。在作者的笔记本的 linux 上运行,这种情况一般发生在创建了 11500 个左右的 thread 时候。
但如果你用 Go 语言来做类似的尝试,每创建一个 Goroutine ,并让它永久的 Sleep ,你会得到一个完全不同的结果。在作者的笔记本上,在作者等待的不耐烦之前,GO语言创建了大约7千万个 Goroutine 。为什么我们可以创建的 Goroutines 比 thread 多这么多呢?回答这个问题需要回到操作系统层面来进行一次愉快的探索。
1.到底什么是线程(Thread)?
“Thread" 本身其实可以代表很多不同的意义。在这篇文章中,CPU执行任务的最小单元。Thread 由如下内容组成:
-
一系列按照线性顺序可以执行的指令(operations);
-
一个逻辑上可以执行的路径。CPUs 中的每一个 Core 在同一时刻只能真正并发执行一个线程。
这就产生了一个结论:如果你的 threads 个数大于 CPU 的 Core 个数的话,有一部分的 Threads 就必须要暂停来让其他 Threads 工作,直到这些 Threads 到达一定的时机时才会被恢复继续执行。
而暂停和恢复一个线程,至少需要记录两件事情(线程切换开销):
-
记录当前执行的指令位置。亦称为:说当前线程被暂停时,线程正在执行的代码行;
-
还需要一个栈空间。 亦可认为:这个栈空间保存了当前线程的状态。一个栈包含了 local 变量也就是一些指针指向堆内存的变量(这个是对于 Java 来说的,对于 C/C++ 可以存储非指针)。一个进程里面所有的 threads 是共享一个堆内存的[2]。
有了上面两样东西后,cpu 在调度 thread 的时候,就有了足够的信息,可以暂停一个 thread,调度其他 thread 运行,然后再将暂停的 thread 恢复,从而继续执行。这些操作对于 thread 来说通常是完全透明的。
***下面来分析一下 jvm 和 go 中创建线程的不同之处***
2.JVM 中开启的 Thread
JVM 使用的是操作系统的 Thread
尽管规范没有要求,但在我所知道的范围内,当前市面上所有的现代通用目的的 JVM 中的 thread 都是被设计成为了调用操作系统的thread。下面,我将使用“用户空间 threads" 的来指代被go语言来直接调度的线程,用于区别jvm这种通过调用操作系统来调度的 threads。操作系统级别实现的 threads 主要有如下两点限制:首先限制了 threads 的总数量,其次对于语言层面的 thread 和操作系统层面的 thread 进行 1:1 映射的场景,没有支持海量并发的解决方案。
JVM 中固定大小的栈
使用操作系统层面的 thread,每一个 thread 都需要耗费静态的大量的内存, 使用操作系统层面的 thread 所带来的问题是,每一个 thread 都需要一个固定的栈内存。虽然这个内存大小是可以配置的,但在 64 位的 JVM 环境中,一个 thread 默认使用1MB的栈内存。虽然你可以将默认的栈内存大小改小一点,但是您会权衡内存使用情况, 从而增加堆栈溢出的风险。在你的代码中递归次数越大,越有可能触发栈溢出。如果使用1MB的栈默认值,那么创建1000个 threads ,将使用 1GB 的 RAM ,虽然 RAM 现在很便宜,但是如果要创建一亿个 threads ,就需要T级别的内存。
在 JVM 中:上下文切换的延迟(高并发瓶颈)
使用操作系统的 threads 的最大能力一般在万级别,主要消耗是在上下文切换的延迟。
因为 JVM 是使用操作系统的 threads ,也就是说是由操作系统内核进行 threads 的调度。操作系统本身有一个所有正在运行的进程和线程的列表,同时操作系统给它们中的每一个都分配一个“公平”的使用 CPU 的时间片。当内核从一个 thread 切换到另外一个时候,它其实有很多事情需要去做(即上文中提到的上下文开销)。
开启过程: 新线程或进程的运行必须以世界的视角开始,它可以抽象出其他线程在同一 CPU 上运行的事实。这个问题的关键点是上下文的切换大概需要消耗 1-100µ 秒。这个看上去好像不是很耗时,但是在现实中每次平均切换需要消耗10µ秒,如果想让在一秒钟内,所有的 threads 都能被调用到,那么 threads 在一个 CPU 上最多只能有 10 万个 threads开启,而事实上这些 threads 自身已经没有任何时间去做自己的有意义的工作了。
上下文切换: 即使 JVM 把 threads 带到了用户空间,它依然无法支持百万级别的 threads ,想象下在你的新的系统中,在 thread 间进行上下文切换只需要耗费100 纳秒,即使只做上下文切换,有也只能使 100 万个 threads 每秒钟做 10 次上下文的切换,更重要的是,你必须要让你的 CPU 满负荷的做这样的事情。
3.go 中开启的 Thread
Go 语言的处理办法:动态大小的栈
Go 语言为了避免是使用过大的栈内存导致内存溢出,使用了一个非常聪明的技巧:Go 的栈大小是动态的,随着存储的数据大小增长和收缩。这个特性经过了好几个版本的迭代开发。很多其他人的关于 Go 语言的文章中都已经做了详细的说明,本文不打算在这里讨论内部的细节。结果就是新建的一个 Goroutine 实际只占用 4KB 的栈空间。一个栈只占用 4KB,1GB 的内存可以创建 250 万个 Goroutine,相对于 Java 一个栈占用 1MB 的内存,这的确是一个很大的提高。
Go 的用户空间 threads:在一个操作系统线程(内核 thread)上运行多个 Goroutines
Golang 语言本身有自己的调度策略,允许多个 Goroutines 运行在一个内核 thread 上。既然 Golang 能像内核一样 通过运行代码实现线程的上下文切换,这样它就能省下大量的时间来避免从用户态切换到 ring-0 内核态再切换回来的过程,这节约了大量的开销。但是这只是表面上能看到的,事实上为 Go 语言支持 100 万的 goroutines,Go 语言其实还做了更多更复杂的事情。
支持真正的高并发需要另外一种优化思路:
1. 当你知道这个线程能做有用的工作的时候,才去调度这个线程!如果你正在运行多线程,其实无论何时,只有少部分的线程在做有用的工作。Go 语言引入了 channel 的机制来协助这种调度机制。如果一个 goroutine 正在一个空的 channel 上等待,那么调度器就能看到这些,并不再运行这个 goroutine 。
2. 同时 Go 语言更进了一步。它把很多个大部分时间空闲的 goroutines 合并到了一个自己的操作系统线程上。这样可以通过一个线程来调度活动的 Goroutine(这个数量小得多),而是数百万大部分状态处于睡眠的 goroutines 被分离出来。这种机制也有助于降低延迟。
除非 Java 增加一些语言特性来支持调度可见的功能,否则支持智能调度是不可能实现的。但是你可以自己在“用户态”构建一个运行时的调度器,来调度何时线程可以工作。其实这就是构成Akka这种数百万 actors[6] 并发框架的基础概念。
结语思考
未来,会有越来越多的从操作系统层面的 thread 模型向轻量级的用户空间级别的 threads 模型迁移发生。从使用角度看,使用高级的并发特性是必须的,也是唯一的需求。这种需求其实并没有增加过多的的复杂度。如果 Go 语言改用操作系统级别的 threads 来替代目前现有的调度和栈空间自增长的机制,其实也就是在 runtime 的代码包中减少数千行的代码。但对于大多数的用户案例上考虑,这是一个更好的的模式。复杂度被语言库的作者做了很好的抽象,这样软件工程师就可以写出高并发的程序了。