Go学习笔记:并发编程,Go 的王牌特性

0 阅读5分钟

一、并发 vs 并行

在学习 goroutine 之前,先分清两个概念:

概念说明比喻
并发(Concurrency)同时处理多个任务一个人同时泡面+刷手机,一会做这个一会做那个
并行(Parallelism)同时执行多个任务两个人,一个泡面一个刷手机

Go 的 goroutine 让编写并发代码变得极其简单——它不保证并行(取决于 CPU 核数),但让你轻松写出并发逻辑


二、goroutine(协程)

2.1 基本用法

在函数调用前面加一个 go 关键字,这个函数就会在 goroutine 中并发执行:

package main

import (
    "fmt"
    "time"
)

func printNumbers() {
    for i := 1; i <= 5; i++ {
        fmt.Println(i)
        time.Sleep(500 * time.Millisecond)
    }
}

func main() {
    go printNumbers()       // 并发执行
    fmt.Println("main 继续执行...")
    time.Sleep(3 * time.Second) // 等 goroutine 跑完
}

2.2 goroutine 的特点

特性说明
轻量级初始栈只有几 KB,可以轻松启动上万个
非阻塞启动 goroutine 后,程序继续往下执行
不等待main 函数退出时,所有 goroutine 都会被强制结束

⚠️ main 函数不会等待 goroutine。main 结束了,所有 goroutine 都会停止。所以上面的例子用了 time.Sleep 来等。


三、sync.WaitGroup(等待组)

time.Sleep 来等 goroutine 是不靠谱的。正确的方式是用 sync.WaitGroup

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

func downloadFile(name string) {
    fmt.Printf("下载中: %s\n", name)
    time.Sleep(2 * time.Second)
    fmt.Printf("完成: %s\n", name)
}

func main() {
    var wg sync.WaitGroup

    files := []string{"photo.jpg", "doc.pdf", "music.mp3"}

    for _, f := range files {
        wg.Add(1)      // 计数器 +1

        go func(filename string) {
            defer wg.Done()  // 计数器 -1(defer 确保函数结束时执行)
            downloadFile(filename)
        }(f)
    }

    wg.Wait()  // 阻塞,直到计数器归零
    fmt.Println("全部下载完成!")
}

WaitGroup 的三个方法

方法作用
wg.Add(n)计数器 +n
wg.Done()计数器 -1
wg.Wait()阻塞等待计数器归零

💡 defer 关键字:函数返回前一定会执行的操作。defer wg.Done() 确保不管函数是否出错,计数器都会减一。


四、channel(通道)

channel 是 goroutine 之间通信的管道。一个 goroutine 发送数据到 channel,另一个 goroutine 从 channel 接收数据。

4.1 基本语法

// 创建 channel
ch := make(chan int)     // 无缓冲 channel,传输 int 类型
ch := make(chan string)  // 传输 string 类型

// 发送数据
ch <- 42                 // 箭头指向 channel = 发送

// 接收数据
value := <-ch            // 箭头从 channel 出来 = 接收

4.2 生产者-消费者模式

func producer(out chan<- string) {
    for i := 1; i <= 5; i++ {
        out <- fmt.Sprintf("消息 #%d", i)
    }
    close(out)   // 发送完毕,关闭 channel
}

func consumer(in <-chan string) {
    for msg := range in {  // 用 range 持续接收
        fmt.Println("收到:", msg)
    }
    fmt.Println("处理完毕")
}

func main() {
    ch := make(chan string)
    go producer(ch)
    consumer(ch)
}

4.3 channel 方向

函数参数可以指定 channel 方向,让代码更安全:

声明方向作用
out chan<- string只写只能发送,不能接收
in <-chan string只读只能接收,不能发送
ch chan string读写双向
// 如果尝试在只写 channel 里接收,编译不通过
// val := <-out  // ❌ 编译错误

五、无缓冲 vs 有缓冲

5.1 无缓冲 channel

  • 发和收必须同时准备好,否则阻塞
  • 保证发送和接收"同时发生"(同步)
ch := make(chan int)  // 无缓冲

// 发送方会阻塞,直到有人接收
go func() {
    ch <- 42  // 阻塞,直到 main 中的 <-ch 执行
}()

val := <-ch  // 接收,同时唤醒发送方

5.2 有缓冲 channel

  • 可以存 N 个值,存满才阻塞
  • 发和收解耦(异步)
queue := make(chan string, 3)  // 容量 3

queue <- "A"  // 不阻塞
queue <- "B"  // 不阻塞
queue <- "C"  // 不阻塞,缓冲区满了
// queue <- "D"  // 阻塞!缓冲区满了,需要有人取走
类型容量行为
make(chan T)0无缓冲,必须收发同时进行
make(chan T, N)N有缓冲,存满才阻塞

5.3 关闭 channel

close(ch)     // 生产者发送完数据后关闭

// 接收方用 range 自动检测关闭
for msg := range ch {
    fmt.Println(msg)
}

// 或者手动检测
msg, ok := <-ch
if !ok {
    fmt.Println("channel 已关闭")
}

💡 只应由发送方关闭 channel。向已关闭的 channel 发送数据会 panic。


六、select(多路复用)

select 可以同时等待多个 channel,哪个 channel 有数据就处理哪个:

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

    // 两个 goroutine 在不同时间返回
    go func() {
        time.Sleep(1 * time.Second)
        ch1 <- "快递 A 到了"
    }()

    go func() {
        time.Sleep(2 * time.Second)
        ch2 <- "快递 B 到了"
    }()

    // 同时等待两个 channel
    for i := 0; i < 2; i++ {
        select {
        case msg := <-ch1:
            fmt.Println(msg)     // 1 秒后先到
        case msg := <-ch2:
            fmt.Println(msg)     // 2 秒后到
        case <-time.After(3 * time.Second):
            fmt.Println("超时")   // 防止永远阻塞
        }
    }
}

select 的特点

特性说明
同时等待多个 case 同时等待,谁先 ready 执行谁
随机公平如果多个 channel 同时 ready,随机选一个执行
超时机制time.After 可以设置超时
default没有 channel ready 时执行 default(非阻塞)

七、完整示例:并发下载管理器

package main

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

type Result struct {
    Name string
    Size int
}

func fetch(name string) Result {
    // 模拟不同大小的文件耗时不同
    time.Sleep(time.Duration(len(name) * 100) * time.Millisecond)
    return Result{Name: name, Size: len(name) * 1024}
}

func main() {
    urls := []string{"photo.jpg", "doc.pdf", "music.mp3", "video.mp4"}
    resultCh := make(chan Result, len(urls))

    var wg sync.WaitGroup

    // 并发获取所有文件
    for _, url := range urls {
        wg.Add(1)
        go func(u string) {
            defer wg.Done()
            resultCh <- fetch(u)
        }(url)
    }

    // 等待所有下载完成,关闭 channel
    go func() {
        wg.Wait()
        close(resultCh)
    }()

    // 读取结果(按完成顺序输出)
    for r := range resultCh {
        fmt.Printf("📄 %s (%d KB)\n", r.Name, r.Size)
    }
}

输出:

📄 doc.pdf (7168 KB)
📄 music.mp3 (9216 KB)
📄 video.mp4 (9216 KB)
📄 photo.jpg (9216 KB)

注意:输出顺序不固定,每次运行可能不同——哪个文件先下载完就先显示哪个。


八、使用建议

8.1 什么时候用 goroutine

  • 需要并发的 I/O 操作(网络请求、文件读写、数据库查询)
  • 多个独立任务(不影响彼此结果)
  • 后台任务(定时清理、日志写入)

8.2 注意事项

// ❌ 错误:循环变量被共享
for _, url := range urls {
    go func() {
        fetch(url)   // url 可能已经被循环改变了!
    }()
}

// ✅ 正确:通过参数复制
for _, url := range urls {
    go func(u string) {
        fetch(u)
    }(url)
}

8.3 Go 的并发哲学

"Don't communicate by sharing memory; share memory by communicating." (不要通过共享内存来通信,要通过通信来共享内存。)

意思是:不要用锁去保护共享变量,而是用 channel 把数据从一个 goroutine 传给另一个。数据归谁,谁就操作它。

场景推荐方案
goroutine 间通信channel
保护共享变量sync.Mutex
等待多个 goroutinesync.WaitGroup
只执行一次sync.Once

九、小结

知识点一句话
goroutinego fn() 启动轻量级并发执行
WaitGroupAdd/Done/Wait 等待一组 goroutine
channelch <- v 发送,v := <-ch 接收
带缓冲 channelmake(chan T, N) 解耦收发
close发送方关闭 channel,接收方用 range 读
select同时等待多个 channel,谁先来先处理谁
deferdefer wg.Done() 确保函数结束时执行
并发哲学用通信共享内存,不用共享内存通信