在Go语言中,测试并发代码一直是一个具有挑战性的任务。尤其是测试一些需要跟时间相关的case的时候,传统的测试方法通常依赖于真实的系统时钟和同步机制,这会导致测试变得缓慢且容易出现不确定性。
假设我们需要测试go-cache
的过期删除功能,我们可能会写出这种代码
go-cache是一个非常流行的 Go 缓存库,支持过期时间(TTL)和定期清理过期的缓存项。它适用于缓存数据,能够自动删除过期的条目。
list1: TestGoCacheEntryExpires
1
2
3
4
5
6
7
8
9
10
11
|
func TestGoCacheEntryExpires(t *testing.T) {
c := cache.New(5*time.Second, 10*time.Second)
c.Set("foo", "bar", cache.DefaultExpiration)
v, found := c.Get("foo")
assert.True(t, found)
assert.Equal(t, "bar", v)
time.Sleep(5 * time.Second)
v, found = c.Get("foo")
assert.False(t, found)
assert.Nil(t, v)
}
|
运行这个case 需要等待5s,让 cache
过期。
1
2
3
|
➜ synctest git:(main) ✗ time go test go-cacha_test.go
ok command-line-arguments 5.012s
go test go-cacha_test.go 0.38s user 1.27s system 27% cpu 6.049 total
|
在 Golang 1.24
之后,如何使用testing/synctest
解决这个问题呢?首先看看基于 testing/synctest
如何写这个test case 把。
List 2: TestGoCacheEntryExpiresWithSynctest
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
func TestGoCacheEntryExpiresWithSynctest(t *testing.T) {
c := cache.New(2*time.Second, 5*time.Second)
synctest.Run(func() {
c.Set("foo", "bar", cache.DefaultExpiration)
// Get an entry from the cache.
if got, exist := c.Get("foo"); !exist && got != "bar" {
t.Errorf("c.Get(k) = %v, want %v", got, "bar")
}
// Verify that we get the same entry when accessing it before the expiry.
time.Sleep(1 * time.Second)
if got, exist := c.Get("foo"); !exist && got != "bar" {
t.Errorf("c.Get(k) = %v, want %v", got,"bar")
}
// Wait for the entry to expire and verify that we now get a new one.
time.Sleep(3 * time.Second)
if got, exist := c.Get("foo"); exist {
t.Errorf("c.Get(k) = %v, want %v", got, nil)
}
})
}
|
运行时间:
1
2
3
4
|
➜ synctest git:(main) ✗ time GOEXPERIMENT=synctest gotip test -run TestGoCacheEntryExpiresWithSynctest
PASS
ok blog-example/go/go1.24/synctest 0.009s
GOEXPERIMENT=synctest gotip test -run TestGoCacheEntryExpiresWithSynctest 0.36s user 1.23s system 171% cpu 0.933 total
|
仅仅耗时 0.009s
testing/synctest 揭秘
testing/synctest主要目的是为了简化并发代码的测试。它的核心思想是通过使用虚拟时钟和goroutine
组(也称为bubble
)来控制并发代码的执行,从而使测试既快速又可靠。它仅仅只有两个接口。
1
2
|
func Run(f func()) { synctest.Run(f) }
func Wait() { synctest.Wait() }
|
Run
函数在一个新的goroutine
中执行f函数,并创建一个独立的bubble
,确保所有相关的goroutine
都在虚拟时钟的控制下执行。
Wait
用来同步 bubble
中goroutine‘s
的状态。调用该函数调用后,主goroutine
将阻塞,直到bubble
中的其他goroutine
都处于durably blocked
状态。
durably blocked
是 bubble
中goroutine
的一个特有的状态,有几种方式可以使 goroutine
进入 durably blocked
状态。
- 在
bubble
内向channel
发送或接收数据
- select语句中,每个case都是
bubble
内的channel
- A send or receive on a channel from within the bubble
- A select statement where every case is a channel within the bubble
sync.Cond.Wait
time.Sleep
但是如果goroutine
是因为 系统调用
或外部事件
而阻塞, 它就不是 durably blocked
状态,synctest.Wait
也不会因为它而阻塞。
每个bubble
都有一个虚拟时钟,从2000-01-01 00:00:00 UTC
开始. 这个虚拟时钟不会根据代码的执行时间前进,只有在所有goroutine
都处于空闲状态时才会向前推进。在TestGoCacheEntryExpiresWithSynctest
中调用time.Sleep
的时候, goroutine
只有一个并且处于空闲状态,时间会快速前进,而不必等待真实时间过去,所以测试运行速度很快。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
func TestSynctest(t *testing.T) {
synctest.Run(func() {
before := time.Now()
fmt.Println("before", before)
f1:=func() {
count := 0
for i := 0; i < 10e9; i++ { // time consuming, It's about 3s in my machine
count++
}
}
go f1()
synctest.Wait() //wait f1
after := time.Now() // time is not affected by the running time of f1
fmt.Println("after", after)
})
}
|
运行结果为:
1
2
3
4
5
6
|
➜ synctest git:(main) ✗ time GOEXPERIMENT=synctest gotip test -run TestSynctest
before 2000-01-01 08:00:00 +0800 CST m=+946164282.733407335
after 2000-01-01 08:00:00 +0800 CST m=+946164282.733407335
PASS
ok blog-example/go/go1.24/synctest 3.131s
GOEXPERIMENT=synctest gotip test -run TestSynctest 3.45s user 1.25s system 133% cpu 3.533 total
|
在 gosrc 中已经已经有蛮多使用 synctest
优化case 的例子。
参考资料:
https://github.com/golang/go/blob/05d8984781f7cf2f0f39b53699a558b6a1965c6c/src/testing/synctest/synctest.go#L41