并发、并行、协程、线程
<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><font color='red'>抛出问题:那么问题来了,什么是并发和并行?</font></p>
<ol>
<li>
<p><font color='red'>并发</font>是指在同一时间间隔内执行多个任务的能力。具体来说,它是指在一个处理器上交替执行多个任务,通过快速的切换和调度来模拟同时执行的效果。多线程程序在一个核的cpu上运行,就是并发</p>
</li>
<li><font color='red'> 并行</font>是指同时执行多个任务的能力。具体来说,它是指在多个处理器上同时执行多个任务,每个处理器可以独立地执行自己的任务。多线程程序在多个核的cpu上运行,就是并行。</li>
</ol>
<p>>想象这样一个场景:我们同时执行 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>>从图中可以看出,<strong>并发是针对某个 CPU 核心而言的,利用切换 CPU 的时间片来实现多个程序同时运行</strong>。切换时间片的过程通常非常迅速,我们是无法察觉的。
<strong>并行则是将 4 个任务真正地分配给 4 个CPU核心执行</strong>。这就是并发和并行的区别,二者虽有关系,但<strong>调度机制完全不同</strong>。<strong>并发更关注单核心的能力,并行更关注同时做的事情</strong>。</p>
<p>3.<font color='red'>进程</font>是程序在操作系统中的一次执行过程,系统进行资源分配和调度的一个独立单位。</p>
<p>4.<font color='red'>线程(Thread)</font>是进程的一个执行实体,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。
是操作系统中执行调度的最小单位,它可以被看做是一条执行路径。与进程不同,线程可以共享进程中的资源和地址空间,因此能够更加高效地完成任务。在Go语言中,也可以使用多线程来实现并行和并发,但由于线程上下文切换需要消耗大量的时间和资源,因此相对于协程而言,多线程的性能较差。</p>
<p>5.<font color='red'>协程(Goroutine)</font>是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(&quot;程序运行结束&quot;)
}
// 并发测试函数
func testFunc() {
for i := 1; i &lt;= 3; i++ {
fmt.Printf(&quot;第%d次运行\n&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>> 第 1 次运行
>
> 第 2 次运行
>
> 第 3 次运行
>
> 程序运行结束</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 (
&quot;fmt&quot;
&quot;sync&quot;
&quot;time&quot;
)
var wg sync.WaitGroup
func main() {
wg.Add(1)
// 并发调用testFunc()
go testFunc()
wg.Wait()
fmt.Println(&quot;程序运行结束&quot;)
}
// 并发测试函数
func testFunc() {
defer wg.Done()
for i := 1; i &lt;= 3; i++ {
fmt.Printf(&quot;第%d次运行\n&quot;, i)
time.Sleep(time.Second)
}
}
</code></pre>
<p>>第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>>💡 提示:从源码中,有一个 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(&quot;程序运行结束&quot;)
}
// 并发测试函数
func testFunc() {
defer wg.Done()
for i := 1; i &lt;= 3; i++ {
fmt.Printf(&quot;第%d次运行\n&quot;, i)
time.Sleep(time.Second)
}
}</code></pre>
<p>由于并发两次,所以要向 wg.Add() 方法传入 2。程序运行结果为:</p>
<p>> 第 1 次运行
>
> 第 1 次运行
>
> 第 2 次运行
>
> 第 2 次运行
>
> 第 3 次运行
>
> 第 3 次运行
>
> 程序运行结束</p>
<p>在 Go 语言中开启 Goroutine,还可以<strong>通过匿名函数的方式,当代码中只发生一次调用时特别方便</strong>。比如:</p>
<pre><code>func main() {
wg.Add(1)
go func() {
defer wg.Done()
for i := 1; i &lt;= 3; i++ {
fmt.Printf(&quot;第%d次运行\n&quot;, i)
time.Sleep(time.Second)
}
}()
wg.Wait()
fmt.Println(&quot;程序运行结束&quot;)
}</code></pre>
<p>这段代码依然会输出:</p>
<p>> 第 1 次运行
>
> 第 2 次运行
>
> 第 3 次运行
>
> 程序运行结束</p>
<p>细心的朋友会发现,在 testFunc() 函数体中,<strong>使用 defer 执行 wg.Done()。如此是为了保证即使在执行函数体时发生错误,goRoutineWait.Done() 方法也依然会被调用,从而保证main() 函数的正常运行。</strong> 在某种角度上说,这是一种“舍车保帅”的做法。这一点在使用并发时同样需要注意。</p>
<p>作为“初探”,本讲内容就先介绍到这里,是不是感觉在 Go 语言中调度任务非常简单呢?</p>