《并发哲学:从编程入道到开悟升天》4.1 谈并发实现为何简单而又困难

谈并发实现为何简单而又困难

你肯定已经不再是只知道掌握并发重要性的人了。通过对投掷者模型和包工头模型的贯彻落实,你也明白了并发思想起源于人类社会发展过程中在人-物、人-人两类要素的顺序作业之间协调的艺术。随后通过一门简单易懂,对并发超简洁抽象的语言——Golang,来掌握了借助基本并发要素,构建可承接伸缩需求任务的并发程序。你一定现在有很多的问题,有自身的疑惑,有对背后机制和原理探索的渴望,甚至对书本知识点内容不全的质疑。

带着这些问题,本章,我们开始问道。

从计算机编程发展至今,已经走过几十年的历史。人们不禁要问,为何在计算机发展早期,编程语言的并发特性并没有很好的跟上人们朴素生活流程的抽象,正如编程初学者接触到的那些与多线程、并发丝毫不沾边的代码——细细想来这种代码它很难表示生活中这些紧密组织却井然有序的并行事件。

而在流程控制上决断性高效跳转goto原语的诞生、万物皆可对象——面向对象思想的诞生,这些现在看来也是革命性里程碑思想诞生的时代,并发的变革也迟迟没有到来,似乎存在着某种禁忌,人们一直避讳不谈。甚至现代并发研究的始祖论文,由Hoare提出的CSP理论模型已经发表之时(1978),并发相关的研究也并未得到学术界广泛的响应并进一步开展大规模的进步研究。

当大数据、人工智能这些未来的概念渐渐诞生,多核CPU技术成为事实标准的时候,并发特性在编程语言上支持现象才缓慢出现。C++、java等高级编程语言开始支持并发,但也只是好比亡羊补牢,打补丁的方式总令人感觉技术人对并发相关技术带着偏见。许许多多的人们,已经明白并行作业可以减轻单台压力,却丝毫不重视分配任务上的效率问题……

直到当并行作业的风潮带来了云计算的发展,面对跨越全球,通过网络便触手可达的海量计算资源,这时候现***人员才发现了自己的不知所措。现有颇有赶鸭子上架之风的并发语言生态,面对程序员日益高涨关于面对海量请求处理代码的建模需求,已经远远不能满足需求。人们天生就会投掷,这是进化带给人们的礼物,但并不意味着并行程序的运行也需要人们用远古的“投掷”方式一块一块搬到不同的执行单元上。更别提我们无法大规模扩展并行计算资源来支撑越来越廉价的网络请求。越来越细化的切换单元,越来越灵活的基础架构,基于编程的“投掷”能力,亟待进化。

2005年,ISO C++标准委员会主席,C++/CLI首席架构师Herb Sutter为Dobb(不是阿里巴巴高并发面向分布式开源java框架dubbo!)博士撰写了一篇文章,标题为“免费午餐结束:软件并发的根本转向”。 标题贴切,文章有先见之明。 Sutter最后表示,“我们迫切需要一种更高层次的并发性编程模型,而非当前语言所能提供给我们的。”

为何一个时***创的先贤,会在并发编程技术的推进上犯了拖延症,浪潮压头才缓慢推进几步?为何高级并发抽象直到CSP论文发布二十多年后才到来?现代并发抽象究竟需要解决什么问题?回答这些问题,我们首先就需要知道,如今看似简单的并发代码背后,更不为人知的种种“坑”。

数据竞争

当两个或更多的操作必须以正确的顺序执行时,就会出现竞争状态。

大多数时候,这出现在所谓的数据竞争中,其中一个并发操作尝试在某些未确定的时间读取变量,而另一个并发操作尝试写入同一个变量。如果你看过3.4节,想想A和B抢占一个没上锁的茅坑的场景,嗐,这太有味道了!

我们回过头来,看一个简单代码的例子:

var data int // 此时初始化完,data为0
go func(){
    data ++ // 1
}()
if data==0{ // 2
    fmt.Printf(data) // 打印data
}

在1和2处,都试图访问名为data的变量。按照思维惯性,我们总会觉得“先写的代码先执行”,因而会认为data在2处,一定已经经过了data++,从而不会触发if条件,但实际情况呢?我们按照第三章内的知识,分析一下这段代码,看其中的并发任务,有怎样的特征:

  • data++ 发生在1处开启的并发任务内
  • 并没有其他机制来保证这个并发任务的执行顺序

如果把1处开启的并发任务再一次比作包工头派出去的工人,对这个工人,没有任何监视,没有任何跟踪机制,你只知道给他派遣了什么任务,试问你可否直到他什么时候完成这个任务?

醒醒!你不是那个工人!它走了,你看不见他了!一切进入了未知!是不是毛骨悚然!

因而,上述的代码,会像薛定谔的猫一样,在最终执行结束结果出来之前,输出结果将不可能通过分析代码得到,换言之,这个程序的输出结果将处于“没有”,“0”,“1”的叠加态。

具体来说,这三种可能结果分别在下述情况发生:

  • 没有输出。因为1在2之前执行了,if没有触发。
  • 输出0。因为2和2之后的打印操作(打印data的时候,也取了data的值)在1之前执行。
  • 输出1。2在1之前执行,但是,打印时再次取data则在1之后执行,故此时data为1。

年轻的包工头不以为意,脚底充满赌徒气息。许多的程序员相信,没有监测,但我有“经验”。于是就有了如下的改进代码:

var data int // 此时初始化完,data为0
go func(){
    data ++ // 1
}()

time.Sleep(2*time.Second) // 我就觉得两秒后1应该能完事儿

if data==0{ // 2
    fmt.Printf(data) // 打印data
}

这些程序员惊奇的发现,面对没有控制的并发,在操作之间等待很长一段时间会很有很大帮助。事实也“出乎意料”的证明,只要我等待(time.Sleep)的时间够长,规律就在我的手里掌控!现在,在几乎所有计算机几乎所有的执行情况下,这段程序都可以“确保”2在1之后执行了。

但是,我们解决了数据竞争的问题吗?作为一个包工头,你在尝试和另外一个独立个体赌什么??答案是,我们这样做并没有解决数据竞争的问题!事实上,从这个方案中产生的所有三个结果仍然是可能的。我们在调用我们的goroutine和检查数据值之间的让程序休眠的时间越长,程序越接近实现正确性——但这只是在概率上渐近地接近逻辑正确而已。

除此之外,这样做已经在算法中引入了低效率。 程序现在必须休眠一秒钟才能使我们更有可能看不到的数据竞争。如果我们使用正确的方式来编写代码,我们可能无需等待,或者等待时间可能只有1微秒。

这里要说的是,你应该总是以逻辑的正确性为目标。 在你的代码中引入休眠可以是一种调试并发程序的方便方式,但不是解决方案。

数据竞争的产生条件是最隐秘的并发错误类型之一,因为它们可能在代码投入生产后才会展现出来。 正如飞来横祸大老板也有可能破产,黑天鹅和灰犀牛总归不确定哪个先来——这些程序内的“隐秘的角落”通常是由代码执行环境发生变化或前所未有的突发事件引起的。 在这些情况下,代码看起来行为正确,但实际上,这些操作按顺序执行的出现不确定性的几率非常高。

原子性问题

当某种东西被认为是原子性的或者具有原子性的时候,这意味着在它运行的环境中,它是不可分割的或不可中断的。

那么这到底意味着什么,为什么在使用并发代码时知道这很重要?

第一件非常重要的事情就是了解“上下文(context)”这个词。在某个特定的上下文中,有的操作可能是原子的,有的可能不是。 在你的流程环境中,原子状态的操作在操作系统环境中可能不是原子操作; 在操作系统环境中是原子的操作在你的机器环境中可能不是原子的; 并且在机器上下文中是原子的操作在应用程序上下文中可能不是原子的。 换言之,操作的原子性可以根据当前定义的范围而改变。是不是有点懵?

我们换一种说法吧,端茶、送水在一个公司的规章制度内,是一个原子化操作——公司也没法关心到太细致的东西。但是到业务员层面,端茶又可以分为捧起茶杯、举起茶杯、移动茶杯、放下茶杯几个原子化操作,送水也同样。再进一步深入,捧起茶杯又涉及到神经系统信号的发送、肌肉的运动几个原子化操作。再进一步深入,神经系统信号的发送又分为电信号接受,突触传导,间质递送,电信号再传导等几个原子化操作。

在不同的层面考虑的问题,往往最小单元是不一样的,尽管经过各种组合形成的宏观行为是等价的

在考虑原子性时,经常需要做的第一件事是定义上下文或作用域,通过这样的方式,来确定哪些操作将被视为原子性的。这是思考程序的基础。

可能抽象的概述又会被诟病令人糊涂,让我们直接来看一个例子:

i++

这应该是一个任何程序员都可以明白的简单代码,但它很容易用来阐述清楚原子性的概念。它看起来很原子吗?一句话嘛。但是,简单的一通分析下来,揭示了下面的几个操作:

  • 检索i的值
  • 增加i的值
  • 存储i的值

尽管这些操作中的每一个都是原子的,但三者的组合可能不是,这取决于你的上下文。 这揭示了原子操作的一个有趣特性:将小的原子性操作结合并不一定会产生更大的原子操作。 创建操作原子取决于你希望它在哪个上下文中处于原子状态。 如果你的上下文是一个没有并发任务的程序,那么这个代码在该上下文中是原子的。 如果你的上下文是一个不会将 i 暴露给其他协程的协程,那么这个代码是原子的。

为什么我们如此在意原子性?原子性非常重要,因为如果说某些东西是原子的,那么就隐式地意味着在并发环境中是安全的。这使我们能够编写逻辑上正确的程序。

如这个再简单不过的例子所体现的:大多数语句都不是原子的,更不用说函数,方法和程序了。如果原子性是组成逻辑上正确的程序关键,并且大多数语句不是原子的,那么我们如何调和这两个问题?稍后我们会深入探讨,但总之,我们可以通过采用各种技术来强制原子性。正如第三章内介绍的各种要素和范式,通过钳制信息流,框定作用域,原子性得到了逻辑保证。至于如何决定你的代码的哪些部分需要是原子的,以及需要划分到什么样的粒度,很大一部分程度取决于你自己。

内存访问同步

如果你已经熟读3.4节,不禁会感叹,幸亏我学习了sync.Mutex,于是,就有了如下的代码:

var memoryAccess sync.Mutex //1
var data int
go func() {
    memoryAccess.Lock() //2
    data++
    memoryAccess.Unlock() //3
}()

memoryAccess.Lock() //4
if data == 0 {
    fmt.Printf(data)
}
memoryAccess.Unlock() //5

许许多多的程序员从其他语言泊入Golang,往往还不习惯利用channel组合的艺术解决并发中遇到的问题。好在Golang提供sync包,包内有一系列拿来即用,广泛遍布,行之有效的基于内存访问同步的并发原语,正如sync.Mutex,通过构建一把锁,我们似乎“完美”的解决了数据竞争问题。

但请注意!我们只是解决了数据竞争可能导致的严重程序问题,我们解决了程序执行结果的不确定问题吗?没有!甚至说,我们连竞争这个问题也没有解决!只是这种不确定的叠加态,好比原先可能会存在A在拉屎的时候被B夺门而入的灾难,现在我们只是解决了灾难。如果要维持A和B指定的先后顺序,仅依托内存访问同步原语还是远远不够的……

A和B还是存在竞争。可见,可以通过内存访问同步来解决一些问题,但正如上面的代码,它不会自动解决数据竞争或者是逻辑正确性问题,此外,它可能还会导致维护和性能上的问题。简单说来,通过这种内存访问同步方式,会导致每次我们执行一项需要加锁的操作时,对于程序执行的业务逻辑来说,都暂停了一段时间,并且带来了额外的内存消耗。这样一来,不但原来的数据竞争问题没有完全解决,我们还引入了两个全新的问题:

  • 加锁的程序部分是否有重复进入和退出?
  • 加锁的程序对内存占用究竟有多大?

慌不慌,事情进入了越来越不可控的状态!

死锁、活锁、饥饿锁

宙斯为新世代的人们带来了潘多拉,也带来了潘多拉的盒子。锁也是如此。人们寄希望于通过锁机制解决资源抢占问题,让大家都进入到有序工作的状态,但当情绪遇上秩序,越来越多需要考虑的问题,伴随着标题所述的三大锁而萌生了出来。

首先考虑死锁。

正如名字所说,这听起来很严峻,事实上也确实很严峻。死锁泛指两个或两个以上的任务在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。在操作系统中,这样的概念被落实到进程和线程,而在Golang程序中,这样的概念也适用于协程。

回顾一下在3.3中看到的deadlock吧!如果所有协程都退出,只有主程的情况下,如果主程处于select{}或者是从一个不可能再产生数据的管道中取值,程序将会被强行中断,并提示产生死锁。分析这个案例,为什么会构成死锁呢?

  • select等待和chan取值分别需要信号触发和数据的送入
  • 其他协程的停止意味着主程之外不再有更多的可能性,意味着除了主程,不会再有其他信号触发和数据送入的可能性

此时,你会发现,上面两个条件带来了冲突,且冲突是不可解决的。

事实证明,出现这种僵局时必定存在一些条件,早在1971年,埃德加科夫曼在一篇论文中列举了这些条件。这些条件现在称为科夫曼条件,是帮助检测,防止和纠正死锁的技术基础。科夫曼条件如下:

  • 相互排斥
  • 等待条件
  • 没有抢占
  • 循环等待

科夫曼条件同样有助于我们规避死锁。如果我们确保至少有一个条件不成立,就可以防止发生死锁。不幸的是,实际上这些条件很难推理,因此难以预防。当局者迷,旁观者清,如果人人都能根据这些条件准确的分析出自己代码中的死锁问题,stackoverflow和思否上面也就不会有这么多的求助帖了。

死锁的问题或许还清晰可见,在没有运行时检查的机制下,程序陷入长时间没有动静的情况,程序员也能意识到可能产生了重大问题。而接下来要阐述的活锁则滑稽得多。

你骑着自行车,别人骑着电动车,现在你们狭路相逢了。你也往左边拐,他也往右边拐,眼看着就要撞上!于是,你开始往右边拐,但你以为他没有意识到危险吗?很明显,意识到了。于是,他也往左边拐。许许多多的事故就在大家都想要避开事故的时候,在高频率的躲避中,像命运一样的发生。

不过,活锁要讲述的问题,相比于生活中随处可见的这种交通事故,要缓和不少。实际上,活锁就好比你和对方都在尝试转向车头来避开危险,但其实不会继续前进——这么看来程序就比人聪明了。此时,双方都在高速运动(左右转头),但实际两辆车都停滞不前,这就是活锁。

两个或多个并发任务试图在没有协调的情况下防止死锁,将可能产生活锁。活锁比死锁更难以发现,因为它看起来好像程序正在工作。 如果活锁程序在你的机器上运行,并且你查看了CPU利用率以确定它是否在执行任何操作,那么你可能会认为它是毫无问题。根据活锁的不同,它甚至可能会发出其他信号,使你认为它正在工作。

其实,活锁是饥饿问题的一个子集,在本小段的最后,我们简单探讨下锁的饥饿问题。

当我们讨论活锁时,每个协程所缺乏的资源就是一个共享锁。 活锁需要与饥饿分开讨论,因为在活锁过程中,所有并发任务都是平等的,并且没有任何任务可以被完成。 更广泛地说,饥饿通常意味着有一个或多个贪婪的并发任务不公平地阻止一个或多个并发任务尽可能有效地完成工作,或者根本不可能完成工作。

我们来看接下来的编程案例:

编程案例17--贪婪协程和老实协程
package main

import (
    "fmt"
    "sync"
    "time"
)

var (
    wg         sync.WaitGroup
    sharedLock sync.Mutex
)

const runtime = 1 * time.Second

func greedyMan() {
    defer wg.Done()
    var count int
    for begin := time.Now(); time.Since(begin) <= runtime; { //Golang通过这种方式循环,实现从现在到流逝指定时间中一直循环
        sharedLock.Lock()
        time.Sleep(3 * time.Nanosecond)
        sharedLock.Unlock()
        count++
    }
    fmt.Printf("贪婪人吃了%v碗饭\n", count)
}

func politeMan() {
    defer wg.Done()
    var count int
    for begin := time.Now(); time.Since(begin) <= runtime; {
        sharedLock.Lock()
        time.Sleep(1 * time.Nanosecond)
        sharedLock.Unlock()

        sharedLock.Lock()
        time.Sleep(1 * time.Nanosecond)
        sharedLock.Unlock()

        sharedLock.Lock()
        time.Sleep(1 * time.Nanosecond)
        sharedLock.Unlock()

        count++
    }
    fmt.Printf("老实人吃了%v碗饭\n", count)
}

func main() {
    wg.Add(2)
    go greedyMan()
    go politeMan()
    wg.Wait()
}
编程案例17输出
老实人吃了157碗饭
贪婪人吃了460碗饭

该案例内,我们可以看到,贪婪人和老实人在几乎相同的时间内,吃饭数量相差几乎两倍。但是,通过查看代码,我们发现,贪婪人和老实人在时间流逝中每一个循环的sleep时间,总和都是3纳秒,而且都是在sleep完毕之后,再count++(吃饭)。造成这种差距的原因,就在于,贪婪者霸占锁时间的区间更长:由于切换锁开闭本身需要时间,老实人每一次锁的释放都给贪婪人创造了机会。贪婪人不必要的扩大了共享锁的控制,实现了资源上对老实人的挤占,着实可恶!

像这种,某些协程因为程序设计原因,巧妙利用锁的机制,形成了对其他协程资源的压倒性挤占,就产生了饥饿问题。

虽然饥饿问题很恼人,但是通过学习编程案例17,你可以使用计数的方式识别饥饿。检测和解决饥饿的方法之一就是记录程序执行完毕的时间,然后确定你的程序执行速度是否和预期的一样高。

可以预见的是,饥饿可能会导致程序无效或不正确。 前面的例子表明了我们程序的执行效率是如何被降低的,如果你有一个非常贪婪的并发任务,以至于完全阻止另一个并发任务完成工作,那么你的问题就大了。

我们还需要考虑来自程序之外导致的饥饿问题。请记住,饥饿还可以产生于于CPU,内存,文件句柄和数据库连接:任何必须共享的资源都是饥饿的候选对象

并发安全

3.6中为了形象生动给大家讲解可伸缩并发设计,我们引入了一个血泪创业史的故事,相信你还记忆尤新。在这之前,大厨烧菜、AB抢厕所的故事相信更让你的鼻子萦绕着香臭。让我们来回顾一下一路走来我们提及的,可能成为灾难的问题吧:

  • 不受控发配大量的人干事,可能会使项目不可控,发生危险——例如隔壁村侧翻的油罐车把一村子人都给燎了的事情。
  • 没有锁的资源共享可能会遭遇尴尬——A脱了裤子B突然也冲了进来。
  • 都试图躲避危险,然而缺乏事先约定导致活锁——看着都在干事儿,实际丝毫没有产出,危害性甚于带薪拉屎。
  • 没有监管的任务分配,带来资源和结果状态的不可见——去挖核废料的劳工挖着挖着就没了,你也不清楚他是在核反应堆上撒尿呢,还是在核反应堆上拉屎,还是人真的死了。

即使抛开这些问题,在你所掌控的范围内一切都井井有条,你是一个合格的包工头!但是在不可控层面呢?

再退一万步说,天灾易躲,也有言:人祸难防。相信如果你参与过大型工程建设,一定会了解到与他人代码的有机结合是一件多么有艺术的事情。简而言之,其他人的代码,书写方式和行文逻辑,可能与个人都有很大的差异。即使你对其人有充分的信任,但当你在他负责的模块对外暴露的代码中,发现了一个文档内没有覆盖的函数,你敢贸然使用吗?我想答案需要划上一个大大的问号。

想想苹果生产线上工人拉尿的故事吧(3.6节),现在,你找到了一个看上去好像是督察员的代码片段,正在犹豫要不要使用它:

func Duchayuan(applePipline []string) {
    // 一大堆看上去很绕的逻辑,然后引用了很多其他文件的函数,很明显,这个代码片段逻辑你很难理解。
}

func main(){
    var YourApplePipLine []string
    //go Duchayuan() // 你在犹豫要不要使用它
    //select{}
}

你可能会思考以下的问题:

  • 我该如何调用这个函数?
  • 督察员逻辑并发相关是我把这个函数本身作为一个并发任务,还是这个函数内部自己控制了并发逻辑?
  • 我把生产线(一个并发安全脆弱的切片)交付给他了,他会对我的生产线做什么么?

也许,年轻的脚底充满了胆大,赌徒的字典里没有害怕,你毅然决然的决定使用这位督察员,果然生产线上再也没有工人尿尿了!但是看着自己充满了尿骚味的办公室,我觉得你心里一定不会好受——这个督察员自己的逻辑里,有安排工人到你的办公室解决,虽然逻辑很绕,但觉得你应该看不到,于是事情成了你才知道。

注释也许可以缓解一下这个问题,在函数的注释中,尤其是涉及并发的函数,最好需要涵盖下面的内容:

  • 谁负责发起并发?(是把整个函数模块作为并发任务,还是函数自己内部分配并发任务)
  • 问题空间如何映射到基本并发任务?(模块会如何分解任务)
  • 谁负责并发控制?(是否有并发安全风险,模块解决了什么,还需要使用者解决什么)

除了注释,函数名称和返回值等本身的属性也可以给人带来安全感,体会下面的两个例子:

func Duchayuan(applePipline []string) []string{ // 1
    // 你看不懂的一些代码
}
func Duchayuan(applePipline []string) <-chan string{ // 2
    // 你看不懂的一些代码
}

通过对3.5节并发范式的学习,我们可以建立较为可靠的预估,认为2以通道形式返回结果,说明函数内部有较大可能已经进行了任务分配和结果的收敛到流,我们应该不必为创建协程而烦恼。

和历史上的大咖一样颤抖

数据竞争,看不见的原子性分层,各种令人烦恼的锁,和这些问题还有团队协作等软件工程中数不胜数的环节带来的问题。

现在,把他们汇总到一起,就是你现在看到的一个简单关键字go背后所涵盖的种种并发实现困难。

再扩大几倍,想象历史上的关键人物,以更加深邃的视角,看见除了这些问题以外的更多,或许你可以明白为何人们在早期往往对并发流程的优化和系统性研究几乎到了避而不谈的地步,往往是逼一步走一步。在简单搬运,利用一些最低级并发操作,实现了最终并行的作业,人们就已经感觉知足,开始优先的追求横向扩容带来的数据处理上限提升(大数据优先从并行优化开始切入,而不是并发),从而放弃在更高效率下并发技术的探索。

和其他许多问题不同,并发背后的困难,在很长一段时间内,往往不是走一步少一点。正如本节介绍的锁相关带来的问题,锁的引入,带来了流程上更多需要考虑的问题。人们跌跌撞撞向前,向前一步,看到的是更大的黑暗,我想这对于技术人来说无疑是折磨的。

好在,Golang已经逐步的给出了清晰、简单、实用的解决方案。语言本身就具备了较强的可读性,还不失简约。在这个看不见的斗争中,人们终于看到了光明。现在我们能够借助Golang,编写出健壮、可伸缩的高并发程序。进一步的借助优化并发成果的底层硬件、中间层软件,我们能够比前人无比高效的利用当下的资源,面对更加海量和频繁的数据请求以及任务处理。

全部评论

相关推荐

点赞 收藏 评论
分享
牛客网
牛客企业服务