Go 并发编程的思考

如果我必须选择 Go 的一个伟大特性,那么它必须是内置的并发模型。Go 不仅支持并发性,而且使其更好,更易于使用。Go 并发模型 (goroutine) 对并发编程的作用,就类似于 docker 之于虚拟化的作用。

什么是并发

在计算机程序设计中,并发性指的是计算机同时处理多个任务的能力。例如,如果你在浏览器中上网,可能会有很多事情同时在发生。比如,你正在下载一些文件,同时滚动页面来收听音乐。因此浏览器需要同时处理这两件事情。如果浏览器无法处理这些问题,则需要等到所有下载任务完成,然后才能够重新浏览网站,这对于用户来说是一件很痛苦的事情。

一台通用的 PC 机可能只有一个 CPU 核心来完成所有的任务,一个 CPU 核心可以一次处理一件事。当我们讨论并发性的时候,指的的是我们将 CPU 的时间片分配给需要处理的事情。因此,我们感觉到有很多事情在同时发生。

让我们来看一个CPU管理web浏览器如何让处理我们示例图表中的内容。

从上图中,你可以看到一个单核处理器根据每个任务的优先级来划分工作负载。例如,当页面滚动的时候,听音乐的优先级可能很低,因此有时你的音乐会因为网速低而停止,但是你仍然可以滚动页面。单个处理器通过某种切换时间片调度任务执行的策略,让用户感受到多个任务在同时执行。

什么是并行

接下来问题来了,如果我们CPU有多个内核呢?实际上现代的CPU上都是多核架构,如果一个CPU有多个处理核心,我们就把它叫做“多核处理器”。你可能在购买电脑或者智能手机的时候听说过这个词,例如我目前工作的笔记本就是2核心的 ,相对于目前比较先进的个人电脑 CPU 来说,是很 LOW的了。而且

商用服务器一般达到了 64 核心的处理能力,多个处理其能够同时处理多个任务。在前面 web 浏览器的示例中,我们的单核处理器必须将 CPU 时间分配给不同的任务对象。使用多核处理器,我们可以在不同的核中同时运行不同的任务,可以看到下图。

同时运行多个任务的概念我们称之为并行。当我们的 CPU 有多个内核时,我们可以使用不同的 CPU 内核来同时执行多个任务,因此我们可以很快的去完成一项包括很多任务的工作。

并发 vs 并行

Go 建议只在一个内核上使用 goruntines,但是我们可以修改 Go 程序,以遍在不同的处理器内核上运行 goruntines。

并发和并行之前有几个区别。并发是交替的处理多个事情,并行则是同时处理多个事情。那是不是并行一定会被并发更有益呢?也不一定,我们会在后续的播客里面讨论到这一点

现在,可能会有很多问题在你的脑海里飞舞,你可能已经建立的并行和并发的想法,但你可能想知道如何使用 Go 的并发体系去实现它,在这之前我们先来了解一下计算机进程。

什么是计算机进程?

当你用 C、Java 或 Go 等语言编写一个计算机程序时,它只是一个文本文件。但是由于计算机是只理解 0和1组成的二进制指令,所以需要将该代码翻译成机器语言。这就是编译器的用武之地。在像 python 和 js这样的脚本语言中,解释器做同样的事情。 当编译后的程序被发送到操作系统进行处理时,操作系统会分配不同的东西,比如内存地址空间(进程的堆和堆栈位于其中)、程序计数器、进程id (PID) 和其他关键的东西。进程至少又一个称之为主线程的线程,而主线程可以创建多个其他线程。当主线程执行完成时,进程退出。

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

什么是计算机线程?

线程是一段代码的实际执行者。线程可以访问进程提供的内存、操作系统资源和其他东西。执行程序代码是,内存区域内的线程存储变量(数据)被称为堆栈,其中暂存的变量占用堆栈空间,堆栈是在运行时创建的,通常具有固定大小,最好是1-2MB,线程堆栈只能由改线程使用,并且不会与其他线程共享。堆是进程的属性,任何线程都可以使用它,堆是一个共享的内存空间,一个线程中的数据也可以被其他线程访问。

现在,我们对进程和线程有了一个大致的了解。但是它们有什么用呢?

当你启动Web浏览器的时候,必须有一些调用os进程操作的代码。这意味着我们正在创建一个进程,一个进程可能会操作 os 为新选项卡创建另一个进程。当浏览器选项卡打开并且您在执行日常工作的时候,该选项卡将开始为不容的活动(如页面滚动,下载,听音乐等)创建不同的线程,就像我们前面的两个进程处理任务图里看到的那样。 以下是 MacOS 上Chrome浏览器应用程序任务图

该图显示了 Google Chrome 浏览器对打开的标签页和内部服务使用的不同进程。由于每个进程都至少又一个线程,因此我们可以看到线程数是大于进程数的。

在多线程中,在一个进程中产生多个线程的情况下,具有内存泄漏的线程可能会耗尽其他现层需要的资源而导致进程无响应。使用浏览器或其他任何程序的时候,你可能都遇到过出现无响应进程,任务管理器提示要将其杀死的现象。

线程调度

当多个线程串行或者并行运行的时候,由于多个线程之间可能共享一些数据,因此线程之间需要协同工作,以便于一次只有一个线程可以访问特定的数据,保证任务的安全执行。我们把以某种顺序执行多个线程称为调度,操作系统线程由内核调度,某些线程由编程语言(如:Java的运行时环境-JRE )的运行时环境管理。当多个线程试图同时访问同一数据导致数据被更改或导致意外结果时,我们就说发生了争用(race condition)。

当我们设计并发的 Go 程序时,关键在于寻找到这种争用的情况,并且通过合理的措施才可以争用情况下,多线程程序的安全运行。

在 Go 中使用并发

接下来,我们来讨论如何在 Go 代码中实现并发。我们知道,在 Java, C++ 之类具有面向对象编程(OOP)特性的的语言中一般具有一个线程类,我们可以通过该类在当前进程中创建多个线程对象。由于 Go 语言没有传统 OOP 语法,因此它提供了 go 关键字来创建 goruntine。当go关键字放在函数调用之前时,它将成为 goruntine 并被 go 调度执行。

在后续的文章中,我们将单独讨论协程 goroutine (文中goroutine和协程是等价的概念),目前你可以将它看作是一个线程,从技术上来讲,协程的行为类似于线程,它是线程的抽象,下一小节将会介绍这两者之间的区别。

当我们运行 Go 程序时,Go 运行时将在一个内核上创建一定数量的线程。所有的 goruntine 在该内核上进行多路复用。在任意时间点,一个线程执行一个 goroutine,如果该 goroutine 被停止,则它将被换成在该线程上执行另一个 goroutine。这有点类似于内核的线程调度,但是由 Go 的运行时 (runtime) 处理,将比内核调度更快。

建议在大多数的情况下,在一个内核上运行所有的 goroutine,但是如果你需要在系统的多核内核之前调度执行 goroutine,则可以使用 GOMAXPROCS 环境变量控制,也可以使用runtime.GOMAXPROCS(n)(https://golang.org/pkg/runtime/#GOMAXPROCS) 调节运行时环境,其中 n 就是你要使用的核心数。你可能会觉得将 GOMAXPROCS 设置成 1 使程序变慢。不过这不是绝对的,如何设置这个参数取决于你目前运行程序的性质,很有可能花在多个核之间的通信开销要比你的运行开销还要大,这时候操作系统线程和进程将会遇到性能下降的情况,同样你的 Go 程序性能也就随之下降了。 Go 有一个 M:N 调度程序,它可以调度 Go 程序在多个处理器上执行。任何时候,都需要在 GOMAXPROCS 个处理器上运行 N 个操作系统线程上再调度 M 个协程 。在任何时候,每个内核最多运行一个线程,但如果需要,调度程序可以创建更多的线程,但是这种情况很少发生。如果你的代码里面没有启动任何的 goroutine,那么无论你是用多少个内核,你的程序都只会在一个线程中、一个核上运行。

线程 vs 协程

由于线程和协程之间存在着明显的区别,下面我们将通过对比项来解释为什么线程开销比协程更高,以及为什么协程是我们应用程序实现高级别并发特性的关键所在。

以上是几个重要的区别,推荐你去深入的研究 Go 并发模型的实现,它将会颠覆你对并发编程的理解。为了突出这个 Go 协程模型的强大,我们可以来分析一个案例。假设有一台 web 服务器,每分钟处理 1000 个请求。如果必须同时运行每个请求,则意味着你需要创建 1000 个线程或将它们划分到不同的进程中。这就是经典服务器 Apache (https://www.apache.org/) 的做法,如果每个线程消耗 1MB 的堆栈大小,则意味着你将要使用 1GB 的内存用于处理改流量。当然,Apache 提供了ThreadStackSize 指令来管理每个线程的堆栈大小,但是问题仍然没有得到根本的解决。对于 Go 写成来说,由于堆栈大小可以动态增长,因此,你可以毫无问题的生成 1000 个 goruntine 。由于 goruntine 的初始堆栈空间可以调节,初始为8KB(更高的Go版本可能会更小),因此并不会消耗多大的内存空间。同时当某个 goruntine 里面需要进行递归操作。Go可以轻松的将堆栈大小调大,可以达到1GB的大小,这样无疑是“用更低的成本去做同样的事情”。

上面我们提到,一个线程上在一个时刻执行运行一个协程,协程与协程之前是 Go 运行时来进行协同调度的。另一个协程不会被 “被占用的线程” 调度,知道在该线程上运行着的协程被阻塞。以下情况可以阻塞一个协程:

- 网络流输入

- 休眠 (sleeping)

- 通道 (channel) 操作

- 阻塞同步包 (https://golang.org/pkg/sync/) 中的一些原语触发

我们可以思考,假设协程不在上述情况下阻塞,那么阻塞住的协程将导致它所运行在的线程阻塞,杀掉其他需要调度的协程,我们需要通过详细谨慎的编程手段来阻止这样的事情发生。通道和同步原语在 Go 语言并发编程中扮演的举足轻重的角色,后面我们将通过详细的文章来分析它们的原理以及使用上的注意事项,这里不再过多阐述。

通过这篇文章,我们了解了线程调度的概念,以及 Go 中的并发使用和协程调度模型,最后我们对线程和协程进行了详细的对比项,希望这些对比项可以帮助你在 Go 并发编程时做出更好的决策来使得程序达到更优的性能。后续的文章,我们将给出一些实际的程序代码来探索 Go 并发编程的奥秘,尽情期待。

引用

[1] 表格中协程 8KB 堆栈设计的参考 https://golang.org/doc/go1.2#stack_size

参考

Achieving concurrency in Go

https://www.youtube.com/watch?v=f6kdp27TYZs

https://golang.org/pkg/sync/

欢迎关注我们的微信公众号,每天学习Go知识

我来评几句
登录后评论

已发表评论数()

相关站点

热门文章