《并发哲学:从编程入道到开悟升天》4.2 谈代码背后的行为建模和分析

谈代码背后的行为建模和分析

得益于基于Golang超强力的并发原语抽象,我们能够站在前人的肩膀上,史无前例的对自己将要展开的代码工程进行充分的行为建模和分析。

进一步总结第三章的内容,我们已经掌握了绝大部分的流程要素。包工头的视角过于庞大,但只要不离开包工头背后蕴含的基本哲学——基于任务分配的考量,那么我们探讨类似的相关理解模型,也是有意义的。现在我们以一个简单的日常生活作为范本,进行基于并发思想的代码编写流程解构,并探讨其中蕴含着的,我们关心的问题。

充实的早晨——代码抽象、模块化、成型

李华是红星中学的学生。李华的周六首先是起床:

  • 舍友早早起床,前去上厕所
  • 闹钟尝试唤醒,李华将检查时间,如果不到9点,则接着睡觉
  • 如果李华起床,开始正常刷牙洗脸,完毕后,如果厕所空着上厕所
  • 李华上厕所完毕后,开始烧水,同时打开新闻,并开始自己做早餐

在这个早上的简单案例内,我们可以抽象成如下的工作流:

【绘图】

对工作流进行进一步解释,则有了如下的结论:

  • 舍友起床、上厕所/李华睡觉,对世界时间流逝,是两个任务
  • 上厕所是需要上锁
  • 舍友和李华上厕所需要请求锁,用后释放锁
  • 李华由闹钟唤醒、检查结果并决定是否继续睡觉是等待-条件循环
  • 烧水、新闻是李华触发的动作,但在开始后,不再直接由李华控制
  • 李华自己做早餐则实时受李华控制

进一步对模块化工作流进行抽象,我们发现我们需要如下要素:

  • 任务分配:世界任务分配、李华任务分配
  • 互斥锁机制:厕所需要用
  • 唤醒机制,和进一步的等待、条件循环机制:李华起床

最后,我们发现全部的要素经过有序组织,就变成了下面实际的代码:

package main

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

var (
    Time int
    ShitInToilet int
    ToiletLock sync.Mutex
    ClockKnock = make(chan int,0)
)



func TimeThrough(){ //世界线时间流逝是一个隐式的并发任务
    for {
        time.Sleep(1 * time.Second)
        Time++
    }
}

func Clock(){ //闹钟是一个游离于人考虑之外,但是却很容易想到的并发任务
    for {
        ClockKnock <- 1
    }
}

func MakeBreakFast(){
    fmt.Println("李华开始做起了早餐……")
}

func PutShit(){
    fmt.Println("厕所:扑通!一坨屎掉进了马桶。")
    ShitInToilet++
}

func BoilWater(){
    time.Sleep(1*time.Second)
    fmt.Println("水壶:wuwuwu!!!!!")
}

func ListenNews(){
    time.Sleep(1*time.Second)
    fmt.Println("电视:今天的早间新闻节目到此为止")
}

func OtherMan(){
    ToiletLock.Lock()
    PutShit()
    ToiletLock.Unlock()
}

func LiHua(){
Sleep:
    for  {
        select {
            case <-ClockKnock:
                if Time%24>9{
                    break Sleep
                }
        }
    }
    ToiletLock.Lock()
    PutShit()
    ToiletLock.Unlock()
    go BoilWater()
    go ListenNews()
    MakeBreakFast()
    fmt.Println("李华:我的早餐做完啦!")
}

func main(){
    go Clock()
    go TimeThrough()
    go OtherMan()
    go LiHua()
    select {}
}

上面的程序将会输出:

厕所:扑通!一坨屎掉进了马桶。
厕所:扑通!一坨屎掉进了马桶。
李华开始做起了早餐……
充实的早晨结束了!
电视:今天的早间新闻节目到此为止
水壶:wuwuwu!!!!!

通过这个简单的例子,我们能够体会到,对代码书写目的进行完整建模,剩下的就只有代码堆砌,思路会十分清晰,实际编写的工作量也将大大压缩。这也是为什么很多时候说一个素质良好的程序员,在需求评审的时候,就可以做到腹稿已成,用90%的时间探讨代码建模,再用最后10%的时间书写代码。

现在,让我们通过上面这个简单、形象、生动的例子,来继续探讨接下来的问题。

并发和并行

和许多人的误解一样,起初作者我也认为并行是并发的一个子集。这样的阐述,内在的含义表示的是:如果经过适当程度的退化和进化,并发和并行这两种概念是可以相互转化的。譬如有人认为,不同的并发任务在广度的时间空间下,就做到了并行,他们认为这是一种并发和并行相互转化的体现。

但我们应该这样认为吗?

我们来回过头来看本节最开头例子的分析。为了使一切事物的推进有序进行,我们引入了时间的概念,时间是另外开辟的一个协程,基于标准计算机时间流逝,维护一个大家都可以探查到的变量,我们称之为模拟出来的李华的早晨——这个场景下的自然规律。一切需要获知时间的地方,都可以通过各种方法,取到这个时间变量,从而获知时间。

上一个代码中代表时间流逝的协程

func TimeThrough(){ //世界线时间流逝是一个隐式的并发任务
    for {
        time.Sleep(1 * time.Second)
        Time++
    }
}

这样来看,如果我们一定要认为“时间”这个协程和其他的协程一定是串行交替的,那我们只能认为:

  • 在现实生活中,你的每一个动作,和自然时间的流逝也是交替进行的。只是切换的频率足够快,我们误认为时间流逝和我们动作是同时发生的。

这无疑是对传统世界观的重大颠覆!但更令人不寒而栗的是,我们将在第五章简要提及民间狂人基于如此世界观进一步对量子力学和相对论等经典方程和现象的进一步推论!!

可见,通过引入“时间”协程,我们发现,许多人理解的并发和并行——认为并发切换速度足够快,从而直接构成宏观并行,对这种想法我们无法断定是否正确或者错误,毕竟只要实用,都是OK的。但这样的话,就和我们对世界的朴素认知产生了巨大冲击。天天编写冲击我们传统世界观的代码,对自己而言无疑是折磨的。

也就是说,并发和并行,其实并不适合统一看待,仅仅适合统筹看待。

那么,什么算是统一看待,什么算是统筹看待呢?简要说来,我们应该在代码建模的过程中认为:并发和并行探讨的是两个不同的问题,并发探讨的是任务分配、切换、控制;而并行是对事物行进时间片重叠的一种客观描述。二者探讨的范围不一致,仅仅是描述作用在的实体可能是一致的。

而简单的认为:微观上并发,宏观上并行,我们应该认为这种想法不应该要继续。探讨范围一致,叫做统一;探讨范围不同,但映射实体尽可能一致以易于理解,称之为统筹。

这也是为什么很多人这样阐述并发和并行的关系,叫做“并发是设计、并行是理想”。在代码建模的过程中,我们应该做到只想着并发,而不去思考是否能够并行。这也就意味着一些人追求的并发任务间切换速度并不应该是作为代码编写者的我们追求的目标。将并发和并行分离来考虑问题,是上佳之选。

Golang恰恰也是这么做的。在下一小节,我们将介绍止部抽象链——计算机系统层面设计和考量,以及历史抽象链演进和最终止部的哲学。

同一建模问题的不同解决方案

这个问题时有发生。相比于在Golang中遇到任务需要分配给其他实体(函数段)或者是自然规律(其他独立计算机体系)完成,直接使用go关键字即可。在遇到需要等待并确认结果的时候,可以根据规模和复杂度,选用chan和select,一切都是很祥和的。但有种情况特别让人犯难:

  • 锁定问题

对于锁的实现,用sync包提供工具和chan关键字都很直观,表现力也都相当OK。sync包提供的基本的最原生态的CSP风格代码实现,横跨多语种,最易于理解。而通道的chan关键字,通过通道本身特性,能够某种程度上更加方便的提供与锁一样的功能——而且对于熟练的Golang编码者来说,基于通道的锁可能更加易于理解。

许多的人和团队也都有这样的问题。我们称之为针对同一个锁定问题带来的不同解决方案的选型。

Golang确实在sync包中提供了传统的锁定机制,使得大多数锁定问题在Golang内都可以使用通道或传统锁来解决。而Golang其实还有一句格言,叫做“通过沟通共享内存,不要通过共享内存进行通信”。所以我们究竟应该选择哪种方式?其实,遵循下面的原则,这个问题可以得到较好的解决:

  • 使用最具表现力和/或最简单的

正如之前的编程案例,我们通常习惯在请求具体资源,资源可量化的情况下,我们使用通道来很好的模拟饥饿带来的锁状态。但到了厕所等很明显已经和生活中“锁”概念高度关联的场景,我们直观的使用sync.Mutex来实现锁的机制,代码效用和理解难易程度我们认为都是领先的。

不过话又说回来,我们在之前代码案例内所使用的案例,也不见得就能成为权威。有没有更加统一和直观的标准,来进一步量化“最具表现力”和“最简单”这样的理解名词?答案是肯定的,Golang的设计团队也想到了这一点。我们可以使用一些清晰的指导来帮助我们做正确的事情,正如我们将会看到的那样,主要区分的方式来自试图管理并发性的地方:从内部、到紧密的范围,或者在整个系统中。

来看Golang官方的一张图:

【放图】

这张图内的内容,主要是针对下面的一些细化问题进行阐述:

  • 你想转移数据的所有权吗?

    如果你有一些代码能够产生结果,并希望与另一部分代码共享这个结果,那么你真正在做的是转移那些数据的所有权。如果你熟悉不支持垃圾回收的语言的内存所有权概念,那么也可以称之为:数据所有者,使并发程序安全的一种方法是确保只有一个并发上下文拥有数据的所有权。通道可以帮助我们来传达这一概念。

    这样做的一大好处是你可以创建缓冲通道来实现资源廉价的内存队列,从而将你的生产者与消费者分离。 另一个是通过使用通道,你可以隐式地将你的并发代码与其他并发代码组合在一起。

  • 你是否试图保护结构的内部状态?

这是内存访问同步原语的一个很好的选择,也是一个非常强大的指示器,你不应该使用通道。 通过使用内存访问同步原语,你可以隐藏从呼叫者锁定关键部分的实现细节,但不会给调用者带来复杂性。 这是一个线程安全类型的小例子:

type Counter struct {
    mu sync.Mutex value int
}
func(c *Counter) Increment()
{
    c.mu.Lock()
    defer c.mu.Unlock()
    c.value++
}

如果你回想一下原子性的概念,我们可以说在这里所做的是定义了Counter类型的原子性范围。 调用增量可以被认为是原子的。

记住这里的关键词是"内部的"。 如果你发现自己超出锁定范围,这应该会引起重视。 尽量将锁限制在一个小的范围内。

  • 你是否想要协调多个逻辑?

请记住,通道本质上比内存访问同步基元更具可组合性。正如你在3.5节看到的那些并发范式,各类通道有机巧妙的组合在一起形成了各类交汇点、有序数据流、抢占式应答、全链路通知取消等各类高级工具。而将锁分散在各个结构中听起来像是一场噩梦。

如果你因Golang的选择语句而使用通道,且能够充当队列安全地传递,你会发现控制软件中出现的紧急复杂性要容易得多。 如果你发现自己在努力了解并发代码的工作原理,为什么会发生死锁或竞争,并且你正在使用基元,这可能是你需要切换到通道的一个很好的信号。

  • 这是一个性能的关键部分吗?

这绝对不意味着,“我希望我的程序是高性能的,因此我只会使用互斥锁。”相反,如果你有一部分程序是已经分析过的,并且事实证明它是一个主要的瓶颈,当你发现这里比程序的其余部分慢一些,使用内存访问同步原语可以帮助这个关键部分在负载下执行。由于通道使用内存访问同步来操作,因此它们只能更慢。

希望这可以清楚地说明是否利用CSP风格的并发或内存访问同步。 还有其他一些模式和做法在使用操作系统线程作为抽象并发的方式的语言中很有用。 例如,像线程池这样的东西经常出现。 因为这些抽象的大部分是针对OS线程的优点和缺点的,所以使用Golang时的一个很好的经验法则是放弃这些模式

这并不是说它们根本没有用处,而是Go中的用例受到了更多限制。 坚持用goroutines为你的问题建模,用它们来代表你的工作流程的并发部分,并且不要害怕在启动它们时变得自由。 你很可能需要重新构建你的程序,而不是关注你的硬件可以支持多少个协程的上限。

Golang的并发理念指导下的代码建模过程可以这样概括:为了简单起见,应该在在可能的情况下使用通道。另外的一点则是我们在3.6节可伸缩并发设计中提及的,提倡多利用协程,利用海量压制化协程作业来处理试图并行、实际相似的作业。也就是像免费资源一样处理协程而不必过早的考虑资源占用情况。

在学习了Golang和相关的并发编程知识后,经过本节对代码建模和分析的影响探讨,我相信这对个人代码建模和分析带来的变化一定是革命性的。我们通过以更加科学的视角,剥离并发和并行的概念,运用现有的并发要素、并发范式、并发设计,对代码实现结果、需求和目的进行解构、抽象,梳理出必备的组件,最终只需简单拼接,就可以构建出卓有成效、层次更高的实际代码工程。

全部评论

相关推荐

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