0
  • 聊天消息
  • 系统消息
  • 评论与回复
登录后你可以
  • 下载海量资料
  • 学习在线课程
  • 观看技术视频
  • 写文章/发帖/加入社区
会员中心
创作中心

完善资料让更多小伙伴认识你,还能领取20积分哦,立即完善>

3天内不再提示

golang并发机制和其他语言在实现上有什么不同

马哥Linux运维 来源:Go开发大全 作者:Go开发大全 2021-07-29 16:35 次阅读
加入交流群
微信小助手二维码

扫码添加小助手

加入工程师交流群

golang 并发机制和其他语言在实现上有什么不同?为什么能做到高效快速?本文做了详细介绍。

由于对普通语法的介绍网上资源极多,Go 官方的上手指南 A Tour of Go: https://tour.golang.org/ (请自备梯子)就是极好的例子,我不再打算就语法细节进行详述。这次,让我们直切肯綮,从 Go 最大的卖点入手——并发 (Concurrency)。

func Hello() {

fmt.Println(“I‘m B”) // Output A

}

go Hello()

fmt.Println(“I’m A”) // Output B

如果在双核(及以上)的机器编译运行上述 Go 代码,我们能观测到 A/B 输出的顺序随着运行次数的不同而不同,也就是说,仅依靠 5 行代码,我们就创建了两线并发的程序。

相较于 C/C++/Java/Python 等语言为了创建一个并发执行环境所需要的调用 POSIX-API/定义继承类等繁琐步骤,Golang 简单一句 go func()的确给人眼前一亮的感觉。当然了,仅凭语法上的简洁显然不足以成为一个编程语言拿来吹嘘的资本,下文我们将对在这几行语句下 Golang 的并发机制和实现进行详细探索。

一等公民-Goroutine

Goroutine 是 Go 的并发机制中绝对的主角。它代表了指令流及其执行环境,也是被调度的基本单位。宏观来看,goroutine 类似操作系统中线程的概念(注意这里的类比并不严格,下文将会对两者做出详细比较):不同线程间共享同一个内存空间,但不共享栈且各自并发执行;同样地,goroutine 也同内存不同栈,并发运行。

如上图所示,上文代码片段第四行的 go Hello()会创建一个新的 goroutine(绿色线条),并开始执行 Hello()函数。需要注意的是,由于主 goroutine(蓝色线条)和新创建的 goroutine 拥有并发性,且主 goroutine 在执行 go Hello()时并不会等待被调用函数执行结束,故“I‘m A”(主 goroutine 输出)和“I’m B”(新 goroutine 输出)可能以任何顺序交错展现。

为何不用线程 (pThread)?

直到现在,我们并不能从 goroutine 中看到任何有别于 thread、从而促成 Golang 编写者抛弃传统的线程模型自己造轮子的地方。那么操作系统层面的线程 (pThread) 有什么问题呢?

生命周期开销太高

线程的创建、销毁和切换都需要一系列系统调用,而每一个系统调用意味着触发软中断、进入内核态、将寄存器的值全部存入内存、维护相关数据结构、恢复寄存器、返回用户态等一系列组合拳。这一轮操作不仅十分耗时、还可能让内存缓存的加速效果大幅度下滑。所以,避免频繁创建、销毁线程作为高性能并发的必要条件这一点已成为程序员的共识。

以线程为并发模型的 C/C++/Java 采用线程池的方法来降低线程昂贵的生命周期开销。既然线程创建/死亡代价高昂,我们何不让创建的线程永不死亡呢?具体来说,对于每个已经创建但已经完成工作的线程,我们令其休眠,并放进一个资源池中,在下次需要新的线程的时候,我们直接将线程池中休眠的线程拿出来唤醒使用而非新建线程。

这样一来,绝大部分的线程创建/销毁需求都成功地被线程池吸收了。进一步,通过规定线程池的最大容量,我们可以将花费在线程创建和销毁上的开销控制在固定值,例如,常见的 Java Web 应用会设立一个 30~50 大小的线程池来处理 HTTP 请求,并取得非常好的并发效果。

不必要的线程切换

即使线程池很好地砍掉了线程生命周期开销,操作系统层面的线程依然存在不足:线程的语义在于并行,当线程数超出 CPU 核心数时,操作系统会定时给每个 CPU 核心切换不同的线程,让他们“看上去”是同时在进行的。当然,这样的切换同样需要付出若干中断、系统调用,以及当前线程的工作集从缓存中被新线程完全抹去的代价。

乍一听上去这样的代价是必不可少的,实则不然。由于在绝大部分时候我们的应用都是 I/O 和计算混合的,即,一段时间与硬盘/网络交互(I/O)、一段时间进行相对密集的内存访问和计算,而等待 I/O 完成期间该线程处于休眠状态。

CPU 已经会切换到其他线程,即使操作系统不强行打断并切换处于计算密集期的线程,应用在宏观上依然显示出一定并发性。而通过去掉计算密集期的线程切换,整体 CPU 效率得到了有效提升——NodeJS 就是在这样的哲学下诞生的:单一线程、全异步的 I/O、事件驱动、非抢占式调度(当某一个函数单纯进行计算和内存访问时不会被打断)。

在进行 I/O 密集型工作(如网站后台)时通过将单一 CPU 利用率逼到 100%的方式在效率上力挫几乎其他所有能利用多线程多核脚本语言。这简直是本来就特立独行的 Javascript 对整个编程语言界的同僚竖起的又一根中指。当然了,仅仅能利用单核处理能力的 NodeJS 在处理对计算要求更高的工作上显然会力不从心,但其给我们的启示值得注意。

较高的切换开销

在锁竞争、协程同步等情况下,频繁进入内核态的线程模型会放大自身在切换开销上的劣势。而用户态的调度器(如 goroutine 调度器)则可以在用户态处理这一切,省时省力。另外,由于编程语言能够更好地对自己语言中的同步原语进行分析,编程语言自己的调度器能够更好地根据语义对调度进行优化。

Goroutine 调度模型

Go 使用用户态的调度器对 goroutine 的执行进行控制,从而避免了大部分内核开销。具体而言,Golang 的调度模型由三部分组成:执行环境 (Executor)、调度器 (Scheduler) 和 goroutine。

执行环境,顾名思义,用来执行代码。尽管其在抽象概念上应该对应一个 CPU 核心,但由于在用户态不能接触硬件资源,故 Go 将其具体实现为线程。当线程数等于 CPU 核心数时,既最大化了 CPU 核心利用率,又最小化了线程切换的开销,是最理想的情况(当然,实际情况下操作系统还会运行、切换来自其他进程的线程,但这已经超出一个普通程序的控制范畴)。

故默认情况下,用于指定执行环境个数的运行时变量 GOMAXPROCS等于 CPU 核心数目。当然,开发者可以根据自己的需求更改该值,当 GOMAXPROCS=1时,Go 的执行模型几乎等同于 NodeJS。

调度器则是调度模型的核心,它决定了每个执行环境(核)在什么时候执行什么样的 goroutine。Go 采用任务队列的方式对 goroutine 进行调度:

4a01a970-ee00-11eb-a97a-12bb97331649.png

如上图所示,所有 goroutine 作为任务排在任务队列中,而 scheduler 所做的则是在 executor 空闲时从队首拿出下一个 goroutine 给其执行。每个任务 (goroutine) 会被 executor 执行到完成或阻塞(如发起 I/O 请求、系统调用、请求一个正在被其他人使用的锁或自行 yield 计算资源等)。

在第二种情况下,该 goroutine 既不在 executor 也不在队列中,而是处于阻塞态被 Scheduler 监视直到阻塞结束重新入队。值得注意的是,这里与上文提到的“去掉计算密集期的线程切换”的联系:由于调度器对任务采用非抢占式调度,即在正常计算和内存访问的情况下 executor 不会放弃当前 goroutine,故多余的 goroutine 切换代价得以被去除。

这样的任务队列模型仍然存在不小的问题:由于任务队列只有一个,为了保证出入队的原子性,任务分配/加入时需要对整个队列加互斥锁,当 goroutine 执行时间短时,频繁给大量 executor 分配新任务会让单一队列成为并行的性能瓶颈。为了解决该问题,Go 采用了多任务队列的方式进行任务调度:

4a2fe042-ee00-11eb-a97a-12bb97331649.png

如上图所示,在多任务调度模型中,每个 executor 均有一个自己对应的任务队列。在正常情况下,每个 executor 从自己的队列中拿 goroutine,并将生成的新 goroutine 放进自己队列队尾。分布式结构可能带来的问题是显而易见的:

如果任务在队列的分布不均匀会导致计算资源的浪费,如上图中的 executor3,如果缺乏其他措施,该核会因为对应队列没有任务而空闲。对于该问题,Go 的解决方法是引入“偷任务”机制:当 Scheduler 发现某队列无任务可用时,会从其他队列里“偷”一部分任务过来。由于偷任务的代价较高(需要锁两个队列),Scheduler 会争取一次性偷足够多的任务以降低未来偷任务的频率。

而对于处于阻塞状态的 goroutine,Scheduler 需要监视其脱离阻塞状态并重新入队。Goroutine 被阻塞的原因大体分两种:

阻塞 I/O 或系统调用。由于底层实现限制,该类阻塞需要一个线程显式执行相应的 syscall 并等待调用返回。在这种情况下,Scheduler 会新建一个线程执行该 syscall,并在返回后通知 Scheduler。

同样地,为了节省开销,该线程被维护在线程池中。值得注意的是,该类线程由于整个生命周期都几乎在等待阻塞(阻塞结束后立即通知 Scheduler 而后结束),而阻塞的线程是不参与操作系统线程切换的,故其并不会带来太大的线程切换开销。

当然,如果借鉴 NodeJS、尽可能用异步版本 api 替换同步版,则可以省去线程池操作,进一步优化性能(Go 是否采用该优化尚存疑)。

内部同步机制,Goroutine 因为调用了 Go 内部同步机制(channel、互斥锁、wait group、conditional variable 等)而阻塞。对于此类阻塞,由于同步机制的语义是 Go 定义从而对 Scheduler 透明的,Scheduler 可以分析出阻塞依赖,从而将监视该阻塞状态的任务交给其依赖的 goroutine。

例如,goroutine A 请求了一个正被 goroutine B 获取了的互斥锁,从而陷入阻塞,那么 Scheduler 可以在 goroutine B 释放该锁时由对应的 executor 将 goroutine A 唤醒并加入队列。在这整个过程中不需要引入新的线程。

以上便是 Golang Scheduler 的大致工作逻辑,在各个组件的相互配合下,一个高性能、支持调度成千上万 goroutine 的并发环境就此搭建起来。

总结和启发

从 Golang 的并发机制中我们可以得到如下几点启发:

系统调用和内核态是昂贵的,用户态的调度器拥有更好的性能。

由于频繁进行不必要的切换,线程并不是合适的并发执行基本单位;相反,将线程作为执行资源 (CPU) 的抽象、为一个 CPU 核心建立一个线程作为执行器则是一个很不错的主意。

单一任务队列在任务短而多时劣势明显,分布式队列+任务偷取能够较好的解决问题。

可以说,Golang 的并发机制是 NodeJS 的普适版,拥有能够更好利用多核计算力的优势;和 采用 OS 线程、阻塞 I/O、GIL 的 Python 并发模式 相比则更是云泥之别。正是更为精巧的并发机制和简单的并发原语,使得 Concurrency 成为 Go 语言最大的卖点。

需要指出的是,Go 所采用的一切技术都并非原创—— go func()的同步原语与 Cilk 十分类似,分布式任务队列也多少有模仿 Cilk/OpenMP 的意味,如果非要说不同之处,大概在于 Go 是一个原生支持该功能的完整编程语言,而另外两者只是 C/C++的语法扩展插件吧。

文章转载:Go开发大全

(版权归原作者所有,侵删)

编辑:jq

声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表电子发烧友网立场。文章及其配图仅供工程师学习之用,如有内容侵权或者其他违规问题,请联系本站处理。 举报投诉
  • 语言
    +关注

    关注

    1

    文章

    97

    浏览量

    24752

原文标题:Golang 学习之并发机制

文章出处:【微信号:magedu-Linux,微信公众号:马哥Linux运维】欢迎添加关注!文章转载请注明出处。

收藏 人收藏
加入交流群
微信小助手二维码

扫码添加小助手

加入工程师交流群

    评论

    相关推荐
    热点推荐

    C语言特性

    数据,实现对设备的精准控制,同时降低功耗,延长设备的使用寿命。 2、可移植性:跨平台的通行证 C 语言具有良好的可移植性,这意味着用 C 语言编写的程序可以多种不同的硬件平台和操
    发表于 11-24 07:01

    C语言嵌入式开发中的应用

    C 语言汽车电子控制系统开发中的主导地位。 2、设备驱动程序 设备驱动程序是嵌入式系统中连接硬件和软件的桥梁,它负责实现嵌入式系统与外部设备之间的通信和控制。C 语言由于其对底
    发表于 11-21 08:09

    基于IAP功能实现远程升级,如何设计Flash双Bank热切换的回滚机制

    基于IAP功能实现远程升级时,如何设计Flash双Bank热切换的回滚机制
    发表于 11-21 07:26

    如何利用Trace机制实现LLCP预览功能

    蓝牙协议栈开发过程中,有时需要预先知道 LLCP。本文将介绍如何利用 Trace 机制实现 LLCP 预览功能。
    的头像 发表于 10-09 17:55 1503次阅读

    教程来啦!LuatOS中的消息通信机制详解及其应用场景

    资源受限的嵌入式环境中,LuatOS采用消息机制实现模块间解耦与高效通信。通过预定义消息名称(如“new_msg”),开发者可轻松构建响应式程序结构。接下来我们将深入剖析其实现原理与
    的头像 发表于 09-26 18:59 221次阅读
    教程来啦!LuatOS中的消息通信<b class='flag-5'>机制</b>详解及其应用场景

    Task任务:LuatOS实现“任务级并发”的核心引擎

    Task任务通过其强大的并发处理能力,使LuatOS能够单线程环境中模拟多线程执行,通过协程的挂起与恢复机制实现任务级的并行操作,显著提升系统效能。 sys核心库是LuatOS运行
    的头像 发表于 08-28 13:49 332次阅读
    Task任务:LuatOS<b class='flag-5'>实现</b>“任务级<b class='flag-5'>并发</b>”的核心引擎

    【HZ-T536开发板免费体验】5、安装sqlite3和使用golang读写数据库

    如果想在嵌入式设备上实现简单的设备管理功能,需要数据库和服务后端程序。服务端程序,我更倾向使用golang实现。 安装sqlite3,使用ubuntu环境,可以直接用apt install安装程序
    发表于 08-26 00:04

    国产主板耐用性和可靠性上有哪些具体表现呢

    国产主板耐用性和可靠性上有着诸多令人瞩目的具体表现,不同领域发挥着关键作用。
    的头像 发表于 07-22 18:21 752次阅读

    鸿蒙5开发宝藏案例分享---应用并发设计

    到性能调优,这些案例都是华为工程师的血泪经验结晶。下面用最直白的语言+代码示例,带你玩转HarmonyOS并发开发! ?一、ArkTS并发模型:颠覆传统的设计 传统模型痛点 graph LR A[共享
    发表于 06-12 16:19

    STM32微控制器中实现数据加密的方法

    调试端口访问控制、读保护(RDP)等。这些措施可以防止代码被未经授权的第三方读取或修改。 · 结合其他安全机制: · · 实际应用中,数据加密往往需要与其他安全
    发表于 03-07 07:30

    Java的SPI机制详解

    作者:京东物流 杨苇苇 1.SPI简介 SPI(Service Provicer Interface)是Java语言提供的一种接口发现机制,用来实现接口和接口实现的解耦。简单来说,就是
    的头像 发表于 03-05 11:35 1104次阅读
    Java的SPI<b class='flag-5'>机制</b>详解

    RK3568驱动指南|第三篇-并发与竞争-第19章 并发与竞争实验

    RK3568驱动指南|第三篇-并发与竞争-第19章 并发与竞争实验
    的头像 发表于 02-24 16:26 843次阅读
    RK3568驱动指南|第三篇-<b class='flag-5'>并发</b>与竞争-第19章 <b class='flag-5'>并发</b>与竞争实验

    语言模型的解码策略与关键优化总结

    本文系统性地阐述了大型语言模型(LargeLanguageModels,LLMs)中的解码策略技术原理及其实践应用。通过深入分析各类解码算法的工作机制、性能特征和优化方法,为研究者和工程师提供了全面
    的头像 发表于 02-18 12:00 1063次阅读
    大<b class='flag-5'>语言</b>模型的解码策略与关键优化总结

    数字电路编程语言介绍

    文本形式描述电路的行为和结构。 并行性和并发性 :数字电路编程语言支持并行和并发操作的描述,这是数字电路设计中的基本特性。 模块化 :这些语言支持模块化设计,允许设计师将复杂的电路分解
    的头像 发表于 01-24 09:39 1385次阅读

    EE-188:使用C语言ADSP-219x DSP上实现中断驱动系统

    电子发烧友网站提供《EE-188:使用C语言ADSP-219x DSP上实现中断驱动系统.pdf》资料免费下载
    发表于 01-15 16:06 0次下载
    EE-188:使用C<b class='flag-5'>语言</b><b class='flag-5'>在</b>ADSP-219x DSP上<b class='flag-5'>实现</b>中断驱动系统