当前位置: 首页 > news >正文

Go 异步编程

在现在的软件开发里,高并发场景越来越多,异步编程是应对这类场景的关键技术。之前接触过一些其他编程语言,它们实现异步往往要靠复杂的回调或者线程管理。而 Go 不一样,它原生就有 Goroutine、Channel 和调度器,搭起了一套简洁高效的异步体系。从基础原理到实际应用,较系统梳理一下 Go 异步编程

一、Goroutine:轻量级并发的基石

Go 异步编程的基础,是 Go 运行时管理的轻量级执行单元,和传统操作系统线程比,资源优势特别明显。

1.1 Goroutine 的资源优势

和传统线程比,Goroutine 的轻量级主要体现在三个方面:

  • 内存占用:传统线程初始栈一般是 1MB,而且固定不变;Goroutine 初始栈只有 2KB,还能根据需要动态伸缩,最大能到 1GB,能大幅减少内存浪费。
  • 创建销毁成本:线程的创建销毁要内核参与,成本很高;Goroutine 的创建销毁全在用户态完成,耗时是纳秒级的,差不多是线程的百分之一。
  • 上下文切换:线程切换要保存恢复完整的寄存器状态,还得涉及内核操作,一次要 1000 纳秒左右;Goroutine 切换只需要保存少量关键信息,几十纳秒就能完成。

也正因为这些特点,Go 程序轻松创建几十万个 Goroutine 都没问题,不会耗尽系统资源。之前查资料,普通服务器同时运行 100 多万个 Goroutine,内存占用也就几百 MB。

1.2 Goroutine 的基本使用

创建 Goroutine 特别简单,在函数调用前加个go关键字就行

package mainimport ("fmt""time"
)func task() {fmt.Println("执行异步任务")time.Sleep(1 * time.Second)fmt.Println("异步任务完成")
}func main() {// 启动一个Goroutinego task()// 主Goroutine等待,避免程序提前退出time.Sleep(2 * time.Second)fmt.Println("主程序结束")
}

这里task函数会在新的 Goroutine 里异步执行,主 Goroutine 继续往下走。要注意的是,主 Goroutine 要是执行完退出了,其他 Goroutine 都会被强制终止,所以这里加了Sleep让主程序等一等。

1.3 Goroutine 的同步机制

实际开发中,经常需要等 Goroutine 完成任务,或者让多个 Goroutine 同步操作。Go 主要有两种同步机制:

1.3.1 使用 Channel 进行同步

Channel 不仅能传数据,还能当同步信号用。比如这样:

package mainimport "fmt"func task(done chan<- bool) {fmt.Println("执行异步任务")// 任务完成后发信号done <- true
}func main() {// 创建布尔类型Channeldone := make(chan bool)// 启动Goroutine并传Channelgo task(done)// 等待任务完成信号<-donefmt.Println("主程序结束")
}

这里用的是无缓冲 Channel,发送操作会一直阻塞,直到接收方准备好,这样就能保证主 Goroutine 等任务完成。

1.3.2 使用 sync.WaitGroup

如果要等多个 Goroutine 完成,sync.WaitGroup更方便。示例如下:

package mainimport ("fmt""sync""time"
)func task(id int, wg *sync.WaitGroup) {// 任务完成通知WaitGroupdefer wg.Done()fmt.Printf("任务%d开始执行\n", id)time.Sleep(time.Duration(id) * 100 * time.Millisecond)fmt.Printf("任务%d执行完成\n", id)
}func main() {var wg sync.WaitGroup// 启动5个Goroutinefor i := 1; i <= 5; i++ {wg.Add(1)go task(i, &wg)}// 等待所有任务完成wg.Wait()fmt.Println("所有任务完成,主程序结束")
}

WaitGroup靠计数器工作:Add(n)加计数,Done()减计数,Wait()会阻塞到计数为零。

1.4 Goroutine 的常见陷阱

用 Goroutine 的时候,有两个问题容易踩坑,大家要注意:

1.4.1 循环变量捕获问题

在循环里启动 Goroutine,直接用循环变量会出问题。比如:

// 错误示例
for i := 0; i < 5; i++ {go func() {fmt.Println(i)  // 可能输出多个相同的值}()
}

因为 Goroutine 启动需要时间,循环变量可能已经变了。正确的做法是把循环变量当参数传进去:

// 正确示例
for i := 0; i < 5; i++ {go func(num int) {fmt.Println(num)  // 正确输出0-4}(i)
}

这样会创建变量副本,就不会有问题了。

1.4.2 未捕获的 Panic

Goroutine 里的未捕获 Panic 会让整个程序崩溃。比如:

// 危险示例
go func() {panic("发生错误")  // 导致程序崩溃
}()

所以一定要在 Goroutine 里用recover捕获 Panic:

// 安全示例
go func() {defer func() {if err := recover(); err != nil {fmt.Printf("捕获到错误: %v\n", err)}}()panic("发生错误")  // 错误被捕获,不影响程序
}()

二、Channel:Goroutine 间的通信机制

Channel 是 Goroutine 之间安全通信的关键,也是 Go“通过通信共享内存,而非通过共享内存通信” 理念的核心。

2.1 Channel 的基本概念

Channel 可以理解成先进先出的队列,用make函数创建:

// 创建无缓冲Channel
ch1 := make(chan int)// 创建带缓冲Channel,容量5
ch2 := make(chan string, 5)

Channel 主要有发送(<-)和接收(->)两种操作:

ch <- 42    // 发数据到Channel
value := <-ch  // 从Channel收数据

close函数能关闭 Channel,关闭后不能再发数据,但还能收剩余数据。

2.2 无缓冲 Channel

无缓冲 Channel 没有缓冲区,发送会阻塞到有 Goroutine 接收,接收也会阻塞到有数据发送。示例:

package mainimport "fmt"func sender(ch chan<- int) {fmt.Println("发送者:准备发送数据")ch <- 42  // 阻塞,直到接收者准备好fmt.Println("发送者:数据发送完成")
}func receiver(ch <-chan int) {fmt.Println("接收者:准备接收数据")value := <-ch  // 阻塞,直到有数据发送fmt.Printf("接收者:收到数据 %d\n", value)
}func main() {ch := make(chan int)  // 无缓冲Channelgo sender(ch)go receiver(ch)// 等待Goroutine完成var wg sync.WaitGroupwg.Add(2)// 实际代码要正确用WaitGrouptime.Sleep(1 * time.Second)
}

无缓冲 Channel 适合需要严格同步的场景,能确保收发双方都准备好再传数据。

2.3 带缓冲 Channel

带缓冲 Channel 有固定容量的缓冲区,缓冲区未满时发送能立即完成,未空时接收能立即完成。示例:

package mainimport "fmt"func main() {// 创建容量3的带缓冲Channelch := make(chan int, 3)// 发数据,缓冲区未满,不阻塞ch <- 1ch <- 2ch <- 3fmt.Println("发送了3个数据")// 第四个发送会阻塞,因为缓冲区满了// ch <- 4  // 取消注释会 deadlock// 接收数据fmt.Println(<-ch)  // 1fmt.Println(<-ch)  // 2// 缓冲区有空位,能再发一个ch <- 4fmt.Println("发送了第4个数据")// 接收剩余数据fmt.Println(<-ch)  // 3fmt.Println(<-ch)  // 4
}

带缓冲 Channel 适合生产 - 消费模型,能平衡不同速率的生产者和消费者。

2.4 Channel 的方向

在函数参数里,可以指定 Channel 的方向,这样能让代码更安全、易读:

// 只发送的Channel
func sender(ch chan<- int) {ch <- 42
}// 只接收的Channel
func receiver(ch <-chan int) {fmt.Println(<-ch)
}

指定方向后,编译器会阻止在函数里做和方向不符的操作,比如往只接收的 Channel 发数据。

2.5 选择多个 Channel:select 语句

select语句能同时等多个 Channel 操作,哪个能执行就执行哪个。示例:

package mainimport ("fmt""time"
)func main() {ch1 := make(chan string)ch2 := make(chan string)go func() {time.Sleep(1 * time.Second)ch1 <- "来自通道1的数据"}()go func() {time.Sleep(2 * time.Second)ch2 <- "来自通道2的数据"}()// 等第一个可用的Channel操作select {case msg1 := <-ch1:fmt.Println(msg1)case msg2 := <-ch2:fmt.Println(msg2)case <-time.After(1500 * time.Millisecond):fmt.Println("超时")}
}

select还能和default配合,避免阻塞:

select {
case msg := <-ch:fmt.Println(msg)
default:// Channel没数据时执行fmt.Println("没有数据")
}

三、Go 调度器:Goroutine 的高效调度

Go 的调度器负责管理 Goroutine 的执行,它实现了 M:N 调度模型,就是把 M 个 Goroutine 映射到 N 个操作系统线程上执行。

3.1 调度器的核心组件

调度器主要有三个核心组件:

  • G(Goroutine):代表一个 Goroutine,包含执行栈、程序计数器等信息。
  • M(Machine):代表一个操作系统线程。
  • P(Processor):代表逻辑处理器,负责把 Goroutine 分配到 M 上执行,还维护一个本地 Goroutine 队列。

P 的数量由GOMAXPROCS环境变量控制,默认是 CPU 核心数。每个 P 都会和一个 M 关联,形成一个执行单元。

3.2 调度器的工作原理

调度器的工作流程大概是这样的:

  1. 创建新的 Goroutine 后,会把它加到当前 P 的本地队列里。
  2. P 从本地队列里取出 Goroutine,交给关联的 M 执行。
  3. 当 Goroutine 执行阻塞操作(比如等 Channel、I/O 操作)时,M 会和 P 分离,P 会关联一个新的 M 继续执行其他 Goroutine。
  4. 阻塞的 Goroutine 恢复后,会被放到全局队列或者其他 P 的本地队列里,等下次调度。
  5. 当 P 的本地队列为空时,会从全局队列或者其他 P 的本地队列 “窃取” Goroutine 来执行,实现负载均衡。

这种设计能让调度器高效利用 CPU 资源,就算有很多阻塞操作,性能也能保持不错。

3.3 GOMAXPROCS 的设置

GOMAXPROCS控制着能同时执行用户级代码的操作系统线程数,也就是 P 的数量。Go 1.5 之前默认是 1,得手动设置;1.5 之后默认是 CPU 核心数。

可以用代码或者环境变量设置GOMAXPROCS

// 代码设置
import "runtime"func main() {runtime.GOMAXPROCS(4)  // 设置为4个P
}

或者用环境变量:

GOMAXPROCS=4 ./myprogram

设置GOMAXPROCS要根据应用类型来:

  • CPU 密集型应用:一般设成 CPU 核心数,避免太多线程切换开销。
  • I/O 密集型应用:可以适当调大,因为 Goroutine 大部分时间在等 I/O 完成。

四、异步编程的实战模式

掌握了 Goroutine、Channel 和调度器的基础后,再给大家讲几个常见的异步编程模式。

4.1 扇出 - 扇入模式(Fan-out/Fan-in)

扇出 - 扇入模式是把一个任务拆成多个子任务并行执行,然后合并结果。示例:

package mainimport ("fmt""sync"
)// 生成数据
func generate(data []int, out chan<- int) {defer close(out)for _, v := range data {out <- v}
}// 处理数据(平方)
func square(in <-chan int, out chan<- int, wg *sync.WaitGroup) {defer wg.Done()for v := range in {out <- v * v}
}// 合并结果
func merge(outs []<-chan int, result chan<- int) {var wg sync.WaitGroup// 每个输出通道启动一个Goroutinefor _, out := range outs {wg.Add(1)go func(ch <-chan int) {defer wg.Done()for v := range ch {result <- v}}(out)}// 等所有Goroutine完成后关闭结果通道go func() {wg.Wait()close(result)}()
}func main() {data := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}input := make(chan int)// 启动生成数据的Goroutinego generate(data, input)// 启动3个处理数据的Goroutine(扇出)const workers = 3var wg sync.WaitGroupouts := make([]<-chan int, workers)for i := 0; i < workers; i++ {ch := make(chan int)outs[i] = chwg.Add(1)go square(input, ch, &wg)}// 关闭输入通道后,等所有处理Goroutine完成go func() {wg.Wait()// 实际不用关输出通道,square会在输入关闭后退出}()// 合并结果(扇入)result := make(chan int)go merge(outs, result)// 打印结果for v := range result {fmt.Println(v)}
}

这种模式能充分利用多核 CPU,提高数据处理效率,适合能并行的任务。

4.2 超时控制模式

异步操作里,超时控制很重要,能避免程序一直等。示例:

package mainimport ("fmt""time"
)// 模拟可能超时的操作
func doWork() string {// 随机休眠一段时间time.Sleep(time.Duration(800+time.Now().UnixNano()%400) * time.Millisecond)return "操作结果"
}// 带超时的异步操作
func asyncWorkWithTimeout(timeout time.Duration) (string, error) {resultChan := make(chan string)// 启动异步操作go func() {result := doWork()resultChan <- result}()// 等结果或超时select {case result := <-resultChan:return result, nilcase <-time.After(timeout):return "", fmt.Errorf("操作超时(%v)", timeout)}
}func main() {result, err := asyncWorkWithTimeout(1 * time.Second)if err != nil {fmt.Println("错误:", err)} else {fmt.Println("结果:", result)}
}

这个模式用time.After创建超时信号通道,配合select实现超时控制。

4.3 工作池模式

在实际开发中,我们经常会遇到需要处理大量任务的场景,但如果无限制地创建 Goroutine,可能会导致资源耗尽。这时候,工作池模式就很有用了,它能帮我们控制并发数量。

我之前做过一个处理批量任务的小项目,一开始没控制 Goroutine 数量,结果任务一多就出问题了。后来用了工作池模式,把并发数固定住,就稳定多了。给大家看个示例:

package mainimport ("fmt""sync""time"
)// 定义一个任务结构
type Task struct {ID int
}// 工作函数,负责处理任务
func worker(id int, tasks <-chan Task, wg *sync.WaitGroup) {defer wg.Done()for task := range tasks {fmt.Printf("工作者%d: 处理任务%d\n", id, task.ID)time.Sleep(500 * time.Millisecond)  // 模拟处理耗时fmt.Printf("工作者%d: 完成任务%d\n", id, task.ID)}
}func main() {const numWorkers = 3  // 工作池大小,控制并发数const numTasks = 10   // 总任务数量// 创建任务通道tasks := make(chan Task, numTasks)// 启动工作池var wg sync.WaitGroupwg.Add(numWorkers)for i := 1; i <= numWorkers; i++ {go worker(i, tasks, &wg)}// 提交所有任务for i := 1; i <= numTasks; i++ {tasks <- Task{ID: i}}close(tasks)  // 所有任务提交完毕,关闭通道// 等待所有工作者完成wg.Wait()fmt.Println("所有任务处理完成")
}

这个模式的核心就是通过固定数量的工作 Goroutine 和一个任务通道,让任务被均匀分配处理。我觉得这种方式特别适合那些任务数量多,但又不想因为创建太多 Goroutine 而消耗过多资源的场景。

4.4 取消机制

有时候,我们可能需要中途取消正在执行的 Goroutine,比如用户发起了一个请求,后来又后悔了,或者操作超时了。这时候,用context.Context来实现取消机制就很合适。

我之前在做一个定时任务系统时,就用到了这个机制。当某个任务超过规定时间还没完成,就通过 context 把它取消掉,避免资源浪费。示例如下:

package mainimport ("context""fmt""time"
)// 可被取消的任务
func cancelableTask(ctx context.Context, id int) {for {select {case <-ctx.Done():// 收到取消信号,退出fmt.Printf("任务%d: 收到取消信号,退出\n", id)returndefault:// 执行任务逻辑fmt.Printf("任务%d: 正在执行\n", id)time.Sleep(500 * time.Millisecond)}}
}func main() {// 创建可取消的上下文ctx, cancel := context.WithCancel(context.Background())// 启动3个任务for i := 1; i <= 3; i++ {go cancelableTask(ctx, i)}// 运行3秒后取消所有任务time.Sleep(3 * time.Second)fmt.Println("主程序:发送取消信号")cancel()// 等待任务退出time.Sleep(1 * time.Second)fmt.Println("主程序:退出")
}

context.Context不仅能用来取消操作,还能传递超时时间、截止时间和一些请求相关的值,是 Go 里管理 Goroutine 生命周期的标准方式,建议大家在实际项目中多运用。

五、异步编程的最佳实践

经过一段时间的实践,我总结了一些 Go 异步编程的最佳实践,分享给大家:

5.1 避免创建过多的 Goroutine

虽然 Goroutine 很轻量,但也不是越多越好。如果创建数百万个,还是会消耗大量内存和调度资源。对于大量任务,最好用工作池模式来控制并发数量。

5.2 始终处理 Goroutine 中的 Panic

这点特别重要,我之前就踩过坑。如果 Goroutine 里的 Panic 没被捕获,整个程序都会崩溃。所以每个 Goroutine 都应该加上 Panic 处理:

go func() {defer func() {if err := recover(); err != nil {// 记录错误日志log.Printf("Goroutine panic: %v", err)}}()// 业务逻辑代码
}()

5.3 正确关闭 Channel

关闭 Channel 有个原则:谁发送数据谁负责关闭。如果向已关闭的 Channel 发送数据,会导致 Panic,这点一定要注意。

5.4 使用 context 管理 Goroutine 生命周期

对于那些长时间运行的 Goroutine,用context.Context来管理能实现优雅的取消和超时控制,让程序更健壮。

5.5 避免阻塞主 Goroutine

主 Goroutine 是程序的入口,不能在里面执行耗时操作,也不能让它提前退出,否则其他 Goroutine 都会被终止。

5.6 合理设置 GOMAXPROCS

根据应用类型来调整GOMAXPROCS的值。CPU 密集型应用,一般设成 CPU 核心数就行;I/O 密集型应用,可以适当调大一些。

总结

这阵子学习 Go 异步编程,感觉它确实比传统的多线程或回调机制要直观得多。Goroutine、Channel 和调度器三者配合,形成了一套高效的并发解决方案。

我认为要掌握 Go 异步编程,首先得理解 Goroutine 的创建与同步、Channel 的使用方式以及调度器的工作原理。然后在实际开发中,灵活运用扇出 - 扇入、超时控制、工作池和取消机制这些模式,就能应对各种复杂的并发场景。

http://www.dtcms.com/a/473635.html

相关文章:

  • 基于贪心最小化包围盒策略的布阵算法
  • 《Python 异步数据库访问全景解析:从阻塞陷阱到高性能实践》
  • AI 自己造“乐高积木”:生成式 AI 设计可拼装模块化硬件的实战笔记
  • 10.11笔记
  • 冒泡排序的多种实现方式详解
  • 网页设计平面设计温州网站优化页面
  • 特别分享:聊聊Git
  • M|蝙蝠侠:侠影之谜
  • crawl4ai智能爬虫(一):playwright爬虫框架详解
  • 探究Java、C语言、Python、PHP、C#与C++在多线程编程中的核心差异与应用场景
  • 国外网站模板网站建设ui培训班好
  • 瑞安建设公司网站旅游网站系统的设计与实现
  • MongoDB-基本介绍(一)基本概念、特点、适用场景、技术选型
  • 国产之光金仓数据库,真能平替MongoDB?实测来了!
  • 网站开发需要学什么语言wordpress所有栏目循环输出
  • 低代码革命:拖拽式界面生成器与API网关的深度集成
  • “事件风暴 → 上下文映射 → 模块化”在 ABP vNext 的全链路模板
  • 如何在Linux服务器上部署jenkins?
  • 2.1 阵列信号处理基础
  • Centos7下docker的jenkins下载并配置jdk与maven
  • 网络数据侦探:抓包工具在爬虫开发中的艺术与科学
  • 手搓docker - 实现篇
  • soho做网站谷歌推广网站建设采购项目
  • 深入理解HTTP协议的本质
  • 以太网通信
  • 网站运营推广方式网站建设需要学编程么
  • 开源合规:GPL-3.0项目的专利风险规避
  • Java基于SpringBoot的医院门诊管理系统,附源码+文档说明
  • windows查询与设备通讯的mac地址
  • Tauri Android 开发踩坑实录:从 Gradle 版本冲突到离线构建成功