Go 的 goroutine 到底怎么启动?一个最小例子看懂并发

编程狮(w3cschool.cn) 2026-10-15 07:07:55 浏览数 (70)
反馈

在 Go 里启动一个并发任务,只要在函数调用前加一个 go 关键字:go doWork()。这个被 go 修饰的函数会在一个轻量的 goroutine 里并发执行,主流程不会被它阻塞。本文用一个最小例子把并发的启动和收尾讲清楚。

Go goroutine 启动流程图

你写一个程序,想一边打印日志一边做主业务,不想让日志把主流程卡住。这时候就用到 Go 的并发:用 go 把日志工作丢到后台跑。但很多人第一次写,程序一运行就「啥也没打印就退出了」,因为主函数跑完就结束了,后台任务还没来得及执行。今天这篇文章,编程狮就把 goroutine 启动这件事讲透。

一、先弄清 goroutine 是什么

goroutine 是 Go 运行时管理的轻量线程,比操作系统线程更省内存,一个程序可以同时跑成千上万个。你不用自己管理线程池,只要在调用前写 go,Go 调度器会替你安排它在哪个系统线程上跑。

goroutine 采用的是 M:N 调度模型:M 个 goroutine 被复用到 N 个操作系统线程上,由 Go 运行时的调度器(G-M-P 模型)自动在可用线程间迁移。正因为这一层调度,单个 goroutine 的初始栈只有几 KB,需要时才动态扩容,所以你能轻松起上万个,而不像系统线程那样很快耗尽内存。

⚠️ 注意:主函数 main 结束时,整个程序就退出了,不管还有多少 goroutine 没跑完。所以「主函数退太快」是新手第一个坑。

如果还没装好 Go 环境,可以先过一遍 Go 教程,把基础语法过一遍再回来。

二、方案一:最小启动,加 go 即可

最简单的 goroutine,就是在一个普通函数前加 go。下面这个例子里,后台任务会并发地和主流程一起跑。

package main

import (
    "fmt"
    "time"
)

func say() {
    fmt.Println("后台任务执行了")
}

func main() {
    go say()
    fmt.Println("主流程继续")
    time.Sleep(100 * time.Millisecond)
}

上面这段做了什么:第 13 行 go say() 把 say 丢到后台 goroutine;主流程立刻打印「主流程继续」,然后 time.Sleep 稍微等一下,让后台任务有机会执行。执行后你应该看到两行输出,顺序可能因调度而不同。这就是 Go 并发最直观的样子。

要注意 time.Sleep 在这里只是「给后台任务留点时间」的演示手段,它并不能保证任务一定跑完——任务万一比 Sleep 久,照样会漏执行。生产代码里它只能出现在示例里,真正要等就得用后面的 WaitGroup 或 channel。

想看更底层的调度细节,Go Internals 教程 值得深入。

Go goroutine 启动与等待流程图

三、方案二:用匿名函数启动后台任务

很多时候任务只在这一个地方用一次,没必要单独定义函数,可以直接写匿名函数。

func main() {
    go func(msg string) {
        fmt.Println(msg)
    }("来自后台")
    fmt.Println("主流程")
    time.Sleep(100 * time.Millisecond)
}

注意参数要写在 () 里传进去。匿名函数体里如果引用了外部变量,要小心多个 goroutine 同时改同一个变量带来的竞态,后面会讲。涉及 Web 框架时,Goframe 教程 里也能看到 goroutine 在请求处理中的实际用法。

顺带提一个高频坑:匿名函数里如果直接引用循环变量(比如 for i := 0; i < 3; i++ { go func(){ fmt.Println(i) }() }),所有 goroutine 可能打印出同一个最终值,因为 i 是同一份变量、被并发读取。正确做法是把 i 作为参数传进闭包,像上面示例那样 (i) 传入,让每个 goroutine 拿到自己的副本。

四、方案三:用 WaitGroup 等所有任务收尾

time.Sleep 只是演示用,真实项目里你不知道任务要跑多久。更稳妥的是用 sync.WaitGroup,主流程显式等一组 goroutine 全部完成。

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 3; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            fmt.Println("任务", id, "完成")
        }(i)
    }
    wg.Wait()
}
方式 等待机制 适合场景
go f() + Sleep 粗略延时 仅演示、确定很快
sync.WaitGroup 精确等全部完成 真实并发任务
channel 通信 传递结果与信号 需要取回返回值

WaitGroup 的 Add 在启动前调用,Done 在任务结束调用,Wait 阻塞主流程直到计数归零,比盲等 Sleep 可靠得多。

如果除了「等完成」还想「拿回结果」,channel 更合适:开一个 chan string,每个 goroutine 结束时往里写一句结果,主流程用 for r := range ch 收齐。它和 WaitGroup 不冲突,常常配合着用——WaitGroup 管「何时全完」,channel 管「传递什么」,大型并发任务基本都建立在这两套原语之上。

五、踩坑清单

三个最常翻车的地方,每个给现象、原因和办法。

  • 现象:程序一运行就结束,后台什么都没打印。原因:main 先退出,goroutine 没机会跑。解决:用 WaitGroup 等,而不是靠 Sleep 猜时间。
  • 现象:多个 goroutine 改同一个变量,结果时好时坏。原因:发生了数据竞态。解决:用 sync.Mutex 加锁,或改用 channel 传递数据。
  • 现象:panic 在 goroutine 里没被主流程捕获,程序直接崩。原因:goroutine 的 recover 要写在它自己内部。解决:在 goroutine 函数里用 defer recover() 兜底。

验证最直接:把任务数改成 5,用 WaitGroup 等待,确认 5 行输出都出现且不丢不重。预期结果(未在本机执行)是主流程在所有后台任务完成后才退出。

动手前先 检查 一下本地 Go 版本是否够新;运行后如果某个 goroutine 内部 失败 且没做 recover,整个程序会直接崩,所以后台任务一定要兜底。把这些并发细节想清楚,再把它用进真实项目。

总结

Go 的并发靠 go 关键字启动 goroutine,轻量又简单。最小例子是 go f(),但生产里要用 WaitGroup 或 channel 等任务真正收尾,别靠 Sleep 猜测时间。同时小心主函数退出过早和共享变量的竞态。

要点带走:

  • go 关键字让函数并发执行;
  • main 退出会终结所有 goroutine,必须显式等待;
  • 共享数据要加锁或用 channel,避免竞态。

下一步可以看 channel 怎么在 goroutine 之间传递数据和信号,把并发协作写得更地道;掌握了 channel 之后,select 多路复用、超时控制、context 取消这类进阶模式也就能顺理成章地用起来了。

延伸学习

想把这块知识系统补齐,可以按这个顺序来:

  1. 先过一遍 Go 语言中的 select 语句并发精华,看多路并发怎么调度;
  2. 涉及共享变量保护可翻 Golang 原子操作保证并发安全,理解无锁并发的边界。

常见问题

Q:为什么加了 go 什么都没打印?

A:因为主函数 main 一结束整个程序就退出,后台 goroutine 还没轮到执行。要用 WaitGroup 等它完成,或在演示里用 Sleep 留出时间,但生产环境别靠猜时间。

Q:goroutine 和线程有什么区别?

A:goroutine 是 Go 运行时调度的轻量协程,初始栈很小、可以动态增长,成千上万个也不吃力;系统线程由操作系统管理,创建和切换成本更高。Go 调度器会把 goroutine 复用到少量系统线程上。

Q:多个 goroutine 改同一个变量安全吗?

A:不安全,会产生数据竞态,结果不可预测。应当用 sync.Mutex 加锁保护,或者通过 channel 把数据传递出去,让同一份数据只被一个 goroutine 修改。

Go

0 人点赞