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: 感受并发的“随机性”

通过这个例子,你会理解为什么并发程序需要协调。
package main

import (
    "fmt"
    "time"
)

func task(name string) {
    for i := 1; i <= 3; i++ {
        // 模拟干活耗时 100ms
        time.Sleep(100 * time.Millisecond)
        fmt.Printf("[%s] is processing step %d\n", name, i)
    }
}

func main() {
    // 瞬间派出两名员工,它们会同时开始跑
    go task("Agent-001")
    go task("Agent-002")

    // ⚠️ 极其重要: 主线程必须等待
    // 如果没有下面这行 Sleep,main 函数会立即退出
    // 导致上面的两个协程还没来得及打印就被系统杀死了
    time.Sleep(500 * time.Millisecond) 
    fmt.Println("Main thread quit.")
}
代码解释
  • 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 最经典的应用场景。
package main

import (
    "fmt"
    "time"
)

// LogProducer 模拟日志抓取
// chan<- 声明该 channel 只能用于发送内容 (Send Only)
func LogProducer(logQueue chan<- string) {
    logs := []string{"User login", "API 404 error", "DB query timeout", "Backup success"}
    
    for _, msg := range logs {
        fmt.Println("🚀 [Scraper] Captured log:", msg)
        logQueue <- msg // 将日志塞入传送带 (如果没人接,这里会阻塞)
        time.Sleep(200 * time.Millisecond)
    }
    // 重要:发完了必须关闭,否则消费者会永远等待(导致死锁)
    close(logQueue)
}

// LogConsumer 模拟日志持久化
// <-chan 声明该 channel 只能用于接收内容 (Receive Only)
func LogConsumer(logQueue <-chan string, done chan<- bool) {
    // 使用 range 优雅地从 Channel 读取
    // 它会一直循环,直到 Channel 被 Close() 且数据读完
    for msg := range logQueue {
        fmt.Printf("💾 [Storage] Saving to DB: %s...\n", msg)
        time.Sleep(300 * time.Millisecond) // 模拟存入数据库比较慢
    }
    fmt.Println("✅ [Storage] All logs processed.")
    
    // 任务全部搞定,给主线程发个信号
    done <- true
}

func main() {
    // 1. 创建无缓冲通道(传送带)
    logQueue := make(chan string)
    // 2. 用于同步的完成状态通道
    done := make(chan bool)

    // 启动并发流程
    go LogProducer(logQueue)
    go LogConsumer(logQueue, done)

    // 3. 阻塞主线程,等待 done 信号
    fmt.Println("System running...")
    <-done // 如果没有这行,主线程会直接退出
    fmt.Println("System stopped.")
}
代码解释
  • 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 操作。这是以后写并发脚本最常用的模板。
package main

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

// 注意:wg 必须传指针 *sync.WaitGroup,否则是拷贝,Done() 不会生效!
func checkHost(ip string, wg *sync.WaitGroup) {
    // 函数退出前,一定要通知计数器 -1
    // 使用 defer 是为了防止中间代码崩了导致没减计数,导致主线程死锁
    defer wg.Done()
    
    fmt.Printf("🔍 Checking host: %s\n", ip)
    time.Sleep(1 * time.Second) // 模拟网络延迟
    fmt.Printf("✅ %s is ONLINE\n", ip)
}

func main() {
    var wg sync.WaitGroup
    hosts := []string{"10.0.0.1", "10.0.0.2", "192.168.1.100", "8.8.8.8"}

    for _, ip := range hosts {
        wg.Add(1) // 派人,计数器 +1
        
        // 启动协程
        // 注意:这里传的是 &wg (指针)
        go checkHost(ip, &wg)
    }

    fmt.Println("⏳ Monitoring started. Waiting for all results...")
    
    // 这里会卡住,直到计数器归零
    wg.Wait()
    
    fmt.Println("🚀 All hosts checked. Report generated.")
}
代码解释
  • *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 实战工具开发 (一)