1. 简介
本文将介绍首先为什么须要被动敞开 goroutine
,并介绍如何在 Go 语言中敞开goroutine
的常见套路, 包含传递终止信号和协程外部捕获终止信号。之后,文章列举了须要被动敞开协程运行的常见场景,如启动一个协程执行一个一直反复的工作。心愿通过本文的介绍,读者可能把握如何在适当的时候敞开 goroutine
,以及理解敞开goroutine
的常见套路。
2. 为什么须要敞开 goroutine
2.1 协程的生命周期
理解协程的生命周期是优雅地敞开协程的前提,因为在敞开协程之前须要晓得协程的以后状态,以便采取相应的措施。所以这里咱们须要先理解下 goroutine
的生命周期。
在 Go
语言中,协程(goroutine)是一种轻量级的线程,能够在一个程序中同时运行多个协程,进步程序的并发性能。协程的生命周期包含创立、运行和完结三个阶段。
首先须要创立一个协程,协程的创立能够通过关键字 go 来实现,例如:
go func() {// 协程执行的代码}()
下面的代码会启动一个新的协程,同时在新的协程中执行匿名函数,此时协程便已被创立了。
一旦协程被创立,它就会在新的线程中运行。协程的运行状态能够由 Go 运行时(goroutine scheduler)来治理,它会主动将协程调度到适当的 P
中运行,并确保协程的偏心调度和均衡负载。
在运行阶段,协程会一直地执行工作,直到工作实现或者遇到终止条件。在终止阶段,协程将会被回收,从而实现其整个生命周期。
综上所述,协程由 go
关键字启动,在协程中执行其业务逻辑,直到最初遇到终止条件,此时代表着协程的工作曾经完结了,将进入终止阶段。最终协程将会被回收。
2.2 协程的终止条件
失常来说,都是协程工作执行实现之后,此时协程主动退出,例如:
func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {defer wg.Done()
// 协程执行的代码
fmt.Println("协程执行结束")
}()
wg.Wait()
// 期待协程执行结束
fmt.Println("主程序完结")
下面的代码中,咱们应用 WaitGroup
期待协程执行结束。在协程执行结束后,程序会输入协程执行结束和主程序完结两条信息。
还有一种状况是协程产生 panic,它将会主动退出。例如:
func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {defer wg.Done()
// 协程执行的代码
panic("协程产生谬误")
}()
// 期待协程执行结束
wg.Wait()
fmt.Println("主程序完结")
}
在这种状况下,协程也会主动退出,不会再占用系统资源。
综合看来,协程的终止条件,其实就是协程中的工作执行实现了,或者是执行过程中产生了 panic,协程将满足终止条件,退出执行。
2.3 为什么须要被动敞开 goroutine
从下面协程的终止条件来看,失常状况下,协程只有将工作失常解决实现,协程主动退出,此时并不需要被动敞开goroutine
。
这里先举一个生产者消费者的例子,在这个例子中,咱们创立了一个生产者和一个消费者,它们之间通过一个 channel
进行通信。生产者生产数据并发送到一个 channel
中,消费者从这个 channel
中读取数据并进行解决。代码示例如下:
func main() {
// 生产者代码
go func(out chan<- int) {
for i := 0; ; i++ {
select {
case out <- i:
fmt.Printf("producer: produced %d\n", i)
time.Sleep(time.Second)
}
}
// 消费者逻辑
go func(in <-chan int) {
for {
select {
case i := <-in:
fmt.Printf("consumer: consumed %d\n", i)
}
}
}
// 让生产者协程和消费者协程始终执行上来
time.Sleep(100000000)
}
在这个例子中,咱们应用了两个 goroutine
:生产者和消费者。生产者向channel
中生产数据,消费者从 channel
中生产数据。
然而,如果生产者呈现了问题,此时生产者的协程将会被退出,不再执行。而消费者依然在期待数据的输出。此时消费者协程曾经没有存在的必要了,其实是须要退出执行。
因而,对于一些尽管没有达到终止条件的协程,然而其又没有再继续执行上来的必要,此时被动敞开其执行,从而保障程序的健壮性和性能。
3. 如何优雅得敞开 goroutine
优雅得敞开 goroutine
的执行,咱们能够遵循以下三个步骤。首先是传递敞开协程的信号,其次是协程外部须要可能到敞开信号,最初是协程退出时,可能正确开释其所占据的资源。通过以上步骤,能够保在须要时优雅地进行 goroutine
的执行。上面对这三个步骤具体进行解说。
3.1 传递敞开终止信号
首先是通过给 goroutine
传递敞开协程的信号,从而让协程进行退出操作。这里能够应用 context.Context
来传递信号,具体实现能够通过调用 WithCancel
,WithDeadline
,WithTimeout
等办法来创立一个带有勾销性能的 Context
,并在须要敞开协程时调用Cancel
办法来向 Context
发送勾销信号。示例代码如下:
ctx, cancel := context.WithCancel(context.Background())
go func(ctx context.Context) {
for {
select {
// 调用 cancel 函数后, 这里将可能收到告诉
case <-ctx.Done():
return
default:
// do something
}
}
}(ctx)
// 在须要敞开协程时调用 cancel 办法发送勾销信号
cancel()
这里,当咱们想要终止协程的执行时,只须要调用可勾销 context
对象的 Cancel
办法,协程外部将可能通过 context
对象接管到终止协程执行的告诉。
3.2 协程外部捕获终止信号
协程外部也须要在勾销信号传递过去时,可能正确被捕捉到,才可能失常终止流程。这里咱们能够应用 select
语句来监听勾销信号。select
语句能够有多个 case
子句,能够同时监听多个 channel
,当select
语句执行时,它会始终阻塞,直到有一个 case
子句能够执行。select
语句也能够蕴含 default 子句,这个子句在所有的 case 子句都不能执行时会被执行,通常用于避免 select 语句的阻塞。如下:
select {
case <-channel:
// channel 有数据到来时执行的代码
default:
// 所有 channel 都没有数据时执行的代码
}
而 context
对象的 Done
办法刚好也是返回一个 channel
, 勾销信号便是通过该channel
来进行传递的。所以咱们能够在协程外部,通过 select
语句,在其中一个 case
分支来监听勾销信号;同时应用一个 default
分支在协程中执行具体的业务逻辑。在终止信号没有到来时,就执行业务逻辑;在收到协程终止信号后,也可能及时终止协程的执行。如下:
go func(ctx context.Context) {
for {
select {
// 调用 cancel 函数后, 这里将可能收到告诉
case <-ctx.Done():
return
default:
// 执行业务逻辑
}
}
}(ctx)
3.3 回收协程资源
最初,当协程被终止执行时,须要开释占用的资源,包含文件句柄、内存等,以便其余程序能够持续应用这些资源。在 Go 语言中,能够应用 defer
语句来确保协程在退出时可能正确地开释资源。比方协程中关上了一个文件,此时能够通过 defer 语句来敞开,防止资源的透露。代码示例如下:
func doWork() {file, err := os.Open("test.txt")
if err != nil {log.Fatal(err)
}
defer file.Close()
// Do some work
}
在这个例子中,咱们在文件关上之后应用 defer
语句注册了一个函数,当协程完结时会主动调用该函数来敞开文件。这样协程无论在何时退出,咱们都能够确保文件被正确敞开,防止资源透露和其余问题。
3.4 敞开 goroutine 示例
上面展现一个简略的例子,联合 Context
对象,select
语句以及 defer
语句这三局部内容,优雅得终止一个协程的运行,具体代码示例如下:
package main
import (
"context"
"fmt"
"time"
)
func worker(ctx context.Context) {
// 最初, 在协程退出前, 开释资源.
defer fmt.Println("worker stopped")
for {
// 通过 select 语句监听勾销信号, 勾销信号没达到, 则执行业务逻辑, 等下次循环查看
select {
default:
fmt.Println("working")
case <-ctx.Done():
return
}
time.Sleep(time.Second)
}
}
func main() {ctx, cancel := context.WithCancel(context.Background())
// 启动一个协程执行工作
go worker(ctx)
// 执行 5s 后, 调用 cancel 函数终止协程
time.Sleep(5 * time.Second)
cancel()
time.Sleep(2 * time.Second)
}
在 main
函数中,咱们应用 context.WithCancel
函数创立了一个新的 context
,并将其传递给worker
函数,同时启动协程运行 worker
函数。
当 worker
函数执行 5s 后,主协程调用 cancel
函数来终止 worker
协程。之后,worker
协程中监听勾销信号的 select
语句,将可能捕捉到这个信号,执行终止协程操作。
最初,在退出协程时,通过 defer
语句实现资源的开释。综上,咱们实现了协程的优雅敞开,同时也正确回收了资源。
4. 须要被动敞开协程运行的常见场景
4.1 协程在执行一个一直反复的工作
协程在执行一个一直反复的工作时,此时协程是不会被动终止运行的。然而在某个时刻之后,不须要再继续执行该工作了,须要被动敞开 goroutine
的执行,开释协程的资源。
这里以 etcd
为例来进行阐明。etcd
次要用于在分布式系统中存储配置信息、元数据和一些小规模的共享数据。也就是说,咱们能够在 etcd
当中存储一些键值对。那么,如果咱们想要设置键值对的有效期,那该如何实现呢?
etcd
中存在一个 租约 的概念,租约能够看作是一个时间段,该时间段内某个键值对的存在是有意义的,而在租约到期后,该键值对的存在便没有意义,能够被删除,同时一个租约能够作用于多个键值对。上面先展现如何将一个租约和一个 key 进行关联的示例:
// client 为 etcd 客户端的连贯, 基于此建设一个 Lease 实例
// Lease 示例提供一些 api, 能过创立租约,勾销租约, 续约租约
lease := clientv3.NewLease(client)
// 创立一个租约, 同时租约工夫为 10 秒
grantResp, err := lease.Grant(context.Background(), 10)
if err != nil {log.Fatal(err)
}
// 租约 ID, 每一个租约都有一个惟一的 ID
leaseID := grantResp.ID
// 将租约与 key 进行关联, 此时该 key 的有效期, 也就是该租约的有效期
_, err = kv.Put(context.Background(), "key1", "value1", clientv3.WithLease(leaseID))
if err != nil {log.Fatal(err)
}
以上代码演示了如何在 etcd
中创立一个租约并将其与一个键值对进行关联。首先,通过 etcd
客户端的连贯创立了一个 Lease
实例,该实例提供了一些 api,能够创立租约、勾销租约和续约租约。而后应用 Grant
函数创立了一个租约并指定了租约的有效期为 10 秒。接下来,获取租约 ID,每个租约都有一个惟一的 ID。最初,应用 Put
函数将租约与 key 进行关联,从而将该 key 的有效期设定为该租约的有效期。
所以,咱们如果想要操作 etcd
中键值对的有效期,只须要操作租约的有效期即可。
而刚好,etcd
其实定义了一个 Lease
接口,该接口定义了对租约的一些操作,能过创立租约,勾销租约,同时也反对续约租约,获取过期工夫等内容,具体如下:
type Lease interface {
// 1. 创立一个新的租约
Grant(ctx context.Context, ttl int64) (*LeaseGrantResponse, error)
// 2. 勾销租约
Revoke(ctx context.Context, id LeaseID) (*LeaseRevokeResponse, error)
// 3. 获取租约的残余有效期
TimeToLive(ctx context.Context, id LeaseID, opts ...LeaseOption) (*LeaseTimeToLiveResponse, error)
// 4. 获取所有的租约
Leases(ctx context.Context) (*LeaseLeasesResponse, error)
// 5. 一直对租约进行续约,这里假如 10s 后过期,此时大略的含意为每隔 10s 续约一次租约, 调用该办法后, 租约将永远不会过期
KeepAlive(ctx context.Context, id LeaseID) (<-chan *LeaseKeepAliveResponse, error)
// 6. 续约一次租约
KeepAliveOnce(ctx context.Context, id LeaseID) (*LeaseKeepAliveResponse, error)
// 7. 敞开 Lease 实例
Close() error}
到此为止,咱们引出了 Lease
接口,而其中 KeepAlive
办法便是咱们今日的配角,从该办法定义能够看出,当调用 KeepAlive
办法对某个租约进行续约后,其每隔一段时间都会执行对指标租约的续约操作。这个时候个别都是启动一个协程,由协程来实现对租约的续约操作。
此时协程其实就是在执行一个一直反复的工作,那如果 Lease
接口的实例调用了 Close
办法,想要回收掉 Lease
实例,不会再通过该实例对租约进行操作,回收掉 Lease
所有占据的资源,那么 KeepAlive
办法创立的协程,此时也应该被被动敞开,不应该再继续执行上来。
事实上,以后 etcd
中Lease
接口中 KeepAlive
办法的默认实现也是如此。并且对被动敞开协程运行的实现,也是通过 context
传递对象,select
获取勾销信号,最初通过defer
来回收资源这三者组合起来实现的。
上面来看看执行续约操作的函数,会启动一个协程在后盾一直执行,具体实现如下:
func (l *lessor) sendKeepAliveLoop(stream pb.Lease_LeaseKeepAliveClient) {
for {var tosend []LeaseID
now := time.Now()
l.mu.Lock()
// keepAlives 是保留了所有待续约的 租约 ID
for id, ka := range l.keepAlives {
// 而后 nextKeepAlive 为下次续约的工夫, 如果超过该工夫, 则执行续约操作
if ka.nextKeepAlive.Before(now) {tosend = append(tosend, id)
}
}
l.mu.Unlock()
// 发送续约申请
for _, id := range tosend {r := &pb.LeaseKeepAliveRequest{ID: int64(id)}
// 向 etcd 集群发送续约申请
if err := stream.Send(r); err != nil {return}
}
select {
// 每隔 500ms 执行一次
case <-time.After(500 * time.Millisecond):
// 如果接管到终止信号, 则间接终止
case <-l.stopCtx.Done():
return
}
}
}
能够看到,其会一直循环,首先会查看以后工夫是否超过了所有租约的下次续约工夫,如果超过了,则会将这些租约的 ID 放入 tosend
数组中,并在循环的下一步中向 etcd
集群发送续约申请。接着会期待 500 毫秒,而后再次执行上述操作。失常状况下,其不会退出循环,会始终向 etcd
集群发送续约申请。除非收到了终止信号,其才会退出,从而失常完结协程。
而 stopCtx
则是 lessor
实例的变量,用于传递勾销信号。在创立 lessor
实例时,stopCtx
是由 context.WithCancel()
函数创立的。这个函数会返回两个对象:一个带有勾销办法的 context.Context
对象(即 stopCtx
),以及一个函数对象 stopCancel
,调用这个函数会勾销上下文对象。具体如下:
// 创立 Lease 实例
func NewLeaseFromLeaseClient(remote pb.LeaseClient, c *Client, keepAliveTimeout time.Duration) Lease {
// ... 省略一些无关内容
reqLeaderCtx := WithRequireLeader(context.Background())
// 通过 withCancel 函数创立 cancelCtx 对象
l.stopCtx, l.stopCancel = context.WithCancel(reqLeaderCtx)
return l
}
在 lessor.Close()
函数中,咱们调用 stopCancel()
函数来发送勾销信号。
func (l *lessor) Close() error {l.stopCancel()
// close for synchronous teardown if stream goroutines never launched
// 省略无关内容
return nil
}
因为 sendKeepAliveLoop()
协程会在 stopCtx
上期待信号,所以一旦调用了 stopCancel()
,协程会收到信号并退出。这个机制非常灵活,因为 stopCtx
是实例的成员变量,所以 lessor
实例创立的所有协程,都能够通过监听 stopCtx
来决定是否要退出执行。
5. 总结
这篇文章次要介绍了为什么须要被动敞开 goroutine
,以及在 Go 语言中敞开goroutine
的常见套路。
文章首先介绍了为什么须要被动敞开 goroutine
。接下来,文章具体介绍了Go
语言中敞开 goroutine
的常见套路,包含传递终止信号和协程外部捕获终止信号。在传递终止信号的计划中,文章介绍了如何应用 context
对象传递信号,并应用 select
语句期待信号。在协程外部捕获终止信号的计划中,文章介绍了如何应用 defer
语句来回收资源。
最初,文章列举了须要被动敞开协程运行的常见场景,如协程在执行一个一直反复的工作,在不再须要继续执行上来的话,就须要被动敞开协程的执行。心愿通过本文的介绍,读者可能把握如何在适当的时候敞开goroutine
,从而防止资源节约的问题。