前言

单线程能保证程序执行顺序和简单性,但无法提高吞吐量,也无法充分利用 CPU/IO 资源,并发的目的不是让执行单元同步执行,而是在保证执行单元共享数据安全的前提下,让多个执行单元并发执行,从而充分利用硬件资源。

Go协程(Goroutine)轻量高效,当多个执行单元(Goroutines)并发执行时,会产生如下并发控制问题

  • 主Goroutine怎么等待所有Goroutines执行完再退出
  • Goroutines之间怎么安全共享数据?即如何避免竞态问题
  • 多个Goroutines开启后如何1次终止

Golang标准库中的3大工具(WaitGroup、Channel、Context)分别解决了以上并发控制问题。

工具作用底层
channel goroutine 通信 队列 + 锁 +读/写等待队列
context 取消信号传播 close(channel)
WaitGroup 等待所有Goroutine结束 atomic + semaphore

WaitGroup

main函数等待,开启的子Goroutines正常执行完毕。

package main

import (
    "fmt"
    "golang.org/x/sys/windows"
    "sync"
)

var naturalNumberCh = make(chan int, 100)
var wg sync.WaitGroup
func write() {
    threadID := windows.GetCurrentThreadId()
    defer func() {
        wg.Done()
        //记得channel写入完成关闭,否则for range循环读一直不结束!
        close(naturalNumberCh)
        fmt.Printf("----write-%d结束\n", threadID)
    }()
    for i := 0; i <= 100; i++ {
        naturalNumberCh <- i
    }

}

func read() {
    defer wg.Done()
    threadID := windows.GetCurrentThreadId()
    //记得写完了关闭Channel,否则for range循环不结束!
    for n := range naturalNumberCh {
        fmt.Printf("----read-goroutine-%d读取到值%d\n", threadID, n)
    }
}

func main() {
    defer fmt.Println("mian函数结束")
    readerCount := 3
    writeCount := 1
    //开1个写go程
    for i := 0; i < writeCount; i++ {
        go write()

    }
    //开3个读go程执行结束后主动通知main函数,发请求结束的请求!
    for i := 0; i < readerCount; i++ {
        go read()
    }
    gocount := readerCount + writeCount
    wg.Add(gocount)
    wg.Wait()
}
View Code

Channel

Golang的Channel是CSP模型的核心实现,本质是

  • 环形队列(Buffer)
  • 互斥锁(Mutex)
  • 读写等待队列(Recvq/Sendq)

有以下两大使用场景

  • 无缓冲区Channel:主要用于在2个Goroutine之间建立执行同步点(可替代传统互斥锁功能)
  • 有缓冲Channel:主要用于在多个Goroutine之间安全异步传输数据(可满足并发执行单元共享内存的需求)

以上2种类型的Channel可以完全替代传统的共享内存+锁并发模型,实现更加安全、更加简洁的并发控制。

package main

import (
    "context"
    "fmt"
    "sync"
)

func main() {
    ch1 := make(chan int, 5)
    ch2 := make(chan int, 5)
    sCh := make(chan int, 10)
    wg := &sync.WaitGroup{}
    ctx, cancel := context.WithCancel(context.Background())

    wg.Add(4)

    //生产者1
    go func() {
        defer wg.Done()
        defer close(ch1)
        for i := 1; i <= 5; i++ {
            ch1 <- i
        }
    }()
    //生产者2
    go func() {
        defer wg.Done()
        defer close(ch2)
        for i := 6; i <= 10; i++ {
            ch2 <- i
        }
    }()

    //生产者:组装ch1和ch2
    go func(ctx context.Context) {
        ch1Closed := false // 标记ch1是否已关闭
        ch2Closed := false // 标记ch2是否已关闭
        defer wg.Done()
        defer close(sCh)
        for {
            if ch1Closed && ch2Closed {
                return
            }
            select {
            case i1, ok := <-ch1:
                if ok {
                    sCh <- i1
                } else {
                    ch1 = nil
                    ch1Closed = true
                }
            case i2, ok := <-ch2:
                if ok {
                    sCh <- i2
                } else {
                    ch2 = nil
                    ch2Closed = true
                }

            case <-ctx.Done():
                return
            }
        }
    }(ctx)

    //Fan-In消费者
    go func() {
        defer wg.Done()
        for i := 0; i < 10; i++ {
            fmt.Println(<-sCh)

        }
        fmt.Println("所有数据消费完成")
        cancel()
    }()
    wg.Wait()

}
waitGroup+channel+context

Context

在并发编程场景,通常会开启多个Goroutines执行任务,当多个Goroutines并发开启后,如何发送1次退出信号,就能一次性优雅退出这些Goroutines呢?一石多鸟。

单播

在Golang中Channel的发送操作具有单播语义:1个生产者向1个Channel中发送1个值,只会被1个消费者Goroutine接收并消费。(发送1次 → 只会消费1 次)

即使有多个Goroutine同时监听同1个Channel,这个值最终也只会被其中1个Goroutine抢到并消费,因此Channel更接近于消息队列,1条消息被消费后就不复存在了。

广播

Close(channel) 的行为与发送值完全不同,当Channel被关闭后,所有正在监听该Channel的消费者Goroutines都会立即被唤醒

关闭Channel实现Context

Context的Cancel取消机制正是利用了Close(channel) 的广播特性,实现了1个Goroutine向多个Goroutines广播取消信号

Context是什么?

Context直译为上下文,主要作用是利用1个Goroutine向多个Goroutines传递上下文信息。上下文信息包含

  • K-V值
  • 取消信息
  • 超时时间
  • 截止时间

Context是链式向下传播的每1次WithValue/WithCancel/WitTimeout 都在链上增加一个节点,当子Context 再派生下一代Context时,逐渐形成层级化的树形结构父节点的取消或超时会被下游感知

                context.Background()  【根节点】
                       │
        ┌──────────────┴──────────────┐
        │                             │
 WithCancel(child1)          WithTimeout(child2)  【子节点】
        │                             │
        │                     ┌───────┴───────┐
        │                     │               │
 WithValue(grand1)    WithCancel(grand2)  WithValue(grand3) 【孙子节点】
        │                     │               │
   【曾孙节点】          【曾孙节点】     【曾孙节点】

当父节点触发取消(Cancel)或超时(Timeout)时,这个信号会沿着树形结构递归传递到所有下游子节点,确保所有关联的Goroutines都能感知并优雅退出。

Goroutine泄漏

想象1个场景,老板叫了多个工人(Gorutines)来公司干活,活干完了,招来的工人(Gorutine)赖在老板办公室不走,还占老板的卧室跟老板一起睡觉,已经完成使命的Gorutine占着内存不退出,就是Gorutine泄露。

Goroutine泄露:启动了Goroutine,但Goroutine永远阻塞、永远不退出,一直占系统资源。

Channel可以保证Goroutine的数据传输通信安全且同步。

Contxet代表的是1次调用链的控制信号,同1条调用链共享1个Context,不要跨调用链复用Context可以随时终止当前调用链上关联的Goroutine,防止Goroutine泄露

Contxt有2大核心作用

  • 第一,掌控终止权:对同一调用链上的所有Goroutine统一管理 退出时机,要么主动发指令叫停,要么设置超时自动终止,杜绝协程赖着不走;
  • 第二,传递公共值:在同一调用链的Goroutine之间,安全传递请求级元数据(比如用户 ID、请求 ID、traceID),不用层层传参数,简洁又统一。

Context方法

Context是1个接口,该接口定义了四个需要实现的方法。具体签名如下:

type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key interface{}) interface{}
}

Background()和TODO() (根节点)

Go内置两个函数:Background()TODO(),这两个函数分别返回一个实现了Context接口的backgroundtodo。我们代码中最开始都是以这两个内置的上下文对象作为最顶层的partent context,衍生出更多的子上下文对象。

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

TODO(),它目前还不知道具体的使用场景,如果我们不知道该使用什么Context的时候,可以使用这个。(必须要传递1个context类型的参数)

todo本质上也是emptyCtx结构体类型,是一个不可取消,没有设置截止时间,没有携带任何值的Context

WithCancel(取消/退出)

主要用在父goroutine,控制子goroutine退出。
特性:一旦节点的Goroutine执行cancel() 关闭的时候,它的所有后代都将被关闭。         

WithDeadline(绝对超时时间)

当Context的截止日过期时, ctx.Done()返回后context deadline exceeded。

import (
	"context"
	"fmt"
	"sync"
	"time"
)

var wg sync.WaitGroup

func connectMyql(ctx context.Context) {
	defer wg.Done()
	for {
		time.Sleep(time.Second * 1)
		fmt.Println("我连我连...我连莲莲....")
		select {
		case <-ctx.Done():
			fmt.Println(ctx.Err())
			return
		default:
		}

	}
}

func main() {
	//设置context 10秒钟之后过期
	d := time.Now().Add(time.Second * 10)
	ctx, cancel := context.WithDeadline(context.Background(), d)
	/*
	尽管ctx会过期,但在任何情况下调用它的cancel函数都是很好的实践
	如果不这样做,可能会使上下文及其父类存活的时间超过必要的时间。
	*/
	defer cancel()
	wg.Add(1)
	go connectMyql(ctx)
	wg.Wait()

}

WithTimeout(相对超时时间)

和WithDeadline 是一对,过期之后context超时context deadline exceeded
 
package main

import (
	"context"
	"fmt"
	"sync"
	"time"
)

var wg sync.WaitGroup

func connectMyql(ctx context.Context) {
	defer wg.Done()
	for {
		time.Sleep(time.Second * 1)
		fmt.Println("我连我连...我连莲莲....")
		select {
		case <-ctx.Done():
			fmt.Println(ctx.Err())
			return
		default:
		}

	}
}

func main() {
	//设置context 从当前时间开始10秒钟之后过期(决对时间)
	// d := time.Now().Add(time.Second * 10)
	// ctx, cancel := context.WithDeadline(context.Background(), d)
	//设置相对时间 5秒钟后过期(相对时间)
	ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
	/*
		尽管ctx会过期,但在任何情况下调用它的cancel函数都是很好的实践
		如果不这样做,可能会使上下文及其父类存活的时间超过必要的时间。
	*/
	defer cancel()
	wg.Add(1)
	go connectMyql(ctx)
	wg.Wait()

} 

WithValue(传递值)

如何在子Goroutine开启时就与生俱来一些元数据!

WithValue可以1个Gorutin繁衍了N代子Goroutines之后,它的后代Goroutines都能with(携带)1个固定值,这样就可以自上而下追溯这个调用链了!

package main

import (
	"context"
	"fmt"
	"sync"

	"time"
)

// context.WithValue

//TraceCode 自定义类型
type TraceCode string

var wg sync.WaitGroup

func worker(ctx context.Context) {
	defer wg.Done()

	key := TraceCode("TRACE_CODE")
	// 在子goroutine中获取trace code,(string)是类型断言!
	traceCode, ok := ctx.Value(key).(string)
	if !ok {
		fmt.Println("invalid trace code")
	}

	for {
		fmt.Printf("worker, trace code:%s\n", traceCode)
		// 假设正常连接数据库耗时1秒
		time.Sleep(time.Second * 1)
		// 10秒后自动调用
		select {
		case <-ctx.Done():
			fmt.Println("worker done!")
			return
		default:
		}
	}

}

func main() {
	// 设置1个10秒的超时
	ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
	//在系统的入口中设置trace code传递给后续启动的goroutine实现微服务日志数据聚合
	ctx = context.WithValue(ctx, TraceCode("TRACE_CODE"), "666")
	wg.Add(1)
	go worker(ctx)
	//主线程等待10秒后
	time.Sleep(time.Second * 10)
	//通知子goroutine结束
	cancel()
	wg.Wait()
	fmt.Println("over")
}

Context应用场景

微服务链路追踪:作为1个微服务架构,微服务之间session不共享的服务端,如何追踪客户端1次request都调用了哪些微服务组件?并且聚合日志。

Context注意事项

错误操作导致后果正确操作
不 defer cancel Goroutine泄露 所有取消型Context必defer cancel ()
存结构体 跨请求覆盖、随机取消 Context只传不存,仅作函数参数
父取消子必挂 内层任务被意外终止 独立任务用 background 重建 Context
value 用字符串 key 数据被篡改、隐蔽Bug 自定义唯一类型作为 key
value 传业务参数 代码混乱、无参数校验 业务参数显式传,Context 只传元数据
跨请求复用 Context 批量请求异常、生产事故 一条调用链使用1个新的Context,严格区分调用链

 

 参考

posted on 2020-05-06 08:16  运维开发之路  阅读(502)  评论(0)    收藏  举报