没有不值得去解决的问题,也没有不值得去学习的技术!

goroutine快速入门:多场景执行流程解析(附代码+流程图)

其中一次执行结果输出了 hello,world 11 。小概率事件,或者说 执行结果大概率不会输出 “hello,world 12”及以后的输出。以下是一个简单的执行示意图1

作者:

, ,

Go 并发编程实战步步通

其中一次执行结果输出了 hello,world 11 。小概率事件,或者说 执行结果大概率不会输出 “hello,world 12”及以后的输出。以下是一个简单的执行示意图1

(1) goroutine快速入门:多场景执行流程解析(附代码+流程图)

运行后,控制台要么打印空结果,要么只打印少量结果,大部分阶乘结果丢失。

(2) Go并发学习:从阶乘案例看Channel的必要性

测试代码(故意混用类型,验证报错) 测试结果(编译报错):如图1

(3) Go学习笔记:Channel基础用法与4种核心注意事项(附代码测试)

我运行了上述代码,整理出核心运行结果(截取部分),方便自己后续回顾:如图1

(4) Go复习笔记:Goroutine与Channel协同工作(解决加锁加休眠隐患)

Go 语言通道阻塞深度解析:从 Deadlock 到异步处理

(5) Go 语言通道阻塞深度解析:从 Deadlock 到异步处理

在我的测试环境(2 核 CPU)下,统计 1-200,000 的素数,结果如下:如图1 - 传统串行实现:耗时约 25 秒。 - 多协程并发实现:耗时约 12 秒。

(6) Go 语言并发实战:素数统计性能优化对比(“传统串行实现”与“多协程并发实现”)

运行结果:如图1

(7) Go 语言复习:使用 select 解决 Channel 读取阻塞问题

运行结果:如图1

(8) Go 语言中 Goroutine 的 Panic 捕获与 recover 机制

作为Go语言的核心特性,goroutine是实现轻量级并发的关键,它比传统操作系统线程更轻量、创建开销更低,默认初始栈仅2KB且支持动态扩容,能让开发者轻松实现高并发编程。对于刚接触Go并发的初学者来说,理解goroutine与主线程(可以理解成进程或者主协程)的执行关系,是入门的核心难点。
本文将从一个基础需求出发,延伸出4种不同场景(含未开启goroutine的情况),通过完整代码、可视化执行流程图,拆解goroutine与主线程的并发执行逻辑,帮你快速掌握goroutine的核心用法、调度特点,以及“开启协程”与“未开启协程”的本质区别。

一、基础需求回顾
核心需求:在主线程(可以理解成进程或者主协程)中开启一个goroutine,协程每隔1秒输出“hello,world”,主线程每隔1秒输出“hello:golang”,输出10次后退出程序,要求两者同时执行。
先看基础场景的完整代码,再逐步延伸其他场景,重点补充“未开启goroutine”的对比场景:

Go
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个关键知识点,避免理解偏差,尤其重点区分“开启协程”与“未开启协程”的核心差异:

  1. 主线程与协程:main函数启动后,会默认创建一个主线程;通过go关键字启动的是协程(goroutine)。Go程序的退出规则是:主线程退出后,所有协程会被强制终止,无论是否执行完毕,这是后续3种“开启协程”场景的核心逻辑基础;而未开启协程(去掉go关键字)时,会执行“同步调用”,即先执行完test函数,再执行main函数的后续逻辑,不存在“并发”。
  2. 并发执行本质:goroutine由Go运行时的调度器(GMP模型)管理,而非操作系统直接调度。GMP模型中,G(goroutine)是任务载体,M(machine)是操作系统线程,P(processor)是逻辑处理器,负责协调G和M的绑定与调度,通过工作窃取机制实现负载均衡,让主线程和协程看似“同时执行”,实际是调度器快速切换执行权的结果;未开启goroutine时,只有主线程在执行,程序按“顺序执行”逻辑推进,无调度切换。
  3. 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)

  1. 完整代码
    与基础需求代码一致,核心是协程和主线程的执行次数相同,均为10次,且每次执行后都休眠1秒,通过go test()开启协程。执行结果如下
Plaintext
/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 # 
  1. 执行流程图
    我们用流程图直观展示两者的执行顺序(箭头表示执行权切换,休眠后重新获取执行权):
flowchart 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
  1. 执行分析
  • 启动逻辑:程序启动后,主线程先执行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)

  1. 完整代码
    仅修改test函数的循环次数为20次,主线程仍为10次,其余逻辑不变,通过go test()开启协程:
Go
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)
	}
}
  1. 执行流程图
flowchart 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["程序终止"]
  1. 执行分析
  • 前10次执行:与场景1完全一致,主线程和协程交替输出,每次输出后休眠1秒,调度器交替分配执行权。
  • 关键转折点:当主线程执行完第10次循环后,会直接退出。此时协程仅执行了10次(与主线程执行次数一致,因为每次休眠时间相同),还剩余10次未执行。
  • 退出逻辑(重点):根据Go的协程规则,主线程是程序的“入口”,主线程退出后,无论协程是否执行完毕,都会被强制终止,因此协程无法继续执行剩余的10次输出,程序直接终止。这也是Go并发编程中容易踩坑的点——若需协程执行完毕,需使用sync.WaitGroup等同步机制。
  • 现象验证:运行代码后,控制台会交替输出10组“hello,world X”和“hello:golang X”,之后程序直接退出,不会出现“hello,world 11”及以后的输出。执行结果如下
Plaintext
/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

其中一次执行结果输出了 hello,world 11 。小概率事件,或者说 执行结果大概率不会输出 “hello,world 12”及以后的输出。以下是一个简单的执行示意图1

场景3:test()执行10次,main()执行100次(主线程执行次数更多,开启goroutine)

  1. 完整代码
    修改main函数的循环次数为100次,协程仍为10次,其余逻辑不变,通过go test()开启协程:
Go
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)
	}
}
  1. 执行流程图
flowchart 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["程序终止"]
  1. 执行分析
  • 前10次执行:与场景1、场景2一致,主线程和协程交替输出,调度器交替分配执行权,两者同步执行。
  • 关键转折点:当协程执行完第10次循环后,会正常退出(协程执行完毕后,会被Go运行时自动回收,释放资源)。此时主线程仅执行了10次,还剩余90次未执行。
  • 后续执行:协程退出后,主线程会继续执行剩余的90次循环,此时程序中只有主线程在运行,控制台会持续输出“hello:golang X”(X从11到100),直到主线程执行完毕。
  • 核心区别:协程的退出不会影响主线程的执行——主线程会一直执行到自身循环结束,程序才会终止。这与场景2形成鲜明对比,核心原因是:主线程是程序的核心,协程是主线程启动的“附属任务”,协程退出不影响主线程,而主线程退出会终止所有协程,这也是由Go的协程生命周期管理规则决定的。执行结果如下
Plaintext
/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关键字)

  1. 完整代码
    核心修改:去掉go关键字,直接调用test()函数,不开启协程,test()和main()的执行次数均为10次,其余逻辑不变,重点对比“同步执行”与“并发执行”的差异:
Go
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)
	}
}
  1. 执行流程图
    未开启goroutine时,程序按“顺序执行”逻辑推进,无执行权切换,流程图如下:
flowchart 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最基础的使用区别,也是理解“并发”与“同步”的关键。执行结果如下
Plaintext
/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的使用逻辑:

  1. 核心特性与开启/未开启的区别
  • 轻量高效:goroutine由Go运行时调度,初始栈仅2KB,创建和销毁开销远低于操作系统线程,支持百万级并发;未开启goroutine时,仅主线程执行,无并发能力。
  • 并发与同步的区别:开启goroutine(go test())→ 主线程与协程并发执行,调度器通过工作窃取机制分配执行权,出现交替输出;未开启goroutine(test())→ 同步执行,主线程先执行完test(),再执行自身逻辑,顺序固定,无交替输出。
  • 生命周期规则:开启goroutine时,主线程主导程序退出(主线程退 → 协程强制终止),协程退出不影响主线程;未开启goroutine时,无主/协程区分,所有逻辑在主线程中顺序执行,执行完毕后程序终止。
  1. 入门避坑要点
  • 避免“协程未执行完就退出”:若需要协程执行完毕后,主线程再退出,不能依赖“执行次数一致”,需使用sync.WaitGroup(后续会讲解),否则主线程提前退出会导致协程被强制终止,造成任务未完成或资源泄露问题。
  • 理解“调度随机性”:开启goroutine后,执行顺序由调度器决定,并非固定交替,即使代码中两者休眠时间一致,也可能出现连续输出的情况(尤其是CPU资源紧张时),这是正常现象;未开启goroutine时,执行顺序完全固定,无随机性。
  • 慎用无阻塞协程:若协程中没有time.Sleep等阻塞操作,调度器可能会让一个协程执行完毕后再执行另一个(Go 1.14+虽支持抢占式调度,但仍不建议依赖),可通过runtime.Gosched()主动让出CPU时间片,确保并发效果;未开启goroutine时,无此问题。
  • 区分“go关键字的作用”:go关键字是开启goroutine的核心,去掉go则变为普通的同步函数调用,失去并发能力,这是初学者最容易混淆的点,务必通过代码实操对比记忆。

五、后续学习方向
本文通过4种场景,帮你掌握了goroutine的基础用法、执行流程,以及“开启协程”与“未开启协程”的本质区别,但这只是Go并发的入门。后续可以继续学习:

  1. sync.WaitGroup:实现主线程等待协程执行完毕,解决场景2中“协程未执行完被终止”的问题。
  2. sync.Mutex:解决多个goroutine同时操作同一个变量时的“竞态条件”问题,避免数据错乱。
  3. channel:Go语言中“通信优于共享内存”的核心机制,实现goroutine之间的安全通信和同步。
  4. GMP调度模型深入:理解G、M、P的协同工作机制,以及工作窃取、抢占式调度的底层原理,提升并发程序性能优化能力。
    goroutine是Go语言的灵魂,掌握它的基础执行逻辑,以及“开启与未开启”的区别,能为后续高并发编程打下坚实的基础。建议大家多动手运行本文中的代码,观察不同场景下的输出结果,结合流程图理解调度逻辑,才能真正吃透goroutine的用法~
Go并发学习:从阶乘案例看Channel的必要性

Go / Gin 后端接口维护与远程技术支持

如果你的 Go / Gin 后端接口、小型服务或内部系统需要维护、Bug 修复、接口排查、性能优化或小功能迭代,可以联系我。

适合以下情况:
✅ Go / Gin 接口报错或响应变慢
✅ 已有 Go 服务需要长期维护
✅ 后端 API 需要小功能迭代
✅ 定时任务、数据处理服务异常
✅ MySQL / Redis / Nginx 相关问题排查
✅ 需要远程兼职技术支持

可先从一次小问题开始:
✅ 接口异常排查
✅ 日志分析
✅ 性能瓶颈定位
✅ 部署环境检查
✅ 小功能评估与迭代

如需咨询,请联系我,并注明:Go / Gin 维护咨询

联系方式:
Telegram:@shuijingwan
微信:13980074657
邮箱:shuijingwanwq@gmail.com

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

这个站点使用 Akismet 来减少垃圾评论。了解你的评论数据如何被处理