如何用 go 实现超时控制

在实现一些服务的过程中,我们需要对内部处理时间进行控制,以防客户端一直在等待响应。

select-case 实现的超时控制

在 go 中,利用 select + case + time 包,就可以很轻松实现超时控制。我们修改Go by Example: Timeouts中的一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package main

import (
"time"
"fmt"
)

func main() {

c1 := make(chan string, 1)
go func() {
time.Sleep(2 * time.Second)
fmt.Println("get result 1")
c1 <- "result 1"
}()

select {
case res := <-c1:
fmt.Println(res)
case <-time.After(1 * time.Second):
fmt.Println("timeout 1")
}

c2 := make(chan string, 1)
go func() {
time.Sleep(2 * time.Second)
fmt.Println("get result 2")
c2 <- "result 2"
}()
select {
case res := <-c2:
fmt.Println(res)
case <-time.After(3 * time.Second):
fmt.Println("timeout 2")
}
}

在上面的例子中,从第 10 到第 22 行,我们创建了一个大小为 1 的 channel c1,然后创建一个 goroutine。在这个 goroutine 中,等待 2 秒后,打印日志,并发送一条消息到 channel c1 中。接着,我们利用 select-case 实现超时时间为 1 秒的超时控制。在第一个 case 中,等待来自 c1 的消息,并将此消息打印出来。在第二个 case 中,利用 time 包的 After 方法(这个方法在指定的时间间隔后,发送当前时间到返回的 channel 中),等待 1 秒后打印超时信息。

从第 24 到第 35 行,我们创建了另一个大小为 2 的 channel c2,然后创建另一个 goroutine。在这个 goroutine 中,同样等待 2 秒后打印日志,并发送一条消息到 channel c2 中。接着,利用另一个 select-case 实现超时时间为 3 秒的超时控制。在第一个 case 中,等待来自 c1 的消息,并将此消息打印出来。在第二个 case 中,利用 time 包的 After 方法,等待 3 秒后打印超时信息。

运行得到输出如下:

1
2
3
4
timeout 1
get result 1
get result 2
result 2

超时传播

从上面的例子的输出,我们会发现,第一个 goroutine 并没有在 1 秒超时后结束,而是完整地执行了整个方法。这是不彻底的超时控制,有可能影响后续的处理。为了更清楚地看出这个问题,我们稍微修改下上面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

package main

import (
"fmt"
"sync"
"time"
)

var (
mutex sync.Mutex
id int
)

func dosomething(val int) {
mutex.Lock()
defer mutex.Unlock()
//....
time.Sleep(time.Second)
id = val
}

func main() {

for i := 0; i < 3; i++ {
done := make(chan bool)
go func(i int) {
dosomething(i)
done <-true
}(i)
select {
case res := <-done:
fmt.Println(time.Now(), res, id)
case <-time.After(time.Duration(i) * time.Second):
fmt.Println(time.Now(), "timeout ", i)
}
}
}

在上面的例子中,从第 10 到 13 行,我们声明了一个类型为 Mutext 的全局锁 mutex 和一个全局变量 id。前者用以解决后者的同步写冲突。接下来的第 15 到 21 行,定义了一个函数 dosomething,这个函数等待 1 秒后对变量 id 进行设值。

程序主入口处,我们依次创建 3 个 goroutine,每个 goroutine 都调用了 dosomething 函数进行设值。函数执行结束后,通过外部的 channel done 来通知调用者。接下来,在第 31 到 36 行,利用 select-case 进行超时控制,超时时间为当前索引指定的秒数。为了更清楚地看出耗时,我们在日志打印中加上了时间打印。

运行会发现,第 2 个请求因为第 1 个请求尚未返回导致没有释放锁,从而超时。而接下来的第 3 个请求也因为同样的原因超时了:

1
2
3
2009-11-10 23:00:00 +0000 UTC m=+0.000000001 timeout  0
2009-11-10 23:00:01 +0000 UTC m=+1.000000001 timeout 1
2009-11-10 23:00:03 +0000 UTC m=+3.000000001 timeout 2

加上 context 如何?

在上面的例子中,上次请求超时对下次请求,甚至是下下次请求会发生影响。而这种影响是可以减轻或者避免的。我们可以使用 context 包来处理这种问题。

context 中有两个方法:

  • func WithDeadline(parent Context, d time.Time) (Context, CancelFunc):返回参数 parent 的一个拷贝,并且调整该拷贝的截止时间至不超过 d 指定的时间。如果 parent 的截止时间比 d 早,那么该拷贝语义上等同于 parent。当截止时间过期时,或者调用了返回的 CancelFunc,又或者 parentDone channel 被关闭了,这三种情况之一发生了,返回的 context 的 Done channel 就会被关闭。注意,关闭该 context 会释放其相关资源,因此,只要在这个 context 上的操作完成了,就必须立即调用 CancelFunc
  • func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc):返回 WithDeadline(parent, time.Now().Add(timeout))

现在,我们使用 WithTimeout 方法来改进上面的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package main

import (
"fmt"
"sync"
"time"
"context"
)

var (
mutex sync.Mutex
id int
)

func dosomething(ctx context.Context, val int) {
mutex.Lock()
defer mutex.Unlock()
select {
case <- ctx.Done():
fmt.Println(time.Now(), "op timeout", val)
return
default:
//....
time.Sleep(time.Second)
id = val
}

}

func main() {

for i := 0; i < 3; i++ {
done := make(chan bool)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(i) * time.Second)
defer cancel()
dosomething(ctx, i)
done <-true
}()
select {
case res := <-done:
fmt.Println(time.Now(), res, id)
case <-time.After(time.Duration(i) * time.Second):
fmt.Println(time.Now(), "timeout ", i)
}
}
}

在第 15 行至 28 行,我们修改 dosomething 的函数签名,将 context.Context 类型的参数作为函数的第一个参数。然后,在该函数中,利用 select-case 和这个参数的 Done 方法来判断是否退出。在第 35 行到第 37 行,调用 context.WithTimeout 方法创建一个 context.Context 对象,超时时间为该 goroutine 的超时时间。然后将其传给 dosomething 函数。

运行得到以下输出:

1
2
3
4
2009-11-10 23:00:00 +0000 UTC m=+0.000000001 timeout  0
2009-11-10 23:00:01 +0000 UTC m=+1.000000001 timeout 1
2009-11-10 23:00:01 +0000 UTC m=+1.000000001 op timeout 1
2009-11-10 23:00:02 +0000 UTC m=+2.000000001 true 2

可以看到,虽然第 2 个请求超时了。但是,第 3 个请求能够快速恢复。

总结

第一次在 go 中实现超时控制的时候,满篇的 select-case,粗糙地在超时的时候返回而不管尚在执行中的 goroutine 的死活。结果是,大批量调用受到几个调用超时的影响,一直超时无法恢复。

context 这个包就在这种情况下出现在我的视线中。按惯例,context.Context 对象应该作为函数的第一个参数,并且不建议将其当成结构体的一个部分。此外,它还可以用来传递值等等。

但是,如果只是为了进行超时控制,而不得不把所有的函数方法都加上这个参数的话,总感觉不那么漂亮。希望未来 go 可以更好地更漂亮地解决超时退出下 goroutine 的退出问题。