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 语言单线程基于事件循环的并发有两个特点:
-
不存在数据竞争 ------两个
setTimeout回调不可能同时改同一个变量(因为主线程一次只跑一个)。 -
阻塞就是致命 ------一个
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 个工具:
goroutinechannelselectsynccontext
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 更加强大一些,除了传递取消信息,还能做以下两件事:
- 传递超时 :
WithTimeout/WithDeadline; - 传递请求级数据 :
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 // 只做透传,供业务埋点
}
Fn和FnCtx二选一 。TaskFunc对使用者最友好(写起来像普通函数),但它无法被中断 ;TaskFuncCtx多一个能力------响应取消。两者都给时优先FnCtx。RunAt用「绝对时间」而不是「延时」 。time.Now().Add(100*time.Millisecond)是调用方的事,调度器只认时间点。这样堆的比较是单调的,不会因为「什么时候提交」而漂移。- 值语义 。
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
}
状态流转的流程图:
最小堆的实现
最小堆让「最早到期」的任务永远在堆顶。
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
}
三个细节:
Less里的 ID 兜底 。两个任务RunAt完全相同时,按 ID 排。这让调度顺序确定、可测试、可复现------并发测试特别依赖这一点,否则同样的代码每次跑的顺序都可能不同。Swap里同步index。这是「取消任意任务 O(log n)」的关键(下面 4.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,代码里永不允许反向。
为什么这样就不会死锁? 死锁需要「锁依赖成环」。整份代码里只有一条跨锁路径(先拿全局锁,再拿某个 entry 的锁):
- 需要跨锁的地方只有四处:
Cancel(摘堆 + 写终态)、Close(遍历 + 写终态)、loop(出堆 + 标记执行中)、Stats(遍历 + 读状态); - worker 完成回调(写结果)不需要全局锁,它只锁自己那个 entry,所以不可能产生反向依赖。
锁依赖图是一棵无环的树,环无从形成。
反例 :如果只用一把
mu保护所有东西,「任务执行完写结果」也要抢全局锁。任务越多、完成越频繁,这个全局热点越严重,还会阻塞调度循环。分层之后,写结果和查状态互不干扰。
终态幂等:三个写者,只有一个能赢
终态有三个可能的写入者,且可能同时发生:
- worker 执行完成 → 写「已执行」或「错误」;
- 用户
Cancel→ 写「已取消」; 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()(终态幂等)──► 统计 + 事件广播(非阻塞)
五条最重要的并发原则:
- 缩小临界区:锁里只做 O(log n) 的结构操作,绝不跑用户代码、绝不做 IO。
- 固定加锁顺序:全代码只有一条跨锁路径,锁依赖图无环 → 不可能死锁。
- 单一终态入口 :所有状态写入都走
finish(),用「先到先得」消灭竞态脏写。 - 用 channel 表达「信号」,用 Mutex 表达「状态」:信号可以合并、可以广播;状态需要一致性快照。
- 隔离失败 :
recover在 goroutine 边界拦住 panic;用户任务在自己的 goroutine 里跑,卡死也拖不住 worker。
此任务调度器的完整源码:github.com/Panda-plus5...