Golang 并发控制

一、临界资源

临界资源是指并发环境中,多个进程、线程或协程共享的资源。在并发编程中,若对临界资源处理不当,往往会导致数据不一致的问题,即临界资源的安全问题。

二、CSP 并发模型

CSP(Communicating Sequential Processes)并发模型是 Go 语言的核心并发设计哲学,由 Tony Hoare 于 1978 年提出,强调“通过通信共享内存,而非共享内存来通信”。这一模型通过 goroutine 和 channel 实现,显著简化了并发编程的复杂度。

1. 核心思想

(1)通信优于共享内存

  • 传统并发:线程通过锁保护共享内存,易引发死锁、竞态条件等问题。
  • Go 的 CSP 模型:goroutine 之间通过 channel 传递数据,每个协程独立处理自身状态,避免直接共享内存,从而消除锁的需求。

2. 关键组件

ch := make(chan int, 3) // 缓冲大小为 3 的整型通道
go func() { ch <- 42 }() // 异步发送
value := <-ch           // 接收数据

(1)goroutine

是一种轻量线程,它不是操作系统线程,而是将一个操作系统线程分段使用,通过调度器实现协作式调度。

(2)channel

类似 Unix 的 Pipe,用于协程之间通讯和同步。

3. Go 对 CSP 的实现

实际上,Go 并没有完全实现 CSP 模型的所有理论,仅仅是借用了 process(在 Go 上表现为 goroutine)和 channel 这两个概念。

(1)goroutine 的调度机制(GMP 模型)

(2)channel 的同步与通信

  • 同步阻塞:无缓冲通道的发送、接收需双方就绪,天然实现同步。
  • Select 多路复用:监听多个 channel,响应最先就绪的操作,支持超时控制。

4. CSP 与传统共享内存模型对比

对比
image-1766157669847

三、并发控制场景

1. channel

主协程启动 N 个子协程,主协程等待所有子协程退出后再继续后续流程。

(1)优点

实现简单。

(2)缺点

  • 当需要大量创建协程时,需要相同数量的 channel。
  • 对子协程派生出来的协程不方便控制。

(3)示例代码

package main

import (
	"fmt"
	"time"
)

// channel 控制子协程
func process(ch chan int) {
	time.Sleep(time.Second)
	ch <- 1
}

func main() {
	chs := make([]chan int, 10)

	for i := 0; i < 10; i++ {
		chs[i] = make(chan int)
		go process(chs[i])
	}

	for i, ch := range chs {
		<-ch
		fmt.Printf("routine %d quit\n", i)
	}
}

2. waitgroup

等待一组协程结束。

(1)WaitGroup 实现协程间的同步

  • 声明一个 WaitGroup:var wg sync.WaitGroup
  • 相关函数:
    • func (wg *WaitGroup) Add(delta int):队列计数 +1
    • func (wg *WaitGroup) Done():队列计数 -1
    • func (wg *WaitGroup) Go(f func()):协程开始时队列计数 +1,协程结束时自动 -1(Go 1.25 新增,用来简化 Add() 和 Done())
    • func (wg *WaitGroup) Wait():等待队列中所有协程完成

(2)优点

实现简单。

(3)缺点

  • 计数器不匹配会导致死锁(Add 和 Done 的调用次数必须一致)。
  • 无法处理超时和错误传递。

(4)示例代码

package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup

	wg.Add(1)
	go func() {
		defer wg.Done()
		fmt.Println("goroutine 1")
	}()

	wg.Add(1)
	go func() {
		defer wg.Done()
		fmt.Println("goroutine 2")
	}()

	// go 1.25 以后的简便写法 func (wg *WaitGroup) Go(f func())
	//wg.Go(func() {
	//	fmt.Println("goroutine 3")
	//})

	wg.Wait()
	fmt.Println("all goroutine finish")
}

3. context

对于派生 goroutine 有很强的控制力,可以控制多级派生 goroutine。

(1)示例代码

package main

import (
	"context"
	"fmt"
	"time"
)

func HandelRequest(ctx context.Context) {
	go WriteRedis(ctx)
	go WriteDatabase(ctx)
	for {
		select {
		case <-ctx.Done():
			fmt.Println("Done.")
			return
		default:
			fmt.Println("HandelRequest running...")
			time.Sleep(2 * time.Second)
		}
	}
}

func WriteRedis(ctx context.Context) {
	for {
		select {
		case <-ctx.Done():
			fmt.Println("WriteRedis Done.")
			return
		default:
			fmt.Println("WriteRedis running...")
			time.Sleep(2 * time.Second)
		}
	}
}

func WriteDatabase(ctx context.Context) {
	for {
		select {
		case <-ctx.Done():
			fmt.Println("WriteDatabase Done.")
			return
		default:
			fmt.Println("WriteDatabase running...")
			time.Sleep(2 * time.Second)
		}
	}
}

func main() {
	ctx, _ := context.WithTimeout(context.Background(), 5*time.Second)
	go HandelRequest(ctx)
	fmt.Println("main Done.")
}

4. 锁

(1)两类锁

1)互斥锁(sync.Mutex)
var lock sync.Mutex
lock.Lock()
lock.Unlock()
  • 特性:独占。
  • 适用场景:写操作频繁,或临界区较长的场景(如全局配置更新)。
2)读写锁(sync.RWMutex)
  • 特性:读时共享,写时独占。
  • 适用场景:读多写少(如缓存系统)。

(2)自旋锁(spin lock)

1)核心思想

自旋锁的核心思想是“忙等待”:当一个线程尝试获取锁时,如果锁已被其他线程持有,该线程会持续循环检查锁的状态,直到成功获取锁为止。

2)适用场景
  • 锁持有时间非常短(纳秒或微秒级别)。
  • 锁竞争不激烈,通常只有少数几个 goroutine 需要访问共享资源。
  • 希望避免上下文切换开销的场景。
  • 对延迟要求严格的实时系统。

(3)模式

1)正常模式(Normal)
  • 默认模式。
  • 锁竞争流程:新请求锁的协程,先尝试自旋(4 次),通过原子操作直接获取锁;自旋失败后,进入等待队列休眠。
2)饥饿模式(Starving)

在饥饿模式下,不会启动自旋过程,一旦有协程释放了锁,那么一定会唤醒协程,被唤醒的协程将成功获取锁。

  • 设计目的:解决长期等待的公平问题,避免尾端延迟。
  • 锁分配机制:新请求的 goroutine 直接加入队列尾部,禁止自旋或竞争;锁释放时,所有权直接转移给队列头部。
3)切换逻辑
  • 正常模式切换到饥饿模式:队列中协程等待超时(1ms)。
  • 饥饿模式切换到正常模式:当前持有者等待时间 < 1ms(队列压力缓解),或队列为空(无等待者)。