前言
又开新书了,按照原来的计划,其实这次应该读的是 Kafka 相关的。
但奈何计划赶不上变化。
前几天给鱼排论坛写了聊天室的消息分发节点 rhyus-golang,纯纯的多线程需要考虑并发的应用(虽然并发不高)。
所以变成了并发相关的书。
这篇计划阅读前两章,是关于并发概念的。
第一章 - 并发概述
书中前面关于摩尔定律、Web Scale和云计算相关的话题就跳过了。
为什么并发很难?
众所周知,并发代码是很难正确构建的。它通常需要完成几个迭代才能让它按预期的方式工作,即使这样,在某些时间点(更高的磁盘利用率、更多的用户登录到系统等)到达之前,bug在代码中存在数年的事情也不少见,以至于以前未被发现的bug在后面显露出来。
这段话我是有点体会的,上面的节点程序我迭代了很多次,很多问题都得在高并发或者高负载时才会出现。
竞态条件
当两个或多个操作必须按正确的顺序执行,而程序并未保证这个顺序,就会发生竞争条件。
大多数情况下,这将在所谓的数据中出现,其中一个并发操作尝试读取一个变量,而在某个不确定的时间,另一个并发操作试图写入同一个变量。
下面是一个基本示例:
1 | package main |
这里,第8行和第11行都试图访问变量data,但并不能保证以什么顺序进行访问。
运行这段代码有三种可能的结果:
- 不打印任何东西。在这种情况下,第8行在第10行之前执行。
- 打印“the value is 0.”。在这种情况下,第10行和第11行在第8行之前执行。
- 打印“the value is 1.”。在这种情况下,第8行在第10行之前执行,但第8行在第11行之前执行。
如你所见,虽然只有几行不正确的代码,但在你的程序中引入了巨大的不确定性。
在并发代码中定位问题是非常困难的,需要考虑到各种可能出现的情况。
有时候想象在两个操作之间会经过很长一段时间很有帮助。
假设调用goroutine的时间和它运行的时间相差1h。那程序的其余部分将如何运行呢?
如果在goroutine执行成功和程序执行到if语句之间也花费了一小时,又会发生什么呢?
以这种方式思考对我很有帮助,因为对于计算机来说,规模可能不同,但相对的时间差异或多或少是相同的。
所以有时候,会出现下面的代码(在程序中大量使用休眠语句)
1 | package main |
我们的数据竞争问题解决了吗?并没有。事实上,在这个程序中之前的三个结果仍然有可能出现,只是可能性更小了。
我们在调用 goroutine 和检查数据值之间的休眠的时间越长,我们的程序就越接近正确,但那只是概率上接近逻辑的正确性:它永远不会真的变成逻辑上的正确。
除此之外,这让我们的算法变得低效。我们现在不得不休眠1s,来降低我们的程序出现数据竞争的可能。
所以,我们应该始终以逻辑正确性为目标。在代码中引人休眠可以方便地调试并发程序,但这并不能称之为一个解决方案。
竞争条件是最难以发现的并发bug类型之一,因为它们可能在代码投人生产多年之后才出现。
通常代码正在执行时环境产生变化,或发生了某些罕见的事情,都有可能使其浮现出来。
往往代码只是看上去在用正确的方式来执行,但是事实上只是执行的顺序是正确的这件事本身的概率比较大而已,最终早晚有可能会出现一些意想之外的结果。
原子性
当某些东西被队为是原子的,或者具有原子性的时候,这意味着在它运行的环境中,它是不可分割的或不可中断的。
那么这到底意味着什么,为什么在使用并发代码时知道这一点很重要?
第一件非常重要的事情是“上下文(context)”这个词。
可能在某个上下文中有些东西是原子性的,而在另一个上下文中却不是。
在你的进程上下文中进行原子操作在操作系统的上下文中可能就不是原子操作;
在操作系统环境中原子操作在机器环境中可能就不是原子的,在你的机器上下文中原子操作在你的应用程序的上下文中可能不是原子的。
换句话说,操作的原子性可以根据当前定义的范围而改变。这种特性对你来说有利有弊!
在考虑原子性时,经常第一件需要做的事就是定义上下文或范围,然后再考虑这些操作是否是原子性的。一切都应当遵循这个原则。
术语“不可分割”(indivisible)和“不可中断”(uninterruptible)。
这些术语意味着在你所定义的上下文中,原子的东西将被完整的运行,而在这种情况下不会同时发生任何事情。
这仍然是一个整体,所以我们来看一个例子:i++
这是一个任何人都可以设计的简单例子,但它很容易证明原子性的概念。它
可能看起来很原子,但是简要地分析一下就会发现其中有以下步骤:
- 检索i的值。
- 增加i的值。
- 存储i的值。
尽管这些操作中的每一个都是原子的,但三者的结合就可能不是,这取决于你的上下文。
这揭示了原子操作的一个有趣的性质:将它们结合并不一定会产生更大的原子操作。
使一个操作变为原子操作取决于你想让它在哪个上下文中。
如果你的上下文是一个没有并发进程的程序,那么该代码在该上下文中就是原子的。
如果你的上下文是一个goroutine,它不会将i暴露给其他 goroutine ,那么这个代码就是原子的。
为什么我们要关心这些呢?原子性非常重要,因为如果某个东西是原子的,隐含的意思是它在并发环境中是安全的。这使我们能够编写逻辑上正确的程序,并且这甚至可以作为优化并发程序的一种方式。
但大多数语句不是原子的,更不用说函数、方法和程序了。后面会通过各种方法来调和这个矛盾。
内存访问同步
假设有这样一个数据竞争:两个并发进程试图访问相同的内存区域,它们访问内存的方式不是原子的。
将之前的数据竞争的例子稍作修改就可以说明:
1 | package main |
在这里添加了一个e1se子句,所以不管数据的值如何,我们总会得到一些输出。请记住,正如之前所介绍,如果有一个数据竞争存在,那么该程序的输出将是完全不确定的。
实际上,程序中需要独占访问共享资源的部分有一个专有名词,叫临界区(critical section)。
在这个例子中,我们有三个临界区:
- 我们的 goroutine 正在增加数据变量。
- 我们的 if 语句,它检查数据的值是否为0。
- 我们的 fmt.Printf 语句,在检索并输出数据的值。
有很多方法可以保护你的程序的临界区,go语言在设计时有一些更好的想法来解决这个问题,不过解决这个问题的其中一个办法是在你的临界区之间内存访问做同步。(加锁)
下面的代码不是Go语言中惯用的方法(我不建议像这样解决你的数据竞争问题),但它很简单地演示了内存访问同步。
1 | package main |
在这里添加一个变量 memoryAccess,它将允许我们的代码对内存数据的访问做同步。(锁,后续介绍)
这样虽然解决了数据竞争,但是并没有解决竞争条件。即这个程序的操作顺序仍然是不确定的,只是缩小了非确定性的范围。
从表面上看,这似乎很简单:如果你发现你的代码中有临界区,那就添加锁来同步内存访问!
虽然通过内存访问同步来解决一些问题,但正如我们刚刚看到的,它不会自动解决数据竞争或逻辑正确性问题。此外,它也可能造成维护和性能问题。
还有个问题就是 Lock 的调用会使我们的程序变慢。每次执行这些操作时,我们的程序就会暂停一段时间。
这会带来两个问题:
- 临界区是否是频繁进入和退出?(锁竞争)
- 临界区应该有多大?(锁的粒度)
在程序的上下文中解决这两个问题是一种艺术,并且增加了内存访问同步的难度。
死锁、活锁和饥饿
死锁
死锁程序是所有的并发进程彼此等待的程序。在这种情况下,如果没有外界的干预,这个程序将永远无法恢复。
这听起来很严峻,那是因为的确如此!Go语言的运行时会尽其所能,检测一些死锁(所有的goroutine必须被阻塞,或者“asleep”),但是这对于防止死锁并没有太多的帮助。
下面是一个死锁的例子:
1 | package main |
运行会报错 fatal error: all goroutines are asleep - deadlock!
事实证明,出现死锁有儿个必要条件。I971年,Edgar Coffman 在一篇论文中列举了这些条件。这些条件现在被称为 Coffman 条件,是帮助检测、防止和纠正死锁的技术依据。
Coffman 条件如下:
- 互斥条件 - 并发进程同时拥有资源的独占权。
- 占有并等待条件 - 并发进程必须同时拥有一个资源,并等待额外的资源。
- 非抢占条件 - 并发进程拥有的资源只能被该进程释放,即可满足这个条件。
- 循环等待条件 - 一个并发进程(P1)必须等待一系列其他并发进程(P2),这些并发进程同时也在等待进程(P1),这样便满足了这个最终条件。
这些规则也帮助我们防止死锁。如果确保至少有一个条件不成立,我们可以防止发生死锁。不幸的是,实际上这些条件很难推理,因此很难预防。
活锁
活锁是正在主动执行并发操作的程序,但是这些操作无法向前推进程序的状态。
你曾经在走廊走向另一个人吗?她移动到一边让你通过,但你也做了同样的事情。所以你转到另一边,但她也是这样做的。想象一下这个情形永远持续下去,你就明白了活锁。这个倒是经常发生
下面是一个活锁的例子,就是上面所说的情况:
1 | package main |
输出如下:
1 | Alice is trying to scoot: left right left right left right left right left right |
你可以看到,Alice和Barbara在最终退出之前,会持续竞争。
- tryDir 函数允许一个人尝试向一个方向移动,并返回是否成功。dir 表示试图朝这个方向移动的人数。
- 首先,宣布尝试向这个方向移动,atomic 使 dir 原子操作加一。
- takeStep 函数调用 cadence.Wait 来等待其他并发进程,用以同步每个协程的节奏。因为要演示活锁,每个人都必须以相同的速度或节奏移动。
- 当意识到不能向这个方向走时,便放弃。将 dir 减一。
- walk 函数中,人为限制了尝试的次数,否则,它将会一直无意义执行下去。
- walk 中,会尝试向左走,如果失败,则尝试向右走。
这个例子演示了使用活锁的一个十分常见的原因:两个或两个以上的并发进程试图在没有协调的情况下防止死锁。
这就好比,如果走廊里的人都同意,只有一个人会移动,那就不会有活锁;一个人会站着不动,另一个人会移到另一边,他们就会继续移动。
在我看来,活锁要比死锁更复杂,因为它看起来程序好像在工作。
如果一个活锁程序在你的机器上运行,那你可以通过查看CPU利用率来确定它是否在做处理某些逻辑,你可能会认为它确实是在工作。
根据活锁的不同,它甚至可能发出其他信号,让你认为它在工作。然而,你的程序将会一直上演“hallway-shuffle’”的循环游戏。
活锁是一组被称为“饥饿”的更大问题的子集。
饥饿
饥饿是在任何情况下,并发进程都无法获得执行工作所需的所有资源。
当我们讨论活锁时,每个goroutine的资源是一个共享锁。
活锁保证讨论与饥饿是无关的,因为在活锁中,所有并发进程都是相同的,并且没有完成工作。
更广泛地说,饥饿通常意味着有一个或多个贪婪的并发进程,它们不公平地阻止一个或多个并发进程,以尽可能有效地完成工作,或者阻止全部并发进程。
下面的例子有一个贪婪的 goroutine 和一个平和的 goroutine:
1 | package main |
输出如下:
1 | Greedy worker was able to execute 33 work loops. |
贪婪的 worker 会贪婪地抢占共享锁,以完成整个工作循环,而平和的 worker 只会在必要时锁定。
两种 worker 都做到同样的工作(sleep 3 ns),但贪婪的 worker 工作量会比平和的 worker 多得多。
通过记录和采样确定进程工作速度是否符合预期,可以发现和解决饥饿。
所以,饥饿会导致你的程序表现不佳或不正确。前面的示例演示了低效场景,但是如果你有一个非常贪婪的并发进程,以至于完全阻止另一个并发进程完成工作,那么你就会遇到一个更大的问题。
我们还应该考虑到来自于外部过程的饥饿。请记住,饥饿也可以应用于CPU、内存、文件句柄、数据库连接:任何必须共享的资源都是饥饿的候选者。
第二章 - 对你的代码建:通信顺序进程
并发与并行的区别
并发属于代码,并行属于一个运行中的程序。
这个区别其实仔细体会一下,描述得很清楚。
比如,我写了一个两部分可以并行运行的程序,但我却在一个只有一个核心的机器上运行它。那么同一时刻只会有一个部分被执行,他不可能是并行的。但如果是多个核心的机器,那么两个部分就可以并行执行。
但这两台机器上我们都可以认为它是并发的,因为并发是一段时间内两个部分都被执行就认为是并发的。(当然,这句是我以前的观点。学习操作系统进程相关知识时的观点)
但这章中的观点是:并行是一个时间或者上下文的函数。
上一章中,上下文被定义为一个操作被认为是原子性的界限。这里,上下文定义为两个或以上的操作被认为是并行的界限。
比如,我们的上下文是一段5s的时长,执行了两个分别消耗1s的操作,我们应该认为这些操作是并行执行的。但如果我们的上下文是1s,应该认为这些操作是分别运行的。
对于我们来说,用不同的时间对上下文的概念进行重定义并不是一件好事,但是请记住上下文和时间并设有关系。
我们可以把上下文定义成我们程序所在运行的进程,一个操作系统的线程,或者是一台机器。
这很重要,因为你所定义的上下文是和并发性以及正确性密切相关。
就像原子操作可以按照你所定义的上下文来定义是否为原子性,并发操作也依据你所定义的上下文来确定正确性。一切都是相关的。
什么是 CSP
CSP即“Communicating Sequential Processes’”(通信顺序进程),既是个技术名词,也是介绍这种技术的论文的名字。
在1978年,Charles Antony Richard Hoare在Association for Computing Machinery(一般被称作ACM)中发表的论文。
在这篇论文里,Hoar认为输入与输出是两个被忽略的编程原语,尤其是在并发代码中。
在Hoare写作这篇论文的同时,关于如何架构程序的相关研究还在进行中,但是大部分的研究都是针对编写顺序代码的方法:goto语句的使用正在被讨论,面向对象范型正在成为编程的基石。并发操作并没有被给予过多的思考。
Hoare开始纠正这个现象,所以,关于CSP的这篇论文就横空出世了。
在1978年的论文中,CSP仅是一个完全用来展示通信顺序进程的能力的一个简单的编程语言。
事实上,他甚至在论文中写道:因此,本文介绍的概念和符号应该...不被认为适合作为一种编程语言,无论是抽象的还是具体的编程。
而且正是因为CSP的原始论文以及从论文中进化而来的原语正是Go语言并发模型的主要灵感,而这正是我们接下来所要聚焦的。
用来支撑他关于输入与输出需要被按照语言的原语来考虑,Hoare的CSP编程语言包含用来建模输入与输出,或者说“在进程间正确通信”(这就是论文名字的由来)的原语。
为了在进程之间进行通信,Hoar心创造了输入与输出的命令:!代表发送输入到一个进程,?代表读取一个进程的输出。
每一个指令都需要指定具体是一个输出变量(从一个进程中读取一个变量的情况),还是一个目的地(将输入发送到一个进程的情况)。
有时,这两种方法会引用相同的东西,在这种情况下,这两个过程会被认为是相对应的。
换言之,一个进程的输出应该直接流向另一个进程的输入。
这种语言同时利用了一个所谓的守护命令,也就是Edgar Dijkstra在一篇之前在I974年所写的论文中介绍的,“Guarded commands,nondeterminacy and formal derivation of programs’”。
一个有守护的命令仅仅是一个带有左和右倾向的语句,由一来分割。左侧服务是有运行条件的,或者是守护右侧服务,如果左侧服务运行失败,或者在一个命令执行后,返回false或者退出,右侧服务永远不会被执行。
将这些与Hoare的I/O命令组合起来,为Hoare的通信过程奠定了基础,从而实现了channel。
经验判断Hoare的建议是正确的,然而,有趣的是,在Go语言发布之前,很少有语言能够真正地为这些原语提供支持。
大多数流行的语言都支持共享和内存访问同步到CSP的消息传递样式。 当然也有例外,但不幸的是,这些都局限于没有广泛采用的语言。
Go语言是最早将CSP的原则纳人共核心的语言之一,并将这种并发编程风格引入到大众中。它的成功也使得其他语言尝试添加这些原语。内存访问同步并不是天生就不好。
在Go语言中,甚至有时共享内存在某些情况下是合适的。但是,共享内存模型很难正确地使用,特别是在大型或复杂的程序中。
正是由于这个原因,并发被认为是Go语言的优势之一,它从一开始就建立在CSP的原则之上,因此很容易阅读、编写和推理。
Go语言的并发哲学
CSP一直都是Go语言设计的重要组成部分。然而,Go语言还支持通过内存访问同步和遵循该技术的原语来编写并发代码的传统方式。sync与其他包中的结构体与方法可以让你执行锁,创建资源池取代goroutine等。
能够在CSP原语和内存访问同步之间选择对于你来说很棒,因为它让你去编写解决问题的并发代码上有了更多选择,但这可能显得有些莫名其妙。
Go语言的初学者总是认为CSP样式编写并发代码是Go语言编写并发代码的唯一方式。
比如说,在sync包的文档中,有如下描述:
sync包提供了基本的同步基元,如互斥锁。除了Once类型和WaitGroup类型,大部分都是适用低水平程序线程,高水平的同步使用channel通信更好一些。
在Go语言的FAQ中,有如下陈述:
为了尊重mutex,sync包实现了mutex,但是我们希望Go语言的编程风格将会鼓励人们尝试更高等级的技巧。尤其是考虑构建你的程序,以便一次只有一个goroutine负责某个特定的数据。
不要通过共享内存进行通信。相反,通过通信来共享内存。有数不清的关于Go语言核心图队的文章、讲座和访谈,相对于使用像sync.Mutex这样的原语,他们更加拥护CSP。
因此,Go语言团队为什么选择公开内存访问同步原语会感到困惑是完全可以理解的。更令人因惑的是,你通常会在外面看到出现的同步原语。
见到人们抱怨过度使用channel,也会听到一些Go语言团队成员说使用它们是“OK”的。
Go语言的维基上有一个关于此的引用:
Go语言的一个座右铭是,“使用通信来共享内存,而不是通过共享内存来通信。”
这就是说,Go语言确实在syc包中提供了传统的锁机制。大多数的锁问题都可以通过channel或者传统的锁两者之一来解决。
所以说,我该用哪个?
使用最好描述和最简单的那个方式。
这是很好的建议,也是你在使用Go语言时经常看到的谁则,但它有点含糊。
我们如何理解什么更具表现力、更简单?我们应该使用什么标准?
幸运的是,我们可以使用一些标准来帮助我们做正确的事情。
正如我们将看到的那样,我们主要的区分方式来自于试图管理并发的地方:主观地想象一个狭窄的范围,或者在我们的系统外部。
让我们逐步来介绍这些决策:
你想要转让数据的所有权么?
如果你有一块产生计算结果并想共享这个结果给其他代码块的代码,你所实际做的事情是传递了数据的所有权。
如果你对内存所有制且不支持GC的语言很熟悉的话,对于这个概念你应该是很熟悉的:数据拥有所有者,并发程序安全就是保证同时只有一个并发上下文拥有数据的所有权。
channel通过将这个意图编写进channel类型本身来帮助我们表达这个意图。
这么做的一个很大的好处就是可以创建一个带缓存的channel来实现一个低成本的在内存中的队列来解耦你的生产者与消费者。
另一个好处就是通过使用channel确保你的并发代码可以和其他的并发代码进行组合。
你是否试图在保护某个结构的内部状态?
这时候内存访问同步原语的一个很好的选择,也是一个你不应该使用channel的很好的示例。
通过使用内存访问同步原语,可以为你的调用者隐藏关于重要代码块的实现细节。
这是一个线程安全类型的小例子,且不会给调用者带来复杂性:
1 | package main |
如果你能回想起关于原子性的细节,可以说我们在这里所做的就是定义了Counter类型的原子性范围。调用增量可被认为是原子的。
记住这里的关键词是“内部的”。如果你发现自己正在将锁暴露在一个类型之外,这时侯你应该注意了。试着将你的锁放在一个小的字典范围内。
你是否试图协调多个逻辑片段?
请记住,channel本质上比内存访问同步原语更具可组合性。
将锁分散在整个对象图中听起来像是一场噩梦,但是,将channel编写的随处可见是被鼓励以及期待的!
我可以组合channel,但是我不能轻易的组合锁或者有返回值的方法。
你会发现,因为Go语言的select语句,以及channel可以当作队列使用和被安全的随意传递。
所以,当在使用channel的时候,你可以更简单的控制你软件中出现的激增的复杂性。
如果你发现正在挣扎着理解你的并发代码是如何工作的,为什么会出现死锁以及竞争,而你正在只用原语,这是一个你应该切换到channel的好示例。
这是一个对性能要求很高的临界区吗?
这绝对不意味着“我想让我的程序拥有高性能,因此,我应该只是用mutex’”。
当然,如果你程序中的某部分,事实证明是一个主要的性能瓶颈,比程序的其他部分慢几个数量级,使用内存访问同步原语可能会帮助这个重要的部分在负载下执行。
这是因为channel使用内存访问同步来操作,因此它们只能更慢。
然而,在我们考虑这一点之前,性能至关重要的程序部分可能暗示着需要重新规划我们的程序。
希望这可以清楚地说明是否利用CSP风格的并发或内存访问同步。
还有其他一些模式和做法在使用操作系统线程作为并发抽象方式的语言中很有用。
在使用操作系统线程作为主要并发抽象的语言中还有其他的方式以及实践。
比如说,像是线程池之类的东西经常出现。因为这些抽象大多数都是为了利用操作系统线程的优点与缺点。
Go语言的并发性哲学可以这样总结:追求简洁,尽量使用channel,并且认为goroutine的使用是没有成本的。