Goroutine 协程
协程是一种用户态的轻量级线程
简单示例
go func () {
...
}()
需要注意,协程的本质还是线程,因此,它依赖于主程序,并且在运行时是无序的。(参考线程)
这就提醒我们,在调用协程时需要注意它的并发安全问题。
并发安全
- 协程被动退出问题
当多个协程同时运行时,可能有的协程运行完了,有的协程还在运行。如果这时候主线程退出可能导致未完成的协程被动退出。
这里我们使用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。这样就保证了主线程会在所有的协程结束后退出。
- 并发数据不一致问题
当多个协程操作同一个数据时,由于CPU多核并行或中断则可能导致该数据在多个协程中不一致。
这里我们使用sync.Mutex加锁来解决该类问题。
var mu sync.Mutex
func() {
mu.Lock()
defer mu.Unlock()
... // operator the data
}
考虑到性能以及实际场景,有时会使用读写锁sync.RWMutex来代替sync.Mutex。
- 协程通信
多个协程之间可以使用channel通信。
ch := make(chan string)
go func() {
ch <- "send data"
}()
go func() {
msg := <-ch
...
}()
使用make(chan string)可以创建一个channel。
ch <- "send data"表示往channel中传入数据,msg := <-ch则会将channel中的数据读取到msg中。
实际上,直接这样定义每次往ch中传入数据后都会阻塞住,直到被读取前,他都不会执行ch <- "send data"后面的代码。
实际上,我们也可以使用make(chan string 10)来指定其channel的大小,如果定义其大小为10的话,则在channel中的数据量小于10时,往里面添加数据就不会被阻塞。
- 监听协程
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。
- 消息收发
server := make(chan string, 10)
client := make(chan string, 10)
go func() {
for {
select {
case msg, ok := <-client: // or <-server
...
}
}
}()
这里使用两个channel,其中一个为服务端,一个为客户端。可以开启两个协程,服务端从客户端的通道中读取数据,并可以向服务端通道中写入数据。同理,客户端也能够在这两个通道中进行读写操作。这样就是一个简单的全双工消息收发机制。
- 超时机制
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
}
利用上述代码的模型可以做超时处理,实际上该代码比较粗糙,在实际开发时倒是很少遇到这种情况,更多的时候会用包装好的框架进行超时处理机制。这里只是为了演示可以利用二者结合实现超时机制。
- 定时器
timer := time.NewTimer(2 * time.Second)
<-timer.C
上述代码可以进行延时处理,等待两秒后阻塞才会结束。
t := time.Tick(time.Second)
select {
case <-t:
}
这时候就可以利用二者结合实现定时器任务。原理也很简单,就是利用通道的被监听特性来进行定时。
- 原子操作
atomic.{xxx}
原子操作的原理和加锁是一样的,因为它能保证我们在进行数据的操作时,该数据的某一权限(写数据)是被锁定的,这样能够保证数据在加锁期间不会被别的数据修改,保证并发是安全的。
总结
Go语言提供了十分方便的并发操作,我们只需在调用函数时再其前面添加go关键字即可。但需要注意的是并发安全,尤其是在大量协程进行数据交互,数据修改时,如果不能保证数据的一致性将会有很大问题(如淘宝等电商网站的秒杀服务)
- 管理多个协程的生命周期(保证协程操作执行完),可以使用
sync.WaitGroup等待协程完成,这样能够保证在主线程退出之前,所有的协程均结束。 - 多个协程操作同一份数据时,为避免数据不一致,可以使用
sync.Mutex来对数据进行加锁。但是如果一个协程长时间持有锁则会造成别的协程阻塞,这无疑会影响程序的效率。这时,可以根据实际情况来添加读写锁。如果该数据需要一直被读而不进行写操作,实际上是没有必要加锁的,只要不对原始数据进行修改,完全可以不用加锁。 - 多个协程之间进行通讯可以使用通道,我认为Go语言中的通道有点像C++中的管道或消息队列。其核心操作就是向其中写入数据和读取数据。
- 配合监听器,通道可以实现消息收发,超时机制,定时器等功能。
- Go提供了原子操作,其核心也是对数据进行加锁,只是实现是在汇编层面,在操作数据时会判断数据的版本是否与都进来的时候一致。