如果去网上搜索:“Go 语言为 而生”,很大概率会看到“并发”、“云”等等关联词。没错,Go 语言的出发点即是瞄准大数据云计算时代背景下的高并发、分布式应用场景的。

目前服务端的发展趋势集中在容器化、分布式和微服务方面,Go 语言在这些领域已经大显身手。容器领域中,Docker 和 Kubernets(K8S)是由 Go 语言实现的;分布式领域中,Etcd、Fleet、InfluxDB 这类新型分布式数据库也是由 Go 语言编写的;微服务方面,字节跳动旗下的今日头条产品使用 Go 语言构建了超过 100 个微服务。据统计,其高峰 QPS 超过 700 万,日处理请求量超过 3000 亿。

从本讲开始,我将详细介绍 Go 语言中的并发。都说 Go 语言的并发简单易学,本讲就来带着大家一起体会其中的奥秘,具体内容包括:

  • 基本概念
  • 并发任务的启动;
  • 使用并发的注意事项。

# 基本概念

我们先从一些基本的概念谈起。

# 并发与并行

提到并发,就不得不说说并行。很多人搞不清它们是什么,甚至将其混为一谈,是完全错误的。

想象这样一个场景:我们同时执行 4 个任务,假设这些任务运行在配备了 4 核 CPU 的电脑上。现在,用图表来描述计算机的运行情况,横轴表示时间,纵轴表示每个 CPU 核心,不同颜色的色块表示不同的任务在执行。具体如下:

并发VS并行

从图中可以看出,并发是针对某个 CPU 核心而言的,利用切换 CPU 的时间片来实现多个程序同时运行。切换时间片的过程通常非常迅速,我们是无法察觉的。

并行则是将 4 个任务真正地分配给 4 个CPU核心执行。这就是并发和并行的区别,二者虽有关系,但调度机制完全不同并发更关注单核心的能力,并行更关注同时做的事情

❗️ 注意:从图上看,并行似乎比并发效率更高。在某些情况下确实如此,但如果遇到任务间相互通信、彼此依赖的情况就不一定了。所以用并行还是并发,要看代码的具体逻辑而定。

# 协程与线程

相信大家对“线程”一词更为熟悉,尤其是具有 Java 基础的朋友。在 Java 中,若要创建一个线程需要斟酌再三。这是因为线程是操作系统的资源,它的创建、切换、停止等等都属于操作系统操作,比较“重”

协程看上去和线程类似,但协程是在用户层面的,它的创建、切换、停止等等由用户操作,更“轻”

线程能充分发挥多核 CPU 的优势,可以做到并行执行多任务。协程则不然,协程是为并发而生的,一个线程上可以跑多个协程。

Go 语言中的并发是靠协程来实现的。在后端服务器软件开发中,有大量的 IO 密集操作,这正是协程最适合的场景。这也正是 Go 语言更适合高并发场景的原因。

💡 提示: Go 语言的任务调度模型被称为 GPM,我将在下一讲详述GPM模型架构及原理。

# 并发任务的启动

在 Go 语言中启动并发任务非常简单,只需要在相应的语句前面加上 go 即可。来看下面这段代码:

go
复制代码func main() {
   // 并发调用testFunc()
   go testFunc()
   time.Sleep(time.Second * 5)
   fmt.Println("程序运行结束")
}
// 并发测试函数
func testFunc() {
   for i := 1; i <= 3; i++ {
      fmt.Printf("第%d次运行\n", i)
      time.Sleep(time.Second)
   }
}

在 testFunc() 函数中调用了 time.Sleep() 函数,time.Sleep() 的作用是让当前协程暂停特定的时间。所以整个testFunc() 函数的目的就是每隔1秒执行1次循环体中的代码,总共执行 3 次,共计耗时 3 秒。main() 函数中在调用 testFunc() 函数时前面加了 “go ”,表示创建一个 Goroutine,在另一个协程中执行 testFunc()。程序运行结果为:

第 1 次运行

第 2 次运行

第 3 次运行

程序运行结束

为什么 main() 函数中要等待 5 秒呢?这是因为 testFunc() 函数需要至少 3 秒才能完成,由于 testFunc() 在另一个协程中,并不会影响 main() 函数体中后续代码的执行。因此main() 函数将迅速完成,整个程序便宣告终止了。

一旦程序终止,所有在 main() 函数中启动的 Goroutine 也会随之终止,我们便看不到其它协程中的输出了。所以要给 testFunc() 预留足够多的时长,等待它完成执行。这是使用并发时特别需要注意的一点。

然而,在实际开发中,我们通常无法确切地得知一个协程的准确执行时长。况且像上述代码中,过长的等待时间将会导致程序运行效率的降低。Go 语言提供了一种特别方便的方式确保执行协程任务的完整性,它来自 sync 包。下面的代码演示了它的使用方法:

go
复制代码var goRoutineWait sync.WaitGroup
func main() {
   goRoutineWait.Add(1)
   // 并发调用testFunc()
   go testFunc()
   goRoutineWait.Wait()
   fmt.Println("程序运行结束")
}
// 并发测试函数
func testFunc() {
   defer goRoutineWait.Done()
   for i := 1; i <= 3; i++ {
      fmt.Printf("第%d次运行\n", i)
      time.Sleep(time.Second)
   }
}

这段代码中,声明了 sync.WaitGroup 类型的变量goRoutineWait。main() 函数体一上来调用了goRoutineWait.Add() 方法,并向其中传入 1。表示即将开启 1 个 Goroutine。紧接着便是启动 Goroutine 了。最后执行了 goRoutineWait.Wait() 方法,该方法将告知程序在此处等待协程任务的完成。在 testFunc() 函数体中,末尾调用了goRoutineWait.Done() 方法,表示协程任务执行完成。

运行这段代码,控制台将得到同样的输出,但不会傻傻地等待 5 秒了。

💡 提示:从源码中,有一个 Goroutine 计数器。每次调用 goRoutineWait.Add() 方法时,传入的参数便作为累加值使用;调用 goRoutineWait.Done() 方法时相当于让计数器自减 1。当计数器归 0 时,goRoutineWait.Wait() 方法才会结束。

接下来上升一点难度,如果要连续并发两次 testFunc() 任务,该如何修改上述代码呢?

答案是:

go
复制代码var goRoutineWait sync.WaitGroup
func main() {
   // Goroutine计数器增2
   goRoutineWait.Add(2)
   // 第一次并发调用testFunc()
   go testFunc()
   // 第二次并发调用testFunc()
   go testFunc()
   goRoutineWait.Wait()
   fmt.Println("程序运行结束")
}
// 并发测试函数
func testFunc() {
   defer goRoutineWait.Done()
   for i := 1; i <= 3; i++ {
      fmt.Printf("第%d次运行\n", i)
      time.Sleep(time.Second)
   }
}

由于并发两次,所以要向 goRoutineWait.Add() 方法传入 2。程序运行结果为:

第 1 次运行

第 1 次运行

第 2 次运行

第 2 次运行

第 3 次运行

第 3 次运行

程序运行结束

在 Go 语言中开启 Goroutine,还可以通过匿名函数的方式,当代码中只发生一次调用时特别方便。比如:

go
复制代码func main() {
   goRoutineWait.Add(1)
   go func() {
      defer goRoutineWait.Done()
      for i := 1; i <= 3; i++ {
         fmt.Printf("第%d次运行\n", i)
         time.Sleep(time.Second)
      }
   }()
   goRoutineWait.Wait()
   fmt.Println("程序运行结束")
}

这段代码依然会输出:

第 1 次运行

第 2 次运行

第 3 次运行

程序运行结束

细心的朋友会发现,在 testFunc() 函数体中,使用 defer 执行 goRoutineWait.Done()。如此是为了保证即使在执行函数体时发生错误,goRoutineWait.Done() 方法也依然会被调用,从而保证main() 函数的正常运行。 在某种角度上说,这是一种“舍车保帅”的做法。这一点在使用并发时同样需要注意。

作为“初探”,本讲内容就先介绍到这里,是不是感觉在 Go 语言中调度任务非常简单呢?

# 小结

🎉 恭喜,您完成了本次课程的学习!

📌 以下是本次课程的重点内容总结:

1.基本概念; 2.并发任务的启动; 3.使用并发的注意事项。

本讲首先回顾了四个易混淆的概念,包括并发、并行、协程、线程。简单地说,并发和并行都和执行多任务有关,前者采用切换 CPU 时间片的形式,后者利用多核心 CPU 的形式,二者各有优势。协程是轻量级的线程,协程是用户层面的,线程是操作系统层面的。一个线程可以承载多个协程任务。

使用 Go 语言开启 Goroutine(即协程任务)非常简单,只需在语句前添加 “go ” 即可。同时,为了确保协程任务执行的完整性,可使用 sync.WaitGroup,在某个需要等待地方等候协程任务的完成。此外,执行协程任务时,最好采用断言(defer)来调用goRoutineWait.Done() 方法,确保协程任务的错误不会影响到整个线程的正常运行。

➡️ 在下次课程中,我们会介绍如下内容:

  • Go 语言中高并发原理解密——GPM模型