并发编程基础:Goroutine入门、WaitGroup同步、Channel与Select多路复用

并发编程,是每个Go开发者绕不开的坎。说实话,我当年从Java转Go时,最震撼的就是Goroutine——这玩意儿太轻量了,几万甚至几十万个并发任务,放Java里早把JVM撑爆了,但在Go里跑得跟玩儿似的。

这一章,咱们就扎扎实实地把Go并发编程的四个核心点啃下来:GoroutineWaitGroupChannelSelect。别急,一个一个来。

1. Goroutine入门:go关键字

Goroutine是什么?说白了,就是Go语言里的“轻量级线程”。你只需要在函数调用前加一个 go 关键字,这个函数就会在一个新的Goroutine里异步执行。

package main

import (
    "fmt"
    "time"
)

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

func main() {
    go sayHello() // 启动一个goroutine
    time.Sleep(100 * time.Millisecond) // 等一等,不然main退出了goroutine就没了
    fmt.Println("Main function ends")
}

嗯,这里要注意:main函数退出时,所有Goroutine都会被强制终止。所以上面我加了个 time.Sleep,不然你根本看不到goroutine的输出。

避坑指南: 我曾经在线上服务里犯过一个低级错误——启动了一个goroutine去做异步日志上报,结果main函数直接退出了,日志全丢了。后来我养成了习惯:生产代码里几乎不用time.Sleep做同步,而是用WaitGroup或Channel。

Goroutine的调度由Go运行时管理,它运行在操作系统线程之上,但一个线程可以承载成千上万个Goroutine。我个人习惯把Goroutine看作“廉价的并发单元”,创建成本极低(栈初始只有几KB),所以你可以放心大胆地用。

2. WaitGroup同步

刚才说了,time.Sleep 不是正经的同步手段。那怎么等一组Goroutine都执行完呢?WaitGroup 就是干这个的。

package main

import (
    "fmt"
    "sync"
)

func worker(id int, wg *sync.WaitGroup) {
    defer wg.Done() // 任务完成时调用Done,计数器减1
    fmt.Printf("Worker %d starting\n", id)
    // 模拟工作
    fmt.Printf("Worker %d done\n", id)
}

func main() {
    var wg sync.WaitGroup

    for i := 1; i <= 5; i++ {
        wg.Add(1) // 每启动一个goroutine,计数器加1
        go worker(i, &wg)
    }

    wg.Wait() // 阻塞,直到计数器归零
    fmt.Println("All workers completed")
}

WaitGroup的原理很简单:内部维护一个计数器。Add 增加计数,Done 减少计数,Wait 阻塞直到计数为0。

个人经验: 我建议把 wg.Add(1) 放在启动goroutine之前,而不是goroutine内部。为什么?因为如果goroutine启动得太快,可能 Wait 已经执行了,但 Add 还没调用,导致提前退出。我在项目里踩过这个坑,后来就形成了这个习惯。

另外,wg.Done() 最好用 defer 调用。万一函数中间panic了,defer还能保证计数器减掉,不然就死锁了。

3. Channel基础:无缓冲与有缓冲

Channel是Goroutine之间通信的桥梁。Go的哲学是:“不要通过共享内存来通信,而要通过通信来共享内存。” Channel就是这句话的具体实现。

3.1 无缓冲Channel

无缓冲Channel,也叫同步Channel。发送操作会阻塞,直到有接收方准备好;接收操作也会阻塞,直到有发送方发送数据。

ch := make(chan int) // 无缓冲channel

go func() {
    ch <- 42 // 发送,会阻塞直到有人接收
}()

value := <-ch // 接收,会阻塞直到有人发送
fmt.Println(value) // 输出42

无缓冲Channel常用于同步场景。比如两个Goroutine需要“握手”,一个发完信号另一个才能继续。

3.2 有缓冲Channel

有缓冲Channel可以存一定数量的数据。发送操作只有在缓冲区满时才会阻塞,接收操作只有在缓冲区空时才会阻塞。

ch := make(chan int, 3) // 缓冲区大小为3

ch <- 1 // 不阻塞
ch <- 2 // 不阻塞
ch <- 3 // 不阻塞
// ch <- 4 // 如果取消注释,这里会阻塞,因为缓冲区满了

fmt.Println(<-ch) // 输出1
fmt.Println(<-ch) // 输出2
fmt.Println(<-ch) // 输出3

有缓冲Channel常用于解耦生产者和消费者。比如日志收集、任务队列等场景。

核心区别:
特性 无缓冲Channel 有缓冲Channel
发送阻塞条件 立即阻塞,直到有人接收 缓冲区满时才阻塞
接收阻塞条件 立即阻塞,直到有人发送 缓冲区空时才阻塞
典型用途 同步、信号传递 任务队列、数据流

你想想看,如果生产者比消费者快很多,用无缓冲Channel就会频繁阻塞生产者,导致性能下降。这时候有缓冲Channel就派上用场了,它能吸收短时间的流量波动。

4. Select多路复用

当你有多个Channel需要同时监听时,select 就是你的瑞士军刀。它类似于 switch,但每个case都是一个Channel操作。

package main

import (
    "fmt"
    "time"
)

func main() {
    ch1 := make(chan string)
    ch2 := make(chan string)

    go func() {
        time.Sleep(1 * time.Second)
        ch1 <- "one"
    }()

    go func() {
        time.Sleep(2 * time.Second)
        ch2 <- "two"
    }()

    select {
    case msg1 := <-ch1:
        fmt.Println("Received from ch1:", msg1)
    case msg2 := <-ch2:
        fmt.Println("Received from ch2:", msg2)
    case <-time.After(500 * time.Millisecond):
        fmt.Println("Timeout! No message received within 500ms")
    }
}

select会随机选择一个可执行的case。如果多个case同时就绪,Go会伪随机选择一个,避免饥饿。

实用技巧: 我经常用 select 配合 time.After 实现超时控制。比如调用外部API时,如果3秒没响应就返回超时错误,而不是一直卡着。这在微服务架构里特别重要,能防止级联故障。

还有一个经典用法:用select监听退出信号。比如一个后台worker,既要处理任务,又要能优雅退出:

for {
    select {
    case task := <-taskChan:
        // 处理任务
        process(task)
    case <-quitChan:
        // 收到退出信号,清理资源
        fmt.Println("Worker shutting down")
        return
    }
}

这种模式在微服务里太常见了。每个服务启动时都会开一堆Goroutine,每个Goroutine里都有一个select循环,监听任务和退出信号。这样服务关闭时,只要发一个信号,所有Goroutine就能优雅退出。

总结

这一章我们聊了Go并发编程的四个基础组件:

  • Goroutine:用 go 关键字启动轻量级并发任务
  • WaitGroup:同步一组Goroutine的完成
  • Channel:无缓冲用于同步,有缓冲用于解耦
  • Select:多路复用,处理多个Channel的读写

说实话,这些基础概念看起来简单,但真正用好需要大量实践。我记得刚学Go那会儿,总觉得Channel用起来别扭,不如锁直接。后来写多了才发现,Channel的威力在于组合——用Channel传递数据、用Select做超时控制、用WaitGroup做生命周期管理,这些组合起来就能构建出非常健壮的并发系统。

下一章,我们会深入探讨更高级的并发模式,比如扇出/扇入、管道模式、以及如何避免常见的并发陷阱。到时候见。