Golang

关注公众号 jb51net

关闭
首页 > 脚本专栏 > Golang > Golang并发控制

Golang并发控制的三种实现方法

作者:林林林ZEYU

在Golang中,有多种方式可以进行并发控制,本文详细的介绍了三种实现方法,Channel优点是实现简单,清晰易懂,WaitGroup优点是子协程个数动态可调整,Context 优点是对子协程派生出来的孙子协程的控制,缺点是相对而言的,要结合实例应用场景进行选择

我们考虑这么一种场景,协程A执行过程中需要创建子协程A1、A2、A3…An,协程A创建完子协程后就等待子协程退 出。针对这种场景,GO提供了三种解决方案:

Channel: 使用channel控制子协程
WaitGroup : 使用信号量机制控制子协程
Context: 使用上下文控制子协程

三种方案各有优劣,比如Channel优点是实现简单,清晰易懂,WaitGroup优点是子协程个数动态可调整,Context 优点是对子协程派生出来的孙子协程的控制。缺点是相对而言的,要结合实例应用场景进行选择。

channel

channel一般用于协程之间的通信,channel也可以用于并发控制。比如主协程启动N个子协程,主协程等待所有子 协程退出后再继续后续流程,这种场景下channel也可轻易实现。

场景示例

下面程序展示一个使用channel控制子协程的例子:

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

总结

使用channel来控制子协程的优点是实现简单,缺点是当需要大量创建协程时就需要有相同数量的channel,而且对于子协程继续派生出来的协程不方便控制。后面继续介绍的WaitGroup、Context看起来比channel优雅一些,在各种开源组件中使用频率比channel高得 多。

WaitGroup

WaitGroup是Golang应用开发过程中经常使用的并发控制技术。 WaitGroup,可理解为Wait-Goroutine-Group,即等待一组goroutine结束。比如某个goroutine需要等待其 他几个goroutine全部完成,那么使用WaitGroup可以轻松实现。

下面程序展示了一个goroutine等待另外两个goroutine结束的例子:

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

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

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

信号量

信号量是Unix系统提供的一种保护共享资源的机制,用于防止多个线程同时访问某个资源。 可简单理解为信号量为一个数值:
当信号量>0时,表示资源可用,获取信号量时系统自动将信号量减1;
当信号量= =0时,表示资源暂不可用,获取信号量时,当前线程会进入睡眠,当信号量为正时被唤醒;
由于WaitGroup实现中也使用了信号量,在此做个简单介绍。

WaitGroup数据结构

源码包中 src/sync/waitgroup.go:WaitGroup 定义了其数据结构:

type WaitGroup struct { 
state1 [3]uint32
}

state1是个长度为3的数组,其中包含了state和一个信号量,而state实际上是两个计数器:

考虑到字节是否对齐,三者出现的位置不同,为简单起见,依照字节已对齐情况下,三者在内存中的位置如下所示:

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

Add()伪代码如下:

Wait()

Wait()方法也做了两件事,一是累加waiter, 二是阻塞等待信号量

这里用到了CAS算法保证有多个goroutine同时执行Wait()时也能正确累加waiter。

Done()

Done()只做一件事,即把counter减1,我们知道Add()可以接受负值,所以Done实际上只是调用了Add(-1)。 源码如下:

func (wg *WaitGroup) Done() { 
wg.Add(-1) 
}

Done()的执行逻辑就转到了Add(),实际上也正是最后一个完成的goroutine把等待者唤醒的。

注意事项:Add()操作必须早于Wait(), 否则会panicAdd()设置的值必须与实际等待的goroutine个数一致,否则会panic

context

Golang context是Golang应用开发常用的并发控制技术,它与WaitGroup最大的不同点是context对于派生 goroutine有更强的控制力,它可以控制多级的goroutine。
context翻译成中文是”上下文”,即它可以控制一组呈树状结构的goroutine,每个goroutine拥有相同的上下 文。

典型的使用场景如下图所示:

上图中由于goroutine派生出子goroutine,而子goroutine又继续派生新的goroutine,这种情况下使用 WaitGroup就不太容易,因为子goroutine个数不容易确定。而使用context就可以很容易实现。

Context实现原理

context实际上只定义了接口,凡是实现该接口的类都可称为是一种context,官方包中实现了几个常用的 context,分别可用于不同的场景。

接口定义

源码包中 src/context/context.go:Context 定义了该接口:

基础的context接口只定义了4个方法,下面分别简要说明一下:

Deadline()

该方法返回一个deadline和标识是否已设置deadline的bool值,如果没有设置deadline,则ok == false,此 时deadline为一个初始值的time.Time值

Done()

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

Err()

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

Value()

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

空context

context包中定义了一个空的context, 名为emptyCtx,用于context的根节点,空的context只是简单的实现 了Context,本身不包含任何值,仅用于其他context的父节点。
emptyCtx类型定义如下代码所示:

context包中定义了一个公用的emptCtx全局变量,名为background,可以使用context.Background()获取 它,实现代码如下所示:

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

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

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

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

struct cancelCtx、valueCtx、valueCtx都继承于Context,下面分别介绍这三个struct。

cancelCtx

源码包中 src/context/context.go:cancelCtx 定义了该类型context:

children中记录了由此context派生的所有child,此context被cancle时会把其中的所有child都cancle掉。

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。

Err()接口实现

按照Context定义,Err()只需要返回一个error告知context被关闭的原因。对于cancelCtx来说只需要返回成员 变量err即可。还是直接看下源码:

cancelCtx.err默认是nil,在context被cancel时指定一个error变量: var Canceled = errors.New(“context canceled”) 。

cancel()接口实现

cancel()内部方法是理解cancelCtx的最关键的方法,其作用是关闭自己和其后代,其后代存储在 cancelCtx.children的map中,其中key值即后代对象,value值并没有意义,这里使用map只是为了方便查询而 已。
cancel方法实现伪代码如下所示:

实际上,WithCancel()返回的第二个用于cancel context的方法正是此cancel()。

WithCancel()方法实现

WithCancel()方法作了三件事:

其实现源码如下所示:

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

典型使用案例

一个典型的使用cancel context的例子如下所示:

上面代码中协程HandelRequest()用于处理某个请求,其又会创建两个协程:WriteRedis()、 WriteDatabase(),main协程创建创建context,并把context在各子协程间传递,main协程在适当的时机可以 cancel掉所有子协程。

程序输出如下所示:

timerCtx

源码包中 src/context/context.go:timerCtx 定义了该类型context:

type timerCtx struct { 
cancelCtx 
timer *time.Timer // Under cancelCtx.mu. 
deadline time.Time 
}

timerCtx在cancelCtx基础上增加了deadline用于标示自动cancel的最终时间,而timer就是一个触发自动 cancel的定时器。由此,衍生出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()接口实现

cancel()方法基本继承cancelCtx,只需要额外把timer关闭。
timerCtx被关闭后,timerCtx.cancelCtx.err将会存储关闭原因:
如果deadline到来之前手动关闭,则关闭原因与cancelCtx显示一致;
如果deadline到来时自动关闭,则原因为:”context deadline exceeded”

WithDeadline()方法实现

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

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

WithTimeout()方法实现

WithTimeout()实际调用了WithDeadline,二者实现原理一致。 看代码会非常清晰:

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) { 
return WithDeadline(parent, time.Now().Add(timeout)) 
}

典型使用案例下面例子中使用WithTimeout()获得一个context并在其了协程中传递:

主协程中创建一个10s超时的context,并将其传递给子协程,10s自动关闭context。程序输出如下:

valueCtx

源码包中 src/context/context.go:valueCtx 定义了该类型context:

type valueCtx struct { 
Context 
key, val interface{} 
}

valueCtx只是在Context基础上增加了一个key-value对,用于在各级协程间传递一些数据。由于valueCtx既不需要cancel,也不需要deadline,那么只需要实现Value()接口即可。

Value()接口实现

由valueCtx数据结构定义可见,valueCtx.key和valueCtx.val分别代表其key和value值。 实现也很简单:

这里有个细节需要关注一下,即当前context查找不到key时,会向父节点查找,如果查询不到则最终返回interface{}。也就是说,可以通过子context查询到父的value值。

WithValue()方法实现

WithValue()实现也是非常的简单, 伪代码如下:

典型使用案例

下面示例程序展示valueCtx的用法:

上例main()中通过WithValue()方法获得一个context,需要指定一个父context、key和value。然后通将该 context传递给子协程HandelRequest,子协程可以读取到context的key-value。 注意:本例中子协程无法自动结束,因为context是不支持cancle的,也就是说<-ctx.Done()永远无法返回。

如果需要返回,需要在创建context时指定一个可以cancel的context作为父节点,使用父节点的cancel()在适当的 时机结束整个context。

总结

Context仅仅是一个接口定义,跟据实现的不同,可以衍生出不同的context类型;

cancelCtx实现了Context接口,通过WithCancel()创建cancelCtx实例; timerCtx实现了Context接口,通过WithDeadline()和WithTimeout()创建timerCtx实例;

valueCtx实现了Context接口,通过WithValue()创建valueCtx实例;

三种context实例可互为父节点,从而可以组合成不同的应用形式;

到此这篇关于Golang并发控制的方法的文章就介绍到这了,更多相关Golang并发控制内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

您可能感兴趣的文章:
阅读全文