支持高并发编程模式是 Go 的一大特色。其中,Goroutine(协程)是 Go 中最基本的执行单元。每个 Go 程序至少包含一个 主 Goroutine。
并发与并行区别:
-
并发(concurrency):指在同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,使得在宏观上具有多个进程同时执行的效果,但在微观上并不是同时执行的,只是把时间分成若干段,使多个进程快速交替的执行。
-
并行(parallel):指在同一时刻,有多条指令在多个处理器上同时执行。所以无论从微观还是从宏观来看,二者都是一起执行的。
协程 Goroutine
Go 语言支持并发,通过 go
关键字来开启 goroutine 。goroutine 是轻量级线程,调度由 Golang 运行时进行管理的。
- 轻量级线程
- 非抢占式多任务处理,由协程主动交出控制权
- 编译器/解释器/虚拟机层面的多任务
- 多个协程可能再一个或多个线程上运行
func main() {
for i := 0; i < 1000; i++ {
// 使用 go 语句开启一个新的运行期线程, 即 goroutine
// 通过新创建的 goroutine 来执行一个函数。
// 同一个程序中的所有 goroutine 共享同一个地址空间。
go func(i int) {
for {
fmt.Printf("Hello from goroutine %d\n", i)
}
}(i)
}
// 延迟程序退出以打印出 go函数执行结果
time.Sleep(time.Millisecond)
}
协程交出控制权:
- IO 调用
- 使用
runtime.Gosched()
方法
go run -race go文件
检测数据访问冲突
主 goroutine
package main
import "fmt"
func main() {
for i := 0; i < 10; i++ {
go func() {
fmt.Println(i)
}()
}
}
// 不会打印任何内容
每一个独立的 Go 程序在运行时也总会有一个主 goroutine。这个主 goroutine 会被自动地启用,并不需要任何操作。
每条go
语句一般携带一个函数调用,被称为go
函数。而主 goroutine 的go
函数就是那个作为程序入口的main
函数。
注意: go
函数真正被执行的时间总会与其go
语句被执行的时间不同。当执行到一条go
语句的时候,Go 语言的运行时系统,会先试图从存放空闲的 G 的队列中获取一个 G(也就是 goroutine),找不到空闲 G 的情况下才会去创建一个新的 G。
找到空闲的 G 之后,Go 语言运行时系统会用这个 G 去包装当前的那个go
函数(或者说该函数中的那些代码),再把这个 G 追加到可运行的 G 的队列中。这类队列中的 G 总是会按照先入先出的顺序,很快地由运行时系统内部的调度器安排运行。
只要go
语句本身执行完毕,Go 程序完全不会等待go
函数的执行,它会立刻去执行后边的语句。这就是所谓的异步并发地执行。
代码中的for
语句会以很快的速度执行完毕。当它执行完毕时,那 10 个包装了go
函数的 goroutine 往往还没有获得运行的机会。一旦主 goroutine 中的代码(也就是main
函数中的那些代码)执行完毕,当前的 Go 程序就会结束运行。
主 goroutine 等待
-
time.Sleep(time.Millisecond * 500)
暂停运行一段时间,直到到达指定的恢复运行时间。 -
使用 通道阻塞 让主 goroutine 进入等待状态。好处是可以预估时间
num := 10 sign := make(chan struct{}, num) for i := 0; i < num; i++ { go func() { fmt.Println(i) sign <- struct{}{} }() } for j := 0; j < num; j++ { <-sign }
类型字面量
struct{}
有些类似于空接口类型interface{}
,它代表了既不包含任何字段也不拥有任何方法的空结构体类型。
控制 goroutine 执行顺序
for i := 0; i < 10; i++ {
go func(i int) {
fmt.Println(i)
}(i)
}
保证每个 goroutine 都可以拿到一个唯一的整数。go
语句被执行时,我们传给go
函数的参数i
会先被求值,如此就得到了当次迭代的序号。之后,无论go
函数会在什么时候执行,这个参数值都不会变。
Go 调度器
Go 语言不但有着独特的并发编程模型,以及用户级线程 goroutine,还拥有强大的用于调度 goroutine、对接系统级线程的调度器。
调度器是 Go 语言运行时系统的重要组成部分,它主要负责统筹调配 Go 并发编程模型中的三个主要元素,即:G(goroutine 的缩写)、P(processor 的缩写)和 M(machine 的缩写)。
Go 需要调度器原因:
- 系统原生线程有很多特性对于Go程序来说都是累赘。
- 基于Go语言模型,系统的调度决定并不一定合理。例如,Go的垃圾回收需要内存处于一致性的状态,这需要所有运行的线程都停止。垃圾回收的时间点是不确定的,如果仅由OS来调度,将会由大量的线程停止工作。
- 单独开发一个Go的调度器能保证内存处于一致性状态。当开始垃圾回收时,运行时只需要为当时正在CPU核上运行的那个线程等待即可,而不是等待所有的线程。
任何函数只需加上 go
就能送给调度器运行。调度器将goroutine分配到工作线程中运行,涉及3种类型的对象:
- G goroutine,拥有自己的栈,程序计数器(instruction counter)和一些关于goroutine调度的信息(如正在阻塞的channel)。每个goroutine都是由一个 G结构 来表示。
- M 工作线程即os线程,M代表 machine,指代的就是系统级线程。
- P 代表processor,表示调度的上下文。一种用来运行go代码的抽象资源,最大数目不能超过GOMAXPROCS,在运行go代码时需要关联一个M
从宏观上说,G 和 M 由于 P 的存在可以呈现出多对多的关系。当一个正在与某个 M 对接并运行着的 G,需要因某个事件(比如等待 I/O 或锁的解除)而暂停运行的时候,调度器总会及时地发现,并把这个 G 与那个 M 分离开,以释放计算资源供那些等待运行的 G 使用。
而当一个 G 需要恢复运行的时候,调度器又会尽快地为它寻找空闲的计算资源(包括 M)并安排运行。另外,当 M 不够用时,调度器会帮我们向操作系统申请新的系统级线程,而当某个 M 已无用时,调度器又会负责把它及时地销毁掉。
M、P 和 G 之间的交互
可以看到,Go运行时存在两种类型的queue: 一种是一个全局的queue(在schedt结构体中,很少用到), 一种是每个P都维护自己的G的queue。
为了运行goroutine, M需要持有上下文P。M会从P的queue弹出一个goroutine并执行。
当你创建一个新的goroutine的时候(go func()
方法),它会被放入P的queue,当M执行了一些G后,如果它的queue为空,它会随机的选择另外一个P,从它的queue中取走一半的G到自己的queue中执行。
当你的goroutine执行阻塞的系统调用的时候(syscall),阻塞的系统调用会中断(intercepted),如果当前有一些G在执行,运行时会把这个线程从P中摘除(detach),然后再创建一个新的操作系统的线程(如果没有空闲的线程可用的话)来服务于这个P。当系统调用继续的时候,这个goroutine被放入到本地运行queue,线程会park
它自己(休眠), 加入到空闲线程中。
goroutine特点
不需要在定义时区分是否是异步函数
调度器在合适的点进行切换
使用 -race
来检测数据访问冲突
goroutine 可能的切换点
- I/O, select
- channel
- 等待锁
- 函数调用(有时)
- runtime.Goshed()
- 只是参考,不能保证切换,不能保证在其他地方不切换
其他语言的协程
- C++: Boost.Coroutine
- Java: 不支持协程
- python:
- 使用 yield 关键字实现协程
- Python 3.5 加入 async defer 对协程的原生支持