Sorry, your browser cannot access this site
This page requires browser support (enable) JavaScript
Learn more >

Go 基础

数据类型占用空间

类型 空间
int8 1
int16 2
int32 4
int64 8
int 4(32位)/8(64位)
float32 4
float64 8
string 1(英文)/2~4(中文取决于字符集)
bool 1
byte 1

func

Go语言中,函数被认为是一等公民(First-class citizens),这意味着函数在语言中具有与其他类型(如整数、字符串等)相同的权利和地位。以下是函数在Go语言中被视为一等公民的原因:

  1. 函数可以作为值进行传递:在Go语言中,函数可以像其他类型的值一样被传递给其他函数或赋值给变量。这意味着可以将函数作为参数传递给其他函数,也可以将函数作为返回值返回。
  2. 函数可以赋值给变量:在Go语言中,可以将函数赋值给变量,然后通过变量来调用函数。这种能力使得函数可以像其他数据类型一样被操作和处理。
  3. 函数可以匿名定义Go语言支持匿名函数的定义,也称为闭包。这意味着可以在不给函数命名的情况下直接定义和使用函数,更加灵活和便捷。
  4. 函数可以作为数据结构的成员:在Go语言中,函数可以作为结构体的成员,从而使得函数与其他数据一起存储在结构体中。这种特性使得函数能够更好地与数据相关联,实现更复杂的功能。

Init()函数

golang程序初始化先于main函数执行,由runtime进行初始化,初始化顺序如下:

  1. 初始化导入的包(包的初始化顺序并不是按导入顺序(“从上到下”)执行的,runtime需要解析包依赖关系,==没有依赖的包最先初始化==,与变量初始化依赖关系类似)
  2. 初始化包作用域的变量(该作用域的变量的初始化也并非按照“从上到下、从左到右”的顺序,runtime解析变量依赖关系,没有依赖的变量最先初始化)
  3. 执行包的init函数;
  • init函数先于main函数自动执行,不能被其他函数调用;

  • init函数没有输入参数、返回值;

  • 每个包可以有多个init函数;

  • 包的每个源文件也可以有多个init函数,这点比较特殊;

  • 同一个包的init执行顺序,golang没有明确定义,编程时要注意程序不要依赖这个执行顺序。

  • 不同包的init函数按照包导入的依赖关系决定执行顺序。

  • init 函数没有输入参数、返回值,也未声明,所以无法被显示的调用,不能被引用(赋值给函数变量),否则会出现编译错误

  • 一个go文件可以拥有多个init函数,执行顺序按定义顺序执行

  • 初始化常量/变量优于 init 函数执行,init函数先于main函数自动执行。执行顺序先后为: const常量 > var 变量 > init函数 > main函数

只想调用包的init函数,不需要其他方法

1
import _ "net/http/pprof"

golang对没有使用的导入包会编译报错,但是有时我们只想调用该包的init函数,不使用包导出的变量或者方法,这时就采用上面的导入方案。

结构体

Go 语言中没有类的概念,因此在 Go 中结构体有着更为重要的地位。结构体是复合类型(composite types),当需要定义一个类型,它由一系列属性组成,每个属性都有自己的类型和值的时候,就应该使用结构体,它把数据聚集在一起。然后可以访问这些数据,就好像它是一个独立实体的一部分。结构体也是值类型,因此可以通过 new 函数来创建。

初始化

因为Go语言结构体是一个值类型,也就是说当你声明了一个结构体类型的变量时,实际上是在内存中分配了一块连续的内存空间的,这个空间里面包含这个结构体中定义的所有字段。字段均为默认零值

go的结构体能不能比较

golang中 SliceMapFunc 这三种数据类型是不可以直接比较的。

同一个struct的两个实例可比较也不可比较,当结构不包含不可直接比较成员变量时可直接比较,否则不可直接比较

对struct{}{}的理解

结构体常用于抽象表示一类事物,可以拥有行为或者状态。

struct{ } :表示struct类型
struct{}{}是一种普通数据类型,一个无元素的结构体类型,通常在没有信息存储时使用。
==优点是大小为0,不需要内存来存储struct {}类型的值==。

空结构体是一种特殊的结构体,没有任何字段,不会进行内存对齐,也不占用内存,但是有固定的地址 zerobase。

struct {} {}:表示struct类型的值,该值也是空。
struct {} {}是一个复合字面量,它构造了一个struct {}类型的值,该值也是空。

应用场景
  1. struct{}类型的chan,用于传递信号,用于流转各类状态或是控制并发情况。
  2. map[type]struct{},实现set
  3. 实现方法接收者,不占空间,也便于未来针对该类型进行公共字段等的增加

实现方法接收者

在业务场景下,我们需要将方法组合起来,代表其是一个 ”分组“ 的,便于后续拓展和维护。
但是如果我们使用:

1
2
type T string
func (s *T) Call()

又似乎有点不大友好,因为作为一个字符串类型,其本身会占据定的空间。
这种时候我们会采用空结构体的方式,这样==也便于未来针对该类型进行公共字段等的增加==。如下:

1
2
3
4
5
6
7
8
9
type T struct{}

func (s *T) Call() {
}

func main() {
var s T
s.Call()
}

在该场景下,使用空结构体从多维度来考量是最合适的,易拓展,省空间,最结构化。

函数传值

go都是值传递只是传的参数是值类型还是引用类型
golang中所有函数参数传递都是传值,slice、map和chan看上去像引用只是因为他们内部有指针或本身就是指针而已。由于它们本身就是引用类型,因此使用引用传递可以避免复制数据和额外的内存开销。

string在底层实现上是引用类型,但是因为string不允许修改,只能生成新的对象,在逻辑上和值类型无差别。

值传递或者指针传递都有可能发生逃逸,关键是有没有外部引用!!!,不是传指针就一定会逃逸!!!

当结构体很小且拷贝成本很低时,例如结构体中只包含几个基本类型的字段,可以直接传递结构体的值,这样可以避免创建指针的额外开销,而且较小的结构体会在栈上分配内存更加高效,减少GC压力。

当结构体很大时或者需要修改结构体中的字段时,传递指向结构体的指针会更加高效。因为在 Go 中,函数传递结构体时会进行一次值拷贝,如果结构体很大,则拷贝的成本也很高。而如果传递结构体的指针,函数就可以直接操作原始数据,避免了值拷贝的开销。

所以得出结论,当我们需要修改结构体的变量内容的时候,方法传入的结构体变量参数需要使用指针也就是结构体的地址。

需要修改map中的架构体的变量的时候也需要使用结构体地址作为map的value。
如果仅仅是读取结构体变量,可以不使用指针,直接传递引用即可。
*type 这里的type这个变量存放的东西是地址,这点需要明确,需要使用&type获取到地址。

引用类型

引用类型 变量存储的是一个地址,这个地址存储最终的值。内存通常在堆上分配。通过 GC 回收。

包括 指针、slice 切片、管道 channel、接口 interface、map、函数等。

==struct是值类型==

值类型 直接存放值,内存通常在栈中分配

应用类型变量存储的地址(也就是通过指针访问类型里面的数据),通常真正的值在堆上分配。当没有变量引用这个地址的时候,该值会被gc回收。

make和new和var的区别?

引用类型 变量存储的是一个地址,这个地址存储最终的值。内存通常在堆上分配。通过 GC 回收。包括 指针、slice 切片、管道 channel、接口 interface、map、函数等。
值类型是基本数据类型,int,float,bool,string, 以及数组和 struct 特点:变量直接存储值,内存通常在栈中分配,栈在函数调用后会被释放
对于引用类型的变量,我们不光要声明它,还要为它分配内存空间
对于值类型的则不需要显示分配内存空间,是因为go会默认帮我们分配好

简单的说,new只分配内存,make用于slice,map,和channel的初始化。

  • make和new都是golang用来分配内存的內建函数,且在堆上分配内存,==make 即分配内存,也初始化内存。new只是将内存清零(赋零值),并没有初始化内存。==

  • make返回的还是==引用类型本身==;而new返回的是==指向类型的指针==。

  • make只能用来分配及初始化类型为==slice,map,channel==的数据;==new可以分配任意类型的数据==。

  • 使用make(),来初始化slice,map 和channel 。

  • 大多数场合,类型明确的场合下,使用短变量声明方式:=。

  • 当使用文字方式初始化一个变量,并且需要指明类型时,使用var变量声明方式。

  • 避免使用new(),除非你需要一个指针变量。

  • 对于值类型的变量,var 声明(包括结构体),系统会默认为他分配内存空间,并赋该类型的零值。

  1. new 和 make都是Go语言的两个内建函数,用于分配内存
  2. new 一般用来返回指针类型(一般不用),make返回引用类型(map, slice,chan 这三个引用)
  3. var 声明的 基本类型和struct这种已经分配了内存,并且赋零值了。

make的参数

1
make(Type, len, cap)

Type:数据类型,必要参数,Type 的值只能是 slice、 map、 channel 这三种数据类型。
len:数据类型实际占用的内存空间长度,map、 channel 是可选参数,slice 是必要参数。
cap:为数据类型提前预留的内存空间长度,可选参数。所谓的提前预留是当前为数据类型申请内存空间的时候,提前申请好额外的内存空间,这样可以避免二次分配内存带来的开销,大大提高程序的性能。

深拷贝和浅拷贝

  1. 深拷贝: 拷贝的是数据本身,创造一个新对象,新创建的对象与原对象不共享内存,新创建的对象在内存中开辟一个新的内存地址,新对象值修改时不会影响原对象值。 实现深拷贝的方式:
    • copy(slice2, slice1)
    • 遍历slice进行append赋值
  2. 浅拷贝∶拷贝的是数据地址,只复制指向的对象的指针,此时新对象和老对象指向的内存地址是一样的,新对象值修改时老对象也会变化。 实现浅拷贝的方式:引用类型的变量,默认赋值操作就是浅拷贝
    • slice2 := slice1

go中的uint无符号整型是否可以相减(uint类型溢出 )

不可以,如果相减会进行类型的自动推导c为uint32位,所以系统会把负数的1的正负位当做最高进制来算,造成数值很大

涉及到原码补码反码,计算机存储,减去一个数,相当于加上这个数的相反数的补码,负数的补码是符号位不变,其他位取反,相加变成一个很大的数,因为是无符号位,首位也会再变。

string 的底层

go底层系列-string底层实现原理与使用 - 掘金 (juejin.cn)

string我们看起来是一个整体,但是本质上是一片连续的内存空间,我们也可以将它理解成一个由字符组成的数组,相比于切片仅仅少了一个Cap属性。

  1. 相比于切片少了一个容量的cap字段,就意味着string是不能发生地址空间扩容;
  2. 可以把string当成一个只读的byte切片类型;
  3. string本身的切片是只读的,所以不会直接向字符串直接追加元素改变其本身的内存空间,所有在字符串上的写入操作都是通过拷贝实现的。
1
2
3
4
5
6
7
8
9
10
type stringStruct struct {
str unsafe.Pointer //字符串首地址,指向底层字节数组的指针
len int //字符串长度
}
// 实例化
func gostringnocopy(str *byte) string {
ss := stringStruct{str: unsafe.Pointer(str), len: findnull(str)}
s := *(*string)(unsafe.Pointer(&ss))
return s
}

string的元素不能取地址,s[i] 代表第i个元素,但是&s[i]是违法的。

如果一个string很大1G,值传递的时候也是复制一遍吗?可以修改吗?

传递指针的复制,不可以修改

string在底层实现上是引用类型,但是因为string不允许修改,只能生成新的对象,在逻辑上和值类型无差别。

string vs []byte

既然string就是一系列字节,而[]byte也可以表达一系列字节,那么实际运用中应当如何取舍?

  • string可以直接比较,而[]byte不可以,所以[]byte不可以当map的key值。
  • 因为无法修改string中的某个字符,需要粒度小到操作一个字符时,用[]byte
  • string值不可为nil,所以如果你想要通过返回nil表达额外的含义,就用[]byte
  • []byte切片这么灵活,想要用切片的特性就用[]byte
  • 需要大量字符串处理的时候用[]byte,性能好很多。

string转成byte数组会发生内存拷贝吗?


字符串转成切片,会产生拷贝。严格来说,只要是发生类型强转都会发生内存拷贝。那么问题来了。
频繁的内存拷贝操作听起来对性能不大友好。有没有什么办法可以在字符串转成切片的时候不用发生拷贝呢?

1
2
3
a :="aaa"  
addr := *(*reflect.StringHeader)(unsafe.Pointer(&a))
b := *(*[]byte)(unsafe.Pointer(&addr))

那么如果想要在底层转换二者,只需要把 StringHeader 的地址强转成 SliceHeader 就行。那么go有个很强的包叫 unsafe

  1. unsafe.Pointer(&a)方法可以得到变量a的地址。
  2. (*reflect.StringHeader)(unsafe.Pointer(&a)) 可以把字符串a转成底层结构的形式。
  3. (*[]byte)(unsafe.Pointer(&ssh)) 可以把ssh底层结构体转成byte的切片的指针。
  4. 再通过 *转为指针指向的实际内容。

两个string合并,用“+”,fmt,strings,哪个效率高

Go 字符串拼接6种,最快的方式 – strings.builder - 技术颜良 - 博客园 (cnblogs.com)

通过两次benchmark对比,我们可以看到

  • 当进行少量字符串拼接时,直接使用+操作符进行拼接字符串,效率还是挺高的,但是当要拼接的字符串数量上来时,+操作符的性能就比较低了;
  • 函数fmt.Sprintf还是不适合进行字符串拼接,无论拼接字符串数量多少,性能损耗都很大,还是老老实实做他的字符串格式化就好了;
  • strings.Builder无论是少量字符串的拼接还是大量的字符串拼接,性能一直都能稳定,这也是为什么Go语言官方推荐使用strings.builder进行字符串拼接的原因,在使用strings.builder时最好使用Grow方法进行初步的容量分配,观察strings.join方法的benchmark就可以发现,因为使用了grow方法,提前分配好内存,在字符串拼接的过程中,不需要进行字符串的拷贝,也不需要分配新的内存,这样使用strings.builder性能最好,且内存消耗最小。
  • bytes.Buffer方法性能是低于strings.builder的,bytes.Buffer 转化为字符串时重新申请了一块空间,存放生成的字符串变量,不像strings.buidler这样直接将底层的 []byte 转换成了字符串类型返回,这就占用了更多的空间。

同步最后分析的结论:

无论什么情况下使用strings.builder进行字符串拼接都是最高效的,不过要主要使用方法,记得调用grow进行容量分配,才会高效。strings.join的性能约等于strings.builder,在已经字符串slice的时候可以使用,未知时不建议使用,构造切片也是有性能损耗的;如果进行少量的字符串拼接时,直接使用+操作符是最方便也是性能最高的,可以放弃strings.builder的使用。

综合对比性能排序:

1
strings.join` ≈ `strings.builder` > `bytes.buffer` > `[]byte`转换`string` > "+" > `fmt.sprintf

strings.builder避免内存拷贝的问题,使用了强制转换来避免内存拷贝

1
2
3
func (b *Builder) String() string {
return *(*string)(unsafe.Pointer(&b.buf))
}

strings.join也是基于strings.builder来实现的,唯一不同在于在join方法内调用了b.Grow(n)方法,这个是进行初步的容量分配,而前面计算的n的长度就是我们要拼接的slice的长度,因为我们传入切片长度固定,所以提前进行容量分配可以减少内存分配,很高效。

rune 和 byte

rune

rune是int32的别名,代表字符的Unicode编码,采用4个字节存储,将string转成rune就意味着任何一个字符都用4个字节来存储其unicode值,这样每次遍历的时候返回的就是unicode值,而不再是字节了,这样就可以解决乱码问题了。
==中文、特殊字符==

byte

bytes操作的对象也是字节切片,与string的不可变不同,byte是可变的,因此string按增量方式构建字符串会导致多次内存分配和复制,使用bytes就不会,因而更高效一点

区别:byte 表示一个字节,rune 表示四个字节

1
2
3
first := "社区" 
fmt.Println([]rune(first)) // 输出[31038 21306]
fmt.Println([]byte(first)) // [231 164 190 229 140 186]

Slice切片

切片(Slice)是一个动态数组,它不需要指定长度,可以动态增长。切片是对底层数组的一层封装,支持对底层数组进行动态增删改操作。切片的定义方式为 var s []int,其中 s 为切片名,int 为元素类型。切片可以使用 append() 函数对其进行动态增长,例如 s = append(s, 1)。切片在内存中不是连续的存储空间,而是由一个指向底层数组的指针、长度和容量组成。

1.19源码:切片一定会分配在堆上 go/complit.go at master · golang/go · GitHub
截取规则左闭右开

1
2
3
4
5
type slice struct {
array unsafe.Pointer
len int
cap int
}

使用var声名的切片其实是一个nil切片,它与nil比较返回true
而使用语法糖或者make声名的切片是一个空切片,他们与ni比较返回false

arr[low:high:max]

len = high-low
cap = max-low

max不指定时max=high

max不允许超过cap

1
panic: runtime error: slice bounds out of range [::6] with capacity 5

切片cap的值在被切时会改变

slice和数组的区别

数组(Array)是一种固定长度的数据结构,元素的类型都是相同的。数组的长度在创建时就已经确定,并且不可更改。数组的定义方式为 var a [5]int,其中 a 为数组名,5 为数组的长度,int 为元素类型。数组可以使用下标进行访问和修改,例如 a[0] = 1。数组在内存中是连续的存储空间。
在Golang中数组是一个长度固定的数据类型,数组的长度是类型的一部分,也就是说[5]int和[10]int是两个不同的类型。

  1. 长度不同:数组的长度是固定的,而切片的长度可以动态增长。
  2. 内存分配方式不同:数组在定义时就已经分配好了内存空间,而切片需要使用 make() 函数进行初始化分配内存。
  3. 数据类型不同:数组中的元素类型必须相同,而切片可以是不同类型的元素的序列。
  4. 传递方式不同:数组是值类型,传递时会复制一份,而切片是引用类型,传递时会传递指向底层数组的指针,多个切片可能会共享底层数组。
  5. 访问方式不同:数组使用下标访问元素,而切片支持切片操作和下标访问元素。

为什么append()需要在传入一个切片后还需要再赋值

因为append之后可能会生成新的slice对象,赋值操作是用来接受可能会产生的新对象,确保期望使用的silce对象始终符合预期
append这种写法看起来有点奇怪而且重复,为什么需要将append函数返回的值再赋值给传入的值,而不是直接append(x,value)呢?这涉及到go语言的设计哲学,即参数传递是值拷贝。传入到函数中的参数x会建立一个新的副本。因此需要将添加元素后返回的新副本赋值给原始的变量。

nil slice

切片的零值是nil,所以只声明变量时,其缺省值为零值nil,这时也就是我们所说的nil slice
nil切片不能直接访问元素值,但可通过append()追加元素。

append 内部append 会初始化 nil slice,与此类似的函数还有 copy 。这两个函数内部都进行 make 初始化。每次对 slice 的操作内部是会产生一个新的数组,然后返回

拷贝大切片一定比小切片代价大吗?

并不是,所有切片的大小相同;三个字段(一个 uintptr,两个int)。切片中的第一个字是指向切片底层数组的指针,这是切片的存储空间,第二个字段是切片的长度,第三个字段是容量。将一个 slice 变量分配给另一个变量只会复制三个机器字。所以 拷贝大切片跟小切片的代价应该是一样的

函数传参修改会影响原值吗?

如果没有扩容则会影响,如果扩容此时函数的哪个就会变成独立的一个切片

注意

1
2
3
4
5
6
7
8
9
10
func main() {
x := []int{1, 2, 3}
y := x[:2]
y = append(y, 50)
y = append(y, 60)
fmt.Println(x) // [1 2 50]
y[0] = 10
fmt.Println(x) // [1 2 50]
fmt.Println(y) // [10 2 50 60]
}

x的大小为3,当y扩容后其大小超过3此时xy就变成了两个独立的切片

Slice扩容规则

1.18 前
当原 slice 容量小于 1024 的时候,新 slice 容量变成原来的 2 倍;原 slice 容量超过 1024,新 slice 容量变成原来的1.25倍。

1.18 后
如果新切片的容量大于原切片的两倍,则直接将切片扩容到新切片的容量
当原slice容量(oldcap)小于256的时候,新slice(newcap)容量为原来的2倍;原slice容量超过256,新slice容量$newcap = oldcap+(oldcap+3*256)/4$

原来 newcap只是一个我们的预期容量,实际的容量需要根据切片中的元素大小对齐内存
最后,会根据切片元素的大小和新容量计算内存,将超出切片长度的内存清空,并拷贝旧切片的内存数据到新申请的内存中,最后返回

defer

在 Go 中,defer 语句用于注册一个函数,这个函数会在当前函数返回前执行,即使函数发生错误或者 panic。

defer 语句的实现原理是,Go 编译器会把 defer 语句转换为一个栈。在函数调用时,每遇到一个 defer 语句,就将其函数推入栈中。在函数返回时,栈中的函数会按照后进先出(LIFO)的顺序执行。这意味着最后注册的函数会最先执行。

return之后的语句先执行,defer后的语句后执行,return 不是原子级操作的,执行过程是: 保存返回值—>执行 defer —>执行 ret

底层

defer 语句后面是要跟一个函数的,所以 defer 的数据结构跟一般的函数类似,不同之处是 defer 结构含有一个指针,用于指向另一个 defer ,每个 goroutine 数据结构中实际上也有一个 defer 指针指向一个 defer 的单链表,每次声明一个 defer 时就将 defer 插入单链表的表头,每次执行 defer 时就从单链表的表头取出一个 defer 执行。保证 defer 是按 LIFO 方式执行的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type _defer struct {
started bool
heap bool

openDefer bool
sp uintptr // sp at time of defer
pc uintptr // pc at time of defer
fn func() // can be nil for open-coded defers
_panic *_panic // panic that is running defer
link *_defer // next defer on G; can point to either heap or stack!
fd unsafe.Pointer // funcdata for the function associated with the frame
varp uintptr // value of varp for the stack frame

framepc uintptr
}

在Go语言的运行时环境中,_defer 结构体的实现可以通过一个链表来维护多个 defer 语句的执行顺序。具体来说,每个_defer 结构体都有指向下一个_defer 结构体的指针,从而可以形成一个链表。

当一个函数执行结束时,runtime 会自动遍历这个链表,按照 defer 语句的执行顺序依次执行这些被延迟的函数。同时,runtime 也会对这些 _defer 结构体进行释放,回收内存。

defer遇到panic时

遇到panic时,遍历本协程的defer链表,并执行defer。在执行defer过程中:遇到recover则停止panic,返回recover处继续往下执行。如果没有遇到recover,遍历完本协程的defer链表后,向stderr抛出panic信息。

panic仅有最后一个可以被revover捕获

panic 和 recover

Panic :在 Go 语言中,出现 Panic 是代表一个严重问题,意味着程序结束并退出。在 Go 中 Panic 关键字用于抛出异常的。类似 Java 中的 throw。

recover:在 Go 语言中,用于将程序状态出现严重错误恢复到正常状态。当 发生 Panic 后,你需要使用recover 捕获,不捕获程序会退出。类似 Java 的 try catch 捕获异常。

  • panic 能够改变程序的控制流,调用 panic 后会立刻停止执行当前函数的剩余代码,并在当前 Goroutine 中递归执行调用方的 defer
  • recover 可以中止 panic 造成的程序崩溃。它是一个只能在 defer 中发挥作用的函数,在其他作用域中调用不会发挥作用;

如果有若干个goroutine,其中有一个panic,会发生什么

有一个panic,那么剩余goroutine也会退出,程序退出。如果不想程序退出,那么必须通过调用 recover() 方法来捕获 panic 并恢复将要崩掉的程序。

defer可以捕获到其goroutine中的子goroutine的panic吗?

不能,它们处于不同的调度器P中。对于子goroutine,必须通过 recover() 机制来进行恢复,然后结合日志进行打印(或者通过channel传递error),下面是一个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func main() {
defer func() {
defer func() {
if err := recover(); err != nil {
panic("3")
}
}()
if err := recover(); err != nil {
panic("2")
}
}()
panic("1")
}

// 结果
panic: 1 [recovered]
panic: 2 [recovered]
panic: 3

接口inteface

在Golang中接口(interface)是一种类型,一种抽象的类型。接口(interface)是一组函数method的集合,Golang中的接口不能包含任何变量。

在 Golang 中,interface 是一组 method 的集合,是 duck-type programming 的一种体现。不关心属性(数据),只关心行为(方法)。具体使用中你可以自定义自己的 struct,并提供特定的 interface 里面的 method 就可以把它当成 interface 来使用。

1
2
3
4
type 接口名 interface {
方法名1 (参数列表1) 返回值列表1
方法名2 (参数列表2) 返回值列表2
}
  1. interface 是方法声明的集合
  2. 任何类型的对象实现了在interface 接口中声明的全部方法,则表明该类型实现了该接口。
  3. interface 可以作为一种数据类型,实现了该接口的任何对象都可以给对应的接口类型变量赋值

注意:

  1. interface 可以被任意对象实现,一个类型/对象也可以实现多个 interface
  2. 方法不能重载,如 eat() eat(s string) 不能同时存在

空接口

空接口可以作为函数的参数,使用空接口可以接收任意类型的函数参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 空接口表示没有任何约束,任意的类型都可以实现空接口
type EmptyA interface {

}

func main() {
var a EmptyA
var str = "你好golang"
// 让字符串实现A接口
a = str
fmt.Println(a)

var a interface{}
a = 20
a = "hello"
a = true
}

两个接口可以比较吗?

DeepEqual 函数的参数是两个 interface,实际上也就是可以输入任意类型,输出 true 或者 flase 表示输入的两个变量是否是“深度”相等。

1
2
3
4
5
6
// 判断类型是否一样
reflect.TypeOf(a).Kind() == reflect.TypeOf(b).Kind()
// 判断两个interface{}是否相等
reflect.DeepEqual(a, b interface{})
// 将一个interface{}赋值给另一个interface{}
reflect.ValueOf(a).Elem().Set(reflect.ValueOf(b))

类型断言

前面说过,因为空接口 interface{} 没有定义任何函数,因此 Go 中所有类型都实现了空接口。当一个函数的形参是 interface{},那么在函数中,需要对形参进行断言,从而得到它的真实类型。

类型断言的本质,跟类型转换类似,都是类型之间进行转换,不同之处在于,类型断言实在接口之间进行

1
2
3
<目标类型的值>,<布尔参数> := <表达式>.( 目标类型 ) // 安全类型断言

<目标类型的值> := <表达式>.( 目标类型 )  //非安全类型断言
1
2
3
4
5
6
7
8
9
func test6() {
var i interface{} = "TT"
j, b := i.(int)
if b {
fmt.Printf("%T->%d\n", j, j)
} else {
fmt.Println("类型不匹配")
}
}

空接口类型断言实现流程:空接口类型断言实质是将eface_type与要匹配的类型进行对比,匹配成功在内存中组装返回值,匹配失败直接清空寄存器,返回默认值。

小结非空接口类型断言的实质是 iface 中 *itab 的对比。*itab 匹配成功会在内存中组装返回值。匹配失败直接清空寄存器,返回默认值。

作用

  1. 空接口
    1. 通用类型:实现可以接收任意类型的函数参数,保存任意值的字典。
    2. 类型断言:当我们需要在运行时确定一个值的类型时,可以使用类型断言将空接口转换为其他类型。
    3. 泛型编程:可以使用空接口将不同的类型转换为通用的类型,在函数或方法中进行处理,然后再将其转换为原来的类型。
  2. 接口可以定义通用的行为,提高代码复用性。例如,如果你编写了一个可以排序的数据结构,你可以定义一个名为Sort的接口,它定义了一个排序方法,然后任何实现了Sort接口的类型都可以使用这个排序方法。
  3. 接口可以实现多态性,让一个变量可以持有多种类型的值。这使得你可以写出更灵活的代码,可以在运行时根据具体情况选择使用哪个具体类型的方法。这对于实现插件系统、扩展性很高的应用程序或者抽象底层实现等场景非常有用。
  4. 抽象底层实现,接口可以降低模块之间的耦合度,增强代码的灵活性和可扩展性。通过面向接口编程,不同的模块之间可以更容易地协作,可以实现组件化的架构,让系统更加易于维护和扩展。使得上层代码只关注接口定义的行为特征,而不需要关心底层的实现细节。这使得代码更加易于维护和扩展

多态 怎么去复用一个接口的方法?

在Go语言中,实现接口只需要实现接口中所有的方法即可。也就是说,当一个类型定义了接口所包含的全部方法时,该类型就自动地实现了该接口。由于Go语言中不存在显示实现的语法,一个类型实现的接口的集合是由该类型自动地决定的。

在Go语言中,接口的嵌套是一种用于组合接口类型的机制。嵌套接口就是将多个接口的方法组合在一起,以便某个类型可以同时满足这些接口的方法要求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package main

import "fmt"

type Phone interface {
call()
}

type NokiaPhone struct {
}

func (nokiaPhone NokiaPhone) call() {
fmt.Println("I am Nokia, I can call you!")
}

type ApplePhone struct {
}

func (iPhone ApplePhone) call() {
fmt.Println("I am Apple Phone, I can call you!")
}

func main() {
var phone Phone
phone = new(NokiaPhone)
phone.call()

phone = new(ApplePhone)
phone.call()
}

上述中体现了interface接口的语法,在main函数中,也体现了多态的特性。
同样一个phone的抽象接口,分别指向不同的实体对象,调用的call()方法,打印的效果不同,那么就是体现出了多态的特性。

底层

【golang】interface原理 - 个人文章 - SegmentFault 思否

ifaceeface 都是 Go 中描述interface{}的底层结构体,区别在于 iface 描述的接口包含方法,而 eface 则是不包含任何方法的空接口:interface{}

eface

eface表示不含 method 的 interface 结构,或者叫 empty interface。对于 Golang 中的大部分数据类型都可以抽象出来 _type 结构,同时针对不同的类型还会有一些其他信息。

eface结构体是golang中实现interface的一种方式。在语言中,interface是一种类型,代表了一组方法的签名,也就是说实现一个interface的类型必须拥有该interface定义的一组方法。而在golang中,使用interface时,由于interface的底层实现是复合类型,需要保存类型信息和值信息等,在实现中就要使用到eface结构体。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type eface struct {  
_type *_type
data unsafe.Pointer
}

type _type struct {
size uintptr // type size
ptrdata uintptr // size of memory prefix holding all pointers
hash uint32 // hash of type; avoids computation in hash tables
tflag tflag // extra type information flags
align uint8 // alignment of variable with this type
fieldalign uint8 // alignment of struct field with this type
kind uint8 // enumeration for C
alg *typeAlg // algorithm table
gcdata *byte // garbage collection data
str nameOff // string form
ptrToThis typeOff // type for pointer to this type, may be zero
}

其中_type指向类型信息(type information),data指向该类型的值(value of type)。因此,eface结构体可以保存任何类型的值,而不需要提前知道其类型。

当程序使用interface来定义变量时,这个变量实际上是一个eface结构体。程序在使用该变量时,可以通过类型信息对其进行断言,并调用具体的方法。

iface

iface 表示 non-empty interface 的底层实现。相比于 empty interface,non-empty 要包含一些 method。method 的具体实现存放在 itab.fun 变量里。如果 interface 包含多个 method,这里只有一个 fun 变量怎么存呢?这个下面再细说。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
type iface struct {  
tab *itab // 方法表
data unsafe.Pointer // 具体的值
}

// layout of Itab known to compilers
// allocated in non-garbage-collected memory
// Needs to be in sync with
// ../cmd/compile/internal/gc/reflect.go:/^func.dumptypestructs.
type itab struct {
inter *interfacetype
_type *_type
link *itab
bad int32
inhash int32 // has this itab been added to hash?
fun [1]uintptr // variable sized
}
// 包含了一些关于 interface 本身的信息
type interfacetype struct {
typ _type
pkgpath name
mhdr []imethod
}

type imethod struct { //这里的 method 只是一种函数声明的抽象,比如 func Print() error
name nameOff
ityp typeOff
}

iface 结构体有两个字段:

  • tab,指向该接口类型变量的方法表指针,其中 itab 是一个包含了该接口类型的方法集的结构体。
  • data,指向该接口类型变量具体的值的指针,其中 data 具体指向实现该接口的具体类型的值。

通过 iface 结构体的方法表指针,可以在运行时动态分派实际调用的具体实现方法。

使用 iface 结构体,可以在运行时实现接口类型的多态,这些多态的接口类型变量指向的具体类型也会在运行时动态确定。因此,iface 结构体在 Go 中的作用非常重要。

注意 nil != nil

  1. 接口是一种引用类型的数据结构,它的值可以为nil。
  2. 实现接口的类型必须实现接口中所有的方法,否则会编译错误。
  3. 接口的值可以赋给实现接口的类型的变量,反之亦然。
  4. 在实现接口的类型的方法中,可以通过类型断言来判断接口值的实际类型和值。
1
2
3
4
5
6
7
8
9
10
11
12
13
var i interface{} = nil
var j interface{} = (*int)(nil)
var k interface{}
var a chan int
var b chan int
var c chan bool
fmt.Println(i, j, k, a, b, c)
fmt.Println(i == nil, j == nil, a == nil, b == nil, c == nil)
fmt.Println(i == j, i == k, i == a, a == b)
// a == c invalid operation: a == c (mismatched types chan int and chan bool)
// 输出<nil> <nil> <nil> <nil> <nil>
// true false true true true
// false true false true

nil特点

  • nil 不是关键字或保留字:
    var nil = errors.New("my god") 不会报错

  • nil没有默认类型:
    `fmt.Printf(“%T”, nil) // ./hello.go:9:7: use of untyped nil

  • 不同类型 nil 的指针是一样的

    1
    2
    3
    var arr []int 
    var num *int
    fmt.Printf("%p %p", arr, num) // 0x0 0x0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
- nil 是 map、slice、pointer、channel、func、interface 的零值
- 不同类型的 nil 是不能比较的
- 不同类型的 nil 值占用的内存大小可能是不一样的

#### 不能和nil比较的情况

1. nil 标识符是不能比较的: `invalid operation: nil == nil (operator == not defined on nil)`
2. 不同类型的 nil 是不能比较的
3. `map` 、 `slice` 和 `function` 类型的 `nil` 值不能比较,比较两个无法比较类型的值是非法的

```go
var s1 []int
var s2 []int
fmt.Printf(s1 == s2) // invalid operation: s1 == s2 (slice can only be compared to nil)

map

Go 语言map采用的是哈希查找表,并且使用链表解决哈希冲突。

哈希查找表用一个哈希函数将 key 分配到不同的桶(bucket,也就是数组的不同 index)。这样,开销主要在哈希函数的计算以及数组的常数访问时间。在很多场景下,哈希查找表的性能很高。

不能做为map的key

slice、map、func
以及包含这些类型的struct

why?因为这些类型不可以用==比较

nil map 和空map的区别

根据官方定义,nil是预定义标识,代表了指针pointer通道channel函数func接口interfacemap切片slice类型变量的零值。

  • 只声明一个map类型变量时,为nil map
  • 此时为只读map,无法进行写操作,否则会触发panic
  • nil mapempty map区别:
    • nil map:只声明未初始化,此时为只读map,不能写入操作,示例:var m map[t]v
    • empty map:空map,已初始化,可写入,示例:m := map[t]v{}m := make(map[string]string, 0)

删除

外层的循环就是在遍历整个 map,删除的核心就在那个empty。它修改了当前 key 的标记,而不是直接删除了内存里面的数据。

内存没有释放。清空只是修改了一个标记,底层内存还是被占用了
只有将整个map置为nil时才会被GC map = nil
08794
我觉得这样不算是内存泄漏。如果继续给这个map写入值,如果这个值命中了之前被删除的bucket,那么会覆盖之前的empty数据。

哈希冲突

当两个不同的 key 落在同一个桶中,就是发生了哈希冲突。冲突的解决手段是采用链表法:在 桶 中,从前往后找到第一个空位进行插入。如果8个kv满了,那么当前桶就会连接到下一个溢出桶(bmap)

如何有序遍历map

当我们在遍历 map 时,并不是固定地从 0 号 bucket 开始遍历,每次都是从一个随机值序号的 bucket 开始遍历,并且是从这个 bucket 的一个随机序号的 cell 开始遍历。

  1. Map 中的 key 拿出来,放入 slice 中做排序
  2. 利用官方库里的 list(链表) 封装一个结构体,实现一个有序的 K-V 存储结构,在里面维护一个 keys 的 list。
1
2
3
4
5
6
type OrderedMap struct { 
//存储 k-v,使用 *list.Element 当做 value 是利用 map O(1) 的性能找到 list 中的
element kv map[interface{}]*list.Element
//按顺序存储 k-v,保证插入、删除的时间复杂度O(1)
ll *list.List
}

可以对key/value取地址吗?

不可以,因为没必要,扩容会改变

如果通过其他 hack 的方式,例如 unsafe.Pointer 等获取到了 key 或 value 的地址,也不能长期持有,因为一旦发生扩容,key 和 value 的位置就会改变,之前保存的地址也就失效了。

注意

  • map的key是无序的,不会通过key保持data的顺序。
  • map中的data不是按插入顺序存储的。
  • 每次迭代循环map时,key的输出都是无序的
  • 在迭代期间对map进行添加的新元素有可能被输出,也有可能被跳过。
  • 在使用make函数初始化map时,指定元素个数,在操作中可以降低内存分配次数,提高性能。
  • map是非并发安全的,不能同时对同一个map进行读和写。想满足并发安全的场景,需要通过sync.RWMutex进行加锁同步。
  • 可作为map的key的类型必须是能够是用 == 操作符进行可比较的类型。
  • 当从map访问一个不存在的键时,他会返回该类型的零值
  • 两个map不能判断相等,只能判断深度相等

底层

Golang | 由浅入深理解哈希表Map - 掘金 (juejin.cn)

哈希查找表用一个哈希函数将 key 分配到不同的桶(bucket,也就是数组的不同 index)。这样,开销主要在哈希函数的计算以及数组的常数访问时间。在很多场景下,哈希查找表的性能很高。

在go的map实现中,它的底层结构体是hmap,hmap里维护着若干个bucket数组 (即桶数组)。

Bucket数组中每个元素都是bmap结构,也即每个bucket(桶)都是bmap结构,【ps:后文为了语义一致,和方便理解,就不再提bmap了,统一叫作桶】 每个桶中保存了8个kv对,如果8个满了,又来了一个key落在了这个桶里,会使用overflow连接下一个桶(溢出桶)。

image-202304061953544

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
type hmap struct {
count int // 元素的个数
flags uint8 // 标识 map 是否被 goroutine 并发读写
B uint8 // buckets 数组的长度就是 2^B 个
overflow uint16 // 溢出桶的数量
buckets unsafe.Pointer // 2^B个桶对应的数组指针
oldbuckets unsafe.Pointer // 发生扩容时,记录扩容前的buckets数组指针
nevacuate unsafe.Pointer // 扩容时的进度标识,index 小于 nevacuate 的桶都已经由老桶转移到新桶中
extra *mapextra //用于保存溢出桶的地址
}
type mapextra struct {
overflow *[]*bmap // 供桶数组 buckets 使用的溢出桶
oldoverflow *[]*bmap // 扩容流程中,供老桶数组 oldBuckets 使用的溢出桶;
nextOverflow *bmap // 下一个可用的溢出桶.
}
const bucketCnt = 8
type bmap struct {
// bmap 就是 map 中的桶,可以存储 8 组 key-value 对的数据,以及一个指向下一个溢出桶的指针;
//每组 key-value 对数据包含 key 高 8 位 hash 值 tophash,key 和 val 三部分;
tophash [bucketCnt]uint8
}
//在编译期间会产生新的结构体
type bmap struct {
tophash [8]uint8 //存储哈希值的高8位
data byte[1] //key value数据:key/key/key/.../value/value/value...
overflow *bmap //溢出bucket的地址
}

核心流程

  1. 根据 key 取 hash 值;
  2. 根据 hash 值对桶数组取模,确定所在的桶;
  3. 倘若 map 处于扩容,则迁移命中的桶,帮助推进渐进式扩容;
  4. 沿着桶链表依次遍历各个桶内的 key-value 对;
  5. 倘若命中相同的 key,则对 value 中进行更新;
  6. 倘若 key 不存在,则插入 key-value 对;
  7. 倘若发现 map 达成扩容条件,则会开启扩容模式,并重新返回第2步.

  1. 根据 key 取 hash 值;
  2. 根据 hash 值对桶数组取模,确定所在的桶;
  3. 沿着桶链表依次遍历各个桶内的 key-value 对;
  4. 命中相同的 key,则返回 value;倘若 key 不存在,则返回零值.

  1. 根据 key 取 hash 值;
  2. 根据 hash 值对桶数组取模,确定所在的桶;
  3. 倘若 map 处于扩容,则迁移命中的桶,帮助推进渐进式扩容;
  4. 沿着桶链表依次遍历各个桶内的 key-value 对;
  5. 倘若命中相同的 key,删除对应的 key-value 对;并将当前位置的 tophash 置为 emptyOne,表示为空;
  6. 倘若当前位置为末位,或者下一个位置的 tophash 为 emptyRest,则沿当前位置向前遍历,将毗邻的 emptyOne 统一更新为 emptyRest.

桶数组

map 中,会通过长度为 2 的整数次幂的桶数组进行 key-value 对的存储:

  1. 每个桶固定可以存放 8 个 key-value 对;
  2. 倘若超过 8 个 key-value 对打到桶数组的同一个索引当中,此时会通过创建桶链表的方式来化解这一问题.

解决哈希冲突

在 map 解决 hash /分桶 冲突问题时,实际上结合了拉链法和开放寻址法两种思路. 以 map 的插入写流程为例,进行思路阐述:

  1. 桶数组中的每个桶,严格意义上是一个单向桶链表,以桶为节点进行串联;
  2. 每个桶固定可以存放 8 个 key-value 对;
  3. 当 key 命中一个桶时,首先根据开放寻址法,在桶的 8 个位置中寻找空位进行插入;
  4. 倘若桶的 8 个位置都已被占满,则基于桶的溢出桶指针,找到下一个桶,重复第3步;
  5. 倘若遍历到链表尾部,仍未找到空位,则基于拉链法,在桶链表尾部续接新桶,并插入 key-value 对.

哈希算法

对于哈希算法的选择,程序会根据当前架构判断是否支持AES,如果支持就使用AES hash,其实现代码位于src/runtime/asm_{386,amd64,arm64}.s中;若不支持,其hash算法则根据xxhash算法(https://code.google.com/p/xxhash/)和cityhash算法(https://code.google.com/p/cityhash/)启发而来,代码分别对应于32位(src/runtime/hash32.go)和64位机器(src/runtime/hash64.go)中,对这部分内容感兴趣的读者可以深入研究。

map扩容机制

[[go源码#map#扩容]]

在赋值过程中,会判断是否需要扩容,主要有两个函数:overLoadFactory和tooManyOverflowBuckets

(1)扩容分为增量扩容和等量扩容;
(2)当桶内 key-value 总数/桶数组长度 > 6.5 时发生增量扩容,桶数组长度增长为原值的两倍;
(3)当桶内溢出桶数量大于等于 2^B 时( B 为桶数组长度的指数,B 最大取 15),发生等量扩容,桶的长度保持为原值;
(4)采用渐进扩容的方式,当桶被实际操作到时,由使用者负责完成数据迁移,避免因为一次性的全量数据迁移引发性能抖动.

小结一下:map扩容有两种情况,一==种是map的负载因子超过了6.5,一种是溢出桶(数组)太多了==

扩容方式:

  1. 相同容量扩容:因为有添加和删除操作,所以桶中会出现一些空位,这种扩容实际上是进行了元素重排,不会换桶。
  2. 2倍容量扩容:由于当前桶数组确实不够用了,发生这种扩容时,元素会重排,可能会发生桶迁移。

扩容迁移规则:只有写操作会触发扩容

(1)在等量扩容中,新桶数组长度与原桶数组相同;
(2)key-value 对在新桶数组和老桶数组的中的索引号保持一致;
(3)在增量扩容中,新桶数组长度为原桶数组的两倍;
(4)把新桶数组中桶号对应于老桶数组的区域称为 x 区域,新扩展的区域称为 y 区域.
(5)实际上,一个 key 属于哪个桶,取决于其 hash 值对桶数组长度取模得到的结果,因此依赖于其低位的 hash 值结果.;
(6)在增量扩容流程中,新桶数组的长度会扩展一位,假定 key 原本从属的桶号为 i,则在新桶数组中从属的桶号只可能是 i (x 区域)或者 i + 老桶数组长度(y 区域);
(7)当 key 低位 hash 值向左扩展一位的 bit 位为 0,则应该迁往 x 区域的 i 位置;倘若该 bit 位为 1,应该迁往 y 区域对应的 i + 老桶数组长度的位置.

扩容过程:
==类似redis的渐进式rehash==
由于 map 扩容需要将原有的 key/value 重新搬迁到新的内存地址,如果有大量的 key/value 需要搬迁,会非常影响性能。因此 Go map 的扩容采取了一种称为“渐进式”地方式,原有的 key 并不会一次性搬迁完毕,每次最多只会搬迁 2 个 bucket。
当每次触发写、删操作时,会为处于扩容流程中的 map 完成两组桶的数据迁移:

  1. 一组桶是当前写、删操作所命中的桶;
  2. 另一组桶是,当前未迁移的桶中,索引最小的那个桶.

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

如何保证并发安全?

(1)并发读没有问题;
(2)并发读写中的“写”是广义上的,包含写入、更新、删除等操作;
(3)读的时候发现其他 goroutine 在并发写,抛出 fatal error;
(4)写的时候发现其他 goroutine 在并发写,抛出 fatal error.

需要关注,此处并发读写会引发 fatal error,是一种比 panic 更严重的错误,无法使用 recover 操作捕获.

如果对map进行并发读写会报fatal error,是不能被recover捕获的

使用时加锁或使用sync.Map

倘若发现存在其他 goroutine 在写 map,直接抛出并发读写的 fatal error;其中,并发写标记,位于 hmap.flags 的第 3 个 bit 位;
==在读写前,和读写后都会判断==

1
2
3
4
5
// 检测 hmap 中的 flags uint8 // 标识 map 是否被 goroutine 并发读写
const hashWriting = 4
if h.flags&hashWriting != 0 {
fatal("concurrent map read and map write")
}

sync.Map

底层是两个map , 一个read map 一个dirty map , 一开始读read map ,没有数据则加锁穿透去读dirty map

并且记录一个计数 ,计数满的时候read map被用dirty map进行覆盖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type Map struct { 
mu sync.Mutex
read atomic.Value // readOnly
dirty map[interface{}]*entry
misses int
}

type entry struct {
p unsafe.Pointer // *interface{}
}

type readOnly struct {
m map[interface{}]*entry
amended bool // true if the dirty map contains some key not in m.
}
  • 通过 readdirty 两个字段将读写分离,读的数据存在只读字段 read 上,将最新写入的数据则存在 dirty 字段上
  • 读取时会先查询 read,不存在再查询 dirty,写入时则只写入 dirty
  • 读取 read 并不需要加锁,而读或写 dirty 都需要加锁
  • 另外有 misses 字段来统计 read 被穿透的次数(被穿透只需要读 dirty 的情况),超过一定次数则将 dirty 数据同步到 read 上
  • 对于删除数据则直接通过标记来延迟删除

sync.Map 由两个 map 构成:

  • • read map:访问时全程无锁;
  • • dirty map:是兜底的读写 map,访问时需要加锁.

之所以这样处理,是希望能根据对读、删、更新、写操作频次的探测,来实时动态地调整操作方式,希望在读、更新、删频次较高时,更多地采用 CAS 的方式无锁化地完成操作;在写操作频次较高时,则直接了当地采用加锁操作完成.

  • • sync.Map 适用于读多、更新多、删多、写少的场景;
  • • 倘若写操作过多,sync.Map 基本等价于互斥锁 + map;
  • • sync.Map 可能存在性能抖动问题,主要发生于在读/删流程 miss 只读 map 次数过多时(触发 missLocked 流程),下一次插入操作的过程当中(dirtyLocked 流程).

读流程

  • • 查看 read map 中是否存在 key-entry 对,若存在,则直接读取 entry 返回;

  • • 倘若第一轮 read map 查询 miss,且 read map 不全,则需要加锁 double check;

  • • 第二轮 read map 查询仍 miss(加锁后),且 read map 不全,则查询 dirty map 兜底;

  • • 查询操作涉及到与 dirty map 的交互,misses 加一;

  • • 解锁,返回查得的结果.

  • • 在读流程中,倘若未命中 read map,且由于 read map 内容存在缺失需要和 dirty map 交互时,会走进 missLocked 流程;

  • • 在 missLocked 流程中,首先 misses 计数器累加 1;

  • • 倘若 miss 次数小于 dirty map 中存在的 key-entry 对数量,直接返回即可;

  • • 倘若 miss 次数大于等于 dirty map 中存在的 key-entry 对数量,则使用 dirty map 覆盖 read map,并将 read map 的 amended flag 置为 false;

  • • 新的 dirty map 置为 nil,misses 计数器清零.

写流程

(1)倘若 read map 存在拟写入的 key,且 entry 不为 expunged 状态,说明这次操作属于更新而非插入,直接基于 CAS 操作进行 entry 值的更新,并直接返回(存活态或者软删除,直接覆盖更新);
(2)倘若未命中(1)的分支,则需要加锁 double check;
(3)倘若第二轮检查中发现 read map 或者 dirty map 中存在 key-entry 对,则直接将 entry 更新为新值即可(存活态或者软删除,直接覆盖更新);
(4)在第(3)步中,如果发现 read map 中该 key-entry 为 expunged 态,需要在 dirty map 先补齐 key-entry 对,再更新 entry 值(从硬删除中恢复,然后覆盖更新);
(5)倘若 read map 和 dirty map 均不存在,则在 dirty map 中插入新 key-entry 对,并且保证 read map 的 amended flag 为 true.(插入)
(6)第(5)步的分支中,倘若发现 dirty map 未初始化,需要前置执行 dirtyLocked 流程;
(7)解锁返回.

删流程

(1)倘若 read map 中存在 key,则直接基于 cas 操作将其删除;
(2)倘若read map 不存在 key,且 read map 有缺失(amended flag 为 true),则加锁 dou check;
(3)倘若加锁 double check 时,read map 仍不存在 key 且 read map 有缺失,则从 dirty map 中取元素,并且将 key-entry 对从 dirty map 中物理删除;
(4)走入步骤(3),删操作需要和 dirty map 交互,需要走进 3.3 小节介绍的 missLocked 流程;
(5)解锁;
(6)倘若从 read map 或 dirty map 中获取到了 key 对应的 entry,则走入 entry.delete() 方法逻辑删除 entry;
(7)倘若 read map 和 dirty map 中均不存在 key,返回 false 标识删除失败.

Context

context包定义了Context类型,该类型包含了截止日期、取消信号以及跨API的进程间的其他用户级别范围的变量。

作用

一句话:context 用来解决 goroutine 之间退出通知元数据传递的功能。

传递共享数据、取消goroutine、

Go语言中的context包提供了一种在程序中传递请求范围内的上下文信息的方式。这个上下文信息可以包括请求相关的元数据、取消信号以及其他请求范围内的数据。
context 主要用来在 goroutine 之间传递上下文信息,包括:取消信号、超时时间、截止时间、k-v 等。

取消 goroutine(ctx怎么通知子节点取消的)

Done() 返回一个 channel,==标识ctx是否结束==,可以表示 context 被取消的信号:当这个 channel 被关闭时,说明 context 被取消了。注意,这是一个只读的channel。 我们又知道,读一个关闭的 channel 会读出相应类型的零值。并且源码里没有地方会向这个 channel 里面塞入值。换句话说,这是一个 receive-only 的 channel。因此在子协程里读这个 channel,除非被关闭,否则读不出来任何东西。也正是利用了这一点,子协程从 channel 里读出了值(零值)后,就可以做一些收尾工作,尽快退出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func Perform(ctx context.Context) {
for {
calculatePos()
sendResult()

select {
case <-ctx.Done():
// 被取消,直接返回
return
case <-time.After(time.Second):
// block 1 秒钟
}
}
}

底层

1
2
3
4
5
6
7
8
9
10
11
12
13
type Context interface {
// 当 context 被取消或者到了 deadline,返回一个被关闭的 channel
Done() <-chan struct{}

// 在 channel Done 关闭后,返回 context 取消原因
Err() error

// 返回 context 是否会被取消以及自动取消时间(即 deadline)
Deadline() (deadline time.Time, ok bool)

// 获取 key 对应的 value
Value(key interface{}) interface{}
}

Context 是一个接口,定义了 4 个方法,它们都是幂等的。也就是说连续多次调用同一个方法,得到的结果都是相同的。

Done() 返回一个 channel,==标识ctx是否结束==,可以表示 context 被取消的信号:当这个 channel 被关闭时,说明 context 被取消了。注意,这是一个只读的channel。 我们又知道,读一个关闭的 channel 会读出相应类型的零值。并且源码里没有地方会向这个 channel 里面塞入值。换句话说,这是一个 receive-only 的 channel。因此在子协程里读这个 channel,除非被关闭,否则读不出来任何东西。也正是利用了这一点,子协程从 channel 里读出了值(零值)后,就可以做一些收尾工作,尽快退出。

Err() 返回一个==错误==,表示 context 被关闭的原因。例如是被取消,还是超时。

Deadline() 返回 context 的截止时间,==过期时间==,通过此时间,函数就可以决定是否进行接下来的操作,如果时间太短,就可以不往下做了,否则浪费系统资源。当然,也可以用这个 deadline 来设置一个 I/O 操作的超时时间。

Value() 获取之前设置的 key 对应的 value。==返回ctx存放的对应key的value==

context有几种类型

WithCancel

context.WithCancel(parent Context) (ctx Context, cancel CancelFunc)
返回派生 context 和取消函数。只有创建它的函数才能调用取消函数来取消此 context。如果您愿意,可以传递取消函数,但是,强烈建议不要这样做。这可能导致取消函数的调用者没有意识到取消 context 的下游影响。

1
ctx, cancel := context.WithCancel(context.Background())
WithDeadline

context.WithDeadline(parent Context, d time.Time) (ctx Context, cancel CancelFunc)
此函数返回其父项的派生 context,当截止日期超过或取消函数被调用时,该 context 将被取消。例如,您可以创建一个将在以后的某个时间自动取消的 context,并在子函数中传递它。当因为截止日期耗尽而取消该 context 时,获此 context 的所有函数都会收到通知去停止运行并返回。

1
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(2 * time.Second))
WithTimeout

context.WithTimeout(parent Context, timeout time.Duration) (ctx Context, cancel CancelFunc)

此函数类似于 context.WithDeadline。不同之处在于它将持续时间作为参数输入而不是时间对象。此函数返回派生 context,如果调用取消函数或超出超时持续时间,则会取消该派生 context。

1
ctx, cancel := context.WithTimeout(context.Background(), 2 * time.Second)
WithValue

context.WithValue(parent Context, key, val interface{}) (ctx Context, cancel CancelFunc)

此函数接收 context 并返回派生 context,其中值 val 与 key 关联,并通过 context 树与 context 一起传递。这意味着一旦获得带有值的 context,从中派生的任何 context 都会获得此值。

闭包

在函数内部引用了函数内部变量的函数

==一个函数内引用了外部的局部变量,这种现象,就称之为闭包。==

一般来说,一个函数返回另外一个函数,这个被返回的函数可以引用外层函数的局部变量,这形成了一个闭包。通常,闭包通过一个结构体来实现,它存储一个函数和一个关联的上下文环境。但 Go 语言中,匿名函数就是一个闭包,它可以直接引用外部函数的局部变量

  • 在函数外部访问函数内部变量成为可能
  • 函数内部变量离开其作用域后始终保持在内存中而不被销毁

闭包环境中引用的变量是不能够在栈上分配的,而是在堆上分配。因为如果引用的变量在栈上分配,那么该变量会跟随函数f返回之后回收,那么闭包函数就不可能访问未分配的一个变量,即未声明的变量,之所以能够再堆上分配,而不是在栈上分配,是Go的一个语言特性—-escape analyze(能够自动分析出变量的作用范围,是否将变量分配堆上)。

应用场景

  1. 数据隔离
  2. defer延迟调用与闭包
  3. 中间件
    在闭包中,除了动态创建函数,还可以通过参数传递的方式,将函数穿进去,实现闭包。典型应用计算函数执行时间
  4. 访问到原本访问不到的数据
  5. 二分查找,排序时实现排序函数

注意

  1. 闭包对自由变量的修改是引用的方式。
  2. 闭包中,自由变量的生命周期等同于闭包函数的生命周期,和局部环境的周期无关。

评论