Go并发模式进阶:从Worker Pool到可取消任务调度器

摘要

在上一篇文章中,我们已经学习了 Go 并发中的 Worker PoolPipelineFan-Out/Fan-In 等常见模式。它们能够帮助我们提高任务处理效率,但在真实项目中,仅仅"能并发执行"还不够。

实际开发中,我们还需要考虑:

  • 某个任务失败后,其他任务是否应该继续执行?
  • 如果任务执行时间过长,如何自动超时退出?
  • 主程序退出时,如何通知所有 Goroutine 停止?
  • 如何避免 Goroutine 泄漏?
  • 如何限制最大并发数量,防止资源被打满?

本文将继续深入 Go 并发模式,围绕 contexterrgroup 和并发限制,手写一个可取消、可超时、可限制并发数量的任务调度器。


文章目录

    • 摘要
    • [一、前言:Worker Pool 为什么还不够?](#一、前言:Worker Pool 为什么还不够?)
    • 二、本文要实现什么?
    • [三、传统 WaitGroup 写法的问题](#三、传统 WaitGroup 写法的问题)
      • [1. 不方便获取子任务错误](#1. 不方便获取子任务错误)
      • [2. 某个任务失败后,其他任务不会自动停止](#2. 某个任务失败后,其他任务不会自动停止)
      • [3. 不方便统一超时控制](#3. 不方便统一超时控制)
      • [4. 容易产生 Goroutine 泄漏](#4. 容易产生 Goroutine 泄漏)
    • 四、Context:并发任务的取消信号
    • [五、ErrGroup:比 WaitGroup 更适合工程并发](#五、ErrGroup:比 WaitGroup 更适合工程并发)
    • 六、项目结构
    • 七、完整代码实现
    • 八、运行代码
    • 九、模拟任务失败
    • 十、模拟任务超时
    • 十一、核心代码解析
      • [1. 创建带超时的 Context](#1. 创建带超时的 Context)
      • [2. 使用 errgroup 统一管理 Goroutine](#2. 使用 errgroup 统一管理 Goroutine)
      • [3. 限制最大并发数量](#3. 限制最大并发数量)
      • [4. 任务中必须监听 ctx.Done()](#4. 任务中必须监听 ctx.Done())
      • [5. 多 Goroutine 写结果时要加锁](#5. 多 Goroutine 写结果时要加锁)
    • 十二、这个调度器可以用在哪些场景?
      • [1. 批量图片处理](#1. 批量图片处理)
      • [2. 批量 HTTP 请求](#2. 批量 HTTP 请求)
      • [3. 批量文件扫描](#3. 批量文件扫描)
      • [4. 批量数据库任务](#4. 批量数据库任务)
    • 十三、常见问题
      • [1. errgroup 能不能完全替代 WaitGroup?](#1. errgroup 能不能完全替代 WaitGroup?)
      • [2. Context 能不能强制停止 Goroutine?](#2. Context 能不能强制停止 Goroutine?)
      • [3. 为什么结果顺序和任务顺序不一致?](#3. 为什么结果顺序和任务顺序不一致?)
      • [4. SetLimit 可以运行中修改吗?](#4. SetLimit 可以运行中修改吗?)
    • 十四、进一步优化方向
      • [1. 增加任务重试机制](#1. 增加任务重试机制)
      • [2. 增加失败任务记录](#2. 增加失败任务记录)
      • [3. 增加任务进度统计](#3. 增加任务进度统计)
      • [4. 增加日志模块](#4. 增加日志模块)
      • [5. 增加 pprof 排查 Goroutine 泄漏](#5. 增加 pprof 排查 Goroutine 泄漏)
    • 十五、总结
    • 参考资料

一、前言:Worker Pool 为什么还不够?

在 Go 中,Worker Pool 是非常常见的并发模式。它通常由以下几个部分组成:

go 复制代码
jobs := make(chan Job)
results := make(chan Result)

for i := 0; i < workerCount; i++ {
    go worker(jobs, results)
}

这种写法可以很好地控制 Worker 数量,避免为每个任务都创建一个 Goroutine。

但是在工程项目中,它仍然存在一些问题:

text 复制代码
1. 任务失败后,不方便统一取消其他任务
2. 主流程退出后,子 Goroutine 可能仍然运行
3. 任务执行时间过长时,不方便统一超时控制
4. Channel 如果没有正确关闭,容易造成 Goroutine 阻塞
5. 错误处理逻辑容易分散在多个地方

Go 官方在 Pipeline 相关的文章中也强调过,如果下游提前退出,上游 Goroutine 可能会永久阻塞,从而造成资源泄漏;Goroutine 不会被垃圾回收,必须自己主动退出。(Go)

因此,我们需要在 Worker Pool 的基础上继续升级,让并发任务具备更强的工程可控性。


二、本文要实现什么?

本文最终会实现一个简单的任务调度器,它具备以下能力:

text 复制代码
1. 支持批量任务并发执行
2. 支持最大并发数量限制
3. 支持任务超时控制
4. 支持某个任务失败后取消其他任务
5. 支持统一返回错误
6. 支持防止 Goroutine 泄漏

最终效果类似于:

go 复制代码
scheduler, err := NewScheduler(3, 5*time.Second)
if err != nil {
    panic(err)
}

results, err := scheduler.Run(context.Background(), tasks)
if err != nil {
    fmt.Println("任务执行失败:", err)
    return
}

fmt.Println("任务全部执行完成:", results)

其中:

go 复制代码
NewScheduler(3, 5*time.Second)

表示:

text 复制代码
最多同时运行 3 个任务
整个任务组最长执行 5 秒

三、传统 WaitGroup 写法的问题

很多初学者在写并发代码时,会优先使用 sync.WaitGroup

例如:

go 复制代码
var wg sync.WaitGroup

for _, task := range tasks {
    wg.Add(1)

    go func(task Task) {
        defer wg.Done()
        processTask(task)
    }(task)
}

wg.Wait()

这种写法简单直接,但它的问题也很明显:

1. 不方便获取子任务错误

如果 processTask 返回错误,我们需要额外设计错误通道:

go 复制代码
errCh := make(chan error, len(tasks))

然后在 Goroutine 中手动写入错误。

2. 某个任务失败后,其他任务不会自动停止

假设一共有 100 个任务,其中第 3 个任务已经失败。

使用 WaitGroup 时,剩下的 97 个任务仍然会继续执行,除非我们自己写取消逻辑。

3. 不方便统一超时控制

如果任务执行时间过长,WaitGroup 本身没有超时机制。

我们需要配合 selecttime.Aftercontext 等机制自己处理。

4. 容易产生 Goroutine 泄漏

如果某些 Goroutine 卡在 Channel 读写、网络请求、文件处理等操作中,而外部又没有通知它们退出,就可能出现 Goroutine 泄漏。

所以,WaitGroup 适合简单并发等待,但如果要写更工程化的并发任务,更推荐使用:

go 复制代码
context + errgroup

四、Context:并发任务的取消信号

context 是 Go 中控制任务生命周期的重要工具。

它主要用于在多个 Goroutine 之间传递:

text 复制代码
1. 取消信号
2. 超时时间
3. 截止时间
4. 请求级别的数据

Go 官方文档中说明,Context 用于跨 API 边界和进程之间传递 deadline、cancel signal 以及 request-scoped values。(Go Packages)

常见用法如下:

go 复制代码
ctx := context.Background()

创建可取消的 Context:

go 复制代码
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

创建带超时时间的 Context:

go 复制代码
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()

在任务中监听取消信号:

go 复制代码
select {
case <-ctx.Done():
    return ctx.Err()
default:
    // 继续执行任务
}

ctx.Done() 返回一个只读 Channel,当 Context 被取消或超时时,这个 Channel 会被关闭。ctx.Err() 可以返回取消原因,例如 context.Canceledcontext.DeadlineExceeded。Go 官方 Context 文章也明确指出,Done 可作为取消信号,Err 用于说明 Context 为什么被取消。(Go)


五、ErrGroup:比 WaitGroup 更适合工程并发

errgroup 可以理解为增强版的 sync.WaitGroup

它主要增强了两个能力:

text 复制代码
1. 可以接收 Goroutine 返回的 error
2. 可以结合 Context 实现错误传播和取消控制

errgroup 官方文档说明,它用于为处理同一任务的多个 Goroutine 提供同步、错误传播和 Context 取消能力;它和 sync.WaitGroup 相关,但增加了错误处理能力。(Go Packages)

安装依赖:

bash 复制代码
go get golang.org/x/sync/errgroup

基本用法:

go 复制代码
g, ctx := errgroup.WithContext(context.Background())

g.Go(func() error {
    return doSomething(ctx)
})

g.Go(func() error {
    return doAnotherThing(ctx)
})

if err := g.Wait(); err != nil {
    fmt.Println("任务失败:", err)
}

如果其中一个 Goroutine 返回错误,errgroup 会把错误传递给 Wait(),并且结合 WithContext 可以通知其他任务尽快退出。


六、项目结构

为了方便演示,本文使用一个简单项目:

text 复制代码
go-task-scheduler/
├── go.mod
└── main.go

创建项目:

bash 复制代码
mkdir go-task-scheduler
cd go-task-scheduler
go mod init go-task-scheduler
go get golang.org/x/sync/errgroup

七、完整代码实现

下面直接给出完整代码。

main.go

go 复制代码
package main

import (
	"context"
	"errors"
	"fmt"
	"sync"
	"time"

	"golang.org/x/sync/errgroup"
)

// Task 表示一个待执行任务
type Task struct {
	ID         int
	Name       string
	Duration   time.Duration
	ShouldFail bool
}

// TaskResult 表示任务执行结果
type TaskResult struct {
	TaskID  int
	Message string
}

// Scheduler 表示任务调度器
type Scheduler struct {
	concurrency int
	timeout     time.Duration
}

// NewScheduler 创建任务调度器
func NewScheduler(concurrency int, timeout time.Duration) (*Scheduler, error) {
	if concurrency <= 0 {
		return nil, errors.New("concurrency must be greater than 0")
	}

	if timeout <= 0 {
		return nil, errors.New("timeout must be greater than 0")
	}

	return &Scheduler{
		concurrency: concurrency,
		timeout:     timeout,
	}, nil
}

// Run 执行任务列表
func (s *Scheduler) Run(parent context.Context, tasks []Task) ([]TaskResult, error) {
	// 为整个任务组设置超时时间
	ctx, cancel := context.WithTimeout(parent, s.timeout)
	defer cancel()

	// 创建 errgroup,并绑定 context
	g, ctx := errgroup.WithContext(ctx)

	// 限制最大并发数量
	g.SetLimit(s.concurrency)

	results := make([]TaskResult, 0, len(tasks))
	var mu sync.Mutex

	for _, task := range tasks {
		// 避免闭包引用问题
		task := task

		g.Go(func() error {
			result, err := processTask(ctx, task)
			if err != nil {
				return fmt.Errorf("task %d failed: %w", task.ID, err)
			}

			mu.Lock()
			results = append(results, result)
			mu.Unlock()

			return nil
		})
	}

	// 等待所有任务完成
	if err := g.Wait(); err != nil {
		return results, err
	}

	return results, nil
}

// processTask 模拟任务处理逻辑
func processTask(ctx context.Context, task Task) (TaskResult, error) {
	fmt.Printf("开始执行任务: ID=%d, Name=%s\n", task.ID, task.Name)

	select {
	case <-time.After(task.Duration):
		if task.ShouldFail {
			return TaskResult{}, errors.New("模拟业务错误")
		}

		fmt.Printf("任务执行完成: ID=%d, Name=%s\n", task.ID, task.Name)

		return TaskResult{
			TaskID:  task.ID,
			Message: fmt.Sprintf("任务 %s 执行成功", task.Name),
		}, nil

	case <-ctx.Done():
		fmt.Printf("任务被取消: ID=%d, Name=%s, Reason=%v\n", task.ID, task.Name, ctx.Err())
		return TaskResult{}, ctx.Err()
	}
}

func main() {
	tasks := []Task{
		{ID: 1, Name: "处理图片-1", Duration: 1 * time.Second},
		{ID: 2, Name: "处理图片-2", Duration: 2 * time.Second},
		{ID: 3, Name: "处理图片-3", Duration: 1 * time.Second},
		{ID: 4, Name: "处理图片-4", Duration: 3 * time.Second},
		{ID: 5, Name: "处理图片-5", Duration: 1 * time.Second},
	}

	scheduler, err := NewScheduler(3, 5*time.Second)
	if err != nil {
		panic(err)
	}

	results, err := scheduler.Run(context.Background(), tasks)
	if err != nil {
		fmt.Println("任务执行失败:", err)
		return
	}

	fmt.Println("所有任务执行完成")
	for _, result := range results {
		fmt.Printf("任务ID: %d, 结果: %s\n", result.TaskID, result.Message)
	}
}

八、运行代码

运行命令:

bash 复制代码
go run main.go

可能输出如下:

text 复制代码
开始执行任务: ID=1, Name=处理图片-1
开始执行任务: ID=2, Name=处理图片-2
开始执行任务: ID=3, Name=处理图片-3
任务执行完成: ID=1, Name=处理图片-1
任务执行完成: ID=3, Name=处理图片-3
开始执行任务: ID=4, Name=处理图片-4
开始执行任务: ID=5, Name=处理图片-5
任务执行完成: ID=2, Name=处理图片-2
任务执行完成: ID=5, Name=处理图片-5
任务执行完成: ID=4, Name=处理图片-4
所有任务执行完成
任务ID: 1, 结果: 任务 处理图片-1 执行成功
任务ID: 3, 结果: 任务 处理图片-3 执行成功
任务ID: 2, 结果: 任务 处理图片-2 执行成功
任务ID: 5, 结果: 任务 处理图片-5 执行成功
任务ID: 4, 结果: 任务 处理图片-4 执行成功

需要注意:

text 复制代码
结果输出顺序不一定和任务输入顺序一致

因为任务是并发执行的,谁先完成,谁就先写入结果。


九、模拟任务失败

我们将第 3 个任务设置为失败:

go 复制代码
tasks := []Task{
	{ID: 1, Name: "处理图片-1", Duration: 1 * time.Second},
	{ID: 2, Name: "处理图片-2", Duration: 2 * time.Second},
	{ID: 3, Name: "处理图片-3", Duration: 1 * time.Second, ShouldFail: true},
	{ID: 4, Name: "处理图片-4", Duration: 3 * time.Second},
	{ID: 5, Name: "处理图片-5", Duration: 1 * time.Second},
}

再次运行:

bash 复制代码
go run main.go

可能输出:

text 复制代码
开始执行任务: ID=1, Name=处理图片-1
开始执行任务: ID=2, Name=处理图片-2
开始执行任务: ID=3, Name=处理图片-3
任务执行完成: ID=1, Name=处理图片-1
任务执行失败: task 3 failed: 模拟业务错误

当某个任务失败后,errgroup 会返回错误,并且通过 Context 通知其他任务取消。

这就是它相比普通 WaitGroup 更适合工程项目的地方。


十、模拟任务超时

现在我们把调度器超时时间改短:

go 复制代码
scheduler, err := NewScheduler(3, 2*time.Second)

而任务中有一个任务需要执行 3 秒:

go 复制代码
{ID: 4, Name: "处理图片-4", Duration: 3 * time.Second}

再次运行时,可能会看到:

text 复制代码
任务被取消: ID=4, Name=处理图片-4, Reason=context deadline exceeded
任务执行失败: task 4 failed: context deadline exceeded

这说明整个任务组超过 2 秒后,被 context.WithTimeout 自动取消了。


十一、核心代码解析

1. 创建带超时的 Context

go 复制代码
ctx, cancel := context.WithTimeout(parent, s.timeout)
defer cancel()

这段代码的作用是:

text 复制代码
1. 为整个任务组设置最大执行时间
2. 超时后自动取消所有监听 ctx.Done() 的任务
3. 函数退出时释放 Context 相关资源

注意:

go 复制代码
defer cancel()

一定要写。

即使没有超时,也应该主动释放资源。


2. 使用 errgroup 统一管理 Goroutine

go 复制代码
g, ctx := errgroup.WithContext(ctx)

这行代码会返回:

text 复制代码
1. g:用于启动和等待多个 Goroutine
2. ctx:用于在任务失败时通知其他 Goroutine 取消

后续所有任务都使用这个 ctx


3. 限制最大并发数量

go 复制代码
g.SetLimit(s.concurrency)

SetLimit 用于限制当前 errgroup 中最多同时运行多少个 Goroutine。官方文档中说明,当活跃 Goroutine 数量达到限制后,后续 Go 调用会阻塞,直到可以新增活跃 Goroutine。(Go Packages)

比如:

go 复制代码
NewScheduler(3, 5*time.Second)

就表示最多同时执行 3 个任务。

这对于以下场景非常重要:

text 复制代码
1. 批量处理图片
2. 批量请求接口
3. 批量写入数据库
4. 批量读取文件
5. 批量模型推理

如果不限制并发数量,任务量一大,很容易打满 CPU、内存、数据库连接池或网络连接。


4. 任务中必须监听 ctx.Done()

go 复制代码
select {
case <-time.After(task.Duration):
    // 正常完成任务

case <-ctx.Done():
    return TaskResult{}, ctx.Err()
}

这一点非常关键。

Context 只能发出取消信号,但不能强制杀死 Goroutine。

也就是说,子任务必须主动监听:

go 复制代码
ctx.Done()

否则即使外部已经取消,Goroutine 仍然可能继续执行。


5. 多 Goroutine 写结果时要加锁

go 复制代码
mu.Lock()
results = append(results, result)
mu.Unlock()

因为多个 Goroutine 会同时向 results 中追加数据,所以这里需要使用互斥锁。

否则可能会出现数据竞争问题。

如果你想检测数据竞争,可以使用:

bash 复制代码
go run -race main.go

十二、这个调度器可以用在哪些场景?

这个任务调度器虽然简单,但它的思想非常适合真实项目。

1. 批量图片处理

例如你有一批图片,需要调用模型进行识别:

text 复制代码
图片1 -> 模型识别
图片2 -> 模型识别
图片3 -> 模型识别
...

可以设置:

go 复制代码
NewScheduler(4, 30*time.Second)

表示最多 4 张图片同时处理,整体最多运行 30 秒。


2. 批量 HTTP 请求

例如同时请求多个接口:

text 复制代码
请求用户服务
请求订单服务
请求库存服务
请求支付服务

如果其中一个核心接口失败,就可以取消其他请求,避免浪费资源。


3. 批量文件扫描

例如扫描目录中的文件:

text 复制代码
读取文件
解析文件
写入数据库
生成结果

如果某个文件解析失败,可以快速终止任务,统一返回错误。


4. 批量数据库任务

例如批量同步数据:

text 复制代码
读取源数据
转换数据
写入目标库

通过并发限制,可以避免数据库连接数被瞬间打满。


十三、常见问题

1. errgroup 能不能完全替代 WaitGroup?

不能。

如果只是简单等待多个 Goroutine 完成,并且不关心错误,sync.WaitGroup 就足够了。

如果你需要:

text 复制代码
1. 错误返回
2. 错误传播
3. Context 取消
4. 并发数量限制

那么 errgroup 更合适。


2. Context 能不能强制停止 Goroutine?

不能。

Context 只是通知机制。

它只能告诉 Goroutine:

text 复制代码
现在应该退出了

但 Goroutine 是否真的退出,取决于你的代码有没有监听:

go 复制代码
ctx.Done()

3. 为什么结果顺序和任务顺序不一致?

因为任务是并发执行的。

如果任务 3 比任务 1 更早完成,那么任务 3 的结果可能先写入 results

如果你要求结果顺序和输入顺序一致,可以改成根据下标写入:

go 复制代码
results := make([]TaskResult, len(tasks))

for i, task := range tasks {
    i, task := i, task

    g.Go(func() error {
        result, err := processTask(ctx, task)
        if err != nil {
            return err
        }

        results[i] = result
        return nil
    })
}

4. SetLimit 可以运行中修改吗?

不建议。

errgroup 官方文档说明,不能在当前 group 中存在活跃 Goroutine 时修改限制。(Go Packages)

所以一般在启动任务之前设置一次即可:

go 复制代码
g.SetLimit(5)

十四、进一步优化方向

当前这个调度器只是一个入门版本,后续还可以继续增强。

1. 增加任务重试机制

例如任务失败后重试 3 次:

text 复制代码
第 1 次失败 -> 等待 1 秒
第 2 次失败 -> 等待 2 秒
第 3 次失败 -> 返回错误

2. 增加失败任务记录

可以记录失败任务 ID、失败原因、失败时间,方便后续排查。

3. 增加任务进度统计

例如:

text 复制代码
总任务数:100
已完成:60
失败:2
剩余:38

4. 增加日志模块

可以接入 Go 1.21 后标准库中的 slog,输出结构化日志。

5. 增加 pprof 排查 Goroutine 泄漏

如果是长期运行的后端服务,可以结合 pprof 观察 Goroutine 数量是否持续上涨。


十五、总结

本文从 Worker Pool 的局限出发,进一步介绍了 Go 并发任务在工程实践中的常见问题:

text 复制代码
1. 如何取消任务
2. 如何控制超时
3. 如何统一处理错误
4. 如何限制并发数量
5. 如何避免 Goroutine 泄漏

最终我们通过:

text 复制代码
context + errgroup + SetLimit

实现了一个简单但实用的可取消任务调度器。

相比普通 Worker Pool,这种方式更适合真实项目,因为它不仅能"并发执行任务",还可以做到:

text 复制代码
可取消
可超时
可控并发
可返回错误
可避免资源泄漏

在实际开发中,只要涉及批量任务处理、文件处理、接口请求、数据库同步、模型推理等场景,都可以参考本文的思路进行封装。


参考资料

本文涉及的 context、Pipeline 取消机制和 errgroup 使用方式,主要参考 Go 官方文档、Go 官方博客以及 golang.org/x/sync/errgroup 包文档。(Go Packages)

相关推荐
平凡但不平庸的码农1 小时前
Go 语言:值传递 vs 指针传递
开发语言·后端·golang
云边有个稻草人1 小时前
金仓 KingbaseES Pro*C 迁移指南:从 Oracle 平滑迁移
oracle·数据库迁移·kingbasees·金仓数据库·国产化适配·proc 迁移
重生之小比特1 小时前
【MySQL 数据库】内置函数
数据库·mysql
jimy11 小时前
Oracle的always free oci实例,standard em2.1.micro,保活脚本
服务器·oracle
七夜zippoe1 小时前
OpenClaw 记忆维护:自动整理与归档
大数据·网络·数据库·openclaw·记忆维护
今儿敲了吗1 小时前
数据库(六)——数据库控制功能
数据库
瀚高PG实验室1 小时前
postgresql因在从库备份时间长而失败
运维·数据库·postgresql·瀚高数据库
phltxy1 小时前
Redis:从入门到精通的第一步
数据库·redis·缓存
User_芊芊君子1 小时前
数据库V9R4C19安全加固:最小权限部署与不可逆哈希存储实战
数据库·安全·哈希算法