Go语言之并发编程入门 | 青训营

132 阅读6分钟

在Go中进行并发编程非常容易,所以Go的性能还是很好的,下面来了解一下基础的一些并发编程语法。

Goroutines

Goroutine 是由 Go 运行时管理的轻量级线程,即协程。 要创建 goroutine,只需调用前面带有 go 关键字的函数即可。 该函数将与程序的其余部分同时执行:

func doWork() {
  // do some work
}

go doWork() // this will run concurrently

那么协程到底有多轻量呢,我们与之和线程进行比较:

Threads

  • 线程由操作系统内核管理。创建和在线程之间切换需要内核的参与,线程切换需要完整的上下文切换——CPU寄存器和堆栈必须被保存和恢复,这种操作是非常昂贵耗时的。
  • 线程具有由操作系统预先分配的固定堆栈大小(通常为1MB或更多)。如果你有很多线程,这可能会导致内存使用率较高。

Goroutines

  • Goroutines由Go运行时管理,而非操作系统内核,Goroutine切换只是将寄存器和程序计数器存储在Goroutine堆栈上,这使得Goroutine切换非常快速且轻量级。
  • Goroutines在少量的操作系统线程上进行复用。Goroutines从一个2kB的小堆栈开始,可以根据需要增长和缩小。这使得内存占用很小。

所以在代码中使用goroutines进行并发操作非常高效。


Goroutines同步方法

Channels

通道为goroutine提供了一种通信和同步执行的方式。 创建通道:

ch := make(chan int)

这将创建一个整数型通道。 一个goroutine可以在通道上发送值:

ch <- 10 // send value 10

另一个可以从这个通道中获取这个值:

x := <-ch // receive value from ch

这种同步允许 goroutine 安全地相互等待。

Empty Channel

  • 空通道上的发送操作将阻塞,直到另一个 goroutine 在同一通道上执行接收。
  • 空通道上的接收操作将阻塞,直到另一个 goroutine 在该通道上执行发送。

例如:

ch := make(chan int) // empty channel

go func() {
  ch <- 42 // blocks until receive
}()

x := <-ch // blocks until send

这允许goroutine使用阻塞来同步执行。

Non-empty Channel

  • 非空通道上的发送操作不会阻塞。 该值将立即发送。
  • 非空通道上的接收操作不会阻塞。 它将立即收到一个值。

例如:

ch := make(chan int, 1) // buffer size 1
ch <- 10 // send succeeds immediately

x := <- ch // receive succeeds immediately

对于缓冲通道,当缓冲区已满时仅发送块。 仅当缓冲区为空时才接收块。

Select

select语句会阻塞多个通道操作,等待其中一个操作继续。这可用于实现超时行为。

例如:

import "time"

func process(ch <-chan int) {
  // process channel
}

func main() {
  ch := make(chan int)
  go populate(ch) // populate channel in background 

  timeout := time.After(5 * time.Second)
  
  for {
    select {
    case v := <-ch:
      // Got value from channel ch, process it
      process(v)
    case <-timeout:
      // Timeout happened, exit the loop
      fmt.Println("timed out")
      return
    }
  }
}

func populate(ch chan<- int) {
  // Goroutine to populate channel
  ch <- 1
  ch <- 2
  ch <- 3
  // ...
}
  • select 会进行阻塞,直到其中一个 case 语句可以继续执行。
  • time.After 创建一个在给定持续时间后提供超时的通道。
  • 即使通道尚未准备好,超时情况也会在 5 秒后触发。
  • 如果我们先从ch中接收到数据,我们就会进行正常处理。
  • 但如果首先触发超时,我们将退出循环并打印“timed out”。

这允许实现一个超时通道以防止卡在等待数据传输通道上。select将选择第一个准备好的通道来执行。

我们还可以使用默认情况来实现非阻塞接收:

select {
case v := <-ch:
  process(v) 
default:
  // no data ready, move on
}

Sync

sync包提供了很多有用的同步原语。

Mutex

互斥锁mutex提供互斥锁定。可以用 Lock() 锁定它,用 Unlock() 解锁。

例如:

var count int
var mutex sync.Mutex 

func worker() {
  mutex.Lock()
  count++
  mutex.Unlock()
}

func main() {
  for i:=0; i<10; i++ {
    go worker()
  }

  mutex.Lock()
  fmt.Println(count) // prints 10
  mutex.Unlock()
}

RWMutex

RWMutex 提供读取/写入互斥操作。多个读取者可以同时访问数据,但写入者必须具有独占访问权限。

例如:

var rwmutex sync.RWMutex
var data []int

func reader() {
  rwmutex.RLock() 
  v := data[0] // read
  rwmutex.RUnlock()
}

func writer() {
  rwmutex.Lock()
  data[0] = 10 // write
  rwmutex.Unlock() 
}

reader可以多个同时访问data[0]的数据,但是当writer访问时,独占data[0]。

WaitGroup

WaitGroup 可以让你等待一组 goroutine 完成。使用 Add() 添加 goroutine,使用 Done() 标记完成,使用 Wait() 进行等待。

例如:

var wg sync.WaitGroup

func worker() {
  defer wg.Done()
  
  // do work
}

func main() {
  wg.Add(2) // 2 goroutines

  go worker()
  go worker()

  wg.Wait() // blocks until Done called
}

如果没有wg.Wait(),main函数会退出,但是程序并不会结束,等到worker中的Done()都执行完后,程序才结束。

sync.Cond

sync.Cond 为更复杂的等待场景实现了条件变量。 一个 Goroutine 可以在 Cond 上 Wait(),另一个 Goroutine 可以 Signal() 或 Broadcast() 来唤醒它们。

例如:

import "sync"

var cond = sync.NewCond(&sync.Mutex{})

func producer() {
  // Produce some data
  data := make([]int, 10)
  
  cond.L.Lock()
  cond.Signal() // Signal condition
  cond.L.Unlock()
}

func consumer() {
  cond.L.Lock()
  cond.Wait() // Wait on condition

  // Access data 
  data := getData()
  
  cond.L.Unlock()
}

func main() {
  go producer()
  go consumer()
}
  • sync.NewCond 创建一个带有关联 Locker(通常是 Mutex)的新 Cond
  • Cond.Wait() 会阻塞 goroutine,直到调用 Cond.Signal()
  • Cond.Signal() 唤醒一个等待cond的 goroutine
  • Cond.Broadcast() 唤醒所有等待cond的 goroutine
  • 锁在发信号和等待时保护共享数据

这可以协调生成数据的生产者和等待数据准备好的消费者。

消费者调用 Wait() 进行阻塞,直到生产者发出数据准备就绪的信号。 锁确保在此切换期间对共享数据的独占访问。

当简单的通道通信不够时,Cond 对于更复杂的同步场景非常有用。

总结

以下是 Go 并发编程的一些技巧和见解:

  • 使用 goroutine 来进行并发 - 而不是线程。 Goroutines 是轻量级的并且由 Go 管理。
  • 使用通道在 goroutine 之间进行通信和同步。
  • 了解通道阻塞的工作原理以及如何缓冲通道。
  • 使用 WaitGroup 等待一组 goroutine 完成。
  • 使用互斥锁锁定共享数据,以确保并发 Goroutine 的访问安全。
  • 使用sync.Cond 来实现更高级的等待场景。
  • 如果适用,将程序构建为并发流水线。
  • 通过正确的同步避免竞争条件 - 使用竞争检测器进行测试。
  • 考虑如何优雅的关闭 - 关闭通道或使用context。
  • 使用 select 对通道进行多路复用操作。
  • 利用工作池、扇出/扇入、并行映射等模式。
  • 如果需要,使用 ThreadPool 或sync.Semaphore 限制并发。
  • 测量负载下的性能以查找瓶颈。 调整 GOMAXPROCS。
  • 注释和文档应注意同步和线程安全。
  • 调试并发问题可能很棘手 - 使用日志语句。
  • 如果需要,可以使用 T.Run 模拟并发测试。

关键是要考虑并发组件之间的同步和通信。 避免竞争条件并警惕共享的状态。