Appearance
同步的单位是操作,取消的单位是请求
若两个请求都读到剩余 1,再各扣 1,哪怕每次读写都是原子的,复合操作仍错。实验的 CAS 把“比较旧值并扣减”合成一个线性化点;失败者必须重读。这个保证只涉及一处内存。总容量与租户容量是两处状态,用两个互不协调的 CAS 会出现一处成功另一处失败;本地 Store 用一把锁保护整体不变量,跨进程则需要条件更新与事务。
hchan 管理环形缓冲、发送/接收索引及阻塞队列。缓冲有空位时发送者复制数据,否则可能把 sudog 放入等待队列并 park;有等待的接收者时可以直接交接。channel send 与对应 receive 建立同步关系,但发一个指针不会让发送者后续修改变安全。Mutex 的 Unlock 到后继 Lock 建立 happens-before,RWMutex 适合读操作确实可并行且临界区足够长的情况;热点更新时读锁计数本身也有代价。sync.Map 不是能维护多 key 事务的 map;sync.Once 适合一次初始化,初始化函数 panic 不会自动重试。
直接交接的锁顺序。 src/runtime/chan.go 的 send 在通道锁保护下把数据复制给已等待的接收者,解除锁后再让 G 进入 runnable;若在持有 channel 锁时改变另一 G 的执行状态,可能与栈收缩时的锁顺序相撞。对比普通有缓冲发送只移动 sendx 和 qcount,这解释了同样一次发送为何可能呈现不同延迟。
Worker pool 需要固定并发数、有限队列和取消出口。CPU Mock 用容量为 2 的 channel 表示执行位,等待者可随 HTTP context 取消。这个 semaphore 只限制执行,并未限制等待 G 总数;入口必须限流、设置请求体上限和请求期限。把队列设成几百万只会把过载转成内存峰值和更长尾延迟。
sh
cd modeldock
go test -race ./internal/platform ./labs/runtime
GOMAXPROCS=1 go test ./labs/runtime -run TestConcurrentClaim -count=20预期一百个申请者恰有七个成功。Race Detector 发现的是执行过路径上的非同步内存访问;数据竞争测试通过不证明配额不会超卖,后者要断言业务不变量。
让取消一路到达上游
下面用真正的回环 HTTP 请求验证父 context 取消后,上游 handler 也退出。WithCancel 向子树广播,WithTimeout 限制最晚结束时间;谁创建派生 context,谁负责调用 cancel,提前释放定时器。Done 是通知而不是抢占 Go 函数的指令。
完整源码:modeldock/labs/runtime/contracts_test.go · 第 34–60 行
go
func TestCancelReachesUpstream(t *testing.T) {
entered, stopped := make(chan struct{}), make(chan struct{})
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
close(entered)
<-r.Context().Done()
close(stopped)
}))
defer upstream.Close()
parent, cancel := context.WithCancel(context.Background())
defer cancel()
ctx, stop := context.WithTimeout(parent, 2*time.Second)
defer stop()
req, err := http.NewRequestWithContext(ctx, "GET", upstream.URL, nil)
if err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
go func() {
resp, e := http.DefaultClient.Do(req)
if resp != nil {
resp.Body.Close()
}
done <- e
}()
select {
case <-entered:
case <-ctx.Done():sh
(cd modeldock && go test -race -count=10 -run 'TestConcurrentClaim|TestBlockedProducerExits|TestCancelReachesUpstream' ./labs/runtime)同样的 context 可传给数据库:ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second); defer cancel(),再 row := db.QueryRowContext(ctx, "SELECT state FROM reservation WHERE id=?", id) 并检查 row.Scan 的错误。database/sql 的 DB 是连接池,不要每请求 Open;用 SetMaxOpenConns 限制并发,观察 Stats().WaitCount 和 WaitDuration 区分“SQL 慢”和“拿不到连接”。事务内部必须始终用 tx,而非偶尔用 db.Exec;否则既逃出原子边界,又可能在小池里自锁。
复现泄漏。 在测试副本里只留下无缓冲发送 q←1,去掉 select 的取消分支;以 go test -timeout=2s -run TestBlockedProducerExits ./labs/runtime 运行,超时栈显示发送者停在 chan send,主测试停在 Wait。恢复文件再跑应为 PASS。容量更大的 channel 只延后故障。对象不释放也可能是这个等待者持有它,不一定是 GC 算法慢。
从通道阻塞队列落到网关 worker pool
完整源码:modeldock/operator/cmd/gateway/main.go · 第 92–113 行
go
for range 8 {
wg.Add(1)
go func() {
defer wg.Done()
for p := range jobs {
if budget.Err() != nil {
return
}
probe(p)
}
}()
}
enqueue:
for _, ps := range out {
for _, p := range ps {
select {
case jobs <- p:
case <-budget.Done():
break enqueue
}
}
}八个 worker 读取有界 jobs。生产者必须同时监听 budget.Done();否则探测超时后所有 worker 离开,生产者仍可能阻塞在发送上,使 refresh 永远无法公布新快照。发送方关闭队列,WaitGroup 等待 worker 结束;消费者不能以“我已经退出”为由关闭仍有其他生产者写入的通道。只有碰到同一块共享数据才要选锁:单计数器用 atomic;既改路由又改在途数时保护复合状态;读多写少、有可测锁竞争时才考虑 RWMutex。
完整源码:handbook/snippets/send.go.txt · 第 1–14 行
txt
// Go 1.26.2 src/runtime/chan.go:318 (Go Authors; BSD license)
func send(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) {
if c.bubble != nil && getg().bubble != c.bubble {
unlockf()
fatal("send on synctest channel from outside bubble")
}
if raceenabled {
if c.dataqsiz == 0 {
racesync(c, sg)
} else {
// Pretend we go through the buffer, even though
// we copy directly. Note that we need to increment
// the head/tail locations only when raceenabled.
racenotify(c, c.recvx, nil)现场题:取消请求,为什么探测任务还在运行?
沿请求方向画两条线。第一条从入站 HTTP context 到上游推理请求,客户端走了应退出;第二条从服务进程 context 到后台端点发现,取消一位用户不应让全站停止发现。两者都叫“取消”,归属却不同。检查实现时先找到谁创建 context、谁负责调用 cancel,最后检查每一个可能阻塞的发送与读取是否能被唤醒。若把两条线都接到 Background,泄漏会藏在成功的 HTTP 返回之后;若都接到第一个用户的请求,下一位用户会拿到过期路由。