Go的秘密生活:并发

发布: (2026年1月19日 GMT+8 16:10)
7 min read
原文: Dev.to

Source: Dev.to

为竞争条件的混乱带来秩序。

第15章:通过通信共享

那个星期二档案室异常嘈杂。不是人声,而是雨水敲击铜屋顶的声音,形成一种混乱的鼓点节奏,回荡在高高的天花板上。

伊桑在踱步。他的笔记本散热风扇正以最高速旋转。

“它能运行,但却不对,”他一边把手伸进头发里说,“我在处理这千个日志文件。我用了 go 关键字为每个文件生成一个后台任务。速度快得惊人。”
“结果怎样?”埃莉诺平静地搅动着茶水问。
“一团糟。有时得到 998 条结果,有时是 1005 条。有时程序因为映射赋值错误而崩溃。简直是混乱。”

他给她看了代码:

func processLogs(logs []string) map[string]int {
    results := make(map[string]int)

    for _, log := range logs {
        go func(l string) {
            // Simulate processing
            user := parseUser(l)
            results[user]++ // THE BUG IS HERE
        }(log)
    }
    return results
}

“对,”埃莉诺说,凑近了点。“你遇到了经典的 竞争条件。你启动了一千个 goroutine,它们都在争抢同一个 map。因为没有任何东西阻止它们,它们相互覆盖对方的工作。”

伊桑: “所以我需要锁?一个 Mutex?”

埃莉诺: “你可以使用 Mutex,但那样你就会不断地暂停所有操作来管理这个变量。在 Go 中我们尽量避免这种做法。我们有一句话:‘不要通过共享内存来通信;通过通信来共享内存。’

Goroutine

“首先,”埃莉诺说,“看看你到底构建了什么。一个 goroutine 不仅仅是一次函数调用。它是 fire‑and‑forget(发起即忘)。Go 调度器会管理它们,把成千上万的 goroutine 多路复用到少数几个 OS 线程上。”

“听起来很高效,”伊桑说。
“确实如此。但因为它们是独立的,你的主函数——返回 results 的那个——并不会等它们完成。它肯定会在工作者们甚至还没结束时就返回。”
“这解释了数据缺失,”伊桑恍然大悟,“我在返回一个空 map,而工作者们仍在后台运行。”
“正是如此。你需要一种安全地获取数据的方式。与其让每个人都去触碰 map,不如让它们把数据传回给你。”

Channel

她打开了一个新文件。“我们使用 channel。它是运行任务之间直接传递数据的管道。”

ch := make(chan string) // Create a channel of strings

“它会为你处理同步,”埃莉诺解释道。“如果你向它发送数据,代码会暂停,直到有人接收它。它强制两端完美对齐。”

她重构了伊桑的代码:

func processLogs(logs []string) map[string]int {
    results := make(map[string]int)
    userChan := make(chan string)

    // Spawn workers
    for _, log := range logs {
        go func(l string) {
            user := parseUser(l)
            userChan <- user
        }(log)
    }

    // Collect results
    for i := 0; i < len(logs); i++ {
        user := <-userChan
        results[user]++
    }
    return results
}

“看出区别了吗?”埃莉诺问。“你的工作者计算出用户,但它们不再触碰 map。它们只把结果交给你。主函数等待、获取结果并更新 map。只有一个地方会触碰内存。”
“所以 channel 实际上把写操作序列化了,”伊桑领悟到。
“正是如此。它创建了唯一的入口点。”

阻塞是一种特性

“但是,”伊桑问,“如果 channel 堵住了怎么办?”
“默认情况下是不会堵住的,”埃莉诺说。“它是直接的交接。当工作者在 userChan 上发送时,它会阻塞,直到主 goroutine 接收到该值。所以它们会相互等待。”

缓冲通道

“现在,”埃莉诺补充道,“有时你不想让它们这么频繁地锁住。你想要一点队列的效果。”

ch := make(chan string, 100) // Buffer of 100 slots

“这给了你一个缓冲区。你的工作者…”]

can drop off 100 items without waiting. It lets them run a bit faster than the consumer for short bursts. But be careful—once that buffer is full, they block again.”

The select Statement

“一句话,” Eleanor 说。 “如果你在等待两件事怎么办?比如获取结果 超时?”

select {
case msg := <-ch1:
    // handle msg from ch1
case ch2 <- value:
    // send value to ch2
case <-time.After(5 * time.Second):
    // handle timeout
}

“不要通过共享内存来通信;相反,要通过通信来共享内存。”

避免让多个 goroutine 直接访问同一个变量(例如 map)。相反,应通过通道将数据传递给唯一的 owner goroutine。

缓冲通道

ch := make(chan int, 100) // capacity of 100
  • 缓冲通道具有固定容量。
  • 发送 在缓冲区满时阻塞;接收在缓冲区为空时阻塞。

select 语句

  • 允许 goroutine 同时等待 多个 通道操作。
  • 执行第 一个 准备好的 case。
  • 对处理超时、取消以及复用 I/O 至关重要。
select {
case msg := <-ch1:
    // handle msg from ch1
case ch2 <- value:
    // send value to ch2
case <-time.After(5 * time.Second):
    // handle timeout
}

下一章:Context 包 —— Ethan 学习如何礼貌地停止失控的 goroutine 并管理请求截止时间。

Aaron Rose 是一名软件工程师兼技术作者,供职于 tech-reader.blog 并且是 Think Like a Genius 的作者。

Back to Blog

相关文章

阅读更多 »