Concurrency in GO CSP并发模型

并发(Concurrency)和并行(Parallelism)的区别

并发(Concurrency)理解为:单核 CPU 利用调度算法,快速的在多个任务之间切换执行,现代 CPU 的频率非常快,多任务看起来同时进行。


并发.png

并行(Parallelism)理解为:多任务是真正的在同时执行。


并行.png

CPU 是程序真正执行的地方,它由几个部分组成,其中最主要的部分被称为核心(Core),一个 CPU 核心同一时刻只能执行一个操作。这是一个非常明显的缺陷,因此,操作系统(OS)设计了一系列机制使得单核设备上也可以支持多任务(多进程、多线程)。其中最重要的技术称为抢占式多任务处理。

抢占式多任务处理是指:中断当前任务并切换到另一个任务,稍后再恢复到前一个任务执行。如果 CPU 只有一个核心,那么操作系统会将这个核心的计算能力分配给多个进程或线程,它们会在循环中一个接一个地执行。这种设计造成一种同时在执行多个任务的错觉,满足了并发,但不是真正的并行,因其并没有真正在同时运行多个任务。

现代 CPU 早已经不止一个核心,同一时刻每个核心都可以独立执行一个操作,这意味着,对于多核心 CPU,能够真正做到并行(Parallelism)。

现代操作系统能够支持多核 CPU:检测 CPU 核心数,为每个核心分配进程或线程。进线程可以分配到任意核心上执行,这个过程对正在运行的程序而言是完全透明的,不仅如此,仍然可以使用抢占式多任务机制,这样一来,系统可以运行的进线程,实际远超过物理硬件的核心数。

单核虽然无法做到真正的并行,但多线程编程仍是有意义的。当进程包含了多个线程时,由于抢占式多任务机制,即使其中某个线程执行缓慢或任务阻塞,也可以保持程序正常运行。

举个例子:运行的桌面程序从磁盘读取数据,读磁盘这一过程非常缓慢,如果程序只有一个线程,那么整个程序都会卡住无法响应直至磁盘读取结束,因为所有计算资源都分配给了唯一的线程,浪费在等待磁盘 IO 上了。

使用多线程技术重新设计这个程序:线程 A 访问磁盘,与此同时线程 B 负责主界面。当线程 A 由于读取磁盘进入等待时,线程 B 仍然可以保持界面作出响应,这里存在两个线程,操作系统可以在它们之间来回切换,而不会在阻塞的线程上卡住。

什么是 CSP

CSP 代表"Communicating Sequential Processes",1978年,Charles Antony Richard Hoare在计算机械协会(更通俗地称为ACM)上发表了这篇论文。
引用一下论文的摘要:

This paper suggests that input and output are basic primitives of programming and that parallel composition of communicating sequential processes is a fundamental program structuring method. When combined with a development of Dijkstra's guarded command, these concepts are surprisingly versatile. Their use is illustrated by sample solutions of a variety of a familiar programming exercises.

本论文认为输入和输出是编程的基本原语,通信顺序进程的并行组合是一种基本的程序结构方法。 当与 Dijkstra 的守卫命令的发展相结合时,这些概念的用途非常广泛。 各种熟悉的编程练习的示例解决方案说明了它们的使用。

为了进程之间的通信,Hoare 创建了输入和输出命令: ! 用于将输入发送到进程中,以及 ? 用于读取进程的输出。 每个命令必须指定一个输出变量(在从进程中读取变量的情况下)或目的地(在将输入发送到进程的情况下)。感兴趣的可以看下参考资料里的 PDF。


csp.png

熟悉 GO 语言的一看就知道,这与 Channel 的实现非常相似。
Go 是第一批将 CSP 原理融入其核心的语言之一,并将这种并发编程风格带给了大众。

Go 的并发哲学

GO 关于并发有这么一句谚语:Do not communicate by sharing memory. Instead, share memory by communicating.(不要通过共享内存进行通信。 相反,通过通信共享内存。 )

很多初学者甚至留下 GO 中只支持 CPS 形式的并发模型。GO 中不仅提供了基于 CSP 的并发模型,还支持很多传统的基于内存同步访问的同步原语。比如 sync 包的结构和方法提供了诸如锁、资源池、抢占 goroutine 等等。

选择使用哪种同步原语往往会带来一些困惑,sync 包的官方文档中也提到,sync 包提供了诸如互斥锁等多种基本的同步原语,但是除了 Once 和 WaitGroup,大部分都是在底层库中使用。通过通道和通信可以更好的实现高等级的同步。

Go 的 FAQ 中提到低级同步和原子原语可以在 sync 和 sync/atomic 包中使用。 这些包适用于增加引用计数或保证小规模互斥等简单任务。对于更高级的操作,比如并发服务器之间的协调,更高级的技术可以带来更好的程序,Go通过它的 goroutine 和通道支持这种方法。 例如,您可以构造您的程序,使每次只有一个 goroutine 负责特定的数据。

还有许多文章、讲座和采访,其中 Go 核心团队的各个成员都支持 CSP 风格,而不是像 sync.Mutex 这样的原语。可以看到某种程度上 Go 是推荐使用 CSP 风格构建并发程序,但是会在非官方库中看到基于内存同步访问的同步原语,看到人们抱怨过度使用通道,还听到一些 Go 团队成员表示可以使用它们。

这里引用 Go Wiki 里的一段描述:

One of Go's mottos is "Share memory by communicating, don't communicate by sharing memory."
That said, Go does provide traditional locking mechanisms in the sync package. Most locking issues can be solved using either channels or traditional locks.
So which should you use?
Use whichever is most expressive and/or most simple.

大部分锁问题可以通过 channel 或者 传统的锁解决,那你应该使用哪一个?使用最具表现力或最简单的。

什么是最具表现力或最简单的,如何理解,分辨的边界是什么?决策树走起。。。


go.png

Are you trying to transfer ownership of data?(你想转移数据的所有权吗?)
如果你有一段代码产生结果,并希望与另一段代码共享该结果,那么你真正要做的是转移该数据的所有权。 使并发程序安全的一种方法是确保一次只有一个并发上下文拥有数据的所有权。 通道就可以派上用场。

这样做的一大好处是你可以创建缓冲通道(buffered channel)来实现轻量的内存队列,从而将生产者与消费者解耦。 另一个好处是通过使用通道,隐式地使并发代码可与其他并发代码组合。

Are you trying to guard internal state of a struct?(你是否试图保护结构体的内部状态?)
这是一个应该使用内存访问同步原语而不是通道的典型场景。你可以通过使用内存访问同步原语达到对调用者隐藏锁定临界区的实现细节,并且不会将这种复杂性暴露给调用者。
这是一个线程安全的例子:

type Counter struct {
    mu    sync.Mutex
    value int
}

func (c *Counter) Increment() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.value++
}

如果你看了之前关于原子性的介绍,就可以看出我们在这里定义了一个 Counter 类型的原子性上下文,调用 Increment() 就是原子的。

注意关键字内部,把锁暴露给结构体的外部是一件危险的事情。尽量限制锁的粒度在一个较小的范围有助于程序的性能。

Are you trying to coordinate multiple pieces of logic?(你是否正在尝试协调多个逻辑?)
通道比内存访问同步原语更具有组合性。相比于将锁分散在各个对象中,到处使用通道明显更胜一筹。可以很方便的组合通道,但是没办法简单的组合锁以及方法的返回值。如果使用通道,由于 Go 的 select 语句以及它们作为队列和安全传递的能力,你会发现控制软件中出现的紧急复杂性要容易得多。 如果你发现很难弄清楚你的并发代码如何工作,为什么会出现死锁和竞态等问题时,或许你该使用通道来构建你的程序了。

Is it a performance-critical section?(它是性能瓶颈吗?)
这绝对不意味着:我希望我的程序能够性能更高,所以我只使用内存访问同步原语。相反,如果你通过性能分析结果证明这是一个主要瓶颈,比程序的其余部分慢几个数量级,使用内存访问同步原语或许可以解决一些性能问题。因为通道的底层实现就使用了内存访问同步原语 mutex ,所以通道只会更慢。在考虑这一点优化之前,性能瓶颈部分的代码可能需要重构了。

以上已经给出该选择 CSP 风格的通道还是内存访问同步原语来构建并发程序的一些最佳实践。像线程池等这些在使用 OS 线程为抽象的并发模型中较好的实践,使用 Go 的一个很好的经验法则就是放弃这些模式,因为这些抽象主要针对的是线程的优缺点。并不是完全没用,在 Go 中只有一些极端场景才能用到 goroutine 池来复用 goroutine。

坚持用 goroutines 为你的问题建模,用它们来代表你的工作流程的并发部分,并不需要担心启动他们的开销。在你考虑硬件能支持的 goroutines 上限的问题的之前,你可能需要考虑的项目重构。

Go 的并发哲学可以总结如下:以简单为目标,尽可能使用通道,不用过早得考虑 goroutine 的资源消耗。

水平有限,有不对的地方欢迎提出修改意见。

参考资料

Communicating sequential processes
CSP-2up.pdf
MutexOrChannel · golang/go Wiki · GitHub
《Concurrency in GO》作者: Katherine Cox-Buday

你可能感兴趣的:(Concurrency in GO CSP并发模型)