Go 语言的并发模型是它最强大的特性之一。不同于传统线程模型,Go 通过 Goroutine 和 Channel 提供了一种更优雅、更安全的并发编程方式。本文将通过一个完整的任务调度器示例,带你深入理解 Go 并发编程的核心模式。
一、为什么选择 Go 的并发模型
传统的多线程编程面临几个核心痛点:
-
线程创建和切换开销大(每个线程约 1MB 栈空间)
-
共享内存需要复杂的锁机制
-
死锁和竞态条件难以调试
Go 的解决方案很简洁:Goroutine 是轻量级协程(初始栈仅 2KB),Channel 是类型安全的通信管道。Go 的哲学是"不要通过共享内存来通信,而要通过通信来共享内存"。
二、核心概念速览
Goroutine:用 go 关键字启动的轻量级执行单元,由 Go 运行时调度,可以轻松创建数十万个。
Channel:Goroutine 之间的通信管道,支持同步和异步两种模式。
Select:多路复用,监听多个 Channel 操作,类似网络编程中的 IO 多路复用。
三、实战:构建任务调度器
下面我们构建一个支持并发限制、超时控制和优雅关闭的任务调度器。
3.1 定义核心结构
package main
import (
"context"
"fmt"
"math/rand"
"sync"
"time"
)
// Task 表示一个待执行的任务
type Task struct {
ID int
Name string
Payload interface{}
}
// Result 表示任务执行结果
type Result struct {
TaskID int
Output string
Err error
Elapsed time.Duration
}
// Scheduler 任务调度器
type Scheduler struct {
maxWorkers int
taskCh chan Task
resultCh chan Result
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
}
3.2 实现调度器
// NewScheduler 创建调度器
func NewScheduler(maxWorkers, bufferSize int) *Scheduler {
ctx, cancel := context.WithCancel(context.Background())
return &Scheduler{
maxWorkers: maxWorkers,
taskCh: make(chan Task, bufferSize),
resultCh: make(chan Result, bufferSize),
ctx: ctx,
cancel: cancel,
}
}
// Start 启动 worker 池
func (s *Scheduler) Start() {
for i := 0; i < s.maxWorkers; i++ {
s.wg.Add(1)
go s.worker(i)
}
}
// worker 工作协程
func (s *Scheduler) worker(id int) {
defer s.wg.Done()
for {
select {
case task, ok := <-s.taskCh:
if !ok {
return
}
start := time.Now()
output, err := processTask(task)
s.resultCh <- Result{
TaskID: task.ID,
Output: output,
Err: err,
Elapsed: time.Since(start),
}
case <-s.ctx.Done():
fmt.Printf("Worker %d: 收到停止信号\n", id)
return
}
}
}
// processTask 模拟任务处理
func processTask(t Task) (string, error) {
// 模拟耗时操作
duration := time.Duration(rand.Intn(500)+100) * time.Millisecond
time.Sleep(duration)
return fmt.Sprintf("任务 %d (%s) 处理完成", t.ID, t.Name), nil
}
3.3 提交任务和收集结果
// Submit 提交任务
func (s *Scheduler) Submit(task Task) {
select {
case s.taskCh <- task:
case <-s.ctx.Done():
fmt.Printf("调度器已关闭,无法提交任务 %d\n", task.ID)
}
}
// Results 返回结果 Channel
func (s *Scheduler) Results() <-chan Result {
return s.resultCh
}
// Shutdown 优雅关闭
func (s *Scheduler) Shutdown() {
close(s.taskCh) // 停止接收新任务
s.wg.Wait() // 等待所有 worker 完成
close(s.resultCh) // 关闭结果通道
}
// Stop 立即停止
func (s *Scheduler) Stop() {
s.cancel() // 发送取消信号
close(s.taskCh)
s.wg.Wait()
close(s.resultCh)
}
3.4 完整使用示例
func main() {
scheduler := NewScheduler(5, 100)
scheduler.Start()
// 异步收集结果
var results []Result
done := make(chan struct{})
go func() {
for r := range scheduler.Results() {
results = append(results, r)
if r.Err != nil {
fmt.Printf("任务 %d 失败: %v\n", r.TaskID, r.Err)
} else {
fmt.Printf("完成: %s (耗时 %v)\n", r.Output, r.Elapsed)
}
}
close(done)
}()
// 提交 20 个任务
tasks := []string{"数据清洗", "API调用", "文件解析", "日志分析", "缓存更新"}
for i := 0; i < 20; i++ {
scheduler.Submit(Task{
ID: i + 1,
Name: tasks[i%len(tasks)],
})
}
// 优雅关闭
scheduler.Shutdown()
<-done
fmt.Printf("\n共完成 %d 个任务\n", len(results))
}
四、关键设计要点
4.1 为什么用 buffered channel
taskCh 使用了缓冲通道。这样生产者(Submit)不会在每次提交时都阻塞等待消费者,提高了吞吐量。缓冲大小需要根据实际场景调优------太小会导致生产者阻塞,太大会占用过多内存。
4.2 select 的妙用
worker 中的 select 同时监听任务通道和取消信号,这是 Go 并发编程的经典模式。它保证了 worker 既能正常处理任务,又能及时响应停止信号。
4.3 优雅关闭 vs 立即停止
Shutdown 通过关闭 taskCh 让 worker 自然退出(处理完剩余任务),Stop 通过 context 取消信号让 worker 立即退出。实际生产中通常先尝试 Shutdown,超时后再 Stop。
五、性能对比
我们用 20 个任务、每个耗时 100-600ms 来对比:
串行执行:约 7 秒(平均 350ms × 20)
5 个 Worker:约 1.4 秒(20/5 × 350ms)
10 个 Worker:约 0.7 秒(20/10 × 350ms)
并发带来了近乎线性的性能提升。当然实际场景中还需要考虑 IO 瓶颈、锁竞争等因素。
六、总结
Go 的并发模型核心优势:
-
Goroutine 极其轻量,可以大规模创建
-
Channel 提供类型安全的通信,避免共享内存的复杂性
-
select 实现优雅的多路复用
-
context 提供统一的取消和超时机制
这个任务调度器涵盖了 Go 并发编程的核心模式:worker pool、channel 通信、context 控制、优雅关闭。掌握这些模式,基本上可以应对大部分并发场景。
建议进一步学习:sync.Pool 对象复用、errgroup 错误处理、rate limiter 限流等高级主题。