Go高阶-语言基础

前言 #

func main(){
	name:="张三"
	fmt.printf("%d",len(name))
}
6 每个汉字3个字符

逃逸分析 #

Go语言中,调用new函数得到的内存不一定在堆上,还有可能在栈上。这是因为在Go语言中,堆和栈的区别被“模糊化”了,当然这一切都是Go编译器在后台完成的。

一个变量是在堆上分配,还是在栈上分配,是经过编译器的逃逸分析之后得出的“结论”。

Go语言里就是指编译器的逃逸分析:它是编译器执行静态代码分析后,对内存管理进行的优化和简化。

在编译原理中,分析指针动态范围的方法被称为逃逸分析通俗来讲,当一个对象的指针被多个方法或线程引用时,则称这个指针发生了逃逸。逃逸分析决定一个变量是分配在堆上还是分配在栈上

作用 #

逃逸分析把变量合理地分配到它该去的地方,“找准自己的位置”。即使是用new函数申请到的内存,如果编译器发现这块内存在退出函数后就没有使用了,那就分配到栈上,毕竟栈上的内存分配比堆上块很多;反之,即使表面上只是一个普通的变量,但是经过编译器的逃逸分析后发现,在函数之外还有其他的地方在引用,那就分配到堆上。真正做到了按需分配。

如果变量都分配到堆上,堆不像栈可以自动清理。就会引起Go频繁的进行垃圾回收,而垃圾回收会占用比较大的系统开销。

堆和栈相比,堆适合不可预知大小的的内存分配但是为此付出的代价是分配速度较慢,而且会形成内存碎片;栈内存分配则会非常快。栈分配内存只需要通过PUSH指令,并且会被自动释放;而堆分配内存首先需要去找一个大小合适的内存块,之后要通过垃圾回收才能释放。

通过逃逸分析,可以尽量把哪些不需要分配到堆上的变量直接分配到栈上,堆上的压力变小了,会减轻堆内存分配开销,同时也会减轻垃圾回收的压力,提高程序运行速度。

原则 #

Go语言逃逸分析最基本的原则是:如果一个函数返回对一个变量的引用,那么这个变量就会发生逃逸。

Go中的变量只有在编译器可以证明在函数返回后不再被引用的,才分配到栈上,其他情况都分配到堆上。

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

  • 如果变量在函数外部没有引用,则优先放到栈上。
  • 如果变量在函数外部存在引用,则必定放到堆上。

针对第一条,放到堆上的情形:定义了一个很大的数组,需要申请的内存过大,超过了栈的存储能力。

判断 #

Go提供了相关的命令,可以查看变量是否发生逃逸。

go build -gcflags '-m -l' main.go

其中-gcflags参数用于启动编译器支持的额外标志。例如,-m用于输出编译器的优化细节(包括使用逃逸分析这种优化),相反可以使用-N来关闭编译器优化;而-l则用于禁用foo函数的内联优化,防止逃逸被编译器通过内联彻底抹除。

GO与C/C++中的堆和栈是同一个概念吗 #

不是

C/C++中提及的“程序堆栈”本质上是操作系统层级的概念,它通过C/C++语言的编译器和所在的系统环境来共同决定。在程序启动时,操纵系统会自动维护一个所启动程序消耗内存的地址空间,并自动将这个空间从逻辑上划分为堆内存空间和栈内存空间。这时,“栈”的概念是指程序运行时自动获得的一小块内存,而后续的函数调用所消耗的栈大小,会在编译期间有编译器决定,用于保存局部变量或者保存函数调用栈。如果在C/C++中声明一个局部变量,则会执行逻辑上的压栈操作,在栈中记录局部变量。而当局部变量离开作用域之后,所谓的自动释放本质上是该位置的内存在下一次函数调用压栈过程中,可以被无条件的覆盖;对于堆而言,每当程序通过系统调用向操作系统申请内存时,会将所需的空间从维护的堆内存地址空间中分配出去,而在归还时则会将归还的内存合并到所维护的地址空间中。

Go程序也是运行在操作系统上的程序,自然同样拥有前面提到的堆和栈的概念。但区别在于传统意义上的“栈”被Go语言的运行时全部消耗了,用于维护运行时各个组件之间的协调,例如调度器、垃圾回收、系统调用等。而对于用户态的Go代码而言,他们所消耗的“堆和栈”,其实只是Go运行时通过管理向操作系统申请的堆内存,构造的逻辑上的“堆和栈”,它们的本质都是从操作系统申请而来的堆内存。

延迟语句 #

延迟语句defer,能把资源的释放语句与申请语句放到距离相近的位置,从而减少资源泄露的发生。

defer是Go语言提供的一种用于注册延迟调用的机制:让函数或语句可以在当前函数执行完毕后(包括通过return正常结束或者Panic导致的异常结束)执行。通常用于一些成对操作的场景:打开连接/关闭连接、加锁/释放锁、打开文件/关闭文件等。

defer会有短暂延迟,对时间要求特别高的程序,可以避免使用它。

defer的执行顺序 #

defer语句并不会马上执行,而是会进入一个栈,函数return前,会按先进后出的顺序执行。先进后出的原因是后面定义的函数可能会依赖前面的资源,自然要先执行;否则,如果前面的先执行了,那后面的函数依赖就没有了,因而可能会出错。

在defer函数定义时,对外部变量的引用有两种方式:函数参数闭包引用。前者在defer定义时就把值传递给defer,并且被cache起来;后者则会在defer函数真正调用时根据整个上下文确定参数当前的值。

func main(){
	var whatever [3]struct{}
	for i:=range whatever{
		defer func(){
			fmt.Println(i)
		}()
	}
}
2
2
2
defer 后面跟的是一个闭包,i是“引用”类型的变量,for循环结束后i的值为2,因此后面打印了3个2.
type number int
func (n number)print(){fmt.Println(n)}
func (n *number)pprint(){fmt.Println(*n)}
func main(){
	var n number 
	defer n.print()             //刚开始n=0,已经传入了0
	defer n.pprint()            //引用
	defer func(){n.print()}()   //闭包引用
	defer func(){n.pprint()}()  //闭包引用
	n=3
}
3
3
3
0
func main(){
  defer func(){
    fmt.Println("befer return")
  }()
  if true{
    fmt.Println("during retrun")
    return    //这里return了,后面的defer函数没有注册  不执行
  }
  defer func(){
    fmt.Println("after return")
  }()
}
during return
befer return

在某些情况下,会故意用到defer的“先求值,再延迟调用”的性质,像这样的场景:在一个函数里,需要打开两个文件进行合并操作,合并完成后,在函数结束前关闭打开的文件句柄。

func mergeFile()error{
	//打开文件1
  f,_:=os.Open("file1.txt")
  if f!=nil{
    defer func(f io.Closer){   //定义时,参数已经复制
      if err:=f.Closer();err!=nil{
        fmt.Printf("defer close file1.txt err %v\n",err)
      }
    }(f)
  }
  //打开文件2
  f,_=os.Open("file2.txt")
  if f!=nil{
    defer func(f io.Closer){    // 定义时,参数已经复制
      if err:=f.Close();err!=nil{    //关闭的就是正确的文件
        fmt.Printf("defer close file2.txt err %v\n",err)
      }
    }(f)
  }
  //...
  return nil
}

在调用close()函数时,要注意一点:先判断调用主体是否为空,否则可能会解引用了一个空指针,进而Panic。

拆解延迟语句 #

return xxx

上面这条语句经过编译之后,实际上生成了3条指令:

  1. 返回值=xxx
  2. 调用defer函数
  3. 空的return
func f() (r int) {
	defer func(r int) {//1.先赋值,r=1
		r = r + 5        //2.这里改的r是之前传进去的r,不会改变返回的那个r
	}(r)  //改变的是传值进去的r,是形参的一个复制值,不会影响实参r。
	return 1  //3.空的return
}
1
func f() (r int) {
	t := 5 //1.赋值,r=5
	defer func() { //2.defer被插入到赋值与返回之间执行,这个例子中返回值r没有被修改过
		t = t + 5
	}()
	return t  //3.最后执行空的return指令
}
5

闭包 #

闭包是由函数及其相关引用环境组合而成的实体,即:闭包=函数+引用环境。

匿名函数不能独立存在,但可以直接调用或者赋值于某个变量。匿名函数也被称为闭包,一个闭包继承了函数声明时的作用域。在Go语言中,所有的匿名函数都是闭包。

可以把闭包看成是一个类,一个闭包函数调用就是实例化一个类。闭包在运行时可以有很多个实例,它会将同一个作用域里的变量和常量捕获下来,无论闭包在什么地方被调用(实例化)时,都可以使用这些变量和常量。

闭包捕获的变量和常量时引用传递,不是值传递。

延迟语句如何配合恢复语句 #

Go函数总是会返回一个error,留给调用者处理;而如果是致命的错误,比如程序执行初始化的时候出问题,最好直接Panic掉,避免上线运行后出更大的问题。

有些时候,需要从异常中恢复。比如服务器程序遇到严重问题,防止客户端一直等待等;并且单个请求导致的Panic,也不影响整个服务器程序的运行。

Panic会停掉当前正在执行的程序,而不只是当前的线程。在这之前,它会有序地执行完当前线程defer列表里的语句,其他协程里定义的defer语句不做保证。所以在defer里定义一个recover语句,防止程序直接挂掉。

注意:recover()函数只在defer的函数中直接调用才有效。

func main(){
  defer fmt.Println("defer main")
  var user=os.Getenv("USER_")
  go func(){
    defer func(){
      fmt.Println("defer caller")
      if err:=recover();err!=nil{
        fmt.Println("recover success .err",err)
      }
    }()
    func(){
      defer func(){
        fmt.Println("defer here")
      }()
      if user==""{
        panic("should se user env.")
      }
      //此处不会执行
      fmt.Println("after panic")
    }()
  }()
  time.Sleep(100)
  fmt.Println("end of main function")
}
defer here
defer caller
recover success.err: should set user env.
end of main function
defer main

代码中的Panic最终会被recover捕获到。这样的处理方式在一个http server的主流程常常会被用到。一次偶然的请求可能会触发某个bug,这时用recover捕获Panic,稳住主流程,不影响其他请求。

recover()函数调用位置 #

func main(){
  defer f()
  painc(404)
}
func f(){
  if e:=recover();e!=nil{
    fmt.Println("recover")
    return
  }
}
//能调用,在defer的函数中调用,生效
func main(){
  recover()
  painc(404)
}
//不能,直接调用recover,返回nil
func main(){
  defer recover()
  painc(404)
}
//不能,要在defer函数里调用recover
func main(){
  defer func(){
    if e:=recover();e!=nil{
    fmt.Println("recover")
  	}
  }()
  painc(404)
}
//能,在defer的函数中调用,生效
func main(){
  defer func(){
    recover()
  }()
  painc(404)
}
//能,在defer的函数中调用,生效
func main(){
  defer func(){
  	defer func(){
    	recover()
  	}()
  }()
  painc(404)
}
//不能,多重defer嵌套

为什么无法从父goroutine恢复子goroutine的Panic #

即为什么无法recover其他goroutine里产生的Panic???

因为goroutine被设计为一个独立的代码执行单元,拥有自己的执行栈,不与其他goroutine共享任何数据。这意味着,无法让goroutine拥有返回值、也无法让goroutine拥有自身的ID编号等。若需要与其他goroutine产生交互,要么可以使用channel的方式与其他goroutine进行通信,要么通过共享内存同步方式对共享的内存添加读写锁。

如果希望有一个全局的恐慌捕获中心,那么可以通过创建一个恐慌通知channel,并在产生恐慌时,通过recover字段将其恢复,并将发生的错误通过channel通知给这个全局的恐慌通知器:

var notifier chan interface{}
func startGlobalPanicCapturing(){
  notifier=make(chan interface{})
  go func(){
    for{
      select{
        case r:=<-notifier:
        fmt.Println(r)
      }
    }
  }()
}
func main(){
  startGlobalPanicCapturing()
  //产生恐慌,但该恐慌会被捕获
  Go(func(){
    a:=make([]int,1)
    println(a[1])
  })
  time.Sleep(time.Second)
}
//Go是一个恐慌安全的goroutine
func Go(f func()){
  go func(){
    defer func(){
      if r:=recover();r!=nil{
        notifier<-r
      }
    }()
    f()
  }()
}

上面的func Go(f func())本质上是对go关键字进行了一层封装,确保在执行并发单元前插入一个defer,从而保证恢复一些可恢复的错误。

这个方案并不完美,原因是如果函数f内部不在使用Go函数来创建goroutine,而且含有继续产生必然恐慌的代码,那么仍然会出现不可恢复的情况。或者还有一些不可恢复的运行时恐慌(例如并发读写map),如果这类恐慌一旦发生,那么任何补救都是徒劳的。

数据容器 #

数组与切片 #

异同 #

Go推荐使用slice而不是数组

Go语言中,切片是对数组的封装,数组固定长度,不能更改,切片可以动态扩容,且切片的类型和长度无关。

数组长度不一致,不属于同一类型,无法进行比较。

type slice struct{
	array unsafe.Pointer  //元素指针
  len int          //长度
  cap int         //容量
}

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

切片截取 #

基于已有slice创建新slice对象,被称为replace,共用底层数组。如果因为执行append操作使得新slice或老slice底层数组扩容,移动到了新的位置,两者就不会相互影响了。

func main(){//想想为什么
  slice:=[]int{0,1,2,3,4,5,6,7,8,9} //容量10
  s1:=slice[2:5] //[2,3,4]len=3  cap=8  后面的还在,
  s2:=s1[2:6:7] //[low,high,max]要求max>=high>=low   high和max必须在老slice的容量cap范围内
                //[4] len=4 cap=5
  s2=append(s2,100) //第一次追加,容量够用,会修改原始数组对应位置的元素。
  s2=append(s2,200) //第二次追加,容量不够,另起炉灶,将原来元素复制到新位置,扩大容量,故不再变化。
  
  s1[2]=20
  fmt.Println(s1)
  fmt.Println(s2)
  fmt.Println(slice)
}
[2 3 20]
[4 5 6 7 100 200]
[0 1 2 3 20 5 6 7 100 9]

切片扩容 #

一般都是在向切片追加元素之后,由于容量不足,才会引起扩容。调用append函数

func append(slice []Type,elems...Type)[]Type

Append函数的参数长度可变,因此可以追加多个值到slice中,还可以在切片后面追加"…“符号直接传入slice,即追加切片里所有的元素。

实际上是往底层数组相应的位置放置要追加的元素。但底层数组的长度是固定的,如果超出容量,slice会迁移到新的位置,并且底层数组的长度也会增加。

同时,为了应对未来可能再次发生append操作,新的底层数组的长度,也就是新slice的容量需要预留一定的buffer。否则,每次添加元素的时候,都会发生迁移,成本太高。

  • 当原slice容量小于1024时,新slice容量变为原来的2倍 //也是不准确,大概是
  • 当原slice容量大于1024时,新slice容量变为原来的1.25倍,但由于Go进行了内存对齐,新slice的容量要大于等于老slice容量的2倍或1.25倍。
func main(){ //想想为什么
	s:=[]int{5} //cap=1
	s=append(s,7) //扩容 cap=2  [5 7]
	s=append(s,9) //扩容 cap=4   [5 7 9]
	x:=append(s,11)  //没有扩容  cap=4 [5 7 9 11]
	y:=append(s,12)  //没有扩容  cap=4 [5 7 9 12] 然后底层被改了 都变成12了
	fmt.Println(s,x,y)
}
[5 7 9] [5 7 9 12] [5 7 9 12]
func main(){
	s:=[]int{1,2}
	s=append(s,4,5,6)   //len=5 cap=6 而不是8  注意一下就行, 大于等于2倍或1.25倍
}

切片作为函数参数会被改变吗 #

当slice作为函数参数时,就是一个普通的结构体。若直接传slice,在调用者看来,实参slice并不会被函数中对形参的操作而改变,实参是形参的复制;若传的是slice指针,则会影响实参。

不论传的是slice还是slice指针,如果改变了slice底层数组的数据,都会反映到实参slice到底层数据。因为底层数组在slice结构体里是一个指针。

Go语言中的函数参数传递,只有值传递,没有引用传递。

func main(){
  s:=[]int{1,1,1}
  f(s)     //向f传递了一个slice副本,s是main函数中s的一个复制
  fmt.Println(s)
}
func f(s []int){
  //i 只是一个副本,不能改变s中元素的值
  //for _,i:=range s{
  //	i++
  //}
  for i:=range s{
    s[i]+=1    //这里改变了 将返回的新slice赋值到原始slice中
  }
}
[2 2 2]

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

func myAppend(s []int)[]int{
	//这里s结构体虽然改变了,但并不会改变外层函数的s结构体  因为它是值传递
	s=append(s,100)
	return s
}
func myAppendPtr(s *[]int){
	//会改变外层s结构体本身
	*s=append(*s,100)
	return 
}
func main(){
	s:=[]int{1,1,1}
	newS:=myAppend(s)
	fmt.Println(s)
	fmt.Println(newS)
	s=newS   //新切片赋值
	myAppendPtr(&s)
	fmt.Println(s)
}
[1 1 1]
[1 1 1 100]
[1 1 1 100 100]

make和new的区别 #

make和new是Go语言内置的用来分配内存的函数。make用于slice,map,channel等引用类型;new适用于int型、数组、结构体等值类型。

make返回一个值,new返回一个指针。

使用上,make返回初始化之后的类型的引用,new会为类型的新值分配已置零的内存空间,并返回指针。

Slice未初始化并没有分配内存时,可以用append函数插入

make函数用来初始化slice、map、以及channel;而一个slice、map、以及channel必须先被初始化才能使用

// 定义未初始化的map, nil map不能赋值
var m1 map[int]string
// m1 = make(map[int]string, 0) // 初始化
 
// 通过字面量形式定义并初始化为空map
var m2 = map[int]string{}
 
// 通过make函数定义并初始化为空map
var m3 = make(map[int]string, 0)

map #

map它是一个组<key,value>对组成的抽象数据结构,并且同一个key只会出现一次。

map的设计也被称为“The dictionary problem”,它的任务是设计一种数据结构用来维护一个集合的数据,并且可以同时对集合进行增删查改的操作。最主要的数据结构有两种:哈希查找表(Hash table)(Go采用的)、搜索树(Search tree)

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

哈希查找表解决碰撞问题,不同的key被哈希到了同一个bucket。

  • 链表法 (GO使用的)

将一个bucket实现成一个链表,落在同一个bucket中的key都会插入这个链表。

  • 开放地址法

在碰撞之后,根据一定的规律,在bucket的后面挑选空位,用来放置新的key。

搜索树一般采用自平衡搜索树,包括AVL树红黑树等。

自平衡搜索树法的最差搜索效率是O(logN),而哈希表是O(N)。当然,哈希查找表的平均查找效率是O(1),如果哈希函数设计的好,最坏的情况基本不会出现。还有一点,遍历自平衡搜索树,返回的key序列,一般会按照从小到大的顺序,而哈希查找表则是乱序的。

map的底层原理 #

map内存模型 #

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

B是buckets数组的长度的对数,即buckets数组的长度为2^B,bucket里面存储了key和value,buckets是一个指针,指向一个结构体。

bmap 就是我们常说的“桶”,一个桶里面会最多装 8 个< key,value>对,这些 key 之所以会落入同一个桶,是因为它们经过哈希计算后,哈希结果的最后B个bit位是相同的(哈希值并不是完全相等,是后面几位相同)。在桶内,又会根据key计算出来的hash值的高8位来决定key到底落入桶内的那个槽位。

type bmap struct { // A bucket for a Go map.
tophash [bucketCnt]uint8        
// len为8的数组
// 用来快速定位key是否在这个bmap中
// 一个桶最多8个槽位,如果key所在的tophash值在tophash中,则代表该key在这个桶中
}

上面bmap结构是静态结构,在编译过程中runtime.bmap会拓展成以下结构体:

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

注意到 key 和 value 是各自放在一起的,并不是 key/value/key/value/... 这样的形式,当key和value类型不一样的时候,key和value占用字节大小不一样,使用key/value这种形式可能会因为内存对齐导致内存空间浪费,所以Go采用key和value分开存储的设计,更节省内存空间。

每个bucket设计成最对只能放8个key-value对,如果有第9个key-value落入当前bucket,需要重新构建一个bucket,并且通过overflow指针连接起来。这就是所谓的链表法。

mapextra结构体

当map的key和value都不是指针类型时候,并且size都小于128字节的情况下,会把bmap标记为不含指针,那么gc时候就不用扫描bmap,提升效率。但bmap指向溢出桶的字段overflow是指针类型,为了防止这些overflow桶被gc掉,所以需要mapextra.overflow将它保存起来。如果bmap的overflow是*bmap类型,那么gc扫描的是一个个拉链表,效率明显不如直接扫描一段内存(hmap.mapextra.overflow)。

当key/value都不含指针的情况下,启用overflow和oldoverflow字段。

type mapextra struct {
overflow    *[]*bmap // overflow 包含的是 hmap.buckets 的 overflow 的 buckets
oldoverflow *[]*bma // oldoverflow 包含扩容时 hmap.oldbuckets 的 overflow 的 bucket
nextOverflow *bmap  // 指向空闲的 overflow bucket 的指针
}

创建map #

创建map的底层掉用的是makemap函数,主要做的工作是初始化hmap结构体的各种字段,例如计算B的大小,设置哈希种子hash0等。

slice和map分别作为函数参数时有什么区别?

makemap函数返回的结果是*hmap,是一个指针,而makeslice函数返回的则是slice结构体,结构体内部包含底层数组的指针。

makemap和makeslice返回值的区别,使得当map和slice作为函数参数时,在函数内部对map的操作会影响map结构体;而对slice操作却不会(注意,这里的不变指的是slice结构体自身,而不是slice底层数组的元素可能会被改变)。

主要原因:前者是指针(*hmap),后者是结构体(slice)。Go语言中的函数传参都是值传递,在函数内部,参数会被复制到本地。*hmap指针复制完成后,仍然指向同一个map,因此函数内部对map的操作会影响实参。而slice被复制后,成为一个新slice,对它进行的操作不会影响到实参。

哈希函数 #

在程序启动时,Go会检测CPU是否支持aes,如果支持则使用aes hash,如果不支持,则使用memhash。

在map应用场景中,hash函数用于查找功能。

key定位过程 #

Key经过哈希计算后得到哈希值,共有64个bit位,但计算它到底要落在那个bucket时,只会用到最后B个bit位。

先用B=5,则bucket的总数是2^5=32。用最后5个bit位,找到6号桶。再取哈希值的高8位,找到此key在bucket中的槽位。最开始因为桶内还没有key,在遍历完bucket中的所有槽位,包括overflow的槽位,找不到相同的key,因此会被放到第一个槽位。

因为根据后B个bit位决定key落入的bucket编号,也就是桶编号,因此肯定会存在哈希冲突。当两个不同的key落在同一个桶中,也就是发生了哈希冲突。冲突解决的手段就是用链表法:在bucket中,从前往后找到第一个空位,放入新加入的有冲突的key。之后,在找某个key时,先找到对应的桶,再去遍历bucket中所有的key。

具体定位过程:

假定B=5,则bucket的总数是2^5=32。首先计算出待查找key的哈希,使用低5位00110,找到对应的bucket,也就是6号bucket。使用哈希值的高8位10010111,对应151,在6号bucket中寻找tophash值(HOBhash)为151的key,找到二号槽位就结束了,如果没找到,并且overflow不为空,则去overflow指向的bucket中找。

map的赋值过程 #

向map插入或修改key,调用的是mapassign函数。

流程:

对key计算hash值,根据hash值按照之前的流程,找到要赋值的位置(可能是插入新key,也可能是更新老key),在相应的位置进行赋值操作。

mapassign函数首先会检查map的标志位flags。如果flags的写标志位被置成1了,说明有其他协程正在执行“写“操作,由于assign本身也是写操作,因此产生了并发写,直接使程序Panic。

map的扩容是渐进式的。如果map处在扩容的过程中,那么定位key到了某个bucket后,需要确保这个bucket对应的老bucket已经完成了迁移过程。即老bucket里的key都要迁移到新bucket中来(老bucket中的key会被分散到2个新bucket),才能在新的bucket中进行插入或者更新操作。

只有在完成迁移操作之后,才能安全的在新bucket里定位key要安置的地址,再进行之后的赋值操作。

现在到了定位key应该放置的位置了:准备两个指针,一个(inserti)指向key的hash值在tophash数组所处的位置,另一个(insertk)指向cell的位置(也就是key最终放置的地址)。当然,对应value的位置就很容易计算出来:在tophash数组中的索引位置决定了key在整个bucket中的位置(共8个key),而value的位置需要跨过8个key的长度。

在循环过程中,inserti和insetk分别指向第一个空的topash、第一个空闲的cell。如果之后在map没有找到key的存在,也就是说map中没有此key,这意味着插入新key,而不是更新原有的key。那最终key的安置地址就是第一次发现的空闲的cell。

如果这个bucket的8个key都放满了,在跳出循环后,会发现inserti和insertk都为空,这时需要在bucket后面挂上overflow bucket。当然,也有可能是在overflow buxket后面再挂上一个overflow bucket。这就说明,有太多key 被哈希到了此bucket。在这种情况下,正式放置key之前,还要检查map的状态,看它是否需要扩容,如果满足扩容的条件,就主动触发一次扩容操作。

扩容完成后,之前的查找定位key的过程,还得重新再走一次。因为扩容之后,key的分布发生了变化。

最后,会更新map相关的值,如果是插入新key,map的元素数量字段count值会+1,并且会将hashWriting写标志位清零。

map的删除过程 #

删除操作低成的执行函数是mapdelete;

它会首先检查h.flags标志,如果发现写标志位是1,直接Panic,因为这表明有其他协程同时在进行写操作。大致逻辑如下:

  • 检测是否存在并发写操作。
  • 计算key的哈希,找到落入的bucket。
  • 设置写标志位。
  • 检查此map是否正在扩容的过程中,如果是则直接触发一次搬迁操作。
  • 两层循环,核心是找到key的具体位置。寻找过程都是类似的,在bucket中挨个cell寻找。
  • 找到对应位置后,对key或者value进行清零操作。
  • 将count值-1,将对应位置的tophash值置成emptyOne。
  • 最后检测此槽位后面是否为空,若是将tophash改为emptyRest。
  • 若前一步成功,将此cell之前的tophash值为emptyOne的槽位都置为emptyRest。

map的扩容过程 #

Go语言中一个bucket装载8个key,所以在定位到某个bucket后,还需要再定位到具体的槽位cell,这实际上又是时间换空间。

当然,这样做,要有一个度,不然所有的key都落在了同一个bucket里,直接退化成了链表,各种操作的效率直接降为O(n),也是不行的。

**装载因子:**衡量前面所说的情况。

loadFactor:=count/(2^B)
count:元素个数,2^B总的bucket数量

在向map插入新key时,会进行条件检测,符合下面两个条件,就会触发扩容:

  • 装载因子超过阙值(源码里定义的阙值是6.5)
  • overflow的bucket数量过多:当B<15,也就是bucket总数2^B小于2^15时,overflow的bucket数量超过2^B;当B>=15,也就是bucket总数2^B大于等于2^15,overflow的bucket数量超过2^15。

第一点:

当B=2,则bucket的总数为2^2=4,四个桶装满有4*8个元素,故正常情况下装满装载因子是8 ,当为6.5时证明快要装满了,则扩容。

第二点:

是对第一点的补充,当bucket数量多(真实分配的bucket数量多,包括大量的overflow bucket),但是装载因子却很低。

当B为3 则overflow的bucket超过 2^3=8 ,则扩容

当B为19 则overflow的buxket数量超过 2^15,则扩容

扩容策略

条件一:

元素太多,但是bucket数量太少。扩容后新buckets时原来的一倍。

方法:将B+1,bucket总数(2^B)直接变为原来的2倍。出现新老bucket。注意,这时候元素都在老bucket中,还没迁移到新bucket来。而且,新bucket只是最大数量变为原来最大数量(2^B)的2倍(2^B*2)。

搬迁要重新计算key的哈希,才能决定它到底落在那个bucket。例如原来B=5,计算出key哈希后,只用看它低5位,就能决定它落在那个bucket。扩容后,B变成了6,因此需要多看一位,哈希值的低6位决定key落在那个bucket。这称为map rehash。

条件二:

元素不多,但overflow bucket数特别多,说明很多bucket没有装满。扩容后,新的buckets数量和之前相等。

方法:开辟新的bucket空间,将老bucket中的元素移动到新bucket,是的同一个bucket中的key排列的更紧密。

由于map扩容需要将原有的key/value重新搬迁 到新的内存地址,如果有大量的key/value需要搬迁,会非常影响性能。因此Go map的扩容采取了一种“渐进式”的方式,原有的key不会一次性搬迁完毕,每次最多只会2个bucket。

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

hashGrow函数的主要工作时申请到了新的bucket空间,把相关标志位都进行了处理。

从老的buckets搬迁到新的buckets,由于buckets 数量不变,因此可以按序号来搬,比如key在原来0号buckets,到新地方后,仍然放到0号buckets。

map的遍历过程 #

map扩容过程不是一个原子的操作,它每次最多只能搬运2个bucket,所以如果触发了扩容操作,那么很长时间里,map的状态都是处于一个中间态:有些bucket已经搬迁到“新家”,而有些bucket还待在“老家”。

过程:

先是调用mapiterinit函数初始化迭代器,然后循环调用mapiternext函数进行map遍历。mapiterinit()就是对hitter结构体里的字段进行初始化赋值操作。

map 的遍历顺序是无序的

假设B=1,则有两个桶,0和1 ,0号桶搬迁后裂变为2个桶,分别是新0号和新2号。1号桶裂变后成为新1号和新4号。

map中的key为什么是无序的 #

在Go语言的实现中,当遍历map时,并不是固定地从0号bucket开始遍历,而是每次都从一个随机号bucket开始,并且从这bucket的一个随机号的cell开始遍历。这样,即使是一个写死的map,仅仅只是遍历它,也不太可能会返回一个固定序号的key/value对。

map是线程安全的吗 #

map不是线程安全的,不支持并发 注意sync包里面的map

在查找、赋值、遍历、删除的过程中都会检测写标志,一旦发现写标志置位(=1),则直接Panic。赋值和删除函数在检测完写标志是复位状态(=0)之后,先将写标志位置位(置为1),才会进行之后的操作。

float类型可以作为map的key吗 #

Go语言中,只要是可以比较对类型都可以作为key。除了slice、map、functions这几种类型,其他的都可以作为map的key。具体包括:布尔值、数字、字符串、指针、通道、接口类型、结构体、只包含上述类型的数组。这些类型的共同特征是支持==和!=操作符。

任何类型都可以作为value,包括map类型。

map如何实现两种get操作 #

Go语言中,读取map有两种语法:带comma和不带comma。当要查询的key不在map里,带comma的用法会返回一个bool型变量提示key是否在map中;而不带comma的语句则会只返回一个key类型的零值。如果key是int型就会返回0,如果key是string类型,则会返回空字符串。

func main(){
  ageMap:=make(map[string]int)
  ageMap["qcrao"]=18
  //不带comma用法
  age1:=ageMap["stefno"]
  fmt.Println(age1)
  //带comma用法
  age2,ok:=ageMap["stefno"]
  fmt.Println(age2,ok)
}
0
0 false

如何比较两个map是否相等 #

直接使用map1==map2是错误的,这种写法只能比较map是否为nil

只能通过遍历map的每一个元素,比较元素是否都是深度相等的。

两个map深度相等的条件如下:

  • 都为nil
  • 非空、长度相等,指向同一个map实体对象。
  • 相应的key指向的value“深度”相等。

三个条件是或的关系,满足任何一个条即认为两个map深度相等。

可以对map的元素取地址吗 #

不能对map的元素取地址,即使用unsafe.Pointer等获取到了key或value的地址,也不能长期持有,因为一旦发生扩容,key和value的位置就会改变,之前保存的地址就失效了。

可以边遍历边删除吗 #

map不是线程安全的数据结构,多个线程同时读写同一个map是未定义的行为,如果被检测到,会直接Panic。

如果在同一个协程内边遍历边删除,并不会检测到同时读写,理论上是可以这样做的。

channel #

channel是线程安全的

通道有哪些应用 #

通过与select、cancel、timer等结合,能实现各种各样的功能。

停止信号 #

channel用于停止信号的场景很多,通常是通过关闭某个channel或者向channel发送一个元素,使得接收channel的那一方获知道此信息,进而做一些其他的操作,如停止某个循环等。

定时任务 #

与计时器结合,一般有两种做法:实现超时控制、实现定期执行某个任务。

超时控制

有时候,需要执行某项操作,但又不想耗费太长时间,上一个定时器就可以搞定。

select{
	case<-time.After(100*time.Millisecond):
	case<-s.stopc:
		return false
}

等待100ms后,如果s.stopc还么有读出数据或者关闭,就直接结束。

定时执行某个任务

func worker(){
	ticker:=time.Tick(1*time.Second)
	for{
		select{
			case<-ticker:
				//执行定时任务
				fmt.Println("执行1s定时任务")
		}
	}
}

和定时任务相关的两个例子虽然主要依赖于timer/ticker的作用,但收到定时消息的途径仍然是channel。

解耦生产方和消费方 #

服务启动时,启动N个worker,作为工作协程池,这些协程工作在一个for{}无限循环里,从某个channel消费工作任务并执行。

func main(){
	tackCh:=make(chan int,100)
	go worker(taskCh)
	//阻塞任务
	for i:=0;i<100;i++{
		taskCh<-i
	}
	//等待1小时
	select{
		case<-time.After(time.Hour)
	}
}
func worker(tashCh<-chan int){
	const N=5
	for i:=0;i<N;i++{
		go func(id int){
			for{
				task:=<-taskCh
				fmt.Printf("finish task:%d by worker %d\n",task,id)
				time.Sleep(time.Second)
			}
		}(i)
	}
}

作为消费方的5个工作协程不断地从工作队列里取任务,生产方只管往channel发送任务即可,解耦了生产方和消费方。

程序输出:

finsh task:1 by worker 4
finsh task:2 by worker 2
finsh task:4 by worker 3
finsh task:3 by worker 1
finsh task:0 by worker 0
finsh task:6 by worker 0
finsh task:8 by worker 3
finsh task:9 by worker 1
finsh task:7 by worker 4
finsh task:5 by worker 2
finsh task:1 by worker 4
finsh task:1 by worker 4
finsh task:1 by worker 4
finsh task:1 by worker 4

控制并发数 #

有时需要定时执行几百个任务,例如每天定时按城市来执行一些离线计算的任务。但是并发数又不能太高,因为任务执行过程会依赖第三方的一些资源,对请求的速率有限制。这时就可以通过channel来控制并发数;

var token =make(chan int,3) 
func main(){
	//....
	for _,w:=range work{
		go func(){   //以并发的方式调用匿名函数func
			token<-1
			w()
			<-token
		}()
	}
	//...
}

构建缓冲型的channel,容量为3.接着遍历任务列表,每个任务启动一个goroutine去完成任务。真正执行任务、访问第三方动作在w()中完成,在执行w()之前,先要从token中拿“许可证”,拿到许可证之后,才能执行w()。并且执行完任务后,要将“许可证”归还,这样就可以控制同时运行的goroutine数目。

这里,token<-1放在func内部而不是外部,原因是:

如果放在外层,就是控制系统goroutine的数量,可能会阻塞for循环,影响业务逻辑。而token其实和逻辑无关,只是性能调优,放在内层和外层的语义不太一样。

还有一点要注意的是,如果w()发生Panic,那“许可证”可能就还不回去了,这可以使用defer来保证。

channel底层结构 #

type hchan struct {
    qcount   uint           // channel中的元素个数
    dataqsiz uint           // channel中循环队列的长度
    buf      unsafe.Pointer // channel缓冲区数据指针
    elemsize uint16            // buffer中每个元素的大小
    closed   uint32            // channel是否已经关闭,0未关闭
    elemtype *_type // channel中的元素的类型
    sendx    uint   // channel发送操作处理到的位置
    recvx    uint   // channel接收操作处理到的位置
    recvq    waitq  // 等待接收的sudog(sudog为封装了goroutine和数据的结构)队列由于缓冲区空间不足而阻塞的Goroutine列表
    sendq    waitq  // 等待发送的sudogo队列,由于缓冲区空间不足而阻塞的Goroutine列表
    lock mutex   // 一个轻量级锁
}

因为channel免不了支持协程间并发访问,所以要有一个锁(lock)来保护整个channel数据结构对于有缓冲区channel来讲,需要知道缓冲区在哪里(buf),已经存储量多少个元素(qcount),最多存储多少个元素(dataqsize),每个元素占多大空间(elemsize),所以实际上缓冲区就是一个数组。因为Golang运行时中,内存复制,垃圾回收等机制,依赖数据的类型信息,所以hchan这里还要有一个指针,指向元素类型的类型元数据。此外,channel支持交替的读(接收),写(发送)。需要分别记录读,写 下标的位置,当读和写不能立即完成时,需要能够让当前协程在channel上等待,待到条件满足时,要能够立即唤醒等待的协程,所以要有两个等待队列,分别针对读和写。此外,channel能够close,所以还要记录它的关闭状态,综上所述,channel底层就长这样。

我们通过make创建一个缓冲区大小为5,元素类型为int的channel。ch是存在于函数栈帧上的一个指针,指向堆上的hchan数据结构。

创建channel:

channel有两个方向:发送和接收。理论上来说,可以创建一个只发送或只接收的通道,通过作为函数参数,只发送或只接收可以保证函数内部对channel的操作是“安全”的。

ch := make(chan int,3) //有缓冲通道
ch := make(chan int)   //无缓冲通道
  • 创建channel实际上就是在内存中实例化了一个hchan结构体,并返回一个chan指针
  • channel在函数间传递都是使用的这个指针,这就是为什么函数传递中无需使用channel的指针,直接使用channel就可以了,因为channel本身就是一个指针

接收过程 #

接收操作有两种写法,一种带“OK”,反应channel是否关闭;一种不带“OK”,当接收到相应类型的零值时无法知道是真实的发送者发送过来的值,还是channel被关闭后,channel返回给接受者的默认类型的零值。

func chanrecv1(c *hchan,elem unsafe.Pointer){
	chanrecv(c,elem,true)
}
func chanrecv2(c *hchan,elem unsafe.Pointer)(received bool){
	_,received=chanrecv(c,elem,true)
	return
}

函数chanrev1处理不带“OK”的情形,chanrev2则通过返回“received"这个字段来得知channel是否被关闭。接收值则比较特殊,会被“放到”参数elem所指向的地址,如果代码里忽略了接收值,这里的elem传的实惨为nil。

  • 如果channel是一个空值(nil),在非阻塞模式下,会直接返回。在阻塞模式下,会调用gopark函数挂起goroutine,这个会一直阻塞下去。因为在channel是nil的情况下,要想不阻塞,只有关闭它,但关闭一个nil的channel会产生Panic,所以goroutine没有机会被唤醒。
  • 在非阻塞模式下,不用获取锁,快速检测到失败并且返回。

接下来,我们继续使用ch,初始状态下,ch的缓冲区为空,读、写下标都指向下标0的位置,等待队列也都为空。

然后一个协程g1向ch中发送数据,因为没有协程在等待接收数据,所以元素都被存到缓冲区中,sendx从0开始向后挪,

第5个元素会放到下标为4的位置,然后sendx重新回到0,此时缓冲区已经没有空闲位置了。

所以接下来发送的第6个元素无处可放,g1会进到ch的发送等待队列中。这是一个sudog类型的链表,里面会记录哪个协程在等待,等待哪个channel,等待发送的数据在哪里,等等消息。

接下来协程g2从ch接收一个元素,recv指向下个位置,第0个位置就空出来了,

所以会唤醒sendq中的g1,将elem指向的数据发送给ch,然后缓冲区再次满了,sendq队列为空。

在这一过程中,可以看到sendx和recvx,都会从0到4再到0,所以channel的缓冲区,被称为"环形"缓冲区。

如果像这样给channel发送数据,只有在缓冲区还有空闲位置,或者有协程在等着接收数据的时候,才不会发送阻塞。

碰到ch为nil,或者ch没有缓冲区,而且也没有协程等着接收数据,又或者,ch有缓冲区但缓冲区已用尽的情况,都会发生阻塞

解决发送阻塞

那如果不想阻塞的话,就可以使用select,使用select这种写法时,如果检测到ch可以发送数据,就会执行case分支;如果会阻塞,就会执行default分支了。

接收阻塞

这是发送数据的写法,接收数据的写法要更多一点。第一种写法会将结果丢弃,第二种写法将结果赋给变量v,第三种是comma ok风格的写法,ok为false时表示ch已关闭,此时v是channel元素类型的零值。这几种写法都允许发生阻塞,只有在缓冲区中有数据,或者有协程等待发送数据时 ,才不会阻塞。如果ch为nil,或者ch无缓冲而且没有协程等着发送数据,又或者ch有缓冲但缓冲区无数据时,都会发生阻塞。

解决接收阻塞

如果无论如何都不想阻塞,同样可以采用非阻塞式写法,这样在检测到ch的recv操作不会阻塞时,就会执行case分支,如果会阻塞,就会执行default分支。

多路select

上面的selec只是针对的单个channel的操作; 多路select指的是存在两个或者更多的case分支,每个分支可以是一个channel的send或recv操作。例如一个协程通过多路select等待ch1和ch2。这里的default分支是可选的。

我们暂且把这个协程记为g1,多路select会被编译器转换为runtime.selectgo函数调用。 第一个参数cas0指向一个数组,数组里装的是select中所有的case分支,顺序是send在前,recv在后。 第二个参数order0指向一个uint16类型的数组,数组大小等于case分支的两倍。实际上被用作两个数组,第一个数组用来对所有channel的轮询进行乱序,第二个数组用来对所有channel的加锁操作进行排序。轮询需要乱序才能保障公平性,而按照固定算法确定加锁顺序才能避免死锁。

第三个参数pc0和race检测相关,我们暂时不关心。 第四、五个参数nsends和nrecvs分别表示所有case中执行send和recv操作的分支分别有多少个。 第六个参数block表示多路select是否要阻塞等待,对应到代码中,就是有default分支的不会阻塞,没有的会阻塞。

再来看第一个返回值,它代表最终哪个case分支被执行了,对应到参数cas0数组的下标。但是如果进到default分支则对应-1。第二个返回值用于在执行recv操作的case分支时,表明是实际接收到了一个值,还是因channel关闭而得到了零值。

多路select需要进行轮询来确定哪个case分支可操作了,但是轮询前要先加锁,所以selectgo函数执行时,会先按照有序的加锁顺序,对所有channel加锁,然后按照乱序的轮询顺序检查所有channel的等待队列和缓冲区。

假如检查到ch1时,发现有数据可读,那就直接拷贝数据,进入对应分支。

假如所有channel都不可操作,就把当前协程添加到所有channel的sendq或recvq中。对应到本例中,g1会被添加到ch1的recvq,以及ch2的sendq中。之后g1会挂起,并解锁所有的channel的锁。

假如接下来ch1有数据可读了,g1就会被唤醒,完成对应分支的操作。

完成对应分支的操作后,会再次按照加锁顺序对所有channel加锁,然后从所有sendq或recvq中将自己移除,最后全部解锁,然后返回。

发送过程 #

收发数据的本质 #

channel的发送和接收操作本质上都是“值的复制”。

相关问题 #

通道关闭过程发生了什么? #

关闭某个channel,需要调用closechan执行函数。

对于一个channel,recvq和sendq中分别保存了阻塞的发送者和接受者。关闭channel后,对于等待接收者而言,会收到一个相应类型的零值;对于等待发送者而言,会直接Panic。所以,在不清楚channel还有没有接受者的情况下,不能贸然关闭它。

函数closechan()先上了一把大锁,接着把所有挂在这个channel上的sender和receiver全都连成一个sudo链表,再解锁。最后,再将所有的sudog全部唤醒。唤醒之后,sender会继续执行chansend函数里goparkunlock函数之后的代码,很不幸,检测到channel已经关闭,发生Panic。而receiver则比较幸运,在进行一些扫尾工作后,函数返回。这里,selected返回true,而返回值received则要根据channel是否关闭,返回不同的值。如果channel关闭,received的值为false,否则为true。

从一个关闭的通道里仍然能读出数据吗? #

从一个有缓冲的channel里读数据,当channel被关闭,依然能读出有效值,只有当返回的OK为false时,读出的数据是无效的。

func main(){
	ch:=make(chan int,5)
	ch<-18
	close(ch)
	x,ok:=<-ch  //OK=true
	if ok{
		fmt.Println("received:",x)
	}
	x,ok=<-ch  //ok=false
	if !ok{
		fmt.println("channel closed,data invalid")
	}
}
received:18
channel closed,data invalid

如何优雅的关闭通道? #

关于channel有几个使用不便的地方:

  • 在不改变channel自身状态的情况下,无法得知一个channel是否关闭。
  • 关闭一个closed channel会导致Panic。所以,如果关闭channel的一方在不知道channel是否处于关闭状态时就去贸然关闭channel是很危险的事情。
  • 向一个closed channel发送数据会导致Panic。所以,如果向channel发送数据的一方不知道channel是否处于关闭状态时就贸然向channel发送数据也是很危险的事情。

**关闭channel的原则:**不要再receiver侧关闭channel,也不要在有多个sender时,关闭channel。不要关闭一个closed channel,也不要向一个closed channel发送数据。

向channel发送数据就是sender,因此sender可以决定何时不发送数据,并且关闭channel。但是如果有多个sender,某个sender同样无法确定其他sender的情况,这时也不能贸然关闭channel。

不那么优雅的关闭通道的方法:

  • 使用defer- recover机制,放心大胆的关闭channel或者向channel发送数据。即使发生了Panic,也有defer- recover兜底。
  • 使用sync.Once来保证只关闭一次。

优雅的关闭channel,根据sender和receiver的个数,分下面几种情况:

(1)一个sender,一个receiver。

(2)一个sender,M个receiver。

(3)N个sender,一个receiver。

(4)N个sender,M个receiver。

对于(1)(2)种情况,只有一个sender的情况下下,直接从sender端关闭就好了。

对于(3)中情况,关闭channel的方法是:唯一的接收者通过关闭一个第三方充当信号的channel,来关闭channel。方案就是增加一个传递关闭信号的channel,receiver通过关闭信号channel下达关闭数据channel的指令。当senders监听到关闭信号后,停止发送数据。代码并没有明确的关闭channel。在Go语言中,对于一个channel,如果最终没有任何goroutine引用它,不管channel有没有关闭,最终都会被GC回收。所以在这种情况下,所谓优雅的关闭channel就是不关闭channel,让GC代劳。

对于(4)种情况,关闭channel的方法是:通知中间人来关闭一个额外的信号channel,从而关闭channel。 增加一个中间人,M个receiver都向它发送关闭dataCh的“请求”,中间人收到第一个请求后,就会直接下达关闭dataCh的指令。通过关闭stopCh,这时就不会发生重复关闭的情况,因为stopCh的发送方只有中间人一个。另外,这里的N个sender也可以向中间人发送关闭dataCh的请求。

关于通道的happened-before有哪些? #

简单来说,如果事件a和事件b存在happened- before关系,即a->b,那么a,b完成后的结果一定要体现出这种关系。

关于channel的发送(send)、发送完成(send finished)、接收(receive)、接收完成(receive finished)的happened- before的关系如下:

  1. 第n个send一定happens- before第n个receive finished,无论是缓冲型还是非缓冲型的channel。

我不知道这个能做什么 先不总结了 ,先这样。。。。。

通道在什么情况下会引起资源泄漏? #

泄漏的原因是goroutine操作channel后,处于发送或接收阻塞状态,而channel处于满或空的状态,一直得不到改变。如果没有goroutine引用,GC会对其进行回收操作,不会引起内存泄漏。

通道操作的情况总结 #

操作 空channel 已关闭channel 活跃中的channel
close(ch) panic panic 成功关闭
ch<- v 写 永远阻塞 panic 成功发送或阻塞
v,ok = <-ch 读 永远阻塞 不阻塞 成功接收或阻塞

发生Panic的情况有三种:向一个关闭的channel进行写操作,关闭一个nil的channel;关闭一个已经被关闭的channel。

读、写一个nil channel都会被无限阻塞。

接口 #

接口定义了一种规范,描述了类的行为和功能,而不做具体实现。Go采用“非侵入式”接口设计,不需要显示声明,只需要实现接口定义的函数,编译器就会自动识别。Go通过itab中的fun字段来实现接口变量调用是实体类型的函数。Go的itab中的fun字段是在运行期间自动生成的。

Go与“鸭子类型”的关系 #

Go语言作为一门静态语言,它通过接口的方式完美支持鸭子类型。

静态语言在编译期间就能发现类型不匹配的错误,而动态语言,必须运行到那一行代码才会报错。

Go语言不要求类型显示地声明实现了某个接口,只要实现了相关方法即可,因为编译器能够检测到。

type IGreeting interface{  //定义接口
  sayHello()
}
func sayHello(i IGreeting){ //定义以此接口为参数的函数
  i.sayHello
}
type Go struct{}  //定义结构体
func(g Go)sayHello(){
  fmt.Println("Hi,I am GO!")
}
type PHP struct{}
func(p PHP)sayHello(){
  fmt.Println("Hi, I am PHP!")
}
func main(){
  golang:=Go{}
  php:=PHP{}
  sayHello(golang)
  sayHEllo(php)
}
Hi,I am GO!
Hi,I am PHP!

在main函数中,调用sayHello()函数时,传入golang、php对象,它们并没有显式地声明实现IGreeting接口,知识实现了接口规定的sayHello()函数。

值接收者和指针接收者的区别 #

方法 #

方法能给用户自定义的类型添加新的行为,它和函数的区别在于方法有一个接收者,给一个函数添加一个接收者,它就变成了方法。接收者可以是值接收者,也可以是指针接收者。

在调用方法的时候,不管方法的接收者是什么类型,该类型的值和指针都可以调用,不必严格符合接收者的类型

type Person struct{
  age int
}
func (p Person)howOld()int{
  return p.age
}
func (p *Person)growUp(){
  p.age+=1
}
func main(){
  qcrao:=Person{age:18} //值类型
  fmt.Println(qcrao.howOld()) //调用接收者是值类型的方法
  qcrao.growUp()//调用接收者是指针类型的方法
  fmt.Println(qcrao.howOld())
  
  stefo:=&Person{age:100} //指针类型
  fmt.Println(stefo.howOld())//调用接收者是值类型的方法
  stefno.growUp() //调用接收者是指针类型的方法
  fmt.Println(stefno.howOld())
}
18
19
100
101
值接收者 指针接收者
值类型调用者 方法会使用调用者的一个副本,类似于“传值” 使用值的引用来调用方法,上例中,qcrao.growUp()实际上是(&qcrao).growUp()
指针类型调用者 指针被解引用为值,上例中,stefno.howOld()实际上是(*stefno).howOld() 实际上也是传值,方法里的操作会影响到调用者,类似于指针传惨,复制了一份指针。

值接收者和指针接收者 #

实现了接收者是值类型的方法,相当于自动实现了接收者是指针类型的方法;而实现了接收者是指针类型的方法,不会自动生成对应接收者是值类型的方法。

type coder interface{
  code()
  debug()
}
type Gopher struct{
  language string
}
func(p Gopher)code(){
  fmt.Printf("I am coding %s language\n",p.language)
}
func(p *Gopher)debug(){
  fmt.Printf("I am debuging %s language\n",p.language)
}
func main(){
  var c coder = &Gopher{"Go"}  //var c coer = Gopher{"Go"}  则会报错
  c.code()
  c.debug()
}
I am coding Go language
I am debuging Go language

接收者是指针类型的方法,很可能在方法中会对接收者的属性进行更改操作,从而影响接收者;而对于接收者是值类型的方法,在方法中不会对接收者本身产生影响。

当实现了一个接收者是值类型的方法,就可以自动生成一个接收者对应指针类型的方法,因为两者都不会影响接收者;

如果实现了接收者是值类型的方法,会隐含地也实现了接收者是指针类型的方法。

两者分别在何时使用 #

如果方法的接收者是值类型,无论调用者是对象还是对象指针,修改的都是对象的副本,不影响调用者;如果方法的接收者是指针类型,则调用者修改的是指针指向的对象本身。

使用指针作为方法的接收者的理由如下:

  • 方法能够修改接收者指向的值
  • 避免在每次调用方法时复制该值,在值的类型为大型结构体时,这样做会更加高效。

相关问题 #

iface和eface的区别 #

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

type iface struct{
  tab *itab //指向一个itab实体,表示接口的类型以及赋给这个接口的实体类型
  data unsafe.Pointer //指向接口具体的值,一般是一个指向堆内存的指针
}
type itab struct{
  inter *interfacetype  //接口类型
  _type *_type //描述了实体的类型,包括内存对齐方式、大小等
  link *itab
  hash uint32
  _    [4]byte
  fun  [1]   //放置和接口方法对应的具体数据类型的方法地址,实现接口调用方法的动态分派
}
type interfacetype struct{ 
	type _type //描述Go语言中各种数据类型的结构体
	pkgpath name //定义接口的包名
	mhdr []imethod //接口所定义的函数列表
}
type eface struct{
	_type *_type   //表示空接口所承载地具体的实体类型
  data unsafe.Pointer //描述具体的值
}

如何用interface实现多态 #

Go语言通过接口非常优雅地支持了面向对象的特性。

多态是一种运行期的行为,它有以下几个特点:

  • 一种类型具有多种类型的能力
  • 允许不同的对象对同一消息作出灵活的反应
  • 以一种通用的方式对待使用的对象
  • 非动态语言必须通过继承和接口的方式来实现

接口的动态类型和动态值 #

type iface struct{
  tab *itab //指向一个itab实体,表示接口的类型以及赋给这个接口的实体类型
  data unsafe.Pointer //指向接口具体的值,一般是一个指向堆内存的指针
}

data是数据指针,指向具体的数据,它们分别被称为动态类型和动态值,而接口值则包括动态类型和动态值。

//当切仅当动态类型和动态值这两部分的值都为nil的情况下,接口值==nil为true
type Coder interface{
  code()
}
type Gopher struct{
  name string
}
func(g Gopher)code(){
  fmt.Printf("%s is coding\n",g.name)
}
func main(){
  var c Coder
  fmt.Println(c==nil)
  fmt.Printf("c:%T,%v\n",c,c)
  var g *Gopher
  fmt.Println(g==nil)
  c=g
  fmt.Println(c==nil)
  fmt.Printf("c:%T,%v\n",c,c)
}
true
c:<nil>,<nil>
true
false
c:*main.Gopher,<nil>  //动态类型为*main.Gopher 动态值为nil

接口的转换原理 #

iface包含接口的类型interfacetype和实体类型的类型_type,两者都是iface的字段itab的成员。也就是说生存一个itab同时需要接口的类型实体的类型

<interface 类型,实体类型>->itab

当判定一种类型是否满足某个接口时,Go将类型的方法集和接口所需的方法集进行匹配,如果类型的方法集完全包含接口的方法集,则可认为该类型实现了该接口。

例如某类型有 m 个方法,某接口有 n 个方法,则很容易知道这种判定的时间复杂度为 O(mn),Go 会对方法集的函数按照函数名的字典序进行排序,所以实际的时间复杂度为 O(m+n)。这里我们来探索将一个接口转换给另外一个接口背后的原理,当然,能转换的原因必然是类型兼容。 直接来看一个例子:

type coder interface {
	code()
	run()
}
type runner interface {
	run()
}
type Gopher struct {
	language string
}
func (g Gopher) code() {
	return
}
func (g Gopher) run() {
	return
}
func main() {
	var c coder = Gopher{}
	var r runner
	r = c
	fmt.Println(c, r)
}

简单解释下上述代码:定义了两个 interface: coder 和 runner。定义了一个实体类型 Gopher,类型 Gopher 实现了两个方法,分别是 run() 和 code()。main 函数里定义了一个接口变量 c,绑定了一个 Gopher 对象,之后将 c 赋值给另外一个接口变量 r 。赋值成功的原因是 c 中包含 run() 方法。这样,两个接口变量完成了转换。

类型转换和断言的区别 #

Go语言中不允许隐式类型转换,也就是说符号“=”两边,不允许出现类型不相同的变量。

类型转换、类型断言本质都是把一个类型转换成另外一个类型,不同之处在于类型断言是对接口变量进行的操作。

断言 #

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

语法:

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

类型转换和类型断言有些相似,不同之处,在于类型断言是对接口进行的操作。

type Student struct{
	Name string
  Age int
}
func main(){
  var i interface{}=new(Student)
  s:=i.(Student)
  fmt.Println(s)
}
panic:interface conversion:interface{}is *main.Student,not main.Student

因为i是*Student类型,并非Student类型,所以断言失败。

func main(){  //安全方法
	var i interface{}=new(Student)
  s,ok:=i.(Student)
  if ok{
    fmt.Println(s)
  }
}

断言还有另外一种形式,就是用switch语句判断接口的类型,每一个case会被顺序地考虑。当命中一个case时,就会执行case中的语句,因此case语句的顺序是很重要的,因为可能会有多个case匹配的情况。

func main() {
	var i interface{} = new(Student)
	//var i interface{} = (*Student)(nil)
	//var i interface{}
	fmt.Printf("%p %v\n", &i, i)
	judge(i)
}
func judge(v interface{}) {
	fmt.Printf("%p %v", &v, v)
	switch v := v.(type) {
	case nil:
		fmt.Printf("%p %v", &v, v)
		fmt.Printf("nil type[%T]%v", v, v)
	case Student:
		fmt.Printf("%p %v", &v, v)
		fmt.Printf("Student type[%T]%v", v, v)
	case *Student:
		fmt.Printf("%p %v", &v, v)
		fmt.Printf("*Student type[%T]%v", v, v)
	default:
		fmt.Printf("%p %v", &v, v)
		fmt.Printf("unknow\n")
	}
}
type Student struct {
	Name string
	Age  int
}

在main函数里有三行不同的声明,按顺序每次运行一行,得到三组结果:

//var i interface{} = new(Student)
0x14000110210 &{ 0}
0x14000110230 &{ 0}
0x14000120020 &{ 0}
*Student type[*main.Student]&{ 0}
因为i是*Student类型,匹配第三个case。从打印的3个地址来看,这3处的变量实际上都是不一样的。在main函数里有一个局部变量i,调用函数时,实际上是复制了一份参数,因此函数里又有一个变量V,它是i的复制。断言之后,又生成了一份新的复制。所以最终打印的三个变量的地址都不一样。
//var i interface{} = (*Student)(nil)
0x14000110210 <nil>
0x14000110220 <nil>
0x14000120020 <nil>
i在这里的动态类型是*Student,数据为nil,它的类型并不是nil,它与nil做比较的时候,得到的结果也是false.
*Student type[*main.Student]<nil>

//var i interface{}
0x14000188050 <nil>
0x14000188060 <nil>
0x14000188070 <nil>
nil type[<nil>]<nil>
这里i才是nil类型

代码v.(type)中,v只能是一个接口类型,如果是其他类型,例如int型,会编译不通过。

函数fmt.Println的参数是interface。对于内置类型,函数内部会用穷举法,得出它的真实类型,然后转换为字符串打印。而对于自定义类型,首先确定该类型是否实现了Stirng()方法,如果实现了,则直接打印输出String()方法的结果;否则,会通过反射来遍历对象的成员进行打印。

type Student struct{
	Name string
	Age int
}
func main(){
	var s=Student{
    Name:"qcrao",
    Age:18,
	}
  fmt.Println(s)
}
{qcrao 18}

因为Student结构体没有实现Sting()方法,所以fmt.Println会利用反射挨个打印成员变量;

func (s Student)String()string{
	return fmt.Sprintf("[Name:%s],[Age:%d]",s.Name,s.Age)
}
[Name:qcrao],[Age:18]  //如果实现了String()方法,则结果如下
func (s *Student)String()string{   //这种要用fmt.Println(&s)来打印
	return fmt.Sprintf("[Name:%s],[Age:%d]",s.Name,s.Age)
}
{qcrao 18}

类型T只有接受者是T的方法:而类型*T拥有接受者是T和*T的方法。语法上T能直接调用*T的方法仅仅是通过Go语言的语法糖。

防止有关自定义String()方法时无限递归 #

func (s Student)String()string{
	return fmt.Sprintf("%v",s) //格式化输出  导致递归调用
}
func main(){
	var s=Student{
    Name:"qcrao",
    Age:18,
	}
  fmt.Println("%v",s) //格式化输出,
}

直接运行,最后会导致栈溢出:

fatal error:stack overflow

如果类型实现了String()方法,格式化输出时就会自动调用String()方法。

func (s Student)String()string{ //改进方法
	return fmt.Sprintf("%v",s.Name+""+strconv.Itoa(s.Age)) 
}

switch用法 #

于C/C++、java等不同的是,Go的switch语句从上到下进行匹配,仅执行第一个匹配成功的分支。因此Go不用在每个分支里都增加break语句。另外一个不同点在于,Go switch语句的case值不需要是常量,也不必是整数。

用法一:比较单个值和多个值

func main(){
  fmt.Print("Go runs on")
  switch os:=runtime.GOOS;os{
    case "darwin":
    	fmt.Println("OS X.")
    case "linux":
    	fmt.Println("Linux.")
    default:
    	//freebsd,openbsd,
    	//plan9,windows...
    fmt.Printf("%s.\n",os)
  }
}
//直接在switch语句内声明os变量,使得os的作用范围仅在switch语句内。

用法二:每个分支单独设置比较条件

func main(){
  t:=time.Now()
  swtich{
    case t.Hour()<12:
    	fmt.Println("Good moring!")
    case t.Hour()<17:
    	fmt.Println("Good afternoon!")
    default:
    	fmt.Println("Good evening!")
  }
}
//直接在case语句中判断表达式的真假,并且只会执行第一个满足条件的case。

用法三:使用fallthrough关键字

func main(){
  t:=time.Now()
  switch{
    case t.Hour()<12:
    	fmt.Println("Good moring!")
    fallthrough              //表示支持下一个分支
    case t.Hour()<17:
    	fmt.Println("Good afternoon!")
    default:
    	fmt.Println("Good evening!")
  	}  
  }
}
func main(){
  t:=time.Now()
  switch{
    case t.Hour()<12t.Hour()<15: //可以使用,分隔,合并成一个分支
    	fmt.Println("Good moring!")
    fallthrough              //表示支持下一个分支
    case t.Hour()<17:
    	fmt.Println("Good afternoon!")
    default:
    	fmt.Println("Good evening!")
  	}  
  }
}

如何让编译器自动检测类型是否实现了接口 #

type myWriter struct{

}
/*func (w myWriter)Write(p []byte)(n int,err error){
  return
}*/
func main(){
  //检查*myWriter类型是否实现了io.Writer接口
  var _io.Writer=(*myWriter)(nil)
  //检查myWriter类型是否实现了io.Writer接口
  var _io.Writer=myWriter{}
}