goroutine(协程)是golang最重要的特性,是唯一在语言层面支持的主流语言,也是golang主打的优势——“高并发”的实现所在。
1.为什么要协程?
2.goroutine怎么用
1.为什么要协程
追求cpu的最大利用。同一台设备上同时运行着许多的进程,同时向cpu发出许多条指令,在这些指令中,一部分可以马上执行得到结果,但有一些系统IO相关的,它们需要一些输入,比如服务器监听一个端口,一直到客户端连接上才需要继续运行之后的指令;比如等待磁盘读取一个文件。
这些碎片时间,我们也希望能够利用起来。幸好,系统内核(kernal)会帮我们处理这堆麻烦。我们把一串中途需要等待IO的指令放进一个内核线程中,当需要等待的时候,内核将这个线程的状态保存好,等满足条件的时候,再将这个线程的指令发送给cpu执行,在这期间内,cpu就能处理其他线程的指令,实现cpu的最大利用。
内核维护线程状态的操作叫做保存现场-恢复现场,这是一个有开销的行为,如果我们的程序只有几个读写文件的操作,那么没关系。使用内核线程的除了我们的自己程序外,还有许许多多正在运行的程序和后台线程,内核线程的切换是我们单个程序控制不了也不应该控制的。但是,如果我们的程序频繁的切换内核线程,这绝对是一件不合理的事情。
于是,大部分语言有了用户级线程和线程池的概念。之前本来交给内核的事情——给cpu分配任务,现在先在程序内部自我合理规划一遍,减少内核线程切换的频率,转而在用户程序内切换。而用户级的线程,因为信息量更少,数据不需要从程序和内核间拷贝,所以消耗更少,更轻量。但这不意味这用户线程就是没有开销的,用户线程还会计入gc,所以一些高频创建销毁的场景下,还会用线程池来管理这些用户线程,尽可能复用。
golang在内置了线程一个线程池——MPG模型,其中G就代表goroutine,可以认为是一个更轻量级的用户线程。
2.goroutine怎么用
从上面来看,golang中的协程也并没有高级到哪去,为什么还能以此流行起来呢?
因为使用简单!
func goHappy() {
//好多代码
//...
}
我们想同时运行很多个上面的代码,也就是多线程编程。在golang中我们只需要
go goHappy()
就完成了多线程调用,至于他们是不是在多个线程中运行,谁先谁后,不用担心,golang的MPG模型会处理好(在大多数情况下适应)。
这是最简单的情况,在实际使用中,还要配合一些其他东西。
2.1 控制多个协程
当我们将一堆耗时的操作分散给多个协程同时处理,并且希望他们都处理完成再执行之后的操作。尤其go的主线程,并不会等待所有协程执行完才关闭,如果我们不等待协程,可能协程还没走完,程序进程就关闭了。
这时候我们需要用到sync.WaitGroup
import (
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1) //计数加1
go goHappy(&wg) //传指针
}
wg.Wait() //等待知道wg的计数变成0
}
func goHappy(wg *sync.WaitGroup) {
defer wg.Done() //计数减1
println("golang so easy")
}
2.2 关闭协程
有一些任务是循环监听网络IO输入,有些是定时循环触发的,我们用context.Context来控制关闭它。
func oneDay() {
ctx, cancel := context.WithCancel(context.Background())
go Tick(ctx)
time.AfterFunc(time.Hour*24, cancel)
}
func Tick(ctx context.Context) {
ticker := time.NewTimer(time.Hour)
for {
select {
case <-ctx.Done(): //当调用cancel时,触发这里
return
case <-ticker.C:
println("A hour gone")
}
}
}
这在我们优雅关闭服务器时就是这么使用的,通知各个模块处理好最后的工作,体面的结束各个协程。
2.3 协程之间通信
golang不鼓励我们多个协程共享数据,提出要用通信的方式让各个协程之间协作,并给出了类似linux的通道——channel。
channel当然是线程安全的,即多个协程同时读取一个channel,也只有一个协程能读取到数据。channel可以设置缓冲,实现同步和异步通信。
sync := make(chan interface{})
sync <- 1
这是一个同步通信,只有其他协程读取了这个channel中的数据,才会继续执行,否则一直等待。
如果要异步通信,即继续执行下去,就给一个缓冲区
async := make(chan interface{},1)
async <- 1
合理设置缓冲区大小,也能提高并发量。
比如写日志时,多个协程同时都有日志输出,但只能有一个协程去向文件中写,这样在日志密集的时候,日志内容先存在channel的缓冲区中,不影响业务性能,负责写日志的协程可以慢慢写,不会拖慢整个程序。
当然,如果日志量一直很大,超过了缓冲区,还是会慢下来,这时应该考虑日志是否合理了。
2.4 协程共享数据
虽然channel能够满足大部分场景的需求,但有时候我们仍然不得不共享数据,尤其是大量的数据。比如读写数据库时,我们可以只用一个协程读写数据库,其他协程需要数据时通过channel请求,之后再通过channel返回数据,但是,读取数据库需要等待时间,我们想要修改一个用户的数据时,也不需要让所有用户都短暂时间内不可操作。
假如不做限制,会造成数据不同步。我读了,还没来得及改,你先改了,我在不知情的情况下又改了等等情况。这是一大块内容,这里不细说了。
golang中有锁的相关库(sync),包括常用的互斥锁(Mutex)、读写锁(RWMutex),可以保证在一个协程操作一处数据的时候,其他线程如果也要操作,只能先挂起等待。
内存中的一些数据,我们为了保证它的原子性(同一时间只有一个线程能操作),golang提供了原子操作库sync/atomic。自旋锁就可以用这个库实现。
锁不是golang的内容,而是所有多线程同步的关键内容,这里的多线程不一定是一个程序中,也不一定是一台机器中,甚至可能是分布式的,以后再专门细将,这里主要是gorountine的常用方法。
ps: 欢迎大家指出文中错误和不足,提出意见,以免误导。