Go语言核心编程第5章 “并发”
Go语言核心编程第5章 “并发”
并行意味着程序在任意时刻都是同时运行的,最简单的并行是多机(硬件和操作系统开发者重点考虑的问题)。
并发意味着程序在单位时间内是同时运行的,强调是给外界的感觉,实质上内部可能是分时操作的。
并发具有过程性,并发在结构。
操作系统可以进行线程和进程的调度,本身具有并发处理能力,但进程切换代价过高。希望在用户层再筑造一级调度,将并发粒度进一步降低。
Go语言在语言层面支持这种并发模式。
Go语言的并发执行体称为goroutine,Go例程。通过关键字go来启动goroutine。
注意:go关键字后面必须跟一个函数,不能是语句或者其他东西,函数的返回值忽略。
通过go加匿名函数形式启动goroutine
package main
import (
"runtime"
"time"
)
func main() {
go func() {
sum := 0
for i := 0; i < 10000; i++ {
sum += i
}
println(sum)
time.Sleep(1 * time.Second)
}()
println("NumGoroutine=",runtime.NumGoroutine())
NumGoroutine()可以返回当前程序的goroutine数目
time.Sleep(5 * time.Second)
main goroutine故意"sleep"5秒,防止其提前终止退出
}
程序结果
NumGoroutine=2
49995000
通过go加有名函数形式启动goroutine
package main
import (
"runtime"
"time"
)
func sum() {
sum := 0
for i := 0; i < 10000; i++ {
sum += i
}
println(sum)
time.Sleep(1 * time.Second)
}
func main() {
go sum()
println("NumGoroutine=",runtime.NumGoroutine())
NumGoroutine()可以返回当前程序的goroutine数目
time.Sleep(5 * time.Second)
main goroutine故意"sleep"5秒,防止其提前终止退出
}
程序结果
NumGoroutine=2
49995000
goroutine有以下特性:
go的执行是非阻塞的,不会等待。
go后面函数的返回值会被忽略。
调度器不能保证多个goroutine的执行次序。
没有父子goroutine的概念,所有的goroutine是平等地被调度和执行的。
Go程序在执行时,会单独给main函数创建一个goroutine,遇到其他go关键字时再去创建其他的goroutine
Go没有暴露goroutine id给用户,所以不能在一个goroutine里面显式地操作另一个goroutine,不过runtime包提供了一些函数访问和设置goroutine的相关信息。
1.func GOMAXPROCS
func GOMAXPROCS(n int) int 用来设置或查询可以并发执行的goroutine数目,
n大于1表示设置GOMAXPROCS值,否则表示查询当前的GOMAXPROCS值。
package main() {
println("GOMAXPROCS=",runtime.GOMAXPROCS(0)) 获取当前的GOMAXPROCS值
runtime.GOMAXPROCS(2) 设置GOMAXPROCS值为2
println("GOMAXPROCS=",runtime.GOMAXPROCS(0)) 获取当前的GOMAXPROCS值
}
2.func Goexit
func Goexit是用来结束当前goroutine的运行,Goexit在结束当前goroutine运行之前会调用当前goroutine已经注册的defer。Goexit并不会产生panic,所以该 goroutine defer里面的recover调用都会返回nil
3.func Gosched
func Gosched()是放弃当前调度执行机会,将当前goroutine放入到队列中等待下次被调度。
chan是Go语言里面的一个关键字,是channel通道的简写。通道是goroutine之间通信和同步的重要组件。
Go的哲学是"不要通过共享内存来通信,而是通过通信来共享内存”,通道是Go通过通信来共享内存的载体。
通道是有类型的,可以简单地把它理解为有类型的管道。
声明一个简单的通道语句是chan dataType。
但简单声明一个通道变量没有意义,并没有初始化,其值是nil。Go语言提供一个内置函数make来创建通道。
创建一个无缓冲的通道,通道存放元素的类型为datatype
make(chan datatype)
创建一个有10个缓冲的通道,通道存放元素的类型为datatype
make(chan datatype,10)
通道分为无缓冲的通道和有缓冲的通道。
Go提供内置函数len和cap。
无缓冲通道len和cap都是0,有缓冲通道len代表未被读取的元素数,cap代表整个通道的容量。
无缓冲的通道既可以用于通信,也可用于两个goroutine的同步,有缓冲的通道主要用于通信。
之前代码中为了避免main goroutine过早退出用了sleep,现在可利用无缓冲通道实现
package main
import "runtime"
func main() {
c := make(chan struct{})
go func(c chan struct{}) {
sum := 0
for i := 0; i < 10000; i++ {
sum += i
}
println(sum)
c <- struct{}{}
}(c)
println("NumGoroutine=", runtime.NumGoroutine())
<-c
}
goroutine运行结束后退出,写到缓冲通道中的数据不会消失,它可以缓冲和适配两个goroutine处理速率不一致的情况,缓冲通道和消息队列类似,有削峰和增大吞吐量的功能。
package main
import "runtime"
func main() {
c := make(chan struct{})
ci := make(chan int, 100)
go func(i chan struct{}, j chan int) {
for i := 0; i < 10; i++ {
ci <- i
}
close(ci)
//写通道
c <- struct{}{}
}(c, ci)
println("NumGoroutine=", runtime.NumGoroutine())
//读通道c,通过通道进行同步等待
<-c
//此时ci通道已经关闭,匿名函数启动的goroutine已经退出
println("NumGoroutine=", runtime.NumGoroutine())
//但通道ci还可以继续读取
for v := range ci {
println(v)
}
}
//结果
NumGoroutine= 2
NumGoroutine= 1
0
1
2
3
4
5
6
7
8
9
panic
1.向已经关闭的通道写数据会导致panic,最佳实践是由写入者关闭通道,能最大程度地避免向已经关闭的通道写数据而导致的panic。
2.重复关闭的通道会导致panic。
阻塞
1.向未初始化的通道写数据或读数据都会导致当前goroutine的永久阻塞。
2.向缓冲区已满的通道写入数据会导致goroutine阻塞。
3.通道中没有数据,读取该通道会导致goroutine阻塞。
非阻塞
1.读取已经关闭的通道不会引发阻塞,而是立即返回通道元素类型的零值,可以使用comma,ok语法判断通道是否已经关闭。
2.向有缓冲且没有满的通道读/写不会引发阻塞。
goroutine和chan,一个用于并发,另一个用于通信。没有缓冲的通道具有同步的功能。
除此之外,sync包也提供了多个goroutine同步的机制,主要是通过WaitGroup实现的。
主要数据结构和操作如下:
type WaitGroup struct {
contains filtered or unexported fields
}
添加等待信号
func (wg *WaitGroup) Add(delta int)
释放等待信号
func (wg *WaitGroup) Done()
等待
func (wg *WaitGroup) Wait()
WaitGroup用来等待多个goroutine完成,main goroutine调用Add设置需要等待goroutine的数目,每一个goroutine结束时调用Done(),Wait()被main用来等待所有的goroutine完成。
下面的程序演示如何使用sync.WaitGroup完成多个goroutine之间的协同工作。
package main
import (
"net/http"
"sync"
)
var wg sync.WaitGroup
var urls = []string{
"http://www.baidu.com/",
"http://www.sina.com.cn/",
"http://www.qq.com/",
}
func main() {
for _, url := range urls {
每一个URL启动一个goroutine,同时wg加一
wg.Add(1)
go func(url string) {
当前goroutine结束后给wg计数减一,wg.Done()等价于wg.add(-1)
//defer wg.Add(-1)
defer wg.Done()
发送HTTP get请求并打印HTTP返回码
resp, err := http.Get(url)
if err == nil {
println(resp.Status)
}
}(url)
}
等待所有请求结束
wg.Wait()
}
//结果
200 OK
200 OK
501 Not Implemented
select是类UNIX系统提供的一个多路复用系统API,Go借助多路复用的概念,提供select关键字,用于监听多个通道。当监听的通道没有状态是可读或者可写的,select是阻塞的,只要监听的通道中有一个是可读或者可写的,select就不会阻塞,而是处理就绪通道的分支流程。如果监听的通道有多个可读可写的状态,则select随机选取一个处理。
package main
func main() {
ch := make(chan int, 1)
go func(chan int) {
for {
select {
//0或1的写入是随机的
case ch <- 0;
case ch <- 1;
}
}
}(ch)
for i := 0; i < 10; i++ {
println(<-ch)
}
}
运行结果
0
1
0
0
0
1
1
1
0
1
1
编程中经常遇到“扇入和扇出”两个概念,所谓的扇入是指将多路通道聚合到一条通道中处理,Go语言最简单的扇入就是使用select聚合多条通道服务;所谓的扇出是指将一条通道发散到多条通道中处理,在Go语言里面具体实现就是使用go关键字启动多个goroutine并发处理。
中国有句经典的哲学名句叫“分久必合,合久必分”,软件的设计和开发也遵循同样的哲学思想,扇入就是合,扇出就是分。当生产者的速度很慢时,需要使用扇入技术聚合多个生产者满足消费者,比如很耗时的加密/解密服务;当消费者的速度很慢时,需要使用扇出技术,比如Web服务器并发请求处理。扇入和扇出是Go并发编程中常用的技术。
读取已经关闭的通道不会引起阻塞,也不会导致panic,而是立即返回该通道存储类型的零值。关闭select监听的某个通道能使select立即感知这种通知,然后进行相应的处理,这就是所谓的退出通知机制(close channel to broadcast)。
下面通过一个随机数生成器的示例演示退出通知机制,下游的消费者不需要随机数时,显式地通知生产者停止生产:
package main
import (
"fmt"
"math/rand"
"runtime"
)
func main() {
done := make(chan struct{})
ch := GenerateIntA(done)
fmt.Println(<-ch)
fmt.Println(<-ch)
//发送通知,告诉生产者停止生产
close(done)
fmt.Println(<-ch)
fmt.Println(<-ch)
//次数生产者已经退出
fmt.Println("NumGoroutine=", runtime.NumGoroutine())
}
//GenerateIntA是一个随机数生成器
func GenerateIntA(done chan struct{}) chan int {
ch := make(chan int)
go func() {
Lable:
for {
select {
case ch <- rand.Int():
//增加一路监听,就是对退出通知信号done的监听
case <-done:
break Lable
}
}
//收到通知后关闭通道ch
close(ch)
}()
return ch
}
//结果
5577006791947779410
8674665223082153551
0
0
NumGoroutine= 1
在应用系统编程中,常见场景是调用一个统一的全局的生成器服务,用于生成全局事务号、订单号和随机数等。
最简单的带缓存的生成器
package main
import (
"fmt"
"math/rand"
)
func GenerateIntA() chan int {
ch := make(chan int, 10)
go func() {
for {
ch <- rand.Int()
}
}()
return ch
}
func main() {
ch := GenerateIntA()
fmt.Println(<-ch)
fmt.Println(<-ch)
}
多个goroutine增强型生成器
package main
import (
"fmt"
"math/rand"
)
func GenerateIntA() chan int {
ch := make(chan int, 10)
go func() {
for {
ch <- rand.Int()
}
}()
return ch
}
func GenerateIntB() chan int {
ch := make(chan int, 10)
go func() {
for {
ch <- rand.Int()
}
}()
return ch
}
func GenerateInt() chan int {
ch := make(chan int, 20)
go func() {
for {
select {
case ch <- <-GenerateIntA():
case ch <- <-GenerateIntB():
}
}
}()
return ch
}
func main() {
ch := GenerateInt()
for i := 0; i < 100; i++ {
fmt.Println(<-ch)
}
}
有时希望生成器能自动退出,可以借助Go通道的退出通知机制实现
package main
import (
"fmt"
"math/rand"
)
func GenerateIntA(done chan struct{}) chan int {
ch := make(chan int, 10)
go func() {
Label:
for {
select {
case ch <- rand.Int():
case <-done:
break Label
}
}
close(ch)
}()
return ch
}
func main() {
done := make(chan struct{})
ch := GenerateIntA(done)
fmt.Println(<-ch)
fmt.Println(<-ch)
close(done)
for v := range ch {
fmt.Println(v)
}
fmt.Println(len(ch))
}
一个融合了并发、缓存、退出通知等多重特性的生成器
package main
import (
"fmt"
"math/rand"
)
func GenerateIntA(done chan struct{}) chan int {
ch := make(chan int, 5)
go func() {
Label:
for {
select {
case ch <- rand.Int():
case <-done:
break Label
}
}
close(ch)
}()
return ch
}
func GenerateIntB(done chan struct{}) chan int {
ch := make(chan int, 10)
go func() {
Label:
for {
select {
case ch <- rand.Int():
case <-done:
break Label
}
}
close(ch)
}()
return ch
}
func GenerateInt(done chan struct{}) chan int {
ch := make(chan int)
sendA := make(chan struct{})
sendB := make(chan struct{})
go func() {
Label:
for {
select {
case ch <- <-GenerateIntA(sendA):
case ch <- <-GenerateIntB(sendB):
case <-done:
sendA <- struct{}{}
sendB <- struct{}{}
break Label
}
}
close(ch)
}()
return ch
}
func main() {
done := make(chan struct{})
ch := GenerateInt(done)
for i := 0; i < 10; i++ {
fmt.Println(<-ch)
}
done <- struct{}{}
fmt.Println("stop generate")
}
管道可以分为两个方向,一个是读,一个是写,如果函数的输入参数和输出参数都是相同的chan类型,则该函数可以自己调用自己,最终形成一个调用链。当然多个具有相同参数类型的函数也能组成一个调用链。
package main
import "fmt"
func chain(in chan int) chan int { //将chan内数据加1
out := make(chan int)
go func() {
for v := range in {
out <- 1 + v
}
close(out)
}()
return out
}
func main() {
in := make(chan int)
go func() {
for i := 0; i < 10; i++ {
in <- i
}
close(in)
}()
out := chain(chain(chain(in)))
for v := range out {
fmt.Println(v)
}
}
这种并发模式相对比较简单,就是一个请求或任务就启动一个goroutine去处理,典型的是Go中的HTTP server服务。
Go语言http标准库处理请求的方式
for {
//监听获取连接
rw, err := l.Accept()
if err != nil {
select {
case <-srv.getDoneChan():
return ErrServerClosed
default:
}
if ne, ok := err.(net.Error); ok && ne.Temporary() {
if tempDelay == 0 {
tempDelay = 5 * time.Millisecond
} else {
tempDelay *= 2
}
if max := 1 * time.Second; tempDelay > max {
tempDelay = max
}
srv.logf("http: Accept error: %v; retrying in %v", err, tempDelay)
time.Sleep(tempDelay)
continue
}
return err
}
connCtx := ctx
if cc := srv.ConnContext; cc != nil {
connCtx = cc(connCtx, rw)
if connCtx == nil {
panic("ConnContext returned nil")
}
}
tempDelay = 0
c := srv.newConn(rw)
c.setState(c.rwc, StateNew, runHooks) // before Serve can return
//启动 1 个独立的 goroutine 处理该 Web 请求
go c.serve(connCtx)
}
下面以计算100个自然数的和来举例,将计算任务拆分为多个task,每个task启动一个goroutine进行处理。
package main
import (
"fmt"
"sync"
)
//工作任务
type task struct {
begin int
end int
result chan<- int //接收类型的通道
}
//任务执行:计算begin到end的和
//结果写入到chan result
func (t *task) do() {
sum := 0
for i := t.begin; i <= t.end; i++ {
sum += i
}
t.result <- sum
}
//构建task并进入task通道
func InitTask(taskchan chan<- task, r chan int, p int) {
//计算p个自然数之和
qu := p / 10 //10个数为1个区
mod := p % 10
high := qu * 10
for j := 0; j < qu; j++ {
// 1 - 10
// 11 - 20
b := 10*j + 1
e := 10 * (j + 1)
tsk := task{
begin: b,
end: e,
result: r,
}
taskchan <- tsk
}
if mod != 0 {
tsk := task{
begin: high + 1,
end: p,
result: r,
}
taskchan <- tsk
}
close(taskchan)
}
//读取task chan,每个task启动一个worker goroutine进行处理
//并等待每个task运行完,关闭结果通道
func DistributeTask(taskchan <-chan task, wait *sync.WaitGroup, result chan int) {
for v := range taskchan {
wait.Add(1)
go ProcessTask(v, wait)
}
wait.Wait()
close(result)
}
//goroutine处理具体工作,并将处理结果发送到结果通道
func ProcessTask(t task, wait *sync.WaitGroup) {
t.do()
wait.Done()
}
func ProcessResult(resultchan chan int) int {
sum := 0
for r := range resultchan {
sum += r
}
return sum
}
func main() {
//创建任务通道
taskchan := make(chan task, 10)
//创建结果通道
resultchan := make(chan int, 10)
//wait用于同步等待任务的执行
wait := &sync.WaitGroup{}
//初始化task的goroutine,计算100个自然数之和
go InitTask(taskchan, resultchan, 100)
//每个task启动一个goroutine进行处理
go DistributeTask(taskchan, wait, resultchan)
//通过结果通道获取结果并汇总
sum := ProcessResult(resultchan)
fmt.Println("sum=", sum)
}
程序逻辑分析:
(1) InitTask构建task并发送到task通道中。
(2) 函数DistributeTask为每个task启动一个goroutine处理任务,等待其处理完成,然后关闭结果通道。
(3) ProcessResult函数读取并统计所有结果。
服务器编程中使用最多的就是通过线程池来提升服务的并发能力。以计算多个整数的和为例。
程序中除了主要的 main goroutine ,还开启了如下几类 goroutine:
( 1 )初始化任务的 goroutme
( 2 )分发任务的 goroutine
( 3 )等待所有 worker 结束通知,然后关闭结果通道的 goroutine
main 函数负责拉起上述 goroutine ,并从结果通道获取最终的结果。
程序采用三个通道,分别是
( 1 )传递 task 任务的通道
( 2 )传递 task 结果的通道。
( 3 )接收 worker 处理完任务后所发送通知的通道。
package main
import (
"fmt"
)
const (
NUMBER = 10
)
//工作任务
type task struct {
begin int
end int
result chan<- int //接收类型的通道
}
//任务执行:计算begin到end的和
//结果写入到chan result
func (t *task) do() {
sum := 0
for i := t.begin; i <= t.end; i++ {
sum += i
}
t.result <- sum
}
//构建task并进入task通道
func InitTask(taskchan chan<- task, r chan int, p int) {
//计算p个自然数之和
qu := p / 10 //10个数为1个区
mod := p % 10
high := qu * 10
for j := 0; j < qu; j++ {
// 1 - 10
// 11 - 20
b := 10*j + 1
e := 10 * (j + 1)
tsk := task{
begin: b,
end: e,
result: r,
}
taskchan <- tsk
}
if mod != 0 {
tsk := task{
begin: high + 1,
end: p,
result: r,
}
taskchan <- tsk
}
close(taskchan)
}
//读取task chan,每个task启动一个worker goroutine进行处理
//并等待每个task运行完,关闭结果通道
func DistributeTask(taskchan <-chan task, workers int, done chan struct{}) {
for i := 0; i < workers; i++ {
go ProcessTask(taskchan, done)
}
}
//goroutine处理具体工作,并将处理结果发送到结果通道
func ProcessTask(taskchan <-chan task, done chan struct{}) {
for t := range taskchan {
t.do()
}
done <- struct{}{}
}
func ProcessResult(resultchan chan int) int {
sum := 0
for r := range resultchan {
sum += r
}
return sum
}
//通过done channel同步等待所有工作的goroutine结束,然后关闭结果chan
func CloseResult(done chan struct{}, resultchan chan int, workers int) {
for i := 0; i < workers; i++ {
<-done
}
close(done)
close(resultchan)
}
func main() {
workers := NUMBER
//创建任务通道
taskchan := make(chan task, 10)
//创建结果通道
resultchan := make(chan int, 10)
//worker信号通道
done := make(chan struct{}, 10)
//初始化task的goroutine,计算100个自然数之和
go InitTask(taskchan, resultchan, 100)
//分发任务到NUMBER个goroutine池
go DistributeTask(taskchan, workers, done)
//获取各个goroutine处理完任务的通知,并关闭结果通道
go CloseResult(done, resultchan, workers)
//通过结果通道获取结果并汇总
sum := ProcessResult(resultchan)
fmt.Println("sum=", sum)
}
程序逻辑分析:
(1) 构建task并发送到task通道中。
(2) 分别启动n个工作线程,不停地从通道中获取任务,然后将结果写入结果通道,如果任务通道被关闭,
则负责向收敛结果的 goroutine 发送通知,告诉其当前 worker 已经完成工作。
(3) 收敛结果的goroutine接收到所有task已经处理完毕的信号后,主动关闭结果通道。
(4) main中ProcessResult函数读取并统计所有结果。
编程中经常遇到在一个流程中需要调用多个子调用的情况,这些子调用相互之间没有依赖,
如果串行地调用,则耗时会很长,此时可以使用并发编程中的future模式
future 模式的基本工作原理
( 1 )使用 chan 作为函数参数。
( 2 )启动 goroutine 调用函数。
(3 )通过 chan 传入参数。
(4 )做其他可以并行处理的事情。
(5 )通过 chan 异步获取结果
下面通过一段抽象的代码来学习该模式:
package main
import (
"fmt"
"time"
)
//一个查询结构体
//这里的sql和result一个简单的抽象,具体的应用可能是更复杂的数据类型
type query struct {
//参数Channel
sql chan string
//结果Channel
result chan string
}
//执行Query
func execQuery(q query) {
go func() {
sql := <-q.sql
//访问数据库
//输出结果通道
q.result <- "result from" + sql
}()
}
func main() {
q := query{make(chan string, 1),make(chan string, 1)}
go execQuery(q)
q.sql <- "select * from table"
//做其他事情
time.Sleep(1 * time.Second)
//获取结果
fmt.Println(<-q.result)
}
//执行结果
result from select * from table
future 最大的好处是将函数的同步调用转换为异步调用,适用于一个交易需要多子调用,且这些子调用没有依赖的场景。实际情况可能比上面示例复杂得多,要考虑错误和异常的处理。
future模式工作流程如下:
Go中的goroutine之间无父子关系,没有子进程退出后的通知机制,多个goroutine是平行被调度的。
多个 goroutine 如何协作工作涉及通信、同步、通知和退出四个方面。
( 1 ) 通信: chan 通道当然是 goroutine 之间通信的基础, 注意这里的通信主要是指程序的数据通道。
( 2 ) 同步:不带缓冲的 chan 提供了一个天然的同步等待机制,当然 sync WaitGroup 也为 goroutine 协同工作提供一种同步等待机制。
( 3 ) 通知:这个通知和上面通信的数据不一样,通知通常不是业务数据,而是管理、控制流数据。要处理这个也好办,在输入端绑定两个 chan ,一个用于业务流数据,另一个用于异常通知数据,然后通过 select 收敛进行处理。这个方案可以解决简单的问题,但不是一个通用的解决方案。
( 4 ) 退出:goroutine 之间没有父子关系,如何通知 goroutine 退出?可以通过增加一个单独的通道,借助通道和 select 的广播机制( close channel to broadcast )实现退出。
Go 语言在语法上处理某个 goroutine 通知机制很简单。但是遇到复杂的井发结构处理起来就显得力不从心。
实际编程中 goroutine 会拉起新的 goroutine ,新的 gorotine 又会拉起另一个新的 goroutine ,最终形成一个树状的结构,由于 goroutine 里并没有父子的概念,这个树状结构只是在程序员头脑中抽象出来的,程序的执行模型并没有维护这么一 树状结构 。
怎么通知这个树状上的所有 goroutine 退出?
仅依靠语法层面的支持显然比较难处理。为此 Go 1.7 提供了一个标准库 context 来解决这个问题。
它提供两种功能 :
(1)退出通知
(2)元数据传递
context 设计目的就是跟踪 goroutine 调用,在其内部维护一个调用树,井在这些调用树中传递通知和元数据。
conte 库的设计目的就是跟踪 goroutine 调用树,并在这些 gouroutine 调用树中传递通知和元数据。
两个目的:
( 1 )退出通知机制 一一通知可以传递给整个 goroutine 调用树上的每一个 goroutine。
( 2 )传递数据 一一数据可以传递给整个 goroutine 调用树上的每 goroutine。
在介绍 context 包之前,先理解 context 包的整体工作机制:
第一个创建 Context goroutine 被称为 root 节点。 root 节点负责创建一个实现 context 接口的具体对象,并将该对象作为参数传递到其新拉起的 goroutine,下游的 goroutine 继续封装该对象,再传递到更下游的 goroutine 。
Context 对象在传递的过程中最终形成一个树状的数据结构,这样通过位于 root 节点(树的根节点) Context 对象就能遍历整个 Contex 对象树,通知和消息就可以通过 root 节点传递出去。实现了上游 goroutine 对下游 goroutine 的消息传递。
Context 接口
Contex 是一个基本接口,所有 Context 对象都要实现该接口, context 的使用者在调用接口中都使 Context 作为参数类型。具体分析如下。
type Context interface {
如果 Context 实现了超时控制,则该方法返回 ok true, deadline 为超时时间,否则 ok false
Deadline() (deadline time.Time, ok bool)
后端被调的 goroutine 应该监听该方法返回的 chan , 以便及时释放资源
Done() <-chan struct{}
Done 返回的 chan 收到通知的时候, 才可以访问 Err()获知因为什么原因被取消
Err() error
可以访问上游 goroutine 传递给下游 goroutine 的值
Value(key interface{}) interface{}
canceler 接口
canceler 接口是一个扩展接口,规定了取消通知的 Context 具体类型需要实现的接口。
context 包中的具体类型 *cancelCtx 和*timerCtx 都实现了该接口。示例如下:
//context 对象如采实现了 canceler 接口,则可以被取消
type canceler interface {
//创建 cancel 接口实例的 groutine 调用 cancel 方法通知后续创建的 goroutine 退出
cancel(removeFromParent bool, err error)
//Do 方法返回的 chan 需要后端 goroutine 来监听并及时退出
Done() <-chan struct{}
}
empty Context 结构
emptyCtx 实现了 Context 接口,但不具备任何功能,因为其所有的方法都是空实现。
其存在的目的是作为 Contex 对象树的根( roo 节点)。
因为 contex 包的使用思路就是不停地调用 context 包提供的包装函数来创建具有特殊功能的 Context 实例 ,每一个 Context 实例的创建都以上一个 Context 对象为参数,最终形成一个树状的结构。示例如下:
//emptyCtx 实现了 Context
type emptyCtx int
func (*emptyCtx) Deadline() (deadline time.Time , ok bool) {
return
}
func (*emptyCtx) Done () <-chan struct {} {
return nil
}
func (*emptyCtx) Err() error {
return nil
}
func (*emptyCtx) Value(key nterface{}) interface{} {
return nil
}
func (e *emptyCtx ) String() string {
switch e {
case background :
return "context.Background"
case todo:
return "context.TODO"
return "unknown empty Context"
}
}
package 定义了两个全局变量和两个封装函数,返回两个 emptyCtx 实例对象,实际使用时通过调用这两个封装函数来构造 Context root 节点。示例如下:
var (
background= new(emptyCtx)
todo = new(emptyCtx)
)
func Background() Context {
return background
}
func TODO() Context {
return todo
}
cancelCtx
cancelCtx 是一个实现了 Context 接口的具体类型,同时实现了 conceler 接口。 conceler 具有退出通知方法。注意退出通知机制不但能通知自己,也能逐层通知其 children 节点。示例如下
//cancelCtx 可以被取消, cancelCtx 取消时会同时取消所有实现 canceler 接口的孩子节点
type cancelCtx struct {
Context
done chan struct{} //closed by the first cancel call.
mu sync.Mutex
children map[canceler]bool //set to nil by the first cancel call
err error //set to non - nil by the first cancel call
}
func (c *cancelCtx) Done() <-chan struct {} {
return c.done
}
func (c *cancelCtx) Err() error {
c.mu.Lock()
defer c.mu.Unlock ()
return c.err
}
func (c *cancelCtx) String() string {
return fmt.Sprintf("%v.WithCancel",c.Context)
}
//cancel closes c . done , cancels each of c ’ s children , and, if
//removeFromParent is true , removes c from its parent ’ s children
func (c *cancelCtx) cancel(removeFromParent bool, err error) {
if err == nil {
panic("context: internal error : missing cancel error")
}
c.mu.Lock ()
if c.err != nil {
c.mu.Unlock ()
return //already canceled
}
c.err = err
//显示通知自己
close(c.done)
循环调用 children cancel 函数,由于 parent 已经取消,所以此时 child 调用
cancel 传入的是 false
for child : = range c.children {
//NOTE : acquiring the child ’ s lock while holding parent ’ s lock .
child.cancel(false , err)
c.children = nil
c.mu.Unlock ()
if removeFromParent {
removeChild(c .Context , c)
}
}
timerCtx
timerCtx 是一个实现了 Context 接口的具体类型 ,内部封装了 cancelCtx 类型实例 ,同时有一个 deadline 变量,用来实现定时退出通知。示例如下:
type timerCtx struct {
cancelCtx
timer *time.Timer //Under cancelCtx.mu.
deadline time.Time
}
func (c *timerCtx) Deadline() (deadline time.Time , ok bool) {
return c.deadline, true
}
func (c *timerCtx) String() string {
return fmt.Sprintf("%v.WithDeadline(%s [%s])",c.cancelCtx.Context,c.deadline,c.deadline.Sub(time.Now() ))}
func (c *timerCtx) cancel(removeFromParent bool , err error) {
c.cancelCtx.cancel(false, err)
if removeFromParent {
//Remove this timerCtx from its parent cancelCtx’ s children .
removeChild(c.cancelCtx.Context, c)
}
c.mu.Lock()
if c.timer != nil {
c.timer.Stop()
c.timer = nil
c.mu.Unlock()
}
}
valueCtx
valueCtx 是一个实现了 Context 接口的具体类型,内部封装了 Context 接口类型,同时封装了一个 k/v 的存储变量。 valueCtx 可用来传递通知信息。示例如下:
type valueCtx struct {
Context
key , val interface{}
}
func (c *valueCtx) String() string {
return fmt.Sprintf("%v.WithValue(%#v, %#v)", c.Context, c.key, c.val)
}
func (c *valueCtx) Value(key interface{}) interface{} {
if c.key == key {
return c.val
}
return c.Context.Value(key)
}
下面这两个函数是构造 Context 取消树的根节点对象,根节点对象用作后续 With 包装函数的实参。
func Background() Context
func TODO() Context
With 包装函数用来构建不同功能的 Context 具体对象
( 1 )创建一个带有退出通知的 Context 具体对象,内部创建一个 cancelCtx 类型实例。例如:
func WithCancel (parent Context) (ctx Context, cancel CancelFunc)
( 2 )创建一个带有超时通知的 Context 具体对象 ,内部创建 timerCtx 类型实例。例如:
func WithDeadline (parent Context, deadline time.Time) (Context, CancelFunc)
( 3 )创建一个带有超时通知的 Context 具体对象 ,内部创建 timerCtx 类型实例。例如:
func WithTimeout (parent Context , timeout time.Duration) (Context, CancelFunc)
( 4 )创建一个能够传递数据的 Context 具体对象,内部创建 valueCtx 类型实例。例如:
func WithValue(parent Context , key , val interface{ }) Context
这些函数都有一个共同的特点一parent 参数,其实这就是实现 Context 通知树的必备条件。在 goroutine 的调用链中, Context 实例被逐层地包装并传递,每层又可以对传进来的 Context 实例再封装自己的功能 ,整个调用树需要一个数据结构来维护,这个维护逻辑在这些包装函数内部实现。
package main
import (
"context"
"fmt"
"time"
)
//define a new type include a Context Field
type otherContext struct {
context.Context
}
func work(ctx context.Context , name string) {
for {
select {
case <-ctx.Done():
fmt.Printf ("%s get msg to cancel\n", name)
return
default:
fmt.Printf("%s is running \n ", name)
time.Sleep(l * time.Second)
}
}
}
等待前端的退出通知,并试图获取 Context 传递的数据
func workWithValue(ctx context.Context, name string) {
for {
select {
case <- ctx.Done():
fmt.Printf("%s get msg to cancel\n" , name)
return
default:
value : = ctx.Value ("key").(string)
fmt.Printf("%s is running value=%s \n ", name , value)
time.Sleep(l * time.Second)
}
}
}
func main() {
使用 context.Background() 构建 WithCancel 类型的上下文
ctxa, cancel : = context.WithCancel(context.Background())
work 模拟运行并检测前端的退出通知
go work(ctxa,"workl")
使用 WithDeadline 包装前面的上下文对象 ctxa
tm := time.Now().Add(3 * time.Second)
ctxb, := context.WithDeadline(ctxa, tm)
go work(ctxb, "work2")
使用 WithValue 包装前面的上下文对象 ctxb
oc : = otherContext{ctxb}
ctxc := context.WithValue(oc, "key", "andes, pass from main ")
go workWithValue(ctxc,"work3")
故意"sleep" 10 秒, 让work2 work3 超时退出
time.Sleep(lO * time.Second)
显式调用 workl cancel 方法通知其退出
cancel()
等待 workl 打印退出信息
time.Sleep(5 * time.Second)
fmt.Println ("main stop")
}
运行结果:
//work3 在运行中且能够获取前端传递过来的参数 key
work3 is running value=andes, pass from main
workl is running
work2 is running
work3 is running value=andes, pass from main
work2 is running
workl is running
workl is running
work3 is running value=andes, pass from main
work2 is running
//work2 wo rk3 超时退出
work2 get msg to cancel
work3 get msg to cancel
workl is running
workl is running
workl is run ing
workl is running
workl is runn ng
workl is running
//workl 被显式地通知退出
workl get msg to cancel
main stop
程序分析
在使用 Context 的过程中,程序在底层实际上维护了两条关系链,理解这个关系链对理解 context 包非常有好处,两条引用关系链如下。
( 1 ) children key 构成从根到叶子 Context 实例的引用关系,这个关系在调用 With 函数时进行维护(调用上文介绍的 propagateCancel(parent Context, child canceler) 函数维护,程序有一层这样的树状结构(本示例是一个链表结构):
ctxa.children--->ctxb
ctxb.children--->ctxc
这个树提供一种从根节点开始遍历树的方法, context 包的取消广播通知的核心就是基于这一点实现的 。取消通知沿着这条链从根节点向下层节点逐层广播。当然也可以在任意一个子树上调用取消通知,一样会扩散到整棵树。示例程序中 ctxa 收到退出通知,会通知其绑定 work1,同时会广播给 ctxb 和 ctxc 绑定的 work2 work3 。同理,ctxb 收到通知,会通知到其绑定的 work2,同时会广播给 ctxc 绑定的 work3。
( 2 ) 在构造 Context 的对象中不断地包裹 Context 实例形成一个引用关系链,这个关系链的方向是相反的,是自底向上的。示例程序中多个 Context 对象的关系如下:
ctxc.Context -->oc
ctxc.Context.Context -->ctxb
ctxc.Context.Context.cancelCtx -->ctxa
ctxc.Context.Context.cancelCtx.Context -->new(empt yCtx} //context.Background(}
这个关系链主要用来切断当前 Context 示例和上层的 Context 实例之间的关系,比如 ctxb 调用了退出通知或者定时器到期,ctxb就没必要在通知广播树上继续存在,他需找到自己parent,然后执行delete(parent,children,ctxb),把自己从广播树上清理掉。
对核心代码理解:
ctxa, cancel := context.WithCancel(cotext.Background())
/*
ctxa 内部状态-->ctxa=&cancelCtx {
Context: new(emptyCtx)
}
*/
go work(ctxa,"workl")
tm := time.Now().Add(3 * time.Second)
ctxb,_ := context.WithDeadline(ctxa, tm)
/*
ctxb 内部状态--->
ctxb=&timerCtx{
cancelCtx: ctxa
dataline : tm
}
同时触发 ctxa,在 children 中维护 ctxb 作为子节点
*/
go work(ctxb , "work2")
oc := otherContext{ctxb}
ctxc := context.WithValue(oc,"key","pass from main ")
/*
ctxc --> ctxc=&cancelCtx {
Context: oc
}
同时通过 oc.Context 找到 ctxb,通过 ctxb cancelCtx 找到 ctxa,在 ctxa children 字段中维护 ctxc 作为其子节点
*/
go workWthValue(ctxc, "work3")
通过上下文示例梳理出使用Context包的一般流程如下:
( 1 ) 创建一个 Context 根对象
func Background() Context
func TODO() Context
( 2 ) 包装上一步创建的Context对象,使其具有特定的功能。
这些包装函数是 context package 的核心,几乎所有的封装都是从包装函数开始的。原因很简单,使用 context 包的核心就是使用其退出广播功能。示例如下:
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)
func WithValue(parent Context, key, val interface{}) Context
( 3 ) 将上一步创建的对象作为实参传给后续启动的并发函数(通常作为函数的第一个参数),每个并发函数内部可以继续使用包装函数对传进来的 Context 对象进行包装,添加自己所需的功能。
( 4 ) 顶端的 goroutine 在超时后调用 cancel 退出通知函数,通知后端的所有 goroutine 释放资源。
( 5 ) 后端的 goroutine 通过select 监听 Context.Done() 返回的chan,及时响应前端 goroutine 的退出通知,一般停止本次处理,释放所占用的资源。
该不该使用 context 传递数据
首先要清楚使用 context 包主要是解决 goroutine 的通知退出,传递数据是其 个额外功能。
可以使用它传递一些元信息 ,总之使用 context 传递的信息不能影响正常的业务流程,程序不要期待在 context 中传递一些必需的参数等,没有这些参数,程序也应该能正常工作。
context 中传递数据的坏处
**( 1 )**传递的都是 interface{}类型的值,编译器不能进行严格的类型校验。
**( 2 )**从 interface{}到具体类型需要使用类型断言和接口查询,有一定的运行期开销和性能损失
**( 3 )**值在传递过程中有可能被后续的服务覆盖,且不易被发现
**( 4 )**传递信息不简明,较晦涩;不能通过代码或文档 眼看到传递的是什么,不利于后续维护。
context 应该传递什么数据
( 1 ) 日志信息
( 2 ) 调试信息
( 3 ) 不影响业务主逻辑的可选数据。
context 包提供的核心的功能是多 goroutine 之间的退出通知机制,传递数据只是一个辅助功能,应谨慎使用 context 传递数据。
《 Communicating Sequential Processes 》( CSP )是计算机科学领域的“大牛”托尼.霍尔于 1978 年发表的一篇论文,后期不断优化最终发展为一个代数理论,用来描述并发系统消息通信模型并验证其正确性。
其最基本的思想是将并发系统抽象为 Channel 和 Process 两个部分,Channel 用来传递消息,Process 用于执行, Channel 和 Process 两个部分相互独立,没有从属关系,消息的发送和接收有严格的时序限制。
Go 语言主要借鉴了 Channel 和 Process 概念,Go Channel 是通道, Process 就是 goroutine。
CPU 执行指令的速度是非常快的。在 3.0GHz 主频的单颗 CPU 核心上,大部分简单指令的执行仅需要 1 个时钟周期, 1 个时钟周期也就是三分之一纳秒。也就是说, 1s 可以执行 30 亿条简单指令(仅考虑执行,不考虑读取数据耗时),这个速度是极快的。CPU 慢在对外部数据的读/写上。外部 I/O 的速度慢和阻塞是导致 CPU 使用效率不高的最大原因。在大部分真实系统中, CPU 都不是瓶颈, CPU 的大部分时间被白白浪费了,增加 CPU 的有效吞吐量是工程师的重要目标。
所谓增加 CPU 有效吞吐量,通俗讲就是让 CPU 尽量多干活,而不是在空跑或等待。理想状态是机器的每个 CPU 核心都有事情做,而且尽可能快地做事情,这里有两层含义,我们以现代带有操作系统的计算机来进行论述。
( 1 )尽可能让每个 CPU 核心都有事情做
这就要求工作的线程要大于 CPU 的核心数,单进程的程序最多使用一个 CPU 干活,是没有办法有效利用机器资源的。由于 CPU 要和外部设备通信,单个线程经常会被阻塞,包括 I/O 等待、缺页中断、等待网络等。所以 CPU 和线程的比例是 1 : 1 ,大部分情况下也不能充分发挥 CPU 的威力。实际上依据程序的特性( CPU 密集型还是 I/O 密集型),合理调整 CPU 和线程的关系, 一般情况下,线程数要大于 CPU 的个数,才能发挥机器的价值。
( 2 )尽可能提高每个 CPU 核心做事情的效率。
现代操作系统虽然能够进行并行调度,但是当进程数大于 CPU 核心的时候,就存在进程切换的问题。这个切换需要保存上下行,恢复堆栈。频繁地切换也很耗时,我们的目标是尽量让程序减少阻塞和切换,尽量让进程跑满操作系统分配的时间片(分时系统)。
上述是从整个系统的角度来看程序的运行效率问题,具体到应用程序又有所不同 。
应用程序的并发模型是多样的,总结一下有三种。
• 多进程模型
进程都能被多核 CPU 并发调度 ,优点是每个进程都有自己独立的内存空间,隔离性好、健壮性高;缺点是进程比较重 ,进程的切换消耗较大,进程间的通信需要多次在内核区和用户区之间复制数据
• 多线程模型
这里的多线程是指启动多个内核线程进行处理,线程的优点是通过共享内存进行通信更快捷,切换代价小;缺点是多个线程共享内存空间,极易导致数据访问混乱,某个线程误操作内存挂掉可能危及整个线程组,健壮性不高。
• 用户级多线程模型
用户级多线程又分两种情况, 一种是 M : 1 的方式, M 个用户线程对应一个内核进程,这种情况很容易因为一个系统阻塞,其他用户线程都会被阻塞,不能利用机器多核的优势。还有一种模式就是 M : N 的方式,M个用户线程对应N个内核线程,这种模式一般需要语言运行时或库的支持,效率最高。
程序井发处理的要求越来越高,但是不能无限制地增加系统线程数,线程数过多会导致操作系统的调度开销变大,单个线程的单位时间内被分配的运行时间片减少,单个线程的运行速度降低,单靠增加系统线程数不能满足要求,为了不让系统线程无限膨胀,于是就有了协程的概念,**协程是一种用户态的轻量级线程,协程的调度完全由用户态程序控制,协程拥有自己的寄存器上下文和栈。**协程调度切换时,将寄存器上下文和栈保存到其他的地方,在切回来的时候, 恢复先前保存的寄存器上下文和栈, 每个内核线程可以对应多个用户协程,当一个协程执行阻塞了,调度器会调度另一个协程执行, 最大效率地利用操作系统分给系统线程的时间片 。前面提到的用户级多线程模型就是一种协程模型,尤其以 M:N 模型最为高效。
这样的好处显而易见:
( 1 )控制了系统线程数,保证每个线程的运行时间片充足。
( 2 )调度层进行用户态的切换,不会导致单个协程阻塞整个程序的情况,尽量减少上下文切换,提升运行效率。
由此可见,协程是一种非常高效、理想的执行模型。
Go 的并发执行模型就是一种变种的协程模型。
Go 语言在语言层面引入 goroutine 有以下好处:
( 1 ) goroutine 可以在用户空间调度,避免了内核态和用户态的切换导致的成本。
( 2 ) goroutine 是语言原生支持的,提供了非常简洁的语法,屏蔽了大部分复杂底层实现。
( 3 ) goroutine 更小的栈空间允许用户创建成千上万的实例。
下面介绍 Go 语言 goroutine 调度模型。
Go 的调度模型中抽象出三个实体
G (Goroutine)
Go 运行时对 goroutine 的抽象描述,G 中存放并发执行的代码入口地址、上下文、运行环境(关联的 P 和 M)、运行栈等执行相关的元信息。G 的新建、休眠、恢复 、停止都受到 Go 运行时的管理。 Go 运行时的监控线程会监控 G 的调度, G 不会长久地阻塞系统线程,运行时的调度器会自动切换到其他 G 上继续运行。 G 新建或恢复时会添加到运行队列,等待 M 取出并运行。
M (Machine)
M 代表 OS 内核线程,是操作系统层面调度和执行的实体。M 仅负责执行,不停地被唤醒或创建,然后执行。M 启动时进入的是运行时的管理代码,由这段代码获取 G 和 P 资源,然后执行调度。另外, Go 语言运行时会单独创建一个监控线程,负责对程序的内存、调度等信息进行监控和控制。
P (Processor)
P 代表 M 运行 G 所需要的资源,是对资源的一种抽象和管理, 不是一段代码实体,而是一个管理的数据结构,P 主要是降低 M 管理调度 G 的复杂性,增加一个间接的控制层数据结构。把 P 看作资源,而不是处理器,P 控制 Go 代码的并行度,它不是运行实体。P 持有 G 的队列,P 可以隔离调度,解除 P 和 M 的绑定就解除了 M 对一串 G 的调用。P 在运行模型中只是一个数据模型,而不是程序控制模型,理解这一点非常重要。
M 和 P 一起构成一个运行时环境,每个 P 有一个本地的可调度 G 队列,队列里面的 G 依次调度执行,如果本地队列空了, 会去全局队列偷取一部分 ,如果全局队列也是空的,则去其他 P 中偷取一部分 ,这是 Work Stealing 算法的基本原理。调度结构如下图:
G 并不是执行体,而是用于存放井发执行体的元信息,包括并发执行的入口函数、堆栈、上下文等信息。由于保存的是元信息,为了减少对象的分配和回收, 对象是可以复用的,只需将相关元信息初始化为新值即可。M 仅负责执行,M 启动时进入运行时的管理代码,这段管理代码必须拿到可用的 P 后,才能执行调度。P 的数目默认是 CPU 核心的数 ,可以通过 sync.GOMAXPROCS 函数设置或查询,M 和 P 的数目差不多,但运行时会根据当前的状态动态地创建 M, 有一个最大值上限,目前是 10000; G 与 P 是一种 M : N 的关系,M 可以成千上万,远远大于 N 。下面从宏观层面调解 Go 程序初始化和调度。
m0 g0
Go 中还有特殊的 M 和 G,它们是 m0 和 g0。m0 是启动程序后的主线程,这个 m 对应的信息会存放在全局变量 m0 中, m0 负责执行初始化操作和启动第一个 g ,之后 m0 就和其他的 M 一样了。
每个 M 都会有一个自己的管理堆栈 g0, g0 不指向任何可执行的函数, g0 仅在 M 执行管理和调度逻辑时使用。在调度或系统调用时会切换到 g0 的栈空间,全局变量的 g0 是 m0 的 g0。
Go 启动初始化过程
( 1 )分配和检查栈空间。
( 2 )初始化参数和环境变量。
( 3 )当前运行线程标记为 m0 , m0 是程序启动的主线程。
( 4 )调用运行时初始化函数 runtime.schedinit 进行初始化。
主要是初始化内存空间分配器、 GC 、生成空闲 P 列表。
( 5 )在 m0 上调度第一个 G ,这个 G 运行 runtime.main 函数。
runtime,main 会拉起运行时的监控线程,然后调用 main 包的 init() 初始化函数,最后执行 main 函数。
什么时候创建M、P、G
在程序启动过程中会初始化空闲 P 列表,P 是在这个时候被创建,同时第一个 G 也是在初始化过程中被创建的。
后续在有 go 并发调用的地方都有可能创建 G ,由于 G 只是一个数据结构,并不是执行实体,所以 G 是可以被复用的。在需要 G 结构时,首先要去 P 的空闲列表里面寻找己经运行结束的 goroutine ,其 G 会被缓存起来。每个并发调用都会初始化一个新的 G 任务,然后唤醒 M 执行任务。这个唤醒不是特定唤醒某个线程去工作,而是先尝试获取当前线程 M,如果无法获取,则从全局调度的空闲 M 列表中获取可用的 ,如果没有可用的,则新建 M ,然后绑定 P 和 G 进行运行。所以 M 和 P 不是一一对应的, 是按需分配的,但是运行时会设置一个上限值(默认是 10000 ),超出最大值将导致程序崩溃。
注意: 创建新的 M 有一个自己的栈 g0 ,在没有执行并发程序的过程中,M 一直是在 g0 栈上工作的。M 一定要拿到 P 才能执行,G、M 和 P 维护着绑定关系, M 在自己的堆栈 g0 上运行恢复 G 上下文的逻辑。完成初始化后, M 从 g0 栈切换到 G 的栈,并跳转到并发程序代码点开始执行。
M 线程里有管理调度和切换堆栈的逻辑,但是 M 必须拿到 P 才能运行 ,可以看到 M 自驱动的,但是需要 P 的配合。这是一个巧妙的设计。
抢占调度
抢占调度的原因
( 1 )不让某个 G 长久地被系统调用阻塞,阻碍其他 G 运行。
( 2 )不让某个 G 一直占用某个 M 不释放
( 3 )避免全局队列里面的 G 得不到执行。
抢占调度的策略
( 1 )在进入系统调用 syscall 前后 ,各封装一层代码检测 G 的状态, 当检测到当前 G 己经被监控线程抢占调度,则 M 停止执行当前 G ,进行调度切换
( 2 )监控线程经过一段时间检测感知到 P 运行超过一定时间,取消 P 和 M 的关联,这也一种更高层次的调度
( 3 )监控线程经过一段时间检测感知到 G 一直运行,超过了一定的时间,设置 G 标记,G 执行栈扩展逻辑检测到抢占标记,根据相关条件决定是否抢占调度。