Golang Goroutine小结
1. CSP 并发模型
Communication Sequential Processes
Do not communicate by sharing memory; instead, share memory by communicating.
不要通过共享内存来通信,而要通过通信实现内存共享
Go的CSP并发模型,通过goroutine和channel来实现:
-
goroutine: 并发的执行单位
Goroutine是Golang并发的实体,它底层使用协程实现并发,coroutine是一种运行在用户态的用户线程,类似greenthread,具有如下特点:
- 用户空间,避免了内核态和用户态的切换导致的成本
- 可由语言和框架层进行调度
- 更小的栈空间允许创建大量的实例
-
channel: 并发的通信机制
channel被单独创建,可以在进程之间传递,一个实体通过将消息发到channel中,然后又监听这个channel的实体处理,两个实体之间是匿名的,它实现了实体中间的解藕。
2. 协程、线程、进程
-
进程:系统进行资源分配和调度的一个独立单位。每个进程都有自己独立的内存空间,不同进程通过IPC通信。进程上下文切换开销(栈、寄存器、虚拟内存、文件句柄)比较大,但相对比较稳定安全。
-
线程:处理器调度和分配的基本单位。线程是进程内部的一个执行单元,每个进程至少有一个主线程,它无需用户去主动创建,由系统自动创建。线程间通信主要通过共享内存、上下文切换较快,资源开销小,但相比进程不够稳定,容易丢数据
-
协程:用户态轻量级线程,它的调度完全由用户控制。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈。开销小,切换快。
进程、线程、协程的关系和区别:
- 进程:拥有独立的堆和栈,由操作系统调度
- 线程:拥有独立的栈,但共享堆。由操作系统调度(标准线程)
- 协程:和线程一样,拥有独立的栈,共享堆。由程序开发者在代码中显示调度
为什么协程比线程轻量?
- 内存消耗极小:
- goroutine: 4-5KB, 如果栈内存不足,自动扩容
- thread: 1M, 另外还需要一个"a guard page"区域用于与其他线程的栈空间进行隔离
- 创建和销毁损耗资源小:
- goroutine: 由Go runtime负责管理,创建和销毁的消耗非常小,是用户级别的。
- thread: 是内核级的,创建和销毁都会有巨大的消耗,一般通过线程池来缓解
- 切换快:
- goroutine: 不依赖于系统,由golang自己实现的CSP并发模型实现:G-P-M。go协程也叫用户态线程,协程的切换发生在用户态,约为200ns
- thread: 内核对外提供的服务,应用程序可以通过系统调用让内核启动线程。线程在等待IO操作时变成unrunnable状态触发上下文切换,1000~1500ns
3. 并发的实现原理
KSE:Kernel Scheduling Entity, 内核调度实体,即可以被操作系统内核调度器调度的实体对象,它是内核的最小调度单元,也就是内核级线程
三种线程模型:
- 用户级线程模型
- 内核级线程模型
- 两级线程模型(即混合型线程模型)
3.1 用户级线程模型
多个用户态的线程对应一个内核线程,线程的创建、终止、切换或同步等工作必须自身来完成;
优点:线程调度在用户层面完成,不存在CPU在用户态和内核态之间切换,轻量级,对系统资源消耗少
缺点:做不到真正意义上的并发。如果存在某个线程阻塞调用(比如I/O操作),其他线程将被阻塞,整个进程被挂起。因为在用户线程模式下,进程内的线程绑定到CPU是由用户进程调度实现的,内部线程对CPU不可见,即CPU调度的是进程,而非线程
Python协程库gevent,把阻塞的操作重新封装为完成给阻塞模式,在阻塞点上,主动让出自己,并通知或唤醒其他等待的用户线程。
3.2 内核级线程模型
直接调用操作系统的内核线程,所有线程的创建、终止、切换、同步等操作,都由内核完成
优点:简单。直接借助内核的线程和调度器,可以快速实现线程切换,做到真正的并发处理
缺点:直接使用内核区创建、销毁及线程上下文切换和调度,系统资源开销大,影响性能
Java/C++ 线程库
3.3 两级线程模型(即混合型线程模型)
一个进程可与多个内核线程KSE关联,该进程内的多个线程绑定到了不同的KSE上
进程内的线程并不与KSE一一绑定,当某个KSE绑定的线程因阻塞操作被内核调度出CPU时,其关联的进程中的某个线程又会重新与KSE绑定
为什么称为两级?用户调度实现用户线程到KSE的调度,内核调度器实现KSE到CPU上的调度
Golang
4. G-P-M 模型
G-P-M 模型:
- G:Goroutine:独立执行单元。相较于每个OS线程固定分配2M内存的模式,Goroutine的栈采取动态扩容方式,2k ~ 1G(AMD64, AMD32: 256M)。周期性回收内存,收缩栈空间
- 每个Goroutine对应一个G结构体,它存储Goroutine的运行堆栈、状态及任务函数,可重用。
- G并非执行体,每个G需要绑定到P才能被调度执行
- P:Processor: 逻辑处理器,中介
- 对G来说,P相当于CPU,G只有绑定到P才能被调用
- 对M来说,P提供相关的运行环境(Context),如内存分配状态(mcache),任务队列(G)等
- P的数量决定系统最大并行的G的数量 (CPU核数 >= P的数量),用户可通过GOMAXPROCS设置数量,但不能超过256
- M:Machine
- OS线程抽象,真正执行计算的资源,在绑定有效的P后,进入schedule循环
- schedule循环的机制大致从Global队列、P的Local队列及wait队列中获取G,切换到G的执行栈上执行G的函数,调用goexit做清理工作并回到M
- M不保留G的状态
- M的数量不定,由Go Runtime调整,目前默认不超过10K
5. Golang 并发控制模型
- channel
- sync.WaitGroup:
Add(), Done(), Wait()
- Context:
Done(), Err(), Deadline(), Value()
较复杂的并发
6. gorountine调度时机
情形 | 说明 |
---|---|
go | go 创建一个新的 goroutine,Go scheduler 会考虑调度 |
GC | 由于进行 GC 的 goroutine 也需要在 M 上运行,因此肯定会发生调度。当然,Go scheduler 还会做很多其他的调度,例如调度不涉及堆访问的 goroutine 来运行。GC 不管栈上的内存,只会回收堆上的内存 |
系统调用 | 当 goroutine 进行系统调用时,会阻塞 M,所以它会被调度走,同时一个新的 goroutine 会被调度上来 |
内存同步访问 | atomic,mutex,channel 操作等会使 goroutine 阻塞,因此会被调度走。等条件满足后(例如其他 goroutine 解锁了)还会被调度上来继续运行 |
7. goroutine的切换点
- I/O, select
- channel
- 等待锁
- 函数调用 (有时)
- runtime.Gosched()
8. 实例
8.1 go关键字开启新协程
func say(s string) {
for i := 0; i < 5; i ++ {
time.Sleep(100 * time.Millisecond)
fmt.Println(s)
}
}
func main() {
go say("world")
say("hello")
}
8.2 runtime包
runtime.Gosched()
让出时间片
runtime.Goexit()
终止协程
runtime.GOMAXPROCS(N)
指定运行CPU个数
func main() {
go func() {
for i := 0; i < 5; i++ {
fmt.Println("go")
}
}()
for i := 0; i < 2; i++ {
runtime.Gosched() // 让出时间片
fmt.Println("hello")
}
}
// 打印函数属IO操作,自动切换控制权
func auto() {
for i := 0; i < 10; i++ {
go func(i int) {
for {
fmt.Printf("Hello from goroutine %d\n", i)
}
}(i)
}
time.Sleep(time.Millisecond)
}
// 不自动切换控制权
func manual() {
var a [10]int
for i := 0; i < 10; i++ {
go func(i int) { // race condition
for {
a[i]++
runtime.Gosched() // 交出控制权
}
}(i)
}
time.Sleep(time.Millisecond)
fmt.Println(a) // 存在读写抢占
}
// out of range
func outOfRange() {
var a [10]int
for i := 0; i < 10; i++ {
go func() { // race condition
for {
a[i]++
runtime.Gosched() // 交出控制权
}
}()
}
time.Sleep(time.Millisecond)
fmt.Println(a)
}
go run -race goroutine.go # manual()函数存在抢占,race选项可检查到
9. goroutine 泄露
检测 goroutine 泄漏:使用runtime.Stack()在测试代码前后计算goroutine的数量,代码运行完毕会触发gc,如果触发gc后,发现还有goroutine未被回收,那么这个goroutine很可能是被泄漏的
打印堆栈:
当前堆栈
log.Info("stack %s", debug.Stack())
全局堆栈
buf := make([]byte, 1<<16) runtime.Stack(buf, true) log.Info("stack %s", buf)
goroutine 泄漏:一个程序不断地产生新的goroutine,且又不结束它们,会造成泄漏
func main() {
for i := 0; i < 10000; i++ {
go func() {
select {}
}()
}
}