Skip to main content
今日目标:Python 的多线程受到 GIL 的限制,属于“伪并发”。而 Go 的并发是真并行。今天我们将学习如何启动成千上万个轻量级线程 (Goroutine),并使用 Channel 在它们之间优雅地传递数据,实现 “不要通过共享内存来通信,而要通过通信来共享内存” 的 Go 核心哲学。

学习内容 (30 mins)

在开始写代码前,先搞懂这些核心概念,否则后面的代码你会看得云里雾里。
为什么 Go 能轻松支持百万级并发?
  • OS 线程: JVM 或 Python 调用的通常是操作系统线程,一个就要消耗约 2MB 内存,启动和切换都很慢。
  • Goroutine: 这是由 Go 运行时管理的“用户态线程”。启动一个只需约 2KB 内存。
  • 使用方法: 在任何函数前输入 go 即可(例如 go task())。
  • 生命周期: 主线程 main 像是一个指挥官,如果指挥官下班了(main 执行结束),他在外面派出的所有斥候(Goroutines)都会被强制瞬间销毁。此特性需要特别注意。
并发里的安全传送带
  • 痛点: 最怕多个线程同时抢着改同一个变量(竞态条件),导致数据乱套。
  • Go 的解决方案: 既然抢数据会打架,那我们就把数据通过 Channel 传过去,谁接到谁处理。
  • 语法:
    • 创建: ch := make(chan int)
    • 发送: ch <- 1 (向通道塞数据)
    • 接收: x := <-ch (从通道拿数据)
  • 阻塞特性: 无缓冲的 channel,发送方会阻塞直到有人接收,接收方也会阻塞直到有人发送。这种阻塞是天生的同步机制。
如何优雅地等待?sync.WaitGroup 是一个计数器,用来等待一组 Goroutine 完成。
  • Add(n): “报告长官,派出了 n 个人!”
  • Done(): “报告长官,我干完活了!”(计数器 -1)
  • Wait(): 阻塞主线程,直到计数器归零。

代码任务 (90 mins)

1

任务 A: 感受并发的“随机性”

通过这个例子,你会理解为什么并发程序需要协调。
代码解释
  • go task("Agent-001"):启动一个 Goroutine,异步执行
  • time.Sleep(500 * time.Millisecond):主线程等待,防止提前退出
  • 并发执行:两个 Goroutine 同时运行,输出顺序不确定
Go 并发的设计哲学
  • “不要通过共享内存来通信,而要通过通信来共享内存”:这是 Go 的核心哲学
  • 轻量级:Goroutine 比线程轻量得多,可以轻松创建成千上万个
  • 自动调度:Go 运行时自动在多个 OS 线程上调度 Goroutine
验证步骤
  1. 运行程序,观察输出顺序。你会发现 Agent-001 和 Agent-002 的输出是交替的,而且每次运行的交替顺序可能都不一样。
  2. 尝试注释掉 time.Sleep 那一行,再运行。你会发现很可能什么都打印不出来,程序就结束了。
  3. 多次运行,观察输出顺序的变化,理解并发的非确定性
常见错误
  • ❌ 忘记等待:主线程退出导致子协程被杀。
  • ❌ 竞态条件:如果两个 task 同时修改一个全局变量且没加锁,数据会出错(虽然本例全是打印,没有共享变量)。
2

任务 B: 生产者-消费者模式 (Channel)

模拟一个“日志收集器”:抓取器负责生产日志,存储器负责消费并保存。这是 Channel 最经典的应用场景。
代码解释
  • chan<- string:只发送通道(Send Only)
  • <-chan string:只接收通道(Receive Only)
  • close(logQueue):关闭通道,通知接收方没有更多数据
  • for msg := range logQueue:优雅地从通道读取,直到通道关闭
Channel 的设计哲学
  • 通信即同步:Channel 既是数据传递的通道,也是同步的机制
  • 阻塞特性:无缓冲通道天然提供同步,发送方和接收方必须同时准备好
  • 关闭信号:关闭通道是一种信号,表示没有更多数据
验证步骤
  1. 运行程序:go run 24_channel.go
  2. 观察生产者和消费者的协作过程
  3. 尝试注释掉 close(logQueue),观察程序是否会死锁
3

任务 C: WaitGroup 并发大杀器

模拟同时对多个 IP 进行 Ping 操作。这是以后写并发脚本最常用的模板。
代码解释
  • *sync.WaitGroup:必须传指针,否则是副本,Done() 不会生效
  • defer wg.Done():使用 defer 确保即使函数出错也会调用 Done
  • wg.Add(1):在启动 Goroutine 前增加计数
  • wg.Wait():阻塞直到所有 Goroutine 完成
WaitGroup 的设计哲学
  • 计数器模式:通过计数跟踪 Goroutine 的完成状态
  • 必须传指针:WaitGroup 是值类型,必须传指针才能共享状态
  • defer 保护:使用 defer 确保 Done 一定会被调用
验证步骤
  1. 运行程序:go run 24_waitgroup.go
  2. 观察所有主机检查的并发执行
  3. 尝试修改 wg.Add(1) 的位置,观察行为变化
常见错误
  • ❌ 传值而不是传指针:如果在 checkHost 里写 wg sync.WaitGroup,那么那是 wg 的一个副本。你在副本上调用 Done(),主线程里的 wg 根本不知道,最后 Wait() 会永久卡死。
  • ❌ 忘记 Add():如果你没有 Add 就 Wait,或者 Add 数量少于任务数,逻辑会错乱。一般建议在 go 关键字之前立即调用 Add(1)

拓展任务 (30 mins)

带缓冲的 Channel 挑战

任务:将 make(chan string) 改为 make(chan string, 10)实验:在 LogProducer 里不加 Sleep。观察运行行为。
  • 现象: 生产者会瞬间把 4 条日志塞进去然后退出,而不是像无缓冲那样必须等消费者拿走一条才能塞下一条。
  • 思考: 这种模式适合应对什么样的流量高峰?

Timeout 挑战 (Select 语句)

任务:查阅 select 关键字。挑战:在主线程接收 <-done 时,如果过了一秒还没干完,就打印“Timeout! Program terminated.”。这是防止程序hang死的常见手段。

今日产出物

  • ~/projects/learn-go/24_race.go - 并发竞争演示
  • ~/projects/learn-go/24_channel.go - 经典解耦流水线
  • ~/projects/learn-go/24_waitgroup.go - 批量处理同步模板

参考代码

查看参考代码

在 GitHub 查看完整的示例代码

Go Playground

使用在线编辑器运行 Go 代码

实际应用场景

为什么 Go 开发的 Agent 强?

  • 极低抖动: 即使监控成千上万个文件,Goroutine 的上下文切换开销也极小,不会导致 CPU 突然飙升。
  • 原生的超时控制: 在处理网络请求时,使用 context 结合 select 可以完美控制每个连接的超时,防止把系统卡死。

并发 vs 并行

  • 并发 (Concurrency): 多个任务在一个时间段内运行(可能是单核上的快速切换)。
  • 并行 (Parallelism): 多个任务在同一个时刻同时运行(需要多核 CPU 支持)。
  • Go 的优势: 它自动帮你在这两者之间调度,你只需要关心 go 关键字。
与 Day 25 的关联:今天掌握了“内功”原理,明天我们将把这些并发技巧应用到真正的运维场景中,开发一个高并发端口扫描器,实测它比 Python 版本快多少倍。

回到目录

查看完整进度

下一天: 工具开发 I

Day 25 | Go 实战工具开发 (一)