Goroutine 便宜,并不等于你可以随便 go func。泄漏的 goroutine 和泄漏的 setInterval 是同一类问题:没有人负责停下来。

select 把「等数据、等取消、等超时」写成一个分支表。

Gointernal/worker/worker.go
1func run(ctx context.Context, jobs <-chan Job) error {2    for {3        select {4        case <-ctx.Done():5            return ctx.Err()6        case job, ok := <-jobs:7            if !ok {8                return nil9            }10            if err := job.Do(ctx); err != nil {11                return err12            }13        }14    }15}

ctx.Done() 优先退出,channel 关闭则收尾。没有 defaultselect 会阻塞,这通常是你想要的;加上 default 就变成轮询,CPU 会先抗议。

超时不要睡死

超时是取消的一种。用 context.WithTimeout,不要在业务里 time.Sleep 然后祈祷。

Gointernal/fetch/fetch.go
1func fetch(parent context.Context, url string) ([]byte, error) {2    ctx, cancel := context.WithTimeout(parent, 3*time.Second)3    defer cancel()4 5    req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)6    if err != nil {7        return nil, err8    }9    res, err := http.DefaultClient.Do(req)10    if err != nil {11        return nil, err12    }13    defer res.Body.Close()14    return io.ReadAll(res.Body)15}

只关闭你拥有的 channel

发送方关闭,接收方判断 ok。反过来关,是 panic 的经典现场。需要广播结束时,关的是 ctx,不是所有人的 channel。

Go
1select {2case jobs <- job:3case <-ctx.Done():4    return ctx.Err()5}
并发代码的可读性,取决于退出路径是否和进入路径一样显式。

Go 不靠类型系统拦住数据竞争(-race 是运行时的)。它靠的是:不要共享内存,用通信来共享。 select 就是那张通信日程表。Rust 用 所有权账本 在编译期做同样的事。取消信号从 HTTP 传到 worker,见 hono-typed-routes