Go 18.0 专业五


并发、并行、协程、线程

<h2>常见的并发场景:</h2> <p><strong>电商常见的并发场景包括:</strong></p> <ol> <li> <p>促销活动时的高并发访问:如双十一、618等电商大促销活动期间,用户涌入网站进行购物,导致网站流量和访问量激增。</p> </li> <li> <p>订单提交和支付时的高并发处理:当用户在电商网站提交订单或者进行支付时,需要对订单信息进行验证和处理。大量用户同时提交订单或支付时,会导致系统的并发请求压力增大。</p> </li> <li> <p>库存查询和更新时的高并发处理:当用户在电商网站查询商品库存或者下单时,需要对库存进行实时查询和更新。如果有多个用户同时查询或下单同一件商品,就会产生高并发处理的场景。</p> </li> <li> <p>物流配送时的高并发处理:当用户下单后,电商网站需要对物流配送进行处理和跟踪。在订单量较大时,物流系统也会面临高并发请求处理的挑战。</p> </li> <li>售后服务时的高并发处理:当用户需要进行退货、换货、维修等售后服务时,需要对申请进行审核和处理。在大量用户同时进行售后服务时,也会出现高并发处理的场景。</li> </ol> <p><strong>八维考试平台存在一些并发问题,主要体现在以下几个方面:</strong></p> <p>1.用户并发访问问题:当大量用户同时访问八维考试平台时,会导致服务器压力增大,可能会出现网站响应慢或者崩溃等问题。</p> <p>2.考试答题并发问题:在进行在线考试过程中,如果有多个用户同时提交答案或切换页面,可能会导致系统并发请求增大,从而影响考试的顺利进行。</p> <hr /> <p>从本讲开始,我将详细介绍 Go 语言中的并发。都说 Go 语言的并发简单易学,本讲就来带着大家一起体会其中的奥秘,具体内容包括:</p> <ul> <li>基本概念</li> <li>并发任务的启动</li> </ul> <h2>基本概念</h2> <p>我们先从一些基本的概念谈起。</p> <p>并发(Concurrency)和并行(Parallelism)是计算机领域中两个重要的概念,而协程(Goroutine)和线程(Thread)则是Go语言中实现并发和并行的两种方式。</p> <p>&lt;font color='red'&gt;抛出问题:那么问题来了,什么是并发和并行?&lt;/font&gt;</p> <ol> <li> <p>&lt;font color='red'&gt;并发&lt;/font&gt;是指在同一时间间隔内执行多个任务的能力。具体来说,它是指在一个处理器上交替执行多个任务,通过快速的切换和调度来模拟同时执行的效果。多线程程序在一个核的cpu上运行,就是并发</p> </li> <li>&lt;font color='red'&gt; 并行&lt;/font&gt;是指同时执行多个任务的能力。具体来说,它是指在多个处理器上同时执行多个任务,每个处理器可以独立地执行自己的任务。多线程程序在多个核的cpu上运行,就是并行。</li> </ol> <p>&gt;想象这样一个场景:我们同时执行 4 个任务,假设这些任务运行在配备了 4 核 CPU 的电脑上。现在,用图表来描述计算机的运行情况,横轴表示时间,纵轴表示每个 CPU 核心,不同颜色的色块表示不同的任务在执行。具体如下:</p> <p><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/4089c766e99d4bf6ac306918f45a6416~tplv-k3u1fbpfcp-zoom-in-crop-mark:3024:0:0:0.awebp?" alt="并发VS并行" /></p> <p>&gt;从图中可以看出,<strong>并发是针对某个 CPU 核心而言的,利用切换 CPU 的时间片来实现多个程序同时运行</strong>。切换时间片的过程通常非常迅速,我们是无法察觉的。 <strong>并行则是将 4 个任务真正地分配给 4 个CPU核心执行</strong>。这就是并发和并行的区别,二者虽有关系,但<strong>调度机制完全不同</strong>。<strong>并发更关注单核心的能力,并行更关注同时做的事情</strong>。</p> <p>3.&lt;font color='red'&gt;进程&lt;/font&gt;是程序在操作系统中的一次执行过程,系统进行资源分配和调度的一个独立单位。</p> <p>4.&lt;font color='red'&gt;线程(Thread)&lt;/font&gt;是进程的一个执行实体,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。 是操作系统中执行调度的最小单位,它可以被看做是一条执行路径。与进程不同,线程可以共享进程中的资源和地址空间,因此能够更加高效地完成任务。在Go语言中,也可以使用多线程来实现并行和并发,但由于线程上下文切换需要消耗大量的时间和资源,因此相对于协程而言,多线程的性能较差。</p> <p>5.&lt;font color='red'&gt;协程(Goroutine)&lt;/font&gt;是Go语言中轻量级的线程实现。它通过快速的切换和调度,可以在单个系统线程中实现并发执行多个任务。与传统的线程相比,协程更加轻量级、高效,并且具有更好的扩展性和并发性。在Go语言中,可以通过关键字<code>go</code>来启动一个协程,例如<code>go func() {...}</code>。</p> <p>综上所述,Go语言通过协程和通道的方式实现了高效的并发处理,同时也支持多线程的方式实现并行处理。由于协程具有轻量级、高效等特点,因此在Go语言中,协程是实现并发处理的首选方案。</p> <h3>协程与线程</h3> <p>线程能充分发挥多核 CPU 的优势,可以做到并行执行多任务。协程则不然,协程是为并发而生的,一个线程上可以跑多个协程。</p> <p><strong>Go 语言中的并发是靠协程来实现的。在后端服务器软件开发中,有大量的 IO 密集操作,这正是协程最适合的场景。这也正是 Go 语言更适合高并发场景的原因。</strong></p> <p><code>💡 提示: Go 语言的任务调度模型被称为 GPM,我将在下一讲详述GPM模型架构及原理。</code></p> <h2>并发任务的启动</h2> <p>在 Go 语言中启动并发任务非常简单,只需要在相应的语句前面加上 go 即可。来看下面这段代码:</p> <pre><code>func main() { // 并发调用testFunc() go testFunc() time.Sleep(time.Second * 5) fmt.Println(&amp;quot;程序运行结束&amp;quot;) } // 并发测试函数 func testFunc() { for i := 1; i &amp;lt;= 3; i++ { fmt.Printf(&amp;quot;第%d次运行\n&amp;quot;, i) time.Sleep(time.Second) } }</code></pre> <p>在 testFunc() 函数中调用了 time.Sleep() 函数,<strong>time.Sleep() 的作用是让当前协程暂停特定的时间</strong>。所以整个testFunc() 函数的目的就是每隔1秒执行1次循环体中的代码,总共执行 3 次,共计耗时 3 秒。main() 函数中在调用 testFunc() 函数时前面加了 “go ”,表示创建一个 Goroutine,在另一个协程中执行 testFunc()。程序运行结果为:</p> <p>&gt; 第 1 次运行 &gt; &gt; 第 2 次运行 &gt; &gt; 第 3 次运行 &gt; &gt; 程序运行结束</p> <p>为什么 main() 函数中要等待 5 秒呢?这是因为 testFunc() 函数需要至少 3 秒才能完成,由于 testFunc() 在另一个协程中,并不会影响 main() 函数体中后续代码的执行。因此main() 函数将迅速完成,整个程序便宣告终止了。</p> <p><strong>一旦程序终止,所有在 main() 函数中启动的 Goroutine 也会随之终止</strong>,我们便看不到其它协程中的输出了。所以要给 testFunc() 预留足够多的时长,等待它完成执行。这是使用并发时特别需要注意的一点。</p> <p>然而,在实际开发中,我们通常无法确切地得知一个协程的准确执行时长。况且像上述代码中,过长的等待时间将会导致程序运行效率的降低。Go 语言提供了一种特别方便的方式确保执行协程任务的完整性,它来自 sync 包。下面的代码演示了它的使用方法:</p> <pre><code class="language-go">package main import ( &amp;quot;fmt&amp;quot; &amp;quot;sync&amp;quot; &amp;quot;time&amp;quot; ) var wg sync.WaitGroup func main() { wg.Add(1) // 并发调用testFunc() go testFunc() wg.Wait() fmt.Println(&amp;quot;程序运行结束&amp;quot;) } // 并发测试函数 func testFunc() { defer wg.Done() for i := 1; i &amp;lt;= 3; i++ { fmt.Printf(&amp;quot;第%d次运行\n&amp;quot;, i) time.Sleep(time.Second) } } </code></pre> <p>&gt;第1次运行 第2次运行 第3次运行 程序运行结束</p> <p>这段代码中,声明了 sync.WaitGroup 类型的变量wg。main() 函数体一上来调用了wg.Add() 方法,并向其中传入 1。表示即将开启 1 个 Goroutine。紧接着便是启动 Goroutine 了。最后执行了 wg.Wait() 方法,该方法将告知程序在此处等待协程任务的完成。在 testFunc() 函数体中,末尾调用了wg.Done() 方法,表示协程任务执行完成。</p> <p>运行这段代码,控制台将得到同样的输出,但不会傻傻地等待 5 秒了。</p> <p>&gt;💡 提示:从源码中,有一个 Goroutine 计数器。每次调用 wg.Add() 方法时,传入的参数便作为累加值使用;调用 wg.Done() 方法时相当于让计数器自减 1。当计数器归 0 时,wg.Wait() 方法才会结束。</p> <p>接下来上升一点难度,如果要连续并发两次 testFunc() 任务,该如何修改上述代码呢?</p> <p>答案是:</p> <pre><code>var wg sync.WaitGroup func main() { // Goroutine计数器增2 wg.Add(2) // 第一次并发调用testFunc() go testFunc() // 第二次并发调用testFunc() go testFunc() wg.Wait() fmt.Println(&amp;quot;程序运行结束&amp;quot;) } // 并发测试函数 func testFunc() { defer wg.Done() for i := 1; i &amp;lt;= 3; i++ { fmt.Printf(&amp;quot;第%d次运行\n&amp;quot;, i) time.Sleep(time.Second) } }</code></pre> <p>由于并发两次,所以要向 wg.Add() 方法传入 2。程序运行结果为:</p> <p>&gt; 第 1 次运行 &gt; &gt; 第 1 次运行 &gt; &gt; 第 2 次运行 &gt; &gt; 第 2 次运行 &gt; &gt; 第 3 次运行 &gt; &gt; 第 3 次运行 &gt; &gt; 程序运行结束</p> <p>在 Go 语言中开启 Goroutine,还可以<strong>通过匿名函数的方式,当代码中只发生一次调用时特别方便</strong>。比如:</p> <pre><code>func main() { wg.Add(1) go func() { defer wg.Done() for i := 1; i &amp;lt;= 3; i++ { fmt.Printf(&amp;quot;第%d次运行\n&amp;quot;, i) time.Sleep(time.Second) } }() wg.Wait() fmt.Println(&amp;quot;程序运行结束&amp;quot;) }</code></pre> <p>这段代码依然会输出:</p> <p>&gt; 第 1 次运行 &gt; &gt; 第 2 次运行 &gt; &gt; 第 3 次运行 &gt; &gt; 程序运行结束</p> <p>细心的朋友会发现,在 testFunc() 函数体中,<strong>使用 defer 执行 wg.Done()。如此是为了保证即使在执行函数体时发生错误,goRoutineWait.Done() 方法也依然会被调用,从而保证main() 函数的正常运行。</strong> 在某种角度上说,这是一种“舍车保帅”的做法。这一点在使用并发时同样需要注意。</p> <p>作为“初探”,本讲内容就先介绍到这里,是不是感觉在 Go 语言中调度任务非常简单呢?</p>

页面列表

ITEM_HTML