第一章 并发编程的概念及挑战(上—概念)

发布于 2021-08-19  936 次阅读


对于大部分程序员来说,如果你学习的技术,并不能转换为生产工具,那就毫无意义。因此,在学习并发编程之前,我们有必要了解

什么是并发(转载)

作者:大宽宽
链接:https://www.zhihu.com/question/307100151/answer/894486042
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

深度好文!!!值得推荐!!!

【并发】(Concurrency)是由【P进程】引申出来的抽象概念。

上面说到了你可以假设自己一个人按照一定的步骤来铺路,一个人从头干到尾,这是一个“串行”的【P进程】。

但你也可以假设有2个人铺路。比如你可以按照长度分两半,一人铺500m * 50m;也可以按宽度划分,一人铺1000m * 25m;你还可以说让一个人负责铺全部路面的前5个步骤,另外一个人负责铺路面的余下5个步骤。然后你可以进一步想,假如不是雇2个人,而是雇20个人概如何分工呢?你可以混搭按长度,宽度,步骤等各种方式进行拆分。你甚至可以考虑这20个人不是完全一样的,有的能力强,有的能力弱,可以适当的调整工作量的比例等等。

不管怎样拆,都意味着你得到了【并发】的【P进程】。换成说人话就是,你有一套方案,可以让多个人一起把事情做的更高效。注意是“可以“让事情更高效,而不是“必然“让事情更高效。是不是更高效要看到底是怎么执行的,后边会讲。

举个写代码的例子,你有一个很长很长的数组,目标是把每一个数都*2。一个并发的做法就是把数组拆为很多个小段,然后每个小段的元素依次自己*2。这样的程序写出来就是一个【并发】的【程序】。这个程序如果运行起来就是【并发】的【OS进程】。

这时就会出现一个问题,当你想把一个【并发】的【P进程】写成程序时,你怎么用编程语言告诉操作系统你的程序的一些步骤是【并发】的。更确切地说,你需要一个写法(可能是语法,也可能是函数库)表达:

  • 几个任务是【并发】的
  • 【并发】的任务之间是怎么交互协作的

为了解决这两个问题,人们总结了一些方法,并将其称为“并发模型”。

比如:

  • Fork & Join模型(大任务拆解为小任务并发的跑,结果再拼起来)
  • Actor模型(干活的步骤之间直接发消息)
  • CSP模型(干活的步骤之间订阅通话的频道来协作)
  • 线程&锁模型(干活的人共享一个小本本,用来协作。注意小本本不能改乱套了,所以得加锁)
  • ……

以Java中的线程为例,大家想表达【并发】就启动新的Thread(或者某种等价操作,如利用线程池);想让Thread之间交互,就要依靠共享内容。但是【并发】的Thread如果同时修改同一份数据就有可能出错(被称为竞争问题),为了解决这个问题就要引入锁(Lock,或者一些高级的同步工具,如CountdownLatch,Semaphore)。

特别强调下,Java的线程是表达并发的概念的类。这个类在绝大部分操作系统上使用操作系统内核中的【线程】实现。二者之间还是有一些细微的差异。即用开发者用Java Thread写代码表达思路,和操作系统调度线程执行是两个层面的事情。请努力认识到这一点。

再比如Erlang是基于Actor的并发模型(其实这是原教旨主义的OO)。那么就是每个参与【并发】的任务称为Process(又一个进程……,和【P进程】以及【OS进程都不太一样】,叫【E进程】好了,Erlang中的"进程“)。【E进程】之间通过消息来协作。每个【E进程】要不是在处理消息,要不就是在等新的消息。

如果你用go,那么表达并发的工具就是goroutine,goroutine之间协作要用channel。(当然也可以用Sync包加锁,不展开)。

对于并发模型《7周7并发模型》这本书讲的非常好。推荐阅读。书中展示了七种最经典的并发模型和大量的编码实例。

 

为啥要并发

把事情设计为【并发】有什么好处呢?假如能同时干活的人只有1个,其实并没有什么好处。【并发】的方法的总耗时总会>=串行的方法。因为【并发】或多或少总会引入需要协作和沟通成本。最小的代价就是不需要沟通,此时【并发】的方法和串行的方法工作量是一样的。

但是【并发】的巨大优势是在可以干活的人数量变多时,马上得到【并行】的好处。假如我们可以得到一个【并发】的【P进程】,并且真的为其配备足够多的人,那么做事的效率就会高很多。回到软件系统,假如有一个【并发】的【程序】,它在只有1个CPU的核心的机器上可以跑,在2个的CPU的也可以跑,在4核CPU上也可以跑。物理上可用CPU核心越多,程序能够越快执行完。而不管在哪里跑,程序本身不用做变化。编程是一件成本很高的事,能够做到程序不变而适应各种环境,可以极大的降低开发成本。你能想象下为1核心CPU开发的Office软件和4核心的不一样吗?

 

并发和并行的关系是什么

【并发】(Concurrency)这个词的本意是指两件事没有谁先谁后的关系,或者说关系不确定。举个通俗的例子,自然数任何两个数字都可以比较大小。我们可以明确地说5 > 3。但是如果换一个领域,并不是任何两个元素都有明确的顺序关系,或者说“谁在前面谁在后面都是可以的“。

对于任务执行这个领域,对于两个任务A和B,如果我们说他们俩是【并发】的,这就要求不能在任务B里使用A的结果,也不能让A执行时使用B的结果。因此在执行层面,A可以在B之前执行,也可以在B执行,或者A和B交替执行,或者A和B【并行】的执行。不管执行层面怎么折腾,结果都是对的。

反过来,如果A的执行需要B的结果,那也就意味着A和B不是【并发】的,必须让B先执行完,A才可以开始。在实现层面,就可以用加锁、channel等方式来表达“先B后A”。

Rob Pike在一个Talk里(blog.golang.org/concurr)提到了很重要的两个观点:

  • Concurrency is not Parallelism
  • Concurrency enables parallelism & makes parallelism (and scaling and everything else) easy

前一个观点【并发】和【并行】不是一件事,我们都可以理解了。【并发】说的是处理(Deal)的方法;【并行】说的是执行(Execution)的方法。

后一个观点指的是,如果想让一个事情变得容易【并行】,先得让制定一个【并发】的方法。倘若一个事情压根就没有【并发】的方法,那么无论有多少个可以干活的人,也不能【并行】。比如你让20个人不铺路,而是一起去拧同一个灯泡,也只能有一个人踩在梯子上去拧,其他19个人只能看着,啥也干不了。

对于一个问题,能不能找到【并发】的办法,取决于问题本身。有些问题很容易【并发】,有些问题可以一部分【并发】其余的串行(比如对数组排序就是,无论怎么拆,最终也要把每个拆开的问题结果合并到一起再排序才行),有些问题则根本上就不能【并发】。找不到【并发】的方法也就意味着不管有多少CPU核心,也没法【并行】执行。

换一个极端,假如为最多20个人设计了【并发】的方法,结果来了40个人,就意味着40人里有20个人是闲着的,是浪费。也就是说【并行】的上限是由【并发】的方法的设计决定的。这就解释了你吃鸡的时候,4核CPU和8核差别不大,因为这个游戏压根就没设计成可以利用这么多个CPU核心。(BTW,但游戏被设计为能充分利用显卡的多核心)

其实上面只是将CPU核心当作是“做事的人“,再广义一点,比如显卡,网卡,磁盘都是独立的可以干活的人。这些组件之间也可以并行的跑。因此,在设计程序的时候,可以比如把计算和IO任务拆开设计一个【并发】的方法,然后利用CPU和网卡是两个零件来【并行】的跑

常见的误解

你可能看到过下面的论断:

并发是多个任务交替使用CPU,同一时刻只有一个任务在跑;并行是多个任务同时跑

这个理解不能说全错,但是合到一起就形成了错误的理解。这个错误的理解就是:并发和并行是两个并列的,非此即彼的概念,一个状态要不就是并行的,要不就是并发的。这是完全错误的,实际上看到上面的解释你就会发现【并发】和【并行】描述的是两个频道的事情。正如Rob Pike所言,一个是“如何处理”,一个是“如何执行”。因此,对于:

并发是多个任务交替使用CPU,同一时刻只有一个任务在跑

其实正确的理解是:针对一个问题,想到了一个可以拆解为多个【并发】的任务,这些任务执行时因为只有一个CPU只能“切换”的跑。

对于:

并行是多个任务同时跑

其实的意思是:如果这些并行执行的任务是解决同一个问题的,那么他们既是【并发】的,同时也是【并行】的。

那么可不可以做到只【并行】,而不【并发】呢?当然可以,但这也就意味着【并行】的程序之间没有什么关联,各干各的,就像大街上来来往往的陌生人一样。这的确是【并行】,并且是这个世界的常态。但是一群不认识的,各干各的人是不能一起解决问题的,要一起就得有同一个目标,制定一套沟通的方法,形成【并发】的方案。这种形式在现实当中就是“公司”。


她喜欢所以就做咯