Go语言并发编程 ----- sync包

sync包是Go语言标准库中提供的基础同步原语包,用于实现并发编程中的同步控制。在并发编程中同步原语也就是我们通常说的锁的主要作用是保证多个线程或者 goroutine在访问同一片内存时不会出现混乱的问题。

在前置文章中我讲述了sync包下的Mutex和RWMutex,文章地址《Go语言并发编程 ------ 锁机制详解

接下来我会讲解sync包下其他几个的方法

1. sync.WaitGroup

sync.WaitGroup是一个经常会用到的同步原语,它的使用场景是在一个goroutine等待一组goroutine执行完成。

sync.WaitGroup拥有一个内部计数器。当计数器等于0时,则Wait()方法会立即返回。否则它将阻塞执行Wait()方法的goroutine直到计数器等于0时为止。

要增加计数器,我们必须使用Add(int)方法。要减少它,我们可以使用Done()(将计数器减1),也可以传递负数给Add方法把计数器减少指定大小,Done()方法底层就是通过Add(-1)实现的 。

复制代码
var wg sync.WaitGroup

for i := 0; i < 5; i++ {
    wg.Add(1) // 增加计数器
    go func(i int) {
        defer wg.Done() // 完成后计数器减1
        fmt.Println(i)
    }(i)
}

wg.Wait() // 阻塞直到计数器为0
// 继续往下执行...

每次创建goroutine时,我们都会使用wg.Add(1)来增加wg的内部计数器。我们也可以在for循环之前调用wg.Add(5)

与此同时,每个goroutine完成时,都会使用wg.Done()减少wg的内部计数器。

main goroutine会在五个goroutine都执行wg.Done()将计数器变为0后才能继续执行。

2.sync.Once

sync.Once确保某个操作只执行一次。

复制代码
var once sync.Once

func main() {
	for i := 0; i < 5; i++ {
		go func() {
			once.Do(func() {
				fmt.Println("Hello, world!")
			})
		}()
	}
}

这里解释以下Do方法,

如果只有一个协程使用一个方法肯定是没有任何问题的,但是如果有多个协程访问的话就可能会出现问题了。比如协程 A 和 B 同时调用了 Add 方法,A 执行的稍微快一些,已经初始化完毕了,并且将数据成功添加,随后协程 B 又初始化了一遍,这样一来将协程 A 添加的数据直接覆盖掉了,这就是问题所在。

而这就是 sync.Once 要解决的问题,顾名思义,Once 译为一次,sync.Once 保证了在并发条件下指定操作只会执行一次。它的使用非常简单,只对外暴露了一个 Do 方法。

3.sync.Cond

sync.Cond可能是sync包提供的同步原语中最不常用的一个,它用于发出信号(一对一)或广播信号(一对多)到goroutine.

复制代码
var (
    mu      sync.Mutex
    cond    = sync.NewCond(&mu)
    ready   bool
)

// 等待方
mu.Lock()
for !ready {
    cond.Wait() // 等待条件满足
}
mu.Unlock()

// 通知方
mu.Lock()
ready = true
cond.Broadcast() // 通知所有等待者
mu.Unlock()

4.sync.Pool

sync.Pool 的设计目的是用于存储临时对象以便后续的复用,是一个临时的并发安全对象池,将暂时用不到的对象放入池中,在后续使用中就不需要再额外的创建对象可以直接复用,减少内存的分配与释放频率,最重要的一点就是降低 GC 压力。sync.Pool 总共只有两个方法,如下:

复制代码
// 申请一个对象
func (p *Pool) Get() any

// 放入一个对象
func (p *Pool) Put(x any)

并且**sync.Pool** 有一个对外暴露的 New 字段,用于对象池在申请不到对象时初始化一个对象

复制代码
New func() any

简单的例子:

复制代码
var bufPool = sync.Pool{
    New: func() interface{} {
        return new(bytes.Buffer)
    },
}

// 获取
//将从对象池中获取的interface{}类型的对象断言为*bytes.Buffer类型。
buf := bufPool.Get().(*bytes.Buffer) 
// 使用后放回
buf.Reset()
bufPool.Put(buf)

这里New字段: 是一个函数指针,用于在对象池为空时创建新的bytes.Buffer对象。每次调用New函数时,会返回一个interface{}类型的对象,这里通过new(bytes.Buffer)创建了一个新的bytes.Buffer对象。

Reset方法用于清空bytes.Buffer的内容,使其可以被重新使用。调用Reset方法后,bytes.Buffer的内部状态会被重置,包括已写入的数据和缓冲区的大小。

5.sync.Map

sync.Map是并发安全的map实现,适合读多写少场景。其对外暴露了多个方法

复制代码
// 根据一个key读取值,返回值会返回对应的值和该值是否存在
func (m *Map) Load(key any) (value any, ok bool)

// 存储一个键值对
func (m *Map) Store(key, value any)

// 删除一个键值对
func (m *Map) Delete(key any)

// 如果该key已存在,就返回原有的值,否则将新的值存入并返回,当成功读取到值时,loaded为true,否则为false
func (m *Map) LoadOrStore(key, value any) (actual any, loaded bool)

// 删除一个键值对,并返回其原有的值,loaded的值取决于key是否存在
func (m *Map) LoadAndDelete(key any) (value any, loaded bool)

// 遍历Map,当f()返回false时,就会停止遍历
func (m *Map) Range(f func(key, value any) bool)

例子:

复制代码
var m = make(map[string]int)

func get(key string) int {
    return m[key]
}

func set(key string, value int) {
    m[key] = value
}

func main() {
    wg := sync.WaitGroup{}
    for i := 0; i < 20; i++ {
        wg.Add(1)
        go func(n int) {
            key := strconv.Itoa(n)
            set(key, n)
            fmt.Printf("k=:%v,v:=%v\n", key, get(key))
            wg.Done()
        }(i)
    }
    wg.Wait()
}

上面的代码开启少量几个goroutine的时候可能没什么问题,当并发多了之后执行上面的代码就会报fatal error: concurrent map writes错误。

像这种并发安全问题,使用sync.Map加锁就可以解决。

复制代码
var m = sync.Map{}

func main() {
    wg := sync.WaitGroup{}
    for i := 0; i < 20; i++ {
        wg.Add(1)
        go func(n int) {
            //将整数n转换为字符串,作为即将存储到sync.Map中的键。
            key := strconv.Itoa(n)
            m.Store(key, n)
            value, _ := m.Load(key)
            fmt.Printf("k=%v,v=%v\n", key, value)
            wg.Done()
        }(i)
    }
    wg.Wait()
}

Go并发编程总结

Goroutine:

  • 定义: goroutine是Go语言中的并发执行单元,可以看作是轻量级的线程。
  • 启动: 使用 go 关键字启动一个新的goroutine。例如:go 函数名(参数)。
  • 特性: Goroutine非常轻量,启动和切换的开销很小,可以在单个线程中启动数以千计的goroutine。

Channel:

  • 定义: channel是用于在不同的goroutine之间传递数据的机制。
  • 创建: 使用 make 函数创建一个channel。例如:ch := make(chan int)。
  • 发送和接收: 使用 <- 操作符发送和接收数据。例如:ch <- data(发送),data := <- ch(接收)。
  • 类型: Channel可以是有缓冲的(make(chan int, 缓冲大小))和无缓冲的(make(chan int))。无缓冲的channel在发送和接收时会阻塞,直到操作完成;有缓冲的channel可以在达到缓冲大小前异步发送数据。

同步工具:

  • WaitGroup: sync.WaitGroup用于等待一组goroutine完成。通过Add方法增加待完成的任务数,通过Done方法通知任务完成,使用Wait方法阻塞主goroutine直到所有任务完成。
  • Mutex和RWMutex: sync.Mutex和sync.RWMutex用于在多个goroutine之间提供互斥访问,确保同一时间只有一个goroutine可以访问共享资源。RWMutex允许多个读操作同时进行,但写操作是独占的。
  • Cond: sync.Cond用于在多个goroutine之间进行条件变量的同步,可以在特定条件下通知等待的goroutine。
  • Once: sync.Once确保某个函数只被调用一次,即使在多个goroutine中调用,该函数也只会被执行一次。

选择器(select):

  • 定义: select用于在多个channel操作中进行选择,类似于switch语句,但用于channel操作。
  • 特性: select可以选择一个可操作的channel执行,如果多个channel都可操作,则随机选择一个。如果所有通道都不可操作,select会阻塞,直到某个通道可操作。

并发模式:

  • 生产者-消费者模式: 多个生产者goroutine生成数据并将其发送到channel,多个消费者goroutine从channel接收数据并进行处理。
  • 工作池模式: 使用固定数量的goroutine(工作池)处理任务队列中的任务,可以有效控制并发度。
  • Pipeline模式: 通过多个channel连接多个goroutine,形成一个数据处理流水线,每个goroutine负责处理特定的任务。