Go synctest:解决不稳定测试的利器

英文来源: Go synctest: Solving Flaky Tests

要理解 synctest 解决的问题,我们首先必须认识核心问题:并发测试中的非确定性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func TestSharedValue(t *testing.T) {
var shared atomic.Int64
go func() {
shared.Store(1)

<!--more-->

time.Sleep(1 * time.Microsecond)
shared.Store(2)
}()

// 5微秒后检查共享值
time.Sleep(5 * time.Microsecond)
if shared.Load() != 2 {
t.Errorf("shared = %d, want 2", shared.Load())
}
}

这个测试启动一个goroutine来修改共享变量。它将 shared 设为1,休眠1微秒,然后设为2。

与此同时,主测试函数等待5微秒后检查 shared 是否达到2。乍一看,这个测试似乎应该总是通过。毕竟,5微秒应该足够goroutine完成执行。

然而,重复运行测试:

1
go test -run TestSharedValue -count=1000

会显示测试有时会失败。你可能会看到如下输出:

1
shared = 0, want 2

或者

1
shared = 1, want 2

这是因为测试是不稳定的。有时goroutine在检查运行时还没有完成,甚至还没有开始。结果取决于系统调度器以及运行时选择goroutine的速度。

time.Sleep 的准确性和调度器的行为可能存在很大差异。操作系统差异和系统负载等因素都会影响时序。这使得任何仅基于休眠的同步策略都不可靠。

虽然这个例子使用微秒级延迟进行演示,但现实世界的问题往往涉及毫秒或秒级的延迟,特别是在高负载下。

受这种不稳定性影响的真实系统包括后台清理、重试逻辑、基于时间的缓存驱逐、心跳监控、分布式环境中的领导者选举等。

像这样依赖时序的测试也可能很耗时。想象一下,如果它必须等待5秒而不是仅仅5微秒。

什么是 synctest?

synctest 是Go 1.24引入的新特性。它通过在受控的隔离环境中运行goroutine,实现了并发代码的确定性测试。

考虑这个不使用 synctest 的例子:

1
2
3
4
5
func TestTimingWithoutSynctest(t *testing.T) {
start := time.Now().UTC()
time.Sleep(5 * time.Second)
t.Log(time.Since(start))
}

当你运行这个测试:

1
go test . -v

你会发现输出永远不会恰好是 5s。相反,它可能看起来像 5.329s5.394s5.456s。这些变化来自系统调度和时序分辨率的延迟。

使用 synctest,时间完全受控。持续时间变得一致,输出将始终显示 5s

要使用 synctest,将你的测试逻辑包装在一个函数中,并将其传递给 synctest.Run()

1
2
3
4
5
6
7
8
9
import "testing/synctest"

func TestTimingWithSynctest(t *testing.T) {
synctest.Run(func() {
start := time.Now().UTC()
time.Sleep(5 * time.Second)
t.Log(time.Since(start))
})
}

然后使用 GOEXPERIMENT=synctest 标志运行测试:

1
GOEXPERIMENT=synctest go test -run TestTimingWithSynctest -v

示例输出:

1
2
3
4
=== RUN   TestTimingWithSynctest
main_test.go:8: 5s
--- PASS: TestTimingWithSynctest (0.00s)
PASS

注意 synctest 内部的 time.Sleep 立即返回。测试实际上不会等待5秒。这使得测试运行得更快,同时仍然保持准确性。

现在我们知道 synctest 操纵时间来产生确定性行为,我们可以用它来修复之前的不稳定测试。只需用 synctest.Run 包装测试主体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func TestSharedValue(t *testing.T) {
synctest.Run(func() {
var shared atomic.Int64
go func() {
shared.Store(1)
time.Sleep(1 * time.Microsecond)
shared.Store(2)
}()

// 5微秒后检查共享值
time.Sleep(5 * time.Microsecond)
if shared.Load() != 2 {
t.Errorf("shared = %d, want 2", shared.Load())
}
})
}

通过这个改变,测试将每次都通过。但它是如何解决Go运行时调度器没有选择goroutine运行的问题的呢?

原因是时间是受控的。5微秒是模拟的而不是真实的。当代码运行时,时间实际上是冻结的,synctest 管理其进展。换句话说,逻辑不依赖于真实时间,而是依赖于确定性的执行顺序。

等待机制

除了合成时间外,synctest 还提供了一个强大的同步原语:synctest.Wait 函数。

当你调用 synctest.Wait() 时,它会阻塞直到所有其他goroutine(在同一 synctest 组中)要么完成,要么持久阻塞。Wait() 最常见的用法是启动后台goroutine,然后暂停直到它们达到稳定点,再进行断言。

这里是一个例子,其中 Wait() 确保 afterFunc 回调已被调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
synctest.Run(func() {
ctx, cancel := context.WithCancel(context.Background())

afterFuncCalled := false
context.AfterFunc(ctx, func() {
afterFuncCalled = true
})

// 取消上下文并等待AfterFunc完成
cancel()
synctest.Wait()

// 现在我们可以安全地检查回调是否已被调用
fmt.Printf("after context is canceled: afterFuncCalled=%v\n", afterFuncCalled)
})

当我们调用 cancel() 时,传递给 context.AfterFunc 的函数在单独的goroutine中运行。没有协调,我们无法确定该goroutine何时被调度或何时完成。

因为 synctest 跟踪测试气泡中的所有goroutine,它知道它们的确切状态。当 Wait() 返回时,它保证所有其他goroutine要么完成,要么阻塞。这使你能够对程序状态进行可靠和确定性的断言。

synctest 如何工作

synctest 通过创建称为"气泡"的隔离环境来工作。气泡是一组在受控和独立环境中运行的goroutine,与程序的正常执行分离。

当你调用 synctest.Run(f) 时,Go运行时创建一个新的执行气泡。这个气泡有几个独特的特征,使其与常规Go行为不同:

1. 合成时间

每个气泡都有自己的合成时钟。这个合成时间从2000年1月1日UTC午夜开始(纪元946684800000000000):

1
2
3
4
5
6
7
8
func TestTimingWithSynctest(t *testing.T) {
synctest.Run(func() {
t.Log(time.Now().UTC())
})
}

// 输出:
// 2000-01-01 00:00:00 +0000 UTC

在气泡内部,时间不会实时向前移动。相反,Go暂停时间并观察goroutine在做什么。如果任何goroutine仍然活跃(未阻塞),合成时间保持冻结:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func TestTimingWithSynctest(t *testing.T) {
synctest.Run(func() {
t.Log(time.Now().UnixNano())

var now int64
for range 10000000 {
now = time.Now().UnixNano()
}

t.Log(now)
})
}

// 输出:
// 946684800000000000
// 946684800000000000

时间只有在气泡中的所有goroutine都被阻塞时才会前进。这意味着它们正在等待操作,如 time.Sleep、通道接收、互斥锁或其他阻塞调用。

synctest 气泡中,时间只为触发预定事件而前进。这使测试完全控制执行时序和顺序。

例如,如果一个goroutine休眠5秒,而所有其他goroutine也被阻塞,Go将立即将合成时间向前移动5秒。这允许goroutine立即恢复,无需等待真实时间过去。

2. Goroutine协调

当调用 synctest.Run(f) 时,当前goroutine成为气泡的根。这个根goroutine管理合成时间并协调气泡内所有其他goroutine的执行。

传递给 synctest.Run 的函数 f 在新goroutine中启动并成为气泡的一部分。然后根goroutine进入循环来管理时间并控制其他气泡goroutine的调度。

被阻塞的goroutine有两类:外部阻塞持久阻塞

持久阻塞意味着goroutine无法继续,直到其他东西触发解除阻塞,而那个"其他东西"在测试环境内受控。例子包括:

  • time.Sleep()
  • sync.Cond.Wait()
  • sync.WaitGroup.Wait()
  • nil 通道的操作
  • 所有case都涉及气泡内通道的 select 语句
  • 在气泡内创建的通道上的发送和接收

如果goroutine等待气泡外的事件,则它们不是持久阻塞。这些包括:

  • 文件或网络I/O等系统调用
  • 外部事件处理(如从套接字读取)
  • 在气泡外创建的通道上的通道操作

synctest 的角度来看,在外部事件上阻塞的goroutine被认为是运行的,因为它们的进展取决于现实世界的状态。

所以如果你有一个永远外部阻塞的goroutine,以及另一个在像 time.Sleep(5 * time.Microsecond) 这样的东西上持久阻塞的goroutine,休眠将永远不会完成。由于外部阻塞阻止系统达到完全阻塞状态,合成时间不会前进,持久阻塞的goroutine将保持暂停。

当没有运行的goroutine且所有活跃的goroutine都持久阻塞时,synctest 继续要么唤醒等待 synctest.Wait() 的goroutine,要么继续执行根goroutine。决策逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
func (sg *synctestGroup) maybeWakeLocked() *g {
if sg.running > 0 || sg.active > 0 {
return nil
}

sg.active++
if gp := sg.waiter; gp != nil {
return gp
}

return sg.root
}

此时根goroutine的作用是找到下一个预定的计时器事件。这可能由 time.Sleeptime.Timertime.Tickertime.AfterFunc 等函数触发。所有这些都在内部创建计时器。

一旦根找到下一个事件,它将合成时间设置到那个时刻(sg.now = next),然后停驻自己并等待测试调度器恢复现在应该运行的goroutine。

请记住,synctest 主要设计用于测试同步逻辑的时序和正确性,而不是完全模拟现实世界的时序行为。如果使用不当,它可能隐藏在现实条件下会出现的错误。

最后注意,本文是在 synctest 仍处于实验阶段时编写的。一些细节可能随时间而改变,但核心概念预期保持不变。

Go 语言中集中处理 HTTP 错误

https://www.alexisbouchez.com/blog/http-error-handling-in-go

在这篇短文中,我将与你分享一个我用来集中处理 HTTP 处理程序的错误的简单模式。

如果你写过一些 Go HTTP 服务器,你可能已经厌倦了一遍又一遍地编写相同的错误处理代码:

1
2
3
4
5
6
7
8
9
10
func SomeHandler(w http.ResponseWriter, r *http.Request) {
data, err := fetchSomeData()
if err != nil {
http.Error(w, "Failed to fetch data", http.StatusInternalServerError)
log.Printf("Error fetching data: %v", err)
return
}

// More if-err blocks...
}

这段代码重复性很高,并且用样板代码塞满了你的处理程序,而不是业务逻辑。

阅读全文

微软10x工程师优化10倍typescript编译性能后被优化

Ron Buckton是微软的一位10x工程师,专注于TypeScript开发。他在微软工作了18年,其中10年致力于TypeScript,为其性能提升做出了巨大贡献,尤其是让构建速度和编辑器响应速度提升了10倍。然而,2025年5月14日,微软宣布裁员6000人,Ron不幸被解雇。他在X上发文表达失望,提到需要时间消化消息。


Ron Buckton是微软的一位资深软件开发工程师,专注于TypeScript的开发。TypeScript是由微软开发的自由与跨平台的编程语言,是JavaScript的超集,主要提供类型检查和静态检查功能。根据Ron Buckton的LinkedIn资料,他曾是TypeScript团队的成员,长期致力于该语言的优化与发展。他的GitHub账户显示,他参与了TypeScript编译器和语言服务的多个项目,体现了他在技术领域的深厚积累。

Ron在TypeScript团队工作了近十年,为其性能提升做出了显著贡献。根据一篇技术文章,他的努力让TypeScript的构建速度和编辑器响应速度提升了10倍,这一成果被开发者广泛认可。可以说,Ron是TypeScript的“灵魂人物”,他的代码如同语言的DNA,深深嵌入其中。

然而,2025年5月14日,微软宣布裁员6000人,作为组织调整的一部分,旨在适应动态市场。根据微软裁员报道,Ron不幸成为被解雇的员工之一。他在X上发文表示:“在微软工作了18年,其中十年都在TypeScript团队工作,不幸在最近的裁员中被解雇了。我需要几天时间来消化这个消息。感谢一路以来支持我的人。”这一消息引发了社区的广泛讨论,Hacker News上的讨论中,有人表达了对微软决策的质疑,认为放走这样的人才令人遗憾。

Ron的遭遇在网络上引发了诸多反应,网友的评论为故事增添了喜剧色彩。有人戏称:“Ron,你是TypeScript的英雄,任何公司都会抢着要你的!”也有人开玩笑:“微软真是瞎了眼,居然放走了这样的人才!”还有人调侃:“Ron,你现在有空了,不如来帮我优化一下我的个人网站吧?”这些评论既表达了对Ron的敬意,也通过幽默缓解了裁员带来的沉重感。

Ron的职业生涯中,与TypeScript首席架构师Anders Hejlsberg的合作令人难忘。Anders是编程界的传奇人物,创造了Turbo Pascal、Delphi、JScript、C#和TypeScript等多款语言。根据Ron的职业经历,他曾与Anders就TypeScript的未来方向展开无数次讨论。Anders曾对他说:“Ron,你对TypeScript的贡献无人能及,你就是这门语言的守护者。”然而,如今守护者却被自己的“孩子”抛弃,这其中的讽刺与无奈让人唏嘘。

Ron年近40,在编程界被视为大龄工程师。随着年龄增长,找一份合适的工作变得越来越难。根据行业分析,Tech行业的竞争日益激烈,年轻工程师如雨后春笋般涌现,而老一辈工程师则面临被淘汰的风险。Ron坐在家中,看着窗外的风景,叹了口气。他深知,编程世界瞬息万变,今天的英雄,明天可能就成为昨日的黄花。即使为公司奉献了十几二十年,也可能在一夜之间被扫地出门。

Ron的遭遇反映了Tech行业裁员潮对大龄工程师的冲击。根据最新报道,微软的裁员涉及全球3%的员工,这一决策被认为是适应AI和数据中心投资的组织调整。然而,对于像Ron这样为公司贡献了近20年的老兵来说,这种调整显得冷酷无情。

尽管如此,Ron并没有气馁。他决定利用这段时间,好好休息,陪陪家人,同时开始寻找新的机会。他相信,以他的能力和经验,一定会找到一个适合他的位置。或许,他还会继续为编程世界做出贡献,只是下一个舞台在哪里,还不得而知。

啥时候等到Go官方支持SIMD?

单指令多数据流(SIMD,Single Instruction Multiple Data)是一种并行计算技术,允许一条指令同时处理多个数据点。SIMD在现代CPU中广泛应用,能够显著提升计算密集型任务的性能,如图像处理、机器学习、科学计算等。随着Go语言在高性能计算领域的应用逐渐增多,SIMD支持成为了开发者关注的焦点。

当前很多主流和新型的语言都有相应的simd库了,比如C++、Rust、Zig等,但Go语言的simd官方支持还一直在讨论中(issue#67520)。Go语言的设计目标是简单性和可移植性,而SIMD的实现通常需要针对不同的硬件架构进行优化,这与Go的设计目标存在一定冲突。因此,Go语言对SIMD的支持一直备受争议。
最近几周这个issue的讨论有活跃起来, 希望能快点支持。

阅读全文

DeepSeek数据库暴露?扫描一下,应该不止此一家吧!

DeepSeek出街老火了,整个AI界都在热火朝天的讨论它。

同时,安全界也没闲着,来自美国的攻击使它不得不通知中国大陆以外的手机号的注册,同时大家也对它的网站和服务安全性进行了审视,这不Wiz Research就发现它们的数据库面向公网暴露并且无需任何身份即可访问。这两个域名oauth2callback.deepseek.com:9000和dev.deepseek.com:9000。

AI的核心技术既需要这些清北的天才去研究,产品也需要专业的人才去打磨。像DeepSeek这么专业的公司都可能出现这样的漏洞,相信互联网上这么数据库无密码暴露的实例也应该不在少数(实际只找到了2个)。

基于上一篇《扫描全国的公网IP要多久》,我们改造一下代码,让它使用 tcp_syn 的方式探测clickhopuse的9000端口。

首先声明,所有的技术都是为了给大家介绍使用Go语言开发底层的网络程序所做的演示,不是为了介绍安全和攻击方面的内容,所以也不会使用已经成熟的端口和IP扫描工具如zmap、rustscan、nmap、masscan、Advanced IP Scanner、Angry IP Scanner、unicornscan等工具。

同时,也不会追求快速,我仅仅在家中的100M的网络中,使用一台10多年前的4核Linux机器进行测试,尽可能让它能出结果。我一般晚上启动它,早上吃过早餐后来查看结果。

阅读全文

扫描全国的公网IP需要多久?

自从加入百度负责物理网络的监控业务之后,我大部分的都是编写各种各样额度底层的网络程序。业余时间我也是编写一些有趣的网络程序,不仅仅是兴趣,也是为未来的某个业务探索一下技术方案。

而且这次,我想知道,就在我这一个10年前的小mini机器4核机器上,在家庭网络中扫描全国(中国大陆)的所有的公网IP地址需要多少时间。

利用它,我可以知道和全国各省市的运营商、云服务商的联通情况。有没有运营商的出口故障以及IP已没有被运营商或者有关部门劫持。

TL;DR: 一共扫描了3亿个地址(343142912),当前ping的通的IP 592万个(5923768),耗时1小时(1h2m57.973755197s)。

这次我重构了以前的一个扫描公网IP的程序。先前的程序使用gopacket收发包,也使用gopacket组装包。但是gopacket很讨厌的的一个地方是它依赖libpcap库,没有办法在禁用CGO的情况下。

事实上利用Go的扩展包icmp和ipv4,我们完全可以不使用gopacket实现这个功能,本文介绍具体的实现。

程序的全部代码在:https://github.com/smallnest/fishfinder

阅读全文

Go中秘而不宣的数据结构: 四叉堆,不是普通的二叉堆

Go语言中Timer以及相关的Ticker、time.After、time.AfterFunc 等定时器最终是以四叉堆的数据形式存放的。

全局的 timer 堆也经历过三个阶段的重要升级。

  • Go 1.9 版本之前,所有的计时器由全局唯一的四叉堆维护,goroutine间竞争激烈。
  • Go 1.10 - 1.13,全局使用 64 个四叉堆维护全部的计时器,通过分片减少了竞争的压力,但是本质上还是没有解决 1.9 版本之前的问题
  • Go 1.14 版本之后,每个 P 单独维护一个四叉堆,避免了goroutine的竞争。 (后面我们再介绍 per-P 的数据结构)

常见的堆(heap)常常以二叉堆的形式实现。可是为什么Go timer使用四叉堆呢?

阅读全文

HeapMap, 一个混合功能的数据结构Go语言实现

今天在准备《秘而不宣》系列下一篇文章时,思绪飘散了,突然想到使用 Heap 的功能再加 HashTable (Map) 的功能,可以构造一种新的数据结构,然后把我聚合程序中的数据聚合数据结构替换掉,总之思绪翩翩。然后在网上搜了一下,这种数据结构其实早就有了,名字叫 HeapMap

阅读全文