如何有效控制 Go 线程数?

共 1644字,需浏览 4分钟

 ·

2021-11-06 11:59

前阵子,在读者交流群中有人提到 Go 默认设置的最大线程数的问题:如果超过一万个 G (挂载于 M 上)阻塞于系统调用,那么程序就会被挂掉。

这是对的,因为 Go 对运行时创建的线程数量有一个限制,默认是 10000 个线程。今天我们就来探讨一下 Go 线程数相关的问题。

闲置线程

相信对 Go 有所了解的人,对下图所示的 GMP 模型不会陌生,每个 P 都会有一个操作系统线程 M 来执行其上的 G。

GMP 模型

我们可以通过设置 GOMAXPROCS 来设定 P 的最大值,这个值代表什么含义呢?

The GOMAXPROCS variable limits the number of operating system threads that
can execute user-level Go code simultaneously. There is no limit to the number of threads
that can be blocked in system calls on behalf of Go code; those do not count against
the GOMAXPROCS limit. This package's GOMAXPROCS function queries and changes
the limit.

通过 GOMAXPROCS 的定义文档,我们可以看到该变量只是限制了可以同时执行用户级 Go 代码的 OS 系统线程数量(通俗地讲:Go 程序最多只能有和 P 相等个数的系统线程同时运行)。但是,在系统调用中被阻塞的线程不在此限制之中

对于系统调用,可分为同步和异步两种方式。

我们在《Go 网络编程和 TCP 抓包实操》一文中阐述的 Go 网络编程模型,就是一种异步系统调用。它使用网路轮询器进行系统调用,调度器可以防止 G 在进行这些系统调用时阻塞 M。这可以让 M 继续执行其他的 G,而不需要创建新的 M。

但是,如果 G 要进行的是无法异步完成的系统调用时怎么办?当网络轮询器无法使用时,进行系统调用的 G 将会阻塞 M。在 Linux 下基于普通文件(Linux 下的 epoll 只支持 socket,Windows 下的 iocp 可以支持 socket、file)的系统调用就是一个典型的例子。

同步系统调用 1

如上图所示,运行在 M1 上的 G1 想要请求一个同步系统调用。

同步系统调用 2

当发生同步系统调用并阻塞时,调度器将 M1 和仍然挂载在其之上的 G1 与 P 分离,并引入新的 M2 来运行 P 上的其他 G。

同步系统调用 3

当 G1 进行的阻塞式系统调用结束时,G1 重新回到 P 的 LRQ 中去,但 M1 变成了闲置线程,不会被回收,以留备后续复用

问题来了,如果在某一短时段内,Go 程序存在大量无法短暂结束的同步系统调用,那线程数岂不是会一直涨下去?

最大线程数限制

线程数限制的问题,在官方 issues#4056: "runtime: limit number of operating system threads" 中,有过讨论,并最终将线程限制数值确定为 10000。

这个值存在的主要目的是限制可以创建无限数量线程的 Go 程序:在程序把操作系统干爆之前,干掉程序

当然,Go 也暴露了 debug.SetMaxThreads() 方法可以让我们修改最大线程数值。

package main

import (
 "os/exec"
 "runtime/debug"
 "time"
)

func main() {
 debug.SetMaxThreads(10)
 for i := 0; i < 20; i++ {
  go func() {
   _, err := exec.Command("bash""-c""sleep 3").Output()
   if err != nil {
    panic(err)
   }
  }()
 }
 time.Sleep(time.Second * 5)
}

如程序所示,我们将最大线程数设置为 10,然后通过执行 shell 命令 sleep 3 来模拟同步系统调用过程。那么,执行 sleep 操作的 G 和 M 都会阻塞,当程序启动的线程 M 超过 10 个时,会得到以下报错。

runtime: program exceeds 10-thread limit
fatal error: thread exhaustion
***

让闲置线程退出

闲置线程退出的问题,在官方 issues#14592: "runtime: let idle OS threads exit" 中有过讨论。目前,还没有一个完美的解决方案。

但是,在该 issue 里有人提出使用 runtime.LockOSThread() 方法来杀死线程。

简单了解下这个函数的特性:

  • 调用 LockOSThread 函数会把当前 G 绑定在当前的系统线程 M 上,这个 G 总是在这个 M 上执行,并且阻止其它 G 在该 M 执行。
  • 只有当前 G 调用了与之前调用 LockOSThread 相同次数的 UnlockOSThread 函数之后,G 与 M 才会解绑。
  • 如果当前 G 在退出时,没有调用 UnlockOSThread,这个线程会被终止。

那么,我们可以利用第三个特性,在启动 G 时,调用 LockOSThread 来独占一个 M。当 G 退出时,而不调用 UnlockOSThread,那这个 M 将不会被闲置,就被终止了。

下面,我们来看一个例子

package main

import (
 "fmt"
 "os/exec"
 "runtime/pprof"
 "time"
)

func main() {
 threadProfile := pprof.Lookup("threadcreate")
 fmt.Printf(" init threads counts: %d\n", threadProfile.Count())

 for i := 0; i < 20; i++ {
  go func() {
   _, err := exec.Command("bash""-c""sleep 3").Output()
   if err != nil {
    panic(err)
   }
  }()
 }
 time.Sleep(time.Second * 5)
 fmt.Printf(" end threads counts: %d\n", threadProfile.Count())
}

通过 threadProfile.Count() 我们可以实时获取当前线程数目,那么在发生了阻塞式系统调用后,该程序的线程数目是多少呢?

 init threads counts: 5
 end threads counts: 25

根据结果可以看到,G 执行完毕后,闲置线程并没有被释放。

在程序中添加一行代码 runtime.LockOSThread() 代码

  go func() {
   runtime.LockOSThread() // 增加的一行代码
   _, err := exec.Command("bash""-c""sleep 3").Output()
   if err != nil {
    panic(err)
   }
  }()

此时,程序的执行结果如下

 init threads counts: 5
 end threads counts: 11

可以看到,由于调用了 LockOSThread 函数的 G 没有执行 UnlockOSThread 函数,在 G 执行完毕后,M 也被终止了。

总结

在 GMP 模型中,P 与 M 一对一的挂载形式,通过设定 GOMAXPROCS 变量就能控制并行线程数。

当 M 遇到同步系统调用时,G 和 M 会与 P 剥离,当系统调用完成,G 重新进入可运行状态,而 M 就会被闲置起来。

Go 目前并没有对闲置线程做清除处理,它们被当作复用的资源,以备后续需要。但是,如果在 Go 程序中积累大量空闲线程,这是对资源的一种浪费,同时对操作系统也产生了威胁。因此,Go 设定了 10000 的默认线程数限制。

我们发现了一种利用 LockOSThread 函数的 trik 做法,可以借此做一些限制线程数的方案:例如启动定期排查线程数的 goroutine,当发现程序的线程数超过某阈值后,就回收掉一部分闲置线程。

当然,这个方法也存在隐患。例如在 issues#14592 有人提到:当子进程由一个带有 PdeathSignal: SIGKILL 的 A 线程创建,A 变为空闲时,如果 A 退出,那么子进程将会收到 KILL 信号,从而引起其他问题。

当然,绝大多数情况下,我们的 Go 程序并不会遇到空闲线程数过多的问题。如果真的存在线程数暴涨的问题,那么你应该思考代码逻辑是否合理(为什么你能允许短时间内如此多的系统同步调用),是否可以做一些例如限流之类的处理。而不是想着通过 SetMaxThreads 方法来处理。

参考

Scheduling In Go:https://www.ardanlabs.com/blog/2018/08/scheduling-in-go-part2.html

issues#4056:https://github.com/golang/go/issues/4056

issues#14592:https://github.com/golang/go/issues/14592


 最后,再打个广告吧。

双十一快到了,阿里云也开始搞活动了,刚好我这边可以带大家 白Piao 阿里云的服务器。

说白了就是大家 可以一分钱不花,就可以领到服务器,规格是 2c2g(2vcpu 2G memory) 的机器。

有需要的可以加我下微信,备注『服务器,我统一拉群,带大家一起薅羊毛。


浏览 46
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报