Go语言中的并发与包管理
1. 协程与线程
1.1 可增长的栈
操作系统(OS)线程的栈通常是固定大小的内存块,一般为 2MB。这个栈用于保存正在执行或暂时挂起的函数调用的局部变量。然而,这种固定大小的栈存在着明显的问题:对于一些简单的协程,如仅等待
WaitGroup
然后关闭通道的协程,2MB 的栈会造成巨大的内存浪费。而且,Go 程序常常会同时创建数十万个协程,如果每个协程都使用这么大的栈,显然是不可行的。另一方面,对于一些复杂的递归函数,固定大小的栈又可能不够用。
与之不同的是,协程初始时的栈通常只有 2KB。和 OS 线程的栈一样,协程的栈也用于保存活动和挂起的函数调用的局部变量,但它的大小不是固定的,可以根据需要动态增长和收缩。协程栈的大小上限可达 1GB,比典型的固定大小线程栈大几个数量级,不过实际上很少有协程会用到这么大的栈。
graph LR
A[OS线程栈] -->|固定大小| B[2MB]
C[协程栈] -->|初始小| D[2KB]
C -->|动态调整| E[最大1GB]
1.2 协程调度
OS 线程由操作系统内核调度。每隔几毫秒,硬件定时器会中断处理器,触发内核中的调度器函数。该函数会暂停当前执行的线程,将其寄存器状态保存到内存中,然后遍历线程列表,决定下一个要执行的线程,恢复该线程的寄存器状态,最后继续执行该线程。由于 OS 线程的调度由内核完成,从一个线程切换到另一个线程需要进行完整的上下文切换,包括将一个用户线程的状态保存到内存、恢复另一个线程的状态以及更新调度器的数据结构。这种操作速度较慢,因为它的局部性较差,且需要大量的内存访问。
Go 运行时拥有自己的调度器,采用 m:n 调度技术,即将 m 个协程多路复用到 n 个 OS 线程上。Go 调度器的工作类似于内核调度器,但它只关注单个 Go 程序中的协程。与操作系统的线程调度器不同,Go 调度器不是由硬件定时器定期触发的,而是由某些 Go 语言构造隐式触发。例如,当一个协程调用
time.Sleep
或在通道或互斥操作中阻塞时,调度器会将其挂起,转而运行另一个协程,直到需要唤醒该协程为止。由于不需要切换到内核上下文,重新调度协程的成本比重新调度线程要低得多。
1.3 GOMAXPROCS
Go 调度器使用
GOMAXPROCS
参数来确定可以同时执行 Go 代码的 OS 线程数量。其默认值是机器上的 CPU 数量,因此在一个有 8 个 CPU 的机器上,调度器最多可以同时在 8 个 OS 线程上调度 Go 代码。处于睡眠或通信阻塞状态的协程不需要线程,而在 I/O 或其他系统调用中阻塞或调用非 Go 函数的协程则需要 OS 线程,但
GOMAXPROCS
不需要考虑这些协程。
可以使用
GOMAXPROCS
环境变量或
runtime.GOMAXPROCS
函数来显式控制这个参数。以下是一个简单的程序,用于演示
GOMAXPROCS
的效果:
for {
go fmt.Print(0)
fmt.Print(1)
}
当使用
GOMAXPROCS=1
运行时,输出可能是:
111111111111111111110000000000000000000011111...
当使用
GOMAXPROCS=2
运行时,输出可能是:
010101010101010101011001100101011010010100110...
在第一次运行时,一次最多只有一个协程执行。最初是主协程打印 1,一段时间后,Go 调度器将其挂起,唤醒打印 0 的协程。在第二次运行时,有两个 OS 线程可用,因此两个协程可以同时运行,以大致相同的速率打印数字。
1.4 协程没有身份标识
在大多数支持多线程的操作系统和编程语言中,当前线程有一个明确的身份标识,通常是一个整数或指针。这使得构建线程局部存储(Thread-Local Storage)变得容易,它本质上是一个以线程身份为键的全局映射,每个线程可以独立于其他线程存储和检索值。
然而,协程没有可供程序员访问的身份标识。这是有意设计的,因为线程局部存储容易被滥用。例如,在使用线程局部存储的语言实现的 Web 服务器中,许多函数常常通过该存储来查找当前正在处理的 HTTP 请求的信息。但这可能会导致一种不健康的“远程作用”现象,即函数的行为不仅取决于其参数,还取决于它所在的线程的身份。因此,如果线程身份发生变化,函数可能会出现异常行为。
Go 鼓励采用更简单的编程风格,即明确指定影响函数行为的参数。这样不仅使程序更易于阅读,还可以让我们自由地将一个函数的子任务分配给多个不同的协程,而不必担心它们的身份。
2. 包和 Go 工具
2.1 包系统的目的
包系统的目的是将相关的功能组织成易于理解和修改的单元,从而使大型程序的设计和维护变得可行。这种模块化使得包可以在不同的项目中共享和重用,在组织内部进行分发,或者向更广泛的世界开放。
每个包定义了一个独立的命名空间,其中包含了它的标识符。每个名称都与特定的包相关联,这使得我们可以为最常用的类型、函数等选择简短、清晰的名称,而不会与程序的其他部分产生冲突。
2.2 导入路径
每个包都由一个唯一的字符串标识,称为导入路径。导入路径是在导入声明中出现的字符串。
import (
"fmt"
"math/rand"
"encoding/json"
"golang.org/x/net/html"
"github.com/go-sql-driver/mysql"
)
Go 语言规范并未定义这些字符串的含义或如何确定包的导入路径,而是将这些问题留给了工具。大多数 Go 程序员使用
go
工具进行构建、测试等操作,在本文中,我们将详细了解
go
工具如何解释这些导入路径。不过,还有其他工具存在,例如使用 Google 内部多语言构建系统的 Go 程序员遵循的命名和定位包、指定测试等规则更符合该系统的惯例。
对于打算共享或发布的包,导入路径应该是全局唯一的。为了避免冲突,除标准库之外的所有包的导入路径都应该以拥有或托管该包的组织的互联网域名开头,这样也便于查找包。例如,上述声明中导入了 Go 团队维护的 HTML 解析器和流行的第三方 MySQL 数据库驱动。
2.3 包声明
每个 Go 源文件的开头都必须有一个包声明。其主要目的是确定该包被其他包导入时的默认标识符(即包名)。
例如,
math/rand
包的每个文件都以
package rand
开头,因此当你导入这个包时,可以通过
rand.Int
、
rand.Float64
等方式访问其成员。
package main
import (
"fmt"
"math/rand"
)
func main() {
fmt.Println(rand.Int())
}
通常,包名是导入路径的最后一段。因此,即使两个包的导入路径不同,它们的包名也可能相同。例如,
math/rand
和
crypto/rand
这两个包的包名都为
rand
。
不过,有三个主要的例外情况:
- 定义命令(可执行 Go 程序)的包始终名为
main
,无论该包的导入路径是什么。这是给
go build
的信号,指示它必须调用链接器来生成可执行文件。
- 如果文件名以
_test.go
结尾,目录中的某些文件的包名可能带有
_test
后缀。这样的目录可能定义两个包:通常的包和另一个称为外部测试包。
_test
后缀告诉
go test
必须构建这两个包,并指示哪些文件属于哪个包。
- 一些依赖管理工具会在包导入路径后附加版本号后缀,例如
"gopkg.in/yaml.v2"
,但包名不包含该后缀,在这种情况下包名仅为
yaml
。
2.4 导入声明
Go 源文件可以在包声明之后、第一个非导入声明之前包含零个或多个导入声明。每个导入声明可以指定单个包的导入路径,也可以在括号内列出多个包。以下两种形式是等价的,但第二种形式更常见。
// 单个包导入
import "fmt"
// 多个包导入
import (
"fmt"
"math/rand"
)
通过合理使用包和导入路径,我们可以更好地组织和管理 Go 程序,提高代码的可维护性和可重用性。同时,协程和线程的合理运用也能让我们充分发挥 Go 语言的并发优势。
2.5 包的封装性
包还通过控制哪些名称在包外部可见或导出,来提供封装性。限制包成员的可见性可以隐藏包 API 背后的辅助函数和类型,使包维护者能够放心地更改实现,而不用担心包外部的代码会受到影响。同时,限制可见性还可以隐藏变量,使得客户端只能通过导出的函数来访问和更新这些变量,从而在并发程序中保持内部不变性或强制互斥。
例如,一个包可能有一些内部使用的函数和类型,这些函数和类型以小写字母开头,它们在包外部是不可见的。而以大写字母开头的函数和类型则是导出的,可以被其他包使用。
package mypackage
// 导出的函数
func ExportedFunction() {
// 调用内部函数
internalFunction()
}
// 内部函数,外部不可见
func internalFunction() {
// 函数实现
}
2.6 Go 编译速度优势
当我们修改一个文件时,必须重新编译该文件所在的包,并且可能需要编译所有依赖于它的包。然而,Go 编译速度明显快于大多数其他编译型语言,即使是从头开始构建也是如此。这主要有三个原因:
-
显式导入声明
:所有导入必须在每个源文件的开头显式列出,因此编译器不需要读取和处理整个文件来确定其依赖关系。
-
无循环依赖
:包的依赖关系形成有向无环图,由于没有循环,包可以单独编译,甚至可以并行编译。
-
对象文件记录
:编译后的 Go 包的对象文件不仅记录了该包本身的导出信息,还记录了其依赖项的导出信息。在编译一个包时,编译器只需要为每个导入读取一个对象文件,而不需要查看这些文件之外的内容。
2.7 包的使用示例
下面我们通过一个简单的示例来展示如何使用不同的包:
package main
import (
"fmt"
"math/rand"
"time"
)
func main() {
// 初始化随机数种子
rand.Seed(time.Now().UnixNano())
// 生成随机数
num := rand.Intn(100)
fmt.Printf("生成的随机数是: %d\n", num)
}
在这个示例中,我们导入了
fmt
包用于格式化输出,
math/rand
包用于生成随机数,
time
包用于初始化随机数种子。
3. 相关练习及分析
3.1 管道练习
练习要求构建一个用通道连接任意数量协程的管道,并探究在不耗尽内存的情况下可以创建的最大管道阶段数,以及一个值通过整个管道所需的时间。
graph LR
A[输入值] --> B[协程1]
B --> C[通道1]
C --> D[协程2]
D --> E[通道2]
E --> F[协程3]
F --> G[输出值]
操作步骤如下:
1. 定义一个函数,该函数接收一个输入通道和一个输出通道,用于处理值并将其传递到下一个阶段。
2. 创建多个协程,每个协程调用上述函数,并通过通道连接它们。
3. 记录值进入管道的时间和离开管道的时间,计算时间差。
4. 逐渐增加管道的阶段数,直到出现内存不足的错误。
3.2 协程通信练习
练习要求编写一个包含两个协程的程序,这两个协程通过两个无缓冲通道以乒乓方式来回发送消息,并测量程序每秒可以维持的通信次数。
package main
import (
"fmt"
"time"
)
func main() {
ch1 := make(chan int)
ch2 := make(chan int)
go func() {
for {
ch2 <- <-ch1
}
}()
go func() {
for {
ch1 <- <-ch2
}
}()
start := time.Now()
count := 0
ch1 <- 1
for time.Since(start) < time.Second {
<-ch2
count++
ch1 <- 1
}
fmt.Printf("每秒通信次数: %d\n", count)
}
3.3 GOMAXPROCS 性能测试练习
练习要求测量一个计算密集型并行程序的性能如何随
GOMAXPROCS
变化,并找出计算机上的最优值以及计算机的 CPU 数量。
操作步骤如下:
1. 编写一个计算密集型的并行程序,例如对一个大数组进行并行计算。
2. 使用不同的
GOMAXPROCS
值运行该程序,并记录每次运行的时间。
3. 分析不同
GOMAXPROCS
值下的运行时间,找出最优值。
4. 通过系统命令或编程语言的相关函数获取计算机的 CPU 数量。
4. 总结
Go 语言在并发和包管理方面具有独特的优势。协程与线程在栈大小、调度方式、身份标识等方面存在显著差异,合理利用协程可以充分发挥 Go 语言的并发性能。同时,包系统通过导入路径、包声明、导入声明等机制,提供了良好的模块化和封装性,使得大型程序的开发和维护更加高效。通过完成相关练习,我们可以更深入地理解这些概念,并掌握如何在实际开发中运用它们。在实际编程中,我们应该根据具体需求选择合适的并发模型和包管理方式,以提高程序的性能和可维护性。
超级会员免费看
5万+

被折叠的 条评论
为什么被折叠?



