查看原文
其他

Go 并行性和并发性:有什么不同?

程序员ug 幽鬼 2022-11-24

争做团队核心程序员,关注「幽鬼

大家好,我是程序员幽鬼。

并发和并行,Go 刚发布时,官方就不断强调这两点的不同。可能新手依然迷糊。这次给大家弄一个系列,详细讲解并发和并行。


软件中的并行性是同时执行指令。每种编程语言要么实现自己的库,要么提供语言级支持,如 Go。并行性允许软件工程师通过在多个处理器上并行执行任务来回避硬件的物理限制。

由于正确利用并行构建的复杂性,应用程序的并行性取决于构建软件的工程师的技能 。

并行任务示例:

  • 多人在餐厅点单
  • 杂货店的多个收银员
  • 多核 CPU

实际上,任何应用程序都存在多层并行性。有应用程序本身的并行度,由应用程序开发者定义,还有 CPU 在操作系统编排的物理硬件上执行的指令的并行度(或多路复用)。

1、并行性的构建

应用程序开发人员利用抽象来描述应用程序的并行性。这些抽象在实现并行性但概念相同的每种语言中通常是不同的。例如,在 C 中,并行性是通过使用 pthreads 来定义的 ,而在 Go 中,并行性是通过使用 goroutines 来定义的 。

进程

进程是一个执行单元,包括它自己的“程序计数器、寄存器和变量。从概念上讲,每个进程都有自己的虚拟 CPU”。理解这一点很重要,因为创建和管理进程会产生开销。除了创建进程的开销外,每个进程只能访问自己的内存。这意味着该进程无法访问其他进程的内存。

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

线程

引入线程是为了在同一进程内但在不同的并行执行单元上授予对共享内存的访问权限。线程几乎是它们自己的进程,但可以访问父进程的共享地址空间。

线程的开销远低于进程,因为它们不必为每个线程创建一个新进程,并且资源可以共享或重用。

以下是 Ubuntu 18.04 的示例,比较了 fork 进程和创建线程的开销:

# Borrowed from https://stackoverflow.com/a/52231151/834319
# Ubuntu 18.04 start_method: fork
# ================================
results for Process:

count    1000.000000
mean        0.002081
std         0.000288
min         0.001466
25%         0.001866
50%         0.001973
75%         0.002268
max         0.003365 

Minimum with 1.47 ms
------------------------------------------------------------

results for Thread:

count    1000.000000
mean        0.000054
std         0.000013
min         0.000044
25%         0.000047
50%         0.000051
75%         0.000058
max         0.000319 

Minimum with 43.89 µs
------------------------------------------------------------
Minimum start-up time for processes takes 33.41x longer than for threads.

临界区

临界区是进程中各种并行任务所需的共享内存区。这些部分可能是共享数据、类型或其他资源。

并行的复杂性

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

当多个线程同时访问共享内存时,会出现两个主要问题。

竞态条件

竞态条件是多个并行执行线程在没有任何保护的情况下直接读取或写入共享资源。这可能导致存储在资源中的数据可能被损坏或导致其他意外行为的情况。

例如,想象一个进程,其中单个线程正在从共享内存位置读取值,而另一个线程正在将新值写入同一位置。如果第一个线程在第二个线程写入值之前读取该值,则第一个线程将读取旧值。

这会导致应用程序未按预期运行的情况。

死锁

当两个或多个线程互相等待做某事时,就会发生死锁。这可能导致应用程序挂起或崩溃。

例如,一个线程针对一个临界区执行等待满足条件,而另一个线程针对同一临界区执行并等待来自另一个线程的条件满足。如果第一个线程正在等待满足条件,而第二个线程正在等待第一个线程,则两个线程将永远等待。

当试图通过使用互斥锁来防止竞争条件时,可能会发生第二种形式的死锁。

屏障(Barriers)

屏障是同步点,用于管理进程内多个线程对共享资源或临界区的访问。

这些屏障允许应用程序开发人员控制并行访问,以确保不会以不安全的方式访问资源。

互斥锁

互斥锁是一种屏障,它一次只允许一个线程访问共享资源。这对于在读取或写入共享资源时通过锁定解锁来防止竞争条件很有用。

// Example of a mutex barrier in Go
import (
  "sync"
  "fmt"
)

var shared string
var sharedMu sync.Mutex

func main() {

  // Start a goroutine to write to the shared variable
  go func() {
    for i := 0; i < 10; i++ {
      write(fmt.Sprintf("%d", i))
    }
  }()

  // read from the shared variable
  for i := 0; i < 10; i++ {
    read(fmt.Sprintf("%d", i))
  }
}

func write(value string) {
  sharedMu.Lock()
  defer sharedMu.Unlock()

  // set a new value for the `shared` variable
  shared = value
}

func read() {
  sharedMu.Lock()
  defer sharedMu.Unlock()

  // print the critical section `shared` to stdout
  fmt.Println(shared)
}

如果我们查看上面的示例,可以看到 shared 变量受到互斥锁的保护。这意味着一次只有一个线程可以访问该 shared 变量。这确保了shared 变量不会被破坏并且行为可预测。

注意:使用互斥锁时,确保在函数返回时释放互斥锁至关重要。例如,在 Go 中,可以通过使用defer关键字来完成。这确保了其他线程(goroutine)可以访问共享资源。

信号量

信号量是一种屏障,它一次只允许一定数量的线程访问共享资源。这与互斥锁的不同之处在于,可以访问资源的线程数不限于一个。

Go 标准库中没有信号量实现。但是可以使用通道来实现。

忙等待(busy waiting)

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

// Example of Busy Waiting in Go
var x int

func main() {
  go func() {
    for i := 0; i < 10; i++ {
      x = i
    }
  }()

  for x != 1 { // Loop until x is set to 1
    fmt.Println("Waiting...")
    time.Sleep(time.Millisecond * 100)
  }  
}

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

上述示例的问题是循环访问不受互斥锁保护的临界区。这可能导致循环访问该值但它可能已被进程的另一个线程更改的竞态条件。事实上,上面的例子也是竞态条件的一个很好的例子。这个应用程序可能永远不会退出,因为不能保证循环足够快以读取 x=1 时的值,这意味着循环永远不会退出。

如果我们用互斥锁保护变量x,循环将被保护,应用程序将退出,但这仍然不完美,循环设置x仍然足够快,可以在读取值的循环执行之前两次命中互斥锁(虽然不太可能)。

import "sync"

var x int
var xMu sync.Mutex

func main() {
  go func() {
    for i := 0; i < 10; i++ {
      xMu.Lock()
      x = i
      xMu.Unlock()
    }
  }()

  var value int
  for value != 1 { // Loop until x is set to 1
    xMu.Lock()
    value = x // Set value == x
    xMu.Unlock()
  }  
}

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

WaitGroup

WaitGroup 是确保所有并行代码路径在继续之前已完成处理的方法。在 Go 中,这是通过使用标准库中 sync.WaitGroup 来完成的。

// Example of a `sync.WaitGroup` in Go
import (
  "sync"
)

func main() {
  var wg sync.WaitGroup
  var N int = 10

  wg.Add(N)
  for i := 0; i < N; i++ {
    go func() {
      defer wg.Done()
      
      // do some work      
    }()
  }

  // wait for all of the goroutines to finish
  wg.Wait()
}

在上面的示例中,wg.Wait() 是一个阻塞调用。这意味着主线程将不会继续,直到所有 goroutine 中的 defer wg.Done() 都调用。在内部,WaitGroup 是一个计数器,对于添加到wg.Add(N)调用的 WaitGroup 中的每个 goroutine,它都会加一。当计数器为零时,主线程将继续处理,或者在这种情况下应用程序将退出。

2、什么是并发?

并发性和并行性经常混为一谈。为了更好地理解并发和并行之间的区别,让我们看一个现实世界中的并发示例。

如果我们以一家餐馆为例,那么就有几组不同的工作类型(或可复制的程序)发生在一家餐馆中。

  1. 主管(负责安排客人入座)
  2. 服务员(负责接单和提供食物)
  3. 厨房(负责烹饪食物)
  4. Bussers(负责清理桌子)
  5. 洗碗机(负责清理餐具)

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

如果餐厅每项任务只雇用一个人,餐厅的效率就会受到限制。这称为序列化。如果餐厅只有一个服务员,那么一次只能接受一个订单。

并行性是处理并发任务并将它们分布在多个资源中的能力。在餐厅,这将包括服务员、食物准备和清洁。如果有多个服务器,则可以一次接受多个订单。

每个小组都能够专注于他们的特定工作中心,而不必担心上下文切换、最大化吞吐量或最小化延迟。

具有并行工作中心的行业的其他示例包括工厂工人和装配线工人。本质上,任何可以分解为更小的可重复任务的过程都可以被认为是并发的,因此在使用适当的并发设计时可以并行化。

TL;DR: 并发可以实现正确的并行性,但并行代码不需要并行性。

原文链接:https://benjiv.com/parallelism-vs-concurrency/




往期推荐


欢迎关注「幽鬼」,像她一样做团队的核心。



您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存