Go 并发实战:从零实现一个高并发安全的任务调度器

JS 是单线程 的(主线程只有一个),基于事件循环(Event Loop) 实现并发,让 JS 看起来能「同时做好多事」。而实际上,你写的 JS 代码永远不会被并行执行 。所谓并发只是「任务排队、轮流执行」。

事件循环(Event Loop) 的流程:

text 复制代码
        ┌───────────────────────────┐
        │        调用栈 Call Stack   │   ← 一次只跑一个函数
        └─────────────┬─────────────┘
                      │ 栈空了
        ┌─────────────▼─────────────┐
        │   微任务队列 Microtasks     │   ← Promise.then 在这
        └─────────────┬─────────────┘
                      │ 微任务清空
        ┌─────────────▼─────────────┐
        │   宏任务队列 Macrotasks     │   ← setTimeout 在这
        └───────────────────────────┘
javascript 复制代码
console.log("1");

setTimeout(() => console.log("3"), 0);

Promise.resolve().then(() => console.log("2"));

console.log("4");
// 输出:1 4 2 3

JS 语言单线程基于事件循环的并发有两个特点:

  1. 不存在数据竞争 ------两个 setTimeout 回调不可能同时改同一个变量(因为主线程一次只跑一个)。

  2. 阻塞就是致命 ------一个 while(true) 就能让整个页面卡死,因为没人能抢占它。

而本篇文章的主角,Go 语言通过多线程并行 + 用 channel 通信实现并发。

go 复制代码
fmt.Println("1")

go func() { fmt.Println("3") }()   // 开一个 goroutine,类似"另一个线程"

fmt.Println("4")

go 关键字会把函数丢进一个新的 goroutine 里执行,然后立刻返回,不等待它跑完。

goroutine 和 JS 的 setTimeout(f, 0) 表面像,本质完全不同:

维度 JS setTimeout(f, 0) Go go f()
执行线程 同一个主线程,排队 可能在不同 OS 线程上,真并行
是否可能同时执行 永远不会 会,所以需要各种锁来防数据竞争
谁来决定何时跑 事件循环,主线程空闲时 Go runtime 调度器(GMP 模型)
阻塞的影响 阻塞一个就阻塞全部 阻塞一个 goroutine 不影响其他
开销 有任务队列开销 初始栈仅 2KB,可轻易开几十万个
通信方式 闭包捕获变量 / 全局变量 / Promise channel(推荐)

Go 官方的一句口号概括了设计哲学:

Do not communicate by sharing memory; instead, share memory by communicating. (不要通过共享内存来通信,而要通过通信来共享内存。)

也就是:与其让多个 goroutine 抢同一块内存(然后加锁),不如把数据通过 channel 传过去,让同一时刻只有一个人在碰它。

第一个 Go 并发程序:小心,它不按你想的输出

go 复制代码
package main

import "fmt"

func main() {
  for i := 0; i < 3; i++ {
    go fmt.Println(i)
  }
  fmt.Println("main 结束了")
}

多跑几次,你可能会看到:

text 复制代码
main 结束了

或者

text 复制代码
main 结束了
1
0
2

为什么上面的代码没有按照预想的顺序输出?

为什么? 因为 main 函数本身也是一个 goroutine。当它返回时,整个程序立刻退出,其他还在跑的 goroutine 会直接结束。

这是新手第一个坑:Go 不会等你的 goroutine 。想等,就得使用 sync.WaitGroup 显式地等:

go 复制代码
package main

import (
  "fmt"
  "sync"
)

func main() {
  var wg sync.WaitGroup

  for i := 0; i < 3; i++ {
    wg.Add(1) // 计数器 +1,必须在 go 之前调用
    go func() {
      defer wg.Done() // 计数器 -1,defer 保证一定执行
      fmt.Println(i)
    }()
  }

  wg.Wait() // 阻塞,直到计数器归零
  fmt.Println("所有 goroutine 都结束了")
}

小陷阱(Go < 1.22) :for 循环里的 i 是被所有 goroutine 共享 的同一个变量,闭包捕获它,结果就可能是 3 3 3。 解决办法是老写法 i := i(在循环体里造一份副本)。从 Go 1.22 起语言层面已修复:每轮迭代都会新建变量。

Go 并发五大件

Go 并发编程中常用到 5 个工具:

  • goroutine
  • channel
  • select
  • sync
  • context

goroutine:最轻的并发单位

在函数面前加 go 关键字便启动了 goroutine 。

go 复制代码
go func() { /* 干活 */ }()

goroutine 启动成本极低(初始栈 2KB,按需增长),但并发并不是免费的 。

  • 开太多 goroutine,内存和调度都会成为负担;
  • 任务函数里跑的可能是 IO / CPU 密集操作,无限开等于没有背压(backpressure)。

后面实现的调度器的做法是:不无限开 goroutine,而是固定 N 个 worker 。

调度的调度器还有一个原则:永远不要在持有锁的时候执行用户代码。用户代码可能阻塞几十秒,那期间所有抢锁的人都会卡死。

channel:goroutine 之间的传送带

channel 是带类型的管道,用 <- 发送 / 接收:

go 复制代码
ch := make(chan string) // 无缓冲

go func() {
  ch <- "hello" // 发送:会阻塞,直到有人接收
}()

msg := <-ch // 接收:会阻塞,直到有人发送
fmt.Println(msg)

无缓冲 vs 有缓冲

下面分别是无缓冲和有缓冲两种类型管道的定义方式:

go 复制代码
ch := make(chan int)    // 无缓冲:发送和接收必须"对上",像打电话
buf := make(chan int, 8) // 有缓冲 8:前半段塞进去不阻塞,像邮箱
类型 发送行为 典型用途
无缓冲 阻塞,直到有接收者 严格同步「交接」,保证对方拿到了
有缓冲 N 缓冲区没满时不阻塞;满了才阻塞 削峰、解耦、形成背压

close、range 与「零值」

go 复制代码
close(ch)                 // 关闭:只能由发送方关,关闭后不能再发送
for v := range ch { ... } // 关闭后 range 会自动退出

v, ok := <-ch             // ok==false 说明 channel 已关闭且缓冲已空

一个 Go 里非常常用、但 JS 没有的惯用法:用 channel 关闭当「广播信号」。

go 复制代码
done := make(chan struct{})

// 任意多个 goroutine 都可以监听它
go func() { <-done; fmt.Println("我收到关闭信号") }()
go func() { <-done; fmt.Println("我也收到") }()

close(done) // 一关全醒(不是发一次只醒一个)

struct{} 是空结构体,不占内存,专门用来表示「只传信号,不传数据」。

select:并发世界的 switch

select 让你同时等多个 channel,谁先就绪就走谁:

go 复制代码
select {
case msg := <-ch1:
  fmt.Println("ch1:", msg)
case ch2 <- 42:
  fmt.Println("成功写入 ch2")
case <-time.After(time.Second):
  fmt.Println("超时了")
default:
  fmt.Println("谁都没就绪,走这里")
}

对应 JS 就是 Promise.race,但 select 更强:它还能同时处理「发送」和「接收」,还能带 default 变成非阻塞探测。

一个必须记住的写法:非阻塞发送

go 复制代码
select {
case ch <- v:
  // 发送成功
default:
  // channel 满了,直接走这里,绝不阻塞
}

这是「观察者不能拖慢被观察者」的实现手段。

在本文后面实现的调度器广播事件时就是用它:订阅者如果消费太慢,事件直接丢弃并计数,绝不拖住调度循环。

sync 工具箱

JS 单线程,所以不需要锁。Go 里必须自己防数据竞争(data race)。

go 复制代码
var (
  mu    sync.Mutex   // 互斥锁
  count int
)

mu.Lock()
count++          // 临界区:同一时刻只有一个 goroutine 能进来
mu.Unlock()

最容易被忽略的一点:defer + Unlock 是标配 ,因为中途 return 或 panic 时能保证解锁。

go 复制代码
func (s *Scheduler) Len() int {
  s.mu.Lock()
  defer s.mu.Unlock()   // 无论后面怎么返回,都会解锁
  return len(s.entries)
}

工具箱里其它常用件:

  • sync.Mutex 互斥锁
  • sync.WaitGroup 等一组 goroutine 结束
  • sync.Once 只执行一次
  • atomic.Uint64 原子计数, 不需要锁
  • time.Timer 一次性定时器

为什么统计要用 atomic 而不是 Mutex? 累计计数是热点 (每个任务完成都要 +1)。用原子操作就没锁、无竞争,读的时候也是 Load() 一条指令。只有需要「多个字段一起读才自洽」的地方才需要锁。

context:取消与超时的统一信号

context 类似 JS 的 AbortController ,可以传递取消信号。

javascript 复制代码
// JavaScript
const ac = new AbortController();
fetch(url, { signal: ac.signal });
setTimeout(() => ac.abort(), 3000); // 3 秒后取消
go 复制代码
// Go
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()                        // 必须调用,否则泄漏 timer
doRequest(ctx)

Go 语言中,context 更加强大一些,除了传递取消信息,还能做以下两件事:

  1. 传递超时 :WithTimeout / WithDeadline;
  2. 传递请求级数据 :WithValue(本文用不到,业务里也要克制)。

正式实现:高并发安全的任务调度器

设计并实现一个并发安全的任务调度器,此调度器满足下面的要求和存在的难点:

# 需求 真正的难点
1 按执行时间顺序自动执行,并返回结果 任务可能上万,既要排序正确,又不能用 ticker 轮询空转
2 并发安全:任意多 goroutine 同时添加 / 取消 / 查询 一把大锁会让「写结果」和「查状态」互相阻塞;取消与执行有天然竞态
3 状态可查:待执行 / 执行中 / 已执行 / 已取消 / 错误 查询不能拖慢调度热路径
4 错误隔离:一个任务 panic / 超时 / 失败,不影响其他任务 panic 会击穿整个进程;普通函数无法被强制中断
5 优雅关闭:待执行的取消掉,在途的打断并等待收尾 关闭与「正在提交」「正在执行」三方竞态

具体的实现方案是:

用最小堆决定「谁先跑」,用可复用的 Timer 精确叫醒自己,用有限的 worker 池控制并发,用两层细粒度锁把竞争压到最小;而任何单个任务的 panic / 超时 / 失败,都被关在它自己的盒子里,不外溢到调度循环和其他任务。

代码文件结构如下,方便大家后阅读最终的实现代码:

text 复制代码
scheduler/
├── task.go       ← 先看这里:TaskSpec(你提交什么)、Status(状态机)、taskEntry(内部实体)
├── heap.go       ← 再看:45 行搞定最小堆
├── options.go    ← 顺手看:Worker/QueueSize/Now/Logger 四个开关
├── errors.go     ← 顺手看:哨兵错误 + PanicError
└── scheduler.go  ← 重点:对外接口 → loop() → worker() → runEntry() → invoke()

建议的阅读顺序是「先看结构,再看流程」 :task.go 定数据结构,scheduler.go 的 New → loop → worker → runEntry 是主脉络。

为什么用最小堆

调度器只关心一个问题:谁最早到期?

假设有 10 万个任务,如果用一个切片存:

go 复制代码
[]*taskEntry   // 每次找最早到期都要遍历一遍 = O(n)

每次取任务都是 10 万次比较,太慢。用最小堆(min-heap)则:

text 复制代码
              10:01     ← 堆顶永远是最早到期
             /     \
          10:05   10:02
          /   \
      10:10   10:20
操作 切片 最小堆
获取最早到期任务 O(n) O(1)
插入任务 O(1) O(log n)
弹出最早任务 O(n) O(log n)
按 ID 取消任意任务 O(n) O(log n)

Go 标准库 container/heap 提供了堆操作,你只需要实现 5 个方法:Len / Less / Swap / Push / Pop。

任务定义:你提交什么

go 复制代码
// 任务函数:无参数,返回结果字符串与错误
type TaskFunc func() (string, error)

// 带 context 的版本:任务可以感知取消 / 超时并主动退出
type TaskFuncCtx func(ctx context.Context) (string, error)

type TaskSpec struct {
  ID           string            // 唯一标识,必填
  RunAt        time.Time         // 期望执行时间;零值/过去时间 = 尽快执行
  Fn           TaskFunc          // 与 FnCtx 二选一
  FnCtx        TaskFuncCtx
  Timeout      time.Duration     // 单次执行超时,0 = 不限制
  MaxAttempts  int               // 最大尝试次数(含首次),<=0 按 1 处理
  RetryBackoff time.Duration     // 失败后的重试间隔
  Tags         map[string]string // 只做透传,供业务埋点
}
  1. Fn 和 FnCtx 二选一 。TaskFunc 对使用者最友好(写起来像普通函数),但它无法被中断 ;TaskFuncCtx 多一个能力------响应取消。两者都给时优先 FnCtx。
  2. RunAt 用「绝对时间」而不是「延时」 。time.Now().Add(100*time.Millisecond) 是调用方的事,调度器只认时间点。这样堆的比较是单调的,不会因为「什么时候提交」而漂移。
  3. 值语义 。TaskSpec 全是值字段,提交后调度器不再读原变量,调用方可以放心复用。

校验放在 validate():

go 复制代码
// ErrInvalidSpec 表示任务定义非法(ID 为空、没有任务函数、超时/退避为负)。
func (spec TaskSpec) validate() error {
  if strings.TrimSpace(spec.ID) == "" {
    return fmt.Errorf("%w: task id must not be empty", ErrInvalidSpec)
  }
  if spec.Fn == nil && spec.FnCtx == nil {
    return fmt.Errorf("%w: task %q has no function", ErrInvalidSpec, spec.ID)
  }
  if spec.Timeout < 0 {
    return fmt.Errorf("%w: task %q has negative timeout", ErrInvalidSpec, spec.ID)
  }
  if spec.RetryBackoff < 0 {
    return fmt.Errorf("%w: task %q has negative retry backoff", ErrInvalidSpec, spec.ID)
  }
  return nil
}

Go 的错误风格 :用 fmt.Errorf("%w: ...", ErrInvalidSpec) 包装哨兵错误,调用方就能用 errors.Is(err, ErrInvalidSpec) 判断,而不是比较字符串。这相当于 JS 里自定义 Error 子类 + instanceof,但更轻。

状态机

go 复制代码
type Status uint8

const (
  StatusPending   Status = iota // 待执行
  StatusRunning                 // 执行中
  StatusExecuted                // 已执行
  StatusCancelled               // 已取消
  StatusFailed                  // 错误
)

func (s Status) Terminal() bool {
  return s == StatusExecuted || s == StatusCancelled || s == StatusFailed
}

状态流转的流程图:

stateDiagram-v2 [*] --> 待执行: AddTask 待执行 --> 执行中: 到期出堆 待执行 --> 已取消: Cancel 执行中 --> 已执行: 函数成功返回 执行中 --> 错误: 返回 error / panic / 超时 执行中 --> 已取消: Cancel(打断 ctx,结果作废) 已执行 --> [*] 错误 --> [*] 已取消 --> [*]

最小堆的实现

最小堆让「最早到期」的任务永远在堆顶。

heap.go 的实现如下:

go 复制代码
type taskQueue []*taskEntry

func (q taskQueue) Len() int { return len(q) }

func (q taskQueue) Less(i, j int) bool {
  if !q[i].runAt.Equal(q[j].runAt) {
    return q[i].runAt.Before(q[j].runAt)
  }
  // 同一时刻按 ID 兜底排序,让调度顺序确定、可测试、可复现。
  return q[i].id < q[j].id
}

func (q taskQueue) Swap(i, j int) {
  q[i], q[j] = q[j], q[i]
  q[i].index = i   // ← 关键:Swap 时同步更新堆内下标
  q[j].index = j
}

func (q *taskQueue) Push(x any) {
  e := x.(*taskEntry)
  e.index = len(*q)
  *q = append(*q, e)
}

func (q *taskQueue) Pop() any {
  old := *q
  n := len(old)
  e := old[n-1]
  old[n-1] = nil   // 避免残留引用,帮助 GC
  e.index = -1     // -1 表示"不在堆中"
  *q = old[:n-1]
  return e
}

三个细节:

  1. Less 里的 ID 兜底 。两个任务 RunAt 完全相同时,按 ID 排。这让调度顺序确定、可测试、可复现------并发测试特别依赖这一点,否则同样的代码每次跑的顺序都可能不同。
  2. Swap 里同步 index。这是「取消任意任务 O(log n)」的关键(下面 4.3 讲)。
  3. Pop 里置 nil 和 -1 。置 nil 帮 GC 回收;置 -1 让取消逻辑知道「这个任务已经不在堆里了」。

taskEntry:一个任务的两类字段

taskEntry 是整个并发设计的地基。它的字段被刻意分成三类:

go 复制代码
type taskEntry struct {
  // ---- 只读:创建后不再变,无需加锁 ----
  id, runAt, fn, fnCtx, timeout, attemptsLimit, backoff, tags

  // ---- 结构性数据:只由 Scheduler.mu 保护 ----
  index int // 堆内下标;-1 = 不在堆中

  // ---- 状态型数据:只由自己的 mu 保护 ----
  mu         sync.Mutex
  status     Status
  result     string
  err        error
  attempts   int
  startedAt  time.Time
  finishedAt time.Time
  cancel     context.CancelFunc

  // ---- 终态信号:进入终态时关闭一次,供 Wait 使用 ----
  done     chan struct{}
  doneOnce sync.Once
}

为什么要分? 因为访问它们的人完全不同:

  • index 只有堆操作和取消逻辑碰它,都在 Scheduler.mu 保护下;
  • status/result 被 worker 写、被 Status() 读,它们的访问者根本不需要全局锁。

于是「查询任务状态」和「写入任务结果」就不会去抢那把全局锁,热点被分散到每个任务自己身上。

双索引:Map + Heap

text 复制代码
entries: map[string]*taskEntry             queue: taskQueue 最小堆

 "job-A" ──────► taskEntry{A} ◄──── queue[0]   ← 堆顶
 "job-B" ──────► taskEntry{B} ◄──── queue[3]   index=3
  • 哈希表:按 ID 查找、取消 O(1) 定位到实体;
  • taskEntry.index:把「哈希表找到的任务」映射回「堆内下标」;
  • 于是取消任意任务 = heap.Remove(&s.queue, e.index),O(log n)。

没有 index 就只能遍历整个堆找目标(O(n))。

状态写入全部走统一入口

go 复制代码
// finish 尝试把任务置为终态,返回是否真正发生了状态迁移。
func (e *taskEntry) finish(status Status, result string, err error, at time.Time) (changed bool, attempts int) {
  e.mu.Lock()
  defer e.mu.Unlock()

  if e.status.Terminal() {
    return false, e.attempts // ← 已是终态:拒绝改写
  }
  e.status = status
  e.result = result
  e.err = err
  e.finishedAt = at
  e.cancel = nil
  e.doneOnce.Do(func() { close(e.done) }) // 只关一次,唤醒所有 Wait
  return true, e.attempts
}

markRunning、nextAttempt、setCancel、cancelNow、statusNow 都是同款「先锁自己,再检查状态」的小方法:

go 复制代码
func (e *taskEntry) markRunning(at time.Time) bool {
  e.mu.Lock()
  defer e.mu.Unlock()
  if e.status.Terminal() {
    return false          // 已被取消 / 已完成,不进入执行
  }
  e.status = StatusRunning
  if e.startedAt.IsZero() {
    e.startedAt = at
  }
  return true
}

func (e *taskEntry) nextAttempt() int {
  e.mu.Lock()
  defer e.mu.Unlock()
  if e.status.Terminal() {
    return 0              // 0 = 别执行了
  }
  e.attempts++
  return e.attempts
}

func (e *taskEntry) setCancel(cancel context.CancelFunc) {
  e.mu.Lock()
  defer e.mu.Unlock()
  if e.status.Terminal() {
    cancel()              // 已终态:立刻取消,避免 ctx 泄漏
    return
  }
  e.cancel = cancel
}

snapshot() 是查询用的一致性快照:

go 复制代码
func (e *taskEntry) snapshot() TaskResult {
  e.mu.Lock()
  defer e.mu.Unlock()
  // status / result / err 在同一个临界区里读取,不会撕裂
  return TaskResult{ ID: e.id, Status: e.status, Result: e.result, Err: e.err, ... }
}

「一致性快照」是什么意思? 如果没有锁,可能读到「status 已经是 已执行,但 result 还没来得及写」的中间状态------这就是撕裂读(torn read) 。 加锁后,status 和 result 要么都是旧的,要么都是新的。而 TaskResult 返回的是一个值(副本),拿出去之后再怎么变都影响不到它。

调度循环 loop():整个调度器的心脏

loop 是全局唯一的调度 goroutine。它的逻辑很短,但每行都有讲究:

go 复制代码
func (s *Scheduler) loop() {
  defer s.loopWG.Done()

  timer := time.NewTimer(time.Hour)
  stopTimer(timer)
  defer timer.Stop()

  for {
    s.mu.Lock()
    if s.closed {
      s.mu.Unlock()
      return
    }

    if s.queue.Len() == 0 {              // 没任务:睡,等唤醒
      s.mu.Unlock()
      select {
      case <-s.wake:
      case <-s.done:
        return
      }
      continue
    }

    next := s.queue[0]                   // 只看堆顶(O(1))
    wait := next.runAt.Sub(s.opts.Now())
    if wait > 0 {                        // 还没到期:定时等
      s.mu.Unlock()

      timer.Reset(wait)
      select {
      case <-timer.C:                  // 到期了
      case <-s.wake:                   // 新任务/取消,重算
        stopTimer(timer)
      case <-s.done:                   // 关闭
        stopTimer(timer)
        return
      }
      continue
    }

    // 已到期:出堆 + 标记执行中,在同一临界区内原子完成
    heap.Pop(&s.queue)
    if !next.markRunning(s.opts.Now()) {
      s.mu.Unlock()                    // 被取消了,跳过
      continue
    }
    s.mu.Unlock()

    // 投递给 worker。队列满时这里阻塞 → 形成背压
    select {
    case s.ready <- next:
    case <-s.done:
      s.finishEntry(next, StatusCancelled, "", ErrClosed)
      return
    }
  }
}

四个必须看懂的点:

① 为什么不用 ticker 轮询? time.Ticker 每秒扫一遍,既空转 CPU,又有精度损失(任务可能晚 1 秒才执行)。用 timer.Reset(wait) 可以恰好等到堆顶到期,精度是毫秒以下,且不空转。

② 为什么新任务要 notify() 唤醒? 假设堆顶是「1 小时后到期」,调度器正准备睡 1 小时。此时来了个「10 毫秒后到期」的新任务------如果不唤醒,它就要等 1 小时。所以 AddTask / Cancel 都会 notify() 叫醒调度循环重新计算等待时间。

③ 为什么 stopTimer 要排空 channel? time.Timer 有个经典坑:Stop() 返回 false 说明「定时器已经触发,但信号还在 channel 缓冲里」。不排空的话,下次 Reset 后可能立刻 从 channel 读到一个陈旧的信号,导致任务提前执行。

go 复制代码
func stopTimer(t *time.Timer) {
  if !t.Stop() {
    select {
    case <-t.C:  // 排空已触发的信号
    default:
    }
  }
}

④ 为什么「出堆 + markRunning」要在同一把锁里? 因为 Cancel 也会抢 Scheduler.mu 。如果中间放开锁,就可能出现「调度器刚出堆还没标记执行中,用户取消了它,结果任务还是被执行了」。把两步放进同一个临界区,就保证了和取消操作互斥 :要么取消方先赢(markRunning 返回 false,任务不执行),要么调度方先赢(Cancel 看到已在执行中,转而取消 ctx)。

worker 池:有界并发 + 背压

worker 数量就是并发上限。任务再多,也不会无限开 goroutine:

go 复制代码
func (s *Scheduler) worker() {
  defer s.workerWG.Done()

  for {
    select {
    case <-s.done:
      return
    case e := <-s.ready:     // 领任务
      s.runEntry(e)
    }
  }
}

任务通过 ready channel 从调度循环流向 worker:

text 复制代码
loop ──► ready chan(容量 = QueueSize)──► worker × N
  • ready 满了 → loop 阻塞在 s.ready <- next → 背压:任务堆在堆里,不会被丢,只是推迟;
  • ready 空了 → worker 阻塞在 <-s.ready,等待新任务。

对比 JS:Promise 是「有多少跑多少」,没有背压;Go 的 worker 池相当于手写了一个「并发度 = N 的队列」,这正是 p-limit、tiny-async-pool 这类库在做的事。

配置来自传入的配置选项:

go 复制代码
s := scheduler.New(
  scheduler.WithWorkers(8),      // 并发上限
  scheduler.WithQueueSize(32),   // 已到期但没被领走的缓冲长度
  scheduler.WithLogger(logger),  // 记录失败与 panic
)
go 复制代码
func WithWorkers(n int) Option  { return func(o *Options) { o.Workers = n } }
func WithQueueSize(n int) Option { return func(o *Options) { o.QueueSize = n } }

apply() 里做兜底,保证内部读到的一定合法:

go 复制代码
if o.Workers < 1 {
  o.Workers = 1
}
if o.QueueSize <= 0 {
  o.QueueSize = o.Workers * 2   // 默认 2 倍 worker 数
}

AddTask:一条清晰的写路径

go 复制代码
func (s *Scheduler) AddTask(spec TaskSpec) error {
  if err := spec.validate(); err != nil {
    return err
  }

  s.mu.Lock()
  defer s.mu.Unlock()

  if s.closed {
    return ErrClosed
  }
  if _, ok := s.entries[spec.ID]; ok {
    return fmt.Errorf("%w: %s", ErrTaskExists, spec.ID)
  }

  e := newTaskEntry(spec, s.opts.Now())
  s.entries[e.id] = e
  heap.Push(&s.queue, e)      // 入堆后 e.index 有效
  s.stats.submitted.Add(1)    // 原子计数,不需要额外锁

  s.notify()                  // 唤醒调度循环重算等待时间
  return nil
}

注意 newTaskEntry 里的一行归一化:

go 复制代码
runAt := spec.RunAt
if runAt.IsZero() || runAt.Before(now) {
  runAt = now   // 过去时间 → "立刻到期"
}

把「过去时间」统一成 now,保证堆顶判定单调,也让「立即执行」和「延迟执行」走同一条路径。

取消:三种时机,一个语义

Cancel 是全篇最需要小心的函数,因为它要和另外两个写者竞速:

go 复制代码
func (s *Scheduler) Cancel(id string) error {
  s.mu.Lock()

  e, ok := s.entries[id]
  if !ok {
    s.mu.Unlock()
    return fmt.Errorf("%w: %s", ErrTaskNotFound, id)
  }

  // 先出堆:还在堆里就摘掉,调度循环不会再投递它
  if e.index >= 0 {
    heap.Remove(&s.queue, e.index)
    e.index = -1
  }

  switch e.statusNow() {
  case StatusCancelled:
    s.mu.Unlock()
    return nil // 幂等:重复取消不报错
  case StatusExecuted, StatusFailed:
    s.mu.Unlock()
    return fmt.Errorf("%w: %s", ErrTaskFinished, id)
  }

  e.cancelNow()   // 执行中的任务:打断它的 ctx

  if s.finishEntry(e, StatusCancelled, "", ErrTaskCancelled) {
    s.mu.Unlock()
    s.notify()
    return nil
  }

  // 极小概率:worker 恰好同时写入了终态,先到先得
  st := e.statusNow()
  s.mu.Unlock()
  s.notify()

  if st == StatusCancelled {
    return nil
  }
  return fmt.Errorf("%w: %s", ErrTaskFinished, id)
}

三种时机的处理:

任务当前状态 处理 结果
待执行 从堆中摘除 + 置为已取消 函数根本不会被调用
执行中 取消其 ctx(FnCtx 任务可立即退出)+ 置为已取消 结果丢弃
已取消 直接返回 nil 幂等
已执行/错误 返回 ErrTaskFinished 不能取消已完成的任务

想等一个任务进入终态(StatusExecuted-已执行、StatusCancelled-已取消、StatusFailed-错误),用 Wait:

go 复制代码
func (s *Scheduler) Wait(ctx context.Context, id string) (TaskResult, error) {
  e, err := s.lookup(id)
  if err != nil {
    return TaskResult{}, err
  }

  select {
  case <-e.done:          // 进入终态时由 finish 关闭
    return e.snapshot(), nil
  case <-ctx.Done():      // 调用方自己放弃等待
    return e.snapshot(), ctx.Err()
  }
}

注意 e.done 是只关一次 的 channel。Wait 可能有多个调用者同时等,close 一次全部唤醒。对应 JS 就是「一个 Promise 被 await 多次」------但 Promise 只能有一个值,而 Go 这里可以随时 snapshot() 拿最新状态。

错误隔离:panic、超时、重试

panic:别让一个任务炸掉整个进程

任务函数里的 panic 如果没人接,会直接终止进程 。所以在执行任务的那个 goroutine 里必须 recover:

go 复制代码
func (s *Scheduler) invoke(ctx context.Context, e *taskEntry) (string, error) {
  type outcome struct {
    result string
    err    error
  }
  ch := make(chan outcome, 1) // 缓冲 1:发送端永不阻塞,不泄漏 goroutine

  go func() {
    defer func() {
      if r := recover(); r != nil {   // ← 拦住 panic
        ch <- outcome{err: newPanicError(r)}
      }
    }()

    if e.fnCtx != nil {
      out, err := e.fnCtx(ctx)
      ch <- outcome{result: out, err: err}
      return
    }
    out, err := e.fn()
    ch <- outcome{result: out, err: err}
  }()

  select {
  case o := <-ch:
    return o.result, o.err
  case <-ctx.Done():
    return "", contextError(ctx)
  }
}

newPanicError 在 recover 现场抓调用栈,把 panic 变成普通 error:

go 复制代码
type PanicError struct {
  Value any
  Stack []byte
}
func (e *PanicError) Error() string { return fmt.Sprintf("scheduler: task panicked: %v", e.Value) }
func (e *PanicError) StackTrace() []byte { return e.Stack }

func newPanicError(v any) *PanicError {
  return &PanicError{Value: v, Stack: debug.Stack()} // Stack 必须在现场取
}

超时:为什么要再开一个 goroutine

任务函数签名是 func() (string, error),Go 无法从外部强制中断它 。所以 invoke 把任务放进独立 goroutine ,然后和 ctx 竞速:

text 复制代码
       ┌──────────── worker ────────────┐
       │  select {                      │
       │  case o := <-ch:   ← 算完了     │
       │  case <-ctx.Done(): ← 超时了    │
       │  }                             │
       └────────────────────────────────┘
                 ▲
                 │ ch(容量 1)
       ┌─────────┴─────────┐
       │ 任务函数 goroutine  │  即使它不响应 ctx,也拖不住 worker
       └───────────────────┘

代价与取舍:函数本身仍在后台跑,直到它自己返回。但对于「不响应 ctx 的卡死任务」,worker 能在超时后立刻脱身继续干别的活。

如果任务用 TaskFuncCtx 并主动检查 ctx,它在超时后会自己退出,没有这个代价。

错误归一化:两条路径,同一个错误

超时可能由两条路径产生:worker 的 select 命中 ctx.Done(),或者任务自己检查 ctx 后返回了 ctx.Err()。对外必须是同一个错误,否则调用方拿到的错误会随机摇摆:

go 复制代码
func normalizeTaskError(err error) error {
  switch {
  case err == nil:
    return nil
  case errors.Is(err, context.DeadlineExceeded):
    return ErrTaskTimeout      // 归一到调度器错误
  case errors.Is(err, context.Canceled):
    return ErrTaskCancelled
  default:
    return err                 // 业务错误原样保留
  }
}

重试:单次尝试 + 失败重试的闭环

go 复制代码
func (s *Scheduler) runEntry(e *taskEntry) {
  for {
    attempt := e.nextAttempt()
    if attempt == 0 {
      return // 投递后、执行前被取消
    }

    ctx, cancel := s.newTaskContext(e)
    e.setCancel(cancel)

    result, err := s.invoke(ctx, e)

    cancel()
    e.clearCancel()

    if err == nil {
      s.finishEntry(e, StatusExecuted, result, nil)
      return
    }

    err = normalizeTaskError(err)

    var pe *PanicError
    if errors.As(err, &pe) {
      s.stats.panicked.Add(1)
    }
    s.logTaskError(e, attempt, err)

    if errors.Is(err, ErrTaskCancelled) {
      s.finishEntry(e, StatusCancelled, "", ErrTaskCancelled)
      return
    }

    if attempt < e.attemptsLimit {
      s.stats.retried.Add(1)
      if !s.sleep(e.backoff) {
        return // 调度器已关闭
      }
      continue
    }

    s.finishEntry(e, StatusFailed, "", err)
    return
  }
}

newTaskContext 让每个任务挂在调度器的 baseCtx 之下:

go 复制代码
func (s *Scheduler) newTaskContext(e *taskEntry) (context.Context, context.CancelFunc) {
  if e.timeout > 0 {
    return context.WithTimeout(s.baseCtx, e.timeout)
  }
  return context.WithCancel(s.baseCtx)
}

于是 Close() 一旦调用 baseCancel(),所有在途任务会一起被取消。

优雅关闭

go 复制代码
func (s *Scheduler) Close(ctx context.Context) error {
  if ctx == nil {
    ctx = context.Background()
  }

  s.closeOnce.Do(func() {           // 幂等:只有第一次真正生效
    s.mu.Lock()
    s.closed = true
    for _, e := range s.entries {
      e.cancelNow()
      s.finishEntry(e, StatusCancelled, "", ErrClosed) // 未终态的一律记为已取消
    }
    s.queue = nil
    s.mu.Unlock()

    s.baseCancel()  // 打断在途任务的 ctx
    close(s.done)   // 广播:调度循环与所有 worker 从此退出

    go func() {
      s.loopWG.Wait()
      s.workerWG.Wait()
      close(s.finished)   // 全部退出后关闭
    }()
  })

  select {
  case <-s.finished:
    return nil
  case <-ctx.Done():
    return ctx.Err()   // 关闭超时,交回调用方决定
  }
}

关闭是一次「四方协调」:入口(拒绝新任务)→ 待执行任务(取消)→ 在途任务(打断 ctx)→ 后台 goroutine(等待退出)。调用方还可以传一个带超时的 ctx,超时就放弃等待。

并发安全的六个关键设计

两层锁 + 固定加锁顺序

锁 保护的数据 谁在用 临界区里做什么
Scheduler.mu entries、queue、closed AddTask、Cancel、loop、Close、Stats、lookup 只做 map / 堆操作,绝不跑用户代码、绝不做 IO
taskEntry.mu status、result、err、attempts、cancel worker 执行路径、Cancel、Close、查询 只读写几个字段

加锁顺序被固定为 Scheduler.mu → taskEntry.mu,代码里永不允许反向。

graph LR SMU[Scheduler.mu] -->|唯一依赖方向| EMU[taskEntry.mu] EMU -.->|禁止| SMU

为什么这样就不会死锁? 死锁需要「锁依赖成环」。整份代码里只有一条跨锁路径(先拿全局锁,再拿某个 entry 的锁):

  • 需要跨锁的地方只有四处:Cancel(摘堆 + 写终态)、Close(遍历 + 写终态)、loop(出堆 + 标记执行中)、Stats(遍历 + 读状态);
  • worker 完成回调(写结果)不需要全局锁,它只锁自己那个 entry,所以不可能产生反向依赖。

锁依赖图是一棵无环的树,环无从形成。

反例 :如果只用一把 mu 保护所有东西,「任务执行完写结果」也要抢全局锁。任务越多、完成越频繁,这个全局热点越严重,还会阻塞调度循环。分层之后,写结果和查状态互不干扰。

终态幂等:三个写者,只有一个能赢

终态有三个可能的写入者,且可能同时发生:

  1. worker 执行完成 → 写「已执行」或「错误」;
  2. 用户 Cancel → 写「已取消」;
  3. Close → 把所有未终态任务写「已取消」。

如果不管,就可能出现「已取消的任务又被写成已执行」的脏写。finish() 用「终态检查 + 写入在同一临界区内」解决:

go 复制代码
if e.status.Terminal() {
  return false, e.attempts   // 已经有人写过终态了 → 我是空操作
}

于是第一个到达的写者生效,其余全部空操作 。返回的 changed bool 告诉调用方「别重复计数、别重复发事件」:

go 复制代码
func (s *Scheduler) finishEntry(e *taskEntry, status Status, result string, err error) bool {
  changed, attempts := e.finish(status, result, err, s.opts.Now())
  if !changed {
    return false
  }
  switch status {
  case StatusExecuted:
    s.stats.executed.Add(1)
  case StatusFailed:
    s.stats.failed.Add(1)
  case StatusCancelled:
    s.stats.cancelled.Add(1)
  }
  s.publish(Event{...})
  return true
}

这一条保证了:统计数字和事件永不重复、永不矛盾。

一致性快照:永远读不到「撕裂」的中间态

查询走 snapshot(),status / result / err 在同一个临界区内读取。返回的 TaskResult 是值拷贝,拿出去之后怎么变都不影响它。

如果没有这层保护,Status() 就可能读到「状态已执行、结果还是空」的中间态。

信号合并:容量 1 的 channel

go 复制代码
wake chan struct{}   // 容量 1

func (s *Scheduler) notify() {
  select {
  case s.wake <- struct{}{}:
  default:              // 已经有信号在里面了,直接走
  }
}

容量 1 + default = 天然的信号合并 。连续提交 1 万个任务,无论 notify 被调用多少次,channel 里永远最多只有 1 个信号;调度循环醒来后一次性看到全部新任务。

这比「用无缓冲 channel 逐个唤醒」高效得多------后者会让提交者在 notify 上阻塞,或者需要开额外的转发 goroutine。

可复用 Timer:既省内存又不出错

调度循环里的 timer 只创建一次,之后反复 Reset:

go 复制代码
timer := time.NewTimer(time.Hour)
stopTimer(timer)      // 先停掉初始的 1 小时
defer timer.Stop()

// 循环里:
timer.Reset(wait)

好处是不产生垃圾 (1 万个任务不产生 1 万个 Timer),而且通过 stopTimer 排空陈旧信号,保证不会「提前触发」。

非阻塞事件广播:观察者不能拖慢被观察者

go 复制代码
func (s *Scheduler) publish(ev Event) {
  s.subMu.Lock()
  defer s.subMu.Unlock()

  for _, ch := range s.subs {
    select {
    case ch <- ev:
    default:
      s.stats.dropped.Add(1)   // 订阅者消费太慢 → 丢弃并计数
    }
  }
}

订阅者(日志、埋点、UI 推送)如果消费慢,绝不能拖住调度循环。所以:

  • 事件发送永远是「非阻塞 + 满了就丢」;
  • 丢弃数量记录在 Stats().Dropped,可观测;
  • 订阅用独立的 subMu,不干扰调度热路径;
  • 退订函数用 sync.Once 保证只执行一次,并在 delete 之后再 close(ch),避免「close 后仍有发送」的 panic。

总结

此调度器中 1 个 loop 负责定序与唤醒,N 个 worker 负责有界并发执行,1 个最小堆负责按到期时间排序,1 张索引表(map[id]*taskEntry)负责 O(1) 按 ID 查找、查询与取消。

text 复制代码
┌─────────────────────────────────────────────────────────────┐
│  调度器 = 1 个 loop + N 个 worker + 1 个最小堆 + 1 张索引表     │
└─────────────────────────────────────────────────────────────┘

数据流为:

text 复制代码
数据流:
  AddTask ──[s.mu]──► entries[id]=e ──► heap.Push(e) ──► notify()
                                                          │
                     loop 醒来,看堆顶 ────────────────────┘
                          │
                     RunAt 没到 → timer.Reset 继续睡(可被 wake 打断)
                     RunAt 到了 → [s.mu] heap.Pop + markRunning  ← 原子
                          │
                     ready chan(满了就背压)──► worker
                          │
                     invoke:独立 goroutine 执行 + 与 ctx 竞速 + recover
                          │
                     finish()(终态幂等)──► 统计 + 事件广播(非阻塞)

五条最重要的并发原则:

  1. 缩小临界区:锁里只做 O(log n) 的结构操作,绝不跑用户代码、绝不做 IO。
  2. 固定加锁顺序:全代码只有一条跨锁路径,锁依赖图无环 → 不可能死锁。
  3. 单一终态入口 :所有状态写入都走 finish(),用「先到先得」消灭竞态脏写。
  4. 用 channel 表达「信号」,用 Mutex 表达「状态」:信号可以合并、可以广播;状态需要一致性快照。
  5. 隔离失败 :recover 在 goroutine 边界拦住 panic;用户任务在自己的 goroutine 里跑,卡死也拖不住 worker。

此任务调度器的完整源码:github.com/Panda-plus5...

相关推荐
苏三的开发日记1 小时前
Docker 启动顺序优化(CentOS / Ubuntu)
后端
敲代码的玉米C1 小时前
并排 diff、提交标签:砚的 Git 阅读体验又补了一轮
go
敲代码的玉米C2 小时前
给编辑器补拖拽:标签挪过去,状态也得跟过去
go
敲代码的玉米C2 小时前
写到这里,砚还要继续照着 IDE 加功能吗
go
敲代码的玉米C2 小时前
把终端拖进编辑区,别把正在跑的 shell 弄丢了
go
敲代码的玉米C2 小时前
给砚加一个「所有改动」页,提交前少来回点几次
go
高频因子挖掘机3 小时前
同一交易日出现重复 K 线:去重前先确认时间、来源和粒度
后端·github·api
feely02084 小时前
《我写了 31 个静态扫描插件,踩了 7 个「假绿」》
后端