Go语言系统编程——刷新并发与并行

316 阅读28分钟

本章将深入探讨Go并发编程的核心——goroutines。你将学习它们的工作原理,区分并发与并行,管理当前运行的goroutines,处理数据竞争问题,使用channels进行通信,并利用Channel的状态和信号来最大化它们的潜力。掌握这些概念对于编写高效且无错误的Go代码至关重要。

在本章中,我们将涵盖以下主要内容:

  • 理解goroutines
  • 管理数据竞争
  • 理解channels
  • 交付保障
  • 状态与信号
  • 技术要求

本章的源代码可以在以下网址找到:github.com/PacktPublis…

理解goroutines

Goroutines是由Go调度器创建并安排独立运行的函数。Go调度器负责goroutines的管理和执行。

在背后,Go使用一个复杂的算法来使goroutines正常工作。幸运的是,在Go中,我们可以通过简单的go关键字来实现这个高复杂度的操作。

注意
如果你习惯于使用具有async/await特性的语言,可能已经习惯了提前决定哪些函数要并发执行,并通过修改函数签名来表明该函数可以暂停/恢复。调用此函数时通常也需要特殊的标记。然而,在使用goroutines时,我们不需要修改函数签名。

在以下代码片段中,main函数顺序地调用了say函数,分别传入参数“hello”和“world”:

func main() {
  say("hello")
  say("world")
}

say函数接收一个字符串作为参数,并执行5次迭代。每次迭代时,函数会休眠500毫秒,然后立即打印参数s

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

当我们执行程序时,输出应该是:

hello
hello
hello
hello
hello
world
world
world
world
world

现在,我们在第一次调用say函数之前加上go关键字,以便在程序中引入并发:

func main() {
  go say("hello")
  say("world")
}

输出应该交替显示helloworld

我们是否可以通过为第二次函数调用创建一个goroutine来达到相同的效果呢?

func main() {
  say("hello")
  go say("world")
}

现在,让我们看看程序的结果:

hello
hello
hello
hello

等等!这里好像出了点问题。我们做错了什么?main函数和goroutine似乎不同步。

我们并没有做错什么。这是预期的行为。仔细观察第一个程序,goroutine被触发,第二次调用say函数按顺序在main函数的上下文中执行。

换句话说,程序应该等待函数执行完毕,才会执行main函数的后续代码。而对于第二个程序,行为正好相反。第一次调用是普通的函数调用,因此它会按预期打印5次,但当第二个goroutine被触发时,main函数没有后续的指令,因此程序会终止。

虽然从程序的运行方式来看,行为是正确的,但这并不是我们希望的结果。我们需要一种方法来同步等待这一组goroutine执行完毕,才给main函数提供结束的机会。在这种情况下,我们可以利用Go的sync包中的WaitGroup来实现同步。

WaitGroup

WaitGroup,顾名思义,是Go标准库中的一种机制,允许我们等待一组goroutine完成后再继续执行。没有特定的工厂函数来创建WaitGroup,因为它的零值本身就是一个有效的可用状态。创建WaitGroup之后,我们需要控制等待多少个goroutine完成。我们可以使用Add()方法来告知WaitGroup我们正在等待的goroutine数量。

如何告知WaitGroup我们已经完成了一个goroutine的任务呢?这个操作非常直观。我们可以使用Done()方法来实现这一点。

在以下示例中,我们引入了WaitGroup来确保我们的程序按照预期输出消息:

func main() {
  wg := sync.WaitGroup{}
  wg.Add(2)  // 告诉 WaitGroup 我们需要等待 2 个 goroutines
  go say("world", &wg)  // 第一个 goroutine
  go say("hello", &wg)  // 第二个 goroutine
  wg.Wait()  // 等待所有 goroutines 执行完毕
}

我们创建了一个WaitGroupwg := sync.WaitGroup{}),并声明这组WaitGroup中有两个goroutine(wg.Add(2))。在程序的最后一行,我们通过调用Wait()方法显式地保持程序执行,直到所有的goroutines完成,这样可以避免程序过早退出。

为了使我们的函数与WaitGroup进行交互,我们需要传递WaitGroup的引用。一旦拥有了该引用,函数可以使用defer来调用Done(),以确保每次函数完成时都会正确地通知WaitGroup

这是更新后的say函数:

func say(s string, wg *sync.WaitGroup) {
  defer wg.Done()  // 确保 goroutine 完成时调用 Done()
  for i := 0; i < 5; i++ {
    fmt.Println(s)
  }
}

我们不再依赖time.Sleep(),因此这个版本的代码中没有使用它。

现在,我们能够控制goroutine组的执行,接下来我们将讨论并发编程中的一个核心问题——状态。

修改共享状态

假设有两个勤奋的工人,他们的任务是在一个忙碌的仓库中将物品打包进箱子。每个工人会将一定数量的物品打包成袋,我们必须跟踪总共打包了多少物品。

这个看似简单的任务,在并发编程中可能迅速变得复杂。当没有正确的同步时,工人们可能会无意中干扰彼此的工作,从而导致错误的结果和不可预测的行为。这是数据竞争的经典例子,是并发编程中常见的挑战。

以下代码将带你走进一个场景,在这个场景中,两个仓库工人在打包物品时遇到了数据竞争问题。我们首先展示没有正确同步的代码,演示数据竞争问题。然后,我们将修改代码以解决这个问题,确保工人们能够顺利且准确地协作。

让我们一起进入这个繁忙的仓库,亲身体验并发编程中的挑战,以及同步的重要性:

package main
import (
  "fmt"
  "sync"
)

func main() {
  fmt.Println("Total Items Packed:", PackItems(0))
}

func PackItems(totalItems int) int {
  const workers = 2
  const itemsPerWorker = 1000
  var wg sync.WaitGroup
  itemsPacked := 0

  for i := 0; i < workers; i++ {
    wg.Add(1)  // 为每个 worker 添加一个计数
    go func(workerID int) {
      defer wg.Done()  // 在 goroutine 完成时调用 Done()
      
      // 模拟工人打包物品进箱子
      for j := 0; j < itemsPerWorker; j++ {
        itemsPacked = totalItems
        // 模拟打包一个物品
        itemsPacked++
        // 不正确地更新 totalItems,缺少同步机制
        totalItems = itemsPacked
      }
    }(i)
  }

  // 等待所有工人完成工作
  wg.Wait()
  return totalItems
}

程序的执行步骤如下:

  • main函数通过调用PackItems函数并传入初始值totalItems为0来开始。

  • PackItems函数中,定义了两个常量:

    • workers:工作goroutine的数量(设置为2)
    • itemsPerWorker:每个工人需要打包的物品数量(设置为1000)

    然后创建一个sync.WaitGroupwg),用于等待所有工作goroutine完成之后再返回最终的totalItems值。

  • 代码通过循环启动两个goroutine,模拟每个工人将物品打包进箱子的过程。在每个goroutine内部执行以下步骤:

    • 为goroutine传递一个工人ID作为参数。
    • 使用defer wg.Done()确保每个goroutine结束时都会通知WaitGroup
    • 初始化一个itemsPacked变量,用来跟踪当前工人打包的物品数量。
    • 运行一个循环来模拟工人打包物品。每次循环递增itemsPacked,但是并没有真正地打包物品。

    在每次内循环的最后,totalItems更新为itemsPacked的值,这就是问题的所在。由于没有适当的同步机制,多个goroutine会并发修改totalItems,导致数据竞争,从而产生不可预测和错误的结果。

非确定性结果

考虑以下替代版本的 main 函数:

func main() {
  times := 0
  for {
    times++
    counter := PackItems(0)
    if counter != 2000 {
      log.Fatalf("it should be 2000 but found %d on execution %d", counter, times)
    }
  }
}

该程序会不断运行 PackItems 函数,直到返回的结果与预期的 2000 不符。一旦发生这种情况,程序将显示函数返回的错误值,并显示到达该点所需的尝试次数。

由于Go调度器的非确定性特性,结果大部分时间会是正确的。为了暴露这个同步问题,这段代码需要运行多次。在一次执行中,我需要超过 16,000 次迭代才能发现问题:

it should be 2000 but found 1170 on execution 16421

试试看

在你的机器上实验运行这段代码。你的代码需要多少次迭代才能失败呢?

如果你使用的是个人计算机,可能有很多任务在同时进行,但你的计算机也可能有很多空闲资源。不过,如果你在云环境中的容器里运行程序,值得注意的是共享节点上的噪音量。这里的“噪音”指的是在运行程序时,主机上其他任务所占用的资源。虽然可能和你本地实验一样处于空闲状态,但在一个成本效益较高的场景中,主机的每个核心和内存可能被充分利用。

这种资源的竞争场景使得调度程序更倾向于选择其他工作负载,而不是继续执行我们的goroutine。

在以下示例中,我们调用 runtime.Gosched 函数来模拟噪音。其目的是向Go调度器发出提示,表示:“嘿!也许现在是暂停我的好时机”:

for j := 0; j < itemsPerWorker; j++ {
  itemsPacked = totalItems
  runtime.Gosched() // 模拟噪音!
  itemsPacked++
  totalItems = itemsPacked
}

再次运行 main 函数时,我们可以看到错误结果出现得比之前更快。例如,在我的执行中,我只需要四次迭代:

it should be 2000 but found 1507 on execution 4

不幸的是,代码仍然有bug。我们怎么能预测这一点呢?此时,你应该已经猜到Go工具有解决方案,而你又猜对了。我们可以通过Go的工具来管理数据竞争,帮助我们识别并解决测试中的问题。

管理数据竞争

当多个 goroutine 同时访问共享数据或资源时,就可能发生“竞争条件”(race condition)。正如我们所见,这种并发错误会导致不可预测和不希望出现的行为。Go 提供了一个名为“Go 数据竞争检测”的内置功能,可以检测并识别代码中的竞争条件。

接下来,我们将创建一个 main_test.go 文件,编写一个简单的测试用例:

package main

import (
  "testing"
)

func TestPackItems(t *testing.T) {
  totalItems := PackItems(2000)
  expectedTotal := 2000
  if totalItems != expectedTotal {
    t.Errorf("Expected total: %d, Actual total: %d", expectedTotal, totalItems)
  }
}

现在,使用数据竞争检测工具:

go test -race

控制台中的输出可能会像这样:

==================
WARNING: DATA RACE
Read at 0x00c00000e288 by goroutine 9:
  example1.PackItems.func1()
      /tmp/main.go:35 +0xa8
  example1.PackItems.func2()
      /tmp/main.go:45 +0x47
Previous write at 0x00c00000e288 by goroutine 8:
  example1.PackItems.func1()
      /tmp/main.go:39 +0xba
  example1.PackItems.func2()
      /tmp/main.go:45 +0x47
// 其他行省略

输出初看起来可能有些吓人,但最关键的信息是 WARNING: DATA RACE 的提示。

为了修复代码中的同步问题,我们应该使用同步机制来保护对 totalItems 变量的访问。如果没有适当的同步,多个 goroutine 同时写入共享数据,就可能引发竞争条件和不可预料的结果。

我们已经使用了来自 sync 包的 WaitGroup。接下来,我们将探索更多的同步机制,确保程序的正确性。

原子操作

令人失望的是,Go 中的“原子”并不涉及物理上操控原子,就像物理学或化学中的那样。要是编程语言能做到这一点,肯定会非常有趣。然而,Go 中的原子操作专注于使用 sync/atomic 包来同步和管理 goroutine 之间的并发。

Go 提供了对某些类型(如 int32int64uint32uint64uintptrfloat32float64)的原子操作,允许加载、存储、加法和 CAS(比较并交换)等操作。原子操作不能直接对任意数据结构进行操作。

让我们使用 atomic 包来修改程序。首先,我们需要导入它:

import (
  "fmt"
  "sync"
  "sync/atomic"
)

在这里,我们不再直接更新 totalItems,而是利用 AddInt32 函数来确保同步:

for j := 0; j < itemsPerWorker; j++ {
  atomic.AddInt32(&totalItems, int32(itemsPacked))
}

如果我们再次检查数据竞争,系统将不会报告任何问题。

原子结构在需要同步单个操作时非常有用,但当我们想同步一块代码时,其他工具会更合适,比如互斥锁(mutex)。

互斥锁

啊,互斥锁!它们就像是 goroutine 派对的保镖。想象一下,一群小小的 Go 进程试图在共享数据的舞池上跳舞。刚开始大家都很开心,直到混乱爆发,goroutine 发生了交通堵塞,数据洒得到处都是!

别担心,互斥锁就像是舞池上的监督员,确保每次只有一个炫酷的 goroutine 能在关键区域跳舞。它们是并发的节奏守护者,确保每个人都轮流跳舞,没人踩到对方的脚。

你可以通过声明一个 sync.Mutex 类型的变量来创建一个互斥锁。互斥锁允许我们保护代码中的关键部分,使用 Lock()Unlock() 方法。当一个 goroutine 调用 Lock() 时,它会获得互斥锁,其他尝试调用 Lock() 的 goroutine 将被阻塞,直到锁被释放(调用 Unlock())。

以下是使用互斥锁的代码:

package main
import (
  "fmt"
  "sync"
)

func main() {
  m := sync.Mutex{}
  fmt.Println("Total Items Packed:", PackItems(&m, 0))
}

func PackItems(m *sync.Mutex, totalItems int) int {
  const workers = 2
  const itemsPerWorker = 1000
  var wg sync.WaitGroup
  for i := 0; i < workers; i++ {
    wg.Add(1)
    go func(workerID int) {
      defer wg.Done()
      for j := 0; j < itemsPerWorker; j++ {
        m.Lock()
        itemsPacked := totalItems
        itemsPacked++
        totalItems = itemsPacked
        m.Unlock()
      }
    }(i)
  }
  // 等待所有工作完成
  wg.Wait()
  return totalItems
}

在这个例子中,我们锁住了修改共享状态的代码块,并在完成后解锁互斥锁。

如果互斥锁能确保共享状态的正确性,你可以考虑两种选择:

  1. 对每一行关键代码使用 LockUnlock
  2. 在函数开始时锁定,并使用 defer 来解锁

是的,你可以这么做!可惜的是,这两种方法都有一个问题——它们会引入不必要的延迟。为了说明这一点,我们来对比第二种方法与原始使用互斥锁的性能。

我们创建一个函数版本 MultiplePackItems,该版本使用多次调用 Lock/Unlock,除函数名称和内部循环外,其他都保持不变。这里是内部循环的代码:

for j := 0; j < itemsPerWorker; j++ {
  m.Lock()
  itemsPacked = totalItems
  m.Unlock()
  m.Lock()
  itemsPacked++
  m.Unlock()
  m.Lock()
  totalItems = itemsPacked
  m.Unlock()
}

现在,让我们进行基准测试,查看两种方式的性能差异:

Benchmark-8                36546            32629 ns/op
BenchmarkMultipleLocks-8    13243            91246 ns/op

使用多次锁的版本大约比第一个版本慢了 64%,即每次操作所需的时间更长。

基准测试

我们将在第6章《分析性能》中详细讨论基准测试和其他性能测量技术。

这些例子展示了 goroutines 独立执行任务,而没有与其他 goroutines 协作。然而,在许多情况下,我们的任务需要交换信息或信号来做出决策,比如启动或停止某个过程。

当交换信息变得至关重要时,我们可以使用 Go 中的一个重要工具——通道(channel)。

理解通道(Channels)

欢迎来到通道的嘉年华!

把 Go 中的通道想象成魔法般的、大小适中的管道,允许马戏团演员(goroutine)在表演时传递杂技球(数据),而且确保没人掉球——简直是字面上的保证!

如何使用通道

要使用通道,我们需要使用 Go 内置的 make() 函数,并声明我们想要通过该通道传递的数据类型:

make(Chan T)

如果我们想要一个 string 类型的通道,我们应该声明如下:

make(chan string)

我们还可以为通道指定容量。具有容量的通道称为缓冲通道。暂时我们不详细探讨容量的概念。当我们不指定容量时,通道就是无缓冲通道。

无缓冲通道

无缓冲通道是 goroutines 之间通信的一种方式,它需要遵循一个简单的规则——希望发送数据的 goroutine 和希望接收数据的 goroutine 必须在同一时刻准备好。

可以将其想象为“信任跌落”练习。发送者和接收者必须完全信任对方,确保数据的安全,就像杂技演员信任他们的搭档在空中接住他们一样。

抽象吗?让我们通过实例来探索这个概念。

首先,让我们尝试将信息发送到一个没有接收者的通道:

package main

func main() {
    c := make(chan string)
    c <- "message"
}

执行后,控制台会打印类似以下内容的错误信息:

fatal error: all goroutines are sleepdeadlock!
goroutine 1 [chan send]:
main.main()

让我们分析一下这条输出信息。

all goroutines are sleep – deadlock! 是主要的错误信息,意味着程序中的所有 goroutine 都处于休眠状态,这表明它们都在等待某个事件或资源变得可用。然而,由于它们都在等待并且无法继续执行,程序就遇到了死锁。

goroutine 1 [chan send]: 这一部分提供了更多关于发生死锁的具体 goroutine 信息。在这里,它是 goroutine 1,且它涉及到了一个通道的发送操作(chan send)。

这个死锁发生的原因是执行暂停,等待另一个 goroutine 来接收信息,但没有接收者。

死锁(Deadlocks)

死锁是一种情况,其中两个或更多进程或 goroutine 无法继续执行,因为它们都在等待某些永远不会发生的事情。

接下来,我们可以尝试相反的操作;在下一个例子中,我们希望从一个没有发送者的通道接收数据:

package main

func main() {
    c := make(chan string)
    fmt.Println(<- c)
}

控制台的输出会非常相似,只是现在的错误是关于接收的:

fatal error: all goroutines are sleepdeadlock!
goroutine 1 [chan receive]:
main.main()

现在,遵循规则变得很简单——发送和接收必须同时发生。所以,声明发送和接收操作就足够了:

package main

func main() {
    c := make(chan string)
    c <- "message" // 发送
    fmt.Println(<- c) // 接收
}

这是一个不错的想法,但不幸的是,它并不起作用,我们会看到以下输出:

fatal error: all goroutines are sleepdeadlock!
goroutine 1 [chan send]:
main.main()

如果我们遵循规则,为什么会不工作呢?

好吧,其实我们并没有完全遵循规则。规则是:想要发送数据的 goroutine 和想要接收数据的 goroutine 应该在同一时刻准备好。

关键点在于“同一时刻准备好”。由于代码是按顺序执行的,一行接着一行,当我们尝试执行 c <- "message" 时,程序会等待接收者接收消息。我们需要使这两方同时发送和接收消息。为了实现这一点,我们可以使用并发编程的知识。

使用 goroutines 解决死锁问题

让我们加入 goroutines 来解决这个问题,继续使用马戏团的类比。我们会引入一个名为 throwBalls 的函数,它将期望接收抛掷的球的颜色(color)以及一个通道(balls),该通道用于接收这些球:

package main

import "fmt"

func main() {
    balls := make(chan string) // 创建一个无缓冲的通道
    go throwBalls("red", balls) // 启动一个 goroutine 来发送“红色”球
    fmt.Println(<-balls, "received!") // 接收并打印接收到的球
}

func throwBalls(color string, balls chan string) {
    fmt.Printf("throwing the %s ball\n", color)
    balls <- color // 将颜色发送到通道
}

在这里,我们有三个主要步骤:

  1. 我们创建了一个名为 balls 的无缓冲字符串通道。
  2. 使用 goroutine 调用 throwBalls 函数,将“红色”球发送到通道。
  3. 主函数从通道接收并打印接收到的球的颜色。

通过使用 goroutines,我们确保了发送和接收操作可以并发地同时发生,从而避免了死锁问题。

这个例子的输出如下:

throwing the red ball
red received!

我们成功了!我们通过通道成功地在 goroutines 之间传递了信息!

但是,如果我们再发送一个球会发生什么?让我们试试发送一个绿色的球:

func main() {
    balls := make(chan string)
    go throwBalls("red", balls)
    go throwBalls("green", balls)
    fmt.Println(<-balls, "received!")
}

输出只显示了一个球被接收。发生了什么呢?

throwing the red ball
red received!

由于我们启动了多个 goroutine,调度器会任意决定哪个 goroutine 先执行。因此,你会看到绿色或红色的代码随机运行。

我们可以通过再添加一条打印语句来解决这个问题,这样就能接收两个球:

func main() {
    balls := make(chan string)
    go throwBalls("red", balls)
    go throwBalls("green", balls)
    fmt.Println(<-balls, "received!")
    fmt.Println(<-balls, "received!")
}

虽然这样可以解决问题,但它并不是最优雅的解决方案。如果接收者比发送者多,我们可能会再次遇到死锁问题:

func main() {
    balls := make(chan string)
    go throwBalls("red", balls)
    go throwBalls("green", balls)
    fmt.Println(<-balls, "received!")
    fmt.Println(<-balls, "received!")
    fmt.Println(<-balls, "received!")
}

最后一条打印语句将永远等待,导致死锁。

如果我们希望代码能够处理任意数量的球,我们应该停止不断添加更多的打印语句,而是使用 range 关键字来简化代码。

遍历通道

用于遍历通过通道发送的值的机制是 range 关键字。

让我们修改代码,使用 range 遍历通道的值:

func main() {
    balls := make(chan string)
    go throwBalls("red", balls)
    go throwBalls("green", balls)
    for color := range balls {
        fmt.Println(color, "received!")
    }
}

我们可以高兴地查看控制台,看到优雅地接收到的球,但等等——所有的 goroutine 都睡着了!是死锁了吗?

这个错误发生在我们遍历通道时,range 期望通道被关闭来停止迭代。

关闭通道

为了关闭通道,我们需要调用内置的 close 函数,并传入通道:

close(balls)

好的,现在我们可以保证通道被关闭了。让我们修改代码,在发送者和 range 之间添加 close 调用:

go throwBalls("green", balls)
close(balls)
for color := range balls {
    fmt.Println(color, "received!")
}

你可能已经注意到,如果通道关闭后 range 停止工作,那么在这个代码中,一旦通道关闭,range 将永远不会执行。

使用 WaitGroup 协调任务

我们需要协调这些任务,没错——这次我们再次依靠 WaitGroup 来救场。这次,我们不想修改 throwBalls 函数的签名来接收 WaitGroup,所以我们将创建内联匿名函数来保持我们的函数不受并发影响。此外,我们希望在所有任务完成后关闭通道。我们通过 WaitGroupWait() 方法来保证这一点。

这是我们的主函数:

func main() {
    balls := make(chan string)
    wg := sync.WaitGroup{}
    wg.Add(2)
    go func() {
        defer wg.Done()
        throwBalls("red", balls)
    }()
    go func() {
        defer wg.Done()
        throwBalls("green", balls)
    }()
    go func() {
        wg.Wait()
        close(balls)
    }()
    for color := range balls {
        fmt.Println(color, "received!")
    }
}

呼!这次输出正确显示了:

throwing the green ball
green received!
throwing the red ball
red received!

这真是一次过山车之旅,对吧?不过等等!我们还需要探索一下缓冲通道!

缓冲通道

是时候用比喻了! 这些就是 clowns(小丑)登场的通道!想象一个小丑车,它有有限的座位(容量)。小丑(发送者)可以进出车内,向车内扔下杂耍球(数据)。

我们想创建一个使用缓冲通道的程序,模拟一个马戏团小丑车的场景,在这个场景中,小丑试图进入小丑车(每次最多三个小丑),车内放着气球。司机控制车辆,管理小丑的乘车,而小丑们则尝试进入。如果车满了,它们就会等待,并打印一条信息。等所有小丑都完成后,程序会等待司机完成工作,并打印出马戏团车之旅结束的消息。

如果有小丑尝试把太多的杂耍球塞进车里,那就像车里溢出了小丑和杂耍球,形成了一场滑稽的景象!

首先,我们创建程序结构来接收发送者和接收者:

package main
import (
    "fmt"
    "sync"
    "time"
)

func main() {
    clownChannel := make(chan int, 3)  // 创建一个缓冲区为 3 的通道
    clowns := 5                        // 总共有 5 个小丑
    // 发送者和接收者的逻辑写在这里!
    var wg sync.WaitGroup
    wg.Wait()
    fmt.Println("Circus car ride is over!")
}

司机的 goroutine(接收者):

go func() {
    defer close(clownChannel)  // 完成后关闭通道
    for clownID := range clownChannel {
        balloon := fmt.Sprintf("Balloon %d", clownID)
        fmt.Printf("Driver: Drove the car with %s inside\n", balloon)
        time.Sleep(time.Millisecond * 500)
        fmt.Printf("Driver: Clown finished with %s, the car is ready for more!\n", balloon)
    }
}()

小丑的逻辑(发送者):

for clown := 1; clown <= clowns; clown++ {
    wg.Add(1)
    go func(clownID int) {
        defer wg.Done()
        balloon := fmt.Sprintf("Balloon %d", clownID)
        fmt.Printf("Clown %d: Hopped into the car with %s\n", clownID, balloon)
        select {
        case clownChannel <- clownID:  // 发送小丑 ID 到通道
            fmt.Printf("Clown %d: Finished with %s\n", clownID, balloon)
        default:  // 如果通道满了,则打印车满的信息
            fmt.Printf("Clown %d: Oops, the car is full, can't fit %s!\n", clownID, balloon)
        }
    }(clown)
}

运行代码时,我们可以看到所有小丑制造的麻烦:

Clown 1: Hopped into the car with Balloon 1
Clown 1: Finished with Balloon 1
Driver: Drove the car with Balloon 1 inside
Clown 2: Hopped into the car with Balloon 2
Clown 2: Finished with Balloon 2
Clown 5: Hopped into the car with Balloon 5
Clown 5: Finished with Balloon 5
Clown 3: Hopped into the car with Balloon 3
Clown 3: Finished with Balloon 3
Clown 4: Hopped into the car with Balloon 4
Clown 4: Oops, the car is full, can't fit Balloon 4!
Circus car ride is over!

SELECT

select 语句允许我们等待多个通信通道,并选择第一个准备好的通道,它有效地让我们在通道上执行非阻塞操作。

当处理通道时,很容易将消息队列和通道进行比较,但有时可能会有更好的方式来理解它们。通道内部实现通常是环形缓冲区,这种信息在选择程序设计时可能会让人感到困惑和不那么有帮助。通过优先理解信号传递和消息的可靠交付,你会更有效地与通道进行交互。

消息交付保证

缓冲通道和无缓冲通道之间的主要区别在于消息交付的保证。

正如我们之前看到的,无缓冲通道总是保证交付,因为它们只有在接收者准备好时才发送消息。相反,缓冲通道不能保证消息交付,因为它们可以在同步步骤变得强制执行之前“缓冲”任意数量的消息。因此,接收者可能无法从通道缓冲区读取到消息。

选择缓冲通道或无缓冲通道最重要的副作用是你能够接受多少延迟。

延迟

在并发编程的上下文中,延迟指的是数据从发送者(goroutine)通过通道传输到接收者(goroutine)所花费的时间。

在 Go 的通道中,延迟受多个因素的影响:

  1. 缓冲:当发送者和接收者未能完美同步时,缓冲可以减少延迟。
  2. 阻塞:无缓冲通道会在发送者和接收者准备好之前阻塞它们,这可能会导致较高的延迟。缓冲通道允许发送者在不立即同步的情况下继续执行,从而可能减少延迟。
  3. Goroutine 调度:通道通信中的延迟还取决于 Go 运行时如何调度 goroutine。可用的 CPU 核心数和调度算法等因素会影响 goroutine 执行的速度,从而影响通道通信的延迟。

选择通道类型

作为经验法则,我们可以在以下场景中考虑使用无缓冲通道:

  1. 保证交付:无缓冲通道提供保证,确保发送的值被另一个 goroutine 接收。这在需要确保数据完整性并且不丢失任何数据的场景中特别有用。
  2. 一对一通信:无缓冲通道最适合在 goroutine 之间进行一对一的通信。
  3. 负载均衡:无缓冲通道可用于实现负载均衡模式,确保工作在多个工作 goroutine 之间均匀分配。

相反,缓冲通道提供以下优势:

  1. 异步通信:缓冲通道允许 goroutine 之间进行异步通信。当发送数据到缓冲通道时,发送者不会被阻塞,直到通道的缓冲区有空间接收数据。这可以在某些场景下提高吞吐量。
  2. 减少竞争:在多个发送者和接收者的场景中,使用缓冲通道可以减少竞争。例如,在生产者-消费者模式中,使用缓冲通道可以让生产者继续生产,而不必等待消费者跟上。
  3. 防止死锁:缓冲通道通过提供一定的缓冲区,帮助防止 goroutine 死锁,这在处理工作负载变化不可预测的场景中非常有用。
  4. 批量处理:缓冲通道可用于批量处理或流水线处理,其中数据以一种速率生成,以另一种速率消费。

现在我们已经讨论了延迟的关键方面及其如何影响并发编程中的通道通信,接下来我们将把焦点转向另一个关键方面——状态和信号。理解状态和信号的语义对于避免常见陷阱并做出明智的设计决策至关重要。

状态与信号

探索状态和信号的语义可以帮助你避免常见的错误,并做出更好的设计决策。

状态

虽然 Go 通过通道简化了并发编程的使用,但通道仍然有一些特点和陷阱。我们应该记住,通道有三种状态:nilopen(空或非空)closed。这些状态密切相关,我们可以根据发送方和接收方的视角来理解通道能做什么和不能做什么。

考虑一个通道,当你想从中读取时:

  • 从一个只写通道读取会导致编译错误。
  • 如果通道为 nil,从中读取会导致 goroutine 永远阻塞,直到它被初始化。
  • 从一个空的 open 通道读取会一直阻塞,直到数据可用。
  • 从一个非空的 open 通道读取会返回数据。
  • 如果通道已关闭,从中读取会返回该类型的默认值,并且返回 false 表示通道已关闭。

写入也有一些细微差别:

  • 向一个只读通道写入会导致编译错误。
  • 向一个 nil 通道写入会一直阻塞,直到它被初始化。
  • 向一个已满的 open 通道写入会阻塞,直到通道有空闲空间。
  • 向一个非满的 open 通道写入会成功。
  • 向一个已关闭的通道写入会引发 panic。

关闭通道的行为取决于其状态:

  • 关闭一个有数据的 open 通道后,仍然可以读取直到通道数据被消耗完,之后返回类型的默认值。
  • 关闭一个空的 open 通道会立即关闭,读取会返回类型的默认值。
  • 尝试关闭一个已关闭的通道会引发 panic。
  • 关闭一个只读通道会导致编译错误。

信号

在 goroutine 之间传递信号是通道的常见应用场景。你可以通过通道在 goroutine 之间发送信号或消息,协调和同步它们的执行。

这里是一个简单的示例,展示如何使用 Go 通道在两个 goroutine 之间传递信号:

package main

import (
    "fmt"
    "sync"
)

func main() {
    signalChannel := make(chan bool)
    var wg sync.WaitGroup
    wg.Add(1)
    go func() {
        defer wg.Done()
        fmt.Println("Goroutine 1 is waiting for a signal...")
        <-signalChannel
        fmt.Println("Goroutine 1 received the signal and is now doing something.")
    }()
    wg.Add(1)
    go func() {
        defer wg.Done()
        fmt.Println("Goroutine 2 is about to send a signal.")
        signalChannel <- true
        fmt.Println("Goroutine 2 sent the signal.")
    }()
    wg.Wait()
    fmt.Println("Both goroutines have finished.")
}

在这个示例中,我们创建了一个名为 signalChannel 的通道,用于在两个 goroutine 之间传递信号。Goroutine 1 等待来自通道的信号,通过 <-signalChannel 来接收信号;Goroutine 2 使用 signalChannel <- true 发送信号。

sync.WaitGroup 确保在打印 "Both goroutines have finished." 之前,两个 goroutine 都已经完成。

运行该程序时,你会看到 Goroutine 1 等待来自 Goroutine 2 的信号,然后继续执行它的任务。

Go 通道是一种灵活的方式,可以用于同步和协调 goroutine 之间的复杂交互。它们可以用来实现并发模式,如生产者-消费者模式或分发/汇聚模式(fan-out/fan-in)。

选择同步机制

通道始终是答案吗?绝对不是!我们可以使用互斥锁(mutexes)或通道来解决相同的问题。我们应该如何选择?选择务实主义。当互斥锁使你的解决方案更易于阅读和维护时,不要犹豫,直接使用互斥锁!

如果你在两者之间难以抉择,以下是一个带有明确意见的指南:

  • 使用通道 当你需要做以下事情时:

    • 传递数据的所有权
    • 分配工作单元
    • 以异步方式传递结果
  • 使用互斥锁 当你处理以下情况时:

    • 缓存
    • 共享状态

总结

在本章中,我们了解了 goroutine 的工作原理,它们的简洁性,以及使用 WaitGroup 进行同步的重要性。我们还意识到管理共享状态的困难,使用仓库类比来解释数据竞争问题。此外,我们介绍了 Go 的竞争检测工具,用于识别竞争条件,理解了通信通道的重要性及其潜在的陷阱。

现在,我们已经刷新了并发编程的知识,接下来让我们在下一章中探索如何通过系统调用与操作系统进行交互。