返回首页

Golang进阶:2.1 Goroutine 与并发基础

在 Go 语言的设计哲学里,并发不是附加能力,而是语言层面的核心特性。只要你开始写网络服务、任务处理程序、爬虫、消息消费程序,几乎都会和 Goroutine 打交道。

很多初学者第一次接触 Goroutine 时,会把它简单理解成“Go 里的线程”。这个理解不能说完全错误,但并不准确。Goroutine 并不是操作系统线程,它是一种由 Go 运行时管理的轻量级执行单元。理解这一点,是掌握 Go 并发编程的关键。

这一节我们围绕四个核心问题展开:

  • Goroutine 到底是什么,它为什么轻量
  • Goroutine 是如何创建、运行和结束的
  • 多个 Goroutine 如何协作等待完成
  • 并发程序为什么会出现数据竞争,以及如何用 -race 检测

一、Goroutine 原理:轻量级线程的调度模型

1.1 什么是 Goroutine

Goroutine 是 Go 运行时提供的并发执行单位。你可以把它理解成“由 Go 自己调度的用户态任务”。

在传统编程语言中,并发通常依赖操作系统线程。线程由操作系统内核负责创建、销毁和调度,成本相对较高:

  • 线程创建开销较大
  • 线程切换需要进入内核态
  • 每个线程通常会分配较大的栈空间
  • 线程数量过多时,调度成本会迅速上升

而 Goroutine 的目标,就是让开发者可以用非常低的成本创建成千上万个并发任务。它的“轻量”主要体现在两个方面:

  1. 初始栈空间很小:Goroutine 初始栈远小于传统线程栈,并且支持按需扩容。
  2. 调度由 Go 运行时完成:很多切换工作发生在用户态,不必每次都依赖操作系统内核调度。

这就是为什么在 Go 中,我们经常可以轻松启动几万甚至几十万个 Goroutine,而如果换成操作系统线程,往往很快就会遇到资源瓶颈。

1.2 Goroutine 与操作系统线程的本质区别

很多文章会说“Goroutine 是轻量级线程”,这句话更适合作为入门类比,而不能当成严格定义。

Goroutine 与操作系统线程的本质区别在于:

  • 线程:由操作系统内核调度,是内核级执行实体
  • Goroutine:由 Go 运行时调度,是用户态的并发任务

下面这张表可以帮助你快速建立对比:

对比项 Goroutine 操作系统线程
调度者 Go 运行时 操作系统内核
创建成本 很低 较高
初始栈空间 很小,按需增长 通常较大
切换成本 相对更低 相对更高
数量规模 可轻松创建大量 数量过多成本明显上升
使用方式 go 关键字启动 依赖系统线程库或语言封装

要特别注意:Goroutine 不是线程,它最终仍然需要依附在线程上执行。

也就是说,Go 并不是绕过了操作系统线程,而是在操作系统线程之上又抽象出了一层更轻量的调度模型。这个模型,就是我们经常提到的 GMP 调度模型

1.3 GMP 调度模型

GMP 是 Go 运行时调度器中的三个核心角色:

  • G(Goroutine):表示一个待执行的 Goroutine
  • M(Machine):表示一个真正的操作系统线程
  • P(Processor):表示调度所需的逻辑处理器,可以理解为运行 Go 代码的上下文资源

很多人第一次看到 GMP 会疑惑:为什么已经有线程 M 了,还要引入 P?

因为 Go 希望把“线程”与“执行 Go 代码所需的调度资源”拆开管理。M 是操作系统线程,但只有拿到 P,M 才能执行 Go 代码中的 G。这样做可以让 Go 运行时更高效地控制任务分发、局部队列和工作窃取。

GMP 的基本关系可以概括为:

  • G 是待执行任务
  • M 是实际执行者
  • P 负责把 G 交给 M 执行

一个简化理解如下:

  1. 程序中创建了很多个 G
  2. 每个 P 会维护一个本地可运行队列
  3. M 必须绑定一个 P,才能从队列中取出 G 执行
  4. 如果某个 P 的本地队列空了,它可以从全局队列或其他 P 那里“偷”任务

这种设计带来几个明显好处:

  • 减少全局锁竞争
  • 提高多核 CPU 的利用率
  • 让 Goroutine 的调度更灵活
  • 避免大量线程一对一映射造成的资源浪费

1.4 为什么说 Go 更擅长高并发

Go 高并发能力的关键并不只是因为 Goroutine“轻”,而是因为它同时具备:

  • 低成本的任务创建模型
  • 高效的运行时调度器
  • 面向并发设计的语言原生支持
  • 简洁的同步通信机制

换句话说,Goroutine 轻量只是表面,真正支撑高并发的是 轻量任务 + 运行时调度 + 同步机制 + 工具链支持 的整套体系。

1.5 示例:观察多个 Goroutine 并发执行

下面这个例子可以直观感受 Goroutine 的基本使用方式。程序会同时启动多个任务,并交错输出内容。

package main

import (
	"fmt"
	"time"
)

func worker(id int) {
	for i := 1; i <= 3; i++ {
		fmt.Printf("worker %d: 第 %d 次执行\n", id, i)
		time.Sleep(200 * time.Millisecond)
	}
}

func main() {
	go worker(1)
	go worker(2)
	go worker(3)

	// 主 Goroutine 稍等一会儿,避免程序直接退出
	time.Sleep(1 * time.Second)
	fmt.Println("main goroutine 结束")
}

运行后你会看到三个 worker 的输出顺序通常是交错的。这说明它们是并发执行的,而不是串行执行。

不过,这个例子也暴露出一个问题:我们现在只是靠 Sleep“硬等”,这并不是可靠的同步方式。后面讲 WaitGroup 时,我们会用更规范的办法来等待多个 Goroutine 完成。

二、Goroutine 的创建与生命周期

2.1 如何创建 Goroutine

Go 中创建 Goroutine 非常简单,只需要在函数调用前加上 go 关键字。

基本语法如下:

go 函数调用()

例如:

go task()
go func() {
	fmt.Println("匿名 Goroutine")
}()

当你写下 go task() 时,Go 运行时会把这个任务包装成一个新的 Goroutine,放入调度系统中等待执行。这里要注意,go 只是发起并发执行请求,不代表该函数会立刻执行完毕

2.2 main 函数与主 Goroutine

每个 Go 程序启动时,都会先创建一个主 Goroutine,它负责执行 main 函数。

这一点非常重要:main 函数结束时,整个进程就会退出,哪怕其他 Goroutine 还没执行完。

下面的代码展示了这个现象:

package main

import (
	"fmt"
	"time"
)

func main() {
	go func() {
		fmt.Println("子 Goroutine 启动")
		time.Sleep(500 * time.Millisecond)
		fmt.Println("子 Goroutine 准备结束")
	}()

	fmt.Println("main 函数结束")
}

这段程序很可能只输出:

main 函数结束

原因很简单:主 Goroutine 执行完 main 后,程序直接退出了,子 Goroutine 还没来得及完成。

2.3 Goroutine 的生命周期

一个 Goroutine 通常会经历以下几个阶段:

  1. 创建:通过 go 关键字启动
  2. 可运行:进入调度队列,等待被调度
  3. 运行中:被某个线程实际执行
  4. 阻塞:例如等待 channel、锁、系统调用或 I/O
  5. 结束:函数执行完成,Goroutine 生命周期结束

需要注意两点:

  • Goroutine 没有“手动销毁”的常规操作,函数返回后通常就自然结束
  • 如果 Goroutine 永久阻塞且无人唤醒,就可能造成 Goroutine 泄漏

所谓 Goroutine 泄漏,是指程序中某些 Goroutine 一直存在、一直占用资源,但实际上已经没有业务价值。这类问题在服务端程序里比较常见,尤其是在 channel、超时控制和取消机制设计不当时。

2.4 示例:观察 Goroutine 生命周期

下面的例子演示一个 Goroutine 从启动到结束的完整过程。

package main

import (
	"fmt"
	"time"
)

func job(name string) {
	fmt.Printf("%s: 创建后开始执行\n", name)
	for i := 1; i <= 3; i++ {
		fmt.Printf("%s: 正在执行第 %d 步\n", name, i)
		time.Sleep(300 * time.Millisecond)
	}
	fmt.Printf("%s: 执行结束\n", name)
}

func main() {
	fmt.Println("main: 启动 goroutine")
	go job("task-1")

	fmt.Println("main: 等待 task-1 执行完成")
	time.Sleep(2 * time.Second)
	fmt.Println("main: 程序结束")
}

这个例子虽然仍然使用了 Sleep,但它可以帮助你先建立直观认识:一个 Goroutine 被创建后,并不是必须立刻执行到底,它可能被调度、挂起、恢复,最终在函数返回时结束。

2.5 示例:批量创建多个 Goroutine

在实际项目中,我们经常会在循环里启动多个 Goroutine。这里有一个非常经典的示例。

package main

import (
	"fmt"
	"time"
)

func main() {
	for i := 1; i <= 5; i++ {
		go func(id int) {
			fmt.Printf("goroutine %d 启动\n", id)
			time.Sleep(200 * time.Millisecond)
			fmt.Printf("goroutine %d 结束\n", id)
		}(i)
	}

	time.Sleep(1 * time.Second)
}

这里要特别留意:匿名函数通过参数 id int 接收了循环变量 i 的当前值。这是并发编程中的一个常见写法,可以避免因为变量共享而产生意料之外的结果。

三、WaitGroup 同步

3.1 为什么需要 WaitGroup

前面我们已经看到,仅靠 time.Sleep() 等待 Goroutine 完成并不可靠:

  • 睡短了,任务可能还没做完
  • 睡长了,会平白浪费时间
  • 代码语义不清晰,维护性差

更合理的做法是:主 Goroutine 明确知道“我需要等几个任务完成”,等它们全部结束后再继续执行。为此,Go 标准库提供了 sync.WaitGroup

它的核心作用就是:等待一组 Goroutine 执行结束。

3.2 WaitGroup 的常用方法

WaitGroup 最常见的三个方法如下:

方法 作用
Add(n) 设置需要等待的任务数量
Done() 表示一个任务已经完成,内部计数减 1
Wait() 阻塞等待,直到计数归零

典型流程是:

  1. 启动若干个 Goroutine 之前,先调用 Add(n)
  2. 每个 Goroutine 完成时调用 Done()
  3. 主 Goroutine 调用 Wait() 阻塞等待

3.3 示例:使用 WaitGroup 等待多个任务完成

下面是一个完整可运行的示例。

package main

import (
	"fmt"
	"sync"
	"time"
)

func worker(id int, wg *sync.WaitGroup) {
	defer wg.Done()

	fmt.Printf("worker %d 开始工作\n", id)
	time.Sleep(time.Duration(id) * 300 * time.Millisecond)
	fmt.Printf("worker %d 工作完成\n", id)
}

func main() {
	var wg sync.WaitGroup

	for i := 1; i <= 3; i++ {
		wg.Add(1)
		go worker(i, &wg)
	}

	fmt.Println("main: 等待所有 worker 完成")
	wg.Wait()
	fmt.Println("main: 所有 worker 已完成")
}

这个例子里:

  • 每启动一个 Goroutine,就通过 wg.Add(1) 把等待计数加 1
  • 每个 worker 执行结束前,通过 defer wg.Done() 把计数减 1
  • 主 Goroutine 在 wg.Wait() 处阻塞,直到所有 worker 都完成

这才是多 Goroutine 协作中更规范的等待方式。

3.4 为什么 Done() 通常配合 defer

很多示例都会把 wg.Done() 写成:

defer wg.Done()

这是一个非常推荐的习惯。原因是只要函数中途出现多个返回路径,或者未来代码被修改后新增了 returndefer 都能保证 Done() 最终被执行。

如果忘记调用 Done(),那么 Wait() 就可能永远阻塞,程序看起来像“卡死”了一样。

3.5 WaitGroup 使用时的注意事项

WaitGroup 虽然简单,但也有几个常见坑:

常见问题 说明
Add 次数与 Done 次数不匹配 会导致程序提前结束或永久阻塞
在 Goroutine 内部才调用 Add 可能发生竞态,导致 Wait 提前返回
WaitGroup 被复制使用 官方明确不推荐,应该传指针或直接共享同一个实例
任务函数中忘记 Done Wait() 会一直等待

最稳妥的实践是:

  • 在启动 Goroutine 之前就调用好 Add
  • 在任务函数开头立刻 defer wg.Done()
  • 通过指针传递 *sync.WaitGroup

3.6 示例:WaitGroup 与匿名函数结合使用

下面再看一个更贴近日常编码的例子。

package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup

	urls := []string{"/home", "/about", "/contact"}

	for _, url := range urls {
		wg.Add(1)
		go func(path string) {
			defer wg.Done()
			fmt.Printf("开始处理请求: %s\n", path)
			fmt.Printf("处理完成: %s\n", path)
		}(url)
	}

	wg.Wait()
	fmt.Println("所有请求处理完毕")
}

这个写法在爬虫、批量任务处理、接口并发调用中都很常见。

四、并发安全问题与数据竞争检测(-race

4.1 什么是并发安全问题

并发并不天然等于高性能,也不天然等于正确性。

当多个 Goroutine 同时访问同一份共享数据,并且其中至少有一个操作是写操作时,如果没有做好同步控制,就可能出现并发安全问题。

这类问题最常见的表现有:

  • 统计结果不准确
  • 程序输出不稳定
  • 某些 bug 偶发、难以复现
  • 在压力增大后才暴露问题

并发程序最麻烦的地方在于:代码看起来没问题,但运行结果会因为执行时机不同而变化。

4.2 什么是数据竞争

数据竞争(Data Race)是并发安全问题中的典型代表。它通常满足以下条件:

  1. 多个 Goroutine 同时访问同一块内存
  2. 至少有一个是写操作
  3. 这些访问之间没有建立正确的同步关系

一旦出现数据竞争,程序行为就可能变得不可预测。

4.3 示例:一个典型的数据竞争问题

下面这个例子非常经典:多个 Goroutine 同时对同一个计数器做自增。

package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	count := 0

	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			count++
		}()
	}

	wg.Wait()
	fmt.Println("count =", count)
}

很多人第一次运行时,会以为结果一定是 1000。但实际上,你可能得到各种不同结果,比如 947986,甚至在某些环境下恰好是 1000

原因在于 count++ 并不是一个原子操作,它通常包含:

  1. 读取当前值
  2. 值加 1
  3. 写回内存

多个 Goroutine 同时执行这三个步骤时,就会互相覆盖,最终导致结果丢失。

4.4 使用互斥锁修复并发安全问题

解决共享数据并发写入问题,最常见的方法之一就是加锁。Go 标准库中的 sync.Mutex 可以保证同一时刻只有一个 Goroutine 进入临界区。

下面是修复后的版本:

package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	var mu sync.Mutex
	count := 0

	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()

			mu.Lock()
			count++
			mu.Unlock()
		}()
	}

	wg.Wait()
	fmt.Println("count =", count)
}

此时输出结果就会稳定为 1000

这里的关键思想是:共享数据可以访问,但访问必须被同步保护。

4.5 用 -race 检测数据竞争

Go 工具链非常实用的一点是,它内置了数据竞争检测器。你只需要在运行或测试时加上 -race 参数,就可以帮助发现潜在的数据竞争问题。

例如运行某个程序:

go run -race main.go

或者运行测试:

go test -race ./...

如果程序中存在数据竞争,Go 会在运行时给出详细告警,包括:

  • 哪个变量发生了竞争
  • 哪些 Goroutine 在读写它
  • 对应的代码位置

这对排查并发 bug 非常有帮助。

4.6 示例:配合 -race 观察数据竞争

你可以把下面这段代码保存为 main.go,然后执行 go run -race main.go

package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	value := 0

	for i := 0; i < 10; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			value++
		}()
	}

	wg.Wait()
	fmt.Println("value =", value)
}

在普通运行时,这段代码可能只是输出一个结果;但在 -race 模式下,运行时通常会提示这里存在数据竞争。

要注意:

  • -race 很适合开发和测试阶段使用
  • 它会带来额外性能开销,因此通常不用于正式生产运行
  • 它能帮助你发现问题,但不能自动修复问题

4.7 WaitGroup 解决不了数据竞争

这是一个非常容易混淆的点。

WaitGroup 只能解决“等谁完成”的问题,不能解决“共享数据是否安全”的问题。

比如下面这种情况,即便用了 WaitGroup,也依然可能有数据竞争:

package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	count := 0

	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			count++
		}()
	}

	wg.Wait()
	fmt.Println(count)
}

这里 WaitGroup 只能保证所有 Goroutine 都执行完了,但不能保证 count++ 的过程是安全的。

所以在并发编程中,必须分清两个问题:

  • 同步完成顺序:用 WaitGroup
  • 保护共享数据:用互斥锁、原子操作、channel 等机制

4.8 示例:同时使用 WaitGroup 与 Mutex

下面给出一个更完整的示例,展示两者如何配合使用。

package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	var mu sync.Mutex
	counter := 0

	for i := 0; i < 5; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()

			for j := 0; j < 1000; j++ {
				mu.Lock()
				counter++
				mu.Unlock()
			}

			fmt.Printf("goroutine %d 完成\n", id)
		}(i)
	}

	wg.Wait()
	fmt.Println("最终 counter =", counter)
}

这个程序里:

  • WaitGroup 负责等待 5 个 Goroutine 全部结束
  • Mutex 负责保护 counter 的并发写操作

二者职责不同,但在实际项目中经常一起使用。

五、理解并发,不要停留在“会写 go 关键字”

学到这里,你应该建立一个更清晰的认知:

  • Goroutine 是 Go 运行时管理的轻量级并发任务,不是操作系统线程本身
  • Goroutine 能高效运行,背后依赖的是 GMP 调度模型
  • go 关键字只是创建并发任务的入口,不代表自动处理生命周期管理
  • WaitGroup 用于等待多个任务完成,是最基础的并发同步工具之一
  • 并发程序最容易出错的地方在共享数据访问,-race 是排查问题的重要工具

如果只会写:

go doSomething()

那还只能算“会启动一个 Goroutine”。真正理解 Go 并发,关键在于你是否知道:

  • 它为什么轻量
  • 它如何被调度
  • 它何时结束
  • 它如何和其他 Goroutine 协作
  • 它什么时候会产生数据竞争

这些问题搞清楚之后,后续学习 channel、context、并发模式、worker pool 等内容时,你会轻松很多。

六、小结

本节我们系统梳理了 Go 并发编程的入门基础,重点包括:

  • Goroutine 原理:它是 Go 运行时管理的轻量级并发任务,核心优势来自小栈与用户态调度
  • GMP 模型:G、M、P 三者协同完成 Goroutine 的高效调度,这是理解 Go 并发的关键底层模型
  • Goroutine 生命周期:创建、调度、运行、阻塞、结束,主 Goroutine 退出会带走整个进程
  • WaitGroup 同步:用于等待多个 Goroutine 完成,是并发协作中的基础工具
  • 并发安全与 -race:共享数据访问不当会产生数据竞争,-race 是定位问题的实用利器

下一节我们会继续进入更核心的并发通信机制,讨论 channel 的设计思想与使用方式。到了那时,你会看到:Go 并发不仅强调“同时执行”,更强调“如何安全地协作”。


📝 版权声明:本文为原创技术博客,转载请注明出处。

如文章中存在错误或不准确之处,欢迎在评论区指正,感谢您的阅读与支持!

上一篇

Golang入门:1.9 包管理与模块化

下一篇

Golang进阶:2.2 Channel 深度解析