gotraining之concurrency-goroutines

设计指南

concurrent-software-design

并发软件设计

并发意味着“无序”执行。取一组本来会按顺序执行的指令,然后找到一种方法,在不按顺序执行它们的情况下,仍然生成相同的结果。无序执行需要能够在复出复杂性成本的同时增加足够的性能收益。根据问题,无序执行可能是合适的,甚至是没有意义的。
并发不同于并行。并行意味着同时执行两条或多条指令。只有当至少有2个操作系统(OS)和硬件线程可用,并且至少有2个goroutine,每个goroutine在每个OS/硬件线程上独立执行指令时,才可能实现并行。
开发者和运行时都有责任管理应用程序的并发性。

设计理念

  • 应用程序必须完整地启动和关闭
    • 知道创建的每个goroutine如何以及何时终止
    • 创建的所有goroutine都应该在main返回之前终止
    • 应用程序应该能够按需关闭,甚至在负载下,以一种受控的方式关闭
      • 您希望停止接受新请求,并完成已有的请求(减载)
  • 识别和监视应用程序中可能存在的背压临界点
    • 当goroutine需要等待时,通道、互斥锁和原子函数会产生背压
    • 有一点背压是好的,这意味着有一个良好的平衡
    • 很多背压是不好的,这意味着事情是不平衡的
    • 背压不平衡会导致:
      • 软件内部和整个平台的故障
      • 您的应用程序要崩溃、内爆或冻结
    • 测量背压是测量应用程序健康状况的一种方法
  • 速率限制,以防止应用程序存在压倒性的背压
    • 每个系统都有一个断点,您必须知道它对于您的应用程序是什么
    • 一旦新请求过载,应用程序应该尽早拒绝它们
      • 不要做超过你一次合理工作量的工作
      • 当你处于临界质量时,把它往后推。创建自己的外部背压
    • 在合理和实用的情况下,使用外部系统进行速率限制
  • 使用超时来释放应用程序中的背压
    • 任何请求或任务都不允许长时间执行
    • 确定用户愿意等待多长时间
    • 高级调用应该告诉低级调用它们必须运行多长时间
    • 在顶层,用户应该决定他们愿意等待多久
    • 使用Context包
      • 用户等待的函数应该具有上下文Context
        • 这些函数应该select <-ctx.Done(),否则它们将无限期阻塞
      • 只有当您有充分的理由认为函数的执行有实际的时间限制时,才可以在Context中设置超时
      • 允许上游调用程序决定何时应该取消Context
      • 当用户放弃或显式中止调用时,取消Context
  • 应用架构师需要:
    • 当问题发生时,找出它们
    • 止血
    • 将系统恢复到正常状态

Scheduling In Go : Part I - OS Scheduler

Scheduling In Go : Part I - OS Scheduler
这部分主讲OS调度器。要正确地设计多线程软件,对OS和Go调度程序的工作原理有一个全面而有代表性的理解是很重要的。这篇文章重点介绍OS调度程序的高级机制和语义。

OS调度程序

程序是一系列需要依次执行的机器指令。为了实现这一点,操作系统使用线程的概念。线程的任务是解释并顺序执行分配给它的一组指令。继续执行,直到没有更多的指令供线程执行为止。
每个程序运行时都创建一个进程,每个进程都有一个初始线程。线程能够创建更多的线程。所有这些不同的线程都是独立运行的,并且调度决策是在线程级别而不是在进程级别做出的。线程可以并发运行(每个线程轮流运行一个单独的内核),也可以并行运行(每个线程同时运行在不同的内核上)。线程还维护自己的状态,以便安全、本地和独立地执行它们的指令。
如果有线程可以执行,OS调度程序负责确保内核不是空闲的。它还必须创建一个假象,即所有可以执行的线程都在同时执行。在创建这个假象的过程中,调度程序需要运行优先级较高的线程,而不是优先级较低的线程。但是,具有较低优先级的线程不能缺少执行时间。调度程序还需要通过快速、明智的决策尽可能减少调度延迟。

执行指令

程序计数器(PC)有时称为指令指针(IP),它允许线程跟踪要执行的下一条指令。在大多数处理器中,PC指向下一条指令,而不是当前指令。

1
2
3
4
5
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

这些数字表示从各自函数顶部偏移的PC值。+0x39 PC偏移量表示如果程序没有panic,线程将在example函数中执行的下一条指令。如果返回到main函数,则main函数中的下一条指令是0+x72 PC偏移量。更重要的是,指针之前的指令表示正在执行什么指令。

线程状态

线程状态指示调度程序在线程中所扮演的角色。线程可以处于三种状态之一:等待、可运行或执行。

  • Waiting
    线程被停止并等待唤醒。可能是由于等待硬件(磁盘、网络)、操作系统(系统调用)或同步调用(原子、互斥)等原因。这些类型的延迟是性能差的根本原因。
  • Runnable
    线程需要内核时间以便能够执行分配给它的机器指令。如果有很多线程需要时间,那么线程必须等待更长时间才能获得时间。而且,随着更多的线程争夺时间,任何给定线程获得的单个时间量都会缩短。这种类型的调度延迟也可能导致性能下降。
  • Executing
    线程已被放置在一个核上,并且正在执行它的机器指令。

工作类型

线程可以做两种类型的工作:cpu绑定和io绑定。
CPU-Bound:CPU-Bound工作是不断进行计算的,不会导致线程处于Waiting状态。例子:计算Pi到第n位数字。
IO-Bound:这是导致线程进入Waiting状态的工作。这项工作包括请求通过网络访问资源或对操作系统进行系统调用。需要访问数据库的线程将是io绑定的。

上下文切换

在核上交换线程的物理行为称为上下文切换。当调度程序将Executing线程从核中取出并用Runnable线程替换它时,将发生上下文切换。从运行队列中选择的线程将进入Executing状态。被拉出的线程可以回到Runnable状态(如果它仍然有能力运行),或者进入Waiting状态(如果由于io绑定类型的请求而被替换)。
上下文切换被认为是昂贵的,因为它需要时间来切换线程上、下核。实际上,程序正在失去在上下文切换期间执行大量指令的能力。
如果有一个专注于io绑定工作的程序,那么上下文切换将是一个优势。一旦一个线程进入Waiting状态,另一个处于Runnable状态的线程将代替它。这使得核总是在工作。
如果程序专注于cpu绑定的工作,那么上下文切换将成为性能噩梦。由于线程总是有工作要做,上下文切换会阻止工作的进展。

少即是多

早期处理器只有一个核,因为只有一个处理器和一个核心,所以在任何给定时间只能执行一个线程。其思想是定义一个调度程序周期,并尝试在此期间执行所有可运行线程:将调度周期除以需要执行的线程数。
如果将调度程序周期定义为10ms,并且有两个线程,那么每个线程将得到5ms。如果有5个线程,每个线程将得到2ms。但是,当有100个线程时,给每个线程一个时间片10μs不起作用,因为会花大量的时间在上下文切换。
需要的是限制时间片的长度。在上一个场景中,如果最小时间片是2ms,并且有100个线程,那么调度程序周期需要增加到2000ms或2s。如果有1000个线程,现在您看到的调度程序周期是20s。在这个简单的例子中,如果每个线程都使用它的全时间片,那么所有线程运行一次需要20秒。
在制定调度决策时,调度程序需要考虑和处理更多的事情。开发者可以控制应用程序中使用的线程数。当有更多的线程需要考虑,并且执行io绑定的工作时,就会出现更多的混乱和不确定性行为。
少即是多的规则:处于可运行状态的线程越少,调度开销就越小,每个线程的时间也就越多。处于可运行状态的线程越多,意味着每个线程超时的时间就越少。这意味着随着时间的推移,完成的工作也会越来越少。

平衡

开发者需要在拥有的内核数量和获得应用程序最佳吞吐量所需的线程数量之间找到一个平衡。当涉及到管理这种平衡时,线程池是一个很好的答案,但在Go中不再需要这样做。
作为一名工程师,需要计算出需要多少线程池,以及给定线程池的最大线程数,以便最大限度地提高给定内核数量的吞吐量。
如果每个内核使用线程数低了,那么完成所有的工作将花费更长的时间。如果每个内核使用线程数高了,也会花费更长的时间,因为在上下文切换中有更多的延迟。对于所有不同的工作负载,可能不可能找到一个始终有效的神奇数字。当涉及到使用线程池来调优服务的性能时,要找到正确的一致配置可能会变得非常复杂。

缓存行

从主存访问数据的延迟成本非常高(大约100到300个时钟周期),以至于处理器和核都有本地缓存,以便将数据保存在需要数据的硬件线程附近。根据访问的缓存,从缓存访问数据的成本要低得多(大约3到40个时钟周期)。如今,性能的一个方面是如何有效地将数据输入处理器以减少这些数据访问延迟。编写改变状态的多线程应用程序需要考虑缓存系统的机制。

数据通过高速缓存行在处理器和主存之间交换。高速缓存行是主存和高速缓存系统之间交换的64字节内存块。每个核心都有它自己需要的高速缓存行的副本,这意味着硬件使用了值语义。这就是多线程应用程序中内存的变化会导致性能噩梦的原因。
当多个并行运行的线程正在访问相同的数据值,甚至是相邻的数据值时,它们将访问同一高速缓存行上的数据。在任何核心上运行的任何线程都将从相同的高速缓存行进行拷贝。

如果给定核上的一个线程对其高速缓存行的副本进行更改,必须将同一高速缓存行的所有其他副本标记为dirty。当线程尝试对脏缓存行进行读写访问时,需要主内存访问来获得缓存行的新副本。
如果32核处理器同时运行32个线程,并且在同一高速缓存行上访问和修改数据,因为处理器到处理器通信增加了延迟。应用程序将在内存中反复运行,性能将非常糟糕,而且很可能开发者无法理解其中的原因。
这被称为缓存一致性问题,同时也引入了错误共享等问题。在编写将改变共享状态的多线程应用程序时,需要考虑缓存系统。

调度决策场景

编写OS调度程序时,考虑这个场景:启动应用程序,创建主线程并在core 1上执行。当线程开始执行指令时,由于需要数据,将检索高速缓存行。线程现在决定为一些并发处理创建一个新线程。一旦线程创建并准备好运行,调度程序应该:

  • 上下文切换掉core 1的主线程?这样做可以提高性能,因为这个新线程很可能需要相同数据,且已被缓存。但是主线程没有得到它的全时间片。
  • 线程是否在主线程的时间片完成之前等待core 1可用?线程没有被运行,但当它启动时,获取数据需要的延迟将被消除。
  • 线程是否等待下一个可用核?如果是,所选核的高速缓存行将被刷新、检索和拷贝,从而导致延迟。然而,线程会启动得更快,主线程可以完成它的时间片。

在制定调度决策时,OS调度程序需要考虑这些问题。幸运的是,开发者不需要做。如果有一个空闲内核,它将被使用。因为我们希望线程在可以运行的时候运行。

总结

这部分提供了关于在编写多线程应用程序时必须考虑的线程和OS调度程序的一些见解。这些也是Go调度程序要考虑的事情。

Scheduling In Go : Part II - Go Scheduler

Scheduling In Go : Part II - Go Scheduler
这部分将从语义层面解释Go调度程序的工作原理,并重点介绍高级行为。通过好的模型来描述Go调度器的工作和行为,方便开发者做出更好的工程决策。

程序启动

Go程序启动时,会为主机上标识的每个虚拟内核提供一个逻辑处理器P。如果有一个处理器,处理器的每个物理内核都有多个硬件线程(超线程),那么每个硬件线程将作为一个虚拟内核呈现给Go程序。虚拟内核个数通过runtime.NumCPU()获取。
每个P分配一个OS线程M。M代表机器。这个线程M仍然由OS管理,OS仍然负责将线程放在核上执行。如果机器上有8个虚拟内核,这意味着在机器上运行一个Go程序时,有8个线程可用来执行工作,每个线程独立地附加到一个P。
每个Go程序都有一个初始goroutine G,这是Go程序的执行路径。goroutine本质上是一个协程Coroutine,用G替换字母C,就得到了goroutine。可以将goroutine视为应用程序级线程,区别在于操作系统线程是在核切换上下文,goroutine是在M切换上下文。
Go调度程序中有两个不同的运行队列:全局运行队列GRQ和本地运行队列LRQ。每个P给定一个LRQ,用于在P的上下文中管理被分配执行的goroutine。这些goroutine轮流在M中切换上下文。GRQ用于尚未尚未分配给P的goroutines。专门有一个process负责将goroutines从GRQ移动到LRQ。

协作式调度器

OS调度程序是一种抢占式调度程序。本质上,这意味着不能预测调度程序在任何给定的时间将要做什么。运行在操作系统之上的应用程序无法控制内核内部的调度,除非它们利用原子指令和互斥调用等同步原语。
Go调度程序是Go运行时的一部分,Go运行时构建到应用程序中。这意味着Go调度程序在内核之上的用户空间中运行。Go调度器的当前实现不是抢占式调度器,而是协作式调度器。作为协作调度程序意味着调度程序需要定义良好的用户空间事件,这些事件发生在代码中的安全点,以便做出调度决策。
Go协作调度程序的绝妙之处在于它看起来是抢占式的。开发者无法预测Go调度程序将要做什么,因为这个协作调度程序的决策取决于Go运行时。将Go调度程序视为抢占式调度程序是很重要的。

协程状态

goroutine具有Waiting、Runnable、Executing三个高级状态。这些状态指示了Go调度程序在任何给定goroutine中所扮演的角色。
Waiting:这意味着goroutine停止了,等待调度。可能是由于等待操作系统(系统调用)或同步调用(原子和互斥操作)等原因。这些类型的延迟是性能差的根本原因。
Runnable:这意味着goroutine需要一个M上的时间以执行指定的指令。如果有很多goroutine需要时间,那么goroutine就需要等待更长的时间才能得到时间。随着更多的goroutine争夺时间,任何给定goroutine获得的时间量都会缩短。这种类型的调度延迟也可能导致性能下降。
Executing:这意味着goroutine已被放置在M上,并正在执行它的指令。

上下文切换

Go调度程序要求定义良好的用户空间事件,这些事件发生在代码中的安全点,以便上下文切换。这些事件和安全点在函数调用中表现出来。函数调用对Go调度程序的健康状况至关重要。在Go 1.11或更低版本中,如果运行任何不执行函数调用的紧密循环,就会在调度程序和垃圾收集中造成延迟。在合理的时间范围内发生函数调用是非常重要的。
NOTE:有一个1.12的提议被接受,即在Go调度程序中应用非协作抢占技术,以允许抢占紧密循环。
Go程序中四类事件允许调度程序做出调度决策。这并不意味着这些事件总是发生,而是意味着调度程序得到了机会。

  • 关键词go的使用
  • 垃圾回收
  • 系统调用
  • 同步和编排

关键词go的使用

关键字go用于创建goroutine。一旦创建了一个新的goroutine,它就为调度程序提供了一个做出调度决策的机会。

垃圾回收

由于GC使用自己的goroutine集运行,所以这些goroutine需要在M上的运行时间。这将导致GC创建大量的调度混乱。然而,调度程序能智能感知goroutine正在做什么并做出明智的决策。例如,GC期间上下文切换,将接触堆的goroutine切换为不接触堆的goroutine。当GC运行时,会做出许多调度决策。

系统调用

当goroutine调用系统调用时,将阻塞M,有时候调度器能够把该goroutine从M切换下来,并且切换新的goroutine到相同的M。但是,有时候需要一个新的M来继续执行在P中排队的goroutine。

同步和编排

如果原子、互斥或通道操作调用会导致goroutine阻塞,则调度程序可以上下文切换一个新的goroutine来运行。一旦goroutine可以再次运行,它就可以重新排队,并最终切换回M。

异步系统调用

当运行的操作系统具有处理异步系统调用的能力时,可以使用网络轮询器来更有效地处理系统调用,这是通过在这些操作系统中使用kqueue(MacOS)、epoll(Linux)或iocp(Windows)来实现的
基于网络的系统调用可以由今天使用的许多操作系统进行异步处理。这就是网络轮询器的名称由来,因为它的主要用途是处理网络操作。通过使用网络轮询器进行网络系统调用,调度程序可以防止goroutine在进行这些系统调用时阻塞M。这有助于保持M可用,从而执行P的LRQ中的其他goroutine,而不需要创建新的M,有助于减少操作系统上的调度负载。
假设只调度LRQ,当goroutine-1希望进行网络系统调用时,将移动到网络轮询器,并处理异步网络系统调用。M可用,切换LRQ的goroutine-2到M。当网络轮询器完成异步网络系统调用,goroutine-1被移回LRQ。这里的好处是要执行网络系统调用,不需要额外的M。网络轮询器有一个OS线程用于处理有效的事件循环。

同步系统调用

当goroutine发出一个不能异步执行的系统调用时,无法使用网络轮询器,发出系统调用的goroutine将阻塞M。一个例子是基于文件的系统调用。如果使用CGO,可能在其他情况下调用C函数也会阻塞M。
NOTE:Windows OS具有异步执行基于文件的系统调用的功能。从技术上讲,当运行在Windows上时,可以使用网络轮询器。
假设只调度LRQ,调度程序识别出goroutine-1已导致M1阻塞后,将M1从P中分离出来,M1仍旧依附着阻塞的goroutine-1。然后,调度程序引入一个新的M2来为P服务。此时,可以从LRQ中选择goroutine-2并切换到M2。如果由于之前的切换而已经存在一个M,那么这种切换比创建一个新的M要快。goroutine-1完成了阻塞系统调用后,可以回到LRQ中,再次由P提供服务。M1将放在一边以便将来面对相同的情况下可以使用。

工作窃取

调度程序能够窃取工作,这在一些方面有助于保证调度效率。一旦M进入等待状态,操作系统将从核上切走M。这意味着即使有goroutine处于可运行状态,P也不能完成任何工作,直到M被上下文切换回核。工作窃取还有助于平衡所有P的goroutine,保证工作更好地分配,更有效地完成。
当P1的LRQ中所有goroutine都执行完,且P2的LRQ和全局GRQ都存在可运行的goroutine时,工作窃取规则如下:

1
2
3
4
5
6
7
8
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.
}

当P1从P2窃取goroutine时,会窃取一半的数量。工作窃取可以参考Go’s work-stealing scheduler

实际例子

设想一个用C语言编写的多线程应用程序,其中程序管理两个OS线程,这两个线程相互传递消息,线程传递完消息后进入Waiting状态,而接受到消息的线程则由Waiting状态转入Runnable状态。在C语言的实现中,所有上下文切换和状态更改都需要执行时间,这限制了完成工作的速度。由于每个上下文切换潜在的延迟约为1000纳秒,而硬件每纳秒约执行12条指令,所以约12k条指令没能在上下文切换期间执行。由于这些线程也在不同的核之间跳跃,所以很可能由于缓存行丢失而导致额外延迟。
而当使用goroutine实现时,同样会发生上下文切换和状态更改。然而,使用线程和goroutine之间有一个主要的区别,在使用goroutine的情况下,所有的处理都使用相同的OS线程和内核。这意味着,从OS的角度来看,OS线程永远不会进入等待状态。因此,在使用线程时由于上下文切换而没能执行12k条指令的情况,在使用goroutine时不会出现。
本质上,Go已经将IO/阻塞工作转换为操作系统级别的CPU绑定工作,所有的上下文切换都发生在应用程序级。在Go中,同样的上下文切换将花费约200纳秒或约2.4k的指令。调度程序还有助于提高缓存行效率和NUMA。这就是为什么不需要拥有比虚拟内核更多的线程。在Go中,随着时间的推移可以完成更多的工作,因为Go调度程序尝试使用更少的线程,在每个线程上做更多的工作,这有助于减少操作系统和硬件上的负载。

总结

Go调度器在设计中考虑了操作系统和硬件工作的复杂性,将IO/阻塞工作转换为操作系统级别的CPU绑定工作,是在利用更多CPU容量方面取得的重大胜利,也是不需要比虚拟内核更多的操作系统线程的原因。可以合理地期望通过一个OS线程/每个虚拟内核来完成所有工作(CPU和IO/阻塞绑定)。这种做法对于网络应用程序和其他不需要系统调用(阻塞OS线程)的应用程序是可能的。
开发人员仍然需要了解应用程序在处理哪些类型的工作,不可能在创建无限数量的goroutine的同时还期望获得惊人的性能。少即是多,通过理解这些Go-scheduler语义,可以做出更好的工程决策。

Scheduling In Go : Part III - Concurrency

Scheduling In Go : Part III - Concurrency

并发

并发与并行的区别参考文章开头。注意,在没有并行的情况下利用并发有时会降低吞吐量。同样有趣的是,有时候在有并行的情况下利用并发并不能带来更大的性能收益。

工作负载

了解工作负载类型,有助于理清“无序”执行是否是有意义的。在考虑并发性时,需要了解CPU-Bound和IO-Bound这两种类型的工作负载。
对于CPU-Bound工作负载,需要在有并行的情况下利用并发。一个OS/硬件线程处理多个goroutine的话效率不高,因为作为工作负载的一部分,goroutine不会切换出/入等待状态。若goroutine数目比OS/硬件线程数多,会降低执行速度,因为OS/硬件线程切换goroutine需要一定的延迟成本。上下文切换为当前工作负载创建一个Stop The World事件:切换期间,工作负载不被执行。
对于IO-Bound工作负载,不需要并行的情况下可以利用并发。作为工作负载的一部分,goroutine会自然切换出/入等待状态,OS/硬件线程可以高效处理多个goroutine。若goroutine数目比OS/硬件线程数多,可以加快工作负载执行速度,因为在OS/硬件线程切换goroutine的延迟成本不会创建Stop the World事件。工作负载会自然停止,允许另一个goroutine有效地利用相同的OS/硬件线程,而不是让OS/硬件线程处于空闲状态。
对于每个OS/硬件线程,需要考虑多少goroutine能提供最佳吞吐量。goroutine太少,会有更多空闲时间。goroutine太多,会有更多的上下文切换延迟时间。本文不作研究。需要强调的是,检查代码以确定工作负载何时可以利用并发性、何时不能利用并发性以及是否需要并行性,是非常重要的。

案例:求和

https://play.golang.org/p/r9LdqUsEzEz
串行程序:

1
2
3
4
5
6
7
func add(numbers []int) int {
var v int
for _, n := range numbers {
v += n
}
return v
}

问题:add函数是一个适合无序执行的工作负载吗?是的。整数集合可以分解为较小的列表,并且可以同时处理这些列表。 一旦将所有较小的列表相加,就可以将这组和加在一起以产生与顺序版本相同的答案。
问题:应该独立创建和处理多少个较小的列表以获得最佳吞吐量?要回答这个问题,需要知道工作负载类型。add函数正在执行CPU-BOUND工作负载,因为算法正在执行纯数学运算,并且它不会导致goroutine进入等待状态。 这意味着每个OS/硬件线程使用一个goroutine就可以获得良好的吞吐量。
并发程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
func addConcurrent(goroutines int, numbers []int) int {
var v int64
totalNumbers := len(numbers)
lastGoroutine := goroutines - 1
stride := totalNumbers / goroutines

var wg sync.WaitGroup
wg.Add(goroutines)

for g := 0; g < goroutines; g++ {
go func(g int) {
start := g * stride
end := start + stride
if g == lastGoroutine {
end = totalNumbers
}

var lv int
for _, n := range numbers[start:end] {
lv += n
}

atomic.AddInt64(&v, int64(lv))
wg.Done()
}(g)
}

wg.Wait()

return int(v)
}

问题:并发版本比顺序版本更复杂,带来的性能如何?要回答这个问题,最好创建一个基准。以下基准测试使用了1000万个数字的集合,关闭了垃圾收集器。
测试程序:

1
2
3
4
5
6
7
8
9
10
11
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)
}
}

没有并行情况下使用并发,cpu指定为1,goroutines参数指定为8,即runtime.NumCPU。顺序版本使用1个goroutine。顺序版本优于并发版本,因为并发版本具有单个OS线程上的上下文切换和goroutine管理的开销。

1
2
3
4
5
6
7
8
9
10
11
12
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

并行情况下使用并发,cpu指定为8,goroutines参数指定为8,即runtime.NumCPU。顺序版本使用1个goroutine。并发版本优于顺序版本,因为所有goroutine并行运行,八个goroutine同时执行工作。

1
2
3
4
5
6
7
8
9
10
11
12
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

案例:排序

并非所有CPU-BOUND工作负载都适合并发。基本上,当分解工作或将所有结果组合起来的代价很高时,就不适合并发,如冒泡排序。https://play.golang.org/p/S0Us1wYBqG6
串行程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
func bubbleSort(numbers []int) {
n := len(numbers)
for i := 0; i < n; i++ {
if !sweep(numbers, i) {
return
}
}
}

func sweep(numbers []int, currentPass int) bool {
var idx int
idxNext := idx + 1
n := len(numbers)
var swap bool

for idxNext < (n - currentPass) {
a := numbers[idx]
b := numbers[idxNext]
if a > b {
numbers[idx] = b
numbers[idxNext] = a
swap = true
}
idx++
idxNext = idx + 1
}
return swap
}

问题:bubbleSort函数是适合于无序执行的工作负载吗?不是。整数的集合可以分解成更小的列表,这些列表可以同时排序。然而,在完成所有并发工作之后,没有有效的方法将较小的列表排序在一起。
并发程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func bubbleSortConcurrent(goroutines int, numbers []int) {
totalNumbers := len(numbers)
lastGoroutine := goroutines - 1
stride := totalNumbers / goroutines

var wg sync.WaitGroup
wg.Add(goroutines)

for g := 0; g < goroutines; g++ {
go func(g int) {
start := g * stride
end := start + stride
if g == lastGoroutine {
end = totalNumbers
}

bubbleSort(numbers[start:end])
wg.Done()
}(g)
}

wg.Wait()

// Ugh, we have to sort the entire list again.
bubbleSort(numbers)
}

由于冒泡排序的本质是遍历列表,因此最后对bubbleSort的调用将抵消使用并发带来的任何潜在收益。对于冒泡排序,使用并发性不会提高性能。

案例:读取文件

文件读取是IO-BOUND工作负载。
串行程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func read(doc string) ([]item, error) {
time.Sleep(time.Millisecond) // Simulate blocking disk read.
var d document
if err := xml.Unmarshal([]byte(file), &d); err != nil {
return nil, err
}
return d.Channel.Items, nil
}

func find(topic string, docs []string) int {
var found int
for _, doc := range docs {
items, err := read(doc)
if err != nil {
continue
}
for _, item := range items {
if strings.Contains(item.Description, topic) {
found++
}
}
}
return found
}

并发程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
func findConcurrent(goroutines int, topic string, docs []string) int {
var found int64

ch := make(chan string, len(docs))
for _, doc := range docs {
ch <- doc
}
close(ch)

var wg sync.WaitGroup
wg.Add(goroutines)

for g := 0; g < goroutines; g++ {
go func() {
var lFound int64
for doc := range ch {
items, err := read(doc)
if err != nil {
continue
}
for _, item := range items {
if strings.Contains(item.Description, topic) {
lFound++
}
}
}
atomic.AddInt64(&found, lFound)
wg.Done()
}()
}

wg.Wait()

return int(found)
}

实现该并发版本的目的是控制用于处理未知数量文档的goroutine的数量,选择了一种池模式,其中通道用于为goroutine池提供数据。
问题:并发版本比顺序版本更复杂,带来的性能如何?要回答这个问题,最好创建一个基准。以下基准测试使用了1000个文档集合,关闭了垃圾收集器。
测试程序:

1
2
3
4
5
6
7
8
9
10
11
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)
}
}

没有并行情况下使用并发,cpu指定为1,goroutines参数指定为8,即runtime.NumCPU。顺序版本使用1个goroutine。并发版本优于顺序版本,因为所有goroutine都有效地共享一个OS/硬件线程。对于read调用上的每个goroutine,上下文切换允许在单个OS/硬件线程上执行更多的工作。

1
2
3
4
5
6
7
8
9
10
11
12
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

并行情况下使用并发,cpu指定为8,goroutines参数指定为8,即runtime.NumCPU。顺序版本使用1个goroutine。引入额外的OS/硬件线程并不能提供更好的性能。

1
2
3
4
5
6
7
8
9
10
11
12
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

结论

本文的目标是提供语义方面的指导,以确定工作负载是否适合使用并发性。通过不同类型的算法和工作负载的示例,以便察觉语义上的差异以及不同的工程决策。
可以看到,在使用IO-BOUND的工作负载时,并不需要并行性来获得性能的大幅提升。这与CPU-BOUND的工作是相反的。当涉及到像冒泡排序这样的算法时,使用并发会增加复杂性,而不会带来真正的性能优势。我们需要先确定工作负载是否适合并发,然后根据工作负载类型使用正确的语义。

显示 Gitment 评论