目录
- 前置知识:厘清并发与并行
- [为什么选择 Goroutine?](#为什么选择 Goroutine?)
- [如何使用 Goroutine?](#如何使用 Goroutine?)
-
- [启动你的第一个 Goroutine](#启动你的第一个 Goroutine)
- [sync.WaitGroup 阻塞等待](#sync.WaitGroup 阻塞等待)
- 设置应用核数
- 实战对比:计算素数
- 总结

在当今这个追求高性能和高效率的时代,并发编程已成为软件开发者的必备技能。然而,传统的基于线程和锁的并发模型往往复杂且容易出错。Go 语言(Golang)从设计之初就将并发作为其核心特性,并提供了一种极其轻量级且强大的并发原语------Goroutine。
本博文将带你从最基础的概念开始,逐步深入 Goroutine 的世界,通过丰富的代码示例,让你真正掌握 Go 语言并发编程的魅力。
前置知识:厘清并发与并行
在深入 Goroutine 之前,我们必须先理解几个基本概念。
进程(Process)与线程(Thread)
- 进程: 是程序在操作系统中的一次执行过程,是系统进行资源分配和调度的基本单位。你可以把进程看作是一个正在运行的应用程序(比如你的浏览器或代码编辑器),它拥有自己独立的内存空间。
- 线程: 是进程的一个执行实例 ,是程序执行的最小单元,它比进程更小,是 CPU 调度的基本单位。一个进程可以包含多个线程,它们共享该进程的内存空间(如堆内存),但各自拥有独立的栈。
通俗地讲,一个"工厂"(进程)可以雇佣多个"工人"(线程)来共同完成任务。
并发(Concurrency)与并行(Parallelism)
- 并发: 逻辑上同时处理多个任务但是实际上交替执行 。在一个时间段内,多个任务都在推进,但不一定在同一时刻同时执行。好比你一个人在"同时"做饭、接电话和看孩子。你可能在切菜(任务A),电话响了,你暂停切菜去接电话(任务B),接完电话发现孩子哭了(任务C),你又去哄孩子,然后再回来继续切菜。并发的核心是任务的切换与调度。
- 并行: 指物理上同时执行 多个任务的能力。这需要多核处理器的支持。好比你有两个帮厨(多核),你(核心1)在切菜,同时你的帮厨(核心2)在烧水。并行的核心是同时执行。
总结一下:
- 并发: 多个线程竞争一个 CPU 核心,通过快速切换实现"看似同时"的执行。
- 并行: 多个线程分配在多个 CPU 核心上,实现"真正同时"的执行。
在多核 CPU 上,Go 语言的 Goroutine 可以同时实现并发与并行,这也是它强大的原因之一。
为什么选择 Goroutine?
传统的线程(OS 线程)由操作系统调度,创建和销毁的开销很大,上下文切换成本也很高。一个程序如果创建成千上万个线程,系统资源会迅速耗尽。
Go 语言引入了 Goroutine(协程),它带来了革命性的改变:
- 极其轻量: 一个
Goroutine的初始栈空间通常只有 2KB,而一个OS线程通常需要 1MB 甚至更多。这意味着你可以轻易地创建成千上万个Goroutine而不消耗太多系统资源。 - Go 运行时调度:
Goroutine由 Go 语言的运行时(runtime)调度器管理,而不是操作系统。这使得切换成本非常低,Go 调度器会智能地将 Goroutine (G) 调度到逻辑处理器 § 上,再由逻辑处理器绑定到 OS 线程 (M) 上执行。 - 使用简单: 开启一个
Goroutine异常简单,只需在函数调用前加上go关键字即可。
Goroutine 是 Go 语言实现高并发、高效率的基石。
如何使用 Goroutine?
启动你的第一个 Goroutine
go
package main
import "fmt"
func sayHello() {
fmt.Println("Hello from Goroutine!")
}
func main() {
go sayHello() // 启动一个协程
fmt.Println("Hello from main function!")
}
上述代码,我们通过 go 命令创建了一个协程 sayHello 并在其中设置打印内容。但是实际上,当你运行这段代码时,很可能会发现这个协程并没有执行,为什么?
原因: 主进程执行过快,执行完毕即退出,不会等待其他 Goroutine 执行完毕。
那么为了让主程序等待协程的输出,我们甚至可以添加时间等待函数 time.sleep(),但是这并不符合程序设计思想。
sync.WaitGroup 阻塞等待
为了解决上述问题,Go 的 sync 包提供了一个非常有用的工具:WaitGroup(协程计数器 )。它能帮助我们阻塞 main goroutine,直到所有我们关心的子 goroutine 都执行完毕。
WaitGroup 包含三个核心方法:
wg.Add(n):计数器增加 n。通常在启动 Goroutine 前调用。wg.Done():计数器减 1。通常在 Goroutine 执行完毕时,使用 defer 来确保调用。wg.Wait():阻塞当前 Goroutine(通常是 main goroutine),直到计数器归零。
go
package main
import (
"fmt"
"sync"
"time"
)
func worker(id int, wg *sync.WaitGroup) {
// 在函数退出时通知 WaitGroup 任务已完成
defer wg.Done()
fmt.Printf("Worker %d starting\n", id)
time.Sleep(1 * time.Second) // 模拟耗时操作
fmt.Printf("Worker %d done\n", id)
}
func main() {
// 声明一个 WaitGroup
var wg sync.WaitGroup
// 启动 3 个 worker Goroutine
for i := 1; i <= 3; i++ {
fmt.Printf("Main: Starting worker %d\n", i)
// 每次启动一个 Goroutine,计数器加 1
wg.Add(1)
go worker(i, &wg)
}
fmt.Println("Main: Waiting for workers to finish...")
// 等待所有 Goroutine 完成(即计数器归零)
wg.Wait()
fmt.Println("Main: All workers done. Exiting.")
}
这个例子完美地展示了 WaitGroup 的用法:main 函数准确地等待了所有 3 个 worker 完成后才退出。
设置应用核数
我们已经知道了 Goroutine 可以实现并发。那它如何利用多核 CPU 实现并行呢?
Go 语言提供了 runtime 包来与运行时环境交互:
runtime.NumCPU():返回当前系统的 CPU 核心数。runtime.GOMAXPROCS(n): 设置 Go 程序可以同时使用的 OS 线程数(即 P 的数量)。
注: GOMAXPROCS 默认值为 runtime.NumCPU(),我们几乎不需要手动设置它,Go 运行时会自动为你利用所有可用的 CPU 核心。
实战对比:计算素数
让我们用一个计算密集型任务来直观感受串行 和并行的效率差异:统计 1 到 200,000 之间有多少个素数。
go
package main
import (
"fmt"
"runtime"
"sync"
"sync/atomic"
"time"
)
// isPrime 是一个简单的(非最高效)素数判断函数
func isPrime(n int) bool {
if n <= 1 {
return false
}
for i := 2; i*i <= n; i++ {
if n%i == 0 {
return false
}
}
return true
}
// --- 版本一:单线程串行计算 ---
func serialCalculate(max int) int64 {
var count int64 = 0
for i := 1; i <= max; i++ {
if isPrime(i) {
count++
}
}
return count
}
// --- 版本二:多 Goroutine 并行计算 ---
func parallelCalculate(max int) int64 {
// 获取 CPU 核心数,决定开启多少个 Goroutine
numCores := runtime.NumCPU()
var count int64 = 0 // 使用 atomic 来安全地累加
var wg sync.WaitGroup
// 将任务均分给每个核心
segmentSize := max / numCores
for i := 0; i < numCores; i++ {
// 计算每个 goroutine 的起止范围
start := i*segmentSize + 1
end := (i + 1) * segmentSize
if i == numCores-1 { // 最后一个 goroutine 处理所有剩余的
end = max
}
wg.Add(1)
go func(start, end int) {
defer wg.Done()
var localCount int64 = 0 // Goroutine 内部计数
for j := start; j <= end; j++ {
if isPrime(j) {
localCount++
}
}
// 使用原子操作,安全地将局部结果加到总数上
// 避免了多 Goroutine 同时写入 count 造成的竞态问题
atomic.AddInt64(&count, localCount)
}(start, end)
}
wg.Wait()
return count
}
func main() {
const MAX_NUM = 200000
// --- 串行测试 ---
startSerial := time.Now()
countSerial := serialCalculate(MAX_NUM)
timeSerial := time.Since(startSerial)
fmt.Printf("[串行] 1-%d 之间共有 %d 个素数\n", MAX_NUM, countSerial)
fmt.Printf("[串行] 耗时: %v\n", timeSerial)
fmt.Println("--------------------")
// --- 并行测试 ---
startParallel := time.Now()
countParallel := parallelCalculate(MAX_NUM)
timeParallel := time.Since(startParallel)
fmt.Printf("[并行] 1-%d 之间共有 %d 个素数 (使用 %d 核心)\n", MAX_NUM, countParallel, runtime.NumCPU())
fmt.Printf("[并行] 耗时: %v\n", timeParallel)
}
一次可能的运行效果(注意不同的电脑因性能不同结果会完全不同)
go
[串行] 1-200000 之间共有 17984 个素数
[串行] 耗时: 1.65s
--------------------
[并行] 1-200000 之间共有 17984 个素数 (使用 8 核心)
[并行] 耗时: 245.5ms
结果分析: 效率提升是惊人的!串行版本耗时 1.65 秒,而并行版本仅耗时 245 毫秒(约为 0.245 秒),速度提升了近 7 倍。
这就是并行的力量。Go 运行时将 8 个计算任务(segmentSize)分别交给了 8 个不同的 CPU 核心去"同时"执行,极大地缩短了总耗时。
总结
Goroutine 协程,是 Go 语言并发设计的灵魂。它以其轻量级、低开销和强大的调度能力,让开发者能以极其简单的方式编写出高性能的并发乃至并行程序。
- Goroutine (
go关键字) 让我们轻松发起并发任务。 sync.WaitGroup让我们能可靠地同步和等待任务完成。- Go 运行时 默认利用多核 CPU,让我们(在计算密集型任务中)"免费"获得并行带来的性能提升。
掌握 Goroutine,是深入 Go 语言、构建现代化高性能服务的必经之路。
2025.10.29 杭州西湖畔