openlang

-race 报了 DATA RACE,可 ack 明明是无缓冲接收,是 time.After 的锅吗?

会不会是竞态检测器在这里判断错了,还是我对 ack 提供的保证理解有误?这是 pushgate,一个小服务,给我的移动应用校验推送通知用的设备令牌上的 HMAC 签名。调用方在无缓冲的 channel 上发送一个 Request,worker 填好 Result,然后把 ID 发到 ack channel 上。调用方只在收到 ack 之后才读 Result。我故意把测试写成这样,就是为了不用 time.Sleep。带 time.After 的 select 只是为了让卡住的测试能超时挂掉。

直接跑 go test 一直是绿的。今天打 tag 发版之前我跑了 go test -race -count=200 ./...。大约每 30-40 次运行会有一次打印 WARNING: DATA RACE,test goroutine 里是读,worker goroutine 里是写。worker 这边指向 verify.go,测试这边指向 verify_test.go 里 t.Logf 那一行。好像更多是 TestWorkerRejectsBadSig 触发的,但我不确定。

我试过的:

  • 带缓冲的 ack channel: 竞态出现得更频繁了,所以我改回去了
  • -cpu 1: 竞态消失了,但我不信这个结果
  • 旧的 Result 有两个字段,上周我为了审计日志加了一个时间戳。我看不出这会有什么影响

worker 代码:

for r := range in {
	want := sign(secret, r.Token)
	ok := hmac.Equal([]byte(want), []byte(r.Sig))

	r.Result.DeviceID = r.ID
	r.Result.Valid = ok
	ack <- r.ID

	// stamp after the ack so the caller is not kept waiting
	r.Result.Checked = time.Now()
	if verbose {
		log.Printf("checked device=%d valid=%v", r.ID, ok)
	}
}

失败更频繁的那个测试:

func TestWorkerRejectsBadSig(t *testing.T) {
	res := runOne(t, 9, "apns-abc123", "deadbeef")
	t.Logf("result for bad sig: %+v", *res)
	if res.Valid {
		t.Fatal("bad signature accepted")
	}
	if res.DeviceID != 9 {
		t.Fatalf("device id %d", res.DeviceID)
	}
}

runOne 用普通的 make(chan ...) 创建两个 channel,启动 worker,发送 Request,然后对 ack 和 time.After(2 * time.Second) 做 select,最后返回 res。我读了 https://go.dev/ref/mem 但还是看不出顺序是在哪里断掉的。是不是结构体里的那个指针对检测器隐藏了什么?

从英语翻译查看原文

后续讨论 4

顺着彼此的依据读下去
  1. 固定链接

    找到了,检测器是对的))问题出在我自己的注释 "stamp after the ack" 上。ack 只能保证它之前发生的写入有序。r.Result.Checked = time.Now() 在 ack <- r.ID 之后才执行,所以它和调用方的读之间没有任何顺序保证。t.Logf("%+v", *res) 会把整个结构体拷贝一遍,包括 Checked,而这时 worker 可能还在写它。

    这也解释了其余几点。

    • 只有两个字段时,两个都是在 ack 之前写的,所以没有 race。我上周加的时间戳是 ack 之后的第一次写。
    • -cpu 1 能藏住问题,是因为 worker 在那个窗口里很少有机会运行。
    • 有缓冲的 ack 会让情况更糟,因为 worker 发送时永远不会阻塞,所以更早就走到了那次靠后的写入。
    • TestWorkerRejectsBadSig 失败更频繁,是因为它是唯一会打印整个结构体的测试。其他测试只读单个字段,只有碰巧读到 Checked 才会撞上。

    修法是把时间戳挪到 ack 之前。

    r.Result.DeviceID = r.ID
    r.Result.Valid = ok
    r.Result.Checked = time.Now()
    ack <- r.ID

    发送 happens-before 接收完成,见 https://go.dev/ref/mem 里 "Channel communication" 那一节。我又跑了一遍 -race -count=200,结果是干净的。ack 之后的 log.Printf 没问题,因为它不碰 Result。

    从英语翻译查看原文
  2. 固定链接

    给以后搜到这帖的人补一个小的后续。我把发送上方的注释改成了“调用方读到的所有东西都在这一行之前写完”,时间戳现在就放在另外两个字段的正下方。当初把我带偏的正是那条错误的注释。

    我这次也认真读了竞态检测器那篇文章 https://go.dev/doc/articles/race_detector ,里面说检测器只会报告这一次运行中真正发生的竞态。所以我之前绿色的普通 go test 什么都没证明,干净的 -count=200 也只是依据,不是保证。所以我现在把 -race 写进了发版前的脚本,而不是只记在脑子里。

    如果审计时间戳必须在 ack 之后写,worker 可以用第二条消息把它发出去,或者调用方在之后的某个同步点再拷贝 Result。pushgate 用不到这个,所以我就没做。

    好笑的是,同样的错误放在 Android 客户端里,就会变成用户日志里偶尔出现的崩溃,而我在笔记本上永远复现不了))

    从英语翻译查看原文
  3. 固定链接

    不同意,我不觉得检测器错了。在我看来 ack 之后的那次写就是 race。为什么非要共享 Result 的指针?为什么不通过 channel 按值发送 Result?我承认审计时间戳会让这样做有点别扭。

    从英语翻译查看原文

同一个问题的其他角度

  1. 仅有一个 webhook 的 HMAC 不匹配(名字里带重音符号的那个)

    我写了个小的 Node 检查器(纯 JS,不需要构建步骤),用来重放保存下来的 webhook 投递,并在下游信任它们之前校验 HMAC-SHA256。三个里有两个能过。来自 billing-partner 的 evt_2044 失败了,但同一个合作…

    回复 7最近活动 typescriptjavascript
  2. memset 在 free 之前调用,默认编译选项下通过,OPT=-O2 时失败

    我在写一个叫 vault 的小型 C 凭据存储库,在 WSL 里构建。vault_release() 的作用是先擦除密钥再释放,代码如下 测试文件 tests/wipe_check.c 会先保存密钥指针,调用 vault_release(),然后从第…

    回复 6最近活动 c
  3. 同样的溢出检查,-O0 和 -O2 下走了不同的错误分支

    恶意构造的 pack,count=65536,rec_size=32768。-O0 下我得到 "pack too large",-O2 下却变成了 "out of memory"。源码完全一样,我真的想不通。是 -O2 把它编译错了,还是 mallo…

    回复 6最近活动 c