Skip to content

同步的单位是操作,取消的单位是请求 ​

若两个请求都读到剩余 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 返回之后;若都接到第一个用户的请求,下一位用户会拿到过期路由。