文章目录
- [1. Ticket 计时器](#1. Ticket 计时器)
- [2. Context](#2. Context)
-
- [2.1 基础介绍](#2.1 基础介绍)
- [2.2 创建方式](#2.2 创建方式)
- [3. recover](#3. recover)
-
- [3.1 基础概念](#3.1 基础概念)
- [3.2 在子协程中使用 recover](#3.2 在子协程中使用 recover)
- [3.3 多层嵌套 recover](#3.3 多层嵌套 recover)
- [4. 任务执行对比](#4. 任务执行对比)
-
- [4.1 Worker 工作池](#4.1 Worker 工作池)
- [4.2 不使用 Worker 工作池](#4.2 不使用 Worker 工作池)
- [5. 小结](#5. 小结)
本系列文章:
- Java 转 go 学习 - 项目管理
- Java 转 go 学习 - 基本语法
- Java 转 go 学习 - 类型转换
- Java 转 go 学习 - 流程控制结构
- Java 转 go 学习 - 数组和切片
- Java 转 go 学习 - map
- Java 转 go 学习 - 函数(1)
- Java 转 go 学习 - 函数(2)
- Java 转 go 学习 - 结构体
- Java 转 go 学习 - 接口
- Java 转 go 学习 - 并发编程(1)
1. Ticket 计时器
time 包中的 Ticket 计时器可以结合通道使用:每隔固定时间自动触发一次,通过内置的只读通道发送时间信号,直到手动停止,有下面特点:
- 周期发送: 每隔指定时间自动触发一次,无限循环,直到调用 stop。
- 通道驱动: 内置只读通道
ticker.C,每次触发会向当前通道写入当前时间。 - 必须手动停止: 不调用
stop()会造成定时器泄露,资源没办法释放。
下面来看下核心的 API:
go
// 1. 创建 Ticker, 间隔时间是 d
ticker := time.NewTicker(d time.Duration)
// 2. 定时通道: 只读通道, ticker 定时向里面发送数据
ticker.C
// 3. 停止定时器: 释放资源
ticker.Stop()
Ticket 计时器属于是定时触发,而 Timer 是触发一次,这点要记住。 下面来看下 ticket 的基础用法,我们创建一个 ticker 配合 for-select 定时输出,然后通过 time 定时 5s 之后发送退出信号,for-select 监听退出。
go
func test41() {
fmt.Printf("[%s] 启动 Ticker 定时器(每隔 1 秒触发)\n", util.Now())
// 1. 创建 ticket
ticker := time.NewTicker(1 * time.Second)
// 2. 创建退出信号
quit := make(chan struct{})
// 3. 定时器 5秒后发送退出信号
time.AfterFunc(5*time.Second, func() {
close(quit) // 发送退出信号
ticker.Stop() // 停止定时器
fmt.Printf("[%s] Ticker 已停止\n", util.Now())
})
// 3. 输出 ticket 每秒往 ticket.C 里面输出时间
for {
select {
case t := <-ticker.C:
fmt.Printf("[%s] 定时任务执行:当前时间=%s\n", util.Now(), t.Format("15:04:05"))
case <-quit:
fmt.Printf("[%s] 主程序正常退出\n", util.Now())
return
}
}
}
❌ 这里有一个注意点,注意不要用 for-range 去遍历 ticker.C,会报错:fatal error: all goroutines are asleep - deadlock!。
go
for t := range ticker.C {
fmt.Printf("[%s] 定时任务执行:当前时间=%s\n", util.Now(), t.Format("15:04:05"))
}
因为 ticker.stop 只会停止触发定时任务,不会关闭 ticker.C 通道,而 for-range 特点就是如果通道里面没有数据,会永久阻塞等待,由于主 goroutine 阻塞,没有其他 goroutine 运行,这时候就触发了 all goroutines are asleep - deadlock!。
下面我们再来看下如果 for-select 里面处理 ticker.C 通道的耗时比发送的间隔要长,会怎么样。下面我们每秒往通道写入时间,然后 for-select 的时候处理一次就 sleep 两秒。
go
func test41() {
fmt.Printf("[%s] 启动 Ticker 定时器(每隔 1 秒触发)\n", util.Now())
// 1. 创建 ticket
ticker := time.NewTicker(1 * time.Second)
// 2. 创建退出信号
quit := make(chan struct{})
// 3. 定时器 20秒后发送退出信号
time.AfterFunc(20*time.Second, func() {
close(quit) // 发送退出信号
ticker.Stop() // 停止定时器
fmt.Printf("[%s] Ticker 已停止\n", util.Now())
})
// 3. 输出 ticket 每秒往 ticket.C 里面输出时间
for {
select {
case t := <-ticker.C:
time.Sleep(2 * time.Second)
fmt.Printf("[%s] 定时任务执行:当前时间=%s\n", util.Now(), t.Format("15:04:05"))
case <-quit:
fmt.Printf("[%s] 主程序正常退出\n", util.Now())
return
}
}
}
最终输出如下:
go
[2026-03-26 00:16:00] 启动 Ticker 定时器(每隔 1 秒触发)
[2026-03-26 00:16:03] 定时任务执行:当前时间=00:16:01
[2026-03-26 00:16:05] 定时任务执行:当前时间=00:16:02
[2026-03-26 00:16:07] 定时任务执行:当前时间=00:16:04
[2026-03-26 00:16:09] 定时任务执行:当前时间=00:16:06
[2026-03-26 00:16:11] 定时任务执行:当前时间=00:16:08
[2026-03-26 00:16:13] 定时任务执行:当前时间=00:16:10
[2026-03-26 00:16:15] 定时任务执行:当前时间=00:16:12
[2026-03-26 00:16:17] 定时任务执行:当前时间=00:16:14
[2026-03-26 00:16:19] 定时任务执行:当前时间=00:16:16
[2026-03-26 00:16:20] Ticker 已停止
[2026-03-26 00:16:21] 定时任务执行:当前时间=00:16:18
[2026-03-26 00:16:21] 主程序正常退出
可以看到从通道读取出来的时间也是间隔 2s 的,但是第一次是相隔 1s,这是为什么,可以看下执行的过程。
00:16:00开始启动定时器。00:16:01开始执行任务,往里面写入时间,此时for-select接受到数据,正在sleep 2s。00:16:02时隔 1s 继续执行任务,因为通道的时间被读走了,所以往里面写入当前时间。00:16:03for-select进行sleep 2s之后把00:16:01打印出来,然后读取00:16:02,继续sleep 2s。00:16:04通道时间已经被读取,此时继续往里面写入当前时间。00:16:05sleep 2s结束,把00:16:02打印出来,继续读取00:16:04,然后sleep 2s。- ...
整体来说还是可以看到如果处理过程超过了时间间隔,那么中间的定时输出就会去掉,可以理解的是 ticker.C 本身应该是一个无缓冲通道,往里面写入数据之后就需要阻塞等到读取才会进行下一次写入。
2. Context
2.1 基础介绍
Context 是用在并发执行的 Coroutine 之间传递 取消信号、截止时间 和 请求范围 的值的标准接口,可以用于管理 Goroutine 生命周期 、实现超时控制 和 优雅退出。
Context 可以理解成树形结构:
- 父子关系: 一个 Context 可以派生出多个子 Context。
- 信号传播: 当一个父 Context 被取消的时候,所有子 Context 也会被自动取消,这种机制使得取消信号可以从一个请求的入口快速传播到所有相关的后台任务。
- 值隔离: 每个 Context 节点都可以存储自己的 key-value,查找的时候会沿着树向上回溯,但是修改时隔离的,保证数据安全性。
Context 接口设计比较间接,就包含下面四个方法。
Deadline() (deadline time.Time, ok bool):返回当前 Context 应该被取消的截止时间,如果没有设置截止时间,ok 为 false。Done() <-chan struct{}:返回一个 只读 的通道,当 Context 被取消或者超时的时候,这个通道会被关闭,Coroutine 可以通过监听这个通道来获取是否应该停止工作。Err() error:在Done()通道关闭后,调用这个方法可以返回 Context 被取消的原因,比如context.Canceled或者context.DeadlineExceeded。Value(key interface{}) interface{}:获取 Context 中和key关联的值,可以在当前请求链中传递一些请求范围的数据,比如认证令牌、用户 ID 等。
2.2 创建方式
Go 的 context 包提供了四种创建方式:
context.Background():创建一个空的根Context,永远不会被取消,没有值和截止时间,一般来说都是作为其他 Context 的起点。context.TODO():跟Background()类似,但是语义上表示暂时不确定使用哪个 Context,一般来说在重构1或者编写通用库的时候使用。context.WithCancel(parent Context):从 Context 派生一个可以被手动取消的子 Context,返回值是Context和cancel函数,调用这个cancel()函数会触发取消信号。context.WithTimeout(parent Context,timeout time.Duration):派生一个在指定时间后自动取消的子 Context,用于实现操作超时。context.WithValue(parent Context, key, val interface{}):派生一个可带 key-value 的子 Context,值可以通过Value()方法获取。
3. recover
3.1 基础概念
recover() 时 Go 语言的内置函数,用来获取当前协程中的 panic 并且恢复程序执行,避免程序崩溃,和 panic、defer 构成 Go 的异常处理体系。
recover仅仅对当前goroutine生效,没办法跨协程 捕获panic。recover只能在defer函数中调用,并且 defer 必须在 panic 前注册。- 捕获到 panic 的时候返回其参数,一般都是 error/string,其他情况就是 nil,捕获 panic 之后协程恢复正常执行流程。
下面来看一下最基本的 recover 用法。
go
package main
import "fmt"
func test51() {
defer func() {
if r := recover(); r != nil {
fmt.Printf("捕获到 panic: %v\n", r)
}
}()
fmt.Println("执行业务代码...")
panic("模拟程序发生 panic")
fmt.Println("这段代码不会执行")
}
func main() {
test51()
fmt.Println("主程序继续执行")
// 执行业务代码...
// 捕获到 panic: 模拟程序发生 panic
// 主程序继续执行
}
可以看到 test51 方法中发生了 panic,但是我们捕获之后外部的主程序代码是可以正常执行的,同时要关注下的是 panic 后面的代码也不会再执行了。
3.2 在子协程中使用 recover
子协程中的 panic 不会影响到主协程,各自独自处理,所以每一个可能发生 panic 的协程都应该设置通过 recover 来处理。
go
func test52() {
go func() {
defer func() {
if r := recover(); r != nil {
fmt.Println("子协程获取到 panic:", r)
}
}()
panic("协程发生错误")
}()
time.Sleep(1 * time.Second)
fmt.Println("主协程继续执行")
// 子协程获取到 panic: 协程发生错误
// 主协程继续执行
}
所以当协程内发生 panic 的时候,通过会立刻停止当前函数的执行,然后去 defer 内捕获 panic,并不会将 panic 向上继续传递下去导致整个链路不可用。
3.3 多层嵌套 recover
go
func level1() {
defer func() {
if r := recover(); r != nil {
fmt.Println("level1 panic:", r)
}
}()
level2()
}
func level2() {
defer func() {
if r := recover(); r != nil {
fmt.Println("level2 panic:", r)
}
}()
level3()
}
func level3() {
panic("error in level3")
}
func main() {
level1()
// level2 panic: error in level3
}
可以看到多层嵌套的情况下只会被当前层级的 defer 捕获到,当然可以在捕获之后重新抛出 panic,让上层去处理。
4. 任务执行对比
首先明确下面几个基本的概念:
- Task(任务):要执行的业务逻辑(函数、方法)
- Worker(工作者):执行任务的协程(goroutine)
核心的目标就是控制并发量,避免不断创建 goroutine 去处理任务导致程序崩溃,尽管 goroutine 是比较轻量级的,但是也不能无限创建。
4.1 Worker 工作池
核心逻辑就是启动给固定数量的 Worker goroutine,然后创建一个任务通道,Worker 监听通道,取出任务执行,Worker 是永久运行的。
go
package main
import (
"fmt"
"sync"
)
// 简单任务函数
type Task func()
type FixedWorkerPool struct {
taskChan chan Task
wg sync.WaitGroup
}
// 所有 goroutine 都调用这个方法去监听 chan 获取任务
func (p *FixedWorkerPool) work(id int) {
for {
select {
case task := <-p.taskChan:
fmt.Printf("worker %d 执行任务\n", id)
task()
fmt.Printf("worker %d 任务执行完成\n", id)
p.wg.Done()
}
}
}
// 提交任务到池子
func (p *FixedWorkerPool) Submit(task Task) {
p.wg.Add(1)
p.taskChan <- task
}
// 关闭池子的通道
func (p *FixedWorkerPool) Close() {
close(p.taskChan)
}
func NewFixedPool(routineSize int, poolSize int) *FixedWorkerPool {
pool := &FixedWorkerPool{
taskChan: make(chan Task, poolSize),
}
for i := 0; i < routineSize; i++ {
go pool.work(i)
}
return pool
}
func main() {
// 通道大小是 20, goroutine 数量是 5
pool := NewFixedPool(5, 20)
// 提交 50 个任务到池子
for i := 0; i < 50; i++ {
taskNo := i
pool.Submit(func() {
fmt.Printf("执行具体任务, 任务编号: %d\n", taskNo)
})
}
// 阻塞等待任务执行完成
pool.wg.Wait()
// 关闭池子
pool.Close()
fmt.Println("所有任务执行完成, 协程池关闭")
}
在上面方法中 FixedWorkerPool 就是协程池子,里面包含了通道 taskChan 和 WaitGroup,所有协程通过 work 去监听通道里面的任务,由于通道的特殊性能确保同一时间内只有一个协程能拿到任务来执行,不需要加锁。work 方法中通过 for-select 不断循环获取消息,如果获取不到就阻塞。
go
package main
import (
"fmt"
"sync"
)
// 简单任务函数
type Task func(id int)
type FixedWorkerPool struct {
taskChan chan Task
wg sync.WaitGroup
mu sync.Mutex
}
// 所有 goroutine 都调用这个方法去监听 chan 获取任务
func (p *FixedWorkerPool) work(id int) {
for task := range p.taskChan {
// 加锁保证日志完整
p.mu.Lock()
fmt.Printf("[worker %d] 执行任务\n", id)
p.mu.Unlock()
// 执行任务, 通道关闭之后获取到的是 nil, 用 range 会自动退出, 如果用 for-select 就要额外判断
task(id)
p.mu.Lock()
fmt.Printf("[worker %d] 任务执行完成\n", id)
p.mu.Unlock()
p.wg.Done()
}
}
// 提交任务到池子
func (p *FixedWorkerPool) Submit(task Task) {
p.wg.Add(1)
p.taskChan <- task
}
// 关闭池子的通道
func (p *FixedWorkerPool) Close() {
close(p.taskChan)
}
func NewFixedPool(routineSize int, poolSize int) *FixedWorkerPool {
pool := &FixedWorkerPool{
taskChan: make(chan Task, poolSize),
}
for i := 0; i < routineSize; i++ {
go pool.work(i)
}
return pool
}
func main() {
// 通道大小是 20, goroutine 数量是 5
pool := NewFixedPool(5, 5)
// 提交 50 个任务到池子
for i := 0; i < 20; i++ {
taskNo := i
pool.Submit(func(workerId int) {
fmt.Printf("[worker %d]执行具体任务, 任务编号: %d\n", workerId, taskNo)
})
}
// 阻塞等待任务执行完成
pool.wg.Wait()
// 关闭池子
pool.Close()
fmt.Println("所有任务执行完成, 协程池关闭")
}
输出如下:
go
[worker 4] 执行任务
[worker 4]执行具体任务, 任务编号: 3
[worker 3] 执行任务
[worker 0] 执行任务
[worker 0]执行具体任务, 任务编号: 0
[worker 0] 任务执行完成
[worker 3]执行具体任务, 任务编号: 1
[worker 0] 执行任务
[worker 0]执行具体任务, 任务编号: 5
[worker 0] 任务执行完成
[worker 0] 执行任务
[worker 0]执行具体任务, 任务编号: 6
[worker 4] 任务执行完成
[worker 4] 执行任务
[worker 4]执行具体任务, 任务编号: 7
[worker 2] 执行任务
[worker 2]执行具体任务, 任务编号: 4
[worker 2] 任务执行完成
[worker 2] 执行任务
[worker 2]执行具体任务, 任务编号: 8
[worker 1] 执行任务
[worker 1]执行具体任务, 任务编号: 2
[worker 1] 任务执行完成
[worker 1] 执行任务
[worker 1]执行具体任务, 任务编号: 9
[worker 3] 任务执行完成
[worker 3] 执行任务
[worker 3]执行具体任务, 任务编号: 10
[worker 0] 任务执行完成
[worker 0] 执行任务
[worker 0]执行具体任务, 任务编号: 11
[worker 0] 任务执行完成
[worker 0] 执行任务
[worker 0]执行具体任务, 任务编号: 12
[worker 4] 任务执行完成
[worker 4] 执行任务
[worker 2] 任务执行完成
[worker 2] 执行任务
[worker 2]执行具体任务, 任务编号: 14
[worker 4]执行具体任务, 任务编号: 13
[worker 1] 任务执行完成
[worker 1] 执行任务
[worker 1]执行具体任务, 任务编号: 15
[worker 1] 任务执行完成
[worker 1] 执行任务
[worker 1]执行具体任务, 任务编号: 16
[worker 3] 任务执行完成
[worker 1] 任务执行完成
[worker 3] 执行任务
[worker 3]执行具体任务, 任务编号: 17
[worker 1] 执行任务
[worker 1]执行具体任务, 任务编号: 18
[worker 3] 任务执行完成
[worker 3] 执行任务
[worker 3]执行具体任务, 任务编号: 19
[worker 0] 任务执行完成
[worker 2] 任务执行完成
[worker 4] 任务执行完成
[worker 1] 任务执行完成
[worker 3] 任务执行完成
所有任务执行完成, 协程池关闭
✅ 上面有个注意点,就是 for-range 在遍历通道的时候如果获取不到数据会阻塞,同时最后如果通道被关闭了就会自动退出。但是如果换成 for-select 就不一样了,这种情况下通道 close 会返回 nil 值,要注意 nil 的处理,否则容易报错,就比如下面的写法。
go
// 所有 goroutine 都调用这个方法去监听 chan 获取任务
func (p *FixedWorkerPool) work(id int) {
for {
select {
case task := <-p.taskChan:
fmt.Printf("[worker %d] 执行任务\n", id)
task(id)
fmt.Printf("[worker %d] 任务执行完成\n", id)
p.wg.Done()
}
}
}
这种情况下通道 close 之后 task = nil,这样就会报错 panic: runtime error: invalid memory address or nil pointer dereference,需要结合 ok 使用。
go
func (p *FixedWorkerPool) work(id int) {
for {
select {
case task, ok:= <-p.taskChan:
if !ok {
fmt.Printf("[worker %d] 退出任务处理\n", id)
return
}
fmt.Printf("[worker %d] 执行任务\n", id)
task(id)
fmt.Printf("[worker %d] 任务执行完成\n", id)
p.wg.Done()
}
}
}
4.2 不使用 Worker 工作池
由于 Go 的协程创建比较轻量,所以可以直接创建 Go 协程来执行任务,当然不能无限创建,可以用信号量(semaphore)限制最大并发数,Worker 随着任务创建,任务完成之后自动销毁,简单来说就是不需要池子管理 Worker,但是需要限制并发数,防止创建太多的协程。
go
package main
import (
"context"
"fmt"
"golang.org/x/sync/errgroup"
"golang.org/x/sync/semaphore"
)
// 使用信号量控制 goroutine 数量
type DynamicPool struct {
sem *semaphore.Weighted
g *errgroup.Group
ctx context.Context
}
func NewDynamicPool(ctx context.Context, maxConcurrency int64) *DynamicPool {
g, ctx := errgroup.WithContext(ctx)
return &DynamicPool{
sem: semaphore.NewWeighted(maxConcurrency),
g: g,
ctx: ctx,
}
}
// 提交任务
func (p *DynamicPool) Submit(task func() error) {
p.g.Go(func() error {
// 1. 获取信号量, 控制最大并发
if err := p.sem.Acquire(p.ctx, 1); err != nil {
return err
}
defer p.sem.Release(1)
// 2. panic 处理
defer func() {
if r := recover(); r != nil {
fmt.Println("Recovered from panic:", r)
}
}()
// 3. 执行任务
return task()
})
}
// 等待所有任务完成
func (p *DynamicPool) Wait() error {
return p.g.Wait()
}
func main() {
const maxConcurrency = 3
const taskCount = 10
// 创建池子
pool := NewDynamicPool(context.Background(), maxConcurrency)
fmt.Printf("🚀 启动动态协程池,最大并发数:%d,总任务数:%d\n", maxConcurrency, taskCount)
for i := 1; i <= taskCount; i++ {
taskID := i
pool.Submit(func() error {
fmt.Printf("✅ 执行任务 %d (当前最大并发限制: %d)\n", taskID, maxConcurrency)
// 测试 panic
if taskID == 5 {
panic("任务5异常崩溃")
}
return nil
})
}
// 等待所有任务执行
if err := pool.Wait(); err != nil {
fmt.Printf("❌ 任务执行失败: %v\n", err)
}
fmt.Printf("🎉 所有任务执行完成")
}
输出如下:
go
🚀 启动动态协程池,最大并发数:3,总任务数:10
✅ 执行任务 2 (当前最大并发限制: 3)
✅ 执行任务 1 (当前最大并发限制: 3)
✅ 执行任务 6 (当前最大并发限制: 3)
✅ 执行任务 7 (当前最大并发限制: 3)
✅ 执行任务 5 (当前最大并发限制: 3)
Recovered from panic: 任务5异常崩溃
✅ 执行任务 10 (当前最大并发限制: 3)
✅ 执行任务 4 (当前最大并发限制: 3)
✅ 执行任务 9 (当前最大并发限制: 3)
✅ 执行任务 3 (当前最大并发限制: 3)
✅ 执行任务 8 (当前最大并发限制: 3)
🎉 所有任务执行完成
可以看到的是就算任务 5 执行异常也没有影响。
5. 小结
这篇文章中我们学习了 go 语言并发编程的一些工具,文章已经超过 1w 字,下一篇文章继续学习。
如有错误,欢迎指出!!!