原文:zh.annas-archive.org/md5/dbd4c55010cdfce77f76381ff78818b0

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

系统编程是软件工程中的一个关键知识领域。对于想要编写与操作系统紧密交互的效率高、低级代码的专业人士来说,尤为重要。"使用 Go 的系统编程基础"旨在指导您掌握使用 Go 进行系统编程所需的原则和实践。本书涵盖了从基本系统编程概念到高级技术的广泛主题,为应对现实世界的系统编程挑战提供了一个全面的工具包。

本书面向对象

本书专为具有编程基础知识的软件工程师、架构师和开发者量身定制,他们希望深化对系统设计的了解。本书非常适合在工作中解决复杂设计问题或仅仅对通过低级编程提升技能感兴趣的人。需要具备编程概念的基础理解,并至少掌握一种编程语言的经验。

本书涵盖内容

第一章为什么选择 Go?,概述了 Go 构建高效和高性能系统软件的适用性,为您提供利用 Go 进行系统级开发所需的知识和技能。本章涵盖了 Go 的并发模型、网络和 I/O、低级控制、系统调用、跨平台支持和工具,为构建健壮的系统程序提供了实用的见解和示例。

第二章刷新并发与并行,概述了 Go 编程语言中 Goroutines、数据竞争、通道及其相互作用的核心理念。理解这些原则对于实现高效的并发、管理共享资源以及确保有效的 goroutine 间通信至关重要。

第三章理解系统调用,概述了系统调用及其实际应用。您将学习如何创建符号链接、解除文件链接以及操作文件名路径。您还将更好地理解 Go 中的 package OS 和 syscall,并学习如何开发和测试 CLI 程序。

第四章文件和目录操作,概述了在 Go 中处理文件系统的方法,重点关注检测不安全权限、计算目录大小和识别重复文件。

第五章与系统事件一起工作,提供了使用 Go 构建高级和高效系统工具的全面见解,重点关注任务调度、文件监控、进程管理和分布式锁定。

第六章理解进程间通信中的管道,探讨了mkfifo中的管道概念,以及管道如何与其他程序交互。

第七章, Unix 套接字,提供了对 UNIX 套接字如何工作、它们的类型以及在 UNIX 和类似 Linux 等 UNIX 操作系统上在 IPC 中作用的了解。

第八章, 内存管理,专注于垃圾回收背后的机制和策略。我们将探讨 Go 垃圾回收的演变,堆栈和堆内存分配的区别,以及高效内存管理的先进技术。

第九章, 性能分析,涵盖了 Go 应用程序的关键优化技术,包括逃逸分析、基准测试、CPU 分析器和内存分析器。它解释了如何通过逃逸分析提高内存使用,通过基准测试测量和比较代码性能,通过 CPU 分析器识别热点,以及使用内存分析器检测内存泄漏。

第十章, 网络编程,深入探讨了 Go 网络编程的迷人世界。网络对于系统编程至关重要,Go 提供了处理网络通信的强大原语。通过探索 TCP、HTTP 和相关的其他协议,你将获得创建健壮网络应用程序所需的技能。

第十一章, 遥测,深入探讨如何利用行业工具实施有效的遥测实践。从日志到跟踪和指标,你将探索监控应用程序所需的工具和指南。

第十二章, 分发您的应用程序,探讨了使用 Go 模块、持续集成和发布策略分发应用程序的关键概念和实际应用。

第十三章, 综合项目 - 分布式缓存,指导你完成综合项目。该项目将使用 Go 构建具有 Memcached 或 Redis 等功能的分布式缓存系统。它将涵盖分片策略、驱逐策略、一致性模型和技术选择,同时导航每个决策带来的权衡。

第十四章, 高效编码实践,探讨了 Go 编程中高效资源管理的原则和技术,特别是专注于避免可能导致性能问题和阻碍整体效率的常见陷阱。它深入探讨了使用 Go 标准库优化资源使用的复杂性,为寻求提高 Go 应用程序有效性的开发者提供策略。

第十五章, 系统编程保持敏锐,提供了一个基于真实案例研究的 Go 系统编程的持续学习路径。通过了解 Go 在实际应用程序中的应用,你可以将这些经验应用到自己的项目中。

附录硬件自动化,探讨了如何利用各种工具使用 USB 驱动器和蓝牙设备自动化日常任务并监控外围事件。通过了解如何自动化这些流程,您将节省宝贵的时间并提高日常生活中的生产力。

要充分利用本书

您需要了解 Golang 的基础知识。

软件 操作系统要求
Golang (1.16+) Windows, macOS, 或 Linux(最好是 Linux)

如果您正在使用本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将帮助您避免与代码的复制和粘贴相关的任何潜在错误

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件 github.com/PacktPublishing/System-Programming-Essentials-with-Go。如果代码有更新,它将在 GitHub 仓库中更新。

我们还有其他来自我们丰富的图书和视频目录的代码包,可在 github.com/PacktPublishing/ 获取。查看它们吧!

使用的约定

本书使用了多种文本约定。

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 账号。以下是一个示例:“最后,更新 main 函数以创建具有指定容量的缓存并测试 TTL 和 LRU 功能。”

代码块设置如下:

func main() {
    cache := NewCache(5) // Setting capacity to 5 for LRU
    cache.startEvictionTicker(1 * time.Minute)
}

任何命令行输入或输出都应如下编写:

go run main.go -port=:8080 -peers=http://localhost:8081

提示或重要注意事项

看起来像这样。

联系我们

我们欢迎读者的反馈。

一般反馈:如果您对本书的任何方面有疑问,请通过电子邮件发送至 customercare@packtpub.com,并在邮件主题中提及书名。

勘误:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告,请访问 www.packtpub.com/support/errata 并填写表格。

盗版:如果您在互联网上发现我们作品的任何形式的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过电子邮件发送至 copyright@packt.com 并附上材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为本书做出贡献,请访问 authors.packtpub.com

分享您的想法

一旦您阅读了《使用 Go 的系统编程基础》,我们非常乐意听到您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。

您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。

下载本书的免费 PDF 副本

感谢您购买本书!

您喜欢在路上阅读,但无法携带您的印刷书籍到处走?

您的电子书购买是否与您选择的设备不兼容?

别担心,现在每购买一本 Packt 书籍,你都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。

优惠远不止于此,您还可以获得独家折扣、时事通讯和每日免费内容的访问权限。

按照以下简单步骤获取优惠:

  1. 扫描二维码或访问以下链接

https://github.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/sys-prog-ess-go/img/B21662_QR_Free_PDF.jpg

packt.link/free-ebook/9781837634132

  1. 提交您的购买证明

  2. 就这些!我们将直接将您的免费 PDF 和其他优惠发送到您的邮箱。

第一部分:简介

在这部分,我们将探讨使用 Go 进行系统编程的基础知识。您将了解管理并发和确保高效跨平台开发的最佳实践。本节将更深入地探讨为什么 Go 是构建高性能系统软件的强大选择,以及如何利用其功能来支持现实世界场景。

本部分包含以下章节:

  • 第一章为什么选择 Go?

  • 第二章刷新并发与并行

第一章:为什么选择 Go?

在你的编程旅程中某个时刻,你的程序执行了与 I/O 相关的任务,例如创建和删除文件和目录。它们可能已经编排了新进程的创建和其他程序的执行,甚至促进了在同一台计算机上运行的线程和进程之间以及通过网络连接的不同计算机上的进程之间的通信。

当我们的程序集中在使用一组低级任务时,我们将它们归类为系统编程。

据说系统编程是乏味的。但我根本不这么认为!事实上,它完全相反——是一种愉快和有趣的体验。它就像是一个魔术师。你可以控制操作系统和硬件,你可以让事情发生,这在其他语言中是不可能的。

在本章中,我们讨论为什么 Go 语言非常适合构建高效、高性能的系统软件以支持现实世界的场景。

在本章中,我们将涵盖以下主要主题:

  • 选择 Go

  • 并发和 goroutines

  • 与操作系统交互

  • 工具

  • 使用 Go 进行跨平台开发

到本章结束时,你将了解 Go 在系统编程生态系统中的位置,Go 并发模型对于构建高效和高性能系统软件的重要性,Go 如何选择与操作系统交互,Go 的跨平台开发方法以及 Go 内置工具的主要命令。

选择 Go

现在系统编程空间中有许多语言:一些已经建立得很好,如 C 和 C++;一些是新来的浪潮,如 Zig、Rust 和 Odin;还有一些声称是“C/C++ 杀手”,承诺有令人印象深刻的性能。

当然,我们可以使用所有这些工具并取得出色的成果。然而,我们可能会陷入隐藏的陷阱,例如陡峭的学习曲线、高认知负荷、缺乏社区和支持、不一致的 API 以及频繁的破坏性更改,以及缺乏采用。

Go 的设计哲学强调简单性、表达性、健壮性和效率。其对并发的支持、强大的依赖管理以及对其组合的关注,使其成为系统编程的一个有吸引力的选择。其创造者旨在构建一个提供强大构建块而不需要不必要复杂性的语言,这使得编写、阅读、理解和维护系统级代码变得更加容易。有编程经验的人通常需要两周时间来熟悉 Go。虽然他们可能不被认为是专家,但他们可以自信地阅读标准的 Go 代码,并编写基本到中等复杂性的程序而不会感到困难。

此外,Go 对于系统编程来说非常出色,因为该语言具有 Unix 风格的设计,通过检查所有简化项。许多熟练掌握 Python 和 Ruby 的程序员通常会转向 Go,因为它允许他们保持表达性水平的同时,实现性能的提升和并发工作的能力。

值得注意的是,Go 的哲学并不优先考虑 CPU 使用上的零成本。相反,该语言旨在减少程序员所需付出的努力,这被认为更为重要,并且作为副产品,使得体验更加愉快。

使用 Go 进行系统编程的主要批评之一是垃圾回收器(垃圾回收器)(GC),特别是其暂停和显式内存限制。如果你对 Go 仍然有这种小烦恼,不用担心。在第六章中,我们将看到从 Go 1.20 及以上版本提供了更细粒度的内存管理。

注意

在垃圾回收暂停的最坏情况下,停止世界的时间通常小于 100 微秒。

并发和 goroutines

Go 最基本的功能之一是其并发模型。并发是同时运行多个任务的能力。在系统编程中,并行执行多个任务是提高程序性能和响应性的关键。

并发

实时系统需要精确性,其中并发是一个关键因素。这些系统以出色的时机协调任务,尤其是在即使是毫秒也重要的场景中。并发通过增加吞吐量(衡量系统在给定时间内可以处理多少信息单位)的同时减少任务完成时间,提供了显著的优势。现实生活中的实例显示了并发如何提高响应性,使系统更加灵活,任务更加高效。此外,并发的隔离能力通过防止干扰来保证数据完整性。

系统编程涉及各种任务,从 CPU 密集型到 I/O 密集型。并发通过允许 CPU 密集型任务进行的同时,I/O 密集型任务等待资源,来协调这种多样性。

在后面的第十章中,当我们讨论分布式系统时,并发的重要性将变得明显。它协调应用程序或甚至网络中不同节点的任务,这对于管理大规模并发是理想的。

Goroutines

Go 的并发模型依赖于 goroutines 和 channels。Goroutines 是轻量级的执行线程,通常被称为绿色线程。创建它们是成本效益的。与传统的线程不同,它们表现出非凡的效率,使得成千上万的 goroutines 可以在仅几个操作系统线程上同时运行。

另一方面,通道为 goroutines 提供了一个无需求助于锁的通信和同步机制。这种方法受到了通信顺序进程CSPwww.cs.cmu.edu/~crary/819-f09/Hoare78.pdf形式主义的启发,强调并发组件之间的协调交互。

与许多依赖外部库或线程结构的并发编程语言不同,Go 将并发无缝地融入其核心语言设计。这个设计决策不仅使代码更容易理解,而且更不容易出错,因为线程的复杂性被抽象化了。

CSP 启发的模型

Go 的并发模型从并发系统描述的正式语言 CSP(Communicating Sequential Processes)中汲取了灵感。CSP 专注于并发执行实体之间的通信和同步。与传统的多线程编程不同,CSP 和 Go 优先考虑通过通道进行通信,而不是共享内存,这减少了复杂性和潜在风险。同步和协调是必不可少的,CSP 使用通道进行进程同步,而 Go 使用类似的通道来协调 goroutines。安全和隔离是关键,因为这两种语言都确保通过通道进行安全交互,增强了可预测性和可靠性。Go 的通道直接实现了 CSP 基于通信的方法,为 goroutines 提供了一个安全的数据交换方式,避免了共享内存和锁的陷阱。

通过沟通来共享

著名的围棋谚语,“不要通过共享记忆来沟通,要通过沟通来共享记忆”常常是讨论和误解的来源。然而,将其理解为“通过沟通来共享,而不是通过锁定”会更加精确,主要是因为主流语言通常依赖于锁来保护共享数据,这可能导致死锁和竞态条件等潜在问题。Go 鼓励一种不同的范式:通过发送和接收消息在通道中共享数据。这种“通过沟通来共享”的哲学减少了显式锁的需求,并促进了一个更安全的并发环境。

如果你热衷于函数式编程,我有个好消息要告诉你。在 Go 中,数据不是在 goroutines 之间隐式共享的。换句话说,数据是被复制的。你注意到数据不可变性的问题了吗?这与那些共享内存是线程间通信默认模式的语言形成对比。Go 强调通过通道进行显式通信,有助于避免在传统线程模型中可能出现的意外数据共享和竞态条件。这种模型的另一个好处是,没有回调地狱,因为与并发代码的每次交互通常都是按程序方式读取的。一个常规的 Go 函数可以在程序代码中使用,而不需要将签名与额外的关键字绑定。

注意

回调地狱,也称为“死亡金字塔”,是编程中用来描述嵌套和相互依赖的回调函数使代码难以阅读、理解和维护的一个术语。这种情况通常发生在使用回调处理异步操作的异步编程环境中,如 JavaScript。

在下一章中,我们将刷新并发及其构建块的所有概念,以便为您与操作系统接口的交互做好准备。

除了其并发模型外,Go 还提供了一种与操作系统在低级别交互的方式。这对于系统编程至关重要,在系统编程中,您通常需要控制操作系统和硬件。

与操作系统交互

Go 对系统调用的方法旨在安全高效,尤其是在其并发模型背景下。

在 Go 中,与某些其他编程语言相比,系统调用相对较低级。如果您需要精细控制系统资源,这可能很有帮助,但也意味着您正在处理更多低级细节。

调用系统通常需要理解底层操作系统 API 和约定。副作用是,如果您是系统编程或低级开发的新手,它可能会引入一个更陡峭的学习曲线。

不熟悉系统调用?不用担心!本书的第二部分将详细探索和实验它们,以涵盖我们在系统编程之旅中需要进步的主要方面。

工具

Go 就像一个工具箱。它拥有我们构建优秀软件所需的一切,因此我们不需要比其标准工具更多的东西来创建我们的程序。

让我们探索便于构建、测试、运行、错误检查和代码格式化的主要工具。

go build

使用 go build 命令将 Go 代码编译成可执行的二进制文件,您可以直接运行。

让我们看看一个例子。

假设您有一个名为 main.go 的 Go 源文件,其中包含以下代码:

package main
import "fmt"
func main() {
    fmt.Println("Hello, Go!")
}

您可以使用 go build 命令来编译它:

go build main.go

这将生成一个名为 main 的可执行二进制文件(在 Windows 上为 main.exe)。然后您可以运行该二进制文件以查看输出:

./main

go test

go test 命令用于在您的 Go 代码上运行测试。它自动找到测试文件并运行相关的测试函数。

这里有一个例子。

假设您有一个名为 math.go 的 Go 源文件,其中包含一个用于添加两个数字的函数:

package math
func Add(a, b int) int {
    return a + b
}

您可以创建一个名为 math_test.go 的测试文件来为 Add 函数编写测试:

package math
import "testing"
func TestAdd(t *testing.T) {
    result := Add(2, 3)
    if result != 5 {
        t.Errorf("Expected 5, but got %d", result)
    }
}

使用 go test 命令运行测试:

go test

go run

go run 命令允许您直接运行 Go 代码,而无需显式将其编译成可执行文件。

让我们用一个例子来看看。

假设您有一个名为 hello.go 的 Go 源文件,其中包含以下代码:

package main
import "fmt"
func main() {
    fmt.Println("Hello, Go!")
}

您可以使用 go run 命令直接运行代码:

go run hello.go

这将执行代码并将 Hello, Go! 打印到控制台。

go vet

我们使用 go vet 命令来检查我们的 Go 代码中可能存在的错误或可疑结构。它使用启发式方法,可能无法确保所有报告都是实际的问题,但它可以揭示编译器未捕获的错误。

这里有一个例子。

假设你有一个名为 error.go 的 Go 源文件,其中包含以下代码,并故意引入了错误:

package main
import "fmt"
func main() {
    movie_year := 1999
    movie_title := "The Matrix"
    fmt.Printf("In %s, %s was released.\n", movie_year, movie_title)
}

你可以使用 go vet 命令来检查错误:

go vet error.go

它可能会报告如下警告:Printf 格式 %s 有 arg 1999 错误的类型 int

go fmt

go fmt 命令用于根据 Go 编程风格指南格式化你的 Go 代码。它自动调整代码缩进、间距等。

让我们看看这个例子。

假设你有一个名为 unformatted.go 的 Go 源文件,其中包含格式不正确的代码:

package main
import "fmt"
func main() {
        msg:="Hello"
        fmt.Println(msg) 
}

你可以使用 go fmt 命令格式化代码:

go fmt unformatted.go

它将更新代码以匹配标准格式化约定:

package main
import "fmt"
func main() {
        msg := "Hello"
        fmt.Println(msg)
}

现在我们已经很好地掌握了基本工具,我们可以开始熟悉 Go 的跨平台功能。

使用 Go 进行跨平台开发

使用 Go 进行跨平台开发非常简单。你可以轻松编写在多种操作系统和架构上运行的代码。

使用 GOOSGOARCH 环境变量可以实现 Go 的跨平台开发。GOOS 环境变量指定了你想要的目标操作系统,而 GOARCH 环境变量指定了你的目标架构。

例如,假设你有一个名为 main.go 的 Go 源文件:

package main
import "fmt"
func main() {
    fmt.Println("This program runs in any OS!")
}

要为 Linux 编译代码,你需要将 GOOS 环境变量设置为 linux,将 GOARCH 环境变量设置为 amd64

GOOS=linux GOARCH=amd64 go build

这个命令将为 Linux 编译代码。

你还可以使用 GOOSGOARCH 环境变量在不同的平台上运行代码。例如,要运行你在 Linux 上编译的代码在 macOS 上,你需要将 GOOS 环境变量设置为 darwin,将 GOARCH 环境变量设置为 amd64

GOOS=darwin GOARCH=amd64 go run

这个命令将在 macOS 上运行代码。

注意

虽然 Go 努力实现跨各种平台的可移植性,但通过系统调用与操作系统交互本质上会将你的代码绑定到特定的操作系统功能。高度依赖这些操作的代码在针对不同平台时可能需要进行条件编译或调整。

利用 Go 中的构建标志允许你根据特定的条件(如目标操作系统或架构)有选择性地编译代码的特定部分。

这在创建与 golang.org/x/sys 包交互的程序时可能很有用,该包用于 Windows 和类 Unix 系统。

假设你有两个名为 main_windows.gomain_linux.go 的 Go 源文件,并且你想要使用构建标签来确保代码分段。

这里是一个使用构建标签对 Windows 进行分段的代码示例:

// go:build windows
package main
import "fmt"
func main() {
    fmt.Println("This is Windows!")
}

我们可以这样做,但这次目标是 Linux:

// go:build linux
package main
import "fmt"
func main() {
    fmt.Println("This is Linux!")
}

这些是编译这些程序的相应命令:

GOOS=windows go build -o app.exe
GOOS=linux go build -o app

当我们在 Linux 环境中执行app时,它应该打印This is Linux!。同时,在 Windows 系统上运行app.exe将显示This is Windows!

摘要

本章全面介绍了为什么 Go 是系统编程的首选,以及 Go 的设计哲学的见解,强调简洁、健壮和高效。我们学习了 Go 的并发模型,它与操作系统的交互方式,以及如何与跨平台开发的工具交互。这些课程对我们很有帮助,因为它们为我们提供了编写、阅读和维护使用 Go 编写的系统级代码所需的知识,从而提高了性能并使我们能够处理并发。

在下一章中,我们将探讨并发概念,刷新所有相关概念和构建块。这将为我们准备更高级的与操作系统接口的交互,增强我们创建强大和响应性程序的能力。

第二章:刷新并发和并行性

本章将探讨 Go 并发核心中的 goroutines。你将学习它们是如何工作的,区分并发和并行性,管理当前运行的 goroutines,处理数据竞争问题,使用通道进行通信,并使用Channel状态和信号来最大化其潜力。掌握这些概念对于编写高效且无错误的 Go 代码至关重要。

在本章中,我们将涵盖以下主要主题:

  • 理解 goroutines

  • 管理数据竞争

  • 理解通道

  • 交付保证

  • 状态和信号

技术要求

你可以在这个章节的源代码中找到github.com/PacktPublishing/System-Programming-Essentials-with-Go/tree/main/ch2

理解 goroutines

Goroutines 是由 Go 调度器创建和调度以独立运行的函数。Go 调度器负责 goroutines 的管理和执行。

在幕后,我们有一个复杂的算法来使 goroutines 工作。幸运的是,在 Golang 中,我们可以使用go关键字以简单的方式实现这个高度复杂的操作。

注意

如果你习惯于具有async/await功能的语言,你可能已经习惯了事先决定你的函数。它将被并发使用来更改函数签名,以表示该函数可以被暂停/恢复。调用此函数也需要特殊的符号。当使用 goroutines 时,不需要更改函数签名。

在以下代码片段中,我们有一个主函数依次调用say函数,分别传递参数"hello""world"

func main() {
  say(«hello»)
  say(«world»)
}

say函数接收一个字符串作为参数,并迭代五次。对于每次迭代,我们让函数休眠 500 毫秒,并在打印s参数后立即执行:

func say(s string) {
  for i := 1; i < 5; i++ {
     time.Sleep(500 * time.Millisecond)
     fmt.Println(s)
  }
}

当我们执行程序时,它应该打印以下输出:

hello
hello
hello
hello
hello
world
world
world
world
world

现在,我们在say函数的第一次调用之前引入go关键字,以在我们的程序中引入并发:

func main() {
  go say(«hello»)
  say(«world»)
}

输出应该在helloworld之间交替。

那么,如果我们为第二次函数调用创建一个 goroutine,我们也能达到相同的结果,对吗?

func main() {
  say(«hello»)
  go say(«world»)
}

让我们看看程序的结果:

hello
hello
hello
hello

等等!这里有什么不对劲。我们做错了什么?主函数和 goroutine 似乎不同步。

我们没有做错什么。这是预期的行为。当你仔细观察第一个程序时,goroutine 被触发,say的第二次调用在主函数的上下文中顺序执行。

换句话说,程序应该等待函数终止,以便达到main函数的末尾。对于第二个程序,我们有相反的行为。第一次调用是一个正常的函数调用,所以它按照预期打印了五次,但当第二个 goroutine 被触发时,主函数没有后续指令,所以程序终止。

虽然从程序运行的角度来看,行为是正确的,但这不是我们的意图。我们需要一种方法来在给main函数一个终止的机会之前,同步这个执行组中所有 goroutine 的wait。在这种情况下,我们可以利用 Go 的构造,即sync包中的WaitGroup

WaitGroup

如同其名,WaitGroup是 Go 标准库中的一种机制,允许我们等待一组 goroutine 直到它们显式完成。

没有特定的工厂函数来创建它们,因为它们的零值已经是一个有效的可用状态。由于WaitGroup已经被创建,我们需要控制我们正在等待多少个 goroutine。我们可以使用Add()方法来通知这个组。

我们如何通知组我们已经完成了一个任务?这再直观不过了。我们可以使用Done()方法来实现这一点。

在下面的示例中,我们引入wait group来使我们的程序按照预期输出消息:

func main() {
  wg := sync.WaitGroup{}
  wg.Add(2)
  go say(«world», &wg)
  go say("hello", &wg)
  wg.Wait()
}

我们创建WaitGroupwg := sync.WaitGroup{})并声明有两个 goroutine 参与这个组(wg.Add(2))。

在程序的最后一行,我们使用Wait()方法显式地保持执行,以避免程序终止。

要使我们的函数与Waitgroup交互,我们需要发送对这个组的引用。一旦我们有了它的引用,函数就可以延迟调用Done(),以确保每次函数完成时都能正确地为我们组发出信号。

这是新的say函数:

func say(s string, wg *sync.WaitGroup) {
  defer wg.Done()
  for i := 0; i < 5; i++ {
     fmt.Println(s)
  }
}

我们不需要依赖time.Sleep(),所以这个版本没有它。

现在,我们可以控制我们的 goroutine 组。让我们处理并发编程中的一个核心担忧问题——状态。

改变共享状态

想象一个场景,两个勤奋的工人在繁忙的仓库里负责将物品装箱。每个工人将固定数量的物品装入包中,我们必须跟踪打包的总物品数量。

这个看似简单的任务,类似于并发编程,如果处理不当,很快就会变成一场噩梦。有了适当的同步,工作者可以避免有意干扰彼此的工作,导致结果不正确和不可预测的行为。这是一个经典的数据竞争示例,是并发编程中常见的挑战。

以下代码将向您展示一个类比,其中两个仓库工人面对打包项目时的数据竞争问题。我们首先展示没有适当同步的代码,以演示数据竞争问题。然后,我们将修改代码以解决问题,确保工人能够顺利且准确地合作。

让我们走进熙熙攘攘的仓库,亲眼见证并发挑战以及在这个例子中同步的重要性:

package main
import (
     "fmt"
     "sync"
)
func main() {
     fmt.Println("Total Items Packed:", PackItems(0))
}
func PackItems(totalItems int) int {
     const workers = 2
     const itemsPerWorker = 1000
     var wg sync.WaitGroup
     itemsPacked := 0
     for i := 0; i < workers; i++ {
          wg.Add(1)
          go func(workerID int) {
               defer wg.Done()
               // Simulate the worker packing items into boxes.
               for j := 0; j < itemsPerWorker; j++ {
                      itemsPacked = totalItems
                    // Simulate packing an item.
                    itemsPacked++
               // Update the total items packed without proper synchronization.
               totalItems = itemsPacked
               }
          }(i)
     }
     // Wait for all workers to finish.
     wg.Wait()
     return totalItems
}

main函数首先调用PackItems函数,初始totalItems值为 0。

PackItems函数中,定义了两个常量:

  • workers:工作 goroutine 的数量(设置为 2)

  • itemsPerWorker:每个工人应该打包进箱子的项目数量(设置为 1,000)

创建名为wgWaitGroup,等待所有工作 goroutine 完成,然后返回最终的totalItems值。

一个循环运行workers次,其中每次迭代启动一个新的 goroutine 来模拟工人将项目打包进箱子。在 goroutine 内部,执行以下步骤:

  1. 将一个工人 ID 作为参数传递给 goroutine。

  2. defer wg.Done()语句确保当 goroutine 退出时,等待组会递减。

  3. itemsPacked变量初始化为totalItems的当前值,以跟踪此工人打包的项目。

  4. 一个循环运行itemsPerWorker次,模拟将项目打包进箱子的过程。然而,实际上并没有发生打包;循环只是递增itemsPacked变量。

  5. 在内循环的最后一步,totalItems接收itemsPacked变量的修改后的值,该变量包含工人打包的项目数量。

  6. 通过将itemsPacked值添加到totalItems变量中。

由于多个 goroutine 尝试在不适当的同步下并发修改totalItems,因此发生数据竞争,导致不可预测和不正确的结果。

非确定性结果

考虑这个替代的main函数:

func main() {
     times := 0
     for {
          times++
          counter := PackItems(0)
          if counter != 2000 {
               log.Fatalf("it should be 2000 but found %d on execution %d", counter, times)
          }
     }
}

程序会不断运行PackItems函数,直到达到预期的 2,000 个结果。一旦发生这种情况,程序将显示函数返回的错误值以及达到该点所需的尝试次数。

由于 Go 调度器的非确定性,结果大多数时候是正确的。这段代码需要多次运行才能揭示其同步缺陷。

在单次执行中,我需要超过 16,000 次迭代:

it should be 2000 but found 1170 on execution 16421

您的机会!

在您的机器上运行代码的实验。您的代码需要多少次迭代才能失败?

如果你正在使用个人电脑,可能有很多任务正在执行,但你的机器可能有很多未使用的资源。然而,如果你在具有容器的云环境中运行程序,重要的是要考虑集群中共享节点上的噪声量。这里的“噪声”是指在运行你的程序时在主机机器上执行的工作。它可能和你本地的实验一样空闲。然而,在成本效益高的场景中,每个核心和内存都得到充分利用,它很可能被充分利用。

这种对资源的持续竞争场景使得我们的调度器更倾向于选择其他工作负载,而不是仅仅继续运行我们的 goroutine。

在下面的示例中,我们调用 runtime.Gosched 函数来模拟噪声。想法是向 Go 调度器发出提示,“嘿!也许现在是暂停我的好时机”:

for j := 0; j < itemsPerWorker; j++ {
    itemsPacked = totalItems
    runtime.Gosched() // emulating noise!
    itemsPacked++
    totalItems = itemsPacked
}

再次运行主函数,我们可以看到错误的结果比以前出现得更快。例如,在我的执行中,我只需要四次迭代:

it should be 2000 but found 1507 on execution 4

不幸的是,代码仍然存在 bug。我们如何预见到这一点?到目前为止,你应该已经猜到了 Go 工具提供了答案,而且你又猜对了。我们可以在测试中管理数据竞争。

管理数据竞争

当多个 goroutines 并发访问共享数据或资源时,可能会发生“竞争条件”。正如我们所证明的,这种类型的并发 bug 可能导致不可预测和不受欢迎的行为。Go 测试工具有一个内置功能,称为Go 竞争检测,可以检测和识别 Go 代码中的竞争条件。

那么,让我们创建一个 main_test.go 文件,并添加一个简单的测试用例:

package main
import (
     "testing"
)
func TestPackItems(t *testing.T) {
     totalItems := PackItems(2000)
     expectedTotal := 2000
     if totalItems != expectedTotal {
          t.Errorf("Expected total: %d, Actual total: %d", expectedTotal, totalItems)
     }
}

现在,让我们使用竞争检测器:

go test -race

控制台中的结果可能如下所示:

==================
WARNING: DATA RACE
Read at 0x00c00000e288 by goroutine 9:
  example1.PackItems.func1()
      /tmp/main.go:35 +0xa8
  example1.PackItems.func2()
      /tmp/main.go:45 +0x47
Previous write at 0x00c00000e288 by goroutine 8:
  example1.PackItems.func1()
      /tmp/main.go:39 +0xba
  example1.PackItems.func2()
      /tmp/main.go:45 +0x47
// Other lines omitted for brevity

初次看到输出时可能会感到有些令人畏惧,但最初最引人注目的信息是消息 WARNING: DATA RACE

为了修复此代码中的同步问题,我们应该使用同步机制来保护对 totalItems 变量的访问。如果没有适当的同步,对共享数据的并发写入可能导致竞争条件和意外结果。

我们已经使用了 sync 包中的 WaitGroup。让我们探索更多的同步机制,以确保程序的正确性。

原子操作

在 Go 中,“atomic”这个术语并不涉及物理上操纵原子,就像在物理学或化学中那样,这让人感到心碎。在编程中拥有这种能力将是非常有趣的;相反,Go 中的原子操作专注于使用 sync/atomic 包同步和管理 goroutines 之间的并发。

Go 提供了原子操作来加载、存储、添加以及 int32int64uint32uint64uintptrfloat32float64。原子操作不能直接在任意数据结构上执行。

让我们使用原子包修改我们的程序。首先,我们应该导入它:

import (
     "fmt"
     "sync"
     "sync/atomic"
)

我们将利用AddInt32函数而不是直接更新totalItems来保证同步:

for j := 0; j < itemsPerWorker; j++ {
    atomic.AddInt32(&totalItems, int32(itemsPacked))
}

如果我们再次检查数据竞争,将不会报告任何问题。

当我们需要同步单个操作时,原子结构非常出色,但当我们想要同步一段代码块时,其他工具则更为合适,例如互斥锁(mutexes)。

互斥锁

哎,互斥锁!它们就像是派对上的保安,为 goroutines 提供保护。想象一下,有一群这些小小的 Go 生物试图围绕共享数据进行舞蹈。一切都很愉快,直到混乱爆发,你会有一个 goroutine 交通堵塞,数据到处溢出!

不要担心,因为互斥锁就像舞池管理员一样迅速介入,确保在任意时刻只有一个酷炫的 goroutine 能在关键部分进行操作。它们就像是并发的节奏守护者,确保每个人轮流进行,没有人会踩到别人的脚趾。

你可以通过声明一个sync.Mutex类型的变量来创建一个互斥锁。互斥锁允许我们使用Lock()Unlock()方法来保护代码的关键部分。当一个 goroutine 调用Lock()时,它会获取互斥锁,而任何尝试调用Lock()的其他 goroutine 将会被阻塞,直到锁通过Unlock()被释放。

下面是我们程序使用互斥锁的代码:

package main
import (
     "fmt"
     "sync"
)
func main() {
      m := sync.Mutex{}
     fmt.Println("Total Items Packed:", PackItems(&m, 0))
}
func PackItems(m *sync.Mutex, totalItems int) int {
     const workers = 2
     const itemsPerWorker = 1000
     var wg sync.WaitGroup
     for i := 0; i < workers; i++ {
          wg.Add(1)
          go func(workerID int) {
               defer wg.Done()
               for j := 0; j < itemsPerWorker; j++ {
                    m.Lock()
                    itemsPacked := totalItems
                   itemsPacked++
                      totalItems = itemsPacked
                    m.Unlock()
               }
          }(i)
     }
     // Wait for all workers to finish.
     wg.Wait()
     return totalItems
}

在这个例子中,我们锁定了一段处理共享状态更改的代码块,并在完成后解锁互斥锁。

如果互斥锁确保了共享状态的正确处理,你可以考虑两种选择:

  • 你可以为每个关键行使用加锁和解锁。

  • 你可以在函数开始时简单地加锁,并使用defer延迟解锁。

是的,你可以这样做!遗憾的是,这两种方法都存在一个缺陷。我们会无差别地引入延迟。为了说明这一点,让我们基准测试第二种方法与原始互斥锁的使用。

让我们创建一个使用多次加锁/解锁调用的函数的第二个版本,称为MultiplePackItems,其中除了函数名和内部循环之外,其他一切保持不变。

下面是内部循环的代码:

for j := 0; j < itemsPerWorker; j++ {
    m.Lock()
    itemsPacked = totalItems
    m.Unlock()
    m.Lock()
    itemsPacked++
    m.Unlock()
    m.Lock()
    totalItems = itemsPacked
    m.Unlock()
}

让我们通过基准测试来查看这两种选项的性能:

Benchmark-8                   36546             32629 ns/op
BenchmarkMultipleLocks-8      13243             91246 ns/op

多重锁的版本在每次操作所需的时间上大约比第一个版本慢**~64%**。

基准测试

我们将在第六章《分析性能》中详细讨论基准测试和其他性能测量技术。

这些例子显示了 goroutines 独立执行任务,没有相互协作。然而,在许多情况下,我们的任务需要交换信息或信号来做出决策,例如启动或停止一个过程。

当信息交换至关重要时,我们可以使用 Go 语言中的一个旗舰工具——通道(channel)。

理解通道的意义

欢迎来到通道狂欢节!

想象 Go 通道就像神奇的、巨型的管子,允许马戏团表演者(goroutines)在确保没有人掉球的情况下传递玩球(数据)。这字面意义上来说,确保没有人掉球。

如何使用通道

要使用通道,我们需要使用一个内置函数make(),来告知我们想要通过这个通道传递哪种类型的数据:

 make(Chan T)

如果我们想要一个string类型的通道,我们应该声明以下内容:

 make (chan string)

我们可以指定容量。具有容量的通道称为缓冲通道。现在我们不会深入讨论容量的问题。当我们没有指定容量时,我们创建一个无缓冲通道。

无缓冲通道

无缓冲通道是多个 goroutine 之间通信的一种方式,它需要遵守一个简单的规则——想要通过通道发送数据和想要接收数据的 goroutine 应该同时准备好

将其想象成一个“信任跌落”练习。发送者和接收者必须完全信任对方,确保数据的安全,就像杂技演员信任他们的搭档在空中接住他们一样。

抽象?让我们通过示例来探索这个概念。

首先,让我们向一个没有接收者的通道发送信息:

package main
func main() {
    c := make(chan string)
    c <- "message"
}

当我们执行时,控制台将打印出类似以下内容:

fatal error: all goroutines are sleep – dead lock!
goroutine 1 [chan send]:
main.main()

让我们分析这个输出。

all goroutines are sleep – deadlock!是主要的错误信息。它告诉我们,我们程序中的所有 goroutine 都处于sleep状态,这意味着它们正在等待某些事件或资源变得可用。然而,由于它们都在等待并且无法取得任何进展,程序遇到了死锁情况。

goroutine 1 [chan send]:是消息的一部分,提供了关于遇到死锁的具体 goroutine 的额外信息。在这种情况下,它是goroutine 1,并且它参与了通道发送操作(chan send)。

这种死锁发生是因为执行被暂停,等待另一个 goroutine 接收信息,但没有人这么做。

死锁

死锁是一种条件,其中两个或多个进程或 goroutine 无法继续进行,因为它们都在等待永远不会发生的事情。

现在,我们可以尝试相反的情况;在下一个示例中,我们想要从一个没有发送者的通道接收信息:

package main
func main() {
    c := make(chan string)
    fmt.Println(<- c )
}

控制台输出的内容非常相似,但现在错误信息是关于接收的:

fatal error: all goroutines are sleep – dead lock!
goroutine 1 [chan receive]:
main.main()

现在,遵循规则就像同时发送和接收一样简单。所以,声明两者都将是足够的:

package main
func main() {
    c := make(chan string)
    c <- "message" // Sending
    fmt.Println(<- c ) // Receiving
}

这是个好主意,但不幸的是,它不起作用,正如我们可以在以下输出中看到的那样:

fatal error: all goroutines are sleep – dead lock!
goroutine 1 [chan send]:
main.main()

如果我们遵循规则,为什么它不起作用呢?

好吧,我们并没有完全遵循规则。规则指出,想要通过通道发送数据和想要接收数据的 goroutine 应该同时准备好

需要注意的重要部分是最后的部分——同时准备好

由于代码是按顺序逐行运行的,当我们尝试发送c <- "message"时,程序会等待接收者接收消息。我们需要让这两方同时发送和接收消息。我们可以使用我们的并发编程知识来实现这一点。

让我们在混合中使用 goroutine,使用马戏团类比。我们将引入一个函数throwBalls,它将期望抛出的球的颜色(color)和它应该接收这些抛出的通道(balls):

package main
import "fmt"
func main() {
    balls := make(chan string)
    go throwBalls("red", balls)
    fmt.Println(<-balls, "received!")
}
func throwBalls(color string, balls chan string) {
    fmt.Printf("throwing the %s ball\n", color)
    balls <- color
}

这里,我们有三个主要步骤:

  1. 我们创建了一个无缓冲的字符串通道,名为balls

  2. 使用throwBalls函数内联启动 goroutine 来将“红色”发送到通道。

  3. 主函数接收并打印从通道接收到的值。

这个示例的输出如下:

throwing the red ball
red received!

我们做到了!我们成功地在 goroutine 之间使用通道传递了信息!

但是当我们再发送一个球时会发生什么?让我们用绿色球试一试:

func main() {
    balls := make(chan string)
    go throwBalls("red", balls)
    go throwBalls("green", balls)
    fmt.Println(<-balls, "received!")
}

输出只显示接收到一个球。发生了什么?

throwing the red ball
red received!

红色还是绿色?

由于我们启动了多个 goroutine,调度器将任意选择哪个应该首先执行。因此,你可以看到绿色或红色随机运行代码。

我们可以通过在通道中添加一个额外的print语句来解决这个问题:

func main() {
    balls := make(chan string)
    go throwBalls("red", balls)
    go throwBalls("green", balls)
    fmt.Println(<-balls, "received!")
    fmt.Println(<-balls, "received!")
}

虽然它有效,但这不是最优雅的解决方案。如果我们有比发送者更多的接收者,我们可能会再次遇到死锁:

func main() {
    balls := make(chan string)
    go throwBalls("red", balls)
    go throwBalls("green", balls)
    fmt.Println(<-balls, "received!")
    fmt.Println(<-balls, "received!")
    fmt.Println(<-balls, "received!")
}

最后的打印将永远等待,导致另一个死锁。

如果我们想让代码能够处理任意数量的球,我们就应该停止添加越来越多的行,并用range关键字替换它们。

遍历通道

遍历通过通道发送的值的机制是range关键字。

让我们更改代码以遍历通道值:

func main() {
    balls := make(chan string)
    go throwBalls("red", balls)
    go throwBalls("green", balls)
    for color := range balls {
         fmt.Println(color, "received!")
    }
}

我们可以愉快地检查控制台以查看优雅地接收到的球,但是等等——所有的 goroutine 都在睡眠中!又死锁了?

当我们遍历通道并且range期望通道关闭以停止迭代时,会发生这个错误。

关闭通道

要关闭通道,我们需要调用内置的close函数,并传递通道:

close(balls)

好的,我们现在可以保证通道已经关闭。让我们通过在发送者和range之间添加close调用来更改代码:

go throwBalls("green", balls)
close(balls)
for color := range balls {

你可能已经注意到,如果range在通道关闭时停止,那么使用这段代码,一旦通道关闭,range将永远不会运行。

我们需要协调这一组任务,是的,你是对的——我们再次使用WaitGroup来帮助我们。这次,我们不想污染throwBalls签名以接收我们的WaitGroup,所以我们将创建内联匿名函数以使我们的函数不知道并发。此外,当我们有保证所有任务都完成时,我们想要关闭通道。我们通过WaitGroupWait()方法来推断这一点。

这里是我们的main函数:

func main() {
    balls := make(chan string)
    wg := sync.WaitGroup{}
    wg.Add(2)
    go func() {
        defer wg.Done()
        throwBalls("red", balls)
    }()
    go func() {
        defer wg.Done()
        throwBalls("green", balls)
    }()
    go func() {
        wg.Wait()
        close(balls)
    }()
    for color := range balls {
        fmt.Println(color, "received!")
    }
}

呼吁!这次,输出显示正确:

throwing the green ball
green received!
throwing the red ball
red received!

哇,这是一段旅程,对吧?但是等等!我们仍然需要探索缓冲通道!

缓冲通道

是时候进行类比了!

这些是小丑发挥作用的地方!想象一辆座位有限(容量)的小丑车。小丑(发送者)可以跳进跳出汽车,把杂技球(数据)扔进去。

我们想要创建一个带有缓冲通道的程序,模拟马戏团车之旅,其中小丑们试图进入一辆小丑车(一次最多三个小丑)并带着气球。司机控制汽车并管理小丑的乘坐,而小丑们试图进入。如果车满了,他们就会等待并打印一条消息。所有小丑都完成后,程序等待车司机完成,然后打印马戏团车之旅结束的消息。

如果一个小丑试图把太多的杂技球塞进车里,就像一辆装满了小丑和杂技球的汽车一样好笑,创造了一个滑稽的景象!

首先,让我们创建程序结构以接收我们的发送者和接收者:

package main
import (
    "fmt"
    "sync"
    "time"
)
func main() {
    clownChannel := make(chan int, 3)
    clowns := 5
    // senders and receivers logic here!
    var wg sync.WaitGroup
    wg.Wait()
    fmt.Println("Circus car ride is over!")
}

这里是司机的 goroutine(接收者):

go func() {
        defer close(clownChannel)
        for clownID := range clownChannel {
            balloon := fmt.Sprintf("Balloon %d", clownID)
            fmt.Printf("Driver: Drove the car with %s inside\n", balloon)
            time.Sleep(time.Millisecond * 500)
            fmt.Printf("Driver: Clown finished with %s, the car is ready for more!\n", balloon)
        }
    }()

我们在骑手块下方添加小丑逻辑(发送者):

for clown := 1; clown <= clowns; clown++ {
    wg.Add(1)
    go func(clownID int) {
        defer wg.Done()
        balloon := fmt.Sprintf("Balloon %d", clownID)
        fmt.Printf("Clown %d: Hopped into the car with %s\n", clownID, balloon)
        select {
            case clownChannel <- clownID:
                fmt.Printf("Clown %d: Finished with %s\n", clownID, balloon)
            default:
                fmt.Printf("Clown %d: Oops, the car is full, can't fit %s!\n", clownID, balloon)
        }
    }(clown)
}

运行代码后,我们可以看到小丑们制造的所有麻烦:

Clown 1: Hopped into the car with Balloon 1
Clown 1: Finished with Balloon 1
Driver: Drove the car with Balloon 1 inside
Clown 2: Hopped into the car with Balloon 2
Clown 2: Finished with Balloon 2
Clown 5: Hopped into the car with Balloon 5
Clown 5: Finished with Balloon 5
Clown 3: Hopped into the car with Balloon 3
Clown 3: Finished with Balloon 3
Clown 4: Hopped into the car with Balloon 4
Clown 4: Oops, the car is full, can't fit Balloon 4!
Circus car ride is over!

select

select语句允许我们在多个通信通道上等待,并选择第一个就绪的通道,从而有效地允许我们在通道上执行非阻塞操作。

当使用通道工作时,很容易陷入比较消息队列和通道的困境,但可能存在更好的理解方式。通道内部是环形缓冲区,当选择程序设计时,这些信息可能会令人困惑且无助于解决问题。通过优先理解信号和消息的保证交付,你将更好地配备高效地与通道一起工作。

交付的保证

缓冲通道和无缓冲通道之间的主要区别是交付的保证。

如我们之前所见,无缓冲通道始终保证交付,因为它们只有在接收者准备好时才发送消息。相反,缓冲通道不能保证消息交付,因为它们可以在同步步骤成为强制性的之前“缓冲”任意数量的消息。因此,读者可能无法从通道缓冲区中读取消息。

选择它们之间最显著的副作用是你可以为程序引入多少延迟。

延迟

在并发编程的上下文中,延迟指的是数据从发送者(goroutine)通过通道到达接收者(goroutine)所需的时间。

在 Go 通道中,延迟受多个因素的影响:

  • 缓冲: 缓冲可以减少发送者和接收者不完全同步时的延迟。

  • 阻塞: 无缓冲通道会阻塞发送者和接收者,直到他们准备好进行通信,这可能导致更高的延迟。缓冲通道允许发送者继续进行,而无需立即同步,这可能会降低延迟。

  • Goroutine 调度:通道通信中的延迟也取决于 Go 运行时如何调度 goroutine。例如,可用的 CPU 核心数量和调度算法等因素会影响 goroutine 的执行速度。

选择通道类型

作为一项经验法则,我们认为无缓冲的通道在以下场景下是一个不错的选择:

  • 保证交付:提供一种保证,即发送的值被另一个 goroutine 接收。这在需要确保数据完整性和无数据丢失的场景中特别有用。

  • 一对一通信:无缓冲通道最适合 goroutine 之间的一对一通信。

  • 负载均衡:无缓冲通道可用于实现负载均衡模式,确保工作在 worker goroutine 之间均匀分布。

相反,缓冲通道提供以下功能:

  • 异步通信:缓冲通道允许 goroutine 之间进行异步通信。在缓冲通道上发送数据时,如果通道的缓冲区有空间,发送者不会阻塞直到数据被接收。这可以在某些场景中提高吞吐量。

  • 减少竞争:在存在多个发送者和接收者的场景中,使用缓冲通道可以减少竞争。例如,在生产者-消费者模式中,可以使用缓冲通道允许生产者继续生产,而无需等待消费者赶上。

  • 防止死锁:缓冲通道可以通过允许一定程度的缓冲来帮助防止 goroutine 死锁,这在工作负载不可预测变化时可能很有用。

  • 批量处理:缓冲通道可用于批量处理或管道,其中数据以一个速率产生,以另一个速率消费。

现在我们已经涵盖了延迟的关键方面以及它如何影响并发编程中的通道通信,让我们将重点转移到另一个关键方面——状态和信号。理解状态和信号的语义对于避免常见陷阱和做出明智的设计决策至关重要。

状态和信号

探索状态和信号的语义可以使你在避免更直接的错误或做出良好的设计选择方面领先一步。

状态

虽然 Go 通过通道简化了并发的采用,但也有一些特性和陷阱。

我们应该记住,通道有三个状态——nil、open(空、非空)和 closed。这些状态与我们能否以及如何使用通道,无论是从发送者还是接收者的角度来看,都有很强的关联。

当你想从通道中读取时考虑:

  • 向一个只写通道读取会导致编译错误

  • 如果通道是nil,则无限期地从它读取将阻塞你的 goroutine,直到它被初始化

  • 在一个开放的通道中读取将阻塞,直到有数据可用

  • 在一个开放非空的通道中,读取将返回数据

  • 如果通道是关闭的,读取它将返回其类型的默认值,并返回false以指示关闭

写入也有其细微之处:

  • 向只读通道写入会导致编译错误

  • nil通道写入会阻塞,直到它被初始化

  • 向一个打开的通道写入会阻塞,直到有空间

  • 在一个打开非满的通道中,写入是成功的

  • 向一个关闭的通道写入会导致panic

关闭通道取决于其状态:

  • 关闭一个带有数据的打开通道允许读取直到耗尽,然后返回默认值。

  • 关闭一个打开空通道会立即关闭它,并且读取也会返回默认值。

  • 尝试关闭一个已关闭的通道会导致panic

  • 关闭只读通道会导致编译错误。

信号

在 goroutine 之间进行信号传递是频道的一个常见用例。你可以通过在它们之间发送信号或消息来使用频道协调和同步不同 goroutine 的执行。

这里有一个如何使用 Go 频道在两个 goroutine 之间进行信号传递的简单示例:

package main
import (
    "fmt"
    "sync"
)
func main() {
    signalChannel := make(chan bool)
    var wg sync.WaitGroup
    wg.Add(1)
    go func() {
        defer wg.Done()
        fmt.Println("Goroutine 1 is waiting for a signal...")
        <-signalChannel
        fmt.Println("Goroutine 1 received the signal and is now doing something.")
    }()
    wg.Add(1)
    go func() {
        defer wg.Done()
        fmt.Println("Goroutine 2 is about to send a signal.")
        signalChannel <- true
        fmt.Println("Goroutine 2 sent the signal.")
    }()
    wg.Wait()
    fmt.Println("Both goroutines have finished.")
}

在这个片段中,我们创建了一个名为signalChannel的通道,用于在两个 goroutine 之间进行信号传递。Goroutine 1使用<-signalChannel在通道上等待信号,而Goroutine 2使用signalChannel <- true发送信号。

sync.WaitGroup确保我们在打印"Both goroutines have finished."之前等待两个 goroutine 都完成。

当你运行这个程序时,你会看到Goroutine 1等待Goroutine 2的信号,然后继续执行其任务。

Go 频道是同步和协调 goroutine 之间复杂交互的灵活方式。它们可以用来实现生产者-消费者或扇出/扇入的并发模式。

选择你的同步机制

频道总是答案吗?绝对不是!我们可以使用互斥锁或频道来解决相同的问题。我们如何选择?偏好实用主义。当互斥锁使你的解决方案易于阅读和维护时,不要犹豫,选择互斥锁!

如果你在它们之间选择有困难,这里有一个有偏见的指南。

当你需要做以下事情时使用频道:

  • 传递数据的所有权

  • 分配工作单元

  • 以异步方式通信结果

当你处理以下内容时,请使用互斥锁:

  • 缓存

  • 共享状态

好的,让我们总结一下,回顾本章我们所学的内容。

摘要

在本章中,我们学习了 goroutine 的功能、它们的简单性和使用WaitGroup进行同步的重要性。我们还意识到了管理共享状态的困难,使用仓库类比来解释数据竞争。此外,我们介绍了 Go 的竞态检测工具来识别竞态条件,通信通道的重要性及其潜在的风险。

现在我们已经更新了并发知识,让我们在下一章中探索使用系统调用的操作系统的交互。

第二部分:与操作系统交互

在本部分,我们将使用 Go 语言深入探讨系统级编程概念。您将探索进程间通信(IPC)机制、系统事件处理、文件操作和 Unix 套接字。本节提供了实际示例和详细解释,以帮助您掌握构建健壮和高效系统级应用程序的知识和技能。

本部分包含以下章节:

  • 第三章, 理解系统调用

  • 第四章, 文件和目录操作

  • 第五章, 处理系统事件

  • 第六章, 理解进程间通信中的管道

  • 第七章, Unix 套接字

第三章:理解系统调用

在本章中,你将开始一段探索系统调用世界的旅程,这些基本接口将用户级程序与操作系统内核连接起来。通过相关的类比和现实世界的平行关系,我们将揭示软件执行的复杂舞蹈,强调内核、用户模式和内核模式的关键作用。

理解系统调用及其与操作系统的交互对于任何希望构建高效和健壮应用的软件开发者至关重要。在本书的更广泛背景下,本章为后续关于高级操作系统交互和系统级编程的讨论奠定了基础。此外,在现实世界的背景下,掌握这些概念使开发者能够优化软件性能,在系统级别解决问题,并充分利用操作系统的能力。

在本章中,我们将涵盖以下主要主题:

  • 系统调用简介

  • syscall

  • 深入了解 osx/sys

  • 每日系统调用

  • 开发和测试命令行界面(CLI)程序

到本章结束时,你不仅将掌握系统调用的理论基础,还将通过使用 Go 构建 CLI 应用程序获得实践经验。

技术要求

您可以在github.com/PacktPublishing/System-Programming-Essentials-with-Go/tree/main/ch3找到本章的源代码。

系统调用简介

系统调用,通常称为“syscalls”,是操作系统接口的基本组成部分。它们是由操作系统内核提供的低级函数,允许用户级进程请求内核的服务。

如果你对这个概念还不熟悉,一些类比可以使理解更加容易。让我们将这个想法与旅行联系起来。

用户模式与内核模式

处理器(或 CPU)有两种操作模式:用户模式和内核模式(也称为管理模式或特权模式)。这些模式决定了程序对系统资源的访问和控制级别。用户模式受限,不允许直接访问某些关键系统资源,而内核模式具有更多权限,可以访问这些资源。权限已授予,请谨慎行事

当涉及到系统调用时,内核扮演着严格的边境控制官员的角色。系统调用就像我们需要穿越软件执行多样化景观的护照。将内核想象成一个高度设防的国际边境检查站。正如旅行者需要获得进入外国土地的许可一样,我们的进程需要获得访问内核资源的批准。系统调用作为护照,允许我们穿越用户和内核空间之间的边界。

服务目录和标识

就像一本旅行指南一样,内核通过系统调用应用程序编程接口API)提供了一整套服务。这些服务从创建新进程到处理输入和输出I/O)操作,如外国国家的便利设施和景点。

一个数字代码唯一地标识每个系统调用,就像你的护照号码。然而,这个编号系统在日常使用中是隐藏的。相反,我们通过它们的名称与系统调用互动,就像旅行者通过当地名称而不是代码来识别服务和地标。例如,一个open系统调用可能在内核的内部系统调用表中被标识为数字 5。然而,作为程序员,我们通过其名称“open”来引用这个系统调用,就像旅行者通过当地名称而不是 GPS 坐标来识别地点。

系统调用表的位置取决于操作系统和架构,但如果你对这些标识符感兴趣,可以访问以下链接中的定制表格:

filippo.io/linux-syscall-table/

信息交换

系统调用不是单向交易;它们涉及仔细的信息交换。每个系统调用都带有参数,这些参数规定了哪些数据需要在用户空间(你的进程域)和内核空间(内核的领域)之间传输。想象一下,这是一个协调良好的跨境对话,信息在双方之间无缝传递。

当你使用write()系统调用来将数据保存到文件时,你不仅传递数据,还传递有关写入位置的信息(例如,文件描述符和数据缓冲区)。这种数据交换就像跨境对话,数据在用户和内核空间之间无缝移动。

syscall

我们观察到一种一致的趋势:我们需要的绝大多数功能都可以在标准库中轻松访问,这是其全面性和实用性的证明。然而,在这个模式中有一个显著的例外——syscall包。

该包一直是跨各种架构和操作系统与系统调用和常量接口的基础。然而,随着时间的推移,出现了几个问题,导致其被弃用:

  • syscall就像你承诺要清理的……某个时候会清理的过度拥挤的衣柜。

  • 测试限制:该包的大部分内容缺乏明确的测试。此外,由于包的设计,跨平台测试不可行。

  • syscall包创下了记录,成为标准库中最少维护、测试和记录的包之一。

  • syscall包,每个系统都有其独特的变体,对于开发者来说就像是一个谜团中的谜团。虽然人们希望有清晰的指导,但godoc工具只提供了一个简要的预览,非常类似于电影预告片,只展示了亮点。这种针对其原生环境的精选显示,加上普遍缺乏文档,使得理解和有效使用这个包成为一项具有挑战性的任务。

  • syscall包常常感觉像是在进行无休止的追逐,就像追逐独角兽一样。操作系统随着不断的进化,提出了超出了 Go 团队控制范围的挑战。例如,FreeBSD 的变化影响了这个包的兼容性。

为了解决这些担忧,Go 团队提出了以下建议:

  • syscall包将被冻结,这意味着不会对其做任何进一步的修改。这包括即使引用的操作系统中有所变化,也不会更新它。

  • x/sys (pkg.go.dev/golang.org/x/sys)是为了替换syscall包而创建的。这个新包更容易维护、文档化和用于跨平台开发。

  • 废弃:虽然syscall包将继续存在并运行,但所有新的公共开发都转移到了x/syssyscall包的文档将指导用户转向这个新的存储库。

从本质上讲,虽然syscall包在一段时间内发挥了作用,但它带来的维护、文档和兼容性方面的挑战,使得它不得不被废弃,转而采用更结构化和可维护的x/sys方法。

关于这个决定的更多信息,Rob Pike 有一篇帖子解释了这一决定(go.googlesource.com/proposal/+/refs/heads/master/design/freeze-syscall.md)。

深入了解 os 和 x/sys 包

正如我们在 Go 文档中关于x/sys包所看到的那样:

x/sys的主要用途是在其他提供更便携接口到系统的包内部,例如“os”、“time”和“net”。”

如果可能的话,使用那些包而不是这个包。关于这个包中函数和数据类型的详细信息,请参阅相应操作系统的手册。这些调用返回 err == nil 表示成功;否则 err 是描述失败的操作系统错误。在大多数系统中,这个错误有 type syscall.Errno.

x/sys 包 – 底层系统调用

Go 语言中的x/sys包提供了对底层系统调用的访问。它通常在需要直接与操作系统交互或进行特定平台操作时使用。使用x/sys时需要谨慎,因为不当的使用可能导致系统不稳定或安全问题。

要使用这个包,你应该使用 Go 工具下载它:

go get -u golang.org/x/sys

让我们来探讨这个包能提供什么。

系统调用

这里有一些系统调用调用和常量:

  • unix.Syscall(): 使用参数调用特定的系统调用

  • unix.Syscall6(): 与Syscall()类似,但用于具有六个参数的系统调用

  • unix.SYS_*: 表示各种系统调用的常量(例如,unix.SYS_READ, unix.SYS_WRITE

例如,下面两个代码片段会产生相同的结果,打印出"Hello World!"

使用fmt包,你可以得到以下输出:

fmt.Println("Hello World!")

通过使用x/sys包,你可以得到以下内容:

unix.Syscall(unix.SYS_WRITE, 1,
  uintptr(unsafe.Pointer(&[]byte("Hello, World!")[0])),
  uintptr(len("Hello, World!")),
 )

如果我们决定使用低级抽象而不是fmt包,事情可能会变得非常复杂。

我们可以通过类别继续探索包 API。

文件操作

这些函数让我们可以与普通文件交互:

  • unix.Create(): 创建新文件

  • unix.Unlink(): 删除文件

  • unix.Mkdir(), unix.Rmdir(), 和 unix.Link(): 创建和删除目录和链接

  • unix.Getdents(): 获取目录条目

信号

这里有两个与 OS 信号交互的函数示例:

  • unix.Kill(): 向进程发送终止信号

  • unix.SIGINT: 中断信号(通常称为Ctrl + C

用户和组管理

我们可以使用以下调用管理用户和组:

  • syscall.Setuid(), syscall.Setgid(), syscall.Setgroups(): 设置用户和组 ID

系统信息

我们可以使用Sysinfo()函数分析一些关于内存和交换使用以及平均负载的统计数据:

  • syscall.Sysinfo(): 获取系统信息

文件描述符

虽然这不是日常任务,但我们也可以直接与文件描述符交互:

  • unix.FcntlInt(): 对文件描述符执行各种操作

  • unix.Dup2(): 复制文件描述符

内存映射文件

Mmap 是内存映射文件的缩写。它提供了一种机制,可以在不依赖系统调用的前提下读写文件。当使用Mmap()时,操作系统会为程序分配一段虚拟地址空间,该空间直接“映射”到相应的文件部分。如果程序访问地址空间的那部分数据,它将检索存储在文件相关部分的数据:

  • syscall.Mmap(): 将文件或设备映射到内存

操作系统功能

Go 语言中的os包提供了一组丰富的函数,用于与操作系统交互。它分为几个子包,每个子包都专注于 OS 功能的一个特定方面。

以下是一些文件和目录操作:

  • os.Create(): 创建或打开文件以写入

  • os.Mkdir()os.MkdirAll(): 创建目录

  • os.Remove()os.RemoveAll(): 删除文件和目录

  • os.Stat(): 获取文件或目录信息(元数据)

  • os.IsExist(), os.IsNotExist(), 和 os.IsPermission(): 检查文件/目录存在或权限错误

  • os.Open(): 以读取方式打开文件

  • os.Rename(): 重命名或移动文件

  • os.Truncate(): 调整文件大小

  • os.Getwd(): 获取当前工作目录

  • os.Chdir(): 更改当前工作目录

  • os.Args: 命令行参数

  • os.Getenv(): 获取环境变量

  • os.Setenv(): 设置环境变量

以下是与进程和信号相关的内容:

  • os.Getpid(): 获取当前进程 ID

  • os.Getppid(): 获取父进程 ID

  • os.Getuid()os.Getgid(): 获取用户和组 ID

  • os.Geteuid()os.Getegid(): 获取有效用户和组 ID

  • os.StartProcess(): 启动新进程

  • os.Exit(): 退出当前进程

  • os.Signal: 表示信号(例如,SIGINTSIGTERM

  • os/signal.Notify(): 在接收到信号时通知

os包允许你创建和操作进程。你可以启动新进程,获取当前进程的信息,并操作其属性:

package main
import (
     "fmt"
     "os"
     "os/exec"
)
func main() {
     // Start a new process
     cmd := exec.Command("ls", "-l")
     cmd.Stdout = os.Stdout
     cmd.Stderr = os.Stderr
     err := cmd.Run()
     if err != nil {
          fmt.Println(err)
          return
     }
     // Get the current process ID
     pid := os.Getpid()
     fmt.Println("Current process ID:", pid)
}

该程序的主要部分如下:

  • exec.Command("ls", "-l"): 这将创建一个新的命令来运行带有-l标志的ls命令。

  • cmd.Stdout = os.Stdout: 这将ls命令的标准输出重定向到主程序的标准输出。

  • cmd.Stderr = os.Stderr: 类似地,这将ls命令的标准错误重定向到主程序的标准错误。

  • err := cmd.Run(): 这将运行ls命令。如果在执行过程中出现错误,它将被存储在err变量中。

  • os.Getpid(): 这将检索当前进程的进程 ID。

虽然os包提供了许多系统相关任务的底层接口,但syscall(和x/sys)包允许你直接进行更底层的系统调用。这在你需要对系统资源进行精细控制时非常有用。

可移植性

虽然x/sys是进行系统调用的首选包,但你必须明确选择 Unix 和 Windows。与操作系统交互的推荐方式是使用os包。当你将程序构建到特定的操作系统和架构时,编译器将执行繁重的工作以使用适当的系统调用版本。

例如,在 Windows 中,你需要调用具有以下签名的函数:

SetEnvironmentVariable(name *uint16, value *uint16) (err error)

对于基于 Unix 的系统,签名甚至没有相同的名称,正如我们将在下一个片段中看到的那样:

Setenv(key, value string) error

为了避免这种“签名俄罗斯方块”,我们可以使用具有相同语义的os包中的函数:

Setenv(key, value string) error

(是的!签名与 Unix 版本相同。)

注意

syscall(pkg.go.dev/syscall)的主要用途是在提供更可移植系统接口的其他包内部,例如ostimenet

从现在开始,我们利用os包,只有在特殊情况下才会直接调用x/sys包。

最佳实践

作为使用 Go 中的osx/sys包的系统程序员,请考虑以下最佳实践:

  • 对于大多数任务,使用os包,因为它提供了一个更安全和更可移植的接口

  • 仅在需要精细控制系统调用的情况下保留x/sys

  • 使用 x/sys 包时,请注意平台特定的常量和类型,以确保跨平台兼容性

  • 认真处理系统调用和 os 包函数返回的错误,以维护应用程序的可靠性

  • 在不同的操作系统上测试你的系统级代码,以验证其在各种环境中的行为

让我们看看我们如何追踪我们在终端上日常执行的命令中发生的事情。

每日系统调用

在我们的程序中,每次都会发生几个系统调用。我们可以使用 strace 工具追踪这些调用。

跟踪系统调用

strace 工具可能不是所有 Linux 发行版都预安装的,但在大多数官方仓库中都有。以下是如何在一些主要发行版上安装它的方法。

Debian(使用 APT):运行以下命令:

apt-get install strace -y

Red Hat 家族(使用 DNF 和 YUM)

  • 当使用 yum 时,运行以下命令:

    yum install strace
    
  • 当使用 dnf 时,运行以下命令:

    dnf install strace
    

Arch Linux(使用 Pacman):运行以下命令:

pacman -S strace

基本 strace 使用

使用 strace 的基本方法是调用 strace 实用程序,后跟程序名称;例如:

strace ls

这将生成一个输出,显示系统调用、它们的参数和返回值。例如,execve 系统调用 (man7.org/linux/man-pages/man2/execve.2.html) 可能看起来像这样:

execve("/usr/bin/ls", ["ls"], 0x7ffdee76b2a0 /* 71 vars */) = 0

跟踪特定系统调用

如果你只想跟踪特定的系统调用,请使用 -e 标志后跟系统调用名称。例如,要跟踪 ls 命令的 execve 系统调用,请运行以下命令:

strace -e execve ls

现在,我们可以使用我们的工具集中的新工具来追踪程序中的系统调用。考虑以下简单的 main.go 文件:

package main
import "unix"
func main() {
     unix.Write(1, []byte{"Hello, World!"})
}

这个程序需要通过向标准输出写入数据与硬件设备交互,即我们的控制台。为了访问控制台并执行此操作,程序需要从内核获得权限。这种权限是通过系统调用获得的,例如请求访问特定功能,如向控制台发送消息,这允许你的程序利用控制台的资源。

unix.Write 函数正在使用两个参数被调用:

  • 第一个参数是 1,它是 Unix-like 系统中标准输出(stdout)的文件描述符。这意味着程序将数据写入程序运行的控制台或终端。

  • 第二个参数是 []byte{"Hello, World!"},它是一个包含 "Hello, World!" 字符串的字节切片。

我们构建的程序将二进制文件命名为 app

go build -o app main.go

我们随后使用 strace 工具运行,过滤 write 系统调用:

strace -e write ./app 2>&1

你应该看到以下输出作为结果:

write(1, "Hello, World!", 13Hello, World!)           = 13

现在,是时候探索一个与操作系统交互的程序了。让我们制作并测试我们的第一个 CLI 应用程序。

开发和测试 CLI 程序

CLI 应用程序是软件开发、系统管理和自动化中的必备工具。在创建 CLI 应用程序时,与stdin(标准输入)、stderr(标准错误)和stdout(标准输出)的交互在确保其有效性和用户友好性方面起着至关重要的作用。

在本节中,我们将探讨为什么这些标准流是 CLI(命令行界面)开发不可或缺的组成部分。

标准流

stdinstderrstdout的概念深深植根于 Unix 哲学的“一切皆文件。”(我们将在第四章文件和目录操作中进一步探讨这一点。)这些标准化流为 CLI 应用程序与用户和其他进程之间的通信提供了一种一致的方式。用户已经习惯了 CLI 工具以某种方式工作,遵守这些约定增强了应用程序的可预测性和用户友好性。

CLI 应用程序最强大的特性之一是它们能够通过管道(详见第六章管道)无缝协作。在类 Unix 系统中,您可以链式连接多个 CLI 工具,每个工具处理前一个工具的stdout中的数据。这种模式允许高效地处理数据并实现复杂任务的自动化。当您的应用程序与stdout交互时,它成为这些管道中的宝贵构建块,使用户能够轻松创建复杂的流程。

输入灵活性

通过利用stdin,您的 CLI 应用程序可以接受来自各种来源的输入。用户可以通过键盘进行交互式输入,或者将其他进程的数据直接通过管道输入到您的工具中。此外,您的应用程序还可以从文件中读取输入,使用户能够处理存储在不同格式和位置的各类数据。这种灵活性使得您的应用程序能够适应广泛的用例场景。

输出灵活性

同样,通过使用stdout,您的 CLI 应用程序可以以易于重定向、保存到文件或用作其他进程输入的格式提供输出。这种适应性确保了用户能够以多种方式利用工具的输出,从而提高工作效率和灵活性。

错误处理

stderr专门设计用于错误消息。将错误消息与常规程序输出分开简化了用户的错误检测和处理。当您的应用程序遇到问题时,stderr提供了一个专门的通道来传达错误信息。这种分离使得用户能够迅速识别和解决问题。

跨平台兼容性

stdinstderrstdout的美丽之处在于它们的平台无关性。这些流在不同的操作系统和环境之间保持一致。因此,我们的命令行应用程序可以保持可移植性和兼容性,确保它们在各种系统上无需修改即可可靠地运行。

测试和调试

通过遵循使用stderr进行错误输出的惯例,可以使测试和调试更加直接。用户可以轻松地单独捕获和分析错误消息,与程序的正常输出分开。这种分离有助于在开发和生产环境中定位和解决问题。

日志记录

许多命令行界面(CLI)应用程序使用stderr记录错误消息。这种做法使用户能够有效地监控应用程序的行为和解决问题。适当的日志记录增强了应用程序的可维护性,并有助于其整体健壮性。

用户体验

在使用stdinstderrstdout时保持一致性有助于提升用户体验。用户熟悉这些流,并期望命令行应用程序以标准方式运行。这种熟悉性降低了新用户的学习曲线,并提高了整体用户满意度。

遵守惯例

在软件开发和脚本编写社区中,许多最佳实践和既定惯例都假设使用stdinstderrstdout。遵守这些惯例使得你的命令行应用程序更容易集成到现有的工作流程和实践中,为开发者和用户节省时间和精力。

文件描述符

你是否曾好奇过,你的电脑是如何在不费吹灰之力地管理所有这些打开的文件、网络连接和设备?好吧,有一个鲜为人知的秘密让一切运行得如此顺畅:文件描述符。这些不起眼的数字 ID 是电脑处理文件、目录、设备等背后的无名英雄。

从正式的角度来说,文件描述符是操作系统用来唯一标识和管理打开的文件、套接字、管道和其他 I/O 资源的抽象表示或数字标识符。它是程序引用打开资源的一种方式。

文件描述符可以表示不同类型的资源:

  • 常规文件:这些是包含数据的磁盘文件

  • 目录:磁盘上目录的表示

  • 字符设备:提供对使用字符流工作的设备(如键盘和串行端口)的访问

  • 块设备:用于访问块设备,如硬盘

  • 套接字:用于进程间的网络通信

  • 管道:用于进程间通信(IPC)

当 shell 启动一个进程时,它通常继承三个打开的文件描述符。描述符 0 代表标准输入,为进程提供输入的文件。描述符 1 代表标准输出,进程写入输出的文件。描述符 2 代表标准错误,进程写入错误消息和有关异常条件的通知的文件。这些描述符通常连接到交互式 shell 或程序中的终端。在os包中,stdinstdoutstderr是打开的文件,分别指向标准输入、输出和错误描述符(cs.opensource.google/go/go/+/refs/tags/go1.21.1:src/os/file.go;l=64)。

总结来说,stdinstderrstdout对于开发有效、用户友好且可互操作的 CLI 应用程序至关重要。这些标准化流提供了一种灵活、灵活且可靠的输入、输出和错误处理方式。通过采用这些流,我们的 CLI 应用程序对用户更加易于访问和有价值,增强了他们自动化任务、处理数据和高效实现目标的能力。

创建 CLI 应用程序

让我们按照标准流的最佳实践创建并测试我们的第一个命令行界面(CLI)应用程序。

此程序将捕获所有给出的参数(从现在起称为单词)。当单词长度为偶数时,它将发送到stdout;否则,它将发送到stderr

words := os.Args[1:]
if len(words) == 0 {
    fmt.Fprintln(os.Stderr, "No words provided.")
    os.Exit(1)
}

第一行检索传递给程序的命令行参数,不包括程序名称本身。程序名称总是os.Args切片中的第一个元素(os.Args[0]),因此通过使用[1:]切片,它获取程序之后的全部参数。

条件检查words切片的长度是否为零,这意味着在程序名称之后没有提供任何命令行参数。如果没有提供参数,它将使用fmt.Fprintln(os.Stderr, "No words provided.")将一个"No words provided."错误信息打印到标准错误流。

然后它使用非零退出代码(os.Exit(1))退出程序。在类 Unix 操作系统中,退出代码为 0 通常表示成功,而非零退出代码表示错误。在这种情况下,程序表示它遇到了由于缺少命令行参数而导致的错误:

for _, w := range words {
    if len(w)%2 == 0 {
        fmt.Fprintf(os.Stdout, "word %s is even\n", w)
    } else {
        fmt.Fprintf(os.Stderr, "word %s is odd\n", w)
    }
}

此代码遍历words切片中的每个单词,检查其长度是偶数还是奇数,然后相应地将消息打印到标准输出或标准错误。

main.go文件将如下所示:

package main
import (
     "fmt"
     "os"
)
func main() {
     words := os.Args[1:]
     if len(words) == 0 {
          fmt.Fprintln(os.Stderr, "No words provided.")
          os.Exit(1)
     }
     for _, w := range words {
          if len(w)%2 == 0 {
               fmt.Fprintf(os.Stdout, "word %s is even\n", w)
          } else {
               fmt.Fprintf(os.Stderr, "word %s is odd\n", w)
          }
     }
}

要查看我们的程序运行效果,我们应该传递参数,如下一个示例所示:

go run main.go alex golang error

要查看哪些单词被打印到stdout(标准输出)和哪些被打印到stderr(标准错误),您可以在终端中使用重定向:

go run main.go word1 word2 word3 > stdout.txt 2> stderr.txt

在运行前面的命令后,您可以检查stdout.txtstderr.txt的内容,以查看哪些单词被打印到每个流:

cat stdout.txt
cat stderr.txt

长度为偶数的单词将位于stdout.txt中,而长度为奇数的单词将位于stderr.txt中。

重定向和标准流

记住stdout是文件描述符 1,而stderr是文件描述符 2 吗?现在,它们将结合在一起。

当我们使用> stdout.txt时,我们使用 shell 重定向运算符。它将命令的标准输出(stdout)重定向到运算符左侧的文件。由于stdout是标准输出,通常省略数字 1,但2>不是这样。它专门重定向标准错误(stderr)。

注意

stdout.txtstderr.txt文件是go run命令的标准输出和标准错误将被写入的地方。如果这些文件中的任何一个不存在,它将被创建;如果它已经存在,它将被覆盖。

使其可测试

我们不希望在终端中执行程序以确保程序在每次小变化后仍然工作。在这方面,我们想要添加自动化测试。让我们重构代码以编写测试。

移动核心理念

将检查单词长度和打印结果的核心理念移动到名为app的单独函数中。这使得代码更加组织化,并且更容易测试:

func app(words []string) {
     for _, w := range words {
          if len(w)%2 == 0 {
               fmt.Fprintf(os.Stdout, "word %s is even\n", w)
          } else {
               fmt.Fprintf(os.Stderr, "word %s is odd\n", w)
          }
     }
}

引入灵活的配置

添加一个CliConfig结构体来保存 CLI 的配置值。这为未来的修改提供了灵活性。目前,我们感兴趣的是使标准流易于更改以进行测试:

type CliConfig struct {
     ErrStream, OutStream io.Writer
}
func app(words []string, cfg CliConfig) {
     for _, w := range words {
          if len(w)%2 == 0 {
               fmt.Fprintf(cfg.OutStream, "word %s is even\n", w)
          } else {
               fmt.Fprintf(cfg.ErrStream, "word %s is odd\n", w)
          }
     }
}

功能选项

功能选项是 Go 中的一个设计模式,它允许灵活且干净地配置对象。当对象有许多可选配置时,它特别有用。

这种模式提供了几个好处:

  • 可读性:无需记住参数的顺序,就可以清楚地知道正在设置哪些选项。

  • 可扩展性:你可以轻松地添加新选项,而无需更改现有的函数签名或调用。

  • 安全性:你可以确保对象在构造后始终处于有效状态。你可以在构造函数中轻松提供默认值。如果没有提供选项,则使用默认值。

在我们的程序中,我们有两个可选配置:outStreamerrStream

你可以使用功能选项而不是使用带有多个参数的构造函数或配置结构体:

type Option func(*CliConfig) error
func WithErrStream(errStream io.Writer) Option {
     return func(c *CliConfig) error {
          c.ErrStream = errStream
          return nil
     }
}
func WithOutStream(outStream io.Writer) Option {
     return func(c *CliConfig) error {
          c.OutStream = outStream
          return nil
     }
}

现在,你可以为CliConfig结构体提供一个接受这些选项的构造函数:

func NewCliConfig(opts ...Option) (CliConfig, error) {
     c := CliConfig{
          ErrStream: os.Stderr,
          OutStream: os.Stdout,
     }
     for _, opt := range opts {
          if err := opt(&c); err != nil {
               return CliConfig{}, err
          }
     }
     return c, nil
}

在前面的设置中,创建新的CliConfig结构体变得直观且易于阅读:

NewCliConfig(WithOutStream(&var1),WithErrStream(&var2))
NewCliConfig(WithOutStream(&var1))
NewCliConfig(WithErrStream(&var2))

更新主函数

我们可以修改main函数以使用新的CliConfig结构体和app函数,并处理NewCliConfig的潜在错误:

func main() {
     words := os.Args[1:]
     if len(words) == 0 {
          fmt.Fprintln(os.Stderr, "No words provided.")
          os.Exit(1)
     }
     cfg, err := NewCliConfig()
     if err != nil {
          fmt.Fprintf(os.Stderr, "Error creating config: %v\n", err)
          os.Exit(1)
     }
     app(words, cfg)
}

测试

让我们来看看我们的测试函数,并检查我们用它实现了什么:

package main
import (
    "bytes"
    "strings"
    "testing"
)
func TestMainProgram(t *testing.T) {
    var stdoutBuf, stderrBuf bytes.Buffer
    config, err := NewCliConfig(WithOutStream(&stdoutBuf), WithErrStream(&stderrBuf))
    if err != nil {
        t.Fatal("Error creating config:", err)
    }
    app([]string{"main", "alex", "golang", "error"}, config)
    output := stdoutBuf.String()
    if len(output) == 0 {
        t.Fatal("Expected output, got nothing")
    }
    if !strings.Contains(output, "word alex is even") {
        t.Fatal("Expected output does not contain 'word alex is even'")
    }
    if !strings.Contains(output, "word golang is even") {
        t.Fatal("Expected output does not contain 'word golang is even'")
    }
    errors := stderrBuf.String()
    if len(errors) == 0 {
        t.Fatal("Expected errors, got nothing")
    }
    if !strings.Contains(errors, "word error is odd") {
        t.Fatal("Expected errors does not contain 'word error is odd'")
    }
}

让我们分解这个测试的关键组件和步骤:

  1. TestMainProgram函数是检查app函数行为的测试函数。

  2. 创建了两个 bytes.Buffer 变量,stdoutBufstderrBuf。这些缓冲区将分别捕获程序的标准输出和标准错误流。这允许你在测试中捕获和检查程序的输出和错误消息。

  3. 调用 NewCliConfig 函数创建一个具有自定义输出和错误流的 CliConfig 配置。使用 WithOutStreamWithErrStream 选项将输出和错误流分别设置为 stdoutBufstderrBuf 缓冲区。这样做是为了捕获程序的输出和错误,并在测试中进行检查。

  4. app 函数被调用,并传入一个单词列表作为输入,同时提供自定义的 CliConfig 结构体作为配置。在这种情况下,单词 "main""alex""golang""error" 作为参数传递,以模拟程序的行为。

测试随后检查程序输出和错误的各个方面:

  1. 它检查 stdoutBuf 中是否有捕获的输出。如果没有输出,则测试失败。

  2. 它检查预期的输出消息,例如 "word alex is even""word golang is even",是否包含在捕获的输出中。如果任何预期的输出消息缺失,则测试失败。

  3. 它检查 stderrBuf 中是否有捕获的错误。如果没有错误,则测试失败。

  4. 它检查预期的错误消息,即 "word error is odd",是否包含在捕获的错误中。如果预期的错误消息缺失,则测试失败。

我们可以使用 go test 命令运行测试,并将显示类似的输出:

=== RUN   TestMainProgram
--- PASS: TestMainProgram (0.00s)
PASS

总结来说,这个单元测试验证了当给定的单词集合特定时,app 函数是否正确地产生了预期的输出和错误消息。它使用 bytes.Buffer 捕获程序的输出和错误,检查预期消息的存在,并在预期输出或错误消息缺失时报告测试失败。这个测试有助于确保 app 函数在不同场景下的行为符合预期,避免了使用终端进行手动测试。

我们现在可以使用我们的程序与其他 Linux 工具一起使用:

go build -o cli-app main.go
ls -l | xargs app | grep even

最后一条命令列出当前目录的内容,将列表中的每一行作为 app 命令的参数传递,然后过滤 app 命令的输出,只显示包含单词 “even” 的行。

在我们继续前进之前,有一个关于本章关键概念的摘要将是有帮助的。

摘要

从旅行的类比中,我们看到了系统调用如何充当护照,使进程能够在软件执行的广阔领域中导航。我们区分了用户模式和内核模式,强调了与每种模式相关的特权和限制。本章还揭示了 Go 中syscall包面临的挑战,导致其最终被更易于维护的x/sys包所取代。此外,在本章中,我们成功构建了一个 CLI 应用程序,利用了 Go 的osx/sys包的力量。我们亲眼见证了系统调用如何被集成到实际软件解决方案中,从而实现与操作系统的直接交互。随着你继续前进,请记住所强调的最佳实践和所获得的技能,确保在 Go 中进行安全、高效的系统级编程和健壮的 CLI 工具创建。

在下一章中,我们将探索 Go 中的文件和目录操作的世界,这对于任何与文件系统打交道的开发者来说都是一项至关重要的技能集。我们的主要重点是识别不安全的权限、确定目录大小和定位重复文件。对于所有与文件系统打交道的开发者来说,这些技术具有重大意义,因为它们在维护数据完整性和保障软件应用中的安全性方面发挥着至关重要的作用。

第四章:文件和目录操作

在本章中,我们将学习如何使用 Go 处理文件和文件夹。我们将探讨许多有价值的主题,包括检查文件和文件夹权限、处理链接以及查找文件夹的大小。

在本章中,你将进行实际操作。你将编写并运行与文件和文件夹交互的代码。这样,你将学会实际编程任务中的实用技能。

到本章结束时,你将知道如何在 Go 中管理文件和文件夹。你可以检查和修复文件和文件夹权限,查找和管理文件和文件夹,以及执行许多其他实用任务。这些知识将帮助你创建安全有效的 Go 相关程序。

在本章中,我们将介绍以下主要内容:

  • 识别不安全的文件和目录权限

  • 在 Go 中扫描目录

  • 符号链接和解除文件链接

  • 计算目录大小

  • 查找重复文件

  • 优化文件系统操作

技术要求

你可以在 github.com/PacktPublishing/System-Programming-Essentials-with-Go/tree/main/ch4 找到本章的源代码。

识别不安全的文件和目录权限

在编程中检索有关文件或目录的信息是一项常见任务,Go 提供了一种平台无关的方式来执行此操作。os.Stat 函数是 os 包的一个基本部分,它作为操作系统功能的一个接口。当调用时,os.Stat 函数返回一个 FileInfo 接口和一个错误。FileInfo 接口包含各种文件元数据,例如其名称、大小、权限和修改时间。

这是 os.Stat 函数的签名:

func Stat(name string) (FileInfo, error)

名称参数是你想要获取信息的文件或目录的路径。

让我们来看看如何使用 os.Stat 获取有关文件的信息:

package main
import (
     "fmt"
     "os"
)
func main() {
     info, err := os.Stat("example.txt")
     if err != nil {
          panic(err)
     }
     fmt.Printf("File name: %s\n", info.Name())
     fmt.Printf("File size: %d\n", info.Size())
     fmt.Printf("File permissions: %s\n", info.Mode())
     fmt.Printf("Last modified: %s\n", info.ModTime())
}

在此示例中,在主函数中,我们使用名为 example.txt 的文件的路径调用 os.Stat。当 os.Stat 返回错误时,我们“恐慌”错误并退出程序。否则,我们使用 FileInfo 方法(NameSizeModeModTime)打印出有关文件的一些信息。

检查 os.Stat 返回的错误是很重要的。如果错误非空,很可能是由于文件不存在或存在权限问题。检查不存在文件的一种常见方法是使用 os.IsNotExist 函数:

info, err := os.Stat("example.txt")
if err != nil {
     if os.IsNotExist(err) {
          fmt.Println("File does not exist")
     } else {
          panic(err)
     }
}

在此代码中,我们首先调用 os.Stat 函数来检查文件的状态。如果在操作过程中发生错误,我们使用 os.IsNotExist 函数检查错误是否是因为文件不存在。如果是由于文件不存在,我们显示一条消息。然而,如果错误是由于其他原因,我们将引发恐慌并终止程序。一旦我们知道了如何读取文件元数据,我们就可以开始探索和理解文件及其权限。

文件和权限

在 Linux 中,文件被分类为各种类型,每种类型都有其独特的作用。以下是常见 Linux 文件类型及其与FileInfo.Mode()调用返回的FileMode位的关联概述。

普通文件

普通文件包含文本、图像或程序等数据。它们在文件列表的第一个字符中用-表示。在 Go 中,普通文件通过没有其他文件类型位来表示。您可以使用FileMode上的IsRegular方法检查文件是否为普通文件。

目录

目录包含其他文件和目录。它们在文件列表的第一个字符中用d表示。os.ModeDir位表示目录。您可以使用IsDir()方法检查一个文件是否是目录。

符号链接

符号链接是指向其他文件的指针。它们在文件列表的第一个字符中用l表示。os.ModeSymlink位表示符号链接。不幸的是,Go 中的FileMode没有直接暴露用于检查符号链接的方法,但我们可以检查FileMode&os.ModeSymlink是否非零。

命名管道(FIFOs)

命名管道是进程间通信的机制,在文件列表的第一个字符中用p表示。os.ModeNamedPipe位表示命名管道。

字符设备

字符设备提供对硬件设备的无缓冲、直接访问,在文件列表的第一个字符中用c表示。os.ModeCharDevice位表示字符设备。

块设备

块设备提供对硬件设备的缓冲访问,在文件列表的第一个字符中用b表示。Go 没有直接为块设备提供FileMode位。但是,您可能仍然可以使用os包的文件操作来处理块设备。

套接字

套接字是通信的端点,在文件列表的第一个字符中用s表示。os.ModeSocket位表示套接字。

Go 中的FileMode类型封装了这些位,并提供用于处理文件类型和权限的方法和常量,这使得跨平台执行文件操作变得更容易。

在 Linux 中,权限系统是文件和目录安全的一个关键方面。它决定了谁可以访问、修改或执行文件和目录。权限由对三个用户类别的读(r)、写(w)和执行(x)权限的组合表示:所有者、组和其他人。

让我们回顾一下这些权限代表什么:

  • 读取(r):允许读取或查看文件内容或列出目录内容

  • 写入(w):允许修改或删除文件内容,或在目录中添加/删除文件

  • 执行(x):允许执行文件或访问目录的内容(如果您对目录本身有执行权限)

Linux 文件权限通常以一个 9 字符的字符串形式显示,例如 rwxr-xr—,其中前三个字符代表所有者的权限,接下来的三个字符代表组的权限,最后的三个字符代表其他用户的权限。

当我们将文件类型和其权限结合起来时,我们形成了一个 10 字符的字符串,这是 ls -l 命令在以下示例的第一列返回的权限。

-rw-r--r-- 1 user group  0 Oct 25 10:00 file1.txt
-rw-r--r-- 1 user group  0 Oct 25 10:01 file2.txt
drwxr-xr-x 2 user group 4096 Oct 25 10:02 directory1

如果我们仔细观察 directory1,我们可以确定以下内容:

  • 由于第一个字母是 d,所以它是一个目录。

  • 所有者拥有读取、写入和执行权限,这是由第一个三元组 rwx 给出的。

  • 组和用户拥有相同的字符串 r-x 的读取和执行权限。

要在 Go 中检查文件权限,可以使用 os 包来检查文件和目录属性。以下是一个使用 Go 检查文件权限的简单示例:

package main
import (
    "fmt"
    "os"
)
func main() {
    // Stat the file to get its information
    fileInfo, err := os.Stat("example.txt")
    if err != nil {
         fmt.Println("Error:", err)
         return
    }
    // Get file permissions
    permissions := fileInfo.Mode().Perm()
    permissionString := fmt.Sprintf("%o", permissions)
    fmt.Printf("Permissions: %s\n", permissionString)
}

在这个例子中,我们使用 os.Stat 来检索文件信息,然后使用 fileInfo.Mode().Perm() 提取权限。Perm() 方法返回一个 os.FileMode 值,我们使用 fmt.Sprintf 将其格式化为八进制字符串。

你可能会问自己,为什么是 八进制字符串

八进制表示法提供了一种紧凑且易于阅读的方式来表示文件权限。八进制数字是读取(4)、写入(2)和执行(1)值的总和。例如,rwx(读取、写入、执行)是 7(4+2+1),r-x(读取、无写入、执行)是 5(4+0+1),依此类推。

例如,权限 -rwxr-xr-- 可以简洁地表示为八进制的 755。

注意

使用八进制表示权限的惯例可以追溯到 Unix 的早期。几十年来,这一惯例被保留下来以保持一致性和与旧脚本和工具的兼容性。

在 Go 中扫描目录

Go 提供了一种健壮且与平台无关的方式来处理文件和目录路径,使其成为构建文件相关应用程序的绝佳选择。我们将涵盖诸如路径连接、清理和遍历等主题,以及一些有效处理文件路径的最佳实践。

理解文件路径

在我们深入探讨在 Go 中操作文件路径之前,了解基础知识非常重要。文件路径是文件或目录在文件系统中的位置字符串表示。文件路径通常由一个或多个目录名组成,这些目录名由路径分隔符分隔,路径分隔符在不同的操作系统之间有所不同。

例如,在类 Unix 系统(Linux、macOS)中,路径分隔符是 /,例如 /home/user/documents/myfile.txt

在 Windows 系统中,路径分隔符是 \,例如 C:\Users\User\Documents\myfile.txt

Go 提供了一种方便的方式来处理文件路径,与底层操作系统无关,确保跨平台兼容性。

使用 path/filepath 包

Go 的标准库包括 path/filepath 包,它提供了一组以平台无关的方式操作文件路径的函数。让我们探索一些可以使用此包执行的一些常见操作。

连接文件路径

要将文件路径的多个部分连接成一个单独的、正确格式的路径,我们可以使用 filepath.Join 函数。它接受任意数量的参数,使用适当的路径分隔符将它们连接起来,并返回结果文件路径:

package main
import (
     "fmt"
     "path/filepath"
)
func main() {
     dir := "/home/user"
     file := "document.txt"
     fullPath := filepath.Join(dir, file)
     fmt.Println("Full path:", fullPath)
}

在此示例中,filepath.Join 正确处理了基于操作系统的路径分隔符。当我们运行此程序时,我们应该看到以下输出:

Full path: /home/user/document.txt

清理文件路径

由于连接或用户输入,文件路径可能会随着时间的推移而变得混乱。filepath.Clean 函数通过删除多余的分隔符和对当前目录(.)以及父目录(..)的引用来帮助清理和简化文件路径。

package main
import (
     "fmt"
     "path/filepath"
)
func main() {
     uncleanPath := "/home/user/../documents/file.txt"
     cleanPath := filepath.Clean(uncleanPath)
     fmt.Println("Cleaned path:", cleanPath)
}

在此示例中,filepath.Clean 将不干净的路径转换为更干净、更易读的路径。当我们运行此程序时,我们应该看到以下输出:

Cleaned path: /home/documents/file.txt

分割文件路径

要从文件路径中提取目录和文件组件,我们可以使用 filepath.Split。在此示例中,filepath.Split 将文件路径的目录和文件部分分开:

package main
import (
     "fmt"
     "path/filepath"
)
func main() {
     path := "/home/user/documents/myfile.txt"
     dir, file := filepath.Split(path)
     fmt.Println("Directory:", dir)
     fmt.Println("File:", file)
}

当我们运行此程序时,我们应该看到以下输出:

Directory: /home/user/documents/
File: myfile.txt

遍历目录

您可以使用 filepath.WalkDir 函数遍历目录并在其中对文件和目录执行操作。此函数递归地探索目录树。

让我们分析这个函数的签名:

func WalkDir(root string, fn fs.WalkDirFunc) error

第一个参数是我们想要遍历的文件树根。第二个参数是 WalkdirFunc,它是一个函数类型。当我们进一步查看时,我们可以看到这个类型决定了什么:

type WalkDirFunc func(path string, d DirEntry, err error) error

path 是包含 WalkDir 参数的参数,作为前缀。换句话说,如果 root/home 并且当前迭代是在 Documents 目录中,那么 path 将包含 /home/Documents 字符串。

第二个参数是 DirEntry 接口。此接口由四个方法定义。

Name() 函数返回文件或子目录的基本名称,而不是完整路径。

例如,它只会返回文件名 hello.go,而不会返回整个文件路径,例如 home/gopher/hello.go

IsDir() 函数检查给定的条目是否指向一个目录。

Type() 方法返回给定条目的类型位,这是 FileMode.Type 方法返回的 FileMode 位的一个子集。

要获取文件或目录的信息,您可以使用Info()函数。它返回一个FileInfo对象,描述文件或目录。请注意,返回的对象可能代表原始目录读取时的文件或目录,或者是在调用Info()时的状态。如果文件或目录在读取目录后被删除或重命名,Info可能返回错误ErrNotExist。如果您正在检查的条目是一个符号链接,Info()将提供有关链接本身的信息,而不是其目标。

当使用WalkDir函数时,函数返回的结果决定了函数的行为。如果函数返回SkipDir值,WalkDir将跳过当前目录(或路径,如果它是目录)并继续下一个。如果函数返回SkipAll值,WalkDir将跳过所有剩余的目录和文件,并停止遍历树。如果函数返回非空错误,WalkDir将完全停止并返回该错误。err参数报告与路径相关的错误,这表示WalkDir将不会进入该目录。使用WalkDir的函数可以决定如何处理该错误。如前所述,返回错误将导致WalkDir停止遍历整个树。

为了使一切更加清晰,让我们扩展第三章的应用。这个程序不仅将输入分类为奇数或偶数,它将遍历一个目录树,直到达到指定的最大深度,并且作为一个附加功能,我们将允许用户将输出重定向到文件。

首先,我们需要在main函数中为我们的程序添加两个新的标志:

var outputFileName string
flag.StringVar(&outputFileName, "f", "", "Output file (default: stdout)")
flag.Parse()

此代码设置了命令行标志(-f)的默认值和描述,将其与一个变量(outputFileName)关联,然后解析命令行参数以用用户提供的值填充此变量。这允许程序在从命令行运行时接受特定选项。

现在,让我们将NewCliConfig函数更改为设置这两个新变量的默认值:

func NewCliConfig(opts ...Option) (CliConfig, error) {
  c := CliConfig{
    OutputFile: "", // empty means only OutStream is used
    ErrStream:  os.Stderr,
    OutStream:  os.Stdout,
  }
  // other lines omitted for brevity
}

现在我们应该更新我们的函数 app 以使用这个新的输出选项:

var outputWriter io.Writer
  if cfg.OutputFile != "" {
    outputFile, err := os.Create(cfg.OutputFile)
    if err != nil {
      fmt.Fprintf(cfg.ErrStream, "Error creating output file: %v\n", err)
      os.Exit(1)
    }
    defer outputFile.Close()
    outputWriter = io.MultiWriter(cfg.OutStream, outputFile)
  } else {
    outputWriter = cfg.OutStream
  }

函数 app 的这一部分首先确定是否基于cfg.OutputFile配置变量创建输出文件。如果成功创建输出文件,它将设置MultiWriter以同时写入标准输出和文件。如果没有指定输出文件,它简单地使用标准输出作为outputWriter。这种设计允许程序灵活地处理输出。

最后,我们将遍历所有目录。为了说明如何跳过目录,让我们假设我们总是想跳过.git目录:

for _, directory := range directories {
    err := filepath.WalkDir(directory, func(path string, d os.DirEntry, err error) error {
      if path == ".git" {
        return filepath.SkipDir
      }
      if d.IsDir() {
        fmt.Fprintf(outputWriter, "%s\n", path)
      }
      return nil
    })
    if err != nil {
      fmt.Fprintf(cfg.ErrStream, "Error walking the path %q: %v\n", directory, err)
      continue
    }
  }

这部分代码遍历一个目录列表,并递归地遍历每个目录的内容。对于它遇到的每个目录,它将目录的路径打印到指定的输出流,并处理在遍历过程中可能发生的错误。如前所述,它跳过处理.git目录,以避免将版本控制元数据包含在输出中。

一旦我们知道了如何遍历我们的文件系统,我们必须在不同的上下文中探索更多的例子。

符号链接和解除链接文件

哦,那个古老的 Unix 系统,其中像linkunlink这样的名字提供了那种诗意的对称感,诱使你陷入一种简单化的理解错觉,结果却让你陷入系统调用的兔子洞。

那么,链接和解除链接应该像豆荚里的两颗豌豆一样相关,对吧?嗯,它们确实如此…在某种程度上。

符号链接 – 文件世界的快捷方式

符号链接就像你的桌面上的快捷方式,只是针对数字世界中的文件。想象一下,你的计算机文件系统就像一个装满了书籍(文件)的庞大图书馆,你想要一个方便的方法从多个书架(目录)中访问你最喜欢的书籍(文件)。你不需要在图书馆里四处跑,你只需挂上一个“快捷方式”标志,上面写着:“嘿,你正在寻找的书籍就在那个书架上!”那就是符号链接!它就像给你的文件施了一个传送咒语,让你能够瞬间从一个位置跳到另一个位置,而不需要一根魔法扫帚。

假设你有一个名为important_document.txt的文件位于名为/home/user/document的目录中。你想要在另一个名为/home/user/desktop的目录中创建这个文件的快捷方式,以便你可以快速访问它。

在 Linux 命令行中,你可以使用带有-s选项的ln命令创建符号链接:

ln -s /home/user/documents/important_document.txt /home/user/desktop/shortcut_to_document.txt

下面是发生的事情:

  • ln:这是创建链接的命令

  • -s:此选项指定我们正在创建一个符号链接(symlink)

  • /home/user/documents/important_document.txt:这是你想要链接的源文件

  • /home/user/desktop/shortcut_to_document.txt:这是你想要创建符号链接的目标位置

现在,当你打开/home/user/desktop/shortcut_to_document.txt时,就像点击电脑桌面的快捷方式一样,它会直接带你到important_document.txt

我们在 Go 中可以取得相同的结果:

package main
import (
  "fmt"
  "os"
)
func main() {
  // Define the source file path.
  sourcePath := "/home/user/Documents/important_document.txt"
  // Define the symlink path.
  symlinkPath := "/home/user/Desktop/shortcut_to_document.txt"
  // Create the symlink.
  err := os.Symlink(sourcePath, symlinkPath)
  if err != nil {
    fmt.Printf("Error creating symlink: %v\n", err)
    return
  }
  fmt.Printf("Symlink created: %s -> %s\n", symlinkPath, sourcePath)
}

os.Symlink函数用于创建符号链接。在终端上运行ls -l命令后,我们应该看到以下类似的输出:

lrwxrwxrwx 1 user user 44 Oct 29 21:44 shortcut_to_document.txt -> /home/alexr/documents/important_document.txt

如我们之前讨论的,字符串lrwxrwxrwx中的第一个字母表示此文件是一个符号链接。

解除链接文件 – 伟大的逃脱表演

删除文件就像是一个有戏剧性退出风格的魔术师。你有一个已经超时的文件,你希望它在一阵烟雾中消失。所以,你拿起你的魔术师的魔杖(unlink 命令),一挥手腕,大喊,“阿布拉卡达布拉,呼呼,消失吧!” 就这样,文件就像空气一样消失了。这是计算世界中消失得无影无踪的终极表演,不留任何痕迹。现在,如果你能对你的洗衣物也这样做就好了!

但记住,就像魔法一样,删除文件可以是强大的,所以要明智地使用它。你不想不小心让你的重要文件消失在数字虚空中!

现在,假设你想执行伟大的消失魔法,并删除你之前创建的符号链接。你可以使用 unlink 命令(或用于删除常规文件的 rm):

unlink /home/user/desktop/shortcut_to_document.txt

rm 的用法如下:

rm /home/user/desktop/shortcut_to_document.txt

下面是发生的事情:

  • unlinkrm:这些命令用于删除文件

  • /home/user/desktop/shortcut_to_document.txt:这是你要删除的符号链接(或文件)的路径

我们可以使用 os 包中的 Remove 函数达到相同的效果:

package main
import (
     "fmt"
     "os"
)
func main() {
     // Define the path to the file or symlink you want to remove.
     filePath := "/path/to/your/file-or-symlink.txt"
     // Attempt to remove the file.
     err := os.Remove(filePath)
     if err != nil {
          fmt.Printf("Error removing the file: %v\n", err)
          return
     }
     fmt.Printf("File removed: %s\n", filePath)
}

当我们执行这个程序时,符号链接就像魔法一样消失了!然而,重要的是要注意,如果你使用了 os.Remove 来删除链接,它不会影响链接指向的文件。它只是删除了快捷方式。

让我们创建一个命令行界面(CLI)来检查符号链接是否悬空;换句话说,它指向的文件已经不再存在。

我们可以像在最后一个 CLI 应用程序中做的那样做所有的事情,只需做几个改动:

for _, directory := range directories {
    err := filepath.Walk(directory, func(path string, info os.FileInfo, err error) error {
      if err != nil {
        fmt.Fprintf(cfg.ErrStream, "Error accessing path %s: %v\n", path, err)
        return nil
      }
      // Check if the current file is a symbolic link.
      if info.Mode()&os.ModeSymlink != 0 {
        // Resolve the symbolic link.
        target, err := os.Readlink(path)
        if err != nil {
          fmt.Fprintf(cfg.ErrStream, "Error reading symlink %s: %v\n", path, err)
        } else {
          // Check if the target of the symlink exists.
          _, err := os.Stat(target)
          if err != nil {
            if os.IsNotExist(err) {
              fmt.Fprintf(outputWriter, "Broken symlink found: %s -> %s\n", path, target)
            } else {
              fmt.Fprintf(cfg.ErrStream, "Error checking symlink target %s: %v\n", target, err)
            }
          }
        }
      }
    })
    if err != nil {
      fmt.Fprintf(cfg.ErrStream, "Error walking directory %s: %v\n", directory, err)
    }
  }

让我们分解最重要的部分:

  • if info.Mode()&os.ModeSymlink != 0 { ... }: 这检查当前文件是否是符号链接。如果是,它进入这个块来解析和检查符号链接的有效性。

  • target, err := os.Readlink(path): 这尝试使用 os.Readlink 读取符号链接的目标。如果发生错误,它将打印一条错误消息,表明读取符号链接失败。

  • 它通过使用 os.Stat(target) 来检查符号链接的目标是否存在。如果在检查过程中发生错误,它将区分不同类型的错误:

  • 如果错误表明目标不存在(os.IsNotExist(err)),它将打印一条消息,表明存在断开的符号链接。

  • 如果错误是其他类型,它将打印一条错误消息,表明检查符号链接目标失败。

简而言之,linkunlink 是 UNIX 文件系统世界的社交协调者。link 通过给文件添加一个新名称来帮助建立新的关联,而 unlink 则将文件送入删除的遗忘之地。它们可能看起来像是同一枚硬币的两面,但 unlink 是对 link 欢乐配对的残酷现实检查。

计算目录大小

最常见的事情之一是检查目录的大小。我们如何使用我们所有的 Go 知识来完成它?我们首先需要创建一个函数来计算目录的大小:

func calculateDirSize(path string) (int64, error) {
  var size int64
  err := filepath.Walk(path, func(filePath string, fileInfo os.FileInfo, err error) error {
    if err != nil {
      return err
    }
    if !fileInfo.IsDir() {
      size += fileInfo.Size()
    }
    return nil
  })
  if err != nil {
    return 0, err
  }
  return size, nil
}

这个函数计算给定目录及其子目录中所有文件的总大小。让我们了解这个函数是如何工作的:

  • func calculateDirSize(path string) (int64, error):这个函数接受一个参数 path,它是你想要计算大小的目录的路径。它返回两个值:一个表示字节数的 int64 值和一个表示在计算过程中是否发生错误的 error 值。

  • 它使用 filepath.Walk 函数从指定的路径开始遍历目录树。在遍历过程中遇到的每个文件或目录,都会调用提供的回调函数。

  • if !fileInfo.IsDir() { size += fileInfo.Size() }:这检查当前项是否不是目录(即,它是一个文件)。如果是文件,它将文件的大小(fileInfo.Size())添加到 size 变量中。这就是它如何累积所有文件的总大小。

  • filepath.Walk 函数完成遍历后,它会检查遍历过程中是否有错误(if err != nil { return 0, err }),如果没有错误,则返回累积的大小。

calculateDirSize 可以作为一个更通用应用程序中不可或缺的部分,在该应用程序中,它被用来计算 directories 切片中列出的各种目录的大小。在这个过程中,这些大小被转换为不同的单位,如字节、千字节、兆字节或吉字节,提供更易于阅读的表示。然后,这些结果通过输出流呈现给用户。

下面是如何在应用程序的更大上下文中应用这个函数的一个快照:

  m := map[string]int64{}
  for _, directory := range directories {
    dirSize, err := calculateDirSize(directory)
    if err != nil {
      fmt.Fprintf(cfg.ErrStream, "Error calculating size of %s: %v\n", directory, err)
      continue
    }
    // Convert to MB
    m[directory] = dirSize
  }
  for dir, size := range m {
    var unit string
    switch {
    case size < 1024:
      unit = "B"
    case size < 1024*1024:
      size /= 1024
      unit = "KB"
    case size < 1024*1024*1024:
      size /= 1024 * 1024
      unit = "MB"
    default:
      size /= 1024 * 1024 * 1024
      unit = "GB"
    }
    fmt.Fprintf(outputWriter, "%s - %d%s\n", dir, size, unit)
  }

前面的代码计算了 directories 切片中列出的目录的大小,将这些大小转换为不同的单位(字节、千字节、兆字节或吉字节),然后打印结果。

查找重复文件

在数据管理领域,一个常见的挑战是识别和管理重复文件。在我们的例子中,findDuplicateFiles 函数成为了这项任务的优选工具。其目的是直接的:在给定的目录中定位和编目重复文件。让我们来探究这个函数是如何工作的:

func findDuplicateFiles(rootDir string) (map[string][]string, error) {
  duplicates := make(map[string][]string)
  err := filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error {
    if err != nil {
      return err
    }
    if !info.IsDir() {
      hash, err := computeFileHash(path)
      if err != nil {
        return err
      }
      duplicates[hash] = append(duplicates[hash], path)
    }
    return nil
  })
  return duplicates, err
}

我们可以观察到以下关键特性:

  • filepath.Walk:该函数使用 filepath.Walk 来系统地遍历指定目录(rootDir)及其子目录中的所有文件。这种遍历覆盖了文件系统的每一个角落。

  • 文件哈希:为了识别重复文件,每个文件都会进行哈希处理。这个哈希过程将文件内容转换为唯一的哈希值。相同的文件将产生相同的哈希值,这使得识别变得容易。

  • duplicates用于跟踪重复文件。该映射将每个唯一的哈希值与具有相同哈希值的文件路径数组关联起来。具有不同哈希值的文件不被视为重复文件。

为了在实际中应用这个函数,让我们利用它来扫描多个目录以查找重复文件。以下是过程的概述:

for _, directory := range directories {
  duplicates, err := findDuplicateFiles(directory)
  if err != nil {
    fmt.Fprintf(cfg.ErrStream, "Error finding duplicate files: %v\n", err)
    continue
  }
  // Display Duplicate Files
  for hash, files := range duplicates {
    if len(files) > 1 {
      fmt.Printf("Duplicate Hash: %s\n", hash)
      for _, file := range files {
        fmt.Fprintln(outputWriter, "  -", file)
      }
    }
  }
}

findDuplicateFiles函数递归地探索目录及其子目录,对非目录文件进行哈希处理,并根据它们的哈希值将它们组织成组。这允许在指定的目录结构中有效地识别重复文件。

这是computeFileHash函数的代码:

func computeFileHash(filePath string) (string, error) {
  // Attempt to open the file for reading
  file, err := os.Open(filePath)
  if err != nil {
    return "", err
  }
  // Ensure that the file is closed when the function exits
  defer file.Close()
  // Create an MD5 hash object
  hash := md5.New()
  // Copy the contents of the file into the hash object
  if _, err := io.Copy(hash, file); err != nil {
    return "", err
  }
  // Generate a hexadecimal representation of the MD5 hash and return it
  return fmt.Sprintf("%x", hash.Sum(nil)), nil
}

computeFileHash函数打开一个文件,计算其内容的 MD5 哈希值,将哈希值转换为十六进制字符串,并返回它。这个函数对于生成文件的唯一标识符(哈希值)非常有用,可用于各种目的,包括识别重复文件、验证数据完整性或根据内容索引文件。在最后一节中,我们将探讨在处理文件时的高级优化。

优化文件系统操作

系统编程在优化文件操作时经常面临挑战,尤其是在处理超出可用内存容量的数据时。解决这个问题的一个有效方法是使用内存映射文件(mmap),如果正确使用,可以显著提高文件操作的效率。

内存映射文件(mmap)提供了一种解决此问题的可行方法。通过直接将文件映射到内存中,mmap 简化了与文件一起工作的过程。本质上,操作系统管理磁盘写入,而程序与内存中的数据交互。

Go 编程语言的一个简单示例演示了 mmap 如何有效地处理文件操作,即使处理大文件时也是如此。

首先,我们需要打开一个大文件:

  filePath := "example.txt"
  file, err := os.OpenFile(filePath, os.O_RDWR|os.O_CREATE, 0644)
  if err != nil {
    fmt.Printf("Failed to open file: %v\n", err)
    return
  }
  defer file.Close()

接下来,我们应该读取文件的元数据以使用mmap系统调用:

  fileInfo, err := file.Stat()
  if err != nil {
    fmt.Printf("Failed to get file info: %v\n", err)
    return
  }
  fileSize := fileInfo.Size()

现在我们可以使用内存映射:

data, err := syscall.Mmap(int(file.Fd()), 0, int(fileSize), syscall.PROT_READ|syscall.PROT_WRITE, syscall.MAP_SHARED)
  if err != nil {
    fmt.Printf("Failed to mmap file: %v\n", err)
    return
  }
  defer syscall.Munmap(data)

让我们从前面的代码块中取出以下一行:

data, err := syscall.Mmap(int(file.Fd()), 0, int(fileSize), syscall.PROT_READ|syscall.PROT_WRITE, syscall.MAP_SHARED). 这段代码有两个主要需要注意的区域:

  • syscall.Mmap用于将文件映射到内存中。它接受以下参数:

  • int(file.Fd()):这从文件对象中提取文件描述符(表示打开文件的整数)。file.Fd()方法返回文件描述符。

  • 0:这表示映射应开始的文件内的偏移量。在这种情况下,它从文件开始(偏移量0)。int(fileSize):映射的长度,指定为表示文件大小的整数(fileSize)。这决定了将映射到内存中的文件部分。

  • syscall.PROT_READ|syscall.PROT_WRITE:这设置了映射内存的保护模式。PROT_READ允许读取访问,而PROT_WRITE允许写入访问。

  • syscall.MAP_SHARED:这指定了映射的内存被多个进程共享。对内存的更改将在文件中反映出来,反之亦然。

  • defer syscall.Munmap(data)

    • 假设内存映射操作成功(即没有发生错误),这个defer语句安排在周围函数返回时调用syscall.Munmap函数。

    • syscall.Munmap用于取消映射之前使用syscall.Mmap映射的内存区域。它确保在不再需要映射的内存时,映射的内存被正确释放。

一旦数据被内存映射,我们就可以修改数据:

  fmt.Printf("Initial content: %s\n", string(data))
  // Modify the content in memory
  newContent := []byte("Hello, mmap!")
  copy(data, newContent)
  fmt.Println("Content updated successfully.")

在拥有这些知识的情况下,我们可以毫无顾虑地与大型文件进行交互,无需担心内存的可用性。

内存不足安全性

需要注意的是,对于 mmap 来说,使用基于文件的映射是合适的选择,而不是匿名映射。如果你打算修改映射的内存并将这些更改写回文件,那么就需要一个共享映射。在 64 位环境中,使用基于文件的共享映射可以减轻对内存不足(OOM)杀手的担忧。即使在非 64 位环境中,问题也会与地址空间限制有关,而不是 RAM 限制,因此 OOM 杀手不会成为问题;相反,你的 mmap 操作将简单地优雅失败。

概述

恭喜你完成第四章!在本章中,我们探讨了 Go 中的文件和目录操作。我们涵盖了从识别不安全文件和目录权限到优化文件系统操作的基本主题。

随着本章的结束,你现在在 Go 中处理文件和目录方面有了坚实的基础,拥有了构建安全高效文件相关应用程序的知识和技能。你不仅学到了理论,还学到了可以直接应用于你项目的实际编码技巧。

在接下来的章节中,我们将进一步探讨系统编程概念,涵盖进程间通信。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐