前言
自从使用 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 处理
- 多余的任务排队等待空闲
- 保证系统资源使用受控且高效
我们来做一下简单的对比:
| 直接开 goroutine | Worker 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()保证主程序最后等所有任务执行完成
我们运行上面代码结果(顺序会打乱)
这样,我们就实现了一个最基础的发奖协程池。它已经能处理并发任务、控制协程数量,并确保任务全部完成后再退出程序。
不过,在实际业务开发中,我们设计一个发奖系统远不止这些。
我们不能只满足于“能跑通逻辑”,还需要考虑很多实际运行时可能出现的问题,比如:
- 某些任务执行时间过长,是否需要设置 超时时间?
- 发奖请求可能失败,是否应该加入 失败重试机制?
- 某个任务内部逻辑出现异常甚至 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("所有奖励发放完毕")
}
我们执行下上述代码后结果如下:
继续完善:任务结果的收集与统计
在实际的业务场景中,我们发完奖励后,通常还需要统计成功和失败的数量,甚至需要把失败名单汇总给到运营同学处理。
所以,继续在上面的基础上,我们可以让每个任务在执行完之后,把结果信息统一收集起来。
第一步:新增结果结构体
我们先新增一个 RewardResult 结构体,用来保存每个任务的执行结果:
type RewardResult struct {
UserID int
Success bool
Message string
}
UserID:用户 IDSuccess:是否发奖成功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),需要时引入即可