关于go:如何优雅得关闭协程呢

3次阅读

共计 7822 个字符,预计需要花费 20 分钟才能阅读完成。

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 办法创立的协程,此时也应该被被动敞开,不应该再继续执行上来。

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

正文完
 0