Goroutine:Go 的并发魔法

深入理解 Go 语言的 goroutine,掌握轻量级并发的核心概念和最佳实践

Goroutine:Go 的并发魔法

如果你问我 Go 语言最酷的特性是什么,我会毫不犹豫地回答:goroutine

在当今这个多核 CPU 普及的时代,并发编程已经不是什么新鲜事了。但传统的并发模型——不管是 Java 的线程、Python 的多进程,还是 Node.js 的事件循环——都有各自的痛点:要么太重、要么太复杂、要么性能不够。

Go 语言另辟蹊径,提出了 goroutine 这个概念。它是一种轻量级的线程,由 Go 运行时管理,而不是操作系统。你可以轻松启动成千上万个 goroutine,而不用担心系统资源耗尽。

今天我们就来揭开 goroutine 的神秘面纱,看看它到底是怎么工作的,以及如何正确使用它。

什么是并发?为什么需要它?

在深入 goroutine 之前,我们先聊聊并发编程的基本概念。

并发 vs 并行

很多人把这两个概念搞混了,其实它们是不同的:

  • 并发(Concurrency):多个任务在同一时间段内交替执行。单核 CPU 也能实现并发。
  • 并行(Parallelism):多个任务在同一时刻同时执行。需要多核 CPU。

打个比方:

  • 并发:一个厨师同时做三道菜,先切 A 菜的菜,然后去炒 B 菜,再回来炒 A 菜。三道菜在同一时间段内都在"进行中",但同一时刻厨师只做一件事。
  • 并行:三个厨师同时做三道菜,每道菜都有一个专门的厨师在做。三道菜在同一时刻都在"被制作"。

Go 的 goroutine 支持并发,在多核 CPU 上也能实现并行。

为什么需要并发?

  1. 提高性能:充分利用多核 CPU,同时处理多个任务
  2. 提升响应性:不让耗时操作阻塞主流程
  3. 简化模型:把复杂的异步操作拆分成多个独立的逻辑单元

举个例子:一个 Web 服务器需要同时处理多个用户的请求。如果没有并发,服务器只能一个接一个地处理请求,后面的用户就得排队等待。有了并发,服务器可以同时处理多个请求,用户体验就好多了。

第一个 Goroutine

让我们从一个最简单的例子开始:

package main

import (
	"fmt"
	"time"
)

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

func main() {
	// 启动一个 goroutine
	go sayHello()

	// 主函数继续执行
	fmt.Println("Hello from main!")

	// 等待一下,让 goroutine 有机会执行
	time.Sleep(100 * time.Millisecond)
}

就这么简单!在函数调用前加上 go 关键字,这个函数就会在一个新的 goroutine 中异步执行。

运行结果可能是:

Hello from main!
Hello from goroutine!

或者:

Hello from goroutine!
Hello from main!

顺序是不确定的,因为两个 goroutine 是并发执行的。

⚠️ 注意:我们在最后加了 time.Sleep(100 * time.Millisecond)。如果不加这个,程序可能在 goroutine 还没执行完就退出了,你就看不到 “Hello from goroutine!” 的输出。

Goroutine 的生命周期

启动 goroutine

启动 goroutine 有两种方式:

方式一:调用函数

go functionName(args)

方式二:匿名函数

go func() {
    // 做一些事情
}()

主 goroutine 退出

重要:当 main() 函数返回时,所有的 goroutine 都会被强制终止,不管它们是否执行完毕。

package main

import (
	"fmt"
	"time"
)

func worker(id int) {
	for i := 0; i < 5; i++ {
		fmt.Printf("Worker %d: working... %d\n", id, i)
		time.Sleep(100 * time.Millisecond)
	}
}

func main() {
	go worker(1)
	go worker(2)
	
	fmt.Println("Main: I'm done!")
	// 没有等待,直接退出
}

运行结果:

Main: I'm done!

Worker 根本没有机会执行!因为 main 函数一返回,整个程序就结束了。

如何等待 goroutine?

这是我们遇到的第一个实际问题。目前我们用的是 time.Sleep(),但这显然不是个好办法——你很难准确估计要等多久。

后面我们会学习更好的方式:

  • Channel:通过通道同步
  • WaitGroup:专门的同步工具
  • Context:带超时的等待

现在先用 time.Sleep() 凑合一下,后面会详细介绍这些工具。

Goroutine 的轻量级特性

Goroutine 和操作系统线程最大的区别在于轻量级

栈大小对比

  • 操作系统线程:初始栈大小通常是 1-8 MB(Linux 默认 8 MB)
  • Goroutine:初始栈大小只有 2 KB

这意味着你可以在同样的内存中启动多得多的 goroutine。

栈的动态增长

Goroutine 的栈不是固定的,它会根据需要动态增长和缩小。这得益于 Go 运行时的栈分段(segmented stacks)技术。

package main

import (
	"fmt"
	"runtime"
)

func recursive(depth int) {
	if depth > 10000 {
		return
	}
	recursive(depth + 1)
}

func main() {
	fmt.Println("当前 goroutine 数量:", runtime.NumGoroutine())
	
	for i := 0; i < 1000; i++ {
		go recursive(0)
	}
	
	// 等待 goroutine 启动
	runtime.Gosched()
	
	fmt.Println("启动后 goroutine 数量:", runtime.NumGoroutine())
}

这段代码启动了 1000 个 goroutine,每个都执行深度为 10000 的递归。如果是操作系统线程,早就因为栈溢出崩溃了。但 goroutine 可以轻松应对。

启动成本

启动一个 goroutine 的成本非常低:

  • 内存:2 KB 初始栈
  • CPU:几百条指令
  • 时间:微秒级

相比之下,启动一个操作系统线程的成本要高得多:

  • 内存:1-8 MB 栈
  • CPU:数千条指令
  • 时间:毫秒级

这就是为什么你可以轻松启动成千上万个 goroutine,而启动同样数量的线程会让系统崩溃。

实战:多任务下载器

让我们用 goroutine 写一个实用的程序——并发下载多个文件:

package main

import (
	"fmt"
	"math/rand"
	"time"
)

// simulateDownload 模拟下载文件
func simulateDownload(filename string, duration time.Duration) {
	fmt.Printf("[%s] 开始下载...\n", filename)
	time.Sleep(duration)
	fmt.Printf("[%s] 下载完成!耗时: %v\n", filename, duration)
}

func main() {
	// 模拟要下载的文件
	files := []struct {
		name     string
		duration time.Duration
	}{
		{"file1.zip", time.Duration(rand.Intn(3)+1) * time.Second},
		{"file2.zip", time.Duration(rand.Intn(3)+1) * time.Second},
		{"file3.zip", time.Duration(rand.Intn(3)+1) * time.Second},
		{"file4.zip", time.Duration(rand.Intn(3)+1) * time.Second},
	}

	start := time.Now()

	// 启动所有下载任务
	for _, file := range files {
		go simulateDownload(file.name, file.duration)
	}

	// 等待所有下载完成(粗略的方式)
	time.Sleep(4 * time.Second)

	elapsed := time.Since(start)
	fmt.Printf("\n总耗时: %v\n", elapsed)
}

运行结果类似:

[file1.zip] 开始下载...
[file2.zip] 开始下载...
[file3.zip] 开始下载...
[file4.zip] 开始下载...
[file2.zip] 下载完成!耗时: 1s
[file1.zip] 下载完成!耗时: 2s
[file4.zip] 下载完成!耗时: 2s
[file3.zip] 下载完成!耗时: 3s

总耗时: 4.001s

看到了吗?虽然每个文件下载需要 1-3 秒,但 4 个文件同时下载,总耗时只有约 4 秒(最长的下载时间 + 一些开销),而不是串行下载的 1+2+2+3=8 秒。

这就是并发的力量!

Goroutine 调度器

Goroutine 的执行是由 Go 运行时的调度器管理的。了解调度器的工作原理,有助于你写出更高效的并发代码。

M:N 调度模型

Go 使用的是 M:N 调度模型

  • M(Machine):操作系统线程
  • N(Goroutine):用户态的 goroutine

Go 调度器会把 N 个 goroutine 调度到 M 个操作系统线程上执行。默认情况下,M 的数量等于 CPU 核心数。

package main

import (
	"fmt"
	"runtime"
)

func main() {
	// 查看当前可用的 CPU 核心数
	fmt.Println("CPU 核心数:", runtime.NumCPU())
	
	// 查看当前使用的线程数
	fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
	
	// 可以手动设置使用的线程数
	// runtime.GOMAXPROCS(4)  // 使用 4 个线程
}

抢占式调度

在 Go 1.14 之前,goroutine 使用的是协作式调度——goroutine 必须主动让出 CPU(比如调用 time.Sleep()、I/O 操作、channel 操作等),否则它会一直占用线程。

从 Go 1.14 开始,Go 引入了抢占式调度——调度器可以强制中断一个运行中的 goroutine,即使它没有主动让出 CPU。这解决了"一个 goroutine 死循环占用整个线程"的问题。

runtime.Gosched()

你可以手动让当前 goroutine 让出 CPU,给其他 goroutine 执行的机会:

package main

import (
	"fmt"
	"runtime"
)

func worker(id int) {
	for i := 0; i < 3; i++ {
		fmt.Printf("Worker %d: iteration %d\n", id, i)
		runtime.Gosched()  // 主动让出 CPU
	}
}

func main() {
	go worker(1)
	go worker(2)
	
	// 主 goroutine 也让出 CPU
	for i := 0; i < 3; i++ {
		fmt.Printf("Main: iteration %d\n", i)
		runtime.Gosched()
	}
}

输出结果会交替显示各个 goroutine 的执行情况。

Goroutine 泄漏

Goroutine 泄漏是 Go 程序中常见的 bug。它指的是一个 goroutine 因为某种原因永远无法退出,一直占用资源。

常见的泄漏原因

1. 等待永远不会发生的 channel 操作

func leaky() {
	ch := make(chan int)
	
	go func() {
		// 这个 goroutine 永远不会收到数据
		// 因为它等待的 channel 永远不会被发送
		value := <-ch
		fmt.Println(value)
	}()
	
	// 忘记发送数据就返回了
	// ch <- 42
}

2. 无限循环

func leaky() {
	go func() {
		for {
			// 没有退出条件的死循环
			time.Sleep(time.Second)
		}
	}()
}

3. 阻塞在 I/O 操作

func leaky() {
	go func() {
		conn, _ := net.Dial("tcp", "example.com:80")
		// 如果连接一直不关闭,这个 goroutine 就泄漏了
		defer conn.Close()
		
		buf := make([]byte, 1024)
		for {
			_, err := conn.Read(buf)
			if err != nil {
				return
			}
		}
	}()
}

如何避免泄漏?

  1. 使用 context:给 goroutine 设置超时或取消机制
  2. 使用 select:不要无限期等待
  3. 确保有退出条件:每个 goroutine 都应该有明确的退出路径
  4. 监控 goroutine 数量:使用 runtime.NumGoroutine() 检测泄漏
package main

import (
	"context"
	"fmt"
	"runtime"
	"time"
)

func worker(ctx context.Context) {
	for {
		select {
		case <-ctx.Done():
			fmt.Println("Worker: 收到取消信号,退出")
			return
		default:
			fmt.Println("Worker: 工作中...")
			time.Sleep(500 * time.Millisecond)
		}
	}
}

func main() {
	fmt.Println("开始时 goroutine 数量:", runtime.NumGoroutine())
	
	// 创建一个 2 秒后取消的 context
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()
	
	go worker(ctx)
	
	time.Sleep(3 * time.Second)
	
	fmt.Println("结束时 goroutine 数量:", runtime.NumGoroutine())
}

Goroutine 和闭包

在循环中启动 goroutine 时,要特别小心闭包的问题:

// ❌ 错误示例
for i := 0; i < 5; i++ {
	go func() {
		fmt.Println(i)  // 所有 goroutine 共享同一个 i
	}()
}
time.Sleep(100 * time.Millisecond)
// 输出:5 5 5 5 5(或者其他的 5)

所有 goroutine 都捕获了同一个变量 i。当它们执行时,循环已经结束了,i 的值已经是 5。

// ✅ 正确方式一:通过参数传递
for i := 0; i < 5; i++ {
	go func(n int) {
		fmt.Println(n)  // 每个 goroutine 有自己的 n
	}(i)
}

// ✅ 正确方式二:在循环内创建局部变量
for i := 0; i < 5; i++ {
	i := i  // 创建新的局部变量
	go func() {
		fmt.Println(i)
	}()
}

time.Sleep(100 * time.Millisecond)
// 输出:0 1 2 3 4(顺序不确定)

性能考量

什么时候该用 goroutine?

适合的场景

  • I/O 密集型操作(网络请求、文件读写、数据库查询)
  • 独立的计算任务
  • 事件处理和消息传递
  • 后台任务和定时任务

不适合的场景

  • CPU 密集型的纯计算(应该用 worker pool)
  • 需要严格顺序执行的任务
  • 启动成本大于任务本身的轻量操作

Goroutine 池

如果你需要处理大量任务,但又不想启动太多 goroutine,可以使用 goroutine 池

package main

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

func worker(id int, jobs <-chan int, wg *sync.WaitGroup) {
	defer wg.Done()
	
	for job := range jobs {
		fmt.Printf("Worker %d: processing job %d\n", id, job)
		time.Sleep(100 * time.Millisecond)
	}
}

func main() {
	const numWorkers = 3
	const numJobs = 10
	
	jobs := make(chan int, numJobs)
	var wg sync.WaitGroup
	
	// 启动 worker 池
	for i := 1; i <= numWorkers; i++ {
		wg.Add(1)
		go worker(i, jobs, &wg)
	}
	
	// 发送任务
	for j := 1; j <= numJobs; j++ {
		jobs <- j
	}
	close(jobs)
	
	// 等待所有任务完成
	wg.Wait()
	fmt.Println("所有任务完成!")
}

这个模式限制了同时运行的 goroutine 数量,避免资源耗尽。

小结

今天我们深入学习了 Go 语言的 goroutine:

  1. 什么是 goroutine:Go 运行时的轻量级线程,初始栈只有 2 KB
  2. 启动方式go func()go functionName()
  3. 生命周期:main 函数退出时所有 goroutine 强制终止
  4. 轻量级特性:启动成本低,可以启动成千上万个
  5. 调度器:M:N 模型,Go 1.14 后支持抢占式调度
  6. Goroutine 泄漏:常见原因和避免方法
  7. 闭包陷阱:循环中的变量捕获问题
  8. 性能考量:适合 I/O 密集型任务,不适合纯计算
  9. Goroutine 池:控制并发数量的模式

Goroutine 是 Go 并发编程的基础,但它只是一个开始。要写出正确的并发程序,你还需要学习 channel(通道)——这是 goroutine 之间通信的桥梁。

下一篇文章,我们就来学习 channel!

练习时间

  1. 并行计算:写一个程序,用多个 goroutine 并行计算 1 到 1000000 的平方和
  2. 并发下载:修改多任务下载器,让它能够处理任意数量的文件
  3. Goroutine 泄漏检测:写一个程序,启动一些可能泄漏的 goroutine,用 runtime.NumGoroutine() 检测
  4. 生产者-消费者:用 goroutine 实现一个简单的生产者-消费者模型(暂时用 sleep 同步)
  5. 性能测试:对比串行和并发执行 100 个任务的耗时差异

下一篇预告

下一篇文章,我们将学习 Channel(通道)——goroutine 之间通信的桥梁。Channel 是 Go 并发编程的核心,它会让你真正理解 Go 的并发哲学:“不要通过共享内存来通信,而要通过通信来共享内存。”

我们下篇见!👋


参考资料:

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页