[奔跑的 Go] 教程十五、深入学习 Go 语言的并发编程

Golang

如果必须选择一个 Go 语言很棒的功能的话,那就是它内置的并发模型。不仅仅因为它支持并发而且使其更好。Go 并发模型 (goroutines) 并发性支撑了 Docker 的虚拟化要求。

☛ 什么是并发?

在计算机编程中, 并发是指在同一时刻处理多个事务的能力。举个一般的例子,如果您使用浏览器浏览网页,就有可能发生很多事情。在一个特殊的场景中,您可能同时在 正在滑动页面 听音乐的时候下载文件。因此浏览器在这时就需要处理很多事情。如果浏览器不能立刻处理它们,那就需要等待所有的文件下载完成然后才能继续浏览网页。那就很令人沮丧了。

通常情况下 PC 可能只有一个 CPU 核心用来完成所有的处理和计算。 CPU 核心可以一次处理一个任务。当我们讨论并发的时候,我们一次处理一个任务但我们在需要处理的任务划分 CPU 时间。因此,我们感受到的多任务的发生,实际上一次只能处理一个任务。

让我们看一下图 CPU 管理浏览器是如何管理示例中讨论的内容。

所以从上面这张图中可以看出,单个核心可以根据每个任务的优先级划分工作量,例如,在滑动页面的时候,听音乐可能是低优先级的,因此在某些时候因为网速慢可能音乐停止播放了,却依然可以滑动网页。

☛ 什么是并行?

现在问题来了,如果我的 cpu 是多个内核呢?如果一个处理器中内置多个处理器,那么这个处理器则被称为多核处理器。你可能在买电脑、笔记本电脑或者在手机时听说这个词。一个多核处理器可以同时处理多件事情。

在前面的 Web 示例中,我们的单内核处理器必须在不同的事物之间分配 CPU 处理时间。在使用多核处理器的情况下,我们可以在不同的内核中同时运行不同的东西,让我们通过用下面的图来评估它。

并行运行不同事物的概念则被称为并行性。当我们 CPU 有多个内核时,我们可以使用不同的 CPU 内核同时做多个事情。因此,现在我们可以说我们可以很快的完成一项工作(包括很多事情),但事实并非如此,我后面会解释为什么。

☛ 并发与并行

Go 建议只在一个内核上使用 goroutines ,但我们可以修改 Go 程序,在不同的处理器内核上运行 goroutines 。现在,把 goroutines 当做一个函数就好,它确实是一个函数,但它功能远不止于此。

并发性和并行性之间有几处不同,并发是同时处理多个事物,并行是同时做多个事物。并行并不总是有益于并发,我们将在接下来课程中学习到这一点。

在这一点上,你可能有很多的疑惑或者问题,你也有可能已经有了并发的概念,但是你可能更想知道 go 是如何实现的,以及如何使用的。为了理解 Go 的并发体系结构,以及如何在代码中使用它,以及何时在程序中使用,我们还需要理解什么是计算机进程。

☛ 什么是计算机进程?

当你用像 Cjavago 语言写程序时,它只是一个文本文件。但是,你的计算机只理解由 01 组成的二进制指令,因此你还需要将该代码翻译成机器语言,这就是编译器的用处所在。在像 pythonjavascript 这样的脚本语言中,解释器也在做同样的事情。

当编译后的程序被发送至操作系统处理时,操作系统会分配不同的东西,比如内存地址空间(进程的堆和堆栈将位于在哪里)、程序计数器、PID(进程 id)和其他非常重要的东西。一个进程至少有一个线程作为主线程,而主线程可以创建其他多个线程。当主线程执行完毕后,进程则退出。

所以我们理解了进程是一个容器,它编译了代码、内存、不同的操作系统资源以及其他可以提供给线程的东西。简而言之,进程就是内存中的程序。但是什么是线程,它们的工作是什么?

☛ 什么是线程?

线程是进程内的轻量级进程。线程是一段代码的实际执行者。线程可以访问进程提供的内存、操作系统资源句柄和其他内容。

在执行代码时,线程将变量(数据)存储在称为堆栈的内存区域中,该堆栈将抓取变量存放临时空间的空间。堆栈在编译时创建,通常具有固定大小,最好是1-2 MB。而线程堆栈只能由该线程使用,不会与其他线程共享。堆是进程的属性,任何线程都可以使用它。堆是一个共享内存空间,其中一个线程的数据也可以被其他线程访问。

我们对线程和进程有了大致了解。那么它们是用途是什么?

当你打开一个游览器,一定会有一些代码指示操作系统执行某些操作。这就代表我们创建了进程。 这个进程可能会在打开新的选项卡时请求操作系统创建其他的进程。当浏览器选项卡打开并且你在处理正常的日常事务时,选项卡进程将为不同的活动创建不同的线程(例如页面滚动,下载,听音乐等等。) 如之前的图标展示的那样。

以下是 iOS 平台上 Chrome 浏览器 应用的截图。

上面这张截图展示了谷歌 Chrome 浏览器正在为打开的标签页和内部服务创建不同的进程。由于每个进程至少有一个线程,因为我们看到的谷歌 Chrome 浏览器在这种场景下超过了 10 个线程。

在前面的主题中,我们讨论了关于 处理多任务 或 做多任务。这里 任务 是线程执行的活动。因此当多任务发生在并发或并行模型中时,有多个线程以串行或并行方式运行,也叫做 多线程

在多线程中,在进程中产生多个线程,发生内存泄露的线程可能会耗光其他线程资源导致进程无响应。在使用浏览器或其他应用时,你可能已经多次看到这种情况了。你可以使用活动监视器或任务管理器来查看无响应的进程并且将其终止。

☛ 线程调度

当多个线程串行或并行运行时,由于多个线程可能会共享某些数据,因此线程需要协同工作,以保证某一时刻只有一个线程可以访问特定数据。 按某种顺序执行多个线程称为调度。操作系统的线程由内核进程调度,部分线程由语言的运行时进行管理,比如 JRE。当多个线程同时访问数据导致访问相同数据被修改或得到意外结果时, 就会出现 竞争条件

在设计go 语言并发程序设计时,我们需要考虑 竞争条件,在接下来的课程中我们会去讨论它。

☛ go 语言中的并发性

最后,我们来谈一谈 go 如何实现并发。传统的编程语言像 java 有一个线程类用来在当前进程中创建多线程。由于 go 没有传统的 OOP 语法,因此它提供了 go 关键字来创建 goroutines 。在方法前添加 go 关键字,它就变成了 goroutines

我们在接下来会讨论 goroutines ,简言之 goroutines 是行为上面表现的像线程但是实现上确实线程的抽象。

在运行 go 程序的时候, go 运行时 会在核心上创建几个线程,所有的 goroutines 都是复用的(衍生)。在任何时间点,一个线程执行一个 goroutine,如果那个 goroutine 被堵塞,会交换给其他将在该线程上执行的 goroutine。这就像 线程调度 但是由 go 运行时 处理,并且更快。

在大多数情况下,可以在一个内核上面运行所有的 goroutines,如果需要把 goroutines 分散在系统所有的 CPU 内核上面。你可以使用 GOMAXPROCS 环境变量或者调用运行时方法 runtime.GOMAXPROCS(n) ,参数 n 是内核的数量。但是有时候你会发现设置了 GOMAXPROCS > 1 会让程序变慢。它真正取决于你的程序本身,但是你可以在网上找到问题的解决方案或解释。实际上,在使用多内核进行多线程和多进程处理时,花费更多时间在 channels 间的通信而不是计算的程序会出现性能下降。

Go 有 M:N 调度器可以用来使用多个处理器。在任何时候,M 个 goroutines 需要调度在 N 个操作系统线程运行在最多 GOMAXPROCS 个处理器上面。在任何时候,每个内核上面最多只有一个线程运行。但是调度器在需要的时候可以创建多个线程,但很少发生。如果你的程序没有启动额外的 goroutines,那么它会仅在一个线程中运行,不论你设置了它允许使用多少个内核。

☛ 线程 与 协程

由于我们之前看到的线程和协程之前存在明显的差异,下表中的对比会展示为什么线程比协程代价更大,也解释了为什么协程是在程序中实现高并发的关键。

线程 协程
操作系统的线程由内核管理并且依赖硬件 协程由 go 运行时管理不依赖硬件
操作系统的线程通常固定占用 1-2MB 栈空间 协程在新版本 go 中通常占用 8KB 栈空间
栈空间在编译期间确定并且无法增长 go 中的栈空间由运行时管理,并且可以通过分配和释放堆存储增长到 1GB
线程之间没有简单的通信介质,线程之间通信有巨大的延迟。 协程使用 channels 进行通信,且延迟低 (阅读更多)。
线程有标识。 TID 用来区分进程中的线程。 协程没有标识。 go 实现它,因为 go 没有 TLS(线程局部存储).
线程有显著的创建和销毁代价,因为线程必须从操作系统请求大量资源在完成后返回。 协程由 go 运行时创建和销毁。与线程相比,这些操作非常便捷,因为 go 运行时已经为协程维护线程池。在这种情况下,对于操作系统来说协程是无感知的。
线程是抢占式调度的 (read here)。 由于线程的调度需要保存/恢复超过50个寄存器和状态,因此线程之前的切换代价很大。当线程之间快速切换时,显的非常有必要。 协程是合作式调度 (read more)。当协程发生切换时,只有3个寄存器需要保存或恢复。

上面只是几个重要的不同点,但是如果深入研究,你会发现 Go 并发模型的惊人之处。为了突出 Go 并发性的优势,假设你有一个 Web 服务器,其中每分钟处理 1000 个请求。如果必须同时处理每个请求,这意味着需要创建 1000 个线程,或者将它们分到不同的进程中。这就是 Apache 服务器管理传入请求的方式(更多)。如果一个操作系统的线程每线程消耗 1MB 堆栈的大小,这就意味着你将消耗 1GB 的 RAM 来处理该流量。Apache 提供了 ThreadStackSize 指令来管理每个线程堆栈的大小,但是你仍然不知道你是否因此而遇到问题。

对于 goroutines ,由于堆栈的大小可以动态增加,所以可以毫无疑问的生成 1000 个 goroutine。由于 goroutine 从 8KB 大小的堆栈开始,它们中的大多数通常不会增加到超过 8KB。但是,如果有一个递归操作需要更多内存, go 可以将堆栈大小增加到 1GB,我认为除了 for {} 之外(这显然是个 bug),几乎不会发生这样的情况。

与前面看到的线程相比,goroutines 之间的快速切换是完全可行的,而且效率非常高。由于一个 goroutine 一次只在一个线程上运行,并且 goroutine 是协同调度的,所以在阻止当前 goroutine 之前,不会调度另一个 goroutine 。如果线程块中的任何 goroutine 表示等待用户输入,则在该位置调度另一个 goroutine。goroutine 可以在以下条件之一阻塞

  • 网络的输入
  • 休眠
  • 通道操作
  • 阻塞同步包中的 primitives

如果 goroutine 没有在其中一个条件下阻塞,它可能会使多路复用的线程及其缺乏,从而杀死进程中的其他 goroutine。虽然有一些补救办法,但如果有,那么它就被认为是糟糕的编程。

Channels 将发挥巨大的作用,而与 goroutines 作为一个媒体之间的共享数据,我们将在未来对其进行学习。这将防止竞争条件和它们之间对共享数据的不适当访问,而不是线程访问共享内存。

更多资源

有一篇 [https://github.com/rakyll] 写的关于Go 调度器的文章 Go 的 work-stealing 模式任务调度,你应该阅读它了解 Go语言运行时是如何管理 goroutine 的。

还有 Rob Pike 关于 golang 并发, ["并发不是并行"] 很精彩的演讲(https://www.youtube.com/watch?v=cN_DpYBzKs... "https://www.youtube.com/watch?v=cN_DpYBzKs...").

由于我们了解了并行是什么并且是如何在后台工作的,让我们进入下一课,深入了解学习并行在 go 程序中如何创建并使用它。并行是在众多工作中分配负载让工作变的简洁高效的有效载体。这就是为什么 go 语言是你下一个构建微服务架构应用的完美语言。

本文中的所有译文仅用于学习和交流目的,转载请务必注明文章译者、出处、和本文链接
我们的翻译工作遵照 CC 协议,如果我们的工作有侵犯到您的权益,请及时联系我们。

原文地址:https://medium.com/rungo/achieving-concu...

译文地址:https://learnku.com/go/t/30845

本文为协同翻译文章,如您发现瑕疵请点击「改进」按钮提交优化建议
讨论数量: 0
(= ̄ω ̄=)··· 暂无内容!

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!