在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 模拟并发测试。
关键是要考虑并发组件之间的同步和通信。 避免竞争条件并警惕共享的状态。