Go 语言入门:并发编程2(多路复用与加锁)

并发编程(2)

多路复用

在某些场景下我们需要同时从多个通道接收数据。

  • 通道在接收数据时,如果没有数据可以接收将会发生阻塞。你也许会写出如下代码使用遍历的方式来实现:

    for{
        // 尝试从ch1接收值
        data, ok := <-ch1
        // 尝试从ch2接收值
        data, ok := <-ch2
        …
    }
    

这种方式虽然可以实现从多个通道接收值的需求,但是运行性能会差很多。为了应对这种场景,Go内置了select关键字,可以同时响应多个通道的操作。

  • select的使用类似于 switch 语句,它有一系列 case分支和一个默认的分支。

    • 每个case会对应一个通道的通信(接收或发送)过程。
    • select会一直等待,直到某个case的通信操作完成时,就会执行case分支对应的语句。
  • 具体格式如下:

    select {
    case <-chan1:
       // 如果 chan1 成功读到数据,则进行该case处理语句
    case chan2 <- 1:
       // 如果成功向chan2写入数据,则进行该case处理语句
    default:
       // 如果上面都没有成功,则进入default处理流程
    }
    
  • select可以同时监听一个或多个channel,直到其中一个channel ready

    package main
    
    import (
       "fmt"
       "time"
    )
    
    func test1(ch chan string) {
       time.Sleep(time.Second * 5)
       ch <- "test1"
    }
    func test2(ch chan string) {
       time.Sleep(time.Second * 2)
       ch <- "test2"
    }
    
    func main() {
       // 2个管道
       output1 := make(chan string)
       output2 := make(chan string)
       // 跑2个子协程,写数据
       go test1(output1)
       go test2(output2)
       // 用select监控
       select {
       case s1 := <-output1:
          fmt.Println("s1=", s1)
       case s2 := <-output2:
          fmt.Println("s2=", s2)
       }
    }
    
  • 如果多个channel同时ready,则随机选择一个执行

    package main
    
    import (
       "fmt"
    )
    
    func main() {
       // 创建2个管道
       int_chan := make(chan int, 1)
       string_chan := make(chan string, 1)
       go func() {
          //time.Sleep(2 * time.Second)
          int_chan <- 1
       }()
       go func() {
          string_chan <- "hello"
       }()
       select {
       case value := <-int_chan:
          fmt.Println("int:", value)
       case value := <-string_chan:
          fmt.Println("string:", value)
       }
       fmt.Println("main结束")
    }
    
  • 可以用于判断管道是否存满

    package main
    
    import (
       "fmt"
       "time"
    )
    
    // 判断管道有没有存满
    func main() {
       // 创建管道
       output1 := make(chan string, 10)
       // 子协程写数据
       go write(output1)
       // 取数据
       for s := range output1 {
          fmt.Println("res:", s)
          time.Sleep(time.Second)
       }
    }
    
    func write(ch chan string) {
       for {
          select {
          // 写数据
          case ch <- "hello":
             fmt.Println("write hello")
          default:
             fmt.Println("channel full")
          }
          time.Sleep(time.Millisecond * 500)
       }
    }
    

并发安全和锁

有时候在Go代码中可能会存在多个goroutine同时操作一个资源(临界区),这种情况会发生竞态问题(数据竞态)。举个例子:

var x int64
var wg sync.WaitGroup

func add() {
    for i := 0; i < 5000; i++ {
        x = x + 1
    }
    wg.Done()
}
func main() {
    wg.Add(2)
    go add()
    go add()
    wg.Wait()
    fmt.Println(x)
}

上面的代码中我们开启了两个goroutine去累加变量x的值,这两个goroutine在访问和修改x变量的时候就会存在数据竞争,导致最后的结果与期待的不符。

  • 结果: 结果只有7000多,没有达到10000。出现的情况是,x=1,进入Agoroutine,x=1+1=2。此时还没完成x的赋值时,有进入B goroutine,则 x=1+1=2.因此会出现残余。

互斥锁

  • 互斥锁是一种常用的控制共享资源访问的方法,它能够保证同时只有一个goroutine可以访问共享资源。Go语言中使用sync包的Mutex类型来实现互斥锁。

  • 使用互斥锁来修复上面代码的问题:

    var x int64
    var wg sync.WaitGroup
    var lock sync.Mutex
    
    func add() {
        for i := 0; i < 5000; i++ {
            lock.Lock() // 加锁
            x = x + 1
            lock.Unlock() // 解锁
        }
        wg.Done()
    }
    func main() {
        wg.Add(2)
        go add()
        go add()
        wg.Wait()
        fmt.Println(x)
    }
    

    使用互斥锁能够保证同一时间有且只有一个goroutine进入临界区,其他的goroutine则在等待锁;

    • 当互斥锁释放后,等待的goroutine才可以获取锁进入临界区,多个goroutine同时等待一个锁时,唤醒的策略是随机的。

读写互斥锁
互斥锁是完全互斥的,但是有很多实际的场景下是读多写少的,当我们并发的去读取一个资源不涉及资源修改的时候是没有必要加锁的,这种场景下使用读写锁是更好的一种选择。

  • 读写锁在Go语言中使用sync 包中的 RWMutex 类型

读写锁分为两种:读锁和写锁

  • 当一个 goroutine 获取读锁之后,其他的goroutine如果是获取读锁会继续获得锁,如果是获取写锁就会等待;
  • 当一个goroutine获取写锁之后,其他的goroutine无论是获取读锁还是写锁都会等待。

读写锁示例:

var (
    x      int64
    wg     sync.WaitGroup
    lock   sync.Mutex
    rwlock sync.RWMutex
)

func write() {
    // lock.Lock()   // 加互斥锁
    rwlock.Lock() // 加写锁
    x = x + 1
    time.Sleep(10 * time.Millisecond) // 假设读操作耗时10毫秒
    rwlock.Unlock()                   // 解写锁
    // lock.Unlock()                     // 解互斥锁
    wg.Done()
}

func read() {
    // lock.Lock()                  // 加互斥锁
    rwlock.RLock()               // 加读锁
    time.Sleep(time.Millisecond) // 假设读操作耗时1毫秒
    rwlock.RUnlock()             // 解读锁
    // lock.Unlock()                // 解互斥锁
    wg.Done()
}

func main() {
    start := time.Now()
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go write()
    }

    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go read()
    }

    wg.Wait()
    end := time.Now()
    fmt.Println(end.Sub(start))
}

注意:读写锁非常适合读多写少的场景,如果读和写的操作差别不大,读写锁的优势就发挥不出来。

Sync

sync.WaitGroup: 实现并发任务的同步

  • sync.WaitGroup有以下几个方法:

    方法名功能
    (wg * WaitGroup) Add(delta int)计数器+delta
    (wg *WaitGroup) Done()计数器-1
    (wg *WaitGroup) Wait()阻塞直到计数器变为0
  • sync.WaitGroup内部维护着一个计数器,计数器的值可以增加和减少。

  • 例如当我们启动了N 个并发任务时,就将计数器值增加N。每个任务完成时通过调用Done()方法将计数器减1。通过调用Wait()来等待并发任务执行完,当计数器值为0时,表示所有并发任务已经完成。

  • 我们利用sync.WaitGroup将上面的代码优化一下:

    var wg sync.WaitGroup
    
    func hello() {
        defer wg.Done()
        fmt.Println("Hello Goroutine!")
    }
    func main() {
        wg.Add(1)
        go hello() // 启动另外一个goroutine去执行hello函数
        fmt.Println("main goroutine done!")
        wg.Wait()
    }
    

    需要注意sync.WaitGroup是一个结构体,传递的时候要传递指针。

sync.Once

  • 在编程的很多场景下我们需要确保某些操作在高并发的场景下只执行一次,例如只加载一次配置文件、只关闭一次通道等

  • Go语言中的sync包中提供了一个针对只执行一次场景的解决方案–sync.Once

    • sync.Once只有一个Do方法,其签名如下:

      func (o *Once) Do(f func()) {}
      

      注意:如果要执行的函数f需要传递参数就需要搭配闭包来使用。

  • 单例模式示例

    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函数可能会被重排为以下结果:

    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的时候不会被其他的goroutine操作,但是这样做又会引发性能问题。
  • 使用sync.Once改造的示例代码如下:

    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其实内部包含一个互斥锁和一个布尔值,互斥锁保证布尔值和数据的安全,而布尔值用来记录初始化是否完成。这样设计就能保证初始化操作的时候是并发安全的并且初始化操作也不会被执行多次。 相当于内部加锁做了一层单例模式的封装

sync.Map

  • Go语言中内置的 map 不是并发安全的。请看下面的示例:

    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错误。

  • 像这种场景下就需要为map加锁来保证并发的安全性了,Go语言的sync包中提供了一个开箱即用的并发安全版map–sync.Map

  • 同时sync.Map内置了诸如Store、Load、LoadOrStore、Delete、Range等操作方法。

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)
            value, _ := m.Load(key)
            fmt.Printf("k=:%v,v:=%v\n", key, value)
            wg.Done()
        }(i)
    }
    wg.Wait()
}

原子操作(atomic包)

代码中的加锁操作因为涉及内核态的上下文切换会比较耗时、代价比较高。

  • 针对基本数据类型我们还可以使用原子操作来保证并发安全,因为原子操作是Go语言提供的方法它在用户态就可以完成,因此性能比加锁操作更好
  • Go语言中原子操作由内置的标准库sync/atomic提供。

atomic包

方法解释
func LoadInt32(addr int32) (val int32)
func LoadInt64(addr int64) (val int64)
func LoadUint32(addruint32) (val uint32)
func LoadUint64(addruint64) (val uint64)
func LoadUintptr(addruintptr) (val uintptr)
func LoadPointer(addrunsafe.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)
比较并交换操作
  • 示例:我们填写一个示例来比较下互斥锁和原子操作的性能。

    var x int64
    var l sync.Mutex
    var wg sync.WaitGroup
    
    // 普通版加函数
    func add() {
        // x = x + 1
        x++ // 等价于上面的操作
        wg.Done()
    }
    
    // 互斥锁版加函数
    func mutexAdd() {
        l.Lock()
        x++
        l.Unlock()
        wg.Done()
    }
    
    // 原子操作版加函数
    func atomicAdd() {
        atomic.AddInt64(&x, 1)
        wg.Done()
    }
    
    func main() {
        start := time.Now()
        for i := 0; i < 10000; i++ {
            wg.Add(1)
            // go add()       // 普通版add函数 不是并发安全的
            // go mutexAdd()  // 加锁版add函数 是并发安全的,但是加锁性能开销大
            go atomicAdd() // 原子操作版add函数 是并发安全,性能优于加锁版
        }
        wg.Wait()
        end := time.Now()
        fmt.Println(x)
        fmt.Println(end.Sub(start))
    }
    
  • 结果如下:

    • go add()
      9454//线程不安全所以结果都是错的
      2.0963ms
      
    • go mutexAdd()
      10000
      2.0371ms
      
    • go atomicAdd
      10000
       2.0426ms
      
  • atomic包提供了底层的原子级内存操作,对于同步算法的实现很有用。这些函数必须谨慎地保证正确使用。除了某些特殊的底层应用,使用通道或者sync包的函数/类型实现同步更好。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值