再也不用怕开太多 goroutine 了!手撸一个 Go 并发调度器

526 阅读28分钟

前言

自从使用 Go 语言以来,我们就能用它来编写或者重构我们项目中的服务端接口,比如最常见的增删改查(CRUD)这些后端逻辑代码。
不过话说回来,如果我们一直停留在这些基础 API 的开发层面,其实并没有真正发挥 Go 的优势哈。

毕竟我们选择用 Go,很大一部分原因就是它在并发处理方面的能力非常强 —— goroutine、channel 这些“轻量又高效”的并发原语,就是 Go 最厉害的地方。

所以这篇文章,就通过一个我们日常开发中能遇到的小场景,来了解一下:用 Go 怎么实现一个带并发处理能力的业务流程,也顺便巩固下我们对 goroutine 和 channel 的理解。

goroutine:Go 并发的第一步

在我们学习 Go 基础语法的过程中,应该都接触过 goroutine 的用法。Go 中开启并发任务的方式非常简单,只需要在函数调用前加一个 go 关键字:

go doSomething()

或者也可以直接用匿名函数的方式:

go func() {
    // 执行异步逻辑
}()

这一行代码,就能启动一个 goroutine,也就是一个由 Go 运行时管理的轻量级协程,它的开销远远小于线程,几乎不需要我们手动管理。

Go 的并发模型是通过 调度器(GMP 模型) 来完成 goroutine 和系统线程之间的映射的,所以我们可以非常轻松地同时运行成百上千个 goroutine,而不用像传统语言一样担心线程上下文切换带来的负担。

举个例子:批量给用户发送系统消息

假设我们后台系统中有这样一个功能:批量给用户发送系统通知消息,用户量可能成千上万。

如果我们用 PHP 来实现,大概会这样写:

foreach ($users as $user) {
    pushMessageToQueue($user, $message);
}

PHP 本身是同步阻塞的,为了提升处理能力,通常是循环每个用户,把任务推到 MQ(消息队列)里去,由队列后端异步慢慢消费。
这种方式虽然能解耦,但是:

  • 每推一条消息,都要走一次 MQ,频繁 IO,增加系统压力
  • 消费端消费速度如果跟不上,会堆积,延迟高
  • PHP 本身并没有协程调度能力,只能靠外部系统去并发

如果我们用 Go 呢?

在 Go 里,我们可以很简单地直接通过 goroutine,把发消息的任务并发执行:

emails := []string{"a@example.com", "b@example.com", "c@example.com"}

for _, email := range emails {
    go sendSystemMessage(email, "欢迎加入我们!")
}
  • 每个发送任务独立由一个 goroutine 执行
  • 不需要额外依赖 MQ 中转,直接快速推送
  • 整体速度非常快,而且系统开销小

这样,即便是处理几千上万个用户通知,也能轻松应对。


chan:协程之间的数据通信方式

虽然我们已经知道了,使用 go 关键字可以非常简单地启动一个 goroutine,并发地处理任务。
但是在实际业务开发中,仅仅让多个协程各自做各自的事情,很多时候是远远不够的。

比如,我们正在开发一款社交 App,用户有一个好友列表。
在展示好友列表的时候,需要通过像云信(网易云信)这样的第三方接口,去实时查询每个好友的在线状态,然后统一展示在页面上。

我们当然可以用 Go 的 goroutine 来并发调用接口,快速查询所有好友的在线状态。
但是查询完成后,这些查询结果需要统一收集起来,等全部拿到以后再一并返回给前端。
这就遇到了一个实际问题:

怎么让多个 goroutine,把各自查询到的结果传回来?

在 Go 语言中,解决这个问题最常见的方式,就是使用 channel

channel 可以理解为 goroutine 之间传递数据的并发安全通道:

  • 每个查询任务,将自己的查询结果写进 channel
  • 主程序统一从 channel 中读取所有查询结果
  • 中间过程天然并发安全,不需要加锁控制

简单来说,我们可以把 channel 想象成一个协程之间共享的“传输带”
一个 goroutine 可以往上面放数据,另一个 goroutine 负责取数据。

这里我们简单再回顾一下 Go 中 channel 的基本用法:

ch := make(chan string) // 创建一个 string 类型的 channel

go func() {
    ch <- "Hello, World" // 将数据写进 channel
}()

msg := <-ch // 从 channel 读取数据
fmt.Println(msg) // 输出 "Hello, World"

这段代码中:

  • 通过 make(chan string) 创建了一个可以传递字符串的 channel
  • 使用 ch <- "Hello, World" 把数据发送到 channel
  • 再通过 msg := <-ch 从 channel 中接收数据

发送和接收过程是同步阻塞的,这也是 Go 控制协程协作的一种重要手段。

回到我们上面说的业务场景, 接下来我们来用 goroutine + channel,来简单实现一下上面提到的社交 App 批量查询好友在线状态的功能。

示例:并发查询好友在线状态

friends := []string{"Tom", "Alice", "Bob"}
ch := make(chan string)

for _, friend := range friends {
    go func(name string) {
        // 模拟查询在线状态
        time.Sleep(time.Millisecond * 500)
        ch <- fmt.Sprintf("%s 当前在线", name)
    }(friend)
}

// 主程序统一收集查询结果
for i := 0; i < len(friends); i++ {
    status := <-ch
    fmt.Println(status)
}

运行结果类似:

Tom 当前在线
Alice 当前在线
Bob 当前在线

这里我们使用 channel,把每个 goroutine 查询到的结果,统一汇总到了主程序里,非常自然高效。


WaitGroup:等待多个 goroutine 完成的控制器

上面我们用 goroutine + channel,成功并发查询了每个好友的在线状态。
但是,我们有没有注意到一个问题?

虽然我们收集到了数据,但其实是通过循环 for i := 0; i < len(friends); i++阻塞读取 channel 的。

这种方式在简单场景下是没问题的,但如果业务复杂一点,比如:

场景:好友状态查询 + 额外的业务处理

比如在查询完好友的在线状态后,我们还要给状态变化的好友发送通知,或者记录到日志系统里。
如果我们只是用 for 循环读取 channel,有可能主程序还没等所有 goroutine 真正执行完就提前继续向下跑了。
这时候就容易出问题,比如:

  • 有的 goroutine 还没执行到发送通知
  • 有的日志记录丢失
  • 资源没释放,程序异常退出

那怎么办?

这时候我们就需要一个重要的并发工具了 —— sync.WaitGroup

WaitGroup 的作用

简单理解,WaitGroup 就是一个并发控制器:

  • 可以告诉主程序:“我一共启动了多少个任务”
  • 然后主程序可以调用 Wait() 等待,直到所有 goroutine 都干完活,再继续执行后面的逻辑

用法也很简单:

var wg sync.WaitGroup

wg.Add(1) // 表示要等待一个任务
go func() {
    defer wg.Done() // 任务完成
    doSomething()
}()

wg.Wait() // 等待所有任务完成

通过这种方式,我们就可以很自然地管理多个协程的执行顺序,防止主程序跑太快,导致协程还没执行完就退出了。

如果我们有使用过 PHP 的 Swoole 框架,其实这个 WaitGroup 的概念并不陌生。
在 Swoole 里面,协程并发处理请求时,也可以通过 WaitGroup 机制来控制多个协程的完成状态。 所以,对于从 PHP 转 Go 的同学来说,理解 Go 的 sync.WaitGroup 并不难,只不过 Go 是语言级原生支持,写法更加轻量、简洁。

回到我们的好友状态查询业务

如果我们要确保:

  • 所有好友状态都查询完
  • 状态变化通知也发完
  • 日志也记录好

再统一返回数据,那就需要改造一下,加上 WaitGroup

var wg sync.WaitGroup
friends := []string{"Tom", "Alice", "Bob"}
ch := make(chan string, len(friends)) // 注意:加缓冲,避免 goroutine 堵住

for _, friend := range friends {
    wg.Add(1)
    go func(name string) {
        defer wg.Done()

        // 模拟查询在线状态
        time.Sleep(time.Millisecond * 500)
        status := fmt.Sprintf("%s 当前在线", name)

        // 额外业务处理
        log.Printf("[日志] %s 状态查询完毕", name)

        ch <- status
    }(friend)
}

// 等所有查询和处理都完成
wg.Wait()
close(ch) // 关闭 channel,通知读取方没有新数据了

for status := range ch {
    fmt.Println(status)
}

输出:

[日志] Tom 状态查询完毕
[日志] Alice 状态查询完毕
[日志] Bob 状态查询完毕
Tom 当前在线
Alice 当前在线
Bob 当前在线
  • 每个好友并发查询 + 记录日志 + 写入 channel
  • 等所有任务真的完成以后,主程序再统一收集结果

这样就不会出现漏处理、未完成的情况了。


协程池(Worker Pool)?

我们再回头看上面发送系统消息的例子中,我们是这样做的:

emails := []string{"a@example.com", "b@example.com", "c@example.com"}

for _, email := range emails {
    go sendSystemMessage(email, "欢迎加入我们!")
}

通过简单的 for 循环,我们为每个用户启动了一个 goroutine,同时并发发送消息。
在用户量不大的情况下,比如几百几千人,这种写法没有任何问题,而且非常高效。

但如果用户量达到数万 十万 甚至百万级呢?

在实际业务场景中,我们可能会面临:

  • 成千上万、甚至上百万条任务需要处理
  • 每一条都直接开启一个新的 goroutine
  • 内存开销巨大,调度压力爆表,容易导致系统崩溃

这里我简单总结一下直接开 goroutine 的隐患:

问题描述
内存飙升每个 goroutine 虽小,但百万量级一样能把内存打爆
调度开销大Go 运行时需要不停调度 goroutine,导致 CPU 压力急剧上升
资源不可控高并发访问下游接口,可能拖垮整个后端系统

这里就涉及到一个我们架构设计中通用的工程思路 —— 池化(Pool)

如果我们有过 Java 开发经验,就一定知道:

  • Java 里面有 线程池(ThreadPool)
  • 大量请求来了,并不是每个请求都起一个线程,而是从线程池里取一个空闲线程来执行
  • 线程数是受控的,比如固定开 50 个线程,多余的请求排队

又比如:

  • MySQL 客户端连接数据库时,也不会每次重新建立连接
  • 通常会用 连接池(Connection Pool)
  • 连接池中维持一定数量的活跃连接,需要时拿一个,用完放回

总结一句话:池化的核心思想就是:

不要每来一个任务就“开资源”,而是用有限的资源去复用处理无限的任务。

那 Go 里怎么办?

Go 语言没有内置协程池(Worker Pool),但是由于 goroutine 创建销毁很轻量,所以我们可以很容易地自己实现一个固定大小的 Worker Pool:

  • 先开好固定数量的 goroutine
  • 后续任务都交给这些 goroutine 处理
  • 多余的任务排队等待空闲
  • 保证系统资源使用受控且高效

我们来做一下简单的对比:

直接开 goroutineWorker Pool
每个任务开一个协程只开固定数量协程,统一调度
内存开销大,易爆炸内存稳定,负载可控
无法限流保护系统可限流,保护下游接口
适合小规模处理适合大规模并发任务

所以,当我们从小项目(几百任务)走向真实中大型项目(百万请求)时,
Worker Pool 协程池 就变成了我们业务场景中必不可少的一种并发控制手段。

那么,我们自己怎么设计一个协程池呢?

既然 Go 没有内置协程池,那我们就可以自己动手造一个简单的 Worker Pool。

设计思路也很简单:

  • 先初始化固定数量的 worker(每个 worker 是一个 goroutine)
  • 所有需要执行的任务,统一丢到一个任务队列(channel)
  • worker 不断从任务队列里取任务来执行
  • 主程序只需要往任务队列里提交任务即可

通过这种方式,我们就可以控制同时并发的 goroutine 数量,让系统资源保持在一个合理可控的范围内。

简单版协程池 Demo

下面我们来写一个最基础的协程池。

package main

import (
	"fmt"
	"sync"
	"time"
)

// 定义任务结构体
type Task struct {
	ID      int
	Content string
}

// 定义协程池结构体
type WorkerPool struct {
	taskChan  chan Task    // 任务通道
	workerNum int          // worker数量
	wg        sync.WaitGroup // 用于等待所有任务完成
}

// 创建新的协程池
func NewWorkerPool(workerNum int) *WorkerPool {
	return &WorkerPool{
		taskChan:  make(chan Task, 100), // 初始化带缓冲的任务通道
		workerNum: workerNum,
	}
}

// 启动 worker
func (p *WorkerPool) Start() {
	for i := 1; i <= p.workerNum; i++ {
		go p.worker(i)
	}
}

// 提交任务到任务通道
func (p *WorkerPool) Submit(task Task) {
	p.wg.Add(1)
	p.taskChan <- task
}

// 每个 worker 不断从任务通道获取任务并执行
func (p *WorkerPool) worker(id int) {
	for task := range p.taskChan {
		fmt.Printf("Worker %d 正在处理任务 %d: %s\n", id, task.ID, task.Content)
		time.Sleep(time.Second) // 模拟处理耗时
		p.wg.Done()             // 任务处理完成,通知 WaitGroup
	}
}

// 关闭任务通道并等待所有任务完成
func (p *WorkerPool) Wait() {
	close(p.taskChan) // 关闭任务通道,防止继续发送任务
	p.wg.Wait()       // 阻塞等待所有任务完成
	fmt.Println("所有任务处理完毕!")
}

func main() {
	pool := NewWorkerPool(3) // 创建协程池,3个 worker
	pool.Start()             // 启动 worker

	// 提交10个任务
	for i := 1; i <= 10; i++ {
		task := Task{
			ID:      i,
			Content: fmt.Sprintf("任务内容 %d", i),
		}
		pool.Submit(task)
	}

	pool.Wait() // 等待所有任务处理完成

	fmt.Println("所有任务执行完毕")
}

我们来简单解释一下这段代码:

WorkerPool 结构体

  • 持有一个 taskChan,用来接收提交的任务
  • 持有一个 workerNum,指定并发 worker 的数量
  • 持有一个 sync.WaitGroup,用来等待所有任务完成

NewWorkerPool(workerNum int) 方法

  • 创建一个新的协程池实例
  • 初始化带缓冲的任务通道 taskChan
  • 设置需要启动的 worker 数量 workerNum

Start() 方法

  • 启动固定数量的 worker
  • 每个 worker 独立运行,监听任务通道

worker(id int) 方法

  • 每个 worker 在死循环中从 taskChan 拿任务

  • 有任务就执行,没任务时阻塞等待

  • 每完成一个任务,调用 wg.Done(),告诉主程序减少一个未完成任务数 Submit(task Task) 方法

  • 对外暴露的任务提交接口

  • 每提交一个任务,wg.Add(1),然后把任务丢进 taskChan

Wait() 方法

  • 关闭 taskChan,告诉 worker 没有新任务了
  • 调用 wg.Wait(),阻塞等待所有任务处理完毕
  • 所有任务完成后,程序统一收尾

输出示例(有可能交错打印)

Worker 1 正在处理任务 1: 任务内容 1
Worker 2 正在处理任务 2: 任务内容 2
Worker 3 正在处理任务 3: 任务内容 3
Worker 1 正在处理任务 4: 任务内容 4
Worker 2 正在处理任务 5: 任务内容 5
Worker 3 正在处理任务 6: 任务内容 6
Worker 1 正在处理任务 7: 任务内容 7
Worker 2 正在处理任务 8: 任务内容 8
Worker 3 正在处理任务 9: 任务内容 9
Worker 1 正在处理任务 10: 任务内容 10
所有任务处理完毕!
所有任务执行完毕

注意:worker 是并发处理的,所以日志输出顺序可能和任务提交顺序不完全一样,但总能确保所有任务都被处理完。

基础协程池的局限 & 实际业务中的应用

我们上面自己手写的这个协程池,其实只是一个最基础的 Demo,实现了一个简单的“池化”模型。

它基本具备了协程池的核心能力限制并发数量、支持任务投递、等待所有任务完成后统一收尾。

但是它依然还有很多实际业务中需要考虑的点没有覆盖,比如:

  • 每个任务执行是否需要超时机制?
  • 如果任务队列塞满了怎么办?是否需要限流或丢弃?
  • 如果某个任务执行 panic,如何防止整个 worker 崩溃?
  • 如何动态伸缩 worker 数量?

这些都可以作为我们实际业务场景中扩展的方向。
不过在进入更复杂的功能之前,我们可以先用当前这个协程池模型,来实现一个我们实际项目中会遇到的业务问题

业务场景示例:后台批量发放维护补偿奖励

比如我们项目每月都会根据业务需求进行功能迭代和版本更新。
而在每次发版的过程中,通常都需要开启维护模式来保障版本平稳上线。

当维护结束后,运营同学会发起一个维护补偿活动,向所有注册用户统一发放一笔维护补偿奖励。

假如这个需求需要我们来实现。我们就需要考虑一些问题:

  • 活跃用户可能有几万、几十万,甚至更多
  • 如果一条条地串行发放奖励,效率极低
  • 并发太高又可能压垮数据库或奖励服务

所以我们就可以借助前面自己实现的 Worker Pool 协程池,来应对这个问题:

  • 控制并发数量,比如每次最多并发 100 个发奖任务
  • 避免瞬时写入压力,保护后端服务的稳定性
  • 任务执行受控,方便我们后续加上失败重试、日志记录等能力

接下来,我们就基于这个业务场景,使用前面构建的协程池,
来简单模拟一个:如何用 Go 并发、安全、高效地批量发放奖励。

设计思路:基于协程池的发奖调度器

在这个业务场景下,我们设计代码需要考虑的是:

  • 控制并发数量,防止发奖服务和数据库被打爆
  • 每个任务支持超时机制,避免卡死
  • 支持失败 重试机制,比如失败最多尝试 3 次
  • 发奖任务之间互不影响,即使某个用户出错,也不影响其他人
  • 能实时收集结果并统计发奖成功/失败数量
  • 最好有异常捕获机制,防止某个任务 panic 直接导致整个程序挂掉

那我们就可以基于上面我们设计的协程池,来实现一个简单的发奖业务逻辑:

package main

import (
    "fmt"
    "sync"
    "time"
)

// 模拟发奖函数(这里固定耗时 500ms)
func GiveReward(userID int, giftID int) {
    time.Sleep(500 * time.Millisecond)
    fmt.Printf("发奖成功:用户 %d,礼物 %d\n", userID, giftID)
}

// 定义任务结构
type RewardTask struct {
    UserID int
    GiftID int
}

// 简单协程池结构
type RewardPool struct {
    TaskChan  chan RewardTask
    WorkerNum int
    wg        sync.WaitGroup
}

// 创建协程池
func NewRewardPool(workerNum int) *RewardPool {
    return &RewardPool{
       TaskChan:  make(chan RewardTask, 100),
       WorkerNum: workerNum,
    }
}

// 启动 worker
func (p *RewardPool) Start() {
    for i := 1; i <= p.WorkerNum; i++ {
       go p.worker(i)
    }
}

// 提交任务
func (p *RewardPool) Submit(task RewardTask) {
    p.wg.Add(1)
    p.TaskChan <- task
}

// worker 执行任务逻辑
func (p *RewardPool) worker(id int) {
    for task := range p.TaskChan {
       fmt.Printf("Worker %d 正在处理用户 %d 发奖任务\n", id, task.UserID)
       GiveReward(task.UserID, task.GiftID)
       p.wg.Done()
    }
}

// 等待所有任务完成
func (p *RewardPool) Wait() {
    close(p.TaskChan)
    p.wg.Wait()
}

func main() {
    pool := NewRewardPool(3) // 3 个 worker 并发发奖
    pool.Start()

    // 模拟发 10 个任务
    for i := 1; i <= 10; i++ {
       pool.Submit(RewardTask{
          UserID: i,
          GiftID: 1001,
       })
    }

    pool.Wait()
    fmt.Println("所有奖励发放完毕")
}

上面代码中我们的设计思路是这样的

  • 每个 RewardTask 表示一次发奖操作(用户 ID + 礼物 ID)
  • RewardPool 控制并发数量(3 个 worker)
  • 所有任务都从 TaskChan 进入,worker 持续消费执行
  • wg.Wait() 保证主程序最后等所有任务执行完成

我们运行上面代码结果(顺序会打乱)

image.png

这样,我们就实现了一个最基础的发奖协程池。它已经能处理并发任务、控制协程数量,并确保任务全部完成后再退出程序。

不过,在实际业务开发中,我们设计一个发奖系统远不止这些

我们不能只满足于“能跑通逻辑”,还需要考虑很多实际运行时可能出现的问题,比如:

  • 某些任务执行时间过长,是否需要设置 超时时间
  • 发奖请求可能失败,是否应该加入 失败重试机制
  • 某个任务内部逻辑出现异常甚至 panic,是否会导致整个 worker 崩掉?
  • 如何 收集每个任务的执行结果,做最终的统计汇总?

为了让我们设计的这个发奖系统更加完善,接下来,我们就继续在当前这个协程池的基础上,逐步完善这些问题的处理逻辑

我们来修改一下上面代码中的 worker 执行逻辑

为了让发奖逻辑更贴近真实业务,我们新增两个能力:

  • 超时控制:每个任务最多执行 2 秒,超时算失败
  • 失败重试:发奖失败最多重试 MaxRetry

扩展 RewardTask,加入最大重试次数字段

type RewardTask struct {
    UserID int
    GiftID int
    MaxRetry int // 新增:最大重试次数
}

新增一个任务执行函数:handleWithRetryAndTimeout

// 发奖处理函数,支持重试和超时
func handleWithRetryAndTimeout(task RewardTask, timeout time.Duration) error {
    for attempt := 1; attempt <= task.MaxRetry+1; attempt++ {
       ctx, cancel := context.WithTimeout(context.Background(), timeout)
       done := make(chan error, 1)

       go func() {
          // 模拟发奖逻辑(可失败)
          err := GiveReward(task.UserID, task.GiftID)
          done <- err
       }()

       select {
       case err := <-done:
          cancel()
          if err == nil {
             return nil // 成功
          }
          fmt.Printf("用户 %d 第 %d 次发奖失败:%v\n", task.UserID, attempt, err)
       case <-ctx.Done():
          cancel()
          fmt.Printf("用户 %d 第 %d 次发奖超时\n", task.UserID, attempt)
       }
    }
    return errors.New("任务执行失败(已重试)")
}

上面我们实现了 handleWithRetryAndTimeout 函数,这个函数的目标是:

  • 给发奖任务加上超时控制
  • 每次失败可以进行多次重试

我们先来看里面的这段关键逻辑:

ctx, cancel := context.WithTimeout(context.Background(), timeout)
done := make(chan error, 1)

在 Go 中,如果我们想控制某段代码的执行时长
最常用的就是用 context.WithTimeout() 来创建一个带“超时时间”的上下文。

ctx, cancel := context.WithTimeout(context.Background(), timeout)

这行代码的含义是:

  • 创建了一个 2 秒(我们传入的 timeout)超时的上下文 ctx
  • cancel() 是释放资源的函数(用完后记得调用)

我们在 goroutine 中传递这个上下文,就可以实现在超时时间一到时主动中断逻辑。
不过这段代码中我们并没有把 ctx 传给 GiveReward()
因为 GiveReward() 是个模拟函数本身不支持上下文。
所以这里我们是通过 select 来实现超时的外部中断。

我们在上述代码中写了一个done := make(chan error, 1) 是因为我们希望:

  • 在 goroutine 中执行 GiveReward() 发奖逻辑
  • 发奖成功后把结果发送出来
  • 主协程在外部等待结果,或判断是否超时

这里我们用一个 chan error 来传递发奖的执行结果:

go func() {
    done <- GiveReward(task.UserID, task.GiftID)
}()

因为这个 goroutine 是异步的,所以我们需要一个 done channel 来让外部“感知”它完成与否。

接下来是重点:select 控制执行逻辑

select {
case err := <-done:
    // 发奖函数返回(成功或失败)
case <-ctx.Done():
    // 超时触发
}

select 是 Go 并发控制的核心语法之一,它的作用是:
在多个 channel 中选择“哪个先收到消息”,就执行对应的逻辑

在我们的例子里:

  • 如果 done <- GiveReward(...) 先返回了,说明发奖逻辑在超时前完成,我们就继续判断成功/失败
  • 如果 ctx.Done() 先触发了,说明超时了,我们就认为任务失败,并进行下一次重试

这就是为什么我们说:

select + channel + context.WithTimeout 是 Go 中实现“任务超时控制”的经典组合模式。

整理下上面我们用到的技术点:

技术点用途
context.WithTimeout()设置任务最大执行时间
cancel()释放 context 相关资源,防止泄露
chan error用于异步 goroutine 传递结果
select在超时 / 正常完成之间做选择

修改 worker() 方法,接入新的执行逻辑

// worker 执行任务逻辑
func (p *RewardPool) worker(id int) {
    for task := range p.TaskChan {
       fmt.Printf("Worker %d 正在处理用户 %d 发奖任务\n", id, task.UserID)

       err := handleWithRetryAndTimeout(task, 2*time.Second)

       if err != nil {
          fmt.Printf("用户 %d 发奖最终失败:%v\n", task.UserID, err)
       } else {
          fmt.Printf("用户 %d 发奖成功!\n", task.UserID)
       }

       p.wg.Done()
    }
}

修改GiveReward方法模拟失败几率

func GiveReward(userID, giftID int) error {
    if rand.Intn(100) < 30 {
       return fmt.Errorf("用户 %d 发奖失败(模拟错误)", userID)
    }

    time.Sleep(time.Millisecond * time.Duration(300+rand.Intn(700))) // 模拟延迟
    return nil
}

示例任务提交代码(示例用法)

// 模拟发 10 个任务
for i := 1; i <= 10; i++ {
    pool.Submit(RewardTask{
       UserID:   i,
       GiftID:   1001,
       MaxRetry: 3, // 最多重试 3 次
    })
}

最终代码如下:

package main

import (
    "context"
    "errors"
    "fmt"
    "math/rand"
    "sync"
    "time"
)

// 模拟发奖逻辑:30% 概率失败 + 耗时
func GiveReward(userID, giftID int) error {
    if rand.Intn(100) < 30 {
       return fmt.Errorf("用户 %d 发奖失败(模拟错误)", userID)
    }

    time.Sleep(time.Millisecond * time.Duration(300+rand.Intn(700))) // 模拟延迟
    return nil
}

// 定义任务结构
type RewardTask struct {
    UserID   int
    GiftID   int
    MaxRetry int // 新增:最大重试次数
}

// 简单协程池结构
type RewardPool struct {
    TaskChan  chan RewardTask
    WorkerNum int
    wg        sync.WaitGroup
}

// 发奖处理函数,支持重试和超时
func handleWithRetryAndTimeout(task RewardTask, timeout time.Duration) error {
    for attempt := 1; attempt <= task.MaxRetry+1; attempt++ {
       ctx, cancel := context.WithTimeout(context.Background(), timeout)
       done := make(chan error, 1)

       go func() {
          // 模拟发奖逻辑(可失败)
          err := GiveReward(task.UserID, task.GiftID)
          done <- err
       }()

       select {
       case err := <-done:
          cancel()
          if err == nil {
             return nil // 成功
          }
          fmt.Printf("用户 %d 第 %d 次发奖失败:%v\n", task.UserID, attempt, err)
       case <-ctx.Done():
          cancel()
          fmt.Printf("用户 %d 第 %d 次发奖超时\n", task.UserID, attempt)
       }
    }
    return errors.New("任务执行失败(已重试)")
}

// 创建协程池
func NewRewardPool(workerNum int) *RewardPool {
    return &RewardPool{
       TaskChan:  make(chan RewardTask, 100),
       WorkerNum: workerNum,
    }
}

// 启动 worker
func (p *RewardPool) Start() {
    for i := 1; i <= p.WorkerNum; i++ {
       go p.worker(i)
    }
}

// 提交任务
func (p *RewardPool) Submit(task RewardTask) {
    p.wg.Add(1)
    p.TaskChan <- task
}

// worker 执行任务逻辑
func (p *RewardPool) worker(id int) {
    for task := range p.TaskChan {
       fmt.Printf("Worker %d 正在处理用户 %d 发奖任务\n", id, task.UserID)

       err := handleWithRetryAndTimeout(task, 2*time.Second)

       if err != nil {
          fmt.Printf("用户 %d 发奖最终失败:%v\n", task.UserID, err)
       } else {
          fmt.Printf("用户 %d 发奖成功!\n", task.UserID)
       }

       p.wg.Done()
    }
}

// 等待所有任务完成
func (p *RewardPool) Wait() {
    close(p.TaskChan)
    p.wg.Wait()
}

func main() {
    pool := NewRewardPool(3) // 3 个 worker 并发发奖
    pool.Start()

    // 模拟发 10 个任务
    for i := 1; i <= 10; i++ {
       pool.Submit(RewardTask{
          UserID:   i,
          GiftID:   1001,
          MaxRetry: 3, // 最多重试 3 次
       })
    }

    pool.Wait()
    fmt.Println("所有奖励发放完毕")
}

我们执行下上述代码后结果如下:

image.png

继续完善:任务结果的收集与统计

在实际的业务场景中,我们发完奖励后,通常还需要统计成功和失败的数量,甚至需要把失败名单汇总给到运营同学处理。
所以,继续在上面的基础上,我们可以让每个任务在执行完之后,把结果信息统一收集起来。

第一步:新增结果结构体

我们先新增一个 RewardResult 结构体,用来保存每个任务的执行结果:

type RewardResult struct {
	UserID  int
	Success bool
	Message string
}
  • UserID:用户 ID
  • Success:是否发奖成功
  • Message:备注信息,比如失败原因或者成功提示

第二步:扩展协程池,增加结果通道

修改 RewardPool,新增一个 ResultChan 来收集结果:

type RewardPool struct {
	TaskChan   chan RewardTask
	ResultChan chan RewardResult // 新增:结果通道
	WorkerNum  int
	wg         sync.WaitGroup
}

NewRewardPool 初始化的时候也一并把 ResultChan 创建出来:

func NewRewardPool(workerNum int) *RewardPool {
	return &RewardPool{
		TaskChan:   make(chan RewardTask, 100),
		ResultChan: make(chan RewardResult, 100), // 新增
		WorkerNum:  workerNum,
	}
}

Wait等待结果的时候也一并关闭ResultChan

// 等待所有任务完成
func (p *RewardPool) Wait() {
    close(p.TaskChan) // 关闭任务通道,worker会自然退出
    p.wg.Wait()       // 等待所有worker完成
    close(p.ResultChan) // 最后关闭结果通道,通知结果收集器退出
}

第三步:修改 handleWithRetryAndTimeout 返回结果信息

现在 handleWithRetryAndTimeout 不再只返回 error,而是返回一个 RewardResult

func handleWithRetryAndTimeout(task RewardTask, timeout time.Duration) RewardResult {
	for i := 0; i <= task.MaxRetry; i++ {
		ctx, cancel := context.WithTimeout(context.Background(), timeout)
		done := make(chan error, 1)

		go func() {
			done <- GiveReward(task.UserID, task.GiftID)
		}()

		select {
		case err := <-done:
			cancel()
			if err == nil {
				return RewardResult{UserID: task.UserID, Success: true, Message: "发奖成功"}
			}
			fmt.Printf("用户 %d 第 %d 次发奖失败:%v\n", task.UserID, i+1, err)
		case <-ctx.Done():
			cancel()
			fmt.Printf("用户 %d 发奖超时!\n", task.UserID)
		}
	}
	return RewardResult{UserID: task.UserID, Success: false, Message: "发奖失败(重试耗尽)"}
}

第四步:修改 worker,把结果写进 ResultChan

让每个 worker 在处理完一个任务后,把结果发送到 ResultChan

func (p *RewardPool) worker(id int) {
	for task := range p.TaskChan {
		fmt.Printf("Worker %d 正在处理用户 %d 发奖任务\n", id, task.UserID)
		result := handleWithRetryAndTimeout(task, 2*time.Second)
		p.ResultChan <- result
		p.wg.Done()
	}
}

第五步:在 main() 中增加结果收集器

到目前为止,我们的协程池已经可以支持:

  • 控制并发处理发奖任务
  • 每个任务带超时机制
  • 支持失败自动重试
  • 任务执行完之后,把结果写入 ResultChan

接下来,我们要做的就是启动一个单独的结果收集器,实时消费 ResultChan 中的发奖结果,
并且最终统计出:

  • 总任务数量
  • 发奖成功数量
  • 发奖失败数量
  • 失败的用户 ID 列表

这样一来,发奖系统就具备了完整的可观测性和可追溯性,后续如果出现问题,能快速定位并补偿处理。 代码如下:

func main() {
    pool := NewRewardPool(3) // 3 个 worker 并发发奖
    pool.Start()

    // 模拟发 10 个任务
    for i := 1; i <= 10; i++ {
       pool.Submit(RewardTask{
          UserID:   i,
          GiftID:   1001,
          MaxRetry: 0,
       })
    }

    // 启动结果收集器
    var successCount, failCount int
    var failUserIDs []int
    var resultWg sync.WaitGroup
    resultWg.Add(1)

    go func() {
       defer resultWg.Done()
       for result := range pool.ResultChan {
          if result.Success {
             successCount++
          } else {
             failCount++
             failUserIDs = append(failUserIDs, result.UserID)
          }
          fmt.Printf("用户 %d 发奖结果:%s\n", result.UserID, result.Message)
       }
    }()

    // 等待所有任务执行完毕
    pool.Wait()
    // 等待收集器收集完所有结果
    resultWg.Wait()
    fmt.Println("所有奖励发放完毕")

    // 最终统计输出
    fmt.Println("\n发奖统计结果")
    fmt.Printf("总任务数:%d\n", successCount+failCount)
    fmt.Printf("成功数量:%d\n", successCount)
    fmt.Printf("失败数量:%d\n", failCount)
    if len(failUserIDs) > 0 {
       fmt.Printf("失败用户ID列表:%v\n", failUserIDs)
    } else {
       fmt.Println("全部发奖成功!")
    }
}
  • 我们在 main() 启动了一个专门收集结果的 goroutine。
  • 每收到一个任务的 RewardResult,就实时更新成功/失败统计。
  • 所有任务完成后,统一输出最终汇总结果。
  • 这样即使任务很多(几万几十万),系统也可以边处理边记录,性能开销极小。

完整代码demo如下:

package main

import (
    "context"
    "fmt"
    "math/rand"
    "sync"
    "time"
)

// 模拟发奖逻辑:30% 概率失败 + 耗时
func GiveReward(userID, giftID int) error {
    if rand.Intn(100) < 30 {
       return fmt.Errorf("用户 %d 发奖失败(模拟错误)", userID)
    }

    time.Sleep(time.Millisecond * time.Duration(300+rand.Intn(700))) // 模拟延迟
    return nil
}

// 定义任务结构
type RewardTask struct {
    UserID   int
    GiftID   int
    MaxRetry int // 新增:最大重试次数
}

type RewardResult struct {
    UserID  int
    Success bool
    Message string
}

// 简单协程池结构
type RewardPool struct {
    TaskChan   chan RewardTask
    WorkerNum  int
    ResultChan chan RewardResult
    wg         sync.WaitGroup
}

// 发奖处理函数,支持重试和超时
func handleWithRetryAndTimeout(task RewardTask, timeout time.Duration) RewardResult {
    for i := 0; i <= task.MaxRetry; i++ {
       ctx, cancel := context.WithTimeout(context.Background(), timeout)
       done := make(chan error, 1)

       go func() {
          done <- GiveReward(task.UserID, task.GiftID)
       }()

       select {
       case err := <-done:
          cancel()
          if err == nil {
             return RewardResult{UserID: task.UserID, Success: true, Message: "发奖成功"}
          }
          // 失败时打印重试日志
          fmt.Printf("用户 %d 第 %d 次发奖失败:%v\n", task.UserID, i+1, err)
       case <-ctx.Done():
          cancel()
          fmt.Printf("用户 %d 发奖超时!\n", task.UserID)
       }
    }
    return RewardResult{UserID: task.UserID, Success: false, Message: "发奖失败(重试耗尽)"}
}

// 创建协程池
func NewRewardPool(workerNum int) *RewardPool {
    return &RewardPool{
       TaskChan:   make(chan RewardTask, 100),
       ResultChan: make(chan RewardResult, 100),
       WorkerNum:  workerNum,
    }
}

// 启动 worker
func (p *RewardPool) Start() {
    for i := 1; i <= p.WorkerNum; i++ {
       go p.worker(i)
    }
}

// 提交任务
func (p *RewardPool) Submit(task RewardTask) {
    p.wg.Add(1)
    p.TaskChan <- task
}

// worker 执行任务逻辑
func (p *RewardPool) worker(id int) {
    for task := range p.TaskChan {
       fmt.Printf("Worker %d 正在处理用户 %d 发奖任务\n", id, task.UserID)
       result := handleWithRetryAndTimeout(task, 2*time.Second)
       p.ResultChan <- result
       p.wg.Done()
    }
}

// 等待所有任务完成
func (p *RewardPool) Wait() {
    close(p.TaskChan)   // 关闭任务通道,worker会自然退出
    p.wg.Wait()         // 等待所有worker完成
    close(p.ResultChan) // 最后关闭结果通道,通知结果收集器退出
}

func main() {
    pool := NewRewardPool(3) // 3 个 worker 并发发奖
    pool.Start()

    // 模拟发 10 个任务
    for i := 1; i <= 10; i++ {
       pool.Submit(RewardTask{
          UserID:   i,
          GiftID:   1001,
          MaxRetry: 0,
       })
    }

    // 启动结果收集器
    var successCount, failCount int
    var failUserIDs []int
    var resultWg sync.WaitGroup
    resultWg.Add(1)

    go func() {
       defer resultWg.Done()
       for result := range pool.ResultChan {
          if result.Success {
             successCount++
          } else {
             failCount++
             failUserIDs = append(failUserIDs, result.UserID)
          }
          fmt.Printf("用户 %d 发奖结果:%s\n", result.UserID, result.Message)
       }
    }()

    // 等待所有任务执行完毕
    pool.Wait()
    // 等待收集器收集完所有结果
    resultWg.Wait()
    fmt.Println("所有奖励发放完毕")

    // 最终统计输出
    fmt.Println("\n发奖统计结果")
    fmt.Printf("总任务数:%d\n", successCount+failCount)
    fmt.Printf("成功数量:%d\n", successCount)
    fmt.Printf("失败数量:%d\n", failCount)
    if len(failUserIDs) > 0 {
       fmt.Printf("失败用户ID列表:%v\n", failUserIDs)
    } else {
       fmt.Println("全部发奖成功!")
    }
}

最后

通过上面的例子,我们已经用一个简单的 Demo 实现了基于协程池的并发发奖处理逻辑:

  • 能并发处理大量任务
  • 控制并发数量,保护下游资源
  • 支持超时控制、失败重试、结果收集
  • 并且所有逻辑都运行在 worker 中,互不干扰

不过我们在实际运用中也大概要认识到:

真实的业务系统中,并不会每遇到一个并发场景就“手撸一个协程池”。

如果每个地方都复制一套协程池结构,既不优雅,也不利于维护。更好的做法是:将协程池封装成通用组件,对外暴露统一接口

比如我们可以将协程池设计为一个接口:

type Task interface {
	Handle(ctx context.Context) error
}

然后我们的 WorkerPool 只负责调度,实现为:

type WorkerPool interface {
	Submit(task Task)
	Start()
	Wait()
}

这样来设计后,无论是:

  • 发奖任务
  • 导出数据
  • 推送消息
  • 甚至并发调用第三方接口

只要实现了 Task 接口,我们都可以复用这套 WorkerPool 调度系统。

这就我们就实现了业务与并发调度的解耦 —— 我们只关心写业务逻辑,而并发控制这件事交给封装好的协程池即可。

在实际项目中,我们也可以把协程池组件做成一个独立包(比如 internal/pool),需要时引入即可