🚉

Effective Go(有效的 GO)[4]

并发编程是一个庞大的主题,这里只能介绍一些 Go 特有的亮点。

并发

通过通信共享

在许多环境中,并发编程因实现对共享变量的正确访问所需的细微差别而变得困难。Go 鼓励一种不同的方法,其中共享值通过通道传递,实际上,多个执行线程之间从不主动共享。任何时刻只有一个 goroutine 可以访问该值。数据竞争在设计上无法发生。为了鼓励这种思维方式,我们将其简化为一个口号:
不要通过共享内存进行通信;相反,通过通信共享内存。
这种方法可能过于极端。例如,引用计数最好是将互斥锁放在一个整数变量周围。但作为一种高级方法,使用通道控制访问使得编写清晰、正确的程序变得更容易。
可以这样思考这一模型:考虑一个在一个 CPU 上运行的典型单线程程序。它不需要同步原语。现在运行另一个这样的实例;它也不需要同步。现在让这两个实例进行通信;如果通信是同步器,则仍然不需要其他同步。Unix 管道正好完美地符合这一模型。虽然 Go 的并发方式源于 Hoare 的通信顺序进程(CSP),但它也可以被视为 Unix 管道的类型安全泛化。

Goroutines

它们被称为 goroutines,因为现有的术语——线程、协程、进程等——传达了不准确的含义。Goroutine 的模型很简单:它是一个与其他 goroutine 在同一地址空间内并发执行的函数。它是轻量级的,成本仅略高于分配堆栈空间。堆栈开始时很小,因此成本低,并根据需要通过分配(和释放)堆存储来增长。
Goroutines 被多路复用到多个操作系统线程上,因此如果一个线程阻塞,例如在等待 I/O 时,其他线程仍然可以继续运行。它们的设计隐藏了线程创建和管理的许多复杂性。
在函数或方法调用前加上 go 关键字,可以在新的 goroutine 中运行该调用。当调用完成时,goroutine 会默默退出。(效果类似于 Unix shell 中的 & 符号,用于在后台运行命令。)
函数字面量在 goroutine 调用中非常方便。
在 Go 中,函数字面量是闭包:实现确保函数引用的变量在它们活动期间存活。
这些示例并不太实用,因为函数没有办法发出完成信号。为此,我们需要通道。

通道

与映射一样,通道通过 make 分配,结果值作为对底层数据结构的引用。如果提供了一个可选的整数参数,它设置通道的缓冲区大小。默认值为零,表示无缓冲或同步通道。
无缓冲通道将通信(值的交换)与同步(确保两个计算(goroutine)处于已知状态)结合在一起。
使用通道有很多优雅的习惯用法。以下是一个起步示例。在上一节中,我们在后台启动了一个排序操作。通道可以让启动的 goroutine 等待排序完成。
接收者总是会阻塞,直到有数据可以接收。如果通道是无缓冲的,发送者会阻塞,直到接收者接收到值。如果通道有缓冲,发送者仅在值被复制到缓冲区时阻塞;如果缓冲区已满,则意味着需要等待某个接收者取走一个值。
有缓冲的通道可以像信号量一样使用,例如限制吞吐量。在这个示例中,传入的请求被传递到 handle 函数,该函数向通道发送一个值,处理请求,然后从通道接收一个值,以准备下一个消费者的“信号量”。通道缓冲区的容量限制了对 process 的同时调用次数。
一旦 MaxOutstanding 个处理程序正在执行 process,任何更多的请求将阻塞,尝试发送到已满的通道缓冲区,直到一个现有的处理程序完成并从缓冲区接收。
不过,这种设计有一个问题:Serve 为每个传入请求创建一个新的 goroutine,尽管只有 MaxOutstanding 个请求可以同时运行。因此,如果请求到达得太快,程序可能会消耗无限的资源。我们可以通过改变 Serve 来控制 goroutine 的创建:
(请注意,在 Go 1.22 之前的版本中,这段代码有一个错误:循环变量在所有 goroutine 中共享。有关详细信息,请参见 Go wiki。)
另一种更好地管理资源的方法是启动固定数量的 handle goroutine,它们都从请求通道读取。goroutine 的数量限制了对 process 的同时调用次数。这个 Serve 函数还接受一个通道,用于告知它退出;在启动 goroutines 后,它会阻塞接收该通道的信号。

通道的通道

Go 的一个重要特性是通道是第一类值,可以像其他值一样分配和传递。这个特性的一个常见用法是实现安全的并行解复用。
在前一节的示例中,handle 是一个理想化的请求处理程序,但我们没有定义它处理的类型。如果该类型包含一个用于回复的通道,每个客户端可以提供自己的答案路径。以下是 Request 类型的示意定义。
客户端提供一个函数及其参数,以及一个用于接收答案的通道。
在服务器端,处理函数唯一的变化是:
显然,还有很多工作要做以使其更现实,但这段代码是一个框架,用于构建一个速率限制的、并行的、非阻塞的 RPC 系统,并且没有任何互斥锁。

并行化

这些思想的另一个应用是将计算并行化到多个 CPU 核心。如果计算可以拆分为可以独立执行的单独部分,则可以并行化,并使用通道来信号每个部分的完成。
假设我们有一个昂贵的操作要在一个向量的项目上执行,并且每个项目的操作值是独立的,如以下理想化示例所示。
我们在循环中独立启动这些部分,每个 CPU 启动一个。它们可以以任何顺序完成,但这无关紧要;我们只需通过在启动所有 goroutine 后清空通道来计数完成信号。
我们可以通过运行时询问适当的值,而不是为 numCPU 创建一个常量值。函数 runtime.NumCPU 返回机器上的硬件 CPU 核心数量,因此我们可以写:
还有一个函数 runtime.GOMAXPROCS,它报告(或设置)用户指定的 Go 程序可以同时运行的核心数量。它默认值为 runtime.NumCPU 的值,但可以通过设置同名的环境变量或通过调用该函数并传递正数来覆盖。调用它并传递零仅查询该值。因此,如果我们想尊重用户的资源请求,我们应该写:
请务必不要混淆并发性(将程序结构为独立执行的组件)和并行性(为了效率在多个 CPU 上并行执行计算)。虽然 Go 的并发特性可以使某些问题易于结构化为并行计算,但 Go 是一种并发语言,而不是并行语言,并非所有的并行化问题都适合 Go 的模型。有关区别的讨论,请参见 这篇博客文章

漏水的缓冲区

并发编程的工具甚至可以使非并发的想法更容易表达。以下是一个从 RPC 包中抽象出来的示例。客户端 goroutine 循环接收来自某个来源的数据,可能是网络。为了避免分配和释放缓冲区,它保持一个空闲列表,并使用一个缓冲通道来表示它。如果通道为空,则分配一个新的缓冲区。一旦消息缓冲区准备好,就将其发送到 serverChan
服务器循环从客户端接收每条消息,处理它,并将缓冲区返回到空闲列表。
客户端尝试从 freeList 中获取一个缓冲区;如果没有可用的,则分配一个新的。服务器对 freeList 的发送将 b 放回空闲列表,除非列表已满,在这种情况下,缓冲区将被丢弃,由垃圾收集器回收。(select 语句中的 default 子句在没有其他情况准备好时执行,这意味着 select 从不阻塞。)此实现仅用几行代码构建了一个漏水的桶空闲列表,依赖于缓冲通道和垃圾收集器进行记账。

错误处理

库函数通常需要向调用者返回某种错误指示。如前所述,Go 的多值返回使得可以轻松地在正常返回值旁边返回详细的错误描述。使用这个特性提供详细的错误信息是良好的编程风格。例如,os.Open 不仅在失败时返回 nil 指针,还返回一个描述出错原因的错误值。
按照约定,错误的类型是 error,这是一个简单的内置接口。
库的作者可以自由地实现这个接口,以提供更丰富的模型,这样不仅可以看到错误,还可以提供一些上下文信息。如前所述,os.Open 除了返回通常的 *os.File 值外,还返回一个错误值。如果文件成功打开,错误将是 nil;但如果出现问题,它将包含一个 os.PathError
PathErrorError 方法生成的字符串如下所示:
这样的错误信息包含了问题文件名、操作和引发的操作系统错误,即使在调用它的地方很远也很有用;它比简单的“没有这样的文件或目录”要信息丰富得多。
当可行时,错误字符串应标识其来源,例如通过在生成错误的操作或包名前添加前缀。例如,在 image 包中,由于未知格式而导致的解码错误的字符串表示为“image: unknown format”。
关心具体错误细节的调用者可以使用类型开关或类型断言来查找特定错误并提取详细信息。对于 PathErrors,这可能包括检查内部的 Err 字段以获取可恢复的失败。
第二个 if 语句是另一个类型断言。如果失败,ok 将为 falsee 将为 nil。如果成功,ok 将为 true,这意味着错误的类型是 *os.PathError,然后我们可以检查以获取更多关于错误的信息。

恐慌处理

报告错误给调用者的常规方式是返回一个 error 作为额外的返回值。典型的 Read 方法就是一个众所周知的例子;它返回一个字节计数和一个 error。但如果错误是不可恢复的呢?有时程序根本无法继续。
为此,有一个内置函数 panic,它实际上会创建一个运行时错误,停止程序(但见下一节)。该函数接受一个任意类型的单一参数——通常是一个字符串——在程序终止时打印出来。它也是指示某些不可能发生的事情的方式,例如退出一个无限循环。
这只是一个示例,但真实的库函数应避免使用 panic。如果问题可以被掩盖或解决,让程序继续运行总是更好的选择。一个可能的反例是在初始化期间:如果库确实无法设置自己,可能合理地选择 panic

恢复处理

当调用 panic 时,包括因运行时错误(例如超出切片边界或失败的类型断言)而隐式调用时,当前函数的执行立即停止,并开始展开 goroutine 的调用栈,同时沿途运行任何延迟函数。如果展开到达 goroutine 调用栈的顶部,程序将终止。然而,可以使用内置函数 recover 来重新获得对 goroutine 的控制并恢复正常执行。
调用 recover 会停止展开并返回传递给 panic 的参数。由于在展开期间运行的代码仅限于延迟函数,因此 recover 仅在延迟函数内部有用。
recover 的一个应用是在服务器内部关闭失败的 goroutine,而不影响其他正在执行的 goroutine。
在这个示例中,如果 do(work) 发生恐慌,结果将被记录,goroutine 将干净地退出,而不干扰其他 goroutine。延迟闭包中无需做任何其他事情;调用 recover 完全处理了这个条件。
因为 recover 只有在直接从延迟函数调用时才返回 nil,所以延迟代码可以调用自己使用 panicrecover 的库例程,而不会失败。例如,safelyDo 中的延迟函数可以在调用 recover 之前调用记录函数,而该记录代码将不会受到恐慌状态的影响。
通过设置恢复模式,do 函数(及其调用的任何函数)可以通过调用 panic 来优雅地处理任何不好的情况。我们可以利用这个想法来简化复杂软件中的错误处理。让我们看一个理想化的 regexp 包版本,它通过调用 panic 来报告解析错误,使用一个本地错误类型。以下是 Error 的定义、一个 error 方法,以及 Compile 函数。
如果 doParse 发生恐慌,恢复块将返回值设置为 nil——延迟函数可以修改命名返回值。然后它会在赋值给 err 时检查问题是否是解析错误,通过断言它具有本地类型 Error。如果不是,类型断言将失败,导致运行时错误,继续展开栈,就好像没有中断一样。这种检查意味着如果发生意外情况,例如越界,代码将失败,即使我们使用 panicrecover 来处理解析错误。
通过错误处理,error 方法(因为它是绑定到类型的方法,因此它有相同的名称是合适的,甚至是自然的)使得报告解析错误变得简单,而不必担心手动展开解析栈:
尽管这种模式很有用,但仅应在包内使用。Parse 将其内部的 panic 调用转换为 error 值;它不会将 panic 暴露给其客户端。这是一个良好的规则。
顺便提一下,这种重新 panic 的习惯在实际错误发生时会改变 panic 值。然而,原始和新错误都会在崩溃报告中呈现,因此问题的根本原因仍然可见。因此,这种简单的重新 panic 方法通常是足够的——毕竟这是一场崩溃——但如果您希望仅显示原始值,可以编写更多代码来过滤意外问题,并用原始错误重新 panic。这留给读者作为练习。

一个 Web 服务器

最后,让我们完成一个完整的 Go 程序,一个 Web 服务器。这个服务器实际上是一种 Web 重新服务器。Google 提供了一个服务 chart.apis.google.com,可以自动将数据格式化为图表和图形。但由于需要将数据放入 URL 作为查询,因此交互使用起来比较困难。这里的程序提供了一种更好的接口:给定一小段文本,它调用图表服务器生成一个二维码,这是一种编码文本的矩阵,可以用手机的相机扫描并解释为 URL,从而节省您在手机小键盘上输入 URL 的时间。
以下是完整的程序,后面会有解释。
main 的部分应该很容易理解。一个标志设置了服务器的默认 HTTP 端口。模板变量 templ 是乐趣所在。它构建了一个 HTML 模板,由服务器执行以显示页面;稍后会详细介绍。
main 函数解析标志,并使用我们上面讨论的机制将函数 QR 绑定到服务器的根路径。然后调用 http.ListenAndServe 启动服务器;它在服务器运行时阻塞。
QR 只是接收请求,其中包含表单数据,并在名为 s 的表单值上执行模板。
模板包 html/template 功能强大;这个程序仅触及其能力。本质上,它通过替换来自传递给 templ.Execute 的数据项的元素,动态重写一段 HTML 文本。在模板文本(templateStr)中,双大括号包围的部分表示模板操作。{{if .}}{{end}} 的部分仅在当前数据项(称为 .)的值非空时执行。也就是说,当字符串为空时,这部分模板将被抑制。
两个片段 {{.}} 表示在网页上显示传递给模板的数据——查询字符串。HTML 模板包自动提供适当的转义,因此文本是安全显示的。
模板字符串的其余部分只是加载页面时显示的 HTML。如果这个解释太快,请参阅 文档 以获取有关模板包的更详细讨论。
就这样:几行代码加上一些数据驱动的 HTML 文本,就构成了一个有用的 Web 服务器。Go 足够强大,可以在几行代码中实现很多功能。