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,然后防止资源浪费的问题。