GO并发编程

一、并发编程初体验和问题

  1. 关于 Go 语言和线程的关系

    • Go 语言中存在线程。Go 语言的并发模型是基于 Goroutine、Processor(P)和 Machine(M,操作系统线程)的 GMP 模型。Goroutine 是 Go 语言中轻量级的执行单元,由 Go 运行时管理。Go 运行时会将 Goroutine 调度到操作系统线程(M)上执行。
    • 虽然 Goroutine 是 Go 并发编程的核心抽象,但它的执行最终还是要依赖操作系统线程。Go 运行时会根据系统资源(如 CPU 核心数)自动创建和管理操作系统线程,以高效地执行 Goroutine。
  2. Go 语言并发的优势和 Goroutine 特点

    • 调度开销小:Goroutine 是基于函数级切换的,这使得它的调度相较于传统的操作系统线程的调度,内存耗费和时间耗费都小很多。这是因为 Goroutine 的切换是在用户空间进行的,不需要陷入操作系统内核进行线程切换那样的高开销操作。
    • 容易创建大量协程:由于 Goroutine 的轻量级特性,Go 语言确实很容易就可以创建大量的 Goroutine 来执行并发任务。这使得 Go 在处理高并发场景,如网络编程、分布式系统等方面表现出色。
    • 高效的调度器:Go 语言的运行时调度器采用了一些先进的策略,如工作窃取(Work Stealing)算法。每个 Processor(P)都有一个本地运行队列用于存放等待执行的 Goroutine,当本地队列空时,P 可以从全局队列或者其他 P 的本地队列中 "窃取" Goroutine 来执行,这种机制有助于充分利用多核 CPU 资源,提高并发性能。
    • 通信机制:Go 语言提供了通道(Channel)作为 Goroutine 之间的通信和同步机制。通过 Channel,Goroutine 可以安全地发送和接收数据,避免了传统并发编程中常见的数据竞争等问题。这使得 Go 语言的并发编程模型更加简单和安全。
  3. 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)

  1. 定义
    • Goroutine 是 Go 语言中轻量级的线程实现。它由 Go 运行时(runtime)管理,相比于操作系统线程,Goroutine 的创建和销毁开销非常小。
    • 多个 Goroutine 可以在一个或多个操作系统线程上并发执行。
  2. 特点
    • 它们由 Go 的runtime调度,在用户空间中实现,而不是由操作系统内核调度。
    • Goroutine 之间通过channel进行通信和同步,这有助于避免数据竞争等并发问题。

Machine(Worker,M)

  1. 定义
    • Machine(通常称为 Worker 或 M)代表操作系统线程。Go 运行时会将 Goroutine 分配到这些操作系统线程上执行。
  2. 特点
    • 每个 Machine 都有一个与之关联的 Processor(P),用于执行 Goroutine。
    • Machine 的数量通常由 Go 运行时根据系统资源(如 CPU 核心数)自动调整。

Processor(P)

  1. 定义
    • Processor(P)是 Go 运行时中的一个抽象概念,它代表执行 Goroutine 的逻辑处理器。
    • 每个 Processor 都有一个本地运行队列(Local Run Queue),用于存放等待执行的 Goroutine。
  2. 特点
    • 当一个 Goroutine 被创建时,它会被放入某个 Processor 的本地运行队列中。
    • Processor 负责从本地运行队列中取出 Goroutine,并将其分配到关联的 Machine 上执行。

三者关系

  1. 调度过程
    • 当一个 Goroutine 准备执行时,它会被放入一个 Processor 的本地运行队列。
    • Processor 会将本地运行队列中的 Goroutine 分配到关联的 Machine(操作系统线程)上执行。
    • 如果本地运行队列为空,Processor 可以从全局运行队列(Global Run Queue)或其他 Processor 的本地运行队列中 "窃取" Goroutine 来执行。
  2. 优化目的
    • GMP 模型的设计目的是为了高效地利用多核 CPU 资源,实现高并发和高性能的并发编程。通过合理地调度 Goroutine 到不同的 Processor 和 Machine 上,Go 运行时能够最大限度地发挥硬件的性能。

总之,GMP 是 Go 语言实现高效并发编程的核心机制,通过这三个概念的协同工作,Go 能够轻松地处理大量并发任务。

下图,描绘了他们的关系:

根据上图,我们再来简述一下,他们的工作调度关系:

  1. Goroutine(G)的创建
    • 程序中可以创建多个 Goroutine,如图中顶部所示的多个goroutine。这些 Goroutine 是实际执行任务的轻量级执行单元。
  2. 运行时调度器的作用
    • 当 Goroutine 被创建后,它们首先会被送到运行时调度器。运行时调度器负责管理和分配这些 Goroutine。
    • 调度器会决定将 Goroutine 分配到哪里执行。它有两个选择:本地队列(每个 Processor 都有一个本地队列)或者全局队列。
  3. 全局队列的作用
    • 如果本地队列都已满或者有其他原因,Goroutine 会被放入全局队列。全局队列用于存放那些暂时没有被分配到特定 Processor 本地队列的 Goroutine。
  4. Processor(P)的本地队列
    • 每个 Processor 都有一个本地队列。Processor 是逻辑上的执行资源,其数量通常与 CPU 核心数相关。
    • 运行时调度器会将 Goroutine 从全局队列或者直接将新创建的 Goroutine 分配到 Processor 的本地队列中。
  5. 系统级线程(M)与 Processor 的绑定
    • 每个 Processor 在运行时会绑定到一个系统级线程(M)上。系统级线程是实际执行计算的物理资源。
    • 当 Processor 的本地队列中有 Goroutine 时,它会将 Goroutine 分配到与之绑定的系统级线程上执行。系统级线程负责执行从 Processor 的本地队列中获取的 Goroutine。
  6. 工作窃取机制(图中未体现,但相关)
    • 当一个 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肯定影响性能

即使是设计锁,也应该尽量保证并行

在我们的绝大部分场景都是读多写少,经过分析,我们发现:

  1. 读协程之间可以并行

  2. 读和写之间应该串行(写的时候不可以读)

  3. 写和写之间应该串行

go中设计的读写锁,在包sync中,是sync.RWMutex

介绍用法:

Lock方法就是获取写锁,RLock方法就是获取读锁。相应的UnLock方法就是去掉写锁,RUnLock方法就是去掉读锁。

注意:

  1. 获取写锁后,所有读锁和写锁无法获取

  2. 获取读锁后,所有写锁无法获取,但是可以获取读锁

五、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的应用场景:

  1. 消息传递

  2. 信号广播

  3. 事件订阅和广播

  4. 任务分发

  5. 结果汇总

  6. 并发控制

  7. 同步和异步

。。。。。。

下面分别介绍有缓冲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()
}

小结:

  1. for range 和 传统for循环都可以对channel进行遍历

  2. for range遍历时,注意要搭配close,关闭channel。否则会继续读取空channel,阻塞后死锁(for range在channel关闭后会自动结束)

  3. 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)
	}
}

小结:

  1. channel自动转换类型(只能双向到单向)

  2. 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下的操作永远不会执行到)

注意:

  1. select语句中的所有case(通道操作)都被阻塞时,default子句就会被执行。如果select语句中有default子句,它会提供一种非阻塞的操作方式。使得select语句不会因为所有channel阻塞而阻塞当前goroutine

  2. 但是,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 调用。

对服务器传入的请求应该创建上下文,而对服务器的传出调用应该接受上下文。它们之间的函数调用链必须传递上下文,或者可以使用WithCancelWithDeadlineWithTimeoutWithValue创建的派生上下文。当一个上下文被取消时,它派生的所有上下文也被取消。

简单的说,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接口的backgroundtodo。我们代码中最开始都是以这两个内置的上下文对象作为最顶层的partent context,衍生出更多的子上下文对象。

Background()主要用于main函数、初始化以及测试代码中,作为Context这个树结构的最顶层的Context,也就是根Context。

TODO(),它目前还不知道具体的使用场景,如果我们不知道该使用什么Context的时候,可以使用这个。

backgroundtodo本质上都是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 | 李文周的博客

相关推荐
勤又氪猿1 分钟前
【问题】Qt c++ 界面 lineEdit、comboBox、tableWidget.... SIGSEGV错误
开发语言·c++·qt
Ciderw13 分钟前
Go中的三种锁
开发语言·c++·后端·golang·互斥锁·
查理零世15 分钟前
【算法】经典博弈论问题——巴什博弈 python
开发语言·python·算法
jk_1011 小时前
MATLAB中insertAfter函数用法
开发语言·matlab
啥也学不会a1 小时前
PLC通信
开发语言·网络·网络协议·c#
C++小厨神1 小时前
C#语言的学习路线
开发语言·后端·golang
心之语歌2 小时前
LiteFlow Spring boot使用方式
java·开发语言
人才程序员2 小时前
【C++拓展】vs2022使用SQlite3
c语言·开发语言·数据库·c++·qt·ui·sqlite
梁雨珈2 小时前
PL/SQL语言的图形用户界面
开发语言·后端·golang
励志的小陈3 小时前
C语言-----扫雷游戏
c语言·开发语言·游戏