您好,欢迎来到三六零分类信息网!老站,搜索引擎当天收录,欢迎发信息

Go 为什么这么“快”

2024/3/27 20:58:59发布24次查看
本文主要介绍了 go 程序为了实现极高的并发性能,其内部调度器的实现架构(g-p-m 模型),以及为了最大限度利用计算资源,go 调度器是如何处理线程阻塞的场景。
怎么让我们的系统更快
随着信息技术的迅速发展,单台服务器处理能力越来越强,迫使编程模式由从前的串行模式升级到并发模型。
并发模型包含 io 多路复用、多进程以及多线程,这几种模型都各有优劣,现代复杂的高并发架构大多是几种模型协同使用,不同场景应用不同模型,扬长避短,发挥服务器的最大性能。
而多线程,因为其轻量和易用,成为并发编程中使用频率最高的并发模型,包括后衍生的协程等其他子产品,也都基于它。
并发 ≠ 并行
并发 (concurrency) 和 并行 ( parallelism) 是不同的。
在单个  cpu  核上,线程通过时间片或者让出控制权来实现任务切换,达到  同时  运行多个任务的目的,这就是所谓的并发。但实际上任何时刻都只有一个任务被执行,其他任务通过某种算法来排队。
多核  cpu  可以让同一进程内的  多个线程  做到真正意义上的同时运行,这才是并行。
进程、线程、协程
进程:进程是系统进行资源分配的基本单位,有独立的内存空间。
线程:线程是 cpu 调度和分派的基本单位,线程依附于进程存在,每个线程会共享父进程的资源。
协程:协程是一种用户态的轻量级线程,协程的调度完全由用户控制,协程间切换只需要保存任务的上下文,没有内核的开销。
线程上下文切换
由于中断处理,多任务处理,用户态切换等原因会导致 cpu 从一个线程切换到另一个线程,切换过程需要保存当前进程的状态并恢复另一个进程的状态。
上下文切换的代价是高昂的,因为在核心上交换线程会花费很多时间。上下文切换的延迟取决于不同的因素,大概在在  50  到  100  纳秒之间。考虑到硬件平均在每个核心上每纳秒执行  12  条指令,那么一次上下文切换可能会花费  600  到  1200  条指令的延迟时间。实际上,上下文切换占用了大量程序执行指令的时间。
如果存在跨核上下文切换(cross-core context switch),可能会导致 cpu 缓存失效(cpu 从缓存访问数据的成本大约  3  到  40  个时钟周期,从主存访问数据的成本大约  100  到  300  个时钟周期),这种场景的切换成本会更加昂贵。
golang 为并发而生
golang 从 2009 年正式发布以来,依靠其极高运行速度和高效的开发效率,迅速占据市场份额。golang 从语言级别支持并发,通过轻量级协程 goroutine 来实现程序并发运行。
goroutine 非常轻量,主要体现在以下两个方面:
上下文切换代价小: goroutine 上下文切换只涉及到三个寄存器(pc / sp / dx)的值修改;而对比线程的上下文切换则需要涉及模式切换(从用户态切换到内核态)、以及 16 个寄存器、pc、sp…等寄存器的刷新;
内存占用少:线程栈空间通常是 2m,goroutine 栈空间最小 2k;
golang 程序中可以轻松支持10w 级别的 goroutine 运行,而线程数量达到 1k 时,内存占用就已经达到 2g。
go 调度器实现机制:
go 程序通过调度器来调度goroutine 在内核线程上执行,但是 goroutine 并不直接绑定 os 线程 m - machine运行,而是由 goroutine scheduler 中的  p - processor (逻辑处理器)来作获取内核线程资源的『中介』。
go 调度器模型我们通常叫做g-p-m 模型,他包括 4 个重要结构,分别是g、p、m、sched:
g:goroutine,每个 goroutine 对应一个 g 结构体,g 存储 goroutine 的运行堆栈、状态以及任务函数,可重用。
g 并非执行体,每个 g 需要绑定到 p 才能被调度执行。
p: processor,表示逻辑处理器,对 g 来说,p 相当于 cpu 核,g 只有绑定到 p 才能被调度。对 m 来说,p 提供了相关的执行环境(context),如内存分配状态(mcache),任务队列(g)等。
p 的数量决定了系统内最大可并行的 g 的数量(前提:物理 cpu 核数  >= p 的数量)。
p 的数量由用户设置的 gomaxprocs 决定,但是不论 gomaxprocs 设置为多大,p 的数量最大为 256。
m: machine,os 内核线程抽象,代表着真正执行计算的资源,在绑定有效的 p 后,进入 schedule 循环;而 schedule 循环的机制大致是从 global 队列、p 的 local 队列以及 wait 队列中获取。
m 的数量是不定的,由 go runtime 调整,为了防止创建过多 os 线程导致系统调度不过来,目前默认最大限制为 10000 个。
m 并不保留 g 状态,这是 g 可以跨 m 调度的基础。
sched:go 调度器,它维护有存储 m 和 g 的队列以及调度器的一些状态信息等。
调度器循环的机制大致是从各种队列、p 的本地队列中获取 g,切换到 g 的执行栈上并执行 g 的函数,调用 goexit 做清理工作并回到 m,如此反复。
理解 m、p、g 三者的关系,可以通过经典的地鼠推车搬砖的模型来说明其三者关系:
地鼠(gopher)的工作任务是:工地上有若干砖头,地鼠借助小车把砖头运送到火种上去烧制。m 就可以看作图中的地鼠,p 就是小车,g 就是小车里装的砖。
弄清楚了它们三者的关系,下面我们就开始重点聊地鼠是如何在搬运砖块的。
processor(p):
根据用户设置的  gomaxprocs 值来创建一批小车(p)。
goroutine(g):
通过 go 关键字就是用来创建一个  goroutine,也就相当于制造一块砖(g),然后将这块砖(g)放入当前这辆小车(p)中。
machine (m):
地鼠(m)不能通过外部创建出来,只能砖(g)太多了,地鼠(m)又太少了,实在忙不过来,刚好还有空闲的小车(p)没有使用,那就从别处再借些地鼠(m)过来直到把小车(p)用完为止。
这里有一个地鼠(m)不够用,从别处借地鼠(m)的过程,这个过程就是创建一个内核线程(m)。
需要注意的是:地鼠(m)  如果没有小车(p)是没办法运砖的,小车(p)的数量决定了能够干活的地鼠(m)数量,在 go 程序里面对应的是活动线程数;
在 go 程序里我们通过下面的图示来展示 g-p-m 模型:
p 代表可以“并行”运行的逻辑处理器,每个 p 都被分配到一个系统线程 m,g 代表 go 协程。
go 调度器中有两个不同的运行队列:全局运行队列(grq)和本地运行队列(lrq)。
每个 p 都有一个 lrq,用于管理分配给在 p 的上下文中执行的 goroutines,这些 goroutine 轮流被和 p 绑定的 m 进行上下文切换。grq 适用于尚未分配给 p 的 goroutines。
从上图可以看出,g 的数量可以远远大于 m 的数量,换句话说,go 程序可以利用少量的内核级线程来支撑大量 goroutine 的并发。多个 goroutine 通过用户级别的上下文切换来共享内核线程 m 的计算资源,但对于操作系统来说并没有线程上下文切换产生的性能损耗。
为了更加充分利用线程的计算资源,go 调度器采取了以下几种调度策略:
任务窃取(work-stealing)
我们知道,现实情况有的 goroutine 运行的快,有的慢,那么势必肯定会带来的问题就是,忙的忙死,闲的闲死,go 肯定不允许摸鱼的 p 存在,势必要充分利用好计算资源。
为了提高 go 并行处理能力,调高整体处理效率,当每个 p 之间的 g 任务不均衡时,调度器允许从 grq,或者其他 p 的 lrq 中获取 g 执行。
减少阻塞
如果正在执行的 goroutine 阻塞了线程 m 怎么办?p 上 lrq 中的 goroutine 会获取不到调度么?
在 go 里面阻塞主要分为一下 4 种场景:
场景 1:由于原子、互斥量或通道操作调用导致  goroutine  阻塞,调度器将把当前阻塞的 goroutine 切换出去,重新调度 lrq 上的其他 goroutine;
场景 2:由于网络请求和 io 操作导致  goroutine  阻塞,这种阻塞的情况下,我们的 g 和 m 又会怎么做呢?
go 程序提供了网络轮询器(netpoller)来处理网络请求和 io 操作的问题,其后台通过 kqueue(macos),epoll(linux)或  iocp(windows)来实现 io 多路复用。
通过使用 netpoller 进行网络系统调用,调度器可以防止  goroutine  在进行这些系统调用时阻塞 m。这可以让 m 执行 p 的  lrq  中其他的  goroutines,而不需要创建新的 m。有助于减少操作系统上的调度负载。
下图展示它的工作原理:g1 正在 m 上执行,还有 3 个 goroutine 在 lrq 上等待执行。网络轮询器空闲着,什么都没干。
接下来,g1 想要进行网络系统调用,因此它被移动到网络轮询器并且处理异步网络系统调用。然后,m 可以从 lrq 执行另外的 goroutine。此时,g2 就被上下文切换到 m 上了。
最后,异步网络系统调用由网络轮询器完成,g1 被移回到 p 的 lrq 中。一旦 g1 可以在 m 上进行上下文切换,它负责的 go 相关代码就可以再次执行。这里的最大优势是,执行网络系统调用不需要额外的 m。网络轮询器使用系统线程,它时刻处理一个有效的事件循环。
这种调用方式看起来很复杂,值得庆幸的是,go 语言将该“复杂性”隐藏在 runtime 中:go 开发者无需关注 socket 是否是  non-block 的,也无需亲自注册文件描述符的回调,只需在每个连接对应的 goroutine 中以“block i/o”的方式对待 socket 处理即可,实现了 goroutine-per-connection 简单的网络编程模式(但是大量的 goroutine 也会带来额外的问题,比如栈内存增加和调度器负担加重)。
用户层眼中看到的 goroutine 中的“block socket”,实际上是通过 go runtime 中的 netpoller 通过 non-block socket + i/o 多路复用机制“模拟”出来的。go 中的 net 库正是按照这方式实现的。
场景 3:当调用一些系统方法的时候,如果系统方法调用的时候发生阻塞,这种情况下,网络轮询器(netpoller)无法使用,而进行系统调用的  goroutine  将阻塞当前 m。
让我们来看看同步系统调用(如文件 i/o)会导致 m 阻塞的情况:g1 将进行同步系统调用以阻塞 m1。
调度器介入后:识别出 g1 已导致 m1 阻塞,此时,调度器将 m1 与 p 分离,同时也将 g1 带走。然后调度器引入新的 m2 来服务 p。此时,可以从 lrq 中选择 g2 并在 m2 上进行上下文切换。
阻塞的系统调用完成后:g1 可以移回 lrq 并再次由 p 执行。如果这种情况再次发生,m1 将被放在旁边以备将来重复使用。
场景 4:如果在 goroutine 去执行一个 sleep 操作,导致 m 被阻塞了。
go 程序后台有一个监控线程 sysmon,它监控那些长时间运行的 g 任务然后设置可以强占的标识符,别的 goroutine 就可以抢先进来执行。
只要下次这个 goroutine 进行函数调用,那么就会被强占,同时也会保护现场,然后重新放入 p 的本地队列里面等待下次执行。
小结
本文主要从 go 调度器架构层面上介绍了 g-p-m 模型,通过该模型怎样实现少量内核线程支撑大量 goroutine 的并发运行。以及通过 netpoller、sysmon 等帮助 go 程序减少线程阻塞,充分利用已有的计算资源,从而最大限度提高 go 程序的运行效率。
更多go语言知识请关注go语言教程栏目。
以上就是go 为什么这么“快”的详细内容。
该用户其它信息

VIP推荐

免费发布信息,免费发布B2B信息网站平台 - 三六零分类信息网 沪ICP备09012988号-2
企业名录 Product