一、并发编程初体验和问题
-
关于 Go 语言和线程的关系
- Go 语言中存在线程。Go 语言的并发模型是基于 Goroutine、Processor(P)和 Machine(M,操作系统线程)的 GMP 模型。Goroutine 是 Go 语言中轻量级的执行单元,由 Go 运行时管理。Go 运行时会将 Goroutine 调度到操作系统线程(M)上执行。
- 虽然 Goroutine 是 Go 并发编程的核心抽象,但它的执行最终还是要依赖操作系统线程。Go 运行时会根据系统资源(如 CPU 核心数)自动创建和管理操作系统线程,以高效地执行 Goroutine。
-
Go 语言并发的优势和 Goroutine 特点
- 调度开销小:Goroutine 是基于函数级切换的,这使得它的调度相较于传统的操作系统线程的调度,内存耗费和时间耗费都小很多。这是因为 Goroutine 的切换是在用户空间进行的,不需要陷入操作系统内核进行线程切换那样的高开销操作。
- 容易创建大量协程:由于 Goroutine 的轻量级特性,Go 语言确实很容易就可以创建大量的 Goroutine 来执行并发任务。这使得 Go 在处理高并发场景,如网络编程、分布式系统等方面表现出色。
- 高效的调度器:Go 语言的运行时调度器采用了一些先进的策略,如工作窃取(Work Stealing)算法。每个 Processor(P)都有一个本地运行队列用于存放等待执行的 Goroutine,当本地队列空时,P 可以从全局队列或者其他 P 的本地队列中 "窃取" Goroutine 来执行,这种机制有助于充分利用多核 CPU 资源,提高并发性能。
- 通信机制:Go 语言提供了通道(Channel)作为 Goroutine 之间的通信和同步机制。通过 Channel,Goroutine 可以安全地发送和接收数据,避免了传统并发编程中常见的数据竞争等问题。这使得 Go 语言的并发编程模型更加简单和安全。
-
Go 语言诞生背景和并发的关系
- Go 语言诞生于多核时代,为了解决多核处理器编程的复杂性和高效利用多核资源的问题。虽然 Web2.0 时代的高并发需求对 Go 语言的设计有一定的影响,但 Go 语言的设计目标不仅仅是应对 Web2.0 场景。它旨在提供一种简单、高效、安全的通用并发编程模型,适用于各种高性能、高并发的应用场景,包括网络编程、云计算、分布式系统等众多领域。
下面将有一个简单的并发程序:
Go
package main
import "fmt"
func main(){
for i:=0;i<100;i++ {
go func(){
fmt.Println(i)
}()
}
}
执行后,我们会发现,按道理我们会打印出0~99的所有数字,但是其中总会出现漏打印的问题,这是为什么?
问题一:你知道为什么可能会打印到100,而不是99?
答:这是因为我们闭包的机制,闭包在捕获循环变量时,会捕获到他的地址,当i自增到100时,有些没有执行过的goroutine就会开始执行,这时他们都会共享一个100.
问题二:相信你也可能会发现,打印可能会重复,这又是为何?
答 :其实上面已经给出了答案**"共享",**由于闭包会捕获外部变量的地址,所以,闭包函数中拿到的是这时同步的外部变量的值。goroutine的创建是串行创建的,但是他的执行是由调度器决定的,对我们来说,他仍然是不可控的,所以,就可能会出现外部变量在go程执行前更新的情况。这时就会出现多个go程共享一个值的情况
那么如何解决呢?这里提出两个常用的解决办法:
Go
package main
import "fmt"
func main(){
for i:=0;i<100;i++ {
// 通过复制i,得到i的值,再值传递
temp := i
go func(){
fmt.Println(temp)
}()
}
}
Go
package main
import "fmt"
func main(){
for i:=0;i<100;i++ {
// 参数传递,是值传递
go func(num int){
fmt.Println(num)
}(i)
}
}
二、GMP调度
在 Go 语言中,GMP 是指 Goroutine、Machine(Workers)、Processor(P)这三个重要的概念。
Goroutine(G)
- 定义
- Goroutine 是 Go 语言中轻量级的线程实现。它由 Go 运行时(runtime)管理,相比于操作系统线程,Goroutine 的创建和销毁开销非常小。
- 多个 Goroutine 可以在一个或多个操作系统线程上并发执行。
- 特点
- 它们由 Go 的
runtime
调度,在用户空间中实现,而不是由操作系统内核调度。 - Goroutine 之间通过
channel
进行通信和同步,这有助于避免数据竞争等并发问题。
- 它们由 Go 的
Machine(Worker,M)
- 定义
- Machine(通常称为 Worker 或 M)代表操作系统线程。Go 运行时会将 Goroutine 分配到这些操作系统线程上执行。
- 特点
- 每个 Machine 都有一个与之关联的 Processor(P),用于执行 Goroutine。
- Machine 的数量通常由 Go 运行时根据系统资源(如 CPU 核心数)自动调整。
Processor(P)
- 定义
- Processor(P)是 Go 运行时中的一个抽象概念,它代表执行 Goroutine 的逻辑处理器。
- 每个 Processor 都有一个本地运行队列(Local Run Queue),用于存放等待执行的 Goroutine。
- 特点
- 当一个 Goroutine 被创建时,它会被放入某个 Processor 的本地运行队列中。
- Processor 负责从本地运行队列中取出 Goroutine,并将其分配到关联的 Machine 上执行。
三者关系
- 调度过程
- 当一个 Goroutine 准备执行时,它会被放入一个 Processor 的本地运行队列。
- Processor 会将本地运行队列中的 Goroutine 分配到关联的 Machine(操作系统线程)上执行。
- 如果本地运行队列为空,Processor 可以从全局运行队列(Global Run Queue)或其他 Processor 的本地运行队列中 "窃取" Goroutine 来执行。
- 优化目的
- GMP 模型的设计目的是为了高效地利用多核 CPU 资源,实现高并发和高性能的并发编程。通过合理地调度 Goroutine 到不同的 Processor 和 Machine 上,Go 运行时能够最大限度地发挥硬件的性能。
总之,GMP 是 Go 语言实现高效并发编程的核心机制,通过这三个概念的协同工作,Go 能够轻松地处理大量并发任务。
下图,描绘了他们的关系:
根据上图,我们再来简述一下,他们的工作调度关系:
- Goroutine(G)的创建
- 程序中可以创建多个 Goroutine,如图中顶部所示的多个
goroutine
。这些 Goroutine 是实际执行任务的轻量级执行单元。
- 程序中可以创建多个 Goroutine,如图中顶部所示的多个
- 运行时调度器的作用
- 当 Goroutine 被创建后,它们首先会被送到运行时调度器。运行时调度器负责管理和分配这些 Goroutine。
- 调度器会决定将 Goroutine 分配到哪里执行。它有两个选择:本地队列(每个 Processor 都有一个本地队列)或者全局队列。
- 全局队列的作用
- 如果本地队列都已满或者有其他原因,Goroutine 会被放入全局队列。全局队列用于存放那些暂时没有被分配到特定 Processor 本地队列的 Goroutine。
- Processor(P)的本地队列
- 每个 Processor 都有一个本地队列。Processor 是逻辑上的执行资源,其数量通常与 CPU 核心数相关。
- 运行时调度器会将 Goroutine 从全局队列或者直接将新创建的 Goroutine 分配到 Processor 的本地队列中。
- 系统级线程(M)与 Processor 的绑定
- 每个 Processor 在运行时会绑定到一个系统级线程(M)上。系统级线程是实际执行计算的物理资源。
- 当 Processor 的本地队列中有 Goroutine 时,它会将 Goroutine 分配到与之绑定的系统级线程上执行。系统级线程负责执行从 Processor 的本地队列中获取的 Goroutine。
- 工作窃取机制(图中未体现,但相关)
- 当一个 Processor 的本地队列为空时,它可以从其他 Processor 的本地队列或者全局队列中 "窃取" Goroutine 来执行。这种机制有助于平衡各个 Processor 之间的负载,确保所有 CPU 核心都能得到充分利用。
总结来说,Goroutine 被创建后,由运行时调度器分配到本地队列或全局队列,Processor 从本地队列获取 Goroutine 并将其分配到绑定的系统级线程上执行,同时还可以通过工作窃取机制平衡负载。
三、通过waitGroup等待协程的执行
下面的内容通过代码去理解:
Go
package main
import (
"fmt"
"sync"
)
func main(){
// 一个等待组实例
var wg sync.WaitGroup
// 等待 线程的数目
wg.Add(10000)
for i:=0;i<10000;i++ {
go func(i int){
// 协程结束后,通知结束,数目-1
defer wg.Done()
fmt.Println(i)
}(i)
}
// 等待所有线程结束,否则会有协程未执行完成
wg.Wait()
}
四、goroutine的锁
互斥锁
sync包下,使用互斥锁,可以有效应对共享资源的同步问题
Go
package main
import (
"fmt"
"sync"
)
var wg sync.WaitGroup
var lock sync.Mutex
var total int
func main(){
wg.Add(2)
go add()
go sub()
wg.Wait()
fmt.Println(total)
}
func add(){
defer wg.Done()
for i := 0; i < 100000; i++ {
lock.Lock()
total += 1
lock.Unlock()
}
}
func sub(){
defer wg.Done()
for i := 0; i < 100000; i++ {
lock.Lock()
total -= 1
lock.Unlock()
}
}
这里我们展示了互斥锁在访问共享资源时保证了资源的单独访问。
问题:锁能复制吗?
**答:**不可以,因为锁的本质是一个结构体,锁的工作时本质是随时维护两个字段,
type Mutex struct {
state int32 // 锁持有者,等待数量
sema uint32 // 信号量,阻塞和唤醒有关
}
这两个字段记录了。。。,如果锁复制了,那么一个锁的变化,对于另一个复制锁来说,就是没有变化
如果场景是这样的,那么另外,我们将提出一个操作,相比于锁来说,他的效率更高。
原子性操作
Go
package main
import (
"fmt"
"sync"
"sync/atomic"
)
var wg sync.WaitGroup
var lock sync.Mutex
var total int32
func main(){
wg.Add(2)
go add()
go sub()
wg.Wait()
fmt.Println(total)
}
func add(){
defer wg.Done()
for i := 0; i < 100000; i++ {
atomic.AddInt32(&total,1)
}
}
func sub(){
defer wg.Done()
for i := 0; i < 100000; i++ {
atomic.AddInt32(&total,-1)
}
}
这个包中的函数提供了原子性的操作,同样达到了锁的效果,但是内存和时间效率上更高,但是使用场景简单,锁的场景更加复杂
RW读写锁
锁的本质是将并行的代码串行化了,使用lock肯定影响性能
即使是设计锁,也应该尽量保证并行
在我们的绝大部分场景都是读多写少,经过分析,我们发现:
-
读协程之间可以并行
-
读和写之间应该串行(写的时候不可以读)
-
写和写之间应该串行
go中设计的读写锁,在包sync中,是sync.RWMutex
介绍用法:
Lock方法就是获取写锁,RLock方法就是获取读锁。相应的UnLock方法就是去掉写锁,RUnLock方法就是去掉读锁。
注意:
-
获取写锁后,所有读锁和写锁无法获取
-
获取读锁后,所有写锁无法获取,但是可以获取读锁
五、goroutine的通信
在go中,goroutine的通信是通过channel。
channel的底层其实是通过一个环形数组实现的。
在go的设计哲学中提到:不要用内存共享去通信,而是用通信实现内存共享
怎么理解呢?通过下面两个代码去理解:
引:
(1)基于内存共享(可能出现问题)
假设我们有两个 Goroutine,一个用于增加共享变量的值,另一个用于读取这个值:
Go
package main
import (
"fmt"
"sync"
)
var (
count int
mutex sync.Mutex
)
func increment() {
mutex.Lock()
count++
mutex.Unlock()
}
func readCount() {
mutex.Lock()
fmt.Println("Count:", count)
mutex.Unlock()
}
func main() {
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
for i := 0; i < 1000; i++ {
increment()
}
}()
go func() {
defer wg.Done()
for i := 0; i < 1000; i++ {
readCount()
}
}()
wg.Wait()
}
在这个例子中,我们使用了互斥锁来保护共享变量count
,但是代码比较复杂,而且如果互斥锁使用不当,很容易出现问题。
(2)基于通道通信
Go
package main
import (
"fmt"
)
func main() {
ch := make(chan int)
var count int
go func() {
for i := 0; i < 1000; i++ {
count++
ch <- count
}
close(ch)
}()
for val := range ch {
fmt.Println("Count:", val)
}
}
在这个例子中,一个协程负责更新count
的值并将其发送到通道ch
,另一个协程从通道接收值并打印。这种方式避免了复杂的互斥锁操作,代码更加简洁和安全
有缓冲和无缓冲的channel
channel的应用场景:
-
消息传递
-
信号广播
-
事件订阅和广播
-
任务分发
-
结果汇总
-
并发控制
-
同步和异步
。。。。。。
下面分别介绍有缓冲channel和无缓冲channel:
无缓冲channel:本质是一种同步通道,他要求读写操作紧密进行。
所以,在单channel下,如果在无缓冲channel中进行读写,此时就会在第一次操作处阻塞,发生死锁。
所以,如果使用无缓冲channel,至少需要另外再启动一个goroutine负责读或者写,如下:
Go
package main
import "fmt"
func main(){
// 无缓冲channel
c := make(chan int,0)
// 启动一个goroutine负责读写
// go func(){
// // 读
// res := <-c
// fmt.Println(res)
// }()
go func(){
// 写
c <- 1
}()
res := <-c
fmt.Println(res)
return
}
有缓冲的channel:类似于一种"延迟通道"(这里就支持单goroutine),当然这里不能完全等同于。有缓冲的通道是将进入的消息先暂时缓存到队列中,等待goroutine去读。
Go
package main
import (
"fmt"
)
func main() {
// 创建一个缓冲区大小为3的有缓冲通道
ch := make(chan int, 3)
// 向通道写入数据
ch <- 1
ch <- 2
ch <- 3
// 从通道读取数据并打印
fmt.Println(<-ch)
fmt.Println(<-ch)
fmt.Println(<-ch)
}
当然,有缓冲的channel的缓冲区的大小是限定的,如果超过了缓冲区的大小,那么消息再次进入时,就会发生阻塞,等待goroutine读。(单、多goroutine都可以)
channel的遍历
for range,使得
Go
package main
import (
"fmt"
"sync"
)
func main() {
ch := make(chan int)
var wg sync.WaitGroup
wg.Add(1)
go func (ch chan int) {
defer wg.Done()
// for val := range ch {
// // 这里可以不需要ok值,只要close后,for range会自动结束
// fmt.Println("Received value:", val)
// }
for i := 0;; i++ {
// 这里会得到一个bool值,告诉读取者channel是否关闭
// 这里的ok有必要,ok告诉读取者channel通道关闭,读取者就会得到信息去决策
// 如果没有ok,那么就会无限的读取该类型的零值,这里是 0
value, ok := <-ch
if !ok{
break
}
fmt.Printf("%d ",value)
}
}(ch)
// 启动一个goroutine来发送数据到channel
// go func() {
// for i := 0; i < 10; i++ {
// ch <- i
// }
// close(ch)
// }()
ch <- 1
ch <- 1
ch <- 1
ch <- 1
ch <- 1
ch <- 1
close(ch)
wg.Wait()
}
小结:
-
for range 和 传统for循环都可以对channel进行遍历
-
for range遍历时,注意要搭配close,关闭channel。否则会继续读取空channel,阻塞后死锁(for range在channel关闭后会自动结束)
-
for循环遍历时,注意搭配close,关闭channel,并且使用ok,通知接收者channel关闭,防止死锁(for循环遍历不会自动结束)
单向channel
go中的channel是默认是双向的,但是,我们经常使用的时候是单调的,只读或者只写,所以go支持单向channel。(这样固定为类型,可以有效的防止我们的操作失误,帮助发现问题),怎么使用?代码如下:
Go
package main
import (
"fmt"
"time"
)
func main() {
// 默认channel是双向的
ch := make(chan int)
// 可以自动转换为单向
go producer(ch)
go consumer(ch)
time.Sleep(10*time.Second)
}
// in:只生产
func producer(in chan<- int){
for i := 0; i < 6; i++ {
in <- i
}
// 写完关闭
close(in)
}
// out:只消费
func consumer(out <-chan int){
for v := range out{
fmt.Printf("%d ",v)
}
}
小结:
-
channel自动转换类型(只能双向到单向)
-
channel写完就关闭
通过channel交叉打印
题目:使用两个goroutine交替打印序列,一个打印数字,一个打印字母,最终效果如下:
12AB34CD56EF78GH910IJ1112KL1314MN1516OP17118QR1920ST2122UV2324WX2526YZ2728
代码如下:
Go
package main
/*
题目:使用两个goroutine交替打印序列,一个打印数字,一个打印字母,最终效果如下:
12AB34CD56EF78GH910IJ1112KL1314MN1516OP17118QR1920ST2122UV2324WX2526YZ2728
分析:实现交替,那么就需要通信:a打完告诉b
*/
import (
"fmt"
"time"
)
var num,char = make(chan bool),make(chan bool)
func main() {
go printNum()
go printChar()
// 先打印num,给num一个值(无缓冲区,不能先读写)
num <- true
time.Sleep(10*time.Second)
}
func printNum(){
i := 1
for {
// 什么时候打印?num管道告诉我:(阻塞等待可以打印的通知)
// 这里我压根没有用返回值,所以这里只是阻塞等待和放行的作用
<-num
fmt.Printf("%d%d",i,i+1)
// 打印完成,告诉char管道:(你可以打印了)
i += 2
char <- true
}
}
func printChar(){
i := 0
str := "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
for {
// 什么时候打印?num管道告诉我:(阻塞等待可以打印的通知)
// 这里我压根没有用返回值,所以这里只是阻塞等待和放行的作用
<-char
if i >= len(str){
return
}
fmt.Printf(str[i:i+2])
i += 2
// 打印完成,告诉char管道:(你可以打印了)
num <- true
}
}
六、监控channel的执行
这里其实介绍的是select的用法以及注意事项:
select-case的作用:
阻塞当前goroutine,等待select中的channel准备就绪。如果一旦有channel准备就绪,那么就会知道某个channel的就绪状态并执行对应的操作。如果有多个channel
同时就绪,select
会随机选择一个case
分支来执行。这种随机选择是在运行时决定的,每次运行的结果可能不同。这是一种防止"饥饿"的策略。(如果按照顺序执行,那么可能会使得某个channel下的操作永远不会执行到)
注意:
-
当
select
语句中的所有case
(通道操作)都被阻塞时,default
子句就会被执行。如果select
语句中有default
子句,它会提供一种非阻塞的操作方式。使得select语句不会因为所有channel阻塞而阻塞当前goroutine -
但是,default的"防止阻塞"几乎是瞬时的,就是说,在很短的时间内,如果select没有发现channl准备就绪,那么他就会走default分支(这也相当于一种等待机制替换阻塞机制,但是等待时间太短,因此我们可能会直接在select之前加入一段sleep,但是我们其实并不知道channel到底有没有准备好)
Go
package main
import (
"fmt"
"time"
//"time"
)
func main() {
ch1 := make(chan struct{})
ch2 := make(chan struct{})
go go1(ch1)
go go2(ch2)
// 给充足的时间发送信息(不推荐)
//time.Sleep(time.Second)
select {
case <-ch1:
fmt.Println("ch1")
case <-ch2:
fmt.Println("ch2")
default:
fmt.Println("default")
}
}
func go1(ch chan struct{}){
time.Sleep(time.Second)
ch <- struct{}{}
}
func go2(ch chan struct{}){
//time.Sleep(time.Second)
ch <- struct{}{}
}
因此,我们可以使用一种超时等待机制,最多允许一定的时间,如果超过了就执行特定的一个分支。
Go
package main
import (
"fmt"
"time"
//"time"
)
func main() {
// 创建定时器,指定时间
timer := time.NewTimer(2 * time.Millisecond)
ch1 := make(chan struct{})
ch2 := make(chan struct{})
go go1(ch1)
go go2(ch2)
select {
case <-ch1:
fmt.Println("ch1")
case <-ch2:
fmt.Println("ch2")
case <-timer.C:
fmt.Println("timeout")
}
}
func go1(ch chan struct{}){
//time.Sleep(time.Second)
ch <- struct{}{}
}
func go2(ch chan struct{}){
//time.Sleep(time.Second)
ch <- struct{}{}
}
使用定时器,通过定时器,在指定时间内向定时器的channel中发送"执行"的信号。
七、通过context解决goroutine的信息传递
这里引用自:Go标准库Context | 李文周的博客
为什么需要Context?
引入问题
现在有这样一个代码:
Go
package main
import (
"fmt"
"sync"
"time"
)
var wg sync.WaitGroup
// 初始的例子
func worker() {
for {
fmt.Println("worker")
time.Sleep(time.Second)
}
// 如何接收外部命令实现退出
wg.Done()
}
func main() {
wg.Add(1)
go worker()
wg.Wait()
fmt.Println("over")
}
我们会发现:worker的goroutine一直在运行,而且根本无法退出。
那么如何解决呢?
起初:我们这样分析:要想退出循环,无非就是在循环中加入一个变量作为"传话的人",当外部的goroutine想要他退出的时候,只需要修改变量的值,子goroutine中接收到变量的值,再操作。相信你已经想到了。
我们可以使用全局变量:
Go
package main
import (
"fmt"
"sync"
"time"
)
var wg sync.WaitGroup
// 默认为false
var stop bool
// 初始的例子
func worker() {
for {
if stop{
wg.Done()
return
}
fmt.Println("worker")
time.Sleep(time.Second)
}
// 如何接收外部命令实现退出
}
func main() {
wg.Add(1)
go worker()
// 如何优雅的实现结束子goroutine
time.Sleep(5*time.Second)
stop = true
wg.Wait()
fmt.Println("over")
}
或者,参数传递?
Go
package main
import (
"fmt"
"sync"
"time"
)
var wg sync.WaitGroup
// 默认为false
//var stop bool
// 初始的例子
func worker(stop *bool) {
for {
if *stop{
wg.Done()
return
}
fmt.Println("worker")
time.Sleep(time.Second)
}
// 如何接收外部命令实现退出
}
func main() {
var stop bool
wg.Add(1)
go worker(&stop)
// 如何优雅的实现结束子goroutine
time.Sleep(5*time.Second)
stop = true
wg.Wait()
fmt.Println("over")
}
但是,其实本质上,都是通过变量进行通信的。
所以,可以用专门用来通信的东西------channel。
Go
package main
import (
"fmt"
"sync"
"time"
)
var wg sync.WaitGroup
var stop chan struct{} = make(chan struct{})
// 初始的例子
func worker() {
for {
select{
case <-stop :
wg.Done()
return
default:
fmt.Println("worker")
time.Sleep(time.Second)
}
}
}
func main() {
wg.Add(1)
go worker()
// 如何优雅的实现结束子goroutine
time.Sleep(time.Second * 5)
// 5秒后发送停止信号
stop <- struct{}{}
wg.Wait()
fmt.Println("over")
}
这里其实就是使用了channel,从channel中接收信息去关闭,更加优雅!其中执行的代码放在default中,是因为stop会阻塞,select接收不到准备就绪的channel,那么就会阻塞。所以使用default可以在停止信号没来之前执行代码
一般来说,我们更会希望使用参数传递。全局变量的形式不容易读懂和维护:
Go
package main
import (
"fmt"
"sync"
"time"
)
var wg sync.WaitGroup
// 初始的例子
func worker(stop chan struct{}) {
for {
select{
case <-stop :
wg.Done()
return
default:
fmt.Println("worker")
time.Sleep(time.Second)
}
}
}
func main() {
var stop chan struct{} = make(chan struct{})
wg.Add(1)
go worker(stop)
// 如何优雅的实现结束子goroutine
time.Sleep(time.Second * 5)
// 5秒后发送停止信号
stop <- struct{}{}
wg.Wait()
fmt.Println("over")
}
官方的解决方案:
Go
package main
import (
"context"
"fmt"
"sync"
"time"
)
var wg sync.WaitGroup
func worker(ctx context.Context) {
LOOP:
for {
fmt.Println("worker")
time.Sleep(time.Second)
select {
case <-ctx.Done(): // 等待上级通知
break LOOP
default:
}
}
wg.Done()
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
wg.Add(1)
go worker(ctx)
time.Sleep(time.Second * 3)
cancel() // 通知子goroutine结束
wg.Wait()
fmt.Println("over")
}
官方直接通过context创建一个取消的方法,当满足条件时调用cancel方法,然后子goroutine就会收到控制信息,执行"退出"。这在一个中看上去很简单,没必要。但是如果子goroutine此时又启动一个goroutine呢?
Go
package main
import (
"context"
"fmt"
"sync"
"time"
)
var wg sync.WaitGroup
func worker(ctx context.Context) {
go worker2(ctx)
LOOP:
for {
fmt.Println("worker")
time.Sleep(time.Second)
select {
case <-ctx.Done(): // 等待上级通知
break LOOP
default:
}
}
wg.Done()
}
func worker2(ctx context.Context) {
LOOP:
for {
fmt.Println("worker2")
time.Sleep(time.Second)
select {
case <-ctx.Done(): // 等待上级通知
break LOOP
default:
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
wg.Add(1)
go worker(ctx)
time.Sleep(time.Second * 3)
cancel() // 通知子goroutine结束
wg.Wait()
fmt.Println("over")
}
context是可以进行传递的。
虽然也可以使用channel进行传递:
Go
package main
import (
"fmt"
"sync"
"time"
)
var wg sync.WaitGroup
func worker(ch chan struct{}) {
go worker2(ch)
LOOP:
for {
fmt.Println("worker")
time.Sleep(time.Second)
select {
case <-ch:
break LOOP
default:
}
}
wg.Done()
}
func worker2(ch chan struct{}) {
LOOP:
for {
fmt.Println("worker2")
time.Sleep(time.Second)
select {
case <-ch:
break LOOP
default:
}
}
}
func main() {
ch := make(chan struct{})
wg.Add(1)
go worker(ch)
time.Sleep(time.Second * 3)
close(ch)
wg.Wait()
fmt.Println("over")
}
但是,context
的设计目的就是为了处理goroutine
之间的请求上下文相关的操作,包括取消和值传递。在代码阅读和维护中,看到context
的使用,开发者能够很容易地理解这是在处理请求的上下文相关事务。
初识Context
Go1.7加入了一个新的标准库context
,它定义了Context
类型,专门用来简化 对于处理单个请求的多个 goroutine 之间与请求域的数据、取消信号、截止时间等相关操作,这些操作可能涉及多个 API 调用。
对服务器传入的请求应该创建上下文,而对服务器的传出调用应该接受上下文。它们之间的函数调用链必须传递上下文,或者可以使用WithCancel
、WithDeadline
、WithTimeout
或WithValue
创建的派生上下文。当一个上下文被取消时,它派生的所有上下文也被取消。
简单的说,context库提供了Context类型,他是一个专注于简化多个groutine之间进行单个请求的类型。这个请求的类型一般就是控制(设置过期时间、设置取消信号等)和传值。他是一个链式的调用结构,中间任何一个context结束,都会使得他派生的所有context结束。(它提供一种简洁、高效的信息传递和控制机制。在实际的 Web 服务或者分布式系统中,一个请求可能会触发多个并发操作,Context
类型使得这些操作能够更好地协同工作。)
Context接口
context.Context
是一个接口,该接口定义了四个需要实现的方法。具体签名如下:
Go
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key interface{}) interface{}
}
其中:
Deadline
方法需要返回当前Context
被取消的时间,也就是完成工作的截止时间(deadline);Done
方法需要返回一个Channel
,这个Channel会在当前工作完成或者上下文被取消之后关闭,多次调用Done
方法会返回同一个Channel;Err
方法会返回当前Context
结束的原因,它只会在Done
返回的Channel被关闭时才会返回非空的值;- 如果当前
Context
被取消就会返回Canceled
错误; - 如果当前
Context
超时就会返回DeadlineExceeded
错误;
- 如果当前
Value
方法会从Context
中返回键对应的值,对于同一个上下文来说,多次调用Value
并传入相同的Key
会返回相同的结果,该方法仅用于传递跨API和进程间跟请求域的数据;
Background()和TODO()
Go内置两个函数:Background()
和TODO()
,这两个函数分别返回一个实现了Context
接口的background
和todo
。我们代码中最开始都是以这两个内置的上下文对象作为最顶层的partent context
,衍生出更多的子上下文对象。
Background()
主要用于main函数、初始化以及测试代码中,作为Context这个树结构的最顶层的Context,也就是根Context。
TODO()
,它目前还不知道具体的使用场景,如果我们不知道该使用什么Context的时候,可以使用这个。
background
和todo
本质上都是emptyCtx
结构体类型,是一个不可取消,没有设置截止时间,没有携带任何值的Context。
Context下的四个函数
WithCancel
WithCancel
的函数签名如下:
Go
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
WithCancel
返回带有新Done通道的父节点的副本。当调用返回的cancel函数或当关闭父上下文的Done通道时,将关闭返回上下文的Done通道,无论先发生什么情况。
取消此上下文将释放与其关联的资源,因此代码应该在此上下文中运行的操作完成后立即调用cancel。
Go
func gen(ctx context.Context) <-chan int {
dst := make(chan int)
n := 1
go func() {
for {
select {
case <-ctx.Done():
return // return结束该goroutine,防止泄露
case dst <- n:
n++
}
}
}()
return dst
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // 当我们取完需要的整数后调用cancel
for n := range gen(ctx) {
fmt.Println(n)
if n == 5 {
break
}
}
}
上面的示例代码中,gen
函数在单独的goroutine中生成整数并将它们发送到返回的通道。 gen的调用者在使用生成的整数之后需要取消上下文,以免gen
启动的内部goroutine发生泄漏。
这是一个取消上下文的函数,接收父context,返回派生context和cancel函数。请求结束后立刻结束并释放上下文
WithDeadline
WithDeadline
的函数签名如下:
Go
func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)
返回父上下文的副本,并将deadline调整为不迟于d。如果父上下文的deadline已经早于d,则WithDeadline(parent, d)在语义上等同于父上下文。当截止日过期时,当调用返回的cancel函数时,或者当父上下文的Done通道关闭时,返回上下文的Done通道将被关闭,以最先发生的情况为准。
取消此上下文将释放与其关联的资源,因此代码应该在此上下文中运行的操作完成后立即调用cancel。
Go
func main() {
d := time.Now().Add(50 * time.Millisecond)
ctx, cancel := context.WithDeadline(context.Background(), d)
// 尽管ctx会过期,但在任何情况下调用它的cancel函数都是很好的实践。
// 如果不这样做,可能会使上下文及其父类存活的时间超过必要的时间。
defer cancel()
select {
case <-time.After(1 * time.Second):
fmt.Println("overslept")
case <-ctx.Done():
fmt.Println(ctx.Err())
}
}
上面的代码中,定义了一个50毫秒之后过期的deadline,然后我们调用context.WithDeadline(context.Background(), d)
得到一个上下文(ctx)和一个取消函数(cancel),然后使用一个select让主程序陷入等待:等待1秒后打印overslept
退出或者等待ctx过期后退出。
在上面的示例代码中,因为ctx 50毫秒后就会过期,所以ctx.Done()
会先接收到context到期通知,并且会打印ctx.Err()的内容。
这是一个限定截止时间的context函数,接收父context和过期时间。返回派生context和cancel函数,在context过期时或满足条件时执行特定操作
WithTimeout
WithTimeout
的函数签名如下:
Go
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
WithTimeout
返回WithDeadline(parent, time.Now().Add(timeout))
。
取消此上下文将释放与其相关的资源,因此代码应该在此上下文中运行的操作完成后立即调用cancel,通常用于数据库或者网络连接的超时控制。具体示例如下:
Go
package main
import (
"context"
"fmt"
"sync"
"time"
)
// context.WithTimeout
var wg sync.WaitGroup
func worker(ctx context.Context) {
LOOP:
for {
fmt.Println("db connecting ...")
time.Sleep(time.Millisecond * 10) // 假设正常连接数据库耗时10毫秒
select {
case <-ctx.Done(): // 50毫秒后自动调用
break LOOP
default:
}
}
fmt.Println("worker done!")
wg.Done()
}
func main() {
// 设置一个50毫秒的超时
ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*50)
wg.Add(1)
go worker(ctx)
time.Sleep(time.Second * 5)
cancel() // 通知子goroutine结束
wg.Wait()
fmt.Println("over")
}
这是一个设置超时时间的context函数,接收父context和时间段,返回派生context和cancel函数
WithValue
WithValue
函数能够将请求作用域的数据与 Context 对象建立关系。声明如下:
Go
func WithValue(parent Context, key, val interface{}) Context
WithValue
返回父节点的副本,其中与key关联的值为val。
仅对API和进程间传递请求域的数据使用上下文值,而不是使用它来传递可选参数给函数。
所提供的键必须是可比较的,并且不应该是string
类型或任何其他内置类型,以避免使用上下文在包之间发生冲突。WithValue
的用户应该为键定义自己的类型。为了避免在分配给interface{}时进行分配,上下文键通常具有具体类型struct{}
。或者,导出的上下文关键变量的静态类型应该是指针或接口。
Go
package main
import (
"context"
"fmt"
"sync"
"time"
)
// context.WithValue
type TraceCode string
var wg sync.WaitGroup
func worker(ctx context.Context) {
key := TraceCode("TRACE_CODE")
traceCode, ok := ctx.Value(key).(string) // 在子goroutine中获取trace code
if !ok {
fmt.Println("invalid trace code")
}
LOOP:
for {
fmt.Printf("worker, trace code:%s\n", traceCode)
time.Sleep(time.Millisecond * 10) // 假设正常连接数据库耗时10毫秒
select {
case <-ctx.Done(): // 50毫秒后自动调用
break LOOP
default:
}
}
fmt.Println("worker done!")
wg.Done()
}
func main() {
// 设置一个50毫秒的超时
ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*50)
// 在系统的入口中设置trace code传递给后续启动的goroutine实现日志数据聚合
ctx = context.WithValue(ctx, TraceCode("TRACE_CODE"), "12512312234")
wg.Add(1)
go worker(ctx)
time.Sleep(time.Second * 5)
cancel() // 通知子goroutine结束
wg.Wait()
fmt.Println("over")
}
使用Context的注意事项
- 推荐以参数的方式显示传递Context
- 以Context作为参数的函数方法,应该把Context作为第一个参数。
- 给一个函数方法传递Context的时候,不要传递nil,如果不知道传递什么,就使用context.TODO()
- Context的Value相关方法应该传递请求域的必要数据,不应该用于传递可选参数
- Context是线程安全的,可以放心的在多个goroutine中传递
调用服务端API时如何在客户端实现超时控制?详细:Go标准库Context | 李文周的博客