Go学习之并发控制篇(Channel,WaitGroup,Context)

参考

并发控制

我们考虑这么一种场景,协程A执行过程中需要创建子协程A1、A2、A3…An,协程A创建完子协程后就等待子协程退出。
针对这种场景,GO提供了三种解决方案:
- Channel: 使用channel控制子协程
- WaitGroup : 使用信号量机制控制子协程
- Context: 使用上下文控制子协程
三种方案各有优劣,比如Channel优点是实现简单,清晰易懂,WaitGroup优点是子协程个数动态可调整,Context优点是对子协程派生出来的孙子协程的控制。
缺点是相对而言的,要结合实例应用场景进行选择。

通道 channel

1. 使用必须要初始化(同 map 一样),这是声明个 int 类型的通道
	ch:=make(chan int)
2. 收发信息( <- 符号固定,ch 在左为写入通道,ch 在右 为从通道读)
	ch <- 2 //发送数值2给这个通道
	x:=<-ch //从通道里读取值,并把读取的值赋值给x变量
	<-ch //从通道里读取值,然后忽略
	- 自己总结记忆法(从右向左读法)
		- ch <- ,读为「向通道」,理解为向通道传输,那么就是发送
		- <- ch ,读为「通道向」,理解为通道向外传,那么就是取值
3. 关闭通道
	close(ch)
4. 有无缓冲通道
	ch:=make(chan int)   无缓冲
	ch:=make(chan int,0) 同上,无缓冲
	ch:=make(chan int,2)	有缓冲
	- 无缓冲通道,又称为「同步通道」,因为没有存储能力,因此要求「发送」和「接收」goroutine 必须同时准备好
		- 若没有同时准备好,那么先执行的操作就会「阻塞等待」,知道另一个相对应的操作准备好为止,因此也称之为「同步」
	- 有缓冲的通道,队列满的时候,「发送」操作阻塞;队列空的时候,「接收」操作会阻塞
		- 因此此方式可解耦发送和接收操作
		- 如何知道通道中有多少元素?
			- cap(ch) 返回通道的最大数量
			- len(ch) 返回通道中现在有多少个元素
5. 单向通道
	var send chan<- int //只能发送
	var receive <-chan int //只能接收

举例

控制子协程

  • 使用channel来控制子协程的优点是实现简单

  • 缺点是当需要大量创建协程时就需要有相同数量的channel,而且对于子协程继续派生出来的协程不方便控制

  • 后面继续介绍的WaitGroup、Context看起来比channel优雅一些,在各种开源组件中使用频率比channel高得多。

// 上面程序通过创建N个channel来管理N个协程,每个协程都有一个channel用于跟父协程通信,父协程创建完所有协程后等待所有协程结束。
// 这个例子中,父协程仅仅是等待子协程结束,其实父协程也可以向管道中写入数据通知子协程结束,这时子协程需要定期地探测管道中是否有消息出现。
package main

import (
    "time"
    "fmt"
)

func Process(ch chan int) {
    //Do some work...
    time.Sleep(time.Second)

    ch <- 1 //管道中写入一个元素表示当前协程已结束
}

func main() {
    channels := make([]chan int, 10) //创建一个10个元素的切片,元素类型为channel

    for i:= 0; i < 10; i++ {
        channels[i] = make(chan int) //切片中放入一个channel
        go Process(channels[i])      //启动协程,传一个管道用于通信
    }

    for i, ch := range channels {  //遍历切片,等待子协程结束
        <-ch
        fmt.Println("Routine ", i, " quit!")
    }
}

模拟管道

// 这里例子中我们定义两个通道one和two,然后按照顺序,先把100发送给通道one,然后用另外一个goroutine从one接收值,
// 再发送给通道two,最终在主goroutine里等着接收打印two通道里的值,这就类似于一个管道的操作,
// 把通道one的输出,当成通道two的输入,类似于接力赛一样。
func main() {
	one := make(chan int)
	two := make(chan int)

	go func() {
		one<-100
	}()

	go func() {
		v:=<-one
		two<-v
	}()

	fmt.Println(<-two)

}

WaitGroup

简单说来,WaitGroup通常用于等待一组“工作协程”结束的场景,其内部维护两个计数器,这里把它们称为“工作协程”计数器和“坐等协程”计数器,
WaitGroup对外提供的三个方法分工非常明确:

  • Add(delta int)方法用于增加“工作协程”计数,通常在启动新的“工作协程”之前调用;
  • Done()方法用于减少“工作协程”计数,每次调用递减1,通常在“工作协程”内部且在临近返回之前调用;
  • Wait()方法用于增加“坐等协程”计数,通常在所有”工作协程”全部启动之后调用;

Done()方法除了负责递减“工作协程”计数以外,还会在“工作协程”计数变为0时检查“坐等协程”计数器并把“坐等协程”唤醒。
需要注意的是,Done()方法递减“工作协程”计数后,如果“工作协程”计数变成负数时,将会触发panic,这就要求Add()方法调用要早于Done()方法。

此外,通过Add()方法累加的“工作协程”计数要与实际需要等待的“工作协程”数量一致,否则也会触发panic
当“工作协程”计数多于实际需要等待的“工作协程”数量时,“坐等协程”可能会永远无法被唤醒而产生列锁,此时,Go运行时检测到死锁会触发panic,
当“工作协程”计数小于实际需要等待的“工作协程”数量时,Done()会在“工作协程”计数变为负数时触发panic

使用示例

简单的说,下面程序中wg内部维护了一个计数器:

  1. 启动goroutine前将计数器通过Add(2)将计数器设置为待启动的goroutine个数。
  2. 启动goroutine后,使用Wait()方法阻塞自己,等待计数器变为0。
  3. 每个goroutine执行结束通过Done()方法将计数器减1。
  4. 计数器变为0后,阻塞的goroutine被唤醒。

其实WaitGroup也可以实现一组goroutine等待另一组goroutine,这有点像玩杂技,很容出错,如果不了解其实现原理更是如此。实际上,WaitGroup的实现源码非常简单。

package main

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

func main() {
    var wg sync.WaitGroup

    wg.Add(2) //设置计数器,数值即为goroutine的个数
    go func() {
        //Do some work
        time.Sleep(1*time.Second)

        fmt.Println("Goroutine 1 finished!")
        wg.Done() //goroutine执行结束后将计数器减1
    }()

    go func() {
        //Do some work
        time.Sleep(2*time.Second)

        fmt.Println("Goroutine 2 finished!")
        wg.Done() //goroutine执行结束后将计数器减1
    }()

    wg.Wait() //主goroutine阻塞等待计数器变为0
    fmt.Printf("All Goroutine finished!")
}

实现原理

预备知识 —— 信号量

信号量是Unix系统提供的一种保护共享资源的机制,用于防止多个线程同时访问某个资源。

可简单理解为信号量为一个数值:

  • 当信号量>0时,表示资源可用,获取信号量时系统自动将信号量减1;
  • 当信号量==0时,表示资源暂不可用,获取信号量时,当前线程会进入睡眠,当信号量为正时被唤醒;

由于WaitGroup实现中也使用了信号量,在此做个简单介绍。

WaitGroup 数据结构

type WaitGroup struct {
    state1 [3]uint32
}

// state1是个长度为3的数组,其中包含了state和一个信号量,而state实际上是两个计数器:
// - counter: 当前还未执行结束的goroutine计数器
// - waiter count: 等待goroutine-group结束的goroutine数量,即有多少个等候者
// - semaphore: 信号量
// 考虑到字节是否对齐,三者出现的位置不同,为简单起见,依照字节已对齐情况下,三者在内存中的位置如下所示:

img

接口

// 简单描述
// - counter: 现在正在执行的 goroutine 数量
// - waiter count: 等待结束后,要运行的 goroutine 数量
// - semaphore: 信号量,>0 表示资源可用,==0 表示资源暂不可用

// WaitGroup对外提供三个接口:
// - Add(delta int): 将delta值加到counter中
// - Wait(): waiter递增1,并阻塞等待信号量semaphore
// - Done(): counter递减1,按照waiter数值释放相应次数信号量
--------
// Add(delta int) 方法
// Add()做了两件事,一是把delta值累加到counter中,因为delta可以为负值,也就是说counter有可能变成0或负值
// 所以第二件事就是当counter值变为0时,根据waiter数值释放等量的信号量,把等待的goroutine全部唤醒,如果counter变为负值,则panic.
func (wg *WaitGroup) Add(delta int) {
    statep, semap := wg.state() //获取state和semaphore地址指针

    state := atomic.AddUint64(statep, uint64(delta)<<32) //把delta左移32位累加到state,即累加到counter中
    v := int32(state >> 32) //获取counter值
    w := uint32(state)      //获取waiter值

    if v < 0 {              //经过累加后counter值变为负值,panic
        panic("sync: negative WaitGroup counter")
    }

    //经过累加后,此时,counter >= 0
    //如果counter为正,说明不需要释放信号量,直接退出
    //如果waiter为零,说明没有等待者,也不需要释放信号量,直接退出
    if v > 0 || w == 0 {
        return
    }

    //此时,counter一定等于0,而waiter一定大于0(内部维护waiter,不会出现小于0的情况),
    //先把counter置为0,再释放waiter个数的信号量
    *statep = 0
    for ; w != 0; w-- {
        runtime_Semrelease(semap, false) //释放信号量,执行一次释放一个,唤醒一个等待者
    }
}

-------
// Wait() 方法
// Wait()方法也做了两件事,一是累加waiter, 二是阻塞等待信号量
func (wg *WaitGroup) Wait() {
    statep, semap := wg.state() //获取state和semaphore地址指针
    for {
        state := atomic.LoadUint64(statep) //获取state值
        v := int32(state >> 32)            //获取counter值
        w := uint32(state)                 //获取waiter值
        if v == 0 {                        //如果counter值为0,说明所有goroutine都退出了,不需要待待,直接返回
            return
        }

        // 使用CAS(比较交换算法)累加waiter,累加可能会失败,失败后通过for loop下次重试
        if atomic.CompareAndSwapUint64(statep, state, state+1) {
            runtime_Semacquire(semap) //累加成功后,等待信号量唤醒自己
            return
        }
    }
}
-------
// Done() 方法
// Done()只做一件事,即把counter减1,我们知道Add()可以接受负值,所以Done实际上只是调用了Add(-1)。
func (wg *WaitGroup) Done() {
    wg.Add(-1)
}

Context

简述作用

  • 与WaitGroup最大的不同点是context对于派生goroutine有更强的控制力,它可以控制多级的goroutine
  • context翻译成中文是”上下文”,即它可以控制一组呈树状结构的goroutine,「每个goroutine拥有相同的上下文」。
image-20211115115301836

实现原理

接口定义

// 源码包 src/context/context.go:Context
type Context interface {
    // 该方法返回一个deadline和标识是否已设置deadline的bool值,
  	// 如果没有设置deadline,则ok == false,此时deadline为一个初始值的time.Time值
    Deadline() (deadline time.Time, ok bool)

  	// 该方法返回一个channel,需要在select-case语句中使用,如”case <-context.Done():”。
  	// 当context关闭后,Done()返回一个被关闭的管道,关闭的管道仍然是可读的,据此goroutine可以收到关闭请求;
  	// 当context还未关闭时,Done()返回nil。
    Done() <-chan struct{}

  	// 该方法描述context关闭的原因。关闭原因由context实现控制,不需要用户设置。
  	// 比如Deadline context,关闭原因可能是因为deadline,也可能提前被主动关闭,那么关闭原因就会不同:
  	// - 因deadline关闭:“context deadline exceeded”;
		// - 因主动关闭: “context canceled”。
  	// 当context关闭后,Err()返回context的关闭原因;
  	// 当context还未关闭时,Err()返回nil
    Err() error

  	// 有一种context,它不是用于控制呈树状分布的goroutine,而是「用于在树状分布的goroutine间传递信息」。
  	// Value()方法就是用于此种类型的context,该方法根据key值查询map中的value。具体使用后面示例说明。
    Value(key interface{}) interface{}
}

空 context

// context包中定义了一个空的context, 名为emptyCtx,用于context的根节点,
// 空的context只是简单的实现了Context,本身不包含任何值,仅用于其他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 interface{}) interface{} {
    return nil
}

// context包中定义了一个公用的emptCtx全局变量,名为background,可以使用context.Background()获取它,实现代码如下所示:
var background = new(emptyCtx)
func Background() Context {
    return background
}

四种方法实现不同类型 context

context包提供了4个方法创建不同类型的context,使用这四个方法时如果没有父context,都需要传入backgroud,即backgroud作为其父节点:

  • WithCancel()
  • WithDeadline()
  • WithTimeout()
  • WithValue()

context包中实现Context接口的struct,除了emptyCtx外,还有cancelCtx、timerCtx和valueCtx三种,正是基于这三种context实例,实现了上述4种类型的context。

context包中各context类型之间的关系,如下图所示:

img

小结

  • Context仅仅是一个接口定义,根据实现的不同,可以衍生出不同的context类型;
  • cancelCtx实现了Context接口,通过WithCancel()创建cancelCtx实例;
  • timerCtx实现了Context接口,通过WithDeadline()和WithTimeout()创建timerCtx实例;
  • valueCtx实现了Context接口,通过WithValue()创建valueCtx实例;
  • 三种context实例可互为父节点,从而可以组合成不同的应用形式;

cancelCtx —— WithCancel()方法实现

使用

WithCancel()方法作了三件事:

  • 初始化一个cancelCtx实例
  • 将cancelCtx实例添加到其父节点的children中(如果父节点也可以被cancel的话)
  • 返回cancelCtx实例和cancel()方法

其实现源码如下所示:

func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
    c := newCancelCtx(parent)
    propagateCancel(parent, &c)   //将自身添加到父节点
    return &c, func() { c.cancel(true, Canceled) }
}

这里将自身添加到父节点的过程有必要简单说明一下:

  1. 如果父节点也支持cancel,也就是说其父节点肯定有children成员,那么把新context添加到children里即可;
  2. 如果父节点不支持cancel,就继续向上查询,直到找到一个支持cancel的节点,把新context添加到children里;
  3. 如果所有的父节点均不支持cancel,则启动一个协程等待父节点结束,然后再把当前context结束。
实现原理
// 源码包中src/context/context.go:cancelCtx 定义了该类型context:
type cancelCtx struct {
    Context

    mu       sync.Mutex            // protects following fields
    done     chan struct{}         // created lazily, closed by first cancel call
  	// children中记录了由此context派生的所有child,此context被cancel时会把其中的所有child都cancel掉。
    children map[canceler]struct{} // set to nil by the first cancel call
    err      error                 // set to non-nil by the first cancel call
}

// cancelCtx与deadline和value无关,所以「只需要实现Done()和Err()外露接口即可」。
---------
// Done()接口实现
// 按照Context定义,「Done()接口只需要返回一个channel即可」,对于cancelCtx来说只需要返回成员变量done即可。

// 由于cancelCtx没有指定初始化函数,所以cancelCtx.done可能还未分配,所以需要考虑初始化。
// cancelCtx.done会在context被cancel时关闭,所以cancelCtx.done的值一般经历如下三个阶段:
// nil –> chan struct{} –> closed chan。
func (c *cancelCtx) Done() <-chan struct{} {
    c.mu.Lock()
    if c.done == nil {
        c.done = make(chan struct{})
    }
    d := c.done
    c.mu.Unlock()
    return d
}
---------
// Err()接口实现
// 按照Context定义,Err()「只需要返回一个error告知context被关闭的原因」。对于cancelCtx来说只需要返回成员变量err即可。

// cancelCtx.err默认是nil,在context被cancel时指定一个error变量: var Canceled = errors.New("context canceled")
func (c *cancelCtx) Err() error {
    c.mu.Lock()
    err := c.err
    c.mu.Unlock()
    return err
}

----------

// cancel()接口实现
// cancel()内部方法是理解cancelCtx的最关键的方法,「其作用是关闭自己和其后代」
// 其后代存储在cancelCtx.children的map中,其中key值即后代对象,value值并没有意义,这里使用map只是为了方便查询而已
func (c *cancelCtx) cancel(removeFromParent bool, err error) {
    c.mu.Lock()

    c.err = err                          //设置一个error,说明关闭原因
    close(c.done)                     //将channel关闭,以此通知派生的context

    for child := range c.children {   //遍历所有children,逐个调用cancel方法
        child.cancel(false, err)
    }
    c.children = nil
    c.mu.Unlock()

    if removeFromParent {            //正常情况下,需要将自己从parent删除
        removeChild(c.Context, c)
    }
}

timerCtx —— WithDeadline()方法实现

使用

WithDeadline()方法实现步骤如下:

  • 初始化一个timerCtx实例
  • 将timerCtx实例添加到其父节点的children中(如果父节点也可以被cancel的话)
  • 启动定时器,定时器到期后会自动cancel本context
  • 返回timerCtx实例和cancel()方法

也就是说,timerCtx类型的context不仅支持手动cancel,也会在定时器到来后自动cancel。

实现
// 源码包中src/context/context.go:timerCtx 定义了该类型context:
type timerCtx struct {
    cancelCtx					// 继承上面的结构定义
    timer *time.Timer // Under cancelCtx.mu.
		// timerCtx在cancelCtx基础上『增加了deadline用于标示自动cancel的最终时间』,而timer就是一个触发自动cancel的定时器。
    deadline time.Time
}
// 由此,「衍生出WithDeadline()和WithTimeout()」。实现上这两种类型实现原理一样,只不过使用语境不一样:
// - deadline: 指定最后期限,比如context将2018.10.20 00:00:00之时自动结束
// - timeout: 指定最长存活时间,比如context将在30s后结束。
// 对于接口来说,『timerCtx在cancelCtx基础上还需要实现Deadline()和cancel()方法』,其中cancel()方法是重写的。
--------
// Deadline()接口实现
// Deadline()方法「仅仅是返回timerCtx.deadline而矣」。而timerCtx.deadline是WithDeadline()或WithTimeout()方法设置的。

--------
// cancel()方法基本继承cancelCtx,只需要额外把timer关闭。
// timerCtx被关闭后,timerCtx.cancelCtx.err将会存储关闭原因:
// - 如果deadline到来之前手动关闭,则关闭原因与cancelCtx显示一致;
// - 如果deadline到来时自动关闭,则原因为:”context deadline exceeded”
--------
// WithDeadline()方法实现步骤如下:
// - 初始化一个timerCtx实例
// - 将timerCtx实例添加到其父节点的children中(如果父节点也可以被cancel的话)
// - 启动定时器,定时器到期后会自动cancel本context
// - 返回timerCtx实例和cancel()方法
// 也就是说,timerCtx类型的context不仅支持手动cancel,也会在定时器到来后自动cancel。
-------
// WithTimeout()实际调用了WithDeadline,二者实现原理一致。
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
    return WithDeadline(parent, time.Now().Add(timeout))
}

valueCtx —— WithValue()方法实现

使用
// 上例main()中通过WithValue()方法获得一个context,需要指定一个父context、key和value。
// 然后通将该context传递给子协程HandelRequest,子协程可以读取到context的key-value。

// 注意:本例中子协程无法自动结束,因为context是不支持cancle的,也就是说<-ctx.Done()永远无法返回。
// 如果需要返回,需要在创建context时指定一个可以cancel的context作为父节点,使用父节点的cancel()在适当的时机结束整个context。
package main

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

func HandelRequest(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("HandelRequest Done.")
            return
        default:
            fmt.Println("HandelRequest running, parameter: ", ctx.Value("parameter"))
            time.Sleep(2 * time.Second)
        }
    }
}

func main() {
    ctx := context.WithValue(context.Background(), "parameter", "1")
    go HandelRequest(ctx)

    time.Sleep(10 * time.Second)
}
实现
// 源码包中src/context/context.go:valueCtx 定义了该类型context:
// valueCtx只是在Context基础上「增加了一个key-value对」,用于在各级协程间传递一些数据。
type valueCtx struct {
    Context
    key, val interface{}
}

// 由于valueCtx既不需要cancel,也不需要deadline,那么只需要实现Value()接口即可。
--------
// Value()接口实现
// 由valueCtx数据结构定义可见,valueCtx.key和valueCtx.val分别代表其key和value值。 实现也很简单:
// 这里有个细节需要关注一下,即当前context查找不到key时,会向父节点查找,如果查询不到则最终返回interface{}。也就是说,可以通过子context查询到父的value值。
func (c *valueCtx) Value(key interface{}) interface{} {
    if c.key == key {
        return c.val
    }
    return c.Context.Value(key)
}
---------
func WithValue(parent Context, key, val interface{}) Context {
    if key == nil {
        panic("nil key")
    }
    return &valueCtx{parent, key, val}
}
  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Go语言中的channel、sync.WaitGroupcontext是三个非常重要的并发编程工具。下面我将对它们进行详细介绍。 ### Channel Go语言中的channel是一种在多个goroutine之间进行通信的机制。也可以说,channel是一种数据结构,它可以让一个goroutine向另一个goroutine发送一个值,同时还可以让另一个goroutine从channel中接收这个值。在Go语言中,使用make函数创建一个channel。例如: ```go ch := make(chan int) ``` 这行代码创建了一个类型为int的channel。可以在goroutine中使用ch <- value语句向channel发送一个整数,例如: ```go go func() { ch <- 1 }() ``` 可以使用value := <- ch语句从channel中接收一个整数,例如: ```go value := <- ch ``` 这行代码会阻塞,直到有一个整数被发送到这个channel中为止。需要注意的是,如果没有接收者,发送操作会一直阻塞,直到有接收者为止;如果没有发送者,接收操作也会一直阻塞,直到有发送者为止。 ### sync.WaitGroup sync.WaitGroupGo语言中的一个同步工具,它可以等待一组goroutine完成工作。在WaitGroup中,每个goroutine的工作完成后,都需要调用Done方法。主goroutine可以在Wait方法上阻塞,等待所有的goroutine完成工作。例如: ```go var wg sync.WaitGroup for i := 0; i < 10; i++ { wg.Add(1) go func() { // do some work wg.Done() }() } wg.Wait() ``` 这行代码创建了一个WaitGroup,并且启动了10个goroutine进行工作。每个goroutine完成工作后,都会调用wg.Done方法,主goroutine在wg.Wait上阻塞,等待所有的goroutine完成工作。 ### context contextGo语言中的一个用于传递请求范围数据的机制。在一个请求处理中,可以使用context携带一些请求数据,同时也可以使用context取消请求处理。例如: ```go func handleRequest(ctx context.Context) { // do some work select { case <-ctx.Done(): // handle cancelation default: // continue working } } ``` 这行代码定义了一个处理请求的函数,该函数接收一个context参数。如果context被取消,处理请求的函数将会停止工作。例如: ```go ctx, cancel := context.WithCancel(context.Background()) go func() { time.Sleep(time.Second) cancel() }() handleRequest(ctx) ``` 这行代码创建了一个带有取消功能的context,并且启动了一个goroutine在1秒后取消context。handleRequest函数会使用这个context来处理请求,并且如果context被取消,handleRequest函数会立刻停止工作。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值