并发与并行是不同的,这一事实常常被忽视或误解。 在许多开发人员之间的对话中,这两个术语经常互换使用,意思是“与其他东西同时运行的东西”。有时在这种情况下使用“并行”这个词是正确的,但通常如果开发人员正在讨论代码, 他们真的应该使用“并发”这个词。
并发和并行之间的差异导致在建模代码时,演变成非常显著的抽象区别,Go充分利用了这一点。 让我们来看看这两个概念是如何不同的,以便我们能够理解这种抽象的力量。 我们将从一个非常简单的陈述开始:
并发是代码的一个属性; 并行是正在运行的程序的一个属性。
这是一个有趣的区别。我们编写我们的代码,以便它可以并行执行。 对?
那么,我们再来考虑一下。 如果所编写的代码的意图是两个程序块并行运行,那么当程序运行时,我是否有任何保证? 如果我在只有一个内核的机器上运行代码会发生什么? 你们中有些人可能会想,它会并行运行,但事实并非如此。
程序块可能表现为并行运行,但实际上他们是以一种连续的方式执行,而不是不可区分的。(单核)CPU上下文切换为在不同程序之间共享时间,在足够长的时间间隔内,这些任务表现为并行运行。如果我们要在两个内核的机器上运行相同的二进制文件,那么程序块可能确实是并行运行的。
这揭示了一些有趣且重要的事情。我们的代码可能不是并行的,而表现出来却有可能是并行的。并行是我们程序运行时的一个属性,而非代码。
第二个有趣的地方在于,我们发现运行时不知道我们的并发代码是否实际并行运行。对程序模型的抽象可以使我们区分并发和并行,并最终赋予程序力量和灵活性。
第三,并行性是时间或环境的函数。我们在之前的“原子性”中讨论了上下文的概念。在那里,上下文被定义为一个操作被认为是原子的边界。 在这里,它被定义为两个或多个操作可以被认为是并行的边界。
例如,如果我们的上下文是五秒钟的空间,并且我们运行了两个每秒需要运行一次的操作,那么我们会认为这些操作是并行运行的。 如果我们的情况是一秒钟,我们会认为这些操作是按顺序运行的。
就时间片而言,重新定义我们的上下文可能并不是很好,但记住上下文不受时间限制。 我们可以将上下文定义为程序运行的过程,操作系统线程或其机器。 这很重要,因为定义的上下文与并发性和正确性的概念密切相关。 就像原子操作根据上下文可以被视为原子操作一样,根据定义的上下文,并发操作是正确的。当然 这都是相对的。
这有点抽象,所以我们来看一个例子。 假设我们正在讨论的环境是你的电脑。 除了理论物理外,我们可以合理地预期在我的机器上执行的进程不会影响机器上进程的逻辑。 如果我们都启动计算器过程并开始执行一些简单的算术运算,那么我执行的计算不应该影响你执行的计算。
这个例子有点傻。但是如果我们把它分解,我们会看到所有的部分在起作用:我们的机器是上下文,进程是并发操作。 在这种情况下,我们选择通过独立的计算机,操作系统和流程来思考世界并行操作。 这些抽象使我们能够确认这一的思考是正确的。
使用单独的计算机似乎是一个有意义的例子,但个人计算机并不总是如此无处不在! 直到20世纪70年代末,大型机才是常态,开发人员在同时考虑问题时使用的常见上下文是程序的过程。
现在许多开发人员正在使用分布式系统,它正在向另一种方向转移! 我们现在开始考虑虚拟机管理程序,容器和虚拟机作为我们的并发环境。
我们可以合理地期望一台机器上的一个进程不受另一台机器上的进程的影响(假设它们不属于同一个分布式系统),但是我们可以期望同一台机器上的两个进程不会影响另一台机器上的逻辑吗?进程A可能会覆盖进程B正在读取的某些文件,或者在不安全的操作系统中,进程A甚至可能会破坏正在读取的进程B。
尽管如此,在流程层面上,事情仍然相对容易考虑。 如果我们回到我们的计算器示例,那么期望在同一台计算机上运行两个计算器进程的两个用户合理地期望他们的操作在逻辑上彼此隔离是合理的。 幸运的是,过程边界和操作系统帮助我们以合理的方式思考这些问题。 但是我们可以看到,开发人员开始担心并发问题,并且这个问题只会变得更糟。
如果我们再向下移动到操作系统线程边界,“为什么是并发编程如此困难”一节中列举的所有问题才真正出现:竞争条件,死锁,活锁和饥饿。 如果我们有一台机器上的所有用户都可以查看的计算器进程,那么并发逻辑就会变得更加困难。 我们不得不开始担心同步对内存的访问的影响并为正确的用户检索正确结果烦恼。
当我们开始向下移动抽象层时,对事物的建模变得更加难以推理,抽象对我们来说变得越来越重要。 换句话说,获得并发权越困难,访问容易编写的并发原语就越重要。 不幸的是,我们行业中的大多数并发逻辑都是以最高抽象层次之一编写的:系统线程。
在Go出现前,这是大多数流行编程语言的抽象链的最终解决方案。如果你想编写并发代码,你可以用线程来建模你的程序并同步它们之间的内存访问。 如果你有很多事情需要同时建模,并且你的机器不能处理那么多的线程,你会创建一个线程池并将你的操作复用到线程池中。
Go在该链中添加了另一个链接:goroutine。 此外,Go借鉴了着名计算机科学家托尼霍尔的着作中的几个概念,并引入了我们使用的新原语,即通道(channel)。
继续我们的推理,会发现在系统线程之下引入另一个抽象层次会带来更多困难,但有趣的是,事实并非如此。 它实际上使事情变得更容易 这是因为我们没有在操作系统线程的顶部添加另一个抽象层,我们(在用Go的时候)已经取代了他们。
当然,线程仍然存在,但是我们发现很少需要再从操作系统线程的角度考虑我们的问题空间。 相反,我们在goroutines和channel中建模,偶尔共享内存。 这会产生一些有趣的属性,我们会逐步探讨。但首先,让我们了解下Go哲学的基石:Tony Hoare的开创性论文“序列化交互”。