相比较于其他语言, Go 有什么优势或者特点
- Go 允许跨平台编译,编译出来的是二进制的可执行文件,直接部署在对应系统上即可运行
- Go 在语言层面上天生支持并发编程,通过 goroutine 和 channel 实现。channel 的理论依据是 CSP 并发模型, 即所谓的通过通信来共享内存;Go 在 runtime 运行时里实现了属于自己的调度机制:GMP,降低了内核态和用户态的切换成本。
- Go 是静态类型语言, 代码风格是强制性的统一,如果没有按照规定来,会编译不通过。
Golang 里的 GMP 模型
GMP 模型是 golang 自己的一个调度模型,它抽象出了下面三个结构:
- G: 也就是 goroutine,由 Go runtime 管理。我们可以认为它是用户级别的线程。
- P: processor 调度器。每当有 goroutine 要创建时,会被添加到 P 上的 goroutine 本地队列上,如果 P 的本地队列已满,则会维护到全局队列里。
- M: 系统线程。在 M 上有调度函数,它是真正的调度执行者,M 需要跟 P 绑定,M会优先从 P 的本地队列获取 goroutine 来执行;如果本地队列没有,从全局队列获取,如果全局队列也没有,会从其他的 P 上偷取 goroutine。
goroutine 有什么特点,和线程相比
- 内存占用
goroutine 非常轻量,创建时初始内存分配只有 2KB,运行过程中当栈空间不够用时,会自动扩容。同时,自身存储了执行 stack 信息,用于在调度时能恢复上下文信息。而线程比较重,一般初始大小有几 MB(不同系统分配不同, 一般1-8MB)。 - 创建/销毁/调度成本
线程是由操作系统调度,是操作系统的调度基本单位。而 golang 实现了自己的调度机制,线程创建, 销毁和调度都是内核级的交互, 线程切换会保存上下文信息(寄存器), 私有的栈, 线程状态等, 保存成本高, 线程切换要消耗 1000-1500 纳秒 (一个纳秒平均可以执行 12-18 条指令, 执行指令的条数会减少 12000-18000), 性能开销较大, 而 goroutine 是用户态线程, 由 Go runtime 管理, 并不需要进入内核, 在用户态进行上下文切换, goroutine 的切换约为 200 纳秒(寄存器), 相当于 2400-3600 条指令, 因此 goroutine 切换成本要比 threads 小的多
Go 的垃圾回收机制
Go 采用的是三色标记法,将内存里的对象分为了三种:
- 白色对象:未被使用的对象;
- 灰色对象:当前对象有引用对象,但是还没有对引用对象继续扫描过;
- 黑色对象,对上面提到的灰色对象的引用对象已经全部扫描过了,下次不用再扫描它了。
当垃圾回收开始时,Go 会把根对象标记为灰色,其他对象标记为白色,然后从根对象遍历搜索,按照上面的定义去不断的对灰色对象进行扫描标记。当没有灰色对象时,表示所有对象已扫描过,然后就可以开始清除白色对象了。
channel 的内部实现是怎么样的
channel 内部数据结构通过队列实现, 有一个缓冲队列作为缓冲区,队列的长度是创建chan时指定的。维护了两个 goroutine 等待队列,一个是待发送数据的 goroutine 队列,另一个是待读取数据的 goroutine 队列。
从channel中读数据,如果channel缓冲区为空或者没有缓冲区,当前goroutine会被阻塞;向channel中写数据,如果channel缓冲区已满或者没有缓冲区,当前goroutine会被阻塞。被阻塞的goroutine将会被挂在channel的等待队列中:
因读阻塞的goroutine会被向channel写入数据的goroutine唤醒
因写阻塞的goroutine会被从channel读数据的goroutine唤醒
直到有其他 goroutine 执行了与之相反的读写操作,将它重新唤起。
并且内部维护了一个互斥锁, 来保证线程安全
向channel写数据
- 如果recvq队列不为空,说明缓冲区没有数据或者没有缓冲区,此时直接从recvq等待队列中取出一个G,并把数据写入,最后把该G唤醒,结束发送过程;
- 如果缓冲区有空余位置,则把数据写入缓冲区中,结束发送过程;
- 如果缓冲区没有空余位置,将当前G加入sendq队列,进入休眠,等待被读goroutine唤醒;
从channel读数据
- 如果等待发送队列sendq不为空,且没有缓冲区,直接从sendq队列中取出G,把G中数据读出,最后把G唤醒,结束读取过程;
- 如果等待发送队列sendq不为空,说明缓冲区已满,从缓冲队列中首部读取数据,从sendq等待发送队列中取出G,把G中的数据写入缓冲区尾部,结束读取过程;
- 如果缓冲区中有数据,则从缓冲区取出数据,结束读取过程;
对已经关闭的 channel 进行读写,会怎么样
当 channel 被关闭后,如果继续往里面写数据,程序会直接 panic 退出。且关闭已经关闭的channel会发生Panic, 关闭值为nil的channel会发生Panic
如果是读取关闭后的 channel,不会产生 pannic,还可以读到数据。但关闭后的 channel 没有数据可读取时,将得到零值,即对应类型的默认值。
// 判断当前 channel 是否被关闭
if v, ok := <-ch; !ok {
fmt.Println("channel 已关闭,读取不到数据")
}
还可以使用下面的写法不断的获取 channel 里的数据:
// range迭代从channel中读数据, 只有当channel关闭后才能退出循环, 否则没有数据了也会一直阻塞
for data := range ch {
// get data dosomething
}
map 为什么是不安全的
map 在并发编程中, 读是线程安全的, 写不是
map 在扩缩容时,需要进行数据迁移,迁移的过程并没有采用锁机制防止并发操作,而是会对某个标识位标记为 1,表示此时正在迁移数据。如果有其他 goroutine 对 map 也进行写操作,当它检测到标识位为 1 时,将会直接 panic。
如果我们想要使用并发安全的 map,则需要使用 sync.map。sync.map是拿空间换时间
- 通过冗余的两个数据结构(read、dirty), 减少加锁对性能的影响
- 使用只读数据(read),避免读写冲突
- 动态调整,miss次数多了之后,将dirty数据提升为read
- double-checking
- 延迟删除。 删除一个键值只是打标记,只有在提升dirty的时候才清理删除的数据
- 优先从read读取、更新、删除,因为对 read 的读取不需要锁
sync.Map在读多写少性能比较好,否则并发性能很差
concurrent-map 提供了一种高性能的解决方案:通过对内部 map 进行分片,降低锁粒度,从而达到最少的锁等待时间(锁冲突)。
map 的 key 为什么得是可比较类型的
map 的 key、value 是存在 buckets 数组里的,每个 bucket 又可以容纳 8 个 key-value 键值对。当要插入一个新的 key - value 时,会对 key 进行 hash 运算得到一个 hash 值,然后根据 hash 值的低几位(取几位取决于桶的数量)来决定命中哪个 bucket。
在命中某个 bucket 后,又会根据 hash 值的高 8 位来决定是 8 个 key 里的哪个位置。若发生了 hash 冲突,即该位置上已经有其他 key 存在了,则会去其他空位置寻找插入。如果全都满了,则使用 overflow 指针指向一个新的 bucket,重复刚刚的寻找步骤。
从上面的流程可以看出,在判断 hash 冲突,即该位置是否已有其他 key 时,肯定是要进行比较的,所以 key 必须得是可比较类型的。像 slice、map、function 就不能作为 key。
mutex 的正常模式、饥饿模式、自旋
-
正常模式
当 mutex 调用 Unlock() 方法释放锁资源时,如果发现有正在阻塞并等待唤起的 Goroutine 队列时,则会将队头的 Goroutine 唤起。队头的 goroutine 被唤起后,会采用 CAS 这种乐观锁的方式去修改占有标识位,如果修改成功,则表示占有锁资源成功了,当前占有成功的 goroutine 就可以继续往下执行了。 -
饥饿模式
由于上面的 Goroutine 唤起后并不是直接的占用资源,而是使用 CAS 方法去尝试性占有锁资源。如果此时有新来的 Goroutine,那么它也会调用 CAS 方法去尝试性的占有资源。对于 Go 的并发调度机制来讲,会比较偏向于 CPU 占有时间较短的 Goroutine 先运行,即新来的 Goroutine 比较容易占有资源,而队头的 Goroutine 一直占用不到,导致饿死。
针对这种情况,Go 采用了饥饿模式。即通过判断队头 Goroutine 在超过一定时间后还是得不到资源时,会在 Unlock 释放锁资源时,直接将锁资源交给队头 Goroutine,并且将当前状态改为饥饿模式。
后面如果有新来的 Goroutine 发现是饥饿模式时, 则会直接添加到等待队列的队尾。
- 自旋
如果 Goroutine 占用锁资源的时间比较短,那么每次释放资源后,都调用信号量来唤起正在阻塞等候的 goroutine,将会很浪费资源。
因此在符合一定条件后,mutex 会让等候的 Goroutine 去空转 CPU,在空转完后再次调用 CAS 方法去尝试性的占有锁资源,直到不满足自旋条件,则最终才加入到等待队列里。
Go 的逃逸行为是指
在传统的编程语言里,会根据程序员指定的方式来决定变量内存分配是在栈还是堆上,比如声明的变量是值类型,则会分配到栈上,或者 new 一个对象则会分配到堆上。
在 Go 里变量的内存分配方式则是由编译器来决定的。如果变量在作用域(比如函数范围)之外,还会被引用的话,那么称之为发生了逃逸行为,此时将会把对象放到堆上,即使声明为值类型;如果没有发生逃逸行为的话,则会被分配到栈上,即使 new 了一个对象。
context 使用场景及注意事项
Go 里的 context 有 cancelCtx 、timerCtx、valueCtx。它们分别是用来通知取消、通知超时、存储 key - value 值。context 的 注意事项如下:
- context 的 Done() 方法往往需要配合 select {} 使用,以监听退出。
- 尽量通过函数参数来暴露 context,不要在自定义结构体里包含它。
- WithValue 类型的 context 应该尽量存储一些全局的 data,而不要存储一些可有可无的局部 data。
- context 是并发安全的。
- 一旦 context 执行取消动作,所有派生的 context 都会触发取消。
context 是如何一层一层通知子 context
当 ctx, cancel := context.WithCancel(父Context)时,会将当前的 ctx 挂到父 context 下,然后开个 goroutine 协程去监控父 context 的 channel 事件,一旦有 channel 通知,则自身也会触发自己的 channel 去通知它的子 context, 关键代码如下
go func() {
select {
case <-parent.Done():
child.cancel(false, parent.Err())
case <-child.Done():
}
}()
waitgroup 原理
waitgroup 内部维护了一个计数器,当调用 wg.Add(1) 方法时,就会增加对应的数量;当调用 wg.Done() 时,计数器就会减一。直到计数器的数量减到 0 时,就会唤起之前因为 wg.Wait() 而阻塞住的 goroutine。
sync.Once 原理
内部维护了一个标识位,当它 == 0 时表示还没执行过函数,此时会加锁修改标识位,然后执行对应函数。后续再执行时发现标识位 != 0,则不会再执行后续动作了
type Once struct {
done uint32
m Mutex
}
func (o *Once) Do(f func()) {
// 原子加载标识值,判断是否已被执行过
if atomic.LoadUint32(&o.done) == 0 {
o.doSlow(f)
}
}
func (o *Once) doSlow(f func()) { // 还没执行过函数
o.m.Lock()
defer o.m.Unlock()
if o.done == 0 { // 再次判断下是否已被执行过函数
defer atomic.StoreUint32(&o.done, 1) // 原子操作:修改标识值
f() // 执行函数
}
}
定时器原理
一开始,timer 会被分配到一个全局的 timersBucket 时间桶。每当有 timer 被创建出来时,就会被分配到对应的时间桶里了。
为了不让所有的 timer 都集中到一个时间桶里,Go 会创建 64 个这样的时间桶,然后根据 当前 timer 所在的 Goroutine 的 P 的 id 去哈希到某个桶上:
// assignBucket 将创建好的 timer 关联到某个桶上
func (t *timer) assignBucket() *timersBucket {
id := uint8(getg().m.p.ptr().id) % timersLen
t.tb = &timers[id].timersBucket
return t.tb
}
接着 timersBucket 时间桶将会对这些 timer 进行一个最小堆的维护,每次会挑选出时间最快要达到的 timer。如果挑选出来的 timer 时间还没到,那就会进行 sleep 休眠;如果 timer 的时间到了,则执行 timer 上的函数,并且往 timer 的 channel 字段发送数据,以此来通知 timer 所在的 goroutine。
gorouinte 泄漏有哪些场景
gorouinte 里有关于 channel 的操作,如果没有正确处理 channel 的读取,会导致 channel 一直阻塞住, goroutine 不能正常结束
Slice 注意点
- Slice 的扩容机制
如果 Slice 要扩容的容量大于 2 倍当前的容量,则直接按想要扩容的容量来 new 一个新的 Slice,否则继续判断当前的长度 len,如果 len 小于 1024,则直接按 2 倍容量来扩容,否则一直循环新增 1/4,直到大于想要扩容的容量。除此之外,还会根据 slice 的类型做一些内存对齐的调整,以确定最终要扩容的容量大小。 - Slice 的一些注意写法
// =========== 第一种
a := make([]string, 5)
fmt.Println(len(a), cap(a)) // 输出5 5
a = append(a, "aaa")
fmt.Println(len(a), cap(a)) // 输出6 10
// 总结: 由于make([]string, 5) 则默认会初始化5个 空的"", 因此后面 append 时,则需要2倍了
// =========== 第二种
a:=[]string{}
fmt.Println(len(a), cap(a)) // 输出0 0
a = append(a, "aaa")
fmt.Println(len(a), cap(a)) // 输出1 1
// 总结:由于[]string{}, 没有其他元素, 所以append 按 需要扩容的 cap 来
// =========== 第三种
a := make([]string, 0, 5)
fmt.Println(len(a), cap(a)) // 输出0 5
a = append(a, "aaa")
fmt.Println(len(a), cap(a)) // 输出1 5
// 总结:注意和第一种的区别,这里不会默认初始化5个,所以后面的append容量是够的,不用扩容
// =========== 第四种
b := make([]int, 1, 3)
a := []int{1, 2, 3}
copy(b, a)
fmt.Println(len(b)) // 输出1
// 总结:copy 取决于较短 slice 的 len, 一旦最小的len结束了,也就不再复制了
- range slice
以下代码的执行是不会一直循环下去的,原因在于 range 的时候会 copy 这个 slice 上的 len 属性到一个新的变量上,然后根据这个 copy 值去遍历 slice,因此遍历期间即使 slice 添加了元素,也不会改变这个变量的值了
v := []int{1, 2, 3}
for i := range v {
v = append(v, i)
}
另外,range 一个 slice 的时候是进行一个值拷贝的,如果 slice 里存储的是指针集合,那在 遍历里修改是有效的,如果 slice 存储的是值类型的集合,那么就是在 copy 它们的副本,期间的修改也只是在修改这个副本,跟原来的 slice 里的元素是没有关系的。
- slice 入参注意点
如果 slice 作为函数的入参,通常希望对 slice 的操作可以影响到底层数据,但是如果在函数内部 append 数据超过了 cap,导致重新分配底层数组,这时修改的 slice 将不再是原来入参的那个 slice 了。因此通常不建议在函数内部对 slice 有 append 操作,若有需要则显示的 return 这个 slice。
make 和 new 的区别
new 是返回某个类型的指针,将会申请某个类型的内存。而 make 只能用于 slice, map, channel 这种 golang 内部的数据结构,它们可以只声明不初始化,或者初始化时指定一些特定的参数,比如 slice 的长度、容量;map 的长度;channel 的缓冲数量等, make 返回的该类型本身。
defer、panic、recover 三者的用法
defer 函数调用的顺序是后进先出,当产生 panic 的时候,会先执行 panic 前面的 defer 函数后才真的抛出异常。一般的,recover 会在 defer 函数里执行并捕获异常,防止程序崩溃。
package main
import "fmt"
func main() {
defer func(){
fmt.Println("b")
}()
defer func() {
if err := recover(); err != nil {
fmt.Println("捕获异常:", err)
}
}()
panic("a")
}
// 输出
// 捕获异常: a
// b
slice 和 array 的区别
array 是固定长度的数组,并且是值类型的,也就是说是拷贝复制的, slice 是一个引用类型,指向了一个动态数组的指针,会进行动态扩容。
redis 为什么快
- 纯内存操作
- 在底层上, redis 使用了 IO 多路复用技术,像 select、epoll 等。能较好的保障吞吐量。
- redis 采用了单线程处理请求,避免了线程切换和锁竞争锁带来的额外消耗。
- 加上 redis 本身也对一些数据结构进行了优化设计,所以 redis 的性能非常好,官方给出的测试报告是单机可以支持约 10w/s 的 QPS。
Redis 有哪些使用场景?应用是怎么样的
Redis 的使用场景有很多,最常用的莫过于数据缓存了。但由于它提供了多种数据类型,因此我们还可以进行其他场景的开发,比如:
排行榜:有序集合(sorted set)每次写入都会进行排序,而且不含重复值,所以我们可以将用户的唯一标识,比如 userId 作为 key,分数作为 score,然后就可以进行 ZADD 操作,以得到排行榜。
签到:签到往往只有 2 种状态,已签到和未签到。这就跟 0 和 1 一样,所以 redis 的 setbit、getbit 这种对位的操作就适合签到场景。
计数:redis 是单线程操作,这种计数功能,比如点赞数、粉丝数的操作可以交给 redis 以避免并发竞争问题。当然,也得考虑持久化问题。
Redis 通信协议 是怎么样的
edis 采用文本序列化协议,和 http 协议一样,一个请求一个响应,客户端接到响应后再继续请求。也可以发起多次请求,然后一次响应回所有执行结果,即所谓的 pipeline 管道技术。
redis 的文本序列化协议比较简单,通过一些规范格式去解析文本,
如何实现分布式锁
- Redis
利用 setnx key value 命令, 当key不存在时才可以写入成功, 获取锁成功后执行后续逻辑, 最后再将该key删除来释放锁, 为避免在key被删除前服务异常, 导致锁一直在未释放, 所以还需要给该key维护一个过期时间, set key value [expiration EX seconds|PX milliseconds] [NX|XX], 这里还需要给该key设置一个唯一值, 即只能当前线程释放锁, 避免超时时间设置不合理时, 自己的锁被其他线程释放掉, 导致分布式锁一致失效 - Zookeeper
利用 zookeeper 临时序号节点
- 创建一个临时序号节点, 节点的数据是 write, 表示是写锁
- 获取 zk 中所有的子节点
- 判断自己是否是最小的节点, 如果是则上锁成功, 如果不是, 说明前面还有锁, 则上锁失败, 监听最小的节点, 如果最小节点有变化, 则回到第二步
RPC和HTTP访问的区别在哪
首先底层都是基于socket, 都可以实现远程调用, 都可以使用服务调用服务
- 速度来看, RPC要比HTTP更快, 虽然底层都是TCP, 但是HTTP协议的信息往往比较臃肿
- 难度来看, RPC实现较为复杂, HTTP相对比较简单
- 若对效率要求更高用RPC, 灵活性通用性要求高用HTTP
- RPC是长连接, HTTP是短连接, 效率更高
- RPC可以压缩消息, 实现更极致的流量优化