1 如何使用Goroutine
在函数或方法调用前面加上关键字go,您将会同时运行一个新的Goroutine。例如:
// hi为一个函数
go hi()
2 子协程异常退出的影响
在使用子协程时一定要特别注意保护好每个子协程,确保它们正常安全的运行。因为子协程的异常退出会将异常传播到主协程,直接会导致主协程也跟着挂掉,然后整个程序就崩溃了。
例如:
package main
import (
"fmt"
"time"
)
func main() {
fmt.Println("run in main goroutine")
go func() {
go func() {
defer fmt.Println("异常了")
fmt.Println("run in grand child goroutine")
var ptr *int
*ptr = 0x12345 // 故意制造崩溃
fmt.Println("xx")
go func() {
fmt.Println("run in grand grand child goroutine")
}()
}()
time.Sleep(time.Second * 1) // 确保上面的子协程执行完毕,否则程序是并行的,可能会先打印222 end,会影响测试。
fmt.Println("222 end")
}()
time.Sleep(time.Second * 3)
fmt.Println("main goroutine will quit")
}
结果看到,在含有defer语句的协程崩溃,导致了其父协程、子协程都终止,最终程序崩溃掉。
3 协程异常处理-recover
在前面说过了recover是可以捕捉异常的,所以我们可以使用它进行协程的异常处理。
recover 是一个Go语言的内建函数,可以让进入宕机流程中的 goroutine 恢复过来,recover 仅在延迟函数 defer 中有效。如果当前的 goroutine 陷入panic,调用 recover 可以捕获到 panic 的输入值,并且恢复正常的执行。
// 1.3 协程异常处理-recover
package main
import (
"fmt"
"runtime"
)
// 崩溃时需要传递的上下文信息
type panicContext struct {
function string // 所在函数
}
// 保护方式允许一个函数
func ProtectRun(entry func()) {
// 延迟处理的函数
defer func() {
// 发生宕机时,获取panic传递的上下文并打印
err := recover()
if err != nil {
switch err.(type) {
case runtime.Error: // 运行时错误
fmt.Println("runtime error:", err)
default: // 非运行时错误
fmt.Println("error:", err)
}
} else {
fmt.Println("no error")
}
}()
// 执行函数
entry()
}
func main() {
fmt.Println("运行前")
// 1. 允许一段手动触发的错误,人为制造的错误,代码实际没错。
ProtectRun(func() {
fmt.Println("手动宕机前")
// 使用panic传递上下文
//a := &panicContext{"手动触发panic"}
//panic(a) // 这样传,或者下面直接传都行
panic(&panicContext{"手动触发panic"})
fmt.Println("手动宕机后")
})
// 2. 故意造成空指针访问错误,代码真的有错。
ProtectRun(func() {
fmt.Println("赋值宕机前")
var a *int
*a = 1
fmt.Println("赋值宕机后")
})
fmt.Println("运行后") // 这里看到打印,说明使用了recover函数后,程序出了问题,但是并不会崩溃。
}
结果看到。即使出现代码问题,程序也不会崩溃,而是照常执行。
4 启动百万协程
Go 语言能同时管理上百万的协程,一般启动百万协程,内存大概占用4G-4.8G左右,对应一些内存大的服务器,例如128G,是完全没问题的。
// 1-4 启动百万协程
package main
import (
"fmt"
"runtime"
"time"
)
const N = 1000000
func main() {
fmt.Println("run in main goroutine")
i := 1
for {
go func() {
// 每个开启的协程都死循环,并睡1s防止占用CPU造成电脑卡死。
for {
time.Sleep(time.Second)
}
}()
if i%10000 == 0 {
fmt.Printf("%d goroutine started\n", i)
}
i++
if i == N {
break
}
}
fmt.Println("NumGoroutine:", runtime.NumGoroutine())
time.Sleep(time.Second * 15)
}
在vscode测试,执行前内存占用0.4G,执行完后占用4.8G,即go的百万协程占用4.4G左右。
5 死循环
如果有个别协程死循环了会导致其它协程饥饿得到不运行吗?
答:并不会。
测试代码:
package main
import (
"fmt"
"runtime"
"syscall"
"time"
)
// 获取的是线程ID,不是协程ID
func GetCurrentThreadId() int {
var user32 *syscall.DLL
var GetCurrentThreadId *syscall.Proc
var err error
user32, err = syscall.LoadDLL("Kernel32.dll") // Windows用的
if err != nil {
fmt.Printf("syscall.LoadDLL fail: %v\n", err.Error())
return 0
}
GetCurrentThreadId, err = user32.FindProc("GetCurrentThreadId")
if err != nil {
fmt.Printf("user32.FindProc fail: %v\n", err.Error())
return 0
}
var pid uintptr
pid, _, err = GetCurrentThreadId.Call()
return int(pid)
}
func main() {
//runtime.GOMAXPROCS:该函数的作用是设置当前进程使用的最大cpu数,返回值为上一次调用成功的设置值,首次调用返回的是默认值(例如cpu为4核则返回值为4)。
// runtime.GOMAXPROCS(1) // 设置CPU并发数为1核。
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0)) // 传0代表使用默认值
fmt.Println("run in main goroutine")
n := 5
for i := 0; i < n; i++ {
go func() {
fmt.Println("dead loop goroutine start, threadId:", GetCurrentThreadId())
for {
} // 死循环
fmt.Println("dead loop goroutine stop")
}()
}
go func() {
var count = 0
for {
time.Sleep(time.Second)
count++
fmt.Println("for goroutine running:", count, "threadId:", GetCurrentThreadId())
}
}()
fmt.Println("NumGoroutine: ", runtime.NumGoroutine()) // 打印当前程序正在运行的协程数量,这里是7.因为上面for开了5个,再单独go一个,最后加上main这个主协程就是7个。
var count = 0
for {
time.Sleep(time.Second)
count++
fmt.Println("main goroutine running:", count, "threadId:", GetCurrentThreadId())
}
}
取出部分打印来看,即使有部分协程在进行死循环操作,但是其他协程仍然可以执行。
看到死循环的线程ID可能一样,这是因为同一个线程可以有多个协程。
6 设置线程数
Go的调度器使用了一个叫做GOMAXPROCS的变量来决定会有多少个操作系统的线程同时执行Go的代码。其默认的值是运行机器上的CPU的核心数,所以在一个有8个核心的机器上时,调度器一次会在8个OS线程上去调度GO代码。
package main
import (
"fmt"
"runtime"
)
func main() {
fmt.Println("runtime.NumCPU():", runtime.NumCPU()) // 获取CPU的最大核数
// 读取默认的线程数
fmt.Println(runtime.GOMAXPROCS(0))
// 设置线程数为 10
runtime.GOMAXPROCS(10)
// 读取当前的线程数
fmt.Println(runtime.GOMAXPROCS(0))
// 设置最大cpu数
runtime.GOMAXPROCS(runtime.NumCPU())
fmt.Println(runtime.GOMAXPROCS(0))
}
例如这是我的电脑配置,注意不同人可能会因不同电脑配置,导致打印结果不一样。
7 G-P-M模型
7.1 为什么引入协程?
核心原因为goroutine的轻量级,无论是从进程到线程,还是从线程到协程,其核心都是为了使得我们的调度单元更加轻量级。可以轻易得创建几万几十万的goroutine而不用担心内存耗尽等问题。
其中:
- M Machine :os线程(即操作系统内核提供的线程)。
- G:goroutine,其包含了调度一个协程所需要的堆栈以及instruction pointer(IP指令指针) ,以及其他一些重要的调度信息。
- P Process :M与P的中介,实现m:n 调度模型的关键,M必须拿到P才能对G进行调度,P其实限定了golang调度其的最大并发度。表示一个逻辑处理器一个p绑定一个os线程。
7.2 系统调用
- 调用system call(系统调用)进入内核没有返回之前,为保证调度的并发性,golang 调度器在进入系统调用之前从线程池拿一个线程或者新建一个线程,当前P交给新的线程M1执行。
- G0返回之后,需要找一个可用的P继续运行,如果没有则将其放在全局队列等待调度。M0待G0返回后退出或放回线程池。
7.3 工作流窃取
- 在P队列上的goroutine全部调度完了之后,对应的M首先会尝试从global runqueue中获取goroutine进行调度。如果golbal runqueue中没有goroutine,当前M会从别的M对应P的local runqueue中抢一半的goroutine放入自己的P中进行调度。
简单说就是,当自己的线程的P调度器没有goroutine时,会从其它线程的P调度器中获取goroutine,这就是工作流窃取。
具体要看C代码去了。