并发编程
Goroutine
并行和并发
并发:指在一个时间段内,多个程序都是在同一个处理机上运行,但任一个时刻点上只有一个程序在处理机上运行(即多个任务在同一处理机上交替执行)。
并行:指一个处理器同时处理多个任务。多个任务同时执行,每一个CPU运行一个程序。
并发是逻辑上的同时发生(simultaneous),而并行是物理上的同时发生。对单核CPU,因为一个CPU一次只能执行一条指令,是无法做到并行,只能做到并发。
进程、线程和协程
进程:是一个程序在一个数据集中的一次动态执行过程,可以简单理解为“正在执行的程序”,它是系统进行资源分配和调度的基本单位。进程一般由程序、数据集、进程控制块三部分组成。
- 我们编写的程序用来描述进程要完成哪些功能以及如何完成;
- 数据集则是程序在执行过程中所需要使用的资源;
- 进程控制块用来记录进程的外部特征,描述进程的执行变化过程,系统可以利用它来控制和管理进程,它是系统感知进程存在的唯一标志。
进程拥有独立的系统资源,进程间不容易相互影响,相对比较稳定安全;但是进程切换的时空开销比较大,涉及到很多系统资源的切换,进程间通信(IPC)较为复杂和耗时。
线程:是进程中一个单一顺序的控制流,它是系统进行运算调度(即如何分配CPU去执行不同任务)的基本单位,一个进程的多个线程在执行不同任务的同时共享进程的系统资源(如虚拟地址空间,文件描述符等)。线程由相关堆栈寄存器和线程控制块组成。
线程切换的开销比进程切换的开销小,减少了任务切换的消耗,提高了操作系统的并发性能;但是线程相比进程不够稳定,多线程在操作共享数据时容易出错(比如丢失数据、产生死锁)。
协程:是一种用户态的轻量级线程,又称"微线程",英文名Coroutine,协程的调度完全由用户控制。协程的执行效率非常高。因为子程序切换不是线程切换,而是由程序自身控制,因此,没有线程切换的开销,和多线程比,线程数量越多,协程的性能优势就越明显,在处理大规模并发连接(IO密集型任务)时,协程要优于线程。协程不需要多线程的锁机制。在协程中控制共享资源不加锁,只需要判断状态就好了。
Go为了提供更容易使用的并发方法,使用了goroutine和channel。goroutine来自协程的概念,让一组可复用的函数运行在一组线程之上,即使有协程阻塞,该线程的其他协程也可以被runtime调度,转移到其他可运行的线程上。Goroutine的主要概念:
- G(Goroutine):Go的协程;
- M(Machine):工作线程(由操作系统调度);
- P(Processor):处理器(Go中概念,不指CPU;包含运行go代码的必要资源,有调度goroutine的能力);个数在程序启动时决定,默认为CPU核数(可通过runtime.GOMAXPROCS()设定)。
M必须拥有P才可执行G中的代码,P含有一个包含多个G的队列,P可以调度G交由M执行:
Channel
Channel是Golang在语言层面提供的goroutine间的通信方式,主要用于进程内各goroutine间通信。channel由队列、类型信息、goroutine等待队列组成 从channel读数据,如果channel缓冲区为空或者没有缓冲区,当前goroutine会被阻塞;向channel写数据,如果channel缓冲区已满或者没有缓冲区,当前goroutine会被阻塞。
被阻塞的goroutine将会挂在channel的等待队列中:
- 因读阻塞的goroutine会被向channel写入数据的goroutine唤醒;
- 因写阻塞的goroutine会被从channel读数据的goroutine唤醒;
一个channel同时仅允许被一个goroutine读写。
Sync
sync包提供了一些用于同步和并发控制的工具,包括互斥锁、等待组等。
互斥锁(Mutex)是最常用的同步机制,用于保护临界区,防止多个goroutine同时访问共享资源,保证数据的一致性。 使用sync.Mutex声明一个互斥锁变量,然后通过Lock方法获取锁,使用defer延迟解锁,确保即使发生异常,也能释放锁。
var mutex sync.Mutex
func someFunc() {
mutex.Lock()
defer mutex.Unlock()
// 临界区代码
}
等待组(WaitGroup)用于等待一组goroutine的结束,常用于主goroutine等待其他goroutine完成任务。使用WaitGroup的Add方法添加要等待的goroutine数量。在每个goroutine结束时,使用Done方法减少等待组计数。最后使用Wait方法等待所有goroutine结束。
var wg sync.WaitGroup
func main() {
wg.Add(2) // 添加要等待的goroutine数量
go goroutine1()
go goroutine2()
wg.Wait() // 等待所有goroutine结束
// 执行其他操作
}
func goroutine1() {
defer wg.Done() // goroutine结束时减少等待组计数
// 执行任务
}
func goroutine2() {
defer wg.Done() // goroutine结束时减少等待组计数
// 执行任务
}
测试
单元测试
单元测试是指对软件中最小可测试单元进行检查和验证。主要测试内容为:边界测试、错误处理测试、路径测试、局部数据结构测试和模块接口测试。单元测试的测试重点:
- 模块接囗:数据能否正确进出,检查参数的数目、次序、属性,全局变量的定义和厍法在各个模块中是否一致
- 局部数据结构:局部数据说明、初始化、默认值等方面的错误
- 重要执行通路:选择具有代表性、最可能发现错误的执行通路进行测试
- 出错处理通路:应该能预见出错的条件,并且设置适当的处理错误的通路
- 边界条件:对于刚好小于、等于大于最大值或小于最小值的数据结构、控制量和数据值进行测试
在go语言中自带有一个轻量级的测试框架testing和自带的go test命令来实现单元测试和性能测试,testing框架和其他语言中的测试框架类似,可以基于这个框架写针对相应函数的测试用例,也可以基于该框架写相应的压力测试用例,通过单元测试,可以解决如下问题:
1、确保每个函数是可运行的,并且运行结果是正确的
2、确保写出来的代码性能是好的
3、单元测试能及时的发现程序设计或实现的逻辑错误,是问题及早暴露,便于问题的定位解决,而性能测试的重点在于发现程序设计上的一些问题,让程序 能够在高并发的情况下还能够保持稳定。
单元测试的一些细节:
-
测试用例文件名必须以 _test.go结尾。比如 cal_test.go , cal不是固定的。
-
测试用例函数必须以Test开头,一般来说就是Test+被测试的函数名,比如TestAddUpper。
-
TestAddUpper(t *tesing.T)的形式类型必须是testing.T。
-
一个测试用例文件中,可以有多个测试用例函数,比如TestAddUpper、TestSub。
-
运行测试用例指令:
(1) cmd>go test [如果运行正确,无日志,错误时,会输出日志]
(2) cmd>go test -v [运行正确或是错误,都输出日志] -
当出现错误时,可以使用t.Fatalf来格式化输出错误信息,并退出程序。
-
t.Logf方法可以输出相应的日志。
Mock测试
Mock 测试就是在测试活动中,对于某些不容易构造或者不容易获取的数据/场景,用一个Mock对象来创建以便测试的测试方法。
Gomokey通过在运行时通过汇编语句重写可执行文件,将待打桩函数或方法的实现跳转到桩实现,原理和热补丁类似。通过 Monkey,我们可以解决函数或方法的打桩问题,但 Monkey 不是线程安全的,不要将 Monkey 用于并发的测试中。
gomonkey的常用方法:
func ApplyFunc(target, double interface{}) *Patches //为一个函数打桩
func ApplyMethod(target reflect.Type, methodName string, double interface{}) *Patches //为一个成员方法打桩
//为成员方法和函数打一个桩序列,每次mock返回指定的不同值
func ApplyMethodSeq(target reflect.Type, methodName string, outputs []OutputCell) *Patches
func ApplyFuncSeq(target interface{}, outputs []OutputCell) *Patches
//mock函数变量和全局变量
func ApplyFuncVar(target, double interface{}) *Patches //函数变量
func ApplyGlobalVar(target, double interface{}) *Patches //全局变量
基准测试
基准测试属于性能测试的一种,用于评估和衡量软件的性能指标。我们可以在软件开发的某个阶段通过基准测试建立一个已知的性能水平,称为"基准线"。当系统的软硬件环境发生变化之后再进行一次基准测试以确定那些变化对性能的影响。这是基准测试最常见的用途。
Golang 中的性能基准测试是使用标准库 testing 来实现的,在测试文件中,编写一个以 Benchmark 为前缀的函数,后面跟上一个或多个字符或字符组合来标识测试用例的名称(一般使用被测的函数名称),参数必须是 b *testing.B。
func BenchmarkValid(b *testing.B) {
str := `{"foo":"bar"}`
b.ResetTimer()
for i := 0; i < b.N; i++ {
Valid([]byte(str))
}
}
b.N是基准测试框架提供的,表示循环的次数,因为需要反复调用测试代码来评估性能。b.N 的值会以1, 2, 5, 10, 20, 50, …这样的规律递增下去直到运行时间大于1秒钟,由于程序判断运行时间稳定才会停止运行,所以不能在loop循环里面使用一个变化的值作为函数的参数。