Golang基础
切片
切片底层
Slice(切片)代表变长的序列,序列中每个元素都有相同的类型。一个slice类型一般写作[]T,其中T代表slice中元素的类型;slice的语法和数组很像,只是没有固定长度而已。
数组和slice之间有着紧密的联系。一个slice是一个轻量级的数据结构,提供了访问数组子序列(或者全部)元素的功能,而且slice的底层确实引用一个数组对象。一个slice由三个部分构成:指针
、长度
和容量
。
- 指针指向第一个slice元素对应的底层数组元素的地址,要注意的是slice的第一个元素并不一定就是数组的第一个元素。
- 长度对应slice中元素的数目;长度不能超过容量,
- 容量一般是从slice的开始位置到底层数据的结尾位置。内置的len和cap函数分别返回slice的长度和容量。
多个slice之间可以共享底层的数据,并且引用的数组部分区间可能重叠。
切片扩容
Go 语言中的切片(slice)在需要扩容时会自动进行。当向切片中添加元素时,如果当前切片的容量不足以容纳新元素,Go 会自动分配一个新的底层数组,并将现有元素复制到新数组中。然后,新元素将被添加到新数组中。
切片扩容的具体策略如下:
- 如果切片的当前容量小于 1024 个元素,那么新切片的容量将增加为原来的 2 倍。例如,如果当前容量为 100,那么新切片的容量将为 200。
- 如果切片的当前容量大于等于 1024 个元素,那么新切片的容量将增加为原来的 1.25 倍。例如,如果当前容量为 2000,那么新切片的容量将为 2500。
- 如果根据上述规则计算出的容量仍然不足以容纳新元素,那么新切片的容量将扩展到足够容纳所有新元素的大小。
切片与数组
Go 语言中的数组(array)和切片(slice)都是用于存储和操作一组元素的数据结构。它们之间有一些关键区别:
- 长度:数组的长度是固定的,一旦创建就无法改变。切片的长度是可变的,它可以在运行时动态增长或缩小。这使得切片在处理可变长度的数据集时更加灵活。
- 声明:数组在声明时需要指定其长度,例如 var a [5]int。切片在声明时不需要指定长度,例如 var s []int。
- 初始化:数组可以使用字面量进行初始化,例如 a := [5]int{1, 2, 3, 4, 5}。切片可以使用 make 函数进行初始化,例如 s := make([]int, 5)。切片也可以通过切分已有数组或切片来创建,例如 s := a[1:4]。
- 内部实现:切片实际上是一个引用类型,它包含一个指向底层数组的指针、长度和容量。当创建一个新的切片时,它会共享底层数组的数据。这意味着修改一个切片的元素可能会影响其他切片。而数组是值类型,当将一个数组赋值给另一个数组时,会创建一个新的数组副本,它们的数据是独立的。
- 函数参数:当将数组作为函数参数传递时,会传递整个数组的副本,这可能会导致性能下降。而将切片作为函数参数传递时,只会传递一个包含指针、长度和容量的小型结构体,而不是整个底层数组。因此,使用切片作为函数参数通常具有更好的性能。
核心概念
内存分配
概述
在深入内存分配算法细节前,我们需要了解一些基本概念,这有助于建立宏观认识。
基本策略:
1.每次从操作系统申请一大块内存(比如1MB),以减少系统调用。
2.将申请到的大块内存按照特定大小预先切分成小块,构成链表。
3.为对象分配内存时,只须从大小合适的链表提取一个小块即可。
4.回收对象内存时,将该小块内存重新归还到原链表,以便复用。
5.如闲置内存过多,则尝试归还部分内存给操作系统,降低整体开销。
内存分配器只管理内存块,并不关心对象状态。且它不会主动回收内存,垃圾回收器在完成清理操作后,触发内存分配器的回收操作。
内存块
分配器将其管理的内存块分为两种。
span:由多个地址连续的页(page)组成的大块内存。
object:将span按特定大小切分成多个小块,每个小块可存储一个对象。
按照其用途,span面向内部管理,object面向对象分配。
分配器按页数来区分不同大小的span。比如,以页数为单位将span存放到管理数组中,需要时就以页数为索引进行查找。当然,span大小并非固定不变。在获取闲置span时,如果没找到大小合适的,那就返回页数更多的,此时会引发裁剪操作,多余部分将构成新的span被放回管理数组。分配器还会尝试将地址相邻的空闲span合并,以构建更大的内存块,减少碎片,提供更灵活的分配策略。
malloc.go
_PageShift=13
_PageSize=1<< _PageShift //8KB
mheap.go
type mspan struct{
next *mspan // 双向链表
prev *mspan
start pageID // 起始序号= (address>> _PageShift)
npages uintptr // 页数
freelist gclinkptr // 待分配的object链表
}
用于存储对象的object,按8字节倍数分为n种。比如说,大小为24的object可用来存储范围在17~24字节的对象。这种方式虽然会造成一些内存浪费,但分配器只须面对有限几种规格(size class)的小块内存,优化了分配和复用管理策略。
分配器会尝试将多个微小对象组合到一个object块内,以节约内存。
malloc.go
_NumSizeClasses=67
分配器初始化时,会构建对照表存储大小和规格的对应关系,包括用来切分的span页数。
msize.go
//Size classes. Computed and initialized by InitSizes.
//
//SizeToClass(0<=n<=MaxSmallSize)returns the size class,
// 1<=sizeclass<NumSizeClasses,for n.
// Size class 0 is reserved to mean"not small".
//
//class_to_size[i] =largest size in class i
//class_to_allocnpages[i] =number of pages to allocate when
// making new objects in class i
var class_to_size[_NumSizeClasses]int32
var class_to_allocnpages[_NumSizeClasses]int32
var size_to_class8[1024/8+1]int8
var size_to_class128[(_MaxSmallSize-1024)/128+1]int8
若对象大小超出特定阈值限制,会被当作大对象(large object)特别对待。
malloc.go
_MaxSmallSize=32<<10 //32KB管理组件
优秀的内存分配器必须要在性能和内存利用率之间做到平衡。好在Go的起点很高,直接采用了tcmalloc的成熟架构。
malloc.go
//Memory allocator,based on tcmalloc.
//http://goog-perftools.sourceforge.net/doc/tcmalloc.html
分配器由三种组件组成。
cache:每个运行期工作线程都会绑定一个cache,用于无锁object分配。
central:为所有cache提供切分好的后备span资源。
heap:管理闲置span,需要时向操作系统申请新内存。
mheap.go
type mheap struct{
free [_MaxMHeapList]mspan // 页数在127以内的闲置span链表数组
freelarge mspan // 页数大于127(>=1MB) 的大span链表
// 每个central对应一种sizeclass
central[_NumSizeClasses]struct{
mcentral mcentral
}
}
mcentral.go
type mcentral struct{
sizeclass int32 // 规格
nonempty mspan // 链表:尚有空闲object的span
empty mspan // 链表:没有空闲object,或已被cache取走的span
}
mcache.go
type mcache struct{
alloc[_NumSizeClasses]*mspan // 以sizeclass为索引管理多个用于分配的span
}
分配流程:
1.计算待分配对象对应的规格(size class)。
2.从cache.alloc数组找到规格相同的span。
3.从span.freelist链表提取可用object。
4.如span.freelist为空,从central获取新span。
5.如central.nonempty为空,从heap.free/freelarge获取,并切分成object链表。
6.如heap没有大小合适的闲置span,向操作系统申请新内存块。
释放流程:
1.将标记为可回收的object交还给所属span.freelist。
2.该span被放回central,可供任意cache重新获取使用。
3.如span已收回全部object,则将其交还给heap,以便重新切分复用。
4.定期扫描heap里长时间闲置的span,释放其占用的内存。
注:以上不包括大对象,它直接从heap分配和回收。
作为工作线程私有且不被共享的cache是实现高性能无锁分配的核心,而central的作用是在多个cache间提高object利用率,避免内存浪费。
假如cache1获取一个span后,仅使用了一部分object,那么剩余空间就可能会被浪费。而回收操作将该span交还给central后,该span完全可以被cache2、cacheN获取使用。此时,cache1已不再持有该span,完全不会造成问题。
将span归还给heap,是为了在不同规格object需求间平衡。
某时段某种规格的object需求量可能激增,那么当需求过后,大量被切分成该规格的span就会被闲置浪费。将其归还给heap,就可被其他需求获取,重新切分。
初始化
因为内存分配器和垃圾回收算法都依赖连续地址,所以在初始化阶段,预先保留了
很大的一段虚拟地址空间。
注意:保留地址空间,并不会分配内存。
该段空间被划分成三个区域:
页所属span指针数组 GC标记位图 用户内存分配区域 +-------------------
+--------------------+---------------------------------------------
-+ | spans 512MB | bitmap 32GB | arena 512GB | +-------------------
+--------------------+---------------------------------------------
-+ spans_mapped bitmap_mapped arena_start arena_used arena_end
可分配区域从Go 1.4的128 GB提高到512 GB。
简单点说,就是用三个数组组成一个高性能内存管理结构。
- 使用arena地址向操作系统申请内存,其大小决定了可分配用户内存的上限。
- 位图bitmap为每个对象提供4 bit标记位,用以保存指针、GC标记等信息。
- 创建span时,按页填充对应spans空间。在回收object时,只须将其地址按页
对齐后就可找到所属span。分配器还用此访问相邻span,做合并操作。
任何arena区域的地址,只要将其偏移量配以不同步幅和起始位置,就可快速访问与
之对应的spans、bitmap数据。最关键的是,这三个数组可以按需同步线性扩张,
无须预先分配内存。
这些区域相关属性被保存在heap里,其中包括递进的分配位置mapped/used。
mheap.go
type mheap struct{ spans **mspan spans_mapped uintptr bitmap
uintptr bitmap_mapped uintptr arena_start uintptr arena_used
uintptr arena_end uintptr arena_reserved bool }
初始化工作很简单: - 创建对象规格大小对照表。
- 计算相关区域大小,并尝试从某个指定位置开始保留地址空间。
- 在heap里保存区域信息,包括起始位置和大小。
- 初始化heap其他属性。
malloc.go
func mallocinit() { // 初始化规格对照表 initSizes() ... //64位系统
if ptrSize8&& (limit0||limit>1<<30) { // 计算相关区域大小
arenaSize:=round(MaxMem, PageSize) bitmapSize=arenaSize/
(ptrSize8/4) spansSize=arenaSize/ _PageSizeptrSize
spansSize=round(spansSize, PageSize) // 尝试从0xc000000000开始设置
保留地址 // 如果失败,则尝试0x1c000000000~0x7fc000000000 for
i:=0;i<=0x7f;i++ { switch{ case GOARCH== "arm64" &&GOOS== "darwin":
p=uintptr(i)<<40|uintptrMask&(0x0013<<28) case GOARCH== "arm64":
p=uintptr(i)<<40|uintptrMask&(0x0040<<32) default: p=uintptr(i)
<<40|uintptrMask&(0x00c0<<32) } // 计算整个区域大小,并从指定位置开始
保留地址空间 pSize=bitmapSize+spansSize+arenaSize+ PageSize
p=uintptr(sysReserve(unsafe.Pointer(p),pSize, &reserved)) if p!=0{
break } } } // 按页对齐 p1:=round(p, PageSize) // 保存相关属性
mheap.spans= (**mspan)(unsafe.Pointer(p1))
mheap.bitmap=p1+spansSize mheap.arena_start=p1+
(spansSize+bitmapSize) mheap.arena_used=mheap.arena_start
mheap_.arena_end=p+pSize mheap_.arena_reserved=reserved // 非指定起
始地址,备用地址标记 ... // 初始化heap mHeap_Init(&mheap_,spansSize)
// 为当前线程绑定cache对象 g :=getg() g.m.mcache=allocmcache() }
区域所指定的起始位置,在不同平台会有一些差异。这个无关紧要,实际上我们关
心的是保留地址操作细节。
mem_linux.go
func sysReserve(v unsafe.Pointer,n
uintptr,reserved*bool)unsafe.Pointer{ if ptrSize==8&&uint64(n)
1<<32{ p:=mmap_fixed(v,64<<10, _PROT_NONE, _MAP_ANON|_MAP_PRIVATE,
-1,0) if p!=v{ if uintptr(p) >=4096{ munmap(p,64<<10) } return nil
} munmap(p,64<<10) reserved=false return v } } func mmap_fixed(v
unsafe.Pointer,n uintptr,prot,flags,fd int32,offset uint32) ... {
p:=mmap(v,n,prot,flags,fd,offset) if p!=v&&addrspace_free(v,n) { if
uintptr(p) >4096{ munmap(p,n) }
p=mmap(v,n,prot,flags|_MAP_FIXED,fd,offset) } return p }
对系统编程稍有了解的都知道mmap的用途。
函数mmap要求操作系统内核创建新的虚拟存储器区域,可指定起始地址和长度。
Windows没有此函数,对应API是VirtualAlloc。
PORT_NONE:页面无法访问。
MAP_FIXED:必须使用指定起始地址。
另外,作为内存管理的全局根对象heap,其相关属性也必须初始化。
mheap.go
func mHeap_Init(hmheap,spans_size uintptr) { // 初始化几个用于管理
用途的固定分配器(参见本章后续内容) // 初始化相关属性 for i:=range
h.free{ mSpanList_Init(&h.free[i]) mSpanList_Init(&h.busy[i]) }
mSpanList_Init(&h.freelarge) mSpanList_Init(&h.busylarge) // 创建
central for i:=range h.central{
mCentral_Init(&h.central[i].mcentral,int32(i)) } // 将全局变量
h_spans指向heap.spans sp:= (slice)(unsafe.Pointer(&h_spans))
sp.array=unsafe.Pointer(h.spans) sp.len=int(spans_size/ptrSize)
sp.cap=int(spans_size/ptrSize) }
强烈建议所有程序员都学习一下虚拟存储器的相关知识(推荐《深入理解计算机系
统》),很多误解都源自对系统层面的认知匮乏。下面,我们用一个简单示例来澄
清有关内存分配的几个常见误解。
test.go
package main import( "fmt" "os"
"github.com/shirou/gopsutil/process" ) var psprocess.Process // 输
出内存状态信息 func mem(n int) { if ps==nil{
p,err:=process.NewProcess(int32(os.Getpid())) if err!=nil{
panic(err) } ps=p } mem, _ :=ps.MemoryInfoEx() fmt.Printf("%d.VMS:
%d MB,RSS: %d MB\n",n,mem.VMS>>20,mem.RSS>>20) } func main() { //1.
初始化结束后的内存状态 mem(1) //2. 创建一个101MB数组后的内存状态
data:=new([10][10241024]byte) mem(2) //3. 填充该数组过程中的内存状态
for i:=range data{ for x,n:=0,len(data[i]);x<n;x++ { data[i][x] =1
} mem(3) } }
编译后执行:
1.VMS: 5 MB,RSS:1 MB 2.VMS:15 MB,RSS:1 MB 3.VMS:15 MB,RSS:2 MB
3.VMS:15 MB,RSS:3 MB 3.VMS:15 MB,RSS:4 MB 3.VMS:15 MB,RSS:5 MB
3.VMS:15 MB,RSS:6 MB 3.VMS:15 MB,RSS:7 MB 3.VMS:15 MB,RSS:8 MB
3.VMS:15 MB,RSS:9 MB 3.VMS:15 MB,RSS:10 MB 3.VMS:15 MB,RSS:11 MB
按序号对照输出结果:
- 尽管初始化时预留了544 GB的虚拟地址空间,但并没有分配内存。
- 操作系统大多采取机会主义分配策略,申请内存时,仅承诺但不立即分配物理内
存。 - 物理内存分配在写操作导致缺页异常调度时发生,而且是按页提供的。
注意:不同操作系统,可能会存在一些差异。
分配
回收
释放
其他
GC垃圾回收
并发
通道
通道可以被认为是Goroutines通信的管道。类似于管道中的水从一端到另一端的流动,数据可以从一端发送到另一端,通过通道接收。
在前面讲Go语言的并发时候,我们就说过,当多个Goroutine想实现共享数据的时候,虽然也提供了传统的同步机制,但是Go语言强烈建议的是使用Channel通道来实现Goroutines之间的通信。
“不要通过共享内存来通信,而应该通过通信来共享内存” 这是一句风靡golang社区的经典语
Go语言中,要传递某个数据给另一个goroutine(协程),可以把这个数据封装成一个对象,然后把这个对象的指针传入某个channel中,另外一个goroutine从这个channel中读出这个指针,并处理其指向的内存对象。Go从语言层面保证同一个时间只有一个goroutine能够访问channel里面的数据,为开发者提供了一种优雅简单的工具,所以Go的做法就是使用channel来通信,通过通信来传递内存数据,使得内存数据在不同的goroutine中传递,而不是使用共享内存来通信。
什么是通道
1.1 通道的概念
通道是什么,通道就是goroutine之间的通道。它可以让goroutine之间相互通信。
每个通道都有与其相关的类型。该类型是通道允许传输的数据类型。(通道的零值为nil。nil通道没有任何用处,因此通道必须使用类似于map和切片的方法来定义。)
1.2 通道的声明
声明一个通道和定义一个变量的语法一样:
//声明通道
var 通道名 chan 数据类型
//创建通道:如果通道为nil(就是不存在),就需要先创建通道
通道名 = make(chan 数据类型)
示例代码:
package main
import "fmt"
func main() {
var a chan int
if a == nil {
fmt.Println("channel 是 nil 的, 不能使用,需要先创建通道。。")
a = make(chan int)
fmt.Printf("数据类型是: %T", a)
}
}
运行结果:
channel 是 nil 的, 不能使用,需要先创建通道。。
数据类型是: chan int
也可以简短的声明:
a := make(chan int)
1.3 channel的数据类型
channel是引用类型的数据,在作为参数传递的时候,传递的是内存地址。
示例代码:
package main
import (
"fmt"
)
func main() {
ch1 := make(chan int)
fmt.Printf("%T,%p\n",ch1,ch1)
test1(ch1)
}
func test1(ch chan int){
fmt.Printf("%T,%p\n",ch,ch)
}
运行结果:
我们能够看到,ch和ch1的地址是一样的,说明它们是同一个通道。
1.4 通道的注意点
Channel通道在使用的时候,有以下几个注意点:
-
用于goroutine,传递消息的。
-
通道,每个都有相关联的数据类型,
nil chan,不能使用,类似于nil map,不能直接存储键值对 -
使用通道传递数据:<-
chan <- data,发送数据到通道。向通道中写数据
data <- chan,从通道中获取数据。从通道中读数据 -
阻塞:
发送数据:chan <- data,阻塞的,直到另一条goroutine,读取数据来解除阻塞
读取数据:data <- chan,也是阻塞的。直到另一条goroutine,写出数据解除阻塞。 -
本身channel就是同步的,意味着同一时间,只能有一条goroutine来操作。
-
通道是goroutine之间的连接,所以通道的发送和接收必须处在不同的goroutine中。
通道的使用语法
2.1 发送和接收
发送和接收的语法:
data := <- a // read from channel a
a <- data // write to channel a
在通道上箭头的方向指定数据是发送还是接收。
另外:
v, ok := <- a //从一个channel中读取
2.2 发送和接收默认是阻塞的
一个通道发送和接收数据,默认是阻塞的。当一个数据被发送到通道时,在发送语句中被阻塞,直到另一个Goroutine从该通道读取数据。相对地,当从通道读取数据时,读取被阻塞,直到一个Goroutine将数据写入该通道。
这些通道的特性是帮助Goroutines有效地进行通信,而无需像使用其他编程语言中非常常见的显式锁或条件变量。
示例代码:
package main
import "fmt"
func main() {
var ch1 chan bool //声明,没有创建
fmt.Println(ch1) //<nil>
fmt.Printf("%T\n", ch1) //chan bool
ch1 = make(chan bool) //0xc0000a4000,是引用类型的数据
fmt.Println(ch1)
go func() {
for i := 0; i < 10; i++ {
fmt.Println("子goroutine中,i:", i)
}
// 循环结束后,向通道中写数据,表示要结束了。。
ch1 <- true
fmt.Println("结束。。")
}()
data := <-ch1 // 从ch1通道中读取数据
fmt.Println("data-->", data)
fmt.Println("main。。over。。。。")
}
在上面的程序中,我们先创建了一个chan bool通道。然后启动了一条子Goroutine,并循环打印10个数字。然后我们向通道ch1中写入输入true。然后在主goroutine中,我们从ch1中读取数据。这一行代码是阻塞的,这意味着在子Goroutine将数据写入到该通道之前,主goroutine将不会执行到下一行代码。因此,我们可以通过channel实现子goroutine和主goroutine之间的通信。当子goroutine执行完毕前,主goroutine会因为读取ch1中的数据而阻塞。从而保证了子goroutine会先执行完毕。这就消除了对时间的需求。在之前的程序中,我们要么让主goroutine进入睡眠,以防止主要的Goroutine退出。要么通过WaitGroup来保证子goroutine先执行完毕,主goroutine才结束。
示例代码:以下代码加入了睡眠,可以更好的理解channel的阻塞
package main
import (
"fmt"
"time"
)
func main() {
ch1 := make(chan int)
done := make(chan bool) // 通道
go func() {
fmt.Println("子goroutine执行。。。")
time.Sleep(3 * time.Second)
data := <-ch1 // 从通道中读取数据
fmt.Println("data:", data)
done <- true
}()
// 向通道中写数据。。
time.Sleep(5 * time.Second)
ch1 <- 100
<-done
fmt.Println("main。。over")
}
运行结果:
再一个例子,这个程序将打印一个数字的个位数的平方和。
package main
import (
"fmt"
)
func calcSquares(number int, squareop chan int) {
sum := 0
for number != 0 {
digit := number % 10
sum += digit * digit
number /= 10
}
squareop <- sum
}
func calcCubes(number int, cubeop chan int) {
sum := 0
for number != 0 {
digit := number % 10
sum += digit * digit * digit
number /= 10
}
cubeop <- sum
}
func main() {
number := 589
sqrch := make(chan int)
cubech := make(chan int)
go calcSquares(number, sqrch)
go calcCubes(number, cubech)
squares, cubes := <-sqrch, <-cubech
fmt.Println("Final output", squares + cubes)
}
运行结果:
Final output 1536
2.3 死锁
使用通道时要考虑的一个重要因素是死锁。如果Goroutine在一个通道上发送数据,那么预计其他的Goroutine应该接收数据。如果这种情况不发生,那么程序将在运行时出现死锁。
类似地,如果Goroutine正在等待从通道接收数据,那么另一些Goroutine将会在该通道上写入数据,否则程序将会死锁。
示例代码:
package main
func main() {
ch := make(chan int)
ch <- 5
}
报错:
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan send]:
main.main()
/Users/ruby/go/src/l_goroutine/demo08_chan.go:5 +0x50
在主流的编程语言中为了保证多线程之间共享数据安全性和一致性,都会提供一套基本的同步工具集,如锁,条件变量,原子操作等等。Go语言标准库也毫不意外的提供了这些同步机制,使用方式也和其他语言也差不多。
除了这些基本的同步手段,Go语言还提供了一种新的同步机制: Channel,它在Go语言中是一个像int, float32等的基本类型,一个channel可以认为是一个能够在多个Goroutine之间传递某一类型的数据的管道。Go中的channel无论是实现机制还是使用场景都和Java中的BlockingQueue很接近。
2.4 关闭通道
发送者可以通过关闭信道,来通知接收方不会有更多的数据被发送到channel上。
close(ch)
接收者可以在接收来自通道的数据时使用额外的变量来检查通道是否已经关闭。
语法结构:
v, ok := <- ch
类似map操作,存储key,value键值对
v,ok := map[key] //根据key从map中获取value,如果key存在, v就是对应的数据,如果key不存在,v是默认值
在上面的语句中,如果ok的值是true,表示成功的从通道中读取了一个数据value。如果ok是false,这意味着我们正在从一个封闭的通道读取数据。从闭通道读取的值将是通道类型的零值。
例如,如果通道是一个int通道,那么从封闭通道接收的值将为0。
示例代码:
package main
import (
"fmt"
"time"
)
func main() {
ch1 := make(chan int)
go sendData(ch1)
/*
子goroutine,写出数据10个
每写一个,阻塞一次,主程序读取一次,解除阻塞
主goroutine:循环读
每次读取一个,堵塞一次,子程序,写出一个,解除阻塞
发送发,关闭通道的--->接收方,接收到的数据是该类型的零值,以及false
*/
//主程序中获取通道的数据
for{
time.Sleep(1*time.Second)
v, ok := <- ch1 //其他goroutine,显示的调用close方法关闭通道。
if !ok{
fmt.Println("已经读取了所有的数据,", ok)
break
}
fmt.Println("取出数据:",v, ok)
}
fmt.Println("main...over....")
}
func sendData(ch1 chan int) {
// 发送方:10条数据
for i:=0;i<10 ;i++ {
ch1 <- i//将i写入通道中
}
close(ch1) //将ch1通道关闭了。
}
在上面的程序中,send Goroutine将0到9写入chl通道,然后关闭通道。主函数里有一个无限循环。它检查通道是否在发送数据后,使用变量ok关闭。如果ok是假的,则意味着通道关闭,因此循环结束。还可以打印接收到的值和ok的值。
2.5 通道上的范围循环
我们可以循环从通道上获取数据,直到通道关闭。for循环的for range形式可用于从通道接收值,直到它关闭为止。
使用range循环,示例代码:
func main() {
ch1 :=make(chan int)
go sendData(ch1)
// for循环的for range形式可用于从通道接收值,直到它关闭为止。
for v := range ch1{
fmt.Println("读取数据:",v)
}
fmt.Println("main..over.....")
}
func sendData(ch1 chan int) {
for i:=0;i<10 ; i++ {
time.Sleep(1*time.Second)
ch1 <- i
}
close(ch1)//通知对方,通道关闭
}
2.6 缓冲通道
之前学习的所有通道基本上都没有缓冲。发送和接收到一个未缓冲的通道是阻塞的。
一次发送操作对应一次接收操作,对于一个goroutine来讲,它的一次发送,在另一个goroutine接收之前都是阻塞的。同样的,对于接收来讲,在另一个goroutine发送之前,它也是阻塞的。
缓冲通道就是指一个通道,带有一个缓冲区。发送到一个缓冲通道只有在缓冲区满时才被阻塞。类似地,从缓冲通道接收的信息只有在缓冲区为空时才会被阻塞。
可以通过将额外的容量参数传递给make函数来创建缓冲通道,该函数指定缓冲区的大小。
语法:
ch := make(chan type, capacity)
上述语法的容量应该大于0,以便通道具有缓冲区。默认情况下,无缓冲通道的容量为0,因此在之前创建通道时省略了容量参数。
示例代码:
以下的代码中,chan通道,是带有缓冲区的。
package main
import (
"fmt"
"strconv"
"time"
)
func main() {
/*
非缓存通道:make(chan T)
缓存通道:make(chan T ,size)
缓存通道,理解为是队列:
非缓存,发送还是接受,都是阻塞的
缓存通道,缓存区的数据满了,才会阻塞状态。。
*/
ch1 := make(chan int) //非缓存的通道
fmt.Println(len(ch1), cap(ch1)) //0 0
//ch1 <- 100//阻塞的,需要其他的goroutine解除阻塞,否则deadlock
ch2 := make(chan int, 5) //缓存的通道,缓存区大小是5
fmt.Println(len(ch2), cap(ch2)) //0 5
ch2 <- 100 //
fmt.Println(len(ch2), cap(ch2)) //1 5
//ch2 <- 200
//ch2 <- 300
//ch2 <- 400
//ch2 <- 500
//ch2 <- 600
fmt.Println("--------------")
ch3 := make(chan string, 4)
go sendData3(ch3)
for {
time.Sleep(1*time.Second)
v, ok := <-ch3
if !ok {
fmt.Println("读完了,,", ok)
break
}
fmt.Println("\t读取的数据是:", v)
}
fmt.Println("main...over...")
}
func sendData3(ch3 chan string) {
for i := 0; i < 10; i++ {
ch3 <- "数据" + strconv.Itoa(i)
fmt.Println("子goroutine,写出第", i, "个数据")
}
close(ch3)
}
运行结果:
2.6 双向通道
通道,channel,是用于实现goroutine之间的通信的。一个goroutine可以向通道中发送数据,另一条goroutine可以从该通道中获取数据。截止到现在我们所学习的通道,都是既可以发送数据,也可以读取数据,我们又把这种通道叫做双向通道。
data := <- a // read from channel a
a <- data // write to channel a
单向通道,也就是定向通道。
单向通道并不是用于传递信息的,而是用于限制通道在不同 Goroutine 之间的操作。单向通道的主要目的是在函数参数中指定通道的使用方式,而不是用于传递数据。
单向通道可以确保某个函数只能发送数据或只能接收数据,从而增加了代码的安全性。它们可以在函数之间明确表达意图,并防止不正确的操作。
之前我们学习的通道都是双向通道,我们可以通过这些通道接收或者发送数据。我们也可以创建单向通道,这些通道只能发送或者接收数据。
双向通道,实例代码:
package main
import "fmt"
func main() {
/*
双向:
chan T -->
chan <- data,写出数据,写
data <- chan,获取数据,读
单向:定向
chan <- T,
只支持写,
<- chan T,
只读
*/
ch1 := make(chan string) // 双向,可读,可写
done := make(chan bool)
go sendData(ch1, done)
data :=<- ch1 //阻塞
fmt.Println("子goroutine传来:", data)
ch1 <- "我是main。。" // 阻塞
<-done
fmt.Println("main...over....")
}
//子goroutine-->写数据到ch1通道中
//main goroutine-->从ch1通道中取
func sendData(ch1 chan string, done chan bool) {
ch1 <- "我是小明"// 阻塞
data := <-ch1 // 阻塞
fmt.Println("main goroutine传来:",data)
done <- true
}
创建仅能发送数据的通道,示例代码:
示例代码:
package main
import "fmt"
func main() {
/*
单向:定向
chan <- T,
只支持写,
<- chan T,
只读
用于参数传递:
*/
ch1 := make(chan int)//双向,读,写
//ch2 := make(chan <- int) // 单向,只写,不能读
//ch3 := make(<- chan int) //单向,只读,不能写
//ch1 <- 100
//data :=<-ch1
//ch2 <- 1000
//data := <- ch2
//fmt.Println(data)
// <-ch2 //invalid operation: <-ch2 (receive from send-only type chan<- int)
//ch3 <- 100
// <-ch3
// ch3 <- 100 //invalid operation: ch3 <- 100 (send to receive-only type <-chan int)
//go fun1(ch2)
go fun1(ch1)
data:= <- ch1
fmt.Println("fun1中写出的数据是:",data)
//fun2(ch3)
go fun2(ch1)
ch1 <- 200
fmt.Println("main。。over。。")
}
//该函数接收,只写的通道
func fun1(ch chan <- int){
// 函数内部,对于ch只能写数据,不能读数据
ch <- 100
fmt.Println("fun1函数结束。。")
}
func fun2(ch <-chan int){
//函数内部,对于ch只能读数据,不能写数据
data := <- ch
fmt.Println("fun2函数,从ch中读取的数据是:",data)
}
九、time包中的通道相关函数
主要就是定时器,标准库中的Timer让用户可以定义自己的超时逻辑,尤其是在应对select处理多个channel的超时、单channel读写的超时等情形时尤为方便。
Timer是一次性的时间触发事件,这点与Ticker不同,Ticker是按一定时间间隔持续触发时间事件。
Timer常见的创建方式:
t:= time.NewTimer(d)
t:= time.AfterFunc(d, f)
c:= time.After(d)
虽然说创建方式不同,但是原理是相同的。
Timer有3个要素:
定时时间:就是那个d
触发动作:就是那个f
时间channel: 也就是t.C
9.1、time.NewTimer()
NewTimer()创建一个新的计时器,该计时器将在其通道上至少持续d之后发送当前时间。
它的返回值是一个Timer。
源代码:
// NewTimer creates a new Timer that will send
// the current time on its channel after at least duration d.
func NewTimer(d Duration) *Timer {
c := make(chan Time, 1)
t := &Timer{
C: c,
r: runtimeTimer{
when: when(d),
f: sendTime,
arg: c,
},
}
startTimer(&t.r)
return t
}
通过源代码我们可以看出,首先创建一个channel,关联的类型为Time,然后创建了一个Timer并返回。
用于在指定的Duration类型时间后调用函数或计算表达式。
如果只是想指定时间之后执行,使用time.Sleep()
使用NewTimer(),可以返回的Timer类型在计时器到期之前,取消该计时器
直到使用<-timer.C发送一个值,该计时器才会过期
示例代码:
package main
import (
"time"
"fmt"
)
func main() {
/*
1.func NewTimer(d Duration) *Timer
创建一个计时器:d时间以后触发,go触发计时器的方法比较特别,就是在计时器的channel中发送值
*/
//新建一个计时器:timer
timer := time.NewTimer(3 * time.Second)
fmt.Printf("%T\n", timer) //*time.Timer
fmt.Println(time.Now()) //2019-08-15 10:41:21.800768 +0800 CST m=+0.000461190
//此处在等待channel中的信号,执行此段代码时会阻塞3秒
ch2 := timer.C //<-chan time.Time
fmt.Println(<-ch2) //2019-08-15 10:41:24.803471 +0800 CST m=+3.003225965
}
运行结果:
9.2、timer.Stop
计时器停止:
示例代码:
package main
import (
"time"
"fmt"
)
func main() {
/*
1.func NewTimer(d Duration) *Timer
创建一个计时器:d时间以后触发,go触发计时器的方法比较特别,就是在计时器的channel中发送值
*/
//新建一个计时器:timer
//timer := time.NewTimer(3 * time.Second)
//fmt.Printf("%T\n", timer) //*time.Timer
//fmt.Println(time.Now()) //2019-08-15 10:41:21.800768 +0800 CST m=+0.000461190
//
此处在等待channel中的信号,执行此段代码时会阻塞3秒
//ch2 := timer.C //<-chan time.Time
//fmt.Println(<-ch2) //2019-08-15 10:41:24.803471 +0800 CST m=+3.003225965
fmt.Println("-------------------------------")
//新建计时器,一秒后触发
timer2 := time.NewTimer(5 * time.Second)
//新开启一个线程来处理触发后的事件
go func() {
//等触发时的信号
<-timer2.C
fmt.Println("Timer 2 结束。。")
}()
//由于上面的等待信号是在新线程中,所以代码会继续往下执行,停掉计时器
time.Sleep(3*time.Second)
stop := timer2.Stop()
if stop {
fmt.Println("Timer 2 停止。。")
}
}
9.3、time.After()
在等待持续时间之后,然后在返回的通道上发送当前时间。它相当于NewTimer(d).C。在计时器触发之前,垃圾收集器不会恢复底层计时器。如果效率有问题,使用NewTimer代替,并调用Timer。如果不再需要计时器,请停止。
源码:
// After waits for the duration to elapse and then sends the current time
// on the returned channel.
// It is equivalent to NewTimer(d).C.
// The underlying Timer is not recovered by the garbage collector
// until the timer fires. If efficiency is a concern, use NewTimer
// instead and call Timer.Stop if the timer is no longer needed.
func After(d Duration) <-chan Time {
return NewTimer(d).C
}
示例代码:
package main
import (
"time"
"fmt"
)
func main() {
/*
func After(d Duration) <-chan Time
返回一个通道:chan,存储的是d时间间隔后的当前时间。
*/
ch1 := time.After(3 * time.Second) //3s后
fmt.Printf("%T\n", ch1) // <-chan time.Time
fmt.Println(time.Now()) //2019-08-15 09:56:41.529883 +0800 CST m=+0.000465158
time2 := <-ch1
fmt.Println(time2) //2019-08-15 09:56:44.532047 +0800 CST m=+3.002662179
}
运行结果:
十、select语句
select 是 Go 中的一个控制结构。select 语句类似于 switch 语句,但是select会随机执行一个可运行的case。如果没有case可运行,它将阻塞,直到有case可运行。
select语句的语法结构和switch语句很相似,也有case语句和default语句:
select {
case communication clause :
statement(s);
case communication clause :
statement(s);
/* 你可以定义任意数量的 case /
default : / 可选 */
statement(s);
}
说明:
每个case都必须是一个通信
所有channel表达式都会被求值
所有被发送的表达式都会被求值
如果有多个case都可以运行,select会随机公平地选出一个执行。其他不会执行。
否则:
如果有default子句,则执行该语句。
如果没有default字句,select将阻塞,直到某个通信可以运行;Go不会重新对channel或值进行求值。
示例代码:
package main
import (
"fmt"
"time"
)
func main() {
/*
分支语句:if,switch,select
select 语句类似于 switch 语句,
但是select会随机执行一个可运行的case。
如果没有case可运行,它将阻塞,直到有case可运行。
*/
ch1 := make(chan int)
ch2 := make(chan int)
go func() {
time.Sleep(2 * time.Second)
ch2 <- 200
}()
go func() {
time.Sleep(2 * time.Second)
ch1 <- 100
}()
select {
case num1 := <-ch1:
fmt.Println("ch1中取数据。。", num1)
case num2, ok := <-ch2:
if ok {
fmt.Println("ch2中取数据。。", num2)
}else{
fmt.Println("ch2通道已经关闭。。")
}
}
}
运行结果:可能执行第一个case,打印100,也可能执行第二个case,打印200。(多运行几次,结果就不同了)
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uVOrisDw-1624026985188)(img/WX20190816-104608.png)]
select语句结合time包的和chan相关函数,示例代码:
package main
import (
"fmt"
"time"
)
func main() {
ch1 := make(chan int)
ch2 := make(chan int)
//go func() {
// ch1 <- 100
//}()
select {
case <-ch1:
fmt.Println("case1可以执行。。")
case <-ch2:
fmt.Println("case2可以执行。。")
case <-time.After(3 * time.Second):
fmt.Println("case3执行。。timeout。。")
//default:
// fmt.Println("执行了default。。")
}
}
运行结果:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YT7GXC48-1624026985188)(img/WX20190816-104450.png)]
十一、Go语言的CSP模型
go语言的最大两个亮点,一个是goroutine,一个就是chan了。二者合体的典型应用CSP,基本就是大家认可的并行开发神器,简化了并行程序的开发难度,我们来看一下CSP。
11.1、CSP是什么
CSP 是 Communicating Sequential Process 的简称,中文可以叫做通信顺序进程,是一种并发编程模型,是一个很强大的并发数据模型,是上个世纪七十年代提出的,用于描述两个独立的并发实体通过共享的通讯 channel(管道)进行通信的并发模型。相对于Actor模型,CSP中channel是第一类对象,它不关注发送消息的实体,而关注与发送消息时使用的channel。
严格来说,CSP 是一门形式语言(类似于 ℷ calculus),用于描述并发系统中的互动模式,也因此成为一众面向并发的编程语言的理论源头,并衍生出了 Occam/Limbo/Golang…
而具体到编程语言,如 Golang,其实只用到了 CSP 的很小一部分,即理论中的 Process/Channel(对应到语言中的 goroutine/channel):这两个并发原语之间没有从属关系, Process 可以订阅任意个 Channel,Channel 也并不关心是哪个 Process 在利用它进行通信;Process 围绕 Channel 进行读写,形成一套有序阻塞和可预测的并发模型。
11.2、Golang CSP
与主流语言通过共享内存来进行并发控制方式不同,Go 语言采用了 CSP 模式。这是一种用于描述两个独立的并发实体通过共享的通讯 Channel(管道)进行通信的并发模型。
Golang 就是借用CSP模型的一些概念为之实现并发进行理论支持,其实从实际上出发,go语言并没有,完全实现了CSP模型的所有理论,仅仅是借用了 process和channel这两个概念。process是在go语言上的表现就是 goroutine 是实际并发执行的实体,每个实体之间是通过channel通讯来实现数据共享。
Go语言的CSP模型是由协程Goroutine与通道Channel实现:
Go协程goroutine: 是一种轻量线程,它不是操作系统的线程,而是将一个操作系统线程分段使用,通过调度器实现协作式调度。是一种绿色线程,微线程,它与Coroutine协程也有区别,能够在发现堵塞后启动新的微线程。
通道channel: 类似Unix的Pipe,用于协程之间通讯和同步。协程之间虽然解耦,但是它们和Channel有着耦合。
11.3、Channel
Goroutine 和 channel 是 Go 语言并发编程的 两大基石。Goroutine 用于执行并发任务,channel 用于 goroutine 之间的同步、通信。
Channel 在 gouroutine 间架起了一条管道,在管道里传输数据,实现 gouroutine 间的通信;由于它是线程安全的,所以用起来非常方便;channel 还提供 “先进先出” 的特性;它还能影响 goroutine 的阻塞和唤醒。
相信大家一定见过一句话:
Do not communicate by sharing memory; instead, share memory by communicating.
不要通过共享内存来通信,而要通过通信来实现内存共享。
这就是 Go 的并发哲学,它依赖 CSP 模型,基于 channel 实现。
channel 实现 CSP
Channel 是 Go 语言中一个非常重要的类型,是 Go 里的第一对象。通过 channel,Go 实现了通过通信来实现内存共享。Channel 是在多个 goroutine 之间传递数据和同步的重要手段。
使用原子函数、读写锁可以保证资源的共享访问安全,但使用 channel 更优雅。
channel 字面意义是 “通道”,类似于 Linux 中的管道。声明 channel 的语法如下:
chan T // 声明一个双向通道
chan<- T // 声明一个只能用于发送的通道
<-chan T // 声明一个只能用于接收的通道
1
2
3
单向通道的声明,用 <- 来表示,它指明通道的方向。你只要明白,代码的书写顺序是从左到右就马上能掌握通道的方向是怎样的。
因为 channel 是一个引用类型,所以在它被初始化之前,它的值是 nil,channel 使用 make 函数进行初始化。可以向它传递一个 int 值,代表 channel 缓冲区的大小(容量),构造出来的是一个缓冲型的 channel;不传或传 0 的,构造的就是一个非缓冲型的 channel。
两者有一些差别:非缓冲型 channel 无法缓冲元素,对它的操作一定顺序是 “发送 -> 接收 -> 发送 -> 接收 -> ……”,如果想连续向一个非缓冲 chan 发送 2 个元素,并且没有接收的话,第一次一定会被阻塞;对于缓冲型 channel 的操作,则要 “宽松” 一些,毕竟是带了 “缓冲” 光环。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4KGj1LhB-1624026985189)(img/channel.png)]
对 chan 的发送和接收操作都会在编译期间转换成为底层的发送接收函数。
Channel 分为两种:带缓冲、不带缓冲。对不带缓冲的 channel 进行的操作实际上可以看作 “同步模式”,带缓冲的则称为 “异步模式”。
同步模式下,发送方和接收方要同步就绪,只有在两者都 ready 的情况下,数据才能在两者间传输(后面会看到,实际上就是内存拷贝)。否则,任意一方先行进行发送或接收操作,都会被挂起,等待另一方的出现才能被唤醒。
异步模式下,在缓冲槽可用的情况下(有剩余容量),发送和接收操作都可以顺利进行。否则,操作的一方(如写入)同样会被挂起,直到出现相反操作(如接收)才会被唤醒。
小结一下:同步模式下,必须要使发送方和接收方配对,操作才会成功,否则会被阻塞;异步模式下,缓冲槽要有剩余容量,操作才会成功,否则也会被阻塞。
简单来说,CSP 模型由并发执行的实体(线程或者进程或者协程)所组成,实体之间通过发送消息进行通信,
这里发送消息时使用的就是通道,或者叫 channel。
CSP 模型的关键是关注 channel,而不关注发送消息的实体。Go 语言实现了 CSP 部分理论,goroutine 对应 CSP 中并发执行的实体,channel 也就对应着 CSP 中的 channel。
11.4、Goroutine
Goroutine 是实际并发执行的实体,它底层是使用协程(coroutine)实现并发,coroutine是一种运行在用户态的用户线程,类似于 greenthread,go底层选择使用coroutine的出发点是因为,它具有以下特点:
用户空间 避免了内核态和用户态的切换导致的成本
可以由语言和框架层进行调度
更小的栈空间允许创建大量的实例
可以看到第二条 用户空间线程的调度不是由操作系统来完成的,像在java 1.3中使用的greenthread的是由JVM统一调度的(后java已经改为内核线程),还有在ruby中的fiber(半协程) 是需要在重新中自己进行调度的,而goroutine是在golang层面提供了调度器,并且对网络IO库进行了封装,屏蔽了复杂的细节,对外提供统一的语法关键字支持,简化了并发程序编写的成本。
11.5、Goroutine 调度器
Go并发调度: G-P-M模型
在操作系统提供的内核线程之上,Go搭建了一个特有的两级线程模型。goroutine机制实现了M : N的线程模型,goroutine机制是协程(coroutine)的一种实现,golang内置的调度器,可以让多核CPU中每个CPU执行一个协程。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ZpUZKjSA-1624026985190)(img/goroutine2.png)]
11.6、最后
Golang 的 channel 将 goroutine 隔离开,并发编程的时候可以将注意力放在 channel 上。在一定程度上,这个和消息队列的解耦功能还是挺像的。如果大家感兴趣,还是来看看 channel 的源码吧,对于更深入地理解 channel 还是挺有用的。
Go 通过 channel 实现 CSP 通信模型,主要用于 goroutine 之间的消息传递和事件通知。
有了 channel 和 goroutine 之后,Go 的并发编程变得异常容易和安全,得以让程序员把注意力留到业务上去,实现开发效率的提升。
要知道,技术并不是最重要的,它只是实现业务的工具。一门高效的开发语言让你把节省下来的时间,留着去做更有意义的事情,比如写写文章。
反射
反射(reflection)是在 Java 出现后迅速流行起来的一种概念,通过反射可以获取丰富的类型信息,并可以利用这些类型信息做非常灵活的工作。
大多数现代的高级语言都以各种形式支持反射功能,反射是把双刃剑,功能强大但代码可读性并不理想,若非必要并不推荐使用反射。
下面我们就来将介绍一下反射在Go语言中的具体体现以及反射的基本使用方法。
反射的基本概念
Go语言提供了一种机制在运行时更新和检查变量的值、调用变量的方法和变量支持的内在操作,但是在编译时并不知道这些变量的具体类型,这种机制被称为反射。反射也可以让我们将类型本身作为第一类的值类型处理。
反射是指在程序运行期对程序本身进行访问和修改的能力,程序在编译时变量被转换为内存地址,变量名不会被编译器写入到可执行部分,在运行程序时程序无法获取自身的信息。
支持反射的语言可以在程序编译期将变量的反射信息,如字段名称、类型信息、结构体信息等整合到可执行文件中,并给程序提供接口访问反射信息,这样就可以在程序运行期获取类型的反射信息,并且有能力修改它们。
C/C++语言没有支持反射功能,只能通过 typeid 提供非常弱化的程序运行时类型信息;Java、C# 等语言都支持完整的反射功能;Lua、JavaScript 类动态语言,由于其本身的语法特性就可以让代码在运行期访问程序自身的值和类型信息,因此不需要反射系统。
Go语言程序的反射系统无法获取到一个可执行文件空间中或者是一个包中的所有类型信息,需要配合使用标准库中对应的词法、语法解析器和抽象语法树(AST)对源码进行扫描后获得这些信息。
Go语言提供了 reflect 包来访问程序的反射信息。
reflect 包
Go语言中的反射是由 reflect 包提供支持的,它定义了两个重要的类型 Type 和 Value, 任意接口值在反射中都可以理解为由 reflect.Type 和 reflect.Value 两部分组成,并且 reflect 包提供了 reflect.TypeOf 和 reflect.ValueOf 两个函数来获取任意对象的 Value 和 Type。
反射的类型对象
(reflect.Type)
在Go语言程序中,使用 reflect.TypeOf() 函数可以获得任意值的类型对象(reflect.Type),程序通过类型对象可以访问任意值的类型信息,下面通过示例来理解获取类型对象的过程:
package main
import (
"fmt"
"reflect"
)
func main() {
var a int
typeOfA := reflect.TypeOf(a)
fmt.Println(typeOfA.Name(), typeOfA.Kind())
}
运行结果如下:
int int
代码说明如下:
第 9 行,定义一个 int 类型的变量。
第 10 行,通过 reflect.TypeOf() 取得变量 a 的类型对象 typeOfA,类型为 reflect.Type()。
第 11 行中,通过 typeOfA 类型对象的成员函数,可以分别获取到 typeOfA 变量的类型名为 int,种类(Kind)为 int。
反射的类型(Type)与种类(Kind)
在使用反射时,需要首先理解类型(Type)和种类(Kind)的区别。编程中,使用最多的是类型,但在反射中,当需要区分一个大品种的类型时,就会用到种类(Kind)。例如需要统一判断类型中的指针时,使用种类(Kind)信息就较为方便。
- 反射种类(Kind)的定义
Go语言程序中的类型(Type)指的是系统原生数据类型,如 int、string、bool、float32 等类型,以及使用 type 关键字定义的类型,这些类型的名称就是其类型本身的名称。例如使用 type A struct{} 定义结构体时,A 就是 struct{} 的类型。
种类(Kind)指的是对象归属的品种,在 reflect 包中有如下定义:
type Kind uint
const (
Invalid Kind = iota // 非法类型
Bool // 布尔型
Int // 有符号整型
Int8 // 有符号8位整型
Int16 // 有符号16位整型
Int32 // 有符号32位整型
Int64 // 有符号64位整型
Uint // 无符号整型
Uint8 // 无符号8位整型
Uint16 // 无符号16位整型
Uint32 // 无符号32位整型
Uint64 // 无符号64位整型
Uintptr // 指针
Float32 // 单精度浮点数
Float64 // 双精度浮点数
Complex64 // 64位复数类型
Complex128 // 128位复数类型
Array // 数组
Chan // 通道
Func // 函数
Interface // 接口
Map // 映射
Ptr // 指针
Slice // 切片
String // 字符串
Struct // 结构体
UnsafePointer // 底层指针
)
Map、Slice、Chan 属于引用类型,使用起来类似于指针,但是在种类常量定义中仍然属于独立的种类,不属于 Ptr。type A struct{} 定义的结构体属于 Struct 种类,*A 属于 Ptr。
2) 从类型对象中获取类型名称和种类
Go语言中的类型名称对应的反射获取方法是 reflect.Type 中的 Name() 方法,返回表示类型名称的字符串;类型归属的种类(Kind)使用的是 reflect.Type 中的 Kind() 方法,返回 reflect.Kind 类型的常量。
下面的代码中会对常量和结构体进行类型信息获取。
package main
import (
"fmt"
"reflect"
)
// 定义一个Enum类型
type Enum int
const (
Zero Enum = 0
)
func main() {
// 声明一个空结构体
type cat struct {
}
// 获取结构体实例的反射类型对象
typeOfCat := reflect.TypeOf(cat{})
// 显示反射类型对象的名称和种类
fmt.Println(typeOfCat.Name(), typeOfCat.Kind())
// 获取Zero常量的反射类型对象
typeOfA := reflect.TypeOf(Zero)
// 显示反射类型对象的名称和种类
fmt.Println(typeOfA.Name(), typeOfA.Kind())
}
运行结果如下:
cat struct
Enum int
代码说明如下:
第 17 行,声明结构体类型 cat。
第 20 行,将 cat 实例化,并且使用 reflect.TypeOf() 获取被实例化后的 cat 的反射类型对象。
第 22 行,输出 cat 的类型名称和种类,类型名称就是 cat,而 cat 属于一种结构体种类,因此种类为 struct。
第 24 行,Zero 是一个 Enum 类型的常量。这个 Enum 类型在第 9 行声明,第 12 行声明了常量。如没有常量也不能创建实例,通过 reflect.TypeOf() 直接获取反射类型对象。
第 26 行,输出 Zero 对应的类型对象的类型名和种类。
指针与指针指向的元素
Go语言程序中对指针获取反射对象时,可以通过 reflect.Elem() 方法获取这个指针指向的元素类型,这个获取过程被称为取元素,等效于对指针类型变量做了一个*操作,代码如下:
package main
import (
"fmt"
"reflect"
)
func main() {
// 声明一个空结构体
type cat struct {
}
// 创建cat的实例
ins := &cat{}
// 获取结构体实例的反射类型对象
typeOfCat := reflect.TypeOf(ins)
// 显示反射类型对象的名称和种类
fmt.Printf("name:'%v' kind:'%v'\n", typeOfCat.Name(), typeOfCat.Kind())
// 取类型的元素
typeOfCat = typeOfCat.Elem()
// 显示反射类型对象的名称和种类
fmt.Printf("element name: '%v', element kind: '%v'\n", typeOfCat.Name(), typeOfCat.Kind())
}
运行结果如下:
name:'' kind:'ptr'
element name: 'cat', element kind: 'struct'
代码说明如下:
第 13 行,创建了 cat 结构体的实例,ins 是一个 *cat 类型的指针变量。
第 15 行,对指针变量获取反射类型信息。
第 17 行,输出指针变量的类型名称和种类。Go语言的反射中对所有指针变量的种类都是 Ptr,但需要注意的是,指针变量的类型名称是空,不是 *cat。
第 19 行,取指针类型的元素类型,也就是 cat 类型。这个操作不可逆,不可以通过一个非指针类型获取它的指针类型。
第 21 行,输出指针变量指向元素的类型名称和种类,得到了 cat 的类型名称(cat)和种类(struct)。
使用反射获取结构体的成员类型
任意值通过 reflect.TypeOf() 获得反射对象信息后,如果它的类型是结构体,可以通过反射值对象 reflect.Type 的 NumField() 和 Field() 方法获得结构体成员的详细信息。
与成员获取相关的 reflect.Type 的方法如下表所示。
结构体成员访问的方法列表
方法 说明
Field(i int) StructField 根据索引返回索引对应的结构体字段的信息,当值不是结构体或索引超界时发生宕机
NumField() int 返回结构体成员字段数量,当类型不是结构体或索引超界时发生宕机
FieldByName(name string) (StructField, bool) 根据给定字符串返回字符串对应的结构体字段的信息,没有找到时 bool 返回 false,当类型不是结构体或索引超界时发生宕机
FieldByIndex(index []int) StructField 多层成员访问时,根据 []int 提供的每个结构体的字段索引,返回字段的信息,没有找到时返回零值。当类型不是结构体或索引超界时发生宕机
FieldByNameFunc(match func(string) bool) (StructField,bool) 根据匹配函数匹配需要的字段,当值不是结构体或索引超界时发生宕机
- 结构体字段类型
reflect.Type 的 Field() 方法返回 StructField 结构,这个结构描述结构体的成员信息,通过这个信息可以获取成员与结构体的关系,如偏移、索引、是否为匿名字段、结构体标签(StructTag)等,而且还可以通过 StructField 的 Type 字段进一步获取结构体成员的类型信息。
StructField 的结构如下:
type StructField struct {
Name string // 字段名
PkgPath string // 字段路径
Type Type // 字段反射类型对象
Tag StructTag // 字段的结构体标签
Offset uintptr // 字段在结构体中的相对偏移
Index []int // Type.FieldByIndex中的返回的索引值
Anonymous bool // 是否为匿名字段
}
字段说明如下:
Name:为字段名称。
PkgPath:字段在结构体中的路径。
Type:字段本身的反射类型对象,类型为 reflect.Type,可以进一步获取字段的类型信息。
Tag:结构体标签,为结构体字段标签的额外信息,可以单独提取。
Index:FieldByIndex 中的索引顺序。
Anonymous:表示该字段是否为匿名字段。
2) 获取成员反射信息
下面代码中,实例化一个结构体并遍历其结构体成员,再通过 reflect.Type 的 FieldByName() 方法查找结构体中指定名称的字段,直接获取其类型信息。
反射访问结构体成员类型及信息:
package main
import (
"fmt"
"reflect"
)
func main() {
// 声明一个空结构体
type cat struct {
Name string
// 带有结构体tag的字段
Type int json:"type" id:"100"
}
// 创建cat的实例
ins := cat{Name: "mimi", Type: 1}
// 获取结构体实例的反射类型对象
typeOfCat := reflect.TypeOf(ins)
// 遍历结构体所有成员
for i := 0; i < typeOfCat.NumField(); i++ {
// 获取每个成员的结构体字段类型
fieldType := typeOfCat.Field(i)
// 输出成员名和tag
fmt.Printf("name: %v tag: '%v'\n", fieldType.Name, fieldType.Tag)
}
// 通过字段名, 找到字段类型信息
if catType, ok := typeOfCat.FieldByName("Type"); ok {
// 从tag中取出需要的tag
fmt.Println(catType.Tag.Get("json"), catType.Tag.Get("id"))
}
}
代码输出如下:
name: Name tag: ''
name: Type tag: 'json:"type" id:"100"'
type 100
代码说明如下:
第 10 行,声明了带有两个成员的 cat 结构体。
第 13 行,Type 是 cat 的一个成员,这个成员类型后面带有一个以 ` 开始和结尾的字符串。这个字符串在Go语言中被称为 Tag(标签)。一般用于给字段添加自定义信息,方便其他模块根据信息进行不同功能的处理。
第 16 行,创建 cat 实例,并对两个字段赋值。结构体标签属于类型信息,无须且不能赋值。
第 18 行,获取实例的反射类型对象。
第 20 行,使用 reflect.Type 类型的 NumField() 方法获得一个结构体类型共有多少个字段。如果类型不是结构体,将会触发宕机错误。
第 22 行,reflect.Type 中的 Field() 方法和 NumField 一般都是配对使用,用来实现结构体成员的遍历操作。
第 24 行,使用 reflect.Type 的 Field() 方法返回的结构不再是 reflect.Type 而是 StructField 结构体。
第 27 行,使用 reflect.Type 的 FieldByName() 根据字段名查找结构体字段信息,catType 表示返回的结构体字段信息,类型为 StructField,ok 表示是否找到结构体字段的信息。
第 29 行中,使用 StructField 中 Tag 的 Get() 方法,根据 Tag 中的名字进行信息获取。
结构体标签(Struct Tag)
通过 reflect.Type 获取结构体成员信息 reflect.StructField 结构中的 Tag 被称为结构体标签(StructTag)。结构体标签是对结构体字段的额外信息标签。
JSON、BSON 等格式进行序列化及对象关系映射(Object Relational Mapping,简称 ORM)系统都会用到结构体标签,这些系统使用标签设定字段在处理时应该具备的特殊属性和可能发生的行为。这些信息都是静态的,无须实例化结构体,可以通过反射获取到。
- 结构体标签的格式
Tag 在结构体字段后方书写的格式如下:
key1:"value1" key2:"value2"
结构体标签由一个或多个键值对组成;键与值使用冒号分隔,值用双引号括起来;键值对之间使用一个空格分隔。
2) 从结构体标签中获取值
StructTag 拥有一些方法,可以进行 Tag 信息的解析和提取,如下所示:
func (tag StructTag) Get(key string) string:根据 Tag 中的键获取对应的值,例如key1:"value1" key2:"value2"
的 Tag 中,可以传入“key1”获得“value1”。
func (tag StructTag) Lookup(key string) (value string, ok bool):根据 Tag 中的键,查询值是否存在。
3) 结构体标签格式错误导致的问题
编写 Tag 时,必须严格遵守键值对的规则。结构体标签的解析代码的容错能力很差,一旦格式写错,编译和运行时都不会提示任何错误,示例代码如下:
package main
import (
"fmt"
"reflect"
)
func main() {
type cat struct {
Name string
Type int json: "type" id:"100"
}
typeOfCat := reflect.TypeOf(cat{})
if catType, ok := typeOfCat.FieldByName("Type"); ok {
fmt.Println(catType.Tag.Get("json"))
}
}
运行上面的代码会输出一个空字符串,并不会输出期望的 type。
代码第 11 行中,在 json: 和 "type" 之间增加了一个空格,这种写法没有遵守结构体标签的规则,因此无法通过 Tag.Get 获取到正确的 json 对应的值。这个错误在开发中非常容易被疏忽,造成难以察觉的错误。所以将第 12 行代码修改为下面的样子,则可以正常打印。
纯文本复制
type cat struct {
Name string
Type int json:"type" id:"100"
}
运行结果如下:
type
Go error
在 Go 语言中,错误处理是一种常见的机制,用于处理函数执行过程中可能出现的错误情况。错误是预期的、可处理的异常情况。Go 使用内置的 error 类型来表示错误,并且通过返回一个错误对象来指示函数是否成功执行。
一个简单的例子如下所示:
package main
import (
"errors"
"fmt"
)
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
func main() {
result, err := divide(10, 2)
if err != nil {
fmt.Println("Error:", err)
} else {
fmt.Println("Result:", result)
}
result, err = divide(10, 0)
if err != nil {
fmt.Println("Error:", err)
} else {
fmt.Println("Result:", result)
}
}
在这个例子中,divide 函数尝试进行除法运算,如果除数为零,则返回一个表示错误的 error 对象。在 main 函数中,我们调用了两次 divide 函数,分别检查并处理了可能的错误情况。
需要注意的是,Go 语言鼓励使用显式的错误处理方式,而不是依赖于隐式的异常。这样可以使代码更加可读和可维护。
接口
接口是每一个语言都有的概念,接口是一种约束形式,其中只包括成员函数定义,不包含成员函数实现,一个对象通过实现不同的接口,可以灵活地完成很多任务。
Go语言接口概述
底层实现
接口又称为动态数据类型,在进行接口使用的的时候,会将接口所在位置的动态类型改为所指向的类型,会将动态值改成所指向类型的结构体
定义一个Go语言接口
接口名为Phone,接口内有speak与read方法
type Phone interface {
speak()
read()
}
实战应用(利用接口实现多态)
定义一个接口Phone,使用不同的结构体对接口进行实现,然后利用接口对象作为形式参数,将不同类型的对象
传入并调用相关的函数,实现多态
//定义一个接口
type Phone interface {
speak()
read()
}
//以下结构体可以分别设置自己的属性
type IPhone struct {
name string
}
type Oppo struct {
id int
}
type Mi struct {
f bool
}
// 手机讲话区域(对相关的接口进行实现)
func (a IPhone) speak() {
fmt.Println("我叫sir,您好!")
}
func (a Oppo) speak() {
fmt.Println("我是oppo小精灵!")
}
func (a Mi) speak() {
fmt.Println("大家好,我是小爱童鞋!")
}
// 中央广场展示大舞台(可以直接将不同的实现接口的结构体对象传进来)
func show(myPhone Phone) {
myPhone.speak()
}
func main() {
// 将新建对象传入展示大舞台,大舞台代码不变,展示不同的效果
show(new(IPhone))
}
实现接口两种方式的区别
- 值接受者
使用值接收者,可以将值、指针传进去
但是没有办法修改接受者本身的属性,类似于交换两个值时的值传递
实战代码如下(示例):
//定义接口
type Phone interface {
speak()
read()
}
//定义结构体类型,作为接受者
type IPhone struct {
name string
}
//对特定的结构体进行接口的实现
func (a IPhone) speak() {
fmt.Println("我叫sir,您好!")
}
- 指针接受者
使用指针接收者,必须传地址
可以修改指向对象的属性
代码如下(示例):
//定义接口
type Phone interface {
speak()
read()
}
//定义结构体类型,作为接受者
type IPhone struct {
name string
}
//对特定的结构体进行接口的实现
func (a *IPhone) speak() {
fmt.Println("我叫sir,您好!")
}
在 Go 语言中,接口可以使用值接受者(Value Receiver)或指针接受者(Pointer Receiver)来实现。这两者之间有一些重要的区别,特别是在方法的实现和调用时。让我为您解释一下它们之间的不同之处。
值接受者
(Value Receiver):
当一个类型使用值接受者实现一个接口方法时,它会将该类型的实例的副本传递给方法。这意味着方法操作的是对象的拷贝,不会影响原始对象。值接受者通常用于不需要修改接收者状态的情况,或者在值的复制开销相对较低的情况下。
示例:
type Printer interface {
Print()
}
type MyStruct struct {
Data string
}
// 使用值接受者实现接口方法
func (m MyStruct) Print() {
fmt.Println(m.Data)
}
func main() {
instance := MyStruct{Data: "Hello"}
var printer Printer
printer = instance // 可以将值类型赋值给接口
printer.Print() // 输出: Hello
}
指针接受者
(Pointer Receiver):
当一个类型使用指针接受者实现一个接口方法时,它会将该类型的指针传递给方法。这意味着方法操作的是原始对象,可以修改对象的状态。指针接受者通常用于需要修改接收者状态的情况,或者在值的复制开销相对较高的情况下。
示例:
)
type Modifier interface {
Modify()
}
type MyStruct struct {
Data string
}
// 使用指针接受者实现接口方法
func (m *MyStruct) Modify() {
m.Data = "Modified"
}
func main() {
instance := &MyStruct{Data: "Original"}
var modifier Modifier
modifier = instance // 可以将指针类型赋值给接口
modifier.Modify()
fmt.Println(instance.Data) // 输出: Modified
}
在选择值接受者还是指针接受者时,您应该考虑对象是否需要被修改,以及性能和内存的因素。一般来说:
如果方法需要修改接收者的状态,或者接收者是较大的数据结构,那么使用指针接受者可能更合适。
如果方法不需要修改接收者的状态,或者接收者是较小的数据结构,那么使用值接受者可能更合适。
需要注意的是,一个类型只能使用值接受者或指针接受者中的一种来实现一个接口方法,不能同时实现两种。这是由 Go 语言的方法集规则所决定的。
类型断言
根据传进来的对象不同,判断其类型
- 什么是空接口?
没有任何方法的接口就是空接口,实际上每个类型都实现了空接口,所以空接口类型可以接受任何类型的数据
代码如下(示例):
//定义一个空接口
type phone interface{}
//空接口作为参数,传进来任意类型参数判断其类型与打印其值
func showmpType(q interface{}) {
fmt.Printf("type:%T,value:%v\n", q, q)
}
- 类型断言
根据变量不同的类型进行不同的操作- 类型断言方法一
有两个返回值,如果断言成功第一个返回值为该变量对应的数据,否则返回该类型的空值,第二个参数是一个布尔值如果断言成功则返回的是一个true,否则返回false,代码如下(示例):
func judgeType1(q interface{}) { temp, ok := q.(string) if ok { fmt.Println("类型转换成功!", temp) } else { fmt.Println("类型转换失败!", temp) } }
- 类型断言方法二
使用switch...case...语句,如果断言成功则到指定分支,代码如下(示例):
func judgeType2(q interface{}) { switch i := q.(type) { case string: fmt.Println("这是一个字符串!", i) case int: fmt.Println("这是一个整数!", i) case bool: fmt.Println("这是一个布尔类型!", i) default: fmt.Println("未知类型", i) } }
- 类型断言方法一
接口的嵌套
接口可以进行嵌套实现,通过大接口包含小接口
代码如下(示例):
// 接口类型可以进行嵌套
//定义总接口
type animal interface {
peo
dog
cat
}
//定义包含的接口
type peo interface {
}
type dog interface {
}
type cat interface {
}
总结
接口使用较为灵活,可以在实现的接口内进行本类型对象的操作,在接口外部进行接口方法调用,实现相同的代码段有不同的效果,多态的思想也尤为重要,灵活使用接口,使程序更加灵活是每一名程序员的愿望。在以后的学习中接口使用次数最多,尤为重要,希望能够完全掌握。
逃逸分析
逃逸分析要解决的问题
(escape analysis)
[C/C++语言出身的程序员]对堆内存(heap)和栈内存(stack)都有着“泾渭分明”的理解。在操作系统演化出现进程虚拟内存地址(virtual memory address)的概念后,如下图所示,应用程序的虚拟内存地址空间就被划分为堆内存区(如图中的heap)和栈内存区(如图中的stack):
在x86平台linux操作系统下,如上图,一般将栈内存区放在高地址,栈向下延伸;而堆内存去放在低地址,堆向上延伸,这样做的好处就是便于堆和栈可动态共享那段内存区域。
这是否意味着所有分配在堆内存区域的内存对象地址一定比分配在栈内存区域的内存对象地址要小呢?在C/C++中是这样的,但是在Go语言中,这是不一定的,因为[go堆内存所使用的内存页(page)与goroutine的栈所使用的内存页是交织在一起的]。
无论是栈内存还是堆内存,对于应用而言都是合法可用的内存地址空间。之所以将其区分开,是因为应用程序的内存分配和管理的需要。
栈内存上的对象的存储空间是自动分配和销毁的,无需开发人员或编程语言运行时过多参与,比如下面的这段C代码(用C代码更能体现栈内存与堆内存的差别):
#include <stdio.h>
void bar() {
int e = 31;
int f = 32;
printf("e = %d\n", e);
printf("f = %d\n", f);
}
void foo() {
int c = 21;
int d = 22;
printf("c = %d\n", c);
printf("d = %d\n", d);
}
int main() {
int a = 11;
int b = 12;
printf("a = %d\n", a);
printf("b = %d\n", b);
foo();
bar();
}
上面这段c程序算上main函数共有三个函数,每个函数中都有两个整型变量,C编译器自动为这些变量在栈内存上分配空间,
我们无需考虑它什么时候被创建以及何时被销毁,我们只需在特定的作用域(其所在函数内部)使用它即可,而无需担心其内存地址不合法,因此这些被分配在栈内存上的变量也被称为自动变量。
但是如果将其地址返回到函数的外部,那么函数外部的代码通过解引用而访问这些变量时便会出错,如下面示例
#include<stdio.h>
int *foo(){
int c = 11;
return &c;
}
int main(){
int *p = foo();
printf("the return value of foo = %d\n", *p);
}
如代码所示,在上面这个例子中,我们将foo函数内的自动变量c的地址通过函数返回值返回给foo函数的调用者(main)了,这样当我们在main函数中引用该地址输出该变量值的时候,我们就会收到异常,比如在ubuntu上运行上述程序,我们会得到如下结果(在macos上运行,gcc会给出相同的警告,但程序运行不会dump core):
gcc cstack_dumpcore.c cstack_dumpcore.c: In function ‘foo’: cstack_dumpcore.c:5:12: warning: function returns address of local variable [-Wreturn-local-addr] return &c; ^~ # ./a.out Segmentation fault (core dumped)
这样一来我们就需要一种内存对象,可以在全局(跨函数间)合法使用,这就是堆内存对象。但是和位于栈上的内存对象由程序自行创建销毁不同,堆内存对象需要通过专用API手工分配和释放,在C中对应的分配和释放方法就是malloc和free:
#include <stdio.h>
#include <stdlib.h>
int *foo() {
int *c = malloc(sizeof(int));
*c = 12;
return c;
}
int main() {
int *p = foo();
printf("the return value of foo = %d\n", *p);
free(p);
}
在这个示例中我们使用malloc在foo函数中分配了一个堆内存对象,并将该对象返回给main函数,main函数使用完该对象后调用了free函数手工释放了该堆内存块。
显然和自动变量相比,堆内存对象的生命周期管理将会给开发人员带来很大的心智负担。为了降低这方面的心智负担,带有GC(垃圾回收)的编程语言出现了,比如Java、Go等。这些带有GC的编程语言会对位于堆上的对象进行自动管理。当某个对象不可达时(即没有其对象引用它时),它将会被回收并被重用。
但GC的出现虽然降低了开发人员在内存管理方面的心智负担,但GC不是免费的,它给程序带来的性能损耗是不可忽视的,尤其是当堆内存上有大量待扫描的堆内存对象时,将会给GC带来过大的压力,从而使得GC占用更多本应用于处理业务逻辑的计算和存储资源。于是人们开始想方法尽量减少在堆上的内存分配,可以在栈上分配的变量尽量留在栈上。
逃逸分析(escape analysis)就是在程序编译阶段根据程序代码中的数据流,对代码中哪些变量需要在栈上分配,哪些变量需要在堆上分配进行静态分析的方法。一个理想的逃逸分析算法自然是能将那些人们认为需要分配在栈上的变量尽可能保留在栈上,尽可能少的“逃逸”到堆上的算法。但这太过理想,各种语言都有自己的特殊情况,各种语言的逃逸算法的精确度实际都会受到这方面的影响。
Go语言的逃逸分析
Go从诞生那天起,逃逸分析就始终伴随其左右。正如上面说到的逃逸分析的目标,Go编译器使用逃逸分析来决定哪些变量应该在goroutine的栈上分配,哪些变量应该在堆上分配。
截至目前,Go一共有两个版本的逃逸分析实现,分水岭在[Go 1.13版本]。Go 1.13版本之前是Go逃逸分析的第一版实现,位于Go源码的src/cmd/compile/internal/gc/esc.go中(以go 1.12.7版本为例),代码规模2400多行;Go 1.13版本中加入了由Matthew Dempsky重写的第二版逃逸分析,并默认开启,可以通过-gcflags="-m -newescape=false"恢复到使用第一版逃逸分析。之所以重写,主要是考虑第一版代码的可读性和可维护性问题,新版代码主要位于Go项目源码的src/cmd/compile/internal/gc/escape.go中,它将逃逸分析代码从上一版的2400多行缩减为1600多行,并作了更为完整文档和注释。但注意的是新版代码在算法精确性上并没有质的变化。
但即便如此,经过了这么多年的“修修补补”,Dmitry Vyukov 2015年提出的那些“Go Escape Analysis Flaws”多数已经fix了。Go项目中内置了对逃逸分析的详尽的测试代码(位于Go项目下的test/escape*.go文件中)。
在新版逃逸分析实现的注释中($GOROOT/src/cmd/compile/internal/gc/escape.go),我们可以大致了解逃逸分析的实现原理。注释中的原理说明中提到了算法基于的两个不变性:
指向栈对象的指针不能存储在堆中(pointers to stack objects cannot be stored in the heap);
指向栈对象的指针不能超过该栈对象的存活期(即指针不能在栈对象被销毁后依旧存活)(pointers to a stack object cannot outlive that object)。
源码注释中也给出Go逃逸分析的大致原理和过程。Go逃逸分析的输入是Go编译器解析了Go源文件后所获得的整个程序的抽象语法树(Abstract syntax tree,AST):
源码解析后得到的代码AST的Node切片为xtop:
// $GOROOT/src/cmd/compile/internal/gc/go.go var xtop []*Node
在Main函数中,xtop被传入逃逸分析的入口函数escapes:
// $GOROOT/src/cmd/compile/internal/gc/main.go // Main parses flags and Go source files specified in the command-line // arguments, type-checks the parsed Go package, compiles functions to machine // code, and finally writes the compiled package definition to disk. func Main(archInit func(*Arch)) { ... ... // Phase 6: Escape analysis. // Required for moving heap allocations onto stack, // which in turn is required by the closure implementation, // which stores the addresses of stack variables into the closure. // If the closure does not escape, it needs to be on the stack // or else the stack copier will not update it. // Large values are also moved off stack in escape analysis; // because large values may contain pointers, it must happen early. timings.Start("fe", "escapes") escapes(xtop) ... ... }
下面是escapes函数的实现:
// $GOROOT/src/cmd/compile/internal/gc/esc.go func escapes(all []Node) { visitBottomUp(all, escapeFuncs) } // $GOROOT/src/cmd/compile/internal/gc/scc.go // 强连接node - 一个数据结构 func visitBottomUp(list []Node, analyze func(list []Node, recursive bool)) { var v bottomUpVisitor v.analyze = analyze v.nodeID = make(map[Node]uint32) for _, n := range list { if n.Op == ODCLFUNC && !n.Func.IsHiddenClosure() { v.visit(n) } } } // $GOROOT/src/cmd/compile/internal/gc/escape.go // escapeFuncs performs escape analysis on a minimal batch of // functions. func escapeFuncs(fns []*Node, recursive bool) { for _, fn := range fns { if fn.Op != ODCLFUNC { Fatalf("unexpected node: %v", fn) } } var e Escape e.heapLoc.escapes = true // Construct data-flow graph from syntax trees. for _, fn := range fns { e.initFunc(fn) } for _, fn := range fns { e.walkFunc(fn) } e.curfn = nil e.walkAll() e.finish(fns) }
根据注释,escapes的大致原理是(直译):
首先,构建一个有向加权图,其中顶点(称为"location",由gc.EscLocation表示)代表由语句和表达式分配的变量,而边(gc.EscEdge)代表变量之间的赋值(权重代表寻址/取地址次数)。
接下来,遍历(visitBottomUp)该有向加权图,在图中寻找可能违反上述两个不变量条件的赋值路径。 违反上述不变量的赋值路径。如果一个变量v的地址是储存在堆或其他可能会超过它的存活期的地方,那么v就会被标记为需要在堆上分配。
为了支持函数间的分析,算法还记录了从每个函数的参数到堆的数据流以及到其结果的数据流。算法将这些信息称为“参数标签(parameter tag)”。这些标签信息在静态调用时使用,以改善对函数参数的逃逸分析。
当然即便看到这,你可能依旧一头雾水,没关系,这里不是讲解逃逸分析原理,如果想了解原理,那就请认真阅读那2400多行代码。
注:有一点需要明确,那就是静态逃逸分析也无法确定的对象会被放置在堆上,后续精确的GC会处理这些对象,这样最大程度保证了代码的安全性。
- Go逃逸分析的示例
Go工具链提供了查看逃逸分析过程的方法,我们可以通过在-gcflags中使用-m来让Go编译器输出逃逸分析的过程,下面是一些典型的示例。
- 简单原生类型变量的逃逸分析
我们来看一个原生整型变量的逃逸分析过程,下面是示例的代码:
// github.com/bigwhite/experiments/blob/master/go-escape-analysis/go/int.go 1 package main 2 3 import "testing" 4 5 func foo() { 6 a := 11 7 p := new(int) 8 p = 12 9 println("addr of a is", &a) 10 println("addr that p point to is", p) 11 } 12 13 func bar() (int, *int) { 14 m := 21 15 n := 22 16 println("addr of m is", &m) 17 println("addr of n is", &n) 18 return &m, &n 19 } 20 21 func main() { 22 println(int(testing.AllocsPerRun(1, foo))) 23 println(int(testing.AllocsPerRun(1, func() { 24 bar() 25 }))) 26 }
我们通过-gcflags "-m -l"来执行逃逸分析,之所以传入-l是为了关闭inline,屏蔽掉inline对这个过程以及最终代码生成的影响:
// go 1.16版本 on MacOS $go build -gcflags "-m -l" int.go # command-line-arguments ./int.go:7:10: new(int) does not escape ./int.go:14:2: moved to heap: m ./int.go:15:2: moved to heap: n ./int.go:23:38: func literal does not escape
逃逸分析的结果与我们手工分析的一致:函数bar中的m、n逃逸到heap(对应上面输出的有moved to heap: xx字样的行),这两个变量将在heap上被分配存储空间。而函数foo中的a以及指针p指向的内存块都在栈上分配(即便我们是调用的new创建的int对象,Go中new出来的对象可不一定分配在堆上,逃逸分析的输出日志中还专门提及new(int)没有逃逸)。我们执行一下该示例(执行时同样传入-l关闭inline):
$go run -gcflags "-l" int.go addr of a is 0xc000074860 addr that p point to is 0xc000074868 addr of a is 0xc000074860 addr that p point to is 0xc000074868 0 addr of m is 0xc0000160e0 addr of n is 0xc0000160e8 addr of m is 0xc0000160f0 addr of n is 0xc0000160f8 2
首先,我们看到未逃逸的a和p指向的内存块的地址区域在0xc0000748600xc000074868;而逃逸的m和n被分配到了堆内存空间,从输出的结果来看在0xc0000160e00xc0000160e8。我们可以明显看到这是两块不同的内存地址空间;另外通过testing包的AllocsPerRun的输出,我们同样印证了函数bar中执行了两次堆内存分配动作。
我们再来看看这个代码对应的汇编代码:
$go tool compile -S int.go |grep new 0x002c 00044 (int.go:14) CALL runtime.newobject(SB) 0x004d 00077 (int.go:15) CALL runtime.newobject(SB) rel 45+4 t=8 runtime.newobject+0 rel 78+4 t=8 runtime.newobject+0
我们看到在对应源码的14和15行,汇编调用了runtime.newobject在堆上执行了内存分配动作,这恰是逃逸的m和n声明的位置。从下面newobject代码的实现我们也能看到,它实际上在gc管理的内存上执行了malloc动作:
// $GOROOT/src/runtime/malloc.go // implementation of new builtin // compiler (both frontend and SSA backend) knows the signature // of this function func newobject(typ *_type) unsafe.Pointer { return mallocgc(typ.size, typ, true) }
2) 切片变量自身和切片元素的逃逸分析
了解过切片实现原理的gopher都知道,切片变量实质上是一个三元组:
//$GOROOT/src/runtime/slice.go type slice struct { array unsafe.Pointer len int cap int }
其中这个三元组的第一个字段array指向的是切片底层真正存储元素的指针。这样当为一个切片变量分配内存时,便既要考虑切片本身(即上面的slice结构体)在哪里分配,也要考虑切片元素的存储在哪里分配。我们看下面示例:
// github.com/bigwhite/experiments/blob/master/go-escape-analysis/go/slice.go 1 package main 2 3 import ( 4 "reflect" 5 "unsafe" 6 ) 7 8 func noEscapeSliceWithDataInHeap() { 9 var sl []int 10 println("addr of local(noescape, data in heap) slice = ", &sl) 11 printSliceHeader(&sl) 12 sl = append(sl, 1) 13 println("append 1") 14 printSliceHeader(&sl) 15 println("append 2") 16 sl = append(sl, 2) 17 printSliceHeader(&sl) 18 println("append 3") 19 sl = append(sl, 3) 20 printSliceHeader(&sl) 21 println("append 4") 22 sl = append(sl, 4) 23 printSliceHeader(&sl) 24 } 25 26 func noEscapeSliceWithDataInStack() { 27 var sl = make([]int, 0, 8) 28 println("addr of local(noescape, data in stack) slice = ", &sl) 29 printSliceHeader(&sl) 30 sl = append(sl, 1) 31 println("append 1") 32 printSliceHeader(&sl) 33 sl = append(sl, 2) 34 println("append 2") 35 printSliceHeader(&sl) 36 } 37 38 func escapeSlice() *[]int { 39 var sl = make([]int, 0, 8) 40 println("addr of local(escape) slice = ", &sl) 41 printSliceHeader(&sl) 42 sl = append(sl, 1) 43 println("append 1") 44 printSliceHeader(&sl) 45 sl = append(sl, 2) 46 println("append 2") 47 printSliceHeader(&sl) 48 return &sl 49 } 50 51 func printSliceHeader(p []int) { 52 ph := (reflect.SliceHeader)(unsafe.Pointer(p)) 53 println("slice data =", unsafe.Pointer(ph.Data)) 54 } 55 56 func main() { 57 noEscapeSliceWithDataInHeap() 58 noEscapeSliceWithDataInStack() 59 escapeSlice() 60 }
对上述示例运行逃逸分析:
$go build -gcflags "-m -l" slice.go # command-line-arguments ./slice.go:51:23: p does not escape ./slice.go:27:15: make([]int, 0, 8) does not escape ./slice.go:39:6: moved to heap: sl ./slice.go:39:15: make([]int, 0, 8) escapes to heap
我们从输出的信息中看到:
位于39行的escapeSlice函数中的sl逃逸到堆上了;
位于39行的escapeSlice函数中的切片sl的元素也逃逸到堆上了;
位于27行的切片sl的元素没有逃逸。
由于很难看到三个函数中各个切片的元素是否逃逸,我们通过运行该示例来看一下:
$go run -gcflags " -l" slice.go addr of local(noescape, data in heap) slice = 0xc00006af48 slice data = 0x0 append 1 slice data = 0xc0000160c0 append 2 slice data = 0xc0000160d0 append 3 slice data = 0xc0000140c0 append 4 slice data = 0xc0000140c0 addr of local(noescape, data in stack) slice = 0xc00006af48 slice data = 0xc00006af08 append 1 slice data = 0xc00006af08 append 2 slice data = 0xc00006af08 addr of local(escape) slice = 0xc00000c030 slice data = 0xc00001a100 append 1 slice data = 0xc00001a100 append 2 slice data = 0xc00001a100
注:我们利用reflect包的SliceHeader输出切片三元组中的代表底层数组地址的字段,这里是slice data。
我们看到:
第一个函数noEscapeWithDataInHeap声明了一个空slice,并在后面使用append向切片附加元素。从输出结果来看,slice自身是分配在栈上的,但是运行时在动态扩展切片时,选择了将其元素存储在heap上;
第二个函数noEscapeWithDataInStack直接初始化了一个包含8个元素存储空间的切片,切片自身没有逃逸,并且在附加(append)的元素个数小于等于8个的时候,元素直接使用了为其分配的栈空间;但如果附加的元素超过8个,那么运行时会在堆上分配一个更大的空间并将原栈上的8个元素复制过去,后续该切片的元素就都存储在了堆上。这也是为什么强烈建议在创建 slice 时带上预估的cap参数的原因,不仅减少了堆内存的频繁分配,在切片变量未逃逸的情况下,在cap容量之下,所有元素都分配在栈上,这将提升运行性能。
第三个函数escapeSlice则是切片变量自身以及其元素的存储都在堆上。
- fmt.Printf系列函数让变量逃逸到堆(heap)上了?
很多人在go项目的issue中反馈fmt.Printf系列函数让变量逃逸到堆上了,情况真的是这样么?我们通过下面示例来看一下:
// github.com/bigwhite/experiments/blob/master/go-escape-analysis/go/printf1.go 1 package main 2 3 import "fmt" 4 5 func foo() { 6 var a int = 66666666 7 var b int = 77 8 fmt.Printf("a = %d\n", a) 9 println("addr of a in foo =", &a) 10 println("addr of b in foo =", &b) 11 } 12 13 func main() { 14 foo() 15 }
注:println和print两个预定义函数并没有像fmt.Printf系列函数的“副作用”,不会影响变量的逃逸性。所以这里使用println来输出变量的实际分配内存地址。
对上面的代码运行逃逸分析:
$go build -gcflags "-m -l" printf1.go # command-line-arguments ./printf1.go:8:12: ... argument does not escape ./printf1.go:8:13: a escapes to heap
我们看到逃逸分析输出第8行的变量“a escapes to heap”,不过这个“逃逸”有些奇怪,因为按照之前的经验,如果某个变量真实逃逸了,那么逃逸分析会在其声明的那行输出:“moved to heap: xx”字样。而上面这个输出既不是在变量声明的那一行,也没有输出“moved to heap: a”字样,变量a真的逃逸了么?我们运行一下上面示例,看看变量a的地址究竟是在堆上还是栈上:
$go run -gcflags "-l" printf1.go a = 66666666 addr of a in foo = 0xc000092f50 addr of b in foo = 0xc000092f48
我们看到变量a的地址与未逃逸的变量b的地址都在同一个栈空间,变量a并未逃逸!如果你反编译为汇编,你肯定也看不到runtime.newobject的调用。
那么“./printf1.go:8:13: a escapes to heap”这句的含义究竟是什么呢?显然逃逸分析在这一行是对进入fmt.Printf的数据流的分析,我们修改一下go标准库源码,然后build -a重新编译一下printf1.go,看看在fmt.Printf内部变量的分布情况:
// $GOROOT/src/fmt/print.go func Printf(format string, a ...interface{}) (n int, err error) { // 添加下面四行代码 for i := 0; i < len(a); i++ { println(a[i]) println(&a[i]) } return Fprintf(os.Stdout, format, a...) }
重新编译printf1.go并运行编译后的可执行文件(为了避免):
$go build -a -gcflags "-l" printf1.go $./printf1 (0x10af200,0xc0000160c8) 0xc00006cf58 a = 66666666 addr of a in foo = 0xc00006cf50 addr of b in foo = 0xc00006cf48
我们看到fmt.Printf的实参a在传入后被装箱到一个interface{}类型的形参变量中,而这个形参变量自身则是被分配在栈上的(0xc00006cf58),而通过println输出的该interface{}类型形参变量的类型部分和值部分分别指向0x10af200和0xc0000160c8。显然值部分是在堆内存上分配的。那么“./printf1.go:8:13: a escapes to heap”是否指的是装箱后的值部分在堆上分配呢?这里也不确定。
我们再来看一个例子来对比一下:
// github.com/bigwhite/experiments/blob/master/go-escape-analysis/go/printf2.go 1 package main 2 3 import "fmt" 4 5 func foo() { 6 var a int = 66666666 7 var b int = 77 8 fmt.Printf("addr of a in bar = %p\n", &a) 9 println("addr of a in bar =", &a) 10 println("addr of b in bar =", &b) 11 } 12 13 func main() { 14 foo() 15 }
在printf2.go这个例子中,与printf1.go不同的是我们在foo函数中使用fmt.Printf输出的是变量a的地址:&a。我们运行一下新版逃逸分析:
// go 1.16 $go build -gcflags "-m -l" printf2.go # command-line-arguments ./printf2.go:6:6: moved to heap: a ./printf2.go:8:12: ... argument does not escape
我们看到位于第6行声明的变量a居然真的逃逸到了堆上。我们运行一下printf2.go:
$go build -a -gcflags "-l" printf2.go $./printf2 (0x10ab4a0,0xc0000160c8) 0xc00006cf58 addr of a in bar = 0xc0000160c8 addr of a in bar = 0xc0000160c8 addr of b in bar = 0xc00006cf48
我们看到变量a的地址果然与位于栈上的变量b相差很大,应该就是在堆上,那么这样看那些在go项目中提issue的gopher所言不虚。变量a的地址以实参的形式传入fmt.Printf后被装箱到一个interface{}形参变量中,而从结果来看,fmt.Printf真的要求装箱的形参变量的值部分要在堆上分配,但根据逃逸分析不变性,堆上的对象不能存储一个栈上的地址,而这次存储的是a的地址,于是将a判定为逃逸,于是a自身也就被分配到了堆上(0xc0000160c8)。
我们用go 1.12.7运行一下老版的逃逸分析:
// go 1.12.7 $go build -gcflags "-m -l" printf2.go # command-line-arguments ./printf2.go:8:40: &a escapes to heap ./printf2.go:8:40: &a escapes to heap ./printf2.go:6:6: moved to heap: a ./printf2.go:8:12: foo ... argument does not escape ./printf2.go:9:32: foo &a does not escape ./printf2.go:10:32: foo &b does not escape
老版的逃逸分析给出了更详细的输出,比如:“&a escapes to heap”,其所指想必就是&a被装箱到堆内存上;而println输出&a则无需&a被装箱。但此后对变量a的最终判定为逃逸。
Go核心团队成员Keith Randall对逃逸分析输出的日志给过一个解释,大致意思是:当逃逸分析输出“b escapes to heap”时,意思是指存储在b中的值逃逸到堆上了(当b为指针变量时才有意义),即任何被b引用的对象必须分配在堆上,而b自身则不需要;如果b自身也逃逸到堆上,那么逃逸分析会输出“&b escapes to heap”。
这个问题目前已经没有fix,其核心问题在8618这个issue中。
- 手动强制避免逃逸
对于printf2.go中的例子,我们确定一定以及肯定:a不需要逃逸。但若使用fmt.Printf,我们无法阻拦a的逃逸。那是否有一种方法可以干扰逃逸分析,使逃逸分析认为需要在堆上分配的内存对象而我们确定认为不需要逃逸的对象避免逃逸呢?在Go运行时代码中,我们发现了一个函数:
// $GOROOT/src/runtime/stubs.go func noescape(p unsafe.Pointer) unsafe.Pointer { x := uintptr(p) return unsafe.Pointer(x ^ 0) // 任何数值与0的异或都是原数 }
并且在Go标准库和运行时实现中,该函数得到大量使用。该函数的实现逻辑使得我们传入的指针值与其返回的指针值是一样的。该函数只是通过uintptr做了一次转换,而这次转换将指针转换成了数值,这“切断”了逃逸分析的数据流跟踪,导致传入的指针避免逃逸。
我们看一下下面例子:
// github.com/bigwhite/experiments/blob/master/go-escape-analysis/go/printf3.go package main import ( "fmt" "unsafe" ) func noescape(p unsafe.Pointer) unsafe.Pointer { x := uintptr(p) return unsafe.Pointer(x ^ 0) } func foo() { var a int = 66666666 var b int = 77 fmt.Printf("addr of a in bar = %p\n", (*int)(noescape(unsafe.Pointer(&a)))) println("addr of a in bar =", &a) println("addr of b in bar =", &b) } func main() { foo() }
对该代码实施统一分析:
$go build -gcflags "-m -l" printf3.go # command-line-arguments ./printf3.go:8:15: p does not escape ./printf3.go:16:12: ... argument does not escape
我们看到a这次没有逃逸。运行一下编译后的可执行文件:
$./printf3 (0x10ab4c0,0xc00009af50) 0xc00009af58 addr of a in bar = 0xc00009af50 addr of a in bar = 0xc00009af50 addr of b in bar = 0xc00009af48
我们看到a没有像printf2.go那样被放在堆上,这次和b一样都是在栈上分配的。并且在fmt.Printf执行的过程中a的栈地址始终是有效的。
曾有一篇[通过逃逸分析优化性能的论文]《Escape from Escape Analysis of Golang》使用的就是上述noescape函数的思路,有兴趣的童鞋可以自行下载阅读。
- 小结
通过这篇文章,我们了解到了逃逸分析要解决的问题、Go逃逸分析的现状与简单原理、一些Go逃逸分析的实例以及对逃逸分析输出日志的说明。最后,我们给出一个强制避开逃逸分析的方案,但要谨慎使用。
日常go开发过程,绝大多数情况无需考虑逃逸分析,除非性能敏感的领域。在这些领域,对系统执行热点路径做一次逃逸分析以及相应的优化,可能回带来程序性能的一定提升。
init()
init
函数是 Go 语言中的一种特殊函数,用于在程序启动时进行初始化操作。每个 Go 包(package)可以包含一个或多个 init
函数,这些函数将在程序执行时自动调用,无需显式调用。init
函数的主要特点如下:
-
自动执行:
init
函数会在程序启动时自动执行,无需手动调用。每个包中的init
函数都会在包被导入时按照导入顺序依次执行。 -
无参数和返回值:
init
函数没有参数,也没有返回值。它的声明形式如下:func init() { // 初始化操作 }
-
执行顺序:在一个包内,可以包含多个
init
函数。这些函数的执行顺序是按照它们在文件中的出现顺序从上到下依次执行。 -
用途:
init
函数常用于执行包级别的初始化操作,例如初始化全局变量、配置初始化、注册函数、加载配置文件等。它们可以用于确保在程序运行时的早期阶段执行必要的设置。 -
可以有多个:一个包可以包含多个
init
函数,它们都会被执行。这有助于将初始化操作组织成逻辑上的块。 -
不可显式调用:由于
init
函数是自动执行的,不能在代码中显式调用。它们的执行是 Go 运行时系统自动处理的一部分。
示例:
package mypackage
import (
"fmt"
)
var globalVar int
func init() {
fmt.Println("Initializing mypackage")
globalVar = 42
}
func SomeFunction() {
fmt.Println("Executing SomeFunction")
fmt.Println("GlobalVar:", globalVar)
}
在这个示例中,init
函数用于初始化 globalVar
变量,它将在程序启动时自动执行。其他函数可以访问 globalVar
并使用它。当程序导入 mypackage
时,init
函数将被执行。
总之,init
函数在 Go 中用于执行包级别的初始化操作,它们在程序启动时自动执行,无需手动调用,并且可以有多个 init
函数。这些函数通常用于进行全局配置和初始化,以确保程序在运行时的早期阶段处于正确的状态。
go的init函数在main函数之前执行,它有如下特点:
func init() {
...
}
init函数非常特殊:
初始化不能采用初始化表达式初始化的变量;
程序运行前执行注册
实现sync.Once功能
不能被其它函数调用
init函数没有入口参数和返回值:
每个包可以有多个init函数,每个源文件也可以有多个init函数。
同一个包的init执行顺序,golang没有明确定义,编程时要注意程序不要依赖这个执行顺序。
不同包的init函数按照包导入的依赖关系决定执行顺序。
new()
在Go语言中,new
是一个内置函数,用于动态分配并返回一个新的零值(zero value)的指针。new
函数的用法非常简单,它接受一个参数,该参数是一个类型(type),并返回该类型的指针。下面是new
函数的基本用法和示例:
new(Type)
其中,Type
是你想要分配的类型,例如,int
、float64
、struct
、slice
等。
示例 1:使用 new
分配一个整数的指针:
var x *int
x = new(int)
*x = 42
fmt.Println(*x) // 输出:42
在这个示例中,我们首先声明一个指向整数的指针 x
,然后使用 new
函数分配了一个新的整数,并将其地址分配给 x
,最后我们可以通过 x
指针来设置和访问这个整数的值。
示例 2:使用 new
分配一个结构体的指针:
type Person struct {
Name string
Age int
}
var p *Person
p = new(Person)
p.Name = "Alice"
p.Age = 30
fmt.Println(*p) // 输出:{Alice 30}
在这个示例中,我们声明了一个指向 Person
结构体的指针 p
,然后使用 new
函数分配了一个新的 Person
结构体,并将其地址分配给 p
。然后,我们可以通过 p
指针来设置和访问结构体字段的值。
需要注意的是,new
函数返回的指针指向了一个零值,这意味着基本类型的零值(例如 0
,false
,""
等),或者结构体的零值(结构体字段会被初始化为其类型的零值)。如果你希望为新分配的对象进行初始化,你需要手动设置其字段或调用构造函数等。
panic
在 Go 语言中,panic
是一种运行时错误或异常的机制。当程序遇到无法处理的错误或不一致的情况时,可以使用 panic
来引发一个运行时异常。当 panic
发生时,程序会立即停止当前的执行流程,并开始执行一系列延迟(defer
)函数的调用,然后程序会终止。
下面是有关 Go 语言中 panic
的一些重要信息:
-
引发
panic
:你可以在代码中使用panic
函数来引发一个运行时异常。例如:panic("Something went wrong")
这将引发一个带有指定错误消息的
panic
。 -
recover
函数:为了捕获并处理panic
,可以在defer
语句中使用recover
函数。recover
可以捕获并恢复panic
,允许程序继续执行而不会终止。一般的用法如下:defer func() { if r := recover(); r != nil { // 处理 panic,例如记录错误信息或执行恢复操作 } }()
这样,如果在
defer
函数中发生了panic
,recover
将捕获该panic
并执行指定的处理代码。 -
通常情况下,
panic
用于表示程序遇到了无法继续执行的严重错误,例如空指针引用、数组越界等。在正常的错误处理中,应该使用错误值(error
)而不是panic
。 -
panic
会立即终止当前的执行流程,所以要小心在程序中使用它,避免在不必要的情况下引发panic
。
总之,panic
是 Go 语言中用于处理严重错误的机制,但应谨慎使用,并且通常应该使用错误值来处理程序中的常规错误。
在 Go 语言中,可以使用 recover
函数来捕获并处理 panic,以使程序在 panic 后继续执行而不会立即终止。recover
函数应该在延迟(defer)函数内调用,通常与 defer
一起使用。
以下是捕获和处理 panic 的一般步骤:
-
在可能引发 panic 的代码块中,使用
panic
函数来触发 panic,例如:func doSomething() { // 一些可能引发 panic 的代码 if someCondition { panic("Something went wrong!") } }
-
在调用可能引发 panic 的函数时,使用
defer
来延迟执行一个函数,该函数包含recover
来捕获 panic,例如:func main() { defer func() { if r := recover(); r != nil { fmt.Println("Recovered from panic:", r) // 可以在这里处理 panic 或执行其他操作 } }() // 调用可能引发 panic 的函数 doSomething() // 继续执行其他代码 fmt.Println("Program continues...") }
-
如果在
doSomething
中发生了 panic,它会触发recover
函数内的代码块,然后程序可以在recover
块内处理 panic,或者执行其他操作。在这个示例中,我们只是简单地打印了恢复的消息。
重要注意事项:
recover
只有在延迟函数内调用时才有效。- 一旦 panic 发生,并且
recover
成功捕获到 panic,程序将继续执行,不会终止。 - 通常,
recover
用于记录错误或恢复到一个安全的状态,而不是在所有情况下都恢复到正常执行。 - 一旦 panic 发生并且没有被成功捕获,程序将终止执行。
如果没有defer
如果没有使用 defer
函数来捕获 panic,而且在程序中发生了 panic,那么程序会立即终止执行,并且将 panic 信息打印到标准错误流。这通常会导致程序非正常退出。
当程序发生 panic 时,它会打印 panic 的详细信息,包括 panic 的原因和发生 panic 的位置。然后,程序会退出并返回一个非零的退出状态码,表示程序非正常终止。
这是 Go 语言中处理运行时错误的一种方式,它确保在出现严重错误时能够快速停止程序的执行,以避免进一步的不确定行为。在正式的生产环境中,应该记录 panic 信息并采取适当的措施来处理异常情况,以确保程序的稳定性和可维护性。使用 recover
可以帮助在某些情况下恢复程序的执行,但并不是适用于所有情况的通用解决方案。
vendor
底层细节
值类型和引用类型
在 Go 语言中,值类型(Value Types)是那些变量直接包含数据值本身,而不是指向数据的引用的类型。以下是 Go 中的一些常见的值类型:
- 整数类型(int、int8、int16、int32、int64)
- 无符号整数类型(uint、uint8、uint16、uint32、uint64)
- 浮点数类型(float32、float64)
- 复数类型(complex64、complex128)
- 布尔类型(bool)
- 字符串类型(string)
- 数组类型(array)
- 结构体类型(struct)
- 函数类型(function)
- 接口类型(interface)
- 指针类型(pointer)
这些类型的特点是它们的变量包含了实际的数据值,而不是引用其他数据。当将一个值类型的变量传递给函数或赋值给另一个变量时,会发生值的复制,而不是共享数据的引用。
需要注意的是,值类型在传递和赋值时会产生数据复制,因此如果数据量较大,可能会导致性能开销。在这种情况下,可以使用指针来传递数据的引用,以减少复制。
在 Go 语言中,引用类型是那些在变量中存储的是数据的引用或地址,而不是数据本身的类型。以下是 Go 中的一些常见引用类型:
-
切片类型(slice):切片是对底层数组的引用,可以动态增长或缩小。它们非常灵活,常用于处理集合数据。
-
映射类型(map):映射是一种键-值对的集合,也是对底层哈希表的引用。映射允许通过键来查找值。
-
通道类型(channel):通道是用于在不同 Go 协程之间进行通信和同步的引用类型。它们用于并发编程。
-
函数类型(function):函数也可以视为引用类型,可以将函数作为参数传递给其他函数,也可以从函数中返回函数。
-
接口类型(interface):接口是一种引用类型,它定义了一组方法的集合。任何实现了接口中的所有方法的类型都可以被认为是该接口的实现。
这些引用类型的特点是它们在变量中存储的是数据的引用或地址,而不是数据本身。这使得它们更加灵活,能够支持动态数据结构和并发编程等复杂的应用场景。与值类型不同,引用类型在传递和赋值时不会发生数据复制,而是共享底层数据的引用,这可以提高性能并允许更多的并发操作。
map的引用
当说 map 是引用类型时,意味着在函数之间传递 map 变量时,实际上传递的是对底层数据结构的引用,而不是数据的副本。这意味着对 map 的修改会影响到所有引用该 map 的代码。以下是一个示例:
package main
import "fmt"
func modifyMap(m map[string]int) {
m["one"] = 1
m["two"] = 2
}
func main() {
myMap := make(map[string]int)
// 在函数中修改 map
modifyMap(myMap)
// 在 main 函数中访问修改后的 map
fmt.Println(myMap) // 输出: map[one:1 two:2]
}
在这个示例中,modifyMap
函数接收一个 map,并在函数内部修改了这个 map。然后,我们在 main
函数中访问了修改后的 map,发现它已经包含了修改的键值对。这是因为 map 是引用类型,modifyMap
函数修改的是底层数据结构,而不是复制 map。
这种引用语义对于在函数之间传递大型映射或需要共享映射数据的情况非常有用,因为它避免了不必要的数据复制。
传递切片
在 Go 语言中,切片是引用类型,因此切片的传递是通过传递切片的引用来完成的。这意味着当你将一个切片传递给函数时,实际上传递的是指向底层数组的引用,而不是切片的副本。这样做的好处是传递切片时不会复制整个底层数组,从而节省了内存和性能。
当函数接收一个切片作为参数时,它可以修改切片的内容,并且这些修改将在调用函数的地方生效,因为它们都引用了相同的底层数组。这是因为切片包含了底层数组的指针、长度和容量信息。
以下是一个示例,演示了如何传递和修改切片:
package main
import "fmt"
func modifySlice(slice []int) {
for i := range slice {
slice[i] *= 2
}
}
func main() {
original := []int{1, 2, 3, 4, 5}
fmt.Println("Original:", original)
// 传递切片给函数
modifySlice(original)
fmt.Println("Modified:", original)
}
在上面的示例中,modifySlice
函数接收一个切片,并将切片中的元素都乘以 2。这个修改会影响到在 main
函数中创建的原始切片 original
,因为它们引用同一个底层数组。这就是切片传递的引用特性。
需要注意的是,虽然切片的引用是传递的,但函数内部可以通过 append
函数来修改切片的长度和容量,但这并不会影响到调用函数的地方。这是因为 append
可能会返回一个新的切片,而不是修改原始切片。因此,在函数内部使用 append
时需要小心处理。
append
函数可能会返回一个新的切片的情况是当在原始切片的容量不足以容纳要添加的元素时。append
的行为如下:
-
如果要添加的元素数量小于等于原始切片的剩余容量(即原始切片的长度加上剩余容量大于等于要添加的元素数量),则
append
会在原始切片中添加元素,并返回原始切片,不会创建新的切片。 -
如果要添加的元素数量大于原始切片的剩余容量,
append
会创建一个新的底层数组,将原始切片的元素复制到新数组中,然后添加新元素,并返回新的切片。此时,原始切片不再引用新数组,而是仍然引用旧的底层数组。这就是为什么在这种情况下,append
可能返回一个新的切片的原因。
以下是一个示例:
package main
import "fmt"
func main() {
original := []int{1, 2, 3}
fmt.Printf("Original Slice: %v, Length: %d, Capacity: %d\n", original, len(original), cap(original))
// 添加元素到原始切片
original = append(original, 4, 5, 6)
fmt.Printf("Modified Slice: %v, Length: %d, Capacity: %d\n", original, len(original), cap(original))
// 添加元素到原始切片,超过容量
original = append(original, 7, 8, 9, 10)
fmt.Printf("Modified Slice (New Array): %v, Length: %d, Capacity: %d\n", original, len(original), cap(original))
}
在这个示例中,当添加元素 7, 8, 9, 10
时,原始切片的容量不足以容纳这些元素,因此 append
创建了一个新的底层数组,并返回了一个新的切片,这个新切片引用新数组。原始切片不再引用新数组。
基础语法
01 = 和 := 的区别?
在 Go 语言中,= 和 := 是两种不同的赋值操作符,它们有不同的使用场景和含义。
= 赋值操作符:
在 Go 中,= 用于给已经声明的变量赋值。这意味着你需要先在代码中声明一个变量,然后使用 = 赋值一个新的值给这个变量。例如:
var x int
x = 10
:= 短变量声明操作符:
:= 是用于声明并初始化变量的操作符,它可以在声明变量的同时进行赋值。这个操作符在初始化变量时非常有用,因为它可以自动推断变量的类型。例如:
x := 10
在这个例子中,变量 x 被声明为 int 类型,并且被赋值为 10。
需要注意的是,:= 语法只能在函数内部使用,用于在局部作用域中声明和初始化变量。而在函数外部,或者用于已经声明过的变量赋值时,只能使用 = 赋值操作符。
总结:
= 用于给已经声明的变量赋值。
:= 用于在局部作用域内声明并初始化变量,同时可以自动推断变量类型。
02 指针的作用
一个指针可以指向任意变量的地址,它所指向的地址在32位或64位机器上分别固定占4或8个字节。指针的作用有:
// 获取变量的值
import fmt
func main(){
a := 1
p := &a//取址&
fmt.Printf("%d\n", *p);//取值*
}
//改变变量的值
// 交换函数
func swap(a, b *int) {
*a, *b = *b, *a
}
// 用指针替代值传入函数,比如类的接收器就是这样的。
type A struct{}
func (a *A) fun(){}
03 Go 允许多个返回值吗?
Go 语言允许函数返回多个值。这是 Go 语言中的一个很有用的特性,可以简化代码并提高灵活性。函数可以返回任意数量的值,并且这些值可以是不同的类型。
例如,一个函数可以返回一个值和一个错误,通常用于指示函数是否成功执行。另外,也可以返回多个结果,如下所示:
package main
import "fmt"
func swap(a, b int) (int, int) {
return b, a
}
func main() {
x, y := 10, 20
x, y = swap(x, y)
fmt.Println("x:", x, "y:", y)
}
在这个例子中,swap 函数接受两个整数参数并返回两个整数值。通过调用 swap 函数,我们实现了变量交换的效果。
通过使用多个返回值,Go 语言可以更加优雅地处理多个相关的值,并且避免了在其他语言中可能需要使用临时变量或数据结构来实现类似的操作。
05 什么是协程(Goroutine)
Goroutine 是 Go 语言中的一个重要特性,它是一种轻量级的并发执行单元。与传统的线程相比,Goroutine 更加高效、轻量且易于管理。在 Go 中,你可以创建成千上万个 Goroutine,而不会像操作系统线程那样消耗过多的内存。
Goroutine 的特点包括:
-
轻量级: Goroutine 的创建和销毁比线程更加轻量级,可以在同一个程序中创建大量的 Goroutine,而不会过多消耗系统资源。
-
并发性: Goroutine 可以在多核处理器上并行执行,充分利用硬件资源,从而实现高效的并发。
-
通信: Goroutine 之间通过通道(Channel)进行通信,这是一种在多个 Goroutine 之间进行安全数据传输和同步的方式。
-
调度: Goroutine 的调度是由 Go 运行时(runtime)负责的,它会自动在不同的 Goroutine 之间进行切换,实现并发执行。
创建一个 Goroutine 非常简单,只需要在函数调用前加上关键字 go 即可:
Copy code
func main() {
go func() {
fmt.Println("Goroutine executed!")
}()
// 程序不会等待 Goroutine 执行完成,而是直接继续往下执行
}
在这个例子中,我们使用 go 关键字在一个匿名函数上创建了一个 Goroutine,该函数会异步执行。
需要注意的是,由于 Goroutine 是并发执行的,所以在编写并发代码时需要特别注意数据共享和同步,以避免竞态条件等问题。
06 如何高效地拼接字符串
拼接字符串的方式有:+ , fmt.Sprintf , strings.Builder, bytes.Buffer, strings.Join
1 "+"
使用+操作符进行拼接时,会对字符串进行遍历,计算并开辟一个新的空间来存储原来的两个字符串。
2 fmt.Sprintf
由于采用了接口参数,必须要用反射获取值,因此有性能损耗。
3 strings.Builder:
用WriteString()进行拼接,内部实现是指针+切片,同时String()返回拼接后的字符串,它是直接把[]byte转换为string,从而避免变量拷贝。
4 bytes.Buffer
bytes.Buffer是一个一个缓冲byte类型的缓冲器,这个缓冲器里存放着都是byte,
bytes.buffer底层也是一个[]byte切片。
5 strings.join
strings.join也是基于strings.builder来实现的,并且可以自定义分隔符,在join方法内调用了b.Grow(n)方法,这个是进行初步的容量分配,而前面计算的n的长度就是我们要拼接的slice的长度,因为我们传入切片长度固定,所以提前进行容量分配可以减少内存分配,很高效。
性能比较:
strings.Join ≈ strings.Builder > bytes.Buffer > "+" > fmt.Sprintf
5种拼接方法的实例代码
func main(){
a := []string{"a", "b", "c"}
//方式1:+
ret := a[0] + a[1] + a[2]
//方式2:fmt.Sprintf
ret := fmt.Sprintf("%s%s%s", a[0],a[1],a[2])
//方式3:strings.Builder
var sb strings.Builder
sb.WriteString(a[0])
sb.WriteString(a[1])
sb.WriteString(a[2])
ret := sb.String()
//方式4:bytes.Buffer
buf := new(bytes.Buffer)
buf.Write(a[0])
buf.Write(a[1])
buf.Write(a[2])
ret := buf.String()
//方式5:strings.Join
ret := strings.Join(a,"")
}
07 什么是 rune 类型
ASCII 码只需要 7 bit 就可以完整地表示,但只能表示英文字母在内的128个字符,为了表示世界上大部分的文字系统,发明了 Unicode, 它是ASCII的超集,包含世界上书写系统中存在的所有字符,并为每个代码分配一个标准编号(称为Unicode CodePoint),在 Go 语言中称之为 rune,是 int32 类型的别名。
Go 语言中,字符串的底层表示是 byte (8 bit) 序列,而非 rune (32 bit) 序列。
sample := "我爱GO"
runeSamp := []rune(sample)
runeSamp[0] = '你'
fmt.Println(string(runeSamp)) // "你爱GO"
fmt.Println(len(runeSamp)) // 4
在 Go 语言中,rune 是一个整数类型,用于表示 Unicode 字符。Unicode 是一种字符集,用于表示世界上大部分语言的字符和符号。rune 类型是为了方便处理 Unicode 字符而引入的。
rune 类型实际上是一个类型别名,它是 int32 类型的别名。在 Go 语言中,int32 用于表示 32 位有符号整数,而 rune 则专门用于表示 Unicode 字符。
由于 rune 类型代表 Unicode 字符,因此它可以用于存储单个字符,包括字母、数字、标点符号、汉字等。在字符串中,每个字符都可以被表示为一个 rune。
以下是一个示例,展示了如何使用 rune 类型:
package main
import "fmt"
func main() {
// 使用 rune 表示单个字符
var myRune rune = 'A'
fmt.Printf("Character: %c, Unicode: %d\n", myRune, myRune)
// 使用 rune 表示汉字
var chineseRune rune = '中'
fmt.Printf("Character: %c, Unicode: %d\n", chineseRune, chineseRune)
}
在这个示例中,我们使用 rune 类型分别表示一个英文字母 'A' 和一个汉字 '中'。通过使用 %c 格式化动词,我们可以在 fmt.Printf 函数中将 rune 类型的值输出为字符。另外,通过 %d 格式化动词,我们可以将 rune 类型的值输出为其对应的 Unicode 编码。
在 Go 语言中,rune 是一个别名类型,它实际上是一个表示 Unicode 码点的整数类型。Unicode 码点是字符集中的一个唯一标识符,它对应于每个字符。rune 类型用于处理 Unicode 字符,特别是在字符串操作中。
rune 的别名类型是 int32,它占用 4 个字节。在 Go 中,rune 类型通常用于处理字符串中的字符,因为字符串实际上是由多个 Unicode 码点组成的。
例如,下面的代码演示了如何使用 rune 类型来遍历一个字符串的每个字符:
package main
import (
"fmt"
)
func main() {
str := "Hello, 世界"
for _, char := range str {
fmt.Printf("Character: %c, Unicode Code Point: %U\n", char, char)
}
}
在这个例子中,str 字符串包含英文字符和一个包含 "世界" 的中文字符。通过使用 range 循环迭代字符串,我们可以逐个获取字符串中的字符,然后打印出字符以及其对应的 Unicode 码点。
需要注意的是,由于 Unicode 码点可能占用多个字节,处理字符串时需要格外小心,确保在处理字符时不会出现意外的问题。
08 如何判断 map 中是否包含某个 key ?
var sample map[int]int
if _, ok := sample[10]; ok {
} else {
}
// 获取键 Blue 对应的值
value, exists := colors["Blue"]
// 这个键存在吗?
if exists {
fmt.Println(value)
}
另一个选择是,只返回键对应的值,然后通过判断这个值是不是零值来确定键是否存在,这种方法只能用在映射存储的值都是非零值的情况。
// 获取键 Blue 对应的值
value := colors["Blue"]
// 这个键存在吗?
if value != "" {
fmt.Println(value)
}
在 Go 语言里,通过键来索引映射时,即便这个键不存在也总会返回一个值。在这种情况下,
返回的是该值对应的类型的零值。
09 Go 支持默认参数或可选参数吗?
不支持。但是可以利用结构体参数,或者...传入参数切片数组。
// 这个函数可以传入任意数量的整型参数
func sum(nums ...int) {
total := 0
for _, num := range nums {
total += num
}
fmt.Println(total)
}
10 defer 的执行顺序
defer执行顺序和调用顺序相反,类似于栈后进先出(LIFO)。
defer在return之后执行,但在函数退出之前,defer可以修改返回值。下面是一个例子:
func test() int {
i := 0
defer func() {
fmt.Println("defer1")
}()
defer func() {
i += 1
fmt.Println("defer2")
}()
return i
}
func main() {
fmt.Println("return", test())
}
// defer2
// defer1
// return 0
上面这个例子中,test返回值并没有修改,这是由于Go的返回机制决定的,执行Return语句后,Go会创建一个临时变量保存返回值。如果是有名返回(也就是指明返回值func test() (i int))
func test() (i int) {
i = 0
defer func() {
i += 1
fmt.Println("defer2")
}()
return i
}
func main() {
fmt.Println("return", test())
}
// defer2
// return 1
这个例子中,返回值被修改了。对于有名返回值的函数,执行 return 语句时,并不会再创建临时变量保存,因此,defer 语句修改了 i,即对返回值产生了影响。
11 go语言如何交换 2 个变量的值?
对于变量而言a,b = b,a;
对于指针而言a,b = *b, *a
12 Go 语言 tag 的用处?
tag可以为结构体成员提供属性。常见的:
json序列化或反序列化时字段的名称
db: sqlx模块中对应的数据库字段名
form: gin框架中对应的前端的数据字段名
binding: 搭配 form 使用, 默认如果没查找到结构体中的某个字段则不报错值为空, binding为 required 代表没找到返回错误给前端
在 Go 语言中,结构体字段可以通过使用标签(Tags)来附加元数据。标签是位于字段声明之后的一串字符串,用于描述字段的一些特性或额外的信息。标签通常在反射(reflection)中被使用,例如在序列化和反序列化、验证、ORM(对象关系映射)等情境下。
标签的一般语法是将字段名称和标签字符串用空格分隔。标签字符串一般使用双引号包裹,例如:
Copy code
type Person struct {
FirstName string `json:"first_name" xml:"FirstName"`
LastName string `json:"last_name" xml:"LastName"`
Age int `json:"age" xml:"Age"`
}
在这个例子中,json:"first_name" 和 xml:"FirstName" 就是字段标签。标签可以包含任何信息,但最常见的情况是在序列化和反序列化时使用,以指定字段在 JSON、XML 等格式中的名称。例如,json:"first_name" 意味着在 JSON 中这个字段将被命名为 "first_name"。
要访问结构体字段的标签信息,需要使用 Go 的反射包。可以通过使用 reflect 包中的函数来解析标签。
以下是一个示例代码,演示了如何使用标签和反射获取字段标签:
package main
import (
"fmt"
"reflect"
)
type Person struct {
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Age int `json:"age"`
}
func main() {
p := Person{
FirstName: "John",
LastName: "Doe",
Age: 30,
}
t := reflect.TypeOf(p)
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
fmt.Printf("Field: %s, Tag: %s\n", field.Name, field.Tag.Get("json"))
}
}
在上述代码中,我们使用了反射来遍历 Person 结构体的字段,并获取它们的 json 标签的值。注意,访问标签信息需要使用 field.Tag.Get("tagname") 方法。
13 如何获取一个结构体的所有tag?
利用反射:
import reflect
type Author struct {
Name int `json:Name`
Publications []string `json:Publication,omitempty`
}
func main() {
t := reflect.TypeOf(Author{})
for i := 0; i < t.NumField(); i++ {
name := t.Field(i).Name
s, _ := t.FieldByName(name)
fmt.Println(name, s.Tag)
}
}
上述例子中,reflect.TypeOf方法获取对象的类型,之后NumField()获取结构体成员的数量。 通过Field(i)获取第i个成员的名字。 再通过其Tag 方法获得标签。
14 如何判断 2 个字符串切片(slice) 是相等的?
reflect.DeepEqual() , 但反射非常影响性能。
在 Go 语言中,判断两个字符串切片(slice)是否相等需要比较切片中的每个元素。这是因为切片是引用类型,切片变量存储的是底层数组的地址,而不是实际的数据。以下是判断两个字符串切片是否相等的示例代码:
package main
import (
"fmt"
"reflect"
)
func areStringSlicesEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i, v := range a {
if v != b[i] {
return false
}
}
return true
}
func main() {
slice1 := []string{"apple", "banana", "cherry"}
slice2 := []string{"apple", "banana", "cherry"}
slice3 := []string{"apple", "orange", "cherry"}
fmt.Println("Using loop:", areStringSlicesEqual(slice1, slice2)) // Output: true
fmt.Println("Using loop:", areStringSlicesEqual(slice1, slice3)) // Output: false
reflectEqual := reflect.DeepEqual(slice1, slice2)
fmt.Println("Using reflect.DeepEqual:", reflectEqual) // Output: true
}
在上面的代码中,areStringSlicesEqual 函数使用循环逐个比较切片中的元素。另一种方法是使用 reflect.DeepEqual 函数来进行比较,但需要注意的是,reflect.DeepEqual 不仅可以用于切片,还可以用于比较任何类型的数据结构,但它可能会有性能问题,尤其是对于大型数据结构。因此,如果只是比较简单的字符串切片,循环比较是更常见的选择。
请注意,上述方法假设切片中的元素都是可以直接比较的基本数据类型,如果切片中包含复杂的结构体等类型,可能需要递归地比较每个元素的字段。
15 结构体打印时,%v 和 %+v 的区别
%v输出结构体各成员的值;
%+v输出结构体各成员的名称和值;
%#v输出结构体名称和结构体各成员的名称和值
package main
import "fmt"
// 定义一个结构体
type Person struct {
FirstName string
LastName string
Age int
}
func main() {
// 创建结构体实例
person := Person{
FirstName: "John",
LastName: "Doe",
Age: 30,
}
// 访问结构体字段
fmt.Println("First Name:", person.FirstName)
fmt.Println("Last Name:", person.LastName)
fmt.Println("Age:", person.Age)
// 修改结构体字段
person.Age = 31
fmt.Println("Updated Age:", person.Age)
fmt.Printf("Person: %v\n", person)
fmt.Printf("Person: %+v\n", person)
fmt.Printf("Person: %#v\n", person)
}
16 Go enums
Go 语言没有像某些其他编程语言那样直接支持传统的枚举(enums)类型。然而,你可以使用一些技巧来模拟枚举行为,其中常用的有两种方式:使用常量和使用自定义类型。
使用常量:
可以使用 const 声明一组常量来模拟枚举类型。每个常量都有一个唯一的名称和一个值,表示枚举的不同选项。
package main
import "fmt"
const (
Sunday = 0
Monday = 1
Tuesday = 2
Wednesday = 3
Thursday = 4
Friday = 5
Saturday = 6
)
func main() {
day := Wednesday
fmt.Println("Today is:", day)
}
使用自定义类型:
你也可以通过自定义类型和常量来模拟枚举。这种方式可以为枚举类型定义方法,从而实现一些更复杂的行为。
package main
import "fmt"
type Day int
const (
Sunday Day = iota
Monday
Tuesday
Wednesday
Thursday
Friday
Saturday
)
func (d Day) String() string {
days := [...]string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}
return days[d]
}
func main() {
day := Wednesday
fmt.Println("Today is:", day)
}
在这个例子中,我们通过定义一个 Day 类型来模拟枚举。使用 iota 来生成递增的整数值。我们还在 Day 类型上定义了一个 String() 方法,使得可以通过调用这个方法来获取枚举值的字符串表示。
虽然 Go 没有直接的枚举类型,但使用上述的方式可以很好地模拟枚举行为,并且具有更大的灵活性。
17 空 struct{} 的用途
空结构体 struct{}
在 Go 语言中被用作占位符或者信号,通常用于以下场景:
-
占位符: 空结构体可以被用作一种占位符,表示某个位置不需要存储实际的数据,只需要一个存在的标记。这在构建数据结构或者映射的时候可能会有用,例如创建一个集合类型,只关心元素的存在与否,而不需要实际的值。
set := make(map[string]struct{}) set["item1"] = struct{}{} set["item2"] = struct{}{} if _, exists := set["item1"]; exists { fmt.Println("item1 exists in the set") }
-
信号传递: 空结构体可以用作通道中的信号,用于传递事件或者触发某些操作。在协程间同步或者通信的时候,可以使用空结构体作为通道的元素类型,来传递简单的信号。
signalChannel := make(chan struct{}) go func() { // 做一些任务 signalChannel <- struct{}{} // 发送信号 }() // 等待信号 <-signalChannel
-
优化内存占用: 在某些情况下,如果需要标记一个数据集合而不需要存储实际的数据,使用空结构体可以优化内存占用。因为空结构体不会分配额外的内存空间。
总之,空结构体的主要用途是在不需要实际数据存储的情况下,提供一个简单的标记或者信号。它们非常轻量且不占用内存,适用于一些特定的编程场景。
18 go里面的int和int32是同一个概念吗?
不是一个概念!千万不能混淆。go语言中的int的大小是和操作系统位数相关的,如果是32位操作系统,int类型的大小就是4字节。如果是64位操作系统,int类型的大小就是8个字节。除此之外uint也与操作系统有关。
int8占1个字节,int16占2个字节,int32占4个字节,int64占8个字节。
int: int 是 Go 语言中的基本整数类型,它的大小在不同的平台上可能会有所不同。在大多数情况下,int 的大小与当前计算机体系结构的位数相关,例如在 32 位计算机上,int 的大小通常是 32 位,而在 64 位计算机上,int 的大小通常是 64 位。
int32: int32 是一个固定大小的整数类型,它占据 32 位。这意味着在任何平台上,无论是 32 位还是 64 位,int32 的大小都保持不变。
在某些情况下,你可能需要明确地使用 int32 类型,特别是当你需要确保代码在不同平台上的一致性时。例如,当你与其他系统交互或者需要精确控制数据大小和内存布局时,使用固定大小的类型可以提供更大的可预测性。
然而,大多数情况下,Go 的类型系统会自动处理类型转换,所以你可以在代码中自由地使用 int,而不必过于担心平台差异。在需要进行特定大小的整数操作时,才需要明确选择合适的固定大小类型,如 int32、int64 等。
实现原理
01 init() 函数是什么时候执行的?
简答: 在main函数之前执行。
详细:init()函数是go初始化的一部分,由runtime初始化每个导入的包,初始化不是按照从上到下的导入顺序,而是按照解析的依赖关系,没有依赖的包最先初始化。
每个包首先初始化包作用域的常量和变量(常量优先于变量),然后执行包的init()函数。同一个包,甚至是同一个源文件可以有多个init()函数。init()函数没有入参和返回值,不能被其他函数调用,同一个包内多个init()函数的执行顺序不作保证。
执行顺序:import –> const –> var –>init()–>main()
一个文件可以有多个init()函数!
init() 函数在 Go 语言中具有特殊的作用,它主要用于在程序执行期间进行初始化操作。这些初始化操作可能包括以下方面:
全局变量的初始化: 在包级别的 init() 函数中,可以对包内的全局变量进行初始化。这样,当包被导入时,相关的变量就会被初始化为期望的初始值。
资源的预分配和设置: init() 函数可以用于预分配和设置资源,例如打开文件、建立数据库连接、初始化网络服务等。这样在程序运行过程中就可以直接使用这些资源,而不需要在运行时再进行重复的初始化操作。
注册和初始化: 在某些情况下,不同包之间的初始化可能需要一定的协调。init() 函数可以用于注册一些信息或者初始化其他包的一些内容,以确保在程序运行时所有需要的设置都已经完成。
执行顺序的控制: 如果一个包中包含多个 init() 函数,它们的执行顺序是不确定的。但在某些情况下,你可能需要确保某些初始化操作在其他操作之前执行。通过适当地设计包和 init() 函数,你可以实现一些控制执行顺序的目的。
总之,init() 函数是在程序执行期间用于执行初始化任务的重要工具。它的使用场景多种多样,但需要注意的是,由于 init() 函数的执行顺序可能不确定,因此不应该在不同的 init() 函数之间建立强依赖关系。在需要特定执行顺序的情况下,最好使用更明确的方法来实现控制。
在 Go 语言中,init() 函数是一种特殊的函数,用于在程序运行时进行初始化操作。init() 函数在以下几种情况下会被自动调用:
包的初始化: 当一个包被导入时,其中的 init() 函数会被自动执行。这些 init() 函数按照导入的顺序依次执行。每个包可以有多个 init() 函数,它们在包的级别上完成初始化工作,例如初始化全局变量、设置环境变量等。
程序入口的初始化: 在 Go 语言程序执行时,如果包含了 main 函数,会首先执行 main 包中的 init() 函数,然后执行 main() 函数。
需要注意的是,init() 函数是自动调用的,无法在代码中显式调用,也不能被其他函数调用。此外,如果一个包中包含多个 init() 函数,它们的执行顺序是不确定的,因此不应该依赖于它们之间的调用顺序。
02 如何知道一个对象是分配在栈上还是堆上?
Go和C++不同,Go局部变量会进行逃逸分析。如果变量离开作用域后没有被引用,则优先分配到栈上,否则分配到堆上。那么如何判断是否发生了逃逸呢?
go build -gcflags '-m -m -l' xxx.go.
关于逃逸的可能情况:变量大小不确定,变量类型不确定,变量分配的内存超过用户栈最大值,暴露给了外部指针。
在 Go 语言中,对象(变量、结构体、切片等)的分配位置是由编译器和运行时共同决定的。在大多数情况下,你不需要显式指定对象的分配位置,Go 语言的内存管理机制会自动为你处理。
在 Go 中,基本规则是:
编译器会尽量将对象分配在栈上,以减少内存分配和释放的开销。
但如果对象的大小在编译时无法确定,或者对象需要在函数调用之间保持存在,编译器就会将对象分配在堆上。
例如,对于如下的情况,通常会在栈上分配对象:
整数、浮点数、布尔值等基本类型的变量。
结构体实例,如果它的字段也都是栈上分配的。
而在如下情况,通常会在堆上分配对象:
切片、映射、通道等引用类型,它们的大小在编译时无法确定。
使用 new 或 make 创建的对象,例如 new(int) 或 make([]int, 10)。
需要注意的是,Go 的内存分配和管理是透明的,你不需要关心对象的分配位置。编译器和运行时会根据上述规则自动进行适当的内存分配。这也有助于避免了 C/C++ 中需要手动管理内存的问题,提供了更高级别的内存安全。
03 2 个 interface 可以比较吗 ?
Go 语言中,interface 的内部实现包含了 2 个字段,类型 T 和 值 V,interface 可以使用 == 或 != 比较。2 个 interface 相等有以下 2 种情况
两个 interface 均等于 nil(此时 V 和 T 都处于 unset 状态)
类型 T 相同,且对应的值 V 相等。
看下面的例子:
type Stu struct {
Name string
}
type StuInt interface{}
func main() {
var stu1, stu2 StuInt = &Stu{"Tom"}, &Stu{"Tom"}
var stu3, stu4 StuInt = Stu{"Tom"}, Stu{"Tom"}
fmt.Println(stu1 == stu2) // false
fmt.Println(stu3 == stu4) // true
}
stu1 和 stu2 对应的类型是 *Stu,值是 Stu 结构体的地址,两个地址不同,因此结果为 false。
stu3 和 stu4 对应的类型是 Stu,值是 Stu 结构体,且各字段相等,因此结果为 true。
04 2 个 nil 可能不相等吗?
可能不等。interface在运行时绑定值,只有值为nil接口值才为nil,但是与指针的nil不相等。举个例子:
var p *int = nil
var i interface{} = nil
if(p == i){
fmt.Println("Equal")
}
两者并不相同。总结:两个nil只有在类型相同时才相等。
05 简述 Go 语言GC(垃圾回收)的工作原理
垃圾回收机制是Go一大特(nan)色(dian)。Go1.3采用标记清除法, Go1.5采用三色标记法,Go1.8采用三色标记法+混合写屏障。
标记清除法
分为两个阶段:标记和清除
标记阶段:从根对象出发寻找并标记所有存活的对象。
清除阶段:遍历堆中的对象,回收未标记的对象,并加入空闲链表。
缺点是需要暂停程序STW。
三色标记法:
将对象标记为白色,灰色或黑色。
白色:不确定对象(默认色);黑色:存活对象。灰色:存活对象,子对象待处理。
标记开始时,先将所有对象加入白色集合(需要STW)。首先将根对象标记为灰色,然后将一个对象从灰色集合取出,遍历其子对象,放入灰色集合。同时将取出的对象放入黑色集合,直到灰色集合为空。最后的白色集合对象就是需要清理的对象。
这种方法有一个缺陷,如果对象的引用被用户修改了,那么之前的标记就无效了。因此Go采用了写屏障技术,当对象新增或者更新会将其着色为灰色。
一次完整的GC分为四个阶段:
准备标记(需要STW),开启写屏障。
开始标记
标记结束(STW),关闭写屏障
清理(并发)
基于插入写屏障和删除写屏障在结束时需要STW来重新扫描栈,带来性能瓶颈。混合写屏障分为以下四步:
GC开始时,将栈上的全部对象标记为黑色(不需要二次扫描,无需STW);
GC期间,任何栈上创建的新对象均为黑色
被删除引用的对象标记为灰色
被添加引用的对象标记为灰色
总而言之就是确保黑色对象不能引用白色对象,这个改进直接使得GC时间从 2s降低到2us。
06 函数返回局部变量的指针是否安全?
这一点和C++不同,在Go里面返回局部变量的指针是安全的。因为Go会进行逃逸分析,如果发现局部变量的作用域超过该函数则会把指针分配到堆区,避免内存泄漏。
07 非接口的任意类型 T() 都能够调用 *T 的方法吗?反过来呢?
一个T类型的值可以调用*T类型声明的方法,当且仅当T是可寻址的。
反之:*T 可以调用T()的方法,因为指针可以解引用。
方法与函数的区别
在编程中,方法(Method)和函数(Function)是两个不同的概念,它们之间有一些重要的区别。以下是方法和函数的主要区别:
-
归属:
- 函数:函数是独立的代码块,不依赖于任何特定的对象或类型。函数可以在任何地方定义和调用,它们不与任何特定的数据结构或对象绑定。
- 方法:方法是与特定类型或对象相关联的函数。它们属于某个类型或对象,通常用于执行该类型或对象的操作。方法必须与特定类型的实例关联才能调用。
-
调用方式:
- 函数:函数通过函数名直接调用,传递参数并返回结果。
- 方法:方法通过对象或类型的实例调用,使用点运算符(.)访问,并可以隐式访问该对象的属性和方法。
-
参数传递:
- 函数:函数的参数是独立的,不依赖于任何对象或类型。
- 方法:方法的参数通常包括接收者参数(也称为接收者),该参数是方法绑定的类型或对象的一个实例。方法可以访问接收者的属性和方法。
-
示例代码:
函数示例:
func add(a, b int) int {
return a + b
}
result := add(2, 3)
方法示例:
type Rectangle struct {
Width float64
Height float64
}
func (r Rectangle) Area() float64 {
return r.Width * r.Height
}
rect := Rectangle{Width: 5.0, Height: 3.0}
area := rect.Area()
在上述示例中,add
是一个函数,而 Area
是一个方法,它与 Rectangle
类型相关联。
总之,方法是与类型或对象相关联的函数,而函数是独立的代码块。方法通过对象调用,而函数可以在任何地方直接调用。选择使用方法或函数取决于您的程序设计需求和代码结构。方法通常用于实现对象的行为,而函数通常用于执行通用的操作。
08 go slice是怎么扩容的?
Go <= 1.17
如果当前容量小于1024,则判断所需容量是否大于原来容量2倍,如果大于,当前容量加上所需容量;否则当前容量乘2。
如果当前容量大于1024,则每次按照1.25倍速度递增容量,也就是每次加上cap/4。
Go1.18之后,引入了新的扩容规则:浅谈 Go 1.18.1的切片扩容机制
Go语言中的切片(slice)是一种动态数组,它可以根据需要自动扩容。切片的扩容原理主要涉及到以下几个关键概念:
-
容量(capacity):切片的容量是指切片底层数组中能够存储元素的总数。切片的长度(len)是指切片当前包含的元素个数。容量大于等于长度。
-
增长策略:当切片需要扩容时,Go语言会根据一种增长策略来确定新切片的容量。这个策略是在运行时决定的,但通常会遵循一种类似于指数增长的规律,即每次扩容容量翻倍。
-
新切片:当切片需要扩容时,会创建一个新的底层数组,并将原切片中的元素复制到新数组中。然后,新切片会指向这个新的底层数组。
下面是一个示例来说明切片扩容的原理:
package main
import "fmt"
func main() {
// 创建一个切片,长度为0,容量为5
slice := make([]int, 0, 5)
fmt.Printf("Length: %d, Capacity: %d\n", len(slice), cap(slice))
// 添加元素,触发扩容
for i := 1; i <= 6; i++ {
slice = append(slice, i)
fmt.Printf("Length: %d, Capacity: %d\n", len(slice), cap(slice))
}
}
在这个示例中,我们首先创建了一个长度为0、容量为5的切片。然后,通过循环向切片中添加元素,当切片的长度超过了容量时,切片会自动扩容。观察输出可以看到切片的容量在每次扩容后都翻倍。
需要注意的是,切片扩容会导致底层数组的重新分配和元素的复制,因此在性能敏感的情况下,应该尽量避免频繁的切片扩容操作,可以通过预先分配足够大的容量来优化性能。如果知道切片的最大长度,可以使用make
函数指定容量,以减少扩容次数。
总之,切片的扩容原理涉及容量、增长策略和新切片的创建,Go语言会在需要时自动处理扩容操作,使开发者无需关心底层细节。
03 Go 可以限制运行时操作系统线程的数量吗? 常见的goroutine操作函数有哪些?
可以,使用runtime.GOMAXPROCS(num int)可以设置线程数目。该值默认为CPU逻辑核数,如果设的太大,会引起频繁的线程切换,降低性能。
runtime.Gosched(),用于让出CPU时间片,让出当前goroutine的执行权限,调度器安排其它等待的任务运行,并在下次某个时候从该位置恢复执行。
runtime.Goexit(),调用此函数会立即使当前的goroutine的运行终止(终止协程),而其它的goroutine并不会受此影响。runtime.Goexit在终止当前goroutine前会先执行此goroutine的还未执行的defer语句。请注意千万别在主函数调用runtime.Goexit,因为会引发panic。
04 如何控制协程数目。
The GOMAXPROCS variable limits the number of operating system threads that can execute user-level Go code simultaneously. There is no limit to the number of threads that can be blocked in system calls on behalf of Go code; those do not count against the GOMAXPROCS limit.
从官方文档的解释可以看到,GOMAXPROCS 限制的是同时执行用户态 Go 代码的操作系统线程的数量,但是对于被系统调用阻塞的线程数量是没有限制的。GOMAXPROCS 的默认值等于 CPU 的逻辑核数,同一时间,一个核只能绑定一个线程,然后运行被调度的协程。因此对于 CPU 密集型的任务,若该值过大,例如设置为 CPU 逻辑核数的 2 倍,会增加线程切换的开销,降低性能。对于 I/O 密集型应用,适当地调大该值,可以提高 I/O 吞吐率。
另外对于协程,可以用带缓冲区的channel来控制,下面的例子是协程数为1024的例子
var wg sync.WaitGroup
ch := make(chan struct{}, 1024)
for i:=0; i<20000; i++{
wg.Add(1)
ch<-struct{}{}
go func(){
defer wg.Done()
<-ch
}
}
wg.Wait()
此外还可以用协程池:其原理无外乎是将上述代码中通道和协程函数解耦,并封装成单独的结构体。常见第三方协程池库,比如tunny等。
为什么有协程泄露(Goroutine Leak)?
协程泄漏是指协程创建之后没有得到释放。主要原因有:
缺少接收器,导致发送阻塞
缺少发送器,导致接收阻塞
死锁。多个协程由于竞争资源导致死锁。
创建协程的没有回收。
new和make的区别?
在 Go 语言中,new
和 make
是两个用于创建不同类型的对象的内建函数,它们有不同的用途和行为。
-
new
函数:new
函数用于创建一个指定类型的新实例,并返回该实例的指针。- 它接受一个参数,即要创建的对象类型的指针。它不会初始化对象的值,而只是分配了内存,并将其置为零值。
- 主要用于创建基本数据类型的指针、结构体等值类型的对象。
例如:
numPtr := new(int) // 创建一个 int 类型的指针,并分配内存 strPtr := new(string) // 创建一个 string 类型的指针,并分配内存
-
make
函数:make
函数用于创建内建的引用类型(例如切片、映射、通道)的实例。- 它接受两个参数:要创建的类型和适当的长度/容量参数,具体取决于类型。
make
函数会分配内存、初始化数据结构,并返回类型的实例,通常用于切片、映射和通道的创建。
例如:
slice := make([]int, 0, 10) // 创建一个切片,长度为 0,容量为 10 channel := make(chan string) // 创建一个字符串类型的通道
总结:
- 使用
new
创建的是值类型的指针,分配了内存但不初始化。 - 使用
make
创建的是引用类型的实例,会分配内存并进行初始化。
根据具体的需求和数据类型,你可以选择使用适当的函数来创建对象。
new只用于分配内存,返回一个指向地址的指针。它为每个新类型分配一片内存,初始化为0且返回类型*T的内存地址,它相当于&T{}
make只可用于slice,map,channel的初始化,返回的是引用。
请你讲一下Go面向对象是如何实现的?
Go实现面向对象的两个关键是struct和interface。
封装:对于同一个包,对象对包内的文件可见;对不同的包,需要将对象以大写开头才是可见的。
继承:继承是编译时特征,在struct内加入所需要继承的类即可:
type A struct{}
type B struct{
A
}
多态:多态是运行时特征,Go多态通过interface来实现。类型和接口是松耦合的,某个类型的实例可以赋给它所实现的任意接口类型的变量。
Go支持多重继承,就是在类型中嵌入所有必要的父类型。
uint型变量值分别为 1,2,它们相减的结果是多少?
var a uint = 1
var b uint = 2
fmt.Println(a - b)
答案,结果会溢出,如果是32位系统,结果是232-1,如果是64位系统,结果264-1.
下面这句代码是什么作用,为什么要定义一个空值?
type GobCodec struct{
conn io.ReadWriteCloser
buf *bufio.Writer
dec *gob.Decoder
enc *gob.Encoder
}
type Codec interface {
io.Closer
ReadHeader(*Header) error
ReadBody(interface{}) error
Write(*Header, interface{}) error
}
var _ Codec = (*GobCodec)(nil)
将nil转换为GobCodec类型,然后再转换为Codec接口,如果转换失败,说明GobCodec没有实现Codec接口的所有方法。
这段代码的作用是在 Go 语言中进行接口的静态类型检查和约束。它通过声明一个 Codec 接口的实现(GobCodec 类型),然后使用空值接口赋值来确保 GobCodec 类型实际上实现了 Codec 接口的所有方法。
让我一步一步解释:
首先,你定义了一个名为 GobCodec 的结构体,其中包含了一些字段,这些字段用于处理 Gob 编码和解码相关的操作。
然后,你定义了一个 Codec 接口,这个接口包含了一些方法,用于读取和写入数据,以及关闭操作。
接着,你使用 var _ Codec = (GobCodec)(nil) 这行代码。这行代码的目的是声明一个名为 _(下划线)的变量,将其赋值为 (GobCodec)(nil)。这个操作实际上在编译时会检查 *GobCodec 是否实现了 Codec 接口的所有方法。
这样做的目的是为了确保在代码编译期间就能够发现 *GobCodec 类型是否正确地实现了 Codec 接口的所有方法,避免在运行时因为缺少方法实现而导致错误。如果 *GobCodec 类型没有正确实现 Codec 接口的所有方法,编译器会报错。
需要指出的是,这种技巧在 Go 语言中称为“接口静态类型检查”或“接口断言”。这种方式能够在编译时帮助你避免一些潜在的错误,并确保你的代码与接口的约定保持一致。
nil
在Go语言中,nil 是一个预定义的标识符,表示一个零值或空值。它在不同的上下文中表示不同类型的零值。在大多数情况下,nil 被用来表示指针类型、接口类型、映射类型、切片类型、通道类型和函数类型的零值。
以下是一些常见的用法:
指针类型: 在Go语言中,一个未初始化的指针会被自动赋值为 nil。例如:var ptr *int,此时 ptr 就是一个 nil 指针。
接口类型: 一个未初始化的接口变量会被自动赋值为 nil。例如:var i interface{},此时 i 就是一个 nil 接口。
映射类型、切片类型、通道类型: 当一个映射、切片或通道未被显式初始化时,它们的值会是 nil。例如:var m map[string]int,此时 m 是一个 nil 映射。
函数类型: 未被赋值的函数变量会被初始化为 nil。例如:var f func(int) int,此时 f 是一个 nil 函数。
需要注意的是,nil 在Go语言中并不是关键字,而是预定义的标识符。与其他一些语言不同,Go语言的 nil 并不是一个通用的空值,它只用于特定的类型。当使用指针、接口、映射、切片、通道或函数时,你可以使用 nil 来表示零值或未初始化状态。
golang的内存管理的原理清楚吗?简述go内存管理机制。
golang内存管理基本是参考tcmalloc来进行的。go内存管理本质上是一个内存池,只不过内部做了很多优化:自动伸缩内存池大小,合理的切割内存块。
一些基本概念:
页Page:一块8K大小的内存空间。Go向操作系统申请和释放内存都是以页为单位的。
span : 内存块,一个或多个连续的 page 组成一个 span 。如果把 page 比喻成工人, span 可看成是小队,工人被分成若干个队伍,不同的队伍干不同的活。
sizeclass : 空间规格,每个 span 都带有一个 sizeclass ,标记着该 span 中的 page 应该如何使用。使用上面的比喻,就是 sizeclass 标志着 span 是一个什么样的队伍。
object : 对象,用来存储一个变量数据内存空间,一个 span 在初始化时,会被切割成一堆等大的 object 。假设 object 的大小是 16B , span 大小是 8K ,那么就会把 span 中的 page 就会被初始化 8K / 16B = 512 个 object 。所谓内存分配,就是分配一个 object 出去。
mheap
一开始go从操作系统索取一大块内存作为内存池,并放在一个叫mheap的内存池进行管理,mheap将一整块内存切割为不同的区域,并将一部分内存切割为合适的大小。
mheap.spans :用来存储 page 和 span 信息,比如一个 span 的起始地址是多少,有几个 page,已使用了多大等等。
mheap.bitmap 存储着各个 span 中对象的标记信息,比如对象是否可回收等等。
mheap.arena_start : 将要分配给应用程序使用的空间。
mcentral
用途相同的span会以链表的形式组织在一起存放在mcentral中。这里用途用sizeclass来表示,就是该span存储哪种大小的对象。
找到合适的 span 后,会从中取一个 object 返回给上层使用。
mcache
为了提高内存并发申请效率,加入缓存层mcache。每一个mcache和处理器P对应。Go申请内存首先从P的mcache中分配,如果没有可用的span再从mcentral中获取。
参考资料:Go 语言内存管理(二):Go 内存管理
mutex有几种模式?
mutex有两种模式:normal 和 starvation
正常模式:
所有goroutine按照FIFO的顺序进行锁获取,被唤醒的goroutine和新请求锁的goroutine同时进行锁获取,通常新请求锁的goroutine更容易获取锁(持续占有cpu),被唤醒的goroutine则不容易获取到锁。公平性:否。
饥饿模式:
所有尝试获取锁的goroutine进行等待排队,新请求锁的goroutine不会进行锁获取(禁用自旋),而是加入队列尾部等待获取锁。公平性:是。
参考链接:Go Mutex 饥饿模式,GO 互斥锁(Mutex)原理
go如何进行调度的。GMP中状态流转。
Go里面GMP分别代表:G:goroutine,M:线程(真正在CPU上跑的),P:调度器。
GMP模型
调度器是M和G之间桥梁。
go进行调度过程:
某个线程尝试创建一个新的G,那么这个G就会被安排到这个线程的G本地队列LRQ中,如果LRQ满了,就会分配到全局队列GRQ中;
尝试获取当前线程的M,如果无法获取,就会从空闲的M列表中找一个,如果空闲列表也没有,那么就创建一个M,然后绑定G与P运行。
进入调度循环:
找到一个合适的G
执行G,完成以后退出
Go什么时候发生阻塞?阻塞时,调度器会怎么做。
用于原子、互斥量或通道操作导致goroutine阻塞,调度器将把当前阻塞的goroutine从本地运行队列LRQ换出,并重新调度其它goroutine;
由于网络请求和IO导致的阻塞,Go提供了网络轮询器(Netpoller)来处理,后台用epoll等技术实现IO多路复用。
其它回答:
channel阻塞:当goroutine读写channel发生阻塞时,会调用gopark函数,该G脱离当前的M和P,调度器将新的G放入当前M。
系统调用:当某个G由于系统调用陷入内核态,该P就会脱离当前M,此时P会更新自己的状态为Psyscall,M与G相互绑定,进行系统调用。结束以后,若该P状态还是Psyscall,则直接关联该M和G,否则使用闲置的处理器处理该G。
系统监控:当某个G在P上运行的时间超过10ms时候,或者P处于Psyscall状态过长等情况就会调用retake函数,触发新的调度。
主动让出:由于是协作式调度,该G会主动让出当前的P(通过GoSched),更新状态为Grunnable,该P会调度队列中的G运行。
更多关于netpoller的内容可以参看:https://strikefreedom.top/go-netpoll-io-multiplexing-reactor
❤Go中GMP有哪些状态?
G的状态:
_Gidle:刚刚被分配并且还没有被初始化,值为0,为创建goroutine后的默认值
_Grunnable: 没有执行代码,没有栈的所有权,存储在运行队列中,可能在某个P的本地队列或全局队列中(如上图)。
_Grunning: 正在执行代码的goroutine,拥有栈的所有权(如上图)。
_Gsyscall:正在执行系统调用,拥有栈的所有权,与P脱离,但是与某个M绑定,会在调用结束后被分配到运行队列(如上图)。
_Gwaiting:被阻塞的goroutine,阻塞在某个channel的发送或者接收队列(如上图)。
_Gdead: 当前goroutine未被使用,没有执行代码,可能有分配的栈,分布在空闲列表gFree,可能是一个刚刚初始化的goroutine,也可能是执行了goexit退出的goroutine(如上图)。
_Gcopystac:栈正在被拷贝,没有执行代码,不在运行队列上,执行权在
_Gscan : GC 正在扫描栈空间,没有执行代码,可以与其他状态同时存在。
P的状态:
_Pidle :处理器没有运行用户代码或者调度器,被空闲队列或者改变其状态的结构持有,运行队列为空
_Prunning :被线程 M 持有,并且正在执行用户代码或者调度器(如上图)
_Psyscall:没有执行用户代码,当前线程陷入系统调用(如上图)
_Pgcstop :被线程 M 持有,当前处理器由于垃圾回收被停止
_Pdead :当前处理器已经不被使用
M的状态:
自旋线程:处于运行状态但是没有可执行goroutine的线程,数量最多为GOMAXPROC,若是数量大于GOMAXPROC就会进入休眠。
非自旋线程:处于运行状态有可执行goroutine的线程。
GMP能不能去掉P层?会怎么样?
P层的作用
每个 P 有自己的本地队列,大幅度的减轻了对全局队列的直接依赖,所带来的效果就是锁竞争的减少。而 GM 模型的性能开销大头就是锁竞争。
每个 P 相对的平衡上,在 GMP 模型中也实现了 Work Stealing 算法,如果 P 的本地队列为空,则会从全局队列或其他 P 的本地队列中窃取可运行的 G 来运行,减少空转,提高了资源利用率。
参考资料:https://juejin.cn/post/6968311281220583454
如果有一个G一直占用资源怎么办?什么是work stealing算法?
如果有个goroutine一直占用资源,那么GMP模型会从正常模式转变为饥饿模式(类似于mutex),允许其它goroutine使用work stealing抢占(禁用自旋锁)。
work stealing算法指,一个线程如果处于空闲状态,则帮其它正在忙的线程分担压力,从全局队列取一个G任务来执行,可以极大提高执行效率。
goroutine什么情况会发生内存泄漏?如何避免。
在Go中内存泄露分为暂时性内存泄露和永久性内存泄露。
暂时性内存泄露
获取长字符串中的一段导致长字符串未释放
获取长slice中的一段导致长slice未释放
在长slice新建slice导致泄漏
string相比切片少了一个容量的cap字段,可以把string当成一个只读的切片类型。获取长string或者切片中的一段内容,由于新生成的对象和老的string或者切片共用一个内存空间,会导致老的string和切片资源暂时得不到释放,造成短暂的内存泄漏
永久性内存泄露
goroutine永久阻塞而导致泄漏
time.Ticker未关闭导致泄漏
不正确使用Finalizer(Go版本的析构函数)导致泄漏
Go GC有几个阶段
目前的go GC采用三色标记法和混合写屏障技术。
Go GC有四个阶段:
STW,开启混合写屏障,扫描栈对象;
将所有对象加入白色集合,从根对象开始,将其放入灰色集合。每次从灰色集合取出一个对象标记为黑色,然后遍历其子对象,标记为灰色,放入灰色集合;
如此循环直到灰色集合为空。剩余的白色对象就是需要清理的对象。
STW,关闭混合写屏障;
在后台进行GC(并发)。
go竞态条件了解吗?
所谓竞态竞争,就是当两个或以上的goroutine访问相同资源时候,对资源进行读/写。
比如var a int = 0,有两个协程分别对a+=1,我们发现最后a不一定为2.这就是竞态竞争。
通常我们可以用go run -race xx.go来进行检测。
解决方法是,对临界区资源上锁,或者使用原子操作(atomics),原子操作的开销小于上锁。
如果若干个goroutine,有一个panic会怎么做?
有一个panic,那么剩余goroutine也会退出,程序退出。如果不想程序退出,那么必须通过调用 recover() 方法来捕获 panic 并恢复将要崩掉的程序。
参考理解:goroutine配上panic会怎样。
defer可以捕获goroutine的子goroutine吗?
不可以。它们处于不同的调度器P中。对于子goroutine,必须通过 recover() 机制来进行恢复,然后结合日志进行打印(或者通过channel传递error),下面是一个例子:
// 心跳函数
func Ping(ctx context.Context) error {
... code ...
go func() {
defer func() {
if r := recover(); r != nil {
log.Errorc(ctx, "ping panic: %v, stack: %v", r, string(debug.Stack()))
}
}()
... code ...
}()
... code ...
return nil
}
gRPC是什么?
基于go的远程过程调用。RPC 框架的目标就是让远程服务调用更加简单、透明,RPC 框架负责屏蔽底层的传输方式(TCP 或者 UDP)、序列化方式(XML/Json/ 二进制)和通信细节。服务调用者可以像调用本地接口一样调用远程的服务提供者,而不需要关心底层通信细节和调用过程。
进阶4
需要面试者有一定的大型项目经验经验,了解使用微服务,etcd,gin,gorm,gRPC等典型框架等模型或框架。
微服务了解吗?
微服务是一种开发软件的架构和组织方法,其中软件由通过明确定义的 API 进行通信的小型独立服务组成。微服务架构使应用程序更易于扩展和更快地开发,从而加速创新并缩短新功能的上市时间。
服务发现是怎么做的?
主要有两种服务发现机制:客户端发现和服务端发现。
客户端发现模式:当我们使用客户端发现的时候,客户端负责决定可用服务实例的网络地址并且在集群中对请求负载均衡, 客户端访问服务登记表,也就是一个可用服务的数据库,然后客户端使用一种负载均衡算法选择一个可用的服务实例然后发起请求。该模式如下图所示:
客户端发现模式
服务端发现模式:客户端通过负载均衡器向某个服务提出请求,负载均衡器查询服务注册表,并将请求转发到可用的服务实例。如同客户端发现,服务实例在服务注册表中注册或注销。
服务端发现模式
参考资料:「Chris Richardson 微服务系列」服务发现的可行方案以及实践案例
ETCD用过吗?
etcd是一个高度一致的分布式键值存储,它提供了一种可靠的方式来存储需要由分布式系统或机器集群访问的数据。它可以优雅地处理网络分区期间的领导者选举,即使在领导者节点中也可以容忍机器故障。
etcd 是用Go语言编写的,它具有出色的跨平台支持,小的二进制文件和强大的社区。etcd机器之间的通信通过Raft共识算法处理。
关于文档可以参考:v3.5 docs
GIN怎么做参数校验?
go采用validator作参数校验。
它具有以下独特功能:
使用验证tag或自定义validator进行跨字段Field和跨结构体验证。
允许切片、数组和哈希表,多维字段的任何或所有级别进行校验。
能够对哈希表key和value进行验证
通过在验证之前确定它的基础类型来处理类型接口。
别名验证标签,允许将多个验证映射到单个标签,以便更轻松地定义结构体上的验证
gin web 框架的默认验证器;
参考资料:validator package - pkg.go.dev
中间件用过吗?
Middleware是Web的重要组成部分,中间件(通常)是一小段代码,它们接受一个请求,对其进行处理,每个中间件只处理一件事情,完成后将其传递给另一个中间件或最终处理程序,这样就做到了程序的解耦。
Go解析Tag是怎么实现的?
Go解析tag采用的是反射。
具体来说使用reflect.ValueOf方法获取其反射值,然后获取其Type属性,之后再通过Field(i)获取第i+1个field,再.Tag获得Tag。
反射实现的原理在: src/reflect/type.go
中
你项目有优雅的启停吗?
所谓「优雅」启停就是在启动退出服务时要满足以下几个条件:
不可以关闭现有连接(进程)
新的进程启动并「接管」旧进程
连接要随时响应用户请求,不可以出现拒绝请求的情况
停止的时候,必须处理完既有连接,并且停止接收新的连接。
为此我们必须引用信号来完成这些目的:
启动:
监听SIGHUP(在用户终端连接(正常或非正常)结束时发出);
收到信号后将服务监听的文件描述符传递给新的子进程,此时新老进程同时接收请求;
退出:
监听SIGINT和SIGSTP和SIGQUIT等。
父进程停止接收新请求,等待旧请求完成(或超时);
父进程退出。
实现:go1.8采用Http.Server内置的Shutdown方法支持优雅关机。 然后fvbock/endless可以实现优雅重启。
参考资料:gin框架实践连载八 | 如何优雅重启和停止 - 掘金,优雅地关闭或重启 go web 项目
持久化怎么做的?
所谓持久化就是将要保存的字符串写到硬盘等设备。
最简单的方式就是采用ioutil的WriteFile()方法将字符串写到磁盘上,这种方法面临格式化方面的问题。
更好的做法是将数据按照固定协议进行组织再进行读写,比如JSON,XML,Gob,csv等。
如果要考虑高并发和高可用,必须把数据放入到数据库中,比如MySQL,PostgreDB,MongoDB等。
参考链接:Golang 持久化
面试题5
作者:Dylan2333 链接:
测开转Go开发-面经&总结_笔经面经_牛客网
www.nowcoder.com/discuss/826193?type=post&order=recall&pos=&page=1&ncTraceId=&channel=-1&source_id=search_post_nctrack&gio_id=9C5DC1FFB3FC3BE29281D7CCFC420365-1645173894793
该试题需要面试者有非常丰富的项目阅历和底层原理经验,熟练使用微服务,etcd,gin,gorm,gRPC等典型框架等模型或框架。
channel 死锁的场景
当一个channel中没有数据,而直接读取时,会发生死锁:
q := make(chan int,2)
<-q
解决方案是采用select语句,再default放默认处理方式:
q := make(chan int,2)
select{
case val:=<-q:
default:
...
}
当channel数据满了,再尝试写数据会造成死锁:
q := make(chan int,2)
q<-1
q<-2
q<-3
解决方法,采用select
func main() {
q := make(chan int, 2)
q <- 1
q <- 2
select {
case q <- 3:
fmt.Println("ok")
default:
fmt.Println("wrong")
}
}
向一个关闭的channel写数据。
注意:一个已经关闭的channel,只能读数据,不能写数据。
参考资料:Golang关于channel死锁情况的汇总以及解决方案
对已经关闭的chan进行读写会怎么样?
读已经关闭的chan能一直读到东西,但是读到的内容根据通道内关闭前是否有元素而不同。
如果chan关闭前,buffer内有元素还未读,会正确读到chan内的值,且返回的第二个bool值(是否读成功)为true。
如果chan关闭前,buffer内有元素已经被读完,chan内无值,接下来所有接收的值都会非阻塞直接成功,返回 channel 元素的零值,但是第二个bool值一直为false。
写已经关闭的chan会panic。
说说 atomic底层怎么实现的.
atomic源码位于sync\atomic
。通过阅读源码可知,atomic采用CAS(CompareAndSwap)的方式实现的。所谓CAS就是使用了CPU中的原子性操作。在操作共享变量的时候,CAS不需要对其进行加锁,而是通过类似于乐观锁的方式进行检测,总是假设被操作的值未曾改变(即与旧值相等),并一旦确认这个假设的真实性就立即进行值替换。本质上是不断占用CPU资源来避免加锁的开销。
参考资料:Go语言的原子操作atomic - 编程猎人
channel底层实现?是否线程安全。
channel底层实现在src/runtime/chan.go中
channel内部是一个循环链表。内部包含buf, sendx, recvx, lock ,recvq, sendq几个部分;
buf是有缓冲的channel所特有的结构,用来存储缓存数据。是个循环链表;
sendx和recvx用于记录buf这个循环链表中的发送或者接收的index;
lock是个互斥锁;
recvq和sendq分别是接收(<-channel)或者发送(channel <- xxx)的goroutine抽象出来的结构体(sudog)的队列。是个双向链表。
channel是线程安全的。
参考资料:Kitou:Golang 深度剖析 -- channel的底层实现
map的底层实现。
源码位于src\runtime\map.go 中。
go的map和C++map不一样,底层实现是哈希表,包括两个部分:hmap和bucket。
里面最重要的是buckets(桶),buckets是一个指针,最终它指向的是一个结构体:
// A bucket for a Go map.
type bmap struct {
tophash [bucketCnt]uint8
}
每个bucket固定包含8个key和value(可以查看源码bucketCnt=8).实现上面是一个固定的大小连续内存块,分成四部分:每个条目的状态,8个key值,8个value值,指向下个bucket的指针。
创建哈希表使用的是makemap函数.map 的一个关键点在于,哈希函数的选择。在程序启动时,会检测 cpu 是否支持 aes,如果支持,则使用 aes hash,否则使用 memhash。这是在函数 alginit() 中完成,位于路径:src/runtime/alg.go 下。
map查找就是将key哈希后得到64位(64位机)用最后B个比特位计算在哪个桶。在 bucket 中,从前往后找到第一个空位。这样,在查找某个 key 时,先找到对应的桶,再去遍历 bucket 中的 key。
关于map的查找和扩容可以参考map的用法到map底层实现分析。
select的实现原理?
select源码位于src\runtime\select.go,最重要的scase 数据结构为:
type scase struct {
c *hchan // chan
elem unsafe.Pointer // data element
}
scase.c为当前case语句所操作的channel指针,这也说明了一个case语句只能操作一个channel。
scase.elem表示缓冲区地址:
caseRecv : scase.elem表示读出channel的数据存放地址;
caseSend : scase.elem表示将要写入channel的数据存放地址;
select的主要实现位于:select.go函数:其主要功能如下:
-
锁定scase语句中所有的channel
-
按照随机顺序检测scase中的channel是否ready
2.1 如果case可读,则读取channel中数据,解锁所有的channel,然后返回(case index, true)
2.2 如果case可写,则将数据写入channel,解锁所有的channel,然后返回(case index, false)
2.3 所有case都未ready,则解锁所有的channel,然后返回(default index, false)
- 所有case都未ready,且没有default语句
3.1 将当前协程加入到所有channel的等待队列
3.2 当将协程转入阻塞,等待被唤醒
- 唤醒后返回channel对应的case index
4.1 如果是读操作,解锁所有的channel,然后返回(case index, true)
4.2 如果是写操作,解锁所有的channel,然后返回(case index, false)
参考资料:Go select的使用和实现原理.
go的interface怎么实现的?
go interface源码在runtime\iface.go中。
go的接口由两种类型实现iface和eface。iface是包含方法的接口,而eface不包含方法。
iface
对应的数据结构是(位于src\runtime\runtime2.go):
type iface struct {
tab *itab
data unsafe.Pointer
}
可以简单理解为,tab表示接口的具体结构类型,而data是接口的值。
itab:
type itab struct {
inter *interfacetype //此属性用于定位到具体interface
_type *_type //此属性用于定位到具体interface
hash uint32 // copy of _type.hash. Used for type switches.
_ [4]byte
fun [1]uintptr // variable sized. fun[0]==0 means _type does not implement inter.
}
属性interfacetype类似于_type,其作用就是interface的公共描述,类似的还有maptype、arraytype、chantype…其都是各个结构的公共描述,可以理解为一种外在的表现信息。interfaetype和type唯一确定了接口类型,而hash用于查询和类型判断。fun表示方法集。
eface
与iface基本一致,但是用_type直接表示类型,这样的话就无法使用方法。
type eface struct {
_type *_type
data unsafe.Pointer
}
这里篇幅有限,深入讨论可以看:深入研究 Go interface 底层实现
go的reflect 底层实现
go reflect源码位于src\reflect\下面,作为一个库独立存在。反射是基于接口实现的。
Go反射有三大法则:
反射从接口映射到反射对象;
法则1
反射从反射对象映射到接口值;
法则2
只有值可以修改(settable),才可以修改反射对象。
Go反射基于上述三点实现。我们先从最核心的两个源文件入手type.go和value.go.
type用于获取当前值的类型。value用于获取当前的值。
参考资料:The Laws of Reflection, 图解go反射实现原理
go GC的原理知道吗?
如果需要从源码角度解释GC,推荐阅读(非常详细,图文并茂):
https://draveness.me/golang/docs/part3-runtime/ch07-memory/golang-garbage-collector/
go里用过哪些设计模式 ?
Go设计模式常见面试题【2022版】
12 赞同 · 4 评论文章
go的调试/分析工具用过哪些。
go的自带工具链相当丰富,
go cover : 测试代码覆盖率;
godoc: 用于生成go文档;
pprof:用于性能调优,针对cpu,内存和并发;
race:用于竞争检测;
进程被kill,如何保证所有goroutine顺利退出
goroutine监听SIGKILL信号,一旦接收到SIGKILL,则立刻退出。可采用select方法。
var wg = &sync.WaitGroup{}
func main() {
wg.Add(1)
go func() {
c1 := make(chan os.Signal, 1)
signal.Notify(c1, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
fmt.Printf("goroutine 1 receive a signal : %v\n\n", <-c1)
wg.Done()
}()
wg.Wait()
fmt.Printf("all groutine done!\n")
}
说说context包的作用?你用过哪些,原理知道吗?
context可以用来在goroutine之间传递上下文信息,相同的context可以传递给运行在不同goroutine中的函数,上下文对于多个goroutine同时使用是安全的,context包定义了上下文类型,可以使用background、TODO创建一个上下文,在函数调用链之间传播context,也可以使用WithDeadline、WithTimeout、WithCancel 或 WithValue 创建的修改副本替换它,听起来有点绕,其实总结起就是一句话:context的作用就是在不同的goroutine之间同步请求特定的数据、取消信号以及处理请求的截止日期。
关于context原理,可以参看:小白也能看懂的context包详解:从入门到精通
grpc为啥好,基本原理是什么,和http比呢
官方介绍:gRPC 是一个现代开源的高性能远程过程调用 (RPC) 框架,可以在任何环境中运行。它可以通过对负载平衡、跟踪、健康检查和身份验证的可插拔支持有效地连接数据中心内和跨数据中心的服务。它也适用于分布式计算的最后一英里,将设备、移动应用程序和浏览器连接到后端服务。
区别:
- rpc是远程过程调用,就是本地去调用一个远程的函数,而http是通过 url和符合restful风格的数据包去发送和获取数据;
- rpc的一般使用的编解码协议更加高效,比如grpc使用protobuf编解码。而http的一般使用json进行编解码,数据相比rpc更加直观,但是数据包也更大,效率低下;
- rpc一般用在服务内部的相互调用,而http则用于和用户交互;
相似点:
都有类似的机制,例如grpc的metadata机制和http的头机制作用相似,而且web框架,和rpc框架中都有拦截器的概念。grpc使用的是http2.0协议。
官网:gRPC
etcd怎么搭建的,具体怎么用的
熔断怎么做的
服务降级怎么搞
1亿条数据动态增长,取top10,怎么实现
进程挂了怎么办
nginx配置过吗,有哪些注意的点
设计一个阻塞队列
mq消费阻塞怎么办
性能没达到预期,有什么解决方案
编程系列
实现使用字符串函数名,调用函数。
思路:采用反射的Call方法实现。
package main
import (
"fmt"
"reflect"
)
type Animal struct{
}
func (a *Animal) Eat(){
fmt.Println("Eat")
}
func main(){
a := Animal{}
reflect.ValueOf(&a).MethodByName("Eat").Call([]reflect.Value{})
}
(Goroutine)有三个函数,分别打印"cat", "fish","dog"要求每一个函数都用一个goroutine,按照顺序打印100次。
此题目考察channel,用三个无缓冲channel,如果一个channel收到信号则通知下一个。
package main
import (
"fmt"
"time"
)
var dog = make(chan struct{})
var cat = make(chan struct{})
var fish = make(chan struct{})
func Dog() {
<-fish
fmt.Println("dog")
dog <- struct{}{}
}
func Cat() {
<-dog
fmt.Println("cat")
cat <- struct{}{}
}
func Fish() {
<-cat
fmt.Println("fish")
fish <- struct{}{}
}
func main() {
for i := 0; i < 100; i++ {
go Dog()
go Cat()
go Fish()
}
fish <- struct{}{}
time.Sleep(10 * time.Second)
}
两个协程交替打印10个字母和数字
思路:采用channel来协调goroutine之间顺序。
主线程一般要waitGroup等待协程退出,这里简化了一下直接sleep。
package main
import (
"fmt"
"time"
)
var word = make(chan struct{}, 1)
var num = make(chan struct{}, 1)
func printNums() {
for i := 0; i < 10; i++ {
<-word
fmt.Println(1)
num <- struct{}{}
}
}
func printWords() {
for i := 0; i < 10; i++ {
<-num
fmt.Println("a")
word <- struct{}{}
}
}
func main() {
num <- struct{}{}
go printNums()
go printWords()
time.Sleep(time.Second * 1)
}
启动 2个groutine 2秒后取消, 第一个协程1秒执行完,第二个协程3秒执行完。
思路:采用ctx, _ := context.WithTimeout(context.Background(), time.Second*2)实现2s取消。协程执行完后通过channel通知,是否超时。
package main
import (
"context"
"fmt"
"time"
)
func f1(in chan struct{}) {
time.Sleep(1 * time.Second)
in <- struct{}{}
}
func f2(in chan struct{}) {
time.Sleep(3 * time.Second)
in <- struct{}{}
}
func main() {
ch1 := make(chan struct{})
ch2 := make(chan struct{})
ctx, _ := context.WithTimeout(context.Background(), 2*time.Second)
go func() {
go f1(ch1)
select {
case <-ctx.Done():
fmt.Println("f1 timeout")
break
case <-ch1:
fmt.Println("f1 done")
}
}()
go func() {
go f2(ch2)
select {
case <-ctx.Done():
fmt.Println("f2 timeout")
break
case <-ch2:
fmt.Println("f2 done")
}
}()
time.Sleep(time.Second * 5)
}
代码: @中二的灰太狼
当select监控多个chan同时到达就绪态时,如何先执行某个任务?
可以在子case再加一个for select语句。
func priority_select(ch1, ch2 <-chan string) {
for {
select {
case val := <-ch1:
fmt.Println(val)
case val2 := <-ch2:
priority:
for {
select {
case val1 := <-ch1:
fmt.Println(val1)
default:
break priority
}
}
fmt.Println(val2)
}
}
}
1.并发安全性
Go语言中的并发安全性是什么?如何确保并发安全性?
解答:
并发安全性是指在并发编程中,多个goroutine对共享资源的访问不会导致数据竞争和不确定的结果。
为了确保并发安全性,可以采取以下措施:
使用互斥锁(Mutex):通过使用互斥锁来保护共享资源的访问,一次只允许一个goroutine访问共享资源,从而避免竞争条件。
使用原子操作(Atomic Operations):对于简单的读写操作,可以使用原子操作来保证操作的原子性,避免竞争条件。
使用通道(Channel):通过使用通道来进行goroutine之间的通信和同步,避免共享资源的直接访问。
使用同步机制:使用同步机制如等待组(WaitGroup)、条件变量(Cond)等来协调多个goroutine的执行顺序和状态。
通过以上措施,可以确保并发程序的安全性,避免数据竞争和不确定的结果。
什么是Go中的context(上下文)?它的作用是什么?如何创建和使用context?
在 Go 语言中,context
(上下文)是一个用于在不同 Goroutine 之间传递请求范围数据、管理超时和取消操作的机制。它是 Go 标准库提供的一个重要的并发工具,用于处理并发操作时的资源管理、协调和错误处理。
context
主要用于以下场景:
-
传递请求范围数据: 在一个请求或任务的多个 Goroutine 之间传递数据,如请求 ID、用户信息等。
-
取消操作: 可以使用
context
来优雅地取消 Goroutine 的执行,避免无限期地等待。 -
超时处理: 可以设置 Goroutine 的最大执行时间,以避免由于某些操作阻塞而导致程序无法响应。
-
截断链: 多个
context
可以组合成一个链,方便在不同层级的 Goroutine 之间传递。
以下是如何创建和使用 context
的示例:
package main
import (
"context"
"fmt"
"time"
)
func main() {
// 创建一个父上下文
parentContext := context.Background()
// 派生一个带有超时的上下文
ctx, cancel := context.WithTimeout(parentContext, 3*time.Second)
defer cancel()
// 启动一个Goroutine来模拟任务
go func(ctx context.Context) {
select {
case <-ctx.Done():
fmt.Println("Goroutine cancelled:", ctx.Err())
case <-time.After(2 * time.Second):
fmt.Println("Goroutine completed")
}
}(ctx)
// 阻塞等待用户按下回车键
fmt.Println("Press Enter to exit...")
fmt.Scanln()
}
在这个示例中,我们首先使用 context.Background()
创建了一个父上下文 parentContext
。然后,我们使用 context.WithTimeout
派生了一个带有 3 秒超时的上下文 ctx
。
在一个 Goroutine 中,我们使用 select
语句来监听 ctx.Done()
通道。如果上下文被取消(超时或手动取消),Goroutine 就会收到通知。
最后,我们在主函数中等待用户按下回车键,以确保程序在 Goroutine 完成之前保持运行。
总之,context
在 Go 语言中用于管理并发操作的资源、超时和取消。通过适当地使用 context
,您可以编写更健壮、可靠的并发代码,以及更好地控制 Goroutine 的执行。
defer
Go语言中的defer关键字有什么作用?请给出一个使用defer的示例。
defer关键字用于延迟函数的执行,即在函数退出前执行某个操作。defer通常用于释放资源、关闭文件、解锁互斥锁等清理操作,以确保在函数执行完毕后进行处理。
也可以使用defer语句结合time包实现函数执行时间的统计。
代码示例:
下面是一个使用defer的示例,打开文件并在函数退出前关闭文件:
package main
import (
"fmt"
"os"
)
func main() {
file, err := os.Open("file.txt")
if err != nil {
fmt.Println("Error opening file:", err)
return
}
defer func() {
err := file.Close()
if err != nil {
fmt.Println("Error closing file:", err)
}
}()
// 使用文件进行操作
// ...
fmt.Println("File operations completed")
}
在上述代码中,我们使用defer关键字延迟了文件的关闭操作,确保在函数执行完毕后关闭文件。这样可以避免忘记关闭文件而导致资源泄漏。
指针
面试题:Go语言中的指针有什么作用?请给出一个使用指针的示例。
解答:
指针是一种变量,存储了另一个变量的内存地址。通过指针,我们可以直接访问和修改变量的值,而不是对变量进行拷贝。
指针在传递大型数据结构和在函数间共享数据时非常有用。
代码示例
下面是一个使用指针的示例,交换两个变量的值:
package main
import "fmt"
func swap(a, b *int) {
temp := *a
*a = *b
*b = temp
}
func main() {
x := 10
y := 20
fmt.Println("Before swap:", x, y)
swap(&x, &y)
fmt.Println("After swap:", x, y)
}
在上述代码中,我们定义了一个swap函数,接收两个指针作为参数,并通过指针交换了两个变量的值。在主函数中,我们通过取地址操作符&获取变量的指针,并将指针传递给swap函数。通过使用指针,我们实现了变量值的交换。
4.map
Go语言中的map是什么?请给出一个使用map的示例。
解答:
map是一种无序的键值对集合,也称为字典。map中的键必须是唯一的,而值可以重复。map提供了快速的查找和插入操作,适用于需要根据键快速检索值的场景。
代码示例:
下面是一个使用map的示例,存储学生的成绩信息:
package main
import "fmt"
func main() {
// 创建一个map,键为学生姓名,值为对应的成绩
grades := make(map[string]int)
// 添加学生的成绩
grades["Alice"] = 90
grades["Bob"] = 85
grades["Charlie"] = 95
// 获取学生的成绩
aliceGrade := grades["Alice"]
bobGrade := grades["Bob"]
charlieGrade := grades["Charlie"]
// 打印学生的成绩
fmt.Println("Alice's grade:", aliceGrade)
fmt.Println("Bob's grade:", bobGrade)
fmt.Println("Charlie's grade:", charlieGrade)
}
在上述代码中,我们使用make函数创建了一个map,键的类型为string,值的类型为int。然后,我们通过键来添加学生的成绩信息,并通过键来获取学生的成绩。通过使用map,我们可以根据学生的姓名快速查找对应的成绩。
请注意,map是无序的,每次迭代map的顺序可能不同。
5.map的有序遍历
map是无序的,每次迭代map的顺序可能不同。如果需要按特定顺序遍历map,应该怎么做呢?
解答:
在Go语言中,map是无序的,每次迭代map的顺序可能不同。如果需要按特定顺序遍历map,可以采用以下步骤:
创建一个切片来保存map的键。
遍历map,将键存储到切片中。
对切片进行排序。
根据排序后的键顺序,遍历map并访问对应的值。
示例代码:
以下是一个示例代码,展示如何按键的升序遍历map:
package main
import (
"fmt"
"sort"
)
func main() {
m := map[string]int{
"b": 2,
"a": 1,
"c": 3,
}
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Println(k, m[k])
}
}
21
22
23
24
25
在上述代码中,我们创建了一个map m,其中包含了键值对。然后,我们创建了一个切片 keys,并遍历map将键存储到切片中。接下来,我们对切片进行排序,使用sort.Strings函数对切片进行升序排序。最后,我们根据排序后的键顺序遍历map,并访问对应的值。
通过以上步骤,我们可以按照特定顺序遍历map,并访问对应的键值对。请注意,这里使用的是升序排序,如果需要降序排序,可以使用sort.Sort(sort.Reverse(sort.StringSlice(keys)))进行排序。
6.切片和数组
Go语言中的slice和数组有什么区别?请给出一个使用slice的示例。
解答:
在Go语言中,数组和切片(slice)都是用于存储一组相同类型的元素。它们的区别在于长度的固定性和灵活性。数组的长度是固定的,而切片的长度是可变的。
代码示例:
下面是一个使用切片的示例,演示了如何向切片中添加元素:
package main
import "fmt"
func main() {
// 创建一个切片
numbers := []int{1, 2, 3, 4, 5}
// 向切片中添加元素
numbers = append(numbers, 6)
numbers = append(numbers, 7, 8, 9)
// 打印切片的内容
fmt.Println(numbers)
}
在上述代码中,我们使用[]int语法创建了一个切片numbers,并初始化了一些整数。然后,我们使用append函数向切片中添加元素。通过使用切片,我们可以动态地添加和删除元素,而不需要事先指定切片的长度。
需要注意的是,切片是基于数组的一种封装,它提供了更便捷的操作和灵活性。切片的底层是一个指向数组的指针,它包含了切片的长度和容量信息。
以上是关于Go语言中切片和数组的区别以及使用切片的示例。切片是Go语言中常用的数据结构,它提供了更灵活的长度和操作方式,适用于动态变化的数据集合。
7.切片移除元素
怎么移除切片中的数据?
解答
要移除切片中的数据,可以使用切片的切片操作或使用内置的append函数来实现。以下是两种常见的方法:
- 使用切片的切片操作:
利用切片的切片操作,可以通过指定要移除的元素的索引位置来删除切片中的数据。
例如,要移除切片中的第三个元素,可以使用切片的切片操作将切片分为两部分,并将第三个元素从中间移除。
package main
import "fmt"
func main() {
numbers := []int{1, 2, 3, 4, 5}
// 移除切片中的第三个元素
indexToRemove := 2
numbers = append(numbers[:indexToRemove], numbers[indexToRemove+1:]...)
fmt.Println(numbers) // 输出: [1 2 4 5]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
在上述代码中,我们使用切片的切片操作将切片分为两部分:numbers[:indexToRemove]表示从开头到要移除的元素之前的部分,numbers[indexToRemove+1:]表示从要移除的元素之后到末尾的部分。然后,我们使用append函数将这两部分重新连接起来,从而实现了移除元素的操作。
- 使用append函数:
另一种方法是使用append函数,将要移除的元素之前和之后的部分重新组合成一个新的切片。这种方法更适用于不知道要移除的元素的索引位置的情况。
package main
import "fmt"
func main() {
numbers := []int{1, 2, 3, 4, 5}
// 移除切片中的元素3
elementToRemove := 3
for i := 0; i < len(numbers); i++ {
if numbers[i] == elementToRemove {
numbers = append(numbers[:i], numbers[i+1:]...)
break
}
}
fmt.Println(numbers) // 输出: [1 2 4 5]
}
在上述代码中,我们使用for循环遍历切片,找到要移除的元素的索引位置。一旦找到匹配的元素,我们使用append函数将要移除的元素之前和之后的部分重新连接起来,从而实现了移除元素的操作。
无论是使用切片的切片操作还是使用append函数,都可以实现在切片中移除数据的操作。
8.panic和recover
Go语言中的panic和recover有什么作用?请给出一个使用panic和recover的示例。
解答:
panic和recover是Go语言中用于处理异常的机制。当程序遇到无法处理的错误时,可以使用panic引发一个异常,中断程序的正常执行。而recover用于捕获并处理panic引发的异常,使程序能够继续执行。
代码示例:
下面是一个使用panic和recover的示例,处理除数为零的情况:
package main
import "fmt"
func divide(a, b int) int {
defer func() {
if err := recover(); err != nil {
fmt.Println("Error:", err)
}
}()
if b == 0 {
panic("division by zero")
}
return a / b
}
func main() {
result := divide(10, 0)
fmt.Println("Result:", result)
}
21
22
执行结果如下:
Error: division by zero Result: 0
在上述代码中,我们定义了一个divide函数,用于执行除法运算。在函数中,我们使用panic关键字引发一个异常,当除数为零时,会引发一个"division by zero"的异常。
然后,我们使用defer和recover来捕获并处理这个异常,打印出错误信息。通过使用recover,我们可以避免程序因为异常而崩溃,而是继续执行后续的代码。
9.互斥锁
什么是互斥锁(Mutex)?在Go语言中如何使用互斥锁来保护共享资源?
互斥锁是一种并发编程中常用的同步机制,用于保护共享资源的访问。
在Go语言中,可以使用sync包中的Mutex类型来实现互斥锁。通过调用Lock方法来获取锁,保护共享资源的访问,然后在使用完共享资源后调用Unlock方法释放锁。
package main
import (
"fmt"
"sync"
)
var (
counter int
mutex sync.Mutex
)
func increment() {
mutex.Lock()
counter++
mutex.Unlock()
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
increment()
}()
}
wg.Wait()
fmt.Println("Counter:", counter)
}
在上述代码中,我们定义了一个全局变量counter和一个sync.Mutex类型的互斥锁mutex。在increment函数中,我们使用mutex.Lock()获取锁,对counter进行递增操作,然后使用mutex.Unlock()释放锁。通过使用互斥锁,我们确保了对counter的并发访问的安全性。
10.自旋
解释一下并发编程中的自旋状态?
自旋状态是并发编程中的一种状态,指的是线程或进程在等待某个条件满足时,不会进入休眠或阻塞状态,而是通过不断地检查条件是否满足来进行忙等待。
在自旋状态下,线程会反复执行一个忙等待的循环,直到条件满足或达到一定的等待时间。 这种方式可以减少线程切换的开销,提高并发性能。然而,自旋状态也可能导致CPU资源的浪费,因为线程会持续占用CPU时间片,即使条件尚未满足。
自旋状态通常用于以下情况:
在多处理器系统中,等待某个共享资源的释放,以避免线程切换的开销。
在短暂的等待时间内,期望条件能够快速满足,从而避免进入阻塞状态的开销。
需要注意的是,自旋状态的使用应该谨慎,并且需要根据具体的场景和条件进行评估。如果自旋时间过长或条件不太可能很快满足,那么使用自旋状态可能会浪费大量的CPU资源。在这种情况下,更适合使用阻塞或休眠等待的方式。
总之,自旋状态是一种在等待条件满足时不进入休眠或阻塞状态的并发编程技术。它可以减少线程切换的开销,但需要权衡CPU资源的使用和等待时间的长短。
11.原子操作和锁
原子操作和锁的区别是什么?
原子操作和锁是并发编程中常用的两种同步机制,它们的区别如下:
作用范围:
原子操作(Atomic Operations):原子操作是一种基本的操作,可以在单个指令级别上执行,保证操作的原子性。原子操作通常用于对共享变量进行读取、写入或修改等操作,以确保操作的完整性。
锁(Lock):锁是一种更高级别的同步机制,用于保护临界区(Critical Section)的访问。锁可以用于限制对共享资源的并发访问,以确保线程安全。
使用方式:
原子操作:原子操作是通过硬件指令或特定的原子操作函数来实现的,可以直接应用于变量或内存位置,而无需额外的代码。
锁:锁是通过编程语言提供的锁机制来实现的,需要显式地使用锁的相关方法或语句来保护临界区的访问。
粒度:
原子操作:原子操作通常是针对单个变量或内存位置的操作,可以在非常细粒度的层面上实现同步。
锁:锁通常是针对一段代码或一组操作的访问进行同步,可以控制更大粒度的临界区。
性能开销:
原子操作:原子操作通常具有较低的性能开销,因为它们是在硬件级别上实现的,无需额外的同步机制。
锁:锁通常具有较高的性能开销,因为它们需要进行上下文切换和线程同步等操作。
综上所述,原子操作和锁是两种不同的同步机制,用于处理并发编程中的同步问题。原子操作适用于对单个变量的读写操作,具有较低的性能开销。而锁适用于对一段代码或一组操作的访问进行同步,具有更高的性能开销。选择使用原子操作还是锁取决于具体的场景和需求。
需要注意的是,原子操作通常用于对共享变量进行简单的读写操作,而锁更适用于对临界区的访问进行复杂的操作和保护。在设计并发程序时,需要根据具体的需求和性能要求来选择合适的同步机制。
12.Goroutine
Go语言中的goroutine是什么?请给出一个使用goroutine的示例。
goroutine是Go语言中轻量级的并发执行单元,可以同时执行多个goroutine,而不需要显式地管理线程的生命周期。goroutine由Go运行时(runtime)进行调度,可以在并发编程中实现并行执行。
下面是一个使用goroutine的示例,计算斐波那契数列:
package main
import (
"fmt"
"sync"
)
func fibonacci(n int, wg *sync.WaitGroup) {
defer wg.Done()
x, y := 0, 1
for i := 0; i < n; i++ {
fmt.Println(x)
x, y = y, x+y
}
}
func main() {
var wg sync.WaitGroup
wg.Add(2)
go fibonacci(10, &wg)
go fibonacci(5, &wg)
wg.Wait()
}
在上述代码中,我们使用go关键字启动了两个goroutine,分别计算斐波那契数列的前10个和前5个数字。通过使用goroutine,我们可以并行地执行这两个计算任务,而不需要显式地创建和管理线程。
通道
Go语言中的通道(channel)是什么?请给出一个使用通道的示例。
通道是用于在goroutine之间进行通信和同步的机制。通道提供了一种安全的、阻塞的方式来发送和接收数据。通过通道,可以实现多个goroutine之间的数据传递和同步。
下面是一个使用通道的示例,计算两个数的和:
package main
import "fmt"
func sum(a, b int, c chan int) {
result := a + b
c <- result // 将结果发送到通道
}
func main() {
// 创建一个整型通道
c := make(chan int)
// 启动一个goroutine来计算两个数的和
go sum(10, 20, c)
// 从通道接收结果
result := <-c
fmt.Println("Sum:", result)
}
在上述代码中,我们定义了一个sum函数,用于计算两个数的和,并将结果发送到通道c中。在main函数中,我们创建了一个整型通道c,然后启动一个goroutine来执行sum函数,并将结果发送到通道中。最后,我们通过从通道中接收结果,获取计算的和并打印出来。
通过使用通道,我们实现了goroutine之间的数据传递和同步。在示例中,通道c用于将计算结果从goroutine发送到主goroutine,实现了数据的传递和同步。
select
Go语言中的select语句是什么?请给出一个使用select语句的示例。
select语句是Go语言中用于处理通道操作的一种机制。它可以同时监听多个通道的读写操作,并在其中任意一个通道就绪时执行相应的操作。
代码示例:
下面是一个使用select语句的示例,从两个通道中接收数据:
package main
import "fmt"
func main() {
ch1 := make(chan int)
ch2 := make(chan int)
go func() {
ch1 <- 10
}()
go func() {
ch2 <- 20
}()
select {
case num := <-ch1:
fmt.Println("Received from ch1:", num)
case num := <-ch2:
fmt.Println("Received from ch2:", num)
}
}
在上述代码中,我们创建了两个整型通道ch1和ch2。然后,我们启动了两个goroutine,分别向通道ch1和ch2发送数据。在主goroutine中,我们使用select语句监听这两个通道的读操作,并在其中任意一个通道就绪时执行相应的操作。在示例中,我们从就绪的通道中接收数据,并打印出来。
通过使用select语句,我们可以实现对多个通道的并发操作,并根据就绪的通道执行相应的操作。这在处理并发任务时非常有用。
协程和通道
Go语言如何通过goroutine和channel实现并发的?请给出一个并发编程的示例。
Go语言通过goroutine和channel实现并发。goroutine是一种轻量级的线程,可以同时执行多个goroutine,而不需要显式地管理线程的生命周期。
channel是用于goroutine之间通信的管道。下面是一个简单的并发编程示例,计算斐波那契数列:
package main
import "fmt"
func fibonacci(n int, c chan int) {
x, y := 0, 1
for i := 0; i < n; i++ {
c <- x
x, y = y, x+y
}
close(c)
}
func main() {
c := make(chan int)
go fibonacci(10, c)
for num := range c {
fmt.Println(num)
}
}
在上述代码中,我们使用goroutine启动了一个计算斐波那契数列的函数,并通过channel进行通信。主函数从channel中接收计算结果并打印。通过goroutine和channel的结合,我们实现了并发计算斐波那契数列的功能。
runtime
Go语言中的runtime包是用来做什么的?请给出一个使用runtime包的示例。
runtime包是Go语言的运行时系统,提供了与底层系统交互和控制的功能。它包含了与内存管理、垃圾回收、协程调度等相关的函数和变量。
下面是一个使用runtime包的示例,获取当前goroutine的数量:
package main
import (
"fmt"
"runtime"
)
func main() {
num := runtime.NumGoroutine()
fmt.Println("Number of goroutines:", num)
}
垃圾回收
Go语言中的垃圾回收是如何工作的?请给出一个使用垃圾回收的示例。
Go语言中的垃圾回收器(Garbage Collector)是自动管理内存的机制,用于回收不再使用的内存。垃圾回收器会自动检测不再使用的对象,并释放其占用的内存空间。
下面是一个使用垃圾回收的示例,创建一个大量的临时对象:
package main
import (
"fmt"
"runtime"
"time"
)
func createObjects() {
for i := 0; i < 1000000; i++ {
_ = make([]byte, 1024)
}
}
func main() {
createObjects()
time.Sleep(time.Second) // 等待垃圾回收器执行
var stats runtime.MemStats
runtime.ReadMemStats(&stats)
fmt.Println("Allocated memory:", stats.Alloc)
}
打印结果:
Allocated memory: 77344
在上述代码中,我们通过循环创建了大量的临时对象。然后,我们使用time.Sleep函数等待垃圾回收器执行。最后,我们使用runtime.ReadMemStats函数读取内存统计信息,并打印出已分配的内存大小。
通过使用垃圾回收器,我们可以自动管理内存,避免手动释放不再使用的对象。垃圾回收器会在适当的时机自动回收不再使用的内存,从而提高程序的性能和可靠性。