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 超时控制和资源回收
- context.Context 类型的值可以协调多个 groutine 中的代码执行"取消"操作,并且可以存储键值对,最重要的是它是并发安全的。
- 与它协作的 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 的泄漏。
- context.Background 是上下文的默认值,所有其他的上下文都应该从它衍生出来。
- context.WithCancel 函数能够从 context.Context 中衍生出一个新的子上下文并返回用于取消该上下文的函数。一旦我们执行返回的取消函数,当前上下文以及它的子上下文都会被取消,所有的 goroutine 都会同步收到这一取消信号。
e.g.
使用 httptest 包的 NewServer 函数创建了三个模拟的"气象数据服务中心",然后将这三个"气象数据服务中心"的实例传入 first 函数。后者创建了三个 goroutine,每个 goroutine 对应向一个"气象数据服务中心"发起查询请求。
- 三个发起查询的 goroutine 都会将应答结果写入同一个 channel 中,first 获取第一个结果数据后就返回了。
- 通过增加一个定时器,并通过 select 原语监视该定时器事件和响应 channel 上的事件。如果响应 channel 上长时间没有数据返回,则当定时器事件触发后,first 函数返回。
- 加上了"超时模式"的版本依然有一个明显的问题,那就是即便 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 管道中的消息,一旦接收到取消信号就立刻停止当前正在执行的工作。