为什么Goroutine能有上百万个,Java线程却只能有上千个?

作者|Russell Cohen

译者|张卫滨

本文通过 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]。这就带来一个固有的问题:如果线程的数量多于内核的数量,那么有的线程必须要暂停以便于其他的线程来运行工作,当再次轮到自己的执行的时候,会将任务恢复。为了支持暂停和恢复,线程至少需要如下两件事情:

  1. 某种类型的指令指针。也就是,当我暂停的时候,我正在执行哪行代码?
  2. 一个栈。也就是,我当前的状态是什么?栈中包含了本地变量以及指向变量所分配的堆的指针。同一个进程中的所有线程共享相同的堆 [2]。

于以上两点,系统在将线程调度到 CPU 上时就有了足够的信息,能够暂停某个线程、允许其他的线程运行,随后再次恢复原来的线程。这种操作通常对线程来说是完全透明的。从线程的角度来说,它是连续运行的。线程能够感知到重新调度的唯一方式是测量连续操作之间的计时 [3]。

回到我们最原始的问题:我们为什么能有这么多的 Goroutines 呢?

JVM 使用操作系统线程

尽管并非规范所要求,但是据我所知所有的现代、通用 JVM 都将线程委托给了平台的操作系统线程来处理。在接下来的内容中,我将会使用“用户空间线程(user space thread)”来代指由语言进行调度的线程,而不是内核 /OS 所调度的线程。操作系统实现的线程有两个属性,这两个属性极大地限制了它们可以存在的数量;任何将语言线程和操作系统线程进行 1:1 映射的解决方案都无法支持大规模的并发。

在 JVM 中,固定大小的栈

使用操作系统线程将会导致每个线程都有固定的、较大的内存成本

采用操作系统线程的另一个主要问题是每个 OS 线程都有大小固定的栈。尽管这个大小是可以配置的,但是在 64 位的环境中,JVM 会为每个线程分配 1M 的栈。你可以将默认的栈空间设置地更小一些,但是你需要权衡内存的使用,因为这会增加栈溢出的风险。代码中的递归越多,就越有可能出现栈溢出。如果你保持默认值的话,那么 1000 个线程就将使用 1GB 的 RAM。虽然现在 RAM 便宜了很多,但是几乎没有人会为了运行上百万个线程而准备 TB 级别的 RAM。

Go 的行为有何不同:动态大小的栈

Golang 采取了一种很聪明的技巧,防止系统因为运行大量的(大多数是未使用的)栈而耗尽内存:Go 的栈是动态分配大小的,随着存储数据的数量而增长和收缩。这并不是一件简单的事情,它的设计经历了多轮的迭代 [4]。我并不打算讲解内部的细节(关于这方面的知识,有很多的博客文章和其他材料进行了详细的阐述),但结论就是每个新建的 Goroutine 只有大约 4KB 的栈。每个栈只有 4KB,那么在一个 1GB 的 RAM 上,我们就可以有 250 万个 Goroutine 了,相对于 Java 中每个线程的 1MB,这是巨大的提升。

在 JVM 中:上下文切换的延迟

从上下文切换的角度来说,使用操作系统线程只能有数万个线程

因为 JVM 使用了操作系统线程,所以依赖操作系统内核来调度它们。操作系统有一个所有正在运行的进程和线程的列表,并试图为它们分配“公平”的 CPU 运行时间 [5]。当内核从一个线程切换至另一个线程时,有很多的工作要做。新运行的线程和进程必须要将其他线程也在同一个 CPU 上运行的事实抽象出去。我不会在这里讨论细节问题,但是如果你对此感兴趣的话,可以阅读更多的材料。这里比较重要的就是,切换上下文要消耗 1 到 100 微秒。这看上去时间并不多,相对现实的情况是每次切换 10 微秒,如果你想要每秒钟内至少调度每个线程一次的话,那么每个核心上只能运行大约 10 万个线程。这实际上还没有给线程时间来执行有用的工作。

Go 的行为有何不同:在一个操作系统线程上运行多个 Goroutines

Golang 实现了自己的调度器,允许众多的 Goroutines 运行在相同的 OS 线程上。就算 Go 会运行与内核相同的上下文切换,但是它能够避免切换至 ring-0 以运行内核,然后再切换回来,这样就会节省大量的时间。但是,这只是纸面上的分析。为了支持上百万的 Goroutines,Go 需要完成更复杂的事情。

即便 JVM 将线程放到用户空间,它也无法支持上百万的线程。假设在按照这样新设计系统中,新线程之间的切换只需要 100 纳秒。即便你所做的只是上下文切换,如果你想要每秒钟调度每个线程十次的话,你也只能运行大约 100 万个线程。更重要的是,为了完成这一点,我们需要最大限度地利用 CPU。要支持真正的大并发需要另外一项优化:当你知道线程能够做有用的工作时,才去调度它。如果你运行大量线程的话,其实只有少量的线程会执行有用的工作。Go 通过集成通道(channel)和调度器(scheduler)来实现这一点。如果某个 Goroutine 在一个空的通道上等待,那么调度器会看到这一点并且不会运行该 Goroutine。Go 更近一步,将大多数空闲的线程都放到它的操作系统线程上。通过这种方式,活跃的 Goroutine(预期数量会少得多)会在同一个线程上调度执行,而数以百万计的大多数休眠的 Goroutine 会单独处理。这样有助于降低延迟。

除非 Java 增加语言特性,允许调度器进行观察,否则的话,是不可能支持智能调度的。但是,你可以在“用户空间”中构建运行时调度器,它能够感知线程何时能够执行工作。这构成了像 Akka 这种类型的框架的基础,它能够支持上百万的 Actor[6].

结论

操作系统线程模型与轻量级、用户空间的线程模型之间的转换在不断发生,未来可能还会继续 [7]。对于高度并发的用户场景来说,这是唯一的选择。然而,它具有相当的复杂性。如果 Go 选择采用 OS 线程而不是采用自己的调度器和递增的栈模式的话,那么他们能够在运行时中减少数千行的代码。对于很多用户场景来说,这确实是更好的模型。复杂性可以被语言和库的编写者抽象出去,这样软件工程师就能编写大量并发的程序了。

补充材料

  1. 超线程会将核心的效果加倍。指令流(instruction pipelining)也能增加 CPU 的并行效果。但是就当前来说,它还是 O(numCores)。
  2. 可能在有些特殊场景中,这种说法是不正确的,我想肯定有人会提醒我这一点。
  3. 这实际上是一种攻击。JavaScript 可以检测键盘中断所导致的在计时上的细微差别。恶意的站点用它来监听计时,而不是监听击键。参见:https://mlq.me/download/keystroke_js.pdf。
  4. Golang 首先采用了一个分段的栈模型,在这个模型中,栈实际上会扩展至单独的内存区域,这个过程中使用非常聪明的记录功能进行跟踪。随后的实现在特定的场景下提升了性能,使用连续的栈来取代对栈的拆分,这很像对 hashtable 重新调整大小,分配一个新的更大的栈,并通过一些非常有技巧的指针操作,所有的内容都能够仔细地复制到新的、更大的栈中。
  5. 线程可以通过调用nice(参见man nice)来标记优先级,从而能够更好地控制它们调度的频率。
  6. Actor 通过支持大规模并发,为 Scala/Java 实现了与 Goroutines 相同目的的特性。与 Goroutines 类似,Actor 调度器能够看到哪个 Actor 的收件箱中有消息,从而只运行那些能够执行真正有用工作的 Actor。我们所能拥有的 Actor 的数量甚至还能超过 Goroutines,因为 Actor 并不需要栈。但是,这也意味着,如果 Actor 无法快速处理消息的话,调度器将会阻塞(因为 Actor 没有自己的栈,所以它无法在 Actor 处理消息的过程中暂停)。阻塞的调度器意味着消息不能进行处理,系统很快会出现问题。这就是一种权衡。
  7. 在 Apache 中,每个请求都是由一个 OS 线程来处理的,这限制了 Apache 只能有效处理数千个并发连接。Nginx 选择了另外一种模型,一个 OS 线程能够应对上百个甚至上千的并发连接,从而允许更高程度的并发。Erlang 使用了一个类似的模型,它允许数百万 Actor 并发执行。Gevent 为 Python 带来了 greenlet(用户空间线程),它能够实现比以往更高程度的并发性 (Python 线程是 OS 线程)。

原文链接:

https://rcoh.me/posts/why-you-can-have-a-million-go-routines-but-only-1000-java-threads

课程推荐

很多人都听过持续交付能提高效率,但要说实施得多好、多彻底,估计很多人会面面相觑,我将结合我的个人多年实践经验与你分享如何设计、实施以及落地。

限时优惠 45 元,最后两天!

原文地址:https://www.cnblogs.com/williamjie/p/9466404.html

时间: 2024-10-07 14:30:11

为什么Goroutine能有上百万个,Java线程却只能有上千个?的相关文章

java线程 在其他对象上同步、线程本地存储ThreadLocal:thinking in java4 21.3.6

package org.rui.thread.concurrency; /** * 在其他对象上同步 * synchronized 块必须给定一个在其上进行同步的对象,并且最合理的方式是,使用其方法正在被调用的当前对象 * :synchronized(this), 在 这种方式中,如果获得了synchronized块上的锁, * 那么该对象其他的synchronized方法和临界区就不能被调用了. * 因此,如果在this上同步,临界区的效果就会直接缩小在同步的范围内. * * 有时必须在另一个

Java进阶学习第二十二天——上传与下载

文档版本 开发工具 测试平台 工程名字 日期 作者 备注 V1.0 2016.06.05 lutianfei none Servlet规范中 servlet有初始化参数 Filter有初始化参数 Listener没有初始化参数,要使用,在开发中一般使用<context-param> servletContext的初始化参数. 文件上传 问题:什么是文件上传?为什么使用文件上传? 就是将客户端资源,通过网络传递到服务器端. 就是因为数据比较大,我们必须通过文件上传才可以完成将数据保存到服务器端操

Java线程中的同步

1.对象与锁 每一个Object类及其子类的实例都拥有一个锁.其中,标量类型int,float等不是对象类型,但是标量类型可以通过其包装类来作为锁.单独的成员变量是不能被标明为同步的.锁只能用在使用了这些变量的方法上.成员变量可以被声明为volatile,这种方式会影响该变量的原子性,可见性以及排序性.类似的,持有标量变量元素的数组对象拥有锁,但是其中的标量元素却不拥有锁.(也就是说,没有办法将数组成员声明为volatile类型的).如果锁住了一个数组并不代表其数组成员都可以被原子的锁定.也没有

剑指Offer面试题23(Java版):从上往下打印二叉树

题目:从上往下打印二叉树的每个结点,同一层的结点按照从左到右的顺序打印.例如输入下图的二叉树,则一次打印出8,6,10,5,7,9,11. 这道题实质上考察的就是树的遍历算法,只是这种遍历不是我们熟悉的前序.中序或者后序遍历.由于我们不太熟悉这种按层遍历的方法,可能已下载也想不清楚遍历的过程. 因为按层打印的顺序决定应该先打印的根节点,所以我们从树的根节点开始分析.为了接下来能够打印8的结点的两个子节点,我们应该在遍历到该结点时把值为6和10的两个结点保存到一个容器中,现在容器内就有两个结点了.

我们的APP海外推广之路,让下载量从几百个到上百万

摘要:绝对的真实经历,谈谈我们团队开发的APP是如何在海外进行推广,如何让下载量从几百个到后面的上百万.因为我们的预算有限,所以是不可能投大把大把的钱去推广,最后我们选择了自己花时间再加上很少的投入这种方式达到了不俗的效果! 开发经历我在这儿就不多说了,不同的团队有不同的创意,最后的成果也不同,当然开发的过程也不同,唯一相同的可能是都尝遍了酸甜苦辣.经历过大半年的时间,我们终于完成了自己的产品,又经过一段时间的审核,我们的APP最后成功上线了(这儿我就不说名字了,不然又要说我推广自己的产品了),

JAVA模拟HTTP post请求上传文件

在开发中,我们使用的比较多的HTTP请求方式基本上就是GET.POST.其中GET用于从服务器获取数据,POST主要用于向服务器提交一些表单数据,例如文件上传等.而我们在使用HTTP请求时中遇到的比较麻烦的事情就是构造文件上传的HTTP报文格式,这个格式虽说也比较简单,但也比较容易出错.今天我们就一起来学习HTTP POST的报文格式以及通过Java来模拟文件上传的请求. 首先我们来看一个POST的报文请求,然后我们再来详细的分析它. POST报文格式 [plain] view plain co

致邢台市公安局长陈少军、举报杨万利用职务之便诈骗上百万元的举报信

关于江苏省涟水县诈骗犯杨万利用职务之便诈骗公司财物及客户货款,利用离岸账户避税.逃税上百万的举报信 我是河北巨丰橡塑制品有限公司, 诈骗犯杨万在2013年2月起全日制就职于我公司,为我公司外贸部的业务员,通过巨丰公司交费的阿里巴巴国际贸易交易平台联系客户和客户签订购货合同,每月有我公司发放杨万工资及业务提成.杨万,江苏省涟水县大东镇杨元村人,身份证号320826198802190618,手机号:18931927385.18331977879,QQ号:643856136.他为了侵占河北巨丰橡塑制品

致江苏省公安厅长王立科、举报涟水县杨万利用职务之便诈骗上百万元举报信

关于江苏省涟水县诈骗犯杨万利用职务之便诈骗公司财物及客户货款,利用离岸账户避税.逃税上百万的举报信 我是河北巨丰橡塑制品有限公司, 诈骗犯杨万在2013年2月起全日制就职于我公司,为我公司外贸部的业务员,通过巨丰公司交费的阿里巴巴国际贸易交易平台联系客户和客户签订购货合同,每月有我公司发放杨万工资及业务提成.杨万,江苏省涟水县大东镇杨元村人,身份证号320826198802190618,手机号:18931927385.18331977879,QQ号:643856136.他为了侵占河北巨丰橡塑制品

Java -- 在Eclipse上使用Spring

在.NET上用的VS.NET+Spring.net+Nhibernate,到了Java平台上,自然对应着Eclipse+Spring+Hibernate.上一篇文章介绍了如何在Eclipse上使用Hibernate的入门,本文就简单介绍一下如何在Eclipse使用Spring. (1)首先,是下载Spring,可以从sourceforge上下载,http://sourceforge.net/projects/springframework.目前的最新的可以下载 spring-framework-