字节跳动青训营 | Go 语言并发浅谈

185 阅读5分钟

Goroutine 协程

协程是一种用户态的轻量级线程

简单示例

go func () {
    ...
}()

需要注意,协程的本质还是线程,因此,它依赖于主程序,并且在运行时是无序的。(参考线程)

这就提醒我们,在调用协程时需要注意它的并发安全问题。

并发安全

  1. 协程被动退出问题

当多个协程同时运行时,可能有的协程运行完了,有的协程还在运行。如果这时候主线程退出可能导致未完成的协程被动退出。 这里我们使用sync.WaitGroup解决这类问题。

var wg sync.WaitGroup
for i := 0; i < 10; ++i {
    wg.Add(1)
    go func(i int) {
        defer wg.Done()
        ...
    }(i)
    
    wg.Wait()
}

wg.Add()的作用就是记录添加,wg.Done()的作用是记录减1。如果wg中的记录>0,wg.Wait()会一直阻塞直到记录为0。这样就保证了主线程会在所有的协程结束后退出。

  1. 并发数据不一致问题

当多个协程操作同一个数据时,由于CPU多核并行或中断则可能导致该数据在多个协程中不一致。 这里我们使用sync.Mutex加锁来解决该类问题。

var mu sync.Mutex
func() {
    mu.Lock()
    defer mu.Unlock()
    ... // operator the data
}

考虑到性能以及实际场景,有时会使用读写锁sync.RWMutex来代替sync.Mutex

  1. 协程通信

多个协程之间可以使用channel通信。

ch := make(chan string)
go func() {
    ch <- "send data"
}()
go func() {
    msg := <-ch
    ...
}()

使用make(chan string)可以创建一个channelch <- "send data"表示往channel中传入数据,msg := <-ch则会将channel中的数据读取到msg中。 实际上,直接这样定义每次往ch中传入数据后都会阻塞住,直到被读取前,他都不会执行ch <- "send data"后面的代码。 实际上,我们也可以使用make(chan string 10)来指定其channel的大小,如果定义其大小为10的话,则在channel中的数据量小于10时,往里面添加数据就不会被阻塞。

  1. 监听协程

select用于监听多个channel的读写操作。

ch1 := make(chan string)
ch2 := make(chan string)
go func() {
    for {
        select {
        case msg1, ok := <-ch1:
            ...
        case msg2, ok := <-ch2:
            ...
        }
    }
}()
// close(ch1)
// close(ch2)

监听操作时,如果被监听的channel被关闭,则返回的第二的操作为false。如果被监听的channel中没有数据,则会发生阻塞。注意,select选择时是随机的,当有多个channel发生数据读写操作时,其会随机选择一个case

  1. 消息收发
server := make(chan string, 10)
client := make(chan string, 10)
go func() {
    for {
        select {
            case msg, ok := <-client: // or <-server
                ...
        }
    }
}()

这里使用两个channel,其中一个为服务端,一个为客户端。可以开启两个协程,服务端从客户端的通道中读取数据,并可以向服务端通道中写入数据。同理,客户端也能够在这两个通道中进行读写操作。这样就是一个简单的全双工消息收发机制。

  1. 超时机制
done := make(chan bool)
go func() {
    time.Sleep(2 * time.Second)
    done <- true
}
select {
case <-done: // timeout
case <-time.After(3 * time.Second): // no timeout
}

利用上述代码的模型可以做超时处理,实际上该代码比较粗糙,在实际开发时倒是很少遇到这种情况,更多的时候会用包装好的框架进行超时处理机制。这里只是为了演示可以利用二者结合实现超时机制。

  1. 定时器
timer := time.NewTimer(2 * time.Second)
<-timer.C

上述代码可以进行延时处理,等待两秒后阻塞才会结束。

t := time.Tick(time.Second)
select {
case <-t:
}

这时候就可以利用二者结合实现定时器任务。原理也很简单,就是利用通道的被监听特性来进行定时。

  1. 原子操作
atomic.{xxx}

原子操作的原理和加锁是一样的,因为它能保证我们在进行数据的操作时,该数据的某一权限(写数据)是被锁定的,这样能够保证数据在加锁期间不会被别的数据修改,保证并发是安全的。

总结

Go语言提供了十分方便的并发操作,我们只需在调用函数时再其前面添加go关键字即可。但需要注意的是并发安全,尤其是在大量协程进行数据交互,数据修改时,如果不能保证数据的一致性将会有很大问题(如淘宝等电商网站的秒杀服务)

  1. 管理多个协程的生命周期(保证协程操作执行完),可以使用sync.WaitGroup等待协程完成,这样能够保证在主线程退出之前,所有的协程均结束。
  2. 多个协程操作同一份数据时,为避免数据不一致,可以使用sync.Mutex来对数据进行加锁。但是如果一个协程长时间持有锁则会造成别的协程阻塞,这无疑会影响程序的效率。这时,可以根据实际情况来添加读写锁。如果该数据需要一直被读而不进行写操作,实际上是没有必要加锁的,只要不对原始数据进行修改,完全可以不用加锁。
  3. 多个协程之间进行通讯可以使用通道,我认为Go语言中的通道有点像C++中的管道或消息队列。其核心操作就是向其中写入数据和读取数据。
  4. 配合监听器,通道可以实现消息收发,超时机制,定时器等功能。
  5. Go提供了原子操作,其核心也是对数据进行加锁,只是实现是在汇编层面,在操作数据时会判断数据的版本是否与都进来的时候一致。

参考链接

Go语言并发编程