Java线程只能有上千个,而Go的Goroutine能有上百万个

前言

哈喽,大家好,我是asong,我又来做知识分享了。对于做过Java开发的程序员来说,或许会遇到这个问题:java.lang.OutOfMemoryError: Unable to create new native thread。造成这个问题的原因是因为Thread限制导致内存溢出。对于这个问题,我们可以写一个小demo,测试一下这个问题:

/** 
* 功能:Unable to create new native thread 
* 订阅号:Golang梦工厂 
* create by asong on 2020.6.14 
* email:741896420@qq.com 
**/
public class main {   
 public static void main(String[] args) {        
    while (true){            
        new Thread(new Runnable() {                
            @Override               
             public void run() {
                     try {                                               Thread.sleep(10000000);                    
                     } catch(InterruptedException e) { }                }            
                     }).start();        
        }    
    }
}

运行这段代码就会发生错误,在我的电脑上在创建了13000个thread的时候发生了错误。但是我们如果使用Go语言尝试,创建一个Goroutine,并且让他永久的Sleep,Go语言大概可以创建6千万个Groutine。我们可以看到Goroutine可以比thread创建多这么多。这一文,我们就来解析这个问题!!!

什么是线程

Thread可以由以下内容组成:1. 一系列按照线性顺序可以执行的指令(operations);2. 一个逻辑上可以执行的路径。CPUs中的每一个Core个数在同一时刻只能真正并发执行一个Logic thread。如果你的threads个数大于CPU的Core个数,有一部分的Threads就必须要暂停来让其他Threads工作,直到这些threads到达一定的时机才会被恢复继续执行。而暂停和恢复一个线程,至少需要记录两件事情:1. 当前执行的指令位置。亦称为:说当前线程被暂停时,线程正在执行的代码行;2. 还需要一个栈空间。 亦可认为:这个栈空间保存了当前线程的状态。一个栈包含了 local 变量也就是一些指针指向堆内存的变量(这个是对于 Java 来说的,对于 C/C++ 可以存储非指针)。一个进程里面所有的 threads 是共享一个堆内存的。记录了这两件事情,CPU在调度thread的时候,就有了足够的信息,可以暂停一个thread,调度其他thread运行,然后再将暂停的thread恢复,从而继续执行。这些操作对于thread来说通常是完全透明的。从thread的角度去看,他一直都在连续的运行着。thread被取消调度这样的行为可以被观察的唯一办法就是测量后续操作的时间。

jvm使用的操作系统

尽管规范没有要求所有现代的通用 JVM,在我所知道的范围内,当前市面上所有的现代通用目的的 JVM 中的 thread 都是被设计成为了操作系统的thread。下面,我将使用“用户空间 threads" 的概念来指代被语言来调度而不是被操作系统内核调度的 threads。操作系统级别实现的 threads 主要有如下两点限制:首先限制了 threads 的总数量,其次对于语言层面的 thread 和操作系统层面的 thread 进行 1:1 映射的场景,没有支持海量并发的解决方案。

jvm中固定大小的栈

使用操作系统层面的 thread,每一个 thread 都需要耗费静态的大量的内存
第二个使用操作系统层面的 thread 所带来的问题是,每一个 thread 都需要一个固定的栈内存。虽然这个内存大小是可以配置的,但在 64 位的 JVM 环境中,一个 thread 默认使用1MB的栈内存。虽然你可以将默认的栈内存大小改小一点,但是您会权衡内存使用情况, 从而增加堆栈溢出的风险。在你的代码中递归次数越大,越有可能触发栈溢出。如果使用1MB的栈默认值,那么创建1000个 threads ,将使用 1GB 的 RAM ,虽然 RAM 现在很便宜,但是如果要创建一亿个 threads ,就需要T级别的内存。

Golang处理办法:动态大小的栈

Go 语言为了避免是使用过大的栈内存(大部分都是未使用的)导致内存溢出,使用了一个非常聪明的技巧:Go 的栈大小是动态的,随着存储的数据大小增长和收缩。这不是一件简单微小的事情,这个特性经过了好几个版本的迭代开发[4]。很多其他人的关于 Go 语言的文章中都已经做了详细的说明,本文不打算在这里讨论内部的细节。结果就是新建的一个 Goroutine 实际只占用 4KB 的栈空间。一个栈只占用 4KB,1GB 的内存可以创建 250 万个 Goroutine,相对于 Java 一个栈占用 1MB 的内存,这的确是一个很大的提高。

JVM中上下文切换的延迟

使用操作系统的 threads 的最大能力一般在万级别,主要消耗是在上下文切换的延迟。因为 JVM 是使用操作系统的 threads ,也就是说是由操作系统内核进行 threads 的调度。操作系统本身有一个所有正在运行的进程和线程的列表,同时操作系统给它们中的每一个都分配一个“公平”的使用 CPU 的时间片。当内核从一个 thread 切换到另外一个时候,它其实有很多事情需要去做。新线程或进程的运行必须以世界的视角开始,它可以抽象出其他线程在同一 CPU 上运行的事实。这个问题的关键点是上下文的切换大概需要消耗 1-100µ 秒。这个看上去好像不是很耗时,但是在现实中每次平均切换需要消耗10µ秒,如果想让在一秒钟内,所有的 threads 都能被调用到,那么 threads 在一个 core 上最多只能有 10 万个 threads,而事实上这些 threads 自身已经没有任何时间去做自己的有意义的工作了。Go 的行为有何不同:在一个操作系统线程上运行多个 GoroutinesGolang 语言本身有自己的调度策略,允许多个 Goroutines 运行在一个同样的 OS thread 上。既然 Golang 能像内核一样运行代码的上下文切换,这样它就能省下大量的时间来避免从用户态切换到 ring-0 的内核态再切换回来的过程。但是这只是表面上能看到的,事实上为 Go 语言支持 100 万的 goroutines,Go 语言其实还做了更多更复杂的事情。即使 JVM 把 threads 带到了用户空间,它依然无法支持百万级别的 threads ,想象下在你的新的系统中,在 thread 间进行切换只需要耗费100 纳秒,即使只做上下文切换,有也只能使 100 万个 threads 每秒钟做 10 次上下文的切换,更重要的是,你必须要让你的 CPU 满负荷的做这样的事情。支持真正的高并发需要另外一种优化思路:当你知道这个线程能做有用的工作的时候,才去调度这个线程!如果你正在运行多线程,其实无论何时,只有少部分的线程在做有用的工作。Go 语言引入了 channel 的机制来协助这种调度机制。如果一个 goroutine 正在一个空的 channel 上等待,那么调度器就能看到这些,并不再运行这个 goroutine 。同时 Go 语言更进了一步。它把很多个大部分时间空闲的 goroutines 合并到了一个自己的操作系统线程上。这样可以通过一个线程来调度活动的 Goroutine(这个数量小得多),而是数百万大部分状态处于睡眠的 goroutines 被分离出来。这种机制也有助于降低延迟。除非 Java 增加一些语言特性来支持调度可见的功能,否则支持智能调度是不可能实现的。但是你可以自己在“用户态”构建一个运行时的调度器,来调度何时线程可以工作。其实这就是构成Akka这种数百万 actors 并发框架的基础概念。

总结

未来,会有越来越多的从操作系统层面的 thread 模型向轻量级的用户空间级别的 threads 模型迁移发生。从使用角度看,使用高级的并发特性是必须的,也是唯一的需求。这种需求其实并没有增加过多的的复杂度。如果 Go 语言改用操作系统级别的 threads 来替代目前现有的调度和栈空间自增长的机制,其实也就是在 runtime 的代码包中减少数千行的代码。但对于大多数的用户案例上考虑,这是一个更好的的模式。复杂度被语言库的作者做了很好的抽象,这样软件工程师就可以写出高并发的程序了。
今日的分享到此结束了。感谢各位的观看,如果有错误欢迎指出。欢迎关注公众号:Golang梦工厂,后台回复Gin获取2020最新版本Gin中文文档。


发表评论

电子邮件地址不会被公开。 必填项已用*标注