2. 协程:Goroutine
2.1. 基本概念
在 Go 语言中,协程(Goroutine)是一种轻量级的线程实现,可以帮助程序并发执行。它的实现原理和一般的操作系统线程不同,更加节省资源。
- 轻量级:Goroutine 比线程更轻量,启动一个 Goroutine 的开销远小于线程的开销。每个 Goroutine 在创建时的栈内存非常小(通常是几 KB),并且可以动态扩展。这与操作系统的线程不同,操作系统线程的栈是固定大小的。
- 并发:Goroutine 允许 Go 程序在同一时间运行多个任务,提供了自然的并发编程支持。通过
go关键字,可以轻松地将函数作为协程启动。 - 调度器:Go 语言运行时包含了自己的调度器,将 Goroutine 映射到系统线程上执行。调度器会在 Goroutine 之间公平分配 CPU 时间。
- 通道(Channel) :Goroutine 常常与通道一起使用,用来在不同的 Goroutine 之间传递数据并同步。这种模式可以帮助开发者避免复杂的锁定机制,编写更加简单和安全的并发代码。
- 阻塞和非阻塞:当一个 Goroutine 需要等待某个资源或操作时,它可以自动让出 CPU,使其他 Goroutine 可以继续执行。这种机制使得 Goroutine 可以实现高效的资源利用。
- 错误处理:虽然 Goroutine 简化了并发,但在编写 Goroutine 时仍需注意错误处理和资源清理,避免内存泄漏或意外的程序崩溃。
package main
import (
"fmt"
"time"
)
func sayHello() {
for i := 0; i < 3; i++ {
fmt.Println("Hello")
time.Sleep(500 * time.Millisecond) // 模拟一些耗时操作
}
}
func sayGoodbye() {
for i := 0; i < 3; i++ {
fmt.Println("Goodbye")
time.Sleep(500 * time.Millisecond)
}
}
func main() {
// 启动两个 Goroutine
go sayHello()
go sayGoodbye()
// 主 Goroutine 也可以进行其他操作
time.Sleep(2 * time.Second) // 等待 Goroutine 执行完成
fmt.Println("Main function completed")
}
// 输出的顺序可能会因为 Goroutine 的调度而不同,但通常会看到类似以下的输出:
// Hello
// Goodbye
// Hello
// Goodbye
// Hello
// Goodbye
// Main function completed
2.2. 协程通信
2.2.1. 通过通信共享内存,而不是通过共享内存实现通信
传统共享内存的缺点
在传统的并发编程中,多线程通常通过共享内存(如共享变量、全局变量)来交换信息和状态。然而这种方式存在以下问题:
- 数据竞争(Race Condition) :多个线程同时读写共享内存可能导致数据竞争,需要复杂的锁定机制(如互斥锁)来保证一致性。
- 死锁和活锁:加锁和解锁机制很容易导致死锁和活锁,编写和调试变得非常困难。
- 可读性差:共享内存的代码难以理解,因为要频繁地关注资源加锁和解锁操作。
Go 的通信机制:通道(Channel)
Go 通过通道实现了 协程安全的数据交换,也就是鼓励开发者在 Goroutine 之间直接传递数据,而不是依赖共享内存。通道是一种先进的 消息传递机制,具有以下特点:
- 数据独立性:通过通道,数据直接从一个 Goroutine 传递给另一个 Goroutine,不需要共享内存,也不需要担心并发读写的冲突。
- 同步:无缓冲通道会自动同步发送和接收操作,保证数据被读取后发送方才继续。这种同步机制在设计上更直观、简单。
- 死锁避免:通道的通信方式避免了传统锁定带来的死锁风险,因为它天然具备阻塞和解阻塞的特性
2.2.2. 通道:Channel
在 Go 语言中,协程(Goroutine)之间的通信主要通过 通道(Channel) 来完成。通道是 Go 中的一种特殊类型,专门用于在 Goroutine 之间传递数据和同步协程操作。通道又分为无缓冲通道和有缓冲通道。
通道可以通过 make 函数创建,并且需要指定通道中传递的数据类型。例如:
ch1 := make(chan int) // 创建一个传递 int 类型数据的通道,无缓冲
ch2 := make(chan int, 2) // 创建一个缓冲容量为2的通道
- 无缓冲通道: 同步通道,只能在发送和接收同时发生时传递数据。这种通道用于确保两个 Goroutine 同步,发送方会等待直到数据被接收。
- 有缓冲通道: 可以存储多个数据,不要求发送和接收同时进行。 是一种生产者-消费者模型
2.2.3. 通道的使用
发送数据:使用 ch <- value 将数据发送到通道。
接收数据:使用 value := <-ch 从通道接收数据。
// Gorountine 通道的使用
package main
func CalSquare() {
// 创建通道
src := make(chan int) // 无缓冲通道
dest := make(chan int, 3) // 有缓冲通道,考虑消费者的处理能力会慢一些,所以设置为3,避免生产者一直阻塞
// 开启goroutine将0~9发送到src中
go func() {
defer close(src) // 协程结束后关闭src通道
for i := 0; i < 10; i++ {
src <- i // 写入数据到src通道
}
}()
// 开启goroutine计算平方数
go func() {
defer close(dest)
// 从src中取出半径计算平方数
for i := range src { // 读取数据从src通道,一次一个
dest <- i * i
}
}()
// 主协程从dest中取出平方数并打印
for i := range dest { // 读取数据从dest通道,一次一个
println(i)
}
}
func main() {
CalSquare()
}
2.2.4. 并发安全 —— 加锁
虽然通道非常适合在 Goroutine 之间传递数据,并可以避免大部分加锁,但在以下场景下可能还是需要锁:
- 复杂状态管理:如果多个 Goroutine 需要频繁访问和修改同一个复杂的数据结构(例如,树、图等),锁可能更有效。
- 非协作性操作:当无法通过通道直接传递数据,或数据来源不便于传入通道时,加锁可能更合适。
- 性能:通道在并发高、操作频繁时可能会带来一些性能开销,在这种情况下,锁可能表现更优。
// 共享内存要注意并发安全问题
// 通过加锁的方式来保证并发安全示例
package main
import (
"sync"
"time"
)
// 定义锁和全局变量
var x int
var lock sync.Mutex
// 加锁的自增操作
func addWithLock() {
for i := 0; i < 2000; i++ {
// 加锁
lock.Lock()
// 自增操作
x += 1
// 解锁
lock.Unlock()
}
}
// 不加锁的自增操作
func addWithoutLock() {
for i := 0; i < 2000; i++ {
// 自增操作
x += 1
}
}
// 加法操作
func Add() {
x = 0 // 初始化全局变量
// 开启 5 个 goroutine 执行加锁的自增操作
for i := 0; i < 5; i++ {
go addWithLock()
}
time.Sleep(time.Second) // 主程序不会等待 Goroutine 结束,通过sleep等待保证 Goroutine 结束
// 输出结果
println("加锁的自增操作结果:", x)
// 开启 5 个 goroutine 执行不加锁的自增操作
x = 0
for i := 0; i < 5; i++ {
go addWithoutLock()
}
time.Sleep(time.Second) // 等待所有 goroutine 执行完毕
// 输出结果,结果可能不一致
println("不加锁的自增操作结果:", x)
}
func main() {
Add()
}
2.2.5. 协程阻塞
time.Sleep 是一种不优雅的等待方法,特别是在并发编程中,因为我们没办法准确知道子协程的执行时间。 使用 sync.WaitGroup 可以更优雅地等待多个 Goroutine 完成而不依赖于硬编码的延迟时间。WaitGroup 提供了一种可靠的方式来阻塞主程序,直到所有 Goroutine 完成,避免了不确定的等待时间。
sync.WaitGroup 提供了三个主要的方法:
Add(n int):设置等待的 Goroutine 数量,通常在启动 Goroutine 之前调用。Done():当一个 Goroutine 完成时调用,用于减少WaitGroup的计数。Wait():阻塞调用它的 Goroutine(通常是主 Goroutine),直到WaitGroup计数器变为 0。
// 共享内存要注意并发安全问题
// 通过加锁的方式来保证并发安全示例
package main
import (
"sync"
)
// 定义锁和全局变量
var x int
var lock sync.Mutex
var wg sync.WaitGroup // 等待组,实现阻塞,等待所有协程执行完毕后再继续执行主程序
// 加锁的自增操作
func addWithLock() {
defer wg.Done() // 协程执行完毕后调用,等待组减一
for i := 0; i < 2000; i++ {
// 加锁
lock.Lock()
// 自增操作
x += 1
// 解锁
lock.Unlock()
}
}
// 不加锁的自增操作
func addWithoutLock() {
defer wg.Done() // 协程执行完毕后调用,等待组减一
for i := 0; i < 2000; i++ {
// 自增操作
x += 1
}
}
// 加法操作
func Add() {
x = 0 // 初始化全局变量
// 开启 5 个 goroutine 执行加锁的自增操作
for i := 0; i < 5; i++ {
wg.Add(1)
go addWithLock()
}
// time.Sleep(time.Second) // 主程序不会等待 Goroutine 结束,通过sleep等待保证 Goroutine 结束
wg.Wait() // 等待所有 goroutine 执行完毕
// 输出结果
println("加锁的自增操作结果:", x)
// 开启 5 个 goroutine 执行不加锁的自增操作
x = 0
for i := 0; i < 5; i++ {
wg.Add(1)
go addWithoutLock()
}
// time.Sleep(time.Second) // 等待所有 goroutine 执行完毕
wg.Wait()
// 输出结果,结果可能不一致
println("不加锁的自增操作结果:", x)
}
func main() {
Add()
}