Golang 协程 与 Java 线程池的联系

news2024/11/28 17:58:41

Golang 协程 与 Java 线程池的联系

  • 引言
  • Java 线程池缺陷
  • Golang 协程实现思路
    • 0.x 版本
    • 1.0 版本
    • 1.1 版本
    • Goroutine 抢占式执行
      • 基于信号的抢占式调度
    • 队列轮转
    • 系统调用
    • 工作量窃取
    • GOMAXPROCS设置对性能的影响
  • 小结


引言

如何理解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. 添加新的工作线程
    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. 工作线程执行过程中也会加锁,但是此处加锁仅表明当前工作线程是否处于空闲状态,这一点在线程池销毁阶段链式打断空闲工作线程时会用到
    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实现了资源保护
    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中的协程本身是服务于不同场景的,所以也不能直接画上等号,只是说可以类比学习和思考。

本文只是笔者个人思考,可能有些牵强或者不正确的地方,欢迎各位在评论区指出或者私信与我讨论。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1096383.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

【大数据】Hive SQL语言(学习笔记)

一、DDL数据定义语言 1、建库 1&#xff09;数据库结构 默认的数据库叫做default&#xff0c;存储于HDFS的&#xff1a;/user/hive/warehouse 用户自己创建的数据库存储位置&#xff1a;/user/hive/warehouse/database_name.db 2&#xff09;创建数据库 create (database|…

c#设计模式-行为型模式 之 备忘录模式

&#x1f680;简介 备忘录模式&#xff08;Memento Pattern&#xff09;是一种行为型设计模式&#xff0c;它保存一个对象的某个状态&#xff0c;以便在适当的时候恢复对象。所谓备忘录模式就是在不破坏封装的前提下&#xff0c;捕获一个对象的内部状态&#xff0c;并在该对象…

Android 14 正式发布,已经在 AOSP 中上线

本心、输入输出、结果 文章目录 Android 14 正式发布,已经在 AOSP 中上线前言总结主要更新内容机型支持优化性能的数据体现字体放大、多媒体支持加强Android 14 增加了对 10 位高动态范围 (HDR) 图像的支持提供了新的图形和尺寸管理用户体验 与隐私安全弘扬爱国精神Android 14…

Vue3 + Nodejs 实战 ,文件上传项目--实现拖拽上传

目录 1.拖拽上传的剖析 input的file默认拖动 让其他的盒子成为拖拽对象 2.处理文件的上传 处理数据 上传文件的函数 兼顾点击事件 渲染已处理过的文件 测试效果 3.总结 博客主页&#xff1a;専心_前端,javascript,mysql-CSDN博客 系列专栏&#xff1a;vue3nodejs 实战-…

【JVM】JVM的内存区域划分

JVM的内存区域划分 堆Java虚拟机栈程序计数器方法区运行时常量池 堆 程序中创建的所有对象都保存在堆中 Java虚拟机栈 Java虚拟机栈的生命周期和线程相同,描述的是Java方法执行的内存模型,每个方法在执行的时候都会同时创建一个栈帧用于存储局部变量表,操作栈,动态链接,方法…

C语言 —— 结构体

生活中有许多复杂对象是无法用基本数据类型来描述的, 于是为了描述复杂对象, C语言就会使用到结构体. 1. 结构体的声明与定义 1.1 结构的基础知识 结构是一些值的集合&#xff0c;这些值称为成员变量。结构的每个成员可以是不同类型的变量。 1.2 结构的声明与定义 struct tag {…

vue3后台管理框架之基础配置

配置vite.config.js import { defineConfig } from viteimport vue from @vitejs/plugin-vueexport default defineConfig(({ command, mode }) => {//const env = loadEnv(mode, process.cwd(), ) //获取环境变量return {// 打包devbase: ./,// 开发环境server: {port: 50…

Spring framework Day11:策略模式中注入所有实现类

前言 什么是策略模式&#xff1f; 策略模式&#xff08;Strategy Pattern&#xff09;是一种面向对象设计模式&#xff0c;它定义了算法族&#xff08;一组相似的算法&#xff09;&#xff0c;并且将每个算法都封装起来&#xff0c;使得它们可以互相替换。策略模式让算法的变…

LeetCode【17】电话号码的字母组合

题目&#xff1a; 思路&#xff1a; 参考&#xff1a;https://blog.csdn.net/weixin_46429290/article/details/121888154 和上一个题《子集》的思路一样&#xff0c;先画出树结构&#xff0c;看树的深度&#xff08;遍历层级&#xff09;&#xff0c;树的宽度&#xff08;横向…

压力山大题

找不到工作的面试者总结 提示&#xff1a;写文章的时候&#xff0c;我还在找工作&#xff01;&#xff01;&#xff01; 文章目录 找不到工作的面试者总结前言一、JAVA面死题1. OOP是什么2. 重载与重写的区别3. java基本类型4. String、StringBuffer、StringBuilder的区别 二、…

2022最新版-李宏毅机器学习深度学习课程-P23 为什么用了验证集结果还是过拟合

用了验证集还有可能会过拟合 这个片段可以从理论上证明这一点 以上整个挑选模型的过程也可以想象为一种训练。 把三个模型导出的最小损失公式看成一个集合&#xff0c;现在要做的就是在这个集合中找到某个h&#xff08;此处可以视为训练&#xff09;&#xff0c;使得在验证集…

边写代码边学习之Pycaret

PyCaret 简介 PyCaret 是一个用于简化 Python 机器学习工作流程的开源库。它提供了一个高级、低代码的接口&#xff0c;用于自动化机器学习流程的各个方面&#xff0c;使数据科学家和分析师更容易构建和部署机器学习模型。PyCaret 的一些关键特点和用途包括&#xff1a; 1. 自…

第六章 查找

第六章 查找 基本概念静态查找表顺序表上的查找有序表上的查找索引顺序表上的查找 二叉排序树散列表常见散列法散列表的实现 小试牛刀 基本概念 查找表是由同一类型的数据元素构成的集合&#xff0c;它是一种以查找为“核心”&#xff0c;同时包括其他运算的非常灵活的数据结构…

Android Studio SDK manager加载packages不全

打开Android Studio里的SDK manager&#xff0c;发现除了已安装的&#xff0c;其他的都不显示。 解决方法&#xff1a; 设置代理&#xff1a; 方便复制> http://mirrors.neusoft.edu.cn/ 重启Android Studio

小主机折腾记17

8月9月10月基本在出差&#xff0c;流水账如下 1.由于出差&#xff0c;租了个公寓&#xff0c;所以买了个r2s&#xff0c;卖家已经安装部署好openwrt&#xff0c;风扇以及无线网卡 着重研究了风扇的脚本以及无线网卡的设置 风扇可以完美设置&#xff0c;但是无线网卡效果差强人意…

k8s-11 网络策略

添加网络策略 限制pod流量 控制的对象是具有appmyapp-v1标签的pod 此时访问svc是不通的 给测试pod添加指定标签后&#xff0c;可以访问 重启一下 限制namespace流量 给namespace添加指定标签 同时限制namespace和pod 给test命令空间中的pod添加指定标签后才能访问 限制集群…

k8s-13 存储之secret

Secret 对象类型用来保存敏感信息&#xff0c;例如密码、OAuth 令牌和 ssh key。 敏感信息放在 secret 中比放在 Pod 的定义或者容器镜像中来说更加安全和灵活 。 Pod 可以用两种方式使用 secret:作为 volume 中的文件被挂载到 pod 中的一个或者多个容器里 当 kubelet 为 pod 拉…

山西电力市场日前价格预测【2023-10-16】

日前价格预测 预测说明&#xff1a; 如上图所示&#xff0c;预测明日&#xff08;2023-10-16&#xff09;山西电力市场全天平均日前电价为356.38元/MWh。其中&#xff0c;最高日前电价为502.82元/MWh&#xff0c;预计出现在18: 30。最低日前电价为224.63元/MWh&#xff0c;预计…

PTQ与QAT

对称量化与非对称量化 量化分为对称量化与非对称量化。 非对称量化含有S和Z&#xff0c;对称量化Z为0&#xff0c;计算公式中只需S&#xff0c;为非饱和量化。 动态范围的确认 动态范围的确认Max&#xff08;默认的是对称量化&#xff0c;即不用Z&#xff09;&#xff0c;…

linux 内核中的pid和前缀树

前言&#xff1a; 写这个文章的初衷是因为今天手写了一个字典树&#xff0c;然后写字典树以后忽然想到了之前看的技术文章&#xff0c;linux kernel 之前的pid 申请方式已经从 bitmap 变成了 基数树&#xff0c;所以打算写文章再回顾一下这种数据结构算法 一、内核中pid的申请…