前段时间读者交流群里有人提到了Go默认设置的最大线程数的问题:如果超过10000个G(挂载在M上)阻塞在系统调用中,程序就会挂掉。这是真的,因为Go对运行时创建的线程数有限制,默认情况下是10000个线程。今天我们将讨论与Go线程数相关的问题。Idlethreads相信了解Go的人都对下图所示的GMP模型不陌生。每个P都会有一个操作系统线程M来执行其上的G。在GMP模型中,我们可以通过设置GOMAXPROCS来设置P的最大值。这个值是什么意思?GOMAXPROCS变量限制了可以同时执行用户级Go代码的操作系统线程数。代表Go代码在系统调用中可以阻塞的线程数没有限制;这些不计入GOMAXPROCS限制。这个包的GOMAXPROCS函数查询和更改限制。通过GOMAXPROCS的定义文档可以看出,这个变量只限制了同时执行用户级Go代码的OS系统线程数(通俗地说:一个Go程序最多只能有等于系统线程数到P同时运行)。但是,阻塞在系统调用中的线程不受此限制。有两种类型的系统调用:同步和异步。我们在《Go 网络编程和 TCP 抓包实操》中描述的Go网络编程模型是一个异步系统调用。它使用网络轮询器进行系统调用,并且调度程序防止G在进行这些系统调用时阻塞M。这允许M继续执行其他G而无需创建新的M。但是,如果G即将进行无法异步完成的系统调用怎么办?当网络轮询器不可用时,G进行系统调用将阻塞M。Linux下基于普通文件的系统调用(Linux下的epoll只支持socket,Windows下的iocp可以支持socket和文件)就是一个典型的例子。同步系统调用1如上图所示,运行在M1上的G1想要请求一个同步系统调用。同步系统调用2当发生同步系统调用并阻塞时,调度程序将M1和仍然挂载在其上的G1从P中分离出来,并引入一个新的M2来运行P上的其他G。同步系统调用3当阻塞系统调用由G1结束,G1回到P的LRQ,但是M1变成了空闲线程,不会被回收以供后续重用。问题来了。如果在某个短时间内,Go程序中出现大量短时间无法终止的同步系统调用,线程数会不会继续上升?线程的最大数量是有限的。关于线程数限制的问题,在官方issues#4056:在“runtime:limitnumberofoperatingsystemthreads”中,有过讨论,最后确定线程限制值是10000。这个的主要目的value是限制可以创建无限线程数的Go程序:在程序炸毁操作系统之前杀死程序。当然,Go也暴露了debug.SetMaxThreads()方法,可以让我们修改最大线程值。packagemainimport("os/exec""runtime/debug""time")funcmain(){debug.SetMaxThreads(10)fori:=0;i<20;i++{gofunc(){_,err:=exec.Command("bash","-c","sleep3").Output()iferr!=nil{panic(err)}}()}time.Sleep(time.Second*5)}如程序所示,我们设置最大线程数设置为10,然后通过执行shell命令sleep3模拟同步系统调用过程。那么,执行sleep操作的G和M都会被阻塞。当程序启动的线程数M超过10时,会报如下错误。runtime:programexceeds10-threadlimitfatalerror:threadexhaustion***关于让idlethreadsexitidlethreads的问题在官方issues#14592:"runtime:letidleOSthreadsexit"中已经讨论过了。目前,还没有完美的解决方案。不过本期有人提出用runtime.LockOSThread()方法杀掉线程。我们简单了解一下这个函数的特点:调用LockOSThread函数会将当前G绑定到当前系统线程M上,这个G一直在这个M上执行,并阻止其他G在这个M上执行。G和M会被解除绑定只有在当前G调用UnlockOSThread函数的次数与之前调用LockOSThread的次数相同之后。如果在当前G退出时未调用UnlockOSThread,则该线程将被终止。那么,我们可以利用第三个特性,在G启动时调用LockOSThread来独占一个M。当G没有调用UnlockOSThread就退出了,那么这个M就不会空闲,就会被终止。接下来,我们来看一个例子\n",threadProfile.Count())fori:=0;i<20;i++{gofunc(){_,err:=exec.Command("bash","-c","sleep3").Output()iferr!=nil{panic(err)}}()}time.Sleep(time.Second*5)fmt.Printf("endthreadscounts:%d\n",threadProfile.Count())}bythreadProfile.Count()我们可以实时获取到当前的线程数,那么发生阻塞系统调用后程序中的线程数是多少呢?.在程序中添加一行代码runtime.LockOSThread()codegofunc(){runtime.LockOSThread()//additionallineofcode_,err:=exec.Command("bash","-c","sleep3").Output()iferr!=nil{panic(err)}}()此时程序的执行结果如下:initthreadscounts:5endthreadscounts:11可以看出,因为调用LockOSThread函数的G并没有执行UnlockOSThread函数,G执行后,M也终止。综上所述,在GMP模型中,P和M是一对一的挂载形式,可以通过设置GOMAXPROCS变量来控制并行线程数。当M遇到同步系统调用时,G和M会与P分离。当系统调用完成后,G会重新进入runnable状态,M会空闲。Go目前不会清理空闲线程,它们作为后续需要的复用资源。但是,如果一个Go程序中堆积了大量的空闲线程,既是一种资源浪费,也是对操作系统的一种威胁。因此,Go设置了默认的线程数限制为10000。我们找到了一个使用LockOSThread函数的trik方法,可以用来做一些限制线程数的方案:比如启动一个定期检查线程数的goroutine,并且当发现程序中的线程数超过一定的阈值时,会回收一些空闲的线程。当然,这种方法也有隐患。比如有人在issues#14592中提到:当线程A创建子进程时PdeathSignal:SIGKILL,A变为空闲状态,如果A退出,子进程会收到KILL信号,导致其他问题。当然,在大多数情况下,我们的Go程序是不会遇到空闲线程过多的问题的。如果真的是线程数暴涨的问题,那你应该想想代码逻辑是否合理(为什么你可以在短时间内允许这么多的系统同步调用),能不能做一些这样的处理作为限流。而不是通过SetMaxThreads方法去思考。参考SchedulingInGo:https://www.ardanlabs.com/blog/2018/08/scheduling-in-go-part2.htmlissues#4056:https://github.com/golang/go/issues/4056issues#14592:https://github.com/golang/go/issues/14592
