相信对 Go 有所了解的人, 对下图所示的 GMP 模型不会陌生, 每个 P 都会有一个操作系统线程 M 来执行其上的 G.
前阵子, 在读者交流群中有人提到 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
来源: http://developer.51cto.com/art/202110/687260.htm