Go-系统编程实用指南(全)

Go 系统编程实用指南(全)

原文:zh.annas-archive.org/md5/62FC08F1461495F0676A88A03EA0ECBA

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书将提供对各种有趣的 Go 概念的深入解释。它从 Unix 和系统编程开始,这将帮助您了解 Unix 操作系统提供了哪些组件,从内核 API 到文件系统,并让您熟悉系统编程的基本概念。

接下来,它继续涵盖 I/O 操作的应用,重点放在 Unix 操作系统中的文件系统、文件和流上。它涵盖了许多主题,包括从文件中读取和写入等 I/O 操作。

本书还展示了各种进程如何相互通信。它解释了如何在 Go 中使用基于 Unix 管道的通信,如何在应用程序内部处理信号,以及如何使用网络进行有效通信。此外,它还展示了如何对数据进行编码以提高通信速度。

本书最后将帮助您了解 Go 最现代的特性——并发。它将向您介绍语言的工具,包括 sync 和通道,以及如何何时使用每一个。

本书适合对象

本书适合希望学习 Go 系统编程的开发人员。虽然不需要 Unix 和 Linux 系统编程的先前知识,但一些中级 Go 知识将有助于您理解本书中涵盖的概念。

本书涵盖的内容

第一章,系统编程简介,向您介绍了 Go 和系统编程,并提供了一些基本概念以及 Unix 及其资源的概述,包括内核 API。它还定义了本书其余部分中使用的许多概念。

第二章,Unix 操作系统组件,重点放在 Unix 操作系统以及您将与之交互的组件上——文件和文件系统、进程、用户和权限、线程等。它还解释了操作系统的各种内存管理技术,以及 Unix 如何处理驻留内存和虚拟内存。

第三章,Go 概述,介绍了 Go,从语言的历史开始,然后逐一解释了所有基本概念,从命名空间和类型系统、变量和流程控制,到内置函数和并发模型,同时还解释了 Go 如何交互和管理其内存。

第四章,使用文件系统,帮助您了解 Unix 文件系统的工作原理,以及如何掌握 Go 标准库来处理文件路径操作、文件读取和文件写入。

第五章,处理流,帮助您了解 Go 用于抽象数据流的输入和输出流的接口。它解释了它们的工作原理,以及如何组合它们并在不泄露信息的情况下最好地使用它们。

第六章,构建伪终端,帮助您了解伪终端应用程序的工作原理以及如何创建一个。结果将是一个使用标准流的交互式应用程序,就像命令行一样。

第七章,处理进程和守护进程,提供了进程是什么以及如何在 Go 中处理它们的解释,如何从 Go 应用程序启动子进程,以及如何创建一个将保持在后台(守护进程)并与其交互的命令行应用程序。

第八章,“退出代码、信号和管道”,讨论了 Unix 进程间通信。它解释了如何有效地使用退出代码。它向您展示了应用程序内部如何默认处理信号,以及如何使用一些模式来有效地处理信号。此外,它解释了如何使用管道连接不同进程的输出和输入。

第九章,“网络编程”,解释了如何使用网络进行进程通信。它解释了网络通信协议的工作原理。它最初专注于低级套接字通信,如 TCP 和 UDP,然后转向使用众所周知的 HTTP 协议进行 Web 服务器开发。最后,它展示了如何使用 Go 模板引擎。

第十章,“使用 Go 进行数据编码”,解释了如何利用 Go 标准库对复杂数据结构进行编码,以便促进进程通信。它分析了基于文本的协议,如 XML 和 JSON,以及基于二进制的协议,如 GOB。

第十一章,“处理通道和 Goroutines”,解释了并发和通道的基础知识,以及一些通用规则,可以防止在应用程序中创建死锁和资源泄漏。

第十二章,“使用 sync 和 atomic 进行同步”,讨论了syncsync/atomic标准库的同步包,以及如何使用它们来轻松实现并发,而不是使用通道。它还专注于避免资源泄漏和回收资源。

第十三章,“使用上下文进行协调”,讨论了Context,这是 Go 中一个相对较新的包,提供了一种有效处理异步操作的简单方法。

第十四章,“实现并发模式”,使用前三章的工具,并演示如何有效地使用和组合它们进行通信。它专注于 Go 中最常用的并发模式。

第十五章,“使用反射”,解释了反射是什么,以及是否应该使用它。它展示了标准库中的反射用途,并指导您创建一个实际的例子。它还展示了如何在没有必要使用反射的情况下避免使用它。

第十六章,“使用 CGO”,解释了 CGO 的工作原理,以及为什么以及何时应该使用它。它解释了如何在 Go 应用程序中使用 C 代码,以及如何在 C 代码中使用 Go。

充分利用本书

尝试示例和构建现代应用程序需要一些 Go 的基础知识。

每一章都包括一组问题,这些问题将帮助您评估对该章节的理解。这些问题将对您非常有益,因为它们将帮助您快速复习每一章。

此外,每一章都提供了如何运行代码文件的说明,而书的 GitHub 存储库提供了必要的细节。

下载示例代码文件

您可以从www.packt.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packt.com/support并注册,以便直接通过电子邮件接收文件。

您可以按照以下步骤下载代码文件:

  1. www.packt.com登录或注册。

  2. 选择“支持”选项卡。

  3. 点击“代码下载和勘误”。

  4. 在搜索框中输入书名,并按照屏幕上的说明操作。

下载文件后,请确保使用以下最新版本解压或提取文件夹:

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

本书的代码包也托管在 GitHub 上,链接为github.com/PacktPublishing/Hands-On-System-Programming-with-Go。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

我们还提供了来自我们丰富书籍和视频目录的其他代码包,可在github.com/PacktPublishing/上找到。快去看看吧!

下载彩色图片

我们还提供了一份 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图片。您可以在此处下载:static.packt-cdn.com/downloads/9781789804072_ColorImages.pdf

代码示例

访问以下链接查看代码运行的视频:bit.ly/2ZWgJb5

示例演示

在本书中,您会发现许多代码片段,后面跟着一个链接到play.golang.org,这是一个允许您以一定限制运行 Go 应用程序的服务。您可以在blog.golang.org/playground上了解更多信息。

要查看此类示例的完整源代码,您需要访问 Playground 链接。一旦进入网站,您可以点击运行按钮来执行应用程序。页面底部将显示输出。以下是代码在 Go Playground 中运行的示例:

如果您愿意,您可以通过向示例添加和编辑更多代码,然后运行它们来进行实验。

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。例如:"这种服务包括load,它将程序加载到内存并在将控制传递给程序本身之前准备执行程序,或者execute,它在现有进程的上下文中运行可执行文件。"

代码块设置如下:

<meta name="go-import" content="package-name vcs repository-url">

任何命令行输入或输出都将按如下方式书写:

Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Windows-Subsystem-Linux

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以这种方式出现在文本中。例如:"与此同时,系统开始变得分布式,应用程序开始以容器的形式进行交付,并由其他系统软件进行编排,比如Kubernetes。"

警告或重要说明会显示为这样。

提示和技巧会显示为这样。

第一部分:系统编程和 Go 语言简介

本节是对 Unix 和系统编程的介绍。它将帮助您了解 Unix 操作系统提供了哪些组件,从内核 API 到文件系统,您将熟悉系统编程的基本概念。

本节包括以下章节:

  • 第一章,系统编程简介

  • 第二章,Unix 操作系统组件

  • 第三章,Go 语言概述

第一章:系统编程简介

本章是系统编程的介绍,探讨了从最初定义到随着系统演变而发生变化的一系列主题。本章提供了一些基本概念和 Unix 及其资源的概述,包括内核和应用程序编程接口(API)。这些概念中的许多是在这里定义的,并在本书的其余部分中使用。

本章将涵盖以下主题:

  • 什么是系统编程?

  • 应用程序编程接口

  • 了解保护环如何工作

  • 系统调用概述

  • POSIX 标准

技术要求

如果您使用 Linux,则本章不需要您安装任何特殊软件。

如果您是 Windows 用户,可以安装 Windows 子系统用于 Linux(WSL)。按照以下步骤安装 WSL:

  1. 以管理员身份打开 PowerShell 并运行以下命令:
Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Windows-Subsystem-Linux
  1. 在提示时重新启动计算机。

  2. 从 Microsoft Store 安装您喜欢的 Linux 发行版。

开始系统编程

多年来,IT 领域发生了巨大变化。挑战冯·诺伊曼机的多核 CPU、互联网和分布式系统只是过去 30 年发生的一些变化。那么,系统编程在这个领域中处于什么位置呢?

为软件而设计的软件

让我们首先从标准教科书的定义开始。

系统编程(或系统编程)是编程计算机系统软件的活动。与应用程序编程相比,系统编程的主要区别特征在于,应用程序编程旨在生产直接为用户提供服务的软件(例如,文字处理器),而系统编程旨在生产为其他软件提供服务的软件和软件平台,并且设计为在性能受限的环境中工作,例如操作系统、计算科学应用程序、游戏引擎和 AAA 视频游戏、工业自动化和软件即服务应用程序。

该定义突出了系统应用程序的两个主要概念如下:

  • 被其他软件使用的软件,而不是直接由最终用户使用。

  • 软件具有硬件意识(它知道硬件如何工作),并且面向性能。

这使得很容易将操作系统内核、硬件驱动程序、编译器和调试器等系统软件识别为系统软件,而不是系统软件、聊天客户端或文字处理器。

从历史上看,系统程序是使用汇编语言和 C 创建的。然后出现了用于将系统程序提供的功能联系在一起的 shell 和脚本语言。系统语言的另一个特征是对内存分配的控制。

语言和系统演变

在过去的十年中,脚本语言变得越来越受欢迎,以至于一些脚本语言有了显著的性能改进,并且整个系统都是用它们构建的。例如,让我们想一想 JavaScript 的 V8 引擎和 Python 的 PyPy 实现,它们显著改变了这些语言的性能。

其他语言,如 Go,证明了垃圾收集和性能并不是互斥的。特别是,Go 在 1.5 版中成功用 Go 编写的本地版本替换了 C 编写的内存分配器,将性能提高到可比较的水平。

与此同时,系统开始变得分布式,应用程序开始以容器的形式进行部署,由其他系统软件(例如 Kubernetes)进行编排。这些系统旨在维持巨大的吞吐量,并通过两种主要方式实现:

  • 通过扩展-增加托管系统的机器数量或资源

  • 通过优化软件以提高资源利用效率

系统编程和软件工程

系统编程的一些实践——比如将应用程序与硬件绑定、以性能为导向、在资源受限的环境中工作——是一种在构建分布式系统时也可以有效的方法,其中限制资源使用可以减少所需的实例数量。看起来系统编程是解决通用软件工程问题的好方法。

这意味着学习系统编程的概念,关于如何有效地使用机器资源——从内存使用到文件系统访问——将有助于构建任何类型的应用程序。

应用程序编程接口

API 是一系列子例程定义、通信协议和构建软件的工具。API 最重要的方面是它提供的功能,以及它的文档,这些文档可以帮助用户使用和实现软件在另一个软件中的使用。API 可以是允许应用软件使用系统软件的接口。

API 通常有一个特定的发布政策,旨在供特定的接收者使用。这可以是以下内容:

  • 私有的,仅供内部使用。

  • 合作伙伴和可由确定的群体使用——这可能包括希望将服务与自己的公司整合的公司

  • 公开并可供每个用户使用

API 的类型

我们将看到有几种类型的 API,从用于使不同的应用软件一起工作的 API,到操作系统向其他软件公开的内部 API。

操作系统

API 可以指定如何与应用程序和操作系统进行接口。例如,Windows、Linux 和 macOS 都有一个接口,可以操作文件系统和文件。

库和框架

与软件库相关的 API 描述并规定(提供如何使用它的说明)其每个元素应该如何行为,包括最常见的错误场景。API 的行为和接口通常被称为库规范,而库是这种规范中描述的规则的实现。库和框架通常是语言绑定的,但也有一些工具可以使库在不同的语言中使用。你可以使用 CGO 在 Go 中使用 C 代码,在 Python 中你可以使用 CPython。

远程 API

这些使得可以使用特定的通信标准来操作远程资源,这些标准允许不同的技术一起工作,无论语言或平台如何。一个很好的例子是Java 数据库连接JDBC)API,它允许使用相同的函数查询许多不同类型的数据库,或者 Java 远程方法调用 API(Java RMI),它允许像本地函数一样使用远程函数。

Web API

Web API 是定义有关使用的协议、消息编码和可用端点及其预期输入和输出值的一系列规范的接口。这种 API 有两种主要的范式——REST 和 SOAP:

  • REST API 具有以下特点:

  • 它们将数据视为资源。

  • 每个资源都由 URL 标识。

  • 操作类型由 HTTP 方法指定。

  • SOAP 协议具有以下特点:

  • 它们由 W3C 标准定义。

  • XML 是消息的唯一编码方式。

  • 它们使用一系列 XML 模式来验证数据。

理解保护环

保护环,也称为分层保护域,是用于保护系统免受故障的机制。它的名称源自其权限级别的分层结构,由同心圆环表示,当移动到外部环时,特权会减少。在每个环之间有特殊的门,允许外部环以受限的方式访问内部环的资源。

架构差异

环的数量和顺序取决于 CPU 架构。它们通常以权限降低的顺序编号,使 ring 0 成为最具特权的环。这对于使用四个环(从 ring 0 到 ring 3)的 i386 和 x64 架构是正确的,但对于使用相反顺序(从 EL3 到 EL0)的 ARM 架构是不正确的。大多数操作系统不使用所有四个级别;它们最终使用两级层次结构—用户/应用程序(ring 3)和内核(ring 0)。

内核空间和用户空间

在操作系统下运行的软件将在用户(ring 3)级别执行。为了访问机器资源,它将必须与运行在 ring 0 的操作系统内核进行交互。以下是 ring 3 应用程序无法执行的一些操作:

  • 修改当前段描述符,确定当前环

  • 修改页表,防止一个进程看到其他进程的内存

  • 使用 LGDT 和 LIDT 指令,防止它们注册中断处理程序

  • 使用 I/O 指令,比如 in 和 out,可以忽略文件权限并直接从磁盘读取。

例如,对磁盘内容的访问将由内核进行调解,内核将验证应用程序是否有权限访问数据。这种协商方式提高了安全性,避免了故障,但会带来重要的开销,影响应用程序的性能。

有些应用程序可以直接在硬件上运行,而不需要操作系统提供的框架。这对于实时系统来说是真实的,因为在实时系统中,响应时间和性能都不容许妥协。

深入系统调用

系统调用是操作系统为应用程序提供对资源访问的方式。这是内核实现的 API,用于安全地访问硬件。

提供的服务

有一些类别可以用来分割操作系统提供的众多功能。这些包括控制运行应用程序及其流程、文件系统访问和网络。

进程控制

这种类型的服务包括load,它将程序添加到内存并在将控制传递给程序本身之前准备执行,或者execute,它在现有进程的上下文中运行可执行文件。属于这一类别的其他操作如下:

  • endabort—第一个要求应用程序退出,而第二个强制退出。

  • CreateProcess,也称为 Unix 系统上的fork或 Windows 中的NtCreateProcess

  • 终止进程。

  • 获取/设置进程属性。

  • 等待时间、等待事件或信号事件。

  • 分配和释放内存。

文件管理

文件和文件系统的处理属于文件管理系统调用。有createdelete文件,可以向文件系统添加或删除条目,以及openclose操作,可以控制文件以执行读写操作。还可以读取和更改文件属性。

设备管理

设备管理处理除文件系统之外的所有其他设备,如帧缓冲区或显示器。它包括从设备的请求开始的所有操作,包括与设备的通信(读取、写入、寻址)以及其释放。它还包括更改设备属性和逻辑附加和分离设备的所有操作。

信息维护

读取和写入系统日期和时间属于信息维护类别。这个类别还负责其他系统数据,比如环境。还有一组重要的操作属于这里,包括请求和处理进程、文件和设备属性。

通信

所有网络操作,从处理套接字到接受连接,都属于通信类别。这包括连接的创建、删除和命名,以及发送和接收消息。

操作系统之间的差异

Windows 具有一系列不同的系统调用,涵盖了所有内核操作。其中许多与 Unix 等效的操作完全对应。以下是一些重叠的系统调用列表:

Windows Unix

| 进程控制 | CreateProcess() ExitProcess()

WaitForSingleObject() | fork() exit()

wait()

|

| 文件操作 | CreateFile() ReadFile()

WriteFile()

CloseHandle() | open() read()

write()

close() |

| 文件保护 | SetFileSecurity() InitializeSecurityDescriptor()

SetSecurityDescriptorGroup() | chmod() umask()

chown() |

| 设备管理 | SetConsoleMode() ReadConsole()

WriteConsole() | ioctl() read()

write() |

| 信息维护 | GetCurrentProcessID() SetTimer()

Sleep() | getpid() alarm()

sleep() |

| 通信 | CreatePipe() CreateFileMapping()

MapViewOfFile() | pipe() shmget()

mmap() |

理解 POSIX 标准

为了确保操作系统之间的一致性,IEEE 对操作系统进行了一些标准化。这些标准在以下部分中描述。

POSIX 标准和特性

Unix 的可移植操作系统接口POSIX)代表了操作系统接口的一系列标准。第一个版本可以追溯到 1988 年,涵盖了诸如文件名、shell 和正则表达式等一系列主题。

POSIX 定义了许多特性,它们分为四个不同的标准,每个标准都专注于 Unix 兼容性的不同方面。它们都以 POSIX 加一个数字命名。

POSIX.1 - 核心服务

POSIX.1 是 1988 年的原始标准,最初被命名为 POSIX,但后来改名以便在不放弃名称的情况下添加更多的标准。它定义了以下特性:

  • 进程创建和控制

  • 信号:

  • 浮点异常

  • 分段/内存违规

  • 非法指令

  • 总线错误

  • 定时器

  • 文件和目录操作

  • 管道

  • C 库(标准 C)

  • I/O 端口接口和控制

  • 进程触发器

POSIX.1b 和 POSIX.1c - 实时和线程扩展

POSIX.1b 专注于实时应用程序和需要高性能的应用程序。它专注于以下方面:

  • 优先级调度

  • 实时信号

  • 时钟和定时器

  • 信号量

  • 消息传递

  • 共享内存

  • 异步和同步 I/O

  • 内存锁定接口

POSIX.1c 引入了多线程范式,并定义了以下内容:

  • 线程创建、控制和清理

  • 线程调度

  • 线程同步

  • 信号处理

POSIX.2 - shell 和实用程序

POSIX.2 为命令行解释器和实用程序(如cdechols)指定了标准。

操作系统遵从性

并非所有操作系统都符合 POSIX 标准。例如,Windows 诞生于该标准之后,因此不符合标准。从认证的角度来看,macOS 比 Linux 更符合标准,因为后者使用了另一个建立在 POSIX 之上的标准。

Linux 和 macOS

大多数 Linux 发行版遵循Linux 标准基础LSB),这是另一个包括 POSIX 和更多内容的标准,专注于维护不同 Linux 发行版之间的互操作性。它并未被认为是官方符合标准,因为开发人员没有进行认证过程。

然而,自 2007 年的 Snow Leopard 发行版起,macOS 已经完全兼容,并且自那时起就获得了 POSIX 认证。

Windows

Windows 不符合 POSIX 标准,但有许多尝试使其符合。有一些开源倡议,如 Cygwin 和 MinGW,它们提供了一个不太符合 POSIX 标准的开发环境,并支持使用 Microsoft Visual C 运行时库的 C 应用程序。微软本身也尝试过 POSIX 兼容性,比如 Microsoft POSIX 子系统。微软最新的兼容层是 Windows Linux 子系统,这是 Windows 10 中可选的功能,受到了开发人员(包括我自己)的好评。

总结

在本章中,我们看到了系统编程的含义——编写具有严格要求的系统软件,例如与硬件绑定、使用低级语言以及在资源受限的环境中工作。在构建通常需要优化资源使用的分布式系统时,它的实践可以非常有用。我们讨论了 API,定义了允许软件被其他软件使用的不同类型,包括操作系统中的 API、库和框架中的 API,以及远程和 Web API。

我们分析了在操作系统中,对资源的访问是通过称为保护环的分层级别进行安排的,以防止不受控制的使用,以提高安全性并避免应用程序的故障。Linux 模型简化了这种层次结构,只将其分为称为用户内核空间的两个级别。所有应用程序都在用户空间中运行,为了访问机器的资源,它们需要内核进行干预。

然后我们看到了一种特定类型的 API,称为系统调用,它允许应用程序向内核请求资源,并调解进程控制、文件访问和管理,以及设备和网络通信。

我们概述了 POSIX 标准,该标准定义了 Unix 系统的互操作性。在定义的特性中,还包括 C API、CLI 实用程序、shell 语言、环境变量、程序退出状态、正则表达式、目录结构、文件名和命令行实用程序 API 约定。

在下一章中,我们将探讨 Unix 操作系统资源,如文件系统和 Unix 权限模型。我们将研究进程是什么,它们如何相互通信,以及它们如何处理错误。

问题

  1. 应用程序编程和系统编程之间有什么区别?

  2. 什么是 API?API 为什么如此重要?

  3. 你能解释一下保护环是如何工作的吗?

  4. 你能举一些在用户空间无法完成的例子吗?

  5. 什么是系统调用?

  6. Unix 中使用哪些调用来管理进程?

  7. POSIX 为什么有用?

  8. Windows 是否符合 POSIX 标准?

第二章:Unix 操作系统组件

本章将重点放在 Unix 操作系统上,以及用户将与之交互的组件:文件和文件系统、进程、用户和权限等。它还将解释一些基本的进程通信以及系统程序错误处理的工作原理。在创建系统应用程序时,我们将与操作系统的所有这些部分进行交互。

本章将涵盖以下主题:

  • 内存管理

  • 文件和文件系统

  • 进程

  • 用户、组和权限

  • 进程通信

技术要求

与上一章类似,本章不需要安装任何软件:任何其他符合 POSIX 标准的 shell 都足够了。

您可以选择,例如,Bash (www.gnu.org/software/bash/),这是推荐的,Zsh (www.zsh.org/),或者 fish (fishshell.com/)。

内存管理

操作系统处理应用程序的主要和辅助内存使用。它跟踪内存的使用情况,由哪个进程使用,哪些部分是空闲的。它还处理从进程分配新内存以及进程完成时的内存释放。

管理技术

处理内存有不同的技术,包括以下内容:

  • 单一分配:除了为操作系统保留的部分外,所有内存都可供应用程序使用。这意味着一次只能执行一个应用程序,就像在Microsoft 磁盘操作系统MS-DOS)中一样。

  • 分区分配:这将内存分成不同的块,称为分区。使用其中一个块来执行一个以上的进程是可能的。分区可以重新定位和压缩,以获得下一个进程的更连续的内存空间。

  • 分页内存:内存被分成称为帧的部分,其大小固定。进程的内存被分成相同大小的部分,称为页面。页面和帧之间有映射,使进程看到自己的虚拟内存是连续的。这个过程也被称为分页

虚拟内存

Unix 使用分页内存管理技术,将每个应用程序的内存抽象为连续的虚拟内存。它还使用一种称为交换的技术,将虚拟内存扩展到辅助内存(硬盘或固态硬盘(SSD))使用交换文件。

当内存稀缺时,操作系统将处于休眠状态的进程的页面放入交换分区,以为正在请求更多内存的活动进程腾出空间,执行称为换出的操作。当执行中的进程需要交换文件中的页面时,它会被加载回主内存以执行。这称为换入

交换的主要问题是与辅助内存交互时的性能下降,但它对于扩展多任务处理能力以及处理比物理内存更大的应用程序非常有用,只需在给定时间加载实际需要的部分。创建内存高效的应用程序是通过避免或减少交换来提高性能的一种方式。

top命令显示有关可用内存、交换和每个进程的内存消耗的详细信息:

  • RES是进程使用的物理主内存。

  • VIRT是进程使用的总内存,包括交换内存,因此它等于或大于RES

  • SHR是实际可共享的VIRT的部分,例如加载的库。

了解文件和文件系统

文件系统是在磁盘中结构化数据的方法,文件是指示自包含信息的抽象。如果文件系统是分层的,这意味着文件是组织在目录树中的,目录是用于安排存储文件的特殊文件。

操作系统和文件系统

在过去的 50 年中,已经发明和使用了大量文件系统,每个文件系统都有其自己的特点,包括空间管理、文件名和目录、元数据和访问限制。每个现代操作系统主要使用一种类型的文件系统。

Linux

Linux 的首选文件系统是extended filesystemEXT)家族,但也支持其他文件系统,包括 XFS、Journaled File SystemJFS)和B-tree File SystemBtrfs)。它还兼容旧的File Allocation TableFAT)家族(FAT16 和 FAT32)和New Technology File SystemNTFS)。最常用的文件系统仍然是最新版本的 EXT(EXT4),它于 2006 年发布,扩展了其前身的功能,包括对更大磁盘的支持。

macOS

macOS 使用Apple File SystemAPFS),支持 Unix 权限并具有日志记录。它还具有丰富的元数据和保留大小写,同时又是大小写不敏感的文件系统。它支持其他文件系统,包括 HFS+和 FAT32,支持 NTFS 进行只读操作。要向这样的文件系统写入,我们可以使用实验性功能或第三方应用程序。

Windows

Windows 主要使用的文件系统是 NTFS。除了大小写不敏感外,区分 Windows 文件系统与其他文件系统的特征是在路径中使用字母后跟冒号来表示分区,结合使用反斜杠作为文件夹分隔符,而不是正斜杠。驱动器字母和使用 C 表示主分区来自 MS-DOS,其中 A 和 B 是保留的驱动器字母,用于软盘驱动器。

Windows 还原生支持其他文件系统,如 FAT,这是一个在 70 年代末到 90 年代末非常流行的文件系统家族,以及由 Microsoft 开发的Extended File Allocation TableexFAT),用于可移动设备的格式。

文件和硬链接和软链接

大多数文件都是常规文件,包含一定数量的数据。例如,文本文件包含一系列由特定编码表示的可读字符,而位图包含有关每个像素的大小和使用的位的一些元数据,然后是每个像素的内容。

文件被安排在目录中,这使得可以有不同的命名空间来重用文件名。这些文件通过名称引用,它们的人类可读标识符,并以树结构组织。路径是表示目录的唯一标识符,由所有父目录的名称通过分隔符(Unix 中为/,Windows 中为\)连接而成,从根目录到所需的叶子。例如,如果一个名为a的目录位于另一个名为b的目录下,后者位于名为c的目录下,它将从根目录开始并连接所有目录,直到文件:/c/b/a

当多个文件指向相同的内容时,我们有一个硬链接,但这在所有文件系统中都不允许(例如 NTFS 和 FAT)。软链接是指向另一个软链接或硬链接的文件。硬链接可以被删除或删除而不会破坏原始链接,但对于软链接来说并非如此。符号链接是一个具有自己数据的常规文件,它是另一个文件的路径。它还可以链接其他文件系统或不存在的文件和目录(这将是一个损坏的链接)。

在 Unix 中,一些实际上不是文件的资源被表示为文件,并且与这些资源的通信是通过写入或从它们对应的文件中读取来实现的。例如,/dev/sda文件代表整个磁盘,而/dev/stdoutdev/stdin/dev/stderr是标准输出,输入和错误。一切皆文件的主要优势是可以使用于文件的相同工具也可以与其他设备(网络和管道)或实体(进程)进行交互。

Unix 文件系统

本节中包含的原则特定于 Linux 使用的文件系统,如 EXT4。

根和 inode

在 Linux 和 macOS 中,每个文件和目录都由一个inode表示,这是一种特殊的数据结构,存储有关文件的所有信息,除了其名称和实际数据。

inode 0用于空值,这意味着没有 inode。inode 1用于记录磁盘上的任何坏块。文件系统的分层结构的根使用 inode 2。它由/表示。

从最新的 Linux 内核源代码中,我们可以看到保留了第一个 inode。如下所示:

#define EXT4_BAD_INO 1 /* Bad blocks inode */
#define EXT4_ROOT_INO 2 /* Root inode */
#define EXT4_USR_QUOTA_INO 3 /* User quota inode */
#define EXT4_GRP_QUOTA_INO 4 /* Group quota inode */
#define EXT4_BOOT_LOADER_INO 5 /* Boot loader inode */
#define EXT4_UNDEL_DIR_INO 6 /* Undelete directory inode */
#define EXT4_RESIZE_INO 7 /* Reserved group descriptors inode */
#define EXT4_JOURNAL_INO 8 /* Journal inode */

此链接是上述代码块的来源:elixir.bootlin.com/linux/latest/source/fs/ext4/ext4.h#L212

目录结构

在 Unix 文件系统中,根目录下还有一系列其他目录,每个目录用于特定目的,使得可以在不同操作系统之间保持一定的互操作性,并使得编译的软件可以在不同的操作系统上运行,使得二进制文件具有可移植性。

这是一个包含其范围的目录的全面列表:

目录 描述
/bin 所有用户的可执行文件
/boot 用于引导系统的文件
/dev 设备驱动程序
/etc 应用程序和系统的配置文件
/home 用户的主目录
/kernel 内核文件
/lib 共享库文件和其他与内核相关的文件
/mnt 临时文件系统,从软盘和 CD 到闪存驱动器
/proc 用于活动进程的进程号文件
/sbin 管理员的可执行文件
/tmp 应该安全删除的临时文件
/usr 管理命令,共享文件,库文件等
/var 变长文件(日志和打印文件)

导航和交互

在使用 shell 时,其中一个目录将是工作目录,当路径是相对的时(例如,file.shdir/subdir/file.txt)。工作目录用作前缀以获得绝对路径。这通常显示在命令行的提示中,但可以使用pwd命令(打印工作目录)打印出来。

cd(更改目录)命令可用于更改当前工作目录。要创建新目录,有mkdir(创建目录)命令。

要显示目录的文件列表,有ls命令,它接受一系列选项,包括更多信息(-l),显示隐藏文件和目录(-a),以及按时间(-t)和大小(-S)排序。

还有一系列其他命令可用于与文件交互:touch命令创建一个具有给定名称的新空文件,要编辑其内容,可以使用一系列编辑器,包括 vi 和 nano,而catmoreless是一些可以读取它们的命令。

挂载和卸载

操作系统将硬盘分割为称为分区的逻辑单元,每个分区可以是不同的文件系统。当操作系统启动时,它使用mount命令使一些分区可用,每行对应/etc/fstab文件,看起来更或多是这样:

# device # mount-point # fstype # options # dumpfreq # passno
/dev/sda1     /           ext4    defaults     0            1

此配置将/dev/sda1挂载到*/*disk,使用ext4文件系统和默认选项,不备份(0),并进行根完整性检查(1)。mount命令可以随时用于在文件系统中公开分区。它的对应命令umount用于从主文件系统中删除这些分区。用于操作的空目录称为挂载点,它代表连接文件系统的根目录。

进程

当启动应用程序时,它变成一个进程:操作系统提供的特殊实例,包括运行应用程序所使用的所有资源。为了允许操作系统解释其指令,该程序必须是可执行和可链接格式ELF)。

进程属性

每个进程都是一个五位数的标识符进程 IDPID),它代表了进程的整个生命周期。这意味着在同一时间不能有两个具有相同 PID 的进程。它们的唯一性使得可以通过知道其 PID 来访问特定的进程。一旦进程终止,其 PID 可以在需要时被重用于另一个进程。

与 PID 类似,还有其他特性来表征一个进程。它们如下:

  • P****PID:启动此进程的进程的父进程 ID

  • 优先级数:此进程对其他进程的友好程度

  • 终端或 TTY:进程连接的终端

  • RUID/EUID:进程的真实/有效用户 ID,属于进程所有者

  • RGID/EGID:进程的真实/有效组所有者

要查看活动进程的列表,有ps(进程状态)命令,显示活动用户的当前运行进程列表:

> ps -f
UID    PID  PPID  C  STIME  TTY    TIME      CMD
user   8    4     0  Nov03  pts/0  00:00:00  bash -l -i
user   43   8     0  08:53  pts/0  00:00:00  ps -f

进程生命周期

创建新进程可以以两种不同的方式发生:

  • 使用fork:这会复制调用进程。子进程(新进程)是父进程(调用进程)的精确副本(内存),除了以下内容:

  • PID 是不同的。

  • 子进程的 PPID 等于父进程的 PID。

  • 子进程不会从父进程继承以下内容:

  • 内存锁

  • 信号量调整

  • 未完成的异步 I/O 操作

  • 异步 I/O 上下文

  • 使用exec:这将用新的进程图像替换当前进程,将程序加载到当前进程空间中,并从其入口点运行。

前台和后台

当启动进程时,通常处于前台,这将阻止与 shell 的通信,直到作业完成或中断。在命令的末尾使用&符号启动进程(cat file.txt &)将其启动到后台,从而可以继续使用 shell。可以使用*C**trl *+ Z发送SIGTSTP信号,允许用户从 shell 挂起前台进程。可以使用fg命令恢复它,或使用bg命令将其放到后台。

jobs命令报告正在运行的作业及其编号。在输出中,方括号中的数字是进程控制命令使用的作业编号,如fgbg

终止作业

前台进程可以使用*C**trl *+ Z发送SIGINT信号来终止。为了终止后台进程,或向进程发送任何信号,可以使用kill命令。

kill命令接收一个参数,可以是以下之一:

  • 发送到进程的信号

  • PID 或作业号(带有%前缀)

更显著使用的信号如下:

  • SIGINT:表示由用户输入引起的终止,可以使用kill命令发送-2

  • SIGTERM:表示由用户生成的通用终止请求,也是kill命令的默认信号,值为-6

  • SIGKILL:由操作系统直接处理的终止,立即终止进程,值为-9

用户,组和权限

用户和组以及权限是 Unix 操作系统中用于控制对资源访问的主要实体。

用户和组

用户和组提供对文件和其他资源的授权。用户具有唯一的用户名,这些用户名是人类友好的标识符,但从操作系统方面来看,每个用户都由唯一的正整数表示:用户 IDUID)。组是另一个授权机制,与用户一样,它们有一个名称和一个组 IDGID)。在操作系统中,每个进程都与一个用户关联,每个文件和目录都属于一个用户和一个组。

/etc/passwd文件包含所有这些信息以及更多信息:

# username : encrypted password : UID : GID : full name : home directory : login shell
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
...
user:x:1000:1000:"User Name":/home/user:/bin/bash

用户不直接使用 UID;他们使用用户名和密码的组合来启动他们的第一个进程,即交互式 shell。第一个 shell 的子进程从中继承他们的 UID,因此他们仍然属于同一用户。

UID0保留给一个名为 root 的用户,该用户具有特殊权限,并且几乎可以在系统上执行任何操作,例如读取/写入/执行任何文件,终止任何进程,并更改正在运行的进程 UID。

组是用户的逻辑集合,用于在它们之间共享文件和目录。每个组都独立于其他组,它们之间没有特定的关系。要查看当前用户所属的组的列表,可以使用groups命令。要更改文件的组所有权,可以使用chgrp

所有者,组和其他人

Unix 文件属于用户和组。这创建了三个授权层次:

  • 所有者:与文件关联的 UID

  • :属于与文件关联的 GID 的 UIDS

  • 其他人:其他所有人

可以为这些组的每个组指定不同的权限,并且这些权限通常从所有者到其他人递减。文件的所有者具有较少的权限,这是没有意义的,因为它自己的组或该组外的用户。

读,写和执行

用户和组被用作访问文件的前两个保护层。拥有文件的用户具有与文件组不同的权限集。不是所有者且不属于该组的人具有不同的权限。这三组权限被称为所有者其他

对于集合的每个元素,可以执行三个操作:读取,写入和执行。这对于文件来说非常直接,但对于目录来说意味着不同的东西。读取使其可能列出内容,写入用于在内部添加新链接,执行用于导航。

三个权限由八进制值表示,其中第一个位是读取权限,第二个是写入,第三个是执行。它们也可以按顺序用字母rwx表示,可能的值如下:

  • 0 或---:没有权限

  • 1 或--x:执行权限(执行文件或导航到目录)

  • 2 或-w-:写权限(写文件或在目录中添加新文件)

  • 3 或-wx:写和执行

  • 4 或 r--:读取权限(读取文件或列出目录的内容)

  • 5 或 r-x:读取和执行

  • 6 或 rw-:读取和写入

  • 7 或 rwx:读取,写入和执行

三个八进制值的序列表示用户,组和其他人的权限:

  • 777:每个人都可以读取,写入和执行。

  • 700:所有者可以读取,写入和执行。

  • 664:所有者和组可以读取和写入。

  • 640:所有者可以读取和写入,组可以读取。

  • 755:所有者可以读取,写入和执行,而组和其他人可以读取和执行。

带有-l标志(或其别名ll)的ls命令显示当前目录的文件和文件夹列表及其权限。

更改权限

chmod命令使得可以更改文件或目录的权限。这可以用于覆盖当前权限或修改它们:

  • 为了替换权限,必须发出chmod xxx file命令。*xxx*可以是表示各自层权限的三个八进制值,也可以是指定权限的字符串,例如u=rwxg=rxo=r

  • 要添加或删除一个或多个权限,可以使用chmod +x filechmod -x file

有关更多信息,请使用帮助标志(chmod --help)的chmod命令。

进程通信

操作系统负责进程之间的通信,并具有不同的机制来交换信息。这些进程是单向的,例如退出代码、信号和管道,或者是双向的,例如套接字。

退出代码

应用程序通过返回称为退出状态的值将其结果传达给操作系统。这是在进程结束时传递给父进程的整数值。常见的退出代码列表可以在/usr/include/sysexits.h文件中找到,如下所示:

#define EX_OK 0 /* successful termination */
#define EX__BASE 64 /* base value for error messages */
#define EX_USAGE 64 /* command line usage error */
#define EX_DATAERR 65 /* data format error */
#define EX_NOINPUT 66 /* cannot open input */
#define EX_NOUSER 67 /* addressee unknown */
#define EX_NOHOST 68 /* host name unknown */
#define EX_UNAVAILABLE 69 /* service unavailable */
#define EX_SOFTWARE 70 /* internal software error */
#define EX_OSERR 71 /* system error (e.g., can't fork) */
#define EX_OSFILE 72 /* critical OS file missing */
#define EX_CANTCREAT 73 /* can't create (user) output file */
#define EX_IOERR 74 /* input/output error */
#define EX_TEMPFAIL 75 /* temp failure; user is invited to retry */
#define EX_PROTOCOL 76 /* remote error in protocol */
#define EX_NOPERM 77 /* permission denied */
#define EX_CONFIG 78 /* configuration error */
#define EX__MAX 78 /* maximum listed value */

此来源如下:elixir.bootlin.com/linux/latest/source/fs/ext4/ext4.h#L212

上一个命令的退出代码存储在$?变量中,可以测试它以控制操作的流程。一个常用的运算符是&&(双和号),它仅在第一个命令的退出代码为0时执行下一个指令,例如stat file && echo something >> file,仅在文件存在时向文件附加内容。

信号

退出代码连接进程和它们的父进程,但信号使得任何进程与另一个进程进行接口交互成为可能,包括它自己。它们也是异步的和单向的,但它们代表来自进程外部的通信。

最常见的信号是SIGINT,它告诉应用程序终止,并且可以通过在 shell 中使用Ctrl + C组合键将其发送到前台进程。但是,还有许多其他选项,如下表所示:

名称 编号 描述
SIGHUP 1 控制终端关闭
SIGINT 2 中断信号(Ctrl + C
SIGQUIT 3 退出信号(Ctrl + D
SIGFPE 8 尝试非法数学运算
SIGKILL 9 立即退出应用程序
SIGALRM 14 闹钟信号

kill命令允许您向任何应用程序发送信号,并可以使用-l标志显示可用信号的全面列表:

管道

管道是进程之间最后一种单向通信方法。顾名思义,管道连接两端 - 一个进程的输入与另一个进程的输出 - 使得在同一台主机上进行处理以便交换数据成为可能。

这些被分类为匿名或命名:

  • 匿名管道将一个进程的标准输出链接到另一个进程的标准输入。可以在 shell 中使用|运算符轻松完成,将管道之前的命令的输出链接为管道之后的命令的输入。ls -l | grep "user"获取ls命令的输出并将其用作grep的输入。

  • 命名管道使用特定文件来执行重定向。输出可以使用>(大于)运算符重定向到文件,而<(小于)符号允许您将文件用作另一个进程的输入。ls -l > file.txt将命令的输出保存到文件中。cat < file.txt将文件的内容发送到命令的标准输入,标准输入将其复制到标准输出。

还可以使用>>(双大于)运算符将内容附加到命名管道,这将从文件末尾开始写入。

套接字

Unix 域套接字是同一台机器上应用程序之间的双向通信方法。它们是由内核处理并管理数据交换的逻辑端点。

套接字的性质允许将它们用作流导向或数据报导向。流导向协议确保在转移到下一个数据块之前交付消息,以保持消息完整性。相反,消息导向协议忽略未接收的数据,并继续发送下一个消息,使其成为一个更快但不太可靠且延迟非常低的协议。

套接字分为以下几类:

  • SOCK_STREAM: 连接导向,有序,可靠地传输数据流

  • SOCK_SEQPACKET: 连接导向,有序,可靠地传输具有记录边界的消息数据

  • SOCK_DGRAM: 无序且不可靠地传输消息

总结

本章概述了主要的 Unix 组件及它们之间的交互。我们从内存管理开始,了解 Unix 中的工作原理,理解诸如分页交换等概念。

然后我们分析了文件系统,看了现代操作系统的支持,并解释了现有文件类型之间的区别:文件、目录以及硬链接和软链接。

在了解了 inode 的概念之后,我们看了 Unix 操作系统中目录的结构,并解释了如何浏览和与文件系统交互,以及如何挂载和卸载其他分区。

我们继续讨论了在 Unix 中运行应用程序的进程及其结构和属性。我们分析了进程的生命周期,从通过forkexec创建到通过kill命令结束或终止。

另一个重要的主题是用户、组和权限。我们了解了用户是什么,什么是组,如何加入组,以及这些概念如何用于将权限分为三组:用户、组和其他。这有助于更好地理解 Unix 权限模型,以及如何更改文件和目录的权限。

最后,我们看到了进程之间的通信是如何工作的,包括单向通道如信号和退出码,或双向通信如套接字。

在下一章中,我们将快速概述 Go 语言。

问题

  1. 现代操作系统使用哪种文件系统?

  2. 什么是 inode?Unix 中的 inode 0是什么?

  3. PID 和 PPID 之间有什么区别?

  4. 如何终止在后台运行的进程?

  5. 用户和组之间有什么区别?

  6. Unix 权限模型的范围是什么?

  7. 你能解释一下信号和退出码之间的区别吗?

  8. 什么是交换文件?

第三章:Go 概述

本章将概述 Go 语言及其基本功能。我们将简要解释语言及其特性,并在接下来的章节中详细阐述。这将帮助我们更好地理解 Go,同时使用其所有功能和应用。

本章将涵盖以下主题:

  • 语言的特点

  • 包和导入

  • 基本类型、接口和用户定义类型

  • 变量和函数

  • 流程控制

  • 内置函数

  • 并发模型

  • 内存管理

技术要求

从本章开始,您需要在计算机上安装 Go。按照以下步骤进行操作:

  1. golang.org/dl/下载 Go 的最新版本。

  2. 使用tar -C /usr/local -xzf go$VERSION.$OS-$ARCH.tar.gz进行提取。

  3. 使用export PATH=$PATH:/usr/local/go/bin将其添加到PATH中。

  4. 确保使用go version安装了 Go。

  5. 在您的.profile中添加 export 语句以自动添加。

  6. 如果要使用不同的目录来存放代码,也可以更改GOPATH变量(默认为~/go)。

我还建议安装 Visual Studio Code(https://code.visualstudio.com/)及其 vscode-go(https://github.com/Microsoft/vscode-go)扩展,其中包含一个助手,可安装所有需要改进 Go 开发体验的工具。

语言特性

Go 是一种具有出色并发原语和大部分自动化内存系统的现代服务器语言。一些人认为它是 C 的继任者,并且在许多场景中都能做到这一点,因为它的性能良好,具有广泛的标准库,并且有一个提供许多第三方库的伟大社区,这些库涵盖、扩展和改进了其功能。

Go 的历史

Go 是在 2007 年创建的,旨在解决 Google 的工程问题,并于 2009 年公开宣布,2012 年达到 1.0 版本。主要版本仍然相同(版本 1),而次要版本(版本 1.1、1.2 等)随着其功能一起增长。这样做是为了保持 Go 对所有主要版本的兼容性承诺。2018 年提出了两个新功能(泛型和错误处理)的草案,这些功能可能会包含在 2.0 版本中。

Go 背后的大脑如下:

  • Robert Griesemer:谷歌研究员,参与了许多项目,包括 V8 JavaScript 引擎和设计,以及 Sawzall 的实现。

  • Rob Pike:Unix 团队成员,Plan 9 和 Inferno 操作系统开发团队成员,Limbo 编程语言设计团队成员。

  • Ken Thompson:计算机科学的先驱,原始 Unix 的设计者,B 语言的发明者(C 的前身)。Ken 还是 Plan 9 操作系统的创造者和早期开发者之一。

优势和劣势

Go 是一种非常有主见的语言;有些人喜欢它,有些人讨厌它,主要是由于其一些设计选择。以下是一些未受好评的功能:

  • 冗长的错误处理

  • 缺乏泛型

  • 缺少依赖和版本管理

前两点将在下一个主要版本中解决,而后者首先由社区(godep、glide 和 govendor)和 Google 自己(dep 用于依赖项)以及 gopkg.in(http://labix.org/gopkg.in)在版本管理方面解决。

语言的优势是无数的:

  • 这是一种静态类型的语言,具有静态类型检查等带来的所有优势。

  • 它不需要集成开发环境(IDE),即使它支持许多 IDE。

  • 标准库非常强大,对许多项目来说可能是唯一的依赖项。

  • 它具有并发原语(通道和 goroutines),隐藏了编写高效且安全的异步代码的最困难部分。

  • 它配备了一个格式化工具gofmt,统一了 Go 代码的格式,使其他人的代码看起来非常熟悉。

  • 它生成没有依赖的二进制文件,使部署快速简便。

  • 它是极简主义的,关键字很少,代码非常容易阅读和理解。

  • 它是鸭子类型的,具有隐式接口定义(如果它走起来像鸭子,游泳像鸭子,嘎嘎叫像鸭子,那么它可能就是鸭子)。这在测试系统的特定功能时非常方便,因为它可以被模拟。

  • 它是跨平台的,这意味着它能够为与托管平台不同的架构和操作系统生成二进制文件。

  • 有大量的第三方包,因此在功能方面留下了很少。托管在公共存储库上的每个包都是可索引和可搜索的。

命名空间

现在,让我们看看 Go 代码是如何组织的。GOPATH环境变量决定了代码的位置。里面有三个子目录:

  • src包含所有源代码。

  • pkg包含已编译的包,分为架构/操作系统。

  • bin包含编译后的二进制文件。

源文件夹下的路径对应包的名称($GOPATH/src/my/package/name将是my/package/name)。

go get命令使得可以使用它来获取和编译包。go get调用http://package_name?go-get=1,如果找到go-import元标签,就会使用它来获取包。该标签应包含包名称、使用的 VCS 和存储库 URL,所有这些都用空格分隔。让我们看一个例子:

 <meta name="go-import" content="package-name vcs repository-url">

go get下载一个包后,它会尝试对其他无法递归解析的包执行相同的操作,直到所有必要的源代码都可用。

每个文件都以package定义开头,即package package_name,需要对目录中的所有文件保持一致。如果包生成一个二进制文件,那么包就是main

导入和导出符号

包声明后是一系列import语句,指定所需的包。

导入未使用的包(除非它们被忽略)是编译错误,这就是为什么 Go 格式化工具gofmt会删除未使用的包。还有一些实验性或社区工具,如 goimports (godoc.org/golang.org/x/tools/cmd/goimports)或 goreturns (github.com/sqs/goreturns),也会向 Go 文件添加丢失的导入。避免循环依赖非常重要,因为它们将无法编译。

由于不允许循环依赖,包需要与其他语言设计不同。为了打破循环依赖,最好的做法是从一个包中导出功能,或者用接口替换依赖关系。

Go 将所有符号可见性减少到一个二进制模型 - 导出和未导出 - 不像许多其他语言有中间级别。对于每个包,所有以大写字母开头的符号都是导出的,而其他所有内容只能在包内部使用。导出的值也可以被其他包使用,而未导出的值只能在包内部使用。

一个例外是,如果包路径中的一个元素是internal(例如my/package/internal/pdf),这将限制它及其子包只能被附近的包导入(例如my/package)。如果有很多未导出的符号,并且希望将它们分解为子包,同时阻止其他包使用它,这将非常有用,基本上是私有子包。看一下以下内部包的列表:

  • my/package/internal

  • my/package/internal/numbers

  • my/package/internal/strings

这些只能被my/package使用,不能被任何其他包导入,包括my

导入可以有不同的形式。标准导入形式是完整的包名称:

import "math/rand"
...
rand.Intn

命名导入将包名称替换为自定义名称,引用包时必须使用该名称:

import r "math/rand"
...
r.Intn

相同的包导入使符号可用,而无需命名空间:

import . "math/rand"
...
Intn

忽略的导入用于导入包,而无需使用它们。这使得可以在不在代码中引用包的情况下执行包的init函数:

import _ math/rand  
// still executes the rand.init function

类型系统

Go 类型系统定义了一系列基本类型,包括字节,字符串和缓冲区,复合类型如切片或映射,以及应用程序定义的自定义类型。

基本类型

这些是 Go 的基本类型:

类别 类型
字符串 string
布尔值 bool
整数 intint8int16int32int64
无符号整数 uintuint8uint16uint32uint64
整数指针 uinptr
浮点数 float32float64
复数 complex64complex128

intuintuiptr的位数取决于架构(例如,x86 为 32 位,x86_64 为 64 位)。

复合类型

除了基本类型外,还有其他类型,称为复合类型。它们如下:

类型 描述 示例
指针 变量在内存中的地址 *int
数组 具有固定长度的相同类型元素的容器 [2]int
切片 数组的连续段 []int
映射 字典或关联数组 map[int]int
结构体 可以具有不同类型字段的集合 struct{ value int }
函数 具有相同参数和输出的一组函数 func(int, int) int
通道 用于通信相同类型元素的管道 chan int
接口 一组特定的方法,具有支持它们的基础值 interface{}

空接口interface{}是一个通用类型,可以包含任何值。由于此接口没有要求(方法),因此可以满足任何值。

接口,指针,切片,函数,通道和映射可以具有空值,在 Go 中用nil表示:

  • 指针是不言自明的;它们不指向任何变量地址。

  • 接口的基础值可以为空。

  • 其他指针类型,如切片或通道,可以为空。

自定义定义的类型

包可以通过使用type defined definition表达式定义自己的类型,其中定义是共享定义内存表示的类型。自定义类型可以由基本类型定义:

type Message string    // custom string
type Counter int       // custom integer
type Number float32    // custom float
type Success bool      // custom boolean

它们也可以由切片,映射或指针等复合类型定义:

type StringDuo [2]string            // custom array   
type News chan string               // custom channel
type Score map[string]int           // custom map
type IntPtr *int                    // custom pointer
type Transform func(string) string  // custom function
type Result struct {                // custom struct
    A, B int
}

它们也可以与其他自定义类型结合使用:

type Broadcast Message // custom Message
type Timer Counter     // custom Counter
type News chan Message // custom channel of custom type Message

自定义类型的主要用途是定义方法并使类型特定于范围,例如定义名为Messagestring类型。

接口定义的工作方式不同。可以通过指定一系列不同的方法来定义它们,例如:

type Reader interface {
    Read(p []byte) (n int, err error)
}

它们也可以是其他接口的组合:

type ReadCloser interface {
    Reader 
    Closer 
}

或者,它们可以是两个接口的组合:

type ReadCloser interface {
    Reader        // composition
    Close() error // method
}

变量和函数

既然我们已经看过了类型,我们将看看如何在语言中实例化不同的类型。我们将首先了解变量和常量的工作原理,然后再讨论函数和方法。

处理变量

变量表示映射到连续内存部分的内容。它们具有定义内存扩展量的类型,以及指定内存中内容的值。类型可以是基本类型,复合类型或自定义类型,其值可以通过声明初始化为零值,也可以通过赋值初始化为其他值。

声明

使用 var 关键字和指定名称和类型来声明变量的零值;例如 var a int。对于来自其他语言(如 Java)的人来说,这可能有些反直觉,因为类型和名称的顺序是颠倒的,但实际上更易读。

var a int 的例子描述了一个带有名称 a 的变量(var),它是一个整数(int)。这种表达式创建了一个新的变量,其选定类型的零值为:

类型 零值
数值类型(intuintfloat 类型) 0
字符串(string 类型) ""
布尔值 false
指针、接口、切片、映射和通道 nil

初始化变量的另一种方式是通过赋值,可以有推断类型或特定类型。通过以下方式实现推断类型:

  • 变量名称,后跟 := 运算符和值(例如 a := 1),也称为短声明

  • var 关键字,后跟名称、= 运算符和一个值(例如 var a = 1)。

请注意,这两种方法几乎是等效的,也是多余的,但 Go 团队决定保留它们两者,以遵守 Go 1 的兼容性承诺。就短声明而言,主要区别在于类型不能被指定,而是由值推断出来。

具有特定类型的赋值是通过声明进行的,后跟等号和值。如果声明的类型是接口,或者推断的类型是不正确的,这将非常有用。以下是一些示例:

var a = 1             // this will be an int
b := 1                // this is equivalent
var c int64 = 1       // this will be an int64

var d interface{} = 1 // this is declared as an interface{}

有些类型需要使用内置函数才能正确初始化:

  • new 可以创建某种类型的指针,同时为底层变量分配一些空间。

  • make 初始化切片、映射和通道:

  • 切片需要一个额外的参数用于大小,以及一个可选的参数用于底层数组的容量。

  • 映射可以有一个参数用于其初始容量。

  • 通道也可以有一个参数用于其容量。它们不同于映射,映射不能改变。

使用内置函数初始化类型的方法如下:

a := new(int)                   // pointer of a new in variable
sliceEmpty := make([]int, 0)    // slice of int of size 0, and a capacity of 0
sliceCap := make([]int, 0, 10)  // slice of int of size 0, and a capacity of 10
map1 = make(map[string]int)     // map with default capacity
map2 = make(map[string]int, 10) // map with a capacity of 10
ch1 = make(chan int)            // channel with no capacity (unbuffered)
ch2 = make(chan int, 10)        // channel with capacity of 10 (buffered)

操作

我们已经看到了赋值操作,使用 = 运算符给变量赋予一个新值。让我们再看看一些运算符:

  • 有比较运算符 ==!=,用于比较两个值并返回一个布尔值。

  • 有一些数学运算可以在相同类型的所有数字变量上执行,即 +-*/。求和运算也用于连接字符串。++-- 是用于将数字增加或减少一的简写形式。+=-=*=/= 在等号之前执行操作,并将其赋给左边的变量。这四种运算产生与所涉及的变量相同类型的值;还有其他一些特定于数字的比较运算符:<<=>>=

  • 有些运算仅适用于整数,并产生其他整数:%&|^&^<<>>

  • 还有一些仅适用于布尔值的运算符,产生另一个布尔值:&&||!

  • 一个运算符仅适用于通道,即 <-,用于从通道接收值或向通道发送值。

  • 对于所有非指针变量,也可以使用 &,即引用运算符,来获取可以分配给指针变量的变量地址。* 运算符使得可以对指针执行解引用操作,并获取其指示的变量的值:

运算符 名称 描述 示例
= 赋值 将值赋给变量 a = 10
:= 声明和赋值 声明一个变量并给它赋值 a := 0
== 等于 比较两个变量,如果它们相同则返回布尔值 a == b
!= 不等于 比较两个变量,如果它们不同则返回布尔值 a != b
+ 相同数值类型之间的求和 a + b
- 相同数值类型之间的差异 a - b
* 相同数值类型的乘法 a * b
/ 相同数值类型之间的除法 a / b
% 取模 相同数值类型的除法后余数 a % b
& 按位与 a & b
&^ 位清除 位清除 a &^ b
<< 左移 位向左移动 a << b
>> 右移 位向右移动 a >> b
&& 布尔与 a && b
` `
! 布尔非 !a
<- 接收 从通道接收 <-a
-> 发送 发送到通道 a <- b
& 引用 返回变量的指针 &a
* 解引用 返回指针的内容 *a

转换

将一种类型转换为另一种类型的操作称为转换,对于接口和具体类型的工作方式略有不同:

  • 接口可以转换为实现它的具体类型。此转换可以返回第二个值(布尔值),并显示转换是否成功。如果省略布尔变量,则应用程序将在转换失败时出现恐慌。

  • 具体类型之间可以发生类型转换,这些类型具有相同的内存结构,或者可以在数值类型之间发生转换:

type N [2]int                // User defined type
var n = N{1,2}
var m [2]int = [2]int(N)     // since N is a [2]int this casting is possible

var a = 3.14                 // this is a float64
var b int = int(a)           // numerical types can be casted, in this case a will be rounded to 3

var i interface{} = "hello"  // a new empty interface that contains a string
x, ok := i.(int)             // ok will be false
y := i.(int)                 // this will panic
z, ok := i.(string)          // ok will be true

有一种特殊的条件运算符用于转换,称为类型开关,它允许应用程序一次尝试多次转换。以下是使用interface{}检查底层值的示例:

func main() {
    var a interface{} = 10
    switch a.(type) {
    case int:
        fmt.Println("a is an int")
    case string:
        fmt.Println("a is a string")
    }
}

作用域

变量具有作用域或可见性,这也与其生命周期相关。这可以是以下之一:

  • : 变量在所有包中可见;如果变量被导出,它也可以从其他包中可见。

  • 函数: 变量在声明它的函数内可见。

  • 控制: 变量在定义它的块内可见。

可见性下降,从包到块。由于块可以嵌套,外部块对内部块的变量没有可见性。

同一作用域中的两个变量不能具有相同的名称,但内部作用域的变量可以重用标识符。当这种情况发生时,外部变量在内部作用域中不可见 - 这称为遮蔽,需要牢记以避免出现难以识别的问题,例如以下情况:

// this exists in the outside block
var err error
// this exists only in this block, shadows the outer err
if err := errors.New("Doh!"); err != 
    fmt.Println(err)           // this not is changing the outer err
}
fmt.Println(err)               // outer err has not been changed

常量

Go 的变量没有不可变性,但定义了另一种不可变值的类型称为常量。这由const关键字定义(而不是var),它们是不能改变的值。这些值可以是基本类型和自定义类型,如下所示:

  • 数值(整数,float

  • 复杂

  • 字符串

  • 布尔

指定的值在分配给变量时没有类型。数值类型和基于字符串的类型都会自动转换,如下面的代码所示:

const PiApprox = 3.14

var PiInt int = PiApprox // 3, converted to integer
var Pi float64 = PiApprox // is a float

type MyString string

const Greeting = "Hello!"

var s1 string = Greeting   // is a string
var s2 MyString = Greeting // string is converted to MyString

数值常量对数学运算非常有用,因为它们只是常规数字,所以可以与任何数值变量类型一起使用。

函数和方法

Go 中的函数由func关键字标识,后跟标识符、可能的参数和返回值。Go 中的函数可以一次返回多个值。参数和返回类型的组合称为签名,如下面的代码所示:

func simpleFunc()
func funcReturn() (a, b int)
func funcArgs(a, b int)
func funcArgsReturns(a, b int) error

括号中的部分是函数体,return语句可以在其中用于提前中断函数。如果函数返回值,则return语句必须返回相同类型的值。

return值可以在签名中命名;它们是零值变量,如果return语句没有指定其他值,那么这些值就是返回的值:

func foo(a int) int {        // no variable for returned type
    if a > 100 {
        return 100
    }
    return a
}

func bar(a int) (b int) {    // variable for returned type
    if a > 100 {
        b = 100
        return               // same as return b
    }
    return a
}

在 Go 中,函数是一级类型,它们也可以被分配给变量,每个签名代表不同的类型。它们也可以是匿名的;在这种情况下,它们被称为闭包。一旦一个变量被初始化为一个函数,相同的变量可以被重新分配为具有相同签名的另一个函数。以下是将闭包分配给变量的示例:

var a = func(item string) error { 
    if item != "elixir" {
        return errors.New("Gimme elixir!")
    }
    return nil 
}

由接口声明的函数称为方法,它们可以由自定义类型实现。方法的实现看起来像一个函数,唯一的区别是名称前面有一个实现类型的单个参数。这只是一种语法糖——方法定义在幕后创建一个函数,它接受一个额外的参数,即实现方法的类型。

这种语法使得可以为不同类型定义相同的方法,每个方法将作为函数声明的命名空间。通过这种方式,可以以两种不同的方式调用方法,如下面的代码所示:

type A int

func (a A) Foo() {}

func main() {
    A{}.Foo()  // Call the method on an instance of the type
    A.Foo(A{}) // Call the method on the type and passing an instance as argument  
}

重要的是要注意,类型及其指针共享相同的命名空间,因此同一个方法只能为其中一个实现。同一个方法不能同时为类型和其指针定义,因为对于类型和其指针声明两次方法将产生编译错误(方法重复声明)。方法不能为接口定义,只能为具体类型定义,但接口可以用于复合类型,包括函数参数和返回值,如下面的示例所示:

// use error interface with chan
type ErrChan chan error
// use error interface in a map
type Result map[string]error

type Printer interface{
    Print()
}
// use the interface as argument
func CallPrint(p Printer) {
    p.Print()
}

内置包已经定义了一个接口,它在标准库中和所有在线可用的包中都被使用——error接口:

type error interface {
    Error() string
}

这意味着任何类型都可以使用Error() string方法作为错误,并且每个包都可以根据自己的需要定义其错误类型。这可以用来简洁地携带有关错误的信息。在这个例子中,我们定义了ErrKey,它指定了未找到string键。除了键之外,我们不需要任何其他东西来表示我们的错误,如下面的代码所示:

type ErrKey string

func (e Errkey) Error() string {
    returm fmt.Errorf("key %q not found", e)
}

值和指针

在 Go 中,一切都是按值传递的,所以当函数或方法被调用时,变量的副本会被放在堆栈中。这意味着对值所做的更改不会反映在被调用的函数之外。即使切片、映射和其他引用类型也是按值传递的,但由于它们的内部结构包含指针,它们的行为就像是按引用传递一样。如果为一种类型定义了一个方法,则不能为其指针定义该方法,反之亦然。下面的示例已经用来检查值只在方法内部更新,而这种更改不会反映在main函数中:

package main

import (
    "fmt"
)

type A int

func (a A) Foo() {
    a++
    fmt.Println("foo", a)
}

func main() {
    var a A
    fmt.Println("before", a) // 0
    a.Foo() // 1
    fmt.Println("after", a) // 0
}

为了改变原始变量,参数必须是指向变量本身的指针——指针将被复制,但它将引用相同的内存区域,从而可以改变其值。请注意,分配另一个值指针,而不是其内容,不会改变原始指针所引用的内容,因为它是一个副本。

如果我们为类型而不是其指针使用方法,我们将看不到更改在方法外传播。

在下面的示例中,我们使用了值接收器。这使得Birthday方法中的User值成为main中的User值的副本:

type User struct {
    Name string
    Age int
}

func (u User) Birthday() {
    u.Age++
    fmt.Println(u.Name, "turns", u.Age)
}

func main() {
    u := User{Name: "Pietro", Age: 30}
    fmt.Println(u.Name, "is now", u.Age)
    u.Birthday()
    fmt.Println(u.Name, "is now", u.Age)
}

完整的示例可在play.golang.org/p/hnUldHLkFJY中找到。

由于更改是应用于副本的,原始值保持不变,正如我们从第二个打印语句中所看到的。如果我们想要更改原始对象中的值,我们必须使用指针接收器,这样被复制的对象将是指针,更改将被应用于底层值:

func (u *User) Birthday() {
    u.Age++
    fmt.Println(u.Name, "turns", u.Age)
}

完整示例可在play.golang.org/p/JvnaQL9R7U5中找到。

我们可以看到使用指针接收器允许我们更改底层值,并且我们可以更改struct的一个字段或替换整个struct本身,如下面的代码所示:

func (u *User) Birthday() {
    *u = User{Name: u.Name, Age: u.Age + 1}
   fmt.Println(u.Name, "turns", u.Age)
}

完整示例可在play.golang.org/p/3ugBEZqAood中找到。

如果我们尝试更改指针的值而不是底层值,我们将编辑一个与在main中创建的对象无关的新对象,并且更改不会传播:

func (u *User) Birthday() {
    u = &User{Name: u.Name, Age: u.Age + 1}
    fmt.Println(u.Name, "turns", u.Age)
}

完整示例可在play.golang.org/p/m8u2clKTqEU中找到。

Go 中的一些类型是自动按引用传递的。这是因为这些类型在内部被定义为包含指针的结构。这创建了一个类型列表,以及它们的内部定义:

类型 内部定义
map
struct {
    m *internalHashtable
}

|

slice
struct {
    array *internalArray 
    len int
    cap int
}

|

channel
struct {
    c *internalChannel
}

|

理解流控制

为了控制应用程序的流程,Go 提供了不同的工具 - 一些语句如if/elseswitchfor用于顺序场景,而goselect等其他语句用于并发场景。

条件

if语句验证一个二进制条件,并在条件为true时执行if块内的代码。当存在else块时,当条件为false时执行。该语句还允许在条件之前进行短声明,用分隔。这个条件可以与else if语句链接,如下面的代码所示:

if r := a%10; r != 0 { // if with short declaration
    if r > 5 {         // if without declaration 
        a -= r
    } else if r < 5 {  // else if statement
        a += 10 - r 
    }
} else {               // else statement
    a /= 10
}

另一个条件语句是switch。这允许短声明,就像if一样,然后是一个表达式。这样的表达式的值可以是任何类型(不仅仅是布尔类型),并且它与一系列case语句进行比较,每个case语句后面都跟着一段代码。第一个与表达式匹配的语句,如果switchcase条件相等,将执行其块。

如果在中断块的执行中存在break语句,但有一个fallthrough,则执行以下case块内的代码。一个称为default的特殊情况可以用来在没有满足条件的情况下执行其代码,如下面的代码所示:

switch tier {                        // switch statement
case 1:                              // case statement
    fmt.Println("T-shirt")
    if age < 18{
        break                        // exits the switch block
    }
    fallthrough                      // executes the next case
case 2:
    fmt.Println("Mug")
    fallthrough                      // executes the next case 
case 3:
    fmt.Println("Sticker pack")    
default:                             // executed if no case is satisfied
    fmt.Println("no reward")
}

循环

for语句是 Go 中唯一的循环语句。这要求您指定三个表达式,用分隔:

  • 对现有变量进行短声明或赋值

  • 在每次迭代之前验证的条件

  • 在迭代结束时执行的操作

所有这些语句都是可选的,没有条件意味着它总是truebreak语句中断循环的执行,而continue跳过当前迭代并继续下一个:

for {                    // infinite loop
    if condition {
        break            // exit the loop
    }
}

for i < 0 {              // loop with condition
    if condition {
        continue         // skip current iteration and execute next    
    }
}

for i:=0; i < 10; i++ {  // loop with declaration, condition and operation 
}

switchfor的组合嵌套时,continuebreak语句将引用内部流控制语句。

外部循环或条件可以使用name:表达式进行标记,其中名称是其标识符,loopcontinue都可以在后面跟着名称,以指定在哪里进行干预,如下面的代码所示:

label:
    for i := a; i<a+2; i++ {
        switch i%3 {
        case 0:
            fmt.Println("divisible by 3")
            break label                          // this break the outer for loop
        default:
            fmt.Println("not divisible by 3")
        }
    }

探索内置函数

我们已经列出了一些用于初始化一些变量的内置函数,即makenew。现在,让我们逐个查看每个函数并了解它们的作用:

  • func append(slice []Type, elems ...Type) []Type: 此函数将元素追加到切片的末尾。如果底层数组已满,则在追加之前将内容重新分配到一个更大的切片中。

  • func cap(v Type) int: 返回数组、或者如果参数是切片则返回底层数组的元素数量。

  • func close(c chan<- Type): 关闭一个通道。

  • func complex(r, i FloatType) ComplexType: 给定两个浮点数,返回一个复数。

  • func copy(dst, src []Type) int: 从一个切片复制元素到另一个切片。

  • func delete(m map[Type]Type1, key Type): 从映射中删除一个条目。

  • func imag(c ComplexType) FloatType: 返回复数的虚部。

  • func len(v Type) int: 返回数组、切片、映射、字符串或通道的长度。

  • func make(t Type, size ...IntegerType) Type: 创建一个新的切片、映射或通道。

  • func new(Type) *Type: 返回指向指定类型变量的指针,并初始化为零值。

  • func panic(v interface{}): 停止当前 goroutine 的执行,并且如果没有被拦截,整个程序也会停止。

  • func print(args ...Type): 将参数写入标准错误。

  • func println(args ...Type): 将参数写入标准错误,并在末尾添加一个新行。

  • func real(c ComplexType) FloatType: 返回复数的实部。

  • func recover() interface{}: 停止 panic 序列并捕获 panic 值。

延迟、panic 和 recover

一个隐藏了很多复杂性但使得执行许多操作变得容易的非常重要的关键字是defer。这个关键字应用于函数、方法或闭包的执行,并使得它之前的函数在函数返回之前执行。一个常见且非常有用的用法是关闭资源。在成功打开资源后,延迟的关闭语句将确保它被执行,而不受退出点的影响,如下面的代码所示:

f, err := os.Open("config.txt")
if err != nil {
    return err
}
defer f.Close() // it will be closed anyways

// do operation on f

在函数的生命周期内,所有延迟语句都被添加到一个列表中,并在退出之前按相反的顺序执行,从最后一个defer到第一个。

即使发生 panic,这些语句也会被执行,这就是为什么带有recover调用的延迟函数可以用于拦截相应 goroutine 中的 panic 并避免否则会终止应用程序的 panic。除了手动调用panic函数外,还有一组操作会引发 panic,包括以下操作:

  • 访问负数或不存在的数组/切片索引(索引超出范围)

  • 将整数除以0

  • 向关闭的通道发送数据

  • nil指针进行解引用(nil指针)

  • 使用递归函数调用填充堆栈(堆栈溢出)

Panic 应该用于不可恢复的错误,这就是为什么在 Go 中错误只是值。恢复 panic 应该只是尝试在退出应用程序之前对该错误进行处理。如果发生了意外问题,那是因为它没有被正确处理或者缺少了一些检查。这代表了一个需要处理的严重问题,程序需要改变,这就是为什么它应该被拦截和解除。

并发模型

并发对于 Go 来说是如此核心,以至于它的两个基本工具只是关键字——chango。这是一种非常巧妙的方式,它隐藏了一个设计良好且实现简单易懂的并发模型的复杂性。

理解通道和 goroutine

通道是用于通信的,这就是为什么 Go 的口号是:

“不要通过共享内存来通信,而是通过通信来共享内存。”

通道用于共享数据,通常连接应用程序中的两个或多个执行线程,这使得可以发送和接收数据而不必担心数据安全性。Go 具有由运行时而不是操作系统管理的轻量级线程的实现,它们之间进行通信的最佳方式是通过使用通道。

创建一个新的 goroutine 非常简单 - 只需要使用go运算符,后面跟着一个函数执行。这包括方法调用和闭包。如果函数有任何参数,它们将在例程开始之前被评估。一旦开始,如果不使用通道,就无法保证来自外部作用域的变量更改会被同步:

a := myType{}
go doSomething(a)     // function call
go func() {           // closure call
    // ...
}()                   // note that the closure is executed
go a.someMethod()     // method call

我们已经看到如何使用make函数创建一个新的通道。如果通道是无缓冲的(0容量),向通道发送数据是一个阻塞操作,它会等待另一个 goroutine 从同一个通道接收数据以解锁它。容量显示了通道在进行下一个发送操作之前能够容纳多少消息:

unbuf := make(chan int)    // unbuffered channel
buf := make(chan int, 3)   // channel with size 3

为了向通道发送数据,我们可以使用<-运算符。如果通道在运算符的左边,那么这是一个发送操作,如果在右边,那么这是一个接收操作。从通道接收到的值可以被赋给一个变量,如下所示:

var ch = make(chan int)
go func() {
    b := <-ch        // receive and assign
    fmt.Println(b)
}()
ch <- 10             // send to channel

使用close()函数可以关闭一个通道。这个操作意味着不能再向通道发送更多的值。这通常是发送者的责任。向关闭的通道发送数据会导致panic,这就是为什么应该由接收者来完成。此外,当从通道接收数据时,可以在赋值中指定第二个布尔变量。如果通道仍然打开,这将为真,以便接收者知道通道何时已关闭。

var ch = make(chan int)
go func() {
    b, ok := <-ch        // channel open, ok is true
    b, ok = <-ch         // channel closed, ok is false
    b <- ch              // channel close, b will be a zero value
}()
ch <- 10                 // send to channel
close(ch)                // close the channel

有一个特殊的控制语句叫做select,它的工作方式与switch完全相同,但只能在通道上进行操作:

var ch1 = make(chan int)
var ch2 = make(chan int)
go func() { ch1 <- 10 }
go func() { <-ch2 }
switch {            // the first operation that completes is selected
case a := <-ch1:
    fmt.Println(a)
case ch2 <- 20:
    fmt.Println(b)    
}

理解内存管理

Go 是垃圾收集的;它以计算成本管理自己的内存。编写高效的应用程序需要了解其内存模型和内部工作,以减少垃圾收集器的工作并提高总体性能。

栈和堆

内存被分为两个主要区域 - 栈和堆。应用程序入口函数(main)有一个栈,每个 goroutine 都有一个栈,它们存储在堆中。就像其名字所暗示的那样,是一个随着每个函数调用而增长的内存部分,在函数返回时会收缩。由一系列动态分配的内存区域组成,它们的生命周期不像栈中的项目那样事先定义;堆空间可以随时分配和释放。

所有超出定义它们的函数生存期的变量都存储在堆中,比如返回的指针。编译器使用一个叫做逃逸分析的过程来检查哪些变量进入堆中。可以使用go tool compile -m命令来验证这一点。

栈中的变量随着函数的执行而来而去。让我们看一个栈如何工作的实际例子:

func main() {
    var a, b = 0, 1
    f1(a,b)
    f2(a)
}

func f1(a, b int) {
    c := a + b
    f2(c)
}

func f2(c int) {
    print(c)
}

我们有main函数调用一个名为f1的函数,它调用另一个名为f2的函数。然后,同一个函数直接被main调用。

main函数开始时,栈会随着被使用的变量而增长。在内存中,这看起来像下表,每一列代表栈的伪状态,表示栈随时间变化的方式,从左到右:

main调用 f1调用 f2调用 f2返回 f1返回 f2调用 f2返回 main返回
main() main() main() main() main() main() main() // 空
a = 0 a = 0 a = 0 a = 0 a = 0 a = 0 a = 0
b = 1 b = 1 b = 1 b = 1 b = 1 b = 1 b = 1
f1() f1() f1() f2()
a = 0 a = 0 a = 0 c = 0
b = 1 b = 1 b = 1
c = 1 c = 1 c = 1
f2()
c = 1

当调用f1时,堆栈再次增长,通过将ab变量复制到新部分并添加新变量c来实现。f2也是同样的情况。当f2返回时,堆栈通过摆脱函数及其变量来缩小,这就是f1完成时发生的情况。当直接调用f2时,它会通过回收用于f1的相同内存部分来再次增长。

垃圾收集器负责清理堆中未引用的值,因此避免在其中存储数据是降低垃圾收集器GC)工作量的好方法,这会在 GC 运行时导致应用程序性能略微下降。

Go 中 GC 的历史

GC 负责释放堆中任何栈中未引用的区域。这最初是用 C 编写的,并且具有停止世界行为。程序会在一小段时间内停止,释放内存,然后恢复其流程。

Go 1.4 开始将运行时,包括垃圾收集器,转换为 Go。将这些部分翻译成 Go 为更容易的优化奠定了基础,这在 1.5 版本中已经开始,其中 GC 变得更快,并且可以与其他 goroutine 并发运行。

从那时起,该过程进行了大量的优化和改进,成功将 GC 时间减少了几个数量级。

构建和编译程序

现在我们已经快速概述了所有语言特性和功能,我们可以专注于如何运行和构建我们的应用程序。

安装

在 Go 中,有不同的命令来构建软件包和应用程序。第一个是go install,后面跟着路径或软件包名称,它将在$GOPATH内的pkg目录中创建软件包的编译版本。

所有编译的软件包都按操作系统和架构组织,这些都存储在$GOOS$GOARCH环境变量中。可以使用go env命令查看这些设置,以及其他信息,比如编译标志:

$ go env
GOARCH="amd64"
...
GOOS="linux"
GOPATH="/home/user/go"
...
GOROOT="/usr/lib/go-1.12"
...

对于当前的架构和操作系统,所有编译的软件包将被放置在$GOOS_$GOARCH子目录中:

$ ls /home/user/go/pkg/
linux_amd64

如果软件包名称是main并且包含一个main函数,该命令将生成一个可执行的二进制文件,该文件将存储在$GOPATH/bin中。如果软件包已经安装并且源文件没有更改,它将不会被重新编译,这将在第一次编译后显著加快构建时间。

构建

也可以使用go build命令在特定位置构建二进制文件。可以使用-o标志定义特定的输出文件,否则将在工作目录中构建,使用软件包名称作为二进制文件名:

# building the current package in the working directory
$ go build . 

# building the current package in a specific location
$ go build . -o "/usr/bin/mybinary"

执行go build命令时,参数可以是以下之一:

  • 一个软件包作为相对路径(比如go build .用于当前软件包或go build ../name

  • 一个软件包作为绝对路径(go build some/package)将在$GOPATH中查找

  • 一个特定的 Go 源文件(go build main.go

后一种情况允许您构建一个位于$GOPATH之外的文件,并且将忽略同一目录中的任何其他源文件。

运行

还有第三个命令,它类似于构建,但也运行二进制文件。它使用build命令使用临时目录作为输出创建二进制文件,并即时执行二进制文件:

$ go run main.go
main output line 1
main output line 2

$

当您对源代码进行更改时,可以使用运行而不是构建或安装。如果代码相同,最好是构建一次,然后多次执行。

摘要

在本章中,我们看了一些 Go 的历史以及它当前的优缺点。在了解了命名空间后,我们探讨了包系统和导入的工作方式,以及基本、复合和用户定义类型的类型系统。

我们通过查看变量的声明和初始化方式,允许类型之间的操作,如何将变量转换为其他类型,以及如何查看接口的基础类型,来重点关注变量。我们看到了作用域和屏蔽的工作方式,以及常量和变量之间的区别。之后,我们进入了函数,它是一种一等类型,以及每个签名代表不同类型的方式。然后,我们了解了方法实际上是伪装成函数并附加到允许自定义类型满足接口的类型。

此外,我们学习了如何使用诸如ifforswitch之类的语句来控制应用程序流程。我们分析了各种控制语句和循环语句之间的区别,并查看了每个内置函数的作用。然后,我们看到了基本并发是如何通过通道和 goroutine 工作的。最后,我们对 Go 的内部内存分配方式有了一些了解,以及其垃圾收集器的历史和性能,以及如何构建、安装和运行 Go 二进制文件。

在下一章中,我们将看到如何通过与文件系统交互将其中一些内容付诸实践。

问题

  1. 导出符号和未导出符号之间有什么区别?

  2. 自定义类型为什么重要?

  3. 短声明的主要限制是什么?

  4. 什么是作用域,它如何影响变量屏蔽?

  5. 如何访问一个方法?

  6. 解释一下一系列if/elseswitch之间的区别。

  7. 在典型的用例中,通常谁负责关闭通道?

  8. 什么是逃逸分析?

第二部分:高级文件 I/O 操作

本节涵盖了应用程序的输入和输出操作,重点放在 Unix 操作系统中的文件/文件系统和流上。它涵盖了许多主题,包括从文件中读取和写入,以及其他 I/O 操作。然后我们解释了 Go 在 I/O 方面还有多少更多的接口和实现。

本节包括以下章节:

  • 第四章,与文件系统一起工作

  • 第五章,处理流

  • 第六章,构建伪终端

第四章:处理文件系统

本章主要讲解与 Unix 文件系统的交互。在这里,我们将从基本的读写操作到更高级的缓冲操作,如标记扫描和文件监控,一切都会涉及。

Unix 中所有用户或系统的信息都存储为文件,因此为了与系统和用户数据交互,我们必须与文件系统交互。

在本章中,我们将看到执行读写操作的不同方式,以及每种方式更注重代码的简单性,应用程序的内存使用和性能,以及执行速度。

本章将涵盖以下主题:

  • 文件路径操作

  • 读取文件

  • 写文件

  • 其他文件系统操作

  • 第三方包

技术要求

本章需要安装 Go 并设置您喜欢的编辑器。有关更多信息,请参阅第三章,Go 概述

处理路径

Go 提供了一系列函数,可以操作与平台无关的文件路径,主要包含在path/filepathos包中。

工作目录

每个进程都有一个与之关联的目录,称为工作目录,通常从父进程继承。这使得可以指定相对路径 - 不以根文件夹开头。在 Unix 和 macOS 上为/,在 Windows 上为C:\(或任何其他驱动器号)。

绝对/完整路径以根目录开头,并且表示文件系统中的相同位置,即/usr/local

相对路径不以根目录开头,路径以当前工作目录开头,即documents

操作系统将这些路径解释为相对于当前目录,因此它们的绝对版本是工作目录和相对路径的连接。让我们看一个例子:

user:~ $ cd documents

user:~/documents $ cd ../videos

user:~/videos $

在这里,用户位于他们的家目录~。用户指定要切换到documents目录,cd命令会自动将工作目录作为前缀添加到它,并移动到~/documents

在进入第二个命令之前,让我们介绍一下在所有操作系统的所有目录中都可用的两个特殊文件:

  • .: 点是指当前目录。如果它是路径的第一个元素,则是进程的工作目录,否则它指的是它前面的路径元素(例如,在~/./documents中,.指的是~)。

  • ..: 双点是指当前目录的父目录,如果它是路径的第一个元素,或者是它前面的目录的父目录(例如,在~/images/../documents中,..指的是~/images的父目录,~)。

了解这一点,我们可以轻松推断出第二个路径首先加入~/documents/../videos,然后解析父元素..,得到最终路径~/videos

获取和设置工作目录

我们可以使用os包的func Getwd() (dir string, err error)函数来找出表示当前工作目录的路径。

更改工作目录是使用同一包的另一个函数完成的,即func Chdir(dir string) error,如下面的代码所示:

wd, err := os.Getwd()
if err != nil {
    fmt.Println(err)
    return
}
fmt.Println("starting dir:", wd)

if err := os.Chdir("/"); err != nil {
    fmt.Println(err)
    return
}

if wd, err = os.Getwd(); err != nil {
    fmt.Println(err)
    return
}
fmt.Println("final dir:", wd)

路径操作

filepath包包含不到 20 个函数,与标准库的包相比数量较少,它用于操作路径。让我们快速看一下这些函数:

  • func Abs(path string) (string, error): 返回传递的路径的绝对版本(如果它不是绝对的,则将其连接到当前工作目录),然后清理它。

  • func Base(path string) string: 给出路径的最后一个元素(基本路径)。例如,path/to/some/file返回file。请注意,如果路径为空,此函数将返回一个*.*(点)路径。

  • func Clean(path string) string: 通过应用一系列定义的规则返回路径的最短版本。它执行操作,如替换...,或删除尾部分隔符。

  • func Dir(path string) string: 获取不包含最后一个元素的路径。这通常返回元素的父目录。

  • func EvalSymlinks(path string) (string, error): 在评估符号链接后返回路径。如果提供的路径也是相对的,并且不包含绝对路径的符号链接,则路径是相对的。

  • func Ext(path string) string: 获取路径的文件扩展名,即以路径的最后一个元素的最终点开始的后缀,如果没有点,则为空字符串(例如docs/file.txt返回.txt)。

  • func FromSlash(path string) string: 用操作系统路径分隔符替换路径中找到的所有/(斜杠)。如果操作系统是 Windows,则此函数不执行任何操作,并在 Unix 或 macOS 下执行替换。

  • func Glob(pattern string) (matches []string, err error): 查找与指定模式匹配的所有文件。如果没有匹配的文件,则结果为nil。它不报告在路径探索过程中发生的任何错误。它与Match共享语法。

  • func HasPrefix(p, prefix string) bool: 此函数已弃用。

  • func IsAbs(path string) bool: 显示路径是否为绝对路径。

  • func Join(elem ...string) string: 通过使用文件路径分隔符连接多个路径元素来连接它们。请注意,这也在结果上调用Clean

  • func Match(pattern, name string) (matched bool, err error): 验证给定的名称是否与模式匹配,允许使用通配符字符*?,以及使用方括号的组或字符序列。

  • func Rel(basepath, targpath string) (string, error): 返回从基本路径到目标路径的相对路径,如果不可能则返回错误。此函数在结果上调用Clean

  • func Split(path string) (dir, file string): 使用最终的尾部斜杠将路径分成两部分。结果通常是输入路径的父路径和文件名。如果没有分隔符,dir将为空,文件将是路径本身。

  • func SplitList(path string) []string: 使用列表分隔符字符返回路径列表,Unix 和 macOS 中为:,Windows 中为;

  • func ToSlash(path string) string: 执行与FromSlash函数执行的相反替换,将每个路径分隔符更改为/,在 Unix 和 macOS 上不执行任何操作,并在 Windows 上执行替换。

  • func VolumeName(path string) string: 在非 Windows 平台上不执行任何操作。它返回引用卷的路径组件。这对本地路径和网络资源都适用。

  • func Walk(root string, walkFn WalkFunc) error: 从根目录开始,此函数递归地遍历文件树,对树的每个条目执行遍历函数。如果遍历函数返回错误,则遍历停止,并返回该错误。该函数定义如下:

type WalkFunc func(path string, info os.FileInfo, err error) error

在继续下一个示例之前,让我们介绍一个重要的变量:os.Args。此变量至少包含一个值,即调用当前进程的路径。这可以跟随在同一调用中指定的可能参数。

我们想要实现一个小应用程序,列出并计算目录中的文件数量。我们可以使用刚刚看到的一些工具来实现这一点。

在下面的代码中显示了列出和计数文件的示例:

package main

import (
    "fmt"
    "os"
    "path/filepath"
)

func main() {
    if len(os.Args) != 2 { // ensure path is specified
        fmt.Println("Please specify a path.")
        return
    }
    root, err := filepath.Abs(os.Args[1]) // get absolute path
    if err != nil {
        fmt.Println("Cannot get absolute path:", err)
        return
    }
    fmt.Println("Listing files in", root)
    var c struct {
        files int
        dirs int
    }
    filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
        // walk the tree to count files and folders
        if info.IsDir() {
            c.dirs++
        } else {
            c.files++
        }
        fmt.Println("-", path)
        return nil
    })
    fmt.Printf("Total: %d files in %d directories", c.files, c.dirs)
}

从文件中读取

可以使用io/ioutil包中的辅助函数,以及ReadFile函数来获取文件的内容,该函数一次性打开、读取和关闭文件。这使用一个小缓冲区(512 字节)并将整个内容加载到内存中。如果文件大小非常大,未知,或者文件内容可以一次处理一部分,这不是一个好主意。

一次从磁盘读取一个巨大的文件意味着将所有文件内容复制到主内存中,这是有限的资源。这可能会导致内存不足,以及运行时错误。一次读取文件的一部分可以帮助读取大文件的内容,而不会导致大量内存使用。这是因为在读取下一块时将重用相同部分的内存。

一次性读取所有内容的示例如下所示:

package main

import (
    "fmt"
    "io/ioutil"
    "os"
)

func main() {
    if len(os.Args) != 2 {
        fmt.Println("Please specify a path.")
        return
    }
    b, err := ioutil.ReadFile(os.Args[1])
    if err != nil {
        fmt.Println("Error:", err)
    }
    fmt.Println(string(b))
}

读取器接口

对于所有从磁盘读取的操作,有一个至关重要的接口:

type Reader interface {
    Read(p []byte) (n int, err error)
}

它的工作非常简单 - 用已读取的内容填充给定的字节片,并返回已读取的字节数和错误(如果发生错误)。有一个特殊的错误变量由io包定义,称为EOF文件结束),当没有更多输入可用时应返回它。

读取器使得可以以块的方式处理数据(大小由切片确定),如果同一切片用于后续操作,则由此产生的程序始终更加内存高效,因为它使用了相同的有限内存部分来分配切片。

文件结构

os.File类型满足读取器接口,并且是用于与文件内容交互的主要角色。获取用于读取目的的实例的最常见方法是使用os.Open函数。在使用完文件后记得关闭文件非常重要 - 对于短暂存在的程序可能不明显,但如果一个应用程序不断打开文件而不关闭已经完成的文件,应用程序将达到操作系统强加的打开文件限制并开始失败打开操作。

shell 提供了一些实用程序,如下所示:

  • 获取打开文件的限制 - ulimit -n

  • 另一个是检查某个进程打开了多少文件 - lsof -p PID

前面的示例仅打开文件以将其内容显示到标准输出,通过将所有内容加载到内存中来实现。可以很容易地通过刚才提到的工具进行优化。在下面的示例中,我们使用一个小缓冲区并在下一次读取之前打印其内容,使用小缓冲区以将内存使用量保持在最低。

使用字节数组作为缓冲区的示例如下所示:

func main() {
    if len(os.Args) != 2 {
        fmt.Println("Please specify a file")
        return
    }
    f, err := os.Open(os.Args[1])
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    defer f.Close() // we ensure close to avoid leaks

    var (
        b = make([]byte, 16)
    )
    for n := 0; err == nil; {
        n, err = f.Read(b)
        if err == nil {
            fmt.Print(string(b[:n])) // only print what's been read
        }
    }
    if err != nil && err != io.EOF { // we expect an EOF
        fmt.Println("\n\nError:", err)
    }
}

如果一切正常,读取循环将继续执行读取操作,直到文件内容结束。在这种情况下,读取循环将返回一个io.EOF错误,表明没有更多内容可用。

使用缓冲区

数据缓冲区,或者只是一个缓冲区,是用于在数据移动时存储临时数据的一部分内存。字节缓冲区是在bytes包中实现的,并且由一个底层切片实现,该切片能够在需要存储的数据量不适合时进行扩展。

如果每次分配新缓冲区,旧缓冲区最终将被 GC 自行清理,这不是一个最佳解决方案。最好始终重用缓冲区而不是分配新的。这是因为它们使得可以重置切片同时保持容量不变(数组不会被 GC 清除或收集)。

缓冲区还提供了两个函数来显示其底层长度和容量。在下面的示例中,我们可以看到如何使用Buffer.Reset重用缓冲区以及如何跟踪其容量。

缓冲重用及其底层容量的示例如下所示:

package main

import (
    "bytes"
    "fmt"
)

func main() {
    var b = bytes.NewBuffer(make([]byte, 26))
    var texts = []string{
        `As he came into the window`,
        `It was the sound of a crescendo
He came into her apartment`,
        `He left the bloodstains on the carpet`,
        `She ran underneath the table
He could see she was unable
So she ran into the bedroom
She was struck down, it was her doom`,
    }
    for i := range texts {
        b.Reset()
        b.WriteString(texts[i])
        fmt.Println("Length:", b.Len(), "\tCapacity:", b.Cap())
    }
}

窥视内容

在前面的示例中,我们固定了一定数量的字节,以便在打印之前存储内容。bufio包提供了一些功能,使得可以使用用户无法直接控制的底层缓冲区,并且可以执行一个非常重要的操作,名为peek

Peeking 是在不推进阅读器光标的情况下读取内容的能力。在这里,在幕后,被窥视的数据存储在缓冲区中。每次读取操作都会检查这个缓冲区是否有数据,如果有,那么数据将被返回并从缓冲区中移除。这就像一个队列(先进先出)。

这个简单操作打开的可能性是无穷的,它们都源于窥视直到找到所需的数据序列,然后实际读取感兴趣的块。这个操作的最常见用途包括以下内容:

  • 缓冲区从阅读器中读取,直到找到换行符(一次读取一行)。

  • 直到找到空格为止(一次读取一个单词)。

允许应用程序实现这种行为的结构是bufio.Scanner。这使得可以定义分割函数是什么,并具有以下类型:

type SplitFunc func(data []byte, atEOF bool) (advance int, token []byte, err error)

当返回错误时,此函数停止,否则它返回要在内容中前进的字节数,最终返回一个标记。包中实现的函数如下:

  • ScanBytes:字节标记

  • ScanRunes:符文标记

  • ScanWord:单词标记

  • ScanLines:行标记

我们可以实现一个文件阅读器,只需一个阅读器就可以计算行数。结果程序将尝试模拟 Unix 的wc -l命令的功能。

一个打印文件并计算行数的示例在以下代码中显示:

func main() {
    if len(os.Args) != 2 {
        fmt.Println("Please specify a path.")
        return
    }
    f, err := os.Open(os.Args[1])
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    defer f.Close()
    r := bufio.NewReader(f) // wrapping the reader with a buffered one
    var rowCount int
    for err == nil {
        var b []byte
        for moar := true; err == nil && moar; {
            b, moar, err = r.ReadLine()
            if err == nil {
                fmt.Print(string(b))
            }
        }
        // each time moar is false, a line is completely read
        if err == nil {
            fmt.Println()
            rowCount++

        }
    }
    if err != nil && err != io.EOF {
        fmt.Println("\nError:", err)
        return
    }
    fmt.Println("\nRow count:", rowCount)
}

Closer 和 seeker

还有另外两个与阅读器相关的接口:io.Closerio.Seeker

type Closer interface {
        Close() error
}

type Seeker interface {
        Seek(offset int64, whence int) (int64, error)
}

这些通常与io.Reader结合使用,得到的接口如下:

type ReadCloser interface {
        Reader
        Closer
}

type ReadSeeker interface {
        Reader
        Seeker
}

Close方法确保资源被释放并避免泄漏,而Seek方法使得可以移动当前对象(例如Writer)的光标到文件的起始/结束位置,或者从当前位置移动。

os.File 结构实现了这个方法,以满足所有列出的接口。在操作结束时关闭文件是可能的,或者根据你想要实现的目标移动当前光标。

写入文件

正如我们在阅读中所看到的,写入文件有不同的方式,每种方式都有其自身的缺点和优势。例如,在ioutil包中,我们有另一个名为WriteFile的函数,它允许我们在一行中执行整个操作。这包括打开文件,写入其内容,然后关闭文件。

一个一次性写入文件所有内容的示例在以下代码中显示:

package main

import (
    "fmt"
    "io/ioutil"
    "os"
)

func main() {
    if len(os.Args) != 3 {
        fmt.Println("Please specify a path and some content")
        return
    }
    // the second argument, the content, needs to be casted to a byte slice
    if err := ioutil.WriteFile(os.Args[1], []byte(os.Args[2]), 0644); err != nil {
        fmt.Println("Error:", err)
    }
}

这个示例一次性写入所有内容。这要求我们使用一个字节切片在内存中分配所有内容。如果内容太大,内存使用可能会成为操作系统的问题,这可能会终止我们的应用程序的进程。

如果内容的大小不是很大,而且应用程序的生命周期很短,那么如果内容加载到内存中并用单个操作写入,这不是问题。这对于长期运行的应用程序来说不是最佳实践,这些应用程序对许多不同的文件进行读取和写入。它们必须在内存中分配所有内容,而该内存将在某个时刻被 GC 释放 - 这个操作不是免费的,这意味着它在内存使用和性能方面有缺点。

Writer 接口

对于写入也适用于阅读的相同原则 - 在io包中有一个确定写入行为的接口,如下所示:

type Writer interface {
        Write(p []byte) (n int, err error)
}

io.Writer接口定义了一个方法,给定一个字节片,返回已写入多少字节以及是否有任何错误。写入器使得可以一次写入一块数据,而无需一次性拥有所有数据。os.File结构也恰好是一个写入器,并且可以以这种方式使用。

我们可以使用字节片作为缓冲区逐段写入信息。在下面的示例中,我们将尝试将从上一节读取的内容与写入相结合,使用io.Seeker的能力在写入之前反转其内容。

下面的代码示例显示了反转文件内容的示例:

// Let's omit argument check and file opening, we obtain src and dst
cur, err := src.Seek(0, os.SEEK_END) // Let's go to the end of the file
if err != nil {
    fmt.Println("Error:", err)
    return
}
b := make([]byte, 16)

在移动到文件末尾并定义字节缓冲区后,我们进入一个循环,该循环在文件中稍微向后移动,然后读取其中的一部分,如下面的代码所示:


for step, r, w := int64(16), 0, 0; cur != 0; {
    if cur < step { // ensure cursor is 0 at max
        b, step = b[:cur], cur
    }
    cur = cur - step
    _, err = src.Seek(cur, os.SEEK_SET) // go backwards
    if err != nil {
        break
    }
    if r, err = src.Read(b); err != nil || r != len(b) {
        if err == nil { // all buffer should be read
            err = fmt.Errorf("read: expected %d bytes, got %d", len(b), r)
        }
        break
    }

然后,我们将内容反转并将其写入目标,如下面的代码所示:

    for i, j := 0, len(b)-1; i < j; i, j = i+1, j-1 {
        switch { // Swap (\r\n) so they get back in place
        case b[i] == '\r' && b[i+1] == '\n':
            b[i], b[i+1] = b[i+1], b[i]
        case j != len(b)-1 && b[j-1] == '\r' && b[j] == '\n':
            b[j], b[j-1] = b[j-1], b[j]
        }
        b[i], b[j] = b[j], b[i] // swap bytes
    }
    if w, err = dst.Write(b); err != nil || w != len(b) {
        if err != nil {
            err = fmt.Errorf("write: expected %d bytes, got %d", len(b), w)
        }
    }
}
if err != nil && err != io.EOF { // we expect an EOF
    fmt.Println("\n\nError:", err)
}

缓冲区和格式

在前一节中,我们看到bytes.Buffer可以用于临时存储数据,并且通过附加底层切片来处理自己的增长。fmt包广泛使用缓冲区来执行其操作;由于依赖原因,这些不是字节包中的缓冲区。这种方法是 Go 的谚语之一:

“少量复制胜过少量依赖。”

如果必须导入一个包来使用一个函数或类型,那么应该考虑将必要的代码复制到自己的包中。如果一个包包含的内容远远超出了你的需求,复制可以减少最终二进制文件的大小。您还可以自定义代码并根据自己的需求进行调整。

缓冲区的另一个用途是在写入之前组成消息。让我们编写一些代码,以便我们可以使用缓冲区格式化书籍列表:

const grr = "G.R.R. Martin"

type book struct {
    Author, Title string
    Year int
}

func main() {
    dst, err := os.OpenFile("book_list.txt", os.O_CREATE|os.O_WRONLY, 0666)
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    defer dst.Close()
    bookList := []book{
        {Author: grr, Title: "A Game of Thrones", Year: 1996},
        {Author: grr, Title: "A Clash of Kings", Year: 1998},
        {Author: grr, Title: "A Storm of Swords", Year: 2000},
        {Author: grr, Title: "A Feast for Crows", Year: 2005},
        {Author: grr, Title: "A Dance with Dragons", Year: 2011},
        // if year is omitted it defaulting to zero value
        {Author: grr, Title: "The Winds of Winter"},
        {Author: grr, Title: "A Dream of Spring"},
    }
    b := bytes.NewBuffer(make([]byte, 0, 16))
    for _, v := range bookList {
        // prints a msg formatted with arguments to writer
        fmt.Fprintf(b, "%s - %s", v.Title, v.Author)
        if v.Year > 0 { 
            // we do not print the year if it's not there
            fmt.Fprintf(b, " (%d)", v.Year)
        }
        b.WriteRune('\n')
        if _, err := b.WriteTo(dst); true { // copies bytes, drains buffer
            fmt.Println("Error:", err)
            return
        }
    }
}

缓冲区用于组成书籍描述,如果不存在年份,则会被省略。在处理字节时,这是非常高效的,如果每次都重用缓冲区,效果会更好。如果此类操作的输出应该是一个字符串,则strings包中有一个非常相似的结构称为Builder,它具有相同的写入方法,但也有一些不同之处,例如以下内容:

  • String()方法使用unsafe包将字节转换为字符串,而不是复制它们。

  • 不允许复制strings.Builder,然后对副本进行写入,因为这会导致panic

高效写入

每次执行os.File方法,即Write,这将转换为系统调用,这是一个带有一些开销的操作。一般来说,通过一次写入更多的数据来减少在此类调用上花费的时间是一个好主意,从而最小化操作的数量。

bufio.Writer结构是一个包装另一个写入器(如os.File)的写入器,并且仅在缓冲区满时执行写入操作。这使得可以使用Flush方法执行强制写入,通常保留到写入过程的结束。使用缓冲区的一个良好模式如下:

  var w io.WriteCloser
  // initialise writer
  defer w.Close()
  b := bufio.NewWriter(w)
  defer b.Flush()
  // write operations

defer语句在返回当前函数之前以相反的顺序执行,因此第一个Flush确保将缓冲区中的任何内容写入,然后Close实际关闭文件。如果两个操作以相反的顺序执行,flush 将尝试写入一个关闭的文件,返回错误,并且无法写入最后一块信息。

文件模式

我们看到os.OpenFile函数使得可以选择如何使用文件模式打开文件,文件模式是一个uint32,其中每个位都有特定含义(类似于 Unix 文件和文件夹权限)。os包提供了一系列值,每个值都指定一种模式,正确的组合方式是使用|(按位或)。

下面的代码显示了可用的代码,并且直接从 Go 的源代码中获取:

// Exactly one of O_RDONLY, O_WRONLY, or O_RDWR must be specified.
O_RDONLY int = syscall.O_RDONLY // open the file read-only.
O_WRONLY int = syscall.O_WRONLY // open the file write-only.
O_RDWR int = syscall.O_RDWR // open the file read-write.
// The remaining values may be or'ed in to control behavior.
O_APPEND int = syscall.O_APPEND // append data to the file when writing.
O_CREATE int = syscall.O_CREAT // create a new file if none exists.
O_EXCL int = syscall.O_EXCL // used with O_CREATE, file must not exist.
O_SYNC int = syscall.O_SYNC // open for synchronous I/O.
O_TRUNC int = syscall.O_TRUNC // if possible, truncate file when opened.

前三个表示允许的操作(读、写或两者),其他的如下:

  • O_APPEND: 在每次写入之前,文件偏移量被定位在文件末尾。

  • O_CREATE: 可以创建文件(如果文件不存在)。

  • O_EXCL: 如果与创建一起使用,如果文件已经存在,则失败(独占创建)。

  • O_SYNC: 执行读/写操作并验证其完成。

  • O_TRUNC: 如果文件存在,其大小将被截断为0

其他操作

读和写不是文件上可以执行的唯一操作。在下一节中,我们将看看如何使用os包来执行它们。

创建

要创建一个空文件,可以调用一个名为Create的辅助函数,它以0666权限打开一个新文件,并在文件不存在时将其截断。或者,我们可以使用OpenFileO_CREATE|O_TRUNCATE模式来指定自定义权限,如下面的代码所示:

package main

import "os"

func main() {
    f, err := os.Create("file.txt")
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    f.Close()
}

截断

要截断文件内容到一定尺寸,并且如果文件较小则保持不变,可以使用os.Truncate方法。其用法非常简单,如下面的代码所示:

package main

import "os"

func main() {
    // let's keep thing under 4kB
    if err := os.Truncate("file.txt", 4096); err != nil {
        fmt.Println("Error:", err)
    }
}

删除

为了删除文件,还有另一个简单的函数,称为os.Remove,如下面的代码所示:

package main

import "os"

func main() {
    if err := os.Remove("file.txt"); err != nil {
        fmt.Println("Error:", err)
    }
}

移动

os.Rename函数可以更改文件名和/或其目录。请注意,如果目标文件已经存在,此操作将替换目标文件。

更改文件名或其目录的代码如下:

import "os"

func main() {
    if err := os.Rename("file.txt", "../file.txt"); err != nil {
        fmt.Println("Error:", err)
    }
}

复制

没有唯一的函数可以复制文件,但可以使用io.Copy函数轻松实现。下面的示例显示了如何使用它从一个文件复制到另一个文件:

func CopyFile(from, to string) (int64, error) {
    src, err := os.Open(from)
    if err != nil {
        return 0, err
    }
    defer src.Close()
    dst, err := os.OpenFile(to, os.O_WRONLY|os.O_CREATE, 0644)
    if err != nil {
        return 0, err
    }
    defer dst.Close()  
    return io.Copy(dst, src)
}

统计

os包提供了FileInfo接口,返回文件的元数据,如下面的代码所示:

type FileInfo interface {
        Name() string // base name of the file
        Size() int64 // length in bytes for regular files; system-dependent for others
        Mode() FileMode // file mode bits
        ModTime() time.Time // modification time
        IsDir() bool // abbreviation for Mode().IsDir()
        Sys() interface{} // underlying data source (can return nil)
}

os.Stat函数返回指定路径文件的信息。

更改属性

为了与文件系统交互并更改这些属性,有三个函数可用:

  • func Chmod(name string, mode FileMode) error: 更改文件的权限

  • func Chown(name string, uid, gid int) error: 更改文件的所有者和组

  • func Chtimes(name string, atime time.Time, mtime time.Time) error: 更改文件的访问和修改时间

第三方包

社区提供了许多可以完成各种任务的包。我们将在本节中快速浏览其中一些。

虚拟文件系统

在 Go 中,文件是一个具体类型的结构体,没有围绕它们的抽象,而文件的信息由os.FileInfo表示,它是一个接口。这有点不一致,已经有许多尝试创建一个完整和一致的文件系统抽象,通常称为虚拟文件系统

最常用的两个包如下:

即使它们是分开开发的,它们都做同样的事情——它们定义了一个具有os.File所有方法的接口,然后定义了一个实现os包中可用的函数的接口,比如创建、打开和删除文件等。

它们提供了基于标准包实现的os.File版本,但也有一个使用模拟文件系统的数据结构的内存版本。这对于为任何包构建测试非常有用。

文件系统事件

Go 在golang.org/x/包中有一些实验性功能,这些功能位于 Go 的 GitHub 处理程序下(github.com/golang/)。golang.org/x/sys包是其中之一,包括一个专门用于 Unix 系统事件的子包。这已被用于构建一个在 Go 的文件功能中缺失的功能,可以非常有用 - 观察某个路径上的文件事件,如创建、删除和更新。

最著名的两个实现如下:

这两个包都公开了一个函数,允许创建观察者。观察者是包含负责传递文件事件的通道的结构。它们还公开了另一个负责终止/关闭观察者和底层通道的函数。

总结

在本章中,我们概述了如何在 Go 中执行文件操作。为了定位文件,filepath包提供了广泛的函数数组。这些函数可以帮助您执行各种操作,从组合路径到从中提取元素。

我们还看了如何使用各种方法读取操作,从位于io/ioutil包中的最简单和内存效率较低的方法到需要io.Writer实现来读取固定大小的字节块的方法。在bufio包中实现的查看内容的能力的重要性,允许进行一整套操作,如读取单词或读取行,当找到一个标记时停止读取操作。有其他对文件非常有用的接口;例如,io.Closer确保资源被释放,io.Seeker用于在不需要实际读取文件和丢弃输出的情况下移动读取光标。

将字节切片写入文件可以通过不同的方式实现 - io/ioutil包可以通过函数调用实现,而对于更复杂或更节省内存的操作,可以使用io.Writer接口。这使得可以一次写入一个字节切片,并且可以被fmt包用于打印格式化数据。缓冲写入用于减少在磁盘上的实际写入量。这是通过一个收集内容的缓冲区来实现的,然后每次缓冲区满时将其传输到磁盘上。

最后,我们看到了如何在文件系统上执行其他文件操作(创建、删除、复制/移动和更改文件属性),并查看了一些与文件系统相关的第三方包,即虚拟文件系统抽象和文件系统事件通知。

下一章将讨论流,并将重点放在与文件系统无关的所有读取器和写入器的实例上。

问题

  1. 绝对路径和相对路径有什么区别?

  2. 如何获取或更改当前工作目录?

  3. 使用ioutil.ReadAll的优点和缺点是什么?

  4. 为什么缓冲对于读取操作很重要?

  5. 何时应该使用ioutil.WriteFile

  6. 使用允许查看的缓冲读取器时有哪些操作可用?

  7. 何时最好使用字节缓冲区读取内容?

  8. 缓冲区如何用于写入?使用它们有什么优势?

第五章:处理流

本章涉及数据流,将输入和输出接口扩展到文件系统之外,并介绍如何实现自定义读取器和写入器以满足任何目的。

它还专注于输入和输出实用程序的缺失部分,以多种不同的方式将它们组合在一起,目标是完全控制传入和传出的数据。

本章将涵盖以下主题:

  • 自定义读取器

  • 自定义写入器

  • 实用程序

技术要求

本章需要安装 Go 并设置您喜欢的编辑器。有关更多信息,请参阅第三章,Go 概述

写入器和读取器不仅仅用于文件;它们是抽象数据流的接口,这些流通常被称为,是大多数应用程序的重要组成部分。

输入和读取器

如果应用程序无法控制数据流,并且将等待错误来结束流程,则传入的数据流被视为io.Reader接口,在最佳情况下会收到io.EOF值,这是一个特殊的错误,表示没有更多内容可读取,否则会收到其他错误。另一种选择是读取器也能够终止流。在这种情况下,正确的表示是io.ReadCloser接口。

除了os.File,标准包中还有几个读取器的实现。

字节读取器

bytes包含一个有用的结构,它将字节切片视为io.Reader接口,并实现了许多更多的 I/O 接口:

  • io.Reader:这可以作为常规读取器

  • io.ReaderAt:这使得可以从特定位置开始读取

  • io.WriterTo:这使得可以在偏移量处写入内容

  • io.Seeker:这可以自由移动读取器的光标

  • io.ByteScanner:这可以为每个字节执行读取操作

  • io.RuneScanner:这可以对由多个字节组成的字符执行相同的操作

符文和字节之间的区别可以通过以下示例来澄清,其中我们有一个由一个符文组成的字符串,它由三个字节e28c98表示:

func main() {
    const a = `⌘`

    fmt.Printf("plain string: %s\n", a)
    fmt.Printf("quoted string: %q\n",a)

    fmt.Printf("hex bytes: ")
    for i := 0; i < len(a); i++ {
        fmt.Printf("%x ", a[i])
    }
    fmt.Printf("\n")
}

完整的示例可在play.golang.org/p/gVZOufSmlq1找到。

还有bytes.Buffer,它在bytes.Reader的基础上添加了写入功能,并且可以访问底层切片或将内容作为字符串获取。

Buffer.String方法将字节转换为字符串,在 Go 中进行此类转换是通过复制字节来完成的,因为字符串是不可变的。这意味着对缓冲区的任何更改都将在复制后进行,不会传播到字符串。

字符串读取器

strings包含另一个与io.Reader接口非常相似的结构,称为strings.Reader。它的工作方式与第一个完全相同,但底层值是字符串而不是字节切片。

在处理需要读取的字符串时,使用字符串而不是字节读取器的主要优势之一是避免在初始化时复制数据。这种微妙的差异有助于提高性能和内存使用,因为它减少了分配并需要垃圾回收器GC)清理副本。

定义读取器

任何 Go 应用程序都可以定义io.Reader接口的自定义实现。在实现接口时的一个很好的一般规则是接受接口并返回具体类型,避免不必要的抽象。

让我们看一个实际的例子。我们想要实现一个自定义读取器,它从另一个读取器中获取内容并将其转换为大写;例如,我们可以称之为AngryReader

func NewAngryReader(r io.Reader) *AngryReader {
    return &AngryReader{r: r}
}

type AngryReader struct {
    r io.Reader
}

func (a *AngryReader) Read(b []byte) (int, error) {
    n, err := a.r.Read(b)
    for r, i, w := rune(0), 0, 0; i < n; i += w {
        // read a rune
        r, w = utf8.DecodeRune(b[i:])
        // skip if not a letter
        if !unicode.IsLetter(r) {
            continue
        }
        // uppercase version of the rune
        ru := unicode.ToUpper(r)
        // encode the rune and expect same length
        if wu := utf8.EncodeRune(b[i:], ru); w != wu {
            return n, fmt.Errorf("%c->%c, size mismatch %d->%d", r, ru, w, wu)
        }
    }
    return n, err
}

这是一个非常直接的例子,使用unicodeunicode/utf8来实现其目标:

  • utf8.DecodeRune用于获取第一个符文及其宽度是读取的切片的一部分

  • unicode.IsLetter确定符文是否为字母

  • unicode.ToUpper将文本转换为大写

  • ut8.EncodeLetter将新字母写入必要的字节

  • 字母及其大写版本应该具有相同的宽度

完整示例可在play.golang.org/p/PhdSsbzXcbE找到。

输出和写入器

适用于传入流的推理也适用于传出流。我们有io.Writer接口,应用程序只能发送数据,还有io.WriteCloser接口,它还能关闭连接。

字节写入器

我们已经看到bytes包提供了Buffer,它具有读取和写入功能。这实现了ByteReader接口的所有方法,以及一个以上的Writer接口:

  • io.Writer:这可以作为常规写入器

  • io.WriterAt:这使得可以从某个位置开始写入

  • io.ByteWriter:这使得可以写入单个字节

bytes.Buffer是一个非常灵活的结构,因为它既适用于WriterByteWriter,如果重复使用,它的ResetTruncate方法效果最佳。与其让 GC 回收已使用的缓冲区并创建一个新的缓冲区,不如重置现有的缓冲区,保留缓冲区的底层数组,并将切片长度设置为0

在前一章中,我们看到了缓冲区使用的一个很好的例子:

    bookList := []book{
        {Author: grr, Title: "A Game of Thrones", Year: 1996},
        {Author: grr, Title: "A Clash of Kings", Year: 1998},
        {Author: grr, Title: "A Storm of Swords", Year: 2000},
        {Author: grr, Title: "A Feast for Crows", Year: 2005},
        {Author: grr, Title: "A Dance with Dragons", Year: 2011},
        {Author: grr, Title: "The Winds of Winter"},
        {Author: grr, Title: "A Dream of Spring"},
    }
    b := bytes.NewBuffer(make([]byte, 0, 16))
    for _, v := range bookList {
        // prints a msg formatted with arguments to writer
        fmt.Fprintf(b, "%s - %s", v.Title, v.Author)
        if v.Year > 0 { // we do not print the year if it's not there
            fmt.Fprintf(b, " (%d)", v.Year)
        }
        b.WriteRune('\n')
        if _, err := b.WriteTo(dst); true { // copies bytes, drains buffer
            fmt.Println("Error:", err)
            return
        }
    }

缓冲区不适用于组合字符串值。因此,当调用String方法时,字节会被转换为不可变的字符串,与切片不同。以这种方式创建的新字符串是使用当前切片的副本制作的,对切片的更改不会影响字符串。这既不是限制也不是特性;这是一个属性,如果使用不正确可能会导致错误。以下是重置缓冲区并使用String方法的效果示例:

package main

import (
    "bytes"
    "fmt"
)

func main() {
    b := bytes.NewBuffer(nil)
    b.WriteString("One")
    s1 := b.String()
    b.WriteString("Two")
    s2 := b.String()
    b.Reset()
    b.WriteString("Hey!")    // does not change s1 or s2
    s3 := b.String()
    fmt.Println(s1, s2, s3)  // prints "One OneTwo Hey!"
}

完整示例可在play.golang.org/p/zBjGPMC4sfF找到

字符串写入器

字节缓冲区执行字节的复制以生成一个字符串。这就是为什么在 1.10 版本中,strings.Builder首次亮相。它共享缓冲区的所有与写入相关的方法,并且不允许通过Bytes方法访问底层切片。获取最终字符串的唯一方法是使用String方法,它在底层使用unsafe包将切片转换为字符串而不复制底层数据。

这样做的主要后果是这个结构强烈地不鼓励复制——因为复制的切片的底层数组指向相同的数组,并且在副本中写入会影响另一个。结果的操作会导致恐慌:

package main

import (
    "strings"
)

func main() {
    b := strings.Builder{}
    b.WriteString("One")
    c := b
    c.WriteString("Hey!") // panic: strings: illegal use of non-zero Builder copied by value
}

定义一个写入器

任何写入器的自定义实现都可以在应用程序中定义。一个非常常见的情况是装饰器,它是一个包装另一个写入器并改变或扩展原始写入器功能的写入器。至于读取器,最好有一个接受另一个写入器并可能包装它以使其与许多标准库结构兼容的构造函数,例如以下内容:

  • *os.File

  • *bytes.Buffer

  • *strings.Builder

让我们来看一个真实的用例——我们想要生成一些带有每个单词中混淆字母的文本,以测试何时开始变得无法阅读。我们将创建一个可配置的写入器,在将其写入目标写入器之前混淆字母,并创建一个接受文件并创建其混淆版本的二进制文件。我们将使用math/rand包来随机化混淆。

让我们定义我们的结构及其构造函数。这将接受另一个写入器、一个随机数生成器和一个混淆的chance

func NewScrambleWriter(w io.Writer, r *rand.Rand, chance float64) *ScrambleWriter {
    return &ScrambleWriter{w: w, r: r, c: chance}
}

type ScrambleWriter struct {
    w io.Writer
    r *rand.Rand
    c float64
}

Write方法需要执行字节而不是字母,并打乱字母的顺序。它将迭代符文,使用我们之前看到的ut8.DecodeRune函数,打印出任何不是字母的内容,并堆叠它可以找到的所有字母序列:

func (s *ScrambleWriter) Write(b []byte) (n int, err error) {
    var runes = make([]rune, 0, 10)
    for r, i, w := rune(0), 0, 0; i < len(b); i += w {
        r, w = utf8.DecodeRune(b[i:])
        if unicode.IsLetter(r) {
            runes = append(runes, r)
            continue
        }
        v, err := s.shambleWrite(runes, r)
        if err != nil {
            return n, err
        }
        n += v
        runes = runes[:0]
    }
    if len(runes) != 0 {
        v, err := s.shambleWrite(runes, 0)
        if err != nil {
            return n, err
        }
        n += v
    }
    return
}

当序列结束时,它将由shambleWrite方法处理,该方法将有效地执行一个混乱并写入混乱的符文:

func (s *ScrambleWriter) shambleWrite(runes []rune, sep rune) (n int, err error) {
    //scramble after first letter
    for i := 1; i < len(runes)-1; i++ {
        if s.r.Float64() > s.c {
            continue
        }
        j := s.r.Intn(len(runes)-1) + 1
        runes[i], runes[j] = runes[j], runes[i]
    }
    if sep!= 0 {
        runes = append(runes, sep)
    }
    var b = make([]byte, 10)
    for _, r := range runes {
        v, err := s.w.Write(b[:utf8.EncodeRune(b, r)])
        if err != nil {
            return n, err
        }
        n += v
    }
    return
}

完整示例可在play.golang.org/p/0Xez--6P7nj中找到。

内置实用程序

ioio/ioutil包中有许多其他函数,可以帮助管理读取器、写入器等。了解所有可用的工具将帮助您避免编写不必要的代码,并指导您在使用最佳工具时进行操作。

从一个流复制到另一个流

io包中有三个主要函数,可以实现从写入器到读取器的数据传输。这是一个非常常见的场景;例如,您可以将从打开的文件中读取的内容写入到另一个打开的文件中,或者将缓冲区中的内容排空并将其内容写入标准输出。

我们已经看到如何在文件上使用io.Copy函数来模拟第四章,与文件系统一起工作cp命令的行为。这种行为可以扩展到任何读取器和写入器的实现,从缓冲区到网络连接。

如果写入器也是io.WriterTo接口,复制将调用WriteTo方法。如果不是,它将使用固定大小的缓冲区(32 KB)进行一系列写入。如果操作以io.EOF值结束,则不会返回错误。一个常见的情况是bytes.Buffer结构,它能够将其内容写入另一个写入器,并且将相应地行事。或者,如果目标是io.ReaderFrom接口,则执行ReadFrom方法。

如果接口是一个简单的io.Writer接口,这个方法将使用一个临时缓冲区,之后将被清除。为了避免在垃圾回收上浪费计算资源,并且可能重用相同的缓冲区,还有另一个函数——io.CopyBuffer函数。这有一个额外的参数,只有在这个额外的参数是nil时才会分配一个新的缓冲区。

最后一个函数是io.CopyN,它的工作原理与io.Copy完全相同,但可以指定要写入到额外参数的字节数限制。如果读取器也是io.Seeker,则可以有用地写入部分内容——seeker 首先将光标移动到正确的偏移量,然后写入一定数量的字节。

让我们举一个一次复制n个字节的例子:

func CopyNOffset(dst io.Writer, src io.ReadSeeker, offset, length int64) (int64, error) {
  if _, err := src.Seek(offset, io.SeekStart); err != nil {
    return 0, err
  }
  return io.CopyN(dst, src, length)
}

完整示例可在play.golang.org/p/8wCqGXp5mSZ中找到。

连接的读取器和写入器

io.Pipe函数创建一对连接的读取器和写入器。这意味着发送到写入器的任何内容都将从读取器接收到。如果仍有上次操作的挂起数据,写入操作将被阻塞;只有在读取器完成消耗已发送的内容后,新操作才会结束。

这对于非并发应用程序来说并不是一个重要的工具,非并发应用程序更有可能使用通道等并发工具,但是当读取器和写入器在不同的 goroutine 上执行时,这可以是一个很好的同步机制,就像下面的程序一样:

    pr, pw := io.Pipe()
    go func(w io.WriteCloser) {
        for _, s := range []string{"a string", "another string", 
           "last one"} {
                fmt.Printf("-> writing %q\n", s)
                fmt.Fprint(w, s)
        }
        w.Close()
    }(pw)
    var err error
    for n, b := 0, make([]byte, 100); err == nil; {
        fmt.Println("<- waiting...")
        n, err = pr.Read(b)
        if err == nil {
            fmt.Printf("<- received %q\n", string(b[:n]))
        }
    }
    if err != nil && err != io.EOF {
        fmt.Println("error:", err)
    }

完整示例可在play.golang.org/p/0YpRK25wFw_c中找到。

扩展读取器

当涉及到传入流时,标准库中有很多函数可用于改进读取器的功能。其中一个最简单的例子是ioutil.NopCloser,它接受一个读取器并返回io.ReadCloser,什么也不做。如果一个函数负责释放资源,但使用的读取器不是io.Closer(比如bytes.Buffer),这就很有用。

有两个工具可以限制读取的字节数。ReadAtLeast函数定义了要读取的最小字节数。只有在没有要读取的字节时才会返回EOF;否则,如果在EOF之前读取了较少的字节数,将返回ErrUnexpectedEOF。如果字节缓冲区比请求的字节数要短,这是没有意义的,将会返回ErrShortBuffer。在读取错误的情况下,函数会设法至少读取所需数量的字节,并且会丢弃该错误。

然后是ReadFull,它预期填充缓冲区,否则将返回ErrUnexpectedEOF

另一个约束函数是LimitReader。这个函数是一个装饰器,它接收一个读取器并返回另一个读取器,一旦读取到所需的字节,就会返回EOF。这可以用于预览实际读取器的内容,就像下面的例子一样:

s := strings.NewReader(`Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged.`)
    io.Copy(os.Stdout, io.LimitReader(s, 25)) // will print "Lorem Ipsum is simply dum"

完整的示例可在play.golang.org/p/LllOdWg9uyU找到。

更多的读取器可以使用MultiReader函数组合成一个序列,将依次读取每个部分,直到达到EOF,然后跳转到下一个。

一个读取器和一个写入器可以连接起来,以便来自读取器的任何内容都会被复制到写入器,这与io.Pipe的相反情况相反。这是通过io.TeeReader完成的。

让我们尝试使用它来创建一个在文件系统中充当搜索引擎的写入器,只打印出与所请求的查询匹配的行。我们想要一个执行以下操作的程序:

  • 从参数中读取目录路径和要搜索的字符串

  • 获取所选路径中的文件列表

  • 读取每个文件,并将包含所选字符串的行传递给另一个写入器

  • 另一个写入器将注入颜色字符以突出显示字符串,并将其内容复制到标准输出

让我们从颜色注入开始。在 Unix shell 中,可以通过以下序列获得彩色输出:

  • \xbb1: 一个转义字符

  • [: 一个开放的括号

  • 39: 一个数字

  • m: 字母m

数字确定了背景和前景颜色。对于本例,我们将使用31(红色)和39(默认)。

我们正在创建一个写入器,它将打印出匹配的行并突出显示文本:

type queryWriter struct {
    Query []byte
    io.Writer
}

func (q queryWriter) Write(b []byte) (n int, err error) {
    lines := bytes.Split(b, []byte{'\n'})
    l := len(q.Query)
    for _, b := range lines {
        i := bytes.Index(b, q.Query)
        if i == -1 {
            continue
        }
        for _, s := range [][]byte{
            b[:i], // what's before the match
            []byte("\x1b[31m"), //star red color
            b[i : i+l], // match
            []byte("\x1b[39m"), // default color
            b[i+l:], // whatever is left
        } {
            v, err := q.Writer.Write(s)
            n += v
            if err != nil {
                return 0, err
            }
        }
        fmt.Fprintln(q.Writer)
    }
    return len(b), nil
}

这将与打开文件一起使用TeeReader,以便读取文件将写入queryWriter

func main() {
    if len(os.Args) < 3 {
        fmt.Println("Please specify a path and a search string.")
        return
    }
    root, err := filepath.Abs(os.Args[1]) // get absolute path
    if err != nil {
        fmt.Println("Cannot get absolute path:", err)
        return
    }
    q := []byte(strings.Join(os.Args[2:], " "))
    fmt.Printf("Searching for %q in %s...\n", query, root)
    err = filepath.Walk(root, func(path string, info os.FileInfo,   
        err error) error {
            if info.IsDir() {
                return nil
            }
            fmt.Println(path)
            f, err := os.Open(path)
            if err != nil {
                return err
            }
        defer f.Close()

        _, err = ioutil.ReadAll(io.TeeReader(f, queryWriter{q, os.Stdout}))
        return err
    })
    if err != nil {
        fmt.Println(err)
    }
}

正如你所看到的,无需写入;从文件中读取会自动写入连接到标准输出的查询写入器。

写入器和装饰器

有大量的工具可用于增强、装饰和使用读取器,但对于写入器却不适用。

还有io.WriteString函数,它可以防止将字符串转换为字节。首先,它会检查写入器是否支持字符串写入,尝试将其转换为io.stringWriter,这是一个只有WriteString方法的未导出接口,然后如果成功,写入字符串,否则将其转换为字节。

io.MultiWriter函数,它创建一个写入器,将信息复制到一系列其他写入器中,这些写入器在创建时接收。一个实际的例子是在将内容写入标准输出的同时显示它,就像下面的例子一样:

    r := strings.NewReader("let's read this message\n")
    b := bytes.NewBuffer(nil)
    w := io.MultiWriter(b, os.Stdout)
    io.Copy(w, r) // prints to the standard output
    fmt.Println(b.String()) // buffer also contains string now

完整的示例可在play.golang.org/p/ZWDF2vCDfsM找到。

还有一个有用的变量,ioutil.Discard,它是一个写入器,写入到/dev/null,一个空设备。这意味着写入到这个变量会忽略数据。

总结

在本章中,我们介绍了流的概念,用于描述数据的传入和传出流。我们看到读取器接口表示接收到的数据,而写入器则是发送的数据。

我们比较了标准包中可用的不同读取器。在上一章中我们看了文件,在这一章中我们将字节和字符串读取器加入到列表中。我们学会了如何使用示例实现自定义读取器,并且看到设计一个读取器建立在另一个读取器之上总是一个好主意。

然后,我们专注于写入器。我们发现如果正确打开,文件也是写入器,并且标准包中有几个写入器,包括字节缓冲区和字符串构建器。我们还实现了一个自定义写入器,并看到如何使用utf8包处理字节和符文。

最后,我们探索了ioioutil中剩余的功能,分析了用于复制数据和连接读取器和写入器的各种工具。我们还看到了用于改进或更改读取器和写入器功能的装饰器。

在下一章中,我们将讨论伪终端应用程序,并利用所有这些知识来构建其中一些。

问题

  1. 什么是流?

  2. 哪些接口抽象了传入流?

  3. 哪些接口代表传出流?

  4. 何时应该使用字节读取器?何时应该使用字符串读取器?

  5. 字符串构建器和字节缓冲区之间有什么区别?

  6. 读者和写入者的实现为什么要接受一个接口作为输入?

  7. 管道与TeeReader有什么不同?

第六章:构建伪终端

本章将介绍伪终端应用程序。许多程序(如 SQL 或 SSH 客户端)都是构建为伪终端,因为它能够在终端内进行交互使用。这些类型的应用程序非常重要,因为它们允许我们在没有图形界面的环境中控制应用程序,例如通过安全外壳SSH)连接到服务器时。本章将指导您创建一些此类应用程序。

本章将涵盖以下主题:

  • 终端和伪终端

  • 基本伪终端

  • 高级伪终端

技术要求

本章需要安装 Go 并设置您喜欢的编辑器。有关更多信息,您可以参考[第三章](602a92d5-25f7-46b8-83d4-10c6af1c6750.xhtml),Go 概述

理解伪终端

伪终端或伪电传打字机是在终端或电传打字机下运行并模拟其行为的应用程序。这是一种非常方便的方式,可以在没有图形界面的终端内运行交互式软件。这是因为它使用终端本身来模拟一个终端。

从电传打字机开始

电传打字机TTY)或电传打印机是通过串行端口控制的电机式打字机的名称。它连接到能够向设备发送信息以打印的计算机上。数据由一系列有限的符号组成,例如 ASCII 字符,具有固定的字体。这些设备作为早期计算机的用户界面,因此它们在某种意义上是现代屏幕的前身。

当屏幕取代打印机作为输出设备时,它们的内容以类似的方式组织:字符的二维矩阵。在早期阶段,它们被称为玻璃 TTY,字符显示仍然是显示本身的一部分,由其自己的逻辑电路控制。随着第一批视频显示卡的到来,计算机能够拥有一个不依赖硬件的界面。

作为操作系统的主要界面使用的仅文本控制台从 TTY 继承其名称,并被称为控制台。即使操作系统运行在现代操作系统上的图形环境中,用户仍然可以访问一定数量的虚拟控制台,这些控制台作为命令行界面CLI)使用,通常称为 shell。

伪电传打字机

许多应用程序设计为在 shell 内工作,但其中一些是在模仿 shell 的行为。图形界面有一个专门用于执行 shell 的终端模拟器。这些类型的应用程序被称为伪电传打字机PTY)。为了被视为 PTY,应用程序需要能够执行以下操作:

  • 接受用户输入

  • 将输入发送到控制台并接收输出

  • 向用户显示此输出

已经有一些示例可用的 Linux 实用程序,其中最显著的是screen。这是一个伪终端应用程序,允许用户使用多个 shell 并对其进行控制。它可以打开和关闭新的 shell,并在所有打开的 shell 之间切换。它允许用户命名一个会话,因此,如果由于任何意外原因而被终止,用户可以恢复会话。

创建基本 PTY

我们将从创建输入管理器的简单版本的伪终端开始,然后创建命令选择器,最后创建命令执行。

输入管理

标准输入可用于接收用户命令。我们可以通过使用缓冲输入来读取行并打印它们。为了读取一行,有一个有用的命令bufio.Scanner,它已经提供了一个行读取器。代码将类似于以下代码片段:

s := bufio.NewScanner(os.Stdin)
w := os.Stdout
fmt.Fprint(w, "Some welcome message\n")
for {
    s.Scan() // get next the token
    fmt.Fprint(w, "You wrote \"") 
    w.Write(s.Bytes())
    fmt.Fprintln(w, "\"\n") // writing back the text
}

由于此代码没有退出点,我们可以从创建第一个命令exit开始,该命令将终止 shell 执行。我们可以对代码进行一些小改动,使其正常工作,如下所示:

s := bufio.NewScanner(os.Stdin)
w := os.Stdout
fmt.Fprint(w, "Some welcome message\n")
for {
    s.Scan() // get next the token
    msg := string(s.Bytes())
    if msg == "exit" {
        return
    }
    fmt.Fprintf (w, "You wrote %q\n", msg) // writing back the text
}

现在应用程序有了除kill命令之外的退出点。目前,除了exit命令之外,它并没有实现任何命令,而只是打印出您输入的任何内容。

选择器

为了能够正确解释命令,消息需要被分割成参数。这与操作系统应用于传递给进程的参数的逻辑相同。strings.Split函数通过指定空格作为第二个参数并将字符串分割成单词来实现这一点,如下面的代码所示:

args := strings.Split(string(s.Bytes()), " ")
cmd := args[0]
args = args[1:]

可以对cmd执行任何类型的检查,例如以下的switch语句:

switch cmd {
case "exit":
    return
case "someCommand":
    someCommand(w, args)
case "anotherCommand":
    anotherCommand(w, args)
}

这允许用户通过定义一个函数并在switch语句中添加一个新的case来添加新的命令。

命令执行

现在一切都准备就绪,唯一剩下的就是定义各种命令将实际执行的操作。我们可以定义执行命令的函数类型以及“switch”的行为:

var cmdFunc func(w io.Writer, args []string) (exit bool)
switch cmd {
case "exit":
    cmdFunc = exitCmd
}
if cmdFunc == nil {
    fmt.Fprintf(w, "%q not found\n", cmd)
    continue
}
if cmdFunc(w, args) { // execute and exit if true
    return
}

返回值告诉应用程序是否需要终止,并允许我们轻松定义我们的exit函数,而不需要它成为一个特殊情况:

func exitCmd(w io.Writer, args []string) bool {
    fmt.Fprintf(w, "Goodbye! :)")
    return true
}

现在我们可以实现任何类型的命令,具体取决于我们应用程序的范围。让我们创建一个shuffle命令,它将使用math/rand包以随机顺序打印参数:

func shuffle(w io.Writer, args ...string) bool {
    rand.Shuffle(len(args), func(i, j int) {
        args[i], args[j] = args[j], args[i]
    })
    for i := range args {
        if i > 0 {
            fmt.Fprint(w, " ")
        }
        fmt.Fprintf(w, "%s", args[i])
    }
    fmt.Fprintln(w)
    return false
}

我们可以通过创建一个“print”命令与文件系统和文件进行交互,该命令将在输出中显示文件的内容:

func print(w io.Writer, args ...string) bool {
    if len(args) != 1 {
        fmt.Fprintln(w, "Please specify one file!")
        return false
    }
    f, err := os.Open(args[0])
    if err != nil {
        fmt.Fprintf(w, "Cannot open %s: %s\n", args[0], err)
    }
    defer f.Close()
    if _, err := io.Copy(w, f); err != nil {
        fmt.Fprintf(w, "Cannot print %s: %s\n", args[0], err)
    }
    fmt.Fprintln(w)
    return false
}

一些重构

伪终端应用程序的当前版本可以通过一些重构来改进。我们可以通过将命令定义为自定义类型,并添加描述其行为的一些方法来开始:

type cmd struct {
    Name string // the command name
    Help string // a description string
    Action func(w io.Writer, args ...string) bool
}

func (c cmd) Match(s string) bool {
  return c.Name == s
}

func (c cmd) Run(w io.Writer, args ...string) bool {
  return c.Action(w, args...)
}

每个命令的所有信息都可以包含在一个结构中。我们还可以开始定义依赖其他命令的命令,比如帮助命令。如果我们在var cmds []cmd包中定义了一些命令的切片或映射,那么help命令将如下所示:

help := cmd{
    Name: "help",
    Help: "Shows available commands",
    Action: func(w io.Writer, args ...string) bool {
        fmt.Fprintln(w, "Available commands:")
        for _, c := range cmds {
            fmt.Fprintf(w, " - %-15s %s\n", c.Name, c.Help)
        }
        return false
    },
}

选择正确命令的主循环的部分将略有不同;它需要在切片中找到匹配项并执行它:

for i := range cmds {
    if !cmds[i].Match(args[0]) {
        continue
    }
    idx = i
    break
}
if idx == -1 {
    fmt.Fprintf(w, "%q not found. Use `help` for available commands\n", args[0])
    continue
}
if cmds[idx].Run(w, args[1:]...) {
    fmt.Fprintln(w)
    return
}

现在有一个help命令,显示了可用命令的列表,我们可以建议用户在每次指定不存在的命令时使用它——就像我们当前检查索引是否已从其默认值-1更改一样。

改进 PTY

现在我们已经看到如何创建一个基本的伪终端,我们将看到如何通过一些附加功能来改进它。

多行输入

可以改进的第一件事是参数和间距之间的关系,通过添加对带引号字符串的支持。这可以通过具有自定义分割函数的bufio.Scanner来实现,该函数的行为类似于bufio.ScanWords,除了它知道引号的存在。以下代码演示了这一点:

func ScanArgs(data []byte, atEOF bool) (advance int, token []byte, err error) {
    // first space
    start, first := 0, rune(0)
    for width := 0; start < len(data); start += width {
        first, width = utf8.DecodeRune(data[start:])
        if !unicode.IsSpace(first) {
            break
        }
    }
    // skip quote
    if isQuote(first) {
        start++
    }

该函数有一个跳过空格并找到第一个非空格字符的第一个块;如果该字符是引号,则跳过它。然后,它查找终止参数的第一个字符,对于普通参数是空格,对于其他参数是相应的引号:

    // loop until arg end character
    for width, i := 0, start; i < len(data); i += width {
        var r rune
        r, width = utf8.DecodeRune(data[i:])
        if ok := isQuote(first); !ok && unicode.IsSpace(r) || ok  
            && r == first {
                return i + width, data[start:i], nil
        }
    }

如果在引用上下文中达到文件结尾,则返回部分字符串;否则,不跳过引号并请求更多数据:

    // token from EOF
    if atEOF && len(data) > start {
        return len(data), data[start:], nil
    }
    if isQuote(first) {
        start--
    }
    return start, nil, nil
}

完整的示例可在以下链接找到:play.golang.org/p/CodJjcpzlLx

现在我们可以使用这个作为解析参数的行,同时使用如下定义的辅助结构argsScanner

type argsScanner []string

func (a *argsScanner) Reset() { *a = (*a)[0:0] }

func (a *argsScanner) Parse(r io.Reader) (extra string) {
    s := bufio.NewScanner(r)
    s.Split(ScanArgs)
    for s.Scan() {
        *a = append(*a, s.Text())
    }
    if len(*a) == 0 {
        return ""
    }
    lastArg := (*a)[len(*a)-1]
    if !isQuote(rune(lastArg[0])) {
        return ""
    }
    *a = (*a)[:len(*a)-1]
    return lastArg + "\n"
}

通过更改循环的工作方式,这个自定义切片将允许我们接收带引号和引号之间的新行的行:

func main() {
 s := bufio.NewScanner(os.Stdin)
 w := os.Stdout
 a := argsScanner{}
 b := bytes.Buffer{}
 for {
        // prompt message 
        a.Reset()
        b.Reset()
        for {
            s.Scan()
            b.Write(s.Bytes())
            extra := a.Parse(&b)
            if extra == "" {
                break
            }
            b.WriteString(extra)
        }
        // a contains the split arguments
    }
}

为伪终端提供颜色支持

伪终端可以通过提供彩色输出来改进。我们已经看到,在 Unix 中有可以改变背景和前景颜色的转义序列。让我们首先定义一个自定义类型:

type color int

func (c color) Start(w io.Writer) {
    fmt.Fprintf(w, "\x1b[%dm", c)
}

func (c color) End(w io.Writer) {
    fmt.Fprintf(w, "\x1b[%dm", Reset)
}

func (c color) Sprintf(w io.Writer, format string, args ...interface{}) {
    c.Start(w)
    fmt.Fprintf(w, format, args...)
    c.End(w)
}

// List of colors
const (
    Reset color = 0
    Red color = 31
    Green color = 32
    Yellow color = 33
    Blue color = 34
    Magenta color = 35
    Cyan color = 36
    White color = 37
)

这种新类型可以用于增强具有彩色输出的命令。例如,让我们使用交替颜色来区分字符串,现在我们支持带有空格的参数的shuffle命令:

func shuffle(w io.Writer, args ...string) bool {
    rand.Shuffle(len(args), func(i, j int) {
        args[i], args[j] = args[j], args[i]
    })
    for i := range args {
        if i > 0 {
            fmt.Fprint(w, " ")
        }
        var f func(w io.Writer, format string, args ...interface{})
        if i%2 == 0 {
            f = Red.Fprintf
        } else {
            f = Green.Fprintf
        }
        f(w, "%s", args[i])
    }
    fmt.Fprintln(w)
    return false
}

建议命令

当指定的命令不存在时,我们可以建议一些类似的命令。为了这样做,我们可以使用 Levenshtein 距离公式,通过计算从一个字符串到另一个字符串所需的删除、插入和替换来衡量字符串之间的相似性。

在下面的代码中,我们将使用agnivade/levenshtein包,这将通过go get命令获得:

go get github.com/agnivade/levenshtein/...

然后,我们定义一个新函数,当现有命令没有匹配时调用:

func commandNotFound(w io.Writer, cmd string) {
    var list []string
    for _, c := range cmds {
        d := levenshtein.ComputeDistance(c.Name, cmd)
        if d < 3 {
            list = append(list, c.Name)
        }
    }
    fmt.Fprintf(w, "Command %q not found.", cmd)
    if len(list) == 0 {
        return
    }
    fmt.Fprint(w, " Maybe you meant: ")
    for i := range list {
        if i > 0 {
            fmt.Fprint(w, ", ")
        }
        fmt.Fprintf(w, "%s", list[i])
    }
}

可扩展命令

我们伪终端的当前限制是其可扩展性。如果需要添加新命令,需要直接添加到主包中。我们可以考虑一种方法,将命令与主包分离,并允许其他用户使用其命令扩展功能:

  1. 第一步是创建一个导出的命令。让我们使用一个接口来定义一个命令,以便用户可以实现自己的命令:
// Command represents a terminal command
type Command interface {
    GetName() string
    GetHelp() string
    Run(input io.Reader, output io.Writer, args ...string) (exit bool)
}
  1. 现在我们可以指定一系列命令和一个函数,让其他包添加其他命令:
// ErrDuplicateCommand is returned when two commands have the same name
var ErrDuplicateCommand = errors.New("Duplicate command")

var commands []Command

// Register adds the Command to the command list
func Register(command Command) error {
    name := command.GetName()
    for i, c := range commands {
        // unique commands in alphabetical order
        switch strings.Compare(c.GetName(), name) {
        case 0:
            return ErrDuplicateCommand
        case 1:
            commands = append(commands, nil)
            copy(commands[i+1:], commands[i:])
            commands[i] = command
            return nil
        case -1:
            continue
        }
    }
    commands = append(commands, command)
    return nil
}
  1. 我们可以提供一个命令的基本实现,以执行简单的功能:
// Base is a basic Command that runs a closure
type Base struct {
    Name, Help string
    Action func(input io.Reader, output io.Writer, args ...string) bool
}

func (b Base) String() string { return b.Name }

// GetName returns the Name
func (b Base) GetName() string { return b.Name }

// GetHelp returns the Help
func (b Base) GetHelp() string { return b.Help }

// Run calls the closure
func (b Base) Run(input io.Reader, output io.Writer, args ...string) bool {
    return b.Action(input, output, args...)
}
  1. 我们可以提供一个函数,将命令与名称匹配:
// GetCommand returns the command with the given name
func GetCommand(name string) Command {
    for _, c := range commands {
        if c.GetName() == name {
            return c
        }
    }
    return suggest
}
  1. 我们可以使用前面示例中的逻辑,使此函数返回建议的命令,其定义如下:
var suggest = Base{
    Action: func(in io.Reader, w io.Writer, args ...string) bool {
        var list []string
        for _, c := range commands {
            name := c.GetName()
            d := levenshtein.ComputeDistance(name, args[0])
            if d < 3 {
                list = append(list, name)
            }
        }
        fmt.Fprintf(w, "Command %q not found.", args[0])
        if len(list) == 0 {
            return false
        }
        fmt.Fprint(w, " Maybe you meant: ")
        for i := range list {
            if i > 0 {
                fmt.Fprint(w, ", ")
            }
            fmt.Fprintf(w, "%s", list[i])
        }
        return false
    },
}
  1. 现在我们可以在exithelp包中注册一些命令。只有help可以在这里定义,因为命令列表是私有的:
func init() {
    Register(Base{Name: "help", Help: "...", Action: helpAction})
    Register(Base{Name: "exit", Help: "...", Action: exitAction})
}

func helpAction(in io.Reader, w io.Writer, args ...string) bool {
    fmt.Fprintln(w, "Available commands:")
    for _, c := range commands {
        n := c.GetName()
        fmt.Fprintf(w, " - %-15s %s\n", n, c.GetHelp())
    }
    return false
}

func exitAction(in io.Reader, w io.Writer, args ...string) bool {
    fmt.Fprintf(w, "Goodbye! :)\n")
    return true
}

这种方法将允许用户使用commandBase结构来创建一个简单的命令,或者嵌入它或使用自定义结构,如果他们的命令需要它(比如带有状态的命令):

// Embedded unnamed field (inherits method)
type MyCmd struct {
    Base
    MyField string
}

// custom implementation
type MyImpl struct{}

func (MyImpl) GetName() string { return "myimpl" }
func (MyImpl) GetHelp() string { return "help string"}
func (MyImpl) Run(input io.Reader, output io.Writer, args ...string) bool {
    // do something
    return true
}

MyCmd结构和MyImpl结构之间的区别在于一个可以用作另一个命令的装饰器,而第二个是不同的实现,因此它不能与另一个命令交互。

带状态的命令

到目前为止,我们已经创建了没有内部状态的命令。但是有些命令可以保持内部状态并相应地改变其行为。状态可以限制在会话本身,也可以跨多个会话共享。最明显的例子是终端中的命令历史,其中执行的所有命令都被存储并在会话之间保留。

易失状态

最容易实现的是一个不持久的状态,当应用程序退出时会丢失。我们所需要做的就是创建一个自定义数据结构,托管状态并满足命令接口。方法将属于类型的指针,否则它们将无法修改数据。

在下面的示例中,我们将创建一个非常基本的内存存储,它作为一个堆栈(先进后出)与参数一起工作。让我们从推送和弹出功能开始:

type Stack struct {
    data []string
}

func (s *Stack) push(values ...string) {
    s.data = append(s.data, values...)
}

func (s *Stack) pop() (string, bool) {
    if len(s.data) == 0 {
        return "", false
    }
    v := s.data[len(s.data)-1]
    s.data = s.data[:len(s.data)-1]
    return v, true
}

堆栈中存储的字符串表示命令的状态。现在,我们需要实现命令接口的方法——我们可以从最简单的开始:

func (s *Stack) GetName() string {
    return "stack"
}

func (s *Stack) GetHelp() string {
    return "a stack-like memory storage"
}

现在我们需要决定它在内部是如何工作的。将有两个子命令:

  • push,后跟一个或多个参数,将推送到堆栈。

  • pop将取出堆栈的顶部元素,不需要任何参数。

让我们定义一个辅助方法isValid,检查参数是否有效:

func (s *Stack) isValid(cmd string, args []string) bool {
    switch cmd {
    case "pop":
        return len(args) == 0
    case "push":
        return len(args) > 0
    default:
        return false
    }
}

现在,我们可以实现命令执行方法,它将使用有效性检查。如果通过了这一点,它将执行所选的命令或显示帮助消息:

func (s *Stack) Run(r io.Reader, w io.Writer, args ...string) (exit bool) {
    if l := len(args); l < 2 || !s.isValid(args[1], args[2:]) {
        fmt.Fprintf(w, "Use `stack push <something>` or `stack pop`\n")
        return false
    }
    if args[1] == "push" {
        s.push(args[2:]...)
        return false
    }
    if v, ok := s.pop(); !ok {
        fmt.Fprintf(w, "Empty!\n")
    } else {
        fmt.Fprintf(w, "Got: `%s`\n", v)
    }
    return false
}

持久状态

下一步是在会话之间持久化状态,这需要在应用程序启动时执行一些操作,并在应用程序结束时执行另一些操作。这些新行为可以与命令接口的一些更改集成:

type Command interface {
    Startup() error
    Shutdown() error
    GetName() string
    GetHelp() string
    Run(r io.Reader, w io.Writer, args ...string) (exit bool)
}

Startup()方法负责在应用程序启动时加载状态,Shutdown()方法需要在exit之前将当前状态保存到磁盘。我们可以使用这些方法更新Base结构;但是,这不会做任何事情,因为没有状态:

// Startup does nothing
func (b Base) Startup() error { return nil }

// Shutdown does nothing
func (b Base) Shutdown() error { return nil }

命令列表没有被导出;它是未导出的变量commands。我们可以添加两个函数,这些函数将与这样一个列表进行交互,并确保我们在所有可用的命令上执行这些方法,StartupShutdown

// Shutdown executes shutdown for all commands
func Shutdown(w io.Writer) {
    for _, c := range commands {
        if err := c.Shutdown(); err != nil {
            fmt.Fprintf(w, "%s: shutdown error: %s", c.GetName(), err)
        }
    }
}

// Startup executes Startup for all commands
func Startup(w io.Writer) {
    for _, c := range commands {
        if err := c.Startup(); err != nil {
            fmt.Fprintf(w, "%s: startup error: %s", c.GetName(), err)
        }
    }
}

最后一步是在主循环开始之前在主应用程序中使用这些函数:

func main() {
    s, w, a, b := bufio.NewScanner(os.Stdin), os.Stdout, args{}, bytes.Buffer{}
    command.Startup(w)
    defer command.Shutdown(w) // this is executed before returning
    fmt.Fprint(w, "** Welcome to PseudoTerm! **\nPlease enter a command.\n")
    for {
        // main loop
    }
}

升级 Stack 命令

我们希望之前定义的Stack命令能够在会话之间保存其状态。最简单的解决方案是将堆栈的内容保存为文本文件,每行一个元素。我们可以使用 OS/user 包将此文件对每个用户设置为唯一,并将其放置在用户的home目录中:

func (s *Stack) getPath() (string, error) {
    u, err := user.Current()
    if err != nil {
        return "", err
    }
    return filepath.Join(u.HomeDir, ".stack"), nil
}

让我们开始写作;我们将创建并截断文件(使用TRUNC标志将其大小设置为0),并写入以下行:

func (s *Stack) Shutdown(w io.Writer) error {
    path, err := s.getPath()
    if err != nil {
        return err
    }
    f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
    if err != nil {
        return err
    }
    defer f.Close()
    for _, v := range s.data {
        if _, err := fmt.Fprintln(f, v); err != nil {
            return err
        }
    }
    return nil
}

在关闭期间使用的方法将逐行读取文件,并将元素添加到堆栈中。我们可以使用bufio.Scanner,就像我们在之前的章节中看到的那样,轻松地做到这一点:

func (s *Stack) Startup(w io.Writer) error {
    path, err := s.getPath()
    if err != nil {
        return err
    }
    f, err := os.Open(path)
    if err != nil {
        if os.IsNotExist(err) {
            return nil
        }
        return err
    }
    defer f.Close()
    s.data = s.data[:0]
    scanner := bufio.NewScanner(f)
    for scanner.Scan() {
        s.push(string(scanner.Bytes()))
    }
    return nil
}

总结

在本章中,我们通过一些术语,以便理解为什么现代终端应用程序存在以及它们是如何发展的。

然后,我们专注于如何实现基本的伪终端。第一步是创建一个处理输入管理的循环,然后需要创建一个命令选择器,最后是一个执行器。选择器可以在包中选择一系列定义的函数,并且我们创建了一个特殊的命令来退出应用程序。通过一些重构,我们从函数转变为包含名称和操作的结构体。

我们看到了如何以各种方式改进应用程序。首先,我们创建了对多行输入的支持(使用自定义的分割函数来支持带引号的字符串,以及换行符)。然后,我们创建了一些工具来为我们的函数添加有色输出,并在之前定义的某个命令中使用它们。当用户指定一个不存在的命令时,我们还使用 Levenshtein 距离来建议类似的命令。

最后,我们将命令与主应用程序分离,并创建了一种从外部注册新命令的方式。我们使用了接口,因为这允许更好的扩展和定制,以及接口的基本实现。

在下一章中,我们将开始讨论进程属性和子进程。

问题

  1. 什么是终端,什么是伪终端?

  2. 伪终端应该能够做什么?

  3. 我们使用了哪些 Go 工具来模拟终端?

  4. 我的应用程序如何从标准输入获取指令?

  5. 使用接口来实现命令有什么优势?

  6. Levenshtein 距离是什么?为什么在伪终端中有用?

第三部分:理解进程通信

本节探讨了各种进程如何相互通信。它解释了如何在 Go 中使用基于 Unix 的管道通信,如何在应用程序内部处理信号,以及如何有效地使用网络进行通信。最后,它展示了如何对数据进行编码以提高通信速度。

本节包括以下章节:

  • 第七章,处理进程和守护进程

  • 第八章,退出代码、信号和管道

  • 第九章,网络编程

  • 第十章,使用 Go 进行数据编码

第七章:处理进程和守护进程

本章将介绍如何使用 Go 标准库处理当前进程的属性,以及如何更改它们。我们还将重点介绍如何创建子进程,并概述os/exec包。

最后,我们将解释守护进程是什么,它们具有什么属性,以及如何使用标准库创建它们。

本章将涵盖以下主题:

  • 理解进程

  • 子进程

  • 从守护进程开始

  • 创建服务

技术要求

本章需要安装 Go 并设置您喜欢的编辑器。有关更多信息,您可以参考第三章,Go 概述

理解进程

我们已经看到了 Unix 操作系统中进程的重要性,现在我们将看看如何获取有关当前进程的信息以及如何创建和处理子进程。

当前进程

Go 标准库允许我们获取有关当前进程的信息。这是通过使用os包中提供的一系列函数来完成的。

标准输入

程序可能想要知道的第一件事是它的标识符和父标识符,即 PID 和 PPID。这实际上非常简单 - os.Getpid()os.Getppid()函数都返回一个整数值,其中包含这两个标识符,如下面的代码所示:

package main

import (
    "fmt"
    "os"
)

func main() {
    fmt.Println("Current PID:", os.Getpid())
    fmt.Println("Current Parent PID:", os.Getppid())
}

完整示例可在play.golang.org/p/ng0m9y4LcD5找到。

用户和组 ID

另一个有用的信息是当前用户和进程所属的组。一个典型的用例可能是将它们与特定文件的权限进行比较。

os包提供以下功能:

  • os.Getuid(): 返回进程所有者的用户 ID

  • os.Getgid(): 返回进程所有者的组 ID

  • os.Getgroups(): 返回进程所有者的附加组 ID

我们可以看到这三个函数返回它们的数字形式的 ID:

package main

import (
    "fmt"
    "os"
)

func main() {
    fmt.Println("User ID:", os.Getuid())
    fmt.Println("Group ID:", os.Getgid())
    groups, err := os.Getgroups()
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Println("Group IDs:", groups)
}

完整示例可在play.golang.org/p/EqmonEEc_ZI找到。

为了获取用户和组的名称,os/user包中有一些辅助函数。这些函数(名称相当自明)如下:

  • func LookupGroupId(gid string) (*Group, error)

  • func LookupId(uid string) (*User, error)

即使用户 ID 是一个整数,它需要一个字符串作为参数,因此需要进行转换。最简单的方法是使用strconv包,它提供了一系列实用程序,用于将字符串转换为其他基本数据类型,反之亦然。

我们可以在以下示例中看到它们的作用:

package main

import (
    "fmt"
    "os"
    "os/user"
    "strconv"
)

func main() {
    uid := os.Getuid()
    u, err := user.LookupId(strconv.Itoa(uid))
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    fmt.Printf("User: %s (uid %d)\n", u.Username, uid)
    gid := os.Getgid()
    group, err := user.LookupGroupId(strconv.Itoa(gid))
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    fmt.Printf("Group: %s (uid %d)\n", group.Name, uid)
}

完整示例可在play.golang.org/p/C6EWF2c50DT找到。

工作目录

进程可以提供给我们的另一个非常有用的信息是工作目录,以便我们可以更改它。在第四章,与文件系统一起工作中,我们了解了可以使用的工具 - os.Getwdos.Chdir

在以下实际示例中,我们将看看如何使用这些函数来操作工作目录:

  1. 首先,我们将获取当前工作目录,并使用它获取二进制文件的路径。

  2. 然后,我们将工作目录与另一个路径连接起来,并使用它创建一个目录。

  3. 最后,我们将使用刚创建的目录的路径来更改当前工作目录。

查看以下代码:

// obtain working directory
wd, err := os.Getwd()
if err != nil {
    fmt.Println("Error:", err)
    return
}
fmt.Println("Working Directory:", wd)
fmt.Println("Application:", filepath.Join(wd, os.Args[0]))

// create a new directory
d := filepath.Join(wd, "test")
if err := os.Mkdir(d, 0755); err != nil {
    fmt.Println("Error:", err)
    return
}
fmt.Println("Created", d)

// change the current directory
if err := os.Chdir(d); err != nil {
    fmt.Println("Error:", err)
    return
}
fmt.Println("New Working Directory:", d)

完整示例可在play.golang.org/p/UXAer5nGBtm找到。

子进程

Go 应用程序可以与操作系统交互,创建其他进程。os的另一个子包提供了创建和运行新进程的功能。在os/exec包中,有一个Cmd类型,表示命令执行:

type Cmd struct {
    Path string // command to run.
    Args []string // command line arguments (including command)
    Env []string // environment of the process
    Dir string // working directory
    Stdin io.Reader // standard input`
    Stdout io.Writer // standard output
    Stderr io.Writer // standard error
    ExtraFiles []*os.File // additional open files
    SysProcAttr *syscall.SysProcAttr // os specific attributes
    Process *os.Process // underlying process
    ProcessState *os.ProcessState // information on exited processte
}

创建新命令的最简单方法是使用exec.Command函数,它接受可执行路径和一系列参数。让我们看一个简单的例子,使用echo命令和一些参数:

package main

import (
    "fmt"
    "os/exec"
)

func main() {
    cmd := exec.Command("echo", "A", "sample", "command")
    fmt.Println(cmd.Path, cmd.Args[1:]) // echo [A sample command]
}

完整的示例可在play.golang.org/p/dBIAUteJbxI找到。

一个非常重要的细节是标准输入、输出和错误的性质-它们都是我们已经熟悉的接口:

  • 输入是一个io.Reader,可以是bytes.Readerbytes.Bufferstrings.Readeros.File或任何其他实现。

  • 输出和错误都是io.Writer,也可以是os.Filebytes.Buffer,也可以是strings.Builder或任何其他的写入器实现。

根据父应用程序的需求,有不同的启动进程的方式:

  • Cmd.Run:执行命令,并返回一个错误,如果子进程正确执行,则为nil

  • Cmd.Start:异步执行命令,并让父进程继续其流程。为了等待子进程完成执行,还有另一种方法Cmd.Wait

  • Cmd.Output:执行命令并返回其标准输出,如果Stderr未定义但标准错误产生了输出,则返回错误。

  • Cmd.CombinedOutput:执行命令并返回标准错误和输出的组合,当需要检查或保存子进程的整个输出-标准输出加标准错误时非常有用。

访问子属性

一旦命令开始执行,同步或异步,底层的os.Process就会被填充,可以看到它的 PID,就像下面的例子中所示的那样:

package main

import (
    "fmt"
    "os/exec"
)

func main() {
    cmd := exec.Command("ls", "-l")
    if err := cmd.Start(); err != nil {
        fmt.Println(err)
        return
    }
    fmt.Println("Cmd: ", cmd.Args[0])
    fmt.Println("Args:", cmd.Args[1:])
    fmt.Println("PID: ", cmd.Process.Pid)
    cmd.Wait()
}

标准输入

标准输入可以用来从应用程序向子进程发送一些数据。可以使用缓冲区来存储数据,并让命令读取它,就像下面的例子中所示的那样:

package main

import (
    "bytes"
    "fmt"
    "os"
    "os/exec"
)

func main() {
    b := bytes.NewBuffer(nil)
    cmd := exec.Command("cat")
    cmd.Stdin = b
    cmd.Stdout = os.Stdout
    fmt.Fprintf(b, "Hello World! I'm using this memory address: %p", b)
    if err := cmd.Start(); err != nil {
        fmt.Println(err)
        return
    }
    cmd.Wait()
}

从守护进程开始

在 Unix 中,所有在后台运行的程序都被称为守护进程。它们通常以字母d结尾,比如sshdsyslogd,并提供操作系统的许多功能。

操作系统支持

在 macOS、Unix 和 Linux 中,如果一个进程在其父进程生命周期结束后仍然存在,那么它就是一个守护进程,这是因为父进程终止执行后,子进程的父进程会变成init进程,一个没有父进程的特殊守护进程,PID 为 1,它随着操作系统的启动和终止而启动和终止。在进一步讨论之前,让我们介绍两个非常重要的概念- 会话进程组

  • 进程组是一组共享信号处理的进程。该组的第一个进程称为组长。有一个 Unix 系统调用setpgid,可以改变进程的组,但有一些限制。进程可以在exec系统调用执行之前改变自己的进程组,或者改变其一个子进程的组。当进程组改变时,会话组也需要相应地改变,目标组的领导者也是如此。

  • 会话是一组进程组,允许我们对进程组和其他操作施加一系列限制。会话不允许进程组迁移到另一个会话,并且阻止进程在不同会话中创建进程组。setsid系统调用允许我们改变进程会话到一个新的会话,如果进程不是进程组领导者。此外,第一个进程组 ID 设置为会话 ID。如果这个 ID 与正在运行的进程的 ID 相同,那么该进程被称为会话领导者

现在我们已经解释了这两个属性,我们可以看看创建守护进程所需的标准操作,通常包括以下操作:

  • 清理环境以删除不必要的变量。

  • 创建一个 fork,以便主进程可以正常终止进程。

  • 使用setsid系统调用,完成以下三个步骤:

  1. 从 fork 的进程中删除 PPID,以便它被init进程接管

  2. 为 fork 创建一个新的会话,这将成为会话领导者

  3. 将进程设置为组领导者

  • fork 的当前目录设置为根目录,以避免使用其他目录,并且父进程打开的所有文件都被关闭(如果需要,子进程将打开它们)。

  • 将标准输入设置为/dev/null,并使用一些日志文件作为标准输出和错误。

  • 可选地,fork 可以再次 fork,然后退出。第一个 fork 将成为组领导者,第二个将具有相同的组,允许我们有另一个不是组领导者的 fork。

这对基于 Unix 的操作系统有效,尽管 Windows 也支持永久后台进程,称为服务。服务可以在启动时自动启动,也可以使用名为服务控制管理器SCM)的可视应用程序手动启动和停止。它们还可以通过常规提示中的sc命令以及 PowerShell 中的Start-ServiceStop-Service cmdlet 来进行控制。

守护进程的操作

现在我们了解了守护进程是什么以及它是如何工作的,我们可以尝试使用 Go 标准库来创建一个。Go 应用程序是多线程的,不允许直接调用fork系统调用。

我们已经学会了os/exec包中的Cmd.Start方法允许我们异步启动一个进程。第二步是使用release方法关闭当前进程的所有资源。

以下示例向我们展示了如何做到这一点:

package main

import (
    "fmt"
    "os"
    "os/exec"
    "time"
)

var pid = os.Getpid()

func main() {
    fmt.Printf("[%d] Start\n", pid)
    fmt.Printf("[%d] PPID: %d\n", pid, os.Getppid())
    defer fmt.Printf("[%d] Exit\n\n", pid)
    if len(os.Args) != 1 {
        runDaemon()
        return
    }
    if err := forkProcess(); err != nil {
        fmt.Printf("[%d] Fork error: %s\n", pid, err)
        return
    }
    if err := releaseResources(); err != nil {
        fmt.Printf("[%d] Release error: %s\n", pid, err)
        return
    }
}

让我们看看forkProcess函数的作用,创建另一个进程,并启动它:

  1. 首先,进程的工作目录被设置为根目录,并且输出和错误流被设置为标准流:
func forkProcess() error {
    cmd := exec.Command(os.Args[0], "daemon")
    cmd.Stdout, cmd.Stderr, cmd.Dir = os.Stdout, os.Stderr, "/"
    return cmd.Start()
}
  1. 然后,我们可以释放资源 - 首先,我们需要找到当前进程。然后,我们可以调用os.Process方法Release,以确保主进程释放其资源:
func releaseResources() error {
    p, err := os.FindProcess(pid)
    if err != nil {
        return err
    }
    return p.Release()
}
  1. main函数将包含守护逻辑,在这个例子中非常简单 - 它将每隔几秒打印正在运行的内容。
func runDaemon() {
    for {
        fmt.Printf("[%d] Daemon mode\n", pid)
        time.Sleep(time.Second * 10)
    }
}

服务

我们已经看到了从引导到操作系统关闭的第一个进程被称为initinit.d,因为它是一个守护进程。这个进程负责处理其他守护进程,并将其配置存储在/etc/init.d目录中。

每个 Linux 发行版都使用自己的守护进程控制过程版本,例如 Chrome OS 中的upstart或 Arch Linux 中的systemd。它们都有相同的目的并且行为类似。

每个守护进程都有一个控制脚本或应用程序,驻留在/etc/init.d中,并且应该能够解释一系列命令作为第一个参数,例如statusstartstoprestart。在大多数情况下,init.d文件是一个脚本,根据参数执行开关并相应地行为。

创建一个服务

一些应用程序能够自动处理它们的服务文件,这就是我们将逐步尝试实现的内容。让我们从一个init.d脚本开始:

#!/bin/sh

"/path/to/mydaemon" $1

这是一个将第一个参数传递给守护程序的示例脚本。二进制文件的路径将取决于文件的位置。这需要在运行时定义:

// ErrSudo is an error that suggest to execute the command as super user
// It will be used with the functions that fail because of permissions
var ErrSudo error

var (
    bin string
    cmd string
)

func init() {
    p, err := filepath.Abs(filepath.Dir(os.Args[0]))
    if err != nil {
        panic(err)
    }
    bin = p
    if len(os.Args) != 1 {
        cmd = os.Args[1]
    }
    ErrSudo = fmt.Errorf("try `sudo %s %s`", bin, cmd)
}

main函数将处理不同的命令,如下所示:

func main() {
    var err error
    switch cmd {
    case "run":
        err = runApp()
    case "install":
        err = installApp()
    case "uninstall":
        err = uninstallApp()
    case "status":
        err = statusApp()
    case "start":
        err = startApp()
    case "stop":
        err = stopApp()
    default:
        helpApp()
    }
    if err != nil {
        fmt.Println(cmd, "error:", err)
    }
}

我们如何确保我们的应用程序正在运行?一个非常可靠的策略是使用PID文件,这是一个包含正在运行进程的当前 PID 的文本文件。让我们定义一些辅助函数来实现这一点:

const (
    varDir = "/var/mydaemon/"
    pidFile = "mydaemon.pid"
)

func writePid(pid int) (err error) {
    f, err := os.OpenFile(filepath.Join(varDir, pidFile), os.O_CREATE|os.O_WRONLY, 0644)
    if err != nil {
        return err
    }
    defer f.Close()
    if _, err = fmt.Fprintf(f, "%d", pid); err != nil {
        return err
    }
    return nil
}

func getPid() (pid int, err error) {
    b, err := ioutil.ReadFile(filepath.Join(varDir, pidFile))
    if err != nil {
        return 0, err
    }
    if pid, err = strconv.Atoi(string(b)); err != nil {
        return 0, fmt.Errorf("Invalid PID value: %s", string(b))
    }
    return pid, nil
}

installuninstall函数将负责添加或删除位于/etc/init.d/mydaemon的服务文件,并要求我们以 root 权限启动应用程序,因为文件的位置:

const initdFile = "/etc/init.d/mydaemon"

func installApp() error {
    _, err := os.Stat(initdFile)
    if err == nil {
        return errors.New("Already installed")
    }
    f, err := os.OpenFile(initdFile, os.O_CREATE|os.O_WRONLY, 0755)
    if err != nil {
        if !os.IsPermission(err) {
            return err
        }
        return ErrSudo
    }
    defer f.Close()
    if _, err = fmt.Fprintf(f, "#!/bin/sh\n\"%s\" $1", bin); err != nil {
        return err
    }
    fmt.Println("Daemon", bin, "installed")
    return nil
}

func uninstallApp() error {
    _, err := os.Stat(initdFile)
    if err != nil && os.IsNotExist(err) {
        return errors.New("not installed")
    }
    if err = os.Remove(initdFile); err != nil {
        if err != nil {
            if !os.IsPermission(err) {
                return err
            }
       return ErrSudo
        }
    }
    fmt.Println("Daemon", bin, "removed")
    return err
}

创建文件后,我们可以使用mydaemon install命令将应用程序安装为服务,并使用mydaemon uninstall命令将其删除。

守护程序安装完成后,我们可以使用sudo service mydaemon [start|stop|status]来控制守护程序。现在,我们只需要实现这些操作:

  • status将查找pid文件,读取它,并向进程发送信号以检查它是否正在运行。

  • start将使用run命令运行应用程序,并写入pid文件。

  • stop将获取pid文件,找到进程,杀死它,然后删除pid文件。

让我们看看status命令是如何实现的。请注意,在 Unix 中不存在0信号,并且不会触发操作系统或应用程序的操作,但如果进程没有运行,操作将失败。这告诉我们进程是否存活:

func statusApp() (err error) {
    var pid int
    defer func() {
        if pid == 0 {
            fmt.Println("status: not active")
            return
        }
        fmt.Println("status: active - pid", pid)
    }()
    pid, err = getPid()
    if err != nil {
        if os.IsNotExist(err) {
            return nil
        }
        return err
    }
    p, err := os.FindProcess(pid)
    if err != nil {
        return nil
    }
    if err = p.Signal(syscall.Signal(0)); err != nil {
        fmt.Println(pid, "not found - removing PID file...")
        os.Remove(filepath.Join(varDir, pidFile))
        pid = 0
    }
    return nil
}

start命令中,我们将按照操作系统支持部分中介绍的步骤创建守护程序:

  1. 使用文件进行标准输出和输入

  2. 将工作目录设置为根目录

  3. 异步启动命令

除了这些操作,start命令还将进程的 PID 值保存在特定文件中,用于查看进程是否存活:

func startApp() (err error) {
    const perm = os.O_CREATE | os.O_APPEND | os.O_WRONLY
    if err = os.MkdirAll(varDir, 0755); err != nil {
        if !os.IsPermission(err) {
            return err
        }
        return ErrSudo
    }
    cmd := exec.Command(bin, "run")
    cmd.Stdout, err = os.OpenFile(filepath.Join(varDir, outFile),  
        perm, 0644)
            if err != nil {
                 return err
            }
    cmd.Stderr, err = os.OpenFile(filepath.Join(varDir, errFile), 
        perm, 0644)
            if err != nil {
                return err
           }
    cmd.Dir = "/"
    if err = cmd.Start(); err != nil {
        return err
    }
    if err := writePid(cmd.Process.Pid); err != nil {
        if err := cmd.Process.Kill(); err != nil {
            fmt.Println("Cannot kill process", cmd.Process.Pid, err)
        }
        return err
    }
    fmt.Println("Started with PID", cmd.Process.Pid)
    return nil
}

最后,stopApp将终止由 PID 文件标识的进程(如果存在):

func stopApp() (err error) {
    pid, err := getPid()
    if err != nil {
        if os.IsNotExist(err) {
            return nil
        }
        return err
    }
    p, err := os.FindProcess(pid)
    if err != nil {
        return nil
    }
    if err = p.Signal(os.Kill); err != nil {
        return err
    }
    if err := os.Remove(filepath.Join(varDir, pidFile)); err != nil {
        return err
    }
    fmt.Println("Stopped PID", pid)
    return nil
}

现在,应用程序控制所需的所有部分都已经准备就绪,唯一缺少的是主应用程序部分,它应该是一个循环,以便守护程序保持活动状态:

func runApp() error {
    fmt.Println("RUN")
    for {
        time.Sleep(time.Second)
    }
    return nil
}

在这个例子中,它只是在循环迭代之间固定时间睡眠。这通常是在主循环中一个好主意,因为一个空的for循环会无缘无故地使用大量资源。假设你的应用程序在for循环中检查某个条件。如果满足条件,不断检查这个条件会消耗大量资源。添加几毫秒的空闲睡眠可以帮助减少 90-95%的空闲 CPU 消耗,因此在设计守护程序时请记住这一点!

第三方包

到目前为止,我们已经看到了如何使用init.d服务从头开始实现守护程序。我们的实现非常简单和有限。它可以改进,但已经有许多包提供了相同的功能。它们支持不同的提供者,如init.dsystemd,其中一些还可以在 Windows 等非 Unix 操作系统上工作。

其中一个更有名的包(在 GitHub 上有 1000 多个星)是kardianos/service,它支持所有主要平台 - Linux、macOS 和 Windows。

它定义了一个表示守护程序的主接口,并具有两种方法 - 一种用于启动守护程序,另一种用于停止它。两者都是非阻塞的:

type Interface interface {
    // Start provides a place to initiate the service. The service doesn't not
    // signal a completed start until after this function returns, so the
    // Start function must not take more than a few seconds at most.
    Start(s Service) error

    // Stop provides a place to clean up program execution before it is terminated.
    // It should not take more than a few seconds to execute.
    // Stop should not call os.Exit directly in the function.
    Stop(s Service) error
}

该包已经提供了一些用例,从简单到更复杂的用例,在示例(github.com/kardianos/service/tree/master/example)目录中。最佳实践是使用主活动循环启动一个 goroutine。Start方法可用于打开和准备必要的资源,而Stop应该用于释放它们,以及其他延迟活动,如缓冲区刷新。

一些其他包只与 Unix 系统兼容,比如takama/daemongithub.com/takama/daemon),它的工作方式类似。它也提供了一些使用示例。

总结

在本章中,我们回顾了如何获取与当前进程相关的信息,如 PID 和 PPID,UID 和 GID,以及工作目录。然后,我们看到了os/exec包如何允许我们创建子进程,以及如何读取它们的属性,类似于当前进程。

接下来,我们看了一下守护程序是什么,以及各种操作系统如何支持它们。我们验证了使用os/execCmd.Run来执行一个超出其父进程生存期的进程是多么简单。

然后,我们通过 Unix 提供的自动化守护程序管理系统,逐步创建了一个能够通过service运行的应用程序。

在下一章中,我们将通过查看如何使用退出代码以及如何管理和发送信号来提高我们对子进程的控制。

问题

  1. Go 应用程序中有哪些关于当前进程的信息可用?

  2. 如何创建一个子进程?

  3. 如何确保子进程能够生存其父进程?

  4. 你能访问子属性吗?你如何使用它们?

  5. Linux 中的守护程序是什么,它们是如何处理的?

第八章:退出代码、信号和管道

本章将继续上一章,并演示父子进程之间的通信。特别是,本章将向您展示如何通过正确使用退出代码、自定义信号处理和连接进程与管道来管理通信。这些通信形式将用于使我们的应用程序能够有效地与操作系统和其他进程进行通信。

本章将涵盖以下主题:

  • 返回退出代码

  • 读取退出代码

  • 拦截信号

  • 发送信号

  • 使用管道

  • 使用其他流工具

技术要求

本章需要安装 Go 并设置您喜欢的编辑器。有关更多信息,您可以参考第三章,Go 概述

使用退出代码

退出代码,或退出状态,是进程在退出时传递给其父进程的一个小整数。这是通知您应用程序执行结果的最简单方式。在第二章,Unix 操作系统组件中,我们简要提到了退出代码。现在我们将学习如何在应用程序中使用它们以及如何解释子进程的退出代码。

发送退出代码

退出代码是进程在终止后通知其父进程其状态的方式。为了从当前进程返回任何退出状态,有一个函数可以直接完成工作:os.Exit

此函数接受一个参数,即整数,并表示将返回给父进程的退出代码。可以使用一个简单的程序进行验证,如下面的代码所示:

package main

import (
   "fmt"
    "os"
)

func main() {
    fmt.Println("Hello, playground")
    os.Exit(1)
}

完整示例可在play.golang.org/p/-6GIY7EaVD_V找到。

当应用程序成功执行时,使用退出代码0。任何其他退出代码都表示在执行过程中可能发生的某种错误。当主函数完成时,它返回0;当恐慌未被恢复时,它返回2

Bash 中的退出代码

每次在 shell 中执行命令时,生成的退出代码都会存储在一个变量中。执行的最后一个命令的状态存储在$?变量中,可以如下打印:

> echo  $? # will print 1

重要的是要注意,退出代码仅在使用go buildgo install获得的二进制文件运行时才有效。如果使用go run,则对于任何不是0的代码,它将返回1

退出值位大小

退出状态是一个 8 位整数;这意味着即使 Go 函数的参数是整数,返回的状态也将是传递值和256之间的模运算的结果。

让我们看看以下程序:

package main

import (
    "fmt"
    "os"
)

func main() {
    fmt.Println("Hello, playground")
    os.Exit(-1)
}

完整示例可在play.golang.org/p/vzwI1kDiGrP找到。

即使函数参数为-1,这将具有退出状态255,因为(-1)%256=255。这是因为退出代码是一个 8 位数字(0255)。

退出和延迟函数

关于此函数使用的一个重要注意事项是延迟函数不会被执行。

以下示例将没有输出:

package main

import (
    "fmt"
    "os"
)

func main() {
    defer fmt.Println("Hello, playground")
    os.Exit(0)
}

完整示例可在play.golang.org/p/2zbczc_ckgb找到。

恐慌和退出代码

如果应用程序因未恢复的恐慌而终止,则延迟函数将被执行,但退出代码将为2

package main

import (
    "fmt"
)

func main() {
    defer fmt.Println("Hello, playground")
    panic("panic")
}

完整示例可在play.golang.org/p/mjOMb0KsM3e找到。

退出代码和 goroutines

如果os.Exit函数发生在 goroutine 中,所有 goroutine(包括主 goroutine)将立即终止,而不执行任何延迟调用,如下所示:

package main

import (
    "fmt"
    "os"
    "time"
)

func main() {
    go func() {
        defer fmt.Println("go end (deferred)")
        fmt.Println("go start")
        os.Exit(1)
    }()
    fmt.Println("main end (deferred)")
    fmt.Println("main start")
    time.Sleep(time.Second)
    fmt.Println("main end")
}

完整的示例可在play.golang.org/p/JVEB5MTcEoa找到。

使用os.Exit时需要小心,因为所有延迟操作都不会被执行,这可能导致资源泄漏或错误,比如不刷新缓冲区和未将所有内容写入文件。

读取子进程退出码

我们在上一章中探讨了如何创建子进程。Go 使您可以轻松检查子进程的退出码,但这并不简单,因为exec.Cmd结构中有一个os.ProcessState属性的字段。

os.ProcessState属性有一个Sys方法,返回一个接口。在 Unix 中,它的值是一个syscall.WaitStatus结构,可以使用ExitCode方法访问退出码。下面的代码演示了这一点:

package main

import (
    "fmt"
    "os"
    "os/exec"
    "syscall"
)

func exitStatus(state *os.ProcessState) int {
    status, ok := state.Sys().(syscall.WaitStatus)
    if !ok {
        return -1
    }
    return status.ExitStatus()
}

func main() {
    cmd := exec.Command("ls", "__a__")
    if err := cmd.Run(); err != nil {
        if status := exitStatus(cmd.ProcessState); status == -1 {
            fmt.Println(err)
        } else {
            fmt.Println("Status:", status)
        }
    }
}

如果无法访问命令变量,则返回的错误是exec.ExitError,它包装了os.ProcessState属性,如下所示:

func processState(e error) *os.ProcessState {
    err, ok := e.(*exec.ExitError)
    if !ok {
        return nil
    }
    return err.ProcessState
}

我们可以看到获取退出码并不简单,需要进行一些类型转换。

处理信号

信号是 Unix 操作系统提供的另一种进程间通信工具。它们是可以从一个进程发送到另一个进程的整数值,使我们的应用程序能够与父进程以外的更多进程通信。通过这样做,应用程序能够解释传入的信号,并且还可以向其他进程发送信号。

处理传入信号

Go 应用程序的正常行为是处理一些传入信号,包括SIGHUPSIGINTSIGABRT,然后终止应用程序。我们可以用自定义行为替换这个标准行为,拦截所有或部分信号并相应地处理。

信号包

使用os/signal包可以实现自定义行为,该包公开了必要的函数。

例如,如果应用程序不需要拦截信号,signal.Ignore函数允许将信号添加到被忽略的列表中。signal.Ignored函数也允许验证某个信号是否被忽略。

为了使用通道拦截信号,可以使用核心函数signal.Notify。这使得可以指定一个通道,并选择应该发送到该通道的信号。然后应用程序可以在任何 goroutine 中使用该通道来处理具有自定义行为的信号。请注意,如果未指定信号,则该通道将接收发送到应用程序的所有信号,如下所示:

signal.Notify(ch, signalList...)

signal.Stop函数用于停止从特定通道接收信号,而signal.Reset函数停止拦截一个或多个信号到所有通道。为了重置所有信号,Reset不需要传递任何参数。

优雅关闭

应用程序在等待任务完成并清除所有资源后终止时执行优雅关闭。使用自定义信号处理是一个很好的实践,因为它给我们释放仍然打开的资源的时间。在关闭之前,我们可以执行任何其他应该在退出应用程序之前完成的任务;例如,保存当前状态。

现在我们知道退出码是如何工作的,我们可以介绍log包。从现在开始,将使用它来将语句打印到标准输出,而不是fmt。这使得可以执行Print语句和Fatal语句,后者相当于打印并执行os.Exit(1)log包还允许用户定义日志标志,以打印日期、时间和/或文件/行。

我们可以从一个非常基本的例子开始,处理所有信号如下:

package main

import (
    "log"
    "os"
    "os/signal"
    "syscall"
)

func main() {
    log.Println("Start application...")
    c := make(chan os.Signal)
    signal.Notify(c)
    s := <-c
    log.Println("Exit with signal:", s)
}

为了测试这个应用程序,您可以使用两个不同的终端。 首先,您可以在第一个终端中启动应用程序,并使用另一个终端执行ps命令来查找应用程序的 PID,以便使用kill命令向其发送信号。

第二种方法只使用一个终端,在后台启动应用程序。 这将在屏幕上显示 PID,并将在kill命令中使用,如下所示:

$ go build -o "signal" ch8/signal/base/base.go

$ ./signal &
[1] 265
[Log] Start application...

$ kill -6 265
[Log] Exit with signal: aborted

请注意,如果您使用的是 macOS,您将收到abort trap信号名称。

退出清理和资源释放

更实际和常见的干净关闭的例子是资源清理。 在使用退出语句时,延迟函数(例如bufio.Writer结构的Flush)不会被执行。 这可能会导致信息丢失,如下例所示:

package main

import (
    "bufio"
    "fmt"
    "log"
    "os"
    "time"
)

func main() {
    f, err := os.OpenFile("file.txt", os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
    if err != nil {
        log.Fatal(err)
    }
    defer f.Close()
    w := bufio.NewWriter(f)
    defer w.Flush()
    for i := 0; i < 3; i++ {
        fmt.Fprintln(w, "hello")
        log.Println(i)
        time.Sleep(time.Second)
    }
}

如果在应用程序完成之前向该应用程序发送了TERM信号,则文件将被创建和截断,但刷新将永远不会被执行,导致一个空文件。

这可能是预期的行为,但这很少发生。 最好在信号处理部分进行任何清理,如下例所示:

func main() {
    c := make(chan os.Signal, syscall.SIGTERM)
    signal.Notify(c)
    f, err := os.OpenFile("file.txt", os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
    if err != nil {
        log.Fatal(err)
    }
    defer f.Close()
    w := bufio.NewWriter(f)
    go func() {
        <-c
        w.Flush()
        os.Exit(0)
    }()
    for i := 0; i < 3; i++ {
        fmt.Fprintln(w, "hello")
        log.Println(i)
        time.Sleep(time.Second)
    }
}

在这种情况下,我们将使用 goroutine 与信号通道结合,以在退出之前刷新写入器。 这将确保将缓冲区中写入的任何内容持久保存到文件中。

配置重新加载

信号不仅可以用于终止应用程序。 应用程序可以对每个信号做出不同的反应,以便可以用于执行不同的功能,从而可以控制应用程序流程。

下一个示例将在文本文件中存储一些设置。 设置将以其字符串版本存储为time.Duration类型。 持续时间是一个int64值,其字符串版本以人类可读的格式存储,例如2m10s,它还具有许多有用的方法。 这在time包的不同函数中使用。

应用程序将以取决于当前设置值的频率执行某个操作。 信号的可能操作包括以下内容:

  • SIGHUP (1): 这会从设置文件中加载间隔。

  • SIGTERM (2): 这会保存当前的间隔值,并退出应用程序。

  • SIGQUIT (6): 这会退出而不保存。

  • SIGUSR1 (10): 这会将间隔加倍。

  • SIGUSR2 (11): 这会将间隔减半。

  • SIGALRM (14): 这会保存当前的间隔值。

使用signal.Notify函数捕获这些信号,该函数用于所有不同的信号。 从通道接收到的值需要一个条件语句,即类型开关,以允许应用程序根据值执行不同的操作:

func main() {
    c := make(chan os.Signal, 1)
    d := time.Second * 4
    signal.Notify(c,
        syscall.SIGHUP, syscall.SIGINT, syscall.SIGQUIT,
        syscall.SIGUSR1, syscall.SIGUSR2, syscall.SIGALRM)
    // initial load
    if err := handleSignal(syscall.SIGHUP, &d); err != nil && 
        !os.IsNotExist(err) {
            log.Fatal(err)
    }

    for {
        select {
        case s := <-c:
            if err := handleSignal(s, &d); err != nil {
                log.Printf("Error handling %s: %s", s, err)
                continue
            }
        default:
            time.Sleep(d)
            log.Println("After", d, "Executing action!")
        }
    }
}

handleSignal函数将包含信号中的switch语句:

func handleSignal(s os.Signal, d *time.Duration) error {
    switch s {
    case syscall.SIGHUP:
        return loadSettings(d)
    case syscall.SIGALRM:
        return saveSettings(d)
    case syscall.SIGINT:
        if err := saveSettings(d); err != nil {
            log.Println("Cannot save:", err)
            os.Exit(1)
        }
        fallthrough
    case syscall.SIGQUIT:
        os.Exit(0)
    case syscall.SIGUSR1:
        changeSettings(d, (*d)*2)
        return nil
    case syscall.SIGUSR2:
        changeSettings(d, (*d)/2)
        return nil
    }
    return nil
}

以下描述了将在信号处理函数中实现的不同行为:

  • 更改值只会使用持续指针来存储新值。

  • 加载将尝试扫描文件的内容(如果存在)作为持续时间并更改设置值。

  • 保存将持续时间写入文件,并使用其字符串格式。 以下代码描述了这一点:


func changeSettings(d *time.Duration, v time.Duration) {
    *d = v
    log.Println("Changed", v)
}

func loadSettings(d *time.Duration) error {
    b, err := ioutil.ReadFile(cfgPath)
    if err != nil {
        return err
    }
    var v time.Duration
    if v, err = time.ParseDuration(string(b)); err != nil {
        return err
    }
    *d = v
    log.Println("Loaded", v)
    return nil
}

func saveSettings(d *time.Duration) error {
    f, err := os.OpenFile(cfgPath,   
        os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
            if err != nil {
                return err
            }
        defer f.Close()

    if _, err = fmt.Fprint(f, d); err != nil {
        return err
    }
    log.Println("Saved", *d)
    return nil

我们将在init函数中获取用户主目录的路径,并将其用于组成settings文件的路径,如下所示:

var cfgPath string

func init() {
    u, err := user.Current()
    if err != nil {
        log.Fatalln("user:", err)
    }
    cfgPath = filepath.Join(u.HomeDir, ".multi")
}

我们可以在一个终端中启动应用程序,并使用另一个终端发送信号,如下所示:

终端 1 终端 2

|

$ go run ch08/signal/multi/multi.go
Loaded 1s
After 1s Executing action!

Changed 2s
After 2s Executing action!

Changed 4s
After 4s Executing action!

Changed 2s
After 2s Executing action!

Saved 1s

$

|

 $ kill -SIGUSR1 $(pgrep multi)

$ kill -SIGUSR1 $(pgrep multi)

$ kill -SIGUSR2 $(pgrep multi)

$ kill -SIGINT $(pgrep multi)

|

在左列中,我们可以看到应用程序的输出; 在右列中,我们可以看到我们启动的命令。 为了获取正在运行的应用程序的 PID,我们使用pgrep命令并嵌套在kill中。

向其他进程发送信号

在了解了如何处理传入信号的方式之后,让我们看看如何以编程方式向其他进程发送信号。os.Process结构是我们唯一需要的工具——其Signal方法使得向项目发送信号成为可能。就是这么简单!

较不简单的部分是获取进程。有两种用例,如下:

  • 进程是一个子进程,我们已经通过os.StartProcessexec.Command结构获得了进程值。

  • 进程已经存在,但我们没有它,因此需要使用其 PID 搜索它。

第一个用例更简单,因为我们已经将进程作为变量或作为exec.Cmd变量的属性,并且可以直接调用该方法。

另一个用例需要使用os.FindProcess方法通过 PID 搜索进程,如下:

p, err := os.FindProcess(pid)
if err != nil {
    panic(err)
}

一旦我们有了os.Process,我们可以使用其Signal方法向其发送特定信号,如下:

if err = p.Signal(syscall.SIGTERM); err != nil {
    panic(err)
}

我们将发送给进程的信号类型取决于目标进程和我们想要建议的行为,例如中断或终止。

连接流

在 Go 中,流是一种抽象,可以将任何类型的通信或数据流视为一系列读取器和写入器。我们已经学会了流是 Go 的重要组成部分。现在我们将学习如何使用我们已经了解的有关输入和输出的知识来控制与进程相关的流——输入、输出和错误。

管道

管道是连接输入和输出的同步方式之一,允许进程进行通信。

匿名管道

使用 shell 时,可以将不同的命令链接成一个序列,使一个命令的输出成为下一个命令的输入。例如,考虑以下命令:

cat book_list.txt | grep "Game" | wc -l

在这里,我们正在显示一个文件,使用前面的命令来过滤包含特定字符串的行,并最终使用过滤后的输出来计算行数。

在应用程序内创建进程时,可以在 Go 中以编程方式完成此操作。

io.Pipe函数返回一个连接的读取器/写入器对;写入管道写入的任何内容都将被管道读取器读取。写操作是阻塞的,这意味着所有写入的数据都必须在执行新的写操作之前被读取。

我们已经看到exec.Cmd允许其输出和输入使用通用流,这使我们可以使用io.Pipe函数返回的值将一个进程连接到另一个进程。

首先,我们定义三个命令,如下:

  • cat索引为0

  • grep索引为1

  • wc索引为2

然后,我们可以定义我们需要的两个管道,如下所示:

r1, w1 := io.Pipe()
r2, w2 := io.Pipe()

var cmds = []*exec.Cmd{
   exec.Command("cat", "book_list.txt"),
   exec.Command("grep", "Game"),
   exec.Command("wc", "-l"),
}

接下来,我们连接输入和输出流。我们连接cat(命令0)的输出和grep(命令1)的输入,然后对grep的输出和wc的输入进行相同的操作:

cmds[1].Stdin, cmds[0].Stdout = r1, w1
cmds[2].Stdin, cmds[1].Stdout = r2, w2
cmds[2].Stdout = os.Stdout

然后,我们启动我们的命令,如下:

for i := range cmds {
    if err := cmds[i].Start(); err != nil {
        log.Fatalln("Start", i, err)
    }
}

我们等到每个命令执行结束,然后关闭相应的管道写入器;否则,下一个命令的读取器将挂起。为了简化操作,每个管道写入器都是切片中的一个元素,并且每个写入器的索引与其链接的命令的索引相同。最后一个是nil,因为最后一个命令没有通过管道链接:

for i, closer := range []io.Closer{w1, w2, nil} {
    if err := cmds[i].Wait(); err != nil {
        log.Fatalln("Wait", i, err)
    }
    if closer == nil {
        continue
    }
    if err := closer.Close(); err != nil {
        log.Fatalln("Close", i, err)
    }
}

io包还提供了其他工具,可以帮助简化一些操作。

标准输入和输出管道

io.MultiWriter函数使得可以将相同的内容写入多个读取器。当需要自动将命令的输出广播到一系列不同的命令时,这将非常有用。

假设我们想要做之前做过的事情(即在文件中查找单词),但是要查找不同的单词。我们可以使用MultiWriter函数将输出复制到一系列grep命令,每个命令都将连接到自己的wc命令。

在本例中,我们将使用exec.Command的两个辅助方法:

  • Cmd.StdinPipe:这返回一个PipeWriter结构,将连接到命令的标准输入。

  • Cmd.StdoutPipe:这返回一个PipeReader结构,将连接到命令的标准输出。

让我们首先定义一个搜索项列表:一个用于命令的元组(grepwc),一个用于连接到第一个命令的写入器,一个用于每个命令链的最终输出:

var (
    words = []string{"Game", "Feast", "Dragons", "of"}
    cmds = make([][2]*exec.Cmd, len(words))
    writers = make([]io.Writer, len(words))
    buffers = make([]bytes.Buffer, len(words))
    err error
)

现在让我们定义命令及其连接——每个grep命令将在一侧使用MultiWriter函数与cat连接,并在另一侧连接到wc命令的输入:

for i := range words {
    cmds[i][0] = exec.Command("grep", words[i])
    if writers[i], err = cmds[i][0].StdinPipe(); err != nil {
        log.Fatal("in pipe", i, err)
    }
    cmds[i][1] = exec.Command("wc", "-l")
    if cmds[i][1].Stdin, err = cmds[i][0].StdoutPipe(); err != nil {
        log.Fatal("in pipe", i, err)
    }
    cmds[i][1].Stdout = &buffers[i]
}

cat := exec.Command("cat", "book_list.txt")
cat.Stdout = io.MultiWriter(writers...)

我们可以运行主要的cat命令,当它完成时,我们可以关闭第一组写入管道,这样grep命令就可以终止,如下所示:

for i := range cmds {
    if err := writers[i].(io.Closer).Close(); err != nil {
        log.Fatalln("close 0", i, err)
    }
}

for i := range cmds {
    if err := cmds[i][0].Wait(); err != nil {
        log.Fatalln("grep wait", i, err)
    }
}

然后我们可以等待另一个命令完成并显示结果,如下所示:

for i := range cmds {
    if err := cmds[i][1].Wait(); err != nil {
        log.Fatalln("wc wait", i, err)
    }
    count := bytes.TrimSpace(buffers[i].Bytes())
    log.Printf("%10q %s entries", cmds[i][0].Args[1], count)
}

请注意,当使用StdinPipe方法时,生成的写入器必须关闭,但使用StdoutPipe方法则不需要。

总结

在本章中,我们学习了如何使用三个主要功能处理进程之间的通信:退出代码、信号和管道。

退出代码是 0 到 255 之间的 8 位值,由进程返回给其父进程。退出代码为0表示应用程序执行成功。在 Go 中很容易返回退出代码,但使用os.Exit函数会忽略延迟函数的执行。当发生 panic 时,所有延迟函数都会执行,返回的代码是2。从子进程获取退出代码相对复杂,因为它取决于操作系统;然而,在 Unix 系统中,可以使用一系列类型断言来实现。

信号用于与任何进程进行通信。它们是 6 位值,介于 1 和 64 之间,通过系统调用从一个进程发送到另一个进程。可以使用通道和signal.Notify函数来接收信号。使用Process.Signal方法很容易发送信号。

管道是一组同步连接的输入和输出流。它们用于将一个进程的输入连接到另一个进程的输出。我们看到了如何连接多个命令,就像终端一样,并学习了如何使用io.MultiReader将一个命令的输出广播到多个命令。

在下一章中,我们将深入研究网络编程,从 TCP 一直到 HTTP 服务器。

问题

  1. 退出代码是什么?谁会使用它?

  2. 当应用程序发生 panic 时会发生什么?返回哪个退出代码?

  3. 当接收到所有信号时,Go 应用程序的默认行为是什么?

  4. 如何拦截信号并决定应用程序的行为?

  5. 你能向其他进程发送信号吗?如果可以,怎么做?

  6. 管道是什么,为什么重要?

第九章:网络编程

本章将涵盖网络编程。这将使我们的应用程序能够与在任何远程计算机上运行的其他程序通信,或者在同一本地网络上,甚至在互联网上。

我们将从网络和体系结构的一些理论开始。然后,我们将讨论套接字级通信,并解释如何创建 Web 服务器。最后,我们将讨论 Go 内置模板引擎的工作原理。

本章将涵盖以下主题:

  • 网络

  • 套接字编程

  • Web 服务器

  • 模板引擎

技术要求

本章需要安装 Go 并设置您喜欢的编辑器。有关更多信息,您可以参考第三章,Go 概述

此外,它需要在您的计算机上安装 OpenSSL。许多 Linux 发行版已经附带了一些 OpenSSL 版本。它也可以在 Windows 上安装,使用官方安装程序或第三方软件包管理器,如 Chocolatey 或 Scoop。

通过网络通信

即使应用程序位于同一台机器上,应用程序之间也可以通过网络进行通信。为了传输信息,它们需要建立一个共同的协议,该协议规定了从应用程序到传输介质的所有过程。

OSI 模型

开放系统互联OSI)模型是一个理论模型,可以追溯到 20 世纪 70 年代初。它定义了一种通信标准,无论网络的物理或技术结构如何,都可以提供不同网络的互操作性。

该模型定义了七个不同的层,从一到七编号,每一层的抽象级别都比前一层更高。前三层通常被称为媒体层,而后四层则是主机层。让我们在以下各节中逐一检查每一层。

第 1 层-物理层

OSI 模型的第一层是物理层,负责从设备传输未经处理的数据,类似于以太网端口,以及传输介质,如以太网电缆。该层定义了与连接的物理/材料性质相关的所有特征-连接器的大小、形状、电压、频率和时序。

物理层定义的另一个方面是传输的方向,可以是以下之一:

  • 单工:通信是单向的。

  • 半双工:通信是双向的,但通信只能单向进行。

  • 全双工:双向通信,两端可以同时通信。

许多知名技术,包括蓝牙和以太网,都包括它们正在使用的物理层的定义。

第 2 层-数据链路层

下一层是数据链路层,它定义了两个直接连接的节点之间的数据传输应该如何进行。它负责以下内容:

  • 检测第一层的通信错误

  • 纠正物理错误

  • 控制节点之间的流/传输速率

  • 连接终止

数据链路层定义的一些现实世界示例是以太网(802.3)和 Wi-Fi(802.11)。

第 3 层-网络层

网络层是下一层,它专注于称为数据包的数据序列,可以具有可变长度。数据包从一个节点传输到另一个节点,这两个节点可以位于同一网络上,也可以位于不同的网络上。

该层将网络定义为一系列连接到相同介质的节点,由前两层标识。网络能够传递消息,只知道其目的地地址。

第 4 层-传输层

第四层是传输层,确保数据包从发送方到接收方。这是通过目的地发送确认ACK)和否认确认NACK)消息来实现的,这些消息可以触发消息的重复,直到它们被正确接收。还有其他机制在起作用,例如将消息分割成块进行传输(分段),将部分重新组装成单个消息(去分段),并检查数据是否成功发送和接收(错误控制)。

OSI 模型规定了五种不同的传输协议 - TP0、TP1、TP2、TP3 和 TP4。TP0 是最简单的,只执行消息的分段和重组。其他类别在其基础上添加其他功能,例如重传或超时。

第五层 - 会话层

第五层引入了会话的概念,这是两台计算机之间临时交互信息的交换。它负责创建连接和终止连接(同时跟踪会话),并允许检查点和恢复。

第六层 - 表示层

倒数第二层是表示层,负责处理应用程序之间的语法和语义,通过处理复杂的数据表示。它允许最后一层独立于用于表示数据的编码。OSI 模型的表示使用 ASN.1 编码,但有许多不同的表示协议被广泛使用,例如 XML 和 JSON。

第七层 - 应用层

最后一层,应用层,是直接与应用程序通信的层。应用程序不被视为 OSI 模型的一部分,该层负责定义应用程序使用的接口。它包括 FTP、DNS 和 SMTP 等协议。

TCP/IP - 互联网协议套件

传输控制协议/互联网协议TCP/IP),或者互联网协议套件,是由比 OSI 模型更少层次组成的模型,被广泛采用。

第一层 - 链路层

第一层是链路层,是 OSI 的物理和数据链路的组合,它定义了本地网络通信的方式,指定了协议,例如 MAC(包括以太网和 Wi-Fi)。

第二层 - 互联网层

互联网层是第二层,可以与 OSI 的网络进行比较。它定义了一个通用接口,允许不同的网络在不了解彼此的底层拓扑的情况下有效地进行通信。该层负责局域网(LAN)中节点之间的通信,以及构成互联网的全球互联网络之间的通信。

第三层 - 传输层

第三层类似于第四层 OSI。它处理两个设备的端到端通信,还负责错误检查和恢复,使上层不了解数据的复杂性。它定义了两个主要协议 - TCP,通过使用确认系统允许接收方按正确顺序获取数据,以及用户数据协议(UDP),不对接收方应用错误控制或确认。

第四层 - 应用层

最后一层,应用层,总结了 OSI 的最后三个级别 - 会话、表示和应用。该层定义了应用程序使用的体系结构,例如点对点或客户端和服务器,以及应用程序使用的协议,例如 SSH、HTTP 或 SMTP。每个进程都是一个具有虚拟通信端点的地址,称为端口

理解套接字编程

Go 标准库允许我们轻松地与传输层进行交互,使用 TCP 和 UDP 连接。在本节中,我们将看看如何使用套接字公开服务,以及如何在另一个应用程序中查找并使用它。

网络包

创建和处理 TCP 连接所需的工具位于net包内。该包的主要接口是Conn,表示一个连接。

它有四种实现:

  • IPConn:使用 IP 协议的原始连接,TCP 和 UDP 连接都是基于它构建的

  • TCPConn:使用 TCP 协议的 IP 连接

  • UDPConn:使用 UDP 协议的 IP 连接

  • UnixConn:Unix 域套接字,连接用于同一台机器上的进程

在接下来的章节中,我们将看看如何不同地使用 TCP 和 UDP,以及如何使用 IPConn 来实现通信协议的自定义实现。

TCP 连接

TCP 是互联网上最常用的协议,它能够传递有序的数据(字节)。该协议的主要重点是可靠性,通过建立双向通信来实现,接收方在成功接收数据报时发送确认信号。

可以使用net.Dial函数创建新连接。这是一个通用函数,可以接受不同的网络,例如以下内容:

  • tcptcp4(仅限 IPv4),tcp6(仅限 IPv6)

  • udpudp4(仅限 IPv4),udp6(仅限 IPv6)

  • ipip4(仅限 IPv4),ip6(仅限 IPv6)

  • unix(套接字流),unixgram(套接字数据报),和unixpacket(套接字数据包)

可以创建 TCP 连接,指定tcp协议,以及主机和端口:

conn, err := net.Dial("tcp", "localhost:8080")

创建连接的更直接的方法是net.DialTCP,它允许您指定本地和远程地址。使用它需要创建一个net.TCPAddr

addr, err := net.ResolveTCPAddr("tcp", "localhost:8080")
if err != nil {
    // handle error
}
conn, err := net.DialTCP("tcp", nil, addr)
if err != nil {
    // handle error
}

为了接收和处理连接,还有另一个接口net.Listener,它有四种不同的实现方式,每种连接类型一个。对于连接,有一个通用的net.Listen函数和一个特定的net.ListenTCP函数。

我们可以尝试构建一个简单的应用程序,创建一个 TCP 监听器并连接到它,发送来自标准输入的任何内容。该应用程序应该创建一个监听器来启动后台连接,将标准输入发送到连接,然后接受并处理它。我们将使用换行符作为消息的分隔符。如下面的代码所示:

func main() {
    if len(os.Args) != 2 {
        log.Fatalln("Please specify an address.")
    }
    addr, err := net.ResolveTCPAddr("tcp", os.Args[1])
    if err != nil {
        log.Fatalln("Invalid address:", os.Args[1], err)
    }
    listener, err := net.ListenTCP("tcp", addr)
    if err != nil {
        log.Fatalln("Listener:", os.Args[1], err)
    }
    log.Println("<- Listening on", addr)

    go createConn(addr)

    conn, err := listener.AcceptTCP()
    if err != nil {
        log.Fatalln("<- Accept:", os.Args[1], err)
    }
    handleConn(conn)
}

连接创建非常简单。它创建连接并从标准输入读取消息,并通过写入将其转发到连接:

func createConn(addr *net.TCPAddr) {
    defer log.Println("-> Closing")
    conn, err := net.DialTCP("tcp", nil, addr)
    if err != nil {
        log.Fatalln("-> Connection:", err)
    }
    log.Println("-> Connection to", addr)
    r := bufio.NewReader(os.Stdin)
    for {
        fmt.Print("# ")
        msg, err := r.ReadBytes('\n')
        if err != nil {
            log.Println("-> Message error:", err)
        }
        if _, err := conn.Write(msg); err != nil {
            log.Println("-> Connection:", err)
            return
        }
    }
}

在我们的用例中,发送数据的连接将通过特殊消息\q关闭,这将被解释为一个命令。在监听器中接受连接会创建另一个连接,代表由拨号操作获得的连接。监听器创建的连接将接收来自拨号连接的消息并相应地执行。它将解释特殊消息,如\q,并执行特定操作;否则,它将只是在屏幕上打印消息,如下面的代码所示:

func handleConn(conn net.Conn) {
    r := bufio.NewReader(conn)
    time.Sleep(time.Second / 2)
    for {
        msg, err := r.ReadString('\n')
        if err != nil {
            log.Println("<- Message error:", err)
            continue
        }
        switch msg = strings.TrimSpace(msg); msg {
        case `\q`:
            log.Println("Exiting...")
            if err := conn.Close(); err != nil {
                log.Println("<- Close:", err)
            }
            time.Sleep(time.Second / 2)
            return
        case `\x`:
            log.Println("<- Special message `\\x` received!")
        default:
            log.Println("<- Message Received:", msg)
        }
    }
}

下面的代码示例在一个应用程序中创建了客户端和服务器,但它可以很容易地分成两个应用程序——一个服务器(能够同时处理多个连接)和一个客户端,创建到服务器的单个连接。服务器将具有一个Accept循环,处理单独的 goroutine 上接收的连接。handleConn函数与我们之前定义的相同:

func main() {
    if len(os.Args) != 2 {
        log.Fatalln("Please specify an address.")
    }
    addr, err := net.ResolveTCPAddr("tcp", os.Args[1])
    if err != nil {
        log.Fatalln("Invalid address:", os.Args[1], err)
    }
    listener, err := net.ListenTCP("tcp", addr)
    if err != nil {
        log.Fatalln("Listener:", os.Args[1], err)
    }
    for {
        time.Sleep(time.Millisecond * 100)
        conn, err := listener.AcceptTCP()
        if err != nil {
            log.Fatalln("<- Accept:", os.Args[1], err)
        }
        go handleConn(conn)
    }
}

客户端将创建连接并发送消息。createConn将与我们之前定义的相同:

func main() {
    if len(os.Args) != 2 {
        log.Fatalln("Please specify an address.")
    }
    addr, err := net.ResolveTCPAddr("tcp", os.Args[1])
    if err != nil {
        log.Fatalln("Invalid address:", os.Args[1], err)
    }
    createConn(addr)
}

在分离的客户端和服务器中,可以测试当客户端或服务器关闭连接时会发生什么。

UDP 连接

UDP 是另一种在互联网上广泛使用的协议。它专注于低延迟,这就是为什么它不像 TCP 那样可靠。它有许多应用,从在线游戏到媒体流媒体,再到互联网语音协议(VoIP)。在 UDP 中,如果一个数据包没有收到,它就会丢失,并且不会像在 TCP 中那样再次发送。想象一下 VoIP 通话,如果有连接问题,你将会丢失部分对话,但当你恢复时,你几乎可以实时地继续通信。对于这种类型的应用程序使用 TCP 可能会导致每个数据包丢失都会积累延迟,使得对话变得不可能。

在下面的示例中,我们将创建一个客户端和一个服务器应用程序。服务器将是一种回声,将从客户端接收到的消息发送回去,但它还将颠倒消息内容。

客户端将与 TCP 的客户端非常相似,但也有一些例外——它将使用net.ResolveUDPAddr函数来获取地址,并使用net.DialUDP来获取连接:

func main() {
    if len(os.Args) != 2 {
        log.Fatalln("Please specify an address.")
    }
    addr, err := net.ResolveUDPAddr("udp", os.Args[1])
    if err != nil {
        log.Fatalln("Invalid address:", os.Args[1], err)
    }
    conn, err := net.DialUDP("udp", nil, addr)
    if err != nil {
        log.Fatalln("-> Connection:", err)
    }
    log.Println("-> Connection to", addr)
    r := bufio.NewReader(os.Stdin)
    b := make([]byte, 1024)
    for {
        fmt.Print("# ")
        msg, err := r.ReadBytes('\n')
        if err != nil {
            log.Println("-> Message error:", err)
        }
        if _, err := conn.Write(msg); err != nil {
            log.Println("-> Connection:", err)
            return
        }
        n, err := conn.Read(b)
        if err != nil {
            log.Println("<- Receive error:", err)
        }
        msg = bytes.TrimSpace(b[:n])
        log.Printf("<- %q", msg)
    }
}

服务器将与 TCP 的服务器非常不同。主要区别在于,使用 TCP 时,我们有一个监听器来接受不同的连接,这些连接是分开处理的;与此同时,UDP 监听器是一个连接。它可以盲目地接收数据,或者使用ReceiveFrom方法,该方法还将返回接收者的地址。这可以在WriteTo方法中使用来进行回答,如下面的代码所示:

func main() {
    if len(os.Args) != 2 {
        log.Fatalln("Please specify an address.")
    }
    addr, err := net.ResolveUDPAddr("udp", os.Args[1])
    if err != nil {
        log.Fatalln("Invalid address:", os.Args[1], err)
    }
    conn, err := net.ListenUDP("udp", addr)
    if err != nil {
        log.Fatalln("Listener:", os.Args[1], err)
    }

    b := make([]byte, 1024)
    for {
        n, addr, err := conn.ReadFromUDP(b)
        if err != nil {
            log.Println("<-", addr, "Message error:", err)
            continue
        }
        msg := bytes.TrimSpace(b[:n])
        log.Printf("<- %q from %s", msg, addr)
        for i, l := 0, len(msg); i < l/2; i++ {
            msg[i], msg[l-1-i] = msg[l-1-i], msg[i]
        }
        msg = append(msg, '\n')
        if _, err := conn.WriteTo(b[:n], addr); err != nil {
            log.Println("->", addr, "Send error:", err)
        }
    }
}

编码和校验和

在客户端和服务器之间设置某种形式的编码是一个很好的做法,如果编码包括校验和以验证数据完整性,那就更好了。我们可以改进上一节的示例,使用既进行编码又进行校验和的自定义协议。让我们从定义编码函数开始,给定消息将返回以下字节序列:

函数 字节序列
前四个字节将遵循一个序列 2A 00 2A 00
两个字节将以小端序(最低有效字节在前)存储消息长度 08 00
四个字节用于数据校验和 00 00 00 00
紧随原始消息 0F 1D 3A FF ...
以相同的起始序列结尾 2A 00 2A 00

Checksum函数将通过对消息内容进行求和来计算,使用五个字节的小端序(最低有效字节在前),逐个添加任何剩余的字节,然后将求和的前四个字节作为小端序:

func Checksum(b []byte) []byte {
    var sum uint64
    for len(b) >= 5 {
        for i := range b[:5] {
            v := uint64(b[i])
            for j := 0; j < i; j++ {
                v = v * 256
            }
            sum += v
        }
        b = b[5:]
    }
    for _, v := range b {
        sum += uint64(v)
    }
    s := make([]byte, 8)
    binary.LittleEndian.PutUint64(s, sum)
    return s[:4]
}

现在,让我们创建一个函数,用来使用我们定义的协议封装消息:

var ErrLength = errors.New("message too long")

func CreateMessage(content []byte) ([]byte, error) {
    if len(content) > 65535 {
        return nil, ErrLength
    }
    data := make([]byte, 0, len(content)+14)
    data = append(data, Sequence...)
    data = append(data, byte(len(content)/256), byte(len(content)%256))
    data = append(data, Checksum(content)...)
    data = append(data, content...)
    data = append(data, Sequence...)
    return data, nil
}

我们还需要另一个函数,用来检查消息是否有效并提取其内容:

func MessageContent(b []byte) ([]byte, error) {
    n := len(b)
    if n < 14 {
        return nil, fmt.Errorf("Too short")
    }
    if open := b[:4]; !bytes.Equal(open, Sequence) {
        return nil, fmt.Errorf("Wrong opening sequence %x", open)
    }
    if length := int(b[4])*256 + int(b[5]); n-14 != length {
        return nil, fmt.Errorf("Wrong length: %d (expected %d)", length, n-14)
    }
    if close := b[n-4 : n]; !bytes.Equal(close, Sequence) {
        return nil, fmt.Errorf("Wrong closing sequence %x", close)
    }
    content := b[10 : n-4]
    if !bytes.Equal(Checksum(content), b[6:10]) {
        return nil, fmt.Errorf("Wrong checksum")
    }
    return content, nil
}

现在我们可以用它们来对消息进行编码和解码。例如,我们可以改进上一节中的 UDP 客户端和服务器,并在发送时进行编码:

// Send
data, err := common.CreateMessage(msg)
if err != nil {
    log.Println("->", addr, "Encode error:", err)
    continue
}
if _, err := conn.WriteTo(data, addr); err != nil {
    log.Println("->", addr, "Send error:", err)
}

我们还可以解码接收到的字节以提取内容:

//Receive
n, addr, err := conn.ReadFromUDP(b)
if err != nil {
    log.Println("<-", addr, "Message error:", err)
    continue
}
msg, err := common.MessageContent(b[:n])
if err != nil {
    log.Println("<-", addr, "Decode error:", err)
    continue
}
log.Printf("<- %q from %s", msg, addr)

为了验证我们收到的内容是否有效,我们使用了之前定义的MessageContent实用程序函数。这将检查头部、长度和校验和。它只会提取组成消息的字节。

Go 中的 Web 服务器

Go 语言最大和最成功的应用之一是创建 Web 服务器。在本节中,我们将看到 Web 服务器实际上是什么,HTTP 协议是如何工作的,以及如何使用标准库和第三方包来实现 Web 服务器应用程序。

Web 服务器

Web 服务器应用程序是一种可以使用 HTTP 协议(以及一些其他相关协议)在 TCP/IP 网络上提供内容的软件。有许多知名的 Web 服务器应用程序,如 Apache、NGINX 和 Microsoft IIS。常见的服务器使用情况包括以下几种:

  • 提供静态文件,如网站和相关资源:HTML 页面、图像、样式表和脚本。

  • 暴露 Web 应用程序:在服务器上运行的具有基于 HTML 的界面的应用程序,需要浏览器才能访问。

  • 暴露 Web API:不是由用户而是由其他应用程序使用的远程接口。有关更多详细信息,请参阅第一章,系统编程简介

HTTP 协议

HTTP 协议是 Web 服务器的基石。它的设计始于 1989 年。HTTP 的主要用途是请求和响应范式,其中客户端发送请求,服务器返回响应给客户端。

统一资源定位符URL)是 HTTP 请求的唯一标识符,其结构如下:

部分 示例
协议 http
:// ://
主机 www.website.com
路径 /path/to/some-resource
? ?
查询(可选) query=string&with=values

从上表中,我们可以得出以下结论:

  • 除了 HTTP 及其加密版本(HTTPS)之外,还有几种不同的协议,如文件传输协议FTP)及其安全对应协议,SSH 文件传输协议SFTP)。

  • 主机可以是实际 IP 或主机名。当选择主机名时,还有另一个参与者,即域名服务器DNS),它充当主机名和物理地址之间的电话簿。DNS 将主机名转换为 IP。

  • 路径是服务器中所需的资源,它总是绝对的。

  • 查询字符串是在问号后面添加到路径中的内容。它是一系列以key=value形式的键值对,它们由&符号分隔。

HTTP 是一种文本协议,它包含 URL 的一些元素和其他信息,如方法、标题和正文。

请求正文是发送到服务器的信息,如表单值或上传的文件。

标题是相对于请求的元数据,每行一个,以Key: Value; extra data形式。有一系列定义的具有特定功能的标题,如AuthorizationUser-AgentContent-Type

一些方法表示对资源执行的操作。这些是最常用的方法:

  • GET:所选资源的表示

  • HEAD:类似于GET,但没有任何响应体

  • POST:向服务器提交资源,通常是新资源

  • PUT:提交资源的新版本

  • DELETE:删除资源

  • PATCH:请求对资源进行特定更改

这是 HTTP 请求的样子:

POST /resource/ HTTP/1.1
User-Agent: Mozilla/4.0 (compatible; MSIE5.01; Windows NT)
Host: www.website.com
Content-Length: 1024
Accept-Language: en-us

the actual request content
that is optional

从上述代码中,我们可以看到以下内容:

  • 第一行是空格分隔的三元组:方法—路径—协议。

  • 每个标题后面都跟着一行。

  • 一个空行作为分隔符。

  • 可选的请求体。

对于每个请求,都有一个响应,其结构与 HTTP 请求非常相似。唯一不同的部分是包含不同空格分隔的三元组的第一行:HTTP 版本—状态码—原因。

状态码是代表请求结果的整数。有四个主要的状态类别:

  • 100:已接收信息/请求,并将进行进一步处理

  • 200:成功的请求;例如,OK 200Created 201

  • 300:重定向到另一个 URL,临时或永久

  • 400:客户端错误,如Not Found 404Conflict 409

  • 500:服务器端错误,如Internal Server Error 503

这是 HTTP 响应的样子:

HTTP/1.1 200 OK
Content-Length: 88
Content-Type: text/html

<html>
  <body>
    <h1>Sample Page</h1>
  </body>
</html>

HTTP/2 和 Go

最常用的 HTTP 版本是 HTTP/1.1,日期为 1997 年。2009 年,Google 启动了一个新项目,创建了一个更快的 HTTP/1.1 后继者,名为 SPDY。该协议最终成为现在的超文本传输协议的 2.0 版本,HTTP/2

它是以现有 Web 应用程序的工作方式构建的,但对于使用新协议的应用程序,包括更快的通信速度,有新功能。一些不同之处包括以下内容:

  • 它是二进制的(HTTP/1.1 是文本的)。

  • 它是完全多路复用的,并且可以使用一个 TCP 连接并行请求数据。

  • 它使用头部压缩来减少开销。

  • 服务器可以向客户端推送响应,而不是被客户端周期性地询问。

  • 它具有更快的协议协商——感谢应用层协议协商ALPN)扩展。

所有主要的现代浏览器都支持 HTTP/2。Go 1.6 版本包含了对 HTTP/2 的透明支持,1.8 版本引入了服务器向客户端推送响应的能力。

使用标准包

现在我们将看到如何在 Go 中使用标准包创建一个 Web 服务器。一切都包含在net/http包中,该包公开了一系列用于发出 HTTP 请求和创建 HTTP 服务器的函数。

发出 HTTP 请求

该包公开了一个http.Client类型,可用于发出请求。如果请求是简单的GETPOST,则有专用方法。该包还提供了一个同名的函数,但它只是DefaultClient实例的相应方法的简写。检查以下代码:

resp, err := http.Get("http://example.com/")
resp, err := client.Get("http://example.com/")
...
resp, err := http.Post("http://example.com/upload", "image/jpeg", &buf)
resp, err := client.Post("http://example.com/upload", "image/jpeg", &buf)
...
values := url.Values{"key": {"Value"}, "id": {"123"}}
resp, err := http.PostForm("http://example.com/form", values)
resp, err := client.PostForm("http://example.com/form", values)

对于任何其他类型的需求,Do方法允许我们执行特定的http.RequestNewRequest函数允许我们指定任何io.Reader

req, err := http.NewRequest("GET", "http://example.com", nil)
// ...
req.Header.Add("Content-Type", "text/html")
resp, err := client.Do(req)
// ...

http.Client有几个字段,其中许多是允许我们使用默认实现或自定义实现的接口。第一个是CookieJar,它允许客户端存储和重用 Web cookies。Cookie 是浏览器发送给客户端的数据,客户端可以发送回服务器以替换头部,例如身份验证。默认客户端不使用 cookie jar。另一个接口是RoundTripper,它只有一个方法RoundTrip,它获取一个请求并返回一个响应。如果未指定值,则使用DeafultTransport值,也可以用于组成RoundTripper的自定义实现。客户端返回的http.Response也有一个 body,它是io.ReadCloser,其关闭由应用程序负责。这就是为什么建议在获得响应后立即使用延迟的Close语句。在下面的示例中,我们将实现一个自定义传输,该传输记录请求的 URL 并在执行标准往返之前修改一个头部:

type logTripper struct {
    http.RoundTripper
}

func (l logTripper) RoundTrip(r *http.Request) (*http.Response,  
    error) {
        log.Println(r.URL)
        r.Header.Set("X-Log-Time", time.Now().String())
        return l.RoundTripper.RoundTrip(r)
}

我们将在一个客户端中使用这个传输来发出一个简单的请求:

func main() {
    client := http.Client{Transport: logTripper{http.DefaultTransport}}
    req, err := http.NewRequest("GET", "https://www.google.com/search?q=golang+net+http", nil)
    if err != nil {
        log.Fatal(err)
    }
    resp, err := client.Do(req)
    if err != nil {
        log.Fatal(err)
    }
    defer resp.Body.Close()
    log.Println("Status code:", resp.StatusCode)
}

创建一个简单的服务器

该包提供的另一个功能是服务器创建。该包的主要接口是Handle,它有一个方法ServeHTTP,使用请求来写入响应。它的最简单的实现是HandlerFunc,它是一个具有ServeHTTP相同签名的函数,并通过执行自身来实现Handler

ListenAndServe函数使用给定的地址和处理程序启动 HTTP 服务器。如果未指定处理程序,则使用DefaultServeMux变量。ServeMux是一种特殊类型的Handler,它管理对不同处理程序的执行,具体取决于所请求的 URL 路径。它有两种方法,HandleHandleFunc,允许用户指定路径和相应的处理程序。该包还提供了类似于我们为Client所见的通用处理程序函数,它们将调用默认ServerMux的同名方法。

在下面的示例中,我们将创建一个customHandler并创建一个带有一些端点的简单服务器,包括自定义端点:

type customHandler int

func (c *customHandler) ServeHTTP(w http.ResponseWriter, r  
    *http.Request) {
        fmt.Fprintf(w, "%d", *c)
        *c++
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/hello", func(w http.ResponseWriter, r 
        *http.Request) {
            fmt.Fprintf(w, "Hello!")
    })
    mux.HandleFunc("/bye", func(w http.ResponseWriter, r 
        *http.Request) {
            fmt.Fprintf(w, "Goodbye!")
    })
    mux.HandleFunc("/error", func(w http.ResponseWriter, r 
        *http.Request) {
            w.WriteHeader(http.StatusInternalServerError)
            fmt.Fprintf(w, "An error occurred!")
    })
    mux.Handle("/custom", new(customHandler))
    if err := http.ListenAndServe(":3000", mux); err != nil {
        log.Fatal(err)
    }
}

提供文件系统

Go 标准包允许我们轻松地在文件系统中为特定目录提供服务,使用net.FileServer函数,当给定net.FileSystem接口时,返回一个用于提供该目录的Handler。默认实现是net.Dir,它是一个表示系统中目录的自定义字符串。FileServer函数已经有了一个保护机制,防止我们使用相对路径(如../../../dir)访问提供服务的目录之外的目录。

以下是一个使用提供的目录作为文件服务的根目录的示例文件服务器:

func main() {
    if len(os.Args) != 2 {
        log.Fatalln("Please specify a directory")
    }
    s, err := os.Stat(os.Args[1])
    if err == nil && !s.IsDir() {
        err = errors.New("not a directory")
    }
    if err != nil {
        log.Fatalln("Invalid path:", err)
    }
    http.Handle("/", http.FileServer(http.Dir(os.Args[1])))
    if err := http.ListenAndServe(":3000", nil); err != nil {
        log.Fatal(err)
    }
}

通过路由和方法导航

使用的 HTTP 方法存储在Request.Method字段中。这个字段可以在处理程序内部使用,以便为每种支持的方法设置不同的行为:

switch r.Method {
case http.MethodGet:
    // GET implementation
case http.MethodPost:
    // POST implementation
default:
    http.NotFound(w, r)
}

http.Handler接口的优势在于我们可以定义自定义类型。这可以使代码更易读,并且可以概括这种特定于方法的行为:

type methodHandler map[string]http.Handler

func (m methodHandler) ServeHTTP(w http.ResponseWriter, r 
        *http.Request) {
            h, ok := m[strings.ToUpper(r.Method)]
            if !ok {
                http.NotFound(w, r)
                return
            }
    h.ServeHTTP(w, r)
}

这将使代码更易读,并且可以重复用于不同的路径:

func main() {
    http.HandleFunc("/path1", methodHandler{
        http.MethodGet: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            fmt.Fprint(w, "Showing record")
        }),
        http.MethodPost: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            fmt.Fprint(w, "Updated record")
        }),
    })
    if err := http.ListenAndServe(":3000", nil); err != nil {
        log.Fatal(err)
    }
}

多部分请求和文件

请求体是一个io.ReadCloser。这意味着关闭它是服务器的责任。对于文件上传,请求体不是文件的内容,而是通常是一个多部分请求,它在头部指定一个边界,并在体内使用它来将消息分成部分。

这是一个示例多部分消息:

MIME-Version: 1.0
Content-Type: multipart/mixed; boundary=xxxx

This part before boundary is ignored
--xxxx
Content-Type: text/plain

First part of the message. The next part is binary data encoded in base64
--xxxx
Content-Type: application/octet-stream
Content-Transfer-Encoding: base64

PGh0bWw+CiAgPGhlYWQ+CiAgPC9oZWFkPgogIDxib2R5PgogICAgPHA+VGhpcyBpcyB0aGUg
Ym9keSBvZiB0aGUgbWVzc2FnZS48L3A+CiAgPC9ib2R5Pgo8L2h0bWw+Cg==
--xxxx--

我们可以看到边界有两个破折号作为前缀,后面跟着一个换行符,最终边界也有两个破折号作为后缀。在下面的示例中,服务器将处理文件上传,使用一个小表单从浏览器发送请求。

让我们定义一些在处理程序中将使用的常量:

const (
    param = "file"
    endpoint = "/upload"
    content = `<html><body>` +
        `<form enctype="multipart/form-data" action="%s" method="POST">` +
        `<input type="file" name="%s"/><input type="submit" 
    value="Upload"/>` +
        `</form></html></body>`
)

现在,我们可以定义处理程序函数。第一部分应该在方法为GET时显示模板,因为它在POST上执行上传,并在其他情况下返回未找到状态:

mux.HandleFunc(endpoint, func(w http.ResponseWriter, r 
    *http.Request) {
        if r.Method == "GET" {
            fmt.Fprintf(w, content, endpoint, param)
            return
        } else if r.Method != "POST" {
            http.NotFound(w, r)
            return
        }

    path, err := upload(r)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    fmt.Fprintf(w, "Uploaded to %s", path)
})

upload函数将使用Request.FormFile方法返回文件及其元数据:

func upload(r *http.Request) (string, error) {
    f, h, err := r.FormFile(param)
    if err != nil {
        return "", err
    }
    defer f.Close()

    p := filepath.Join(os.TempDir(), h.Filename)
    fw, err := os.OpenFile(p, os.O_WRONLY|os.O_CREATE, 0666)
    if err != nil {
        return "", err
    }
    defer fw.Close()

    if _, err = io.Copy(fw, f); err != nil {
        return "", err
    }
    return p, nil
}

HTTPS

如果您希望您的 Web 服务器使用 HTTPS 而不是依赖外部应用程序(如 NGINX),如果您已经有有效的证书,您可以很容易地这样做。如果没有,您可以使用 OpenSSL 创建一个:

> openssl genrsa -out server.key 2048

> openssl req -new -x509 -sha256 -key server.key -out server.crt -days 3650

第一条命令生成私钥,而第二条命令创建了一个服务器所需的公共证书。第二条命令还需要大量的额外信息来创建证书,从国家名称到电子邮件地址。

一切准备就绪后,为了创建一个 HTTPS 服务器,需要用其安全对应物http.ListenAndServeTLS替换http.ListenAndServe函数:

func main() {
    http.HandleFunc("/hello", func(w http.ResponseWriter, r 
        *http.Request) {
            fmt.Fprint(w, "Hello!")
    })
    err := http.ListenAndServeTLS(":443", "server.crt", "server.key", nil)
    if err != nil {
        log.Fatal("ListenAndServe: ", err)
    }
}

第三方包

Go 开源社区开发了许多与net/http集成的包,实现了Handler接口,但提供了一组独特的功能,可以更轻松地开发 Web 服务器。

gorilla/mux

github.com/gorilla/mux包包含了Handler的另一个实现,增强了标准ServeMux的功能:

  • 更好的 URL 匹配到处理程序,使用 URL 中的任何元素,包括模式、方法、主机或查询值。

  • URL 元素,如主机、路径和查询键可以有占位符(也可以使用正则表达式)。

  • 通过子路由,可以分层定义路由,测试路径的一部分。

  • 处理程序也可以用作中间件,在主处理程序之前,用于所有路径或一部分路径。

让我们从使用其他路径元素进行匹配的示例开始:

r := mux.NewRouter()
// only local requests
r.Host("localhost:3000")
// only when some header is present
r.Headers("X-Requested-With", "XMLHttpRequest")
only when a query parameter is specified
r.Queries("access_key", "0x20")

变量是另一个非常有用的功能,它允许我们指定占位符,并使用辅助函数mux.Vars获取它们的值,如下例所示:

r := mux.NewRouter()
r.HandleFunc("/products/", ProductsHandler)
r.HandleFunc("/products/{key}/", ProductHandler)
r.HandleFunc("/products/{key}/details", ProductDetailsHandler)
...
// inside an handler
vars := mux.Vars(request)
key:= vars["key"]

Subrouter是另一个有用的函数,用于对相同前缀路由进行分组。这使我们可以将上一个代码简化为以下代码片段:

r := mux.NewRouter()
s := r.PathPrefix("/products").Subrouter()
s.HandleFunc("/", ProductsHandler)
s.HandleFunc("/{key}/", ProductHandler)
s.HandleFunc("/{key}/details", ProductDetailsHandler)

当与子路由结合使用时,中间件也非常有用,可以执行一些常见任务,如身份验证和验证:

r := mux.NewRouter()
pub := r.PathPrefix("/public").Subrouter()
pub.HandleFunc("/login", LoginHandler)
priv := r.PathPrefix("/private").Subrouter()
priv.Use(AuthHandler)
priv.HandleFunc("/profile", ProfileHandler)
priv.HandleFunc("/logout", LogoutHandler)

gin-gonic/gin

github.com/gin-gonic/gin包是另一个 Go Web 框架,它通过许多简写和辅助函数扩展了 Go HTTP 服务器的功能。其特点包括以下内容:

  • 速度:它的路由速度快,内存占用很小。

  • 中间件:它允许定义和使用中间处理程序,并完全控制它们的流程。

  • 无 Panic:它带有从 panic 中恢复的中间件。

  • 分组:它可以将具有相同前缀的路由分组在一起。

  • 错误:它管理和收集请求期间发生的错误。

  • 渲染:它默认带有大多数 Web 格式的渲染器(JSON、XML、HTML)。

该包的核心是gin.Engine,也是一个http.Handlergin.Default函数返回一个使用两个中间件的引擎——Logger,它打印每个接收到的 HTTP 请求的结果,以及Recovery,它从 panic 中恢复。另一个选项是使用gin.New函数,返回一个没有中间件的引擎。

它允许我们将处理程序绑定到单个 HTTP 方法,使用一系列引擎方法,这些方法以它们的 HTTP 对应物命名:

  • DELETE

  • GET

  • HEAD

  • OPTIONS

  • PATCH

  • POST

  • PUT

  • Any(适用于任何 HTTP 方法)

还有一个group方法,返回一个选定路径的路由分组,公开了所有前面的方法:

router := gin.Default()

router.GET("/resource", getResource)
router.POST("/resource", createResource)
router.PUT("/resource", updateResoure)
router.DELETE("/resource", deleteResource)
// with use grouping
g := router.Group("/resource")
g.GET("", getResource)
g.POST("", createResource)
g.PUT("", updateResoure)
g.DELETE("", deleteResource)

该框架中的处理程序具有不同的签名。它不是使用响应写入器和请求作为参数,而是使用gin.Context,这是一个包装了两者的结构,并提供了许多简写和实用工具。例如,该包提供了在 URL 中使用占位符的可能性,而上下文使这些参数可以被读取:

router := gin.Default()
router.GET("/hello/:name", func(c *gin.Context) {
    c.String(http.StatusOK, "Hello %s!", c.Param("name"))
})

我们还可以在示例中看到,上下文提供了一个String方法,使我们能够用一行代码编写 HTTP 状态和响应内容。

其他功能

Web 服务器还有其他功能。其中一些已经受到标准库的支持(如 HTTP/2 推送器),其他功能则可通过实验性包或第三方库获得(如 WebSockets)。

HTTP/2 推送器

我们已经讨论过,自 Go 1.8 版本以来,Golang 支持 HTTP/2 服务器端推送功能。让我们看看如何在应用程序中使用它。它的使用非常简单;如果请求可以转换为http.Pusher接口,它可以用于在主接口中推送额外的请求。在这个例子中,我们用它来并行加载 SVG 图像,以及页面:

func main() {
    const imgPath = "/image.svg"
    http.HandleFunc("/", func(w http.ResponseWriter, r 
        *http.Request) {
            pusher, ok := w.(http.Pusher)
            if ok {
                fmt.Println("Push /image")
                pusher.Push(imgPath, nil)
            }
        w.Header().Add("Content-Type", "text/html")
        fmt.Fprintf(w, `<html><body><img src="img/%s"/>`+
            `</body></html>`, imgPath)
    })
    http.HandleFunc(imgPath, func(w http.ResponseWriter, r 
        *http.Request) {
            w.Header().Add("Content-Type", "image/svg+xml")
            fmt.Fprint(w, `<?xml version="1.0" standalone="no"?>
<svg >
  <rect width="150" height="150" style="fill:blue"/>
</svg>`)
    })
    if err := http.ListenAndServe(":3000", nil); err != nil {
        fmt.Println(err)
    }
}

这将导致 HTTP/1 的两个单独请求,以及 HTTP/2 的一个单一请求,其中第二个请求是使用浏览器的推送功能获得的。

WebSockets 协议

HTTP 协议只实现单向通信,而 WebSocket 协议是客户端和服务器之间的全双工通信。Go 实验性库通过golang.org/x/net/websocket包提供了对 WebSocket 的支持,Gorilla 还有另一个实现,使用了自己的github.com/gorilla/websocket

第二个更加完整,它在github.com/olahol/melody包中使用,该包实现了一个简单的 WebSocket 通信框架。每个包都提供了 WebSocket 服务器和客户端对的不同工作示例。

从模板引擎开始

另一个非常强大的工具是 Go 模板引擎,可在text/template中使用。其功能在html/template包中得到复制和扩展,这构成了 Go Web 开发的另一个强大工具。

语法和基本用法

模板包使我们能够使用文本文件和数据结构将表示与数据分离。模板引擎定义了两个分隔符——左和右——用于表示数据评估的开启和关闭操作。默认的分隔符是{{}},模板只评估这些分隔符内包含的内容,其余部分保持不变。

通常绑定到模板的数据是一个结构或映射,并且可以在模板中的任何位置使用$变量访问。无论是映射还是结构,字段的访问方式始终相同,使用.Field语法。如果省略了美元符号,则该值将被引用为当前上下文,如果不在特殊语句中,例如循环,则为$。在这些例外之外,{{$.Field}}{{.Field}}语句是等效的。

模板中的流程由条件语句{{if}}和循环语句{{range}}控制,并且两者都以{{end}}语句结束。条件语句还提供了链式{{else if}}语句的可能性来指定另一个条件,类似于开关,并且{{else}}语句可以被视为开关的默认情况。{{else}}可以与range语句一起使用,当 range 的参数为nil或长度为零时执行。

创建、解析和执行模板

template.Template类型是一个或多个模板的收集器,并且可以以多种方式初始化。template.New函数创建一个具有给定名称的新空模板,可以用于调用使用字符串创建模板的Parse方法。考虑以下代码:

var data = struct {
    Question string
    Answer int
}{
    Question: "Answer to the Ultimate Question of Life, " +
        "the Universe, and Everything",
    Answer: 42,
}
tpl, err := template.New("question-answer").Parse(`
    <p>Question: {{.Question}}</p>
    <p>Answer: {{.Answer}}</p>
`)
if err != nil {
    log.Fatalln("Error:", err)
}
if err = tpl.Execute(os.Stdout, data); err != nil {
    log.Fatalln("Error:", err)
}

完整示例在此处可用:play.golang.org/p/k-t0Ns1b2Mv

模板也可以从文件系统中加载和解析,使用template.ParseFiles,它接受一个文件列表,以及template.ParseGlob,它使用glob Unix 命令语法来选择文件列表。让我们创建一个包含以下内容的模板文件:

<html>
    <body>
        <h1>{{.name}}</h1>
        <ul>
            <li>First appearance: {{.appearance}}</li>
            <li>Style: {{.style}}</li>
        </ul>
    </body>
</html>

我们可以使用这两个函数中的一个来加载并使用一些示例数据执行它:

func main() {
    tpl, err := template.ParseGlob("ch9/template/parse/*.html")
    if err != nil {
        log.Fatal("Error:", err)
    }
    data := map[string]string{
        "name": "Jin Kazama",
        "style": "Karate",
        "appearance": "Tekken 3",
    }
    if err := tpl.Execute(os.Stdout, data); err != nil {
        log.Fatal("Error:", err)
    }
}

当加载多个模板时,Execute方法将使用最后一个。如果需要选择特定模板,则还有另一种方法ExecuteTemplate,它还接收模板名称作为参数,以指定要使用的模板。

条件和循环

range语句可以以不同的方式使用——最简单的方式就是调用range,后面跟着要迭代的切片或映射。

或者,您可以指定值,或索引和值:

var a = []int{1, 2, 3, 4}
`{{ range . }} {{.}} {{ end }}` // simple
`{{ range $v := . }} {{$v}} {{ end }}` // value
`{{ range $i, $v := . }} {{$v}} {{ end }}` // index and value

在循环中,{{.}}变量假定为迭代中的当前元素的值。以下示例循环一个项目切片:

var data = []struct {
    Question, Answer string
}{{
    Question: "Answer to the Ultimate Question of Life, " +
        "the Universe, and Everything",
    Answer: "42",
}, {
    Question: "Who you gonna call?",
    Answer: "Ghostbusters",
}}
tpl, err := template.New("question-answer").Parse(`{{range .}}
Question: {{.Question}}
Answer: {{.Answer}}
{{end}}`)
if err != nil {
    log.Fatalln("Error:", err)
}
if err = tpl.Execute(os.Stdout, data); err != nil {
    log.Fatalln("Error:", err)
}

完整示例在此处可用:play.golang.org/p/MtU_d9CsFb-

下一个示例是条件语句的用例,也使用了lt函数:

var data = []struct {
    Name string
    Score int
}{
    {"Michelangelo", 30},
    {"Donatello", 50},
    {"Leonardo", 80},
    {"Raffaello", 100},
}
tpl, err := template.New("question-answer").Parse(`{{range .}}
{{.Name}} scored {{.Score}}. He did {{if lt .Score 50}}bad{{else if lt .Score 75}}okay{{else if lt .Score 90}}good{{else}}great{{end}}
{{end}}`)
if err != nil {
    log.Fatalln("Error:", err)
}
if err = tpl.Execute(os.Stdout, data); err != nil {
    log.Fatalln("Error:", err)
}

完整示例在此处可用:play.golang.org/p/eBKDcJ47rPU

我们将在下一节中更详细地探讨函数。

模板函数

函数是模板引擎的重要部分,有许多内置函数,例如比较(eqltgtlege)或逻辑(ANDORNOT)。函数通过它们的名称调用,后面跟着使用空格作为分隔符的参数。在前面的示例中使用的函数lt a b表示lt(a,b)。当函数嵌套更多时,需要用括号包裹函数和参数。例如,not lt a b语句表示X函数有三个参数,not(lt, a, b)。正确的版本是not (lt a b),它告诉模板需要先解决括号中的元素。

在创建模板时,可以使用Funcs方法为其分配自定义函数,并在模板中使用。这非常有用,正如我们在这个例子中看到的:

var data = struct {
    Name, Surname, Occupation, City string
}{
    "Bojack", "Horseman", "Actor", "Los Angeles",
}
tpl, err := template.New("question-answer").Funcs(template.FuncMap{
    "upper": func(s string) string { return strings.ToUpper(s) },
    "lower": func(s string) string { return strings.ToLower(s) },
}).Parse(`{{.Name}} {{.Surname}} - {{lower .Occupation}} from {{upper .City}}`)
if err != nil {
    log.Fatalln("Error:", err)
}
if err = tpl.Execute(os.Stdout, data); err != nil {
    log.Fatalln("Error:", err)
}

完整的示例在这里可用:play.golang.org/p/DdoKEOixDDB.

|运算符可用于将语句的输出链接到另一个语句的输入,类似于 Unix shell 中的情况。例如,{{"put" | printf "%s%s" "out" | printf "%q"}}语句将产生"output"

RPC 服务器

远程过程调用RPC)是一种使用 TCP 协议从另一个系统调用应用功能执行的方法。Go 语言原生支持 RPC 服务器。

定义一个服务

Go RPC 服务器允许我们注册任何 Go 类型及其方法。这将使用 RPC 协议公开方法,并使我们能够通过名称从远程客户端调用它们。让我们创建一个辅助函数来跟踪我们在阅读书籍时的进度:

// Book represents a book entry
type Book struct {
    ISBN string
    Title, Author string
    Year, Pages int
}

// ReadingList keeps tracks of books and pages read
type ReadingList struct {
    Books []Book
    Progress []int
}

首先,让我们定义一个名为bookIndex的小辅助方法,它使用书籍的标识符(ISBN)返回书籍的索引:

func (r *ReadingList) bookIndex(isbn string) int {
    for i := range r.Books {
        if isbn == r.Books[i].ISBN {
            return i
        }
    }
    return -1
}

现在,我们可以定义ReadingList将能够执行的操作。它应该能够添加和删除书籍:

// AddBook checks if the book is not present and adds it
func (r *ReadingList) AddBook(b Book) error {
    if b.ISBN == "" {
        return ErrISBN
    }
    if r.bookIndex(b.ISBN) != -1 {
        return ErrDuplicate
    }
    r.Books = append(r.Books, b)
    r.Progress = append(r.Progress, 0)
    return nil
}

// RemoveBook removes the book from list and forgets its progress
func (r *ReadingList) RemoveBook(isbn string) error {
    if isbn == "" {
        return ErrISBN
    }
    i := r.bookIndex(isbn)
    if i == -1 {
        return ErrMissing
    }
    // replace the deleted book with the last of the list
    r.Books[i] = r.Books[len(r.Books)-1]
    r.Progress[i] = r.Progress[len(r.Progress)-1]
    // shrink the list of 1 element to remove the duplicate
    r.Books = r.Books[:len(r.Books)-1]
    r.Progress = r.Progress[:len(r.Progress)-1]
    return nil
}

它还应该能够读取和修改书籍的进度:

// GetProgress returns the progress of a book
func (r *ReadingList) GetProgress(isbn string) (int, error) {
 if isbn == "" {
 return -1, ErrISBN
 }
 i := r.bookIndex(isbn)
 if i == -1 {
 return -1, ErrMissing
 }
 return r.Progress[i], nil
}

然后,SetProgress改变书的进度,如下所示:

func (r *ReadingList) SetProgress(isbn string, pages int) error {
 if isbn == "" {
 return ErrISBN
 }
 i := r.bookIndex(isbn)
 if i == -1 {
 return ErrMissing
 }
 if p := r.Books[i].Pages; pages > p {
 pages = p
 }
 r.Progress[i] = pages
 return nil
}

AdvanceProgress增加书的进度页数:


func (r *ReadingList) AdvanceProgress(isbn string, pages int) error {
    if isbn == "" {
        return ErrISBN
    }
    i := r.bookIndex(isbn)
    if i == -1 {
        return ErrMissing
    }
    if p := r.Books[i].Pages - r.Progress[i]; p < pages {
        pages = p
    }
    r.Progress[i] += pages
    return nil
}

我们在这些函数中使用的错误变量定义如下:

// List of errors
var (
    ErrISBN = fmt.Errorf("missing ISBN")
    ErrDuplicate = fmt.Errorf("duplicate book")
    ErrMissing = fmt.Errorf("missing book")
)

创建服务器

现在我们有了可以轻松创建 RPC 服务器的服务。但是,所使用的类型必须遵守一些规则,以使其方法可用:

  • 方法的类型和方法本身都是导出的。

  • 该方法有两个参数,都是导出的。

  • 第二个参数是一个指针。

  • 该方法返回一个错误。

该方法应该看起来像这样:func (t *T) Method(in T1, out *T2) error.

下一步是创建一个满足这些规则的ReadingList的包装器:

// ReadingService adapts ReadingList for RPC
type ReadingService struct {
    ReadingList
}

// sets the success pointer value from error
func setSuccess(err error, b *bool) error {
    *b = err == nil
    return err
}

我们可以重新定义书籍,使用Book添加和删除函数,这是一个导出类型和内置类型:

func (r *ReadingService) AddBook(b Book, success *bool) error {
    return setSuccess(r.ReadingList.AddBook(b), success)
}

func (r *ReadingService) RemoveBook(isbn string, success *bool) error {
    return setSuccess(r.ReadingList.RemoveBook(isbn), success)
}

对于进度,我们有两个输入(ISBN 和页数),因此我们必须定义一个包含两者的结构,因为输入必须是单个参数:

func (r *ReadingService) GetProgress(isbn string, pages *int) (err error) {
    *pages, err = r.ReadingList.GetProgress(isbn)
    return err
}

type Progress struct {
    ISBN string
    Pages int
}

func (r *ReadingService) SetProgress(p Progress, success *bool) error {
    return setSuccess(r.ReadingList.SetProgress(p.ISBN, p.Pages), success)
}

func (r *ReadingService) AdvanceProgress(p Progress, success *bool) error {
    return setSuccess(r.ReadingList.AdvanceProgress(p.ISBN, p.Pages), success)
}

定义的类型可以在 RPC 服务器中注册并使用,它将使用rpc.HandleHTTP来注册传入 RPC 消息的 HTTP 处理程序:

if len(os.Args) != 2 {
    log.Fatalln("Please specify an address.")
}
if err := rpc.Register(&common.ReadingService{}); err != nil {
    log.Fatalln(err)
}
rpc.HandleHTTP()

l, err := net.Listen("tcp", os.Args[1])
if err != nil {
    log.Fatalln(err)
}
log.Println("Server Started")
if err := http.Serve(l, nil); err != nil {
    log.Fatal(err)
}

创建客户端

可以使用 RPC 包的rpc.DialHTTP函数创建客户端,使用相同的主机端口来获取客户端:

if len(os.Args) != 2 {
    log.Fatalln("Please specify an address.")
}
client, err := rpc.DialHTTP("tcp", os.Args[1])
if err != nil {
    log.Fatalln(err)
}
defer client.Close()

然后,我们定义了一个我们将在示例中使用的书籍列表:

const hp = "H.P. Lovecraft"
var books = []common.Book{
    {ISBN: "1540335534", Author: hp, Title: "The Call of Cthulhu", Pages: 36},
    {ISBN: "1980722803", Author: hp, Title: "The Dunwich Horror ", Pages: 53},
    {ISBN: "197620299X", Author: hp, Title: "The Shadow Over Innsmouth", Pages: 40},
    {ISBN: "1540335534", Author: hp, Title: "The Case of Charles Dexter Ward", Pages: 176},
}

考虑到格式包会打印内置类型指针的地址,我们将定义一个辅助函数来显示指针的内容:

func callClient(client *rpc.Client, method string, in, out interface{}) {
    var r interface{}
    if err := client.Call(method, in, out); err != nil {
        out = err
    }
    switch v := out.(type) {
    case error:
        r = v
    case *int:
        r = *v
    case *bool:
        r = *v
    }
    log.Printf("%s: [%+v] -> %+v", method, in, r)
}

客户端以type.method的形式获取要执行的操作,因此我们将使用这样的函数:

callClient(client, "ReadingService.GetProgress", books[0].ISBN, new(int))
callClient(client, "ReadingService.AddBook", books[0], new(bool))
callClient(client, "ReadingService.AddBook", books[0], new(bool))
callClient(client, "ReadingService.GetProgress", books[0].ISBN, new(int))
callClient(client, "ReadingService.AddBook", books[1], new(bool))
callClient(client, "ReadingService.AddBook", books[2], new(bool))
callClient(client, "ReadingService.AddBook", books[3], new(bool))
callClient(client, "ReadingService.SetProgress", common.Progress{
    ISBN: books[3].ISBN,
    Pages: 10,
}, new(bool))
callClient(client, "ReadingService.GetProgress", books[3].ISBN, new(int))
callClient(client, "ReadingService.AdvanceProgress", common.Progress{
    ISBN: books[3].ISBN,
    Pages: 40,
}, new(bool))
callClient(client, "ReadingService.GetProgress", books[3].ISBN, new(int))

这将输出每个操作及其结果。

总结

在这一章中,我们研究了 Go 语言中如何处理网络连接。我们从一些网络标准开始。首先,我们讨论了 OSI 模型,然后是 TCP/IP。

然后,我们检查了网络包,并学习了如何使用它来创建和管理 TCP 连接。这包括处理特殊命令以及如何从服务器端终止连接。接下来,我们看到如何使用 UDP 做同样的事情,并且我们已经看到如何实现具有校验和控制的自定义编码。

然后,我们讨论了 HTTP 协议,解释了第一个版本的工作原理,然后谈到了 HTTP/2 的差异和改进。然后,我们学习了如何使用 Go 发出 HTTP 请求,然后是如何设置 Web 服务器。我们探讨了如何提供现有文件,如何将不同的操作关联到不同的 HTTP 方法,以及如何处理多部分请求和文件上传。我们轻松地设置了一个 HTTPS 服务器,然后学习了一些第三方库为 Web 服务器提供的优势。最后,我们演示了模板引擎在 Go 中的工作原理,以及如何轻松构建 RPC 客户端/服务器。

在下一章中,我们将介绍如何使用 JSON 和 XML 等主要数据交换格式,这些格式也可以用于创建 Web 服务器。

问题

  1. 使用通信模型有什么优势?

  2. TCP 连接和 UDP 连接之间有什么区别?

  3. 在发送请求时,谁关闭了请求体?

  4. 在服务器接收时,谁关闭了请求体?

第十章:使用 Go 进行数据编码

本章将向您展示如何使用更常见的编码来交换应用程序中的数据。编码是将数据转换的过程,当应用程序必须与另一个应用程序通信时可以使用它——使用相同的编码将允许两个程序相互理解。本章将解释如何处理基于文本的协议,如首先是 JSON,然后是如何使用二进制协议,如gob

本章将涵盖以下主题:

  • 使用基于文本的编码,如 JSON 和 XML

  • 学习二进制编码,如gobprotobuf

技术要求

本章需要安装 Go 并设置您喜欢的编辑器。有关更多信息,请参阅第三章,Go 概述

为了使用协议缓冲区,您需要安装protobuf库。有关说明,请访问github.com/golang/protobuf

理解基于文本的编码

最易读的数据序列化格式是基于文本的格式。在本节中,我们将分析一些最常用的基于文本的编码方式,如 CSV、JSON、XML 和 YAML。

CSV

逗号分隔值CSV)是一种以文本形式存储数据的编码类型。每一行都是表格条目,一行的值由一个特殊字符分隔,通常是逗号,因此称为 CSV。CSV 文件的每个记录必须具有相同的值计数,并且第一个记录可以用作标题来描述每个记录字段:

name,age,country

字符串值可以用引号引起来,以允许使用逗号。

解码值

Go 允许用户从任何io.Reader创建 CSV 读取器。可以使用Read方法逐个读取记录:

func main() {
    r := csv.NewReader(strings.NewReader("a,b,c\ne,f,g\n1,2,3"))
    for {
        r, err := r.Read()
        if err != nil {
            log.Fatal(err)
        }
        log.Println(r)
    }
}

前面代码的完整示例可在play.golang.org/p/wZgVzMqAN_K找到。

请注意,每条记录都是一个字符串切片,读取器期望每行的长度保持一致。如果一行的条目比第一行多或少,这将导致错误。还可以使用ReadAll一次读取所有记录。使用此方法的相同示例将如下所示:

func main() {
 r := csv.NewReader(strings.NewReader("a,b,c\ne,f,g\n1,2,3"))
 records, err := r.ReadAll()
 if err != nil {
 log.Fatal(err)
 }
 for _, r := range records {
 log.Println(r)
 }
}

前面代码的完整示例可在play.golang.org/p/RJ-wxBB5fs6找到。

编码值

可以使用任何io.Writer创建 CSV 写入器。生成的写入器将被缓冲,因此为了不丢失数据,需要调用其方法Flush:这将确保缓冲区被清空,并且所有内容都传输到写入器。

Write方法接收一个字符串切片并以 CSV 格式对其进行编码。让我们看看下面的示例中它是如何工作的:

func main() {
    const million = 1000000
    type Country struct {
        Code, Name string
        Population int
    }
    records := []Country{
        {Code: "IT", Name: "Italy", Population: 60 * million},
        {Code: "ES", Name: "Spain", Population: 46 * million},
        {Code: "JP", Name: "Japan", Population: 126 * million},
        {Code: "US", Name: "United States of America", Population: 327 * million},
    }
    w := csv.NewWriter(os.Stdout)
    defer w.Flush()
    for _, r := range records {
        if err := w.Write([]string{r.Code, r.Name, strconv.Itoa(r.Population)}); err != nil {
            fmt.Println("error:", err)
            os.Exit(1)
        }
    }
}

前面代码的完整示例可在play.golang.org/p/qwaz3xCJhQT找到。

正如读者所知,有一种方法可以一次写入多条记录。它被称为WriteAll,我们可以在下一个示例中看到它:

func main() {
    const million = 1000000
    type Country struct {
        Code, Name string
        Population int
    }
    records := []Country{
        {Code: "IT", Name: "Italy", Population: 60 * million},
        {Code: "ES", Name: "Spain", Population: 46 * million},
        {Code: "JP", Name: "Japan", Population: 126 * million},
        {Code: "US", Name: "United States of America", Population: 327 * million},
    }
    w := csv.NewWriter(os.Stdout)
    defer w.Flush()
    var ss = make([][]string, 0, len(records))
    for _, r := range records {
        ss = append(ss, []string{r.Code, r.Name, strconv.Itoa(r.Population)})
    }
    if err := w.WriteAll(ss); err != nil {
        fmt.Println("error:", err)
        os.Exit(1)
    }
}

前面代码的完整示例可在play.golang.org/p/lt_GBOLvUfk找到。

WriteWriteAll之间的主要区别是第二个操作使用更多资源,并且在调用之前需要将记录转换为字符串切片。

自定义选项

读取器和写入器都有一些选项,可以在创建后更改。两个结构共享Comma字段,该字段是用于分隔字段的字符。还属于仅写入器的另一个重要字段是FieldsPerRecord,它是一个整数,确定读取器应为每个记录期望多少个字段。

  • 如果大于0,它将是所需字段的数量。

  • 如果等于0,它将设置为第一条记录的字段数。

  • 如果为负,则将跳过对字段计数的所有检查,从而允许读取不一致的记录集。

让我们看一个实际的例子,一个不检查一致性并使用空格作为分隔符的读取器:

func main() {
    r := csv.NewReader(strings.NewReader("a b\ne f g\n1"))
    r.Comma = ' '
    r.FieldsPerRecord = -1
    records, err := r.ReadAll()
    if err != nil {
        log.Fatal(err)
    }
    for _, r := range records {
        log.Println(r)
    }
}

前面代码的完整示例可在play.golang.org/p/KPHXRW5OxXT找到。

JSON

JavaScript 对象表示法JSON)是一种轻量级的基于文本的数据交换格式。它的性质使人类能够轻松阅读和编写它,其小的开销使其非常适合基于 Web 的应用程序。

JSON 由两种主要类型的实体组成:

  • 名称/值对的集合:名称/值表示为对象、结构或字典在各种编程语言中。

  • 有序值列表:这些是集合或值的列表,通常表示为数组或列表。

对象用大括号括起来,每个键用冒号分隔,每个值用逗号分隔。列表用方括号括起来,元素用逗号分隔。这两种类型可以结合使用,因此列表也可以是值,对象可以是列表中的元素。在名称和值之外的空格、换行和制表符将被忽略,并用于缩进数据,使其更易于阅读。

取这个样本 JSON 对象:

{
    "name: "Randolph",
    "surname": "Carter",
    "job": "writer",
    "year_of_birth": 1873
}

它可以压缩成一行,去除缩进,因为当数据长度很重要时,这是一个很好的做法,比如在 Web 服务器或数据库中:

{"name:"Randolph","surname":"Carter","job":"writer","year_of_birth":1873}

在 Go 中,与 JSON 字典和列表相关联的默认类型是map[string]interface{}[]interface{}。这两种类型(非常通用)能够承载任何 JSON 数据结构。

字段标签

struct也可以承载特定的 JSON 数据;所有导出的键将具有与相应字段相同的名称。为了自定义键,Go 允许我们在结构中的字段声明后跟一个字符串,该字符串应包含有关字段的元数据。

这些标签采用冒号分隔的键/值形式。值是带引号的字符串,可以使用逗号(例如job,omitempty)添加附加信息。如果有多个标签,空格用于分隔它们。让我们看一个使用结构标签的实际例子:

type Character struct {
    Name        string `json:"name" tag:"foo"`
    Surname     string `json:"surname"`
    Job         string `json:"job,omitempty"`
    YearOfBirth int    `json:"year_of_birth,omitempty"`
}

此示例显示了如何为相同字段使用两个不同的标签(我们同时使用jsonfoo),并显示了如何指定特定的 JSON 键并引入omitempty标签,用于输出目的,以避免在字段具有零值时进行编组。

解码器

在 JSON 中解码数据有两种方式——第一种是使用字节片作为输入的json.Unmarshal函数,第二种是使用通用的io.Reader获取编码内容的json.Decoder类型。我们将在示例中使用后者,因为它将使我们能够使用诸如strings.Reader之类的结构。解码器的另一个优点是可以使用以下方法进行定制:

  • DisallowUnknownFields:如果发现接收数据结构中未知的字段,则解码将返回错误。

  • UseNumber:数字将存储为json.Number而不是float64

这是使用json.Decoder类型进行数据解码的实际示例:

r := strings.NewReader(`{
    "name":"Lavinia",
    "surname":"Whateley",
    "year_of_birth":1878
}`)
d := json.NewDecoder(r)
var c Character
if err := d.Decode(&c); err != nil {
    log.Fatalln(err)
}
log.Printf("%+v", c)

完整的示例在此处可用:play.golang.org/p/a-qt5Mk9E_J

编码器

数据编码以类似的方式工作,使用json.Marshal函数获取字节片和json.Encoder类型,该类型使用io.Writer。后者更适合于灵活性和定制的明显原因。它允许我们使用以下方法更改输出:

  • SetEscapeHTML:如果为 true,则指定是否应在 JSON 引用的字符串内部转义有问题的 HTML 字符。

  • SetIndent:这允许我们指定每行开头的前缀,以及用于缩进输出 JSON 的字符串。

以下示例使用 encore 将数据结构编组到标准输出,使用制表符进行缩进:

e := json.NewEncoder(os.Stdout)
e.SetIndent("", "\t")
c := Character{
    Name: "Charles Dexter",
    Surname: "Ward",
    YearOfBirth: 1902,
}
if err := e.Encode(c); err != nil {
    log.Fatalln(err)
}

这就是我们可以看到Job字段中omitempty标签的实用性。由于值是空字符串,因此跳过了它的编码。如果标签不存在,那么在姓氏之后会有"job":"",行。

编组器和解组器

通常使用反射包进行编码和解码,这是非常慢的。在诉诸它之前,编码器和解码器将检查数据类型是否实现了json.Marshallerjson.Unmarshaller接口,并使用相应的方法:

type Marshaler interface {
        MarshalJSON() ([]byte, error)
}

type Unmarshaler interface {
        UnmarshalJSON([]byte) error
}

实现此接口可以实现更快的编码和解码,并且可以执行其他类型的操作,否则不可能,例如读取或写入未导出字段;它还可以嵌入一些操作,比如对数据进行检查。

如果目标只是包装默认行为,则需要定义另一个具有相同数据结构的类型,以便它失去所有方法。否则,在方法内调用MarshalUnmarshal将导致递归调用,最终导致堆栈溢出。

在这个实际的例子中,我们正在定义一个自定义的Unmarshal方法,以在Job字段为空时设置默认值:

func (c *Character) UnmarshalJSON(b []byte) error {
    type C Character
    var v C
    if err := json.Unmarshal(b, &v); err != nil {
        return err
    }
    *c = Character(v)
    if c.Job == "" {
        c.Job = "unknown"
    } 
    return nil
}

完整示例在此处可用:play.golang.org/p/4BjFKiMiVHO

UnmarshalJSON方法需要一个指针接收器,因为它必须实际修改数据类型的值,但对于MarshalJSON方法,没有真正的需要,最好使用值接收器——除非数据类型在nil时应该执行不同的操作:

func (c Character) MarshalJSON() ([]byte, error) {
    type C Character
    v := C(c)
    if v.Job == "" {
        v.Job = "unknown"
    }
    return json.Marshal(v)
}

完整示例在此处可用:play.golang.org/p/Q-q-9y6v6u-

接口

当使用接口类型时,编码部分非常简单,因为应用程序知道接口中存储了哪种数据结构,并将继续进行编组。做相反的操作并不那么简单,因为应用程序接收到的是一个接口而不是数据结构,并且不知道该怎么做,因此最终什么也没做。

一种非常有效的策略(即使涉及一些样板文件)是使用具体类型的容器,这将允许我们在UnmarshalJSON方法中处理接口。让我们通过定义一个接口和一些不同的实现来创建一个快速示例:

type Fooer interface {
    Foo()
}

type A struct{ Field string }

func (a *A) Foo() {}

type B struct{ Field float64 }

func (b *B) Foo() {}

然后,我们定义一个包装接口并具有Type字段的类型:

type Wrapper struct {
    Type string
    Value Fooer
}

然后,在编码之前填充Type字段:

func (w Wrapper) MarshalJSON() ([]byte, error) {
    switch w.Value.(type) {
    case *A:
        w.Type = "A"
    case *B:
        w.Type = "B"
    default:
        return nil, fmt.Errorf("invalid type: %T", w.Value)
    }
    type W Wrapper
    return json.Marshal(W(w))
}

解码方法是更重要的:它使用json.RawMessage,这是一种用于延迟解码的特殊字节片类型。我们将首先从字符串字段中获取类型,并将值保留在原始格式中,以便使用正确的数据结构进行解码:

func (w *Wrapper) UnmarshalJSON(b []byte) error {
    var W struct {
        Type string
        Value json.RawMessage
    }
    if err := json.Unmarshal(b, &W); err != nil {
        return err
    }
    var value interface{}
    switch W.Type {
    case "A":
        value = new(A)
    case "B":
        value = new(B)
    default:
        return fmt.Errorf("invalid type: %s", W.Type)
    }
    if err := json.Unmarshal(W.Value, &value); err != nil {
        return err
    }
    w.Type, w.Value = W.Type, value.(Fooer)
    return nil
}

完整示例在此处可用:play.golang.org/p/GXMK_hC8Bpv

生成结构体

有一个非常有用的应用程序,当给定一个 JSON 字符串时,会自动尝试推断字段类型生成 Go 类型。您可以在此地址找到一个部署的:mholt.github.io/json-to-go/

它可以节省一些时间,大多数情况下,在简单转换后,数据结构已经是正确的。有时,它需要一些更改,比如数字类型,例如,如果您想要一个字段是float,但您的示例 JSON 是一个整数。

JSON 模式

JSON 模式是描述 JSON 数据并验证数据有效性的词汇。它可用于测试,也可用作文档。模式指定元素的类型,并可以对其值添加额外的检查。如果类型是数组,还可以指定每个元素的类型和详细信息。如果类型是对象,则描述其字段。让我们看一个我们在示例中使用的Character结构的 JSON 模式:

{
    "type": "object",
    "properties": {
        "name": { "type": "string" },
        "surname": { "type": "string" },
        "year_of_birth": { "type": "number"},
        "job": { "type": "string" }
    },
    "required": ["name", "surname"]
}

我们可以看到它指定了一个带有所有字段的对象,并指示哪些字段是必需的。有一些第三方 Go 包可以让我们非常容易地根据模式验证 JSON,例如github.com/xeipuuv/gojsonschema

XML

可扩展标记语言XML)是另一种广泛使用的数据编码格式。它像 JSON 一样既适合人类阅读又适合机器阅读,并且是由万维网联盟W3C)于 1996 年定义的。它专注于简单性,易用性和通用性,并且实际上被用作许多格式的基础,包括 RSS 或 XHTML。

结构

每个 XML 文件都以一个声明语句开始,该语句指定文件中使用的版本和编码,以及文件是否是独立的(使用的模式是内部的)。这是一个示例 XML 声明:

<?xml version="1.0" encoding="UTF-8"?>

声明后面跟着一个 XML 元素树,这些元素由以下形式的标签界定:

  • <tag>:开放标签,定义元素的开始

  • </tag>:关闭标签,定义元素的结束

  • <tag/>:自关闭标签,定义没有内容的元素

通常,元素是嵌套的,因此一个标签内部有其他标签:

<outer>
    <middle>
        <inner1>content</inner1>
        <inner2/>
    </middle>
</outer>

每个元素都可以以属性的形式具有附加信息,这些信息是在开放或自关闭标签内找到的以空格分隔的键/值对。键和值由等号分隔,并且值由双引号括起来。以下是具有属性的元素示例:

<tag attribute="value" another="something">content</tag>
<selfclosing a="1000" b="-1"/>

文档类型定义

文档类型定义DTD)是定义其他 XML 文档的结构和约束的 XML 文档。它可用于验证 XML 的有效性是否符合预期。XML 可以和应该指定自己的模式,以便简化验证过程。DTD 的元素如下:

  • 模式:这代表文档的根。

  • 复杂类型:它允许元素具有内容。

  • 序列:这指定了描述的序列中必须出现的子元素。

  • 元素:这代表一个 XML 元素。

  • 属性:这代表父标签的 XML 属性。

这是我们在本章中使用的Character结构的示例模式声明:

<?xml version="1.0" encoding="UTF-8" ?>
<xs:schema >
  <xs:element name="character">
    <xs:complexType>
      <xs:sequence>
        <xs:element name="name" type="xs:string" use="required"/>
        <xs:element name="surname" type="xs:string" use="required"/>
        <xs:element name="year_of_birth" type="xs:integer"/>
        <xs:element name="job" type="xs:string"/>
      </xs:sequence>
      <xs:attribute name="id" type="xs:string" use="required"/>
    </xs:complexType>
 </xs:element>
</xs:schema>

我们可以看到它是一个包含其他元素序列的复杂类型元素(字符)的模式。

解码和编码

就像我们已经看到的 JSON 一样,数据解码和编码可以通过两种不同的方式实现:通过使用xml.Unmarshalxml.Marshal提供或返回一个字节片,或者通过使用xml.Decoderxml.Encoder类型与io.Readerio.Writer一起使用。

我们可以通过将Character结构中的json标签替换为xml或简单地添加它们来实现:

type Character struct {
    Name        string `xml:"name"`
    Surname     string `xml:"surname"`
    Job         string `xml:"job,omitempty"`
    YearOfBirth int    `xml:"year_of_birth,omitempty"`
}

然后,我们使用xml.Decoder来解组数据:

r := strings.NewReader(`<?xml version="1.0" encoding="UTF-8"?>
<character>
 <name>Herbert</name>
 <surname>West</surname>
 <job>Scientist</job>
</character>
}`)
d := xml.NewDecoder(r)
var c Character
if err := d.Decode(&c); err != nil {
 log.Fatalln(err)
}
log.Printf("%+v", c)

完整示例可在此处找到:play.golang.org/p/esopq0SMhG_T

在编码时,xml包将从使用的数据类型中获取根节点的名称。如果数据结构有一个名为XMLName的字段,则相对的 XML struct标签将用于根节点。因此,数据结构变为以下形式:

type Character struct {
    XMLName     struct{} `xml:"character"`
    Name        string   `xml:"name"`
    Surname     string   `xml:"surname"`
    Job         string   `xml:"job,omitempty"`
    YearOfBirth int      `xml:"year_of_birth,omitempty"`
}

编码操作也非常简单:

e := xml.NewEncoder(os.Stdout)
e.Indent("", "\t")
c := Character{
    Name:        "Henry",
    Surname:     "Wentworth Akeley",
    Job:         "farmer",
    YearOfBirth: 1871,
}
if err := e.Encode(c); err != nil {
    log.Fatalln(err)
}

完整示例可在此处找到:play.golang.org/p/YgZzdPDoaLX

字段标签

根标签的名称可以使用数据结构中的XMLName字段进行更改。字段标签的一些其他特性可能非常有用:

  • 带有-的标记被省略。

  • 带有attr选项的标记成为父元素的属性。

  • 带有innerxml选项的标记被原样写入,对于懒惰解码很有用。

  • omitempty选项与 JSON 的工作方式相同;它不会为零值生成标记。

  • 标记可以包含 XML 中的路径,使用>作为分隔符,如a > b > c

  • 匿名结构字段被视为其值的字段在外部结构中的字段。

让我们看一个使用其中一些特性的实际示例:

type Character struct {
    XMLName     struct{} `xml:"character"`
    Name        string   `xml:"name"`
    Surname     string   `xml:"surname"`
    Job         string   `xml:"details>job,omitempty"`
    YearOfBirth int      `xml:"year_of_birth,attr,omitempty"`
    IgnoreMe    string   `xml:"-"`
}

这个结构产生以下 XML:

<character year_of_birth="1871">
  <name>Henry</name>
  <surname>Wentworth Akeley</surname>
  <details>
    <job>farmer</job>
  </details>
</character>

完整示例在这里:play.golang.org/p/6zdl9__M0zF

编组器和解组器

就像我们在 JSON 中看到的那样,xml包提供了一些接口来自定义类型在编码和解码操作期间的行为——这可以避免使用反射,或者可以用于建立不同的行为。该包提供的接口来获得这种行为是以下内容:

type Marshaler interface {
    MarshalXML(e *Encoder, start StartElement) error
}

type MarshalerAttr interface {
    MarshalXMLAttr(name Name) (Attr, error)
}

type Unmarshaler interface {
        UnmarshalXML(d *Decoder, start StartElement) error
}

type UnmarshalerAttr interface {
        UnmarshalXMLAttr(attr Attr) error
}

有两对函数——一对用于解码或编码类型作为元素时使用,而另一对用于其作为属性时使用。让我们看看它的作用。首先,我们为自定义类型定义一个MarshalXMLAttr方法:

type Character struct {
    XMLName struct{} `xml:"character"`
    ID ID `xml:"id,attr"`
    Name string `xml:"name"`
    Surname string `xml:"surname"`
    Job string `xml:"job,omitempty"`
    YearOfBirth int `xml:"year_of_birth,omitempty"`
}

type ID string

func (i ID) MarshalXMLAttr(name xml.Name) (xml.Attr, error) {
    return xml.Attr{
        Name: xml.Name{Local: "codename"},
        Value: strings.ToUpper(string(i)),
    }, nil
}

然后,我们对一些数据进行编组,我们会看到属性名称被替换为codename,其值为大写,正如方法所指定的那样:

e := xml.NewEncoder(os.Stdout)
e.Indent("", "\t")
c := Character{
    ID: "aa",
    Name: "Abdul",
    Surname: "Alhazred",
    Job: "poet",
    YearOfBirth: 700,
}
if err := e.Encode(c); err != nil {
    log.Fatalln(err)
}

完整示例在这里:play.golang.org/p/XwJrMozQ6RY

生成结构

就像 JSON 一样,有一个第三方包可以从编码文件生成 Go 结构。对于 XML,我们有github.com/miku/zek

它处理任何类型的 XML 数据,包括带有属性的元素,元素之间的间距或注释。

YAML

YAML是一个递归缩写,代表YAML 不是标记语言,它是另一种广泛使用的数据编码格式的名称。它的成功部分归功于它比 JSON 和 XML 更容易编写,它的轻量级特性和灵活性。

结构

YAML 使用缩进来表示范围,使用换行符来分隔实体。序列中的元素以破折号开头,后跟一个空格。键和值之间用冒号分隔,用井号表示注释。这是样本 YAML 文件的样子:

# list of characters
characters: 
    - name: "Henry"
      surname: "Armitage"
      year_of_birth: 1855
      job: "librarian"
    - name: "Francis"
      surname: "Wayland Thurston"
      job: "anthropologist"

JSON 和 YAML 之间更重要的区别之一是,虽然前者只能使用字符串作为键,但后者可以使用任何类型的标量值(字符串、数字和布尔值)。

解码和编码

YAML 不包含在 Go 标准库中,但有许多第三方库可用。处理此格式最常用的包是go-yaml包(gopkg.in/yaml.v2)。

它是使用以下标准编码包结构构建的:

  • 有编码器和解码器。

  • Marshal/Unmarshal函数。

  • 它允许struct标记。

  • 类型的行为可以通过实现定义的接口的方法来自定义。

接口略有不同——Unmarshaler接收默认编组函数作为参数,然后可以与不同于类型的数据结构一起使用:

type Marshaler interface {
    MarshalYAML() (interface{}, error)
}

type Unmarshaler interface {
    UnmarshalYAML(unmarshal func(interface{}) error) error
}

我们可以像使用 JSON 标记一样使用struct标记:

type Character struct {
    Name        string `yaml:"name"`
    Surname     string `yaml:"surname"`
    Job         string `yaml:"job,omitempty"`
    YearOfBirth int    `yaml:"year_of_birth,omitempty"`
}

我们可以使用它们来编码数据结构,或者在这种情况下,一系列结构:

var chars = []Character{{
    Name:        "William",
    Surname:     "Dyer",
    Job:         "professor",
    YearOfBirth: 1875,
}, {
    Surname: "Danforth",
    Job:     "student",
}}
e := yaml.NewEncoder(os.Stdout)
if err := e.Encode(chars); err != nil {
    log.Fatalln(err)
}

解码方式相同,如下所示:

r := strings.NewReader(`- name: John Raymond
 surname: Legrasse
 job: policeman
- name: "Francis"
 surname: Wayland Thurston
 job: anthropologist`)
// define a new decoder
d := yaml.NewDecoder(r)
var c []Character
// decode the reader
if err := d.Decode(&c); err != nil {
 log.Fatalln(err)
}
log.Printf("%+v", c)

我们可以看到创建Decoder所需的全部内容是io.Reader和接收结构以执行解码。

了解二进制编码

二进制编码协议使用字节,因此它们的字符串表示不友好。它们通常不可读作为字符串,很难编写,但它们的大小更小,导致应用程序之间的通信更快。

BSON

BSON 是 JSON 的二进制版本。它被 MongoDB 使用,并支持一些在 JSON 中不可用的数据类型,例如日期和二进制。

有一些包实现了 BSON 编码和解码,其中两个非常广泛。一个在官方的 MongoDB Golang 驱动程序内部,github.com/mongodb/mongo-go-driver。另一个不是官方的,但自 Go 开始就存在,并且是非官方 MongoDB 驱动程序的一部分,gopkg.in/mgo.v2

第二个与 JSON 包非常相似,无论是接口还是函数。这些接口被称为 getter 和 setter:

  • GetBSON返回将被编码的实际数据结构。

  • SetBSON接收bson.Raw,它是[]byte的包装器,可以与bson.Unmarshal一起使用。

这些 getter 和 setter 的用例如下:

type Setter interface {
    SetBSON(raw Raw) error
}

type Getter interface {
    GetBSON() (interface{}, error)
}

编码

BSON 是为文档/实体设计的格式;因此,用于编码和解码的数据结构应该是结构体或映射,而不是切片或数组。mgo版本的bson不提供通常的编码器,而只提供 marshal:

var char = Character{
    Name: "Robert",
    Surname: "Olmstead",
}
b, err := bson.Marshal(char)
if err != nil {
    log.Fatalln(err)
}
log.Printf("%q", b)

解码

相同的事情也适用于Unmarshal函数:

r := []byte(",\x00\x00\x00\x02name\x00\a\x00\x00" +
 "\x00Robert\x00\x02surname\x00\t\x00\x00\x00" +
 "Olmstead\x00\x00")
var c Character
if err := bson.Unmarshal(r, &c); err != nil {
 log.Fatalln(err)
}
log.Printf("%+v", c)

gob

gob编码是另一种内置于标准库中的二进制编码类型,实际上是由 Go 本身引入的。它是一系列数据项,每个数据项前面都有一个类型声明,并且不允许使用指针。它使用它们的值,禁止使用nil指针(因为它们没有值)。该包还存在与具有创建递归结构的指针的类型相关的问题,这可能导致意外的行为。

数字具有任意精度,可以是浮点数、有符号数或无符号数。有符号整数可以存储在任何有符号整数类型中,无符号整数可以存储在任何无符号整数类型中,浮点值可以接收到任何浮点变量中。但是,如果变量无法表示该值(例如溢出),解码将失败。字符串和字节切片使用非常高效的表示存储,尝试重用相同的基础数组。结构体只会解码导出的字段,因此函数和通道将被忽略。

接口

gob用于替换默认编组和解组行为的接口可以在encoding包中找到:

type BinaryMarshaler interface {
        MarshalBinary() (data []byte, err error)
}

type BinaryUnmarshaler interface {
        UnmarshalBinary(data []byte) error
}

在解码阶段,任何不存在的结构字段都会被忽略,因为字段名称也是序列化的一部分。

编码

让我们尝试使用gob对一个结构进行编码:

var char = Character{
    Name:    "Albert",
    Surname: "Wilmarth",
    Job:     "assistant professor",
}
s := strings.Builder{}
e := gob.NewEncoder(&s)
if err := e.Encode(char); err != nil {
    log.Fatalln(err)
}
log.Printf("%q", s.String())

解码

解码数据也非常简单;它的工作方式与我们已经看到的其他编码包相同:

r := strings.NewReader("D\xff\x81\x03\x01\x01\tCharacter" +
    "\x01\xff\x82\x00\x01\x04\x01\x04Name" +
    "\x01\f\x00\x01\aSurname\x01\f\x00\x01\x03" +
    "Job\x01\f\x00\x01\vYearOfBirth\x01\x04\x00" +
    "\x00\x00*\xff\x82\x01\x06Albert\x01\bWilmarth" +
    "\x01\x13assistant professor\x00")
d := gob.NewDecoder(r)
var c Character
if err := d.Decode(&c); err != nil {
    log.Fatalln(err)
}
log.Printf("%+v", c)

现在,让我们尝试在不同的结构中解码相同的数据——原始数据和一些带有额外或缺少字段的数据。我们将这样做来查看该包的行为。让我们定义一个通用的解码函数,并将不同类型的结构传递给解码器:

func runDecode(data []byte, v interface{}) {
    if err := gob.NewDecoder(bytes.NewReader(data)).Decode(v); err != nil {
        log.Fatalln(err)
    }
    log.Printf("%+v", v)    
}

让我们尝试改变结构体中字段的顺序,看看gob解码器是否仍然有效:

runDecode(data, new(struct {
    YearOfBirth int    `gob:"year_of_birth,omitempty"`
    Surname     string `gob:"surname"`
    Name        string `gob:"name"`
    Job         string `gob:"job,omitempty"`
}))

让我们删除一些字段:


runDecode(data, new(struct {
    Name string `gob:"name"`
}))

让我们在中间加一个字段:

runDecode(data, new(struct {
    Name        string `gob:"name"`
    Surname     string `gob:"surname"`
    Country     string `gob:"country"`
    Job         string `gob:"job,omitempty"`
    YearOfBirth int    `gob:"year_of_birth,omitempty"`
}))

我们可以看到,即使我们混淆、添加或删除字段,该包仍然可以正常工作。但是,如果我们尝试将现有字段的类型更改为另一个类型,它会失败:

runDecode(data, new(struct {
    Name []byte `gob:"name"`
}))

接口

关于该包的另一个注意事项是,如果您使用接口,它们的实现应该首先进行注册,使用以下函数:

func Register(value interface{})
func RegisterName(name string, value interface{})

这将使该包了解指定的类型,并使我们能够在接口类型上调用解码。让我们首先定义一个接口及其实现,用于我们的结构:


type Greeter interface {
    Greet(w io.Writer)
}

type Character struct {
    Name        string `gob:"name"`
    Surname     string `gob:"surname"`
    Job         string `gob:"job,omitempty"`
    YearOfBirth int    `gob:"year_of_birth,omitempty"`
}

func (c Character) Greet(w io.Writer) {
    fmt.Fprintf(w, "Hello, my name is %s %s", c.Name, c.Surname)
    if c.Job != "" {
        fmt.Fprintf(w, " and I am a %s", c.Job)
    }
}

如果我们尝试在没有gob.Register函数的情况下运行以下代码,会返回一个错误:

gob: name not registered for interface: "main.Character"

但是如果我们注册了该类型,它就会像魅力一样工作。请注意,该数据是通过对包含Character结构的Greeter的指针进行编码而获得的:

func main() {
    gob.Register(Greeter(Character{}))
    r := strings.NewReader("U\x10\x00\x0emain.Character" +
        "\xff\x81\x03\x01\x01\tCharacter\x01\xff\x82\x00" +
        "\x01\x04\x01\x04Name\x01\f\x00\x01\aSurname" +
        "\x01\f\x00\x01\x03Job\x01\f\x00\x01\vYearOfBirth" +
        "\x01\x04\x00\x00\x00\x1f\xff\x82\x1c\x01\x05John" +
        " \x01\aKirowan\x01\tprofessor\x00")
    var char Greeter
    if err := gob.NewDecoder(r).Decode(&char); err != nil {
        log.Fatalln(err)
    }
    char.Greet(os.Stdout)
}

Proto

协议缓冲区是由谷歌制作的序列化协议。它是语言和平台中立的,开销很小,非常高效。其背后的想法是定义数据的结构一次,然后使用一些工具为应用程序的目标语言生成源代码。

结构

生成代码所需的主文件是.proto文件,它使用特定的语法。我们将专注于协议语法的最新版本proto3

我们在第一行指定要使用的文件语法版本:

syntax = "proto3";

可以使用import语句使用其他文件中的定义:

import "google/protobuf/any.proto";

文件的其余部分包含消息(数据类型)和服务的定义。服务是用于定义 RPC 服务的接口:

message SearchRequest {
  string query = 1;
  int32 page_number = 2;
  int32 result_per_page = 3;
}

service SearchService {
  rpc Search (SearchRequest) returns (SearchResponse);
}

消息由它们的字段组成,服务由它们的方法组成。字段类型分为标量(包括各种整数、有符号整数、浮点数、字符串和布尔值)和其他消息。每个字段都有一个与之关联的数字,这是它的标识符,一旦选择就不应更改,以便与消息的旧版本保持兼容性。

使用reserved关键字可以防止一些字段或 ID 被重用,这对于避免错误或问题非常有用:

message Foo {
  // lock field IDs
  reserved 2, 15, 9 to 11;
  // lock field names
  reserved "foo", "bar";
}

代码生成

为了从.proto文件生成代码,您需要protoc应用程序和官方的 proto 生成包:

go get -u github.com/golang/protobuf/protoc-gen-go

安装的包带有protoc-gen-go命令;这使得protoc命令可以使用--go_out标志在所需的文件夹中生成 Go 源文件。Go 的 1.4 版本可以指定特殊注释以使用其go generate命令自动生成代码,这些注释以//go:generate开头,后跟命令,如下例所示:

//go:generate protoc -I=$SRC_PATH --go_out=$DST_DIR source.proto

它使我们能够指定导入查找的源路径、输出目录和源文件。路径是相对于找到注释的包目录的,可以使用go generate $pkg命令调用。

让我们从一个简单的.proto文件开始:

syntax = "proto3";

message Character {
    string name = 1;
    string surname = 2;
    string job = 3;
    int32 year_of_birth = 4;
}

让我们在相同的文件夹中创建一个带有用于生成代码的注释的 Go 源文件:

package gen

//go:generate protoc --go_out=. char.proto

现在,我们可以生成go命令,它将生成一个与.proto文件相同名称和.pb.go扩展名的文件。该文件将包含.proto文件中定义的类型和服务的 Go 源代码:

// Code generated by protoc-gen-go. DO NOT EDIT.
// source: char.proto
...
type Character struct {
  Name        string `protobuf:"bytes,1,opt,name=name"`
  Surname     string `protobuf:"bytes,2,opt,name=surname"`
  Job         string `protobuf:"bytes,3,opt,name=job" json:"job,omitempty"`
  YearOfBirth int32  `protobuf:"varint,4,opt,name=year_of_birth,json=yearOfBirth"`
}

编码

这个包允许我们使用proto.Buffer类型来编码pb.Message值。由protoc创建的类型实现了定义的接口,因此Character类型可以直接使用:

var char = gen.Character{
    Name:        "George",
    Surname:     "Gammell Angell",
    YearOfBirth: 1834,
    Job:         "professor emeritus",
}
b := proto.NewBuffer(nil)
if err := b.EncodeMessage(&char); err != nil {
    log.Fatalln(err)
}
log.Printf("%q", b.Bytes())

生成的编码数据与其他编码相比几乎没有额外开销。

解码

解码操作也需要使用proto.Buffer方法和生成的类型来执行:

b := proto.NewBuffer([]byte(
    "/\n\x06George\x12\x0eGammell Angell" +
    "\x1a\x12professor emeritus \xaa\x0e",
))
var char gen.Character
if err := b.DecodeMessage(&char); err != nil {
    log.Fatalln(err)
}
log.Printf("%+v", char)

gRPC 协议

谷歌使用协议缓冲编码来构建名为gRPC的 Web 协议。它是一种使用 HTTP/2 建立连接和使用协议缓冲区来编组和解组数据的远程过程调用类型。

第一步是在目标语言中生成与服务器相关的代码。这将产生一个服务器接口和一个客户端工作实现。接下来,需要手动创建服务器实现,最后,目标语言将使实现能够在 gRPC 服务器中使用,然后使用客户端连接和与之交互。

go-grpc包中有不同的示例,包括客户端/服务器对。客户端使用生成的代码,只需要一个工作的 gRPC 连接到服务器,然后可以使用服务中指定的方法:

conn, err := grpc.Dial(address, grpc.WithInsecure())
if err != nil {
    log.Fatalf("did not connect: %v", err)
}
defer conn.Close()
c := pb.NewGreeterClient(conn)

// Contact the server and print out its response
r, err := c.SayHello(ctx, &pb.HelloRequest{Name: name})

完整的代码可在grpc/grpc-go/blob/master/examples/helloworld/greeter_client/main.go找到。

服务器是客户端接口的实现:

// server is used to implement helloworld.GreeterServer.
type server struct{}

// SayHello implements helloworld.GreeterServer
func (s *server) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) {
    log.Printf("Received: %v", in.Name)
    return &pb.HelloReply{Message: "Hello " + in.Name}, nil
}

这个接口实现可以传递给生成的注册函数RegisterGreeterServer,连同一个有效的 gRPC 服务器,它可以使用 TCP 监听器来服务传入的连接:

func main() {
    lis, err := net.Listen("tcp", port)
    if err != nil {
        log.Fatalf("failed to listen: %v", err)
    }
    s := grpc.NewServer()
    pb.RegisterGreeterServer(s, &server{})
    if err := s.Serve(lis); err != nil {
        log.Fatalf("failed to serve: %v", err)
    }
}

完整的代码可在grpc/grpc-go/blob/master/examples/helloworld/greeter_server/main.go找到。

摘要

在本章中,我们探讨了 Go 标准包和第三方库提供的编码方法。它们可以分为两大类。第一种是基于文本的编码方法,对人类和机器来说都易于阅读和编写。然而,它们的开销更大,而且往往比它们的对应的基于二进制的编码要慢得多。基于二进制的编码方法开销很小,但不易阅读。

在基于文本的编码中,我们发现了 JSON、XML 和 YAML。前两者由标准库处理,最后一个需要外部依赖。我们探讨了 Go 如何允许我们指定结构标签来改变默认的编码和解码行为,以及如何在这些操作中使用这些标签。然后,我们检查并实现了定义在编组和解组操作期间自定义行为的接口。有一些第三方工具可以让我们从 JSON 文件或 JSON 模式生成数据结构,JSON 模式是用于定义其他 JSON 文档结构的 JSON 文件。

XML 是另一种广泛使用的文本格式,HTML 就是基于它的。我们检查了 XML 语法和组成元素,然后展示了一种特定类型的文档,称为 DTD,用于定义其他 XML 文件的内容。我们学习了 XML 中编码和解码的工作原理,以及与 JSON 有关的struct标签的区别,这些标签允许我们为类型定义嵌套的 XML 元素,或者从属性中存储或加载字段。最后,我们介绍了基于文本的编码与第三方 YAML 包。

我们展示的第一个基于二进制的编码是 BSON,这是 JSON 的二进制版本,被 MongoDB 使用(由第三方包处理)。gob是另一种二进制编码方法,但它是 Go 标准库的一部分。我们了解到编码和解码以及涉及的接口,都是以标准包的方式工作的——类似于 JSON 和 XML。

最后,我们看了一下协议缓冲编码,如何编写.proto文件以及其 Go 代码生成用法,以及如何使用它对数据进行编码和解码。我们还介绍了 gRPC 编码的一个实际示例,利用这种编码来创建客户端/服务器应用程序。

在下一章中,我们将开始深入研究 Go 的并发模型,从内置类型开始——通道和 goroutine。

问题

  1. 文本和二进制编码之间的权衡是什么?

  2. Go 默认情况下如何处理数据结构?

  3. 这种行为如何改变?

  4. 结构字段如何在 XML 属性中编码?

  5. 需要什么操作来解码gob接口值?

  6. 什么是协议缓冲编码?

第四部分:深入了解并发

本节重点介绍 Go 语言最现代的特性之一——并发。它向您展示了语言所拥有的工具,介绍了 sync 和 channels,并解释了如何以及何时使用每个工具。

本节包括以下章节:

  • 第十一章,“处理通道和 Goroutines”

  • 第十二章,“Sync 和 Atomic 包”

  • 第十三章,“使用上下文进行协调”

  • 第十四章,“实现并发模式”

第十一章:处理通道和 goroutines

本章将涵盖使用 Go 进行并发编程,使用其基本内置功能、通道和 goroutines。并发描述了在同一时间段内执行应用程序的不同部分的能力。

使软件并发可以成为构建系统应用程序的强大工具,因为一些操作可以在其他操作尚未结束时开始。

本章将涵盖以下主题:

  • 理解 goroutines

  • 探索通道

  • 优势使用

技术要求

本章需要安装 Go 并设置您喜欢的编辑器。有关更多信息,请参阅第三章,Go 概述

理解 goroutines

Go 是一种以并发为中心的语言,以至于两个主要特性——通道和 goroutines——都是内置包的一部分。我们现在将看到它们是如何工作以及它们的基本功能是什么,首先是 goroutines,它使得可以并发执行应用程序的部分。

比较线程和 goroutines

Goroutines 是用于并发的原语之一,但它们与线程有何不同?让我们在这里阅读它们的每一个。

线程

当前操作系统是为具有每个 CPU 多个核心的现代架构构建的,或者使用超线程等技术,允许单个核心支持多个线程。线程是可以由操作系统调度程序管理的进程的一部分,可以将它们分配给特定的核心/CPU。与进程一样,线程携带有关应用程序执行的信息,但是这些信息的大小小于进程。这包括程序中的当前指令,当前执行的堆栈以及所需的变量。

操作系统已经负责进程之间的上下文切换;它保存旧进程信息并加载新进程信息。这被称为进程上下文切换,这是一个非常昂贵的操作,甚至比进程执行更昂贵。

为了从一个线程跳转到另一个线程,可以在线程之间执行相同的操作。这被称为线程上下文切换,它也是一个繁重的操作,即使它不像进程切换那样繁重,因为线程携带的信息比进程少。

Goroutines

线程在内存中有最小大小;通常,它的大小是以 MB 为单位的(Linux 为 2MB)。最小大小对新线程的应用程序创建设置了一些限制——如果每个线程至少有几 MB,那么 1,000 个线程将占用至少几 GB 的内存。Go 解决这些问题的方式是通过使用类似线程的构造,但这是由语言运行时而不是操作系统处理的。goroutine 在内存中的大小是三个数量级(每个 goroutine 为 2KB),这意味着 1,000 个 goroutines 的最小内存使用量与单个线程的内存使用量相当。

这是通过定义 goroutines 内部保留的数据来实现的,使用一个称为g的数据结构来描述 goroutine 信息,例如堆栈和状态。这是runtime包中的一个未导出的数据类型,并且可以在 Go 源代码中找到。Go 使用来自相同包的另一个数据结构来跟踪操作系统,称为m。用于执行 goroutine 的逻辑处理器存储在p结构中。这可以在 Go runtime包文档中进行验证:

这三个实体的交互如下——对于每个 goroutine,都会创建一个新的gg被排入p,每个p都会尝试获取m来执行g中的代码。有一些操作会阻塞执行,例如这些:

  • 内置同步(通道和sync包)

  • 阻塞的系统调用,例如文件操作

  • 网络操作

当这些类型的操作发生时,运行时会将pm中分离出来,并使用(或创建,如果尚不存在)另一个专用的m来执行阻塞操作。执行此类操作后,线程变为空闲状态。

新的 goroutine

Goroutines 是 Go 如何在简单接口后隐藏复杂性的最佳示例之一。在编写应用程序以启动 goroutine 时,所需的只是执行一个以go关键字开头的函数:

func main() {
    go fmt.Println("Hello, playground")
}

完整的示例可在play.golang.org/p/3gPGZkJtJYv找到。

如果我们运行上一个示例的应用程序,我们会发现它不会产生任何输出。为什么?在 Go 中,应用程序在主 goroutine 终止时终止,看起来是这种情况。发生的情况是,Go 语句创建具有相应runtime.g的 goroutine,但这必须由 Go 调度程序接管,而这并没有发生,因为程序在 goroutine 实例化后立即终止。

使用time.Sleep函数让主 goroutine 等待(即使是一纳秒!)足以让调度程序挑选出 goroutine 并执行其代码。这在以下代码中显示:

func main() {
    go fmt.Println("Hello, playground")
    time.Sleep(time.Nanosecond)
}

完整的示例可在play.golang.org/p/2u125pTclv6找到。

我们已经看到 Go 方法也算作函数,这就是为什么它们可以像普通函数一样与go语句并发执行:

type a struct{}

func (a) Method() { fmt.Println("Hello, playground") }

func main() {
    go a{}.Method()
    time.Sleep(time.Nanosecond)
}

完整的示例可在play.golang.org/p/RUhgfRAPa2b找到。

闭包是匿名函数,因此它们也可以被使用,这实际上是一个非常常见的做法:

func main() {
    go func() {
        fmt.Println("Hello, playground")
    }()
    time.Sleep(time.Nanosecond)
}

完整的示例可在play.golang.org/p/a-JvOVwAwUV找到。

多个 goroutines

在多个 goroutine 中组织代码可以帮助将工作分配给处理器,并具有许多其他优势,我们将在接下来的章节中看到。由于它们如此轻量级,我们可以使用循环非常容易地创建多个 goroutine:

func main() {
    for i := 0; i < 10; i++ {
        go fmt.Println(i)
    }
    time.Sleep(time.Nanosecond)
}

完整的示例可在play.golang.org/p/Jaljd1padeX找到。

这个示例并行打印从09的数字列表,使用并发的 goroutines 而不是在单个 goroutine 中顺序执行相同的操作。

参数评估

如果我们稍微改变这个示例,使用没有参数的闭包,我们将看到一个非常不同的结果:

func main() {
    for i := 0; i < 10; i++ {
         go func() { fmt.Println(i) }()
    }
    time.Sleep(time.Nanosecond)
}

完整的示例可在play.golang.org/p/RV54AsYY-2y找到。

如果我们运行此程序,我们会看到 Go 编译器在循环中发出警告:循环变量 i 被函数文字捕获

循环中的变量被引用在我们定义的函数中——goroutines 的创建循环比 goroutines 的执行更快,结果是循环在单个 goroutine 启动之前就完成了,导致在最后一次迭代后打印循环变量的值。

为了避免捕获循环变量的错误,最好将相同的变量作为参数传递给闭包。 goroutine 函数的参数在创建时进行评估,这意味着对该变量的更改不会在 goroutine 内部反映出来,除非您传递对值的引用,例如指针,映射,切片,通道或函数。我们可以通过运行以下示例来看到这种差异:

func main() {
    var a int
    // passing value
    go func(v int) { fmt.Println(v) }(a)

    // passing pointer
    go func(v *int) { fmt.Println(*v) }(&a)

    a = 42
    time.Sleep(time.Nanosecond)
}

完整的示例可在play.golang.org/p/r1dtBiTUMaw找到。

按值传递参数不受程序的最后赋值的影响,而传递指针类型意味着对指针内容的更改将被 goroutine 看到。

同步

Goroutine 允许代码并发执行,但值之间的同步不能保证。我们可以看看在尝试并发使用变量时会发生什么,例如下面的例子:

func main() {
    var i int
    go func(i *int) {
        for j := 0; j < 20; j++ {
            time.Sleep(time.Millisecond)
            fmt.Println(*i, j)
        }
    }(&i)
    for i = 0; i < 20; i++ {
        time.Sleep(time.Millisecond)
        fmt.Println(i)
    }
}

我们有一个整数变量,在主例程中更改——在每次操作之间进行毫秒暂停——并在更改后打印值。

在另一个 goroutine 中,有一个类似的循环(使用另一个变量)和另一个print语句来比较这两个值。考虑到暂停是相同的,我们期望看到相同的值,但事实并非如此。我们看到有时两个 goroutine 不同步。

更改不会立即反映,因为内存不会立即同步。我们将在下一章中学习如何确保数据同步。

探索通道

通道是 Go 和其他几种编程语言中独有的概念。通道是非常强大的工具,可以简单地实现不同 goroutine 之间的同步,这是解决前面例子中提出的问题的一种方法。

属性和操作

通道是 Go 中的一种内置类型,类型为数组、切片和映射。它以chan type的形式呈现,并通过make函数进行初始化。

容量和大小

除了通过通道传输的类型之外,通道还具有另一个属性:它的容量。这代表了通道在进行任何新的发送尝试之前可以容纳的项目数量,从而导致阻塞操作。通道的容量在创建时决定,其默认值为0

// channel with implicit zero capacity
var a = make(chan int)

// channel with explicit zero capacity
var a = make(chan int, 0)

// channel with explicit capacity
var a = make(chan int, 10)

通道的容量在创建后无法更改,并且可以随时使用内置的cap函数进行读取:

func main() {
    var (
        a = make(chan int, 0)
        b = make(chan int, 5)
    )

    fmt.Println("a is", cap(a))
    fmt.Println("b is", cap(b))
}

完整示例可在play.golang.org/p/Yhz4bTxm5L8中找到。

len函数在通道上使用时,告诉我们通道中保存的元素数量:

func main() {
    var (
        a = make(chan int, 5)
    )
    for i := 0; i < 5; i++ {
        a <- i
        fmt.Println("a is", len(a), "/", cap(a))
    }
}

完整示例可在play.golang.org/p/zJCL5VGmMsC中找到。

从前面的例子中,我们可以看到通道容量保持为5,并且随着每个元素的增加而增加。

阻塞操作

如果通道已满或其容量为0,则操作将被阻塞。如果我们采用最后一个例子,填充通道并尝试执行另一个发送操作,我们的应用程序将被卡住。

func main() {
    var (
        a = make(chan int, 5)
    )
    for i := 0; i < 5; i++ {
        a <- i
        fmt.Println("a is", len(a), "/", cap(a))
    }
    a <- 0 // Blocking
}

完整示例可在play.golang.org/p/uSfm5zWN8-x中找到。

当所有 goroutine 都被锁定时(在这种特定情况下,我们只有主 goroutine),Go 运行时会引发死锁,这是一个终止应用程序执行的致命错误:

fatal error: all goroutines are asleep - deadlock!

这种情况可能发生在接收或发送操作中,这是应用程序设计错误的症状。让我们看下面的例子:

func main() {
    var a = make(chan int)
    a <- 10
    fmt.Println(<-a)
}

在前面的例子中,有a <- 10发送操作和匹配的<-a接收操作,但仍然导致死锁。然而,我们创建的通道没有容量,因此第一个发送操作将被阻塞。我们可以通过两种方式进行干预:

  • 通过增加容量:这是一个非常简单的解决方案,涉及使用make(chan int, 1)初始化通道。只有在接收者数量是已知的情况下才能发挥最佳作用;如果它高于容量,则问题会再次出现。

  • 通过使操作并发进行:这是一个更好的方法,因为它使用通道来实现并发。

让我们尝试使用第二种方法使前面的例子工作:

func main() {
    var a = make(chan int)
    go func() {
        a <- 10
    }()
    fmt.Println(<-a)
}

现在,我们可以看到这里没有死锁,程序正确打印了值。使用容量方法也可以使其工作,但它将根据我们发送单个消息的事实进行调整,而另一种方法将允许我们通过通道发送任意数量的消息,并从另一侧相应地接收它们:

func main() {
    const max = 10
    var a = make(chan int)

    go func() {
        for i := 0; i < max; i++ {
            a <- i
        }
    }()
    for i := 0; i < max; i++ {
        fmt.Println(<-a)
    }
}

完整示例可在play.golang.org/p/RKcojupCruB找到。

现在我们有一个常量来存储执行的操作次数,但有一种更好更惯用的方法可以让接收方知道没有更多的消息。我们将在下一章关于同步的内容中介绍这个。

关闭通道

处理发送方和接收方之间同步结束的最佳方法是close操作。这个函数通常由发送方执行,因为接收方可以使用第二个变量验证通道是否仍然打开:

value, ok := <-ch

第二个接收方是一个布尔值,如果通道仍然打开,则为true,否则为false。当在close通道上执行接收操作时,第二个接收到的变量将具有false值,第一个变量将具有通道类型的0值,如下所示:

  • 数字为0

  • 布尔值为false

  • 字符串为""

  • 对于切片、映射或指针,使用nil

可以使用close函数重写发送多条消息的示例,而无需事先知道将发送多少条消息:

func main() {
    const max = 10
    var a = make(chan int)

    go func() {
        for i := 0; i < max; i++ {
            a <- i
        }
        close(a)
    }()
    for {
        v, ok := <-a
        if !ok {
            break
        }
        fmt.Println(v)
    }
}

完整示例可在play.golang.org/p/GUzgG4kf5ta找到。

有一种更简洁和优雅的方法可以接收来自通道的消息,直到它被关闭:通过使用我们用于迭代映射、数组和切片的相同关键字。这是通过range完成的:

for v := range a {
    fmt.Println(v)
}

单向通道

处理通道变量时的另一种可能性是指定它们是仅用于发送还是仅用于接收数据。这由<-箭头指示,如果仅用于接收,则将在chan之前,如果仅用于发送,则将在其后:

func main() {
    var a = make(chan int)
    s, r := (chan<- int)(a), (<-chan int)(a)
    fmt.Printf("%T - %T", s, r)
}

完整示例可在play.golang.org/p/ZgEPZ99PLJv找到。

通道已经是指针了,因此将其中一个转换为其只发送或只接收版本将返回相同的通道,但将减少可以在其上执行的操作数量。通道的类型如下:

  • 只发送通道,chan<-,允许您发送项目,关闭通道,并防止您发送数据,从而导致编译错误。

  • 只接收通道,<-chan,允许您接收数据,任何发送或关闭操作都将导致编译错误。

当函数参数是发送/接收通道时,转换是隐式的,这是一个好习惯,因为它可以防止接收方关闭通道等错误。我们可以采用另一个示例,并利用单向通道进行一些重构。

我们还可以创建一个用于发送值的函数,该函数使用只发送通道:

func send(ch chan<- int, max int) {
    for i := 0; i < max; i++ {
        ch <- i
    }
    close(ch)
}

对于接收,使用只接收通道:

func receive(ch <-chan int) {
    for v := range ch{
        fmt.Println(v)
    }
}

然后,使用相同的通道,它将自动转换为单向版本:

func main() {
    var a = make(chan int)

    go send(a, 10)

    receive(a)
}

完整示例可在play.golang.org/p/pPuqpfnq8jJ找到。

等待接收方

在上一节中,我们看到的大多数示例都是在 goroutine 中完成的发送操作,并且在主 goroutine 中完成了接收操作。可能情况是所有操作都由 goroutine 处理,那么我们如何将主操作与其他操作同步?

一个典型的技术是使用另一个通道,用于唯一的目的是信号一个 goroutine 已经完成了其工作。接收 goroutine 知道通过关闭通信通道没有更多的消息可获取,并在完成操作后关闭与主 goroutine 共享的另一个通道。main函数可以在退出之前等待通道关闭。

用于此范围的典型通道除了打开或关闭之外不携带任何其他信息,因此通常是chan struct{}通道。这是因为空数据结构在内存中没有大小。我们可以通过对先前示例进行一些更改来看到这种模式的实际应用,从接收函数开始:

func receive(ch <-chan int, done chan<- struct{}) {
    for v := range ch {
        fmt.Println(v)
    }
    close(done)
}

接收函数得到了额外的参数——通道。这用于表示发送方已经完成,并且main函数将使用该通道等待接收方完成其任务:

func main() {
    a := make(chan int)
    go send(a, 10)
    done := make(chan struct{})
    go receive(a, done)
    <-done
}

完整示例可在play.golang.org/p/thPflJsnKj4找到。

特殊值

通道在几种情况下的行为不同。我们现在将看看当通道设置为其零值nil时会发生什么,或者当它已经关闭时会发生什么。

nil 通道

我们之前已经讨论过通道在 Go 中属于指针类型,因此它们的默认值是nil。但是当您从nil通道发送或接收时会发生什么?

如果我们创建一个非常简单的应用程序,尝试向空通道发送数据,我们会遇到死锁:

func main() {
    var a chan int
    a <- 1
}

完整示例可在play.golang.org/p/KHJ4rvxh7TM找到。

如果我们对接收操作进行相同的操作,我们会得到死锁的相同结果:

func main() {
    var a chan int
    <-a
}

完整示例可在play.golang.org/p/gIjhy7aMxiR找到。

最后要检查的是close函数在nil通道上的行为。它会导致close of nil channel的明确值的恐慌:

func main() {
    var a chan int
    close(a)
}

完整示例可在play.golang.org/p/5RjdcYUHLSL找到。

总之,我们已经看到nil通道的发送和接收是阻塞操作,并且close会导致恐慌。

关闭通道

我们已经知道从关闭的通道接收会返回通道类型的零值,第二个布尔值为false。但是如果我们在关闭通道后尝试发送一些东西会发生什么?让我们通过以下代码来找出:

func main() {
    a := make(chan int)
    close(a)
    a <- 1
}

完整示例可在play.golang.org/p/_l_xZt1ZojT找到。

如果我们在关闭后尝试发送数据,将返回一个非常特定的恐慌:在关闭的通道上发送。当我们尝试关闭已经关闭的通道时,类似的事情会发生:

func main() {
    a := make(chan int)
    close(a)
    close(a)
}

完整示例可在play.golang.org/p/GHK7ERt1XQf找到。

这个示例将导致特定值的恐慌——关闭已关闭的通道

管理多个操作

有许多情况下,多个 goroutine 正在执行它们的代码并通过通道进行通信。典型的情况是等待其中一个通道的发送或接收操作被执行。

当您操作多个通道时,Go 使得可以使用一个特殊的关键字来执行类似于switch的通道操作。这是通过select语句完成的,后面跟着一系列case语句和一个可选的default case。

我们可以看到一个快速示例,我们在 goroutine 中从一个通道接收值,并在另一个 goroutine 中向另一个通道发送值。在这些示例中,主 goroutine 使用select语句与两个通道进行交互,从第一个接收,然后发送到第二个:

func main() {
    ch1, ch2 := make(chan int), make(chan int)
    a, b := 2, 10
    go func() { <-ch1 }()
    go func() { ch2 <- a }()
    select {
    case ch1 <- b:
        fmt.Println("ch1 got a", b)
    case v := <-ch2:
        fmt.Println("ch2 got a", v)
    }
}

完整示例可在play.golang.org/p/_8P1Edxe3o4找到。

在 playground 中运行此程序时,我们可以看到从第二个通道的接收操作总是最先完成。如果我们改变 goroutine 的执行顺序,我们会得到相反的结果。最后执行的操作是首先接收的。这是因为 playground 是一个在安全环境中运行和执行 Go 代码的网络服务,并且进行了一些优化以使此操作具有确定性。

默认子句

如果我们在上一个示例中添加一个默认情况,应用程序执行的结果将会非常不同,特别是如果我们改变select

select {
case v := <-ch2:
    fmt.Println("ch2 got a", v)
case ch1 <- b:
    fmt.Println("ch1 got a", b)
default:
    fmt.Println("too slow")
}

完整的示例可在play.golang.org/p/F1aE7ImBNFk找到。

select语句将始终选择default语句。这是因为当执行select语句时,调度程序尚未选择 goroutine。如果我们在select切换之前添加一个非常小的暂停(使用time.Sleep),我们将使调度程序至少选择一个 goroutine,然后我们将执行两个操作中的一个:

func main() {
    ch1, ch2 := make(chan int), make(chan int)
    a, b := 2, 10
    for i := 0; i < 10; i++ {
        go func() { <-ch1 }()
        go func() { ch2 <- a }()
        time.Sleep(time.Nanosecond)
        select {
        case ch1 <- b:
            fmt.Println("ch1 got a", b)
        case v := <-ch2:
            fmt.Println("ch2 got a", v)
        default:
            fmt.Println("too slow")
        }
    }
}

完整的示例可在play.golang.org/p/-aXc3FN6qDj找到。

在这种情况下,我们将有一组混合的操作被执行,具体取决于哪个操作被 Go 调度程序选中。

定时器和滴答器

time包提供了一些工具,使得可以编排 goroutines 和 channels——定时器和滴答器。

定时器

可以替换select语句中的default子句的实用程序是time.Timer类型。这包含一个只接收通道,在其构造期间使用time.NewTimer指定持续时间后将返回一个time.Time值:

func main() {
    ch1, ch2 := make(chan int), make(chan int)
    a, b := 2, 10
    go func() { <-ch1 }()
    go func() { ch2 <- a }()
    t := time.NewTimer(time.Nanosecond)
    select {
    case ch1 <- b:
        fmt.Println("ch1 got a", b)
    case v := <-ch2:
        fmt.Println("ch2 got a", v)
    case <-t.C:
        fmt.Println("too slow")
    }
}

完整的示例可在play.golang.org/p/vCAff1kI4yA找到。

定时器公开一个只读通道,因此无法关闭它。使用time.NewTimer创建时,它会在指定的持续时间之前等待在通道中触发一个值。

Timer.Stop方法将尝试避免通过通道发送数据并返回是否成功。如果尝试停止定时器后返回false,我们仍然需要在能够再次使用通道之前从通道中接收值。

Timer.Reset使用给定的持续时间重新启动定时器,并与Stop一样返回一个布尔值。这个值要么是true要么是false

  • 当定时器处于活动状态时为true

  • 当定时器被触发或停止时为false

我们将使用一个实际的示例来测试这些功能:

t := time.NewTimer(time.Millisecond)
time.Sleep(time.Millisecond / 2)
if !t.Stop() {
    panic("it should not fire")
}
select {
case <-t.C:
    panic("not fired")
default:
    fmt.Println("not fired")
}

我们正在创建一个新的1ms定时器。在这里,我们等待0.5ms,然后成功停止它:

if t.Reset(time.Millisecond) {
    panic("timer should not be active")
}
time.Sleep(time.Millisecond)
if t.Stop() {
    panic("it should fire")
}
select {
case <-t.C:
    fmt.Println("fired")
default:
    panic("not fired")
}

完整的示例可在play.golang.org/p/ddL_fP1UBVv找到。

然后,我们将定时器重置为1ms并等待它触发,以查看Stop是否返回false并且通道是否被排空。

AfterFunc

使用time.Timer的一个非常有用的实用程序是time.AfterFunc函数,它返回一个定时器,当定时器触发时将在其自己的 goroutine 中执行传递的函数:

func main() {
    time.AfterFunc(time.Millisecond, func() {
        fmt.Println("Hello 1!")
    })
    t := time.AfterFunc(time.Millisecond*5, func() {
        fmt.Println("Hello 2!")
    })
    if !t.Stop() {
        panic("should not fire")
    }
    time.Sleep(time.Millisecond * 10)
}

完整的示例可在play.golang.org/p/77HIIdlRlZ1找到。

在上一个示例中,我们为两个不同的闭包定义了两个定时器,并停止其中一个,让另一个触发。

滴答声

time.Ticker类似于time.Timer,但其通道以持续时间相等的规则间隔提供更多的元素。它们在创建时使用time.NewTicker指定。这使得可以使用Ticker.Stop方法停止滴答器的触发:

func main() {
    tick := time.NewTicker(time.Millisecond)
    stop := time.NewTimer(time.Millisecond * 10)
    for {
        select {
        case a := <-tick.C:
            fmt.Println(a)
        case <-stop.C:
            tick.Stop()
        case <-time.After(time.Millisecond):
            return
        }
    }
}

完整的示例可在play.golang.org/p/8w8I7zIGe-_j找到。

在这个例子中,我们还使用了time.After——一个从匿名time.Timer返回通道的函数。当不需要停止计时器时,可以使用它。还有另一个函数time.Tick,它返回匿名time.Ticker的通道。这两个函数都会返回一个应用程序无法控制的通道,这个通道最终会被垃圾收集器回收。

这就结束了对通道的概述,从它们的属性和基本用法到一些更高级的并发示例。我们还检查了一些特殊情况以及如何同步多个通道。

将通道和 goroutines 结合

现在我们知道了 Go 并发的基本工具和属性,我们可以使用它们来为我们的应用程序构建更好的工具。我们将看到一些利用通道和 goroutines 解决实际问题的示例。

速率限制器

一个典型的场景是有一个 Web API 在一定时间内对调用次数有一定限制。这种类型的 API 如果超过阈值,将会暂时阻止使用,使其在一段时间内无法使用。在为 API 创建客户端时,我们需要意识到这一点,并确保我们的应用程序不会过度使用它。

这是一个非常好的场景,我们可以使用time.Ticker来定义调用之间的间隔。在这个例子中,我们将创建一个客户端,用于 Google Maps 的地理编码服务,该服务在 24 小时内有 10 万次请求的限制。让我们从定义客户端开始:

type Client struct {
    client *http.Client
    tick *time.Ticker
}

客户端由一个 HTTP 客户端组成,它将调用地图,一个 ticker 将帮助防止超过速率限制,并需要一个 API 密钥用于与服务进行身份验证。我们可以为我们的用例定义一个自定义的Transport结构,它将在请求中注入密钥,如下所示:

type apiTransport struct {
    http.RoundTripper
    key string
}

func (a apiTransport) RoundTrip(r *http.Request) (*http.Response, error) {
    q := r.URL.Query()
    q.Set("key", a.key)
    r.URL.RawQuery = q.Encode()
    return a.RoundTripper.RoundTrip(r)
}

这是一个很好的例子,说明了 Go 接口如何允许扩展自己的行为。我们正在定义一个实现http.RoundTripper接口的类型,并且还有一个是相同接口的实例属性。实现在执行底层传输之前将 API 密钥注入请求。这种类型允许我们定义一个帮助函数,创建一个新的客户端,我们在这里使用我们定义的新传输和默认传输一起:

func NewClient(tick time.Duration, key string) *Client {
    return &Client{
        client: &http.Client{
            Transport: apiTransport{http.DefaultTransport, key},
        },
        tick: time.NewTicker(tick),
    }
}

地图地理编码 API 返回由各种部分组成的一系列地址。这可以在developers.google.com/maps/documentation/geocoding/intro#GeocodingResponses找到。

结果以 JSON 格式编码,因此我们需要一个可以接收它的数据结构:

type Result struct {
    AddressComponents []struct {
        LongName string `json:"long_name"`
        ShortName string `json:"short_name"`
        Types []string `json:"types"`
    } `json:"address_components"`
    FormattedAddress string `json:"formatted_address"`
    Geometry struct {
        Location struct {
            Lat float64 `json:"lat"`
            Lng float64 `json:"lng"`
        } `json:"location"`
        // more fields
    } `json:"geometry"`
    PlaceID string `json:"place_id"`
    // more fields
}

我们可以使用这个结构来执行反向地理编码操作——通过使用相应的端点从坐标获取位置。在执行 HTTP 请求之前,我们等待 ticker,记得defer关闭 body 的闭包:

    const url = "https://maps.googleapis.com/maps/api/geocode/json?latlng=%v,%v"
    <-c.tick.C
    resp, err := c.client.Get(fmt.Sprintf(url, lat, lng))
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

然后,我们可以解码结果,使用我们已经定义的Result类型的数据结构,并检查status字符串:

    var v struct {
        Results []Result `json:"results"`
        Status string `json:"status"`
    }
    // get the result
    if err := json.NewDecoder(resp.Body).Decode(&v); err != nil {
        return nil, err
    }
    switch v.Status {
    case "OK":
        return v.Results, nil
    case "ZERO_RESULTS":
        return nil, nil
    default:
        return nil, fmt.Errorf("status: %q", v.Status)
    }
}

最后,我们可以使用客户端对一系列坐标进行地理编码,期望请求之间至少相隔860ms

c := NewClient(24*time.Hour/100000, os.Getenv("MAPS_APIKEY"))
start := time.Now()
for _, l := range [][2]float64{
    {40.4216448, -3.6904040},
    {40.4163111, -3.7047328},
    {40.4123388, -3.7096724},
    {40.4145150, -3.7064412},
} {
    locs, err := c.ReverseGeocode(l[0], l[1])
    e := time.Since(start)
    if err != nil {
        log.Println(e, l, err)
        continue
    }
    // just print the first location
    if len(locs) != 0 {
        locs = locs[:1]
    }
    log.Println(e, l, locs)
}

工作者

前面的例子是一个使用time.Ticker通道来限制请求速率的 Google Maps 客户端。速率限制对于 API 密钥是有意义的。假设我们有来自不同账户的更多 API 密钥,那么我们可能可以执行更多的请求。

一个非常典型的并发方法是工作池。在这里,你有一系列的客户端可以被选中来处理输入,应用程序的不同部分可以请求使用这些客户端,在完成后将客户端返回。

我们可以创建多个共享相同通道的客户端,其中请求是坐标,响应是服务的响应。由于响应通道是唯一的,我们可以定义一个自定义类型,其中包含所有需要的通道信息:

type result struct {
    Loc [2]float64
    Result []maps.Result
    Error error
}

下一步是创建通道-我们将从环境变量中读取一个逗号分隔的值列表。我们将创建一个用于请求的通道和一个用于响应的通道。这两个通道的容量等于工作人员的数量,在这种情况下,但即使通道是无缓冲的,这也可以工作。由于我们只是使用通道,我们将需要另一个通道“完成”,它表示工作人员是否已完成其最后一项工作:

keys := strings.Split(os.Getenv("MAPS_APIKEYS"), ",")
requests := make(chan [2]float64, len(keys))
results := make(chan result, len(keys))
done := make(chan struct{})

现在,我们将为每个密钥创建一个 goroutine,在其中定义一个客户端,该客户端在请求通道上提供数据,执行请求,并将结果发送到专用通道。当请求通道关闭时,goroutine 将退出范围并向“完成”通道发送消息,如下面的代码所示:

for i := range keys {
    go func(id int) {
        log.Printf("Starting worker %d with API key %q", id, keys[id])
        client := maps.NewClient(maps.DailyCap, keys[id])
        for j := range requests {
            var r = result{Loc: j}
            log.Printf("w[%d] working on %v", id, j)
            r.Result, r.Error = client.ReverseGeocode(j[0], j[1])
            results <- r
        }
        done <- struct{}{}
    }(i)
}

位置可以按顺序发送到另一个 goroutine 中的请求通道:

go func() {
    for _, l := range [][2]float64{
        {40.4216448, -3.6904040},
        {40.4163111, -3.7047328},
        {40.4123388, -3.7096724},
        {40.4145150, -3.7064412},
    } {
        requests <- l
    }
    close(requests)
}()

我们可以统计我们收到的完成信号的数量,并在所有工作人员完成时关闭结果通道:

go func() {
    count := 0
    for range done {
        if count++; count == len(keys) {
            break
        }
    }
    close(results)
}()

该通道用于计算有多少工作人员已完成,一旦所有工作人员都已完成,它将关闭结果通道。这将允许我们只需循环遍历它以获取结果:

for r := range results {
    log.Printf("received %v", r)
}

使用通道只是等待所有 goroutine 完成的一种方式,我们将在下一章中使用sync包看到更多惯用的方法。

工作人员池

通道可以用作资源池,允许我们按需请求它们。在以下示例中,我们将创建一个小应用程序,该应用程序将查找在网络中哪些地址是有效的,使用来自github.com/tatsushid/go-fastping包的第三方客户端。

该池将有两种方法,一种用于获取新客户端,另一种用于将客户端返回到池中。Get方法将尝试从通道中获取现有客户端,如果不可用,则返回一个新客户端。Put方法将尝试将客户端放回通道,否则将丢弃它:

const wait = time.Millisecond * 250

type pingPool chan *fastping.Pinger

func (p pingPool) Get() *fastping.Pinger {
    select {
    case v := <-p:
        return v
    case <-time.After(wait):
        return fastping.NewPinger()
    }
}

func (p pingPool) Put(v *fastping.Pinger) {
    select {
    case p <- v:
    case <-time.After(wait):
    }
    return
}

客户端将需要指定需要扫描的网络,因此它需要一个从net.Interfaces函数开始的可用网络列表,然后遍历接口及其地址:

ifaces, err := net.Interfaces()
if err != nil {
    return nil, err
}
for _, iface := range ifaces {
    // ...
    addrs, err := iface.Addrs()
    // ...
    for _, addr := range addrs {
        var ip net.IP
        switch v := addr.(type) {
        case *net.IPNet:
            ip = v.IP
        case *net.IPAddr:
            ip = v.IP
        }
        // ...
        if ip = ip.To4(); ip != nil {
            result = append(result, ip)
        }
    }
}

我们可以接受命令行参数以在接口之间进行选择,并且当参数不存在或错误时,我们可以向用户显示接口列表以进行选择:

if len(os.Args) != 2 {
    help(ifaces)
}
i, err := strconv.Atoi(os.Args[1])
if err != nil {
    log.Fatalln(err)
}
if i < 0 || i > len(ifaces) {
    help(ifaces)
}

help函数只是一个接口 IP 的打印:

func help(ifaces []net.IP) {
    log.Println("please specify a valid network interface number")
    for i, f := range ifaces {
        mask, _ := f.DefaultMask().Size()
        fmt.Printf("%d - %s/%v\n", i, f, mask)
    }
    os.Exit(0)
}

下一步是获取需要检查的 IP 范围:

m := ifaces[i].DefaultMask()
ip := ifaces[i].Mask(m)
log.Printf("Lookup in %s", ip)

现在我们有了 IP,我们可以创建一个函数来获取同一网络中的其他 IP。在 Go 中,IP 是一个字节切片,因此我们将替换最低有效位以获得最终地址。由于 IP 是一个切片,其值将被每个操作覆盖(切片是指针)。我们将更新原始 IP 的副本-因为切片是指向相同数组的指针-以避免覆盖:

func makeIP(ip net.IP, i int) net.IP {
    addr := make(net.IP, len(ip))
    copy(addr, ip)
    b := new(big.Int)
    b.SetInt64(int64(i))
    v := b.Bytes()
    copy(addr[len(addr)-len(v):], v)
    return addr
}

然后,我们将需要一个用于结果的通道和另一个用于跟踪 goroutine 的通道;对于每个 IP,我们需要检查是否可以为每个地址启动 goroutine。我们将使用 10 个客户端的池,在每个 goroutine 中-我们将为每个客户端请求,然后将它们返回到池中。所有有效的 IP 将通过结果通道发送:

done := make(chan struct{})
address := make(chan net.IP)
ones, bits := m.Size()
pool := make(pingPool, 10)
for i := 0; i < 1<<(uint(bits-ones)); i++ {
    go func(i int) {
        p := pool.Get()
        defer func() {
            pool.Put(p)
            done <- struct{}{}
        }()
        p.AddIPAddr(&net.IPAddr{IP: makeIP(ip, i)})
        p.OnRecv = func(a *net.IPAddr, _ time.Duration) { address <- a.IP }
        p.Run()
    }(i)
}

每次一个例程完成时,我们都会在“完成”通道中发送一个值,以便在退出应用程序之前统计接收到的“完成”信号的数量。这将是结果循环:

i = 0
for {
    select {
    case ip := <-address:
        log.Printf("Found %s", ip)
    case <-done:
        if i >= bits-ones {
            return
        }
        i++
    }
}

循环将继续,直到通道中的计数达到 goroutine 的数量。这结束了一起使用通道和 goroutine 的更复杂的示例。

信号量

信号量是用于解决并发问题的工具。它们具有一定数量的可用配额,用于限制对资源的访问;此外,各种线程可以从中请求一个或多个配额,然后在完成后释放它们。如果可用配额的数量为 1,则意味着信号量一次只支持一个访问,类似于互斥锁的行为。如果配额大于 1,则我们指的是最常见的类型——加权信号量。

在 Go 中,可以使用容量等于配额的通道来实现信号量,其中您向通道发送一条消息以获取配额,并从中接收一条消息以释放配额:

type sem chan struct{}

func (s sem) Acquire() {
    s <- struct{}{}
}

func (s sem) Relase() {
    <-s
}

前面的代码向我们展示了如何使用几行代码在通道中实现信号量。以下是如何使用它的示例:

func main() {
    s := make(sem, 5)
    for i := 0; i < 10; i++ {
        go func(i int) {
            s.Acquire()
            fmt.Println(i, "start")
            time.Sleep(time.Second)
            fmt.Println(i, "end")
            s.Relase()
        }(i)
    }
    time.Sleep(time.Second * 3)
}

完整示例可在play.golang.org/p/BR5GN2QopjQ中找到。

我们可以从前面的示例中看到,程序在第一轮获取时为一些请求提供服务,而在第二轮获取时为其他请求提供服务,不允许同时执行超过五次。

总结

在本章中,我们讨论了 Go 并发中的两个主要角色——goroutines 和通道。我们首先解释了线程是什么,线程和 goroutines 之间的区别,以及它们为什么如此方便。线程很重,需要一个 CPU 核心,而 goroutines 很轻,不绑定到核心。我们看到了一个新的 goroutine 可以通过在函数前加上go关键字来轻松启动,并且可以一次启动一系列不同的 goroutines。我们看到了并发函数的参数在创建 goroutine 时进行评估,而不是在实际开始时进行。我们还看到,如果没有额外的工具,很难保持不同的 goroutines 同步。

然后,我们介绍了通道,用于在不同的 goroutines 之间共享信息,并解决我们之前提到的同步问题。我们看到 goroutines 有一个最大容量和一个大小——它目前持有多少元素。大小不能超过容量,当额外的元素发送到一个满的通道时,该操作会阻塞,直到从通道中删除一个元素。从一个空通道接收也是一个阻塞操作。

我们看到了如何使用close函数关闭通道,这个操作应该在发送数据的同一个 goroutine 中完成,以及在特殊情况下(如nil或关闭的通道)操作的行为。我们介绍了select语句来选择并发通道操作并控制应用程序流程。然后,我们介绍了与time包相关的并发工具——定时器和计时器。

最后,我们展示了一些真实世界的例子,包括一个速率限制的 Google Maps 客户端和一个工具,可以同时 ping 网络中的所有地址。

在下一章中,我们将研究一些同步原语,这些原语将允许更好地处理 goroutines 和内存,使用更清晰和简单的代码。

问题

  1. 什么是线程,谁负责它?

  2. 为什么 goroutines 与线程不同?

  3. 在启动 goroutine 时何时评估参数?

  4. 缓冲和非缓冲通道有什么区别?

  5. 为什么单向通道有用?

  6. 当在nil或关闭的通道上进行操作时会发生什么?

  7. 计时器和定时器用于什么?

第十二章:使用 sync 和 atomic 进行同步

本章将继续介绍 Go 并发,介绍syncatomic包,这是另外两个用于协调 goroutine 同步的工具。这将使编写优雅且简单的代码成为可能,允许并发使用资源并管理 goroutine 的生命周期。sync包含高级同步原语,而atomic包含低级原语。

本章将涵盖以下主题:

  • 等待组

  • 其他同步组件

  • atomic

技术要求

本章需要安装 Go 并设置您喜欢的编辑器。有关更多信息,请参阅第三章,Go 概述

同步原语

我们已经看到通道专注于 goroutine 之间的通信,现在我们将关注sync包提供的工具,其中包括用于 goroutine 之间同步的基本原语。我们将首先看到如何使用锁实现对同一资源的并发访问。

并发访问和锁

Go 提供了一个通用接口,用于可以被锁定和解锁的对象。锁定对象意味着控制它,而解锁则释放它供其他人使用。该接口为每个操作公开了一个方法。以下是代码中的示例:

type Locker interface {
    Lock()
    Unlock()
}

互斥锁

锁的最简单实现是sync.Mutex。由于其方法具有指针接收器,因此不应通过值复制或传递。Lock()方法尝试控制互斥锁,如果可能的话,或者阻塞 goroutine 直到互斥锁可用。Unlock()方法释放互斥锁,如果在未锁定的情况下调用,则返回运行时错误。

以下是一个简单的示例,我们在其中使用锁启动一堆 goroutine,以查看哪个先执行:

func main() {
    var m sync.Mutex
    done := make(chan struct{}, 10)
    for i := 0; i < cap(done); i++ {
        go func(i int, l sync.Locker) {
            l.Lock()
            defer l.Unlock()
            fmt.Println(i)
            time.Sleep(time.Millisecond * 10)
            done <- struct{}{}
        }(i, &m)
    }
    for i := 0; i < cap(done); i++ {
        <-done
    }
}

完整示例可在以下链接找到:play.golang.org/p/resVh7LImLf

我们使用通道来在作业完成时向主 goroutine 发出信号,并退出应用程序。让我们创建一个外部计数器,并使用 goroutine 并发地增加它。

在不同 goroutine 上执行的操作不是线程安全的,如下例所示:

done := make(chan struct{}, 10000)
var a = 0
for i := 0; i < cap(done); i++ {
    go func(i int) {
        if i%2 == 0 {
            a++
        } else {
            a--
        }
        done <- struct{}{}
    }(i)
}
for i := 0; i < cap(done); i++ {
    <-done
}
fmt.Println(a)

我们期望得到 5000 加一和 5000 减一,最后一条指令打印出 0。然而,每次运行应用程序时,我们得到的值都不同。这是因为这种操作不是线程安全的,因此两个或更多的操作可能同时发生,最后一个操作会覆盖其他操作。这种现象被称为竞争条件;也就是说,多个操作试图写入相同的结果。

这意味着没有任何同步,结果是不可预测的;如果我们检查前面的示例并使用锁来避免竞争条件,我们将得到整数的值为零,这是我们期望的结果:

m := sync.Mutex{}
for i := 0; i < cap(done); i++ {
    go func(l sync.Locker, i int) {
        l.Lock()
        defer l.Unlock()
        if i%2 == 0 {
            a++
        } else {
            a--
        }
        done <- struct{}{}
    }(&m, i)
    fmt.Println(a)
}

一个非常常见的做法是在数据结构中嵌入一个互斥锁,以表示要锁定的容器。之前的计数器变量可以表示如下:

type counter struct {
    m     sync.Mutex
    value int
}

计数器执行的操作可以是已经在主要操作之前进行了锁定并在之后进行了解锁的方法,如下面的代码块所示:

func (c *counter) Incr(){
    c.m.Lock()
    c.value++
    c.m.Unlock()
}

func (c *counter) Decr(){
    c.m.Lock()
    c.value--
    c.m.Unlock()
}

func (c *counter) Value() int {
    c.m.Lock()
    a := c.value
    c.m.Unlock()
    return a
}

这将简化 goroutine 循环,使代码更清晰:

var a = counter{}
for i := 0; i < cap(done); i++ {
    go func(i int) {
        if i%2 == 0 {
            a.Incr()
        } else {
            a.Decr()
        }
        done <- struct{}{}
    }(i)
}
// ...
fmt.Println(a.Value())

RWMutex

竞争条件的问题是由并发写入引起的,而不是由读取操作引起的。实现 locker 接口的另一个数据结构sync.RWMutex,旨在支持这两种操作,具有独特的写锁和与读锁互斥。这意味着互斥锁可以被单个写锁或一个或多个读锁锁定。当读者锁定互斥锁时,其他试图锁定它的读者不会被阻塞。它们通常被称为共享-独占锁。这允许读操作同时发生,而不会有等待时间。

使用 locker 接口的LockUnlock方法执行写锁操作。使用另外两种方法执行读取操作:RLockRUnlock。还有另一种方法RLocker,它返回一个用于读取操作的 locker。

我们可以通过创建一个字符串的并发列表来快速演示它们的用法:

type list struct {
    m sync.RWMutex
    value []string
}

我们可以迭代切片以查找所选值,并在读取时使用读锁来延迟写入:

func (l *list) contains(v string) bool {
    for _, s := range l.value {
        if s == v {
            return true
        }
    }
    return false
}

func (l *list) Contains(v string) bool {
    l.m.RLock()
    found := l.contains(v)
    l.m.RUnlock()
    return found
}

在添加新元素时,我们可以使用写锁:

func (l *list) Add(v string) bool {
    l.m.Lock()
    defer l.m.Unlock()
    if l.contains(v) {
        return false
    }
    l.value = append(l.value, v)
    return true
}

然后我们可以尝试使用多个 goroutines 在列表上执行相同的操作:

var src = []string{
    "Ryu", "Ken", "E. Honda", "Guile",
    "Chun-Li", "Blanka", "Zangief", "Dhalsim",
}
var l list
for i := 0; i < 10; i++ {
    go func(i int) {
        for _, s := range src {
            go func(s string) {
                if !l.Contains(s) {
                    if l.Add(s) {
                        fmt.Println(i, "add", s)
                    } else {
                        fmt.Println(i, "too slow", s)
                    }
                }
            }(s)
        }
    }(i)
}
time.Sleep(500 * time.Millisecond)

首先我们检查名称是否包含在锁中,然后尝试添加元素。这会导致多个例程尝试添加新元素,但由于写锁是排他的,只有一个会成功。

写入饥饿

在设计应用程序时,这种类型的互斥锁并不总是显而易见的选择,因为在读锁的数量更多而写锁的数量较少的情况下,互斥锁将在第一个读锁之后接受更多的读锁,让写入操作等待没有活动的读锁的时刻。这是一种被称为写入饥饿的现象。

为了验证这一点,我们可以定义一个类型,其中包含写入和读取操作,这需要一些时间,如下面的代码所示:

type counter struct {
    m sync.RWMutex
    value int
}

func (c *counter) Write(i int) {
    c.m.Lock()
    time.Sleep(time.Millisecond * 100)
    c.value = i
    c.m.Unlock()
}

func (c *counter) Value() int {
    c.m.RLock()
    time.Sleep(time.Millisecond * 100)
    a := c.value
    c.m.RUnlock()
    return a
}

我们可以尝试在单独的 goroutines 中以相同的节奏执行写入和读取操作,使用低于方法执行时间的持续时间(50 毫秒与 100 毫秒)。我们还将检查它们在锁定状态下花费了多少时间:

var c counter
t1 := time.NewTicker(time.Millisecond * 50)
time.AfterFunc(time.Second*2, t1.Stop)
for {
    select {
    case <-t1.C:
        go func() {
            t := time.Now()
            c.Value()
            fmt.Println("val", time.Since(t))
        }()
        go func() {
            t := time.Now()
            c.Write(0)
            fmt.Println("inc", time.Since(t))
        }()
    case <-time.After(time.Millisecond * 200):
        return
    }
}

如果我们执行应用程序,我们会发现对于每个写入操作,都会执行多次读取,并且每次调用都会花费比上一次更多的时间,等待锁。这对于读取操作并不成立,因为它可以同时进行,所以一旦读者成功锁定资源,所有其他等待的读者也会这样做。将RWMutex替换为Mutex将使两种操作具有相同的优先级,就像前面的例子一样。

锁定陷阱

在锁定和解锁互斥锁时必须小心,以避免应用程序中的意外行为和死锁。参考以下代码片段:

for condition {
    mu.Lock()
    defer mu.Unlock()
    action()
}

这段代码乍一看似乎没问题,但它将不可避免地阻塞 goroutine。这是因为defer语句不是在每次循环迭代结束时执行,而是在函数返回时执行。因此,第一次尝试将锁定而不释放,第二次尝试将保持锁定状态。

稍微重构一下可以帮助解决这个问题,如下面的代码片段所示:

for condition {
    func() {
        mu.Lock()
        defer mu.Unlock()
        action()
    }()
}

我们可以使用闭包来确保即使action发生恐慌,也会执行延迟的Unlock

如果在互斥锁上执行的操作不会引起恐慌,可以考虑放弃延迟,只在执行操作后使用它,如下所示:

for condition {
    mu.Lock()
    action()
    mu.Unlock()
}

defer是有成本的,因此最好在不必要时避免使用它,例如在进行简单的变量读取或赋值时。

同步 goroutines

到目前为止,为了等待 goroutines 完成,我们使用了一个空结构的通道,并通过通道发送一个值作为最后一个操作,如下所示:

ch := make(chan struct{})
for i := 0; i < n; n++ {
    go func() {
        // do something
        ch <- struct{}{}
    }()
}
for i := 0; i < n; n++ {
    <-ch
}

这种策略有效,但不是实现任务的首选方式。从语义上讲不正确,因为我们使用通道,而通道是用于通信的工具,用于发送空数据。这种用例是关于同步而不是通信。这就是为什么有sync.WaitGroup数据结构,它涵盖了这种情况。它有一个主要状态,称为计数器,表示等待的元素数量:

type WaitGroup struct {
    noCopy noCopy
    state1 [3]uint32
}

noCopy字段防止结构通过panic按值复制。状态是由三个int32组成的数组,但只使用第一个和最后一个条目;剩下的一个用于编译器优化。

WaitGroup提供了三种方法来实现相同的结果:

  • Add:使用给定值更改计数器的值,该值也可以是负数。如果计数器小于零,应用程序将会 panic。

  • Done:这是Add的简写,参数为-1。通常在 goroutine 完成其工作时调用,将计数器减 1。

  • Wait:此操作会阻塞当前 goroutine,直到计数器达到零。

使用等待组可以使代码更清晰和可读,如下例所示:

func main() {
    wg := sync.WaitGroup{}
    wg.Add(10)
    for i := 1; i <= 10; i++ {
        go func(a int) {
            for i := 1; i <= 10; i++ {
                fmt.Printf("%dx%d=%d\n", a, i, a*i)
            }
            wg.Done()
        }(i)
    }
    wg.Wait()
}

对于等待组,我们正在添加一个等于 goroutines 的delta,我们将在之前启动。在每个单独的 goroutine 中,我们使用Done方法来减少计数。如果 goroutines 的数量未知,则可以在启动每个 goroutine 之前执行Add操作(参数为1),如下所示:

func main() {
    wg := sync.WaitGroup{}
    for i := 1; rand.Intn(10) != 0; i++ {
        wg.Add(1)
        go func(a int) {
            for i := 1; i <= 10; i++ {
                fmt.Printf("%dx%d=%d\n", a, i, a*i)
            }
            wg.Done()
        }(i)
    }
    wg.Wait()
}

在前面的示例中,我们每次for循环迭代有 10%的机会完成,因此在启动 goroutine 之前我们会向组中添加一个。

一个非常常见的错误是在 goroutine 内部添加值,这通常会导致在没有执行任何 goroutine 的情况下过早退出。这是因为应用程序在创建 goroutines 并执行Wait函数之前开始并添加它们自己的增量,如下例所示:

func main() {
    wg := sync.WaitGroup{}
    for i := 1; i < 10; i++ {
        go func(a int) {
            wg.Add(1)
            for i := 1; i <= 10; i++ {
                fmt.Printf("%dx%d=%d\n", a, i, a*i)
            }
            wg.Done()
        }(i)
    }
    wg.Wait()
}

此应用程序不会打印任何内容,因为它在任何 goroutine 启动和调用Add方法之前到达Wait语句。

Go 中的单例

单例模式是软件开发中常用的策略。这涉及将某种类型的实例数量限制为一个,并在整个应用程序中使用相同的实例。该概念的一个非常简单的实现可能是以下代码:

type obj struct {}

var instance *obj

func Get() *obj{
    if instance == nil {
        instance = &obj{}
    }
    return instance
}

这在连续的情况下是完全可以的,但在并发的情况下,就像许多 Go 应用程序一样,这是不安全的,并且可能会产生竞争条件。

通过添加一个锁,可以使前面的示例线程安全,从而避免任何竞争条件,如下所示:

type obj struct {}

var (
    instance *obj
    lock     sync.Mutex
)

func Get() *obj{
    lock.Lock()
    defer lock.Unlock()
    if instance == nil {
        instance = &obj{}
    }
    return instance
}

这是安全的,但速度较慢,因为Mutex将在每次请求实例时进行同步。

实现此模式的最佳解决方案是使用sync.Once结构,如下例所示,它负责使用Mutexatomic读取一次执行函数(我们将在本章的第二部分中看到):

type obj struct {}

var (
    instance *obj
    once     sync.Once
)

func Get() *obj{
    once.Do(func(){
        instance = &obj{}
    })
    return instance
}

结果代码是惯用的和清晰的,与互斥解决方案相比性能更好。由于操作只会执行一次,我们还可以摆脱在先前示例中对实例进行的nil检查。

一次和重置

sync.Once函数用于执行另一个函数一次,不再执行。有一个非常有用的第三方库,允许我们使用Reset方法重置单例的状态。

包的源代码可以在以下位置找到:github.com/matryer/resync

典型用途包括一些需要在特定错误上再次执行的初始化,例如获取 API 密钥或在连接中断时重新拨号。

资源回收

我们已经看到如何在上一章中使用具有工作池的缓冲通道来实现资源回收。将有两种方法如下:

  • 一个Get方法,尝试从通道接收消息或返回一个新实例。

  • 一个Put方法,尝试将实例返回到通道或丢弃它。

这是一个使用通道实现的简单池的实现:

type A struct{}

type Pool chan *A

func (p Pool) Get() *A {
    select {
    case a := <-p:
        return a
    default:
        return new(A)
    }
}

func (p Pool) Put(a *A) {
    select {
    case p <- a:
    default:
    }
}

我们可以使用sync.Pool结构来改进这一点,它实现了一个线程安全的对象集,可以保存或检索。唯一需要定义的是当创建一个新对象时池的行为:

type Pool struct {
    // New optionally specifies a function to generate
    // a value when Get would otherwise return nil.
    // It may not be changed concurrently with calls to Get.
    New func() interface{}
    // contains filtered or unexported fields
}

池提供两种方法:GetPut。这些方法从池中返回对象(或创建新对象),并将对象放回池中。由于Get方法返回一个interface{},因此需要将值转换为特定类型才能正确使用。我们已经广泛讨论了缓冲区回收,在以下示例中,我们将尝试使用sync.Pool来实现缓冲区回收。

我们需要定义池和函数来获取和释放新的缓冲区。我们的缓冲区将具有 4 KB 的初始容量,并且Put函数将确保在将其放回池之前重置缓冲区,如以下代码示例所示:

var pool = sync.Pool{
    New: func() interface{} {
        return bytes.NewBuffer(make([]byte, 0, 4096))
    },
}

func Get() *bytes.Buffer {
    return pool.Get().(*bytes.Buffer)
}

func Put(b *bytes.Buffer) {
    b.Reset()
    pool.Put(b)
}

现在我们将创建一系列 goroutine,它们将使用WaitGroup来在完成时发出信号,并将执行以下操作:

  • 等待一定时间(1-5 秒)。

  • 获取一个缓冲区。

  • 在缓冲区上写入信息。

  • 将内容复制到标准输出。

  • 释放缓冲区。

我们将使用等于1秒的睡眠时间,每4次循环增加一秒,最多达到5秒:

start := time.Now()
wg := sync.WaitGroup{}
wg.Add(20)
for i := 0; i < 20; i++ {
    go func(v int) {
        time.Sleep(time.Second * time.Duration(1+v/4))
        b := Get()
        defer func() {
            Put(b)
            wg.Done()
        }()
        fmt.Fprintf(b, "Goroutine %2d using %p, after %.0fs\n", v, b, time.Since(start).Seconds())
        fmt.Printf("%s", b.Bytes())
    }(i)
}
wg.Wait()

打印的信息还包含缓冲区内存地址。这将帮助我们确认缓冲区始终相同,没有创建新的缓冲区。

切片回收问题

对于具有基础字节片的数据结构,例如bytes.Buffer,在与sync.Pool或类似的回收机制结合使用时,我们应该小心。让我们改变先前的示例,收集缓冲区的字节而不是将它们打印到标准输出。以下是此示例的示例代码:

var (
    list = make([][]byte, 20)
    m sync.Mutex
)
for i := 0; i < 20; i++ {
    go func(v int) {
        time.Sleep(time.Second * time.Duration(1+v/4))
        b := Get()
        defer func() {
            Put(b)
            wg.Done()
        }()
        fmt.Fprintf(b, "Goroutine %2d using %p, after %.0fs\n", v, b, time.Since(start).Seconds())
        m.Lock()
        list[v] = b.Bytes()
        m.Unlock()
    }(i)
}
wg.Wait()

那么,当我们打印字节片段列表时会发生什么?我们可以在以下示例中看到这一点:


for i := range list {
    fmt.Printf("%d - %s", i, list[i])
}

由于缓冲区正在重用相同的基础切片,并且在每次新使用时覆盖内容,因此我们得到了意外的结果。

通常解决此问题的方法是执行字节的副本,而不仅仅是分配它们:

m.Lock()
list[v] = make([]byte, b.Len())
copy(list[v], b.Bytes())
m.Unlock()

条件

在并发编程中,条件变量是一个同步机制,其中包含等待相同条件进行验证的线程。在 Go 中,这意味着有一些 goroutine 在等待某些事情发生。我们已经使用单个 goroutine 等待通道的实现,如以下示例所示:

ch := make(chan struct{})
go func() {
    // do something
    ch <- struct{}{}
}()
go func() {
    // wait for condition
    <-ch
    // do something else
}

这种方法仅限于单个 goroutine,但可以改进为支持更多侦听器,从发送消息切换到关闭通道:

go func() {
    // do something
    close(ch)
}()
for i := 0; i < n; i++ {
    go func() {
        // wait for condition
        <-ch
        // do something else
    }()
}

关闭通道适用于多个侦听器,但在关闭后不允许它们进一步使用通道。

sync.Cond类型是一个工具,可以更好地处理所有这些行为。它在实现中使用锁,并公开三种方法:

  • Broadcast:这会唤醒等待条件的所有 goroutine。

  • Signal:如果至少有一个条件,则唤醒等待条件的单个 goroutine。

  • Wait:这会解锁锁定器,暂停 goroutine 的执行,稍后恢复执行并再次锁定它,等待BroadcastSignal

这不是必需的,但可以在持有锁时执行BroadcastSignal操作,在调用之前锁定它,之后释放它。Wait方法要求在调用之前持有锁,并在使用条件后解锁。

让我们创建一个并发应用程序,该应用程序使用sync.Cond来协调更多的 goroutines。我们将从命令行获得提示,每条记录将被写入一系列文件。我们将有一个主结构来保存所有数据:

type record struct {
    sync.Mutex
    buf string
    cond *sync.Cond
    writers []io.Writer
}

我们将监视的条件是buf字段的更改。在Run方法中,record结构将启动多个 goroutines,每个写入者一个。每个 goroutine 将等待条件触发,并将写入其文件:

func (r *record) Run() {
    for i := range r.writers {
        go func(i int) {
            for {
                r.Lock()
                r.cond.Wait()
                fmt.Fprintf(r.writers[i], "%s\n", r.buf)
                r.Unlock()
            }
        }(i)
    }
}

我们可以看到,在使用Wait之前锁定条件,并在使用我们条件引用的值之后解锁它。主函数将根据提供的命令行参数创建一个记录和一系列文件:

// let's make sure we have at least a file argument
if len(os.Args) < 2 {
    log.Fatal("Please specify at least a file")
}
r := record{
    writers: make([]io.Writer, len(os.Args)-1),
}
r.cond = sync.NewCond(&r)
for i, v := range os.Args[1:] {
    f, err := os.Create(v)
    if err != nil {
        log.Fatal(err)
    }
    defer f.Close()
    r.writers[i] = f
}
r.Run()

然后我们将使用bufio.Scanner读取行并广播buf字段的更改。我们还将接受特殊值\q作为退出命令:

scanner := bufio.NewScanner(os.Stdin)
for {
    fmt.Printf(":> ")
    if !scanner.Scan() {
        break
    }
    r.Lock()
    r.buf = scanner.Text()
    r.Unlock()
    switch {
    case r.buf == `\q`:
        return
    default:
        r.cond.Broadcast()
    }
}

我们可以看到,在持有锁时更改buf,然后调用Broadcast唤醒等待条件的所有 goroutines。

同步地图

Go 中的内置地图不是线程安全的,因此尝试从不同的 goroutines 进行写入可能会导致运行时错误:concurrent map writes。我们可以使用一个简单的程序来验证这一点,该程序尝试并发进行更改:

func main() {
    var m = map[int]int{}
    wg := sync.WaitGroup{}
    wg.Add(10)
    for i := 0; i < 10; i++ {
        go func(i int) {
            m[i%5]++
            fmt.Println(m)
            wg.Done()
        }(i)
    }
    wg.Wait()
}

在写入时进行读取也会导致运行时错误,即concurrent map iteration and map write,我们可以通过运行以下示例来看到这一点:

func main() {
    var m = map[int]int{}
    var done = make(chan struct{})
    go func() {
        for i := 0; i < 100; i++ {
            time.Sleep(time.Nanosecond)
            m[i]++
        }
        close(done)
    }()
    for {
        time.Sleep(time.Nanosecond)
        fmt.Println(len(m), m)
        select {
        case <-done:
            return
        default:
        }
    }
}

有时,尝试迭代地图(如Print语句所做的那样)可能会导致恐慌,例如index out of range,因为内部切片可能已经在其他地方分配了。

使地图并发的一个非常简单的策略是将其与sync.Mutexsync.RWMutex配对。这样可以在执行操作时锁定地图:

type m struct {
    sync.Mutex
    m map[int]int
}

我们使用地图来获取或设置值,例如以下示例:

func (m *m) Get(key int) int {
    m.Lock()
    a := m.m[key]
    m.Unlock()
    return a
}

func (m *m) Put(key, value int) {
    m.Lock()
    m.m[key] = value
    m.Unlock()
}

我们还可以传递一个接受键值对并对每个元组执行的函数,同时锁定地图:

func (m *m) Range(f func(k, v int)) {
    m.Lock()
    for k, v := range m.m {
        f(k, v)
    }
    m.Unlock()
}

Go 1.9 引入了一个名为sync.Map的结构,它正是这样做的。它是一个非常通用的map[interface{}]interface{},可以使用以下方法执行线程安全操作:

  • Load:从地图中获取给定键的值。

  • Store:为给定的键在地图中设置一个值。

  • Delete:从地图中删除给定键的条目。

  • LoadOrStore:返回键的值(如果存在)或存储的值。

  • Range:调用一个函数,该函数针对地图中的每个键值对返回一个布尔值。如果返回false,则迭代停止。

我们可以在以下代码片段中看到这是如何工作的,我们尝试同时进行多次写入:

func main() {
    var m = sync.Map{}
    var wg = sync.WaitGroup{}
    wg.Add(1000)
    for i := 0; i < 1000; i++ {
        go func(i int) {
            m.LoadOrStore(i, i)
            wg.Done()
        }(i)
    }
    wg.Wait()
    i := 0
    m.Range(func(k, v interface{}) bool {
        i++
        return true
    })
   fmt.Println(i)
}

与具有常规Map的版本不同,此应用程序不会崩溃并执行所有操作。

信号量

在上一章中,我们看到可以使用通道创建加权信号量。在实验性的sync包中有更好的实现。可以在golang.org/x/sync/semaphore找到。

这种实现使得可以创建一个新的信号量,使用semaphore.NewWeighted指定权重。

可以使用Acquire方法获取配额,指定要获取的配额数量。这些可以使用Release方法释放,如以下示例所示:

func main() {
    s := semaphore.NewWeighted(int64(10))
    ctx := context.Background()
    for i := 0; i < 20; i++ {
        if err := s.Acquire(ctx, 1); err != nil {
            log.Fatal(err)
        }
        go func(i int) {
            fmt.Println(i)
            s.Release(1)
        }(i)
    }
    time.Sleep(time.Second)
}

获取配额除了数字之外还需要另一个参数,即context.Context。这是 Go 中可用的另一个并发工具,我们将在下一章中看到如何使用它。

原子操作

sync包提供了同步原语,在底层使用整数和指针的线程安全操作。我们可以在另一个名为sync/atomic的包中找到这些功能,该包可用于创建特定于用户用例的工具,具有更好的性能和更少的内存使用。

整数操作

有一系列针对不同类型整数的指针的函数:

  • int32

  • int64

  • uint32

  • uint64

  • uintptr

这包括表示指针的特定类型的整数,uintptr。这些类型可用的操作如下:

  • Load:从指针中检索整数值

  • Store:将整数值存储在指针中

  • Add:将指定的增量添加到指针值

  • Swap:将新值存储在指针中并返回旧值

  • CompareAndSwap:仅当新值与指定值相同时才将其交换

点击器

这个函数对于非常容易定义线程安全的组件非常有帮助。一个非常明显的例子可能是一个简单的整数计数器,它使用Add来改变计数器,Load来检索当前值,Store来重置它:

type clicker int32

func (c *clicker) Click() int32 {
    return atomic.AddInt32((*int32)(c), 1)
}

func (c *clicker) Reset() {
    atomic.StoreInt32((*int32)(c), 0)
}

func (c *clicker) Value() int32 {
    return atomic.LoadInt32((*int32)(c))
}

我们可以在一个简单的程序中看到它的运行情况,该程序尝试同时读取、写入和重置计数器。

我们定义clickerWaitGroup,并将正确数量的元素添加到等待组中,如下所示:

c := clicker(0)
wg := sync.WaitGroup{}
// 2*iteration + reset at 5
wg.Add(21)

我们可以启动一堆不同操作的 goroutines,比如:10 次读取,10 次添加和一次重置:

for i := 0; i < 10; i++ {
    go func() {
        c.Click()
        fmt.Println("click")
        wg.Done()
    }()
    go func() {
        fmt.Println("load", c.Value())
        wg.Done()
    }()
    if i == 0 || i%5 != 0 {
        continue
    }
    go func() {
        c.Reset()
        fmt.Println("reset")
        wg.Done()
    }()
}
wg.Wait()

我们将看到点击器按照预期的方式执行并发求和而没有竞争条件。

线程安全的浮点数

atomic包仅提供整数的原语,但由于float32float64存储在与int32int64相同的数据结构中,我们使用它们来创建原子浮点值。

诀窍是使用math.Floatbits函数将浮点数表示为无符号整数,以及使用math.Floatfrombits函数将无符号整数转换为浮点数。让我们看看这如何在float64中工作:

type f64 uint64

func uf(u uint64) (f float64) { return math.Float64frombits(u) }
func fu(f float64) (u uint64) { return math.Float64bits(f) }

func newF64(f float64) *f64 {
    v := f64(fu(f))
    return &v
}

func (f *f64) Load() float64 {
  return uf(atomic.LoadUint64((*uint64)(f)))
}

func (f *f64) Store(s float64) {
  atomic.StoreUint64((*uint64)(f), fu(s))
}

创建Add函数有点复杂。我们需要使用Load获取值,然后比较和交换。由于这个操作可能失败,因为加载是一个atomic操作,比较和交换CAS)是另一个,我们在循环中不断尝试直到成功:

func (f *f64) Add(s float64) float64 {
    for {
        old := f.Load()
        new := old + s
        if f.CompareAndSwap(old, new) {
            return new
        }
    }
}

func (f *f64) CompareAndSwap(old, new float64) bool {
    return atomic.CompareAndSwapUint64((*uint64)(f), fu(old), fu(new))
}

线程安全的布尔值

我们也可以使用int32来表示布尔值。我们可以使用整数0作为false1作为true,创建一个线程安全的布尔条件:

type cond int32

func (c *cond) Set(v bool) {
    a := int32(0)
    if v {
        a++
    }
    atomic.StoreInt32((*int32)(c), a)
}

func (c *cond) Value() bool {
    return atomic.LoadInt32((*int32)(c)) != 0
}

这将允许我们将cond类型用作线程安全的布尔值。

指针操作

Go 中的指针变量存储在intptr变量中,这些整数足够大以容纳内存地址。atomic包使得可以对其他整数类型执行相同的操作。有一个允许不安全指针操作的包,它提供了unsafe.Pointer类型,用于原子操作。

在下面的示例中,我们定义了两个整数变量及其相关的整数指针。然后我们执行第一个指针与第二个指针的交换:

v1, v2 := 10, 100
p1, p2 := &v1, &v2
log.Printf("P1: %v, P2: %v", *p1, *p2)
atomic.SwapPointer((*unsafe.Pointer)(unsafe.Pointer(&p1)), unsafe.Pointer(p2))
log.Printf("P1: %v, P2: %v", *p1, *p2)
v1 = -10
log.Printf("P1: %v, P2: %v", *p1, *p2)
v2 = 3
log.Printf("P1: %v, P2: %v", *p1, *p2)

交换后,两个指针现在都指向第二个变量;对第一个值的任何更改都不会影响指针。更改第二个变量会改变指针所指的值。

我们可以使用的最简单的工具是atomic.Value。它保存interface{},并且可以通过线程安全地读取和写入它。它公开了两种方法,StoreLoad,这使得设置或检索值成为可能。正如其他线程安全工具一样,sync.Value在第一次使用后不能被复制。

我们可以尝试有许多 goroutines 来设置和读取相同的值。每次加载操作都会获取最新存储的值,并且并发时不会出现错误:

func main() {
    var (
        v atomic.Value
        wg sync.WaitGroup
    )
    wg.Add(20)
    for i := 0; i < 10; i++ {
        go func(i int) {
            fmt.Println("load", v.Load())
            wg.Done()
        }(i)
        go func(i int) {
            v.Store(i)
            fmt.Println("store", i)
            wg.Done()
        }(i)
    }
    wg.Wait()
}

这是一个非常通用的容器;它可以用于任何类型的变量,变量类型应该从一个变为另一个。如果具体类型发生变化,它将使方法恐慌;同样的情况也适用于nil空接口。

底层

sync.Value类型将其数据存储在一个非公开的接口中,如源代码所示:

type Value struct {
    v interface{}
}

它使用unsafe包的一种类型来将该结构转换为另一个具有与接口相同的数据结构:

type ifaceWords struct {
    typ unsafe.Pointer
    data unsafe.Pointer
}

具有完全相同内存布局的两种类型可以以这种方式转换,跳过 Go 的类型安全性。这使得可以使用指针进行 atomic 操作,并执行线程安全的 StoreLoad 操作。

为了写入值获取锁,atomic.Value 使用与类型中的 unsafe.Pointer(^uintptr(0)) 值(即 0xffffffff)进行比较和交换操作;它改变值并用正确的值替换类型。

同样,加载操作会循环,直到类型不同于 0xffffffff,然后尝试读取值。

使用这种方法,atomic.Value 能够使用其他 atomic 操作存储和加载任何值。

总结

在本章中,我们看到了 Go 标准包中用于同步的工具。它们位于两个包中:sync,提供诸如互斥锁之类的高级工具,以及 sync/atomic,执行低级操作。

首先,我们看到了如何使用锁同步数据。我们看到了如何使用 sync.Mutex 来锁定资源,而不管操作类型如何,并使用 sync.RWMutex 允许并发读取和阻塞写入。我们应该小心使用第二个,因为连续读取可能会延迟写入。

接下来,我们看到了如何跟踪正在运行的操作,以便等待一系列 goroutine 的结束,使用 sync.WaitGroup。这充当当前 goroutine 的线程安全计数器,并使得可以使用 Wait 方法将当前 goroutine 置于休眠状态,直到计数达到零。

此外,我们检查了 sync.Once 结构,用于执行功能一次,例如允许实现线程安全的单例。然后我们使用 sync.Pool 在可能的情况下重用实例而不是创建新实例。池需要的唯一东西是返回新实例的函数。

sync.Condition 结构表示特定条件并使用锁来改变它,允许 goroutine 等待改变。这可以使用 Signal 传递给单个 goroutine,或者使用 Broadcast 传递给所有 goroutine。该包还提供了 sync.Map 的线程安全版本。

最后,我们检查了 atomic 的功能,这些功能主要是整数线程安全操作:加载、保存、添加、交换和 CAS。我们还看到了 atomic.Value,它使得可以并发更改接口的值,并且在第一次更改后不允许更改类型。

下一章将介绍 Go 并发中引入的最新元素:Context,这是一个处理截止日期、取消等的接口。

问题

  1. 什么是竞争条件?

  2. 当您尝试并发执行地图的读取和写入操作时会发生什么?

  3. MutexRWMutex 之间有什么区别?

  4. 等待组有什么用?

  5. Once 的主要用途是什么?

  6. 您如何使用 Pool

  7. 使用原子操作的优势是什么?

第十三章:使用上下文进行协调

本章是关于相对较新的上下文包及其在并发编程中的使用。它是一个非常强大的工具,通过定义一个在标准库中的许多不同位置以及许多第三方包中使用的独特接口。

本章将涵盖以下主题:

  • 理解上下文是什么

  • 在标准库中研究其用法

  • 创建使用上下文的包

技术要求

本章需要安装 Go 并设置您喜欢的编辑器。有关更多信息,请参阅第三章,Go 概述

理解上下文

上下文是在 1.7 版本中进入标准库的相对较新的组件。它是用于 goroutine 之间同步的接口,最初由 Go 团队内部使用,最终成为语言的核心部分。

接口

该包中的主要实体是Context本身,它是一个接口。它只有四种方法:

type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key interface{}) interface{}
}

让我们在这里了解这四种方法:

  • Deadline:返回上下文应该被取消的时间,以及一个布尔值,当没有截止日期时为false

  • Done:返回一个只接收空结构的通道,用于信号上下文应该被取消

  • Err:当done通道打开时返回nil;否则返回上下文取消的原因

  • Value:返回与当前上下文中的键关联的值,如果该键没有值,则返回nil

与标准库的其他接口相比,上下文具有许多方法,通常只有一两个方法。其中三个方法密切相关:

  • Deadline是取消的时间

  • Done信号上下文已完成

  • Err返回取消的原因

最后一个方法Value返回与某个键关联的值。包的其余部分是一系列函数,允许您创建不同类型的上下文。让我们浏览包含在该包中的各种函数,并查看创建和装饰上下文的各种工具。

默认上下文

TODOBackground函数返回context.Context,无需任何输入参数。返回的值是一个空上下文,它们之间的区别只是语义上的。

Background

Background是一个空上下文,不会被取消,没有截止日期,也不保存任何值。它主要由main函数用作根上下文或用于测试目的。以下是此上下文的一些示例代码:

func main() {
    ctx := context.Background()
    done := ctx.Done()
    for i :=0; ;i++{
        select {
        case <-done:
            return
        case <-time.After(time.Second):
            fmt.Println("tick", i)
        }
    }
}

完整示例可在此处找到:play.golang.org/p/y_3ip7sdPnx

我们可以看到,在示例的上下文中,循环无限进行,因为上下文从未完成。

TODO

TODO是另一个空上下文,当上下文的范围不清楚或上下文的类型尚不可用时应使用。它的使用方式与Background完全相同。实际上,在底层,它们是相同的东西;区别只是语义上的。如果我们查看源代码,它们具有完全相同的定义:

var (
    background = new(emptyCtx)
    todo = new(emptyCtx)
)

该代码的源代码可以在golang.org/pkg/context/?m=all#pkg-variables找到。

可以使用包的其他函数来扩展这些基本上下文。它们将充当装饰器,并为它们添加更多功能。

取消、超时和截止日期

我们查看的上下文从未被取消,但该包提供了不同的选项来添加此功能。

取消

context.WithCancel装饰器函数获取一个上下文并返回另一个上下文和一个名为cancel的函数。返回的上下文将是具有不同done通道(标记当前上下文完成的通道)的上下文的副本,当父上下文完成或调用cancel函数时关闭该通道-无论哪个先发生。

在以下示例中,我们可以看到在调用cancel函数之前等待几秒钟,程序正确终止。Err的值是context.Canceled变量:

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    time.AfterFunc(time.Second*5, cancel)
    done := ctx.Done()
    for i := 0; ; i++ {
        select {
        case <-done:
            fmt.Println("exit", ctx.Err())
            return
        case <-time.After(time.Second):
            fmt.Println("tick", i)
        }
    }
}

完整示例在这里:play.golang.org/p/fNHLIZL8e0L

截止时间

context.WithDeadline是另一个装饰器,它将time.Time作为时间截止时间,并将其应用于另一个上下文。如果已经有截止时间并且早于提供的截止时间,则指定的截止时间将被忽略。如果在截止时间到达时done通道仍然打开,则会自动关闭它。

在以下示例中,我们将截止时间设置为现在的 5 秒后,并在 10 秒后调用cancel。截止时间在取消之前到达,Err返回context.DeadlineExceeded错误:

func main() {
    ctx, cancel := context.WithDeadline(context.Background(), 
         time.Now().Add(5*time.Second))
    time.AfterFunc(time.Second*10, cancel)
    done := ctx.Done()
    for i := 0; ; i++ {
        select {
        case <-done:
            fmt.Println("exit", ctx.Err())
            return
        case <-time.After(time.Second):
            fmt.Println("tick", i)
        }
    }
}

完整示例在这里:play.golang.org/p/iyuOmd__CGH

我们可以看到前面的示例的行为与预期完全一致。它将打印tick语句每秒几次,直到截止时间到达并返回错误。

超时

最后一个与取消相关的装饰器是context.WithTimeout,它允许您指定time.Duration以及上下文,并在超时时自动关闭done通道。

如果有截止时间活动,则新值仅在早于父级时应用。我们可以看一个几乎相同的示例,除了上下文定义之外,得到与截止时间示例相同的结果:

func main() {
    ctx, cancel := context.WithTimeout(context.Background(),5*time.Second)
    time.AfterFunc(time.Second*10, cancel)
    done := ctx.Done()
    for i := 0; ; i++ {
        select {
        case <-done:
            fmt.Println("exit", ctx.Err())
            return
        case <-time.After(time.Second):
            fmt.Println("tick", i)
        }
    }
}

完整示例在这里:play.golang.org/p/-Zp63_e0zYD

键和值

context.WithValue函数创建了一个父上下文的副本,其中给定的键与指定的值相关联。它的范围包含相对于单个请求的值,而在处理过程中不应该用于其他范围,例如可选的函数参数。

键应该是可以比较的东西,最好避免使用string值,因为使用上下文的两个不同包可能会覆盖彼此的值。建议使用用户定义的具体类型,如struct{}

在这里,我们可以看到一个示例,我们使用空结构作为键,为每个 goroutine 添加不同的值:

type key struct{}

type key struct{}

func main() {
    ctx, canc := context.WithCancel(context.Background())
    wg := sync.WaitGroup{}
    wg.Add(5)
    for i := 0; i < 5; i++ {
        go func(ctx context.Context) {
            v := ctx.Value(key{})
            fmt.Println("key", v)
            wg.Done()
            <-ctx.Done()
            fmt.Println(ctx.Err(), v)
        }(context.WithValue(ctx, key{}, i))
    }
    wg.Wait()
    canc()
    time.Sleep(time.Second)
}

完整示例在这里:play.golang.org/p/lM61u_QKEW1

我们还可以看到取消父级会取消其他上下文。另一个有效的键类型可以是导出的指针值,即使底层数据相同也不会相同:

type key *int

func main() {
    k := new(key)
    ctx, canc := context.WithCancel(context.Background())
    wg := sync.WaitGroup{}
    wg.Add(5)
    for i := 0; i < 5; i++ {
        go func(ctx context.Context) {
            v := ctx.Value(k)
            fmt.Println("key", v, ctx.Value(new(key)))
            wg.Done()
            <-ctx.Done()
            fmt.Println(ctx.Err(), v)
        }(context.WithValue(ctx, k, i))
    }
    wg.Wait()
    canc()
    time.Sleep(time.Second)
}

完整示例在这里:play.golang.org/p/05XJwWF0-0n

我们可以看到,定义具有相同底层值的键指针不会返回预期的值。

标准库中的上下文

现在我们已经介绍了包的内容,我们将看看如何在标准包或应用程序中使用它们。上下文在标准包中的一些函数和方法中使用,主要是网络包。现在让我们来看看它们:

  • http.Server使用Shutdown方法,以便完全控制超时或取消操作。

  • http.Request允许您使用WithContext方法设置上下文。它还允许您使用Context获取当前上下文。

  • net包中,ListenDialLookup有一个使用Context来控制截止时间和超时的版本。

  • database/sql包中,上下文用于停止或超时许多不同的操作。

HTTP 请求

在官方包引入之前,每个与 HTTP 相关的框架都使用自己的版本上下文来存储与 HTTP 请求相关的数据。这导致了碎片化,并且在不重写中间件或任何特定绑定代码的情况下无法重用处理程序和中间件。

传递作用域值

http.Request中引入context.Context试图通过定义一个可以分配、恢复和在各种处理程序中使用的单一接口来解决这个问题。

缺点是上下文不会自动分配给请求,并且上下文值不能被回收利用。没有真正好的理由这样做,因为上下文应该存储特定于某个包或范围的数据,而包本身应该是唯一能够与它们交互的对象。

一个很好的模式是使用一个独特的未导出的密钥类型,结合辅助函数来获取或设置特定的值:

type keyType struct{}

var key = &keyType{}

func WithKey(ctx context.Context, value string) context.Context {
    return context.WithValue(ctx, key, value)
}

func GetKey(ctx context.Context) (string, bool) {
    v := ctx.Value(key)
    if v == nil {
        return "", false
    }
    return v.(string), true
}

上下文请求是标准库中唯一存储在数据结构中的情况,使用WithContext方法存储,并使用Context方法访问。这样做是为了不破坏现有代码,并保持 Go 1 的兼容性承诺。

完整示例在此处可用:play.golang.org/p/W6gGp_InoMp

请求取消

上下文的一个很好的用法是在使用http.Client执行 HTTP 请求时进行取消和超时处理,它会自动处理上下文中的中断。以下示例正是如此:

func main() {
    const addr = "localhost:8080"
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        time.Sleep(time.Second * 5)
    })
    go func() {
        if err := http.ListenAndServe(addr, nil); err != nil {
            log.Fatalln(err)
        }
    }()
    req, _ := http.NewRequest(http.MethodGet, "http://"+addr, nil)
    ctx, canc := context.WithTimeout(context.Background(), time.Second*2)
    defer canc()
    time.Sleep(time.Second)
    if _, err := http.DefaultClient.Do(req.WithContext(ctx)); err != nil {
        log.Fatalln(err)
    }
}

上下文取消方法也可以用于中断传递给客户端的当前 HTTP 请求。在调用不同的端点并返回收到的第一个结果的情况下,取消其他请求是一个好主意。

让我们创建一个应用程序,它在不同的搜索引擎上运行查询,并返回最快的结果,取消其他搜索。我们可以创建一个 Web 服务器,它有一个唯一的端点,在 0 到 10 秒内回复:

const addr = "localhost:8080"
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
    d := time.Second * time.Duration(rand.Intn(10))
    log.Println("wait", d)
    time.Sleep(d)
})
go func() {
    if err := http.ListenAndServe(addr, nil); err != nil {
        log.Fatalln(err)
    }
}()

我们可以为请求使用可取消的上下文,结合等待组将其与请求结束同步。每个 goroutine 将创建一个请求,并尝试使用通道发送结果。由于我们只对第一个感兴趣,我们将使用sync.Once来限制它:

ctx, canc := context.WithCancel(context.Background())
ch, o, wg := make(chan int), sync.Once{}, sync.WaitGroup{}
wg.Add(10)
for i := 0; i < 10; i++ {
    go func(i int) {
        defer wg.Done()
        req, _ := http.NewRequest(http.MethodGet, "http://"+addr, nil)
        if _, err := http.DefaultClient.Do(req.WithContext(ctx)); err != nil {
            log.Println(i, err)
            return
        }
        o.Do(func() { ch <- i })
    }(i)
}
log.Println("received", <-ch)
canc()
log.Println("cancelling")
wg.Wait()

当此程序运行时,我们将看到其中一个请求成功完成并发送到通道,而其他请求要么被取消,要么被忽略。

HTTP 服务器

net/http包中有几种上下文的用法,包括停止监听器或成为请求的一部分。

关闭

http.Server允许我们为关闭操作传递上下文。这使我们能够使用一些上下文的功能,如取消和超时。我们可以定义一个新的服务器及其mux和可取消的上下文:

mux := http.NewServeMux()
server := http.Server{
    Addr: ":3000",
    Handler: mux,
}
ctx, canc := context.WithCancel(context.Background())
defer canc()
mux.HandleFunc("/shutdown", func(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("OK"))
    canc()
})

我们可以在一个单独的 goroutine 中启动服务器:

go func() {
    if err := server.ListenAndServe(); err != nil {
        if err != http.ErrServerClosed {
            log.Fatal(err)
        }
    }
}()

当调用关闭端点并调用取消函数时,上下文将完成。我们可以等待该事件,然后使用具有超时的另一个上下文调用关闭方法:

select {
case <-ctx.Done():
    ctx, canc := context.WithTimeout(context.Background(), time.Second*5)
    defer canc()
    if err := server.Shutdown(ctx); err != nil {
        log.Fatalln("Shutdown:", err)
    } else {
        log.Println("Shutdown:", "ok")
    }
}

这将允许我们在超时内有效地终止服务器,之后将以错误终止。

传递值

服务器中上下文的另一个用法是在不同的 HTTP 处理程序之间传播值和取消。让我们看一个例子,每个请求都有一个整数类型的唯一密钥。我们将使用一对类似于使用整数的值的函数。生成新密钥将使用atomic完成:

type keyType struct{}

var key = &keyType{}

var counter int32

func WithKey(ctx context.Context) context.Context {
    return context.WithValue(ctx, key, atomic.AddInt32(&counter, 1))
}

func GetKey(ctx context.Context) (int32, bool) {
    v := ctx.Value(key)
    if v == nil {
        return 0, false
    }
    return v.(int32), true
}

现在,我们可以定义另一个函数,它接受任何 HTTP 处理程序,并在必要时创建上下文,并将密钥添加到其中:


func AssignKeyHandler(h http.Handler) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        ctx := r.Context()
        if ctx == nil {
            ctx = context.Background()
        }
        if _, ok := GetKey(ctx); !ok {
            ctx = WithKey(ctx)
        }
        h.ServeHTTP(w, r.WithContext(ctx))
    }
}

通过这样做,我们可以定义一个非常简单的处理程序,用于在特定根目录下提供文件。此函数将使用上下文中的键正确记录信息。它还将在尝试提供文件之前检查文件是否存在:

func ReadFileHandler(root string) http.HandlerFunc {
    root = filepath.Clean(root)
    return func(w http.ResponseWriter, r *http.Request) {
        k, _ := GetKey(r.Context())
        path := filepath.Join(root, r.URL.Path)
        log.Printf("[%d] requesting path %s", k, path)
        if !strings.HasPrefix(path, root) {
            http.Error(w, "not found", http.StatusNotFound)
            log.Printf("[%d] unauthorized %s", k, path)
            return
        }
        if stat, err := os.Stat(path); err != nil || stat.IsDir() {
            http.Error(w, "not found", http.StatusNotFound)
            log.Printf("[%d] not found %s", k, path)
            return
        }
        http.ServeFile(w, r, path)
        log.Printf("[%d] ok: %s", k, path)
    }
}

我们可以将这些处理程序组合起来,以便从不同文件夹(如主目录用户或临时目录)提供内容:

home, err := os.UserHomeDir()
if err != nil {
    log.Fatal(err)
}
tmp := os.TempDir()
mux := http.NewServeMux()
server := http.Server{
    Addr: ":3000",
    Handler: mux,
}

mux.Handle("/tmp/", http.StripPrefix("/tmp/", AssignKeyHandler(ReadFileHandler(tmp))))
mux.Handle("/home/", http.StripPrefix("/home/", AssignKeyHandler(ReadFileHandler(home))))
if err := server.ListenAndServe(); err != nil {
    if err != http.ErrServerClosed {
        log.Fatal(err)
    }
}

我们使用http.StipPrefix来删除路径的第一部分并获取相对路径,并将其传递给下面的处理程序。生成的服务器将使用上下文在处理程序之间传递键值——这允许我们创建另一个类似的处理程序,并使用AssignKeyHandler函数来包装处理程序,并使用GetKey(r.Context())来访问处理程序内部的键。

TCP 拨号

网络包提供了与上下文相关的功能,比如在拨号或监听传入连接时取消拨号。它允许我们在拨号连接时使用上下文的超时和取消功能。

取消连接

为了测试在 TCP 连接中使用上下文的用法,我们可以创建一个带有 TCP 服务器的 goroutine,在开始监听之前等待一段时间:

addr := os.Args[1]
go func() {
    time.Sleep(time.Second)
    listener, err := net.Listen("tcp", addr)
    if err != nil {
        log.Fatalln("Listener:", addr, err)
    }
    c, err := listener.Accept()
    if err != nil {
        log.Fatalln("Listener:", addr, err)
    }
    defer c.Close()
}()

我们可以使用一个比服务器等待时间更短的超时上下文。我们必须使用net.Dialer来在拨号操作中使用上下文:

ctx, canc := context.WithTimeout(context.Background(),   
    time.Millisecond*100)
defer canc()
conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", os.Args[1])
if err != nil {
    log.Fatalln("-> Connection:", err)
}
log.Println("-> Connection to", os.Args[1])
conn.Close()

该应用程序将尝试连接一小段时间,但最终在上下文过期时放弃,返回一个错误。

在想要从一系列端点建立单个连接的情况下,上下文取消将是一个完美的用例。所有连接尝试将共享相同的上下文,并且正确拨号的第一个连接将调用取消,停止其他尝试。我们将创建一个单个服务器,它正在监听我们将尝试拨打的地址之一:

list := []string{
    "localhost:9090",
    "localhost:9091",
    "localhost:9092",
}
go func() {
    listener, err := net.Listen("tcp", list[0])
    if err != nil {
        log.Fatalln("Listener:", list[0], err)
    }
    time.Sleep(time.Second * 5)
    c, err := listener.Accept()
    if err != nil {
        log.Fatalln("Listener:", list[0], err)
    }
    defer c.Close()
}()

然后,我们可以尝试拨打所有三个地址,并在其中一个连接时立即取消上下文。我们将使用WaitGroup与 goroutines 的结束进行同步:

ctx, canc := context.WithTimeout(context.Background(), time.Second*10)
defer canc()
wg := sync.WaitGroup{}
wg.Add(len(list))
for _, addr := range list {
    go func(addr string) {
        defer wg.Done()
        conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", addr)
        if err != nil {
            log.Println("-> Connection:", err)
            return
        }
        log.Println("-> Connection to", addr, "cancelling context")
        canc()
        conn.Close()
    }(addr)
}
wg.Wait()

在此程序的输出中,我们将看到一个连接成功,然后是其他尝试的取消错误。

数据库操作

在本书中我们不会讨论sql/database包,但为了完整起见,值得一提的是它也使用了上下文。它的大部分操作都有相应的上下文对应,例如:

  • 开始一个新的事务

  • 执行查询

  • 对数据库进行 ping

  • 准备查询

这就是标准库中使用上下文的包的内容。接下来,我们将尝试使用上下文构建一个包,以允许该包的用户取消请求。

实验性包

实验包中一个值得注意的例子使用了上下文,我们已经看过了——信号量。现在我们对上下文的用途有了更好的理解,很明显为什么获取操作也需要一个上下文作为参数。

在创建应用程序时,我们可以提供带有超时或取消的上下文,并相应地采取行动:

func main() {
    s := semaphore.NewWeighted(int64(5))
    ctx, canc := context.WithTimeout(context.Background(), time.Second)
    defer canc()
    wg := sync.WaitGroup{}
    wg.Add(20)
    for i := 0; i < 20; i++ {
        go func(i int) {
            defer wg.Done()
            if err := s.Acquire(ctx, 1); err != nil {
                fmt.Println(i, err)
                return
            }
            go func(i int) {
                fmt.Println(i)
                time.Sleep(time.Second / 2)
                s.Release(1)
            }(i)
        }(i)
    }
    wg.Wait()
}

运行此应用程序将显示,信号量在第一秒被获取,但之后上下文过期,所有剩余操作都失败了。

应用程序中的上下文

如果包或应用程序具有可能需要很长时间并且用户可以取消的操作,或者应该具有超时或截止日期等时间限制,那么context.Context是集成到其中的完美工具。

要避免的事情

尽管 Go 团队已经非常清楚地定义了上下文的范围,但开发人员一直以各种方式使用它——有些方式不太正统。让我们看看其中一些以及有哪些替代方案,而不是求助于上下文。

错误的键类型

避免的第一个做法是使用内置类型作为键。这是有问题的,因为它们可以被覆盖,因为具有相同内置值的两个接口被认为是相同的,如下例所示:

func main() {
    var a interface{} = "request-id"
    var b interface{} = "request-id"
    fmt.Println(a == b)

    ctx := context.Background()
    ctx = context.WithValue(ctx, a, "a")
    ctx = context.WithValue(ctx, b, "b")
    fmt.Println(ctx.Value(a), ctx.Value(b))
}

完整的示例在这里可用:play.golang.org/p/2W3noYQP5eh

第一个打印指令输出true,由于键是按值比较的,第二个赋值遮蔽了第一个,导致两个键的值相同。解决这个问题的一个潜在方法是使用空结构自定义类型,或者使用内置值的未导出指针。

传递参数

可能会发生这样的情况,你需要通过一系列函数调用长途跋涉。一个非常诱人的解决方案是使用上下文来存储该值,并且只在需要它的函数中调用它。通常不是一个好主意隐藏应该显式传递的必需参数。这会导致代码不够可读,因为它不会清楚地表明什么影响了某个函数的执行。

将函数传递到堆栈下仍然要好得多。如果参数列表变得太长,那么它可以被分组到一个或多个结构中,以便更易读。

让我们来看看以下函数:

func SomeFunc(ctx context.Context, 
    name, surname string, age int, 
    resourceID string, resourceName string) {}

参数可以按以下方式分组:

type User struct {
    Name string
    Surname string
    Age int
}

type Resource struct {
    ID string
    Name string
}

func SomeFunc(ctx context.Context, u User, r Resource) {}

可选参数

上下文应该用于传递可选参数,并且还用作一种类似于 Python kwargs 或 JavaScript arguments 的万能工具。将上下文用作行为的替代品可能会导致非常严重的问题,因为它可能导致变量的遮蔽,就像我们在context.WithValue的示例中看到的那样。

这种方法的另一个重大缺点是隐藏发生的事情,使代码更加晦涩。当涉及可选值时,更好的方法是使用指向结构参数的指针 - 这允许您完全避免传递结构与nil

假设你有以下代码:

// This function has two mandatory args and 4 optional ones
func SomeFunc(ctx context.Context, arg1, arg2 int, 
    opt1, opt2, opt3, opt4 string) {}

通过使用Optional,你会得到这样的东西:

type Optional struct {
    Opt1 string
    Opt2 string
    Opt3 string
    Opt4 string
}

// This function has two mandatory args and 4 optional ones
func SomeFunc(ctx context.Context, arg1, arg2 int, o *Optional) {}

全局变量

一些全局变量可以存储在上下文中,以便它们可以通过一系列函数调用传递。这通常不是一个好的做法,因为全局变量在应用程序的每个点都可用,因此使用上下文来存储和调用它们是毫无意义的,而且是资源和性能的浪费。如果您的包有一些全局变量,您可以使用我们在第十二章中看到的 Singleton 模式,使用 sync 和 atomic 进行同步,允许从包或应用程序的任何点访问它们。

使用上下文构建服务

我们现在将专注于如何创建支持上下文使用的包。这将帮助我们整合到目前为止学到的有关并发性的知识。我们将尝试创建一个并发文件搜索,使用通道、goroutine、同步和上下文。

主接口和用法

包的签名将包括上下文、根文件夹、搜索项和一对可选参数:

  • 在内容中搜索:将在文件内容中查找字符串,而不是名称

  • 排除列表:不会搜索具有所选名称/名称的文件

该函数看起来可能是这样的:

type Options struct {
    Contents bool
    Exclude []string
}

func FileSearch(ctx context.Context, root, term string, o *Options)

由于它应该是一个并发函数,返回类型可以是结果的通道,它可以是错误,也可以是文件中一系列匹配项。由于我们可以搜索内容的名称,后者可能有多个匹配项:

type Result struct {
    Err error
    File string
    Matches []Match
}

type Match struct {
    Line int
    Text string
}

前一个函数将返回一个只接收的Result类型的通道:

func FileSearch(ctx context.Context, root, term string, o *Options) <-chan Result

在这里,这个函数将继续从通道接收值,直到它被关闭:

for r := range FileSearch(ctx, directory, searchTerm, options) {
    if r.Err != nil {
        fmt.Printf("%s - error: %s\n", r.File, r.Err)
        continue
    }
    if !options.Contents {
        fmt.Printf("%s - match\n", r.File)
        continue
    }
    fmt.Printf("%s - matches:\n", r.File)
    for _, m := range r.Matches {
        fmt.Printf("\t%d:%s\n", m.Line, m.Text)
    }
}

出口和入口点

结果通道应该由上下文的取消或搜索结束来关闭。由于通道不能被关闭两次,我们可以使用sync.Once来避免第二次关闭通道。为了跟踪正在运行的 goroutines,我们可以使用sync.Waitgroup

ch, wg, once := make(chan Result), sync.WaitGroup{}, sync.Once{}
go func() {
    wg.Wait()
    fmt.Println("* Search done *")
    once.Do(func() {
        close(ch)
    })
}()
go func() {
    <-ctx.Done()
    fmt.Println("* Context done *")
    once.Do(func() {
        close(ch)
    })
}()

我们可以为每个文件启动一个 goroutine,这样我们可以定义一个私有函数,作为入口点,然后递归地用于子目录:

func fileSearch(ctx context.Context, ch chan<- Result, wg *sync.WaitGroup, file, term string, o *Options)

主要导出的函数将首先向等待组添加一个值。然后,启动私有函数,将其作为异步进程启动:

wg.Add(1)
go fileSearch(ctx, ch, &wg, root, term, o)

每个fileSearch应该做的最后一件事是调用WaitGroup.Done来标记当前文件的结束。

排除列表

私有函数将在完成使用Done方法之前减少等待组计数器。此外,它应该首先检查文件名,以便如果在排除列表中,可以跳过它:

defer wg.Done()
_, name := filepath.Split(file)
if o != nil {
    for _, e := range o.Exclude {
        if e == name {
            return
        }
    }
}

如果不是这种情况,我们可以使用os.Stat来检查当前文件的信息,并且如果不成功,向通道发送错误。由于我们不能冒险通过向关闭的通道发送数据来引发恐慌,我们可以检查上下文是否完成,如果没有,发送错误:

info, err := os.Stat(file)
if err != nil {
    select {
    case <-ctx.Done():
        return
    default:
        ch <- Result{File: file, Err: err}
    }
    return
}

处理目录

接收到的信息将告诉我们文件是否是目录。如果是目录,我们可以获取文件列表并处理错误,就像我们之前使用os.Stat一样。然后,如果上下文尚未完成,我们可以启动另一系列搜索,每个文件一个。以下代码总结了这些操作:

if info.IsDir() {
    files, err := ioutil.ReadDir(file)
    if err != nil {
        select {
        case <-ctx.Done():
            return
        default:
            ch <- Result{File: file, Err: err}
        }
        return
    }
    select {
    case <-ctx.Done():
    default:
        wg.Add(len(files))
        for _, f := range files {
            go fileSearch(ctx, ch, wg, filepath.Join(file, 
        f.Name()), term, o)
        }
    }
    return
}

检查文件名和内容

如果文件是常规文件而不是目录,我们可以比较文件名或其内容,具体取决于指定的选项。检查文件名非常容易:

if o == nil || !o.Contents {
    if name == term {
        select {
        case <-ctx.Done():
        default:
            ch <- Result{File: file}
        }
    }
    return
}

如果我们正在搜索内容,我们应该打开文件:

f, err := os.Open(file)
if err != nil {
    select {
    case <-ctx.Done():
    default:
        ch <- Result{File: file, Err: err}
    }
    return
}
defer f.Close()

然后,我们可以逐行读取文件以搜索所选的术语。如果在读取文件时上下文过期,我们将停止所有操作:

scanner, matches, line := bufio.NewScanner(f), []Match{}, 1
for scanner.Scan() {
    select {
    case <-ctx.Done():
        break
    default:
        if text := scanner.Text(); strings.Contains(text, term) {
            matches = append(matches, Match{Line: line, Text: text})
        }
        line++
    }
}

最后,我们可以检查扫描器的错误。如果没有错误并且搜索有结果,我们可以将所有匹配项发送到输出通道:

select {
case <-ctx.Done():
    break
default:
    if err := scanner.Err(); err != nil {
        ch <- Result{File: file, Err: err}
        return
    }
    if len(matches) != 0 {
        ch <- Result{File: file, Matches: matches}
    }
}

不到 200 行的代码中,我们创建了一个并发文件搜索函数,每个文件使用一个 goroutine。它利用通道发送结果和同步原语来协调操作。

总结

在本章中,我们看到了一个较新的包上下文的用途。我们看到Context是一个简单的接口,有四种方法,并且应该作为函数的第一个参数使用。它的主要作用是处理取消和截止日期,以同步并发操作,并为用户提供取消操作的功能。

我们看到了默认上下文BackgroundTODO不允许取消,但它们可以使用包的各种函数进行扩展,以添加超时或取消。我们还谈到了上下文在持有值方面的能力,以及应该小心使用这一点,以避免遮蔽和其他问题。

然后,我们深入研究了标准包,看看上下文已经被使用在哪里。这包括了请求的 HTTP 功能,它可以用于值、取消和超时,以及服务器关闭操作。我们还看到了 TCP 包如何允许我们以类似的方式使用它,并且列出了数据库包中允许我们使用上下文来取消它们的操作。

在使用上下文构建自己的功能之前,我们先了解了一些应该避免的用法,从使用错误类型的键到使用上下文传递应该在函数或方法签名中的值。然后,我们继续创建一个函数,用于搜索文件和内容,利用了我们从前三章学到的并发知识。

下一章将通过展示最常见的 Go 并发模式及其用法来结束本书的并发部分。这将使我们能够将迄今为止学到的关于并发的所有知识放在一些非常常见和有效的配置中。

问题

  1. 在 Go 中上下文是什么?

  2. 取消、截止时间和超时之间有什么区别?

  3. 在使用上下文传递值时,有哪些最佳实践?

  4. 哪些标准包已经使用了上下文?

第十四章:实现并发模式

本章将介绍并发模式以及如何使用它们构建健壮的系统应用程序。我们已经看过了所有涉及并发的工具(goroutines 和通道,syncatomic,以及上下文),现在我们将看一些常见的组合模式,以便我们可以在程序中使用它们。

本章将涵盖以下主题:

  • 从生成器开始

  • 通过管道进行排序

  • 复用和解复用

  • 其他模式

  • 资源泄漏

技术要求

这一章需要安装 Go 并设置您喜欢的编辑器。有关更多信息,请参阅[第三章](602a92d5-25f7-46b8-83d4-10c6af1c6750.xhtml),Go 概述

从生成器开始

生成器是一个每次调用时返回序列的下一个值的函数。使用生成器的最大优势是惰性创建序列的新值。在 Go 中,这可以用接口或通道来表示。当生成器与通道一起使用时,其中一个优点是它们以并发方式产生值,在单独的 goroutine 中,使主 goroutine 能够执行其他类型的操作。

可以用一个非常简单的接口来抽象化:

type Generator interface {
    Next() interface{}
}

type GenInt64 interface {
    Next() int64
}

接口的返回类型将取决于用例,在我们的情况下是int64。它的基本实现可以是一个简单的计数器:

type genInt64 int64

func (g *genInt64) Next() int64 {
    *g++
    return int64(*g)
}

这个实现不是线程安全的,所以如果我们尝试在 goroutine 中使用它,可能会丢失一些元素:

func main() {
    var g genInt64
    for i := 0; i < 1000; i++ {
        go func(i int) {
            fmt.Println(i, g.Next())
        }(i)
    }
    time.Sleep(time.Second)
}

使生成器并发的一个简单方法是对整数执行原子操作。

这将使并发生成器线程安全,代码需要进行很少的更改:

type genInt64 int64

func (g *genInt64) Next() int64 {
    return atomic.AddInt64((*int64)(g), 1)
}

这将避免应用程序中的竞争条件。但是,还有另一种可能的实现,但这需要使用通道。其思想是在 goroutine 中生成值,然后将其传递到共享通道中的下一个方法,如下例所示:

type genInt64 struct {
    ch chan int64
}

func (g genInt64) Next() int64 {
    return <-g.ch
}

func NewGenInt64() genInt64 {
    g := genInt64{ch: make(chan int64)}
    go func() {
        for i := int64(0); ; i++ {
            g.ch <- i
        }
    }()
    return g
}

循环将永远继续,并且在生成器用户停止使用Next方法请求新值时,将在发送操作中阻塞。

代码之所以以这种方式结构化,是因为我们试图实现我们在开头定义的接口。我们也可以只返回一个通道并用它进行接收:

func GenInt64() <-chan int64 {
 ch:= make(chan int64)
    go func() {
        for i := int64(0); ; i++ {
            ch <- i
        }
    }()
    return ch
}

直接使用通道的主要优势是可以将其包含在select语句中,以便在不同的通道操作之间进行选择。以下显示了两个不同生成器之间的select

func main() {
    ch1, ch2 := GenInt64(), GenInt64()
    for i := 0; i < 20; i++ {
        select {
        case v := <-ch1:
            fmt.Println("ch 1", v)
        case v := <-ch2:
            fmt.Println("ch 2", v)
        }
    }
}

避免泄漏

允许循环结束是个好主意,以避免 goroutine 和资源泄漏。其中一些问题如下:

  • 当 goroutine 挂起而不返回时,内存中的空间仍然被使用,导致应用程序在内存中的大小增加。只有当 goroutine 返回或发生 panic 时,GC 才会收集 goroutine 和堆栈中定义的变量。

  • 如果文件保持打开状态,这可能会阻止其他进程对其执行操作。如果打开的文件数量达到操作系统强加的限制,进程将无法打开其他文件(或接受网络连接)。

这个问题的一个简单解决方案是始终使用context.Context,这样您就有了 goroutine 的明确定义的退出点:

func NewGenInt64(ctx context.Context) genInt64 {
    g := genInt64{ch: make(chan int64)}
    go func() {
        for i := int64(0); ; i++ {
            select {
            case g.ch <- i:
                // do nothing
            case <-ctx.Done():
                close(g.ch)
                return
            }
        }
    }()
    return g
}

这可以用于生成值,直到需要它们并在不需要新值时取消上下文。相同的模式也可以应用于返回通道的版本。例如,我们可以直接使用cancel函数或在上下文上设置超时:

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*100)
    defer cancel()
    g := NewGenInt64(ctx)
    for i := range g.ch {
        go func(i int64) {
            fmt.Println(i, g.Next())
        }(i)
    }
    time.Sleep(time.Second)
}

生成器将生成数字,直到提供的上下文到期。此时,生成器将关闭通道。

通过管道进行排序

管道是一种结构化应用程序流程的方式,通过将主要执行分成可以使用某种通信手段相互交谈的阶段来实现。这可以是以下之一:

  • 外部,比如网络连接或文件

  • 应用程序内部,如 Go 的通道

第一个阶段通常被称为生产者,而最后一个通常被称为消费者。

Go 提供的并发工具集允许我们有效地使用多个 CPU,并通过阻塞输入或输出操作来优化它们的使用。通道特别适用于内部管道通信。它们可以由接收入站通道并返回出站通道的函数表示。基本结构看起来像这样:

func stage(in <-chan interface{}) <-chan interface{} {
    var out = make(chan interface{})
    go func() {
        for v := range in {
            v = v.(int)+1 // some operation
            out <- v
        }
        close(out)
    }()
    return out
}

我们创建一个与输入通道相同类型的通道并返回它。在一个单独的 goroutine 中,我们从输入通道接收数据,对数据执行操作,然后将其发送到输出通道。

这种模式可以通过使用context.Context进一步改进,以便我们更好地控制应用程序流程。它看起来像以下代码:

func stage(ctx context.Context, in <-chan interface{}) <-chan interface{} {
    var out = make(chan interface{})
    go func() {
        defer close(out)
        for v := range in {
            v = v.(int)+1 // some operation
            select {
                case out <- v:
                case <-ctx.Done():
                    return
            }
        }
    }()
    return out
}

在设计管道时,有一些通用规则应该遵循:

  • 中间阶段将接收一个入站通道并返回另一个。

  • 生产者不会接收任何通道,但会返回一个。

  • 消费者将接收一个通道而不返回一个。

  • 每个阶段在创建时都会关闭通道,当它发送完消息时。

  • 每个阶段应该保持从输入通道接收,直到它关闭。

让我们创建一个简单的管道,使用特定字符串从读取器中过滤行并打印过滤后的行,突出显示搜索字符串。我们可以从第一个阶段开始 - 源 - 它在签名中不会接收任何通道,但会使用读取器扫描行。我们为了对早期退出请求做出反应(上下文取消)和使用bufio扫描器逐行读取。以下代码显示了这一点:

func SourceLine(ctx context.Context, r io.ReadCloser) <-chan string {
    ch := make(chan string)
    go func() {
        defer func() { r.Close(); close(ch) }()
        s := bufio.NewScanner(r)
        for s.Scan() {
            select {
            case <-ctx.Done():
                return
            case ch <- s.Text():
            }
        }
    }()
    return ch
}

我们可以将剩余的操作分为两个阶段:过滤阶段和写入阶段。过滤阶段将简单地从源通道过滤到输出通道。我们仍然传递上下文,以避免在上下文已经完成的情况下发送额外的数据。这是文本过滤的实现:

func TextFilter(ctx context.Context, src <-chan string, filter string) <-chan string {
    ch := make(chan string)
    go func() {
        defer close(ch)
        for v := range src {
            if !strings.Contains(v, filter) {
                continue
            }
            select {
            case <-ctx.Done():
                return
            case ch <- v:
            }
        }
    }()
    return ch
}

最后,我们有最终阶段,消费者,它将在写入器中打印输出,并且还将使用上下文进行早期退出:

func Printer(ctx context.Context, src <-chan string, color int, highlight string, w io.Writer) {
    const close = "\x1b[39m"
    open := fmt.Sprintf("\x1b[%dm", color)
    for {
        select {
        case <-ctx.Done():
            return
        case v, ok := <-src:
            if !ok {
                return
            }
            i := strings.Index(v, highlight)
            if i == -1 {
                panic(v)
            }
            fmt.Fprint(w, v[:i], open, highlight, close, v[i+len(highlight):], "\n")
        }
    }
}

使用这个函数的方式如下:

func main() {
    var search string
    ...
    ctx := context.Background()
    src := SourceLine(ctx, ioutil.NopCloser(strings.NewReader(sometext)))
    filter := TextFilter(ctx, src, search)
    Printer(ctx, filter, 31, search, os.Stdout)
}

通过这种方法,我们学会了如何将复杂的操作分解为由阶段执行的简单任务,并使用通道连接。

复用和解复用

现在我们熟悉了管道和阶段,我们可以介绍两个新概念:

  • 复用(多路复用)或扇出:从一个通道接收并发送到多个通道

  • 解复用(解多路复用)或扇入:从多个通道接收并通过一个通道发送

这种模式非常常见,可以让我们以不同的方式利用并发的力量。最明显的方式是从比其后续步骤更快的通道中分发数据,并创建多个此类步骤的实例来弥补速度差异。

扇出

复用的实现非常简单。同一个通道需要传递给不同的阶段,以便每个阶段都从中读取。

每个 goroutine 在运行时调度期间竞争资源,因此如果我们想保留更多的资源,我们可以为管道的某个阶段或应用程序中的某个操作使用多个 goroutine。

我们可以创建一个小应用程序,使用这种方法统计出现在一段文本中的单词的次数。让我们创建一个初始的生产者阶段,从写入器中读取并返回该行的单词切片:

func SourceLineWords(ctx context.Context, r io.ReadCloser) <-chan []string {
    ch := make(chan []string)
    go func() {
        defer func() { r.Close(); close(ch) }()
        b := bytes.Buffer{}
        s := bufio.NewScanner(r)
        for s.Scan() {
            b.Reset()
            b.Write(s.Bytes())
            words := []string{}
            w := bufio.NewScanner(&b)
            w.Split(bufio.ScanWords)
            for w.Scan() {
                words = append(words, w.Text())
            }
            select {
            case <-ctx.Done():
                return
            case ch <- words:
            }
        }
    }()
    return ch
}

现在我们可以定义另一个阶段,用于计算这些单词的出现次数。我们将使用这个阶段进行扇出:

func WordOccurrence(ctx context.Context, src <-chan []string) <-chan map[string]int {
    ch := make(chan map[string]int)
    go func() {
        defer close(ch)
        for v := range src {
            count := make(map[string]int)
            for _, s := range v {
                count[s]++
            }
            select {
            case <-ctx.Done():
                return
            case ch <- count:
            }
        }
    }()
    return ch
}

为了将第一阶段用作第二阶段的多个实例的来源,我们只需要使用相同的输入通道创建多个计数阶段:

ctx, canc := context.WithCancel(context.Background())
defer canc()
src := SourceLineWords(ctx,   
    ioutil.NopCloser(strings.NewReader(cantoUno)))
count1, count2 := WordOccurrence(ctx, src), WordOccurrence(ctx, src)

扇入

Demuxing 有点复杂,因为我们不需要在一个 goroutine 中盲目地接收数据,而是需要同步一系列通道。避免竞争条件的一个好方法是创建另一个通道,所有来自各种输入通道的数据都将被接收到。我们还需要确保一旦所有通道都完成,这个合并通道就会关闭。我们还必须记住,如果上下文被取消,通道将被关闭。我们在这里使用sync.Waitgroup等待所有通道完成:

wg := sync.WaitGroup{}
merge := make(chan map[string]int)
wg.Add(len(src))
go func() {
    wg.Wait()
    close(merge)
}()

问题在于我们有两种可能的触发器来关闭通道:常规传输结束和上下文取消。

我们必须确保如果上下文结束,不会向输出通道发送任何消息。在这里,我们正在从输入通道收集值并将它们发送到合并通道,但前提是上下文没有完成。我们这样做是为了避免将发送操作发送到关闭的通道,这将使我们的应用程序发生恐慌:

for _, ch := range src {
    go func(ch <-chan map[string]int) {
        defer wg.Done()
        for v := range ch {
            select {
            case <-ctx.Done():    
                return
            case merge <- v:
            }
        }
    }(ch)
}

最后,我们可以专注于使用合并通道执行我们的最终字数的最后一个操作:

count := make(map[string]int)
for {
    select {
    case <-ctx.Done():
        return count
    case c, ok := <-merge:
        if !ok {
            return count
        }
        for k, v := range c {
            count[k] += v
        }
    }
}

应用程序的main函数,在添加扇入后,将如下所示:

func main() {
    ctx, canc := context.WithCancel(context.Background())
    defer canc()
    src := SourceLineWords(ctx, ioutil.NopCloser(strings.NewReader(cantoUno)))
    count1, count2 := WordOccurrence(ctx, src), WordOccurrence(ctx, src)
    final := MergeCounts(ctx, count1, count2)
    fmt.Println(final)
}

我们可以看到,扇入是应用程序最复杂和关键的部分。让我们回顾一下帮助构建一个没有恐慌或死锁的扇入函数的决定:

  • 使用合并通道从各种输入中收集值。

  • 使用sync.WaitGroup,计数器等于输入通道的数量。

  • 在一个单独的 goroutine 中使用它,并等待它关闭通道。

  • 对于每个输入通道,创建一个将值传输到合并通道的 goroutine。

  • 确保只有在上下文没有完成的情况下才发送记录。

  • 在退出这样的 goroutine 之前,使用等待组的done函数。

遵循上述步骤将允许我们使用简单的range从合并通道中获取值。在我们的示例中,我们还检查上下文是否完成,然后才从通道接收,以便允许 goroutine 提前退出。

生产者和消费者

通道允许我们轻松处理多个消费者从一个生产者接收数据的情况,反之亦然。

与单个生产者和一个消费者的情况一样,我们已经看到,这是非常直接的:

func main() {
    // one producer
    var ch = make(chan int)
    go func() {
        for i := 0; i < 100; i++ {
            ch <- i
        }
        close(ch)
    }()
    // one consumer
    var done = make(chan struct{})
    go func() {
        for i := range ch {
            fmt.Println(i)
        }
        close(done)
    }()
    <-done
}

完整的示例在这里:play.golang.org/p/hNgehu62kjv

多个生产者(N * 1)

使用等待组可以轻松处理多个生产者或消费者的情况。在多个生产者的情况下,所有的 goroutine 都将共享同一个通道:

// three producer
var ch = make(chan string)
wg := sync.WaitGroup{}
wg.Add(3)
for i := 0; i < 3; i++ {
    go func(n int) {
        for i := 0; i < 100; i++ {
            ch <- fmt.Sprintln(n, i)
        }
        wg.Done()
    }(i)
}
go func() {
    wg.Wait()
    close(ch)
}()

完整的示例在这里:play.golang.org/p/4DqWKntl6sS

他们将使用sync.WaitGroup等待每个生产者完成后关闭通道。

多个消费者(1 * M)

相同的推理适用于多个消费者-它们都在不同的 goroutine 中从同一个通道接收:

func main() {
    // three consumers
    wg := sync.WaitGroup{}
    wg.Add(3)
    var ch = make(chan string)

    for i := 0; i < 3; i++ {
        go func(n int) {
            for i := range ch {
                fmt.Println(n, i)
            }
            wg.Done()
        }(i)
    }

    // one producer
    go func() {
        for i := 0; i < 10; i++ {
            ch <- fmt.Sprintln("prod-", i)
        }
        close(ch)
    }()

    wg.Wait()
}

完整的示例在这里:play.golang.org/p/_SWtw54ITFn

在这种情况下,sync.WaitGroup用于等待应用程序结束。

多个消费者和生产者(N*M)

最后的情况是我们有任意数量的生产者(N)和另一个任意数量的消费者(M)。

在这种情况下,我们需要两个等待组:一个用于生产者,另一个用于消费者:

const (
    N = 3
    M = 5
)
wg1 := sync.WaitGroup{}
wg1.Add(N)
wg2 := sync.WaitGroup{}
wg2.Add(M)
var ch = make(chan string)

接下来是一系列生产者和消费者,每个都在自己的 goroutine 中:

for i := 0; i < N; i++ {
    go func(n int) {
        for i := 0; i < 10; i++ {
            ch <- fmt.Sprintf("src-%d[%d]", n, i)
        }
        wg1.Done()
    }(i)
}

for i := 0; i < M; i++ {
    go func(n int) {
        for i := range ch {
            fmt.Printf("cons-%d, msg %q\n", n, i)
        }
        wg2.Done()
    }(i)
}

最后一步是等待WaitGroup生产者完成工作,以关闭通道。

然后,我们可以等待消费者通道,让所有消息都被消费者处理:

wg1.Wait()
close(ch)
wg2.Wait()

其他模式

到目前为止,我们已经看过了可以使用的最常见的并发模式。现在,我们将专注于一些不太常见但值得一提的模式。

错误组

sync.WaitGroup的强大之处在于它允许我们等待同时运行的 goroutines 完成它们的工作。我们已经看过了如何共享上下文可以让我们在正确使用时为 goroutines 提供早期退出。第一个并发操作,比如从通道发送或接收,位于select块中,与上下文完成通道一起:

func main() {
    ctx, canc := context.WithTimeout(context.Background(), time.Second)
    defer canc()
    wg := sync.WaitGroup{}
    wg.Add(10)
    var ch = make(chan int)
    for i := 0; i < 10; i++ {
        go func(ctx context.Context, i int) {
            defer wg.Done()
            d := time.Duration(rand.Intn(2000)) * time.Millisecond
            time.Sleep(d)
            select {
            case <-ctx.Done():
                fmt.Println(i, "early exit after", d)
                return
            case ch <- i:
                fmt.Println(i, "normal exit after", d)
            }
        }(ctx, i)
    }
    go func() {
        wg.Wait()
        close(ch)
    }()
    for range ch {
    }
}

实验性的golang.org/x/sync/errgroup包提供了对这种情况的改进。

内置的 goroutines 始终是func()类型,但这个包允许我们并发执行func() error并返回从各种 goroutines 接收到的第一个错误。

在启动更多 goroutines 并接收第一个错误的情况下,这非常有用。errgroup.Group类型可以用作零值,其Do方法以func() error作为参数并并发启动函数。

Wait方法要么等待所有函数成功完成并返回nil,要么返回来自任何函数的第一个错误。

让我们创建一个定义 URL 访问者的示例,即一个获取 URL 字符串并返回func() error的函数,用于发起调用:

func visitor(url string) func() error {
    return func() (err error) {
        s := time.Now()
        defer func() {
            log.Println(url, time.Since(s), err)
        }()
        var resp *http.Response
        if resp, err = http.Get(url); err != nil {
            return
        }
        return resp.Body.Close()
    }
}

我们可以直接使用Go方法并等待。这将返回由无效 URL 引起的错误:

func main() {
    eg := errgroup.Group{}
    var urlList = []string{
        "http://www.golang.org/",
        "http://invalidwebsite.hey/",
        "http://www.google.com/",
    }
    for _, url := range urlList {
        eg.Go(visitor(url))
    }
    if err := eg.Wait(); err != nil {
        log.Fatalln("Error:", err)
    }
}

错误组还允许我们使用WithContext函数创建一个组以及上下文。当收到第一个错误时,此上下文将被取消。上下文的取消使Wait方法能够立即返回,但也允许在函数的 goroutines 中进行早期退出。

我们可以创建一个类似的func() error创建者,它会将值发送到通道直到上下文关闭。我们将引入一个小概率(1%)引发错误:

func sender(ctx context.Context, ch chan<- string, n int) func() error {
    return func() (err error) {
        for i := 0; ; i++ {
            if rand.Intn(100) == 42 {
                return errors.New("the answer")
            }
            select {
            case ch <- fmt.Sprintf("[%d]%d", n, i):
            case <-ctx.Done():
                return nil
            }
        }
    }
}

我们将使用专用函数生成一个错误组和一个上下文,并使用它来启动函数的多个实例。在等待组时,我们将在一个单独的 goroutine 中接收到它。等待结束后,我们将确保没有更多的值被发送到通道(这将导致恐慌),通过额外等待一秒钟:

func main() {
    eg, ctx := errgroup.WithContext(context.Background())
    ch := make(chan string)
    for i := 0; i < 10; i++ {
        eg.Go(sender(ctx, ch, i))
    }
    go func() {
        for s := range ch {
            log.Println(s)
        }
    }()
    if err := eg.Wait(); err != nil {
        log.Println("Error:", err)
    }
    close(ch)
    log.Println("waiting...")
    time.Sleep(time.Second)
}

正如预期的那样,由于上下文中的select语句,应用程序运行顺利,不会发生恐慌。

泄漏桶

我们在前几章中看到了如何使用 ticker 构建速率限制器:通过使用time.Ticker强制客户端等待轮到自己被服务。还有另一种对服务和库进行速率限制的方法,称为泄漏桶。这个名字让人联想到一个有几个孔的桶。如果你在往里面加水,就要小心不要把太多水放进去,否则它会溢出。在添加更多水之前,你需要等待水位下降 - 这种速度取决于桶的大小和孔的数量。通过以下类比,我们可以很容易地理解这种并发模式的作用:

  • 通过孔洞流出的水代表已完成的请求。

  • 从桶中溢出的水代表被丢弃的请求。

桶将由两个属性定义:

  • 速率:如果请求频率较低,则每个时间段的理想请求量。

  • 容量:在资源暂时变得无响应之前,可以同时完成的请求数量。

桶具有最大容量,因此当请求的频率高于指定的速率时,该容量开始下降,就像当您放入太多水时,桶开始溢出一样。如果频率为零或低于速率,则桶将缓慢恢复其容量,因此水将被缓慢排出。

漏桶的数据结构将具有容量和可用请求的计数器。该计数器在创建时将与容量相同,并且每次执行请求时都会减少。速率指定了状态需要多久重置到容量的频率:

type bucket struct {
    capacity uint64
    status uint64
}

创建新的桶时,我们还应该注意状态重置。我们可以使用 goroutine 和上下文来正确终止它。我们可以使用速率创建一个 ticker,然后使用这些 ticks 来重置状态。我们需要使用 atomic 包来确保它是线程安全的:

func newBucket(ctx context.Context, cap uint64, rate time.Duration) *bucket {
    b := bucket{capacity: cap, status: cap}
    go func() {
        t := time.NewTicker(rate)
        for {
            select {
            case <-t.C:
                atomic.StoreUint64(&b.status, b.capacity)
            case <-ctx.Done():
                t.Stop()
                return
            }
        }
    }()
    return &b
}

当我们向桶中添加内容时,我们可以检查状态并相应地采取行动:

  • 如果状态为0,我们无法添加任何内容。

  • 如果要添加的数量高于可用性,我们将添加我们可以的内容。

  • 否则,我们将添加完整的数量:

func (b *bucket) Add(n uint64) uint64 {
    for {
        r := atomic.LoadUint64(&b.status)
        if r == 0 {
            return 0
        }
        if n > r {
            n = r
        }
        if !atomic.CompareAndSwapUint64(&b.status, r, r-n) {
            continue
        }
        return n
    }
}

我们使用循环尝试原子交换操作,直到成功为止,以确保我们在进行比较和交换CAS)时得到的内容不会在进行加载操作时发生变化。

桶可以用于尝试向桶中添加随机数量并记录其结果的客户端:

type client struct {
    name string
    max int
    b *bucket
    sleep time.Duration
}

func (c client) Run(ctx context.Context, start time.Time) {
    for {
        select {
        case <-ctx.Done():
            return
        default:
            n := 1 + rand.Intn(c.max-1)
            time.Sleep(c.sleep)
            e := time.Since(start).Seconds()
            a := c.b.Add(uint64(n))
            log.Printf("%s tries to take %d after %.02fs, takes  
                %d", c.name, n, e, a)
        }
    }
}

我们可以同时使用更多客户端,以便并发访问资源将产生以下结果:

  • 一些 goroutine 将向桶中添加他们期望的内容。

  • 一个 goroutine 最终将通过添加与剩余容量相等的数量来填充桶,即使他们试图添加的数量更高。

  • 其他 goroutine 在容量重置之前将无法向桶中添加内容:

func main() {
    ctx, canc := context.WithTimeout(context.Background(), time.Second)
    defer canc()
    start := time.Now()
    b := newBucket(ctx, 10, time.Second/5)
    t := time.Second / 10
    for i := 0; i < 5; i++ {
        c := client{
            name: fmt.Sprint(i),
            b: b,
            sleep: t,
            max: 5,
        }
        go c.Run(ctx, start)
    }
    <-ctx.Done()
}

排序

在具有多个 goroutine 的并发场景中,我们可能需要在 goroutine 之间进行同步,例如在每个 goroutine 发送后需要等待轮次的情况下。

这种情况的一个用例可能是一个基于轮次的应用程序,其中不同的 goroutine 正在向同一个通道发送消息,并且每个 goroutine 都必须等到所有其他 goroutine 完成后才能再次发送消息。

可以使用主 goroutine 和发送者之间的私有通道来获得此场景的非常简单的实现。我们可以定义一个非常简单的结构,其中包含消息和Wait通道。它将有两种方法-一种用于标记交易已完成,另一种等待这样的信号-当它在下面使用通道时。以下方法显示了这一点:

type msg struct {
    value string
    done chan struct{}
}

func (m *msg) Wait() {
    <-m.done
}

func (m *msg) Done() {
    m.done <- struct{}{}
}

我们可以使用生成器创建消息源。我们可以使用send操作进行随机延迟。每次发送后,我们等待通过调用Done方法获得的信号。我们始终使用上下文来确保一切都不会泄漏:

func send(ctx context.Context, v string) <-chan msg {
    ch := make(chan msg)
    go func() {
        done := make(chan struct{})
        for i := 0; ; i++ {
            time.Sleep(time.Duration(float64(time.Second/2) * rand.Float64()))
            m := msg{fmt.Sprintf("%s msg-%d", v, i), done}
            select {
            case <-ctx.Done():
                close(ch)
                return
            case ch <- m:
                m.Wait()
            }
        }
    }()
    return ch
}

我们可以使用 fan-in 将所有通道放入一个单一的通道中:


func merge(ctx context.Context, sources ...<-chan msg) <-chan msg {
    ch := make(chan msg)
    go func() {
        <-ctx.Done()
        close(ch)
    }()
    for i := range sources {
        go func(i int) {
            for {
                select {
                case v := <-sources[i]:
                    select {
                    case <-ctx.Done():
                        return
                    case ch <- v:
                    }
                }
            }
        }(i)
    }
    return ch
}

主应用程序将从合并的通道接收,直到它关闭。当它从每个通道接收到一个消息时,通道将被阻塞,等待主 goroutine 调用Done方法信号。

这种特定的配置将允许主 goroutine 仅从每个通道接收一个消息。当消息计数达到 goroutine 数量时,我们可以从主 goroutine 调用Done并重置列表,以便其他 goroutine 将被解锁并能够再次发送消息:

func main() {
    ctx, canc := context.WithTimeout(context.Background(), time.Second)
    defer canc()
    sources := make([]<-chan msg, 5)
    for i := range sources {
        sources[i] = send(ctx, fmt.Sprint("src-", i))
    }
    msgs := make([]msg, 0, len(sources))
    start := time.Now()
    for v := range merge(ctx, sources...) {
        msgs = append(msgs, v)
        log.Println(v.value, time.Since(start))
        if len(msgs) == len(sources) {
            log.Println("*** done ***")
            for _, m := range msgs {
                m.Done()
            }
            msgs = msgs[:0]
            start = time.Now()
        }
    }
}

运行应用程序将导致所有 goroutine 向主 goroutine 发送一条消息。每个 goroutine 都将等待其他人发送消息。然后,他们将开始再次发送消息。这导致消息按轮次发送,正如预期的那样。

总结

在本章中,我们研究了一些特定的并发模式,用于我们的应用程序。我们了解到生成器是返回通道的函数,并且还向这些通道提供数据,并在没有更多数据时关闭它们。我们还看到我们可以使用上下文来允许生成器提前退出。

接下来,我们专注于管道,这是使用通道进行通信的执行阶段。它们可以是源,不需要任何输入;目的地,不返回通道;或者中间的,接收通道作为输入并返回一个作为输出。

另一个模式是多路复用和分解复用,它包括将一个通道传播到不同的 goroutine,并将多个通道合并成一个。它通常被称为扇出扇入,它允许我们在一组数据上并发执行不同的操作。

最后,我们学习了如何实现一个更好的速率限制器称为漏桶,它限制了在特定时间内的请求数。我们还看了顺序模式,它使用私有通道向所有发送 goroutine 发出信号,告诉它们何时可以再次发送数据。

在下一章中,我们将介绍在顺序部分中提出的两个额外主题中的第一个。在这里,我们将演示如何使用反射来构建适应任何用户提供的类型的通用代码。

问题

  1. 生成器是什么?它的责任是什么?

  2. 你如何描述一个管道?

  3. 什么类型的阶段获得一个通道并返回一个通道?

  4. 扇入和扇出之间有什么区别?

第五部分:使用反射和 CGO 的指南

本节重点介绍两种非常有争议的工具——反射,它允许创建通用代码,但在性能方面代价很大;以及 CGO,它允许在 Go 应用程序中使用 C 代码,但使得调试和控制应用程序变得更加复杂。

本节包括以下章节:

  • 第十五章,使用反射

  • 第十六章,使用 CGO

第十五章:使用反射

本章是关于反射,这是一种工具,允许应用程序检查自己的代码,克服 Go 静态类型和泛型缺乏所施加的一些限制。例如,这对于生成能够处理其接收到的任何类型输入的包可能非常有帮助。

本章将涵盖以下主题:

  • 理解接口和类型断言

  • 了解与基本类型的交互

  • 使用复杂类型进行反射

  • 评估反射的成本

  • 学习反射使用的最佳实践

技术要求

本章需要安装 Go 并设置您喜欢的编辑器。有关更多信息,请参阅第三章,Go 概述

什么是反射?

反射是一种非常强大的功能,允许元编程,即应用程序检查自身结构的能力。它非常有用,可以在运行时分析应用程序中的类型,并且在许多编码包中使用,例如 JSON 和 XML。

类型断言

我们在第三章Go 概述中简要提到了类型断言的工作原理。类型断言是一种操作,允许我们从接口到具体类型以及反之进行转换。它采用以下形式:

# unsafe assertion
v := SomeVar.(SomeType)

# safe assertion
v, ok := SomeVar.(SomeType)

第一个版本是不安全的,它将一个值分配给一个变量。

将断言用作函数的参数也被视为不安全。如果断言错误,此类操作将引发panic

func main() {
    var a interface{} = "hello"
    fmt.Println(a.(string)) // ok
    fmt.Println(a.(int))    // panics!
}

完整示例可在此处找到:play.golang.org/p/hNN87SuprGR

第二个版本使用布尔值作为第二个值,并且它将显示操作的成功。如果断言不可能,第一个值将始终是断言类型的零值:

func main() {
    var a interface{} = "hello"
    s, ok := a.(string) // true
    fmt.Println(s, ok)
    i, ok := a.(int) // false
    fmt.Println(i, ok)
}

完整示例可在此处找到:play.golang.org/p/BIba2ywkNF_j

接口断言

断言也可以从一个接口到另一个接口进行。想象一下有两个不同的接口:

type Fooer interface {
    Foo()
}

type Barer interface {
    Bar()
}

让我们定义一个实现其中一个的类型,另一个实现两者的类型:

type A int

func (A) Foo() {}

type B int

func (B) Bar() {}
func (B) Foo() {}

如果我们为第一个接口定义一个新变量,只有在底层值具有实现两者的类型时,对第二个的断言才会成功;否则,它将失败:

func main() {
    var a Fooer 

    a = A(0)
    v, ok := a.(Barer)
    fmt.Println(v, ok)

    a = B(0) 
    v, ok = a.(Barer)
    fmt.Println(v, ok)
}

完整示例可在此处找到:play.golang.org/p/bX2rnw5pRXJ

一个使用场景可能是拥有io.Reader接口,检查它是否也是io.Closer接口,并在需要时使用ioutil.NopCloser函数(返回io.ReadCloser接口)进行包装:

func Closer(r io.Reader) io.ReadCloser {
    if rc, ok := r.(io.ReadCloser); ok {
        return rc
    }
    return ioutil.NopCloser(r)
}

func main() {
    log.Printf("%T", Closer(&bytes.Buffer{}))
    log.Printf("%T", Closer(&os.File{}))
}

完整示例可在此处找到:play.golang.org/p/hUEsDYHFE7i

在跳转到反射之前,接口有一个重要的方面需要强调——它的表示始终是一个元组接口值,其中值是一个具体类型,不能是另一个接口。

理解基本机制

reflection包允许您从任何interface{}变量中提取类型和值。可以使用以下方法完成:

  • 使用reflection.TypeOf返回接口的类型到reflection.Type变量。

  • reflection.ValueOf函数使用reflection.Value变量返回接口的值。

值和类型方法

reflect.Value类型还携带可以使用Type方法检索的类型信息:

func main() {
    var a interface{} = int64(23)
    fmt.Println(reflect.TypeOf(a).String())
    // int64
    fmt.Println(reflect.ValueOf(a).String())
    // <int64 Value>
    fmt.Println(reflect.ValueOf(a).Type().String())
    // int64
}

完整示例可在此处找到:play.golang.org/p/tmYuMc4AF1T

种类

reflect.Type的另一个重要属性是Kind,它是基本类型和通用复杂类型的枚举。reflect.Kindreflect.Type之间的主要关系是,前者表示后者的内存表示。

对于内置类型,KindType是相同的,但对于自定义类型,它们将不同 - Type值将是预期的值,但Kind值将是自定义类型定义的内置类型之一:

func main() {
    var a interface{}

    a = "" // built in string
    t := reflect.TypeOf(a)
    fmt.Println(t.String(), t.Kind())

    type A string // custom type
    a = A("")
    t = reflect.TypeOf(a)
    fmt.Println(t.String(), t.Kind())
}

完整示例在此处可用:play.golang.org/p/qjiouk88INn

对于复合类型,它将反映出主要类型而不是底层类型。这意味着指向结构或整数的指针是相同类型,reflect.Pointer

func main() {
    var a interface{}

    a = new(int) // int pointer
    t := reflect.TypeOf(a)
    fmt.Println(t.String(), t.Kind())

    a = new(struct{}) // struct pointer
    t = reflect.TypeOf(a)
    fmt.Println(t.String(), t.Kind())
}

完整示例在此处可用:play.golang.org/p/-uJjZvTuzVf

相同的推理适用于所有其他复合类型,例如数组,切片,映射和通道。

值到接口

就像我们可以从任何interface{}值获取reflect.Value一样,我们也可以执行相反的操作,并从reflect.Value获取interface{}。这是使用反射值的Interface方法完成的,并且如果需要,可以转换为具体类型。如果感兴趣的方法或函数接受空接口,例如json.Marshalfmt.Println,则返回的值可以直接传递,而无需任何转换:

func main() {
    var a interface{} = int(12)
    v := reflect.ValueOf(a)
    fmt.Println(v.String())
    fmt.Printf("%v", v.Interface())
}

完整示例在此处可用:play.golang.org/p/1942Dhm5sap

操纵值

将值转换为其反射形式,然后再转回值,如果值本身无法更改,这是没有什么用的。这就是为什么我们的下一步是看看如何使用reflection包来更改它们。

更改值

reflect.Value类型有一系列方法,允许您更改底层值:

  • Set: 使用另一个reflect.Value

  • SetBool: 布尔值

  • SetBytes: 字节切片

  • SetComplex: 任何复杂类型

  • SetFloat: 任何浮点类型

  • SetInt: 任何有符号整数类型

  • SetPointer: 指针

  • SetString: 字符串

  • SetUint: 任何无符号整数

为了设置一个值,它需要是可编辑的,这发生在特定条件下。为了验证这一点,有一个方法CanSet,如果一个值可以被更改,则返回true。如果值无法更改,但仍然调用了Set方法,应用程序将会引发恐慌:

func main() {
    var a = int64(12)
    v := reflect.ValueOf(a)
    fmt.Println(v.String(), v.CanSet())
    v.SetInt(24)
}

完整示例在此处可用:play.golang.org/p/hKn8qNtn0gN

为了进行更改,值需要是可寻址的。如果可以修改对象保存的实际存储位置,则值是可寻址的。当使用基本内置类型(例如string)创建新值时,传递给函数的是interface{},它包含字符串的副本。

更改此副本将导致副本的变化,而不会影响原始变量。这将非常令人困惑,并且会使反射等实用工具的使用变得更加困难。这就是为什么,reflect包会引发恐慌 - 这是一个设计选择。这就解释了为什么最后一个示例会引发恐慌。

我们可以使用要更改的值的指针创建reflect.Value,并使用Elem方法访问该值。这将给我们一个可寻址的值,因为我们复制了指针而不是值,所以反射的值仍然是变量的指针:

func main() {
    var a = int64(12)
    v := reflect.ValueOf(&a)
    fmt.Println(v.String(), v.CanSet())
    e := v.Elem()
    fmt.Println(e.String(), e.CanSet())
    e.SetInt(24)
    fmt.Println(a)
}

完整示例在此处可用:play.golang.org/p/-X5JsBrlr4Q

创建新值

reflect包还允许我们使用类型创建新值。有几个函数允许我们创建一个值:

  • MakeChan创建一个新的通道值

  • MakeFunc创建一个新的函数值

  • MakeMapMakeMapWithSize创建一个新的映射值

  • MakeSlice创建一个新的切片值

  • New创建一个指向该类型的新指针

  • NewAt 使用所选地址创建类型的新指针

  • Zero 创建所选类型的零值

以下代码显示了如何以几种不同的方式创建新值:

func main() {
    t := reflect.TypeOf(int64(100))
    // zero value
    fmt.Printf("%#v\n", reflect.Zero(t))
    // pointer to int
    fmt.Printf("%#v\n", reflect.New(t))
}

完整的示例在这里:play.golang.org/p/wCTILSK1F1C

处理复杂类型

在了解如何处理反射基础知识之后,我们现在将看到如何使用反射处理结构和地图等复杂数据类型。

数据结构

为了可更改性,结构与基本类型的工作方式完全相同; 我们需要获取指针的反射,然后访问其元素以能够更改值,因为直接使用结构会产生其副本,并且在更改值时会出现恐慌。

我们可以使用 Set 方法替换整个结构的值,然后获取新值的反射:

func main() {
    type X struct {
        A, B int
        c string
    }
    var a = X{10, 100, "apple"}
    fmt.Println(a)
    e := reflect.ValueOf(&a).Elem()
    fmt.Println(e.String(), e.CanSet())
    e.Set(reflect.ValueOf(X{1, 2, "banana"}))
    fmt.Println(a)
}

完整的示例在这里:play.golang.org/p/mjb3gJw5CeA

更改字段

也可以使用 Field 方法修改单个字段:

  • Field 使用其索引返回一个字段

  • FieldByIndex 使用一系列索引返回嵌套字段

  • FieldByName 使用其名称返回一个字段

  • FieldByNameFunc 使用 func(string) bool 返回一个字段

让我们定义一个结构来更改字段的值,使用简单和复杂类型,至少有一个未导出的字段:

type A struct {
    B
    x int
    Y int
    Z int
}

type B struct {
    F string
    G string
}

现在我们有了结构,我们可以尝试以不同的方式访问字段:

func main() {
    var a A
    v := reflect.ValueOf(&a)
    func() {
        // trying to get fields from ptr panics
        defer func() {
            log.Println("panic:", recover())
        }()
        log.Printf("%s", v.Field(1).String())
    }()
    v = v.Elem()
    // changing fields by index
    for i := 0; i < 4; i++ {
        f := v.Field(i)
        if f.CanSet() && f.Type().Kind() == reflect.Int {
            f.SetInt(42)
        }
    }
    // changing nested fields by index
    v.FieldByIndex([]int{0, 1}).SetString("banana")

    // getting fields by name
    v.FieldByName("B").FieldByName("F").SetString("apple")

    log.Printf("%+v", a)
}

完整的示例在这里:play.golang.org/p/z5slFkIU5UE

在处理 reflect.Value 和结构字段时,您得到的是其他值,无法与结构区分。 相反,当处理 reflect.Type 时,您获得一个 reflect.StructField 结构,它是另一种携带字段所有信息的类型。

使用标签

结构字段携带大量信息,从字段名称和索引到其标记:

type StructField struct {
    Name string
    PkgPath string

    Type Type      // field type
    Tag StructTag  // field tag string
    Offset uintptr // offset within struct, in bytes
    Index []int    // index sequence for Type.FieldByIndex
    Anonymous bool // is an embedded field
}

可以使用 reflect.Type 方法获取 reflect.StructField 值:

  • Field

  • FieldByName

  • FieldByIndex

它们是由 reflect.Value 使用的相同方法,但它们返回不同的类型。 NumField 方法返回结构的字段总数,允许我们执行迭代:

type Person struct {
    Name string `json:"name,omitempty" xml:"-"`
    Surname string `json:"surname,omitempty" xml:"-"`
}

func main() {
    v := reflect.ValueOf(Person{"Micheal", "Scott"})
    t := v.Type()
    fmt.Println("Type:", t)
    for i := 0; i < t.NumField(); i++ {
       fmt.Printf("%v: %v\n", t.Field(i).Name, v.Field(i))
    }
}

完整的示例在这里:play.golang.org/p/nkEADg77zFC

标签对于反射非常重要,因为它们可以存储有关字段的额外信息以及其他包如何与其交互的信息。 要向字段添加标签,需要在字段名称和类型之后插入一个字符串,该字符串应具有 key:"value" 结构。 一个字段可以在其标记中有多个元组,并且每对由空格分隔。 让我们看一个实际的例子:

type A struct {
    Name    string `json:"name,omitempty" xml:"-"`
    Surname string `json:"surname,omitempty" xml:"-"`
}

该结构有两个字段,都带有标签,每个标签都有两对。 Get 方法返回特定键的值:

func main() {
    t := reflect.TypeOf(A{})
    fmt.Println(t)
    for i := 0; i < t.NumField(); i++ {
        f := t.Field(i)
        fmt.Printf("%s JSON=%s XML=%s\n", f.Name, f.Tag.Get("json"), f.Tag.Get("xml"))
    }
}

完整的示例在这里:play.golang.org/p/P-Te8O1Hyyn

地图和切片

您可以轻松使用反射来读取和操作地图和切片。 由于它们是编写应用程序的重要工具,让我们看看如何使用反射执行操作。

地图

map 类型允许您使用 KeyElem 方法获取值和键的类型:

func main() {
    maps := []interface{}{
        make(map[string]struct{}),
        make(map[int]rune),
        make(map[float64][]byte),
        make(map[int32]chan bool),
        make(map[[2]string]interface{}),
    }
    for _, m := range maps {
        t := reflect.TypeOf(m)
        fmt.Printf("%s k:%-10s v:%-10s\n", m, t.Key(), t.Elem())
    }
}

完整的示例在这里:play.golang.org/p/j__1jtgy-56

可以以正常访问映射的所有方式访问值:

  • 通过键获取值

  • 通过键的范围

  • 通过值的范围

让我们看一个实际的例子:

func main() {
    m := map[string]int64{
        "a": 10,
        "b": 20,
        "c": 100,
        "d": 42,
    }

    v := reflect.ValueOf(m)

    // access one field
    fmt.Println("a", v.MapIndex(reflect.ValueOf("a")))
    fmt.Println()

    // range keys
    for _, k := range v.MapKeys() {
        fmt.Println(k, v.MapIndex(k))
    }
    fmt.Println()

    // range keys and values
    i := v.MapRange()
    for i.Next() {
        fmt.Println(i.Key(), i.Value())
    }
}

请注意,我们无需传递指向地图的指针以使其可寻址,因为地图已经是指针。

每种方法都非常直接,并取决于您对映射的访问类型。设置值也是可能的,并且应该始终是可能的,因为映射是通过引用传递的。以下代码片段显示了一个实际示例:

func main() {
    m := map[string]int64{}
    v := reflect.ValueOf(m)

    // setting one field
    v.SetMapIndex(reflect.ValueOf("key"), reflect.ValueOf(int64(1000)))

    fmt.Println(m)
}

一个完整的示例可以在这里找到:play.golang.org/p/JxK_8VPoWU0

还可以使用此方法取消设置变量,就像我们在调用delete函数时使用reflect.Value的零值作为第二个参数一样:

func main() {
    m := map[string]int64{"a": 10}
    fmt.Println(m, len(m))

    v := reflect.ValueOf(m)

    // deleting field
    v.SetMapIndex(reflect.ValueOf("a"), reflect.Value{})

    fmt.Println(m, len(m))
}

一个完整的示例可以在这里找到:play.golang.org/p/4bPqfmaKzTC

输出将少一个字段,因为在SetMapIndex之后,映射的长度减少了。

切片

切片允许您使用Len方法获取其大小,并使用Index方法访问其元素。让我们在以下代码中看看它的运行情况:

func main() {
    m := []int{10, 20, 100}
    v := reflect.ValueOf(m)

    for i := 0; i < v.Len(); i++ {
        fmt.Println(i, v.Index(i))
    }
}

一个完整的示例可以在这里找到:play.golang.org/p/ifq0O6bFIZc.

由于始终可以获取切片元素的地址,因此也可以使用reflect.Value来更改切片中相应元素的内容:

func main() {
    m := []int64{10, 20, 100}
    v := reflect.ValueOf(m)

    for i := 0; i < v.Len(); i++ {
        v.Index(i).SetInt(v.Index(i).Interface().(int64) * 2)
    }
    fmt.Println(m)
}

一个完整的示例可以在这里找到:play.golang.org/p/onuIvWyQ7GY

还可以使用reflect包将内容附加到切片。如果值是从切片的指针获得的,则此操作的结果也可以用于替换原始切片:

func main() {
    var s = []int{1, 2}
    fmt.Println(s)

    v := reflect.ValueOf(s)
    // same as append(s, 3)
    v2 := reflect.Append(v, reflect.ValueOf(3))
    // s can't and does not change
    fmt.Println(v.CanSet(), v, v2)

    // using the pointer allows change
    v = reflect.ValueOf(&s).Elem()
    v.Set(v2)
    fmt.Println(v.CanSet(), v, v2)
}

一个完整的示例可以在这里找到:play.golang.org/p/2hXRg7Ih9wk

函数

使用反射处理方法和函数可以收集有关特定条目签名的信息,并调用它。

分析函数

包中有一些reflect.Type的方法将返回有关函数的信息。这些方法如下:

  • NumIn:返回函数的输入参数数量

  • In:返回所选输入参数

  • IsVariadic:告诉您函数的最后一个参数是否是可变参数

  • NumOut:返回函数返回的输出值的数量

  • Out:返回选择输出的Type

请注意,如果reflect.Type的类型不是Func,所有这些方法都会引发恐慌。我们可以通过定义一系列函数来测试这些方法:

func Foo() {}

func Bar(a int, b string) {}

func Baz(a int, b string) (int, error) { return 0, nil }

func Qux(a int, b ...string) (int, error) { return 0, nil }

现在我们可以使用reflect.Type的方法来获取有关它们的信息:

func main() {
    for _, f := range []interface{}{Foo, Bar, Baz, Qux} {
        t := reflect.TypeOf(f)
        name := runtime.FuncForPC(reflect.ValueOf(f).Pointer()).Name()
        in := make([]reflect.Type, t.NumIn())
        for i := range in {
            in[i] = t.In(i)
        }
        out := make([]reflect.Type, t.NumOut())
        for i := range out {
            out[i] = t.Out(i)
        }
        fmt.Printf("%q %v %v %v\n", name, in, out, t.IsVariadic())
    }
}

一个完整的示例可以在这里找到:play.golang.org/p/LAjjhw8Et60

为了获取函数的名称,我们使用runtime.FuncForPC函数,它返回包含有关函数的运行时信息的runtime.Func,包括namefileline。该函数以uintptr作为参数,可以通过函数的reflect.Value和其Pointer方法获得。

调用函数

虽然函数的类型显示了有关它的信息,但为了调用函数,我们需要使用它的值。

我们将向函数传递参数值列表,并获取函数调用返回的值:

func main() {
    for _, f := range []interface{}{Foo, Bar, Baz, Qux} {
        v, t := reflect.ValueOf(f), reflect.TypeOf(f)
        name := runtime.FuncForPC(v.Pointer()).Name()
        in := make([]reflect.Value, t.NumIn())
        for i := range in {
            switch a := t.In(i); a.Kind() {
            case reflect.Int:
                in[i] = reflect.ValueOf(42)
            case reflect.String:
                in[i] = reflect.ValueOf("42")
            case reflect.Slice:
                switch a.Elem().Kind() {
                case reflect.Int:
                    in[i] = reflect.ValueOf(21)
                case reflect.String:
                    in[i] = reflect.ValueOf("21")
                }
            }
        }
        out := v.Call(in)
        fmt.Printf("%q %v%v\n", name, in, out)
    }
}

一个完整的示例可以在这里找到:play.golang.org/p/jPxO_G7YP2I

通道

反射允许我们创建通道,发送和接收数据,并且还可以使用select语句。

创建通道

可以通过reflect.MakeChan函数创建一个新的通道,该函数需要一个reflect.Type接口值和一个大小:

func main() {
    t := reflect.ChanOf(reflect.BothDir, reflect.TypeOf(""))
    v := reflect.MakeChan(t, 0)
    fmt.Printf("%T\n", v.Interface())
}

一个完整的示例可以在这里找到:play.golang.org/p/7_RLtzjuTcz

发送、接收和关闭

reflect.Value类型提供了一些方法,必须与通道一起使用,SendRecv用于发送和接收,Close用于关闭通道。让我们看一下这些函数和方法的一个示例用法:

func main() {
    t := reflect.ChanOf(reflect.BothDir, reflect.TypeOf(""))
    v := reflect.MakeChan(t, 0)
    go func() {
        for i := 0; i < 10; i++ {
            v.Send(reflect.ValueOf(fmt.Sprintf("msg-%d", i)))
        }
        v.Close()
    }()
    for msg, ok := v.Recv(); ok; msg, ok = v.Recv() {
        fmt.Println(msg)
    }
}

这里有一个完整的示例:play.golang.org/p/Gp8JJmDbLIL

选择语句

select语句可以使用reflect.Select函数执行。每个 case 由一个数据结构表示:

type SelectCase struct {
    Dir  SelectDir // direction of case
    Chan Value     // channel to use (for send or receive)
    Send Value     // value to send (for send)
}

它包含操作的方向以及通道和值(用于发送操作)。方向可以是发送、接收或无(用于默认语句):

func main() {
    v := reflect.ValueOf(make(chan string, 1))
    fmt.Println("sending", v.TrySend(reflect.ValueOf("message"))) // true 1 1
    branches := []reflect.SelectCase{
        {Dir: reflect.SelectRecv, Chan: v, Send: reflect.Value{}},
        {Dir: reflect.SelectSend, Chan: v, Send: reflect.ValueOf("send")},
        {Dir: reflect.SelectDefault},
    }

    // send, receive and default
    i, recv, closed := reflect.Select(branches)
    fmt.Println("select", i, recv, closed)

    v.Close()
    // just default and receive
    i, _, closed = reflect.Select(branches[:2])
    fmt.Println("select", i, closed) // 1 false
}

这里有一个完整的示例:play.golang.org/p/_DgSYRIBkJA

反射反射

在讨论了反射在所有方面的工作原理之后,我们现在将专注于其缺点,即在标准库中使用它的情况,以及何时在包中使用它。

性能成本

反射允许代码灵活处理未知数据类型,通过分析它们的内存表示。这并不是没有成本的,除了复杂性之外,反射影响的另一个方面是性能。

我们可以创建一些示例来演示使用反射执行一些琐碎操作时的速度要慢得多。我们可以创建一个超时,并在 goroutines 中不断重复这些操作。当超时到期时,两个例程都将终止,我们将比较结果:

func baseTest(fn1, fn2 func(int)) {
    ctx, canc := context.WithTimeout(context.Background(), time.Second)
    defer canc()
    go func() {
        for i := 0; ; i++ {
            select {
            case <-ctx.Done():
                return
            default:
                fn1(i)
            }
        }
    }()
    go func() {
        for i := 0; ; i++ {
            select {
            case <-ctx.Done():
                return
            default:
                fn2(i)
            }
        }
    }()
    <-ctx.Done()
}

我们可以比较普通的 map 写入与使用反射进行相同操作的速度:

func testMap() {
    m1, m2 := make(map[int]int), make(map[int]int)
    m := reflect.ValueOf(m2)
    baseTest(func(i int) { m1[i] = i }, func(i int) {
        v := reflect.ValueOf(i)
        m.SetMapIndex(v, v)
    })
    fmt.Printf("normal %d\n", len(m1))
    fmt.Printf("reflect %d\n", len(m2))
}

我们还可以测试一下使用反射和不使用反射时读取的速度以及结构字段的设置:

func testStruct() {
    type T struct {
        Field int
    }
    var m1, m2 T
    m := reflect.ValueOf(&m2).Elem()
    baseTest(func(i int) { m1.Field++ }, func(i int) {
        f := m.Field(0)
        f.SetInt(int64(f.Interface().(int) + 1))
    })
    fmt.Printf("normal %d\n", m1.Field)
    fmt.Printf("reflect %d\n", m2.Field)
}

通过反射执行操作时,性能至少下降了 50%,与标准的静态操作方式相比。当性能在应用程序中非常重要时,这种下降可能非常关键,但如果不是这种情况,那么使用反射可能是一个合理的选择。

标准库中的使用

标准库中有许多不同的包使用了reflect包:

  • archive/tar

  • context

  • database/sql

  • encoding/asn1

  • encoding/binary

  • encoding/gob

  • encoding/json

  • encoding/xml

  • fmt

  • html/template

  • net/http

  • net/rpc

  • sort/slice

  • text/template

我们可以思考他们对反射的处理方式,以编码包为例。这些包中的每一个都提供了编码和解码的接口,例如encoding/json包。我们定义了以下接口:

type Marshaler interface {
    MarshalJSON() ([]byte, error)
}

type Unmarshaler interface {
    UnmarshalJSON([]byte) error
}

该包首先查看未知类型是否在解码或编码时实现了接口,如果没有,则使用反射。我们可以将反射视为包使用的最后资源。即使sort包也有一个通用的slice方法,使用反射来设置值和一个排序接口,避免使用反射。

还有其他包,比如text/templatehtml/template,它们读取运行时文本文件,其中包含关于要访问或使用的方法或字段的指令。在这种情况下,除了反射之外,没有其他方法可以完成它,也没有可以避免它的接口。

在包中使用反射

在了解了反射的工作原理以及它给代码增加的复杂性之后,我们可以考虑在我们正在编写的包中使用它。来自其创作者 Rob Pike 的 Go 格言之一拯救了我们:

清晰比聪明更好。反射从来不清晰。

反射的威力是巨大的,但这也是以使代码更加复杂和隐式为代价的。只有在极端必要的情况下才应该使用它,就像在模板场景中一样,并且应该在任何其他情况下避免使用它,或者至少提供一个接口来避免使用它,就像在编码包中一样。

属性文件

我们可以尝试使用反射来创建一个读取属性文件的包。

我们可以使用反射来创建一个读取属性文件的包:

  1. 我们应该做的第一件事是定义一个避免使用反射的接口:
type Unmarshaller interface {
    UnmarshalProp([]byte) error
}
  1. 然后,我们可以定义一个解码器结构,它将利用一个io.Reader实例,使用行扫描器来读取各个属性:
type Decoder struct {
    scanner *bufio.Scanner
}

func NewDecoder(r io.Reader) *Decoder {
    return &Decoder{scanner: bufio.NewScanner(r)}
}
  1. 解码器也将被Unmarshal方法使用:
func Unmarshal(data []byte, v interface{}) error {
    return NewDecoder(bytes.NewReader(data)).Decode(v)
}
  1. 我们可以通过构建字段名称和索引的缓存来减少我们将使用反射的次数。这将很有帮助,因为在反射中,字段的值只能通过索引访问,而不能通过名称访问。
var cache = make(map[reflect.Type]map[string]int)

func findIndex(t reflect.Type, k string) (int, bool) {
    if v, ok := cache[t]; ok {
        n, ok := v[k]
        return n, ok
    }
    m := make(map[string]int)
    for i := 0; i < t.NumField(); i++ {
        f := t.Field(i)
        if s := f.Name[:1]; strings.ToLower(s) == s {
            continue
        }
        name := strings.ToLower(f.Name)
        if tag := f.Tag.Get("prop"); tag != "" {
            name = tag
        }
        m[name] = i
    }
    cache[t] = m
    return findIndex(t, k)
}
  1. 下一步是定义Decode方法。这将接收一个指向结构的指针,然后继续从扫描器中处理行并填充结构字段:
func (d *Decoder) Decode(v interface{}) error {
    val := reflect.ValueOf(v)
    t := val.Type()
    if t.Kind() != reflect.Ptr && t.Elem().Kind() != reflect.Struct {
        return fmt.Errorf("%v not a struct pointer", t)
    }
    val = val.Elem()
    t = t.Elem()
    line := 0
    for d.scanner.Scan() {
        line++
        b := d.scanner.Bytes()
        if len(b) == 0 || b[0] == '#' {
            continue
        }
        parts := bytes.SplitN(b, []byte{':'}, 2)
        if len(parts) != 2 {
            return decodeError{line: line, err: errNoSep}
        }
        index, ok := findIndex(t, string(parts[0]))
        if !ok {
            continue
        }
        value := bytes.TrimSpace(parts[1])
        if err := d.decodeValue(val.Field(index), value); err != nil {
            return decodeError{line: line, err: err}
        }
    }
    return d.scanner.Err()
}

最重要的工作将由私有的decodeValue方法完成。首先要验证Unmarshaller接口是否满足,如果满足,则使用它。否则,该方法将使用反射来正确解码接收到的值。对于每种类型,它将使用reflection.Value的不同Set方法,并且如果遇到未知类型,则会返回错误:

func (d *Decoder) decodeValue(v reflect.Value, value []byte) error {
    if v, ok := v.Addr().Interface().(Unmarshaller); ok {
        return v.UnmarshalProp(value)
    }
    switch valStr := string(value); v.Type().Kind() {
    case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
        i, err := strconv.ParseInt(valStr, 10, 64)
        if err != nil {
            return err
        }
        v.SetInt(i)
    case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
        i, err := strconv.ParseUint(valStr, 10, 64)
        if err != nil {
            return err
        }
        v.SetUint(i)
    case reflect.Float32, reflect.Float64:
        i, err := strconv.ParseFloat(valStr, 64)
        if err != nil {
            return err
        }
        v.SetFloat(i)
    case reflect.String:
        v.SetString(valStr)
    case reflect.Bool:
        switch value := valStr; value {
        case "true":
            v.SetBool(true)
        case "false":
            v.SetBool(false)
        default:
            return fmt.Errorf("invalid bool: %s", value)
        }
    default:
        return fmt.Errorf("invalid type: %s", v.Type())
    }
    return nil
}

使用包

为了测试包是否按预期运行,我们可以创建一个满足Unmarshaller接口的自定义类型。实现的类型在解码时将字符串转换为大写:

type UpperString string

func (u *UpperString) UnmarshalProp(b []byte) error {
        *u = UpperString(strings.ToUpper(string(b)))
        return nil
}

现在我们可以将类型用作结构字段,并验证它在decode操作中是否被正确转换:

func main() {
        r := strings.NewReader(
                "\n# comment, ignore\nkey1: 10.5\nkey2: some string" +
                        "\nkey3: 42\nkey4: false\nspecial: another string\n")
        var v struct {
                Key1 float32
                Key2 string
                Key3 uint64
                Key4 bool
                Key5 UpperString `prop:"special"`
                key6 int
        }
        if err := prop.NewDecoder(r).Decode(&v); err != nil {
                log.Fatal(r)
        }
        log.Printf("%+v", v)
}

总结

在本章中,我们详细回顾了 Go 语言接口的内存模型,强调了接口始终包含一个具体类型。我们利用这些信息更好地了解了类型转换,并理解了当一个接口被转换为另一个接口时会发生什么。

然后,我们介绍了反射的基本机制,从类型和值开始,这是该包的两种主要类型。它们分别表示变量的类型和值。值允许您读取变量的内容,如果变量是可寻址的,还可以写入它。为了使变量可寻址,需要从其地址访问变量,例如使用指针。

我们还看到了如何使用反射处理复杂的数据类型,了解如何访问结构字段值。结构的数据类型可以用于获取关于字段的元数据,包括名称和标签,这些在编码包和其他第三方库中被广泛使用。

我们看到了如何创建和操作映射,包括添加、设置和删除值。对于切片,我们看到了如何编辑它们的值以及如何执行追加操作。我们还展示了如何使用通道发送和接收数据,甚至如何像静态类型编程一样使用select语句。

最后,我们列出了标准库中使用反射的地方,并对其计算成本进行了快速分析。我们用一些关于何时何地使用反射的提示来结束本章,无论是在库中还是在您编写的任何应用程序中。

下一章是本书的最后一章,它解释了如何使用 CGO 在 Go 语言中利用现有的 C 库。

问题

  1. Go 语言中接口的内存表示是什么?

  2. 当一个接口类型被转换为另一个接口类型时会发生什么?

  3. 反射中的ValueTypeKind是什么?

  4. 如果一个值是可寻址的,这意味着什么?

  5. 为什么 Go 语言中结构字段标签很重要?

  6. 反射的一般权衡是什么?

  7. 你能描述一种使用反射的良好方法吗?

第十六章:使用 CGO

本章将向您介绍 CGO,这是一个用于 C 语言的 Go 运行时。它使得可以从 Go 应用程序中调用 C 代码,而由于 C 有大量可用的库,这意味着它们可以在 Go 中被利用。

本章将涵盖以下主题:

  • 从 C 和 Go 中使用 CGO

  • 理解类型差异

技术要求

本章需要安装 Go 并设置您喜欢的编辑器。有关更多信息,请参阅第三章,Go 概述

此外,它需要安装 GCC 编译器在你的机器上。在你的 Unix 机器上可以很容易地使用包管理器来完成这个任务。对于 Ubuntu,命令如下:

 sudo apt install gcc

CGO 简介

CGO 是一种工具,可以让我们在 Go 应用程序中运行 C 代码。这个功能自从 Go 在 2009 年达到 1.0 版本以来就一直存在,当时标准库之外可用的包比现在少,所以我们可以使用现有的 C 库。

C 代码通过 C 伪包访问,通过包名和标识符访问和调用,例如 C.print

import 声明前面有一系列特殊的注释,指定应用程序应该导入哪个 C 源文件:

package example

// #include <stdio.h>
import "C"

这个语句也可以是一个多行注释,可以包含更多的 include 指令,就像之前的例子中的那个,甚至可以直接包含实际的 C 代码:

package example

/*
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>

void someFunction(char* s) {
    printf("%s\n", s);
}
*/
import "C"

重要的是在 C 注释和 import 语句之间避免空行,否则库和代码将被 CGO 导入应用程序。

从 Go 调用 C 代码

为了使用我们自己或他人制作的现有 C 代码,我们需要从 Go 中调用 C。让我们进行一个快速的完整示例,使用只有 C 功能打印一个字符串到标准输出:

package main

/*
#include <stdio.h>
#include <stdlib.h>

void customPrint(char* s) {
    printf("%s\n", s);
}
*/
import "C"

import "unsafe"

func main() {
    s := C.CString(`Printing to stdout with CGO
        Using <stdio.h> and <stdlib.h>`)
            defer C.free(unsafe.Pointer(s))
            C.customPrint(s)
}

我们在这里导入了两个 C 核心库,它们分别是:

  • stdio.h:这包含了输入和输出方法。我们正在使用 printf

  • stdlib.h:这包含了一般函数,包括内存管理。

从前面的代码中可以看到,我们注意到我们要打印的变量不是普通的 Go string,而是通过 C.CString 函数获得的,它接受一个字符串并返回一个 char 切片,因为这就是 C 中字符串的处理方式。该函数定义如下:

func C.CString(string) *C.char

我们可以观察到的第二件事是,我们在延迟调用 C.free,传递了我们定义的 s 变量,但转换成了不同的类型。这个函数调用是必要的,因为语言没有垃圾回收,为了释放内存,应用程序需要明确调用 C 的 free 函数。这个函数接收一个通用指针,它在 Go 中被表示为 unsafe.Pointer 类型。根据 Go 文档,以下内容适用:

"任何类型的指针值都可以转换为指针。"

这正是我们正在做的,因为字符串变量的类型是 *C.char 指针。

从 C 中调用 Go 代码

我们刚刚看到了如何使用 C 包和 import 语句从 Go 应用程序中调用 C 代码。现在,我们将看到如何从 C 中调用 Go 代码,这需要使用另一个特殊的语句叫做 export。这是一个需要放在我们想要导出的函数上面的注释,后面跟着那个函数的名称:

//export theAnswer
func theAnswer() C.int {
    return 42
}

Go 函数需要在 C 代码中声明为外部函数。这将允许 C 代码使用它:

extern int theAnswer();

我们可以通过创建一个导出函数的 Go 应用程序来测试这个功能,这个函数被一个 C 函数使用。这个函数在 Go 的 main 函数中被调用:

package main

// extern int goAdd(int, int);
//
// static int cAdd(int a, int b) {
//     return goAdd(a, b);
// }
import "C"
import "fmt"

//export goAdd
func goAdd(a, b C.int) C.int {
    return a + b
}

func main() {
    fmt.Println(C.cAdd(1, 3))
}

在前面的示例中,我们有一个 goAdd 函数,它使用 export 语句导出到 C。导出的名称与函数的名称匹配,注释和函数之间没有空行。

我们可以注意到在导出函数的签名中使用的类型不是常规的 Go 整数,而是C.int变量。我们将在下一节中看到 C 和 Go 系统的不同之处。

C 和 Go 类型系统

为了在 C 和 Go 之间传递数据,我们需要通过执行正确的转换来传递正确的类型。

字符串和字节切片

Go 中的基本类型string在 C 中不存在。它有char类型,表示一个字符,类似于 Go 的rune类型,并且字符串由以\0结尾的char类型的数组表示。

该语言允许直接声明字符数组作为数组或字符串。第二个声明不以0值结束以结束字符串:

char lang[7] = {'G', 'o', 'l', 'a', 'n', 'g', '\0'};

char lang[] = "Golang";

我们已经看到如何使用以下函数将 Go 字符串转换为 C 字符数组:

func C.CString(string) *C.char

此函数将在堆中分配字符串,因此应用程序有责任使用C.free函数释放此内存。

为了将字节片转换为名为*char的 C 字符指针,我们可以使用以下函数:

func C.CBytes([]byte) unsafe.Pointer

对于C.CString,应用程序在堆中分配数据,并将释放的责任留给 Go 应用程序。

这两个函数之间的主要区别在于第一个生成char[],而另一个创建*char。这两种类型相当于 Go 的string[]byte,因为第一种类型的字节不能更改,而第二种类型的字节可以更改。

有一系列函数用于将 C 类型转换回 Go 类型。就字符串而言,有两个函数:C.GoString从整个数组创建字符串,C.GoStringN允许使用显式长度创建字符串:

func C.GoString(*C.char) string

func C.GoStringN(*C.char, C.int) string

要将 C 的*char转换回 Go 的[]byte,有一个单独的函数:

func C.GoBytes(unsafe.Pointer, C.int) []byte

我们可以使用C.CBytes函数使用 C 修改字节片并将其转换回 Go 片:

package main

/*
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

char* reverseString(char* s) {
    int l = strlen(s);
    for (int i=0; i < l/2; i++) {
        char a = s[i];
        s[i] = s[l-1-i];
        s[l-1-i] = a;
    }
    return s;
}
*/
import "C"

import (
    "fmt"
    "unsafe"
)

func main() {
    b1 := []byte("A byte slice")
    c1 := C.CBytes(b1)
    fmt.Printf("Go ptr: %p\n", b1)
    fmt.Printf("C ptr: %p\n", c1)
    defer C.free(c1)
    c2 := unsafe.Pointer(C.reverseString((*C.char)(c1)))
    b2 := C.GoBytes(c2, C.int(len(b1)))
    fmt.Printf("Go ptr: %p\n", b2)
    fmt.Printf("%q -> %q", b1, b2)
}

执行此应用程序将显示,将字节片b1转换为 C 类型作为c1变量时,地址将更改。由 C 函数返回的 C 片段c2将具有与c1相同的地址,因为它是相同的片段。再次转换回 Go 并分配给b2时,它将具有与初始 Go 字节片b1不同的另一个地址。

我们可以使用 C 字符串函数来实现相同的结果。让我们使用上一个示例中的相同 C 代码并更改其余部分:

package main

/*
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

char* reverseString(char* s) {
    int l = strlen(s);
    for (int i=0; i < l/2; i++) {
        char a = s[i];
        s[i] = s[l-1-i];
        s[l-1-i] = a;
    }
    return s;
}
*/
import "C"

import (
    "fmt"
    "unsafe"
)

func main() {
    s1 := "A byte slice"
    c1 := C.CString(s1)
    defer C.free(unsafe.Pointer(c1))
    c2 := C.reverseString(c1)
    s2 := C.GoString(c2)
    fmt.Printf("%q -> %q", s1, s2)
}

重要的是要注意,将 Go 字符串和字节值传输到 C 时,这些值会被复制。因此,C 代码无法直接编辑它们,而是将编辑副本,保持原始 Go 值不变。

整数

在 C 中,可用的整数类型与 Go 有许多相似之处,因为两种语言中每种整数类型都有带符号和无符号版本,但它们在名称和字节大小方面有所不同。 C 的sizeof函数可以检查每种类型的大小。

以下是 C 中可用的整数类型列表:

有符号类型

类型 大小 范围
char 1 字节 [-128, +127]
int 2 或 4 字节 参见shortlong
short 2 字节 [-32 768, +32 767]
long 4 字节 [-2 147 483 648, +2 147 483 647]
long long 8 字节 [-9 223 372 036 854 775 808, +9 223 372 036 854 775 807]

无符号类型

类型 大小 范围
无符号char 1 字节 [0, +255]
无符号int 2 或 4 字节 参见无符号short或无符号long
无符号short 2 字节 [0, +65 535]
无符号long 4 字节 [0, +4 294 967 295]
无符号long long 8 字节 [0, +18 446 744 073 709 551 615 ]

C中,int的大小取决于架构-在 16 位处理器上曾经是 2 字节,但在现代处理器(32 位和 64 位)上是 4 字节。

当我们从 Go 的领域移动到 C 的领域,反之亦然,我们失去了所有变量溢出的信息。当我们尝试将一个整数变量适应另一个没有足够大小的整数变量时,编译器不会警告我们。我们可以通过一个简短的例子来看到这一点,如下所示:

package main

import "C"

import "fmt"

func main() {
    a := int64(0x1122334455667788)

    // a fits in 64 bits
    fmt.Println(a)
    // short overflows, it's 16
    fmt.Println(C.short(a), int16(0x7788))
    // long also overflows, it's 32
    fmt.Println(C.long(a), int32(0x55667788))
    // longlong is okay, it's 64
    fmt.Println(C.longlong(a), int64(0x1122334455667788))
}

我们可以看到a的值是一个确定的数字,但shortlong变量没有足够的字节,所以它们将有不同的值。转换显示了在转换时只有最后的字节被取自变量,其他字节被丢弃。

这是一个有用的 C 类型和可比较的 Go 类型的列表,以及如何在 Go 代码中使用它们:

C 类型 Go 类型 CGO 类型
char int8 C.char
short int16 C.short
long int32, rune C.long
long long int64 C.longlong
int int C.int
无符号的char uint8, byte C.uchar
无符号的short uint16 C.ushort
无符号的long uint32 C.ulong
无符号的long long uint64 C.ulonglong
无符号的int uint C.uint

在执行转换时,您可以使用此表作为参考,并避免使用错误类型导致的错误,因为在使用 CGO 时没有溢出警告。

浮点类型

在 C 中,float类型与 Go 中的类型非常相似:

  • C 提供了 32 位的float和 64 位的double

  • Go 有float32float64

当从 64 位值转换为 32 位值时,可能会导致四舍五入误差,如下面的代码所示:

package main

import "C"

import (
    "fmt"
    "math"
)

func main() {
    a := float64(math.Pi)

    fmt.Println(a)
    fmt.Println(C.float(a))
    fmt.Println(C.double(a))
    fmt.Println(C.double(C.float(a)) - C.double(a))
}

前面的例子显示了math.Pi的值从3.141592653589793变为3.1415927,导致了约1/10⁷的错误。

不安全的转换

现在我们将看到如何使用unsafe包从 C 中直接编辑 Go 变量。

直接编辑字节切片

还可以使用一个不正当的技巧直接编辑 Go 字节切片。从 Go 的角度来看,切片是一组值:

  • 第一个元素的指针

  • 切片的大小

  • 切片的容量

在 C 中,字节切片只是一系列字节,字符串是以\0结尾的字符切片。

如果我们使用unsafe包将指针传递给切片的第一个元素,我们将能够直接编辑现有的字节切片,而无需执行复制和转换。我们可以看到如何在以下应用程序中执行此转换:

package main

/*
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

void reverseString(char* s) {
    int l = strlen(s);
    for (int i=0; i < l/2; i++) {
        char a = s[i];
        s[i] = s[l-1-i];
        s[l-1-i] = a;
    }
}
*/
import "C"

import (
  "fmt"
  "unsafe"
)

func main() {
    b1 := []byte("A byte slice")
    fmt.Printf("Slice: %s\n", b1)
    C.reverseString((*C.char)(unsafe.Pointer(&b1[0])))
    fmt.Printf("Slice: %s\n", b1)
}

转换是使用表达式(*C.char)(unsafe.Pointer(&b1[0]))执行的,它执行以下操作:

  • 获取切片的第一个元素的指针

  • 将其转换为不安全的指针

  • byte指针转换为C.char指针,共享内存表示

数字

使用unsafe包,我们还可以将数字变量的指针转换为其 C 对应项。这使我们能够直接在 C 代码中编辑它:

package main

/*
void half(double* f) {
    *f = *f/2;
}
*/
import "C"

import (
    "fmt"
    "math"
    "unsafe"
)

func main() {
    a := float64(math.Pi)
    fmt.Println(a)
    C.half((*C.double)(unsafe.Pointer(&a)))
    fmt.Println(a)
}

前面的示例确实做到了这一点;它在 C 函数中将a的值减半,而不是在 Go 中复制并分配新值。

使用切片

Go 切片和 C 切片在一个基本方面有所不同——Go 版本嵌入了长度和容量,而在 C 中,我们只有指向第一个元素的指针。这意味着在 C 中,长度和容量必须存储在其他地方,比如另一个变量中。

让我们来看看以下的 Go 函数,它计算一系列float64数字的平均值:

func mean(l []float64) (m float64) {
    for _, a := range l {
        m += a
    }
    return m / float64(len(l))
}

如果我们想在 C 中有一个类似的函数,我们需要传递一个指针以及它的长度。这将避免诸如分段错误之类的错误,当应用程序尝试访问未分配给它的内存时会发生这种错误。如果内存仍然分配给应用程序,结果是它提供对具有未知值的内存区域的访问,导致不可预测的结果:

double mean(int len, double *a) {
    if (a == NULL || len == 0) {
        return 0;
    }
    double m = 0;
    for (int i = 0; i < len; i++) {
        m+=a[i];
    }
    return m / len;
}

我们可以尝试使用一个 Go 包装器来使用这个函数,该包装器接受一个切片,并将长度传递给 C 函数:

func mean(a []float64) float64 {
    if len(a) == 0 {
        return 0
    }
    return float64(C.mean(C.int(len(a)), (*C.double)(&a[0])))
}

为了验证发生了什么,我们还可以创建一个传递了不正确长度的类似函数:

func mean2(a []float64) float64 {
    if len(a) == 0 {
        return 0
    }
    return float64(C.mean(C.int(len(a)*2), (*C.double)(&a[0])))
}

使用这个函数时,我们会看到应用程序不会引发任何分段错误,但得到的结果会有所不同。这是因为第二个将在平均计算中添加一系列额外的值,如下所示:

var a = make([]float64, 10)

func init() {
    for i := range a {
        a[i] = float64(i + 1)
    }
}

func main() {
    cases := [][]float64{a, a[1:4], a[:0], nil}
    for _, slice := range cases {
        fmt.Println(slice, mean(slice))
    }
    for _, slice := range cases {
        fmt.Println(slice, mean2(slice))
    }
}

使用结构

在了解了切片的工作原理之后,我们将知道如何在 C 和 Go 中使用结构处理复杂的数据。接下来让我们看看以下几节。

Go 中的结构

Go 结构使用一种称为对齐的技术,它包括向数据结构添加一个或多个字节,以使其更好地适应内存地址。考虑以下数据结构:

struct {
    a string
    b bool
    c []byte
}

使用 64 位架构在这个结构上调用unsafe.Sizeof,这将给我们一个意外的结果。我们期望的是以下结果:

  • 16 字节来自字符串;8 字节用于指向第一个元素,8 字节用于长度

  • 布尔值占 1 字节

  • 24 用于切片;8 用于地址,8 用于长度,8 用于容量

总数应该是 41,但函数返回 48。这是因为编译器在布尔值之后插入了额外的字节,以达到 8 字节(64 位),并优化 CPU 的操作。该结构可以在内存中表示如下:

我们可以看到布尔变量占用 1 位,并且编译器添加了 7 位额外的位。这非常有帮助,因为它避免了其他变量存储在一个内存槽中的一半,另一半存储在另一个内存槽中。这将导致每次操作需要两次读取和两次写入,性能显著下降。

如果两个或更多字段足够小,可以适应 64 位的一个槽,它们将被顺序存储。我们可以通过以下示例看到这一点:

struct {
    a, b bool
    c rune
    d byte
    e string
}

这个结构在 64 位架构上的内存表示如下:

我们可以清楚地看到布尔变量,runebyte都在同一个内存地址上,并且在最后一个字段上添加了一个字节的填充以对齐。

手动填充

Go 使得可以使用空白标识符手动指定结构中的填充。考虑以下数据结构:

struct{
    a int32
    b int32
}

这将有以下表示:

我们可以使用空白标识符手动指定填充,并为 64 位架构优化数据结构:

struct{
    a int32
    _ int32
    b int32
    _ int32
}

这将允许应用程序将每个int32存储在自己的内存位置,因为空白字段将充当填充:

C 中的结构

C 中的结构共享与 Go 相同的对齐概念,但它们总是使用 4 字节填充对齐。与 Go 不同的是,可以完全避免填充,这有助于通过减少内存使用量来节省空间。让我们在以下几节中了解更多。

未打包的结构

除非另有说明,否则我们定义的每个结构都将是未打包的。我们可以在 C 中定义一个结构如下:

typedef struct{
  unsigned char a;
  char b;
  int c;
  unsigned int d;
  char e[10];
} myStruct;

我们可以直接从我们的 Go 代码中使用它并填充它的值,而不会出现任何问题:

func main() {
    v := C.myStruct{
        a: C.uchar('A'),
        b: C.char('Z'),
        c: C.int(100),
        d: C.uint(10),
        e: [10]C.char{'h', 'e', 'l', 'l', 'o'},
    }
    log.Printf("%#v", v)
}

这个小测试将给我们以下输出:

main._Ctype_struct___0{
    a:0x41, 
    b:90, 
    c:100, 
    d:0xa, 
    e:[10]main._Ctype_char{104, 101, 108, 108, 111, 0, 0, 0, 0, 0},
     _:[2]uint8{0x0, 0x0},
}

这告诉我们有一个额外的空白字段用于填充,因为最后一个字段是 10 字节,比 4 的倍数(即 12 字节)短 2 字节。

紧凑结构

我们可以使用pragma pack指令在 C 中定义一个紧凑的结构。我们可以将之前的结构打包如下:

#pragma pack(1)
typedef struct{
  unsigned char a;
  char b;
  int c;
  unsigned int d;
  char e[10];
} myStruct;

如果我们尝试在我们的 Go 代码中使用 C 结构,如果使用字段cd,我们将获得编译错误:

pack := C.myStruct{
    a: C.uchar('A'),
    b: C.char('Z'),
    c: C.int(100),
    d: C.uint(10),
    e: [10]C.char{},
}

如果我们尝试像我们对未打包版本所做的那样打印结构,我们将看到原因:

main._Ctype_struct___0{
    a:0x41, 
    b:90, 
    _:[8]uint8{0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}, 
    e:[10]main._Ctype_char{104, 101, 108, 108, 111, 0, 0, 0, 0, 0},
}

从输出中我们可以看到,4 字节的cd字段被一个 8 字节的空字段替换,Go 无法访问。因此,我们无法从 Go 中填充结构,但可以在应用程序的 C 部分访问这个字段:

myStruct makeStruct(){
  myStruct p;
  p.a = 'A';
  p.b = 'Z';
  p.c = 100;
  p.d = 10;
  p.e[0] = 'h';
  p.e[1] = 'e';
  p.e[2] = 'l';
  p.e[3] = 'l';
  p.e[4] = 'o';
  p.e[5] = '\0';
  p.e[6] = '\0';
  p.e[7] = '\0';
  p.e[8] = '\0';
  p.e[9] = '\0';
  return p;
}

这将允许我们返回一个带有正确值的结构。我们可以打印它并看到_字段包含cd的值:

main._Ctype_struct___0{
    a:0x41, 
    b:90, 
    _:[8]uint8{0x64, 0x0, 0x0, 0x0, 0xa, 0x0, 0x0, 0x0}, 
    e:[10]main._Ctype_char{104, 101, 108, 108, 111, 0, 0, 0, 0, 0}
}

现在我们有了数据,我们需要创建一个能够承载它的 Go 结构:

type myStruct struct {
    a uint8
    b int8
    c int32
    d uint32
    e [10]uint8
}

现在,我们需要从 C 结构中读取原始字节并手动解包它:

func unpack(i *C.myStruct) (m myStruct) {
    b := bytes.NewBuffer(C.GoBytes(unsafe.Pointer(i), C.sizeof_myStruct))
    for _, v := range []interface{}{&m.a, &m.b, &m.c, &m.d, &m.e} {
        binary.Read(b, binary.LittleEndian, v)
    }
    return
}

我们可以使用C.GoBytes函数,它适用于任何指针(不仅仅是字节),并指定我们定义的结构的大小,该大小存储在常量C.sizeof_myStruct中。然后,我们可以使用binary.Read函数按顺序读取每个字段,使用小端LE)编码。

我们可以看到生成的结构包含所有正确字段中的数据:

main.myStruct{
    a:0x41, 
    b:90, 
    c:100, 
    d:0xa, 
    e:[10]uint8{0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x0, 0x0, 0x0, 0x0, 0x0},
}

CGO 建议

我们现在已经看到了如何在整数、浮点数、切片和结构体中使用 CGO。这是一个非常强大的工具,可以方便我们在 Go 应用程序中使用大量现有的 C 代码。就像我们在上一章中对反射所做的那样,我们现在要谈谈 CGO 的不太明显的缺点。

编译和速度

Go 的一个特点是编译速度非常快。使用 CGO 时,编译所涉及的工作量要大得多,不仅仅是将所有.go文件传递给 Go 编译器。编译过程大致如下:

  • CGO 需要创建 C 到 Go 和 Go 到 C 的存根。

  • 需要调用make命令来编译所有 C 源文件。

  • 所有文件都合并在一个.o文件中。

  • 系统的链接器需要验证 Go 和 C 之间的所有引用是否有效。

如果这个过程顺利进行,您可以启动您的应用程序,但如果遇到任何问题,您需要检查 C 和 Go 之间的错误,这并不像调试纯 Go 应用程序那样容易。

另一个缺点是,并非每个操作系统都自带make命令。C 部分可能需要一些额外的标志才能正确编译,这不能由go installgo build处理。您需要为您的应用程序创建一个编译脚本,比如一个makefile脚本。

性能

在讨论如何让 C 和 Go 相互通信时,我们看到对于每种类型,都需要执行一个转换操作。这对于数字来说可能很简单,但对于字符串、字节或切片来说可能会更复杂,当涉及到结构时甚至更复杂。这些操作不是免费的,无论是在内存使用还是性能方面。这对许多应用程序来说不是问题,但如果您试图实现高性能,这可能成为瓶颈。

C 代码并不知道其 Go 对应部分发生了什么。当需要调用它时,Go 需要以适合它的格式将有关其堆栈的信息传递给 C。当 C 代码完成执行时,需要将有关堆栈状态和使用的变量的信息从 C 传递回 Go。

来自 C 的依赖

使用 CGO 时,您面临着其他语言在创建与 C 代码绑定或包装时面临的相同问题。您完全依赖于它。

Go 应用程序必须处理 C 如何使用内存和其他资源,而 C 应用程序不知道 Go 在做什么,也不使用任何并发,既不是 goroutine 也不是线程。

除此之外,C 代码很难调试、维护和替换,如果您不是 C 开发人员。因此,有时最好从头开始编写一个库,而不是依赖现有的 C 实现。

一个很好的例子是go-gitgithub.com/src-d/go-git),它通过模仿现有的 C 库libgit2,在纯 Go 中实现了 Git 协议功能。

总结

在本章中,我们看到了 Go 工具中非常强大的一个工具:CGO。这允许 Go 应用程序运行 C 代码,反过来又可以调用 Go 函数。我们看到它需要一个特殊的import语句,import "C",这是一个伪包,其中包含所有可供 Go 使用的 C 代码。要导出 Go 代码并使其可供 C 使用,有一个特殊的注释//export,它使 Go 函数在 C 命名空间中可用。

我们看到 C 和 Go 类型系统在某些方面非常相似,但在其他方面非常不同。我们看到字符串和字节数组可以转换为 C 类型,反之亦然。C 和 Go 中的整数也非常相似,主要区别在于int类型。在 C 中,这是 4 个字节,而在 Go 中,它是 4 或 8 个字节,取决于架构。浮点数也非常相似,在 C 和 Go 中都有 4 位和 8 位版本,只是名称不同。

还可以直接编辑数字 Go 变量或字节切片,而不创建副本。这是通过使用unsafe.Pointer函数来强制进行转换,否则是不允许的。C 中的切片只是指向第一个元素的指针,切片的长度需要存储在另一个变量中。这就是为什么我们创建了接受切片并将两个参数传递给它们的 C 对应函数的 Go 函数。

在谈论数据结构之前,我们必须提到对齐是什么,Go 是如何实现对齐的,以及 C 对齐与 Go 的不同之处。CGO 中的数据结构使用对齐,并且非常容易使用。如果它们没有打包,我们可以轻松地传递它们并提取值。如果结构被打包,我们就无法访问其中的一些字段,需要一种解决方法来手动执行转换到 Go。

最后一个主题集中讨论了 CGO 的缺点,从构建时间较慢到性能下降,因为需要转换,以及由于 C 代码的存在,应用程序将变得更难维护。

希望你迄今为止享受了这段 Go 之旅,并且它将帮助你编写现代、并发和高效的应用程序。

问题

  1. CGO 是什么?

  2. 如何从 Go 调用 C 代码?

  3. 如何在 C 中使用 Go 代码?

  4. Go 和 C 之间的数据类型有什么区别?

  5. 如何在 C 代码中编辑 Go 值?

  6. 打包数据结构的主要问题是什么?

  7. CGO 的主要缺点是什么?

第十七章:评估

第一章

  1. 应用程序和系统编程之间有什么区别?

应用程序编程侧重于为最终用户解决问题,而系统编程是关于创建其他软件使用的软件。

  1. 什么是 API?API 为什么如此重要?

API 是软件公开的用于控制其控制资源访问的接口。它描述了其他应用程序应该如何与软件通信。

  1. 你能解释一下保护环是如何工作的吗?

保护环是一种用于防止故障并增加安全性的系统。它以层次化的安全级别安排安全性,并通过使用特定的网关允许对更强大级别的功能进行中介访问。

  1. 你能举一些在用户空间无法执行的例子吗?

用户空间中的应用程序不能将其当前空间更改为内核,也不能忽略文件系统访问硬盘,并且不能更改页表。

  1. 什么是系统调用?

系统调用是操作系统提供的 API,用于访问计算机的资源。

  1. Unix 用哪些调用来管理进程?

Unix 用于管理进程的调用如下:forkexitwait

  1. POSIX 为什么有用?各种 POSIX 标准定义了进程控制、信号、分段、非法指令、文件和目录操作、管道、I/O 控制和 C 库、shell 和实用程序,以及实时和多线程扩展。对于开发人员来说,它非常有用,因为它有助于构建与共享此标准的不同操作系统兼容的应用程序。

  2. Windows 是否符合 POSIX?Windows 不符合 POSIX,但正在尝试提供 POSIX 框架,例如 Windows Linux 子系统。

第二章

  1. 现代操作系统使用哪种文件系统?

现代操作系统使用不同的文件系统:Windows 和 macOS 使用各自专有的格式 NTFS 和 APFS,而 Linux 系统主要使用 EXT4。

  1. 什么是 inode?Unix 中的 inode 0是什么?

inode 是表示文件的文件系统数据结构。它存储有关文件的信息,但不包括名称和数据。

inode 0 保留给/文件夹。

  1. PID 和 PPID 之间有什么区别?

PID 是现有进程的唯一标识符,而 PPID 是父进程的标识符。当现有进程创建另一个进程时,新进程的 PPID 等于现有进程的 PID。

  1. 如何终止后台运行的进程?

虽然SIGINT信号可以通过按Ctrl + C发送给前台进程,但对于后台进程,信号需要使用kill命令发送,此时为kill -2 PID

  1. 用户和组之间有什么区别?

用户标识一个可以拥有文件和进程的帐户,而组是在文件上共享权限的机制。

  1. Unix 权限模型的范围是什么?

Unix 权限模型使得可以通过三种不同级别的权限来限制对文件的访问:所有者、组和所有其他用户。

  1. 你能解释一下信号和退出代码之间的区别吗?

信号和退出代码都是进程之间的通信方法,但信号是从任何进程到另一个进程,而退出代码用于从子进程到其父进程的通信。

  1. 什么是交换文件?

交换文件是用于存储不需要的页面以释放主内存的物理内存的扩展。

第三章

  1. 导出符号和未导出符号有什么区别?

导出符号可以被其他软件包使用,而未导出符号不能。第一组具有以大写字母开头的标识符,而第二组没有。

  1. 自定义类型为什么重要?

自定义类型允许定义方法并有效地使用接口,或者继承另一种类型的数据结构,但是要摆脱它的方法。

  1. 短声明的主要限制是什么?

短声明不允许推断出值的变量类型。通过对值进行类型转换,可以克服这种限制。

  1. 作用域是什么,它如何影响变量遮蔽?

变量的作用域代表了它的生命周期和可见性,可以是包、函数或块。当相同的标识符在内部作用域中使用时,会发生遮蔽,阻止外部作用域共享该标识符的符号访问。

  1. 如何访问一个方法?

方法是一种特殊类型的函数,它们具有与其所属类型相关联的命名空间。它们可以作为其类型实例的属性访问,也可以作为类型本身的属性访问,将实例作为第一个参数传递。

  1. 解释一下一系列if/else语句和switch语句之间的区别。

一系列的ifelse语句允许对每个if语句执行一个简短的声明,并且只会执行一个 case,跳过后续的声明。switch语句只允许一个声明,并且可以使用continuebreak语句修改流程。

  1. 在典型的用例中,通常谁负责关闭通道?

通道应该由发送方关闭,因为发送方负责通知没有更多信息要发送。此外,向关闭的通道发送会引发恐慌,而从中接收是一个非阻塞操作。

  1. 什么是逃逸分析?

逃逸分析是 Go 编译器执行的优化过程,试图通过验证变量是否超出了它们定义的函数的生存期来减少在堆中分配的变量。

第四章

  1. 绝对路径和相对路径有什么区别?

绝对路径以/(根)路径开头,而相对路径不是。要从相对路径获取绝对路径,必须将其连接到当前工作目录。

  1. 如何获取或更改当前工作目录?

要找出当前工作目录,os包提供了Getwd函数,它返回当前工作目录。要更改当前工作目录,必须使用Chdir函数。它接受相对路径和绝对路径。

  1. 使用ioutil.ReadAll的优缺点是什么?

ioutil.ReadAll函数将整个文件内容放入一个字节切片中,因此文件的大小会影响分配和释放的内存量。由于这种方式分配的内存没有回收利用,这些切片在不再使用时会被垃圾回收。

  1. 为什么对于读取操作来说,缓冲区很重要?

字节缓冲区限制了读取操作分配的内存量,但它们也需要一定数量的读取操作,每个操作都带有一些影响速度和性能的开销。

  1. 何时应该使用ioutil.WriteFile?如果内容的大小不是太大,可以使用ioutil.WriteFile函数,因为整个内容需要在内存中。在短期应用中最好使用它,并且避免在频繁写入操作中使用它。

  2. 使用允许窥视的缓冲区读取时可以进行哪些操作?

窥视操作允许检查下一个字节的内容,而不会推进当前读取器的光标,这使我们能够进行上下文操作,例如读取单词、读取行或任何基于自定义标记的操作。

  1. 何时最好使用字节缓冲区读取内容?

使用读取缓冲区是降低应用程序内存使用的一种方式。当不需要一次性获取所有内容时,可以使用它。

  1. 缓冲区如何用于写入?使用它们的优势是什么?

在写入操作中,应用程序已经处理了即将写入的字节,因此使用底层缓冲区来优化系统调用的次数,只有当缓冲区满时才会进行系统调用的添加,以避免在传递给写入器的数据不足时增加系统调用开销。

第五章

  1. 什么是流?

流是表示通用传入或传出数据流的抽象。

  1. 哪些接口抽象了传入流?

io.Reader接口是用于传入流的抽象。

  1. 哪个接口代表传出流?

io.Writer接口是用于传出流的抽象。

  1. 何时应该使用字节读取器?何时应该使用字符串读取器?

当原始数据是字节片时应该使用字节读取器,而当原始数据是字符串时应该使用字符串读取器。从一种数据类型转换为另一种会导致复制并且不方便。

  1. 字符串构建器和字节缓冲区有什么区别?

字节缓冲区可以被重用和覆盖。字符串构建器用于创建一个字符串而不是复制,因此它使用一个字节切片并将其转换为字符串而不复制,使用unsafe包。

  1. 为什么读取器和写入器的实现应该接受接口作为输入?

接受接口作为输入意味着对具有相同行为的不同类型持开放态度。这使得现有的读取器和写入器,如缓冲区和文件,可以被使用。

  1. 管道与 TeeReader 有何不同?

管道将写入器连接到读取器。无论写入了什么,读取器都会读取。TeeReader则相反,将读取器连接到写入器,因此读取的内容也会被写入到其他地方。

第六章

  1. 什么是终端,什么是伪终端? 终端是一个行为类似于电传打字机的应用程序,通过显示一个 2x2 的字符矩阵。伪终端是在终端下运行并通过交互来模拟其行为的应用程序。

  2. 伪终端应该具备什么功能? 伪终端应用程序应该能够接收用户输入,根据接收到的指令执行操作,并将结果显示给用户。

  3. 我们使用了哪些 Go 工具来模拟终端? 为了管理用户输入,我们在标准输入中使用了一个缓冲扫描器,它将逐行读取用户输入。每个命令都是使用相同的接口实现的。为了理解调用的命令,我们使用了第一个参数和可用命令之间的比较。一个写入器被传递给命令来打印它们的输出。

  4. 我的应用程序如何从标准输入获取指令? 应用程序可以使用标准输入结合扫描器,每次遇到新行时都会返回一个新的标记。

  5. 使用接口命令有什么优势? 使用接口命令允许我们和我们包的用户通过实现他们自己的接口版本来扩展行为。

  6. 什么是莱文斯坦距离?为什么在伪终端中有用? 莱文斯坦距离是将一个字符串转换为另一个字符串所需的更改次数。当用户指定一个不存在的命令时,它可以用于向用户建议其他命令。

第七章

  1. Go 应用程序内部的当前进程可用的应用程序有哪些?

进程可用的应用程序有 PID(进程 ID)、PPID(父进程 ID)、UID 和 GID(用户和组 ID)以及工作目录。

  1. 如何创建子进程?

exec.Cmd数据结构可用于定义子进程。当调用RunStartOutputCombinedOutput方法之一时,进程将被创建。

  1. 如何确保子进程在其父进程之后继续存在?

在 Unix 系统中,默认情况下,如果父进程终止,子进程会继续存在。此外,您可以更改子进程的进程组和会话 ID,以确保其继续存在。

  1. 可以访问子属性吗?它们如何使用?

最大的优势之一是访问子 PID 以将其持久化在某个地方,例如磁盘上。这将允许应用程序的另一个实例或任何其他应用程序知道子进程的标识符,并验证它是否仍在运行。

  1. 在 Linux 中,守护进程是什么,它们是如何处理的?

在 Linux 中,守护进程是在后台运行的进程。为了创建一个守护进程,进程可以创建自身的一个分支并终止,将init进程设置为分支的父进程,将当前工作目录设置为分支的根目录,将子进程的输入设置为null,并使用日志文件进行输出和错误处理。

第八章

  1. 退出代码是什么?谁使用它?

退出代码是从进程传递给其父进程的整数值,用于表示进程结束的结果。如果没有错误,则为0。父进程可以使用此值决定下一步该做什么,例如,如果出现错误,则再次运行进程。

  1. 应用程序发生 panic 时会发生什么?返回什么退出代码?

如果panic没有被恢复,应用程序将执行所有延迟函数,并以状态2退出。

  1. Go 应用程序在接收所有信号时的默认行为是什么?

Go 应用程序在处理信号时的默认行为是早期退出。

  1. 你如何拦截信号并决定应用程序的行为?

可以使用signal.Notify方法在通道上拦截接收到的信号,指定要处理的信号类型。通道接收到的值可以与信号值进行比较,并相应地应用程序可以表现出不同的行为。

  1. 你能向其他进程发送信号吗?如果可以,怎么做?

在 Go 应用程序中,可以向另一个进程发送信号。为了做到这一点,应用程序需要使用查找函数获取os.Process结构的实例,然后可以使用该结构的Signal方法发送信号。

  1. 管道是什么,它们为什么重要?

管道是两个流,一个是输出流,另一个是输入流,它们连接在一起。输出中写入的内容可以在输入中使用,这有助于将一个进程的输出连接到另一个进程的输入。

第九章

  1. 使用通信模型的优势是什么?

通信模型允许您抽象处理模型中处理的数据类型,使不同端点之间的通信变得容易。

  1. TCP 和 UDP 连接之间有什么区别?

TCP 是面向连接的,这使得它可靠,因为它在发送新数据之前验证目标是否正确接收数据。UDP 连接持续发送数据,而不确认目标是否接收了数据包。这可能导致数据包丢失,但它使连接更快,不会积累延迟。

  1. 发送请求时,谁关闭请求体?

在进行 HTTP 调用时关闭请求是应用程序的责任。

  1. 在服务器接收请求时,谁关闭请求体?

当连接关闭时,请求体会自动关闭,但服务器也可以在更早的时候关闭它,如果它愿意的话。

第十章

  1. 文本和二进制编码之间的权衡是什么?

基于文本的编码对人类来说更容易阅读,也更容易调试和编写,但由于这个原因占用更多的空间。二进制编码对人类来说更难编写、阅读和调试,但尺寸更小。

  1. Go 在编码时默认如何处理数据结构?

Go 的默认行为是使用反射来读取字段及其值。

  1. 这种行为如何改变?

通过实现你正在使用的编码的编组器接口,如json.Marshaller用于 JSON,可以改变这种行为。

  1. 结构字段如何在 XML 属性中编码?

结构字段需要在其标签中指定,attr值。

  1. 解码gob接口值需要什么操作?

实现接口的数据类型需要使用gob.Register函数在gob包中注册。

  1. 什么是协议缓冲编码?协议缓冲是由谷歌制定的一种编码协议,它使用定义文件来定义数据结构和服务。该文件用于生成数据模型、客户端和服务器存根,只留下服务器的实现给开发人员。

第十一章

  1. 什么是线程,谁负责它?

线程是进程的一部分,可以由特定的核心或 CPU 分配。它携带有关应用程序状态的信息,就像进程一样,并由操作系统调度程序管理。

  1. goroutine 与线程有什么不同?

与线程相比,goroutine 非常小,比例为 1:100,并且它们不受操作系统管理。Go 运行时负责调度 goroutine。

  1. 在启动 goroutine 时何时评估参数?

启动 goroutine 的函数传递的所有参数在创建 goroutine 时进行评估。这意味着如果参数的值在 goroutine 实际被调度程序选中并启动之前发生变化,那么这种变化不会反映在 goroutine 中。

  1. 缓冲和非缓冲通道有什么不同?

如果未指定容量,或者为0,则make函数创建非缓冲通道。对这样的通道的每次发送操作都会阻塞当前的 goroutine,直到另一个 goroutine 执行接收操作。缓冲通道可以支持等于其容量的非阻塞发送操作数量。这意味着如果通道的容量为n,那么前n-1个未被任何接收操作匹配的发送操作将不会阻塞。

  1. 为什么单向通道有用?

它们只允许一部分操作,清楚地告诉用户通道的范围。只接收通道不允许发送数据,或关闭它,这是有道理的,因为这不是接收者的责任。只发送通道不允许接收数据,但允许发送和关闭通道,并暗示发送者关闭通道以表示没有更多数据。

  1. 当对nil或关闭的通道执行操作时会发生什么?

nil通道发送或接收会永久阻塞,关闭它会导致恐慌。从关闭的通道接收会立即返回零值和false,而向关闭的通道发送会引发恐慌,如果再次尝试关闭它也会发生相同的情况。

  1. 计时器和滴答器用于什么?

计时器和滴答器都创建一个只接收通道。计时器可以在循环中与select语句一起使用,而不是使用default,以减少选择的频率并降低应用程序在空闲时的 CPU 使用率。滴答器非常适用于在固定时间间隔内执行操作,而一个实际的用途是速率限制器,它限制了在应用程序的某个部分内在一定时间段内执行的次数。

第十二章

  1. 什么是竞争条件?

竞争条件是应用程序试图同时在同一资源上执行两个操作的情况,而资源的性质只允许一次操作。

  1. 当尝试在映射上同时执行读取和写入操作时会发生什么?

当同时发生对映射的读取和写入操作时,会导致运行时错误:concurrent map writes

  1. MutexRWMutex之间有什么区别?

常规互斥锁允许锁定和解锁资源,并且每个操作的优先级相同。读/写互斥锁有两种类型的锁,一种用于每个操作(读/写)。读锁允许同时进行多个操作,同时它是排他的。如果资源上有许多连续的读操作,写锁可能会受到延迟。这被称为写饥饿。

  1. 等待组有什么用?

等待组是与不同 goroutine 的执行同步的完美工具。这使得在经典设置中有多个并发操作时,主 goroutine 必须等待它们结束才能继续的解决方案变得干净而优雅。

  1. sync.Once的主要用途是什么?

sync.Once可用于在一次性执行并发操作。例如,它可用于一次关闭通道并避免恐慌。另一个用例是延迟初始化变量以实现单例设计模式的线程安全版本。

  1. 你如何使用池?

池允许重复使用短暂的项目。池的一个很好的用例是字节片和字节缓冲区,因为池将防止这些资源被垃圾收集器回收,同时防止分配新的池。

  1. 使用原子操作的优势是什么?

对于数字变量使用互斥锁会有很大的开销。原子操作可以减少这种开销,并在数字变量上执行线程安全操作。它的主要用途是整数,但通过一些转换,我们可以对其他类型进行相同的操作,例如布尔值和浮点数。

第十三章

  1. Go 中的上下文是什么?

上下文是一个包,包含一个通用接口和一些辅助函数来返回上下文实例。它用于在应用程序的各个部分之间同步操作和携带值。

  1. 取消、截止日期和超时之间有什么区别?

上下文有三种不同类型的过期——取消是应用程序显式调用取消函数,截止日期是上下文超过指定时间,超时是上下文经历特定持续时间。

  1. 传递上下文值时的最佳实践是什么?

使用上下文传递的值应与当前范围或请求相关。它们不应该被用作传递可选函数参数或对应用程序至关重要的变量的方式。使用自定义私有类型作为键也是一个好主意,因为内置值可能被其他包覆盖。指向值的指针也是解决此类问题的一种方法。

  1. 哪些标准包已经使用了上下文?

有不同的包使用上下文。最值得注意的是net/http,它使用上下文进行请求和服务器关闭;net使用上下文进行DialListen等功能;database/sql使用上下文来取消查询等操作。

第十四章

  1. 生成器是什么?它的责任是什么?

生成器是一个返回一系列值的工具——每次调用时,它返回系列中的下一个值。它负责按需生成序列中的值。在 Go 中,可以通过使用通道来接收由创建它们的 goroutine 发送的值来实现这一点。

  1. 你会如何描述一个管道?

管道是一种将执行分割成不同阶段的应用程序流程。这些阶段通过某种通信方式进行通信,例如网络,或者运行时内部,例如通道。

  1. 什么类型的阶段获取一个通道并返回一个?

中间阶段将从一个只接收通道接收并返回另一个只接收通道。

  1. 扇入和扇出之间有什么区别?

Fan-in 也被称为分集,它涉及从不同来源收集消息到一个地方。Fan-out,或多路复用,是相反的——它涉及将单个消息源分发给更多的接收者。

第十五章

  1. 在 Go 中,接口的内存表示是什么?

在 Go 中,接口由两个值表示——第一个是接口的具体类型,而第二个是该类型的值。

  1. 当接口类型转换为另一个接口类型时会发生什么?

由于接口值需要是一个具体的值,而不能是另一个接口,所以会创建一个具有不同类型和相同具体值的新接口。

  1. 在反射中,ValueTypeKind是什么?

正如其名称所示,Value代表变量的内容;Type代表变量的 Go 类型;KindType的内存表示,仅指内置类型。

  1. 值是可寻址的意味着什么?

可寻址值是可以被编辑的值,因为它是通过指针获得的。

  1. 为什么 Go 中的结构字段标签很重要?

结构字段标签是一种简单的方法,可以使用反射Type接口添加关于结构字段的额外信息,这样做很容易阅读。

  1. 反射的一般权衡是什么?

反射允许您的代码处理未知类型的数据,并使您的包或应用程序通用化,但它会带来性能成本。它还使代码更加晦涩和难以维护。

  1. 您能描述在使用反射时的一个良好方法吗?

反射的最佳方法是我们在标准库的许多不同部分找到的方法;例如,在encoding包中。它们将反射作为最后的手段,并通过为编码和解码操作提供接口来实现。如果这些接口由某种类型满足,包将使用相应的方法,而不是依赖于反射。

第十六章

  1. CGO 是什么?

CGO 是一个强大的 Go 工具,用于处理 C 代码和 Go 代码之间的通信。这允许 C 代码在 Go 应用程序中使用,并利用现有的大量 C 库。

  1. 如何从 Go 调用 C 代码?

Go 提供了一个名为C的伪包,暴露了 C 类型,如C.int,以及一些函数,将 Go 字符串和字节转换为C字符数组,反之亦然。在导入C包之前的注释将被解释为 C 代码,并且其中定义的所有函数(无论是直接定义还是通过导入文件)都将作为C包的函数在 Go 中可用。

  1. 如何在 C 中使用 Go 代码?

如果 Go 函数前面有一个特殊的注释//export,这个函数将对 C 代码可用。它还必须在 C 中定义为外部函数。

  1. Go 和 C 之间的数据类型有什么区别?

即使它们具有不同的数据类型,C 和 Go 共享大部分内置的数字类型。Go 中的字符串是一种内置的不可变类型,但在 C 中,它们只是以\0值终止的字符数组。

  1. 如何在 C 代码中编辑 Go 值?

使用unsafe包,您可以将在 C 和 Go 中具有相同内存表示的数据类型进行转换。您需要将指针转换为其 C 对应值,这将允许您从应用程序的C部分编辑指针内容。

  1. 紧凑数据结构相关的主要问题是什么?

紧凑的数据结构可以节省内存空间,但它们的字段可能不对齐,这意味着它们分布在多个内存区域之间。这意味着读写操作需要两倍的时间。还有另一个不便之处——一些紧凑的字段无法直接从 Go 中访问。

  1. CGO 的主要缺点是什么?

即使它是一个非常强大的工具,CGO 也有许多缺点——从 C 到 Go 的性能成本,反之亦然;编译时间增加,因为 C 编译器参与了这个过程;以及你的 Go 代码依赖于你的 C 代码工作,这可能更难以维护和调试。

posted @ 2024-05-04 22:36  绝不原创的飞龙  阅读(30)  评论(0编辑  收藏  举报