less is exponentially more.

-Rob Pike,Go Designer.

1.变量:

Go语言是静态类型语言,因此变量(variable)是有明确类型的,编译器也会检查变量类型的正确性。在数学概念中,变量表示没有固定值且可改变的数。但从计算机系统实现角度来看,变量是一段或多段用来存储数据的内存

1.1 基本类型:

  • bool

  • string

  • int、int8、int16、int32、int64

  • uint、uint8、uint16、uint32、uint64、uintptr

  • byte // uint8 的别名

  • rune // int32 的别名 代表一个 Unicode 码

  • float32、float64

  • complex64、complex128

  • 指针

所有的内存在 Go 中都是经过初始化的。当一个变量被声明之后,系统自动赋予它该类型的零值.

  • int 为 0,

  • float 为 0.0,

  • bool 为 false,

  • string 为空字符串,

  • 指针为 nil

1.2 声明:

声明变量的一般形式是使用 var 关键字:

var 变量名 变量类型
var name type

Go语言和许多编程语言不同,它在声明变量时将变量的类型放在变量的名称之后。

这样做的好处就是可以避免像C语言中那样含糊不清的声明形式,例如:int* a, b; 。其中只有 a 是指针而 b 不是。如果你想要这两个变量都是指针,则需要将它们分开书写。而在 Go 中,则可以和轻松地将它们都声明为指针类型:

var a, b *int

使用关键字 var 和括号,可以将一组变量定义放在一起。

var (
    a int
    b string
    c []float32  // 数组元素都是float32
    d func() bool // 定义函数类型返回值是bool类型
    e struct {    // 定义对象属性类型
        x int
    }
)

可以在变量声明时赋予变量一个初始值。

var 变量名 类型 = 表达式
var student-name string = "xiaozhang"

var声明的变量可以赋值时由编译器自动的推导类型:

var attack = 40
var defence = 20
/*
右值为整型,attack 和 defence 变量的类型为 int。
*/

var 形式的声明语句往往是用于需要显式指定变量类型地方,或者因为变量稍后会被重新赋值而初始值无关紧要的地方。

1.2.1 简短模式:

名字 := 表达式

编译器会自动根据右值类型推断出左值的对应类型。

该赋值方式有以下限制:

  • 定义变量,同时显式初始化。

  • 不能提供数据类型。

  • 只能用在函数内部。

由于使用了:=,而不是赋值的=,因此推导声明写法的左值变量必须是没有定义过的变量。若定义过,将会发生编译错误。

和 var 形式声明语句一样,简短变量声明语句也可以用来声明和初始化一组变量:

i, j := 0, 1

多重赋值时,变量的左值和右值按从左到右的顺序赋值。

因为简洁和灵活的特点,简短变量声明被广泛用于大部分的局部变量的声明和初始化。

1.3 匿名变量:

在编码过程中,可能会遇到没有名称的变量、类型或方法。虽然这不是必须的,但有时候这样做可以极大地增强代码的灵活性,这些变量被统称为匿名变量。

匿名变量的特点是一个下画线本身就是一个特殊的标识符,被称为空白标识符。它可以像其他标识符那样用于变量的声明或赋值(任何类型都可以赋值给它),但任何赋给这个标识符的值都将被抛弃,因此这些值不能在后续的代码中使用,也不可以使用这个标识符作为变量对其它变量进行赋值或运算。

使用匿名变量时,只需要在变量声明的地方使用下画线替换即可。

 a, _ := GetData()

匿名变量不占用内存空间,不会分配内存。匿名变量与匿名变量之间也不会因为多次声明而无法使用。 匿名变量可以调用返回多个值的函数时值获取一个值

1.4 作用域:

Go语言会在编译时检查每个变量是否使用过,一旦出现未使用的变量,就会报编译错误.

根据变量定义位置的不同,可以分为以下三个类型:

  • 函数内定义的变量称为局部变量

  • 函数外定义的变量称为全局变量

  • 函数定义中的变量称为形式参数

1.4.1 局部变量:

在函数体内声明的变量称之为局部变量,它们的作用域只在函数体内,函数的参数和返回值变量都属于局部变量。

局部变量不是一直存在的,它只在定义它的函数被调用后存在,函数调用结束后这个局部变量就会被销毁。

1.4.2 全局变量:

在函数体外声明的变量称之为全局变量,全局变量只需要在一个源文件中定义,就可以在所有源文件中使用。

全局变量声明必须以 var 关键字开头,如果想要在外部包中使用全局变量的首字母必须大写。

全局变量与局部变量名称可以相同,但是函数体内的局部变量会被优先考虑。

1.4.3 形式参数:

在定义函数时函数名后面括号中的变量叫做形式参数(简称形参)。

形式参数只在函数调用时才会生效,函数调用结束后就会被销毁,在函数未被调用时,函数的形参并不占用实际的存储单元,也没有实际值。

形式参数会作为函数的局部变量来使用。

2.分支与循环:

2.1 分支结构:

2.1.1. if语句:

if condition {
    // 如果条件为真,则执行这里的代码块
} else if anotherCondition {
    // 如果上述条件为假且这个条件为真,则执行这里的代码块
} else {
    // 如果所有条件都为假,则执行这里的代码块
}

2.1.2. switch语句:

switch expression {
case value1:
    // 如果表达式的值等于value1,则执行这里的代码块
case value2:
    // 如果表达式的值等于value2,则执行这里的代码块
default:
    // 如果表达式的值不等于任何一个case,则执行这里的代码块
}

2.2 循环结构:

2.2.1. for循环:

2.2.1.1 基本for循环:

for initialization; condition; increment {
    // 只要条件为真,就重复执行这里的代码块
}

2.2.1.2 类似于while的for循环:

for condition {
    // 只要条件为真,就重复执行这里的代码块
}

2.2.1.3 无限循环:

goCopy code
for {
    // 无限循环,除非遇到break语句或者程序终止
}

2.2.2. range循环:

for index, value := range arrayOrSlice {
    // 在数组或切片的每个元素上迭代,index为索引,value为元素的值
}

2.2.3. 循环控制语句:

2.2.3.1 break语句:

for {
    // 无限循环,除非遇到break语句或者程序终止
    if condition {
        break // 当条件为真时,退出循环
    }
}

2.2.3.2 continue语句:

for i := 0; i < 5; i++ {
    if i == 3 {
        continue // 当i等于3时,跳过本次循环中剩余的代码,开始下一次循环
    }
    // 执行循环体内的其他代码
}

3.数组与切片:

3.1 数组:

数组是存放在连续内存空间上的相同类型数据的集合。查询简单,增加和删除困难

3.1.1 声明与初始化:

进行声明的时候必须指定长度,可以修改数组元素,但是不可修改数组长度。

var name [len]type
var a [5]int

在声明数组的同时可以进行初始化:

var balance = [5]float32{1000.0, 2.0, 3.4, 7.0, 50.0}

在初始化时,如果数组长度不确定,可以使用 ... 代替数组的长度,编译器会根据元素个数自行推断数组的长度:

var balance = [...]float32{1000.0, 2.0, 3.4, 7.0, 50.0}
balance := [...]float32{1000.0, 2.0, 3.4, 7.0, 50.0}

如果设置了数组的长度,还可以通过指定下标来初始化元素:

//  将索引为 1 和 3 的元素初始化
balance := [5]float32{1:2.0,3:7.0}

注意 不同长度的数组属于不同的类型.

3.1.2 常用方法:

3.1.2.1 元素访问:

数组元素可以通过索引(位置)来读取。格式为数组名后加中括号,中括号中为索引的值。

var salary float32 = balance[9]

3.1.2.2 遍历:

使用range关键字.

 var f [2][3]int = [...][3]int{{1, 2, 3}, {7, 8, 9}}
   for k1, v1 := range f {
       for k2, v2 := range v1 {
           fmt.Printf("(%d,%d)=%d ", k1, k2, v2)
       }
       fmt.Println()
   }

3.1.2.3 获取长度:

内置函数 len 和 cap 都返回数组长度 (元素数量)。

a := [2]int{}
println(len(a), cap(a))

3.1.2.4 函数传递:

在Go语言中数组是一个值类型(value type)。是真真实实的数组,而不是一个指向数组内存起始位置的指针,也不能和同类型的指针进行转化,这一点严重不同于C语言。

所有的值类型变量在赋值和作为参数传递时都将产生一次复制动作。

// 有长度检查, 也为地址传参
func use_array(args [4]int) {
	args[1] = 100 //但是使用还是和C一致,不需要别加"*"操作符
}

func main() {
	var args = [4]int{1, 2, 3, 4}
	use_array(args)
	fmt.Println(args)
}

输出为1.2.3.4

注意:数组做参数时, 需要被检查长度,如果长度不匹配编译时会报错.

如果想修改原本数组的值,则需要传入数组指针:

func printArr(arr *[5]int) {
	arr[0] = 10
	for i, v := range arr {
		fmt.Println(i, v)
	}
}

func main() {
	var arr1 [5]int
	printArr(&arr1)
	fmt.Println(arr1)
	arr2 := [...]int{2, 4, 6, 8, 10}
	printArr(&arr2)
	fmt.Println(arr2)
}

当传递数组时如果数组过大,也会将整个数组复制到函数中,因此会产生很大的性能损耗和内存损耗。

3.2 Slice:

3.2.1 介绍:

数组在声明时必须确定整个数组的容量并且无法在运行期间动态的修改,这导致有些时候的灵活性不足.

因此Go为了解决这种问题,引入了另一种可以在运行时动态增减长度的数据结构,即切片,也可以称其为动态数组,其长度并不固定,我们可以向切片中追加元素,它会在容量不足时自动扩容。

3.2.2 声明与初始化:

共有四种声明方式

3.2.2.1 通过var关键字:

var slicename []type
var test []a

该声明⽅式中未初始化的切⽚为空切⽚。默认为 nil,⻓度为 0。

3.2.2.2 使用字面量:

// 初始化方式2:使用字面量
slice2 := []int{1, 2, 3, 4}

当您使用字符串文字创建切片时,它首先创建一个数组,然后返回对其的切片引用。

3.2.2.3 使用make关键字:

var slice1 []type = make([]type, len, cap)
//type 表示切片的元素类型
//len 表示切片中元素的数量
//cap 表示切片的最大容量

可进行缩写:
var slice1 []type = make([]type, len)
slice1 := make([]type, len)

3.2.2.4 截取数组:

mufeng := [5]int{1, 2, 3, 4, 5}

//切片中包含数组中所有的数据
s := mufeng[:]

//切片中包含部分数据,表示从startIndex到endIndex,索引,前闭后开.
s := mufeng[startIndex:endIndex]
s1 := mufeng[2:4]  //3,5

3.2.3 常用方法:

3.2.3.1 添加元素:append():

var mufeng []int
append(mufeng, 1)														 //追加一个元素1
mufeng = append(mufeng, 1)                   //追加一个元素2
mufeng1 := append(mufeng, 2, 3, 4)           //追加多个元素
mufeng2 := append(mufeng, []int{1, 2, 3}...) //追加一个切片

在追加元素时,中间代码生成阶段的cmd/compile/internal/gc.state.append方法会根据返回值是否会覆盖原变量,选择进入两种流程。

当不覆盖原变量时:

// append(slice, 1, 2, 3)
//把结构体的值解构,赋值给三个变量。
ptr, len, cap := slice
//修改len的大小
newlen := len + 3
//如果超出最大容量,那么就触发扩容操作
if newlen > cap {
    ptr, len, cap = growslice(slice, newlen)
    newlen = len + 3
}
//否则重新赋值
*(ptr+len) = 1
*(ptr+len+1) = 2
*(ptr+len+2) = 3
//返回一个新切片
return makeslice(ptr, newlen, cap)

当覆盖原变量时:

a := &slice
ptr, len, cap := slice
newlen := len + 3
if uint(newlen) > uint(cap) {
   newptr, len, newcap = growslice(slice, newlen)
   vardef(a)
   *a.cap = newcap
   *a.ptr = newptr
}
newlen = len + 3
*a.len = newlen
*(ptr+len) = 1
*(ptr+len+1) = 2
*(ptr+len+2) = 3

二者最大的区别在于得到的新切片是否会赋值回原变量。如果我们选择覆盖原有的变量,就不需要担心切片发生拷贝影响性能,因为 Go 语言编译器已经对这种常见的情况做出了优化。

看起来第二种带有返回值的方式的性能更好。

3.2.3.2 拷贝元素:

3.2.3.2.1 浅拷贝:

拷贝切片有两种方式。浅拷贝就是只改变引用类型的变量:

func main() {
    slice1 := []int{1, 2, 3, 4, 5}
    fmt.Printf("slice1: %v, %p\n", slice1, slice1)
    slice2 := slice1
    fmt.Printf("slice2: %v, %p\n", slice2, slice2)
}

slice1: [1 2 3 4 5], 0xc00001a120
slice2: [1 2 3 4 5], 0xc00001a120

拷贝的是数据地址,只复制指向的对象的指针,此时新对象和老对象指向的内存地址是一样的,新对象值修改时老对象也会变化

3.2.3.2.2 深拷贝:

实现深拷贝的方式:

  1. copy(slice2, slice1)

  2. 遍历append赋值

拷贝的是数据本身,创造一个新对象,新创建的对象与原对象不共享内存,新创建的对象在内存中开辟一个新的内存地址,新对象值修改时不会影响原对象值。

内置函数copy() 可以将一个数组切片复制到另一个数组切片中,如果加入的两个数组切片不一样大,就会按照其中较小的那个数组切片的元素个数进行复制.

copy( destSlice, srcSlice []T) int

// 其中 srcSlice 为数据来源切片
// destSlice 为复制的目标(也就是将 srcSlice 复制到 destSlice)
// 目标切片必须分配过空间且足够承载复制的元素个数,并且来源和目标的类型必须一致
// copy() 函数的返回值表示实际发生复制的元素个数。

无论是编译期间拷贝还是运行时拷贝,两种拷贝方式都会通过runtime.memmove将整块内存的内容拷贝到目标的内存区域中。

image-20240218152908042

整块拷贝内存仍然会占用非常多的资源,在大切片上执行拷贝操作时一定要注意对性能的影响。

3.2.3.3 删除元素:

Go语言并没有对删除切片元素提供专用的语法或者接口,需要使用切片本身的特性来删除元素,根据要删除元素的位置有三种情况,分别是从开头位置删除、从中间位置删除和从尾部删除,其中删除切片尾部的元素速度最快。

mufeng1 := []int{1, 2, 3, 4, 5}
mufeng1 = mufeng1[1:] //删除开头的一个元素
fmt.Println(mufeng1) //[2 3 4 5]

mufeng1 = mufeng1[3:] //删除开头的三个元素
fmt.Println(mufeng1) //【5】

3.2.3.4 元素访问:

与数组元素一样,可以通过索引(位置)来读取。格式为数组名后加中括号,中括号中为索引的值。

slice[3] = 1

3.2.3.5 函数调用:

当 slice 作为函数参数时,就是一个普通的结构体。不管传的是 slice 还是 slice 指针,如果改变了 slice 底层数组的数据,会反应到实参 slice 的底层数据。

也就是说,修改的元素可以影响到原切片,而新增的元素却不会。

修改:有效

package main

func main() {
    sl := []int{6, 6, 6}
    f(sl)
    fmt.Println(sl)
}

func f(sl []int) {
    for i := range sl {
        sl[i] += 1
    }
}

// 输出 [7 7 7]

新增:无效

package main

func main() {
    sl := []int{6, 6, 6}
    f(sl)
    fmt.Println(sl) // [6 6 6]
}

func f(sl []int) {
	for i := 0; i < 3; i++ {
		sl = append(sl, i)
	}
	fmt.Println(sl) // [6 6 6 0 1 2]
}

要想真的改变外层 slice,只有将返回的新的 slice 赋值到原始 slice,或者向函数传递一个指向 slice 的指针。

package main

import "fmt"

func myAppend(s []int) []int {
    // 这里 sl 虽然改变了,但并不会影响外层函数的 sl
    sl = append(sl, 100)
    return sl
}

func myAppendPtr(sl *[]int) {
    // 会改变外层 s 本身
    *sl = append(*sl, 100)
    return
}

func main() {
    sl := []int{1, 1, 1}
	sl2 := myAppend(sl)

	fmt.Println(sl) // [1 1 1]
	fmt.Println(sl2) // [1 1 1 100]

	sl = sl2
	myAppendPtr(&sl)
	fmt.Println(sl) // [1 1 1 100 100]
}

3.2.4 底层结构:

切片是基于数组实现的,它的底层是数组,可以理解为对底层数组的抽象。

源码包中src/runtime/slice.go 定义了slice的数据结构:

type slice struct {
    array unsafe.Pointer 
    len   int
    cap   int
}

array: 指向底层数组的指针,占用8个字节

len: 切片的长度,占用8个字节

cap: 切片的容量,cap 总是大于等于 len 的,占用8个字节

image-20240218150337467

底层数组是可以被多个 slice 同时指向的,因此对一个 slice 的元素进行操作是有可能影响到其他 slice 的。

3.2.5 动态扩容:

在切片达到容量上限时,继续 append 操作会导致切片扩容。扩容是为切片分配新的内存空间并拷贝原切片中元素的过程。

// 切片 append 会影响原底层数组内容
sl = append(sl, 101)
fmt.Println(len(sl), cap(sl)) // 8 8
fmt.Println(sl)               // [1 2 3 4 5 6 100 101]
fmt.Println(arr)              // [1 2 3 4 5 6 100 101 9 10]

// 触发扩容,与原数组解绑
sl = append(sl, 102)
fmt.Println(len(sl), cap(sl)) // 9 16
fmt.Println(sl)               // [1 2 3 4 5 6 100 101 102]
fmt.Println(arr)              // [1 2 3 4 5 6 100 101 9 10]

切片扩容后会迁移到新的内存位置,新底层数组的长度也会增加,这样就可以放置新增的元素。

同时,为了应对未来可能再次发生的 append 操作,新的底层数组的长度会按照一定的规律增加。

3.2.5.1 确定新切片的大小:

func growslice(et *_type, old slice, cap int) slice {
	newcap := old.cap
	doublecap := newcap + newcap
	if cap > doublecap {
		newcap = cap
	} else {
		if old.len < 1024 {
			newcap = doublecap
		} else {
			for 0 < newcap && newcap < cap {
				newcap += newcap / 4
			}
			if newcap <= 0 {
				newcap = cap
			}
		}
	}

在分配内存空间之前需要先确定新的切片容量,运行时根据切片的当前容量选择不同的策略进行扩容:

  1. 如果期望容量大于当前容量的两倍就会使用期望容量;

  2. 如果当前切片的长度小于 1024 就会将容量翻倍;

  3. 如果当前切片的长度大于 1024 就会每次增加 25% 的容量,直到新容量大于期望容量;

上述代码片段仅会确定切片的大致容量,下面还需要根据切片中的元素大小对齐内存,当数组中元素所占的字节大小为 1,或者2的倍数时,运行时会使用如下所示的代码对齐内存:

var overflow bool
	var lenmem, newlenmem, capmem uintptr
	switch {
	case et.size == 1:
		lenmem = uintptr(old.len)
		newlenmem = uintptr(cap)
		capmem = roundupsize(uintptr(newcap))
		overflow = uintptr(newcap) > maxAlloc
		newcap = int(capmem)
	case et.size == sys.PtrSize:
		lenmem = uintptr(old.len) * sys.PtrSize
		newlenmem = uintptr(cap) * sys.PtrSize
		capmem = roundupsize(uintptr(newcap) * sys.PtrSize)
		overflow = uintptr(newcap) > maxAlloc/sys.PtrSize
		newcap = int(capmem / sys.PtrSize)
	case isPowerOfTwo(et.size):
		...
	default:
		...
	}

如果go 1.18+,原来的slice 容量oldcap小于256的时候,新 slice 的容量newcap是oldcap 的2倍;当oldcap容量大于等于 256 的时候,newcap会有个计算公式:newcap += (newcap +3*threshold) / 4 再对 newcap 作了一个内存对齐,这个和内存分配策略相关。进行内存对齐之后,新 slice 的容量是要大于等于按照前半部分生成的newcap。

3.2.5.2 进行扩容:

在默认情况下,我们会将目标容量和元素大小相乘得到占用的内存。如果计算新容量时发生了内存溢出或者请求内存超过上限,就会直接崩溃退出程序。

如果切片中元素不是指针类型,那么会调用runtime.memclrNoHeapPointers 将超出切片当前长度的位置清空并在最后使用 runtime.memmove将原数组内存中的内容拷贝到新申请的内存中。这两个方法都是用目标机器上的汇编指令实现的。

runtime.growslice函数最终会返回一个新的切片,其中包含了新的数组指针、大小和容量,这个返回的三元组最终会覆盖原切片。

3.2.5.3 扩容的例子:

var arr []int64
arr = append(arr, 1, 2, 3, 4, 5)

当我们执行上述代码时,会触发runtime.growslice 函数扩容 arr 切片并传入期望的新容量 5,这时期望分配的内存大小为 40 字节;不过因为切片中的元素大小等于 sys.PtrSize,所以运行时会调用runtime.roundupsize向上取整内存的大小到 48 字节,所以新切片的容量为 48 / 8 = 6。

3.2.6 线程安全:

go语言中的切片并不是线程安全的.

3.2.6.1 定义:

如果多个线程访问同一个对象时,调用这个对象的行为都可以获得正确的结果,那么这个对象就是线程安全的。

3.2.6.2 实现:

Go语言实现线程安全常用的几种方式

  1. 互斥锁

  2. 读写锁

  3. 原子操作

  4. sync.once

  5. sync.atomic

  6. channel

切片底层结构并没有使用加锁等方式,不支持并发读写,所以并不是线程安全的。

使用多个 goroutine 对类型为切片的同一个变量进行操作,每次输出的值大概率都不会一样,与预期值不一致;

切片在并发执行中不会报错,但是数据会丢失。

3.2.6.3 解决方案:

可以考虑使用 channel 本身的特性 (阻塞) 来实现安全的并发读写。

func TestSliceConcurrencySafe(t *testing.T) {
 a := make([]int, 0)
 var wg sync.WaitGroup
 for i := 0; i < 10000; i++ {
  wg.Add(1)
  go func(i int) {
   a = append(a, i)
   wg.Done()
  }(i)
 }
 wg.Wait()
 t.Log(len(a)) 
 // not equal 10000
}

3.2.7 零值可用:

如果向一个 nil 的 slice 添加元素,都可以通过调用 append 函数来获得底层数组的扩容。最终都是调用 mallocgc 来向 Go 的内存管理器申请到一块内存,然后再赋给原来的 nil slice 或 empty slice,然后摇身一变,成为“真正”的 slice 了。 初值为零值 nil 的切片类型变量,可以借助内置的 append 的函数进行操作,这种在 Go 语言中被称为“零值可用”。

4.哈希表:

哈希表是一种古老的数据结构,在 1953 年就有人使用拉链法实现了哈希表,它能够通过键直接获取该键对应的值。它是除了数组之外,最常见的数据结构。

几乎所有的语言都会有数组和哈希表两种集合元素,有的语言将数组实现成列表,而有的语言将哈希称作字典或者映射。

无论如何命名或者如何实现,数组和哈希是两种设计集合元素的思路,数组用于表示元素的序列,而哈希表示的是键值对之间映射关系。

4.1 声明和初始化:

与其他的变量一样,使用var关键字声明一个map:

//声明
var map_name map[key_type]value_type

key_type表示键的数据类型,value_type表示键对应的值的数据类型。

map是引用类型,如果只声明,而不创建 map(用 make 函数创建map),那么就会创建一个 nil map。nil map 不能用来存放键值对,如果对nil map 进行操作会报错。

声明之后,map类型的变量默认初始值为 nil,需要使用 make() 函数来分配内存。语法为:

//初始化或者说是创建
map_name = make(map[key_type]value_type)

//在声明的时候初始化
map_name := make(map[key_type]value_type)

也可以在声明与初始化的时候进行赋值:

//声明+初始化
map_name := map[string]string{"France": "Paris", "Italy": "Rome", "Japan": "Tokyo", "India": "New delhi"}

4.2 常用方法:

4.2.1 Set:

//初始化值
scoreMap["张三"] = 90

4.2.2 Get:

使用map名加key名的方式获取value值.

fmt.Println(scoreMap["小明"])

4.2.3 遍历:

可以使用for-range结构便利map.

scoreMap := make(map[string]int)
scoreMap["张三"] = 90
scoreMap["小明"] = 100
scoreMap["王五"] = 60
for k, v := range scoreMap {
	fmt.Println(k, v)
}

如果只需要遍历key:

scoreMap := make(map[string]int)
scoreMap["张三"] = 90
scoreMap["小明"] = 100
scoreMap["王五"] = 60
for k := range scoreMap {
	fmt.Println(k)
}

遍历 map 时的元素顺序与添加键值对的顺序无关。是无序遍历.

4.2.4 判断key是否存在:

判断 map 中某个键是否存在的特殊写法:value, ok := map[key] 如果键存在,那么会返回相应的值和 true,如果键不存在,那么会返回空和 false.

//查看元素在集合中是否存在
capital, ok := countryCapitalMap["American"] /*如果确定是真实的,则存在,否则不存在 */
fmt.Println(capital)
fmt.Println(ok)
if ok {
	fmt.Println("American 的首都是", capital)
}
else {
	fmt.Println("American 的首都不存在")
}

4.2.5 删除key:

delete() 函数用于删除集合 map 中的元素, 参数为某个 map 和其中的某个 key。

scoreMap["小明"] = 100
scoreMap["王五"] = 60
delete(scoreMap, "小明") //将小明:100从map中删除

4.3 数据结构:

Go中的map是一个指针,占用8个字节,指向hmap结构体

源码包中src/runtime/map.go定义了hmap的数据结构:

hmap包含若干个结构为bmap的数组,每个bmap底层都采用链表结构,bmap通常叫其bucket。当bmap中存储的数据过多,单个桶已经装满时就会使用 extra.nextOverflow 中桶存储溢出的数据。

上述两种不同的桶在内存中是连续存储的,它们分别称为正常桶和溢出桶。

他们之间的关系可以用下图所示:

image-20240218175540539

image-20240218174841337

4.3.1 bmap:

bmap 就是我们常说的“桶”,一个桶里面会最多装 8 个 key,这些 key 之所以会落入同一个桶,是因为它们经过哈希计算后,哈希结果的低B位是相同的。

bmap的结构如下:

type bmap struct {
	tophash [bucketCnt]uint8
}

tophash 存储了键的哈希的高 8 位,通过比较不同键的哈希的高 8 位可以减少访问键值对次数以提高性能。

在运行期间,runtime.bmap结构体其实不止包含 tophash 字段,因为哈希表中可能存储不同类型的键值对,所以键值对占据的内存空间大小只能在编译时进行推导。runtime.bmap中的其他字段在运行时也都是通过计算内存地址的方式访问的,所以它的定义中就不包含这些字段,不过我们能根据编译期间的cmd/compile/internal/gc.bmap函数重建它的结构:

type bmap struct {
    topbits  [8]uint8
    keys     [8]keytype
    // keytype 由编译器编译时候确定
    values   [8]valuetype
    // elemtype 由编译器编译时候确定
    pad      uintptr
    overflow uintptr
    // overflow指向下一个bmap,overflow是uintptr而不是*bmap类型,保证bmap完全不含指针,是为了减少gc,溢出桶存储到extra字段中
}

4.3.2 hmap:

hmap包含若干个结构为bmap的数组,每个bmap底层都采用链表结构,bmap通常叫其bucket.

// A header for a Go map.
type hmap struct {
    count     int 
    // 代表哈希表中的元素个数,调用len(map)时,返回的就是该字段值。
    flags     uint8 
    // 状态标志(是否处于正在写入的状态等)
    B         uint8  
    // buckets(桶)的对数
    // 如果B=5,则buckets数组的长度 = 2^B=32,意味着有32个桶
    noverflow uint16 
    // 溢出桶的数量
    hash0     uint32 
    // 生成hash的随机数种子
    buckets    unsafe.Pointer 
    // 指向buckets数组的指针,数组大小为2^B,如果元素个数为0,它为nil。
    oldbuckets unsafe.Pointer 
    // 如果发生扩容,oldbuckets是指向老的buckets数组的指针,老的buckets数组大小是新的buckets的1/2;非扩容状态下,它为nil。
    nevacuate  uintptr        
    // 表示扩容进度,小于此地址的buckets代表已搬迁完成。
    extra *mapextra 
    // 存储溢出桶,这个字段是为了优化GC扫描而设计的,下面详细介绍
 }

4.4 写入:

4.4.1 计算哈希:

在向Map中写入一个key时,首先回调用hash函数,对key做哈希得到哈希值。

4.4.2 确定桶位:

然后根据哈希值找到这个元素应当存储的桶。

计算桶的时候需要用到的是哈希值的最后B位。比如一个hmap中b为5,那么证明这个hmap中一共存在25=32个桶,而我们都知道,n位二进制数可以表示2n个状态,所以需要的是哈希值的后五位数。

比如,一个key的哈希值为:

10010111|000011110110110010001111001010100010010110010101010│01010

若此时B=5,它的后五位为01010,转换成十进制为10,那么说明这个key存储在第十个桶中。

4.4.3 确定槽位:

可以把每个桶理解为一个具有八个元素的数组(定长),如下图所示:

image-20240219140334706

在Go语言中,根据哈希值的高八位确定槽位(bucket)的过程是通过一个称为“top hash”的额外哈希运算来实现的。这个过程被称为“tophash”算法。该算法的目的是通过对哈希值的高八位进行额外的哈希运算,以便更好地分散元素到不同的桶中,减少哈希冲突的可能性。

Go语言中的top hash算法是通过使用了一系列的位移和异或操作来实现的。这个过程并没有被Go语言的标准库公开,而是内部实现的一部分,因此其具体细节可能会随着Go版本的更新而改变。不过,通常来说,top hash算法会对原始哈希值的高8位进行适当的位移和异或操作,以产生一个小范围的整数值,用作桶内槽位的索引。

4.4.4 写入数据:

找到槽位之后,观察这个槽位是否有数据,一共会遇到三种情况:

  1. 找到键相同的键值对 — 更新键对应的值;

  2. 没有找到键相同的键值对 — 追加新的键值对;

  3. 找到键不同的键值对 — 出现冲突,此时需要进行冲突的解决;

4.4.5 冲突解决:

4.4.5.1 常见的冲突解决方案:

比较常用的Hash冲突解决方案有链地址法和开放寻址法:

链地址法

当哈希冲突发生时,创建新单元,并将新单元添加到冲突单元所在链表的尾部。

开放寻址法

当哈希冲突发生时,从发生冲突的那个单元起,按照一定的次序,从哈希表中寻找一个空闲的单元,然后把发生冲突的元素存入到该单元。开放寻址法需要的表长度要大于等于所需要存放的元素数量

开放寻址法有多种方式:线性探测法、平方探测法、随机探测法和双重哈希法。这里以线性探测法来帮助读者理解开放寻址法思想

线性探测法

Hash(key) 表示关键字 key 的哈希值, 表示哈希表的槽位数(哈希表的大小)。

线性探测法则可以表示为:

如果 Hash(x) % M 已经有数据,则尝试 (Hash(x) + 1) % M ;

如果 (Hash(x) + 1) % M 也有数据了,则尝试 (Hash(x) + 2) % M ;

如果 (Hash(x) + 2) % M 也有数据了,则尝试 (Hash(x) + 3) % M ;

两种解决方案比较

对于链地址法,基于数组 + 链表进行存储,链表节点可以在需要时再创建,不必像开放寻址法那样事先申请好足够内存,因此链地址法对于内存的利用率会比开方寻址法高。链地址法对装载因子的容忍度会更高,并且适合存储大对象、大数据量的哈希表。而且相较于开放寻址法,它更加灵活,支持更多的优化策略,比如可采用红黑树代替链表。但是链地址法需要额外的空间来存储指针。

对于开放寻址法,它只有数组一种数据结构就可完成存储,继承了数组的优点,对CPU缓存友好,易于序列化操作。但是它对内存的利用率不如链地址法,且发生冲突时代价更高。当数据量明确、装载因子小,适合采用开放寻址法。

总结

在发生哈希冲突时,Python中dict采用的开放寻址法,Java的HashMap采用的是链地址法,而Go map也采用链地址法解决冲突,具体就是插入key到map中时,当key定位的桶填满8个元素后(这里的单元就是桶,不是元素),将会创建一个溢出桶,并且将溢出桶插入当前桶所在链表尾部。

if inserti == nil {
        // all current buckets are full, allocate a new one.
        newb := h.newoverflow(t, b)
        // 创建一个新的溢出桶
        inserti = &newb.tophash[0]
        insertk = add(unsafe.Pointer(newb), dataOffset)
        elem = add(insertk, bucketCnt*uintptr(t.keysize))
}

如果新元素的哈希值与桶中已有元素的哈希值产生了冲突,那么新元素将会被插入到链表(或者树)中,而不是直接存储在bmap的数组中。

在哈希表中插入新元素并不会改变已有元素的存储位置。只有当新元素的哈希值与已有元素的哈希值产生了冲突,新元素才会被插入到桶中的链表(或者树)(也就是上文所说的溢出桶)中,而已有元素的位置保持不变。

换句话说,如果在一个桶中已经有三个元素存储且没有哈希冲突,而你插入的新元素的哈希值与这三个元素的哈希值产生了冲突,那么新元素会被插入到链表(或者树)的头部,而原先的三个元素的位置不会改变,它们仍然存储在bmap的数组中。

4.5 访问:

从上文我们可以知道,访问一个map共有两种方式:带comma(也就是上文的判断元素是否存在)和不带comma.

//不带comma
age1 := m["bwll"]
fmt.Println(age1)
//带comma
age2,ok := m["bwll"]
fmp.Println(age2,ok)

map的查找通过生成汇编码可以知道,根据 key 的不同类型/返回参数,编译器会将查找函数用更具体的函数替换,以优化效率:

key 类型

查找

uint32

mapaccess1_fast32(t maptype, h hmap, key uint32) unsafe.Pointer

uint32

mapaccess2_fast32(t maptype, h hmap, key uint32) (unsafe.Pointer, bool)

uint64

mapaccess1_fast64(t maptype, h hmap, key uint64) unsafe.Pointer

uint64

mapaccess2_fast64(t maptype, h hmap, key uint64) (unsafe.Pointer, bool)

string

mapaccess1_faststr(t maptype, h hmap, ky string) unsafe.Pointer

string

mapaccess2_faststr(t maptype, h hmap, ky string) (unsafe.Pointer, bool)

总体的访问流程如下:

202308292358408

4.5.1.写保护监测:

函数首先会检查 map 的标志位 flags。如果 flags 的写标志位此时被置 1 了,说明有其他协程在执行“写”操作,进而导致程序 panic,这也说明了 map 不是线程安全的

if h.flags&hashWriting != 0 {
    throw("concurrent map read and map write")
}

4.5.2.计算hash值:

hash := t.hasher(key, uintptr(h.hash0))

key经过哈希函数计算后,得到的哈希值如下(主流64位机下共 64 个 bit 位), 不同类型的key会有不同的hash函数

10010111 | 000011110110110010001111001010100010010110010101010 │ 01010

4.5.4.找到hash对应的bucket

bucket定位:哈希值的低B个bit 位,用来定位key所存放的bucket

如果当前正在扩容中,并且定位到的旧bucket数据还未完成迁移,则使用旧的bucket(扩容前的bucket)

hash := t.hasher(key, uintptr(h.hash0))
// 桶的个数m-1,即 1<<B-1,B=5时,则有0~31号桶
m := bucketMask(h.B)
// 计算哈希值对应的bucket
// t.bucketsize为一个bmap的大小,通过对哈希值和桶个数取模得到桶编号,通过对桶编号和buckets起始地址进行运算,获取哈希值对应的bucket
b := (*bmap)(add(h.buckets, (hash&m)*uintptr(t.bucketsize)))
// 是否在扩容
if c := h.oldbuckets; c != nil {
  // 桶个数已经发生增长一倍,则旧bucket的桶个数为当前桶个数的一半
    if !h.sameSizeGrow() {
        // There used to be half as many buckets; mask down one more power of two.
        m >>= 1
    }
    // 计算哈希值对应的旧bucket
    oldb := (*bmap)(add(c, (hash&m)*uintptr(t.bucketsize)))
    // 如果旧bucket的数据没有完成迁移,则使用旧bucket查找
    if !evacuated(oldb) {
        b = oldb
    }
}

4.5.5 遍历bucket查找:

tophash值定位:哈希值的高8个bit 位,用来快速判断key是否已在当前bucket中(如果不在的话,需要去bucket的overflow中查找)

用步骤2中的hash值,得到高8个bit位,也就是10010111,转化为十进制,也就是151

top := tophash(hash)
func tophash(hash uintptr) uint8 {
    top := uint8(hash >> (goarch.PtrSize*8 - 8))
    if top < minTopHash {
        top += minTopHash
    }
    return top
}

上面函数中hash是64位的,sys.PtrSize值是8,所以top := uint8(hash >> (sys.PtrSize*8 - 8))等效top = uint8(hash >> 56),最后top取出来的值就是hash的高8位值

在 bucket 及bucket的overflow中寻找tophash 值(HOB hash)为 151* 的 槽位,即为key所在位置,找到了空槽位或者 2 号槽位,这样整个查找过程就结束了,其中找到空槽位代表没找到。

for ; b != nil; b = b.overflow(t) {
        for i := uintptr(0); i < bucketCnt; i++ {
            if b.tophash[i] != top {
              // 未被使用的槽位,插入
                if b.tophash[i] == emptyRest {
                    break bucketloop
                }
                continue
            }
            // 找到tophash值对应的的key
            k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
            if t.key.equal(key, k) {
                e := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))
                return e
            }
        }
    }

img

5. 返回key对应的指针

如果通过上面的步骤找到了key对应的槽位下标 i,我们再详细分析下key/value值是如何获取的:

// keys的偏移量
dataOffset = unsafe.Offsetof(struct{
  b bmap
  v int64
}{}.v)

// 一个bucket的元素个数
bucketCnt = 8

// key 定位公式
k :=add(unsafe.Pointer(b),dataOffset+i*uintptr(t.keysize))

// value 定位公式
v:= add(unsafe.Pointer(b),dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.valuesize))

bucket 里 keys 的起始地址就是 unsafe.Pointer(b)+dataOffset

第 i 个下标 key 的地址就要在此基础上跨过 i 个 key 的大小;

而我们又知道,value 的地址是在所有 key 之后,因此第 i 个下标 value 的地址还需要加上所有 key 的偏移。

4.6 遍历:

使用 range 多次遍历 map 时输出的 key 和 value 的顺序可能不同。这是 Go 语言的设计者们有意为之,旨在提示开发者们,Go 底层实现并不保证 map 遍历顺序稳定,请大家不要依赖 range 遍历结果顺序。

主要原因有2点:

  • map在遍历时,并不是从固定的0号bucket开始遍历的,每次遍历,都会从一个随机值序号的bucket,再从其中随机的cell开始遍历。

  • map遍历时,是按序遍历bucket,同时按需遍历bucket中和其overflow bucket中的cell。但是map在扩容后,会发生key的搬迁,这造成原来落在一个bucket中的key,搬迁后,有可能会落到其他bucket中了,从这个角度看,遍历map的结果就不可能是按照原来的顺序了。

map 本身是无序的,且遍历时顺序还会被随机化,如果想顺序遍历 map,需要对 map key 先排序,再按照 key 的顺序遍历 map。

func TestMapRange(t *testing.T) {
    m := map[int]string{1: "a", 2: "b", 3: "c"}
    t.Log("first range:")
    for i, v := range m {
        t.Logf("m[%v]=%v ", i, v)
    }
    t.Log("\nsecond range:")
    for i, v := range m {
        t.Logf("m[%v]=%v ", i, v)
    }

    // 实现有序遍历
    var sl []int
    // 把 key 单独取出放到切片
    for k := range m {
        sl = append(sl, k)
    }
    // 排序切片
    sort.Ints(sl)
    // 以切片中的 key 顺序遍历 map 就是有序的了
    for _, k := range sl {
        t.Log(k, m[k])
    }
}

4.7 扩容:

4.7.1 装填因子:

装填因子(Load Factor)是指哈希表中已经存储的元素数量与哈希表容量之比。换句话说,装填因子表示了哈希表的使用程度,它反映了哈希表中已经被占用的槽位比例。

装填因子的计算公式如下:

装填因子=元素数量哈希表容量装填因子/哈希表容量元素数量

通常来说,装填因子越大,表示哈希表中已经存储的元素越多,哈希表的使用程度越高。而装填因子越小,则表示哈希表中的槽位还有很多空闲位置。

在哈希表的设计中,装填因子通常是一个重要的考量因素。因为装填因子过高可能会导致哈希冲突的频繁发生,降低哈希表的性能;而装填因子过低则可能会浪费大量的内存空间。

因此,通常会根据实际情况选择一个合适的装填因子阈值,当装填因子超过这个阈值时,会触发哈希表的扩容操作,以保持装填因子在一个合理的范围内,维护哈希表的性能。

根据Go 官方测试结果和讨论,取了一个相对适中的值,把 Go 中的 map 的负载因子硬编码为 6.5

这意味着在 Go 语言中,当 map存储的元素个数大于或等于 6.5 * 桶个数 时,就会触发扩容行为

4.7.2 时机:

runtime.mapassign函数会在以下两种情况发生时触发哈希的扩容:

  1. 装载因子已经超过 6.5;

  2. 哈希使用了太多溢出桶;

当桶总数 < 2 ^ 15 时,如果溢出桶总数 >= 桶总数,则认为溢出桶过多。

当桶总数 >= 2 ^ 15 时,直接与 2 ^ 15 比较,当溢出桶总数 >= 2 ^ 15 时,即认为溢出桶太多了。

对于条件2,其实算是对条件1的补充。因为在负载因子比较小的情况下,有可能 map 的查找和插入效率也很低,而第 1 点识别不出来这种情况。

表面现象就是负载因子比较小比较小,即 map 里元素总数少,但是桶数量多(真实分配的桶数量多,包括大量的溢出桶)。比如不断的增删,这样会造成overflow的bucket数量增多,但负载因子又不高,达不到第 1 点的临界值,就不能触发扩容来缓解这种情况。这样会造成桶的使用率不高,值存储得比较稀疏,查找插入效率会变得非常低,因此有了第 2 扩容条件。

4.7.3 扩容机制:

4.7.3.1 双倍扩容:

针对条件1,新建一个buckets数组,新的buckets大小是原来的2倍,然后旧buckets数据搬迁到新的buckets。该方法我们称之为双倍扩容

4.7.3.2 等量扩容:

针对条件2,并不扩大容量,buckets数量维持不变,重新做一遍类似双倍扩容的搬迁动作,把松散的键值对重新排列一次,使得同一个 bucket 中的 key 排列地更紧密,节省空间,提高 bucket 利用率,进而保证更快的存取。该方法我们称之为等量扩容

4.7.4 扩容函数:

hashGrow() 函数实际上并没有真正地“搬迁”,它只是分配好了新的 buckets,并将老的 buckets 挂到了 oldbuckets 字段上。真正搬迁 buckets 的动作在 growWork() 函数中,而调用 growWork() 函数的动作是在 mapassign 和 mapdelete 函数中。也就是插入或修改、删除 key 的时候,都会尝试进行搬迁 buckets 的工作。先检查 oldbuckets 是否搬迁完毕,具体来说就是检查 oldbuckets 是否为 nil

func hashGrow(t *maptype, h *hmap) {
   // 如果达到条件 1,那么将B值加1,相当于是原来的2倍
   // 否则对应条件 2,进行等量扩容,所以 B 不变
     bigger := uint8(1)
     if !overLoadFactor(h.count+1, h.B) {
         bigger = 0
         h.flags |= sameSizeGrow
     }
   // 记录老的buckets
    oldbuckets := h.buckets
  // 申请新的buckets空间
    newbuckets, nextOverflow := makeBucketArray(t, h.B+bigger, nil)
  // 注意&^ 运算符,这块代码的逻辑是转移标志位
    flags := h.flags &^ (iterator | oldIterator)
    if h.flags&iterator != 0 {
        flags |= oldIterator
    }
    // 提交grow (atomic wrt gc)
    h.B += bigger
    h.flags = flags
    h.oldbuckets = oldbuckets
    h.buckets = newbuckets
  // 搬迁进度为0
    h.nevacuate = 0
  // overflow buckets 数为0
    h.noverflow = 0

  // 如果发现hmap是通过extra字段 来存储 overflow buckets时
    if h.extra != nil && h.extra.overflow != nil {
        if h.extra.oldoverflow != nil {
            throw("oldoverflow is not nil")
        }
        h.extra.oldoverflow = h.extra.overflow
        h.extra.overflow = nil
    }
    if nextOverflow != nil {
        if h.extra == nil {
            h.extra = new(mapextra)
        }
        h.extra.nextOverflow = nextOverflow
    }
}

由于 map 扩容需要将原有的 key/value 重新搬迁到新的内存地址,如果map存储了数以亿计的key-value,一次性搬迁将会造成比较大的延时,因此 Go map 的扩容采取了一种称为“渐进式”的方式,原有的 key 并不会一次性搬迁完毕,每次最多只会搬迁 2 个 bucket。

func growWork(t *maptype, h *hmap, bucket uintptr) {
    // 为了确认搬迁的 bucket 是我们正在使用的 bucket
    // 即如果当前key映射到老的bucket1,那么就搬迁该bucket1。
    evacuate(t, h, bucket&h.oldbucketmask())
    // 如果还未完成扩容工作,则再搬迁一个bucket。
    if h.growing() {
        evacuate(t, h, h.nevacuate)
    }
}

4.8 线程安全:

map默认是并发不安全的,同时对map进行并发读写时,程序会panic,原因如下:

Go 官方在经过了长时间的讨论后,认为 Go map 更应适配典型使用场景(不需要从多个 goroutine 中进行安全访问),而不是为了小部分情况(并发访问),导致大部分程序付出加锁代价(性能),决定了不支持。

4.8.1 不安全场景:

场景: 2个协程同时读和写,以下程序会出现致命错误:fatal error: concurrent map writes

package main

import (
    "fmt"
    "time"
)

func main() {
    s := make(map[int]int)
    for i := 0; i < 100; i++ {
        go func(i int) {
            s[i] = i
        }(i)
    }
    for i := 0; i < 100; i++ {
        go func(i int) {
            fmt.Printf("map第%d个元素值是%d\n", i, s[i])
        }(i)
    }
    time.Sleep(1 * time.Second)
}

4.8.2 解决方案:

如果想实现map线程安全,有两种方式:

4.8.2.1 使用读写锁map + sync.RWMutex:

package main

import (
    "fmt"
    "sync"
    "time"
)

func main() {
    var lock sync.RWMutex
    s := make(map[int]int)
    for i := 0; i < 100; i++ {
        go func(i int) {
            lock.Lock()
            s[i] = i
            lock.Unlock()
        }(i)
    }
    for i := 0; i < 100; i++ {
        go func(i int) {
            lock.RLock()
            fmt.Printf("map第%d个元素值是%d\n", i, s[i])
            lock.RUnlock()
        }(i)
    }
    time.Sleep(1 * time.Second)
}

4.8.2.2 使用Go提供的sync.Map:

package main

import (
    "fmt"
    "sync"
    "time"
)

func main() {
    var m sync.Map
    for i := 0; i < 100; i++ {
        go func(i int) {
            m.Store(i, i)
        }(i)
    }
    for i := 0; i < 100; i++ {
        go func(i int) {
            v, ok := m.Load(i)
            fmt.Printf("Load: %v, %v\n", v, ok)
        }(i)
    }
    time.Sleep(1 * time.Second)
}

Go 语言的 sync.Map 支持并发读写,采取了 “空间换时间” 的机制,冗余了两个数据结构,分别是:read 和 dirty

type Map struct {
   mu Mutex
   read atomic.Value // readOnly
   dirty map[interface{}]*entry
   misses int
}

对比原始map:

和原始map+RWLock的实现并发的方式相比,减少了加锁对性能的影响。它做了一些优化:可以无锁访问read map,而且会优先操作read map,倘若只操作read map就可以满足要求,那就不用去操作write map(dirty),所以在某些特定场景中它发生锁竞争的频率会远远小于map+RWLock的实现方式

优点:

适合读多写少的场景

缺点:

写多的场景,会导致 read map 缓存失效,需要加锁,冲突变多,性能急剧下降。

4.9 总结:

Go 语言使用拉链法来解决哈希碰撞的问题实现了哈希表,它的访问、写入和删除等操作都在编译期间转换成了运行时的函数或者方法。哈希在每一个桶中存储键对应哈希的前 8 位,当对哈希进行操作时,这些 tophash 就成为可以帮助哈希快速遍历桶中元素的缓存。

哈希表的每个桶都只能存储 8 个键值对,一旦当前哈希的某个桶超出 8 个,新的键值对就会存储到哈希的溢出桶中。随着键值对数量的增加,溢出桶的数量和哈希的装载因子也会逐渐升高,超过一定范围就会触发扩容,扩容会将桶的数量翻倍,元素再分配的过程也是在调用写操作时增量进行的,不会造成性能的瞬时巨大抖动。

5.函数:

5.1. 声明函数:

goCopy code
func functionName(parameter1 type1, parameter2 type2) returnType {
    // 函数体内的代码
    return // 可选的返回语句
}
  • func: 关键字用于声明函数。

  • functionName: 函数名,用于在其他地方调用函数。

  • (parameter1 type1, parameter2 type2): 参数列表,每个参数由参数名和类型组成,多个参数之间用逗号分隔。

  • returnType: 返回值的类型,如果函数没有返回值,可以省略该部分。

  • return: 可选的返回语句,用于将值返回给调用者。

5.2. 函数参数:

Go语言中的函数可以有零个或多个参数。

5.2.1 无参数函数:

func sayHello() {
    fmt.Println("Hello!")
}

5.2.2 带参数函数:

func greet(name string) {
    fmt.Println("Hello,", name)
}

5.2.3. 多返回值函数:

func divide(a, b int) (int, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}

5.2.4 可变参数:

在实际开发中,总有一些函数的参数个数是在编码过程中无法确定的,比如我们最常用的fmt.Printf和fmt.Println:

fmt.Printf("一共有%v行%v列\n", rows, cols)
fmt.Println("共计大小:", size)

这种时候就需要可变参数了。

可变参数在函数中将转换为对应的[]Type类型,所以我们可以像使用slice时一样来获取传给函数的参数们;

func Printf(format string, a ...interface{}) (n int, err error)

举个例子:

func Greeting(prefix string, who ...string)
Greeting("nobody")
Greeting("hello:", "Joe", "Anna", "Eileen")

可变参数的使用场景:

  • 避免创建仅作传入参数用的临时切片

  • 当参数数量未知

  • 传达你希望增加可读性的意图

注意,可变参数的类型是不可变的,只有个数可变。

可变参数函数会在其内部创建一个”新的切片”。事实上,可变参数是一个简化了切片类型参数传入的语法糖。

image-20240407111601681

当不传入参数的时候,可变参数会成为一个空值切片( nil ).

传入的切片和函数内部使用的切片共享同一个底层数组,因此在函数内部改变这个数组的值同样会影响到传入的切片.

5.3. 匿名函数:

拥有函数名的函数只能在包级语法块中被声明,通过函数字面量(function literal),我们可绕过这一限制,在任何表达式中表示一个函数值。函数字面量的语法和函数声明相似,区别在于func关键字后没有函数名。函数值字面量是一种表达式,它的值被称为匿名函数(anonymous function)。

add := func(x, y int) int {
    return x + y
}
result := add(3, 5) // result 现在是 8

匿名函数有很多种用途,其中一个用途是,但是用函数作为参数时,我们可以不必关心函数的具体返回值。

以singleflight为例子:有一个方法这样定义

func (g *Group) Do(key string, fn func() (interface{}, error)) (v interface{}, err error, shared bool) 

那么我们可以这样调用:

func main() {
  for i := 0; i < n; i++ {
    go func(j int) {
      //使用匿名函数
      v, _, shared := g.Do(key, func() (interface{}, error) {
        //调用真实的函数
        ret, err := find(context.Background(), key)
        return ret, err
      })
      if atomic.AddInt32(&waited, -1) == 0 {
        close(done)
      }
      fmt.Printf("index: %d, val: %v, shared: %v\n", j, v, shared)
    }(i)
  }
}

这样,就可以实现任意类型的参数被使用的情况。

5.4. 函数类型:

在 Go 语言中,函数类型也是一等的数据类型。简单来说,这意味着函数不但可以用于封装代码、分割功能、解耦逻辑,还可以化身为普通的值,在其他函数间传递、赋予变量、做类型判断和转换等等,就像切片和字典的值那样。

而更深层次的含义就是:函数值可以由此成为能够被随意传播的独立逻辑组件(或者说功能模块)。

函数类型(function types)是一种很特殊的类型,它表示着所有拥有同样的入参类型和返回值类型的函数集合。

如下这一行代码,定义了一个名叫 Greeting 的函数类型:

type Greeting func(name string) string 

只接收一个参数 ,并且该参数的类型为 string

返回值也只有一个参数,其类型为 string

一个函数只要满足这些特征,那么它就可以通过如下方式将该函数转换成 Greeting 类型的函数对象(也即 greet)。

func english(name string) string { 
    return "Hello, " + name 
} 
 
// 转换成 Greeting 类型的函数对象 
greet := Greeting(english) 
// 或者 
var greet Greeting = english

5.5. 高阶函数:

高阶函数可以满足下面的两个条件:

1. 接受其他的函数作为参数传入;

2. 把其他的函数作为结果返回。

只要满足了其中任意一个特点,我们就可以说这个函数是一个高阶函数。高阶函数也是函数式编程中的重要概念和特征。

举个例子:

type operate func(x, y int) int
​
type calculateFunc func(x int, y int) (int, error)
​
func genCalculator(op operate) calculateFunc {
  return func(x int, y int) (int, error) {
    if op == nil {
      return 0, errors.New("invalid operation")
    }
    return op(x, y), nil
  }
}

5.6. 闭包函数:

当匿名函数引用了外部作用域中的变量时就成了闭包函数,闭包函数是函数式编程语言的核心。

闭包引用了函数体之外的变量,这个变量有个专门的术语称呼它,叫自由变量。 这个函数可以对这个引用的变量进行访问和赋值;换句话说这个函数被“绑定”在这个变量上。没有闭包的时候,函数就是一次性买卖,函数执行完毕后就无法再更改函数中变量的值(应该是内存释放了);有了闭包后函数就成为了一个变量的值,只要变量没被释放,函数就会一直处于存活并独享的状态,因此可以后期更改函数中变量的值(因为这样就不会被go给回收内存了,会一直缓存在那里)。

使用闭包的意义是什么?主要就是缩小变量作用域,减少对全局变量的污染

举个例子:一个数从0开始,每次加上自己的值和当前循环次数(当前第几次,循环从0开始,到9,共10次),然后*2,这样迭代10次。

没有闭包的时候这么写:

func adder(x int) int {
    return x * 2
}
​
func main() {
    var a int
    for i := 0; i < 10; i ++ {
        a = adder(a+i)
        fmt.Println(a)
    }
}

如果用闭包的话就可以这样写:

func adder() func(int) int {
    res := 0
    return func(x int) int {
        res = (res + x) * 2
        return res
    }
}
​
func main() {
    a := adder()
    for i := 0; i < 10; i++ {
        fmt.Println(a(i))
    }
}

从上面的例子可以看出,有3个好处:

1、不是一次性消费,被引用声明后可以重复调用,同时变量又只限定在函数里,同时每次调用不是从初始值开始(函数里长期存储变量)

其实有点像使用面向对象的感觉,实例化一个类,这样这个类里的所有方法、属性都是为某个人私有独享的。但比面向对象更加的轻量化

2、用了闭包后,主函数就变得简单了,把算法封装在一个函数里,使得主函数省略了a=adder(a+i)这种麻烦事了

3、变量污染少,因为如果没用闭包,就会为了传递值到函数里,而在函数外部声明变量,但这样声明的变量又会被下面的其他函数或代码误改。

5.7. 方法:

Go语言中的方法是与特定类型相关联的函数。

goCopy code
type Rectangle struct {
    width, height int
}
​
func (r Rectangle) Area() int {
    return r.width * r.height
}
​
rect := Rectangle{width: 10, height: 5}
area := rect.Area() // area 现在是 50

6.指针:

指针是一个变量,其值为另一个变量的内存地址。

6.1. 声明指针:

在Go语言中,你可以通过在变量类型前加上*来声明指针变量。

goCopy code
var ptr *int // 声明一个指向整数的指针

6.2. 获取变量的地址:

你可以使用&操作符获取一个变量的内存地址。

goCopy code
var num int = 10
ptr = &num // 将num的地址赋值给ptr

6.3. 使用指针:

6.3.1 解引用指针:

解引用指针意味着访问指针所指向的值。你可以使用*操作符对指针进行解引用。

goCopy code
fmt.Println(*ptr) // 输出ptr指向的值,这里输出10

6.3.2 修改指针所指向的值:

通过解引用指针,你可以修改指针所指向的值。

goCopy code
*ptr = 20 // 修改ptr指向的值为20
fmt.Println(num) // 输出num,这里输出20

6.4. 指针作为函数参数:

在Go语言中,你可以将指针作为函数的参数传递,以便在函数内部修改外部变量的值。

goCopy code
func changeValue(ptr *int) {
    *ptr = 30 // 修改ptr所指向的值为30
}

changeValue(&num)
fmt.Println(num) // 输出num,这里输出30

6.5. 空指针:

当指针不指向任何有效的地址时,它被称为空指针,通常用nil表示。

goCopy code
var ptr *int // 声明一个空指针
if ptr == nil {
    fmt.Println("ptr是空指针")
}

6.6. new函数:

你可以使用new函数来创建一个指向某种类型的指针,并为其分配内存空间。

goCopy code
ptr := new(int) // 创建一个新的整数类型指针
*ptr = 50 // 给ptr指向的内存地址赋值为50
fmt.Println(*ptr) // 输出ptr指向的值,这里输出50

6.7. 指针的传递:

在Go语言中,函数参数传递默认是值传递。但是当你传递指针作为参数时,你传递的是变量的地址,从而可以在函数内部修改该变量的值,实现了引用传递。

7.结构体:

在Go语言中,结构体(struct)是一种用户定义的复合类型,它可以包含不同类型的数据字段。结构体允许你将相关的数据组织在一起,并且可以方便地对这些数据进行操作。

7.1. 定义结构体:

goCopy code
type Person struct {
    Name string
    Age  int
    City string
}
  • type: 关键字用于声明新的类型。

  • Person: 结构体类型的名称。

  • Name, Age, City: 结构体的字段,每个字段包含一个名称和类型。

7.2. 创建结构体实例:

goCopy code
person := Person{Name: "Alice", Age: 30, City: "New York"}

7.3. 访问结构体字段:

goCopy code
fmt.Println(person.Name) // 输出结构体字段的值,这里输出"Alice"

7.4. 匿名结构体:

goCopy code
student := struct {
    Name  string
    Grade int
}{
    Name:  "Bob",
    Grade: 85,
}

7.5. 结构体嵌套:

goCopy code
type Address struct {
    Street  string
    City    string
    Country string
}

type Person struct {
    Name    string
    Age     int
    Address Address
}

person := Person{
    Name: "John",
    Age:  25,
    Address: Address{
        Street:  "123 Main St",
        City:    "Anytown",
        Country: "USA",
    },
}

7.6. 结构体方法:

goCopy code
type Circle struct {
    Radius float64
}

func (c Circle) Area() float64 {
    return math.Pi * c.Radius * c.Radius
}

circle := Circle{Radius: 5}
area := circle.Area() // 计算圆的面积

7.7. 指针接收者的方法:

goCopy code
func (c *Circle) Scale(factor float64) {
    c.Radius = c.Radius * factor
}

circle := Circle{Radius: 5}
circle.Scale(2) // 将圆的半径放大两倍

7.8. 结构体与JSON:

goCopy code
type User struct {
    Name  string `json:"name"`
    Email string `json:"email"`
}

user := User{Name: "Alice", Email: "alice@example.com"}
jsonData, _ := json.Marshal(user) // 将结构体转换为JSON

7.9结构体标签:

结构体标签是在结构体字段后面的附加信息,常用于存储字段的元数据。

goCopy code
type User struct {
    Name  string `json:"name" xml:"name"`
    Email string `json:"email" xml:"email"`

8.异常处理:

8.1 defer:

defer 是 Go 语言中的一个关键字,用于在函数执行完毕之前延迟(defer)执行某个语句或函数调用。defer 通常用于确保某些操作在当前函数执行结束时得以执行,无论函数是正常返回还是发生了 panic 异常。

8.1.1 基本语法:

goCopy codefunc someFunction() {
    // 在函数执行结束前执行这条语句
    defer fmt.Println("This will be executed at the end.")

    // 函数体
    // ...
}

延迟执行多个语句:

goCopy codefunc multipleDefers() {
    defer fmt.Println("This will be executed third.")
    defer fmt.Println("This will be executed second.")
    defer fmt.Println("This will be executed first.")

    // 函数体
    // ...
}

8.1.2 常见用途:

  1. 资源释放:确保打开的文件、网络连接或其他资源在函数执行结束时得到释放。

    goCopy codefunc processFile() {
        file := openFile("example.txt")
        defer file.Close() // 确保文件在函数执行结束时关闭
        // 其他文件处理操作
        // ...
    }
  2. 锁释放:在使用互斥锁时,确保在函数执行结束时释放锁,避免死锁情况。

    goCopy codefunc someCriticalSection() {
        mu.Lock()
        defer mu.Unlock() // 确保在函数执行结束时释放锁
        // 临界区代码
        // ...
    }
  3. 跟踪代码执行时间:在函数执行开始和结束时记录时间,用于性能分析。

    goCopy codefunc someOperation() {
        start := time.Now()
        defer func() {
            fmt.Println("Time taken:", time.Since(start))
        }()
        // 函数体
        // ...
    }

需要注意的是,defer 中的函数参数会在 defer 语句执行时被求值,而不是在函数返回时。因此,如果被推迟的函数是一个有参数的函数,那么这些参数会在 defer 语句出现时求值。

当有多个defer 函数时,执行顺序遵循栈的先进后出原则,也就是从程序末尾倒着执行。

8.2 panic:

panic 是 Go 语言中用于引发运行时异常的内建函数。当程序执行到一个无法继续执行的严重错误时,可以触发 panic。一旦发生 panic,当前函数的执行将被终止,但是会沿着函数调用链向上传递,执行每一层的 defer,然后程序终止。

使用:

goCopy codefunc example() {
    // ...

    // 如果发生了无法处理的错误,可以触发 panic
    panic("Something went wrong!")

    // 这里的代码不会执行,因为上面的 panic 终止了函数的执行
}

8.3 recover:

为了在程序 panic 后能够进行一些清理工作或记录错误信息,Go 提供了 recover 函数。recover 只有在 defer 函数中调用时才会生效,用于捕获 panic 引起的运行时错误,防止程序直接崩溃。

goCopy codefunc example() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("Recovered from panic:", r)
            // 这里可以进行一些清理工作或记录错误信息
        }
    }()

    // ...

    // 如果发生了无法处理的错误,可以触发 panic
    panic("Something went wrong!")

    // 这里的代码不会执行,因为上面的 panic 终止了函数的执行
}

在上述代码中,defer 中的匿名函数通过 recover 捕获了可能发生的 panic,然后程序可以继续执行后续的清理或处理逻辑。

虽然 recover 可以用于避免程序崩溃,但在正常情况下,它并不是 Go 语言推荐的错误处理方式。更常见的做法是使用错误值(error)来处理错误,而不是通过 panic。 panicrecover 通常用于处理一些不可恢复的错误,例如程序逻辑出现了无法修复的问题。

9.依赖管理:

9.1 依赖:

依赖指各种开发包,在开发项目时,一般利用已经封装好的、经过验证的开发组件或开发工具来提升研发的效率。

在实际工程中,项目一般比较复杂,我们不可能基于标准库0-1编码搭建,而更多的是考虑业务逻辑的实现,像其他的涉及框架、日志、driver、以及 collection 等一系列依赖都会通过 sdk 的方式引入,因此对依赖包的管理就显得尤为重要。

Go 的依赖管理主要经历了三个阶段:GOPATH,GO Vendor,Go Module.

9.2 GOPATH:

GOPATH 是 Go 语言中使用的一个环境变量,它使用绝对路径提供项目的工作目录(也称为工作区), 是存放 Golang 项目代码的文件路径, GOPATH 适合处理大量 Go语言源码、多个包组合而成的复杂工程。

通过

go env GOPATH
go env | grep GOPATH

查看GOPATH的位置

image-20240129104352577

GOPATH目录下一般有三个文件夹:

go
├── bin
├── pkg
└── src
    ├── github.com
    ├── golang.org
    ├── google.golang.org
    ....

其中,bin存放编译生成的二进制文件,比如 执行命令 go get github.com/google/gops,bin目录会生成 gops 的二进制文件。

pkg主要包括三个文件夹:

  1. XX_amd64: 其中 XX 是目标操作系统,比如 mac 系统对应的是darwin_amd64, linux 系统对应的是 linux_amd64,存放的是.a结尾的文件。

  2. mod: 当开启go Modules 模式下,go get命令缓存下依赖包存放的位置

  3. sumdb: go get命令缓存下载的checksum数据存放的位置

src 存放项目源代码.

因此在使用 GOPATH 模式下,我们需要将应用代码存放在固定的$GOPATH/src目录下,并且如果执行go get来拉取外部依赖会自动下载并安装到$GOPATH目录下。

9.2.1 存在的问题:

  • 必须指定目录

  • go get 命令的时候,无法指定获取的版本

  • 引用第三方项目的时候,无法处理v1、v2、v3等不同版本的引用问题,因为在GOPATH 模式下项目路径都是 github.com/foo/project

  • 无法同步一致第三方版本号,在运行 Go 应用程序的时候,无法保证其它人与所期望依赖的第三方库是相同的版本。

  • 每个如果项目都需要同样的依赖,那么就会在不同的GoPath的src中下载大量重复的第三方依赖包,这同样会占用大量的磁盘空间

一种解决方式是对不同的项目配置不同的GOPATH.

9.3 Go Vendor:

go vendor 是go引入管理包依赖的方式,在GOPATH的基础上进行修改,1.5版本开始引进,1.6正式引进。

其实就是将依赖的包,特指外部包,复制到当前工程下的vendor目录下,这样go build的时候,go会优先从vendor目录寻找依赖包。

优点:解决了不同项目依赖同一个包的不同版本问题。

存在的问题:

  • 无法控制依赖的版本

  • 更新项目又可能出现依赖冲突,导致编译出错

9.4 Go Module:

Go Module 是 Go 语言的依赖解决方案,同样是在GOPATH的基础上进行修改,发布于 Go1.11,成长于 Go1.12,丰富于 Go1.13,正式于 Go1.14 推荐在生产上使用。

Go Module就是一个用来取代GoPath的Golang的工作空间。

Go Module将依赖包版本信息和程序代码本身实现分离管理,每个Go Module都会有一个go.mod文件,该文件包含了Module的依赖包列表以及对应的版本信息,当一个Module需要引用其他依赖包时,会根据go.mod文件中的信息去下载对应的依赖包和对应版本的代码供程序使用。

Go语言提供了 GO111MODULE 这个环境变量来作为 Go modules 的开关:

go env GO111MODULE
go env | grep GO111MODULE

它共有三个参数:

  1. auto:Go 命令行工具在同时满足以下两个条件时使用 Go Modules:当前目录不在 GOPATH/src/ 下;在当前目录或上层目录中存在 go.mod 文件

  2. off:Go 命令行工具从不使用 Go Modules。相反,它查找 vendor 目录和 GOPATH 以查找依赖项。

  3. on:go 会忽略 GOPATH 和 vendor 文件夹,只根据 go.mod 下载依赖。Go 命令行工具只使用 Go Modules,从不咨询 GOPATH。GOPATH 不再作为导入目录,但它仍然存储下载的依赖项(GOPATH/pkg/mod/)和已安装的命令(GOPATH/bin/),只是移除了 GOPATH/src/.

开启 go mod 模式后,你的项目代码想放哪里就放哪里,你想引用哪个版本就用哪个版本.

另外,go module 会把下载到本地的依赖包会以类似下面的形式保存在 $GOPATH/pkg/mod目录下,每个依赖包都会带有版本号进行区分,这样就允许在本地存在同一个包的多个不同版本。

mod
├── cache
├── cloud.google.com
├── github.com
    	└──google
          ├── uuid@v1.1.2
          ├── uuid@v1.3.0
          └── uuid@v1.3.1

这样依赖,既解决了原来只能局限在GoPath目录src包下进行编程的问题,也解决了第三方依赖包难以管理和重复依赖占用磁盘空间的问题。

总而言之,在引入GoModule之后,我们不会直接在GoPath目录进行编程,而是把GoPath作为一个第三方依赖包的仓库,我们真正的工作空间在GoModule目录下。

简单来说,go module的核心思想就是代码库和依赖库分离进行管理,通过特定的文件进行依赖的管理和引用。

9.4.1 常用命令:

go mod init  # 初始化go.mod
go mod tidy  # 更新依赖文件
go mod download  # 下载依赖文件
go mod vendor  # 将依赖转移至本地的vendor文件
go mod edit  # 手动修改依赖文件
go mod graph  # 打印依赖图
go mod verify  # 校验依赖

9.4.2 go.sum:

Go 并没有一个中央仓库来保证包不会被篡改。

在构建过程中go命令会下载go.mod中的依赖包,下载的依赖包会缓存在本地,以便下次构建。 考虑到下载的依赖包有可能是被黑客恶意篡改的,以及缓存在本地的依赖包也有被篡改的可能,单单一个go.mod文件并不能保证一致性构建。

go.sum的出现正是为了解决这个问题.

go.sum文件,用于记录每个依赖包的哈希值,在构建时,如果本地的依赖包hash值与go.sum文件中记录得不一致,则会拒绝构建。

github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=  
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=

9.5 Go Proxy:

这个环境变量主要是用于设置 Go 模块代理(Go module proxy),其作用是用于使 Go 在后续拉取模块版本时能够脱离传统的 VCS 方式,直接通过镜像站点来快速拉取。

GOPROXY 的默认值是:https://proxy.golang.org,direct,这有一个很严重的问题,就是 proxy.golang.org 在国内是无法访问的,因此这会直接卡住你的第一步,所以你必须在开启 Go modules 的时,同时设置国内的 Go 模块代理,执行如下命令:

go env -w GOPROXY=https://goproxy.cn,direct

GOPROXY 的值是一个以英文逗号 “,” 分割的 Go 模块代理列表,允许设置多个模块代理,假设你不想使用,也可以将其设置为 “off” ,这将会禁止 Go 在后续操作中使用任何 Go 模块代理。

“direct” 是一个特殊指示符,用于指示 Go 回源到模块版本的源地址去抓取(比如 GitHub 等),场景如下:当值列表中上一个 Go 模块代理返回 404 或 410 错误时,Go 自动尝试列表中的下一个,遇见 “direct” 时回源,也就是回到源地址去抓取,而遇见 EOF 时终止并抛出类似 “invalid version: unknown revision…” 的错误。

10.内存模型:

10.1 前置知识:

性能越大的计算机硬件的合理利用和分配就越重要。而内存是最昂贵的,同时也是速度最快的存储,性能要求我们将大部分程序逻辑临时用的数据,全部都存在内存之中.

操作系统就会对内存进行非常详细的管理。其次基于操作系统的基础上,不同语言的内存管理机制也应允而生.

10.1.1 虚拟内存:

计算机对于内存真正的载体是“内存条”,这个是实打实的物理硬件容量,所以,在操作系统中,我们定义为这部门的容量叫“物理内存”。

物理内存的布局实际上就是一个内存大“数组”。

而使用物理内存管理动态占用内存的程序是非常困难的,因此出现了虚拟内存.

image-20240130110856665

用户程序(进程)只能使用虚拟的内存地址来获取数据,系统会将这个虚拟地址翻译成实际的物理地址。

并且这里面每一个程序统一使用一套连续虚拟地址,比如 0x 0000 0000 ~ 0x ffff ffff。从程序的角度来看,它觉得自己独享了一整块内存。不用考虑访问冲突的问题。系统会将虚拟地址翻译成物理地址,从内存上加载数据。

10.2 分区:

程序中的数据和变量都会被分配到程序所在的虚拟内存中,内存空间包含两个重要区域:栈区(Stack)和堆区(Heap)。

函数调用的参数、返回值以及局部变量大都会被分配到栈上,这部分内存会由编译器进行管理;

不同编程语言使用不同的方法管理堆区的内存,C++ 等编程语言会由工程师主动申请和释放内存,Go 以及 Java 等编程语言会由工程师和编译器共同管理,堆中的对象由内存分配器分配并由垃圾收集器回收。

image-20240130111124086

  • 栈区(Stack):空间较小,要求数据读写性能高,数据存放时间较短暂。由编译器自动分配和释放,存放函数的参数值、函数的调用流程方法地址、局部变量等(局部变量如果产生逃逸现象,可能会挂在在堆区)

  • 堆区(heap):空间充裕,数据存放时间较久。一般由开发者分配及释放(但是Golang中会根据变量的逃逸现象来选择是否分配到栈上或堆上),启动Golang的GC由GC清除机制自动回收。

  • 全局区-[静态]全局变量区:全局变量的开辟是在程序在main之前就已经放在内存中。而且对外完全可见。即作用域在全部代码中,任何同包代码均可随时使用,在变量会搞混淆,而且在局部函数中如果同名称变量使用:=赋值会出现编译错误。全局变量最终在进程退出时,由操作系统回收。

  • 全局区-常量区:常量区也归属于全局区,常量为存放数值字面值单位,即不可修改。或者说的有的常量是直接挂钩字面值的。在golang中,常量是无法取出地址的,因为字面量符号并没有地址而言。

10.3 分配:

首先明确几个重要概念:

  1. 内存池mheap Golang 的程序在启动之初,会一次性从操作系统那里申请一大块内存作为内存池。这块内存空间会放在一个叫 mheap 的 struct 中管理,mheap 负责将这一整块内存切割成不同的区域,并将其中一部分的内存切割成合适的大小,分配给用户使用。

  2. 内存页page 一块 8K 大小的内存空间。Go 与操作系统之间的内存申请和释放,都是以 page 为单位的。

  3. 内存块span 一个或多个连续的 page 组成一个 span。

  4. 空间规格sizeclass 每个 span 都带有一个 sizeclass,标记着该 span 中的 page 应该如何使用。

  5. 对象object 用来存储一个变量数据内存空间,一个 span 在初始化时,会被切割成一堆等大的 object。假设 object 的大小是 16B,span 大小是 8K,那么就会把 span 中的 page 就会被初始化 8K / 16B = 512 个 object。所谓内存分配,就是分配一个 object 出去。

10.3.1 稀疏内存:

稀疏内存是 Go 语言在 1.11 中提出的方案.

所有的 Go 语言程序都会在启动时初始化如下图所示的内存布局:

image-20240130111620199

Go内存管理基于TCMalloc,使用连续虚拟地址,以页(8k)为单位、多级缓存进行管理;在分配内存时,需要对size进行对齐处理,根据best-fit找到合适的mspan,对未用完的内存还会拆分成其他大小的mspan继续使用。

在new一个object时(忽略逃逸分析),根据object的size做不同的分配策略:

  1. 极小对象(size<16byte)直接在当前P的mcache上的tiny缓存上分配;

  2. 小对象(16byte <= size <= 32k)在当前P的mcache上对应slot的空闲列表中分配,无空闲列表则会继续向mcentral申请(还是没有则向mheap申请);

  3. 大对象(size>32k)直接通过mheap申请。

image-20240130112304285

10.3.2 数据结构:

image-20240130145023096

10.3.2.1 mspan:

mspan是 内存管理的基本单元,该结构体中包含 nextprev 两个字段,它们分别指向了前一个和后一个mspan,每个mspan 都管理 npages 个大小为 8KB 的页,一个span 是由多个page组成的,这里的页不是操作系统中的内存页,它们是操作系统内存页的整数倍。

page是内存存储的基本单元,“对象”放到page中.

type mspan struct {
    next *mspan // 后指针
    prev *mspan // 前指针
    startAddr uintptr // 管理页的起始地址,指向page
    npages    uintptr // 页数
    spanclass   spanClass // 规格
    ...
}

Go有68种不同大小的spanClass,用于小对象的分配.

10.3.2.2 mcache:

mcache管理线程在本地缓存的mspan,每个goroutine绑定的P都有一个mcache字段.

10.3.2.3 mcentral:

mcentral管理全局的mspan供所有线程使用,全局mheap变量包含central字段,每个 mcentral 结构都维护在mheap结构内.

每个mcentral管理一种spanClass的mspan,并将有空闲空间和没有空闲空间的mspan分开管理。partial和 full的数据类型为spanSet,表示 mspans集,可以通过pop、push来获得mspans.

10.3.2.4 mheap:

mheap管理Go的所有动态分配内存,可以认为是Go程序持有的整个堆空间,全局唯一.

var mheap_ mheap
type mheap struct {
    lock      mutex    // 全局锁
    pages     pageAlloc // 页面分配的数据结构
    allspans []*mspan // 所有通过 mheap_ 申请的mspans
        // 堆
    arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena
    
        // 所有中心缓存mcentral
    central [numSpanClasses]struct {
        mcentral mcentral
        pad      [cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize]byte
    }
    ...
}

所有mcentral的集合则是存放于mheap中的。mheap里的arena 区域是堆内存的抽象,运行时会将 8KB 看做一页,这些内存页中存储了所有在堆上初始化的对象。运行时使用二维的 runtime.heapArena 数组管理所有的内存,每个 runtime.heapArena 都会管理 64MB 的内存。

当申请内存时,依次经过 mcachemcentral 都没有可用合适规格的大小内存,这时候会向 mheap 申请一块内存。然后按指定规格划分为一些列表,并将其添加到相同规格大小的 mcentral非空闲列表 后面.

10.3.3 分配流程:

  • 首先通过计算使用的大小规格

  • 然后使用mcache中对应大小规格的块分配。

  • 如果mcentral中没有可用的块,则向mheap申请,并根据算法找到最合适的mspan

  • 如果申请到的mspan 超出申请大小,将会根据需求进行切分,以返回用户所需的页数。剩余的页构成一个新的 mspan 放回 mheap 的空闲列表。

  • 如果 mheap 中没有可用 span,则向操作系统申请一系列新的页(最小 1MB)

image-20240130145929736

10.4 内存管理模型:

Go 内存管理也是一个金字塔结构:

image-20240130112441781

将有限的计算资源布局成金字塔结构,再将数据从热到冷分为几个层级,放置在金字塔结构上。调度器不断做调整,将热数据放在金字塔顶层,冷数据放在金字塔底层。

这种设计利用了计算机的局部性原理,认为冷热数据的交替是缓慢的。所以最怕的就是,数据访问出现冷热骤变。在操作系统上我们称这种现象为内存颠簸,系统架构上通常被说成是缓存穿透。其实都是一个意思,就是过度的使用了金字塔低端的资源。

10.5 内存逃逸:

10.5.1 概念:

在一段程序中,每一个函数都会有自己的内存区域存放自己的局部变量、返回地址等,这些内存会由编译器在栈中进行分配,每一个函数都会分配一个栈桢,在函数运行结束后进行销毁,但是有些变量我们想在函数运行结束后仍然使用它,那么就需要把这个变量在堆上分配,这种从”栈”上逃逸到”堆”上的现象就成为内存逃逸。

在栈上分配的地址,一般由系统申请和释放,不会有额外性能的开销,比如函数的入参、局部变量、返回值等。在堆上分配的内存,如果要回收掉,需要进行 GC,那么GC 一定会带来额外的性能开销。编程语言不断优化GC算法,主要目的都是为了减少 GC带来的额外性能开销,变量一旦逃逸会导致性能开销变大。

10.5.2 逃逸机制:

编译器会根据变量是否被外部引用来决定是否逃逸:

  1. 如果函数外部没有引用,则优先放到栈中;

  2. 如果函数外部存在引用,则必定放到堆中;

  3. 如果栈上放不下,则必定放到堆上;

逃逸分析也就是由编译器决定哪些变量放在栈,哪些放在堆中,通过编译参数-gcflag=-m可以查看编译过程中的逃逸分析.

10.5.2.1 指针逃逸:

package main

func escape1() *int {
    var a int = 1
    return &a
}

func main() {
    escape1()
}

这个函数返回了一个int型的指针.作为局部变量,函数退出了但是变量内存不能回收,因此只能分配到堆上.

通过go build -gcflags=-m main.go查看逃逸情况:

./main.go:4:6: moved to heap: a

10.5.2.2 栈空间不足:

package main

func escape2() {
    s := make([]int, 0, 10000)
    for index, _ := range s {
        s[index] = index
    }
}

func main() {
    escape2()
}

当栈空间足够时,不会发生逃逸,但是当变量过大时,已经完全超过栈空间的大小时,将会发生逃逸到堆上分配内存。局部变量s占用内存过大,编译器会将其分配到堆上.

10.5.2.3 变量大小不确定:

package main

func escape3() {
    number := 10
    s := make([]int, number) // 编译期间无法确定slice的长度
    for i := 0; i < len(s); i++ {
        s[i] = i
    }
}

func main() {
    escape3()
}

编译期间无法确定slice的长度,这种情况为了保证内存的安全,编译器也会触发逃逸,在堆上进行分配内存。

直接s := make([]int, 10)不会发生逃逸

10.5.2.4 动态类型:

package main

import "fmt"

func escape4() {
    fmt.Println(1111)
}

func main() {
    escape4()
}

空接口 interface{} 可以表示任意的类型,如果函数参数为 interface{},编译期间很难确定其参数的具体类型,也会发生逃逸。

fmt.Println(a …interface{})函数参数为interface,编译器不确定参数的类型,会将变量分配到堆上.

10.5.2.5 闭包引用:

package main

func escape5() func() int {
    var i int = 1
    return func() int {
        i++
        return i
    }
}

func main() {
    escape5()
}

闭包函数中局部变量i在后续函数是继续使用的,编译器将其分配到堆上.

10.5.3 总结:

  1. 栈上分配内存比在堆中分配内存效率更高

  2. 栈上分配的内存不需要 GC 处理,而堆需要

  3. 逃逸分析目的是决定内分配地址是栈还是堆

  4. 逃逸分析在编译阶段完成

因为无论变量的大小,只要是指针变量都会在堆上分配,所以对于小变量我们还是使用传值效率(而不是传指针)更高一点。

10.6 内存对齐:

10.6.1 概念:

为了能让CPU可以更快的存取到各个字段,Go编译器会帮你把struct结构体做数据的对齐。所谓的数据对齐,是指内存地址是所存储数据大小(按字节为单位)的整数倍,以便CPU可以一次将该数据从内存中读取出来。 编译器通过在结构体的各个字段之间填充一些空白已达到对齐的目的。

优点

  1. 提高可移植性,有些CPU可以访问任意地址上的任意数据,而有些CPU只能在特定地址访问数据,因此不同硬件平台具有差异性,这样的代码就不具有移植性,如果在编译时,将分配的内存进行对齐,这就具有平台可以移植性了

  2. 提高内存的访问效率,32位CPU下一次可以从内存中读取32位(4个字节)的数据,64位CPU下一次可以从内存中读取64位(8个字节)的数据,这个长度也称为CPU的字长。CPU一次可以读取1个字长的数据到内存中,如果所需要读取的数据正好跨了1个字长,那就得花两个CPU周期的时间去读取了。因此在内存中存放数据时进行对齐,可以提高内存访问效率。

缺点

  1. 存在内存空间的浪费,实际上是空间换时间

10.6.2 对齐系数:

不同硬件平台占用的大小和对齐值都可能是不一样的,每个特定平台上的编译器都有自己的默认”对齐系数”,32位系统对齐系数是4,64位系统对齐系数是8

不同类型的对齐系数也可能不一样,使用Go语言中的unsafe.Alignof函数可以返回相应类型的对齐系数,对齐系数都符合2^n这个规律,最大也不会超过8.

10.6.3 对齐原则:

  1. 结构体变量中成员的偏移量必须是成员大小的整数倍

  2. 整个结构体的地址必须是最大字节的整数倍(结构体的内存占用是1/4/8/16byte…)

11.垃圾回收:

11.1 概念:

垃圾回收也称为GC(Garbage Collection),是一种自动内存管理机制.

在应用程序中会使用到两种内存,分别为堆(Heap)和栈(Stack),GC负责回收堆内存,而不负责回收栈中的内存:

栈是线程的专用内存,专门为了函数执行而准备的,存储着函数中的局部变量以及调用栈,函数执行完后,编译器可以将栈上分配的内存可以直接释放,不需要通过GC来回收。

堆是程序共享的内存,需要GC进行回收在堆上分配的内存。

现代高级编程语言管理内存的方式分为两种:自动和手动,像C、C++ 等编程语言使用手动管理内存的方式,工程师编写代码过程中需要主动申请或者释放内存;而 PHP、Java 和 Go 等语言使用自动的内存管理系统,有内存分配器和垃圾收集器来代为分配和回收内存,其中垃圾收集器就是我们常说的GC。

11.2 分类:

目前比较常见的垃圾回收算法有三种:

  1. 引用计数:为每个对象维护一个引用计数,当引用该对象的对象销毁时,引用计数 -1,当对象引用计数为 0 时回收该对象。

    • 代表语言:PythonPHPSwift

    • 优点:对象回收快,不会出现内存耗尽或达到某个阈值时才回收。

    • 缺点:不能很好的处理循环引用,而实时维护引用计数也是有损耗的。

  2. 分代收集:按照对象生命周期长短划分不同的代空间,生命周期长的放入老年代,短的放入新生代,不同代有不同的回收算法和回收频率。

    • 代表语言:Java

    • 优点:回收性能好

    • 缺点:算法复杂

  3. 标记-清除:从根变量开始遍历所有引用的对象,标记引用的对象,没有被标记的进行回收。

    • 代表语言:Golang(三色标记法)

    • 优点:解决了引用计数的缺点。

    • 缺点:需要 STW,暂时停掉程序运行。

11.3 三色标记法:

Go 语言采用的是标记清除算法,并在此基础上使用了三色标记法和混合写屏障技术,GC过程和其他用户goroutine可并发运行,但需要一定时间的STW。在Go 1.5版本开始使用。

11.3.1 概念:

三色,对应了垃圾回收过程中对象的三种状态:

  • 灰色:对象还在标记队列中等待

  • 黑色:对象已被标记,gcmarkBits 对应位为 1 (该对象不会在本次 GC 中被回收)

  • 白色:对象未被标记,gcmarkBits 对应位为 0 (该对象将会在本次 GC 中被清理)

11.3.2 流程:

  1. 创建白、灰、黑 三个集合

  2. 将所有对象放入白色集合中

  3. 遍历所有root对象,把遍历到的对象从白色集合放入灰色集合 (这里放入灰色集合的都是根节点的对象)

  4. 遍历灰色集合,将灰色对象引用的对象从白色集合放入灰色集合,自身标记为黑色

  5. 重复步骤4,直到灰色中无任何对象.

  6. 回收所有白色对象.

11.3.2.1 root对象:

根对象在垃圾回收的术语中又叫做根集合,它是垃圾回收器在标记过程时最先检查的对象,包括:

  • 全局变量:程序在编译期就能确定的那些存在于程序整个生命周期的变量。

  • 执行栈:每个 goroutine 都包含自己的执行栈,这些执行栈上指向堆内存的指针。

  • 寄存器:寄存器的值可能表示一个指针,参与计算的这些指针可能指向某些赋值器分配的堆内存区块。

11.3.3 屏障机制:

三色并发标记法强依赖STW。因为如果不暂停程序, 程序的逻辑改变对象引用关系, 这种动作如果在标记阶段做了修改,会影响标记结果的正确性。

经过分析,有两种情况是不想在gc过程中发生的:

  • 一个白色对象被黑色对象引用 (白色被挂在黑色下)

  • 灰色对象与它之间的可达关系的白色对象遭到破坏 (灰色同时丢了该白色)

为了防止这种现象的发生,最简单的方式就是STW,直接禁止掉其他用户程序对对象引用关系的干扰,但是STW的过程有明显的资源浪费,对所有的用户程序都有很大影响,如何能在保证对象不丢失的情况下合理的尽可能的提高GC效率,减少STW时间呢?

使用一个机制,来破坏上面的两个条件就可以了。

11.3.3.1 插入屏障:

在A对象引用B对象的时候,B对象被标记为灰色。(将B挂在A下游,B必须被标记为灰色)。

缺点:结束时需要STW来重新扫描栈,标记栈上引用的白色对象的存活

11.3.3.2 删除屏障:

被删除的对象,如果自身为灰色或者白色,那么被标记为灰色。

缺点:一个对象的引用被删除后,即使没有其他存活的对象引用它,它仍然会活到下一轮,会产生很大冗余扫描成本,且降低了回收精度

11.3.3.3 混合写屏障:

Go V1.8版本引入了混合写屏障机制(hybrid write barrier),避免了对栈re-scan的过程,极大的减少了STW的时间。结合了两者的优点。

  • GC开始将栈上的对象全部扫描并标记为黑色。

  • GC期间,任何在栈上创建的新对象,均为黑色。

  • 被删除的对象标记为灰色。

  • 被添加的对象标记为灰色。

11.4 工作流程:

  1. 标记准备(Mark Setup):打开写屏障(Write Barrier),需 STW(stop the world)

  2. 标记开始(Marking):使用三色标记法并发标记 ,与用户程序并发执行

  3. 标记终止(Mark Termination):对触发写屏障的对象进行重新扫描标记,关闭写屏障(Write Barrier),需 STW(stop the world)

  4. 清理(Sweeping):将需要回收的内存归还到堆中,将过多的内存归还给操作系统,与用户程序并发执行

image-20240130154614284

11.5 触发时机:

11.5.1 主动触发:

调用 runtime.GC() 方法,触发 GC.

11.5.2 被动触发:

  • 定时触发,该触发条件由 runtime.forcegcperiod 变量控制,默认为 2 分 钟。当超过两分钟没有产生任何 GC 时,触发 GC

  • 根据内存分配阈值触发,该触发条件由环境变量GOGC控制,默认值为100(100%),当前堆内存占用是上次GC结束后占用内存的2倍时,触发GC

12.CPU调度:

12.1 概念:

12.1.1 单CPU核心-单进程:

在单机时代是没有多线程、多进程、协程这些概念的。早期的操作系统都是顺序执行.

这种顺序执行的方式有两个显著的问题:

  • cpu只能一个一个任务的处理

  • 如果发生阻塞,会带来CPU时间的巨大浪费.

12.1.2 单CPU核心-多进程:

一个基本的事实前提一个CPU在一个瞬间只能处理一个任务.

为了更好的利用CPU的资源,发展出了多线程操作系统.

一开始这种操作系统使用的是时间片轮询算法.

每个进程会被操作系统分配一个时间片,即每次被 CPU 选中来执行当前进程所用的时间。时间一到,无论进程是否运行结束,操作系统都会强制将 CPU 这个资源转到另一个进程去执行。

image-20240130164224704

这样做的好处是可以充分的利用CPU的性能,让它在当前线程阻塞(比如各种IO)时可以去操作其他线程.

但是也带来了一些问题,例如时间片切换需要花费额外的开销,线程的数量越多,切换成本就越大,也就越浪费.

进程拥有太多的资源,进程的创建、切换、销毁,都会占用很长的时间,CPU虽然利用起来了,但如果进程过多,CPU有很大的一部分都被用来进行进程调度了.

12.1.3 单CPU核心-多线程:

现在需要解决进程的上下文切换问题.

维护进程的系统开销较大,如创建进程时,分配资源、建立 PCB;终止进程时,回收资源、撤销 PCB;进程切换时,保存当前进程的状态信息;

那到底如何解决呢?

需要有一种新的实体,满足以下特性:

  • 实体之间可以并发运行;

  • 实体之间共享相同的地址空间;

这个新的实体,就是线程( Thread ),线程之间可以并发运行且共享相同的地址空间。

有了线程以后,CPU在切换时就可以在线程之间来回切换,大大减小了上下文切换资源的浪费.

12.1.4 多CPU核心-多线程:

在软件设计不断迭代的同时,硬件设备也在进步,当出现了多核心CPU之后,各个进程及其线程可以在不同CPU内核上执行。

image-20240130170630911

12.1.4.1 并发与并行:

并发是一个CPU处理器同时处理多个线程任务.可以在一个CPU处理器和多个CPU处理器系统中都存在.(一对多)

并行是多个CPU处理器同时处理多个线程任务.只在多个CPU处理器系统中存在.(多对多)

12.1.5 线程状态:

12.1.5.1 引入:

不论是在单核心CPU还是在多核心CPU中,只要使用多线程模型,就必然存在多个线程之间对CPU的抢占和上下文切换.

而如果我们想要在多线程模型的基础上进一步的提高切换的效率,那么就不得不考虑上下文切换的问题.

12.1.5.2 用户态与内核态:

我们知道,线程调度CPU,事实上就是在执行一些跟自己有关的CPU指令,在CPU的所有指令中,有一些指令是非常危险的,如果错用,将导致整个系统崩溃。比如:清内存、设置时钟等。为了安全性和稳定性,这些关键指令是不能被所有的线程使用的。

CPU将指令分为特权指令和非特权指令,对于那些危险的指令,只允许操作系统及其相关模块使用,普通的应用程序只能使用那些不会造成灾难的指令。

而如果一个线程想要进行这些操作,就必须去访问操作系统的一些接口来完成,

而根据一个线程是否去进行这些操作,我们可以把线程划分为两个状态:用户态内核态.

  • 用户态:不能直接使用系统资源,也不能改变 CPU 的工作状态,并且只能访问这个用户程序自己的存储空间.

  • 内核态:可以使用计算机所有的硬件资源.

当一个任务(进程)执行系统调用而陷入内核代码中执行时,我们就称进程处于内核运行态(或简称为内核态)。此时处理器处于特权级最高的(0级)内核 代码中执行。当进程处于内核态时,执行的内核代码会使用当前进程的内核栈。每个进程都有自己的内核栈。当进程在执行用户自己的代码时,则称其处于用户运行态(用户态)。

12.1.6 协程:

一个线程需要在内核态用户态之间进行切换,并且切换是受到操作系统控制的,可能需要等待多个时间片才能切换到内核态并调用操作系统底层的接口.

那么我们是否可以用两个线程分别处理这两种状态呢?

两个线程之间再做好绑定,当用户线程将任务提交给内核线程后,就可以不用堵塞了,可以去执行其他的任务了.

我们来给用户线程换个名字——协程(co-runtine)

那么此时,一个线程如下:

image-20240130172736256

总体上是这样的:

image-20240130173745106

这种最容易实现,协程的调度都由 CPU 完成了.

12.1.7 协程调度器:

而如果只有一个协程的话,那么当这个线程进入内核态之后,这个线程所属的CPU去干什么呢?它也没有其他任务可以做啊,于是我们自然而然想到多对一的关系,此时对于一个线程来说:

image-20240130173021329

image-20240130173822349

但是此时我们不得不考虑到阻塞问题:如果其中一个问题在提交任务的过程中,堵塞住了,就会影响其他协程的工作.

此时我们就需要一个工具,用来管理这些协程和线程(此时的线程为专指内核态的线程)之间的绑定关系,于是调度器出现了.

image-20240130173428399

image-20240130173848737

可以简单总结为:

含义

缺点

单进程时代

每个程序就是一个进程,直到一个程序运行完,才能进行下一个进程

1. 无法并发,只能串行 2. 进程阻塞所带来的 CPU 时间浪费

多进程/线程时代

一个线程阻塞, cpu 可以立刻切换到其他线程中去执行

1. 进程/线程占用内存高 2. 进程/线程上下文切换成本高

协程时代

协程(用户态线程)绑定线程(内核态线程),cpu调度线程执行

1. 实现起来较复杂,协程和线程的绑定依赖调度器算法

12.2 GM模型:

GM模型是go语言早期的内存调度模型.其核心思想为:

  • 对于协程,维护一个全局的协程队列,用户态每次创建协程,即放入全局任务队列中

  • 池化技术创建一批内核态线程,从全局任务队列中获取用户任务执行,执行结束or执行时间片结束后再次放入队列等待下次调度。

image-20240130175112517

存在的问题:

  1. 全局队列的锁竞争,当 M 从全局队列中添加或者获取 G 的时候,都需要获取队列锁,导致激烈的锁竞争

  2. M 转移 G 增加额外开销,当 M1 在执行 G1 的时候, M1 创建了 G2,为了继续执行 G1,需要把 G2 保存到全局队列中,无法保证G2是被M1处理。因为 M1 原本就保存了 G2 的信息,所以 G2 最好是在 M1 上执行,这样的话也不需要转移G到全局队列和线程上下文切换

  3. 线程使用效率不能最大化,没有work-stealinghand-off 机制

12.3 GMP模型:

什么才是一个好的调度器?

能在适当的时机将合适的协程分配到合适的位置,保证公平和效率。

Go采用了GMP模型(对两级线程模型的改进实现),使它能够更加灵活地进行协程之间的调度。

12.3.1 概念:

包含4个重要结构,分别是G、M、P、Sched.

12.3.1.1 G:

G(Goroutine):代表Go 协程Goroutine,存储了 Goroutine 的执行栈信息、Goroutine 状态以及 Goroutine 的任务函数等。

G的数量无限制,理论上只受内存的影响,创建一个 G 的初始栈大小为2-4K,配置一般的机器也能简简单单开启数十万个 Goroutine ,而且Go语言在 G 退出的时候还会把 G 清理之后放到 P 本地或者全局的闲置列表 gFree 中以便复用。

12.3.1.2 M:

M(Machine): Go 对操作系统线程(OS thread)的封装,可以看作操作系统内核线程.

想要在 CPU 上执行代码必须有线程,通过系统调用 clone 创建。M在绑定有效的 P 后,进入一个调度循环,而调度循环的机制大致是从 P 的本地运行队列以及全局队列中获取 G,切换到 G 的执行栈上并执行 G 的函数,调用 goexit 做清理工作并回到 M,如此反复。M 并不保留 G 状态,这是 G 可以跨 M 调度的基础。M的数量有限制,默认数量限制是 10000,可以通过 debug.SetMaxThreads() 方法进行设置,如果有M空闲,那么就会回收或者睡眠。

12.3.1.3 P:

P(Processor):虚拟处理器,M执行G所需要的资源和上下文,只有将 P 和 M 绑定,才能让 P 的 runq 中的 G 真正运行起来。P 的数量决定了系统内最大可并行的 G 的数量,P的数量受本机的CPU核数影响,可通过环境变量$GOMAXPROCS或在runtime.GOMAXPROCS()来设置,默认为CPU核心数。

12.3.1.4 Sched:

Sched:调度器结构,它维护有存储M和G的全局队列,以及调度器的一些状态信息.

可以简单总结为:

G

M

P

数量限制

无限制,受机器内存影响

有限制,默认最多10000

有限制,最多GOMAXPROCS个

创建时机

go func

当没有足够的M来关联P并运行其中的可运行的G时会请求创建新的M

在确定了P的最大数量n后,运行时系统会根据这个数量创建个P

12.3.2 总体设计:

12.3.2.1 理念:

goroutine调度的本质就是将 Goroutine (G)按照一定算法放到CPU上去执行。CPU感知不到Goroutine,只知道内核线程,所以需要Go调度器将协程调度到内核线程上面去,然后操作系统调度器将内核线程放到CPU上去执行

M是对内核级线程的封装,所以Go调度器的工作就是将G分配到M.

调度器的设计思想主要包括:

  • 线程复用(work stealing 机制hand off 机制

  • 利用并行(利用多核CPU)

  • 抢占调度(解决公平性问题)

12.3.2.2 组成:

GMP主要包括以下数据结构:

  1. 全局队列(Global Queue):存放等待运行的G

  2. P的本地队列:同全局队列类似,存放的也是等待运行的G,存的数量有限,不超过256个。新建G'时,G'优先加入到P的本地队列,如果队列满了,则会把本地队列中一半的G移动到全局队列

  3. P列表:所有的P都在程序启动时创建,并保存在数组中,最多有GOMAXPROCS(可配置)个

  4. M:线程想运行任务就得获取P,从P的本地队列获取G,P队列为空时,M也会尝试从全局队列拿一批G放到P的本地队列,或从其他P的本地队列一半放到自己P的本地队列。M运行G,G执行之后,M会从P获取下一个G,不断重复下去

image-20240130180054931

12.3.3 线程复用:

golang在线程复用上主要包括work stealing机制hand off机制.

12.3.3.1 work stealing:

干完活的线程与其等着,不如去帮其他线程干活。

对于一个调度器来说,当他的内核线程工作完毕时,就去其他调度器的本地队列里偷一个任务来执行.

而在这时它们会访问同一个队列,所以为了减少窃取任务线程和被窃取任务线程之间的竞争,通常会使用双端队列被窃取任务线程永远从双端队列的头部拿任务执行,而窃取任务的线程永远从双端队列的尾部拿任务执行

12.3.3.2 hand off:

当前线程M阻塞时,释放P,给其它空闲的M处理.

image-20240130180436371

12.3.4 利用并行:

我们可以使用GOMAXPROCS设置P的数量,这样的话最多有GOMAXPROCS个线程分布在多个CPU上同时运行。GOMAXPROCS也限制了并发的程度,比如GOMAXPROCS = 核数/2,则最多利用了一半的CPU核进行并行.

12.3.5 抢占调度:

在1.2版本之前,Go的调度器仍然不支持抢占式调度,程序只能依靠Goroutine主动让出CPU资源才能触发调度,这会引发一些问题,比如:

  • 某些 Goroutine 可以长时间占用线程,造成其它 Goroutine 的饥饿

  • 垃圾回收器是需要stop the world的,如果垃圾回收器想要运行了,那么它必须先通知其它的goroutine停下来,这会造成较长时间的等待时间

为解决这个问题:

  • Go 1.2 中实现了基于协作的“抢占式”调度

  • Go 1.14 中实现了基于信号的“抢占式”调度

12.3.5.1 协作抢占调度:

12.3.5.1.1 协作式与非协作式:

协作式:大家都按事先定义好的规则来,比如:一个goroutine执行完后,退出,让出p,然后下一个goroutine被调度到p上运行。

这样做的缺点就在于是否让出p的决定权在groutine自身。一旦某个g不主动让出p或执行时间较长,那么后面的goroutine只能等着,没有方法让前者让出p,导致延迟甚至饿死。

非协作式: 就是由runtime来决定一个goroutine运行多长时间,如果你不主动让出,对不起,我有手段可以抢占你,把你踢出去,让后面的goroutine进来运行。

12.3.5.1.2 工作流程:
  • 编译器会在调用函数前插入 runtime.morestack,让运行时有机会在这段代码中检查是否需要执行抢占调度

  • Go语言运行时会在垃圾回收暂停程序、系统监控发现 Goroutine 运行超过 10ms,那么会在这个协程设置一个抢占标记

  • 当发生函数调用时,可能会执行编译器插入的 runtime.morestack,它调用的 runtime.newstack会检查抢占标记,如果有抢占标记就会触发抢占让出cpu,切到调度主协程里

这种解决方案只能说局部解决了“饿死”问题,只在有函数调用的地方才能插入“抢占”代码(埋点),对于没有函数调用而是纯算法循环计算的 G,Go 调度器依然无法抢占。

比如,死循环等并没有给编译器插入抢占代码的机会,以下程序在 go 1.14 之前的 go版本中,运行后会一直卡住,而不会打印 I got scheduled!

package main

import (
    "fmt"
    "runtime"
    "time"
)

func main() {
    runtime.GOMAXPROCS(1)
    go func() {
        for {
        }
    }()

    time.Sleep(time.Second)
    fmt.Println("I got scheduled!")
}

12.3.5.2 信号抢占:非协作:

  • M 注册一个 SIGURG 信号的处理函数:sighandler

  • sysmon启动后会间隔性的进行监控,最长间隔10ms,最短间隔20us。如果发现某协程独占P超过10ms,会给M发送抢占信号

  • M 收到信号后,内核执行 sighandler 函数把当前协程的状态从_Grunning正在执行改成 _Grunnable可执行,把抢占的协程放到全局队列里,M继续寻找其他 goroutine 来运行

  • 被抢占的 G 再次调度过来执行时,会继续原来的执行流

直接通过向M发送信号,修改G的状态来进行抢占.

12.3.6 查看调度信息:

有 2 种方式可以查看一个程序的调度GMP信息,分别是go tool trace和GODEBUG.

12.3.6.1 go tool trace:

编写 trace.go文件:

package main

import (
    "fmt"
    "os"
    "runtime/trace"
    "time"
)

func main() {

    //创建trace文件
    f, err := os.Create("trace.out")
    if err != nil {
        panic(err)
    }

    defer f.Close()

    //启动trace goroutine
    err = trace.Start(f)
    if err != nil {
        panic(err)
    }
    defer trace.Stop()

    //main
    for i := 0; i < 5; i++ {
        time.Sleep(time.Second)
        fmt.Println("Hello World")
    }
}

启动可视化界面:

go run trace.go
go tool trace trace.out
2022/04/22 10:44:11 Parsing trace...
2022/04/22 10:44:11 Splitting trace...
2022/04/22 10:44:11 Opening browser. Trace viewer is listening on http://127.0.0.1:35488

打开 http://127.0.0.1:35488 查看可视化界面:

image-20240131164828750

image-20240131164844748

12.3.6.2 GODEBUG:

GODEBUG 变量可以控制运行时内的调试变量。查看调度器信息,将会使用如下两个参数:

  • schedtrace:设置 schedtrace=X 参数可以使运行时在每 X 毫秒发出一行调度器的摘要信息到标准 err 输出中。

  • scheddetail:设置 schedtrace=Xscheddetail=1 可以使运行时在每 X 毫秒发出一次详细的多行信息,信息内容主要包括调度程序、处理器、OS 线程 和 Goroutine 的状态。

查看基本信息:

go build trace.go
GODEBUG=schedtrace=1000 ./trace
SCHED 0ms: gomaxprocs=8 idleprocs=6 threads=4 spinningthreads=1 idlethreads=0 runqueue=0 [1 0 0 0 0 0 0 0]
Hello World
SCHED 1010ms: gomaxprocs=8 idleprocs=8 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [0 0 0 0 0 0 0 0]
Hello World
SCHED 2014ms: gomaxprocs=8 idleprocs=8 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [0 0 0 0 0 0 0 0]
Hello World
SCHED 3024ms: gomaxprocs=8 idleprocs=8 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [0 0 0 0 0 0 0 0]
Hello World
SCHED 4027ms: gomaxprocs=8 idleprocs=8 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [0 0 0 0 0 0 0 0]
Hello World
SCHED 5029ms: gomaxprocs=8 idleprocs=7 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [0 0 0 0 0 0 0 0]
  • sched:每一行都代表调度器的调试信息,后面提示的毫秒数表示启动到现在的运行时间,输出的时间间隔受 schedtrace 的值影响。

  • gomaxprocs:当前的 CPU 核心数(GOMAXPROCS 的当前值)。

  • idleprocs:空闲的处理器数量,后面的数字表示当前的空闲数量。

  • threads:OS 线程数量,后面的数字表示当前正在运行的线程数量。

  • spinningthreads:自旋状态的 OS 线程数量。

  • idlethreads:空闲的线程数量。

  • runqueue:全局队列中中的 Goroutine 数量,而后面的[0 0 0 0 0 0 0 0] 则分别代表这 8 个 P 的本地队列正在运行的 Goroutine 数量。

13.并发编程:

13.1 goroutine:

当前主流的并发模式一共有三种:

  1. 多线程:每个线程一次处理一个请求,线程越多可并发处理的请求数就越多,但是在高并发下,多线程开销会比较大。

  2. 协程:无需抢占式的调度,开销小,可以有效的提高线程的并发性,从而避免了线程的缺点的部分。

  3. 基于异步回调的IO模型:比如nginx使用的就是epoll模型。

13.1.1 引入:

通过上文的GMP调度模式我们可以知道,线程的用户态和内核态的概念是操作系统领域的基本概念。用户态线程和内核态线程的区别在于线程管理和调度的责任归属不同。

  • 用户态线程: 在用户态线程模型中,线程的创建、销毁、调度等操作都由用户空间的程序或者库来完成,而不需要依赖操作系统内核的支持。这种模型的优点是轻量级和灵活性高,但也存在一些局限性,比如阻塞操作可能会导致整个线程被阻塞,无法进行其他任务。

  • 内核态线程: 在内核态线程模型中,线程的创建、销毁、调度等操作由操作系统内核来管理,内核负责线程的调度、上下文切换等操作。内核态线程通常由操作系统内核调度,是操作系统调度的基本单位。

Go 语言的 goroutine 机制就是在语言层面加以利用了用户态线程的概念,利用 Go 运行时系统(runtime system)来管理和调度 goroutine。这种轻量级的用户态线程模型使得 Goroutine 能够更加高效地创建、销毁和调度,而无需依赖于操作系统的内核线程。这使得 Go 语言能够轻松处理大规模的并发任务,而不会因为线程创建和销毁的开销而降低性能。

比如go语言中处理网络请求常用的HTTP服务器包(net/http),就很好的集成了goroutine,会为每一个http请求新建一个goroutine,每个请求都会在自己的goroutine中进行处理。

13.1.1.1 其他实现:

13.1.1.1.1 java-多线程:

同样以处理http请求来看:

在Java中,处理HTTP请求通常由Java Servlet容器负责。常见的Servlet容器包括Apache Tomcat、Jetty、Undertow等。这些容器通常使用线程池来处理HTTP请求,而不是为每个请求启动一个新的线程(与Go语言中的goroutine类似)。

当一个HTTP请求到达Servlet容器时,容器会从线程池中获取一个可用的线程来处理该请求。线程会执行与请求相关的Servlet或者其他处理器,并且在请求处理完成后,该线程会返回到线程池中以供下一个请求使用,而不是被销毁。这种线程池的机制可以有效地处理大量的并发请求,并且减少了线程创建和销毁的开销,提高了性能和效率。

总之,在Java中处理HTTP请求的方式是通过使用线程池来复用线程。

13.1.1.1.2 基于异步回调的IO模型:

基于异步回调的IO模型是一种事件驱动的并发编程模式,通常用于处理IO密集型任务。在这种模型中,程序会发起一个IO操作(比如读取文件、发送网络请求等),然后立即返回,而不会等待IO操作完成。当IO操作完成后,系统会触发一个回调函数来处理结果,从而实现异步非阻塞的IO操作。

下面是基于异步回调的IO模型的主要特点和流程:

  1. 发起IO操作: 程序发起一个IO操作(比如读取文件、发送网络请求等),然后立即返回,不会等待IO操作完成。

  2. 注册回调函数: 在发起IO操作时,程序会注册一个回调函数(callback),用于在IO操作完成后处理结果。回调函数通常包含在一个回调对象中,并与发起IO操作的请求相关联。

  3. 事件循环: 程序进入一个事件循环(Event Loop),不断地监听和处理事件。事件循环会检查IO操作是否已经完成,如果完成了就调用相应的回调函数处理结果。

  4. 处理IO结果: 当IO操作完成后,系统会触发相应的回调函数,程序会在事件循环中调用该回调函数来处理IO结果。在回调函数中可以对IO操作的结果进行处理,比如读取文件内容、处理网络数据等。

  5. 继续执行: 处理完IO结果后,程序可以继续执行其他任务或者发起新的IO操作,从而实现异步非阻塞的IO操作。

基于异步回调的IO模型的优点是能够充分利用系统资源,提高IO操作的并发性能和吞吐量。同时,由于IO操作是非阻塞的,可以避免线程阻塞,提高系统的响应速度和并发能力。然而,异步回调模型也面临一些挑战,比如回调地狱、错误处理复杂等问题,需要谨慎设计和管理。常见的基于异步回调的IO模型包括Node.js、Twisted、Netty等。

13.1.1.1.3 不使用协程的原因:

为什么不选择使用goroutine? 其他语言在实现并发时也有各自的优势和方式,但并不一定都像 Go 语言那样使用用户态线程模型(goroutine)。原因可能有以下几点:

  1. 历史原因: 许多传统的编程语言(如Java、C++)的并发模型是基于操作系统的内核线程的,这种模型已经在很长时间内被广泛使用,并且在很多情况下已经被证明是有效的。因此,这些语言在设计时可能更倾向于沿用已有的模型,而不是尝试引入新的并发模型。

  2. 成本与风险: 将用户态线程模型引入到语言层面可能会增加开发和维护的成本,并且需要解决一系列的技术挑战和复杂性。对于一些传统的编程语言来说,引入这种新的并发模型可能会面临较高的风险和投入,而不是简单地实现并使用现有的模型。

  3. 适用场景不同: 每种编程语言都有自己的设计目标和应用领域,对并发的需求和处理方式也会有所不同。有些语言更侧重于服务器端应用,对于高并发和大规模并发的支持可能会更加重视,而有些语言则更注重于其他方面的特性和优势。

  4. 生态系统和工具支持: Go 语言作为一种新兴的编程语言,在设计之初就将并发性作为一个重要的特性考虑进去,并在语言层面提供了简单易用的并发机制。而一些传统的编程语言可能已经有完善的生态系统和工具支持,并且已经有了成熟的并发编程模型和库,因此没有必要引入新的并发模型。

13.1.2 使用:

13.1.2.1 使用方式:

在Go语言中,每一个并发的执行单元叫作一个goroutine。我们只需要在调用的函数前面添加go关键字,就能使这个函数以协程的方式运行。

go 函数名(函数参数)
package main

import (
	"fmt"
	"runtime"
)

func main() {
	go func() {
		for i := 0; i < 3; i++ {
			fmt.Println("go")
		}
	}()
}

一旦我们使用了go关键字,函数的返回值就会被忽略,故不能使用函数返回值来与主线程进行数据交换,而只能使用channel。

13.1.2.2 使用场景:

在使用Gin框架或者其他常见的框架时,大部分情况下不需要手动创建goroutines,因为这些框架本身已经使用goroutines来处理HTTP请求。他们会自动为每个请求创建一个goroutine,并在请求处理完成后进行清理,这样可以充分利用Go语言的并发优势,处理大量的并发请求。

然而,有时候可能需要在处理请求时执行一些长时间运行的任务,比如与外部服务通信、访问数据库或者进行密集的计算。在这种情况下,你可以考虑在goroutine中执行这些任务,以避免阻塞主请求处理流程,提高系统的并发性能。

func handler(c *gin.Context) {
    // 启动goroutine执行耗时任务
    go func() {
        // 执行耗时任务
        result := timeConsumingTask()
        // 将结果发送到通道或者进行其他处理
    }()

    // 继续处理其他事务,不阻塞主处理流程
    c.JSON(200, gin.H{
        "message": "Request received",
    })
}

13.1.3 底层原理:

13.1.3.1 数据结构:

最终有一个 runtime.g 对象放入调度队列。

type g struct {
    goid    int64 // 唯一的goroutine的ID
    sched gobuf // goroutine切换时,用于保存g的上下文
    stack stack // 栈
  gopc        // pc of go statement that created this goroutine
    startpc    uintptr // pc of goroutine function
    ...
}

type gobuf struct {
    sp   uintptr // 栈指针位置
    pc   uintptr // 运行到的程序位置
    g    guintptr // 指向 goroutine
    ret  uintptr  // 保存系统调用的返回值
    ...
}

type stack struct {
    lo uintptr // 栈的下界内存地址
    hi uintptr // 栈的上界内存地址
}

13.1.3.2 状态流转:

状态

含义

空闲中_Gidle

G刚刚新建, 仍未初始化

待运行_Grunnable

就绪状态,G在运行队列中, 等待M取出并运行

运行中_Grunning

M正在运行这个G, 这时候M会拥有一个P

系统调用中_Gsyscall

M正在运行这个G发起的系统调用, 这时候M并不拥有P

等待中_Gwaiting

G在等待某些条件完成, 这时候G不在运行也不在运行队列中(可能在channel的等待队列中)

已中止_Gdead

G未被使用, 可能已执行完毕

栈复制中_Gcopystack

G正在获取一个新的栈空间并把原来的内容复制过去(用于防止GC扫描)

13.1.3.2.1 创建:

通过go关键字调用底层函数runtime.newproc()创建一个goroutine

当调用该函数之后,goroutine会被设置成runnable状态。

创建好的这个goroutine会新建一个自己的栈空间,同时在G的sched中维护栈地址与程序计数器这些信息。

每个 G 在被创建之后,都会被优先放入到本地队列中,如果本地队列已经满了,就会被放入到全局队列中。

13.1.3.2.2 运行:

goroutine 本身只是一个数据结构,真正让 goroutine 运行起来的是调度器

Go 实现了一个用户态的调度器(就是上文介绍的GMP模型),这个调度器充分利用现代计算机的多核特性,同时让多个 goroutine 运行,同时 goroutine 设计的很轻量级,调度和上下文切换的代价都比较小。

image-20240221112855338

调度时机:

  • 新起一个协程和协程执行完毕

  • 会阻塞的系统调用,比如文件io、网络io

  • channel、mutex等阻塞操作

  • time.sleep

  • 垃圾回收之后

  • 主动调用runtime.Gosched()

  • 运行过久或系统调用过久等等

每个 M 开始执行 P 的本地队列中的 G时,goroutine会被设置成running状态

如果某个 M 把本地队列中的G都执行完成之后,然后就会去全局队列中拿 G,这里需要注意,每次去全局队列拿 G 的时候,都需要上锁,避免同样的任务被多次拿。

如果全局队列都被拿完了,而当前 M 也没有更多的 G 可以执行的时候,它就会去其他 P 的本地队列中拿任务,这个机制被称之为 work stealing 机制,每次会拿走一半的任务,向下取整,比如另一个 P 中有 3 个任务,那一半就是一个任务。

当全局队列为空,M 也没办法从其他的 P 中拿任务的时候,就会让自身进入自选状态,等待有新的 G 进来。最多只会有 GOMAXPROCS 个 M 在自旋状态,过多 M 的自旋会浪费 CPU 资源。

13.1.3.2.3 阻塞:

channel的读写操作、等待锁、等待网络数据、系统调用等都有可能发生阻塞,会调用底层函数runtime.gopark(),会让出CPU时间片,让调度器安排其它等待的任务运行,并在下次某个时候从该位置恢复执行。

当调用该函数之后,goroutine会被设置成waiting状态。

13.1.3.2.4唤醒:

处于waiting状态的goroutine,在调用runtime.goready()函数之后会被唤醒,唤醒的goroutine会被重新放到M对应的上下文P对应的runqueue中,等待被调度。

当调用该函数之后,goroutine会被设置成runnable状态。

13.1.3.2.5 退出:

当goroutine执行完成后,会调用底层函数runtime.Goexit()

当调用该函数之后,goroutine会被设置成dead状态。

13.1.4 泄漏:

如果输出的 goroutines 数量是在不断增加的,就说明存在泄漏。

13.1.4.1 原因:

  • Goroutine 内进行channel/mutex 等读写操作被一直阻塞。

  • Goroutine 内的业务逻辑进入死循环,资源一直无法释放。

  • Goroutine 内的业务逻辑进入长时间等待,有不断新增的 Goroutine 进入等待

13.1.4.2 场景:

13.1.4.2.1 nil channel:

channel 如果忘记初始化,那么无论你是读,还是写操作,都会造成阻塞。

func main() {
    fmt.Println("before goroutines: ", runtime.NumGoroutine())
    block1()
    time.Sleep(time.Second * 1)
    fmt.Println("after goroutines: ", runtime.NumGoroutine())
}

func block1() {
    var ch chan int
    for i := 0; i < 10; i++ {
        go func() {
            <-ch
        }()
    }
}

输出结果:

before goroutines:  1
after goroutines:  11

可以看到,chan只是定义了,却没有初始化。然后一秒钟就增加了10个goroutine。

13.1.4.2.2 发送不接收:

channel 发送数量 超过 channel接收数量,就会造成阻塞

func block2() {
    ch := make(chan int)
    for i := 0; i < 10; i++ {
        go func() {
            ch <- 1
        }()
    }
}
13.1.4.2.3 接收不发送:

channel 接收数量 超过 channel发送数量,也会造成阻塞

func block3() {
    ch := make(chan int)
    for i := 0; i < 10; i++ {
        go func() {
            <-ch
        }()
    }
}
13.1.4.2.4 http request body未关闭:

resp.Body.Close() 未被调用时,goroutine不会退出

func requestWithNoClose() {
    _, err := http.Get("https://www.baidu.com")
    if err != nil {
        fmt.Println("error occurred while fetching page, error: %s", err.Error())
    }
}

func requestWithClose() {
    resp, err := http.Get("https://www.baidu.com")
    if err != nil {
        fmt.Println("error occurred while fetching page, error: %s", err.Error())
        return
    }
    defer resp.Body.Close()
}

func block4() {
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func() {
                defer wg.Done()
                requestWithNoClose()
        }()
    }
}

var wg = sync.WaitGroup{}

func main() {
    block4()
    wg.Wait()
}

一般发起http请求时,需要确保关闭body

defer resp.Body.Close()
13.1.4.2.5 互斥锁忘记解锁:

第一个协程获取 sync.Mutex 加锁了,但是他可能在处理业务逻辑,又或是忘记 Unlock 了。

因此导致后面的协程想加锁,却因锁未释放被阻塞了

func block5() {
    var mutex sync.Mutex
    for i := 0; i < 10; i++ {
        go func() {
            mutex.Lock()
        }()
    }
}
13.1.4.2.6 sync.WaitGroup使用不当:

由于 wg.Add 的数量与 wg.Done 数量并不匹配,因此在调用 wg.Wait 方法后一直阻塞等待

func block6() {
    var wg sync.WaitGroup
    for i := 0; i < 10; i++ {
        go func() {
            wg.Add(2)
            wg.Done()
            wg.Wait()
        }()
    }
}

13.1.4.3 排查:

单个函数:调用 runtime.NumGoroutine 方法来打印 执行代码前后Goroutine 的运行数量,进行前后比较,就能知道有没有泄露了。

生产/测试环境:使用PProf实时监测Goroutine的数量。

13.1.5 监控:

13.1.5.1 pprof:

程序中引入pprof package:

import _ "net/http/pprof"

程序中开启HTTP监听服务:

package main

import (
    "net/http"
    _ "net/http/pprof"
)

func main() {

    for i := 0; i < 100; i++ {
        go func() {
            select {}
        }()
    }

    go func() {
        http.ListenAndServe("localhost:6060", nil)
    }()

    select {}
}

分析goroutine文件,在命令行下执行:

go tool pprof -http=:1248 http://127.0.0.1:6060/debug/pprof/goroutine

会自动打开浏览器页面.

13.1.6 控制:

在开发过程中,如果不对goroutine加以控制而进行滥用的话,可能会导致服务整体崩溃。比如耗尽系统资源导致程序崩溃,或者CPU使用率过高导致系统忙不过来。

13.1.6.1 有缓冲channel:

利用缓冲满时发送阻塞的特性。

package main

import (
    "fmt"
    "runtime"
    "time"
)

var wg = sync.WaitGroup{}

func main() {
    // 模拟用户请求数量
    requestCount := 10
    fmt.Println("goroutine_num", runtime.NumGoroutine())
    // 管道长度即最大并发数
    ch := make(chan bool, 3)
    for i := 0; i < requestCount; i++ {
        wg.Add(1)
        ch <- true
        go Read(ch, i)
    }

     wg.Wait()
}

func Read(ch chan bool, i int) {
    fmt.Printf("goroutine_num: %d, go func: %d\n", runtime.NumGoroutine(), i)
    <-ch
    wg.Done()
}

输出结果:默认最多不超过3(4-1)个goroutine并发执行

goroutine_num 1
goroutine_num: 4, go func: 1
goroutine_num: 4, go func: 3
goroutine_num: 4, go func: 2
goroutine_num: 4, go func: 0
goroutine_num: 4, go func: 4
goroutine_num: 4, go func: 5
goroutine_num: 4, go func: 6
goroutine_num: 4, go func: 8
goroutine_num: 4, go func: 9
goroutine_num: 4, go func: 7

13.1.6.2 无缓冲channel:

任务发送和执行分离,指定消费者并发协程数

package main

import (
    "fmt"
    "runtime"
    "sync"
)

var wg = sync.WaitGroup{}

func main() {
    // 模拟用户请求数量
    requestCount := 10
    fmt.Println("goroutine_num", runtime.NumGoroutine())
    ch := make(chan bool)
    for i := 0; i < 3; i++ {
        go Read(ch, i)
    }

    for i := 0; i < requestCount; i++ {
        wg.Add(1)
        ch <- true
    }

    wg.Wait()
}

func Read(ch chan bool, i int) {
    for _ = range ch {
        fmt.Printf("goroutine_num: %d, go func: %d\n", runtime.NumGoroutine(), i)
        wg.Done()
    }
}

13.2 channel:

Channels 是一种在并发编程中用于协调不同goroutine(或线程)之间通信的机制。它提供了一种在goroutines之间发送数据的方式,而无需显式使用锁或其他同步原语。

13.2.1 使用:

13.2.1.1 声明:

chan 类型的空值是 nil,与map一样,声明后需要配合 make 后才能使用。

所以通道只能传输一种类型的数据,比如 chan int 或者 chan string,所有的类型都可以用于通道,空接口 interface{} 也可以。甚至可以(有时非常有用)创建通道的通道。

var 变量 chan 元素类型
 
var ch1 chan int   // 声明一个传递整型的通道
var ch2 chan bool  // 声明一个传递布尔型的通道
var ch3 chan []int // 声明一个传递int切片的通道

13.2.1.2 初始化:

使用make关键字进行初始化。

var ch1 chan string
ch1 = make(chan string)
 
//或者使用短类型
ch1 := make(chan string)

// 创建一个可以传递字符串类型数据的channel,并指定缓冲区大小为10
ch := make(chan string, 10)

13.2.1.3 发送:

通道有发送(send)、接收(receive)和关闭(close)三种操作。

发送和接收都使用<-符号。

将一个值发送到通道中。

通道名 <- 需要发送的值 
ch <- 10 // 把10发送到ch中

13.2.1.4 接收:

从一个通道中接收值。

<-通道名

赋值的变量 <- 通道名 

x := <- ch // 从ch中接收值并赋值给变量x
<-ch       // 从ch中接收值,忽略结果

一开始很可能会搞混,具体是发送还是接收,主要取决于通道名在左侧还是右侧,在左侧就是向通道发送数据,在右侧就是从通道中取出数据。

13.2.1.5 关闭:

close(通道名)

关于关闭通道需要注意的事情是,只有在通知接收方goroutine所有的数据都发送完毕的时候才需要关闭通道。

通道是可以被垃圾回收机制回收的,它和关闭文件是不一样的,在结束操作之后关闭文件是必须要做的,但关闭通道不是必须的。

注意,被关闭的信道会禁止数据流入, 是只读的。我们仍然可以从关闭的信道中取出数据,但是不能再写入数据了。

关闭后的通道有以下特点:

  1. 对一个关闭的通道再发送值就会导致panic。

  2. 对一个关闭的通道进行接收会一直获取值直到通道为空。(如果通道中还有数据的话)

  3. 对一个关闭的并且没有值的通道执行接收操作会得到对应类型的零值。

  4. 关闭一个已经关闭的通道会导致panic。

13.2.1.6 循环读取:

Go语言允许我们使用range来读取信道。

func main() {
    ch := make(chan int, 3)
    ch <- 1
    ch <- 2
    ch <- 3
 
    for v := range ch {
        fmt.Println(v)
    }
}

但是不能这样写,因为range读取的时候,通道不停止时不会停止读取的。会造成死锁。

可以写成下面这样:

ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
for v := range ch {
    fmt.Println(v)
    if len(ch) <= 0 { // 如果现有数据量为0,跳出循环
        break
    }
}

或者这样:

ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
 
// 显式地关闭信道
close(ch)
 
for v := range ch {
    fmt.Println(v)
}

13.2.2 分类:

channel根据缓冲分类有两种:无缓冲通道和有缓冲通道。

13.2.2.1 无缓冲通道:

使用make创建时,不添加数字,或者使用0创建的通道就是无缓冲通道。

ch := make(chan,int)
ch1 := make(chan,int,0)

无缓冲通道也被称为同步通道(synchronous channel),它在传输数据时没有缓冲区。发送操作和接收操作必须同时准备好,否则它们将会阻塞。

无缓冲通道保证数据在发送方和接收方之间的同步传输,即发送操作会等待接收操作完成,直到数据被接收后发送操作才会结束。


Copy code
package main

import (
	"fmt"
	"time"
)

func main() {
	// 创建一个无缓冲通道
	ch := make(chan string)

	// 启动一个goroutine发送消息
	go func() {
		time.Sleep(time.Second)
		ch <- "Hello, Channel!"
	}()

	// 从通道接收消息
	msg := <-ch
	fmt.Println(msg)
}

对于无缓冲通道来说,发送操作和接收操作必须同时准备好,否则会造成阻塞。

在上面的例子中,当主goroutine执行到发送数据到通道的那一句代码时,如果没有另一个goroutine同时准备好从通道中接收数据,那么发送操作就会被阻塞,直到有其他goroutine准备好接收数据。只有当另一个goroutine接收了数据后,发送操作才会完成,主goroutine才会继续执行后续代码。

反过来也是一样,先执行到从无缓冲通道中读取数据的代码会阻塞,只到读取到数据为止。

使用无缓冲通道进行通信将导致发送和接收的goroutine同步化。因此,无缓冲通道也被称为同步通道。

13.2.2.2 有缓冲通道:

使用make创建时,为其添加不为0的数字的容量的通道就是有缓冲通道。

ch := make(chan,int,10)

有缓冲通道在创建时会指定一个缓冲区大小,允许在通道未满时发送数据,并在通道不为空时接收数据。

与无缓冲通道不同,有缓冲通道允许发送操作和接收操作异步执行,只有在缓冲区满时发送操作才会阻塞,只有在缓冲区空时接收操作才会阻塞。

就是说,对于接收数据而言,在通道中有数据的情况下不会发生阻塞,没有数据时才会阻塞。对于发送数据而言,数据没满时不会阻塞,通道满了才会发生阻塞。

package main

import "fmt"

func main() {
    ch := make(chan int, 2) // 创建一个容量为2的有缓冲通道

    ch <- 42 // 发送数据到通道
    ch <- 43 // 发送另一个数据到通道

    fmt.Println(<-ch) // 从通道接收数据
    fmt.Println(<-ch) // 从通道接收另一个数据
}

13.2.2.3 单向通道:

有的时候我们会将通道作为参数在多个任务函数间传递,很多时候我们在不同的任务函数中使用通道都会对其进行限制,比如限制通道在函数中只能发送或只能接收。

var 通道实例 chan<- 元素类型    // 只能发送通道
 
var 通道实例 <-chan 元素类型    // 只能接收通道
//往通道中写
func counter(out chan<- int) {
    for i := 0; i < 100; i++ {
        out <- i
    }
    close(out)
}
 
func squarer(out chan<- int, in <-chan int) {
    for i := range in {
        out <- i * i
    }
    close(out)
}
 
 
//从通道中读
func printer(in <-chan int) {
    for i := range in {
        fmt.Println(i)
    }
}
 
func main() {
    ch1 := make(chan int)
    ch2 := make(chan int)
    go counter(ch1)
    go squarer(ch2, ch1)
    printer(ch2)
}

13.2.3 数据结构:

通过var声明或者make函数创建的channel变量是一个存储在函数栈帧上的指针,占用8个字节,指向堆上的hchan结构体。

源码包中src/runtime/chan.go定义了hchan的数据结构:

type hchan struct {
 closed   uint32   // channel是否关闭的标志
 elemtype *_type   // channel中的元素类型
 
 // channel分为无缓冲和有缓冲两种。
 // 对于有缓冲的channel存储数据,使用了 ring buffer(环形缓冲区) 来缓存写入的数据,本质是循环数组
 // 为啥是循环数组?普通数组不行吗,普通数组容量固定更适合指定的空间,弹出元素时,普通数组需要全部都前移
 // 当下标超过数组容量后会回到第一个位置,所以需要有两个字段记录当前读和写的下标位置
 buf      unsafe.Pointer // 指向底层循环数组的指针(环形缓冲区)
 qcount   uint           // 循环数组中的元素数量
 dataqsiz uint           // 循环数组的长度
 elemsize uint16                 // 元素的大小
 sendx    uint           // 下一次写下标的位置
 recvx    uint           // 下一次读下标的位置
  
 // 尝试读取channel或向channel写入数据而被阻塞的goroutine
 recvq    waitq  // 读等待队列
 sendq    waitq  // 写等待队列

 lock mutex //互斥锁,保证读写channel时不存在并发竞争问题
}

等待队列:本质是双向链表,包含一个头结点和一个尾结点

每个节点是一个sudog结构体变量,记录哪个协程在等待,等待的是哪个channel,等待发送/接收的数据在哪里

type waitq struct {
   first *sudog
   last  *sudog
}

type sudog struct {
    g *g
    next *sudog
    prev *sudog
    elem unsafe.Pointer 
    c        *hchan 
    ...
}

13.2.4 并发安全:

channel是线程安全的。

不同协程通过channel进行通信,本身的使用场景就是多线程,为了保证数据的一致性,必须实现线程安全。

channel的底层实现中,hchan结构体中采用Mutex锁来保证数据读写安全。在对循环数组buf中的数据进行入队和出队操作时,必须先获取互斥锁,才能操作channel数据。

13.2.5 设计思路:

Go 语言中最常见的、也是经常被人提及的设计模式就是:不要通过共享内存的方式进行通信,而是应该通过通信的方式共享内存。这句话想必大家已经非常熟悉了,在官方的博客,初学时的教程,甚至是在 Go 的源码中都能看到。

在很多主流的编程语言中,多个线程传递数据的方式一般都是共享内存,为了解决线程竞争,我们需要限制同一时间能够读写这些变量的线程数量,然而这与 Go 语言鼓励的设计并不相同。

虽然我们在 Go 语言中也能使用共享内存加互斥锁进行通信,但是 Go 语言提供了一种不同的并发模型,即通信顺序进程(Communicating sequential processes,CSP)1。Goroutine 和 Channel 分别对应 CSP 中的实体和传递信息的媒介,Goroutine 之间会通过 Channel 传递数据。

Go 引入了 Channel 和 Goroutine 实现 CSP 模型将生产者和消费者进行了解耦,Channel 其实和消息队列很相似。

优点

  • 使用 channel 可以帮助我们解耦生产者和消费者,可以降低并发当中的耦合

缺点

  • 容易出现死锁的情况

13.2.6 死锁:

死锁:

  • 单个协程永久阻塞

  • 两个或两个以上的协程的执行过程中,由于竞争资源或由于彼此通信而造成的一种阻塞的现象。

channel死锁场景:

  • 非缓存channel只写不读

  • 非缓存channel读在写后面

  • 缓存channel写入超过缓冲区数量

  • 空读

  • 多个协程互相等待

13.2.6.1 非缓存channel只写不读:

func deadlock1() {
    ch := make(chan int) 
    ch <- 3 //  这里会发生一直阻塞的情况,执行不到下面一句
}

13.2.6.2 非缓存channel读在写后面:

func deadlock2() {
    ch := make(chan int)
    ch <- 3  //  这里会发生一直阻塞的情况,执行不到下面一句
    num := <-ch
    fmt.Println("num=", num)
}

func deadlock2() {
    ch := make(chan int)
    ch <- 100 //  这里会发生一直阻塞的情况,执行不到下面一句
    go func() {
        num := <-ch
        fmt.Println("num=", num)
    }()
    time.Sleep(time.Second)
}

13.2.6.3 缓存channel写入超过缓冲区数量:

func deadlock3() {
    ch := make(chan int, 3)
    ch <- 3
    ch <- 4
    ch <- 5
    ch <- 6  //  这里会发生一直阻塞的情况
}

13.2.6.4 空读:

func deadlock4() {
    ch := make(chan int)
    // ch := make(chan int, 1)
    fmt.Println(<-ch)  //  这里会发生一直阻塞的情况
}

13.2.6.5 多个协程互相等待:

func deadlock5() {
    ch1 := make(chan int)
    ch2 := make(chan int)
    // 互相等对方造成死锁
    go func() {
        for {
            select {
            case num := <-ch1:
                fmt.Println("num=", num)
                ch2 <- 100
            }
        }
    }()

    for {
        select {
        case num := <-ch2:
            fmt.Println("num=", num)
            ch1 <- 300
        }
    }
}

13.3 Mutex:

sync(synchronization的缩略词):同步。

mutex:互斥。

Go sync包提供了两种锁类型:互斥锁sync.Mutex 和 读写互斥锁sync.RWMutex,都属于悲观锁。

Mutex是互斥锁,当一个 goroutine 获得了锁后,其他 goroutine 不能获取锁(只能存在一个写者或读者,不能同时读和写)。Mutex` 用于在多个 goroutine 之间提供排他访问共享资源的能力,以防止竞态条件(Race Condition)的发生。

13.3.1 使用:

13.3.1.1 使用方式:

13.3.1.1.1 初始化:

sync.Mutex 是一个结构体类型。你可以通过声明 sync.Mutex 类型的变量来初始化一个互斥锁。通常,你可以直接声明变量并使用默认的零值初始化,也可以使用 new() 函数显式地创建一个 Mutex 类型的指针。

var mu sync.Mutex // 声明一个互斥锁变量

// 或者

mu := sync.Mutex{} // 使用字面值初始化一个互斥锁变量

// 显式地使用 new() 函数创建 Mutex 类型的指针
mutexPtr := new(sync.Mutex)
13.3.1.1.2 加锁:

在进入临界区之前,你需要调用 Lock() 方法来获取互斥锁。如果互斥锁已被其他 goroutine 获取,则当前 goroutine 将被阻塞,直到互斥锁可用为止。

使用Lock()方法获取互斥锁:

mu.Lock() // 获取互斥锁

加锁之后,只有第一个执行到这里的goroutine可以获取到锁,剩下的goroutine都会阻塞。直到那个获取到锁的goroutine执行完成自己的代码为止。

在 Lock() 之前使用 Unlock() 会导致 panic 异常。

使用 Lock() 加锁后,再次 Lock() 会导致死锁(不支持重入),需Unlock()解锁后才能再加锁。

13.3.1.1.3 解锁:

在临界区操作完成后,你需要调用 Unlock() 方法来释放互斥锁,以允许其他 goroutine 访问共享资源。

mu.Unlock() // 释放互斥锁

通常,为了确保在临界区退出时释放锁,你可以使用 defer 语句来在临界区代码的开头调用 Lock(),并在代码块的末尾调用 Unlock()

mu.Lock() // 获取互斥锁
defer mu.Unlock() // 在函数返回前释放互斥锁
// 临界区代码

13.3.1.2 使用场景:

多个线程同时访问临界区,为保证数据的安全,锁住一些共享资源, 以防止并发访问这些共享数据时可能导致的数据不一致问题。

13.3.2 获取锁的模式:

sync.Mutex有两种模式 — 正常模式和饥饿模式。

13.3.2.1 正常模式:

正常模式下,所有等待锁的 goroutine 按照 FIFO(先进先出)顺序等待。当一个 goroutine 释放锁时,最早等待的 goroutine 将优先获取到锁,即便有其他 goroutine 等待的时间更长。

而锁被释放的时候,队列头的goroutine不会直接获取到这个锁,因为此时可能有新的goroutine被创建出来,并试图获取这个锁。而显而易见的,新请求锁的 goroutine 具有优势:它正在 CPU 上执行,而且可能有好几个,所以刚刚唤醒的 goroutine 有很大可能在锁竞争中失败,导致长时间获取不到锁。

13.3.2.2 饥饿模式:

饥饿模式是在 Go 语言在 1.9 中通过提交sync: make Mutex more fair引入的优化,引入的目的是保证互斥锁的公平性。

在饥饿模式中,互斥锁会直接交给等待队列最前面的 Goroutine。新的 Goroutine 在该状态下不能获取锁、也不会进入自旋状态,它们只会在队列的末尾等待。如果一个

一旦 Goroutine 超过 1ms 没有获取到锁,它就会将当前互斥锁切换饥饿模式,防止部分 Goroutine 被『饿死』。

Goroutine 获得了互斥锁并且它在队列的末尾或者它等待的时间少于 1ms,那么当前的互斥锁就会切换回正常模式。

与饥饿模式相比,正常模式下的互斥锁能够提供更好地性能,饥饿模式的能避免 Goroutine 由于陷入等待无法获取锁而造成的高尾延时。

在某些特殊情况下,饥饿模式可能更合适,比如,如果有一些 goroutine 对锁的请求时间非常短,而其他 goroutine 的请求时间很长,那么让等待时间更长的 goroutine 先获取锁可能会更有效率。

Go 语言的 sync.Mutex 没有提供直接设置饥饿模式的功能,但是在底层实现中,Go 运行时会根据一定的条件来切换锁的模式,以平衡公平性和性能。

正常模式下的性能是最好的,goroutine 可以连续多次获取锁,饥饿模式解决了取锁公平的问题,但是性能会下降,其实是性能和公平的 一个平衡模式。

13.3.3 自旋与阻塞:

线程没有获取到锁时常见有2种处理方式:

  • 一种是没有获取到锁的线程就一直循环等待判断该资源是否已经释放锁,这种锁也叫做自旋锁,它不用将线程阻塞起来, 适用于并发低且程序执行时间短的场景,缺点是cpu占用较高。

  • 另外一种处理方式就是把自己阻塞起来,会释放CPU给其他线程,内核会将线程置为「睡眠」状态,等到锁被释放后,内核会在合适的时机唤醒该线程,适用于高并发场景,缺点是有线程上下文切换的开销。

Go语言中的Mutex实现了自旋与阻塞两种场景,当满足不了自旋条件时,就会进入阻塞。

13.3.3.1 自旋:

允许自旋的条件:

  1. 锁已被占用,并且锁不处于饥饿模式。

  2. 积累的自旋次数小于最大自旋次数(active_spin=4)。

  3. cpu 核数大于 1。

  4. 有空闲的 P。

  5. 当前 goroutine 所挂载的 P 下,本地待运行队列为空。

if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {  
    ...
    runtime_doSpin()   
    continue  
}


func sync_runtime_canSpin(i int) bool {  
    if i >= active_spin 
    || ncpu <= 1 
    || gomaxprocs <= int32(sched.npidle+sched.nmspinning)+1 {  
          return false  
     }  
   if p := getg().m.p.ptr(); !runqempty(p) {  
      return false  
 }  
   return true  
}

13.3.3.2 阻塞:

当一个goroutine不满足上面的自旋条件时,就会进入阻塞。

当前线程会进入睡眠状态,直到锁被释放。同时,当前线程会加入一个等待队列,以确保在释放锁的顺序上实现公平性。

优点:

  • 阻塞锁避免了忙等待,不会占用 CPU 资源。

  • 阻塞锁适用于长时间持有锁的情况,或者竞争激烈的场景。

缺点:

  • 阻塞锁会引入线程切换的开销,因为线程在等待锁时会进入睡眠状态,需要由操作系统进行线程调度。

  • 如果等待队列中有很多线程在等待锁,可能会导致大量的线程切换,影响系统性能。

13.3.4 数据结构:

互斥锁对应的是底层结构是sync.Mutex结构体,,位于 src/sync/mutex.go中

type Mutex struct {  
     state int32  
     sema  uint32
 }

state表示锁的状态,有锁定、被唤醒、饥饿模式等,并且是用state的二进制位来标识的,不同模式下会有不同的处理方式。

image-20240222142743916

sema表示信号量,mutex阻塞队列的定位是通过这个变量来实现的,从而实现goroutine的阻塞和唤醒。

image-20240222142824273

13.3.5 可重入:

可重入锁又称为递归锁,是指在同一个线程在外层方法获取锁的时候,在进入该线程的内层方法时会自动获取锁,不会因为之前已经获取过还没释放而阻塞。

Mutex 不是可重入的锁。Mutex 的实现中没有记录哪个 goroutine 拥有这把锁。理论上,任何 goroutine 都可以随意地 Unlock 这把锁,所以没办法计算重入条件,并且Mutex 重复Lock会导致死锁。 所以 运行下列代码会报错,因为重复上了两次锁:

package main

import (
	"fmt"
	"sync"
)

func main() {
	var mu sync.Mutex
	reentrant(&mu)
}

func reentrant(mu *sync.Mutex) {
	fmt.Println("Locking the mutex")
	mu.Lock()
	defer func() {
		fmt.Println("Unlocking the mutex")
		mu.Unlock()
	}()

	fmt.Println("Calling another function")
	anotherFunction(mu)
}

func anotherFunction(mu *sync.Mutex) {
	fmt.Println("Locking the mutex again")
	mu.Lock()
	defer func() {
		fmt.Println("Unlocking the mutex again")
		mu.Unlock()
	}()

	// 在这里可以执行更多的操作,而不用担心死锁或竞争条件
	fmt.Println("Performing some operations inside anotherFunction")
}

可重入锁的意义在于简化了对共享资源的访问控制,并且提高了代码的可维护性和安全性。以下是可重入锁的一些重要意义:

  1. 简化编程模型:在使用可重入锁的情况下,程序员不必担心在同一线程中多次获取同一把锁会导致死锁的问题。这简化了编程模型,使得编写多线程程序更加容易。

  2. 避免死锁:可重入锁允许同一线程多次获取同一把锁,而不会造成死锁。这样,在一些复杂的场景下,可以避免死锁的发生。

  3. 提高性能:可重入锁在同一线程多次获取锁时,不会陷入阻塞状态,因为锁已经被当前线程持有。这可以提高程序的性能,避免了频繁的锁竞争和线程切换。

  4. 代码复用:可重入锁使得代码可以更好地重用。如果某个函数需要获取多个锁来保护共享资源,那么可以在函数内部多次获取同一把锁,而不必担心死锁的问题。

  5. 增强安全性:使用可重入锁可以确保在同一线程中对共享资源的访问是安全的。因为同一线程多次获取锁时,其他线程无法访问共享资源,从而避免了竞态条件和数据不一致性的问题。

但是go语言没有可重入锁,是因为他们的开发者认为这是不必要的。

Go语言的发明者认为,如果当你的代码需要重入锁时,那就说明你的代码有问题了,我们正常写代码时,从入口函数开始,执行的层次都是一层层往下的,如果有一个锁需要共享给几个函数,那么就在调用这几个函数的上面,直接加上互斥锁就好了,不需要在每一个函数里面都添加锁,再去释放锁。(参看https://stackoverflow.com/questions/14670979/recursive-locking-in-go#14671462)

13.3.5.1 自己写一个可重入锁:

实现一个可重入锁需要这两点:

  • 记住持有锁的线程

  • 统计重入的次数

package main

import (
    "bytes"
    "fmt"
    "runtime"
    "strconv"
    "sync"
    "sync/atomic"
)

type ReentrantLock struct {
    sync.Mutex
    recursion int32 // 这个goroutine 重入的次数
    owner     int64 // 当前持有锁的goroutine id
}

// Get returns the id of the current goroutine.
func GetGoroutineID() int64 {
    var buf [64]byte
    var s = buf[:runtime.Stack(buf[:], false)]
    s = s[len("goroutine "):]
    s = s[:bytes.IndexByte(s, ' ')]
    gid, _ := strconv.ParseInt(string(s), 10, 64)
    return gid
}

func NewReentrantLock() sync.Locker {
    res := &ReentrantLock{
        Mutex:     sync.Mutex{},
        recursion: 0,
        owner:     0,
    }
    return res
}

// ReentrantMutex 包装一个Mutex,实现可重入
type ReentrantMutex struct {
    sync.Mutex
    owner     int64 // 当前持有锁的goroutine id
    recursion int32 // 这个goroutine 重入的次数
}

func (m *ReentrantMutex) Lock() {
    gid := GetGoroutineID()
    // 如果当前持有锁的goroutine就是这次调用的goroutine,说明是重入
    if atomic.LoadInt64(&m.owner) == gid {
        m.recursion++
        return
    }
    m.Mutex.Lock()
    // 获得锁的goroutine第一次调用,记录下它的goroutine id,调用次数加1
    atomic.StoreInt64(&m.owner, gid)
    m.recursion = 1
}

func (m *ReentrantMutex) Unlock() {
    gid := GetGoroutineID()
    // 非持有锁的goroutine尝试释放锁,错误的使用
    if atomic.LoadInt64(&m.owner) != gid {
        panic(fmt.Sprintf("wrong the owner(%d): %d!", m.owner, gid))
    }
    // 调用次数减1
    m.recursion--
    if m.recursion != 0 { // 如果这个goroutine还没有完全释放,则直接返回
        return
    }
    // 此goroutine最后一次调用,需要释放锁
    atomic.StoreInt64(&m.owner, -1)
    m.Mutex.Unlock()
}

func main() {
    var mutex = &ReentrantMutex{}
    mutex.Lock()
    mutex.Lock()
    fmt.Println(111)
    mutex.Unlock()
    mutex.Unlock()
}

13.4 RWMutex:

读写互斥锁 sync.RWMutex是细粒度的互斥锁,它不限制资源的并发读,但是读写、写写操作无法并行执行。

常见服务的资源读写比例会非常高,因为大多数的读请求之间不会相互影响,所以我们可以分离读写操作,以此来提高服务的性能。

主要适用于多于的情况(既保证线程安全,又保证性能不太差)。

13.4.1 使用:

13.4.1.1 初始化:

与mutex相同。

var rwMutex sync.RWMutex

13.4.1.2 方法:

RWMutex 对外暴露的方法有五个:

  1. RLock():读操作获取锁,如果锁已经被 writer 占用,会一直阻塞直到 writer 释放锁;否则直接获得锁;

  2. RUnlock():读操作完毕之后释放锁;

  3. Lock():写操作获取锁,如果锁已经被 reader 或者 writer 占用,会一直阻塞直到获取到锁;否则直接获得锁;

  4. Unlock():写操作完毕之后释放锁;

  5. RLocker():返回读操作的 Locker 对象,该对象的 Lock() 方法对应 RWMutex 的 RLock(),Unlock() 方法对应 RWMutex 的 RUnlock() 方法。

13.4.1.3 读锁:

读写锁区别与互斥锁的主要区别就是读锁之间是共享的,多个goroutine可以同时加读锁。

所以如果当前已经有读锁,那后续goroutine继续加读锁正常情况下是可以加锁成功。

如果有写锁,那么获取锁的时候就会阻塞。

//加读锁
rwMutex.RLock()

//释放读锁
rwMutex.RUnlock()

13.4.1.4 写锁:

写锁与写锁、写锁与读锁之间则是互斥的。

//加写锁
rwMutex.Lock()
		
//释放写锁    
rwMutex.Unlock()

加写锁的时候,如果当前已经存在读锁或者写锁就会阻塞。

13.4.2 数据结构:

读写锁对应的是底层结构是sync.RWMutex结构体,,位于 src/sync/rwmutex.go中。

type RWMutex struct {
    w           Mutex  // 复用互斥锁
    writerSem   uint32 // 信号量,用于写等待读
    readerSem   uint32 // 信号量,用于读等待写
    readerCount int32  // 当前执行读的 goroutine 数量
    readerWait  int32  // 被阻塞的准备读的 goroutine 的数量
}

13.4.3 写锁饥饿:

如果当前已经有读锁,那后续goroutine继续加读锁正常情况下是可以加锁成功,但是如果一直有读锁进行加锁,那尝试加写锁的goroutine则可能会长期获取不到锁,这就是因为读锁而导致的写锁饥饿问题。

解决方案:通过readerCount等字段进行控制。

  • 调用sync.RWMutex.Lock尝试获取写锁时;

    • 每次 sync.RWMutex.RUnlock都会将 readerCount 其减一,当它归零时该 Goroutine 会获得写锁;

    • readerCount 减少 rwmutexMaxReaders 个数以阻塞后续的读操作;

  • 调用 sync.RWMutex.Unlock释放写锁时,会先通知所有的读操作,然后才会释放持有的互斥锁;

虽然读写互斥锁 sync.RWMutex 提供的功能比较复杂,但是因为它建立在sync.Mutex上,所以实现会简单很多。

读写互斥锁在互斥锁之上提供了额外的更细粒度的控制,能够在读操作远远多于写操作时提升性能。

13.5 WaitGroup:

`WaitGroup提供了一种简单的机制,用于同步多个协程的执行。适用于需要并发执行多个任务并等待它们全部完成后才能继续执行后续操作的场景。

13.5.1 使用:

13.5.1.1 初始化:

var wg sync.WaitGroup

13.5.1.2 方法:

sync.WaitGroup 提供了三个主要的方法:

  • Add(delta int):增加等待计数器的值。delta 参数为正数时,表示增加等待的 goroutine 数量;为负数时,表示减少等待的 goroutine 数量。通常在启动一个 goroutine 之前调用。

  • Done():减少等待计数器的值,相当于调用 Add(-1)。在 goroutine 执行完成时调用。

  • Wait():阻塞直到等待计数器归零。主程序通常会在这里等待所有的 goroutine 完成。

package main

import (
    "fmt"
    "sync"
)

func worker(id int, wg *sync.WaitGroup) {
    defer wg.Done() // 在 goroutine 结束时调用 Done() 来减少计数器值

    fmt.Printf("Worker %d starting\n", id)
    // 模拟工作
    for i := 0; i < 3; i++ {
        fmt.Printf("Worker %d: Working... (%d/%d)\n", id, i+1, 3)
    }
    fmt.Printf("Worker %d done\n", id)
}

func main() {
    var wg sync.WaitGroup

    for i := 1; i <= 5; i++ {
        wg.Add(1) // 每启动一个 goroutine,等待计数器加1
        go worker(i, &wg)
    }

    wg.Wait() // 等待所有 goroutine 完成

    fmt.Println("All workers have completed")
}

13.5.2 场景:

WaitGroup 主要用于以下情况:

  1. 等待一组 goroutine 完成: 在某些情况下,你可能希望等待多个 goroutine 完成它们的工作,然后再继续执行接下来的操作。WaitGroup 可以帮助你实现这一点。

  2. 避免主程序提前退出: 在启动了一组 goroutine 后,主程序可能会提前退出,导致 goroutine 还没有完成就被终止。WaitGroup 可以确保主程序等待所有 goroutine 完成后再退出。

13.5.3 数据结构:

sync.WaitGroup结构体中只包含两个成员变量:

type WaitGroup struct {
	noCopy noCopy
	state1 [3]uint32
}
  • noCopy — 保证 sync.WaitGroup不会被开发者通过再赋值的方式拷贝;

  • state1 — 存储着状态和信号量;

sync.noCopy是一个特殊的私有结构体,tools/go/analysis/passes/copylock包中的分析器会在编译期间检查被拷贝的变量中是否包含 sync.noCopy或者实现了 LockUnlock 方法,如果包含该结构体或者实现了对应的方法就会报出以下错误:

func main() {
	wg := sync.WaitGroup{}
	yawg := wg
	fmt.Println(wg, yawg)
}

$ go vet proc.go
./prog.go:10:10: assignment copies lock value to yawg: sync.WaitGroup
./prog.go:11:14: call of fmt.Println copies lock value: sync.WaitGroup
./prog.go:11:18: call of fmt.Println copies lock value: sync.WaitGroup

13.6 Once:

sync.Once 是 Go 语言标准库 sync 包中的一种机制,用于实现只执行一次的操作。这个机制保证了无论被多少个 goroutine 调用,某个函数只会被执行一次。通常用于初始化操作或者一次性的全局设置。

13.6.1 使用:

13.6.1.1 初始化:

var once sync.Once

13.6.1.2 方法:

sync.Once 类型只有一个方法 Do(),它接收一个无参数且无返回值的函数作为参数。这个函数在第一次调用 Do() 时会被执行,而后的调用将被忽略。即使是在多个 goroutine 中同时调用 Do(),函数也只会执行一次。

package main

import (
    "fmt"
    "sync"
)

var (
    once     sync.Once
    initialized bool
)

func initialize() {
    fmt.Println("Initializing...")
    initialized = true
}

func main() {
    // 启动多个 goroutine 并同时调用 initialize 函数,但 initialize 函数只会被执行一次
    for i := 0; i < 5; i++ {
        go func() {
            once.Do(initialize)
        }()
    }

    // 等待所有 goroutine 执行完成
    for i := 0; i < 5; i++ {
        go func() {
            for !initialized {
                // 等待 initialized 被设置为 true
            }
            fmt.Println("Work with initialized data")
        }()
    }

    // 等待所有 goroutine 执行完成
    var wg sync.WaitGroup
    wg.Add(1)
    go func() {
        defer wg.Done()
        for !initialized {
            // 等待 initialized 被设置为 true
        }
        fmt.Println("All work completed")
    }()
    wg.Wait()
}

方法底层:

  • 如果传入的函数已经执行过,会直接返回;

  • 如果传入的函数没有执行过,会调用sync.Once.doSlow 执行传入的函数:

func (o *Once) Do(f func()) {
	if atomic.LoadUint32(&o.done) == 0 {
		o.doSlow(f)
	}
}

func (o *Once) doSlow(f func()) {
	o.m.Lock()
	defer o.m.Unlock()
	if o.done == 0 {
		defer atomic.StoreUint32(&o.done, 1)
		f()
	}
}

13.6.2 使用场景:

通常用于初始化操作或者一次性的全局设置。

13.6.3 数据结构:

每一个 sync.Once 结构体中都只包含一个用于标识代码块是否执行过的 done 以及一个互斥锁 sync.Mutex:

type Once struct {
	done uint32
	m    Mutex
}

13.7 context:

13.7.1 引入:

上下文 context.ContextGo 语言中用来设置截止日期、同步信号,传递请求相关值的结构体。上下文与 Goroutine 有比较密切的关系,是 Go 语言中独特的设计,在其他编程语言中我们很少见到类似的概念。

在 Go 的服务里,通常每来一个请求都会启动若干个 goroutine 同时工作:有些执行业务逻辑,有些去数据库拿数据,有些调用下游接口获取相关数据…

image-20240222153952518

协程 a 生 b c d,c 生 e,e 生 f。父协程与子孙协程之间是关联在一起的,他们需要共享请求的相关信息,比如用户登录态,请求超时时间等。如何将这些协程联系在一起,context 应运而生。

当请求被取消或是处理时间太长,这有可能是使用者关闭了浏览器或是已经超过了请求方规定的超时时间,请求方直接放弃了这次请求结果。此时所有正在为这个请求工作的 goroutine 都需要快速退出,因为它们的“工作成果”不再被需要了。在相关联的 goroutine 都退出后,系统就可以回收相关资源了。

总的来说 Context 的作用是为了在一组 goroutines 间传递上下文信息(cancel signal,deadline,request-scoped value)以达到对它们的管理控制。

在不同的goroutine之间传递消息,控制声明周期

虽然 Go 语言的运行时系统(GMP)可以帮助你管理 goroutine 的执行,但是在实际开发中,往往需要更加精细地控制和管理 goroutine 的生命周期、传递请求范围的值、处理取消操作等。这时候,context.Context 就发挥了重要的作用。

以下是一些使用 context.Context 的情况:

  1. 传递请求范围的值: 当你需要在多个 goroutine 之间传递请求特定的值,比如请求 ID、用户身份、跟踪信息等时,context.Context 可以很方便地帮助你传递这些值。

  2. 控制请求的生命周期: 当你需要控制请求的生命周期,比如设置请求的超时时间、取消请求或者处理请求的取消操作时,context.Context 可以帮助你实现这些功能。

  3. 并发控制: 在一些并发场景下,你可能需要限制并发操作的数量,或者在一组操作中等待任意一个操作完成,这时候 context.Context 也可以发挥重要作用。

  4. 中间件: 在一些框架中,中间件可以访问请求的上下文信息,并根据需要对请求进行处理,这时候使用 context.Context 可以很方便地传递请求的上下文信息给中间件。

总的来说,虽然 Go 语言的运行时系统可以帮助你管理 goroutine 的执行,但是在实际开发中,往往需要更加精细地控制和管理 goroutine 的生命周期、传递请求范围的值、处理取消操作等,这时候 context.Context 就成为了一个非常有用的工具。

13.7.2 使用:

13.7.2.1 初始化:

有多种方式创建一个context:

// 初始化一个 TODO 上下文
ctx := context.TODO()

// 初始化一个根上下文,空的。
ctx := context.Background()

// 初始化一个包含指定值的上下文
ctx := context.WithValue(context.Background(), "key", "value")

// 初始化一个带有取消信号的上下文,返回一个带有取消信号的新的上下文。当调用返回的 cancel 函数时,会向上下文发送取消信号,以便及时终止相关的操作。
ctx, cancel := context.WithCancel(context.Background())

.....

13.7.2.2 方法:

13.7.2.2.1 set:
// 使用 WithValue 方法创建一个新的上下文,并设置一个键值对
ctxWithValue := context.WithValue(ctx, "key", "value")

注意这个set方法不是在原来的context上进行操作,而是新建了一个context,所以必须要接收。

13.7.2.2.2 get:
// 在新的上下文中获取键值对的值
val := ctxWithValue.Value("key")
13.7.2.2.3 取消信号:

Done():取消一个goroutine的执行。

//创建
ctx, cancel := context.WithCancel(context.Background())

//取消
ctx.Done():

一个使用的例子:

package main

import (
    "context"
    "fmt"
    "time"
)

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    go func() {
        defer func() {
            fmt.Println("goroutine exit")
        }()
        for {
            select {
            case <-ctx.Done():
                fmt.Println("receive cancel signal!")
                return
            default:
                fmt.Println("default")
                time.Sleep(time.Second)
            }
        }
    }()
    time.Sleep(time.Second)
    cancel()
    time.Sleep(2 * time.Second)
}

13.7.3 数据结构:

context.Context是 Go 语言在 1.7 版本中引入标准库的接口1,该接口定义了四个需要实现的方法,其中包括:

  1. Deadline — 返回 context.Context被取消的时间,也就是完成工作的截止日期;

  2. Done — 返回一个 Channel,这个 Channel 会在当前工作完成或者上下文被取消后关闭,多次调用 Done 方法会返回同一个 Channel;

  3. Err— 返回 context.Context结束的原因,它只会在Done()方法对应的 Channel 关闭时返回非空的值;

    1. 如果context.Context被取消,会返回 Canceled错误;

    2. 如果context.Context超时,会返回 DeadlineExceeded 错误;

  4. Value — 从context.Context中获取键对应的值,对于同一个上下文来说,多次调用 Value并传入相同的 Key 会返回相同的结果,该方法可以用来传递请求特定的数据;

type Context interface {
	Deadline() (deadline time.Time, ok bool)
	Done() <-chan struct{}
	Err() error
	Value(key interface{}) interface{}
}

context包中提供的context.TODO.context.Background.context.WithDeadline.和context.WithValue函数会返回实现该接口的私有结构体.

13.8 原子操作:

13.8.1 引入:

在计算机科学中,原子性是指操作在执行过程中不会被中断的特性。原子操作是一种不可分割的操作,它要么完全执行,要么完全不执行,不存在部分执行的情况。原子操作可以保证在并发环境中的一致性和可靠性。

在并发编程中,原子性非常重要,因为在多线程或多进程环境下,多个线程或进程可能同时访问共享资源,如果操作不是原子的,就可能导致数据不一致或者出现竞态条件,从而引发各种问题,如数据损坏、死锁等。

原子性通常由硬件支持,现代处理器提供了一些原子操作的指令,例如比较并交换(Compare and Swap,CAS),这些指令可以在不使用锁的情况下实现原子操作。在高级编程语言中,也提供了原子操作的抽象,以方便程序员在并发环境中编写线程安全的代码。

在Go语言中,sync/atomic包提供了一系列原子操作函数,可以在多个goroutine之间安全地操作共享变量,确保其原子性。通过使用这些原子操作,可以避免在并发编程中出现常见的竞态条件问题。

原子操作过程中是不允许中断的,是绝对并发安全的。由于原子操作不允许中断,所以它非常影响系统执行效率,因此,Go 语言的sync/atomic包只针对少数数据类型提供了原子操作函数。

13.8.2 类型和方法:

支持的数据类型主要有7个:int32、int64、uint32、uint64、uintptr,Pointer(unsafe包)以及Value类型,Value类型可以用来存储任意类型的值。

对这些类型的操作函数包括:

增加 (Add):atomic.AddInt32(addr int32, delta int32) 加载(Load):atomic.LoadInt32(addr int32) 存储(Store):atomic.LoadInt32(addr int32) 交换(Swap):atomic.SwapInt32(addr int32, new int32) 比较并交换(CompareAndSwap): atomic.CompareAndSwapInt32(addr *int32, old int32, new int32) 其中,unsafe.Pointer类型没有add操作,Value类型只要Load和Store两个方法。

注意,第一个参数值为被操作值的指针,原子操作根据指针定位到该值的内存地址,操作这个内存地址上的数据。

13.8.3 add:

Add可以用于增加操作:

num := int32(20)
AddInt32(&num, 3)

也可以用于减法:

num := int32(18)
atomic.AddInt32(&num, -3)

13.8.4 load:

Load可以实现对值的原子读取:

num := int32(20)
fmt.Println(atomic.LoadInt32(&num))

13.8.5 Store:

原子的存储某个值:

num := int32(20)
atomic.StoreInt32(&num, 30)
fmt.Println(num) // 30

13.8.6 Swap:

将新的值赋给被操作的旧值,并返回旧值。

num := int32(20)
old := atomic.SwapInt32(&num, 60)
fmt.Println(num) // 60
fmt.Println(old) // 20

13.8.7 CompareAndSwap:

比较并交换(Compare And Swap,CAS操作 )和交换(Swap)不同,会先进行比较,满足条件后再进行交换操作,将新值赋给变量。返回值为true或者false,true表示执行了交换操作。

在使用 CAS 操作时,要防止进入死循环,导致“阻塞”流程。

  1. 如果共享变量的当前值等于旧值,则将共享变量的值更新为新值,并返回 true,表示更新成功。

  2. 如果共享变量的当前值不等于旧值,则不做任何操作,并返回 false,表示更新失败。

func CompareAndSwapUint32(addr *uint32, old, new uint32) (swapped bool)
func CompareAndSwapUint64(addr *uint64, old, new uint64) (swapped bool)
num:= int32(18)
atomic.CompareAndSwapInt32(&num, 20, 0)
fmt.Printf("The number: %d\n", num)
atomic.CompareAndSwapInt32(&num, 18, 0)
fmt.Printf("The number: %d\n", num)

13.8.7.1 实现自旋锁:

核心代码如下:

func (sl *SpinLock) Lock() {
	for !atomic.CompareAndSwapUint32(&sl.flag, 0, 1) {
		// 自旋等待锁释放
		time.Sleep(time.Millisecond)
	}
}

13.8.8 value:

Value类型可以被用来“原子地”存储(Store)和加载(Load)任意的值。

//初始化
var valu atomic.Value

//定义
valu := [...]int{1, 2, 3}

//存储
box.Store(valu)

//加载
fmt.Println(valu.Load())

使用Value类型时需要注意以下事项:

1、Value不能用来存储nil值。

2、一个Value变量不能存储不同类型的值,存储的类型只能是第一个存储值的类型。

3、尽量不要使用Value存储引用类型的值。是非并发安全的。

13.6 其他并发原语:

Go 语言在 sync包中提供了用于同步的一些基本原语,这些基本原语提供了较为基础的同步功能,但是它们是一种相对原始的同步机制,在多数情况下,我们都应该使用抽象层级更高的 Channel 实现同步。

常见的并发原语如下:sync.Mutexsync.RWMutexsync.WaitGroupsync.Condsync.Oncesync.Poolsync.Context,这其中的绝大多数原语我们都已经介绍过了。

除了标准库中提供的同步原语之外,Go 语言还在子仓库 sync]中提供了四种扩展原语

  1. golang/sync/errgroup.Group

  2. golang/sync/semaphore.Weighted

  3. golang/sync/singleflight.Group

  4. golang/sync/syncmap.Map

其中golang/sync/syncmap.Map在 1.9 版本中被移植到了标准库中。

13.6.1 errgroup:

errgroup 可以在一组 Goroutine 中提供了同步、错误传播以及上下文取消的功能。

只要一个 goroutine 出错我们就不再等其他 goroutine 了,减少资源浪费,并且返回错误。

package main

import (
    "fmt"
    "net/http"

    "golang.org/x/sync/errgroup"
)

func main() {
    var g errgroup.Group
    var urls = []string{
        "http://www.baidu.com/",
        "https://www.sina.com.cn/",
    }
    for i := range urls {
        url := urls[i]
        g.Go(func() error {
            resp, err := http.Get(url)
            if err == nil {
                resp.Body.Close()
            }
            return err
        })
    }
    err := g.Wait()
    if err == nil {
        fmt.Println("Successfully fetched all URLs.")
    } else {
        fmt.Println("fetched error:", err.Error())
    }
}

13.6.2 Semaphore:

Semaphore带权重的信号量,控制多个goroutine同时访问资源

使用场景:控制 goroutine 的阻塞和唤醒

package main

import (
    "context"
    "fmt"
    "log"
    "runtime"
    "time"

    "golang.org/x/sync/semaphore"
)

var (
    maxWorkers = runtime.GOMAXPROCS(0)
    sema       = semaphore.NewWeighted(int64(maxWorkers)) //信号量
    task       = make([]int, maxWorkers*4)

// 任务数,是worker的四
)

func main() {
    ctx := context.Background()
    for i := range task {
        // 如果没有worker可用,会阻塞在这里,直到某个worker被释放
        if err := sema.Acquire(ctx, 1); err != nil {
            break
        }
        // 启动worker goroutine
        go func(i int) {
            defer sema.Release(1)
            time.Sleep(100 * time.Millisecond) // 模拟一个耗时操作
            task[i] = i + 1
        }(i)
    }
    // 请求所有的worker,这样能确保前面的worker都执行完
    if err := sema.Acquire(ctx, int64(maxWorkers)); err != nil {
        log.Printf("获取所有的worker失败: %v", err)
    }
    fmt.Println(maxWorkers, task)
}

13.6.3 SingleFlight:

用于抑制对下游的重复请求

使用场景:访问缓存、数据库等场景,缓存过期时只有一个请求去更新数据库

package main

import (
    "fmt"
    "sync"
    "sync/atomic"
    "time"

    "golang.org/x/sync/singleflight"
)

// 模拟从数据库读取
func getArticle(id int) (article string, err error) {
    // 假设这里会对数据库进行调用, 模拟不同并发下耗时不同
    atomic.AddInt32(&count, 1)
    time.Sleep(time.Duration(count) * time.Millisecond)

    return fmt.Sprintf("article: %d", id), nil
}

// 模拟优先读缓存,缓存不存在读取数据库,并且只有一个请求读取数据库,其它请求等待
func singleflightGetArticle(sg *singleflight.Group, id int) (string, error) {
    v, err, _ := sg.Do(fmt.Sprintf("%d", id), func() (interface{}, error) {
        return getArticle(id)
    })

    return v.(string), err
}

var count int32

func main() {
    time.AfterFunc(1*time.Second, func() {
        atomic.AddInt32(&count, -count)
    })

    var (
        wg  sync.WaitGroup
        now = time.Now()
        n   = 1000
        sg  = &singleflight.Group{}
    )

    for i := 0; i < n; i++ {
        wg.Add(1)
        go func() {
            res, _ := singleflightGetArticle(sg, 1)
            // res, _ := getArticle(1)
            if res != "article: 1" {
                panic("err")
            }
            wg.Done()
        }()
    }

    wg.Wait()
    fmt.Printf("同时发起 %d 次请求,耗时: %s", n, time.Since(now))
}

14.测试:

14.1 测试类型:

测试是编写高质量软件的关键部分之一。测试能够确保你的代码行为符合预期,并且在修改代码时防止引入新的错误。

回归测试:人工模拟真实操作。

集成测试:对系统进行测试。

单元测试:对单独的模块进行测试。

从上到下,覆盖率逐渐变大,成本逐渐变低.所以单元测试是最重要的.

14.2 单元测试:

Go语言的测试通常存储在与被测试代码相同的包中,并以_test.go结尾。你可以使用Go的测试框架进行单元测试。

goCopy code
func TestAdd(t *testing.T) {
    result := Add(3, 5)
    if result != 8 {
        t.Errorf("Add(3, 5) = %d; want 8", result)
    }
}

14.2.1 子测试:

你可以在测试函数中定义子测试,以便更细粒度地测试函数的不同方面。

goCopy code
func TestAdd(t *testing.T) {
    t.Run("Positive numbers", func(t *testing.T) {
        result := Add(3, 5)
        if result != 8 {
            t.Errorf("Add(3, 5) = %d; want 8", result)
        }
    })

    t.Run("Negative numbers", func(t *testing.T) {
        result := Add(-3, -5)
        if result != -8 {
            t.Errorf("Add(-3, -5) = %d; want -8", result)
        }
    })
}

14.2.2 表格驱动测试:

表格驱动测试可以让你针对多个输入值运行相同的测试代码,并比较结果。

goCopy code
func TestAdd(t *testing.T) {
    testCases := []struct {
        a, b, want int
    }{
        {3, 5, 8},
        {-3, -5, -8},
        {0, 0, 0},
    }

    for _, tc := range testCases {
        result := Add(tc.a, tc.b)
        if result != tc.want {
            t.Errorf("Add(%d, %d) = %d; want %d", tc.a, tc.b, result, tc.want)
        }
    }
}

14.3 Mock:

Mocking是在测试中替换依赖项的行为,以便更容易地进行单元测试。

14.3.1 使用接口:

将依赖项定义为接口,然后在测试中使用Mock对象实现该接口。

goCopy code
type UserService interface {
    GetUser(id int) (User, error)
}

type MockUserService struct{}

func (m MockUserService) GetUser(id int) (User, error) {
    // Mock GetUser 方法的行为
}

func TestSomething(t *testing.T) {
    // 使用 MockUserService 进行测试
}

14.3.2 使用依赖注入:

通过依赖注入将依赖项传递给被测试代码,然后在测试中注入Mock对象。

goCopy code
func SomeFunction(userService UserService) {
    // 使用 UserService 获取用户信息
}

func TestSomething(t *testing.T) {
    mockUserService := MockUserService{}
    SomeFunction(mockUserService)
}

15.编程规范

15.1 原则:

尽量提供正确可靠,简洁清晰的代码.

QQ截图20230117112904

15.2 注释:

“好的代码有很多注释,坏的代码需要很多注释”

代码是最好的注释,提供代码为表达出的上下文信息.

  • 公共符号始终要注释.(变量.常量.函数.结构等).

  • 任何既不明显也不简短的公共功能必须要注释.

  • 无论长度和复杂程度如何,对库中的函数必须注释.

15.2.1: 解释代码作用:

建议统一放在公共符号之前.适合注释公共符号.

15.2.2: 解释代码是如何做的:

建议统一放在公共符号之前.适合注释实现过程.

15.2.3: 解释代码实现的原因:

建议放在代码内,提供额外的上下文.

15.2.4: 解释代码什么情况下会出错:

建议统一放在公共符号之前.解释代码的限制条件.

15.3 代码格式:

15.3.1 go fmt命令:

自动格式化代码.

15.3.2 go imports命令:

自动修正包.

15.3.3 命名规范;

15.3.3.1 变量:

1.简洁胜过冗长.

2.变量距离被使用的地方越远(库),则需要携带更多的上下文信息.

15.3.3.2 函数:

1.简短,尽量不携带包的上下文信息.

2.返回类型和函数名相同时不需要展示,反之则额外展示.

15.3.3.3 包:

  • 全小写.

  • 包含一定的上下文信息.

  • 不要与标准库同名.

  • 不用常见的变量名.

  • 使用单数而不是复数.

  • 谨慎的使用缩写.

核心是降低阅读理解代码的成本,重点是考虑上下文信息,设计简洁的名称.

QQ截图20230117115503

15.3.4 控制流程:

1.尽量避免嵌套.

2.优先处理错误情况.尽量早的返回或者继续循环.

3.尽量保持正常代码路径为最小缩进.

// Bad
func OneFunc() error {
	err := doSomething()
	if err ==nil {
		err := doAnotherThing()
		if err == nil {
				return nil
		}	
		return nil // normal case
	}
	return err
} 
// Good
func OneFunc() error {
	if err := doSomething(); err != nil { 
		return err
	}
	if err := doAnotherThing(); err != nil {
		return err
	}
return nil // norma case 
}

线性原理.正常流程代码沿着屏幕向下移动,故障问题大多出现在复杂的条件语句和循环语句中.

15.3.5: 异常处理:

15.3.5.1 简单错误:

只出现一次的错误,使用error.New.

15.3.5.2 复杂错误:

使用errors.is判断错误是否为特定错误,errors.as获取特定种类的错误.

15.3.5.3 panic:

QQ截图20230117120637

15.3.5.4.recover:

QQ截图20230117120802

16.性能优化:

QQ截图20230131212817

QQ截图20230131212844

运行结果:

QQ截图20230131212920

3.2 性能优化建议:

3.2.1 slice预分配内存:

QQ截图20230131213029

原因:

QQ截图20230131213302

如果不预先分配内存,那么会造成在内存不够的时候,对底层数组进行扩容,从而导致耗时增加.

3.2.2 释放大切片的内存:

QQ截图20230131213700

在使用切片当中的一部分的时候,要新建一个切片,把原先切片中的数据拷贝过去.

3.2.3 map预分配内存:

QQ截图20230131213831

QQ截图20230131213900

3.2.4: 字符串处理:

QQ截图20230131214031

QQ截图20230131214135

QQ截图20230131214251

最后一项指标是内存分配次数,结果验证了之前的方法.

3.2.5: 空结构体:

QQ截图20230131214457

QQ截图20230131214505

3.2.6: atomic包:

QQ截图20230131214613

QQ截图20230131214634

3.2.7: 总结:

QQ截图20230131214721

3.3 性能优化原则:

QQ截图20230131214852

3.4 性能分析工具:pprof:

QQ截图20230131215026

QQ截图20230131215036

3.4.1 使用:

3.4.1.1 搭建:

QQ截图20230131215133

3.4.1.2 浏览器查看指标:

QQ截图20230131215258

3.4.1.3 采集性能数据:

QQ截图20230131215447

3.4.1.4 操作数据:

3.4.1.4.1: 时间:QQ截图20230131215508

QQ截图20230131215706

QQ截图20230131215820

QQ截图20230131215840

3.4.1.4.2: 内存:

QQ截图20230131215948

QQ截图20230131220918

QQ截图20230131221154

QQ截图20230131221226

3.4.1.4.3: 协程:

QQ截图20230131221450

QQ截图20230131221549

QQ截图20230131221710

3.4.1.4.4: 锁:

QQ截图20230131222038

3.4.1.4.5:阻塞:

QQ截图20230131222133

可能有部分阻塞会被隐藏,需要手动打开。

![QQ截图20230131222559](/C:/Users/wpy/Desktop/blog/source/images/Golang学习笔记/QQ截图20230131222559.png)

3.4.2 原理:

QQ截图20230203094748

QQ截图20230203094830

QQ截图20230203094910

QQ截图20230203095052

QQ截图20230203095145

3.4.3 总结:

QQ截图20230203095252

3.5 性能调优案例:

3.5.1 分类:

QQ截图20230203095355

3.5.2 业务服务优化:

QQ截图20230203095459

3.5.2.1 流程:

QQ截图20230203095620

3.5.2.2 服务评估:

![QQ截图20230203095716](/C:/Users/wpy/Desktop/blog/source/images/Golang学习笔记/QQ截图20230203095716.png)QQ截图20230203095918

3.5.2.3 定位瓶颈:

QQ截图20230203100005

QQ截图20230203100039

QQ截图20230203100051

3.5.2.4 优化改造:

QQ截图20230203100330

重新测试接口,对比数据

3.5.2.5 优化效果验证:

QQ截图20230203100425

3.5.2.6 进一步优化:

QQ截图20230203100525

被调用的服务被称为调用服务的上游服务.

3.5.3 基础库优化:

QQ截图20230203100655

3.5.4 Go语言优化:

QQ截图20230203100813

3.6 总结:

QQ截图20230203100859

以数据为核心.

17.编译与部署:

17.1 编译:

image-20231203155803308

在编译时,编译器会将程序运行依赖的库文件包含在可执行文件中,所以,可执行文件 变大了很多。

如果我们先编译生成了可执行文件,那么我们可以将该可执行文件拷贝到没有go开发环境的机器上,仍然可以运行 如果我们是直接go run go源代码,那么如果要在另外一个机器上这么运行,也需要go 开发环境,否则无法执行。 go run运行时间明显要比第一种方式 长一点点.

17.2 部署:

Go 语言支持跨平台交叉编译,也就是说我们可以在 Windows 或 Mac 平台下编写代码,并且将代码编译成能够在 Linux amd64 服务器上运行的程序。

对于简单的项目,通常我们只需要将编译后的二进制文件拷贝到服务器上,然后设置为后台守护进程运行即可。

可以参考https://juejin.cn/post/6898733410647277576searchId=2024022217191705F132D348170A2D9D92

以在linxu服务上部署项目为例子:

17.2.1 构建项目:

终端输入:

 GOOS=linux GOARCH=amd64 go build

生成构建完成的二进制文件。

注意:直接修改env之后再go build好像不可行,会提示无法执行二进制文件。

17.2.2 授权:

把生成的文件放到服务器上,打开终端进入到文件夹下。

使用如下命令,给执行的权限。

chmod +x 文件名

17.2.3 启动:

//直接启动,这种启动方法在控制台退出时程序会停止
./文件名
//后台启动
nohup ./文件名 &

成功截图如下:

image-20240403182833240

17.2.4 查看启动状况:

ps aux|grep 文件名

18.总结:

18.1 内容和过程:

内容:

  1. 基础语法

  2. 语言特性

  3. 业务开发

  4. 其他知识

学习过程:

  1. 使用chatgpt生成基础知识大纲

  2. 去csdn或者掘金上找一篇较好的文章

  3. 结合二者学习基础知识

  4. 从https://www.youandgentleness.cn/和https://draveness.me/golang/docs/part3-runtime/ch06-concurrency/golang-channel/学习底层

  5. 遇到其他问题再去搜索相关资料。

18.2 参考文档:

1.https://www.youandgentleness.cn

好兄弟的博客,主要参考了其中Go语言面试精讲的部分。

2.https://draveness.me/golang/

《go语言设计与实现》主要探究go语言设计底层的原理,非常有深度的一本书。

3.https://chat.openai.com/

每次学习新的知识或者模块,使用chat先生成简易使用版的大纲。

4.https://juejin.cn/user/3386151545092589

字节跳动青训营.里面有非常多的优秀的文章与课程,本教程的后半部分很多来源于此。

5.其他CSDN和掘金上一些关于go各个模块的优秀的文章,太多不一一指出。

19.泛型:

泛型程序设计(generic programming)是程序设计语言的一种风格或范式。泛型允许程序员在强类型程序设计语言中编写代码时使用一些以后才指定的类型,在实例化时作为参数指明这些类型。

Go 1.18 之后支持泛型。

参考:https://www.cnblogs.com/insipid/p/17772581.html#%E5%8F%82%E8%80%83%E8%B5%84%E6%96%99

https://segmentfault.com/a/1190000041634906#item-5-8

19.1 形参和实参:

19.1.1 函数参数值的形参和实参:

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

调用函数Add时,需要传入实参。实参需要满足形参的约束

scss
复制代码Add(1,2)
Add(100,200)

函数形参,类似占位符,没有具体的值。实参是传入的具体值,只需要满足形参的约束即可。

有个问题是如果想对int32进行相加,则需要定义新方法 func AddInt32(a, b int32) int32 {},因为int32的实参不满足int形参的约束。

此时我们不禁开始思考:既然这玩意儿只是个占位符,那么既然函数的值可以被占位,那么函数的类型是否也可以被占位呢?

类比函数形参和实参,能否定义类型的形参,然后传入的类型的实参满足形参的约束?

19.1.2 函数参数类型的形参和实参:

// 伪代码
T int string // T为类型形参,约束只能是int string的类型实参
func Add(a, b T) T {
  return a + b
}
•
// 使用,实参类型可以是int string
Add(1 int, 2 int)
Add("a" string, "b" string)

引入类型形参和实参,让一个函数获得了处理多种不同类型数据的能力,这种编程方式称为泛型编程

19.2 泛型结构:

image-20240407112144233

类型参数列表出现在常规参数之前。为了区分类型参数列表和常规参数列表,类型参数列表使用方括号[]而不是圆括号()。

19.2.1 类型参数:

当程序运行通用代码时,类型参数就会被类型参数所取代。也就是类型参数是泛型的抽象数据类型

// Print 可以打印任何片断的元素。
// Print 有一个类型参数 T,并有一个单一的(非类型)的 s,它是该类型参数的一个片断。
func Print[T any](s []T) {
  // do something...
}

19.2.2 约束:

所有泛型代码都希望类型参数满足某些要求。这些要求被称为约束。

为了确保调用方能够满足接受方的程序诉求,保证程序中所应用的函数、运算符等特性能够正常运行。泛型的类型参数,类型约束,相辅相成。

// any 并没有约束后续计算的类型
func add[T any](a, b T) T {
    return a + b // 编译错误
}

上述代码中,any约束允许任何类型作为类型参数,并且只允许函数使用任何类型所允许的操作。其接口类型是空接口:interface{}, a和b类型都是T,并且T是any类型的。 因此a和b是不能直接相加操作的。

因此,需要设置可相加的类型约束。

// T类型的约束被设置成 int | float32 | float64
func Add[T int | float32 | float64](a, b T) T {
    return a + b
}

上述代码中将T的类型约束,设置成为int | float32 | float64, 而这三个类型都是可以相加操作的,因此,编译不会出现错误。

类型约束除了基础类型,还可以设置成接口类型

我们看看 Go 官方所提供的例子:

func Stringify[T any](s []T) (ret []string) {
  for _, v := range s {
    ret = append(ret, v.String()) // INVALID
  }
  return ret
}

该方法的实现目的是:任何类型的切片都能转换成对应的字符串切片。但程序逻辑里有一个问题,那就是他的入参 T 是 any 类型,是任意类型都可以传入。

其内部又调用了 String 方法,自然也就会报错,因为只像是 int、float64 等类型,就可能没有实现该方法。

你说要定义有效的类型约束,那像是上面的例子,在泛型中如何实现呢?

要求传入方要有内置方法,就得定义一个 interface 来约束他。

例子如下:

type Stringer interface {
  String() string
}

在泛型方法中应用:

func Stringify[T Stringer](s []T) (ret []string) {
  for _, v := range s {
    ret = append(ret, v.String())
  }
  return ret
}

再将 Stringer 类型放到原有的 any 类型处,就可以实现程序所需的诉求了。

19.3 使用:

19.3.1 确定使用场景:

当你需要参数动态的类型,并且都能对这些类型作出正确的处理时,可以考虑使用泛型。

19.3.2 定义:

19.3.2.1 泛型函数:

func Add(a, b T) T {
  return a + b
}

19.3.2.2. 泛型结构体:

//约束
type cacheable interface {
    Category | Post
}
​
type cache[T cacheable] struct {
    data map[string]T
}

19.3.3 初始化:

19.3.3.1 泛型函数:

在调用泛型函数时,需要给函数的类型参数指定具体的类型,叫做类型实例化

r2 := Max[int]([]int{4, 8, 15}

其中有一点需要注意,在类型参数实例化时,还有一种方式是不需要指定具体的类型,这时在编译阶段,编译器会根据函数的参数自动推导出来T的实际参数值: r3 := Max([]float64{4.1, -8.1, 15.1})。这里Max后面并没有给出中括号以及对应的具体类型,但Go编译器能根据切片元素类型自动推断出是float64类型。

19.3.3.2 泛型结构体:

c := &cache[Category]{
    data: make(map[string]T)
}

19.3.4 使用:

举个在main函数中使用的例子:

package main
​
import (
  "fmt"
)
​
func main() {
  // create a new category
  category := Category{
    ID: 1,
    Name: "Go Generics",
    Slug: "go-generics",
  }
  // create cache for blog.Category struct
  cc := New[Category]()
  // add category to cache
  cc.Set(category.Slug, category)
  fmt.Printf("cp get:%+v\n", cc.Get(category.Slug))
  // create a new post
  post := Post{
    ID: 1,
    Categories: []Category{
      {ID: 1, Name: "Go Generics", Slug: "go-generics"},
    },
    Title: "Generics in Golang structs",
    Text: "Here go's the text",
    Slug: "generics-in-golang-structs",
  }
  // create cache for blog.Post struct
  cp := New[Post]()
  // add post to cache
  cp.Set(post.Slug, post)
​
  fmt.Printf("cp get:%+v\n", cp.Get(post.Slug))
}