Golang 协程 与 Java 线程池的联系

2023-10-19 14:11:50 浏览数 (1)

Golang 协程 与 Java 线程池的联系

引言

如何理解Golang的协程,我觉得可以用一句话概括: Golang 提供的协程是一种支持任务分时复用的高级线程池实现。

为什么这样说呢?首先我们要明白传统线程池实现的缺陷,如: Java中提供的ThreadPoolExecutor实现,它的核心思路就是利用任务队列做为缓冲,从而避免创建大量线程处理任务;但是如果worker线程执行Runnable任务时发生了IO相关的系统调用,则操作系统会将线程挂起,等待相关IO资源就绪,此时线程池中活跃的worker线程数虽然没变,但是实际在工作的线程确减少了,从而削弱了线程池整体的消费能力。

虽然我们可以增加线程池中线程数量来提高线程池的消费能力,但是随着线程数量增多,由于过多线程争抢CPU,消费能力会有上限,甚至不升反降。

而Golang就面临着这样的问题,问题解决的思路有两个方面:

  1. Runnable任务执行可抢占
  2. 细化锁粒度

注意:

  • 我们通常会使用线程池来异步的顺序执行任务,如果站在这个角度来看,传统线程池属于先到先服务的实现,其实现思路在该场景下没有问题。
  • 但是Golang面临的场景是希望任务可以并发的分时执行,也就是说不希望产生任务饥饿的问题,因此我们就不能按照传统线程池思路来实现了,需要采用分时复用的方式实现。

Java 线程池缺陷

ThreadPoolExecutor 的实现思路如下:

  1. 线程池初启动时,按需创建核心线程来执行任务。
  2. 当核心线程创建满时,将任务放入任务队列作为缓冲,再由核心线程慢慢从任务队列获取任务进行处理。
  3. 当任务队列也放满时,创建非核心线程应急。
  4. 当任务量很大时,并且队列和最大线程数都打满时,执行对应的拒绝策略。
  5. 没有任务处于空闲态时,非核心线程超时后自行销毁,核心线程可以通过配置选择空闲时自行销毁。

ThreadPoolExecutor 的实现在 Golang 所处场景下存在下面两个缺陷:

  1. 不支持Runnable任务抢占执行,这意味着如果某个Runnable任务耗时很长,便会一直占据着对应的工作线程不放,直到自身任务执行完毕,因此可能会导致其他的大量任务无法得到及时执行,产生任务饥饿问题。
  2. ThreadPoolExecutor 共享资源有任务队列和工作线程集合,因此这两者都需要相应的全局锁保护,在线程池中线程数量很多的场景下,临界区资源访问便会成为瓶颈,因此需要细化锁粒度。

ThreadPoolExecutor实现中还存在一些共享状态变量也同样需要锁保护,但是由于这些资源访问周期都很短,所以均采用CAS自旋配合重试进行修改,性能上不会存在太大问题。

下面我们简单看看ThreadPoolExecutor哪些地方可能存在共享资源临界区访问问题:

  1. 添加新的工作线程
代码语言:javascript复制
    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;
    }
  1. 工作线程执行过程中也会加锁,但是此处加锁仅表明当前工作线程是否处于空闲状态,这一点在线程池销毁阶段链式打断空闲工作线程时会用到
代码语言:javascript复制
    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);
        }
    }
  1. 任务队列本身也是全局共享资源,因此阻塞任务队列内部也借助ReentrantLock实现了资源保护
代码语言:javascript复制
    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 协程实现思路

golang 主要用于处理高并发场景,因此最直接的思路可能就是创建更多的线程来处理任务,但是这也意味着操作系统会更加频繁的切换线程,因此上下文切换将会成为性能瓶颈。

Go的解决思路就是在工作线程内部实现对任务的调度执行,任务的上下文切换在用户态实现,更加轻量,从而达到了更少的线程数,却能承载更高的并发量。而线程中调度的任务也被称为Goroutine。


0.x 版本

在Go的0.x版本中,只提供了一个工作线程和一个全局调度器实现,此处工作线程也被称为G,整体模型如下图所示:

此时go的调度器实现只能算是个玩具,能跑,但是效率极低。


1.0 版本

Go在1.0版本中引入了多线程调度器,允许运行多线程程序,如下图所示:

此时由于调度器所管理的任务队列是全局资源,因此需要对应的全局锁来保护,这会导致全局锁竞争问题严重,效率也很低下;


1.1 版本

Go在1.1版本中引入了一个新角色处理器P,构成了目前的G-M-P模型,并在处理器P的基础上实现了工作窃取的调度器 ,如下图所示:

处理器P持有一个任务队列,还反向持有其所绑定的线程M,调度器会从处理器P的任务队列中选择队列头部的G放到M上执行。

引入P和其管理的本地队列最大的好处就是避免了全局共享资源竞争带来的资源损耗,大大提高的执行效率。

此时添加需要执行的G时,会首先确定当前G由哪个P来调度执行,然后将G添加到P的本地队列中,如果此时本地队列已经满了,则会添加到由调度器持有的全局队列中去,由于全局队列时共享资源,因此需要获取全局锁后才能访问。

当P需要调度G执行时,需要经历下面几步:

  1. 为了保证公平,当全局运行队列中有待执行的G时,通过随机值判断本次是否优先从全局队列中获取G执行。
  2. 从处理器P本地的队列中查找待执行的G。
  3. 从全局队列中查找待执行的G。
  4. 尝试从其他随机的处理器中窃取待运行的G。
  5. 等待直到有任务需要执行。

在引入了处理器P这个角色后,Golang基本解决了全局资源访问冲突导致的性能瓶颈问题,下一步就是着手解决Goroutine的抢占式执行问题了。


Goroutine 抢占式执行

Go 1.1 版本中的调度器仍然不支持抢占式调度,程序只能依靠Goroutine主动让出CPU资源才能触发调度。Go 语言的调度器在1.2版本中引入了基于协作的抢占式调度解决下面的问题:

  1. 某些Goroutine长时间占用线程,造成其他Goroutine的饥饿。
  2. 垃圾回收需要STW,最长可能需要几分钟的时间,导致整个程序都无法工作。

基于协作实现抢占式调度思路就是在每个函数的进入和出口处由编译器插入相关指令,来检查当前Goroutine是否需要让出线程使用权,过程简单来说如下所示:

  1. 编译器会在调用函数前插入相关的检查函数指令
  2. Go语言运行时会在垃圾回收暂停程序,系统监控发现Goroutine运行超过了10ms时发出抢占请求,会将Goroutine内部对应的抢占标识设置为true
  3. 当Goroutine任务执行过程中发生函数调用时,会执行相关检查逻辑,判断当前Goroutine内部的抢占标志是否为true
  4. 如果抢占标志为true,则会调用调度器的schedule函数,让出当前线程使用权,换为下一个可用的Goroutine

1.2 版本的协作实现抢占式调度只在函数调用入口进行了抢占检查,因此无法解决一些边缘情况的抢占问题,如: for循环或者垃圾回收长时间占用线程,这些问题一直到1.14才被基于信号的抢占式调度解决。


基于信号的抢占式调度

基于协作的抢占式调度虽然实现巧妙,但是并不完备 ,主要原因还是针对一些边缘场景,如: for循环场景下,无法触发抢占逻辑。

为了解决这个问题,Golang引入了信号机制进行解决,大体思路如下:

  1. 程序启动时,会为SIGURG信号注册好对应的处理函数,该处理函数负责实现当前Goroutine的抢占逻辑
  2. 在触发垃圾回收的栈扫描时会调用suspend函数挂起Goroutine,然后将Goroutine状态标记为可抢占,最后向Goroutine所在线程M发送信号SIGURG
  3. 操作系统会中断正在运行的线程并执行预先注册的信号处理函数
  4. 对应的信号处理函数会执行Goroutine抢占逻辑,即调用调度器的schedule函数

这里只是一个大体的实现思路,具体实现细节大家可以阅读源码学习。


队列轮转

上面我们了解了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,则获取一个P,继续执行G0。
  • 如果没有空闲的P,则将G0放入全局队列,等待被其他的P调度。然后M0将进入缓存池睡眠。

工作量窃取

多个P中维护的G队列有可能是不均衡的,比如下图:

竖线左侧中右边的P已经将G全部执行完,然后去查询全局队列,全局队列中也没有G,而另一个M中除了正在运行的G外,队列中还有3个G待运行。此时,空闲的P会将其他P中的G偷取一部分过来,一般每次偷取一半。偷取完如右图所示。


GOMAXPROCS设置对性能的影响

程序中可以使用runtime.GOMAXPROCS()设置P的个数。

一般来讲,程序运行时就将GOMAXPROCS大小设置为CPU核数,可让Go程序充分利用CPU。 在某些IO密集型的应用里,这个值可能并不意味着性能最好。 理论上当某个Goroutine进入系统调用时,会有一个新的M被启用或创建,继续占满CPU。 但由于Go调度器检测到M被阻塞是有一定延迟的,也即旧的M被阻塞和新的M得到运行之间是有一定间隔的,所以在IO密集型应用中不妨把GOMAXPROCS设置的大一些,或许会有好的效果。


小结

golang 协程的高性能主要得益于两个方面:

  1. 引入G-M-P模型,借助本地队列,配合全局队列和工作窃取机制,减少资源竞争,同时又能够保持高性能和均衡性
  2. 引入抢占式调度,在用户态实现Goroutine任务的分时复用执行,减少了任务饥饿问题产生

本文开篇之所以说go提供的协程本质是一种高级线程池实现,主要是因为Goroutine其实可以类比Java中的Runnable实现,这里的M就是Java中的Thread,而调度器模块本身就是ThreadPoolExecutor的实现。

而golang中的线程池实现支持Runnabel任务抢占式调度,同时将共享的全局任务队列划分为了线程私有的本地队列,避免了资源竞争发生。

当然,由于Java中的线程池和Golang中的协程本身是服务于不同场景的,所以也不能直接画上等号,只是说可以类比学习和思考。

0 人点赞