如果必须选择 Go 的一项伟大功能,那么它必须是内置的并发模型。它不仅支持并发,而且使它变得更好。 Go Concurrency Model (goroutines) 之于并发,就像 Docker 之于虚拟化。
什么是并发(Concurrency)?
在计算机编程中,并发(Concurrency)是计算机同时处理多个事物的能力。例如,如果您在浏览器中上网,可能会同时发生很多事情。在特定情况下,您可能正在下载一些文件,同时在您滚动的页面上听一些音乐。因此浏览器需要同时处理很多事情。如果浏览器无法立即处理它们,您需要等待所有下载完成,然后您才能再次开始浏览互联网。那会令人沮丧。
一般来说 PC 可能只有一个 CPU 内核来完成所有的处理和计算。一个 CPU 内核同一时间只能处理一件事。当我们谈论并发(concurrency)时,我们一次只做一件事,但可以将 CPU 时间分配给需要处理的事情。因此,我们会感觉同时发生了多种事情,但事实上一次只发生了一件事情。
我们通过图表来了解上述讨论的案例: CPU 通过 Web 浏览器如何"同时"处理多个事情。
所以从上图可以看出,单核处理器几乎是根据每个任务的优先级来划分工作负载的,例如,在页面滚动时,听音乐的优先级可能较低,因此有时您的音乐会因低优先级而停止互联网速度,但您仍然可以滚动页面。
什么是并行(parallelism)?
但是问题来了,如果 CPU 有多个内核呢?如果一个处理器有多个处理器,则称为多核处理器。多核处理器能够同时处理多项事情。 在之前的网页浏览示例中,我们的单核处理器必须在不同的事物之间分配 CPU 时间。使用多核处理器,我们可以在不同的内核中同时运行不同的东西。让我们使用下图来评估。
并行运行不同事物的概念称为并行(parallelism)性。当我们的 CPU 有多个内核时,我们可以使用不同的 CPU 内核同时做多事情。因此,我们可以说我们可以很快完成一项工作(包括很多东西),但事实并非如此。我们后面在讨论这一点。
并发 (concurrency) vs 并行 (parallelism)
Go 建议只在一个内核上使用 goroutines,但我们可以修改 Go 程序以在不同的处理器内核上运行 goroutines。现在,将 goroutines 视为 Go 函数,因为它们就是,但还有更多。
并发性和并行性之间有几个区别。并发一个人在同一时间处理多个事情,多个事情之间回切换;而并行是多人同时处理多个事情,每个人处理其中的一件。但并行并不总是比并发更有利,我们在下一片文章来讨论这个问题。
此时,您的脑海中可能会有很多问题,您可能已经有了并发的想法,但您可能想知道 Go 如何实现它以及如何使用它。要了解 Go 的并发架构以及如何在代码中使用它,以及何时在应用程序中使用它,我们需要了解什么是计算机进程。
计算机进程(process)是什么?
当您使用 C、java 或 Go 等语言编写计算机程序时,它只是一个文本文件。但是由于计算机只能理解由 0 和 1 组成的二进制指令,因此需要将该代码编译为机器语言。这就是编译器的用武之地。在 python 和 javascript 等脚本语言中,解释器做同样的事情。
当一个编译好的程序被送到操作系统(OS) 处理时,操作系统(os) 会分配不同的东西,比如内存地址空间(进程的堆和栈所在的位置)、程序计数器、PID(进程 ID)和其他非常重要的东西。一个进程至少有一个线程称为主线程,而主线程可以创建多个其他线程。当主线程执行完毕后,进程退出。
所以我们理解进程是一个容器,它已经编译了代码、内存、不同的操作系统资源和其他可以提供给线程的东西。简而言之,进程就是内存中的一个程序。但是什么是线程,它们的工作是什么?
什么是线程(thread)?
线程是进程内的轻量级进程。线程是一段代码的实际执行者。线程可以访问进程提供的内存、操作系统资源和其他东西。
在执行代码时,线程在内存区域内存储变量(数据)称为堆栈,其中临时空间变量保存临时空间。堆栈在运行时创建,通常具有固定大小,最好为 1-2 MB。而一个线程的堆栈只能由该线程使用,不会与其他线程共享。堆是进程的一个属性,可供任何线程使用。堆是一个共享内存空间,来自一个线程的数据也可以被其他线程访问。
现在我们大致了解了进程和线程。但是它们有什么用呢?
当启动 Web 浏览器时,必须有一些代码指示操作系统执行某些操作。这意味着我们正在创建一个进程。该进程可能会要求操作系统为新选项卡创建另一个进程。当浏览器选项卡打开并且同时您正在做日常工作,该选项卡进程将开始为不同的活动(如页面滚动、下载、听音乐等)创建不同的线程,正如我们在之前的图表中看到的那样。
下面是 macOS 平台上 Chrome 浏览器应用程序的屏幕截图
上面的屏幕截图显示 Google Chrome 浏览器对打开的标签页和内部服务使用不同的进程。由于每个进程至少有一个线程,我们可以看到一个谷歌浏览器进程,在这种情况下,有超过 3 个线程。
在之前的话题中,我们谈到了处理多件事或做多件事。这里的事物是由线程执行的活动。因此,当在并发或并行模式下发生多件事情时,会有多个线程串联或并行运行,也就是多线程。
在多线程中,在一个进程中产生多个线程,内存泄漏的线程会耗尽其他线程的资源并使进程无响应。在使用浏览器或任何其他程序时,您可能已经多次看到这种情况。您可能已经使用活动监视器或任务管理器来查看无响应的进程并杀死它。
线程调度
当多个线程串行或并行运行时,由于多个线程可能共享一些数据,因此线程需要协同工作,以便一次只有一个线程可以访问特定数据。以某种顺序执行多个线程称为调度。操作系统线程由内核调度,一些线程由编程语言的运行时环境管理,如 JRE。当多个线程试图同时访问相同的数据导致数据被更改或导致意外结果时,就会发生竞争条件。
在设计并发 Go 程序时,我们需要注意竞争条件,我们将在下一片文章中讨论。
Go 中的并发(concuenry)
最后,我们将讨论 Go 如何实现并发。像java这样的传统语言有一个线程类,可以用来在当前进程中创建多个线程。由于 Go 没有传统的 OOP 语法,它提供了 go 关键字来创建 goroutine。当 go 关键字放在函数调用之前,它就变成了 goroutines。
我们将在下一片文章中讨论 goroutines,但简而言之,goroutines 的行为类似于线程,但在技术上;它是对线程的抽象。
当我们运行 Go 程序时,Go runtime 将在一个核心上创建几个线程,所有 goroutine 都在该核心上复用(产生)。在任何时候,一个线程将执行一个 goroutine,如果该 goroutine 被阻塞,那么它将被替换为另一个将在该线程上执行的 goroutine。这就像线程调度,但由 Go runtime 处理,而且速度要快得多。
在大多数情况下,建议在一个内核上运行所有 goroutines,但是如果您需要在系统的可用 CPU 内核之间划分 goroutines,您可以使用 GOMAXPROCS 环境变量或使用函数 runtime.GOMAXPROCS(n) 调用运行时其中 n 是要使用的内核数。但是有时您可能会觉得设置 GOMAXPROCS > 1 会使您的程序变慢。这确实取决于程序的性质,但您可以在互联网上找到问题的解决方案或解释。实际上,当程序使用多核、操作系统线程和进程时,在通道上通信比在计算上花费更多时间的程序会遇到性能下降。
Go 有一个 M:N 调度器,它也可以使用多个处理器。在任何时候,都需要在 N 个操作系统线程上调度 M 个 goroutine,这些线程最多在 GOMAXPROCS 个处理器上运行。在任何时候,每个内核最多只能运行一个线程。但是调度程序可以根据需要创建更多线程,但这很少发生。如果你的程序没有启动任何额外的 goroutines,那么无论你允许它使用多少个内核,它自然只会在一个线程中运行。
线程(threads) vs goroutines
正如我们之前看到的,线程和 goroutines 之间存在明显的区别,但下面的区别将阐明为什么线程比 goroutines 更昂贵,以及为什么 goroutines 是实现应用程序中最高级别并发的关键解决方案。
thread | goroutines |
---|---|
操作系统线程由内核管理并具有硬件依赖性。 | goroutines 由 go runtime管理,没有硬件依赖。 |
OS 线程通常具有 1-2MB 的固定堆栈大小 | 在较新版本的 go 中,goroutines 通常具有 8KB(自 Go 1.4 以来为 2KB)的堆栈大小 |
堆栈大小在编译时确定,不能增长 | go 的堆栈大小在运行时进行管理,可以通过分配和释放堆存储增加到 1GB |
线程之间没有简单的通信媒介。线程间通信之间存在巨大的延迟。 | goroutine 使用通道以低延迟与其他 goroutine 通信 |
线程具有标识。有 TID 标识进程中的每个线程。 | goroutine 没有任何身份。 go 实现了这一点,因为 go 没有 TLS(线程本地存储Thread Local Storage)。 |
线程具有显着的设置和拆卸成本,因为线程必须从操作系统请求大量资源并在完成后返回 | goroutine 由 go runtime 创建和销毁。与线程相比,这些操作非常便宜,因为 go runtime 已经为 goroutine 维护了线程池。在这种情况下,操作系统不知道 goroutines。 |
线程是预先调度的。由于调度程序需要保存/恢复超过 50 个寄存器和状态,因此线程之间的切换成本很高。当线程之间快速切换时,这可能非常重要。 | goroutine 是协同调度的。当发生 goroutine 切换时,只需要保存或恢复 3 个寄存器。 |
以上是一些重要的区别,但如果你深入研究,你会发现 Go 并发模型的惊人世界。为了突出 Go 并发强度的一些优势,假设您有一个 Web 服务器,每分钟处理 1000 个请求。如果您必须同时运行每个请求,则意味着您需要创建 1000 个线程或将它们划分到不同的进程中。这就是 Apache 服务器管理传入请求的方式。如果 OS 线程每个线程消耗 1MB 堆栈大小,则意味着您将耗尽 1GB RAM 用于该流量。 Apache 提供了 ThreadStackSize 指令来管理每个线程的堆栈大小,但您仍然不知道是否因此而遇到问题。
而在 goroutine 的情况下,由于堆栈大小可以动态增长,您可以毫无问题地生成 1000 个 goroutine。由于 goroutine 以 8KB(自 Go 1.4 以来为 2KB)的堆栈空间开始,它们中的大多数通常不会增长得比这更大。但是,如果存在需要更多内存的递归操作,Go 可以将堆栈大小增加到 1GB,我认为这几乎不会发生,除了 for {}, 这显然是一个错误。
此外,与我们之前看到的线程相比,goroutines 之间的快速切换是可能的并且更高效。由于一个 goroutine 一次在一个线程上运行并且 goroutine 是协作调度的,因此在当前 goroutine 被阻塞之前不会调度另一个 goroutine。如果该线程块中的任何 Goroutine 说等待用户输入,那么另一个 goroutine 将被安排在它的位置。
goroutine 可以在遇到以下条件之一时阻塞
- network input
- sleeping
- channel operation
- blocking on primitives in the sync package
如果 goroutine 没有在这些条件之一上阻塞,它可以使多路复用的线程饿死,杀死进程中的其他 goroutine。虽然有一些补救措施,但如果确实如此,那么它就被认为是糟糕的编程。
Channels 在goroutine 间共享数据时,将发挥重要作用,下一章我们将讨论这个问题。这将防止竞争条件和对共享数据的不当访问,而不应在多线程的情况下访问共享内存。
更多参考
- 本文翻译自 在 Go 中实现并发
- Jaana Dogan 有一篇关于 Go 调度器的文章,名为 Go 的工作窃取调度器,阅读它以了解 Go 的运行时如何管理 goroutine
- Rob Pike 发表了一篇关于 GoLang 并发的精彩演讲,题目是 并发不是并行