标签: Go语言

  • Go 并发控制三种武器:WaitGroup、errgroup 与 semaphore

    Go 并发控制三种武器:WaitGroup、errgroup 与 semaphore

    为什么需要并发控制

    Go 的 goroutine 很便宜,但便宜不等于可以无节制地起。真实业务里并发任务通常有三类约束:要等所有任务跑完(WaitGroup)、任务之间要共享错误并快速取消(errgroup)、还要限制同时跑的数量(semaphore)。这三个原语分别解决「等齐」「出错即停」「限量」三个不同的问题。

    一、WaitGroup:等我的人都到齐

    最朴素的同步原语。Add 登记任务数,每个 goroutine 退出前 Done,Wait 阻塞直到计数归零。

    var wg sync.WaitGroup
    for _, url := range urls {
        wg.Add(1)
        go func(u string) {
            defer wg.Done()
            fetch(u)
        }(url)
    }
    wg.Wait() // 全部完成才继续

    坑点:Add 必须在启动 goroutine 之前调用,否则可能在 Wait 已返回后才 Add,直接 panic。循环变量要用参数传入,别在闭包里直接捕获。

    二、errgroup:出错即停,错误传递

    WaitGroup 只能等齐,不能处理错误,也不能在其中一个任务失败时主动取消其他任务。errgroup 在 WaitGroup 之上加了 context 取消和错误传播能力。

    g, ctx := errgroup.WithContext(context.Background())
    for _, url := range urls {
        url := url
        g.Go(func() error {
            return fetchWithCtx(ctx, url) // 任一返回 error,ctx 即被取消
        })
    }
    if err := g.Wait(); err != nil {
        log.Fatal(err) // 拿到第一个非 nil 错误
    }

    关键点:g.Go 里启动的函数必须监听 ctx 并支持取消,否则「出错即停」只是空话。需要限制并发数时,配合 semaphore 使用(见下)。

    三、semaphore:给并发装上节流阀

    errgroup 默认不限并发,瞬间起几百个 goroutine 可能打垮下游。用 golang.org/x/sync/semaphore 的加权信号量,限制同时运行的任务数。

    sem := semaphore.NewWeighted(int64(10)) // 最多 10 个并发
    g, ctx := errgroup.WithContext(ctx)
    for _, job := range jobs {
        if err := sem.Acquire(ctx, 1); err != nil {
            break
        }
        j := job
        g.Go(func() error {
            defer sem.Release(1)
            return do(j)
        })
    }
    g.Wait()

    三者怎么选

    原语解决的核心问题能处理错误吗
    WaitGroup等所有任务跑完不能
    errgroup等齐 + 出错即停 + 错误传递
    semaphore限制并发数量(节流)需配合 errgroup

    组合才是常态

    生产代码里三者几乎总是组合使用:errgroup 管生命周期与错误,semaphore 管并发度,WaitGroup 作为底层机制被 errgroup 封装。记住一句原则——并发数量由 semaphore 决定,错误与取消由 errgroup 负责,别把三个职责混在一个原语里硬凑。

  • Go 服务容器化实战:用多阶段构建把镜像压到 15MB

    Go 服务容器化实战:用多阶段构建把镜像压到 15MB

    为什么镜像体积值得你花十分钟优化

    很多团队把 Go 服务容器化的第一版 Dockerfile 都是从官方文档抄来的三行脚本,能跑就行。直到某天你在 CI 里看着 1GB 的镜像一层层往上推,或者在 Kubernetes 节点上等了半分钟才拉起新 Pod,才会意识到镜像体积不是「美观问题」,而是实打实的成本与稳定性问题。

    体积大的镜像至少带来三重代价:第一,拉取慢,冷启动和滚动发布的延迟直接变长;第二,占带宽和存储,在多副本、多集群的场景下费用被放大;第三,攻击面更大,基础镜像里那些你根本用不到的 shell、包管理器、系统库,都是潜在的安全隐患。Go 是静态编译语言,理论上完全可以产出「零依赖」的极简镜像,问题在于大多数人没把这件事做对。

    单阶段构建的「原罪」

    最朴素的写法长这样:

    FROM golang:1.22
    WORKDIR /app
    COPY . .
    RUN go build -o server .
    CMD ["./server"]
    

    它的致命问题在于:golang:1.22 这个镜像自带完整的 Go 工具链、Git、shell 和一大堆系统库,体积轻松超过 900MB。而你的运行时真正需要的,只有编译出来的那个二进制文件。换句话说,你把一个「编译器」和「整套源码」一起打包进了生产镜像。

    多阶段构建:把编译和运行彻底拆开

    多阶段构建(multi-stage build)的核心思想是用一个阶段专门编译,再用另一个阶段只装运行产物。两个阶段都写在同一个 Dockerfile 里,最终镜像只保留最后一个阶段的内容。

    FROM golang:1.22 AS builder
    WORKDIR /build
    COPY go.mod go.sum ./
    RUN go mod download
    COPY . .
    RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o server .
    
    FROM gcr.io/distroless/static-debian12
    COPY --from=builder /build/server /server
    EXPOSE 8080
    ENTRYPOINT ["/server"]
    

    注意第一阶段里先单独 COPY go.mod go.sumgo mod download,是一个关键顺序优化:只要依赖不变,这一层就会被缓存,后续改业务代码时不需要重新下载依赖。

    三个编译参数决定了体积下限

    • CGO_ENABLED=0:禁用 CGO,让二进制完全静态链接,这样换到 distroless 或 scratch 这类「裸」基础镜像也不会报缺少 libc。
    • GOOS=linux:即使你在 macOS 上构建,也明确指定目标系统,避免交叉编译出来的二进制不兼容。
    • -ldflags="-s -w":去掉符号表和调试信息,通常能再砍掉 25% 左右的二进制体积。

    记住一条经验法则:镜像里每多一个二进制之外的东西,都是你为「将来可能用得上」提前付的税。生产镜像越「贫瘠」,往往越健康。

    基础镜像到底怎么选

    基础镜像典型体积包含 shell适用场景
    golang:1.22~950MB仅编译阶段
    alpine:3.20~12MB是(ash)需要轻量排障、装包的场景
    distroless/static~20MB纯静态 Go 二进制,追求最小攻击面
    scratch~0MB完全静态二进制,极致瘦身

    如果程序没有通过 net 包用到需要解析 DNS 的动态链接,scratch 是最极致的选择;但一旦你的代码里用到了 net.LookupHost 这类依赖系统解析器的逻辑,scratch 和纯 distroless/static 可能解析不了域名,这时要换成 distroless/base 或在编译时加 -tags netgo 强制用纯 Go 的 DNS 解析。

    别忘了 .dockerignore

    很多体积优化都败在 COPY . . 这一步——如果你把 node_modules.git、本地日志、测试数据一股脑拷进构建上下文,不仅变慢,还会污染镜像。新建一个 .dockerignore

    .git
    node_modules
    *.log
    tmp/
    .env
    Dockerfile
    docker-compose.yml
    

    一个可直接复用的生产级 Dockerfile

    把上面的要点收拢,下面是一个带 BuildKit 缓存挂载的完整版本,兼顾体积与构建速度:

    # syntax=docker/dockerfile:1.6
    FROM golang:1.22 AS builder
    WORKDIR /build
    COPY go.mod go.sum ./
    RUN --mount=type=cache,target=/go/pkg/mod \
        go mod download
    COPY . .
    RUN --mount=type=cache,target=/go/pkg/mod \
        --mount=type=cache,target=/root/.cache/go-build \
        CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o server .
    
    FROM gcr.io/distroless/static-debian12
    COPY --from=builder /build/server /server
    USER nonroot:nonroot
    EXPOSE 8080
    ENTRYPOINT ["/server"]
    

    进阶:用 BuildKit 缓存加速依赖层

    上面 Dockerfile 里的 --mount=type=cache 是 BuildKit 的能力。它把 Go 的模块缓存和编译缓存挂到宿主机,多次构建之间复用,能把「改一行代码重构建」的时间从几十秒压到几秒。配合 --mount=type=cache,target=/root/.cache/go-build 还能跨构建复用编译产物。

    写在最后

    把 Go 镜像从 1GB 压到 15MB,本质上没有魔法,只是把「编译环境」和「运行环境」彻底分离,并诚实地审视镜像里每一样东西是否真的必要。多阶段构建 + distroless + 正确的编译参数 + .dockerignore,这四件套足够应付绝大多数 Go 服务的生产镜像需求。下一次写 Dockerfile,先问自己一句:这一层,上线之后真的用得到吗?