<Go>8 goroutine 和 channel

本文最后更新于:2023年6月27日 上午

8 goroutine 和 channel

8.1 单元测试

传统的测试方法需要在 main 函数中调用,每次要修改 main 函数,很不方便。

测试多个模块时都要写在 main 函数,也不便于管理。

Golang 中带有一个轻量级的测试框架 testing 和自带的 go test 命令来实现单元测试和性能测试。

testing 框架可以基于该框架编写针对相应函数的测试用例,也能基于该框架写相应压力测试用例。

通过单元测试,能解决以下问题:

  • 确保每个函数可运行,且运行结果正确
  • 确保写出来的代码是好的
  • 及时发现程序设计或实现的逻辑错误,使问题及早暴露。性能测试重点在于发现程序设计上的一些问题,让程序在高并发状态下也能稳定运行。

#单元测试流程

  1. 原本的文件,其中包含待测试的函数

    example

    package main
    
    func act(n int) int {				// 待测试函数
    	return -n
    }
  2. 创建 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

  3. 使用 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:只测试指定的测试用例函数

  4. 最后,输出

    ******> 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
        }
    }

<Go>8 goroutine 和 channel
https://i-melody.github.io/2022/07/01/Go/8 goroutine 和 channel/
作者
Melody
发布于
2022年7月1日
许可协议