燃起并发的烈火 — Go并发基础(下)

欢迎小伙伴们观看我的文章,若文中有不正确的地方,小伙伴们可以在评论区处指出,希望能够与小伙伴们一起成长与进步,谢谢!
本文继续探讨Go并发编程基础,上篇戳如下链接~

点燃并发的火花 --- Go并发基础(上)

一、并发安全与锁

在涉及到多个goroutine运行时,难免会存在多个goroutine同时对一个资源进行操作的情况发生,从而产生数据竞态问题。例如一台atm,大家都需要排队等候使用,无法两个人同时使用。举个例子:

go 复制代码
package main

import (
    "fmt"
    "sync"
)

var wg sync.WaitGroup
var x int

func increase() {
    defer wg.Done()
    for i := 0; i < 5000; i++ {
       x++
    }
}

func main() {
    wg.Add(2)
    go increase()
    go increase()
    wg.Wait()
    fmt.Println(x)
}

上述代码的执行结果每次执行都会产生不同的结果,例如7741、6174、6063等,示例代码中开启两个goroutine分别执行increase()函数,两个goroutine分别在访问和修改全局变量i时,由于x++并非原子操作,某个goroutine中对全局变量x的修改可能覆盖另一个goroutine的操作,产生数据竞争,从而导致最后的结果与预期不符。

1、互斥锁

互斥锁介绍

互斥锁(mutex)是一种用于多线程编程中对共享资源进行访问控制的常用机制。它提供了一种排他锁定机制,确保同一时间只能有一个线程访问临界区资源。

当多个线程需要访问共享资源时,如果没有进行有效的同步机制,就会发生数据竞态问题。使用互斥锁可以保证在任何时刻,只有一个线程可以获取到这个锁,从而避免多个线程同时访问共享资源导致的问题。

互斥锁通常包括 lockunlock 两个操作。一个线程在进入临界区之前需要先获取锁,如果锁已经被其他线程占用,则当前线程需要等待。当一个线程退出临界区时,它需要释放锁,以便其他线程可以获取这个锁并访问共享资源。

sync.Mutex

在Go语言中,使用sync包中提供的Mutex类型来实现互斥锁,它能够保证同一时间只有一个goroutine可以访问共享资源。 sync.Mutex提供了两个方法:

方法名 功能说明
func (m *Mutex) Lock() 获取互斥锁
func (m *Mutex) Unlock() 释放互斥锁

使用互斥锁限制每次只能有一个goroutine获取并修改全局变量i,修改后的代码如下:

go 复制代码
package main

import (
    "fmt"
    "sync"
)

var (
    x  int
    wg sync.WaitGroup // 等待组
    m  sync.Mutex     // 互斥锁
)

// increase 对全局变量i执行加一操作
func increase() {
    defer wg.Done()
    for i := 0; i < 5000; i++ {
       m.Lock() // 获取互斥锁,即加锁
       x++
       m.Unlock()
    }
}

func main() {
    wg.Add(2)
    go increase()
    go increase()
    wg.Wait()
    fmt.Println(x) // 10000
}

上述代码加入互斥锁后,每一次运行都会得到预期的结果10000。

  • 使用互斥锁能够保证同一时间有且只有一个 goroutine 进入临界区,其他的 goroutine 则在等待锁;
  • 当互斥锁释放后,等待的 goroutine 才可以获取锁进入临界区,多个 goroutine 同时等待一个锁时,唤醒的策略是随机的。

2、读写锁

在某些场景下,对于资源的访问可能是读多写少,当并发读取一个资源且不涉及资源的修改时,可以考虑使用读写锁。

不同于互斥锁的完全互斥,读写锁分为读锁与写锁。

  • 当一个goroutine获取到读锁后,其他goroutine如果是获取读锁,则可以继续获取该读锁,如果获取写锁,则会阻塞等待。
  • 当一个goroutine获取到写锁后,其他goroutine无论是获取读锁还是写锁,都会阻塞等待。

在Go中,读写锁使用sync包中的RWMutex类型。

读写锁在Go中使用sync包中的RWMutex类型。sync.RWMutex提供的方法如下:

方法名 功能说明
func (rw *RWMutex) Lock() 获取写锁
func (rw *RWMutex) Unlock() 释放写锁
func (rw *RWMutex) RLock() 获取读锁
func (rw *RWMutex) RUnlock() 释放读锁
func (rw *RWMutex) RLocker() Locker 返回一个实现Locker接口的读写锁

通过一个读多写少的场景,通过互斥锁与读写锁的使用来对比两者之间性能的差异。

go 复制代码
package main

import (
    "fmt"
    "sync"
    "time"
)

var (
    x       int
    wg      sync.WaitGroup
    mutex   sync.Mutex
    rwmutex sync.RWMutex
)

// 使用互斥锁进行写操作
func writeByLock() {
    mutex.Lock() // 加互斥锁
    x = x + 1
    time.Sleep(time.Millisecond * 10) // 模拟写操作耗时时长
    mutex.Unlock()
    wg.Done()
}

// 使用互斥锁进行读操作
func readByLock() {
    mutex.Lock()                 // 加互斥锁
    time.Sleep(time.Millisecond) // 模拟读操作耗时时长
    mutex.Unlock()
    wg.Done()
}

// 使用读写互斥锁进写操作
func writeByRWLock() {
    rwmutex.Lock() // 加读写互斥锁
    x = x + 1
    time.Sleep(time.Millisecond * 10) // 模拟写操作耗时时长
    rwmutex.Unlock()
    wg.Done()
}

// 使用读写互斥锁进行读操作
func readByRWLock() {
    rwmutex.RLock()              // 加读写互斥锁
    time.Sleep(time.Millisecond) // 模拟读操作耗时时长
    rwmutex.RUnlock()
    wg.Done()
}

func operate(wf func(), rf func(), wc int, rc int) {
    start := time.Now()

    // wc个并发写操作
    for i := 0; i < wc; i++ {
       wg.Add(1)
       go wf()
    }

    // rc个并发读操作
    for i := 0; i < rc; i++ {
       wg.Add(1)
       go rf()
    }

    wg.Wait()
    cost := time.Since(start)
    fmt.Printf("x: %v cost: %v\n", x, cost)
}

上述代码中,分别使用互斥锁与读写锁执行10次并发写与1000次并发读,并计算执行前后的耗时时间。分别调用查看执行结果:

  • 使用互斥锁
go 复制代码
func main() {
    // 使用互斥锁,10并发写,1000并发读
    operate(writeByLock, readByLock, 10, 1000) // x: 10 cost: 15.7973915s
}
  • 使用读写锁
go 复制代码
func main() {
    // 使用读写互斥锁,10并发写,1000并发读
    operate(writeByRWLock, readByRWLock, 10, 1000) // x: 10 cost: 171.1409ms
}

从执行结果可以看到,使用读写互斥锁在读多写少的场景下,能够极大地提高程序的性能。但如果程序中读操作与写操作的数量级差别不大,则读写锁的优势无法发挥出来。

3、sync.WaitGroup

在前面的代码中,基本都是用到了sync.WaitGroup,通过使用sync.WaitGroup从而来实现并发任务的同步。

在Go中,sync.WaitGroup类型提供了如下几个方法:

方法名 功能说明
func (wg * WaitGroup) Add(delta int) 等待组计数器 + delta
(wg *WaitGroup) Done() 等待组计数器-1
(wg *WaitGroup) Wait() 阻塞直到等待组计数器变为0

sync.WaitGroup在内部实现上,维护了一个计数器,该计数器的值可以通过Add()Done()两个方法来实现增减。goroutine通过调用Wait来等待并发任务执行完,当计数器值为 0 时,表示所有并发任务已经完成。

go 复制代码
package main

import (
    "fmt"
    "sync"
)

// 声明全局等待组变量
var wg sync.WaitGroup

func printHello() {
    fmt.Println("Hello World")
    wg.Done() // 完成一个任务后,调用Done()方法,等待组减1,告知当前goroutine已经完成任务
}

func main() {
    wg.Add(1) // 等待组加1,表示登记一个goroutine
    go printHello()
    fmt.Println("main")
    wg.Wait() // 阻塞当前goroutine,直到等待组中的所有goroutine都完成任务
}

// 执行结果
main
Hello World

4、sync.Once

在高并发场景下,有时候需要保证某些操作只会被执行一次,例如只加载一次配置文件等。

在Go中,sync包提供了一个针对只执行一次场景的解决方案,即sync.Once类型,sync.Once只提供了一个Do方法,其方法签名如下:

go 复制代码
func (o *Once) Do(f func())

上述方法中,如果要执行的函数f需要传递参数,就需要搭配闭包来使用。

  • 加载配置文件示例

延迟一个开销很大的初始化操作,在真正需要使用到时,再执行初始化是一个很好的实践

因为预先初始化一个变量,例如在init函数中完成初始化等操作,会增加程序的启动耗时,并且有可能在实际的执行过程中,这个变量都没有使用到,此时这个初始化操作就不是必要的。

举个例子:

go 复制代码
var icons map[string]image.Image

func loadIcons() {
    icons = map[string]image.Image{
       "left":  loadIcon("left.png"),
       "up":    loadIcon("up.png"),
       "right": loadIcon("right.png"),
       "down":  loadIcon("down.png"),
    }
}

// Icon 被多个goroutine调用时不是并发安全的
func Icon(name string) image.Image {
    if icons == nil {
       loadIcons()
    }
    return icons[name]
}

上述代码中,在多个goroutine并发调用Icon函数时并不是并发安全的,现代的编译器和CPU可能会在保证每个 goroutine 都满足串行一致的基础上自由地重排访问内存的顺序。loadIcons函数可能会被重排为以下结果:

go 复制代码
func loadIcons() {
	icons = make(map[string]image.Image)
	icons["left"] = loadIcon("left.png")
	icons["up"] = loadIcon("up.png")
	icons["right"] = loadIcon("right.png")
	icons["down"] = loadIcon("down.png")
}

由于并非并发安全,就有可能出现即使判断icons不是nil,也有可能icons并未初始化完成。考虑到这种情况,可以通过添加互斥锁来保证icons初始化时不会被其他goroutine操作,但又会引发性能问题。

通过使用sync.Once可以很好的避免上述的问题。

go 复制代码
var icons map[string]image.Image

var loadIconsOnce sync.Once

func loadIcons() {
    icons = map[string]image.Image{
       "left":  loadIcon("left.png"),
       "up":    loadIcon("up.png"),
       "right": loadIcon("right.png"),
       "down":  loadIcon("down.png"),
    }
}

// Icon 并发安全
func Icon(name string) image.Image {
    loadIconsOnce.Do(loadIcons) // 只会执行一次
    return icons[name]
}

并发安全的单例模式

通过sync.Once,可以实现并发安全下的单例模式。

go 复制代码
type singleton struct {}

var instance *singleton
var once sync.Once

func GetInstance() *singleton {
    once.Do(func() {
       instance = &singleton{}
    })
    return instance
}

sync.Once其实内部包含一个互斥锁和一个布尔值,互斥锁保证布尔值和数据的安全,而布尔值用来记录初始化是否完成。

这样的设计保证了初始化操作是并发安全且初始化操作也只执行一次。不会被执行多次。

5、sync.Map

在Go中,内置的map类型不是并发安全的数据类型,在多个goroutinemap写入数据是不被允许的。例如:

go 复制代码
package main

import (
    "strconv"
    "sync"
)

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 < 10; i++ {
       wg.Add(1)
       go func(n int) {
          key := strconv.Itoa(n)
          set(key, n)
       }(i)
    }
    wg.Wait()
}

上述代码编译执行后,终端会报错fatal error: concurrent map writesfatal error: concurrent map writes,告知我们不能在多个goroutine下并发对map进行读写操作,否则会出现数据竞争的情况。

为了保证map的并发安全性,Go语言中的sync包提供了一个开箱即用的并发安全的map ------ sync.Map。sync.Map类型不需要使用make函数进行初始化而可以直接使用,且该类型封装了许多操作方法。例如:

方法名 功能说明
func (m *Map) Store(key, value interface{}) 存储键值对数据
func (m *Map) Load(key interface{}) (value interface{}, ok bool) 查询key对应的value
func (m *Map) LoadOrStore(key, value interface{}) (actual interface{}, loaded bool) 查询或存储key对应的value
func (m *Map) LoadAndDelete(key interface{}) (value interface{}, loaded bool) 查询并删除key以及value
func (m *Map) Delete(key interface{}) 删除对应的key
func (m *Map) Range(f func(key, value interface{}) bool) map中的每个key-value依次调用f
go 复制代码
package main

import (
    "fmt"
    "strconv"
    "sync"
)

var m = sync.Map{}

func main() {
    wg := sync.WaitGroup{}
    for i := 0; i < 20; i++ {
       wg.Add(1)
       go func(n int) {
          key := strconv.Itoa(n)
          m.Store(key, n)         // 存储key
          value, _ := m.Load(key) // 获取key对应的value
          fmt.Printf("k=:%v,v:=%v\n", key, value)
          wg.Done()
       }(i)
    }
    wg.Wait()
}

二、原子操作

针对整数数据类型(int32、uint32、int64、uint64),在并发操作时,为了保证并发安全,可以使用原子操作来保证并发安全,使用原子操作比直接使用锁操作效率更高,因为加锁涉及内核态的上下文切换会比较耗时、代价比较高,而原子操作在用户态即可完成操作。

Go语言中原子操作由内置的标准库sync/atomic提供。具体的一下方法如下:

读操作

  • func LoadInt32(addr *int32) (val int32)
  • func LoadInt64(addr *int64) (val int64)
  • func LoadUint32(addr *uint32) (val uint32)
  • func LoadUint64(addr *uint64) (val uint64)
  • func LoadUintptr(addr *uintptr) (val uintptr)
  • func LoadPointer(addr *unsafe.Pointer) (val unsafe.Pointer)

写操作

  • func StoreInt32(addr *int32, val int32)
  • func StoreInt64(addr *int64, val int64)
  • func StoreUint32(addr *uint32, val uint32)
  • func StoreUint64(addr *uint64, val uint64)
  • func StoreUintptr(addr *uintptr, val uintptr)
  • func StorePointer(addr *unsafe.Pointer, val unsafe.Pointer)

修改操作

  • func AddInt32(addr *int32, delta int32) (new int32)
  • func AddInt64(addr *int64, delta int64) (new int64)
  • func AddUint32(addr *uint32, delta uint32) (new uint32)
  • func AddUint64(addr *uint64, delta uint64) (new uint64)
  • func AddUintptr(addr *uintptr, delta uintptr) (new uintptr)

交换操作

  • func SwapInt32(addr *int32, new int32) (old int32)
  • func SwapInt64(addr *int64, new int64) (old int64)
  • func SwapUint32(addr *uint32, new uint32) (old uint32)
  • func SwapUint64(addr *uint64, new uint64) (old uint64)
  • func SwapUintptr(addr *uintptr, new uintptr) (old uintptr)
  • func SwapPointer(addr *unsafe.Pointer, new unsafe.Pointer) (old unsafe.Pointer)

比较并交换操作

  • func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool)
  • func CompareAndSwapInt64(addr *int64, old, new int64) (swapped bool)
  • func CompareAndSwapUint32(addr *uint32, old, new uint32) (swapped bool)
  • func CompareAndSwapUint64(addr *uint64, old, new uint64) (swapped bool)
  • func CompareAndSwapUintptr(addr *uintptr, old, new uintptr) (swapped bool)
  • func CompareAndSwapPointer(addr *unsafe.Pointer, old, new unsafe.Pointer) (swapped bool)

可以通过使用互斥锁修改变量与原子操作修改变量来比较两者的性能:

go 复制代码
package main

import (
    "fmt"
    "sync"
    "sync/atomic"
    "time"
)

var x int64
var lock sync.Mutex
var wg sync.WaitGroup

// 使用互斥锁
func mutexAdd() {
    lock.Lock()
    x++
    lock.Unlock()
    wg.Done()
}

// 使用原子操作
func atomicAdd() {
    atomic.AddInt64(&x, 1)
    wg.Done()
}

func main() {
    startTime := time.Now()
    for i := 0; i < 10000; i++ {
       wg.Add(1)
       go mutexAdd() // 使用互斥锁add函数
       //go atomicAdd() // 原子操作add函数
    }
    wg.Wait()
    endTime := time.Now()
    fmt.Println(x)
    fmt.Println(endTime.Sub(startTime))
}

// go atomicAdd() 执行结果
10000
2.5564ms

// go mutexAdd() 执行结果
10000
3.1184ms
相关推荐
ai小鬼头7 小时前
Ollama+OpenWeb最新版0.42+0.3.35一键安装教程,轻松搞定AI模型部署
后端·架构·github
萧曵 丶7 小时前
Rust 所有权系统:深入浅出指南
开发语言·后端·rust
老任与码8 小时前
Spring AI Alibaba(1)——基本使用
java·人工智能·后端·springaialibaba
华子w9089258598 小时前
基于 SpringBoot+VueJS 的农产品研究报告管理系统设计与实现
vue.js·spring boot·后端
星辰离彬9 小时前
Java 与 MySQL 性能优化:Java应用中MySQL慢SQL诊断与优化实战
java·后端·sql·mysql·性能优化
GetcharZp10 小时前
彻底告别数据焦虑!这款开源神器 RustDesk,让你自建一个比向日葵、ToDesk 更安全的远程桌面
后端·rust
jack_yin11 小时前
Telegram DeepSeek Bot 管理平台 发布啦!
后端
小码编匠11 小时前
C# 上位机开发怎么学?给自动化工程师的建议
后端·c#·.net
库森学长11 小时前
面试官:发生OOM后,JVM还能运行吗?
jvm·后端·面试
转转技术团队11 小时前
二奢仓店的静默打印代理实现
java·后端