以 Go 为例 - 探究并行与并发的区别

在软件内并行是指多条指令同时执行。每个编程语言都有各自实现并行,或者像 Go,将并行作为语言的一部分,提供原生支持。并行让软件工程师能够同时在多核处理器上并行执行任务,从而抛开硬件的物理限制。1

通常情况下,由于构建并行模块的复杂性,一个应用程序的并行程度取决于工程师编写软件的能力。

并行任务的例子:

  • 多人同时在餐厅点单
  • 多个收银员在杂货铺
  • 多核 CPU

事实上,在任何一个应用程序中都有多层含义的并行。有应用程序本身的并行,这是由应用程序开发人员定义的,还有由操作系统协调的物理硬件上的 CPU 执行的指令的并行(或复用)。

注意:一般情况下,应用程序必须明确写出他们使用并行。这个需要工程师需要有技能写出”正确”的可并行的代码。

目录

构建并行

什么是并发?

构建并行

应用程序开发人员利用抽象概念来描述一个应用程序的并行。这些抽象概念通常在每个实现并行的编程语言上会有所不同,但是概念是一样的。举个例子,在 C 语言,并行是通过pthread来定义的。在 Go,并行是通过goroutines来定义的。

进程

一个进程是一个单一的执行单元,包含它自己的”程序计数器,寄存器和变量”。从概念上来讲,每个进程有它自己的虚拟 CPU”2。这一点很重要,因为涉及到进程在创建和管理过程中的开销。除了创建进程时的开销,每个进程只允许访问自己的内存。这表示进程不能访问其他进程的内存。

如果多个执行线程 (并行任务) 需要访问一些共享资源时,这会是一个问题。

线程

线程是作为一种方法被引入的,它允许在同一进程中访问共享内存,但在不同的并行执行单元上。线程基本上是自己的进程,但是可以访问父进程的共享地址空间。

线程相较于进程只需要更少的开销,因为它们不需要为了每个线程创建新进程,并且资源可以被共享或者复用。

这里有一个在 Ubuntu 18.04 下,克隆进程和创建线程的开销比较:3

  1. # Borrowed from https://stackoverflow.com/a/52231151/834319
  2. # Ubuntu 18.04 start_method: fork
  3. # ================================
  4. results for Process:
  5. count 1000.000000
  6. mean 0.002081
  7. std 0.000288
  8. min 0.001466
  9. 25% 0.001866
  10. 50% 0.001973
  11. 75% 0.002268
  12. max 0.003365
  13. Minimum with 1.47 ms
  14. ------------------------------------------------------------
  15. results for Thread:
  16. count 1000.000000
  17. mean 0.000054
  18. std 0.000013
  19. min 0.000044
  20. 25% 0.000047
  21. 50% 0.000051
  22. 75% 0.000058
  23. max 0.000319
  24. Minimum with 43.89 µs
  25. ------------------------------------------------------------
  26. Minimum start-up time for processes takes 33.41x longer than for threads.

临界区

临界区是共享的内存部分,它被进程中的各种并行任务所需要。这个部分可能是共享数据,类型或者资源。(见下方的范例4)

Borrowed from https://stackoverflow.com/a/52231151/834319 - 图1

并行的复杂性

由于一个进程的线程在同一内存空间中执行,因此存在着临界区被多个线程同时访问的风险。在应用程序中这个可能导致数据损坏或其他无法预料的行为。

这里有 2 个主要问题当多个线程同一时间访问共享内存的时候。

竞态条件

举个例子,想象一个进程的线程正在从一个共享内存地址读取一个数值,同时其他线程正在往同一个地址写一个新的数值。如果第一个线程在第二个线程写数值之前读取了数值,第一个线程就会读取到旧的数值。

这会导致应用程序出现不符合预期的情况。

死锁

当两个或多个线程在互相等待对方做某事时,就会出现死锁。这会导致应用程序挂起或者崩溃。

有一个例子是这样的,当一个线程等待一个时机去执行临界区的同时,另一个线程也正在等待其他线程满足条件后去执行相同的临界区。如果第一个线程正在等待满足时机,然后第二个线程也正在等待第一个线程,那这两个线程将一直等待下去。

第二种形式的死锁会发生在尝试使用互斥锁保护竞态。

1643206061070

屏障

屏障可以称为一个同步点,它管理一个进程中多个线程对共享资源或临界区的访问。

这些屏障允许应用程序开发者去控制并行访问,从而保证资源不会在不安全的情况下被访问。

互斥锁 (Mutexes)

互斥锁是屏障的一个类型,它只允许一个线程在同一时间访问共享资源。这对于防止在读取或写入共享资源时通过锁定和解锁出现竞态的情况非常有用。

  1. // Example of a mutex barrier in Go
  2. import (
  3. "sync"
  4. "fmt"
  5. )
  6. var shared string
  7. var sharedMu sync.Mutex
  8. func main() {
  9. // Start a goroutine to write to the shared variable
  10. go func() {
  11. for i := 0; i < 10; i++ {
  12. write(fmt.Sprintf("%d", i))
  13. }
  14. }()
  15. // read from the shared variable
  16. for i := 0; i < 10; i++ {
  17. read(fmt.Sprintf("%d", i))
  18. }
  19. }
  20. func write(value string) {
  21. sharedMu.Lock()
  22. defer sharedMu.Unlock()
  23. // set a new value for the `shared` variable
  24. shared = value
  25. }
  26. func read() {
  27. sharedMu.Lock()
  28. defer sharedMu.Unlock()
  29. // print the critical section `shared` to stdout
  30. fmt.Println(shared)
  31. }

如果我们看上面的例子,我们可以看到shared变量被互斥锁保护着。这意味着只有一个线程在一个时间点可以访问shared变量。这个保证了shared变量不被损坏,并且是一个可预计的行为。

注意: 在使用互斥锁时,需要注意的一个点是,要在函数返回的时候释放互斥锁。在 Go,举个例子,这个操作可以通过关键字defer实现。这个保证了其他线程可以访问到共享资源。

信号量

信号量是一种类型的屏障,允许一个时间点一定数量的线程访问共享资源。这个和互斥锁的区别在于,访问资源的线程数量不会被限制为 1 个。

在 Go 标准库没有信号的实现,但是可以通过 channels5来实现。

忙等待

忙等待是一个技术用于线程等待一个满足的条件。通常用于等待一个计数器达到某个数值。

  1. // Example of Busy Waiting in Go
  2. var x int
  3. func main() {
  4. go func() {
  5. for i := 0; i < 10; i++ {
  6. x = i
  7. }
  8. }()
  9. for x != 1 { // Loop until x is set to 1
  10. fmt.Println("Waiting...")
  11. time.Sleep(time.Millisecond * 100)
  12. }
  13. }

因此,忙等待需要一个等待条件满足的循环,该循环对共享资源进行读取或写入,必须由一个互斥锁来保护以确保正确的行为。

上面例子的问题是那个循环在访问一个没有被互斥锁保护的临界区。这可能导致竞态,这个循环读取的数值可能已经被另一个进程里的线程修改了。事实上,上面的例子是一个很好的竞态例子。很有可能这个应用程序永远都不会退出,因为无法保证这个循环是否会足够快地读取到x的数值,同时读取出来的数值都是1,这就意味着循环永远不会退出。

如果我们要用互斥锁保护变量x,那么循环就会被保护并且应用程序会退出,但这仍然不完美,设置x的循环仍然可以快到在读取值的循环执行之前击中互斥锁两次(尽管不太可能)。

  1. import "sync"
  2. var x int
  3. var xMu sync.Mutex
  4. func main() {
  5. go func() {
  6. for i := 0; i < 10; i++ {
  7. xMu.Lock()
  8. x = i
  9. xMu.Unlock()
  10. }
  11. }()
  12. var value int
  13. for value != 1 { // Loop until x is set to 1
  14. xMu.Lock()
  15. value = x // Set value == x
  16. xMu.Unlock()
  17. }
  18. }

通常情况下忙等待不是一个好的想法。最好的办法是使用信号或者一个互斥锁去确保临界区是受保护的。 我们将介绍在 Go 中处理这个问题的更好方法,但它说明了编写 “正确的”可并行代码的复杂性。

等待组 (Wait Groups)

等待组是一个用来保证所有并行代码路径在继续之前完成处理的方法。在 Go 里,这个用标准库中的sync包中提供的sync.WaitGroup来实现。

  1. // Example of a `sync.WaitGroup` in Go
  2. import (
  3. "sync"
  4. )
  5. func main() {
  6. var wg sync.WaitGroup
  7. var N int = 10
  8. wg.Add(N)
  9. for i := 0; i < N; i++ {
  10. go func() {
  11. defer wg.Done()
  12. // do some work
  13. }()
  14. }
  15. // wait for all of the goroutines to finish
  16. wg.Wait()
  17. }

在上面这个例子的wg.Wait()是一个阻塞调用。这个表示主线程会等到所有协程完成后再继续执行,并且对应的defer wg.Done()已经被调用。WaitGroup 的内部实现是一个计数器,当每个协程在调用wg.Add(N)后会加 1,同时协程被加到 WaitGroup 内。当计数器计到 0,主线程会继续执行或者在这个例子中会退出。

什么是并发?

并发和并行经常混为一谈。为了更好地理解并发和并行的区别,让我们看一个现实生活中的并发例子。

如果我们用餐厅来当做例子,餐厅里面会有几种不同工作类型 (或可复制的程序) 的组别。

  1. 接待(负责为客人安排座位)
  2. 服务员(负责接单,并提供食物)
  3. 厨房(负责烹饪食物)
  4. 售货员(负责清理桌子
  5. 洗碗工(负责清理餐具)

每个组别负责不同的任务,所有这些任务的最终结果都是让顾客吃到一顿饭。这称之为并发,专门的工作中心,可以专注于单独的任务,这些任务结合起来就会产生一个结果。

如果餐厅只雇佣一个员工来做所有的任务,这对于一个高效率的餐厅是一个限制。这称之为序列化。如果在餐厅里只有一个服务员,那么在一个时间只能够处理一个订单。

并行性是指将并发的任务分配到多个资源上的能力。在餐厅中,这可能会包含服务,食物准备和清理。如果有多个服务员,那么同一时间就可以处理多个订单。

每个组可以专注在他们自己的工作中心,不需要担心上下文切换,最大吞吐量,或最小延迟。

其他有同时进行的工作中心的行业例子包括工厂工人和装配线工人。从本质上讲,任何可以被分解成较小的可重复任务的过程都可以被认为是并发的,因此当使用合适的并发设计的时候可以被并行处理。

TL:DR:并发实现正确的并行,但是并行对并发代码不是必要的。6


  1. Andrew S. Tanenbaum and Herbert Bos, Modern Operating Systems (Boston, MA: Prentice Hall, 2015), 517. ↩︎
  2. Andrew S. Tanenbaum and Herbert Bos, Modern Operating Systems (Boston, MA: Prentice Hall, 2015), 86. ↩︎
  3. Benchmarking Process Fork vs Thread Creation on Ubuntu 18.04 ↩︎
  4. Flowgraph description of critical section - Kartikharia ↩︎
  5. Example semaphore implementation in Go ↩︎
  6. Concurrency is not Parallelism by Rob Pike ↩︎