404 Not Found

404 Not Found


nginx

Goroutine

第13课:Goroutine(协程)

想象一家餐厅:如果只有一个服务员(单线程),他必须为每桌客人点完菜、等上菜、结账后才能服务下一桌。而如果雇很多服务员(多线程),成本又很高。Go 的方案是——雇一群轻量级的兼职服务员(goroutine),他们共享同一套餐厅系统(调度器),谁有空谁就去服务,用极少的人力高效应对大量客人。


核心概念


基本语法与用法

启动 Goroutine

在函数调用前加 go 关键字即可启动一个新的 goroutine:

GO
go 函数名(参数)
// 或
go func() {
    // 匿名函数体
}()

用 sync.WaitGroup 等待 Goroutine

由于主 goroutine 不会自动等待子 goroutine,需要使用 sync.WaitGroup 同步:

GO
var wg sync.WaitGroup

wg.Add(1)        // 计数器 +1
go func() {
    defer wg.Done() // 完成时计数器 -1
    // 执行任务
}()
wg.Wait()        // 阻塞直到计数器归零
💡 Tipwg.Add() 必须在 go 语句之前调用,否则可能出现竞态条件——主 goroutine 还没来得及 Add 就已经 Wait 返回了。

💡 Tip:不要将 wg.Add() 放在 goroutine 内部,否则 wg.Wait() 可能在 Add 之前就执行了。

💡 Tip:始终用 defer wg.Done() 保证即使发生 panic,计数器也能正确递减。


示例

示例:启动多个 Goroutine(难度⭐)

GO
package main

import (
	"fmt"
	"sync"
)

func sayHello(id int, wg *sync.WaitGroup) {
	defer wg.Done() // 完成时通知 WaitGroup
	fmt.Printf("你好,我是 goroutine #%d\n", id)
}

func main() {
	var wg sync.WaitGroup

	for i := 1; i <= 5; i++ {
		wg.Add(1)
		go sayHello(i, &wg)
	}

	wg.Wait() // 等待所有 goroutine 完成
	fmt.Println("所有 goroutine 已完成")
}
▶ 试一试

输出顺序不确定(并发执行):

TEXT
你好,我是 goroutine #3
你好,我是 goroutine #1
你好,我是 goroutine #5
你好,我是 goroutine #2
你好,我是 goroutine #4
所有 goroutine 已完成

示例:并发计算与结果汇总(难度⭐⭐)

GO
package main

import (
	"fmt"
	"sync"
)

// 并发计算平方值并存入共享切片
func main() {
	numbers := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
	results := make([]int, len(numbers))

	var wg sync.WaitGroup

	for i, num := range numbers {
		wg.Add(1)
		go func(index, val int) {
			defer wg.Done()
			results[index] = val * val // 各 goroutine 写不同索引,无需加锁
		}(i, num) // 注意:将循环变量作为参数传入,避免闭包捕获问题
	}

	wg.Wait()

	fmt.Println("原始数据:", numbers)
	fmt.Println("平方结果:", results)
}
▶ 试一试

输出:

TEXT
原始数据: [1 2 3 4 5 6 7 8 9 10]
平方结果: [1 4 9 16 25 36 49 64 81 100]
💡 Tip:Go 1.22 起,for 循环变量每次迭代都会创建新副本,不再需要显式传参。但为了兼容性和可读性,仍然推荐显式传递。


示例:并发任务池与 Goroutine 泄漏防范(难度⭐⭐⭐)

GO
package main

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

// task 表示一个需要处理的任务
func task(ctx context.Context, id int) (string, error) {
	// 模拟耗时操作
	duration := time.Duration(rand.Intn(500)) * time.Millisecond

	select {
	case <-time.After(duration):
		return fmt.Sprintf("任务 #%d 完成 (耗时 %v)", id, duration), nil
	case <-ctx.Done():
		return "", ctx.Err() // 上下文取消时立即返回,防止 goroutine 泄漏
	}
}

func main() {
	rand.Seed(time.Now().UnixNano())

	// 设置总超时,防止 goroutine 泄漏
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel() // 确保资源释放

	taskCount := 20
	results := make(chan string, taskCount) // 带缓冲的 channel 收集结果
	var wg sync.WaitGroup

	// 启动 5 个 worker goroutine 组成任务池
	workerCount := 5
	for w := 1; w <= workerCount; w++ {
		wg.Add(1)
		go func(workerID int) {
			defer wg.Done()
			for i := workerID; i <= taskCount; i += workerCount {
				res, err := task(ctx, i)
				if err != nil {
					fmt.Printf("Worker %d: 任务 #%d 被取消\n", workerID, i)
					return
				}
				results <- res
			}
		}(w)
	}

	// 等待所有 worker 完成后关闭 channel
	go func() {
		wg.Wait()
		close(results)
	}()

	// 收集结果
	for res := range results {
		fmt.Println(res)
	}

	fmt.Println("全部任务处理完毕")
}
▶ 试一试

关键点:


实际应用场景

场景1:并发 HTTP 请求

GO
package main

import (
	"fmt"
	"io"
	"net/http"
	"sync"
	"time"
)

func fetchURL(url string, wg *sync.WaitGroup) {
	defer wg.Done()

	start := time.Now()
	resp, err := http.Get(url)
	if err != nil {
		fmt.Printf("[错误] %s: %v\n", url, err)
		return
	}
	defer resp.Body.Close()

	body, _ := io.ReadAll(resp.Body)
	elapsed := time.Since(start)
	fmt.Printf("[完成] %s — 状态: %d, 大小: %d 字节, 耗时: %v\n",
		url, resp.StatusCode, len(body), elapsed)
}

func main() {
	urls := []string{
		"https://httpbin.org/get",
		"https://httpbin.org/delay/1",
		"https://httpbin.org/delay/2",
		"https://httpbin.org/status/200",
	}

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

	for _, url := range urls {
		wg.Add(1)
		go fetchURL(url, &wg)
	}

	wg.Wait()
	fmt.Printf("\n全部完成,总耗时: %v\n", time.Since(start))
	// 总耗时约等于最慢请求的时间,而非所有请求之和
}

场景2:日志异步写入

GO
package main

import (
	"fmt"
	"time"
)

type LogEntry struct {
	Level   string
	Message string
}

// logWriter 在后台 goroutine 中异步消费日志
func logWriter(entries <-chan LogEntry, done chan<- struct{}) {
	for entry := range entries {
		// 模拟写入文件或远程服务
		time.Sleep(50 * time.Millisecond)
		fmt.Printf("[%s] %s\n", entry.Level, entry.Message)
	}
	done <- struct{}{} // 通知主 goroutine 已完成
}

func main() {
	logCh := make(chan LogEntry, 100) // 带缓冲 channel 作为日志队列
	done := make(chan struct{})

	// 启动后台日志写入 goroutine
	go logWriter(logCh, done)

	// 主程序正常执行,异步记录日志
	for i := 1; i <= 5; i++ {
		logCh <- LogEntry{
			Level:   "INFO",
			Message: fmt.Sprintf("处理请求 #%d", i),
		}
		fmt.Printf("已提交日志 #%d\n", i)
	}

	close(logCh) // 关闭 channel 通知写入器退出
	<-done       // 等待写入器完成
	fmt.Println("程序退出")
}

❓ 常见问题

1. 为什么我的 goroutine 没有执行?

主 goroutine 退出时会强制终止所有子 goroutine。常见原因:

GO
// ❌ 错误:主 goroutine 直接退出,子 goroutine 没机会运行
func main() {
	go fmt.Println("你好")
}

// ✅ 正确:使用 WaitGroup 等待
func main() {
	var wg sync.WaitGroup
	wg.Add(1)
	go func() {
		defer wg.Done()
		fmt.Println("你好")
	}()
	wg.Wait()
}

2. Goroutine 什么时候会泄漏?

Goroutine 泄漏指 goroutine 永远无法退出,持续占用内存和调度资源。常见原因:

GO
// ❌ 泄漏:channel 永远没有接收者
func leaky() <-chan int {
	ch := make(chan int)
	go func() {
		ch <- 42 // 永久阻塞,goroutine 无法退出
	}()
	return ch
}

// ✅ 安全:使用带缓冲的 channel 或 context 取消
func safe() <-chan int {
	ch := make(chan int, 1) // 缓冲为 1,发送不会阻塞
	go func() {
		ch <- 42
	}()
	return ch
}

3. 如何避免闭包捕获循环变量的经典陷阱?

GO
// ❌ Go 1.21 及之前:所有 goroutine 可能打印相同的值
for i := 0; i < 5; i++ {
	go func() {
		fmt.Println(i) // 可能全部打印 5
	}()
}

// ✅ 推荐写法:显式传参,兼容所有 Go 版本
for i := 0; i < 5; i++ {
	go func(n int) {
		fmt.Println(n) // 正确打印 0, 1, 2, 3, 4
	}(i)
}

4. 能否获取当前运行的 goroutine 数量?

GO
import "runtime"

fmt.Println("当前 goroutine 数量:", runtime.NumGoroutine())

这在调试 goroutine 泄漏时非常有用。正常程序空闲时通常只有 1-2 个 goroutine,如果数字持续增长则可能存在泄漏。


📖 小节

概念 说明
go 关键字 启动一个 goroutine,语法为 go func()
调度模型 G-M-P 模型,Go 运行时负责调度,对用户透明
轻量级 初始栈约 2KB,可创建数十万个
WaitGroup 用于等待一组 goroutine 全部完成
Context 用于传递取消信号和超时控制
泄漏防范 确保每个 goroutine 都有退出路径

核心原则:

  1. 始终确保 goroutine 有明确的退出条件。
  2. sync.WaitGroup 或 channel 同步主 goroutine 与子 goroutine。
  3. context 控制 goroutine 的生命周期,防止泄漏。
  4. 注意闭包与循环变量的交互(推荐显式传参)。

📝 作业

练习1:并发质数判断

编写程序,使用 goroutine 并发判断一组数字是否为质数,最后汇总输出结果。

GO
// 提示:
// - 定义 isPrime(n int) bool 函数
// - 为每个数字启动一个 goroutine
// - 使用 WaitGroup 等待全部完成
// - 用互斥锁或 channel 收集结果

练习2:并发下载模拟器

模拟并发下载文件:启动 3 个 worker goroutine 从任务队列中取任务执行,每个下载随机耗时 100-1000ms,统计总耗时和各 worker 完成的任务数。

GO
// 提示:
// - 使用 channel 作为任务队列
// - 每个 worker 从 channel 中读取任务
// - 使用 context.WithTimeout 控制总超时
// - 用 sync.Map 或互斥锁统计每个 worker 的完成数

练习3:Goroutine 泄漏检测器

编写一个工具函数,接受一个任务函数作为参数,执行该函数并监控 goroutine 数量。如果任务完成后 goroutine 数量比执行前多,说明可能存在泄漏,输出警告。

GO
// 提示:
// - 使用 runtime.NumGoroutine() 获取 goroutine 数量
// - 任务执行前后各记录一次数量
// - 执行后等待一小段时间(如 100ms)再检查
// - 输出检测结果

下一课:Channel(通道)→

Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏