Skip to content

goroutine 启动与生命周期

上一篇我们理解了并发的基础概念和 GMP 调度模型,本篇将进入实战:学习如何用 go 关键字启动 goroutine,理解它的生命周期,掌握用 WaitGroup 等待一组 goroutine 完成,识别和避免 goroutine 泄漏,以及如何控制 goroutine 的数量。这些是写出健壮并发程序的必备技能。

一、go 关键字启动 goroutine

启动一个 goroutine 非常简单,只需要在函数调用前加上 go 关键字即可。这个函数会立即在一个新的 goroutine 中异步执行,而当前的 goroutine 继续往下执行。

go
package main

import (
	"fmt"
	"time"
)

func sayHello(name string) {
	fmt.Println("Hello,", name)
}

func main() {
	// 启动一个 goroutine 执行 sayHello
	go sayHello("Go")

	// 主 goroutine 继续执行
	fmt.Println("主 goroutine 继续运行")

	// 等待一会儿,否则主 goroutine 退出后子 goroutine 没机会执行
	time.Sleep(time.Second)
}

注意这段代码末尾的 time.Sleep。这是因为 main 函数所在的 goroutine(主 goroutine)一旦返回,整个程序就结束了,所有还在运行的子 goroutine 都会被强制终止。这里用 Sleep 是最简陋的等待方式,后面我们会学到更优雅的做法。

go 关键字后面不仅可以跟普通函数,还可以跟方法、闭包等,只要调用能形成一个独立的执行单元即可。

1. go 语句的执行语义

理解 go f() 这条语句的语义非常重要:

  • 求值参数go 语句会立即对参数求值,然后把参数传给新 goroutine。这意味着参数在 go 语句执行时就被计算了,而不是在 goroutine 真正运行时。
  • 立即返回go 语句本身立即返回,不会等待函数执行完。
  • 异步执行:新 goroutine 何时执行、何时结束,由调度器决定,调用方无法预知。

看下面这个参数求值的例子:

go
package main

import (
	"fmt"
	"time"
)

func main() {
	i := 1
	// go 语句执行时,i 的值被求值为 1 并传入
	go func(n int) {
		time.Sleep(100 * time.Millisecond)
		fmt.Println("goroutine 收到 i =", n) // 输出 1
	}(i)

	i = 2
	fmt.Println("主 goroutine 中 i =", i) // 输出 2

	time.Sleep(time.Second)
}

输出会是:

主 goroutine 中 i = 2
goroutine 收到 i = 1

因为 i 的值在 go 语句执行时就被复制传入了,后续主 goroutine 修改 i 不影响已传入的值。

二、goroutine 的匿名函数形式

很多时候我们不需要为了一小段逻辑专门定义一个函数,这时可以用匿名函数(闭包)配合 go 启动 goroutine。

1. 无参数匿名函数

go
package main

import (
	"fmt"
	"time"
)

func main() {
	go func() {
		fmt.Println("我是一个匿名 goroutine")
	}()

	time.Sleep(time.Second)
}

2. 带参数的匿名函数

为了避免闭包捕获变量的问题(后面会详细讲),推荐显式传参:

go
package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup

	for i := 0; i < 5; i++ {
		wg.Add(1)
		// 把 i 作为参数传入,避免闭包捕获问题
		go func(n int) {
			defer wg.Done()
			fmt.Printf("goroutine %d\n", n)
		}(i)
	}

	wg.Wait()
	fmt.Println("所有 goroutine 完成")
}

3. 闭包捕获变量的陷阱

如果不传参数,匿名函数会捕获外部变量的引用,这在不同 Go 版本中行为不同:

go
package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup

	// ⚠️ Go 1.22 之前的陷阱:循环变量 i 被所有 goroutine 共享
	// for i := 0; i < 5; i++ {
	// 	wg.Add(1)
	// 	go func() {
	// 		defer wg.Done()
	// 		fmt.Printf("goroutine %d\n", i) // 可能都输出 5
	// 	}()
	// }

	// Go 1.22+ 修复了这个问题:每次循环迭代都有独立的 i
	for i := 0; i < 5; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			fmt.Printf("goroutine %d\n", i)
		}()
	}

	wg.Wait()
}

在 Go 1.22 之前,循环变量 i 在所有迭代中是同一个变量,goroutine 执行时 i 可能已经变成 5。Go 1.22 之后,每次循环迭代会创建新的 i,这个问题自然解决了。但为了代码兼容性和可读性,显式传参始终是最安全的做法

三、goroutine 的退出机制

理解 goroutine 什么时候退出,是避免 bug 的关键。goroutine 在以下情况下会退出:

  1. 函数正常返回:goroutine 执行完对应的函数后自然退出。
  2. 函数发生 panic 且未 recover:goroutine 会崩溃,如果没人 recover,整个程序会崩溃。
  3. 主 goroutine 退出:主 goroutine(即 main 函数)返回时,所有其他 goroutine 都会被强制终止。
  4. 调用 runtime.Goexit():当前 goroutine 立即终止,但已注册的 defer 仍会执行。

1. runtime.Goexit 的效果

go
package main

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

func worker() {
	defer fmt.Println("worker 的 defer 执行了")
	fmt.Println("worker 开始工作")
	runtime.Goexit() // 立即终止当前 goroutine
	fmt.Println("这行永远不会执行") // 不会输出
}

func main() {
	go worker()
	time.Sleep(time.Second)
	fmt.Println("主 goroutine 结束")
}

输出:

worker 开始工作
worker 的 defer 执行了
主 goroutine 结束

runtime.Goexit() 会终止当前 goroutine,但 defer 仍然会执行,这是它与 panic 的相似之处,但它不会导致程序崩溃。

2. goroutine 没有 ID

与 Java 线程不同,Go 故意不暴露 goroutine 的 ID。这是设计决策:鼓励开发者用 channel 等机制协调 goroutine,而不是依赖 ID。runtime 包没有提供获取 goroutine ID 的官方方法,虽然可以通过读取栈信息hack 出来,但不推荐在生产代码中使用。

四、main goroutine 退出导致所有子 goroutine 终止

这是 Go 并发编程中非常重要的一条规则:主 goroutine 退出时,所有子 goroutine 都会被立即终止,没有机会执行 defer,也不会优雅退出。

go
package main

import (
	"fmt"
	"time"
)

func backgroundTask() {
	defer fmt.Println("后台任务的 defer") // 不会执行!
	for i := 0; i < 10; i++ {
		fmt.Printf("后台任务运行 %d\n", i)
		time.Sleep(200 * time.Millisecond)
	}
}

func main() {
	go backgroundTask()

	time.Sleep(500 * time.Millisecond)
	fmt.Println("主 goroutine 准备退出")
	// 主 goroutine 退出,backgroundTask 被强制终止
	// 它的 defer 不会执行
}

输出大致是:

后台任务运行 0
后台任务运行 1
后台任务运行 2
主 goroutine 准备退出

注意「后台任务的 defer」这一行没有输出,说明子 goroutine 是被粗暴终止的。

实践建议:永远不要依赖「程序退出时子 goroutine 会自己结束」,而应该用 WaitGroup 或其他机制确保主 goroutine 等待所有需要完成的子 goroutine。这也是实现「优雅退出」的基础。

五、使用 WaitGroup 等待 goroutine 完成

sync.WaitGroup 是等待一组 goroutine 完成的标准工具。它本质是一个计数器:每启动一个 goroutine 就 Add(1),每个 goroutine 完成时 Done()(即 Add(-1)),主 goroutine 调用 Wait() 阻塞直到计数器归零。

1. 基础用法

go
package main

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

func worker(id int, wg *sync.WaitGroup) {
	defer wg.Done() // 完成时计数器减 1

	fmt.Printf("worker %d 开始工作\n", id)
	time.Sleep(time.Second) // 模拟工作
	fmt.Printf("worker %d 完成\n", id)
}

func main() {
	var wg sync.WaitGroup

	for i := 1; i <= 3; i++ {
		wg.Add(1) // 启动前计数器加 1
		go worker(i, &wg)
	}

	wg.Wait() // 等待所有 worker 完成
	fmt.Println("所有 worker 完成,主 goroutine 退出")
}

2. WaitGroup 的工作原理

可以把 WaitGroup 想象成一个门禁计数器:

  • Add(n):计数器加 n(通常是 1,表示新增一个任务)。
  • Done():计数器减 1(内部就是 Add(-1))。
  • Wait():如果计数器大于 0,阻塞;归零时唤醒所有等待者。

3. 正确的使用模式

注意几个要点:

  • Add 必须在 goroutine 启动之前调用,不能在 goroutine 内部调用,否则可能 Wait 已经返回了 goroutine 才 Add
  • 传给 goroutine 的必须是 WaitGroup 的指针,否则每个 goroutine 拿到的是副本,Done 操作不影响原始计数器。
  • Done 用 defer 调用最安全,确保 goroutine 无论正常返回还是 panic 都会减计数器。
go
package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup

	// ✅ 正确模式:先 Add,再启动 goroutine
	tasks := []string{"任务A", "任务B", "任务C"}
	for _, task := range tasks {
		wg.Add(1)
		go func(name string) {
			defer wg.Done()
			fmt.Printf("完成 %s\n", name)
		}(task)
	}
	wg.Wait()
	fmt.Println("全部完成")
}

4. 复用 WaitGroup 的注意事项

WaitGroup 不能在 Wait 返回前重用,否则会触发 panic。看一个错误的例子:

go
package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup

	// 错误:在 Wait 期间又 Add,会 panic
	wg.Add(1)
	go func() {
		defer wg.Done()
		// 这里如果再 wg.Add(1) 会有问题
	}()

	wg.Wait()
	fmt.Println("完成")

	// ✅ 正确:Wait 返回后可以重用
	wg.Add(1)
	go func() {
		defer wg.Done()
		fmt.Println("第二批")
	}()
	wg.Wait()
}

如果需要多轮等待,最安全的做法是每轮用一个新的 WaitGroup。

六、goroutine 泄漏:原因与检测

goroutine 泄漏是指 goroutine 启动后永远无法退出,一直占用资源(栈、内存、调度开销),却没有做任何有用的工作。这是 Go 程序中最常见的内存泄漏类型。

1. 泄漏的典型场景

最常见的泄漏场景是:goroutine 阻塞在 channel 操作上,而没有任何其他 goroutine 会向该 channel 发送或接收数据。

go
package main

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

// ❌ 泄漏的例子:goroutine 永远阻塞在接收上
func leakyFunction() {
	ch := make(chan int)
	go func() {
		value := <-ch // 永远阻塞,因为没人发送
		fmt.Println("收到:", value)
	}()
	// 函数返回后,ch 没有其他引用,但 goroutine 还在等
}

// ✅ 正确的例子:用 context 控制超时
func safeFunction() {
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	ch := make(chan int, 1)
	go func() {
		// 模拟耗时工作
		time.Sleep(5 * time.Second)
		ch <- 42
	}()

	select {
	case value := <-ch:
		fmt.Println("收到:", value)
	case <-ctx.Done():
		fmt.Println("超时,goroutine 会被 GC(因为 ch 不再被引用)")
	}
}

func main() {
	leakyFunction()
	safeFunction()
	time.Sleep(time.Second)
}

leakyFunction 中的 goroutine 会永远阻塞在 <-ch 上,由于 ch 只被这个 goroutine 引用,它也无法被 GC 回收——这就是泄漏。

safeFunctionselect 监听 ctx.Done(),超时后函数返回,ch 不再被引用,goroutine 虽然还在阻塞但 channel 变得不可达,最终会被 GC(注意:这依赖 Go 的实现,最佳实践是让 goroutine 自己感知取消信号退出)。

2. 更隐蔽的泄漏:发送方阻塞

go
package main

import (
	"fmt"
	"time"
)

func leakySender() {
	ch := make(chan int) // 无缓冲
	go func() {
		ch <- 1 // 永远阻塞,因为没有接收方
		fmt.Println("发送成功") // 不会执行
	}()
}

func main() {
	for i := 0; i < 10; i++ {
		leakySender() // 每次调用都泄漏一个 goroutine
	}
	time.Sleep(time.Second)
	fmt.Println("已泄漏 10 个 goroutine")
}

3. 如何检测泄漏

  • runtime.NumGoroutine():打印当前 goroutine 数量,如果持续增长可能有泄漏。
  • pprofnet/http/pprof 包提供 goroutine 分析,可以在 /debug/pprof/goroutine 查看。
  • 测试:用 goleak 库(go.uber.org/goleak)在测试结束时检测是否有意外残留的 goroutine。
go
package main

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

func main() {
	start := runtime.NumGoroutine()
	fmt.Println("初始 goroutine 数:", start)

	for i := 0; i < 100; i++ {
		ch := make(chan int)
		go func() {
			<-ch // 泄漏
		}()
	}

	time.Sleep(time.Second)
	now := runtime.NumGoroutine()
	fmt.Println("泄漏后 goroutine 数:", now)
	fmt.Println("泄漏了", now-start, "个 goroutine")
}

七、goroutine 数量控制

虽然 goroutine 很轻量,但「无限创建 goroutine」仍然是坏习惯。每个 goroutine 都有自己的栈、调度开销,而且如果它们都在访问同一资源(如数据库、外部 API),数量过多会压垮下游。

1. 用带缓冲 channel 做信号量

go
package main

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

func main() {
	const maxConcurrent = 3 // 最多同时 3 个 goroutine
	sem := make(chan struct{}, maxConcurrent)
	var wg sync.WaitGroup

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

			// 获取信号量
			sem <- struct{}{}
			fmt.Printf("goroutine %d 开始 (当前并发: %d)\n", id, len(sem))

			time.Sleep(time.Second) // 模拟工作

			fmt.Printf("goroutine %d 结束\n", id)
			<-sem // 释放信号量
		}(i)
	}

	wg.Wait()
	fmt.Println("全部完成")
}

2. 固定数量的 worker 模式

另一种控制方式是启动固定数量的 worker,从任务队列取任务执行。这种模式更稳定,goroutine 数量完全可控:

go
package main

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

func main() {
	const numWorkers = 3
	tasks := make(chan int, 10)
	var wg sync.WaitGroup

	// 启动固定数量的 worker
	for w := 1; w <= numWorkers; w++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			for task := range tasks {
				fmt.Printf("worker %d 处理任务 %d\n", id, task)
				time.Sleep(500 * time.Millisecond)
			}
		}(w)
	}

	// 投递任务
	for i := 1; i <= 10; i++ {
		tasks <- i
	}
	close(tasks) // 关闭通道,worker 处理完所有任务后退出

	wg.Wait()
	fmt.Println("所有任务完成")
}

关于 goroutine 数量控制的更深入内容(如 semaphore 包、errgroup),我们会在并发模式和高级并发章节中详细介绍。

八、runtime.Gosched() 和 runtime.GOMAXPROCS()

1. runtime.Gosched()

runtime.Gosched() 让当前 goroutine 主动让出 CPU,给其他 goroutine 执行的机会。调度器会在稍后重新调度这个 goroutine。

go
package main

import (
	"fmt"
	"runtime"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	wg.Add(2)

	go func() {
		defer wg.Done()
		for i := 0; i < 5; i++ {
			fmt.Printf("A%d ", i)
			runtime.Gosched() // 主动让出
		}
	}()

	go func() {
		defer wg.Done()
		for i := 0; i < 5; i++ {
			fmt.Printf("B%d ", i)
			runtime.Gosched() // 主动让出
		}
	}()

	wg.Wait()
	fmt.Println("\n完成")
}

在现代 Go 中,由于有了抢占式调度,Gosched() 的使用场景很少了——调度器会自动在合适的时机切换 goroutine。一般只有在某些极端情况下(比如你想让某个 goroutine 主动配合调度)才需要手动调用。

2. runtime.GOMAXPROCS()

我们在上一篇已经介绍过 GOMAXPROCS,这里补充一个细节:runtime.GOMAXPROCS(n) 会设置 P 的数量并返回之前的值。传 0 表示只查询不修改。

go
package main

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

func main() {
	fmt.Println("默认 GOMAXPROCS:", runtime.GOMAXPROCS(0))
	fmt.Println("CPU 核心数:", runtime.NumCPU())

	// 临时限制为单核(例如在做 benchmark 时)
	prev := runtime.GOMAXPROCS(1)
	defer runtime.GOMAXPROCS(prev) // 恢复

	var wg sync.WaitGroup
	start := time.Now()
	wg.Add(2)
	go func() {
		defer wg.Done()
		time.Sleep(time.Second)
	}()
	go func() {
		defer wg.Done()
		time.Sleep(time.Second)
	}()
	wg.Wait()
	fmt.Println("单核下两个 goroutine 耗时:", time.Since(start))
}

3. 其他有用的 runtime 函数

  • runtime.NumGoroutine():返回当前 goroutine 数量,可用于检测泄漏。
  • runtime.NumCPU():返回 CPU 逻辑核心数。
  • runtime.GC():手动触发 GC(一般不需要)。
  • runtime.ReadMemStats():读取内存统计信息。
go
package main

import (
	"fmt"
	"runtime"
)

func main() {
	fmt.Println("当前 goroutine 数:", runtime.NumGoroutine())
	fmt.Println("CPU 核心数:", runtime.NumCPU())
	fmt.Println("Go 版本:", runtime.Version())

	var m runtime.MemStats
	runtime.ReadMemStats(&m)
	fmt.Printf("已分配内存: %d KB\n", m.Alloc/1024)
	fmt.Printf("GC 次数: %d\n", m.NumGC)
}

九、小结

本篇我们学习了 goroutine 的启动与生命周期管理,核心要点如下:

  1. 启动 goroutine:用 go 关键字,参数在 go 语句执行时就被求值并传入。可以用普通函数、方法或匿名函数。

  2. 匿名函数传参:启动 goroutine 时推荐显式传参,避免闭包捕获循环变量的陷阱(Go 1.22 已修复,但显式传参更安全清晰)。

  3. 退出机制:goroutine 在函数返回、panic 未 recover、主 goroutine 退出或调用 Goexit() 时终止。注意主 goroutine 退出会强制终止所有子 goroutine,它们的 defer 不会执行。

  4. WaitGroup:等待一组 goroutine 完成的标准工具。关键规则:Add 必须在 goroutine 启动前调用,传指针给 goroutine,Done 用 defer 调用。不能在 Wait 期间重用 WaitGroup。

  5. goroutine 泄漏:最常见的原因是 goroutine 阻塞在无人处理的 channel 上。用 runtime.NumGoroutine()、pprof、goleak 检测。预防方法是用 select 监听取消信号、设置超时。

  6. 数量控制:用带缓冲 channel 作为信号量,或用固定数量 worker 模式,避免无限创建 goroutine 压垮下游资源。

  7. runtime 工具Gosched() 主动让出 CPU(现代 Go 中少用),GOMAXPROCS() 控制 P 数量,NumGoroutine() 监控 goroutine 数量。

掌握了 goroutine 的启动和管理,下一篇我们将学习 goroutine 之间如何通信——channel,这是 Go 并发编程的核心抽象。