20·并发编程进阶

Goroutine 基础

goroutinegoconcurrency

第 20 课 - Goroutine 基础

学习目标

完成本课学习后,你将能够:

  1. 理解 Goroutine 的概念及其在 Go 并发中的作用。
  2. 掌握使用 go 关键字启动 Goroutine 的方法。
  3. 明确区分 Goroutine 与传统线程/函数调用的区别。
  4. 初步了解 Goroutine 的调度机制(M:N 模型)。
  5. 识别并避免常见的 Goroutine 泄漏场景。

核心概念

1. 什么是 Goroutine?

想象一个车间(程序)原本只有你一个工人(主 Goroutine)。现在你有多项任务要同时进行,比如一边听歌一边写代码。你可以再雇佣几个“打工仔”(新的 Goroutine)来帮你分担任务。

  • Goroutine 是由 Go 运行时管理的轻量级线程,可以理解为一个并发执行的函数调用。
  • 与操作系统线程相比,创建 Goroutine 的开销极小(初始栈空间仅几KB),因此你可以轻松创建成千上万个。

2. 如何启动 Goroutine?

只需在函数调用前加上 go 关键字即可。

go functionName() // 启动一个新 Goroutine 执行 functionName

3. 调度机制 (GMP 模型简介)

Go 使用 M:N 调度模型,意味着 M 个 Goroutine 会被调度到 N 个操作系统线程上执行。

  • G (Goroutine): 代表一个并发任务。
  • M (Machine): 代表一个操作系统线程。
  • P (Processor): 代表逻辑处理器,它持有本地 Goroutine 队列。 Go 调度器负责将 G 分配到 M 上,并在 P 的协调下高效执行,实现了真正的并行与高效的并发切换。

4. Goroutine vs. 函数调用

  • 普通函数调用:同步、阻塞,调用者必须等待被调用函数执行完毕。
  • go 启动的 Goroutine:异步、非阻塞,启动后立刻返回,新 Goroutine 与当前 Goroutine 并发执行

代码示例

示例1:基础启动与观察

package main

import (
    "fmt"
    "time"
)

// 普通函数,模拟一个耗时任务
func printNumbers(name string) {
    for i := 1; i <= 3; i++ {
        fmt.Printf("%s: %d\n", name, i)
        time.Sleep(100 * time.Millisecond) // 暂停一小会,模拟工作
    }
}

func main() {
    // 启动一个新的 Goroutine 执行 printNumbers
    go printNumbers("Goroutine-One")

    // 在主 Goroutine 中执行同样的函数
    printNumbers("Main-Thread")

    fmt.Println("Main function ends here.")
}

运行结果 (顺序可能每次不同)

Main-Thread: 1
Goroutine-One: 1
Main-Thread: 2
Goroutine-One: 2
Main-Thread: 3
Goroutine-One: 3
Main function ends here.

代码解析

  • 主 Goroutine 和新的 Goroutine 在并发地执行 printNumbers
  • 输出交错出现,证明了并发执行。
  • 注意:程序在主 Goroutine 执行完后立即退出,有时可能来不及看到新 Goroutine 的所有输出(但本例中有 time.Sleep,所以通常能看到)。

示例2:没有等待的问题

package main

import "fmt"

func sayHello() {
    fmt.Println("Hello from Goroutine!")
}

func main() {
    go sayHello()
    fmt.Println("Hello from Main!")
    // 程序立刻结束,可能在新 Goroutine 执行前就退出了
}

可能的输出 (不完整)

Hello from Main!

问题分析:主 Goroutine 没有等待新 Goroutine 完成就退出了,导致新 Goroutine 的任务可能被取消。我们需要一种方式来协调等待,这是下一课 Channel 或后续课程 sync.WaitGroup 的用武之地。

实践练习

练习1:启动你的第一个 Goroutine

编写一个程序,定义一个函数 countDown(n int),该函数从 n 倒数到 1 并打印。在 main 函数中启动一个 Goroutine 来执行 countDown(5)

预期输出(顺序可能交错,但内容应包含):

5
4
3
2
1

练习2:并发执行两个任务

创建两个函数:taskA()taskB()taskA 打印字母 “A” 5次,每次间隔200毫秒;taskB 打印字母 “B” 5次,每次间隔200毫秒。使用 Goroutine 并发启动它们,并观察输出顺序。

预期输出示例(顺序交错):

A
B
A
B
A
B
A
B
A
B

练习3:识别并修复代码错误

以下代码期望同时启动3个 Goroutine,每个打印一个唯一的消息,但可能无法看到所有输出。请分析原因并思考如何修复(提示:需要等待所有 Goroutine 完成)。

package main

import (
    "fmt"
    "time"
)

func printMessage(msg string) {
    time.Sleep(time.Second)
    fmt.Println(msg)
}

func main() {
    go printMessage("Message 1")
    go printMessage("Message 2")
    go printMessage("Message 3")
    fmt.Println("All goroutines launched.")
}

常见错误

  1. 忘记等待 Goroutine 完成:这是最常见的错误。主 Goroutine 退出时,所有其他 Goroutine 都会被强制终止。

    // 错误示例
    func main() {
        go doImportantWork() // 工作可能还没完成
        fmt.Println("Exiting...") // 程序直接退出
    }
    

    修正方案:使用 sync.WaitGroup 或 Channel 等机制进行同步(将在后续课程学习)。

  2. 无限制地创建 Goroutine 导致资源耗尽 (Goroutine 泄漏):如果在循环中不断创建 Goroutine,且这些 Goroutine 因某种原因无法自行结束(如在阻塞读一个永远不会有数据的 Channel),它们会一直占用内存,最终导致程序崩溃。

    // 危险示例:可能无限创建 Goroutine
    for {
        go func() {
            // 假设这个任务会一直阻塞
            <-make(chan struct{})
        }()
    }
    

    修正方案:确保每个启动的 Goroutine 都有明确的退出条件(如使用 contextdone Channel),并控制并发数量。

  3. 误解“并发”不等于“并行”:Goroutine 提供的是并发(多个任务交替执行),只有当 CPU 核心数足够时,它们才会并行(多个任务同时执行)。不要假设所有 Goroutine 都在物理上同时运行。

小结

  • Goroutine 是 Go 语言实现并发的核心,是轻量级的用户态线程。
  • 使用 go 关键字启动一个新的 Goroutine,函数调用立即返回,原函数与新 Goroutine 并发执行
  • Go 运行时使用 GMP 调度模型高效管理成千上万的 Goroutine。
  • 最关键的一点:必须在程序结束前确保所有必要的 Goroutine 已经完成工作。否则,程序可能会提前退出,导致任务中断。这是并发编程中同步问题的起点,我们将在下一课学习如何使用 Channel 来解决它。

练习编辑器

go
Loading...

继续学习

完成本课后,建议继续学习下一课「Channel 通道」 以巩固所学知识。