Go 并发(建议收藏)

Go 并发:从入门到掌握的核心能力

在现代软件开发中,性能与响应速度越来越成为衡量系统质量的重要标准。而“Go 并发”正是 Go 语言最引以为傲的特性之一。它不是简单地“多线程”或“多进程”,而是一种更高效、更安全、更易用的并发模型。对于初学者来说,理解 Go 并发,就像学会了一种全新的“思维模式”——不再只是按顺序执行代码,而是能同时处理多个任务,像一位高效的时间管理者。

Go 语言从设计之初就将并发作为第一优先级,其核心机制——goroutine 和 channel,让并发编程变得异常直观。相比传统语言中复杂的线程管理、锁机制和死锁问题,Go 通过轻量级的协程和通信原语,大大降低了并发编程的门槛。无论你是刚接触编程的新手,还是已有几年经验的中级开发者,掌握 Go 并发,都将显著提升你的工程能力。

本文将从基础概念出发,逐步带你深入理解 Go 并发的核心机制,并通过真实案例演示如何在项目中安全、高效地使用它。

goroutine:轻量级的并发执行单元

在 Go 中,goroutine 是实现并发的基本单位,你可以把它理解为“协程”或“轻量级线程”。与操作系统线程不同,goroutine 的创建和调度由 Go 运行时(runtime)管理,开销极小,一个 goroutine 只需约 2KB 的栈空间,而传统线程通常需要 1MB 以上。

想象一下:你有一个厨房,要同时煮饭、炒菜、煲汤。如果每个任务都交给一个厨师(线程),那厨房很快就会拥挤不堪。但如果你有一个“全能小助手”(goroutine),他能快速切换任务,每完成一步就暂停、等待下一个指令,效率反而更高。

在 Go 中,启动一个 goroutine 非常简单,只需在函数调用前加 go 关键字:

package main

import (
    "fmt"
    "time"
)

func sayHello(name string) {
    for i := 0; i < 3; i++ {
        fmt.Printf("Hello, %s (第 %d 次)\n", name, i+1)
        time.Sleep(100 * time.Millisecond) // 模拟耗时操作
    }
}

func main() {
    // 启动两个 goroutine 并发执行 sayHello
    go sayHello("Alice")
    go sayHello("Bob")

    // 主 goroutine 等待 1 秒,让子任务完成
    time.Sleep(1 * time.Second)
    fmt.Println("所有任务已结束")
}

代码注释

  • go sayHello("Alice"):启动一个名为 Alice 的 goroutine,立即返回,不阻塞主程序。
  • time.Sleep(100 * time.Millisecond):模拟 I/O 或计算耗时,让 goroutine 有时间执行。
  • time.Sleep(1 * time.Second):主 goroutine 等待 1 秒,确保子 goroutine 有足够时间输出内容。
  • 若没有这句等待,主函数可能在子任务执行完前就退出,导致输出不完整。

注意:主函数结束时,所有 goroutine 会立即终止,即使它们还没执行完。因此,使用 time.Sleep 是调试阶段常用的方法,但实际项目中更推荐使用 sync.WaitGroup 来精准控制等待。

channel:goroutine 之间的安全通信管道

goroutine 之间如何协作?答案是 channel。你可以把 channel 想象成一条“消息管道”——一个 goroutine 把数据“投递”进去,另一个 goroutine 从另一端“取出”数据。这种通信方式避免了共享内存带来的竞态问题。

channel 有两个关键操作:发送(<-)和接收(<-)。发送是往 channel 写数据,接收是从 channel 读数据。Go 的 channel 是类型安全的,只能传递指定类型的值。

package main

import (
    "fmt"
    "time"
)

func producer(ch chan<- int) {
    // 生产者 goroutine:生成数字并发送到 channel
    for i := 1; i <= 5; i++ {
        fmt.Printf("生产者:发送数字 %d\n", i)
        ch <- i // 发送数据到 channel
        time.Sleep(200 * time.Millisecond)
    }
    close(ch) // 关闭 channel,表示不再发送数据
}

func consumer(ch <-chan int) {
    // 消费者 goroutine:从 channel 接收数据
    for value := range ch {
        fmt.Printf("消费者:收到数字 %d\n", value)
        time.Sleep(300 * time.Millisecond)
    }
    fmt.Println("消费者:所有数据已接收,结束")
}

func main() {
    // 创建一个整型 channel
    ch := make(chan int)

    // 启动两个 goroutine:一个生产,一个消费
    go producer(ch)
    go consumer(ch)

    // 主 goroutine 等待所有任务完成(通过 channel 关闭自动触发 range 结束)
    time.Sleep(3 * time.Second)
    fmt.Println("主程序结束")
}

代码注释

  • ch := make(chan int):创建一个无缓冲的整型 channel。
  • chan<- int:表示该参数只能用于发送数据(单向 channel)。
  • <-chan int:表示该参数只能用于接收数据。
  • ch <- i:向 channel 发送数据,如果 channel 满(无缓冲时),发送会阻塞,直到有接收者。
  • for value := range ch:遍历 channel,当 channel 被关闭后,range 自动结束。
  • close(ch):关闭 channel,通知所有接收者“数据已结束”。

这种“生产者-消费者”模型是 Go 并发中最常见的模式,广泛用于日志处理、任务队列、数据流处理等场景。

无缓冲 vs 有缓冲 channel:理解阻塞与非阻塞

channel 的行为取决于是否缓冲。理解这两者的区别,是写出健壮并发代码的关键。

  • 无缓冲 channel:发送方必须等待接收方准备好才能发送,否则阻塞。它就像“一对一的电话通话”——只有对方接起,你才能说话。
  • 有缓冲 channel:发送方可以在缓冲区未满时直接发送,不阻塞。它就像“信件投递箱”——只要箱子里还有空位,你随时可以投递,接收方可以随时取走。
package main

import (
    "fmt"
    "time"
)

func unbufferedChannel() {
    ch := make(chan string) // 无缓冲

    go func() {
        fmt.Println("发送方:准备发送")
        ch <- "Hello" // 发送,等待接收方
        fmt.Println("发送方:发送完成")
    }()

    fmt.Println("主程序:等待接收")
    msg := <-ch // 接收,阻塞直到发送完成
    fmt.Printf("主程序:收到消息:%s\n", msg)
}

func bufferedChannel() {
    ch := make(chan string, 2) // 有缓冲,容量为 2

    go func() {
        fmt.Println("发送方:准备发送")
        ch <- "A"
        fmt.Println("发送方:发送 A")
        ch <- "B"
        fmt.Println("发送方:发送 B")
        ch <- "C" // 这里会阻塞,因为缓冲区满了
        fmt.Println("发送方:发送 C")
    }()

    time.Sleep(100 * time.Millisecond)
    fmt.Println("主程序:开始接收")
    fmt.Println("收到:", <-ch)
    fmt.Println("收到:", <-ch)
    fmt.Println("收到:", <-ch) // 阻塞,直到发送方完成
}

func main() {
    fmt.Println("=== 无缓冲 channel ===")
    unbufferedChannel()

    fmt.Println("\n=== 有缓冲 channel ===")
    bufferedChannel()
}

代码注释

  • make(chan string, 2):创建容量为 2 的有缓冲 channel。
  • 当缓冲区满时,ch <- "C" 会阻塞,直到接收方取走数据。
  • 有缓冲 channel 适合用于“流量削峰”场景,比如处理突发请求。

并发安全的共享数据:避免竞态条件

在并发环境中,多个 goroutine 同时访问共享变量,容易引发“竞态条件”(race condition)。例如两个 goroutine 同时对一个计数器加 1,结果可能只加了 1 而不是 2。

Go 提供了 sync.Mutex 来保护共享资源,确保同一时间只有一个 goroutine 能访问临界区。

package main

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

var counter int
var mu sync.Mutex // 互斥锁

func increment(id int, wg *sync.WaitGroup) {
    defer wg.Done()

    for i := 0; i < 1000; i++ {
        mu.Lock()        // 加锁,进入临界区
        counter++
        mu.Unlock()      // 解锁,离开临界区
    }

    fmt.Printf("goroutine %d 完成,当前计数:%d\n", id, counter)
}

func main() {
    var wg sync.WaitGroup

    // 启动 10 个 goroutine 并发增加计数
    for i := 1; i <= 10; i++ {
        wg.Add(1)
        go increment(i, &wg)
    }

    wg.Wait() // 等待所有 goroutine 完成
    fmt.Printf("最终计数: %d\n", counter)
}

代码注释

  • mu.Lock():获取锁,如果已有其他 goroutine 持有锁,则当前 goroutine 阻塞。
  • mu.Unlock():释放锁,允许其他 goroutine 进入。
  • sync.WaitGroup:用于等待一组 goroutine 完成,避免主程序提前退出。
  • 没有锁的情况下,最终计数可能远小于 10000(10 × 1000),因为多次写入冲突。

实战案例:并发爬虫框架设计

让我们用 Go 并发构建一个简单的并发爬虫框架。它从多个 URL 并发下载网页内容,并通过 channel 传递结果。

package main

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

// 定义任务结构体
type Task struct {
    URL string
    ID  int
}

// 定义结果结构体
type Result struct {
    TaskID int
    URL    string
    Status string
    Length int
}

// 工作者函数:处理单个任务
func worker(tasks <-chan Task, results chan<- Result, wg *sync.WaitGroup) {
    defer wg.Done()

    for task := range tasks {
        resp, err := http.Get(task.URL)
        if err != nil {
            results <- Result{TaskID: task.ID, URL: task.URL, Status: "失败", Length: 0}
            continue
        }

        defer resp.Body.Close()
        length := len(resp.Body) // 这里简化处理,实际应读取全部内容
        results <- Result{
            TaskID: task.ID,
            URL:    task.URL,
            Status: "成功",
            Length: length,
        }
    }
}

func main() {
    // 任务列表
    urls := []string{
        "https://httpbin.org/delay/1",
        "https://httpbin.org/delay/2",
        "https://httpbin.org/delay/1",
    }

    tasks := make(chan Task, len(urls))
    results := make(chan Result, len(urls))

    var wg sync.WaitGroup

    // 启动 3 个 worker goroutine
    for i := 0; i < 3; i++ {
        wg.Add(1)
        go worker(tasks, results, &wg)
    }

    // 发送任务
    for i, url := range urls {
        tasks <- Task{ID: i + 1, URL: url}
    }
    close(tasks) // 关闭任务 channel

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

    // 收集结果
    for result := range results {
        fmt.Printf("任务 %d: %s - %s (长度: %d)\n", result.TaskID, result.URL, result.Status, result.Length)
    }
}

代码注释

  • make(chan Task, len(urls)):任务 channel 设置缓冲区,避免 worker 阻塞。
  • defer wg.Done():确保 worker 函数结束后,WaitGroup 计数减 1。
  • close(tasks):通知 worker 没有更多任务。
  • go func() { wg.Wait(); close(results) }():在后台等待所有 worker 完成后关闭结果 channel,触发 range results 结束。

总结:Go 并发的真正价值

Go 并发不是“炫技”,而是解决真实问题的利器。它用简单、清晰的语法,封装了复杂底层的调度与同步机制。通过 goroutine 和 channel,你可以写出高效、可读、可维护的并发代码。

从“启动一个 goroutine”到“设计一个生产者-消费者模型”,再到“构建一个完整的并发框架”,每一步都在训练你用“并发思维”去思考程序结构。这不仅是技术提升,更是一种编程范式的进化。

当你真正掌握 Go 并发,你将不再惧怕高并发场景,反而能轻松驾驭它。无论是 Web 服务、数据处理,还是微服务通信,Go 并发都将成为你最可靠的工具。