第六篇:《并发编程核心:Goroutine 与 Channel》

并发编程是 Go 语言最鲜明的标签,也是它能在云原生时代脱颖而出的关键。Go 没有沿用传统的线程加锁模型,而是提供了 Goroutine(轻量级协程)和 Channel(通信管道)这对组合拳。Goroutine 让你用 go 关键字就能启动一个并发任务,Channel 则提供了安全的 Goroutine 间通信机制。本文深入讲解 Goroutine 的启动与调度、Channel 的两种类型(无缓冲/有缓冲)、select 多路复用、sync 包中的 WaitGroup 和 Mutex,并通过生产者-消费者和工作池两个实战案例,帮你掌握 Go 并发编程的核心技能。

一、Goroutine:轻量级并发单元

1.1 什么是 Goroutine?

Goroutine 是 Go 运行时管理的轻量级线程。它的初始栈大小仅为 2KB(操作系统线程通常为 1-2MB),可以轻松创建成千上万个 Goroutine 而不会耗尽内存。

启动一个 Goroutine 只需要在函数调用前加上 go 关键字:

go 复制代码
package main

import (
    "fmt"
    "time"
)

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

func main() {
    // 启动一个 Goroutine 执行 sayHello
    go sayHello()

    // 主 Goroutine 等待 1 秒,否则子 Goroutine 可能还没执行完主程序就退出了
    time.Sleep(1 * time.Second)
    fmt.Println("Main function finished")
}

1.2 Goroutine 的调度模型:GMP

Go 运行时的调度器采用 GMP 模型:

调度器的工作原理:

GOMAXPROCS 决定了 P 的数量(默认等于 CPU 核心数)。

每个 P 维护一个本地 Goroutine 队列。

M 从 P 的队列中获取 G 并执行。

当一个 G 阻塞(如等待 I/O)时,M 会与 P 分离,P 会寻找或创建新的 M 继续执行其他 G。

当 G 被阻塞时,M 可以继续服务于其他 G,从而实现高效的并发。

设置 P 的数量(通常不需要手动调整):

go 复制代码
import "runtime"

func main() {
    // 获取 CPU 核心数
    fmt.Println(runtime.NumCPU())
    
    // 设置 P 的数量(一般保持默认)
    runtime.GOMAXPROCS(4)
}

1.3 sync.WaitGroup:等待所有 Goroutine 完成

使用 time.Sleep 等待不是一个好的做法。sync.WaitGroup 提供了更可靠的等待机制:

go 复制代码
package main

import (
    "fmt"
    "sync"
)

func worker(id int, wg *sync.WaitGroup) {
    defer wg.Done()  // 函数结束时通知 WaitGroup 该任务已完成
    fmt.Printf("Worker %d 开始工作\n", id)
    // 模拟耗时操作
    // time.Sleep(time.Second)
    fmt.Printf("Worker %d 完成\n", id)
}

func main() {
    var wg sync.WaitGroup

    for i := 0; i < 5; i++ {
        wg.Add(1)  // 增加一个等待计数
        go worker(i, &wg)
    }

    wg.Wait()  // 阻塞等待所有 Goroutine 完成
    fmt.Println("所有 Worker 已完成")
}

⚠️ 注意事项:

wg.Add(1) 必须在启动 Goroutine 之前调用,不能在 Goroutine 内部调用(否则可能因竞争条件导致计数错误)。

wg.Done() 等价于 wg.Add(-1),可以用 defer 确保一定执行。

WaitGroup 必须通过指针传递(值传递会拷贝计数器,导致死锁)。

二、Channel:Goroutine 间的通信管道

Go 并发模型的核心理念是:不要通过共享内存来通信,而要通过通信来共享内存。 Channel 正是实现这一理念的核心工具,它是一个类型化的通信管道,用于在 Goroutine 之间传递数据。

2.1 无缓冲 Channel(同步 Channel)

无缓冲 Channel 在发送和接收时都会阻塞,直到另一端准备就绪。它天然实现了 Goroutine 之间的同步。

go 复制代码
func main() {
    // 创建一个无缓冲的 int 类型 Channel
    ch := make(chan int)

    // 启动一个 Goroutine 发送数据
    go func() {
        fmt.Println("发送数据 42")
        ch <- 42  // 发送,阻塞直到有接收者
        fmt.Println("数据已发送完成")
    }()

    // 主 Goroutine 接收数据
    fmt.Println("等待接收数据...")
    value := <-ch  // 接收,阻塞直到有发送者
    fmt.Printf("接收到数据: %d\n", value)

    // 输出顺序:
    // 等待接收数据...
    // 发送数据 42
    // 接收到数据: 42
    // 数据已发送完成
}

💡 关键理解:无缓冲 Channel 要求发送和接收同时就绪,否则会阻塞等待。这提供了一种天然的同步机制。

2.2 有缓冲 Channel(异步 Channel)

有缓冲 Channel 在缓冲区未满时发送不阻塞,在缓冲区非空时接收不阻塞。

go 复制代码
func main() {
    // 创建一个容量为 2 的缓冲 Channel
    ch := make(chan int, 2)

    // 发送两个数据(不会阻塞)
    ch <- 1
    ch <- 2
    fmt.Println("两个数据已发送,缓冲区已满")

    // 再发送第三个数据会阻塞(缓冲区已满)
    // ch <- 3  // 死锁!

    // 接收数据
    fmt.Println(<-ch)  // 1
    fmt.Println(<-ch)  // 2
}

2.3 关闭 Channel 与遍历

发送方可以主动关闭 Channel,接收方可以通过 v, ok := <-ch 检测 Channel 是否已关闭。更常见的是使用 for range 自动遍历直到 Channel 关闭。

go 复制代码
func producer(ch chan<- int) {  // 只发送 Channel
    for i := 0; i < 5; i++ {
        ch <- i
    }
    close(ch)  // 发送完成后关闭
}

func main() {
    ch := make(chan int, 3)
    go producer(ch)

    // 使用 range 遍历 Channel,直到它被关闭
    for v := range ch {
        fmt.Println("接收到:", v)
    }
    fmt.Println("Channel 已关闭,遍历结束")
}

⚠️ Channel 操作规范:

只能由发送方关闭 Channel,接收方不应关闭。

关闭已关闭的 Channel 会引发 panic。

向已关闭的 Channel 发送数据会引发 panic。

从已关闭的 Channel 接收数据会返回零值(v, ok := <-ch 中 ok 为 false)。

2.4 Channel 的方向(只读/只写)

在函数签名中可以限制 Channel 的方向,提高类型安全:

go 复制代码
// 只发送 Channel
func sendOnly(ch chan<- int, value int) {
    ch <- value
    // <-ch  // 编译错误:不能从只发送 Channel 接收
}

// 只接收 Channel
func receiveOnly(ch <-chan int) {
    value := <-ch
    // ch <- 1  // 编译错误:不能向只接收 Channel 发送
    fmt.Println(value)
}

func main() {
    ch := make(chan int)
    go sendOnly(ch, 42)
    receiveOnly(ch)
}

三、select 多路复用

select 语句用于同时等待多个 Channel 操作,类似于 switch,但每个 case 是一个 Channel 操作:

go 复制代码
func main() {
    ch1 := make(chan string)
    ch2 := make(chan string)

    go func() {
        time.Sleep(2 * time.Second)
        ch1 <- "来自 ch1 的消息"
    }()

    go func() {
        time.Sleep(1 * time.Second)
        ch2 <- "来自 ch2 的消息"
    }()

    // 等待最先到达的消息
    select {
    case msg1 := <-ch1:
        fmt.Println(msg1)
    case msg2 := <-ch2:
        fmt.Println(msg2)
    case <-time.After(3 * time.Second):
        fmt.Println("超时 3 秒")
    }
    // 输出: 来自 ch2 的消息(因为 ch2 先到达)
}

3.1 select 的典型用途

(1)超时控制

go 复制代码
func doWork() string {
    time.Sleep(2 * time.Second)
    return "工作完成"
}

func main() {
    ch := make(chan string)
    go func() {
        ch <- doWork()
    }()

    select {
    case result := <-ch:
        fmt.Println(result)
    case <-time.After(1 * time.Second):
        fmt.Println("超时!工作未在 1 秒内完成")
    }
    // 输出: 超时!工作未在 1 秒内完成
}

(2)非阻塞的 Channel 操作

使用 default 分支实现非阻塞操作:

go 复制代码
func main() {
    ch := make(chan int)

    select {
    case v := <-ch:
        fmt.Println("接收到:", v)
    default:
        fmt.Println("没有数据可接收,不阻塞")
    }

    select {
    case ch <- 42:
        fmt.Println("发送成功")
    default:
        fmt.Println("缓冲区已满,不阻塞")
    }
}

四、互斥锁:保护共享资源

虽然 Channel 是 Go 推荐的并发通信方式,但某些场景下共享内存加锁仍然更合适(如缓存、状态管理)。sync.Mutex 提供了互斥锁,sync.RWMutex 提供了读写锁(允许并发读,互斥写)。

go 复制代码
type Counter struct {
    mu    sync.Mutex
    value int
}

func (c *Counter) Inc() {
    c.mu.Lock()
    c.value++
    c.mu.Unlock()
}

func (c *Counter) Value() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.value
}

func main() {
    var wg sync.WaitGroup
    counter := &Counter{}

    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            counter.Inc()
        }()
    }

    wg.Wait()
    fmt.Println(counter.Value())  // 1000(安全)
}

五、实战:并发工作池(Worker Pool)

工作池模式是一种经典的并发设计模式,用于限制同时执行的并发任务数量,避免资源耗尽:

go 复制代码
package main

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

// 任务结构体
type Task struct {
    ID   int
    Data string
}

// 工作池
type WorkerPool struct {
    numWorkers int
    taskQueue  chan Task
    wg         sync.WaitGroup
}

// 创建工作池
func NewWorkerPool(numWorkers int, queueSize int) *WorkerPool {
    return &WorkerPool{
        numWorkers: numWorkers,
        taskQueue:  make(chan Task, queueSize),
    }
}

// 启动工作池
func (wp *WorkerPool) Start() {
    for i := 0; i < wp.numWorkers; i++ {
        wp.wg.Add(1)
        go wp.worker(i)
    }
}

// 工作 Goroutine
func (wp *WorkerPool) worker(id int) {
    defer wp.wg.Done()
    for task := range wp.taskQueue {
        fmt.Printf("Worker %d 开始处理任务 %d: %s\n", id, task.ID, task.Data)
        time.Sleep(100 * time.Millisecond) // 模拟处理时间
        fmt.Printf("Worker %d 完成任务 %d\n", id, task.ID)
    }
}

// 提交任务
func (wp *WorkerPool) Submit(task Task) {
    wp.taskQueue <- task
}

// 关闭工作池(等待所有任务完成)
func (wp *WorkerPool) Stop() {
    close(wp.taskQueue)
    wp.wg.Wait()
}

func main() {
    // 创建 3 个工作 Goroutine,队列容量 10
    pool := NewWorkerPool(3, 10)
    pool.Start()

    // 提交 10 个任务
    for i := 0; i < 10; i++ {
        pool.Submit(Task{ID: i, Data: fmt.Sprintf("data-%d", i)})
    }

    // 等待所有任务完成
    pool.Stop()
    fmt.Println("所有任务已完成")
}

六、小结

Goroutine:go 关键字启动轻量级协程,栈大小仅 2KB,GMP 调度模型高效管理。

Channel:类型化的通信管道,无缓冲(同步)和有缓冲(异步)两种类型。

select:多路复用 Channel 操作,支持超时控制和默认分支(非阻塞)。

WaitGroup:等待一组 Goroutine 完成,Add、Done、Wait 三方法组合使用。

Mutex:sync.Mutex 互斥锁,sync.RWMutex 读写锁,保护共享资源。

相关推荐
llwszx2 小时前
【Java/Go后端手撸原生Agent(第五篇):多工具并行调用 + BashTool执行引擎 + Judge证据链升级】
java·后端·golang·状态机·pydantic·agnet·llm-as-judge
FfHUCisI2 小时前
Go语言程序结构 —— 变量、声明与零值机制
golang
超人不会飞_Jay3 小时前
Go课程2
开发语言·后端·golang
晴空了无痕15 小时前
从 Go 基础到 K8s:一条可落地的 Go 服务端成长路线
开发语言·后端·golang·kubernetes
qq_4523962319 小时前
第五篇:《接口与错误处理:Go 的哲学》
开发语言·后端·golang
布朗克16821 小时前
Go 入门到精通-29-测试与基准
开发语言·后端·golang·测试与基准
止语Lab1 天前
从 PHP 到 Go:真正迁移的是复杂度的归属
android·golang·php
灯澜忆梦1 天前
GO_并发编程---定时器
开发语言·后端·golang
Wang's Blog2 天前
Go-Zero项目开发10: 微服务治理中心实现原理分析
开发语言·微服务·golang