Go 调度器

454 阅读19分钟

Go 调度器(scheduler)

[TOC]


原文:

  1. Scheduling In Go : Part I - OS Scheduler
  2. Scheduling In Go : Part II - Go Scheduler
  3. Scheduling In Go : Part III - Concurrency

Go 调度器OS 调度器有着非常紧密的关系。

首先我们先看看 OS 调度器(scheduler)

OS 调度器(scheduler)


程序是计算机指令集合,操作系统线程(内核线程)负责顺序执行分配给自己的计算机指令

这里所讲的计算机指令就是线程所要执行的任务


操作系统进程与线程知识

线程分两类:用户线程和操作系统线程(内核线程)

  1. 用户线程属于用户空间,是由用户级语言函数库创建的线程;
  2. 操作系统无法感知用户线程的存在,用户线程的调度是由用户级语言线程库(通常是语言级运行时)负责调度;
  3. 不同语言支持的用户线程的调度算法实现不相同;
  4. 操作系统线程即内核线程是由操作系统调度器负责调度管理的;

用户线程和操作系统线程(内核线程)之间的实现模型

  • 多对一:多个用户线程对应一个内核线程
  • 一对一:一个用户线程对应一个内核线程(例如Rust语言支持的线程模型实现)
  • 多对多:M个用户线程对应N个内核线程(Golang语言支持的线程模型实现)

每个运行程序都创建一个进程,而每个进程最初都给定了一个初始线程,线程具有创建更多线程的能力(线程孪生),所有不同的线程相互独立运行。

内核线程进程组成单元进程PCB负责资源管理,内核线程负责任务执行;

内核线程TCB独享共享所属进程资源如:寄存器、内存、进程被分配的CPU执行时间片等;

内核线程 = 进程 - 共享资源

内核线程创建成本远远低于进程创建成本;

内核线程切换成本远远低于进程切换成本

由于同一个进程下的内核线程同属一个空间,所以内核线程间数据传递效率更高;


线程调度并不是在进程级上、而是线程级上实施的。

  • 同一个进程下的每个线程都可以并发执行,对于单核CPU,线程轮流执行;
  • 对于多核CPU,同一个进程下的多个线程可以在不同CPU上并行执行。
  • 每个线程具有三种基本状态:就绪态、运行态、阻塞态

每个线程需要维护自身状态以便安全、本地(local)、独立执行线程自身指令(即线程任务)。

**OS 调度器(scheduler)**的作用是确保:

  1. 一旦有处于就绪态线程,CPU不会空闲。
  2. OS 调度器(scheduler)还需要有这种能力:保证所有就绪态线程能够同时被执行。

为此,OS 调度器(scheduler)需要使用线程优先权来调度不同线程的执行,尽管优先权的线程有饿死的可能(线程的任务指令无法被CPU执行),**OS 调度器(scheduler)**仍然需要尽最大可能具备最小调度冗余、实现快速、智能调度。

这就要求OS 调度器(scheduler)必须具有合理高效的算法来实现线程调度,很幸运的是行业数十年的工作和经验也有助于线程调度的实现。

为了更好理解OS 调度器(scheduler),以下几个概念尤为重要。


Executing Instructions

program counter (PC)也叫指令指针 (IP)或程序计数器,其作用是允许线程跟踪下一个将要执行的指令,对于绝大多数处理器而言,指令指针指向的是下一条将要执行的指令(并非当前执行指令)。

Figure 1

img
www.slideshare.net/JohnCutajar…

Go程序执行中,你可能注意到过堆栈跟踪信息

你会发现在Listing 1所示的堆栈跟踪信息行的末尾都有一个十六进制数值,例如 +0x39+0x72

Listing 1

goroutine 1 [running]:
   main.example(0xc000042748, 0x2, 0x4, 0x106abae, 0x5, 0xa)
       stack_trace/example1/example1.go:13 +0x39                 <- LOOK HERE
   main.main()
       stack_trace/example1/example1.go:8 +0x72                  <- LOOK HERE

这些数值代表的是从函数顶部开始偏离的指令指针

  1. 指令指针 +0x39 代表执行example 函数的线程所要执行的下一条指令。
  2. 指令指针 0+x72 代表example 函数执行完成后,main 函数将要执行的下一条指令。
  3. 该指针之前的指令告诉您当前执行的是什么指令。

Listing 2 中的代码运行产生了 Listing 1 所展示的堆栈跟踪信息

Listing 2

https://github.com/ardanlabs/gotraining/blob/master/topics/go/profiling/stack_trace/example1/example1.go

07 func main() {
08     example(make([]string, 2, 4), "hello", 10)
09 }

12 func example(slice []string, str string, i int) {
13    panic("Want stack trace")
14 }

十六进制的 +0x39 代表: the PC offset for an instruction inside the example function which is 57 (base 10) bytes below the starting instruction for the function. In Listing 3 below, you can see an objdump of the example function from the binary. Find the 12th instruction, which is listed at the bottom. Notice the line of code above that instruction is the call to panic.

Listing 3

$ go tool objdump -S -s "main.example" ./example1
TEXT main.example(SB) stack_trace/example1/example1.go
func example(slice []string, str string, i int) {
  0x104dfa0		65488b0c2530000000	MOVQ GS:0x30, CX
  0x104dfa9		483b6110		CMPQ 0x10(CX), SP
  0x104dfad		762c			JBE 0x104dfdb
  0x104dfaf		4883ec18		SUBQ $0x18, SP
  0x104dfb3		48896c2410		MOVQ BP, 0x10(SP)
  0x104dfb8		488d6c2410		LEAQ 0x10(SP), BP
	panic("Want stack trace")
  0x104dfbd		488d059ca20000	LEAQ runtime.types+41504(SB), AX
  0x104dfc4		48890424		MOVQ AX, 0(SP)
  0x104dfc8		488d05a1870200	LEAQ main.statictmp_0(SB), AX
  0x104dfcf		4889442408		MOVQ AX, 0x8(SP)
  0x104dfd4		e8c735fdff		CALL runtime.gopanic(SB)
  0x104dfd9		0f0b			UD2              <--- LOOK HERE PC(+0x39)

Remember: the PC is the next instruction, not the current one. Listing 3 is a good example of the amd64 based instructions that the Thread for this Go program is in charge of executing sequentially.


线程状态

线程状态对于OS调度器是非常重要的,任何线程有三个状态:阻塞态、就绪态、执行态

  1. 阻塞态(等待态): This means the Thread is stopped and waiting for something in order to continue. This could be for reasons like, waiting for the hardware (disk, network), the operating system (system calls) or synchronization calls (atomic, mutexes). These types of latencies are a root cause for bad performance.

    导致线程处于阻塞态通常执行三种操作时发生:

    • 硬件磁盘操作或网络请求操作;
    • 系统调用;
    • 同步调用(原子操作、互斥操作);
  2. 就绪态(可执行态): This means the Thread wants time on a core so it can execute its assigned machine instructions. If you have a lot of Threads that want time, then Threads have to wait longer to get time. Also, the individual amount of time any given Thread gets is shortened, as more Threads compete for time. This type of scheduling latency can also be a cause of bad performance.

  3. 执行态: This means the Thread has been placed on a core and is executing its machine instructions. The work related to the application is getting done. This is what everyone wants.


线程任务类型

一个线程可以执行的工作类型分两种: CPU-BoundIO-Bound.

  1. CPU-Bound: 这种工作不会导致线程处于阻塞态, 这种任务通常是科学计算型任务
  2. IO-Bound: 这种任务会导致线程进入阻塞态,这类任务包括通过网络请求对资源访问或向操作系统发出系统调用。 执行数据库访问的线程任务类型就是 IO-Bound,这类任务包含同步事件(mutexes, atomic)、将导致线程等待调用结果。

上下文切换

对于 Linux、Mac 或 Windows系统,这些操作系统都是抢占型OS调度器(优先权)。

这意味着:

  1. 抢占型OS调度器在任何时候都无法预知哪个线程被执行或被转入等待状态线程优先级以及事件的发生(通过网络请求对资源的访问)使得无法预知抢先型OS调度器的下一步动作、以及什么时候会发生什么调度操作。
  2. 不能按照自己的假定和经验来编写代码、开发人员必须充分思考如何处理线程同步线程间协调

在内核上交换线程的物理行为称为上下文切换

OS调度器将一个执行态的线程从CPU退出、用另一个就绪态线程替换时,就会发生上下文切换

就绪队列(run queue)中选择的就绪态的线程进入执行态、占用CPU并开始执行;而退出的执行态的线程或是进入阻塞态或是进入就绪态

  1. 进入阻塞态的线程(IO-Bound类型线程)往往是因为需要等待网络IO请求结果。
  2. 进入就绪态的线程是因为这个线程说分配的CPU时间片使用完、进入执行态,等待下一次轮转执行。

上下文切换的成本通常是很高的,这是因为需要花费一定的线程CPU切换时间 。

决定线程CPU切换时间的因素很多,通常需要 ~1000 到 ~1500 纳秒的时间开销。

而一个CPU可执行的指令平均是 12 条指令/纳秒 ,这意味着一次上下文切换将消耗 ~12k 到 ~18k 条指令的执行时间!

由此可见上下文切换的成本对于程序执行性能的影响之大。

  1. 如果程序是执行IO-Bound 任务,那么上下文切换成为一个优势,因为这种场合下,可以充分发挥CPU执行指令的效率。
  2. 如果程序是执行CPU-Bound任务,那么上下文切换就成为制约CPU性能发挥的严重问题,因为计算型线程需要充分使用CPU,而上下文切换的损耗直接会影响到计算任务的执行效率。

Less Is More

早期的处理器都是单核CPU单核CPU在任何给定时间节点上只能执行一个线程,所以单核CPU的线程调度的实现并不复杂:

  1. 在一个调度周期中,保证所有可执行线程都可以有机会被执行。
  2. 具体做法是在一个调度周期中分别在不同时间段执行不同的可执行状态线程。

比如,一个调度周期是10毫秒(ms--milliseconds),你有两个线程,那么每个线程分配5毫秒;如果有5个线程,那么每个线程分配2毫秒。

如果有100个线程,那么该如何?每线程分配10微秒(μs--microseconds)这种做法是有问题的

因为上下文切换的成本开销就过大了。

每次上下文切换需要 ~1000 到 ~1500 纳秒的时间开销。

所以 OS调度器 必须对时间片最小值有必要的限制,

当时间片最小值是 2ms 、对于 100 个线程而言, OS调度器调度周期应该增大为 2000ms 即 2s (秒)。 假如是 1000 个线程,那么调度周期应该是 20s。

要知道,我们举例所讲的只是一个非常简单的调度场景, OS调度器的实际可能的调度场景要复杂的多。

当线程数更多、线程任务类型是IO-Bound的时候,就更加复杂和更加不确定!

OS调度器调度决策必然要复杂很多。

Less is More

尽量少的就绪态线程意味着更少的调度开销、同时每个线程得到更多CPU执行时间。

过多的就绪态线程必然意味着每个线程得到的CPU执行时间必然更少(单位时间内任务完成量更少)。


寻找平衡

为了使得应用程序具有最佳的吞吐性能,在CPU内核数量和线程数量之间需要找到均衡点。

线程池是平衡管理的好方案,但是Go语言中并没有线程池

Go语言中不需要线程池是Go一个非常特色之处,这使得Go的多线程开发变得容易得多。

在 Go之前,使用 C++ 和 C# 的时候,多线程开发中使用IOCP (IO Completion Ports) 线程池是非常关键的技术,必须提前规划需要多少个线程池以及每个线程池中最大容纳的线程数量。

当实现访问数据库的 web 服务时,在NT环境下**三个线程/CPU core**通常是具有最好的吞吐能力。

**三个线程/CPU core**使得上下文切换冗余最小的同时线程获得最大的CPU执行时间。

在NT服务器主机上创建 IOCP Thread pool时,最小一个线程、最大三个线程/CPU core是证明可行的配置。

但是,如果web服务是各种不同类型任务,问题就变得复杂了,很难说可以找到理想的线程池配置参数

高速缓存行(Cache Lines)

处理器和CPU内核都有本地高速缓存(L1、L2、L3缓存),线程上位CPU执行前需要将主存中的相关数据加载本地高速缓存需要产生大约100 ~ 300 个时钟周期的冗余成本。

硬件线程和软件线程

软件线程是由操作系统执行、管理的线程;

硬件线程是处理器特有、便于更好发挥处理器性能的线程,处理器的硬件线程暴露给操作系统或由有操作系统暴露出来;

在Java中,Java线程软件线程的抽象,对于Java线程而言,它的操作系统是虚拟机(JVM),虚拟机负责Java线程与操作系统线程的映射,如果硬件环境(处理器)有硬件线程,那么操作系统可以使用硬件线程

CPU访问本地高速缓存数据的成本就很低了,大约是3 ~40 个时钟周期

有效获得处理器需要的数据是性能的重要考量。

降低数据访问的冗余量非常重要,多线程应用中对线程做状态转换管理时,必须考虑到缓存系统机制


缓存一致性问题

Figure 2

img

处理器和主存之间数据交换是**通过高速缓存行(Cache Lines)**完成的,高速缓存行(Cache Lines)是64字节的内存块,每个内核都有自己所需的高速缓存数据副本,这意味着硬件使用了值语义 (value semantics)。

所以在多线程应用中,值变会产生性能噩梦( performance nightmares)。

当多个线程基于同一份数据之上并行执行时,不同线程使用的是同一个高速缓存中的数据副本!

Figure 3

img

这被称作 缓存一致性问题 cache-coherency problem 也叫假共享(false sharing)

多线程应用中一旦共享数据发生变化,高速缓存系统必须做相应的应对。

执行数据同步导致处理器和主存之间数据交换的发生,注意:产生大约100 ~ 300 个时钟周期的交换成本。

调度场景举例

在讨论完上述几个重要概念之后,现在假定:

我们的程序所创建主线程是在处理器内核1上执行,当主线程开始执行自己的指令,那么就需要通过高速缓存获得数据,此时主线程就需要决定创建一个新线程执行某些并发任务

一旦创建了新线程,这就需要OS 调度器介入:

  1. 那么我们是立即把主线程从处理器内核1上切换下来执行新线程?这样做可以提高性能,因为新线程需要相同的已缓存数据的可能性非常大。但是主线程还没有用完分配给它的CPU时间片。
  2. 那么我们的这个新线程等待主线程彻底使用完它的全部时间片?那么新线程一旦开始执行,由于可以继续使用处理器内核1高速缓存行中的数据,所以新线程的执行可以避免延迟。
  3. 或者,我们让新线程等待获得处理器另一个可用内核2?这将意味着处理器内核2的高速缓存需要刷新、重新加载主存数据,这必然导致延迟。虽然是这样,但是主线程可以彻底用完自己的时间片、而且新线程的执行速度会更快。

好玩吧?这就是OS 调度器需要做出的抉择。

理想调度

我们的理想是:只要有空闲CPU,就应该使用它,我们希望任何线程在可以执行的时候都能够被执行

总结

本章节讲解内容:在多线程应用时,我们应该考虑到的几个要素。

  1. 执行指令(PC);
  2. 线程状态(就绪态、运行态、阻塞态);
  3. 线程任务类型(CPU-BoundIO-Bound);
  4. 上下文切换(Content-switching);
  5. less is more;
  6. 高速缓存;
  7. 调度策略;

:bell:Golang语言提供run-time(运行时)

Golang语言支持的线程模型实现:多对多:M个用户线程对应N个内核线程

Golang语言提供run-time(运行时)中自带Go调度器

本章节中讲述的概念同样是Go调度器需要考虑的关键点。

下个章节我们将讨论Go调度器是如何和这些概念结合的,最终通过运行几个程序,我们将看到所有这些操作。

Go 协程调度器


现在我们开始从语义层面来解释 Go 调度器的工作过程以及其高级行为。

Go 调度器是一个复杂系统,我们不需要过于关注其机械性小细节。

一个好模型关注的重点在于这个模型如何工作以及其行为方式,以便我们做出更好的工程决策(engineering decisions)

程序启动


如果多核处理器的物理内核支持硬件线程(Hyper-Threading物理内核),那么每个硬件线程可以作为一个虚拟内核供Go 程序使用。

Go 程序启动时,一个虚拟内核所对应的一个**逻辑处理器Logicl Processor(以下我们简称P)**被分配给这个程序。

为了便于理解,我们参考 以下MacBook Pro所提供的硬件系统信息

Figure 1

img

这个MacBook Pro带一个四核处理器,但是没有说明物理内核所暴露的硬件线程数量。

实际上 Intel Core i7 处理器提供硬件线程 (Hyper-Threading)支持:每个物理内核有两个硬件线程

那么对于运行在这台电脑上的 Go 程序而言,可以使用8个逻辑处理器(虚拟内核)

这里我们把 OS Thread 简称为"M"、逻辑处理器(虚拟内核) 简称为"P"。

可以通过下面程序来验证:

创建目录:go-test

ww@ww-Z87X-UD3H:~/myGoProject/go-test$ go version
go version go1.14.2 linux/amd64
ww@ww-Z87X-UD3H:~/myGoProject/go-test$ go mod init go-test
go: creating new go.mod: module go-test
ww@ww-Z87X-UD3H:~/myGoProject/go-test$ ls
go.mod
ww@ww-Z87X-UD3H:~/myGoProject/go-test$ touch main.go
ww@ww-Z87X-UD3H:~/myGoProject/go-test$ ls
go.mod  main.go
ww@ww-Z87X-UD3H:~/myGoProject/go-test$ 

Listing 1

package main

import (
	"fmt"
	"runtime"
)

func main() {

    // NumCPU returns the number of logical
    // CPUs usable by the current process.
    fmt.Println(runtime.NumCPU())
}

输出

~/myGoProject/go-test$ go run main.go
8

每个虚拟内核(P)分配一个OS线程(M),这意味着目前我们的Go程序可以使用八个OS线程(M)。

  1. 操作系统负责把OS线程(M)的管理。
  2. OS线程(M)的上下文切换(on/off 在物理内核上执行/退出)是受操作系统控制。

每个Go程序启动时给定一个初始Goroutine (简称"G")负责程序的执行。

  1. Goroutine的本质是协程 (Coroutine ),在Go语言中协程被称作Goroutine

  2. 可以认为Go协程(Goroutine)应用级线程,很多方面与**OS线程(M)**很类似。

  3. OS线程(M)有上下文切换(CPU执行/退出CPU执行),Go协程(Goroutine) 同样有上下文切换(逻辑处理器执行/从逻辑处理器上退出执行)。

Go语言中的执行队列比较迷惑人, Go 调度器中有两类执行队列

  1. 全局执行队列:the Global Run Queue (GRQ)
  2. 局域执行队列:the Local Run Queue (LRQ).

每个逻辑处理器(虚拟内核)有一个局域执行队列(LRQ),局域执行队列(LRQ)中的**Go协程(Goroutine)只在当前逻辑处理器(虚拟内核)**上切换执行。

所有尚未分配给某个逻辑处理器(虚拟内核)Go协程(Goroutine)放置在全局执行队列(GRQ)中。

:bell:有一个进程专门负责把全局执行队列(GRQ)中的Go协程(Goroutine)移入某个局域执行队列(LRQ),稍后讲解。

Figure 2 provides an image of all these components together.

Figure 2

img

:bell:OS Thread 简称"M"、逻辑处理器(Logicl Processor) 简称"P"

协程调度器


之前我们说过OS调度器抢占型调度器(preemptive scheduler)。

本质上OS调度器下一步做什么是无法预判的。

除非程序使用同步原语(如atomic 指令和 mutex 调用),运行在操作系统之上的程序对于系统内核调度所发生的事情是无法控制的。

Go 调度器是Go runtime的组成部分之一, Go runtime 内置于Go程序中。

这就意味着Go 调度器处于用户空间中,位于操作系统内核之上

Go 调度器不是抢占型调度器,而是一个**协程调度器**。

作为一个**协程调度器**,Go 调度器要求定义用户空间事件、以便在代码中的安全点位执行调度抉择

:loudspeaker:所以准确说 Go调度器GO协程调度器

Go 调度器最为出众之处就是作为一个**协程型调度器但具有抢占型调度器**的特点。

Go 调度器的调度动作与OS调度器一样是不可预知的。

这是因为Go 协程调度器的调度权不受开发者掌控,而是受Go运行时控制!

:bell:对于Go 协程调度器,我们需要有一个重要认识

由于Go 协程调度器是不可预判的(non-deterministic),我们需要把它当做一个抢占型调度器看待。

Go协程(Goroutine) 状态


OS线程类似,**Go协程(Goroutine)**同样有三个状态。

这三种状态决定了Go 协程调度器在任何给定**Go协程(Goroutine)**中所扮演的角色。

一个 Go协程(Goroutine) 有以下三种状态

  1. Waiting: 意味着 Go协程(Goroutine) 处于停止等待状态,这经常发生在由于操作系统调用或是同步调用的时候。这类等待也往往是性能不佳的根源。

  2. Runnable: 意味着Go协程(Goroutine) 可以交由OS 线程 (简称"M")执行。如果有很多个Go协程(Goroutine) 处于可执行状态时,那么每个Go协程就需要等待更长时间。

    随着多个Go协程(Goroutine) 争夺CPU执行时间片,每个的Go协程(Goroutine) 得到的CPU执行时间片都会缩短!这会导致整体性能下降。

  3. Executing: 意味着Go协程(Goroutine) 已经交由某个OS Thread (简称"M")、处于执行中。

上下文切换


Go 调度器要求定义用户空间事件、以便在代码中的安全点位执行上下文切换

调度的本质就是执行上下文切换

函数调用Go调度器的健康状况至关重要。

对于 Go 1.11版本或更低版本,如果代码中执行任何紧凑循环(tight loops即不执行函数调用的循环),将导致调度器和垃圾收集中的延迟 。在合理的时间框架内进行函数调用是非常重要的!

:bell:: 针对Go1.12版本的一个提案( proposal ) 已被采纳:在Go调度器中应用"非协作抢占技术",以允许抢占紧凑循环(tight loops)

在Go开发中有四类事件可供Go调度器抉择(scheduling decisions)。

但这四类事件并不代表Go调度器一定会做出相应的调度动作,而是给Go调度器提供执行调度的机会。

  1. 使用关键字 go
  2. 垃圾回收(Garbage collection)
  3. 系统调用(System calls)
  4. 同步处理(Synchronization and Orchestration)
使用关键字 go

使用关键字 go 可以创建Go协程,一旦创建一个新Go协程,就给了Go调度器做出调度动作(上下文切换)的可能。

垃圾回收(GC)

垃圾回收(GC)是通过一组Go协程完成的。这一组Go协程需要借助一个OS 线程来执行完成GC任务。垃圾回收(GC)的执行会产生一系列复杂上下文切换,垃圾回收(GC)Go协程组需要在管理堆空间的Go协程和不涉及堆空间管理的Go协程之间切换。

:bell:不用担心其中的具体细节,Go调度器完全有能力处理好这个过程。

系统调用

如果某个Go协程执行了系统调用,那么将会导致执行这个Go协程的OS线程阻塞。

  1. 有时Go调度器会切换执行另一个Go协程、在当前OS线程上执行。这是出于不让处理器空闲、提高CPU利用效率的目的。
  2. 有时Go调度器会提取当前逻辑处理器执行队列中的一个Go协程、交由另一个新的OS 线程执行。

具体细节下面讲解。

同步处理

如果在Go协程中发生原子、互斥、通道这三类操作的调用时,将导致当前Go协程执行阻塞。

  1. 当出现这种场景,Go调度器会切换执行另一个Go协程
  2. 当被阻塞的Go协程不再被阻塞时,该Go协程重新进入执行队列中、然后在某个合适节点,这个Go协程重新切换到OS线程上继续执行。

异步系统调用


如果你所使用的OS 支持处理异步系统调用,通常,通过 网络轮询器( network poller) 处理异步系统调用更为有效。

在不同操作系统上,都有网络轮询器( network poller) 的实现,例如:kqueue (MacOS), epoll (Linux) or iocp (Windows) 。

当前很多操作系统都支持异步网络系统调用

  1. 网络轮询器( network poller) 的主要用途就是处理网络操作。
  2. 通过网络轮询器( network poller)Go调度器可以避免Go协程在线程上执行系统调用时发生阻塞。
  3. 借助网络轮询器( network poller) ,使得当前OS线程(M)可以继续用于执行该OS线程(M)所对应的逻辑处理器(P)本地执行队列(LRQ )中的其他Go协程。 这就避免了创建新的**OS线程(M)**的必要。
  4. 使用网络轮询器( network poller) 可以降低OS调度器调度负荷

The best way to see how this works is to run through an example.

:bell:OS Thread 简称"M"、逻辑处理器(Logicl Processor) 简称"P"、core--物理内核

Figure 3

img

Figure 3 shows our base scheduling diagram. Goroutine-1 is executing on the M and there are 3 more Goroutines waiting in the LRQ to get their time on the M.

图3中 **网络轮询器( network poller)**处于空闲状态

Figure 4

img

In figure 4: Goroutine-1 执行网络系统调用时,Goroutine-1将被移入网络轮询器( network poller)、执行异步网络调用;一旦 Goroutine-1 被移入到 network poller中,当前**OS线程(M)就可以从逻辑处理器(P)协程执行队列(LRQ)中提取另一个 Goroutine、随即 Goroutine-2 被切换至当前OS线程(M)**上执行。

Figure 5

img

In figure 5 当Goroutine-1的异步网络调用通过网络轮询器( network poller)执行完成, Goroutine-1进入到当前逻辑处理器(P)协程执行队列中,当 Goroutine-1 可以被再次切换到当前**OS线程(M)**上时, Goroutine-1 的 Go 代码就可以继续执行。

这种调度处理方式最大好处

Go协程(Goroutine)出现网络系统调用操作时,并不需要启动更多的OS线程,网络轮询器( network poller) 本身就使用一个 OS 线程、负责处理有效事件循环。

同步系统调用

那么在Go协程中执行同步系统调用时,会发生什么?

这种场合下网络轮询器( network poller) 不会被使用了,Go协程将会阻塞当前OS线程的执行,很遗憾没有办法解决这个问题。

不能支持异步调用的场景有文件系统调用

:bell::Windows OS 上支持文件系统的异步调用,所以在Windows OS 上是可以使用 network poller 。

让我们通过图示看看在Go协程中执行文件IO操作(同步系统调用)时是如何发生OS线程阻塞的。

:bell:OS Thread 简称"M"、逻辑处理器(Logicl Processor) 简称"P"、core--物理内核

Figure 6

img

Figure 6: Goroutine-1执行同步系统调用将阻塞当前OS 线程( M1).

Figure 7

img

In figure 7:Go调度器 可以发现 Goroutine-1 引起了当前OS线程执行处于阻塞状态,此时,Go调度器会把OS线程(M1) 连同阻塞的Go协程(Goroutine-1)一起从**逻辑处理器(P)**上分离出去。

逻辑处理器(P)被引入一个新OS线程(M2),然后Go协程(Goroutine-2)从**执行队列(LRQ)**中提取出来交由 **OS线程(M2)**执行。

If an M already exists because of a previous swap, this transition is quicker than having to create a new M.

Figure 8

img

In figure 8:一旦Go协程(Goroutine-1)的阻塞系统调用执行完成,Go协程(Goroutine-1)重新回到逻辑处理器(P)执行队列中,OS线程 (M1)可以继续被使用。

任务窃取(Work Stealing)

Go调度器还具备工作窃取的特征 。

任务窃取(Work Stealing)在几种场景下可以提高调度效率

比如,一个OS线程 (M)需要进入等待状态,这时候OS调度器将这个OS线程 (M)切换离开物理内核,这意味着逻辑处理器(P)无事可做。如果此时有一个Go协程处于可执行状态,在OS线程 (M)切换回到一个物理内核之前,工作窃取可以让所有Go协程均衡分布在**所有的逻辑处理器(P)**上,这使得工作任务的分布更均衡,执行效率更好。

具体我们看图 9

:bell:

OS Thread 简称"M"、逻辑处理器(Logicl Processor) 简称"P"、

core--物理内核、GRP--全局执行队列、LRQ--局域执行队列

Figure 9 两个逻辑处理器,分别服务四个Go协程、在全局执行队列中还有一个Go协程

img

In figure 9, we have a multi-threaded Go program with two P’s servicing four Goroutines each and a single Goroutine in the GRQ.

假如:逻辑处理器(P1)可以快速执行完成自己的四个Go协程,那么会发生什么?

Figure 10

img

In figure 10: P1是最后一个要执行的Go协程,这时候在P2的执行队列中剩余三个Go协程,而在全局执行队列中有一个Go协程。

这时候就遇到问题: P1 稍后就处于空闲状态,从哪里找(窃取)工作?

以下是Go官方运行时源码中关于 stealing work 工作过程描述.

Listing 2

runtime.schedule() {
    // only 1/61 of the time, check the global runnable queue for a G.
    // if not found, check the local queue.
    // if not found,
    //     try to steal from other Ps.
    //     if not, check the global runnable queue.
    //     if not found, poll network.
}

按照 Listing 2中的描述, 逻辑处理器P1首先去检查逻辑处理器P2局域执行队列(LRQ)、看看是否有 Goroutines 、如果有就窃取一半协程给自己。

Figure 11

img

In figure 11: 现在两个逻辑处理器(P1、P2)各有一半的Go协程可以执行。

那么,假如逻辑处理器P2执行完所有自己的Go协程,而且这时候逻辑处理器P1的局域执行队列(LRQ)已经没有可执行Go协程,如图示12所示。

这时候如何处理?

Figure 12

img

In figure 12:逻辑处理器 P2完成了所有自己的作业任务,需要再找点活干。

但是从逻辑处理器 P1那边发现这时候局域执行队列(LRQ)中已经没有可干的活,于是就去全局执行队列(GRQ)看看、发现全局执行队列(GRQ)中 Goroutine-9的活可以干,于是逻辑处理器 P2继续当雷锋:smile:。

Figure 13

img

In figure 13, P2 steals Goroutine-9 from the GRQ and begins to execute the work.

**任务窃取(Work Stealing)的好处就是保持所有OS线程(处理器)**都有活干,避免闲置。

更多内容请参考: work-stealing 博客内容

实际案例

With the mechanics and semantics in place, I want to show you how all of this comes together to allow the Go scheduler to execute more work over time. Imagine a multi-threaded application written in C where the program is managing two OS Threads that are passing messages back and forth to each other.

Figure 14

img

In figure 14, there are 2 Threads that are passing a message back and forth. Thread 1 gets context-switched on Core 1 and is now executing, which allows Thread 1 to send its message to Thread 2.

Note: How the message is being passed is unimportant. What’s important is the state of the Threads as this orchestration proceeds.

Figure 15

img

In figure 15, once Thread 1 finishes sending the message, it now needs to wait for the response. This will cause Thread 1 to be context-switched off Core 1 and moved into a waiting state. Once Thread 2 is notified about the message, it moves into a runnable state. Now the OS can perform a context switch and get Thread 2 executing on a Core, which it happens to be Core 2. Next, Thread 2 processes the message and sends a new message back to Thread 1.

Figure 16

img

In figure 16, Threads context-switch once again as the message by Thread 2 is received by Thread 1. Now Thread 2 context-switches from an executing state to a waiting state and Thread 1 context-switches from a waiting state to a runnable state and finally back to an executing state, which allows it to process and send a new message back.

All these context switches and state changes require time to be performed which limits how fast the work can get done. With each context-switching potential incurring a latency of ~1000 nanoseconds, and hopefully the hardware executing 12 instructions per nanosecond, you are looking at 12k instructions, more or less, not executing during these context switches. Since these Threads are also bouncing between different Cores, the chances of incurring additional latency due to cache-line misses are also high.

Let’s take this same example but use Goroutines and the Go scheduler instead.

Figure 17

img

In figure 17, there are two Goroutines that are in orchestration with each other passing a message back and forth. G1 gets context-switched on M1, which happens to be running on Core 1, which allows G1 to be executing its work. The work is for G1 to send its message to G2.

Figure 18

img

In figure 18, once G1 finishes sending the message, it now needs to wait for the response. This will cause G1 to be context-switched off M1 and moved into a waiting state. Once G2 is notified about the message, it moves into a runnable state. Now the Go scheduler can perform a context switch and get G2 executing on M1, which is still running on Core 1. Next, G2 processes the message and sends a new message back to G1.

Figure 19

img

In figure 19, things context-switch once again as the message sent by G2 is received by G1. Now G2 context-switches from an executing state to a waiting state and G1 context-switches from a waiting state to a runnable state and finally back to an executing state, which allows it to process and send a new message back.

Things on the surface don’t appear to be any different. All the same context switches and state changes are occuring whether you use Threads or Goroutines. However, there is a major difference between using Threads and Goroutines that might not be obvious at first glance.

In the case of using Goroutines, the same OS Thread and Core is being used for all the processing. This means that, from the OS’s perspective, the OS Thread never moves into a waiting state; not once. As a result all those instructions we lost to context switches when using Threads are not lost when using Goroutines.

Essentially, Go has turned IO/Blocking work into CPU-bound work at the OS level. Since all the context switching is happening at the application level, we don’t lose the same ~12k instructions (on average) per context switch that we were losing when using Threads. In Go, those same context switches are costing you ~200 nanoseconds or ~2.4k instructions. The scheduler is also helping with gains on cache-line efficiencies and NUMA. This is why we don’t need more Threads than we have virtual cores. In Go, it’s possible to get more work done, over time, because the Go scheduler attempts to use less Threads and do more on each Thread, which helps to reduce load on the OS and the hardware.

总结

The Go scheduler is really amazing in how the design takes into account the intricacies of how the OS and the hardware work. The ability to turn IO/Blocking work into CPU-bound work at the OS level is where we get a big win in leveraging more CPU capacity over time. This is why you don’t need more OS Threads than you have virtual cores. You can reasonably expect to get all of your work done (CPU and IO/Blocking bound) with just one OS Thread per virtual core. Doing so is possible for networking apps and other apps that don’t need system calls that block OS Threads.

As a developer, you still need to understand what your app is doing in terms of the kinds of work you are processing. You can’t create an unlimited number of Goroutines and expect amazing performance. Less is always more, but with the understanding of these Go-scheduler semantics, you can make better engineering decisions. In the next post, I will explore this idea of leveraging concurrency in conservative ways to gain better performance while still balancing the amount of complexity you may need to add to the code.

Go 并发


前言

解决具体业务需求,首先考虑顺序执行指令的方式来实现,然后再考虑是不是并发更适合。

有的时候并发适合,有的时候并发并不适合。

在第一章节(OS调度器)中我们解释了OS调度器的工作机制和语义。

在第二章节(Go调度器)中我们解释了Go协程调度器工作机制和相关概念以及语义。

本章,我们结合前两章节的内容、一起深入理解什么是并发

我们的目标:

  1. 提供必要指导,以确定任务是否适合使用并发方式完成。
  2. 阐述不同任务类型的区别、从而做出合理的工程决策。

什么是并发(Concurrency)

:loud_sound: Go语言的主要应用是指并发场景,是指后台高并发应用场景

并发(Concurrency) 意味着**无序(out of order)**执行指令。

  1. 对于一组原本顺序执行的指令,找到无序执行这组指令的方式、但仍然可以产生相同的结果。
  2. 并发(Concurrency) 是指一个处理器上的一个OS线程交替执行多组指令。

无序执行会带来复杂性的增加,但这种代价必须是能够换取必要的性能提升

但是,就具体应用场景而言,无序执行并非总是值得的,甚至未必是合理的。

另外,需要清楚: 并发(Concurrency)并非是并行(Parallelism)

什么是并行(Parallelism)

并行也是无序(out of order)执行指令。:bell:很多计算机语言都可以执行并行

  1. 并行并发是完全不同的概念。
  2. 并行表示:多个处理器上的多个OS线程同时执行多组指令。

并行前提条件

  1. 必须至少有两个OS线程和两个硬件线性(core)
  2. 同时有两个协程(我们在讲解Go,那么两个协程是指Go协程);
  3. 每个Go协程(Goroutines)分别在不同的OS线程硬件线性上独立、同时被执行;
并发 vs 并行

关于并发并行,我们用图一来解释两者的区别和联系

Figure 1 : Concurrency vs Parallelism

img

In figure 1:有两个逻辑处理器(P)分别有各自的OS线程(M)OS线程(M)分别关联着不同的硬件线程(Core)

  1. Go1协程Go2协程并行执行的,两组指令同时在不同处理器上执行。
  2. 每个OS线程(M)分别有三个Go协程、共用同一个OS线程并发执行(无序执行三个协程的任务指令)。

:bell: ​可以看到:并行和并发往往是同时发生的

有时候不使用并行并发确会导致性能问题。

但是有趣的是:通过并行实现并发有时候并不会产生想象中的性能提升。

任务类型(types of Workloads)

我们如何弄清楚什么时候并发(无序执行)是可能的或是合理的?

首先我们要弄清楚要做的任务类型是什么,这必须先弄清楚。

在讲解线程时,我们知道线程可以执行的任务类型分两种: CPU-BoundIO-Bound.

  1. CPU-Bound 类型任务:这种任务不需要通过多个Go协程切换执行来完成,这是因为这类任务纯属计算型任务,持续完成计算任务即可,本质上就不需要并发执行。因为并发执行计算任务没有任何实际意义和性能增益

    • 多个Go协程执行并行计算适用于CPU-Bound 类型任务
  2. IO-Bound 类型任务:这种任务需要多个Go协程参与完成,因为Go协程可以处于等待状态的同时让出逻辑处理器(P)以便其他Go协程继续执行。

    • 这类任务包括:网络资源请求执行对操作系统的系统调用或是等待某个事件的发生
    • Go协程非常适合执行IO-Bound类型任务,比如执行文件读取操作。
    • 等待同步事件(mutexes, atomic)也特别适合Go协程

对于CPU-Bound 类型任务,应该采取并行方式执行。

把一个CPU-Bound 类型任务分解到多个Go协程、然后借助一个OS线程/硬件线程来执行的做法不仅没有意义,而且由于多个Go协程需要切换执行所带来的上下文切换成本的增加,反而会降低CPU-Bound 类型任务的整体执行效率。

上下文切换不可避免带来"Stop The World",这意味着处理器任务计算过程的暂停。

对于IO-Bound 类型任务,应该采取并发方式执行。

把一个IO-Bound 类型任务分解到多个Go协程对于性能有很好的提升。

IO-Bound 类型任务本身就有延迟特征,多个Go协程切换延迟远低于IO-Bound 类型任务本身的延迟。

Go协程切换执行非常适合IO-Bound 类型任务、可以充分发挥同一个OS线程/硬件线程的执行效率。

那么一个OS线程/硬件线程应该配备多少个Go协程可以获得最佳吞吐效率?

  • 如果Go协程数量太少,会导致线程空闲;
  • 如果Go协程数量太多,会导致Go协程切换过多而产生过多切换冗余成本;

这是一个特别需要考虑的细节问题!

目前,我们先解决关键问题:

具体说什么类型的任务最好采取并行方式完成?什么类型问题最好应该采取并发方式完成?

我们通过代码举例来说明!

任务1:求和计算

我们从最简单的求和计算函数开始。

常规版 add 函数

Listing 1 play.golang.org/p/r9LdqUsEz…

36 func add(numbers []int) int {
37     var v int
38     for _, n := range numbers {
39         v += n
40     }
41     return v
42 }

Question: 这个函数适合无序(out of order)方式执行吗?

answer: yes.

无序执行分两种:并行并发

  1. 整数集合可以划分成更小的集合,然后并发执行求和,最终把多个结果合并得出同样的求和结果。
  2. 整数集合可以划分成更小的集合,然后并行执行求和,最终把多个结果合并得出同样的求和结果。

随之而来的问题:应该划分成几个集合执行并行计算,可以得到最佳吞吐效果?

回答这个问题需要知道这个函数执行的任务类型,求和是典型的计算型任务(CPU-Bound),所以需要多个Go协程切换执行。那么答案是:

每个OS线程硬件线性执行一个Go协程是最合适的。

并发版 add 函数

Listing 2

Note: There are several ways and options you can take when writing a concurrent version of add. Don’t get hung up on my particular implementation at this time. If you have a more readable version that performs the same or better I would love for you to share it.

Listing 2 play.golang.org/p/r9LdqUsEz… (完整源代码)

44 func addConcurrent(goroutines int, numbers []int) int {
45     var v int64
46     totalNumbers := len(numbers)
47     lastGoroutine := goroutines - 1
48     stride := totalNumbers / goroutines
49
50     var wg sync.WaitGroup
51     wg.Add(goroutines)
52
53     for g := 0; g < goroutines; g++ {
54         go func(g int) {
55             start := g * stride
56             end := start + stride
57             if g == lastGoroutine {
58                 end = totalNumbers
59             }
60
61             var lv int
62             for _, n := range numbers[start:end] {
63                 lv += n
64             }
65
66             atomic.AddInt64(&v, int64(lv))
67             wg.Done()
68         }(g)
69     }
70
71     wg.Wait()
72
73     return int(v)
74 }

这个并发版 add 函数用了26行代码,而之前的常规版 add 函数只有5行代码。

  1. Line 48: Each Goroutine will get their own unique but smaller list of numbers to add. The size of the list is calculated by taking the size of the collection and dividing it by the number of Goroutines.
  2. Line 53: The pool of Goroutines are created to perform the adding work.
  3. Line 57-59: The last Goroutine will add the remaining list of numbers which may be greater than the other Goroutines.
  4. Line 66: The sum of the smaller lists are summed together into a final sum.

并发版 add 函数 明显要比常规版 add 函数复杂,但是这值得吗?

要回答这个问题,我们需要做性能基准测试(benchmark测试)

我们使用一千万个整数来做性能基准测试(关闭GC的场合下)

Listing 3

func BenchmarkSequential(b *testing.B) {
    for i := 0; i < b.N; i++ {
        add(numbers)
    }
}

func BenchmarkConcurrent(b *testing.B) {
    for i := 0; i < b.N; i++ {
        addConcurrent(runtime.NumCPU(), numbers)
    }
}

Listing 3 shows the benchmark functions.

这时候我们让Go协程在一个 OS/hardware thread 上执行。

  1. 常规版 add 函数使用1个Go协程
  2. 并发版 add 函数使用使用8个Go协程

:bell: ​并发版 add 函数这时候是并发执行!

Listing 4

10 Million Numbers using 8 goroutines with 1 core
2.9 GHz Intel 4 Core i7
Concurrency WITHOUT Parallelism
-----------------------------------------------------------------------------
$ GOGC=off go test -cpu 1 -run none -bench . -benchtime 3s
goos: darwin
goarch: amd64
pkg: github.com/ardanlabs/gotraining/topics/go/testing/benchmarks/cpu-bound
BenchmarkSequential      	    1000	   5720764 ns/op : ~10% Faster
BenchmarkConcurrent      	    1000	   6387344 ns/op
BenchmarkSequentialAgain 	    1000	   5614666 ns/op : ~13% Faster
BenchmarkConcurrentAgain 	    1000	   6482612 ns/op

Note: Running a benchmark on your local machine is complicated. There are so many factors that can cause your benchmarks to be inaccurate. Make sure your machine is as idle as possible and run benchmarks a few times. You want to make sure you see consistency in the results. Having the benchmark run twice by the testing tool is giving this benchmark the most consistent results.

  1. 此时性能基准测试(benchmark测试)显示:常规版 add 函数的性能比并发版 add 函数要高 10-13%。
  2. 单线程场合下,并发版 add 函数由于上下文切换以及协程调度的额外开销导致实际性能的下降,测试结果同我们预期是一致的。

我们继续测试:

这次,每个Go协程在独立的 OS/hardware thread上执行!

常规版 add 函数其实就只有一个Go协程、而并发版 add 函数因为有8个Go协程 ,而测试机器有8个OS/hardware thread可以使用。

:bell: 并发版 add 函数 此时是并行执行!

Listing 5

10 Million Numbers using 8 goroutines with 8 cores
2.9 GHz Intel 4 Core i7
Concurrency WITH Parallelism
-----------------------------------------------------------------------------
$ GOGC=off go test -cpu 8 -run none -bench . -benchtime 3s
goos: darwin
goarch: amd64
pkg: github.com/ardanlabs/gotraining/topics/go/testing/benchmarks/cpu-bound
BenchmarkSequential-8        	    1000	   5910799 ns/op
BenchmarkConcurrent-8        	    2000	   3362643 ns/op : ~43% Faster
BenchmarkSequentialAgain-8   	    1000	   5933444 ns/op
BenchmarkConcurrentAgain-8   	    2000	   3477253 ns/op : ~41% Faster

此时性能基准测试(benchmark测试)显示:并发版 add 函数常规版 add 函数的性能要高 41-43%。

测试结果和我们预计是吻合的!

任务2:排序

我们已经知道:并不是所有的CPU-Bound 类型任务都适合并发执行。

并发执行所发生的协程切换成本非常大,下面是一个Go语言的排序样例代码:

常规版 bubbleSort 函数

Listing 6 play.golang.org/p/S0Us1wYBq…

01 package main
02
03 import "fmt"
04
05 func bubbleSort(numbers []int) {
06     n := len(numbers)
07     for i := 0; i < n; i++ {
08         if !sweep(numbers, i) {
09             return
10         }
11     }
12 }
13
14 func sweep(numbers []int, currentPass int) bool {
15     var idx int
16     idxNext := idx + 1
17     n := len(numbers)
18     var swap bool
19
20     for idxNext < (n - currentPass) {
21         a := numbers[idx]
22         b := numbers[idxNext]
23         if a > b {
24             numbers[idx] = b
25             numbers[idxNext] = a
26             swap = true
27         }
28         idx++
29         idxNext = idx + 1
30     }
31     return swap
32 }
33
34 func main() {
35     org := []int{1, 3, 2, 4, 8, 6, 7, 2, 3, 0}//切片
36     fmt.Println(org)
37
38     bubbleSort(org)
39     fmt.Println(org)
40 }

In listing 6.

Question:那么冒泡排序函数适合并发执行吗?

answer: no

虽然切片可以分解成多个切片、每个子切片执行并发排序,但是之后如何处理呢?

并发版 bubbleSortConcurrent 函数

Listing 8

01 func bubbleSortConcurrent(goroutines int, numbers []int) {
02     totalNumbers := len(numbers)
03     lastGoroutine := goroutines - 1
04     stride := totalNumbers / goroutines
05
06     var wg sync.WaitGroup
07     wg.Add(goroutines)
08
09     for g := 0; g < goroutines; g++ {
10         go func(g int) {
11             start := g * stride
12             end := start + stride
13             if g == lastGoroutine {
14                 end = totalNumbers
15             }
16
17             bubbleSort(numbers[start:end])
18             wg.Done()
19         }(g)
20     }
21
22     wg.Wait()
23
24     // Ugh, we have to sort the entire list again.
25     bubbleSort(numbers)
26 }

In Listing 8, 并发版 bubbleSortConcurrent 函数

It uses multiple Goroutines to sort portions of the list concurrently. However, what you are left with is a list of sorted values in chunks. Given a list of 36 numbers, split in groups of 12, this would be the resulting list if the entire list is not sorted once more on line 25.

Listing 9

Before:
  25 51 15 57 87 10 10 85 90 32 98 53
  91 82 84 97 67 37 71 94 26  2 81 79
  66 70 93 86 19 81 52 75 85 10 87 49

After:
  10 10 15 25 32 51 53 57 85 87 90 98
   2 26 37 67 71 79 81 82 84 91 94 97
  10 19 49 52 66 70 75 81 85 86 87 93

并发版 bubbleSortConcurrent 函数不能带来性能提升。

任务3:文件读取

我们已经给出了CPU-Bound 类型任务的两个并发执行的样例。

那么对于IO-Bound 类型任务呢?

下面我们看看文件读取操作:读取一个文件、并执行内容检索

常规版 find 函数

Listing 10 play.golang.org/p/8gFe5F8zw…

42 func find(topic string, docs []string) int {
43     var found int
44     for _, doc := range docs {
45         items, err := read(doc)
46         if err != nil {
47             continue
48         }
49         for _, item := range items {
50             if strings.Contains(item.Description, topic) {
51                 found++
52             }
53         }
54     }
55     return found
56 }

在 listing 10 代码中:

  1. line 43 , a variable named found is declared to maintain a count for the number of times the specified topic is found inside a given document.
  2. Then on line 44, the documents are iterated over and each document is read on line 45 using the read function.
  3. Finally on line 49-53, the Contains function from the strings package is used to check if the topic can be found inside the collection of items read from the document. If the topic is found, the found variable is incremented by one.

以下是 read 函数

Listing 11 play.golang.org/p/8gFe5F8zw…

33 func read(doc string) ([]item, error) {
34     time.Sleep(time.Millisecond) // Simulate blocking disk read.
35     var d document
36     if err := xml.Unmarshal([]byte(file), &d); err != nil {
37         return nil, err
38     }
39     return d.Channel.Items, nil
40 }
  1. The read function in listing 11 starts with a time.Sleep call for one millisecond. This call is being used to mock the latency that could be produced if we performed an actual system call to read the document from disk.
  2. The consistency of this latency is important for accurately measuring the performance of the sequential version of find against the concurrent version.
  3. Then on lines 35-39, the mock xml document stored in the global variable file is unmarshaled into a struct value for processing.
  4. Finally, a collection of items is returned back to the caller on line 39.
并发版 findConcurrent 函数

Note: There are several ways and options you can take when writing a concurrent version of find. Don’t get hung up on my particular implementation at this time. If you have a more readable version that performs the same or better I would love for you to share it.

Listing 12 play.golang.org/p/8gFe5F8zw…

58 func findConcurrent(goroutines int, topic string, docs []string) int {
59     var found int64
60
61     ch := make(chan string, len(docs))
62     for _, doc := range docs {
63         ch <- doc
64     }
65     close(ch)
66
67     var wg sync.WaitGroup
68     wg.Add(goroutines)
69
70     for g := 0; g < goroutines; g++ {
71         go func() {
72             var lFound int64
73             for doc := range ch {
74                 items, err := read(doc)
75                 if err != nil {
76                     continue
77                 }
78                 for _, item := range items {
79                     if strings.Contains(item.Description, topic) {
80                         lFound++
81                     }
82                 }
83             }
84             atomic.AddInt64(&found, lFound)
85             wg.Done()
86         }()
87     }
88
89     wg.Wait()
90
91     return int(found)
92 }

In Listing 12,

并发版 findConcurrent 函数 使用了 30 行代码;

常规版 find 函数 使用了 11 行代码;

并发版 findConcurrent 函数的My goal in implementing the concurrent version was to control the number of Goroutines that are used to process the unknown number of documents. A pooling pattern where a channel is used to feed the pool of Goroutines was my choice.

There is a lot of code so I will only highlight the important lines to understand.

Lines 61-64: A channel is created and populated with all the documents to process.

Line 65: The channel is closed so the pool of Goroutines naturally terminate when all the documents are processed.

Line 70: The pool of Goroutines is created.

Line 73-83: Each Goroutine in the pool receives a document from the channel, reads the document into memory and checks the contents for the topic. When there is a match, the local found variable is incremented.

Line 84: The sum of the individual Goroutine counts are summed together into a final count.

The concurrent version is definitely more complex than the sequential version but is the complexity worth it? The best way to answer this question again is to create a benchmark. For these benchmarks I have used a collection of 1 thousand documents with the garbage collector turned off. There is a sequential version that uses the find function and a concurrent version that uses the findConcurrent function.

Listing 13

func BenchmarkSequential(b *testing.B) {
    for i := 0; i < b.N; i++ {
        find("test", docs)
    }
}

func BenchmarkConcurrent(b *testing.B) {
    for i := 0; i < b.N; i++ {
        findConcurrent(runtime.NumCPU(), "test", docs)
    }
}

Listing 13 shows the benchmark functions. Here are the results when only a single OS/hardware thread is available for all Goroutines. The sequential is using 1 Goroutine and the concurrent version is using runtime.NumCPU or 8 Goroutines on my machine. In this case, the concurrent version is leveraging concurrency without parallelism.

Listing 14

10 Thousand Documents using 8 goroutines with 1 core
2.9 GHz Intel 4 Core i7
Concurrency WITHOUT Parallelism
-----------------------------------------------------------------------------
$ GOGC=off go test -cpu 1 -run none -bench . -benchtime 3s
goos: darwin
goarch: amd64
pkg: github.com/ardanlabs/gotraining/topics/go/testing/benchmarks/io-bound
BenchmarkSequential      	       3	1483458120 ns/op
BenchmarkConcurrent      	      20	 188941855 ns/op : ~87% Faster
BenchmarkSequentialAgain 	       2	1502682536 ns/op
BenchmarkConcurrentAgain 	      20	 184037843 ns/op : ~88% Faster

The benchmark in listing 14 shows that the concurrent version is approximately 87 to 88 percent faster than the sequential version when only a single OS/hardware thread is available for all Goroutines. This is what I would have expected since all the Goroutines are efficiently sharing the single OS/hardware thread. The natural context switch happening for each Goroutine on the read call is allowing more work to get done over time on the single OS/hardware thread.

Here is the benchmark when using concurrency with parallelism.

Listing 15

10 Thousand Documents using 8 goroutines with 1 core
2.9 GHz Intel 4 Core i7
Concurrency WITH Parallelism
-----------------------------------------------------------------------------
$ GOGC=off go test -run none -bench . -benchtime 3s
goos: darwin
goarch: amd64
pkg: github.com/ardanlabs/gotraining/topics/go/testing/benchmarks/io-bound
BenchmarkSequential-8        	       3	1490947198 ns/op
BenchmarkConcurrent-8        	      20	 187382200 ns/op : ~88% Faster
BenchmarkSequentialAgain-8   	       3	1416126029 ns/op
BenchmarkConcurrentAgain-8   	      20	 185965460 ns/op : ~87% Faster

The benchmark in listing 15 shows that bringing in the extra OS/hardware threads don’t provide any better performance.

总结

对于IO-Bound 类型任务,如果使用并行执行将导致很大的性能降级。

对于IO-Bound 类型任务,比如冒泡算法函数,使用并发执行会增加代码的复杂程度,同时也不能换来任何真正的性能提升。

所以,并发是否有意义取决于任务类型