goroutine快速入门:多场景执行流程解析(附代码+流程图)
作为Go语言的核心特性,goroutine是实现轻量级并发的关键,它比传统操作系统线程更轻量、创建开销更低,默认初始栈仅2KB且支持动态扩容,能让开发者轻松实现高并发编程。对于刚接触Go并发的初学者来说,理解goroutine与主线程(可以理解成进程或者主协程)的执行关系,是入门的核心难点。
本文将从一个基础需求出发,延伸出4种不同场景(含未开启goroutine的情况),通过完整代码、可视化执行流程图,拆解goroutine与主线程的并发执行逻辑,帮你快速掌握goroutine的核心用法、调度特点,以及“开启协程”与“未开启协程”的本质区别。
一、基础需求回顾
核心需求:在主线程(可以理解成进程或者主协程)中开启一个goroutine,协程每隔1秒输出“hello,world”,主线程每隔1秒输出“hello:golang”,输出10次后退出程序,要求两者同时执行。
先看基础场景的完整代码,再逐步延伸其他场景,重点补充“未开启goroutine”的对比场景:
package main
import (
"fmt"
"strconv"
"time"
)
// test 函数:goroutine执行的逻辑,每隔1秒输出hello,world
func test() {
for i := 1; i <= 10; i++ {
fmt.Println("test() hello,world " + strconv.Itoa(i))
time.Sleep(time.Second) // 暂停1秒,模拟耗时操作
}
}
func main() {
// 开启goroutine:在go关键字后跟上要执行的函数,即可启动一个协程
go test()
// 主线程逻辑:每隔1秒输出hello:golang,执行10次
for i := 1; i <= 10; i++ {
fmt.Println("main() hello,golang " + strconv.Itoa(i))
time.Sleep(time.Second)
}
}
二、核心概念铺垫
在分析场景前,先明确2个关键知识点,避免理解偏差,尤其重点区分“开启协程”与“未开启协程”的核心差异:
- 主线程与协程:main函数启动后,会默认创建一个主线程;通过go关键字启动的是协程(goroutine)。Go程序的退出规则是:主线程退出后,所有协程会被强制终止,无论是否执行完毕,这是后续3种“开启协程”场景的核心逻辑基础;而未开启协程(去掉go关键字)时,会执行“同步调用”,即先执行完test函数,再执行main函数的后续逻辑,不存在“并发”。
- 并发执行本质:goroutine由Go运行时的调度器(GMP模型)管理,而非操作系统直接调度。GMP模型中,G(goroutine)是任务载体,M(machine)是操作系统线程,P(processor)是逻辑处理器,负责协调G和M的绑定与调度,通过工作窃取机制实现负载均衡,让主线程和协程看似“同时执行”,实际是调度器快速切换执行权的结果;未开启goroutine时,只有主线程在执行,程序按“顺序执行”逻辑推进,无调度切换。
- time.Sleep的作用:代码中time.Sleep(time.Second)最直观的作用,是让程序执行速度变慢,方便肉眼清晰观察并行执行的效果——毕竟协程调度切换速度极快,若没有休眠,控制台输出会瞬间刷屏,无法直观看到主协程与子协程的交替执行过程。除此之外,它更关键的作用是让当前协程(无论主协程还是子协程)主动、及时地让出CPU时间片,确保其他协程能快速获得执行权,这也是开启协程后,两者能“稳定实现同时执行”的核心。需要特别说明的是,不使用time.Sleep并非一定没有其他协程抢占执行权,取决于Go版本:Go 1.14之前仅支持协作式抢占,无阻塞操作(如time.Sleep、通道操作)时,当前协程会一直占用CPU,其他协程无法抢占;Go 1.14及之后引入异步抢占式调度,会强制抢占长时间运行的协程,但效果不如time.Sleep主动让出稳定,且无法实现“慢速输出、方便观察”的效果。未开启协程时,程序只有主协程在执行,sleep仅会让主协程的执行流程暂停,既无法实现并行观察,也不存在“其他协程抢占”的情况(因为没有其他协程)。
三、四种场景详解(代码+流程图+执行分析)
下面我们分四种场景,逐一分析goroutine与主线程的执行流程,重点补充“未开启goroutine”的场景,与其他开启协程的场景形成对比,帮你直观理解“开启协程”与“未开启协程”的本质区别。每种场景都包含完整代码、执行流程图和核心分析。
场景1:test()执行10次,main()执行10次(基础场景,开启goroutine)
- 完整代码
与基础需求代码一致,核心是协程和主线程的执行次数相同,均为10次,且每次执行后都休眠1秒,通过go test()开启协程。执行结果如下
/app/go-atguigu/goroutine-demo # go run main.go
main() hello,golang1
test () hello,world 1
main() hello,golang2
test () hello,world 2
test () hello,world 3
main() hello,golang3
main() hello,golang4
test () hello,world 4
test () hello,world 5
main() hello,golang5
test () hello,world 6
main() hello,golang6
main() hello,golang7
test () hello,world 7
test () hello,world 8
main() hello,golang8
main() hello,golang9
test () hello,world 9
test () hello,world 10
main() hello,golang10
/app/go-atguigu/goroutine-demo #
- 执行流程图
我们用流程图直观展示两者的执行顺序(箭头表示执行权切换,休眠后重新获取执行权):
流程图 TD
A[程序启动] --> B[主协程启动,执行go test()]
B --> C[子协程test()启动,进入就绪态]
B --> D[主协程继续执行自身for循环]
C --> E[子协程输出hello,world 1]
D --> F[主协程输出hello:golang 1]
E --> G[子协程休眠1秒,让出CPU,进入等待态]
F --> H[主协程休眠1秒,让出CPU,进入等待态]
G --> I[子协程休眠结束,进入就绪态,等待调度]
H --> J[主协程休眠结束,进入就绪态,等待调度]
I --> K[调度器分配执行权,子协程输出hello,world 2]
J --> L[调度器分配执行权,主协程输出hello:golang 2]
K --> M[子协程休眠1秒]
L --> N[主协程休眠1秒]
M --> O[重复执行,直到两者都执行10次]
N --> O
O --> P[主协程执行完毕,退出]
O --> Q[子协程执行完毕,退出]
P --> R[程序终止]
Q --> R
- 执行分析
- 启动逻辑:程序启动后,主线程先执行go test(),启动协程(此时协程进入就绪态,等待调度),随后主线程继续执行自身的for循环。
- 并发逻辑:由于两者都有time.Sleep(time.Second),每次输出后都会主动让出CPU,调度器会交替将执行权分配给主线程和协程,因此控制台会交替输出“hello,world X”和“hello:golang X”(顺序可能略有差异,由调度器决定,属于正常现象)。
- 退出逻辑:当主线程和协程都执行完10次循环后,两者均正常退出,程序终止。因为两者执行次数相同、休眠时间一致,大概率会同时执行完毕。
- 关键注意:输出顺序并非绝对固定,因为goroutine的调度由Go运行时控制,而非我们手动控制——调度器会根据系统资源和任务情况分配执行权,可能出现主线程连续输出2次、协程再连续输出2次的情况,但整体会保持“每隔1秒交替输出”的规律,这正是GMP调度模型中工作窃取和抢占式调度的体现。
场景2:test()执行20次,main()执行10次(协程执行次数更多,开启goroutine)
- 完整代码
仅修改test函数的循环次数为20次,主线程仍为10次,其余逻辑不变,通过go test()开启协程:
package main
import (
"fmt"
"strconv"
"time"
)
// test()执行20次
func test() {
for i := 1; i <= 20; i++ {
fmt.Println("test() hello,world " + strconv.Itoa(i))
time.Sleep(time.Second)
}
}
func main() {
go test() // 开启goroutine
// main()执行10次
for i := 1; i <= 10; i++ {
fmt.Println("main() hello,golang " + strconv.Itoa(i))
time.Sleep(time.Second)
}
}
- 执行流程图
流程图 TD
A[程序启动] --> B[主协程启动,执行go test()]
B --> C[子协程test()启动,就绪态]
B --> D[主协程执行自身for循环]
C --> E[子协程输出hello,world 1]
D --> F[主协程输出hello:golang 1]
E --> G[子协程休眠1秒,让出CPU]
F --> H[主协程休眠1秒,让出CPU]
G --> I[子协程就绪,等待调度]
H --> J[主协程就绪,等待调度]
I --> K[子协程输出hello,world 2]
J --> L[主协程输出hello:golang 2]
K --> M[子协程休眠]
L --> N[主协程休眠]
M --> O[重复执行,直到主协程执行10次]
N --> O
O --> P[主协程执行完毕,退出]
P --> Q[子协程被强制终止(未执行完20次)]
Q --> R[程序终止]
- 执行分析
- 前10次执行:与场景1完全一致,主线程和协程交替输出,每次输出后休眠1秒,调度器交替分配执行权。
- 关键转折点:当主线程执行完第10次循环后,会直接退出。此时协程仅执行了10次(与主线程执行次数一致,因为每次休眠时间相同),还剩余10次未执行。
- 退出逻辑(重点):根据Go的协程规则,主线程是程序的“入口”,主线程退出后,无论协程是否执行完毕,都会被强制终止,因此协程无法继续执行剩余的10次输出,程序直接终止。这也是Go并发编程中容易踩坑的点——若需协程执行完毕,需使用sync.WaitGroup等同步机制。
- 现象验证:运行代码后,控制台会交替输出10组“hello,world X”和“hello:golang X”,之后程序直接退出,不会出现“hello,world 11”及以后的输出。执行结果如下
/app/go-atguigu/goroutine-demo # go run main.go
main() hello,golang1
test () hello,world 1
test () hello,world 2
main() hello,golang2
test () hello,world 3
main() hello,golang3
main() hello,golang4
test () hello,world 4
main() hello,golang5
test () hello,world 5
test () hello,world 6
main() hello,golang6
test () hello,world 7
main() hello,golang7
main() hello,golang8
test () hello,world 8
main() hello,golang9
test () hello,world 9
main() hello,golang10
test () hello,world 10
test () hello,world 11
/app/go-atguigu/goroutine-demo # go run main.go
main() hello,golang1
test () hello,world 1
test () hello,world 2
main() hello,golang2
main() hello,golang3
test () hello,world 3
test () hello,world 4
main() hello,golang4
main() hello,golang5
test () hello,world 5
test () hello,world 6
main() hello,golang6
main() hello,golang7
test () hello,world 7
main() hello,golang8
test () hello,world 8
main() hello,golang9
test () hello,world 9
main() hello,golang10
test () hello,world 10
/app/go-atguigu/goroutine-demo #
注:其中一次执行结果输出了 hello,world 11 。小概率事件,或者说 执行结果大概率不会输出 “hello,world 12”及以后的输出。以下是一个简单的执行示意图1

场景3:test()执行10次,main()执行100次(主线程执行次数更多,开启goroutine)
- 完整代码
修改main函数的循环次数为100次,协程仍为10次,其余逻辑不变,通过go test()开启协程:
package main
import (
"fmt"
"strconv"
"time"
)
// test()执行10次
func test() {
for i := 1; i <= 10; i++ {
fmt.Println("test() hello,world " + strconv.Itoa(i))
time.Sleep(time.Second)
}
}
func main() {
go test() // 开启goroutine
// main()执行20次
for i := 1; i <= 20; i++ {
fmt.Println("main() hello,golang " + strconv.Itoa(i))
time.Sleep(time.Second)
}
}
- 执行流程图
流程图 TD
A[程序启动] --> B[主协程启动,执行go test()]
B --> C[子协程test()启动,就绪态]
B --> D[主协程执行自身for循环]
C --> E[子协程输出hello,world 1]
D --> F[主协程输出hello:golang 1]
E --> G[子协程休眠1秒,让出CPU]
F --> H[主协程休眠1秒,让出CPU]
G --> I[子协程就绪,等待调度]
H --> J[主协程就绪,等待调度]
I --> K[子协程输出hello,world 2]
J --> L[主协程输出hello:golang 2]
K --> M[子协程休眠]
L --> N[主协程休眠]
M --> O[重复执行,直到子协程执行10次]
N --> O
O --> P[子协程执行完毕,正常退出]
P --> Q[主协程继续执行剩余90次循环]
Q --> R[主协程执行完毕,退出]
R --> S[程序终止]
- 执行分析
- 前10次执行:与场景1、场景2一致,主线程和协程交替输出,调度器交替分配执行权,两者同步执行。
- 关键转折点:当协程执行完第10次循环后,会正常退出(协程执行完毕后,会被Go运行时自动回收,释放资源)。此时主线程仅执行了10次,还剩余90次未执行。
- 后续执行:协程退出后,主线程会继续执行剩余的90次循环,此时程序中只有主线程在运行,控制台会持续输出“hello:golang X”(X从11到100),直到主线程执行完毕。
- 核心区别:协程的退出不会影响主线程的执行——主线程会一直执行到自身循环结束,程序才会终止。这与场景2形成鲜明对比,核心原因是:主线程是程序的核心,协程是主线程启动的“附属任务”,协程退出不影响主线程,而主线程退出会终止所有协程,这也是由Go的协程生命周期管理规则决定的。执行结果如下
/app/go-atguigu/goroutine-demo # go run main.go
main() hello,golang1
test () hello,world 1
main() hello,golang2
test () hello,world 2
main() hello,golang3
test () hello,world 3
main() hello,golang4
test () hello,world 4
main() hello,golang5
test () hello,world 5
main() hello,golang6
test () hello,world 6
main() hello,golang7
test () hello,world 7
main() hello,golang8
test () hello,world 8
main() hello,golang9
test () hello,world 9
main() hello,golang10
test () hello,world 10
main() hello,golang11
main() hello,golang12
main() hello,golang13
main() hello,golang14
main() hello,golang15
main() hello,golang16
main() hello,golang17
main() hello,golang18
main() hello,golang19
main() hello,golang20
/app/go-atguigu/goroutine-demo #
场景4:test()执行10次,main()执行10次(未开启goroutine,去掉go关键字)
- 完整代码
核心修改:去掉go关键字,直接调用test()函数,不开启协程,test()和main()的执行次数均为10次,其余逻辑不变,重点对比“同步执行”与“并发执行”的差异:
package main
import (
"fmt"
"strconv"
"time"
)
// test()执行10次,无go关键字,同步执行
func test() {
for i := 1; i <= 10; i++ {
fmt.Println("test() hello,world " + strconv.Itoa(i))
time.Sleep(time.Second) // 暂停1秒,仅暂停当前执行流程
}
}
func main() {
test() // 去掉go关键字,不开启goroutine,同步调用test()
// 主线程逻辑:只有test()执行完毕后,才会执行此处
for i := 1; i <= 10; i++ {
fmt.Println("main() hello,golang " + strconv.Itoa(i))
time.Sleep(time.Second)
}
}
- 执行流程图
未开启goroutine时,程序按“顺序执行”逻辑推进,无执行权切换,流程图如下:
流程图 TD
A[程序启动] --> B[主协程启动,执行test()(无go关键字)]
B --> C[test()输出hello,world 1]
C --> D[test()休眠1秒,程序暂停]
D --> E[test()继续输出hello,world 2]
E --> F[test()休眠1秒]
F --> G[重复执行,直到test()执行完10次]
G --> H[test()执行完毕,主协程继续执行自身for循环]
H --> I[main()输出hello:golang 1]
I --> J[main()休眠1秒,程序暂停]
J --> K[main()输出hello:golang 2]
K --> L[main()休眠1秒]
L --> M[重复执行,直到main()执行完10次]
M --> N[主协程执行完毕,退出]
N --> O[程序终止]
执行分析(重点对比开启协程的场景)
- 启动逻辑:程序启动后,主线程直接执行test()(无go关键字),此时没有协程被创建,程序只有主线程在执行,且会先完整执行test()函数,再执行main()函数的for循环,属于“同步调用”。
- 执行逻辑(与开启协程的核心区别):无并发效果,执行顺序完全固定——先连续输出10次“hello,world X”(每次间隔1秒),待test()函数完全执行完毕后,才会开始输出“hello:golang X”(每次间隔1秒),不会出现“交替输出”的情况。因为没有协程,不存在调度器切换执行权,程序按代码编写顺序依次执行。
- 退出逻辑:test()执行完10次后,正常退出,主线程继续执行自身的10次循环,main()执行完毕后,程序终止。与开启协程的场景不同,此处不存在“主线程提前退出导致协程终止”的情况,因为没有协程,所有逻辑都在主线程中顺序执行。
- 核心对比总结:开启goroutine(加go)→ 并发执行(主线程与协程交替执行,调度器分配执行权);未开启goroutine(去go)→ 同步执行(主线程顺序执行test()和自身逻辑,无调度切换)。这是goroutine最基础的使用区别,也是理解“并发”与“同步”的关键。执行结果如下
/app/go-atguigu/goroutine-demo # go run main.go
main() hello,golang1
test () hello,world 1
main() hello,golang2
test () hello,world 2
main() hello,golang3
test () hello,world 3
main() hello,golang4
test () hello,world 4
main() hello,golang5
test () hello,world 5
main() hello,golang6
test () hello,world 6
main() hello,golang7
test () hello,world 7
main() hello,golang8
test () hello,world 8
main() hello,golang9
test () hello,world 9
main() hello,golang10
test () hello,world 10
main() hello,golang11
main() hello,golang12
main() hello,golang13
main() hello,golang14
main() hello,golang15
main() hello,golang16
main() hello,golang17
main() hello,golang18
main() hello,golang19
main() hello,golang20
/app/go-atguigu/goroutine-demo #
四、核心总结与避坑指南
通过以上4种场景的对比(3种开启goroutine、1种未开启),我们可以总结出goroutine的核心特性、“开启与未开启”的本质区别,以及入门避坑要点,帮你快速掌握goroutine的使用逻辑:
- 核心特性与开启/未开启的区别
- 轻量高效:goroutine由Go运行时调度,初始栈仅2KB,创建和销毁开销远低于操作系统线程,支持百万级并发;未开启goroutine时,仅主线程执行,无并发能力。
- 并发与同步的区别:开启goroutine(go test())→ 主线程与协程并发执行,调度器通过工作窃取机制分配执行权,出现交替输出;未开启goroutine(test())→ 同步执行,主线程先执行完test(),再执行自身逻辑,顺序固定,无交替输出。
- 生命周期规则:开启goroutine时,主线程主导程序退出(主线程退 → 协程强制终止),协程退出不影响主线程;未开启goroutine时,无主/协程区分,所有逻辑在主线程中顺序执行,执行完毕后程序终止。
- 入门避坑要点
- 避免“协程未执行完就退出”:若需要协程执行完毕后,主线程再退出,不能依赖“执行次数一致”,需使用sync.WaitGroup(后续会讲解),否则主线程提前退出会导致协程被强制终止,造成任务未完成或资源泄露问题。
- 理解“调度随机性”:开启goroutine后,执行顺序由调度器决定,并非固定交替,即使代码中两者休眠时间一致,也可能出现连续输出的情况(尤其是CPU资源紧张时),这是正常现象;未开启goroutine时,执行顺序完全固定,无随机性。
- 慎用无阻塞协程:若协程中没有time.Sleep等阻塞操作,调度器可能会让一个协程执行完毕后再执行另一个(Go 1.14+虽支持抢占式调度,但仍不建议依赖),可通过runtime.Gosched()主动让出CPU时间片,确保并发效果;未开启goroutine时,无此问题。
- 区分“go关键字的作用”:go关键字是开启goroutine的核心,去掉go则变为普通的同步函数调用,失去并发能力,这是初学者最容易混淆的点,务必通过代码实操对比记忆。
五、后续学习方向
本文通过4种场景,帮你掌握了goroutine的基础用法、执行流程,以及“开启协程”与“未开启协程”的本质区别,但这只是Go并发的入门。后续可以继续学习:
- sync.WaitGroup:实现主线程等待协程执行完毕,解决场景2中“协程未执行完被终止”的问题。
- sync.Mutex:解决多个goroutine同时操作同一个变量时的“竞态条件”问题,避免数据错乱。
- channel:Go语言中“通信优于共享内存”的核心机制,实现goroutine之间的安全通信和同步。
- GMP调度模型深入:理解G、M、P的协同工作机制,以及工作窃取、抢占式调度的底层原理,提升并发程序性能优化能力。
goroutine是Go语言的灵魂,掌握它的基础执行逻辑,以及“开启与未开启”的区别,能为后续高并发编程打下坚实的基础。建议大家多动手运行本文中的代码,观察不同场景下的输出结果,结合流程图理解调度逻辑,才能真正吃透goroutine的用法~