Skip to content

4.3 学习 Go 协程:goroutine

说到 Go 语言,许多人对它的第一印象就是从语言层面原生支持并发,这使得开发者可以非常方便地编写高性能且易于理解的程序。

在 Python 等主流编程语言中,并发编程的门槛较高。你需要学习多进程、多线程的概念,还要熟悉各种支持并发的库,比如 asyncio、aiohttp 等,同时要了解它们之间的区别和优缺点,能够根据场景选择合适的并发模式。

而 Golang 作为一门现代化的编程语言,这些复杂的问题已经被大大简化。在 Golang 中,开发者无需关心进程池/线程池的创建,也无需考虑多线程和多进程的具体应用场景。它原生提供了非常优秀的 goroutine(协程)机制,能够自动高效地处理并发任务,你只需简单地调用即可。

一、Goroutine 是什么?

Goroutine 是 Go 语言并发的执行单元。你可以简单地将其视为一个轻量级的线程

在 Go 语言中,每一个并发的执行单元都称为 Goroutine (协程)。

想象一下您的程序中有两个独立的功能函数,一个负责复杂的计算任务,另一个负责将结果输出。在一个传统的、线性的程序中,这两个函数必须依次执行——先完成计算,再进行输出。

而当您使用 Goroutine 时,这两个函数可以同时开始、独立运行。Go 语言的运行时(Runtime)会自动调度它们,让您能够轻松地实现并发执行。

1. Goroutine vs 操作系统线程

特性Goroutine (协程)Thread (操作系统线程)
内存占用极小,初始栈空间仅 几KB (通常 2KB),可动态伸缩。较大,初始栈空间通常为 1MB 或更大。
创建开销极低,只需要很少的 CPU 时间。较高,涉及到操作系统内核的调度。
调度由 Go 语言的 运行时(Runtime) 负责调度(M:N 调度模型)。由操作系统内核负责调度。
数量可轻松创建数十万个 Goroutine。数量有限,创建过多会耗尽系统资源。

总结: Goroutine 相比传统线程更加轻量、高效,使得 Go 语言能以极低的成本实现大规模并发。

2. 并发 (Concurrency) 与并行 (Parallelism)

  • 并发 (Concurrency): 指的是处理多个任务的能力。宏观上看起来是同时在做,但微观上可能在单核 CPU 上通过时间片切换轮流执行。Go 语言的设计目标是实现并发
  • 并行 (Parallelism): 指的是同时做多个任务的能力。这需要多个 CPU 核心同时运行多个任务。

Go Runtime: Go 语言的运行时会自动将 Goroutine 高效地映射到少量的操作系统线程上,通过调度器实现并发,如果有多核 CPU 也会自动实现并行。

二、如何启动 Goroutine

启动 Goroutine 非常简单,只需在一个函数调用前加上 go 关键字。

一个 goroutine 本身就是一个函数,当你直接调用时,它就是一个普通函数,如果你在调用前加一个关键字 go ,那你就开启了一个 goroutine。

go
// 执行一个函数
func()

// 开启一个协程执行这个函数
go func()

1. 主协程与子协程

一个 Go 程序的入口通常是 main 函数,程序启动后,main 函数最先运行,我们称之为 main Goroutine (主协程)。

main 中或者其下调用的代码中才可以使用 go + func() 的方法来启动子协程。

main 的地位相当于主线程,当 main 函数执行完成后,这个线程也就终结了,其下运行着的所有协程也不管代码是不是还在跑,也得乖乖退出。

示例:

go
package main

import "fmt"

func mytest() {
    fmt.Println("hello, go")
}

func main() {
    // 启动一个协程
    go mytest()
    fmt.Println("hello, world")
}

这段代码运行完,通常只会输出 hello, world,而不会输出 hello, go。因为协程的创建需要时间,当 hello, world 打印后,main 函数结束,程序退出,协程还没来得及执行。

为了让大家看到效果,我们可以临时使用 time.Sleep 来阻塞 main 函数(虽然这在实际开发中不推荐,但适合演示):

go
package main

import (
    "fmt"
    "time"
)

func mytest() {
    fmt.Println("hello, go")
}

func main() {
    go mytest()
    fmt.Println("hello, world")
    time.Sleep(time.Second) // 等待 1 秒
}

输出如下:

hello, world
hello, go

2. 多个协程并发执行的效果

为了让你看到并发的效果,这里举个最简单的例子:

go
package main

import (
    "fmt"
    "time"
)

func mygo(name string) {
    for i := 0; i < 5; i++ {
        fmt.Printf("In goroutine %s\n", name)
        // 为了避免第一个协程执行过快,观察不到并发的效果,加个休眠
        time.Sleep(10 * time.Millisecond) 
    }
}

func main() {
    go mygo("协程1号") // 第一个协程
    go mygo("协程2号") // 第二个协程
    
    // 同样需要等待子协程执行完成
    time.Sleep(time.Second)
}

输出如下(顺序可能不同),可以观察到两个协程交替执行:

In goroutine 协程2号
In goroutine 协程1号
In goroutine 协程1号
In goroutine 协程2号
In goroutine 协程2号
In goroutine 协程1号
...

3. 匿名函数与参数传递

Goroutine 也可以通过匿名函数启动,通常用于需要传递参数或访问闭包变量的场景。

go
package main

import (
    "fmt"
    "time"
)

func main() {
    for i := 0; i < 5; i++ {
        // ✅ 正确做法:将 i 作为参数传递给匿名函数,保证 Goroutine 捕获到当前 i 的值
        go func(j int) {
            fmt.Printf("Goroutine %d: 正在执行...\n", j)
        }(i) // 立即调用匿名函数并传入 i 的值
    }

    time.Sleep(100 * time.Millisecond)
    fmt.Println("所有 Goroutine 启动完毕。")
}

重要提示: 在实际项目中,绝对不能依赖 time.Sleep 来等待 Goroutine 完成。更优雅且推荐的方式是使用 sync.WaitGroupChannel 来进行同步。

三、Goroutine 的生命周期

一个 Goroutine 的生命周期从 go 语句开始,并在以下情况之一结束:

  1. 函数自然返回: Goroutine 内部执行的函数正常执行完毕,返回。
  2. 发生未捕获的 panic 如果 Goroutine 发生 panic 且没有在自身的 defer 中被 recover 捕获,则该 Goroutine 终止,并导致整个程序崩溃。
  3. 主 Goroutine 结束:main 函数返回时,程序会强制终止所有正在运行的 Goroutine。

Goroutine 的 Panic 处理示例

go
package main

import (
    "fmt"
    "time"
)

func safeworker(id int) {
    // 使用 defer 和 recover 捕获 panic
    defer func() {
        if r := recover(); r != nil {
            fmt.Printf("Goroutine %d: 捕获到 panic: %v\n", id, r)
        }
    }()
    
    fmt.Printf("Goroutine %d: 开始工作\n", id)
    
    if id == 2 {
        panic("模拟错误") // 故意触发 panic
    }
    
    time.Sleep(500 * time.Millisecond)
    fmt.Printf("Goroutine %d: 正常完成\n", id)
}

func main() {
    // 启动安全的 goroutine(有 panic 处理)
    for i := 1; i <= 3; i++ {
        go safeworker(i)
    }
    
    time.Sleep(1 * time.Second)
    fmt.Println("所有安全的 Goroutine 处理完成")
}

四、Go 调度器原理(GMP 模型)

Go 语言的调度器采用 GMP 模型,这是 Go 能够实现高性能并发的核心:

  • G (Goroutine): 协程,用户态的轻量级线程,包含栈、指令指针等信息。
  • M (Machine): 操作系统线程,真正执行计算的资源,由操作系统调度。
  • P (Processor): 处理器(逻辑处理器),调度的上下文,维护了一个 Goroutine 队列。M 必须持有 P 才能执行 G。

Go 运行时会维护 GOMAXPROCS 个 P,通常默认为 CPU 核心数。

  1. 复用线程:避免频繁创建、销毁线程,而是复用线程。
  2. 利用多核:通过 M 和 P 的绑定,让多个线程同时工作。
  3. 抢占式调度:防止某个 Goroutine 长时间占用 CPU,导致其他 Goroutine 饿死。
  4. 全局队列与本地队列:P 维护本地队列,还有一个全局队列,平衡负载。

五、常见陷阱与最佳实践

1. 循环变量陷阱

for 循环中启动 Goroutine 时,如果直接使用循环变量,可能会导致所有 Goroutine 读取到同一个值(循环结束后的值)。

go
// ❌ 错误示例
for i := 0; i < 5; i++ {
    go func() {
        fmt.Println(i) // 可能全部打印 5
    }()
}

// ✅ 正确示例 1:参数传递
for i := 0; i < 5; i++ {
    go func(val int) {
        fmt.Println(val)
    }(i)
}

// ✅ 正确示例 2:局部变量拷贝
for i := 0; i < 5; i++ {
    i := i // 影子变量
    go func() {
        fmt.Println(i)
    }()
}

2. Goroutine 泄漏

启动了 Goroutine 但没有正确的退出机制,会导致 Goroutine 永远在后台运行,占用内存和 CPU。

常见原因:

  • 从 channel 接收数据,但 channel 永远没有数据且没关闭。
  • 向无缓冲 channel 发送数据,但没有接收者。
  • 死循环且没有退出条件。

解决: 使用 context 包来控制 Goroutine 的退出,或确保 channel 正确关闭。

六、Goroutine 的应用:素数分解

以下是一个使用协程解决计算密集型任务效率问题的例子:计算多个整数的素数分解。

go
package main

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

// factorize 对一个整数进行素数分解
func factorize(num int) []int {
    factors := []int{}
    for i := 2; i*i <= num; i++ {
        time.Sleep(100 * time.Microsecond) // 模拟耗时任务
        for num%i == 0 {
            factors = append(factors, i)
            num /= i
        }
    }
    if num > 1 {
        factors = append(factors, num)
    }
    return factors
}

func main() {
    numbers := make([]int, 100) // 减少数量以便演示
    for i := range numbers {
        numbers[i] = i + 1
    }

    var wg sync.WaitGroup
    start := time.Now()

    // 创建一个 channel 用于接收素数分解结果
    resultCh := make(chan []int, len(numbers))

    for _, num := range numbers {
        wg.Add(1)
        go func(n int) {
            defer wg.Done()
            factors := factorize(n)
            resultCh <- factors
        }(num)
    }

    // 所有的 goroutine 完成后关闭 channel
    go func() {
        wg.Wait()
        close(resultCh)
    }()

    // 读取结果
    count := 0
    for range resultCh {
        count++
    }

    elapsed := time.Since(start)
    fmt.Printf("Processed %d numbers. Total time with goroutines: %v\n", count, elapsed)
}

七、总结

Goroutine 是 Go 语言并发编程的核心,掌握其正确使用方法对于编写高性能 Go 程序至关重要。

  1. 简单易用:一个 go 关键字即可启动。
  2. 轻量高效:内存占用小,切换成本低。
  3. 需要同步:Goroutine 是异步执行的,主程序(main)需要等待它们完成,通常使用 sync.WaitGroup 或 Channel。
  4. 注意安全:并发环境下要注意共享内存的安全问题(Data Race),并小心 Goroutine 泄漏。

本篇只介绍了协程的简单使用,真正的并发程序还是要结合 信道 (channel) 来实现。关于信道的内容,将在下一篇文章中介绍。