<Go>8 goroutine 和 channel
本文最后更新于:2023年6月27日 上午
8 goroutine 和 channel
8.1 单元测试
传统的测试方法需要在 main 函数中调用,每次要修改 main 函数,很不方便。
测试多个模块时都要写在 main 函数,也不便于管理。
Golang 中带有一个轻量级的测试框架 testing 和自带的 go test 命令来实现单元测试和性能测试。
testing 框架可以基于该框架编写针对相应函数的测试用例,也能基于该框架写相应压力测试用例。
通过单元测试,能解决以下问题:
- 确保每个函数可运行,且运行结果正确
- 确保写出来的代码是好的
- 及时发现程序设计或实现的逻辑错误,使问题及早暴露。性能测试重点在于发现程序设计上的一些问题,让程序在高并发状态下也能稳定运行。
#单元测试流程
-
原本的文件,其中包含待测试的函数
example
package main func act(n int) int { // 待测试函数 return -n }
-
创建 xxx_test.go 测试用例文件,放在与被测试函数相同的包中。该文件包含 TestXxx 测试用例函数。
example_test.go
package main import ( "fmt" "testing" // 导入 testing 包 ) func TestAct(t *testing.T) { // 测试用例函数 if n := act(10); n != -10 { t.Fatalf("wrong, expect 10, get %v", n) } else { t.Logf("correct") } } func TestHello(t *testing.T) { fmt.Println("★") }
在 test 包下,提供了自动化测试的函数支持:
-
func (t *testing.T) Logf(format string, args ...interface{})
输出一段测试信息
-
func (t *testing.T) FailNow()
将当前测试标识为失败并停止执行该测试。之后,继续进行下一测试
-
func (t *testing.T) Fatalf(format string, args ...interface{})
相当于调用 Logf 后调用 FailNow
-
-
使用
go test
指令,将所有 xxx_test.go 文件引入,并自动执行所有 TestXxx 的函数******> go test [-v] [xxx.go xxx_test.go] [-test.run TestXxx]
[ ]
是可选项。其中:-
-v
:无论运行正确还是错误,都输出日志。否则,仅发生错误时输出日志 -
xxx.go xxx_test.go
:要测试单个测试用例文件时,将那些文件名写在后面 -
-test.run TestXxx
:只测试指定的测试用例函数
-
-
最后,输出
******> go test -v === RUN TestAct test_test.go:12: correct --- PASS: TestAct (0.00s) === RUN TestHello ★ --- PASS: TestHello (0.00s) PASS ok ******/main 0.040s
其中 PASS 表示测试用例运行成功,FAIL 表示测试用例运行失败
#注意事项
- 测试用例的文件名必须是
xxx_test.go
格式。即文件名以_test.go
结尾 - 测试用例函数名是
TestXxx
格式。其中Xxx
部分必须大写开头,一般是被测试的函数名 - 一个测试用例文件中,可以有多个测试用例函数。那些测试用例函数会被全部调用
- 测试用例函数不需要放在 main 函数中,但也能被执行。这就是测试用例的方便之处
8.2 goroutine 协程
进程:程序在操作系统中的一次执行过程。进程是系统分配资源和调度的基本单位
线程:进程的一个执行实例,是程序执行的最小单元,是比进程更小的能独立运行的基本单位
一个进程可以创建或销毁多个线程。一个进程中的多个线程可以并发执行。
一个程序至少有一个进程。一个进程至少有一个线程
并发:多线程程序在单核上运行。各个线程会被轮流执行,同一时间只有一个线程执行。
并行:多线程程序在多核上运行。各个线程在不同 CPU 上同时执行
#Go 协程和主线程
Go 的主线程(进程/线程)上,可以起多个协程。协程是轻量级的线程
主线程是一个物理线程,是重量级的,非常耗费 CPU 资源
协程是从主线程开启的,是轻量级线程,是逻辑态,资源消耗小
Go 的协程机制是重要的特点,可以轻松开启上万个协程。这也是 Go 语言在并发上的优势
Go 协程的特点:
- 有独立栈空间
- 共享程序堆空间
- 调度由用户控制
- 协程是轻量级的线程
示例:
func act(str string) {
for i := 0; i < 100; i++ {
fmt.Println(str, i)
}
}
func main() {
go act("goroutine") // 该函数在协程中运行
act("main") // 该函数在主线程中运行
}
上述代码的执行流程示意图如下:
graph LR
A[程序开始] --> B(主线程开启协程) --继续执行--> C(主线程调用函数) --> d[程序结束]
B -.协程.-> BB(协程调用函数)
主线程和协程同时进行。
-
如果主线程结束了,那么即使协程还未执行完毕,则协程也会退出。但协程也能在主线程结束前自行结束(如完成任务)
-
在协程中出现 panic 而未处理时,会导致整个程序崩溃。
在 goroutine 中使用 recover,解决协程中出现的 panic
#MPG 模式
M:操作系统的主线程(物理线程)
P:协程需要的上下文环境
G:协程
☆
#设置 Go 运行的 CPU 数
为了充分利用多 CPU 的优势,在 Go 语言中,可以设置运行 CPU 数量
在 runtime 包中包含了 Go 运行环境的互操作。
-
func NumCPU() int
:返回本地机器的逻辑 CPU 的个数 -
func GOMAXPROCS(n int) int
:设置可同时执行的最大 CPU 数,返回之前的设置在 Go 1.8 后,默认让程序在多核上运行。Go 1.8 前还需要手动设置
8.3 channel 管道
使用 goroutine 效率较高,但可能出现 并发/并行安全问题。在编译时增加 -race
就能发现资源竞争问题。
要解决不同 goroutine 间的通信问题,有两种方法:
8.3.1 线程加锁
同一资源同一时间只能被一个协程访问,资源被占用时会被锁定。访问锁定资源的协程会阻塞并被移入等待队列。直到锁定解除时,再让等待队列中的协程依次出列,并访问该资源。
实现方法:全局变量互斥锁
import (
"sync"
"fmt"
)
var (
lock sync.Mutex // lock 是变量名,类型是 sync.Mutex
)
func act(str string) {
lock.Lock() // 加锁,直到全部输出完毕
for i := 0; i < 10; i++ {
fmt.Println(str, i)
}
lock.Unlock() // 解锁
}
func main() {
go act("goroutine")
go act("A")
go act("?????")
time.Sleep(10)
act("main") // 因为互斥锁的存在,输出字符串时不会被插队
}
sync 包提供了基本的同步单元,如互斥锁,适用于低水平程序线程
sync.Mutex 是一个互斥锁,可以创建为其他结构体的字段。其零值为未上锁状态
-
func (m *Mutex) Lock()
:将 Mutex 变为锁定状态已加锁时,会阻塞,直到 m 解锁
-
func (m *Mutex) Unlock()
:将 Mutex 解锁未加锁时会产生错误
8.3.2 channel 的使用
线程加锁的方法仍有缺陷。协程的完成任务的时间难以确定,也不利于多协程对全部变量的读写操作。
所以就要使用 channel 啦
channel 的本质是一个队列,数据先进先出。channel 是数据安全的,不需要加锁。
channel 是有类型的,一个 string 类型的 channel 只能存放 string 类型数据
#声明/定义 channel:
var c1 chan int // 一个存放 int 的 channel
c2 := make(chan map[int]int, 2)
channel 是引用类型。channel 需初始化后(make 后)才能写入数据
channel 中只能存放那个指定的数据类型
不过,也可以试试这个
cc := make(chan interface{}, 0)
因为任何类型都实现了空接口,所以这个 channel 存放的是任意类型的数据。但使用数据时可能需要类型断言。
#加入数据:
c := make(chan int, 3) // 初始化
c <- 1 // 写入一个数据
c <- 5 << 2 // 写入一个数据(表达式 5 << 2)
fmt.Println(len(c), cap(c)) // 2 3
c <- -1
边写边读的场合,写入数据而超过最大容量时,那个写入会阻塞,直到有数据取出。
没有协程读取数据的场合,给管道写入数据超过那个最大容量时,就会报错
#取出数据:
n := <-c // 取出一个数据
fmt.Println(n, <-c) // 1 20
<-c // 仅取出数据而不接收
fmt.Println(len(c), cap(c)) // 0 3
在没有协程的情况下,向空管道索取数据会报错(deadlock)
#关闭 channel:
使用内置函数 close 以关闭 channel。关闭后,该 channel 不再接收数据,但仍能取出数据
c3 := make(chan int, 3)
c3 <- 1
close(c3) // 关闭了 channel
<-c3 // 仍能取出数据
c3 <- 1 // 此处会报错,因为管道已经关闭 ☆
#遍历 channel:
channel 支持 for-range 遍历。那个遍历时没有下标。
在 channel 未关闭前进行遍历,遍历到最后会阻塞,直到 channel 关闭时才完成遍历。没有协程的场合,遍历一个未关闭的 channel 时会报错(deadlock)
c4 := make(chan int, 10)
go func(c chan int) {
for i := 0; i < cap(c); i++ {
c <- i
}
close(c)
}(c4)
for v := range c4 {
fmt.Print(v, " ")
}
#注意事项
-
默认情况下,channel 是双向管道。但也能声明为 只读/只写 的形式。
var ca chan int // 可读可写 var cw chan<- int // 只写 var cr <-chan int // 只读
只读/只写 的管道仍是 chan 类型,只读/只写 只表示一种状态。
在函数的参数声明中,将 chan 声明为 只读/只写,既能避免误操作,也能提升效率。
-
使用 select 可以解决从管道取数据的阻塞问题
cv := make(chan int) cm := make(chan int) ... run := true for run { select { case v := <-cv: // 尝试从 cv 取出数据。未能取出则向后进行 fmt.Println(v) case v := <-cm: // 尝试从 cm 取出数据。未能取出则向后进行 fmt.Println(v) default: run = false } }