Go中的协程的底层是怎么实现的?
- 协程底层是怎么实现的?
- 什么是协程?
- 协程可以认为是轻量级的线程。与线程相比,创建协程的开销更小。比如说一个Go的应用程序同时运行数千个Go协程是很常见的做法
 
 - GPM 模型是什么?
- GPM分别是什么?
- G(Goroutine):叫做Go协程,每个go关键字都会创建一个协程。 P(Processor):叫做调度器(Go中定义的一个摡念,非CPU),里面包含运行Go代码的必要资源,用来调度 G 和 M 之间的关联关系,其数量可通过 GOMAXPROCS() 来设置,默认为核心数。 M(Machine):叫做工作线程,在Go中称为Machine,数量对应真实的CPU核数(真正干活的对象)。 M必须拥有P才可以执行G中的代码,P含有一个包含多个G的队列,P可以调度G交由M执行。
 
 - Goroutine调度策略是什么?
- 简单简述下线程发展: 1.原始的单线程操作系统: 单核,只能同时执行一个线程,线程按照顺序进行执行,一个执行完了再执行下一个。 存在一些问题: 只能一个线程一个线程执行。 如果线程阻塞了,整个系统都会阻塞 CPU浪费,因为大部分时间都在阻塞,CPU都没有用。
 - 2.多线程操作系统 多线程操作系统通过将时间划分程时间片,每个线程分配时间片来执行,即使是单核cpu也可以实现并行的效果。 线程切换成本:切换需要CPU调用系统调用,资源的拷贝复制,都会浪费很多时间,而且线程越多,越浪费时间 内存消耗:线程也会占用内存。线程越多,越是浪费内存 开发困难:多线程就必须要解决变量资源统一问题(锁)和资源竞争问题,开发变得越来越困难
 - 3.go 协程模型/ GMP 模型 GMP模型把上面的线程分成用户态和内核态两部分,然后内核态就是M,是等于真正执行的CPU,用户态可以创建很多个,这两个是N对M的关系,然后具体应该怎么分配呢,其实是通过调度器P进行分配,这就形成了一个模型。 也就是上面的,具体来说,每个P都会对应一个本地队列,里面保存着G,如果新创建了G,会先放到本地队列(每一个本地队列对应一个P,P通过时间片轮询执行G中的任务,这里你可能会问,这样的话不是也会进行上下文切换吗,不是回到线程上下文的缺点了吗??但是你要直到,G是用户态啊,用户态的上下文切换成本是很小的,所以这就优化了很多)中执行,当所有的本地队列都满了之后,放不下之后会放到全局队列中,全局队列是加锁的(这也是优化之前版本的,因为加锁解锁很消耗资源。)
- 这个P调度器有四个策略
- 复用线程
- work stealing机制 如果一个P上的本地协程队列执行完了,为空了,他会优先偷取其他P的本地队列的G,如果其他全部的本地队列都没有G了,就会去全局队列里面拿取G
 - hand off机制 如果协程G1进行阻塞了,但是P1的本地队列还有协程,肯定不能浪费CPU呀,这时候如果有其他空闲的P,就会触发work stealing机制,如果没有空闲的P了,就会唤醒一个线程/开启一个新的线程M,然后把P1和P1的本地队列都给这个新的线程进行执行。 当G1阻塞结束之后,如果还要运行,就把他添加到其他P上的等待队列,然后把G1阻塞的这个线程进行休眠或者销毁。
 
 - 利用并行限制
- 可以利用 GOMAXPROCES 进行设置最大 P的数量限定,P的个数比如 CPU数/2
 
 - 抢占
- 其实就是P对本地G分配时间片进行轮询等算法执行
 
 - 全局队列
- 将 P 中的本地队列放满了,才会放到全局队列。 只有 steal 偷取策略偷不到了,才回去,全局队列去拿,全局队列是加锁的
 
 
 - 复用线程
 
 - 这个P调度器有四个策略
 
 
 - GPM分别是什么?
 - 协程和线程的区别是什么?
- 内存占用
- 创建一个 goroutine 的栈内存消耗为 2 KB,实际运行过程中,如果栈空间不够用,会自动进行扩容。创建一个 thread 则需要消耗 1 MB 栈内存,而且还需要一个被称为 “a guard page” 的区域用于和其他 thread 的栈空间进行隔离。 对于一个用 Go 构建的 HTTP Server 而言,对到来的每个请求,创建一个 goroutine 用来处理是非常轻松的一件事。而如果用一个使用线程作为并发原语的语言构建的服务,例如 Java 来说,每个请求对应一个线程则太浪费资源了,很快就会出 OOM 错误(OutOfMemoryError)。
 
 - 创建和销毀
- Thread 创建和销毀都会有巨大的消耗,因为要和操作系统打交道,是内核级的,通常解决的办法就是线程池。而 goroutine 因为是由 Go runtime 负责管理的,创建和销毁的消耗非常小,是用户级。
 
 - 上下文切换
- threads 切换时,需要保存各种寄存器 而 goroutines 切换只需保存三个寄存器:Program Counter, Stack Pointer and BP。 一般而言,线程切换会消耗 1000-1500 纳秒,一个纳秒平均可以执行 12-18 条指令。所以由于线程切换,执行指令的条数会减少 12000-18000。 Goroutine 的切换约为 200 ns,相当于 2400-3600 条指令。 因此,goroutines 切换成本比 threads 要小得多。
 
 
 - 内存占用
 - 什么是M:N模型?
- Go runtime 会负责 goroutine 的生老病死,从创建到销毁,都一手包办。Runtime 会在程序启动的时候,创建 M 个线程(CPU 执行调度的单位),之后创建的 N 个 goroutine 都会依附在这 M 个线程上执行。这就是 M:N 模型 在同一时刻,一个线程上只能跑一个 goroutine。当 goroutine 发生阻塞时,runtime 会把当前 goroutine 调度走,让其他 goroutine 来执行。目的就是不让一个线程闲着,榨干 CPU 的每一滴油水。
 
 - 什么是协程泄漏?(Goroutine Leak)
- 协程泄漏是指协程创建之后,长期得不到释放,并且还在不断的创建新的协程,最终导致内存耗尽的现象,常见的场景有如下:
 - 缺少接收器,导致发送阻塞 这个例子中,每执行一次 query,则启动1000个协程向信道 ch 发送数字 0,但只接收了一次,导致 999 个协程被阻塞,不能退出。 func query() int { ch := make(chan int) for i := 0; i < 1000; i++ { go func() { ch <- 0 }() } return <-ch } func main() { for i := 0; i < 4; i++ { query() fmt.Printf(“goroutines: %d\n”, runtime.NumGoroutine()) } } // goroutines: 1001 // goroutines: 2000 // goroutines: 2999 // goroutines: 3998
 - 缺少发送器,导致接收阻塞 同样的,如果启动 1000 个协程接收信道的信息,但信道并不会发送那么多次的信息,也会导致接收协程被阻塞,不能退出
 - 死锁(dead lock) 由于资源争抢等现象会造成死锁,导致两个协程被卡死,不能退出
 
 - 怎么限制Goroutine的数量?
- 可以使用有缓冲channel来进行限制,每次执行的go之前向通道写入值,直到通道满的时候就阻塞了。
- type Glimit struct { n int // 最大并发数量 c chan struct{} // 用于限制并发数量的通道 } func (g *Glimit) Run(f func()) { g.c <- struct{}{} // 通过向通道发送信号来限制并发数量 go func() { f() <-g.c // 执行完毕后从通道接收信号,释放并发数量限制 }() } func New(n int) *Glimit { return &Glimit{ n: n, c: make(chan struct{}, n), // 使用缓冲通道来限制并发数量 } } func main() { number := 10 // 待执行的任务数量 g := New(2) // 创建一个限制最大并发数量为 2 的 Glimit 实例 wg := make(chan struct{}) // 等待所有 goroutine 执行完毕的通道 done := make(chan struct{}) // 所有任务执行完毕的信号通道 for i := 0; i < number; i++ { goFunc := func() { // 在这里进行一些业务逻辑处理 fmt.Printf(“go func: %d\n”, i) time.Sleep(time.Second) // 模拟任务执行的耗时 wg <- struct{}{} // 向等待通道发送信号,表示任务执行完毕 } g.Run(goFunc) // 使用 Glimit 实例限制并发数量并执行 goFunc 函数 } go func() { for i := 0; i < number; i++ { <-wg // 从等待通道接收信号,等待所有任务执行完毕 } done <- struct{}{} // 所有任务执行完毕,向 done 通道发送信号 }() <-done // 等待所有任务执行完毕的信号 }
 
 - 使用sync.WaitGroup限制并发数量,WaitGroup 对象内部有一个计数器,最初从0开始,它有三个方法:Add(), Done(), Wait() 用来控制计数器的数量。下面示例代码中wg.Wati会阻塞代码的运行,直到计数器值为0。
- package main import ( “fmt” “sync” “time” ) type Glimit struct { n int // 最大并发数量 } func New(n int) *Glimit { return &Glimit{ n: n, } } func (g *Glimit) Run(f func()) { g.wg.Add(1) go func() { f() g.wg.Done() }() } func main() { number := 10 // 待执行的任务数量 g := New(2) // 创建一个限制最大并发数量为 2 的 Glimit 实例 wg := sync.WaitGroup{} // 用于等待所有 goroutine 执行完毕的等待组 for i := 0; i < number; i++ { goFunc := func() { // 在这里进行一些业务逻辑处理 fmt.Printf(“go func: %d\n”, i) time.Sleep(time.Second) // 模拟任务执行的耗时 wg.Done() // 减少等待组的计数器 } g.Run(goFunc) // 使用 Glimit 实例限制并发数量并执行 goFunc 函数 } wg.Add(number) // 设置等待组的计数器为任务数量 wg.Wait() // 等待所有 goroutine 执行完毕 }
 
 
 - 可以使用有缓冲channel来进行限制,每次执行的go之前向通道写入值,直到通道满的时候就阻塞了。
 - 怎么查看Goroutine的数量?
- 可以通过http/pprof来进行监听协程的数量,这样的话我们可以通过浏览器就可以进行查看一共有多少个协程了。 查看协程的数量是为了排查协程是否泄漏和运行状况 使用go tool pprof -http=:1248 http:.123132123321查看
 
 - 协程和线程和进程的区别?
- 进程: 进程是具有一定独立功能的程序,进程是系统资源分配和调度的最小单位。每个进程都有自己的独立内存空间,不同进程通过进程间通信来通信。由于进程比较重量,占据独立的内存,所以上下文进程间的切换开销(栈、寄存器、虚拟内存、文件句柄等)比较大,但相对比较稳定安全。 线程: 线程是进程的一个实体,线程是内核态,而且是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。线程间通信主要通过共享内存,上下文切换很快,资源开销相比进程较少,但是相比进程不够稳定容易丢失数据。 协程: 协程是一种用户态的轻量级线程,协程的调度完全是由用户来控制的。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。
 
 - main主协程怎么等待其余协程结束之后再进行一些操作?
- 使用sync.WaitGroup。WaitGroup就是用来等待一组操作完成的。WaitGroup内部实现了一个计数器,用来记录未完成的操作个数。Add()用来添加计数;Done()用来在操作结束时调用,使计数减一;Wait()用来等待所有的操作结束,即计数变为0,该函数会在计数不为0时等待,在计数为0时立即返回。
 
 - go可以限制运行时操作系统的线程数量吗?
- 是可以进行限制的 可以使用环境变量 GOMAXPROCS 或 runtime.GOMAXPROCS(num int) 设置,例如: runtime.GOMAXPROCS(1) // 限制同时执行Go代码的操作系统线程数为 1
 - 但是这些限制的是同时执行用户态go代码的操作系统的线程的数量,但是还有线程阻塞的线程数量呢。对于操作系统线程数量的设置,得看是CPU密集型还是IO密集型,如果是CPU密集型,需要尽量压榨cpu,设置核数+1就可以了,防止上下文切换,如果是IO,可以设置成核数*2.
 
 
 - 什么是协程?
 
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 是小白菜哦!

