Go 18.0 专业五


揭秘 Go 语言高并发原理

<p>上一讲我带各位“浅尝”了 Go 语言中的并发,或许最大的感受就是启动一个 Goroutine 太容易了。但在这“容易”的背后,到底隐藏着哪些奥秘呢?Go 语言中的 GPM 任务调度机制又是如何运作的呢?本讲内容将给出答案。</p> <p>为了让各位对计算机的任务调度有更深刻的理解,我会先为大家梳理任务调度的发展史。这部分并不只是故事,通过了解这段历史,能够加深对进程与线程、并发与并行的理解,它们的优势与弊端,以及更优的解决方案。</p> <p>然后向大家全景展示 Go 语言是如何进行任务调度的,揭秘高并发能力的本质。最后再来聊聊如何为其它协程任务让出资源以及终止自身运行,这对于更加灵活的任务执行控制非常有用。</p> <h2>一、任务调度进化史</h2> <p>1946 年 2 月 14 日,第一台通用电子计算机 ENIAC 诞生了。在这短短的不到 80 年期间,计算机经历了飞速发展。能做的事情也越来越多,在一台计算机上(这里指广义的“计算机”,包括但不限于电脑、手机等设备)同时执行多个任务已是很常见的事情了。但回顾这段历史可以发现,刚开始的计算机程序都还只是单进程的,它们由操作系统调度执行,采用的运行机制被称为“串行”工作机制。</p> <p>&lt;font color='red'&gt;1.串行工作机制&lt;/font&gt;</p> <p>如果去翻阅 CPU 的发展史,不难发现真正的多核心处理器是随着奔腾D 处理器的推出才面世的。奔腾D 处理器是英特尔公司在 2005 年推出的双核心处理器,之前的奔腾4 处理器虽然在其漫长的生命周期中推出过含有超线程技术(HT)的产品,但并非是真正意义上的多核。虽然奔腾D 处理器在某些方面广受诟病,但不可否认的是:它开创了多核心处理器的先河,为多核 CPU 的发展奠定了基础。</p> <p>&lt;font color='red'&gt;在此之前,CPU 都是单核心的。正如前文所提到的那样,最早期的计算机程序都是单进程的。因此,操作系统在执行时,必须等待一个程序执行完,再执行下一个程序。&lt;/font&gt;如果把这种执行方式放到现在,简直太耽误事了。比如我们正在下载文件,就必须等下载完成,才能做别的。又比如播放一段乐曲,那就只能听着它,不能干别的,除非暂停。</p> <p>除了易用性上欠妥外,对 CPU 的性能也是一种浪费。当进程阻塞时,CPU的用量其实很低甚至处于闲置状态。</p> <p>综上,这种最为传统的串行单进程工作机制很快便暴露出问题,引入了“并发”的概念。</p> <p>&lt;font color='red'&gt;2.多进程并发模式&lt;/font&gt;</p> <p>从上一讲中我们已经了解到,“并发”是针对单个 CPU 核心而言的,通过切换时间片实现多任务执行。从细节上看,当一个进程发生阻塞时,处于等待状态的其它进程便会得到运行,以此类推。这样做的目的就是为了最大化利用CPU资源。</p> <p>在操作系统中,时间片是一段固定长度的时间,在这段时间内CPU只会执行一个特定的任务或进程。操作系统将CPU时间划分成多个时间片,并轮流分配给各个进程使用,以实现多个任务的并发执行。当时间片用完后,操作系统会将当前任务挂起,将CPU时间片分配给下一个任务执行,直到所有任务都执行完毕。</p> <p>值得一提的是,并发机制仍然由操作系统调度。从上一讲中我们已经了解到,由操作系统调度则需要耗费一定的资源。进程的创建、切换和销毁都需要时间来进行。当切换进程过于频繁时,很多时间将会花费在调度上。在这个过程中,CPU的占用率可能看上去很高,但实际利用率却并不理想。</p> <p>&lt;font color='red'&gt;3.多线程并行模式&lt;/font&gt;</p> <p>自从英特尔推出含超线程技术的奔腾4 以及后续的奔腾D 处理器后,不同的线程便有机会真正地运行在不同的 CPU 核心上,实现并行了。</p> <p>从广义上讲,线程可分为两大类,即内核态和用户态。前者是真正意义上的线程,由 CPU 来调度,由内核态线程共同组成的区域成为“内核空间”。用户态的线程实际上是指协程,由协程调度器来调度。从 CPU 的角度上看,只能发现内核态的线程,协程对其不可见。所以当协程任务发生切换时,更加快速和轻量。下面这张图展示了上述调度结构:</p> <p><img src="https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/b91b6f165ec644e4a95a29e8a5521418~tplv-k3u1fbpfcp-zoom-in-crop-mark:3024:0:0:0.awebp?" alt="image-20220419093331601.png" /></p> <p>图中的橙色线条表示绑定关系,其中隐含了协程调度器和协程队列处理器。</p> <p>然而,单纯的并行模式也并非万金油。如果一个线程承载了全部协程任务,则仍然无法从分利用多核 CPU。在极端情形下,协程任务的阻塞还会引发整个线程的阻塞,后续的任务得不到执行,整个系统便会卡住。另外,当一个线程中只存在一个协程任务时,也并不会带来性能的提升。</p> <p>看到这,一种更优的解决方案便浮现了出来,这种方案也是 Go 语言能实现高并发的原理。即<strong>将多个协程绑定在多个线程中,同时将多个线程分配给不同的 CPU 核心运行。如此将并发与并行模式相结合,便打造出了较为理想的任务调度机制。</strong></p> <h2>二、GPM 任务调度模型</h2> <p>前文中已经讲到,Go 语言中的 GPM 任务调度模型充分利用了多核 CPU 的资源。需要时,将创建与之匹配的线程,并将用户态的协程任务“智能”地分配给多个线程执行。整体上运用的是并行+并发的模式,具体如下图所示:</p> <p><img src="https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/70219d596d1e4ef6bdd291469680df8f~tplv-k3u1fbpfcp-zoom-in-crop-mark:3024:0:0:0.awebp?" alt="图片1.png" /></p> <p>从图中可以看到,整个 GPM 结构分为上下两大部分,我们一起从下往上看,正好对应的是内核空间和用户空间。</p> <p>首先来看内核空间,这是一颗 4 核心的 CPU(暂时不考虑超线程的情况)。由并行的概念不难得出,4 核心的 CPU 可以由操作系统调度,执行 4 个线程。</p> <p><strong>在 Go 程序启动时,会自动根据 CPU 的核心数设置线程的最大数量</strong>。当然,我们也可以通过编码手动设置,稍后会讲到。当一个线程发生阻塞时,新的线程便会创建。图中黄色的线程是一个空闲的线程,它没有绑定任何协程。</p> <p>再来看用户空间,最上方的全局队列存放所有等待运行的协程任务(即 Goroutine)。下方若干个协程队列,<strong>当发起一个协程任务时,该任务会首先尝试加入到协程队列中。每个协程队列的最大任务数被限制在256个以内</strong>。</p> <p>当协程队列满了之后,协程调度器会将一半数量的任务移动至全局队列中。至于一共能有多少个协程队列,在 Go 1.5 版本之后<strong>队列数默认为CPU核心数量,也可以通过编码来指定</strong>。</p> <p>从另一个角度讲,设置了队列数就意味着设置了程序能同时跑多少个 Goroutine 的数量。一般地,在该参数确定后,所有的队列便会一口气创建完成。</p> <p>在 Go 程序运行时,一个内核空间的线程若想获取某个协程任务来执行,就需要通过协程队列处理来获取特定的协程任务。当队列为空时,全局队列中的若干协程任务,或其它队列中的一半任务会被放到空队列中。如此循环往复,周而复始。</p> <p>另一方面,<strong>协程队列处理器的数量和线程在数量上并没有绝对关系</strong>。如果一个线程发生阻塞,协程队列处理器便会创建或切换至其它线程。因此,即使只有一个协程队列,也有可能会有多个线程。</p> <h2>三、动态调整系统资源</h2> <h3>1.在 Go 程序运行时,可以根据需要设置程序要使用的 CPU 资源,也可以动态调整协程任务的执行方式,实现更灵活地运行。这些操作都是通过 runtime 包来实现的。</h3> <p>在 Go 语言中,可以随时获取操作系统类型、CPU 架构类型和 CPU 核心数量,下面的示例代码输出了它们: &gt;1. 获取 CPU 核心数量</p> <ol> <li>设置 CPU 核心数量</li> <li>获取操作系统类型</li> <li>获取CPU 架构类型</li> </ol> <pre><code class="language-go">package main import ( &amp;quot;fmt&amp;quot; &amp;quot;runtime&amp;quot;) func main() { fmt.Printf(&amp;quot;当前程序的操作系统:%v\\n&amp;quot;, runtime.GOOS) fmt.Printf(&amp;quot;当前程序的CPU架构:%v\\n&amp;quot;, runtime.GOARCH) fmt.Printf(&amp;quot;当前程序的CPU核心数量:%v\\n&amp;quot;, runtime.NumCPU()) } // 当前程序的操作系统:darwin // 当前程序的CPU架构:amd64 // 当前程序的CPU核心数量:8 </code></pre> <p>在 macOS 中,操作系统名称为darwin; 在Windows中,操作系统名称即windows; 在Linux中,操作系统名称为linux。</p> <p><strong>对于 32 位的 CPU,运行结果为 386; 对于 64 位的 CPU,运行结果为 amd64; 对于 arm 架构 32 位的 CPU,运行结果为 arm; 对于 arm 架构 64 位的 CPU,运行结果为 arm64。</strong></p> <p>&gt;💡 提示:若要获取Go语言支持的所有操作系统和CPU架构,可执行命令行:go tool dist list。</p> <h3>2.在Go中,默认情况下,程序会使用机器上所有可用的CPU核心。具体的核心数量取决于操作系统和硬件,可以通过 runtime.NumCPU() 函数来获取当前机器的CPU核心数量。</h3> <p>若要设置可用的 CPU 核心数,可以通过 runtime.GOMAXPROCS() 函数实现。需要注意的是:该函数将返回设置之前的核心数。</p> <pre><code class="language-go">package main import ( &amp;quot;fmt&amp;quot; &amp;quot;runtime&amp;quot;) /** * 演示在Go中不设置CPU核心数量时,程序会使用多少核心: * 若要设置可用的 CPU 核心数,可以通过 runtime.GOMAXPROCS() 函数实现。需要注意的是:该函数将返回设置之前的核心数。 * 比如,对于一颗多核心的 CPU,若设置程序只能使用一半数量的核心,代码为: */ func main() { // 获取当前程序可用的CPU核心数 fmt.Println(runtime.GOMAXPROCS(0)) if runtime.NumCPU() &amp;gt; 2 { runtime.GOMAXPROCS(runtime.NumCPU() / 2) } // 获取当前程序可用的CPU核心数 fmt.Println(runtime.GOMAXPROCS(0)) } </code></pre> <p>&gt;请留意代码最后,当向 runtime.GOMAXPROCS() 函数传入 0 时,即可实现获取可用核心数。</p> <h3>给其它任务“让行”</h3> <p>在程序运行中,某些特定的情况下需要暂停当前协程,让其它协程任务先执行。首先来看下面这段代码:</p> <pre><code>func main() { go fmt.Println(&amp;quot;Hello World&amp;quot;)    fmt.Println(&amp;quot;程序运行结束&amp;quot;) }</code></pre> <p>显然,由于输出文本被放在了另一个协程中执行。程序将很快结束,甚至在大多数情况下都不会看到 “Hello World” 输出。</p> <p>若要想正常看到控制台的输出,一种方法便是使用 sync.Wait() 方法,这一招在上一讲中已经介绍过了。另一种方法还可以使主线程中的任务让出资源,优先执行输出文本。方法如下:</p> <pre><code>func main() {   go fmt.Println(&amp;quot;Hello World&amp;quot;)   runtime.Gosched()   fmt.Println(&amp;quot;程序运行结束&amp;quot;) }</code></pre> <p>如此,便会看到控制台输出:</p> <p>&gt; Hello World &gt; &gt; 程序运行结束</p> <h3>终止自身协程</h3> <p>在某些条件下,我们还希望立即停止协程任务的执行。方法便是使用调用 runtime.Goexit() 函数。下面这段示例代码演示了在满足特定条件时终止协程的方法:</p> <pre><code>func main() { syncWait.Add(1) go testFunc() syncWait.Wait() fmt.Println(&amp;quot;程序运行结束&amp;quot;) } func testFunc() { defer syncWait.Done() for i := 1; i &amp;lt; 100; i++ { fmt.Println(i) if i &amp;gt;= 5 { runtime.Goexit() } } }</code></pre>

页面列表

ITEM_HTML