Go 并发控制 Wait & Cancel

Wait 和 Cancel 两种并发控制方式,在使用 Go 开发服务的时候到处都有体现,只要使用了并发就会用到这两种模式。

在 Go 语言中,分别有 sync.WaitGroup 和 context.Context 来实现这两种模式。

sync.WaitGroup 等待多个线程完成

对于要等待 n 个线程完成后再进行下一步的同步操作的做法,使用 sync.WaitGroup 来等待一组事件:

go 复制代码
func main() {
 var wg sync.WaitGroup
 // 开N个后台打印线程
 for i := 0; i < 10; i++ {
  wg.Add(1)
  go func() {
   defer wg.Done()
   fmt.Println("你好, 世界")
  }()
 }
 // 等待 N 个后台线程完成
 wg.Wait()
}

每个 sync.WaitGroup 值内部维护着一个计数。此计数的初始值为 0。如果一个 sync.WaitGroup 值的 Wait 方法在此计数为 0 的时候被调用,则此调用不会阻塞,否则此调用将一直阻塞到此计数变为 0 为止。

为了让一个 WaitGroup 值的使用有意义,在此值的计数为 0 的情况下,对它的下一次 Add 方法的调用必须出现在对它的下一次 Wait 方法的调用之前,即 Add 方法的调用在协程之外

e.g.

go 复制代码
func worker(args ...interface{}) {
 if len(args) == 0 {
  return
 }

 interval, ok := args[0].(int)
 if !ok {
  return
 }

 time.Sleep(time.Second * (time.Duration(interval)))
}

func spawnGroup(n int, f func(args ...interface{}), args ...interface{}) chan struct{} {
 c := make(chan struct{})
 var wg sync.WaitGroup

 for i := 0; i < n; i++ {
  wg.Add(1)
  go func(i int) {
   defer wg.Done()
   name := fmt.Sprintf("worker-%d:", i)
   f(args...)
   fmt.Println(name, "done")
  }(i)
 }

 go func() {
  wg.Wait()
  c <- struct{}{}
 }()

 return c
}

func main() {
 done := spawnGroup(5, worker, 3)
 fmt.Println("spawn a group of workers")
 <-done
 fmt.Println("group workers done")
}
context.Context 超时控制和资源回收
  1. context.Context 类型的值可以协调多个 groutine 中的代码执行"取消"操作,并且可以存储键值对,最重要的是它是并发安全的。
  2. 与它协作的 API 都可以由外部控制执行"取消"操作,例如:取消一个 HTTP 请求的执行。

Go 语言是带内存自动回收的特性,因此内存一般不会泄漏。但是 goroutine 的确存在泄漏的情况,同时泄漏的 goroutine 引用的内存同样无法被回收。

go 复制代码
package main

import (
 "fmt"
)

func main() {
 ch := func() <-chan int {
  ch := make(chan int)
  go func() {
   for i := 0; ; i++ {
    ch <- i
   }
  }()
  return ch
 }()

 for v := range ch {
  fmt.Println(v)
  if v == 5 {
   break
  }
 }
}

上面的程序中后台 goroutine 向管道输入自然数序列,main 函数中输出序列。但是当 break 跳出 for 循环的时候,后台 goroutine 就处于无法被回收的状态了。

我们可以通过 context 包来避免这个问题:

go 复制代码
package main

import (
 "context"
 "fmt"
)

func main() {
 ctx, cancel := context.WithCancel(context.Background())

 ch := func(ctx context.Context) <-chan int {
  ch := make(chan int)
  go func() {
   for i := 0; ; i++ {
    select {
    case <-ctx.Done():
     return
    case ch <- i:
    }
   }
  }()
  return ch
 }(ctx)

 for v := range ch {
  fmt.Println(v)
  if v == 5 {
   cancel()
   break
  }
 }
}

当 main 函数在 break 跳出循环时,通过调用 cancel 来通知后台 goroutine 退出,这样就避免了 goroutine 的泄漏。

  1. context.Background 是上下文的默认值,所有其他的上下文都应该从它衍生出来。
  2. context.WithCancel 函数能够从 context.Context 中衍生出一个新的子上下文并返回用于取消该上下文的函数。一旦我们执行返回的取消函数,当前上下文以及它的子上下文都会被取消,所有的 goroutine 都会同步收到这一取消信号。

e.g.

使用 httptest 包的 NewServer 函数创建了三个模拟的"气象数据服务中心",然后将这三个"气象数据服务中心"的实例传入 first 函数。后者创建了三个 goroutine,每个 goroutine 对应向一个"气象数据服务中心"发起查询请求。

  1. 三个发起查询的 goroutine 都会将应答结果写入同一个 channel 中,first 获取第一个结果数据后就返回了。
  2. 通过增加一个定时器,并通过 select 原语监视该定时器事件和响应 channel 上的事件。如果响应 channel 上长时间没有数据返回,则当定时器事件触发后,first 函数返回。
  3. 加上了"超时模式"的版本依然有一个明显的问题,那就是即便 first 函数因超时返回,三个已经创建的 goroutine 可能依然处在向"气象数据服务中心"请求或等待应答中,没有返回,也没有被回收,资源仍然在占用,即使它们的存在已经没有了任何意义。一种合理的解决思路是让这三个 goroutine 支持"取消"操作。这种情况下,我们一般使用 Go 的 context 包来实现"取消"模式。
go 复制代码
package main

import (
 "context"
 "errors"
 "fmt"
 "io"
 "log"
 "net/http"
 "net/http/httptest"
 "time"
)

type result struct {
 value string
}

func first(servers ...*httptest.Server) (result, error) {
 c := make(chan result)
 ctx, cancel := context.WithCancel(context.Background())
 defer cancel()

 queryFunc := func(i int, server *httptest.Server) {
  url := server.URL
  req, err := http.NewRequest("GET", url, nil)
  if err != nil {
   log.Printf("query goroutine-%d: http NewRequest error: %s\n", i, err)
   return
  }
  req = req.WithContext(ctx)

  log.Printf("query goroutine-%d: send request...\n", i)
  resp, err := http.DefaultClient.Do(req)
  if err != nil {
   log.Printf("query goroutine-%d: get return error: %s\n", i, err)
   return
  }
  log.Printf("query goroutine-%d: get response\n", i)
  defer resp.Body.Close()
  body, _ := io.ReadAll(resp.Body)

  c <- result{
   value: string(body),
  }
  return
 }

 for i, serv := range servers {
  go queryFunc(i, serv)
 }

 select {
 case r := <-c:
  return r, nil
 case <-time.After(500 * time.Millisecond):
  return result{}, errors.New("timeout")
 }
}

func fakeWeatherServer(name string, interval int) *httptest.Server {
 return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  log.Printf("%s receive a http request\n", name)
  time.Sleep(time.Duration(interval) * time.Millisecond)
  w.Write([]byte(name + ":ok"))
 }))
}

func main() {
 result, err := first(
  fakeWeatherServer("open-weather-1", 200),
  fakeWeatherServer("open-weather-2", 1000),
  fakeWeatherServer("open-weather-3", 600),
 )
 if err != nil {
  log.Println("invoke first error:", err)
  return
 }

 fmt.Println(result)
 time.Sleep(10 * time.Second)
}

利用 context.WithCancel 创建了一个可以取消的 context.Context 变量,在每个发起查询请求的 goroutine 中,我们用该变量更新了 request 中的 ctx 变量,使其支持"被取消"。

这样在 first 函数中,无论是成功得到某个查询 goroutine 的返回结果,还是超时失败返回,通过 defer cancel() 设定 cancel 函数在 first 函数返回前被执行,那些尚未返回的在途(on-flight)查询的 goroutine 都将收到 cancel 事件并退出( http 包支持利用 context.Context 的超时和 cancel 机制)。

e.g.

http 包支持利用 context.Context 的超时机制。

go 复制代码
 // Create a new context
 // With a deadline of 100 milliseconds
 ctx, _ := context.WithTimeout(context.Background(), 100*time.Millisecond)

 // Make a request, that will call the google homepage
 req, _ := http.NewRequest(http.MethodGet, "http://google.com", nil)
 // Associate the cancellable context we just created to the request
 req = req.WithContext(ctx)

 // Create a new HTTP client and execute the request
 client := http.DefaultClient
 res, err := client.Do(req)
 // If the request failed, log to STDOUT
 if err != nil {
  fmt.Println("Request failed:", err)
  return
 }
 // Print the statuscode if the request succeeds
 fmt.Println("Response received, status code:", res.StatusCode)
}

e.g.

在下面这段代码中,我们创建了一个过期时间为 1s 的上下文,并向上下文传入 handle 函数,该方法会使用 500ms 的时间处理传入的请求:

css 复制代码
func handle(ctx context.Context, duration time.Duration) {
 select {
 case <-ctx.Done():
  fmt.Println("handle", ctx.Err())
 case <-time.After(duration):
  fmt.Println("process request with", duration)
 }
}

func main() {
 ctx, cancel := context.WithTimeout(context.Background(), time.Second)
 defer cancel()
 go handle(ctx, 500*time.Millisecond)
 select {
 case <-ctx.Done():
  fmt.Println("main", ctx.Err())
 }
 time.Sleep(time.Second)
}

因为过期时间大于处理时间,所以我们有足够的时间处理该请求,运行上述代码会打印出下面的内容:

vbscript 复制代码
process request with 500ms
main context deadline exceeded

handle 函数没有进入超时的 select 分支,但是 main 函数的 select 却会等待 context.Context 超时并打印出 main context deadline exceeded。

如果我们将处理请求时间增加至 1500ms,整个程序都会因为上下文的过期而被中止:

css 复制代码
handle context deadline exceeded
main context deadline exceeded

相信上面的例子能够帮助理解 context.Context 的使用方法和设计原理 ------ 多个 goroutine 同时订阅 ctx.Done 管道中的消息,一旦接收到取消信号就立刻停止当前正在执行的工作。

相关推荐
随风,奔跑2 小时前
Spring Cloud Alibaba学习笔记(一)
java·后端·spring cloud
奔5大叔学编程2 小时前
一个参数取名导致的 DRF 下 GET 方法的行为异常
后端
我叫黑大帅2 小时前
Go 项目中 Redis 缓存的实用设计与实现(Cache-Aside 模式)
redis·后端·面试
didadida2623 小时前
深度解析:现代单页应用(SPA)中微信授权登录的高可用架构实现
后端
小江的记录本3 小时前
【RAG】RAG检索增强生成(核心架构、全流程、RAG优化方案、常见问题与解决方案)
java·前端·人工智能·后端·python·机器学习·架构
ZC跨境爬虫3 小时前
海南大学交友平台登录页开发实战day6(覆写接口+Flask 本地链接正常访问)
前端·后端·python·flask·html
花椒技术3 小时前
从 1.5 秒到 660ms,直播间首屏秒开是怎么做出来的?
人工智能·后端·全栈
Rust研习社3 小时前
深入 Rust 引用计数智能指针:Rc 与 Arc 从入门到实战
开发语言·后端·rust
树獭叔叔3 小时前
OpenCLI:让任何网站成为你的命令行工具
后端·aigc·openai