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 mainimport (    "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,每一个租约都有一个惟一的IDleaseID := 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办法创立的协程,此时也应该被被动敞开,不应该再继续执行上来。

事实上,以后etcdLease接口中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,从而防止资源节约的问题。