文章目录
1. 数据结构
1.1 切片
1.1.1 说一下切片的数据结构
type slice struct {
array unsafe.Pointer //指向底层数组的指针
len int //长度
cap int //容量
}
切片是一个结构体,有三个成员分别是指针、长度、容量,该指针是指向的是切片底层数组的首地址,因为数组的内存是连续的,根据其头指针就可以获取到后续地址。
1.1.2 切片扩容策略
使用append追加元素,如果切片的容量不够就会进行扩容,扩容有三种策略:
- 期望容量大于前容量的两倍,就会使用期望容量。
- 期望容量小于当前容量的两倍,并且切片的容量小于1024,容量翻倍。
- 切片容量已经大于1024,扩容增加25%。
1.1.3 怎么拷贝切片
- 使用copy内置函数,底层是通过memove将整块内存中的内容拷贝到新开辟的一块内存空间中。
1.2 map
1.2.1 什么是map
map是Golang中的一种以键值对格式存储数据的一种数据结构。
1.2.2 说一下map的底层数据结构
map的底层数据结构是hmap结构体,hmap中有一个buckets成员,它指向了一个由bucket组成的数组,每个bucket可以存放最多8个元素,bucket的底层是bmap结构体。
1.2.3 说一下map的扩容机制
map会在以下两种情况进行扩容。
等量扩容
原因:map的溢出桶太多
解决:一旦map中出现了过多的溢出桶,就会创建新桶保存数据,GC会清理老的溢出桶并释放内存。
步骤:
- 创建一组新桶和预创建的溢出桶。
- 将原有桶数组设置到oldbuckets上,将新的空桶设置到buckets上。
增量扩容
原因:map的负载因子超过6.5,map存储的元素过多。
解决:将桶的数量翻倍,将旧桶的元素分流到两个新创建的桶中。
map扩容时不是一个原子操作,所以在扩容时还要判断当前map是否已经处于扩容状态,避免二次扩容。
1.2.4 什么是Hash冲突,如何解决
Hash函数的输入范围大于输出范围,所以我们在map存储大量的值时一定会发生冲突。
解决:
在Golang中使用拉链法解决Hash冲突,Golang使用的是数组+链表作为map的底层数据结构。类似一个二维数组,一维数组里面的元素是链表。
1.2.4 map删除
使用delete函数,如果遇到map正在扩容时,会对即将操作的桶进行分流,分流结束后再删除对应的键值对。
1.3 string
1.3.1 说一下string的数据结构
type StringHeader struct {
Data uintptr
Len int
}
-
string在运行时是一个结构体,成员是一个指针和长度变量。
-
在Golang中,string实际是由字符组成的数组,而且是一个只读数组。
-
string作为一个只读类型,我们不能直接向string追加元素或修改,所有在string上的操作都是通过拷贝内存实现的。
-
虽然string不支持直接修改,但是我们可以将其转换为
[]byte
后进行修改,最后转换成string类型。
1.3.2 说一下字符串的拼接
+
fmt.Sprint
strings.Join()
bytes.Buffer()
1.4 struct
1.4.1 结构体可以比较吗?
可以,但要满足两个条件:
- 结构体中的类型不能包含Map、Slice、Function,但是可以使用其指针。
- 两个不同的结构体比较,字段个数、类型、顺序必须一致。
2. 语言基础
2.1 函数调用
2.1.1 函数参数如何分配(入参,出参)
从右到左函数入参和出参的内存空间在栈上分配,Golang使用栈作为参数和返回值传递的方式设计意味着编译器更加简单,更容易维护,但是牺牲了函数调用的性能。
2.1.2 说一下参数传递(值传递、引用传递)
值传递
- 使用值传递时,函数内部会对参数进行拷贝,被调用方和调用者持有内容相同但不相关的两份数据。
引用传递
- 函数调用时传递该参数的指针,被调用方和调用者持有相同的数据,任意一方做出修改都会影响到另一方。
- Golang使用传值的方式,无论使用基本数据类型、结构体、指针,都会在函数内部对其进行拷贝。将指针作为参数传入时,在函数内部也会对指针进行复制,就会出现有两个指针指向同一内存地址。
- 传递较大结构体时,应使用其指针。
2.2 接口
2.2.1 Golang的接口和Java/C++接口有什么区别?
- Golang中的接口使用隐式声明,只要类型的方法与接口中的方法一致,我们就认为该类型实现了接口。
- Java/C++的接口必须要一个实体类去显式声明实现该接口。
2.3 反射
2.3.1 反射的作用
反射能够动态地,在程序运行时修改变量,判断类型,是否实现了接口,以及动态地调用方法等功能,框架都是基于反射写的。
2.3.2 反射三大法则
- 从interface{}变量可以反射出反射对象。
- 从反射对象可以获取interface{}
- 要修改反射变量,其值必须可设置。
3. 常用关键字
3.1 for&range
3.1.1 for range
for range遍历对象是它的副本,相当于把被遍历的对象拷贝了一份,在for range中对被遍历对象的操作无效。
3.2 select
3.2.1 说一下Golang的select
- select类似switch语句,但是其中的case必须包含一个channel的收/发操作,如果有多个case都被触发,那么select会随机选择一个case去执行。
- select能够让goroutine同时等待多个channel的读或写,在某个channel读/写操作完成前,select会一直阻塞当前goroutine。
3.2.2 select阻塞操作
- 一个select中多个case,select会随机选择一个case执行,避免出现饥饿问题。
- 如果case中的操作发生了阻塞,那么select会阻塞当前goroutine。
3.2.3 select的非阻塞收发
- 通常情况select操作会阻塞当前goroutine,等待case中的某个channel完成收发,如果select语句中出现了default,那么就会遇到两种情况。
- 当存在可收发的channel时,直接执行channel中对应的case。
- 不存在可收发的channel时,执行default。
3.2.4 select直接阻塞
使用空的select可以阻塞住当前goroutine,导致goroutine无法被唤醒,陷入永久休眠。
3.2.5 单一管道
- 当前select语句中只有一个case,编译器会将该select转换成if语句。
- 如果channel是nil时,select会将当前goroutine挂起,陷入永久休眠。
3.2.6 非阻塞操作
- 当select中包含两个case,并且其中一个是default时,这个select就是非阻塞的操作。
发送:
-
如果该channel是无缓冲channel,并且没有接收者时,会直接执行default,不会造成阻塞。
-
如果该channel是有缓冲channel,缓冲区满,并且没有接收者时,会直接执行default,不会造成阻塞。
接收:
-
如果该channel是无缓冲channel,并且没有发送者时,会直接执行default,不会造成阻塞。
-
如果该channel是有缓冲的channel,channel缓冲区没有数据,并且没有发送者时,会执行default,不会造成阻塞。
3.3 defer
3.3.1 什么是defer
- defer是一个关键字,它是一种延迟调用机制。在函数return之前执行。多个defer按照先进后出的顺序执行。
- defer的生效时期是返回值赋值之后,函数返回之前,所以在函数返回之前,我们还可以通过defer操作返回值。
- 使用
recover
进行异常捕获时,必须要在defer中执行。
3.4 panic&recover
3.4.1 panic是什么?
panic
是Golang的一种异常机制,在我们的程序发生错误时,panic立刻停止执行当前函数后的剩余代码,并且在当前goroutine中递归调用defer。recover
可以终止panic造成的程序崩溃,它是一个只能在defer中发挥作用的函数,在其他作用域外不会发挥作用。- panic允许多层嵌套defer使用。
3.4.2 panic触发defer的作用域
panci只会触发当前goroutine的defer。
3.5 make&new
3.5.1 说一下Make和New的区别
- make和new创建的对象都是存储在堆上的。
- make的作用是初始化内置数据结构,channel、map、slice。
- new的作用是返回传入类型的指针,并且该指针指向的值是当前类型的0值。
4. 并发编程
4.1 context
4.1.1 说一下context包
- context是用来管理多个goroutine之间取消信号、参数传递、设置超时的一种上下文技术。
- context最大的作用就是在goroutine树结构中对信号进行同步以减少系统资源的浪费,RPC、HTTP每一个请求都是通过单独的goroutine去处理。
- 在一个请求中我们可能会创建多个goroutine,而context的作用就是在不同的goroutine之间请求特定的数据、同步取消信号、以及处理请求的截止日期。
- 假如goroutine树最上层的goroutine出现了执行错误,那么最上层的goroutine就要将取消信号同步给下层goroutine。
4.1.2 说一下context有哪些函数?
默认上下文,返回预先初始化好的私有变量background或todo
- context.Background,它是context上下文的默认值,所有其他的上下文都应该从它衍生出来。
- context.TODO,它应该在不确定使用哪种上下文的时候使用。
取消信号
- context.WithCancel,该函数可以从context.Context中衍生出一个新的上下文对象,并返回用于取消该上下文的函数,一旦我们执行该取消函数,当前上下文以及它的子上下文都会被取消,所有的goroutine都会同步收到这一取消信号,并停止运行。
- context.WithDeadline,该函数会判断父上下文的截止日期与当前日期是否相等,如果相等,任务超时,将当前日期+超时时间作为参数传入
- contetx.WithTimeout,内部调用了WithDeadline,传入一个超时的时间参数。
传值
- context.WithValue,传入父上下文,创建一个子上下文,可以向其设置一个键值对,返回一个valueCtx类型。
- 当通过valueCtx类型获取参数时,如果当前context中没有该属性,就会依次往上查询,直到返回nil为止。
context主要还是用作多个goroutine组成的树结构中同步取消信号,以减少对资源的消耗和占用
context的传值场景一般是用作用户的认证令牌和分布式追踪ID。
4.2 Golang调度器
4.2.1 进程&线程&协程&多线程?
进程
- 进程是指系统中运行的一个程序,进程是系统进行资源分配的独立的实体,每个进程拥有独立的地址空间。
- 一个进程可以拥有多个线程,每个线程使用其所属进程的栈空间。
- 进程之间通过管道、信号量、消息队列、共享内存进行通信。
线程
- 线程是进程的实体,是进程的一条执行路径。
- 线程是cpu独立运行和独立调度的基本单位。
- 线程之间的通信需要互斥手段来保证数据一致性(加锁)
- 每个线程会占用1M以上的内存空间。
- 线程的上下文切换大概需要1us。
协程
- 协程是一种用户态的轻量级的线程,在Golang中协程的调度由调度器控制。
- 协程拥有自己的寄存器上下文和栈空间。
- 在Golang中,可以使用go 关键字创建一个协程执行单元。
- goroutine只需要4kb左右内存。
- 直接切换协程没有内核开销,Golang调度器对goroutine的上下文切换大概只需要0.2us.
多线程
- 从软件或硬件上实现的多个线程并发执行的技术叫做多线程。
- 好处:
- 使用多线程可以把执行时间长的任务放到后台处理,比如文件I/O。
- 充分发挥多核CPU优势,让系统并发执行效率更高。
- 缺点:
- 需要更多内存空间
- 多个线程对同一资源出现竞争时,要注意线程安全。
并发
- 线程根据CPU分配的时间片交替执行任务,由于CPU的切换速度很快,感觉不到线程被切换。
- 单核CPU只能并发。
并行
- 能够真正的在同一时间执行多个线程,依赖于CPU多核技术。最大并行数=CPU核心数。
4.2.2 什么是goroutine泄露,如何避免?
- 老的goroutine得不到释放,新的goroutine还在不停的创建,就会导致goroutine泄露,短时间发现不了问题,最终会拖垮服务器。
泄露情况分类
- channel导致泄露
- 两个goroutine对一个无缓冲的channel进行收发操作,其中一个协程挂掉,另一个协程就阻塞,导致channel泄露。
- 向channel发送完数据之后要关闭channel
- 对nil channel的收发操作都会造成goroutine泄露。
- 加锁后未关锁。
- 死循环
- 空select
4.2.3 说一下Golang的调度器
- Golang的调度器就是用来解决并发编程中遇到的资源调度问题。
- Golang的调度器通过使用CPU数量相等的线程减少线程频繁切换的开销,同时在一个线程上执行额外开销更低的的协程来降低资源占用,完成高并发编程。
调度器的历史
- 单线程调度器:
- 只有40行代码。
- 程序只能存在一个活跃线程,由GM模型组成。
- 多线程调度器:
- 允许运行多线程程序。
- 全局锁存在严重的同步竞争问题。
- 任务窃取调度器:
- 引入了P处理器,构成了目前的GMP模型,
- 在处理器P的基础上实现了基于工作窃取的调度器。
- 在某些情况下goroutine不会让出线程,出现了饥饿问题。
- GC时间过长STW,服务器不稳定。
- 抢占式调度器:
- 基于协作的抢占式调度器 go1.12~1.13
- 编译器在函数调用时检查当前goroutine是否发起了抢占请求,实现基于协作的抢占式调度。
- 还是存在STW问题。
- for{}循环,GC时间过长这些边缘情况无法解决。
- 基于信号的抢占式调度器 go1.14~now
- 实现基于信号的真抢占式调度。
- Golang在GC的STW时和扫描栈时会触发抢占调度。
- 抢占的点不够多,不能覆盖全部边缘情况
- 基于协作的抢占式调度器 go1.12~1.13
- 非均匀访问调度器(提案)
- 对运行时的各种资源进行分区,实现非常复杂,停留在理论阶段。
4.2.4 Golang调度器的GMP模型
G
- 表示一个goroutine,它是一个待执行任务。
- 在运行时goroutine中的地位和线程差不多,但是它占用了更小的内存,也降低了上下文的开销。
- goroutine只存在于Golang的运行时,它是Golang在用户态提供的线程,作为一种粒度更细的资源调度单元。能够在高并发场景下更高效地利用CPU。
- 一个G最多占用10ms时间。
M
- 表示操作系统的线程,由操作系统的调度器进行调度和管理。
- 最多只有CPU核心数个线程能运行。
- 默认会创建CPU核心数个线程,不会频繁触发操作系统的线程调度和上下文切换,所有的调度都发生在用户态,由Go语言调度器进行触发,减少额外开销。
P
- 表示处理器,可以被看做是运行在线程上的本地调度器。
- P是线程M和G的中间层,它能提供线程需要的上下文环境,也会负责调用线程上的等待队列。
- P的调度,每一个M都能够执行多个G,能让G在I/O操作时让出计算机资源,提高M的利用率。
- 调度器在启动时会创建CPU核心数个P,P会绑定到不同的M上。
4.2.5 基于信号的抢占式调度器的执行过程
- 程序启动时,注册一个信号处理函数
SIGURG
。 - 在触发GC的栈扫描时将运行中的G标记为可抢占,并调用抢占函数将G挂起。
- 抢占函数向线程发送信号
- 操作系统接收到信号后会中断正在运行的线程并执行预先注册的信号处理函数。
- 根据抢占信号,修改当前寄存器,让程序回到用户态时执行异步抢占。
- 抢占函数会修改当前G的状态让当前函数休眠并让出线程,P会选择其他的G继续执行。
4.2.6 goroutine协程调度过程
- 通过go关键字来创建一个goroutine。
- 检查P的本地队列是否已满,加入P本地队列否则加入全局G队列。
- M和P是1:1的关系,M会从绑定的P的本地队列中获取一个G来执行,如果P的本地队列为空,M就会从其他P的本地队列或全局G队列中偷取G来执行。
- M调度G的过程是一个循环机制。
- 如果M执行G时发生了阻塞,runtime就会把这个M从P中摘除,将P绑定到空闲M,如果没有空闲M就将P绑定到新建M上。
- 当M系统调用结束时,这个G会尝试获取一个空闲的P,并放入这个P的本地队列,如果获取不到P,那么这个M将会变成休眠状态,加入到空闲线程中,然后将这个G放入全局G队列。
5. 同步原语&锁
5.1 Mutex互斥锁
5.1.1 为什么要加锁?
Golang作为原生支持协程的语言,涉及并发、多线程编程时,需要锁机制的介入。锁能保证多个goroutine在访问同一内存时出现的竞争问题。
5.1.2 什么是Mutex互斥锁
-
互斥锁是在并发编程环境中对共享资源进行控制的一种手段,在Golang中由sync.Mutex实现。
-
Mutex底层是一个结构体,其中state表示锁的状态,sema表示信号量,其中由sema来控制锁。
type Mutex struct { state int32 //互斥锁的状态 sema uint32 //信号量 }
-
互斥锁加锁和解锁之间的代码不能被多个goroutine同时调用。
-
互斥锁加锁后,所有需要执行被锁资源的goroutine都要进行等待。
5.1.3 Mutex互斥锁的模式?
正常模式
- 正常模式下,一个尝试获取锁的goroutine先会自旋3次,若自旋之后获取不到该锁,就会进入Mutex的信号量队列排队等待。
- 队列中所有的等待者都会遵循FIFO先入先出的顺序排队。
- 当锁被释放时,队列中的第一个goroutine并不会直接获取锁,而是要同正在自旋,还未进入等待队列的goroutine进行竞争;这时,还在进行自旋的goroutine更有优势。因为自旋的goroutine在cpu上运行,而且处于自旋状态的goroutine可以有多个,而队列中被唤醒的goroutine只有一个。
- 等待队列中的第一个goroutine和自旋的goroutine竞争锁失败后,就会回到队列首位。
- 当goroutine等待加锁的时间超过了1ms,为了防止尾部延迟和保证公平,Mutex就会从正常模式切换为饥饿模式。
饥饿模式
- Mutex的所有权从执行Unlock的goroutine直接交给等待队列中的第一个goroutine。
- 后来的goroutine不会进入自旋状态,而是直接进入队列尾部排队。
- 当一个goroutine获取锁后,在两种情况下Mutex会将饥饿模式切换为正常模式:
- 该goroutine等待时间小于1ms。
- 该goroutine是Mutex等待队列中的最后一个goroutine。
5.1.4 Mutext如何加锁、如何解锁?
加锁
- 获取Mutex的goroutine使用Lock方法进行加锁,将MutexLocked位改为1,完成加锁。
- 如果mutexLocked不等于0,说明该Mutex已经被其他goroutine持有,那么这个goroutine就会进入自旋,等待goroutine释放锁。
- 自旋是一种多线程的同步机制,goroutine在进入自旋的时候会一直保持对CPU的占用,只需要检查某个条件是否为真。在多核CPU上自旋可以避免goroutine的切换开销。
- goroutine进入自旋的条件:
- 该互斥锁是普通模式
- 当前goroutine为了获取这把锁进行自旋的次数小于4次。
- 当前机器上至少存在一个正在运行的P,并且P的本地队列为空。
- 自旋的具体内容:
- 执行30次PAUSE命令,该命令只会占用CPU时间。
解锁
- goroutine如果解锁一个已被解锁的互斥锁,会抛异常。
- 互斥锁如果处于饥饿模式,解锁后会将所有权交给等待队列中的第一个goroutine。
- 互斥锁处于正常模式,如果没有其他goroutine等待锁的释放,或者已经有被唤醒的goroutine获得了锁,就会直接返回。
5.2 RWMutex读写互斥锁
5.2.1 什么是读写互斥锁,有什么作用?
-
常见的服务器,读操作的比例比写操作高很多,读操作直接也不会互相影响,所以我们可以使用读写锁做读写分离,提高服务器的处理性能。
-
读写锁,读共享,写独占,写的优先级更高。
-
读写互斥锁
sync.RWMutex
是细粒度的互斥锁,它不限制资源的并发读,但是读写、写写操作无法并行执行。type RWMutex struct { w Mutex writerSem uint32 readerSem uint32 readerCount int32 readerWait int32 }
w
— 复用互斥锁提供的能力;writerSem
和readerSem
— 分别用于写等待读和读等待写:readerCount
存储了当前正在执行的读操作的goroutine数量;readerWait
表示正在执行写操作阻塞时,等待的读操作个数;
5.2.2 RWMutex加锁解锁
写锁
加锁:
- 加锁步骤与互斥锁一致,获取锁后阻塞后续读操作。
- 加锁时可能会出现的情况:有其他goroutine获取了读锁,获取了写锁的goroutine就会进入休眠状态等待,等待所有的读操作完成之后,RWMutex发送writeSem信号量唤醒休眠的持有写锁的goroutine。
- 写锁加锁,进行写操作。
解锁:
- 调用Unlock释放写锁。
- 循环释放所有希望获取读锁而陷入休眠的goroutine。
读锁
加锁:
- 通过原子函数将readerCount+1
- 如果readerCount为负数,说明有写操作正在进行,当前goroutine就会陷入休眠,等待读锁释放。
- 如果readerCount不为负数,说明没有写操作正在进行,当前goroutine就会获得读锁,返回成功。
解锁:
- 调用RUlock解锁。
- 通过原子函数将readerCount-1,直到readerCount=0时,读锁释放。
5.2.3 什么是死锁?
死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象。
5.3.4 死锁产生场景
- 一个线程两次调用lock,第一次调用加锁,第二次再调用,因为想要获取锁就要等待锁的释放,而想要获取的锁正被自己持有,自身则会因为第二次加锁操作陷入休眠,无法唤醒,产生死锁。
- A线程获取1锁,B线程获取了2锁,线程A试图获取2锁,而线程B试图获取1锁,两个线程都要挂起等对方释放锁,所以就会产生死锁。
- 一个goroutine内使用无缓冲channel进行收发操作。
- 一个以上的goroutine中,使用无缓冲channel,但是发送操作先于收取操作先执行。
- 一个以上的goroutine,使用2个无缓冲channel,互相等待对方,造成死锁。
- channel、Mutex、RWMutex
- 空select,或者是有一个阻塞case,没有default。
- 通过nil channel收发数据。
5.3 WaitGroup
5.3.1 什么是WaitGroup?
- WaitGroup底层是一个结构体
type WaitGroup struct {
noCopy noCopy //不能拷贝
state1 [3]uint32 //状态、信号量
}
- noCopy — 保证 sync.WaitGroup 不会被开发者通过再赋值的方式拷贝;
- state1 — 存储着状态和信号量;
- 可以等待一组 Goroutine 的返回。
- 可以将原本顺序执行的代码在多个goroutine中并发执行,提高程序处理速度。
5.3.2 WaitGroup方法
Add
- 用来更新counter计数器,计数器一旦变为负数,程序就会崩溃。
- 计数器归零时,就会唤醒当前goroutine。
Wait
- 在计数器的值大于0时,且当前goroutine未休眠时让当前goroutine进行休眠。
- 当计数器归零时,当前goroutine会被唤醒。
- 同时可以有多个goroutine被阻塞,这些被阻塞的goroutine会被同时唤醒。
Done
- Add方法的封装,Add(-1)
5.4 Once
5.4.1 Once有什么用?
type Once struct {
done uint32
m Mutex
}
- 底层是一个结构体,Once保证程序运行期间只会执行一次传入的函数。
5.5 Cond
让一组goroutine在满足条件时被唤醒,在Cond初始化时,要传入一个互斥锁
type Cond struct {
noCopy noCopy
L Locker
notify notifyList
checker copyChecker
}
5.5.1 Cond方法
- Wait,让goroutine陷入休眠,将其添加到一个休眠链表的尾部
- Signal,将休眠链表中第一个goroutine唤醒,该goroutine是等待最久的goroutine。
- Boardcast,将休眠链表中的所有goroutine唤醒。
5.5 channel管道
5.5.1 什么是channel?
- channel是Go语言核心的数据结构和Goroutine之间的一种通信方式;也是支撑 Go 语言高性能并发编程模型的重要数据结构。
- 多线程使用共享内存来传递数据,而Go使用channel(通信)来共享内存。
- 两个goroutine一个向channel发送数据,一个从channel接收数据,两者独立运行,并不存在直接关联,但是可以完成通信。
type hchan struct {
qcount uint
dataqsiz uint
buf unsafe.Pointer
elemsize uint16
closed uint32
elemtype *_type
sendx uint
recvx uint
recvq waitq
sendq waitq
lock mutex
}
5.5.2 channel收发顺序
- 先从一个channel中接收数据的goroutine先获得数据。
- 先向一个channel中发送数据的goroutine拥有先发送数据的权利。
5.5.3 什么是无锁管道
- 无锁管道,也叫无锁队列、乐观并发队列,并不是一种具体的数据结构或对象,而是一种并发编程思想。
- 无锁管道的三个类型:
- 同步channel,没有缓冲区,发送数据和接收数据的goroutine要并发执行。
- 异步channel,带有缓冲区,基于环形的传统的生产者消费者模型。
- chan struct{},struct{}类型不占用内存空间,不需实现缓冲和直接发送的语义。
- 官方将乐观锁项目搁置了。
5.5.4 channel有缓冲和没有缓冲的区别?
有缓冲
- 有缓冲的channel,收发操作是异步的,只要缓冲区没有满,goroutine就可以一直发送数据。
没有缓冲
- 没有缓冲的channel,收发操作是同步的,收发操作必须在两个goroutine中同步执行,否则就会造成死锁。
5.5.5 channel发送/接收数据的流程
发送,在发送数据执行时,channel会加锁,防止并发环境下多个goroutine修改数据。
- 如果channel没有缓冲区,那么发送数据时,必须有另一个协程进行数据接收,否则就会阻塞,造成死锁。
- 如果channel有缓冲区,那么发送数据时,只要缓冲区没有满,数据就会被暂存到缓冲区中,否则向缓冲区已满的channel发送数据,就会发生阻塞,直到其他goroutine从channel中获取数据。
接收,可以从已关闭的channel中接收值
- 如果channel没有缓冲区,那么接收数据时,需要有另一个协程进行数据发送,否则就会阻塞,造成死锁。
- 如果channel有缓冲区,且缓冲区中存在数据,可以直接接收;如果缓冲区中没有数据,同时也没有goroutine向channel中发送数据,channel就会阻塞。
- 可以使用select机制,非阻塞接收。
6. 内存管理
6.0.1 说一下内存管理器的设计原理
- Go内存管理器由Mutator(用户程序)、Allocator(内存分配器)、Collector(垃圾收集器)组成
- 用户申请内存时会通过内存分配器申请新内存,而分配器会负责从堆中初始化对应的内存区域。
6.1 内存分配器
6.1.1 内存分配器的分配方法
- 线性分配器
- 空闲链表分配器
6.1.2 内存分配器是怎么进行分配的(对象大小)
-
内存分配器根据对象大小进行处理,有利于调高内存分配器的性能。
类别 大小 微对象 (0, 16B)
先使用微型分配器,再依次尝试线程缓存、中心缓存、堆分配内存。 小对象 [16B, 32KB]
依次尝试使用线程缓存、中心缓存、堆分配内存 大对象 (32KB, +∞)
直接在堆上分配内存
6.1.3 内存分配器的组件
- 管理单元、线程缓存、中心缓存、页堆。
6.2 垃圾收集器
6.2.1 什么是垃圾收集器(GC)
垃圾收集器就是用来回收不再使用的对象和内存的一种技术。
6.2.2 垃圾清理的四个阶段
- 清理终止阶段
- 标记阶段(GCmark)
- 标记终止阶段(GCmarktermination)
- 清理阶段(GCoff)
6.2.3 GolangGC算法
标记清除
- 标记阶段:从根对象触发,并标记堆中所有存活的对象。
- 清除阶段:遍历堆中的全部对象,回收未标记的垃圾对象并将回收的内存加入空闲链表,清除阶段会造成STW。
三色标记
- 将程序中的对象标记为黑、白、灰三类。
- 白色对象:潜在的垃圾,内存可能会被GC回收。
- 灰色对象:活跃的对象,内部包括指向白色对象的指针,GC会扫描灰色对象的子对象。
- 黑色对象:活跃的对象,包括不存在任何引用外部指针的对象,以及从根可达的对象。
- GC标记过程
- GC开始时,(程序中不存在黑色对象),将所有根对象标记为灰色。
- GC只会从灰色对象集合中取出对象开始扫描。
- 当灰色对象集合中不存在任何对象时,标记阶段结束。
- GC清除执行流程
- 从灰色对象集合中,选择一个灰色对象并将其标记为黑色对象。
- 将黑色对象指向的所有对象标记为灰色对象。
- 重复前两个步骤直到不存在灰色对象。
- GC开始回收白色对象。
6.2.3 内存屏障技术
- 内存屏障技术,是一种屏障指令,可以让CPU或编译器在执行内存相关操作时遵循特定的约束。
- 内存屏障前执行的操作一定先于内存屏障后的操作。
- 屏障技术就是在并发或者增量标记过程中保证三色不变性的技术。
插入写屏障
简单,将有存活可能的对象都标记成灰色,满足强三色不变性。
缺点:
- 增加写入指针的额外开销。
- 使用插入写屏障会扫描两次栈对象,会造成STW。
删除写屏障
在白色对象或灰色对象的引用被删除时,将白色对象标记为灰色对象。满足弱三色不变性。
混合写屏障(插入写屏障+删除写屏障)
- GC 开始,将栈上的全部可达对象标记为黑色,之后便不再需要对栈进行重新扫描。
- GC 期间,任何在栈上新创建的对象都标记为黑色。
- 写屏障将被删除的对象标记为灰色。
- 写屏障将新添加的对象标记为灰色。
6.2.4 三色不变性
为了在并发或增量的标记算法中保证正确性,我们需要达到两种三色不变性
强三色不变性
黑色对象不会指向白色对象,只会指向黑色对象或灰色对象。
弱三色不变性
黑色对象指向的白色对象必须包含一条由灰色对象经由多个白色对象的可达路径。
6.2.5 目前GC使用的垃圾回收机制
三色标记法+混合写屏障技术
6.2.6 GC暂停(STW)使用的优化策略
增量垃圾回收
增量地标记和清除垃圾,降低STW的最长时间。
并发垃圾回收
- 利用多核CPU,在用户程序运行时并发标记和清除垃圾。
- 开启读写屏障,利用多核优势使用户程序和GC并行。
6.3 栈内存管理
6.3.1 栈内存分配
- 在Go中栈区内存由编译器进行分配,GC进行回收。
- 栈内存中存储着函数的入参以及局部变量,这些变量会随着函数的创建而创建,函数的返回而死亡,一般不会在程序中长期存在。
- 栈内存不能直接控制而是交给编译器去分配。
存储在栈上的对象
- 函数的入参和出参。
- 函数内部的局部变量。
存储在堆上的对象
- 对象大于32KB。
- 全局对象。
- 使用make&new创建的对象
- 指针指向的对象存储在堆上。
6.3.2 栈内存逃逸分析
栈内存廉价、堆内存昂贵。
-
逃逸分析是用来决定指针作用域的方法。
-
Go语言的编译器使用逃逸分析来决定哪些变量应该在堆上分配(new、make),哪些变量应该在栈上分配。
-
Go语言逃逸分析遵循两个不变性
- 指向栈对象的指针不能存在于堆中。
- 指向栈对象的指针不能在栈对象被回收后继续存在。
-
常见的内存逃逸场景:
- 将函数内的局部变量通过指针返回(生命周期延长)
- 发送指针或带有指针的值到channel中(生命周期未知)
- 切片中存储指针或带有指针的值。
- 切片的底层数组被重新分配(append)
- 在interface{}上调用方法。
- 指针指向的数据都会在堆上分配。