如何理解Golang的协程,我觉得可以用一句话概括: Golang 提供的协程是一种支持任务分时复用的高级线程池实现。
为什么这样说呢?首先我们要明白传统线程池实现的缺陷,如: Java中提供的ThreadPoolExecutor实现,它的核心思路就是利用任务队列做为缓冲,从而避免创建大量线程处理任务;但是如果worker线程执行Runnable任务时发生了IO相关的系统调用,则操作系统会将线程挂起,等待相关IO资源就绪,此时线程池中活跃的worker线程数虽然没变,但是实际在工作的线程确减少了,从而削弱了线程池整体的消费能力。
虽然我们可以增加线程池中线程数量来提高线程池的消费能力,但是随着线程数量增多,由于过多线程争抢CPU,消费能力会有上限,甚至不升反降。
而Golang就面临着这样的问题,问题解决的思路有两个方面:
注意:
ThreadPoolExecutor 的实现思路如下:
ThreadPoolExecutor 的实现在 Golang 所处场景下存在下面两个缺陷:
ThreadPoolExecutor实现中还存在一些共享状态变量也同样需要锁保护,但是由于这些资源访问周期都很短,所以均采用CAS自旋配合重试进行修改,性能上不会存在太大问题。
下面我们简单看看ThreadPoolExecutor哪些地方可能存在共享资源临界区访问问题:
private boolean addWorker(Runnable firstTask, boolean core) {
// 1. CAS加自旋来增加工作线程计数(该段代码省略)
...
// 2. 创建新的工作线程,添加到全局共享workers集合中
w = new Worker(firstTask);
final Thread t = w.thread;
if (t != null) {
// 3. 使用全局锁保护workers共享资源
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
...
workers.add(w);
...
} finally {
mainLock.unlock();
}
...
// 4. 启动工作线程
t.start();
...
}
return workerStarted;
}
final void runWorker(Worker w) {
...
try {
// 1. 不断尝试从任务队列中获取任务
while (task != null || (task = getTask()) != null) {
// 2. 加锁,表明当前工作线程处于忙碌状态,此处锁粒度仅限于当前工作线程
w.lock();
try {
...
// 3. 执行获取到的任务
task.run();
...
} finally {
...
// 4. 解锁,表明当前工作线程处于空闲状态
w.unlock();
}
}
...
} finally {
processWorkerExit(w, completedAbruptly);
}
}
private Runnable getTask() {
for (;;) {
...
try {
// 1. 阻塞等待从任务队列中获取任务
Runnable r = timed ?
workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
workQueue.take();
// 2. 返回获取到的任务
if (r != null)
return r;
timedOut = true;
} catch (InterruptedException retry) {
timedOut = false;
}
}
}
// 阻塞队列实现以ArrayBlockingQueue为例
public class ArrayBlockingQueue<E> extends AbstractQueue<E>
implements BlockingQueue<E>, java.io.Serializable {
public E take() throws InterruptedException {
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
// 当阻塞队列为空时,等待直到队列非空
while (count == 0)
notEmpty.await();
return dequeue();
} finally {
lock.unlock();
}
}
...
}
在并发量很大的场景下,ThreadPoolExecutor 的并发瓶颈主要还是出现在BlockingQueue的加锁和解锁损耗上,这一点也是Golang需要解决的问题。
golang 主要用于处理高并发场景,因此最直接的思路可能就是创建更多的线程来处理任务,但是这也意味着操作系统会更加频繁的切换线程,因此上下文切换将会成为性能瓶颈。
Go的解决思路就是在工作线程内部实现对任务的调度执行,任务的上下文切换在用户态实现,更加轻量,从而达到了更少的线程数,却能承载更高的并发量。而线程中调度的任务也被称为Goroutine。
在Go的0.x版本中,只提供了一个工作线程和一个全局调度器实现,此处工作线程也被称为G,整体模型如下图所示:
Go在1.0版本中引入了多线程调度器,允许运行多线程程序,如下图所示:
此时由于调度器所管理的任务队列是全局资源,因此需要对应的全局锁来保护,这会导致全局锁竞争问题严重,效率也很低下;
Go在1.1版本中引入了一个新角色处理器P,构成了目前的G-M-P模型,并在处理器P的基础上实现了工作窃取的调度器 ,如下图所示:
处理器P持有一个任务队列,还反向持有其所绑定的线程M,调度器会从处理器P的任务队列中选择队列头部的G放到M上执行。
引入P和其管理的本地队列最大的好处就是避免了全局共享资源竞争带来的资源损耗,大大提高的执行效率。
此时添加需要执行的G时,会首先确定当前G由哪个P来调度执行,然后将G添加到P的本地队列中,如果此时本地队列已经满了,则会添加到由调度器持有的全局队列中去,由于全局队列时共享资源,因此需要获取全局锁后才能访问。
当P需要调度G执行时,需要经历下面几步:
在引入了处理器P这个角色后,Golang基本解决了全局资源访问冲突导致的性能瓶颈问题,下一步就是着手解决Goroutine的抢占式执行问题了。
Go 1.1 版本中的调度器仍然不支持抢占式调度,程序只能依靠Goroutine主动让出CPU资源才能触发调度。Go 语言的调度器在1.2版本中引入了基于协作的抢占式调度解决下面的问题:
基于协作实现抢占式调度思路就是在每个函数的进入和出口处由编译器插入相关指令,来检查当前Goroutine是否需要让出线程使用权,过程简单来说如下所示:
1.2 版本的协作实现抢占式调度只在函数调用入口进行了抢占检查,因此无法解决一些边缘情况的抢占问题,如: for循环或者垃圾回收长时间占用线程,这些问题一直到1.14才被基于信号的抢占式调度解决。
基于协作的抢占式调度虽然实现巧妙,但是并不完备 ,主要原因还是针对一些边缘场景,如: for循环场景下,无法触发抢占逻辑。
为了解决这个问题,Golang引入了信号机制进行解决,大体思路如下:
这里只是一个大体的实现思路,具体实现细节大家可以阅读源码学习。
上面我们了解了Golang调度器的基本实现逻辑,可以知道核心之一在于处理器P,每个P维护着一个包含G的队列,不考虑G进入系统调用或IO操作的情况下,P周期性的将G调度到M中执行,执行一小段时间,将上下文保存下来,然后将G放到队列尾部,然后从队列中重新取出一个G进行调度。
除了每个P维护的G队列以外,还有一个全局的队列,每个P会周期性地查看全局队列中是否有G待运行并将其调度到M中执行,全局队列中G的来源,主要有从系统调用中恢复的G。之所以P会周期性地查看全局队列,也是为了防止全局队列中的G被饿死。
P的个数默认等于CPU核数,每个M必须持有一个P才可以执行G,一般情况下M的个数会略大于P的个数,这多出来的M将会在G产生系统调用时发挥作用。类似线程池,Go也提供一个M的池子,需要时从池子中获取,用完放回池子,不够用时就再创建一个。
当M运行的某个G产生系统调用时,如下图所示:
如图所示,当G0即将进入系统调用时,M0将释放P,进而某个空闲的M1获取P,继续执行P队列中剩下的G。而M0由于陷入系统调用而进被阻塞,M1接替M0的工作,只要P不空闲,就可以保证充分利用CPU。
M1的来源有可能是M的缓存池,也可能是新建的。当G0系统调用结束后,根据M0是否能获取到P,将会将G0做不同的处理:
多个P中维护的G队列有可能是不均衡的,比如下图:
竖线左侧中右边的P已经将G全部执行完,然后去查询全局队列,全局队列中也没有G,而另一个M中除了正在运行的G外,队列中还有3个G待运行。此时,空闲的P会将其他P中的G偷取一部分过来,一般每次偷取一半。偷取完如右图所示。
程序中可以使用runtime.GOMAXPROCS()设置P的个数。
一般来讲,程序运行时就将GOMAXPROCS大小设置为CPU核数,可让Go程序充分利用CPU。 在某些IO密集型的应用里,这个值可能并不意味着性能最好。 理论上当某个Goroutine进入系统调用时,会有一个新的M被启用或创建,继续占满CPU。 但由于Go调度器检测到M被阻塞是有一定延迟的,也即旧的M被阻塞和新的M得到运行之间是有一定间隔的,所以在IO密集型应用中不妨把GOMAXPROCS设置的大一些,或许会有好的效果。
golang 协程的高性能主要得益于两个方面:
本文开篇之所以说go提供的协程本质是一种高级线程池实现,主要是因为Goroutine其实可以类比Java中的Runnable实现,这里的M就是Java中的Thread,而调度器模块本身就是ThreadPoolExecutor的实现。
而golang中的线程池实现支持Runnabel任务抢占式调度,同时将共享的全局任务队列划分为了线程私有的本地队列,避免了资源竞争发生。
当然,由于Java中的线程池和Golang中的协程本身是服务于不同场景的,所以也不能直接画上等号,只是说可以类比学习和思考。
本文只是笔者个人思考,可能有些牵强或者不正确的地方,欢迎各位在评论区指出或者私信与我讨论。