go语言中并发安全性是什么?
多个 goroutine同时访问共享数据时,程序能够正确地执行而不会导致数据竞争或其他并发问题。
Go 提供了一些机制来确保并发安全性:互斥锁、读写锁、channel
互斥锁(Mutex)
介绍一下互斥锁的原理
互斥锁的结构体字段:
state:
- mutexLocked 持有锁标记
- mutexWoken 唤醒标记
- mutexStarving 饥饿标记
- mutexWaiters 阻塞等待的waiter数量
same:信号量,用来控制等待goroutine的阻塞、休眠和唤醒操作
正常模式和饥饿模式:
正常模式:
- 先入先出,被唤醒的 waiter 和新来的 goroutine 进行竞争
- 唤醒的 waiter获取不到锁,插入到队列的前面。新来的 goroutine获取不到锁,插入到队列尾部
- waiter获取不到锁超过1毫秒,进入饥饿模式
饥饿模式: - 锁交给队列最前面的waiter
- 新来的goroutine不会获取锁,直接插入队列尾部
- waiter 是队列中的最后一个了或者waiter 的等待时间小于 1 毫秒,进入正常模式
四个阶段
- 初版
- 使用一个flag字段标记是否持有锁
- 给新人机会
- 唤醒的goroutine和新来的goroutine竞争锁
- 给新人多一点机会
- 唤醒的goroutine或者新来的goroutine首次获取不到锁,会自旋(spin),尝试一定的自旋次数后,再执行回原来的逻辑
- 解决饥饿
- 有可能每次都被新来的goroutine抢到锁,极端情况下,等待中的goroutine一直获取不到锁
- 等待时间超过1ms进入饥饿模式
初版的实现原理
- key和sema字段,
- key
- 0:锁未被持有
- 1:锁被持有,还没有等待者
- n:锁被持有,还有n-1个等待着
- sema:等待者队列使用的信号量
- 使用key字段标记是否持有锁,请求锁到来,通过原子操作CAS加1,释放锁通过原子操作减1
- 如果锁没有被其他goroutine持有,直接获取到锁,否则使用信号量将自己休眠,等待锁释放的时候,信号量会唤醒
自旋(spin)具体是做些什么事情
- 循环检查锁的状态,如果锁的状态释放了,就竞争锁
- 自旋一定的次数
- 如果锁被持有时间很短,那么避免了线程切换的开销
读写锁(RWMutex)
介绍一下你对读写锁的了解
1、某一时刻只能由任意数量的reader(读锁)持有,或者是只被单个的writer(写锁)持有
2、写优先
- 请求的writer到来,如果已经有一些reader请求了锁的话,writer会等待已经存在的reader都释放锁之后才获取到锁,后面来的新reader要等writer执行完后才会获得锁。
3、结构体:
- w // 互斥锁解决多个writer的竞争
- writerSem // writer信号量
- readerSem // reader信号量
- readerCount // 记录当前 reader 的数量(以及是否有 writer 竞争锁)
- readerWait // 记录 writer 请求锁时需要等待 read 完成的 reader 的数量
- 一个常量:rwmutexMaxReaders = 1<<30 //定义了最大的 reader 数量
4、易错场景 - 不可复制
- 重入导致死锁
- 释放未加锁的 RWMutex
针对写优先,比如说现在有一个写锁已经加锁了,这个时候有一个读锁过来,又有几个写锁过来,他们几乎是同时到达的。那么,第二个到来的读写跟后面几个写锁谁先去加锁?
- 虽然是写优先,但是是读锁先加锁
- 写锁释放锁后,会先对现在等待队列里面的读锁先一批全部唤醒加锁
- 写->读->写->读
- 只要是现在写锁持有锁
- 后面不管是读锁先到还是写锁先到
- 写锁释放之后,都会先唤醒一批读锁
读写锁适合什么场景
读多写少
说一下sync包下面并发原语
- Mutex
- RWMutex
- WaitGroup
- Cond
- Once
- Map
- Pool
WaitGroup
介绍一下WaitGroup
等待一组goroutine完成执行的同步原语
方法:
- Add:用来设置 WaitGroup 的计数值
- Done:用来将 WaitGroup 的计数值减 1,其实就是调用了 Add(-1)
- Wait:调用这个方法的 goroutine 会一直阻塞,直到 WaitGroup 的计数值变为 0
结构体:
- noCopy
- 不能在第一次使用之后复制使用
- state1
- waiter数
- 计数值
- 信号量
易错场景:
- 计数器设置为负值
- 调用 Add 的时候传递一个负数
- 调用 Done 方法的次数过多,超过了 WaitGroup 的计数值
- 不期望的 Add 时机。 并发Wait 和 Add,会出现 panic。
有实际使用过吗?
- 场景:起3个协程跑API接口获取数据,等3个协程都获取完数据之后再往下执行
- 主goroutine,声明WaitGroup,调用Add(3),起3个协程跑API
- 每个协程执行完之后,调用Done()方法
- 主goroutine,调用Wait()方法等待3个协程执行完
map
介绍一下map
无序的,键值对,并发安全
方法:
- Store
- Load
- Delete
- Range
Key的要求:
- K 必须是可比较的
- 不可比较类型:slice、map、函数、struct包含slice也不可以当key
slice、map、函数为什么不可以比较?
- slice:
- 引用类型,底层是数组
- 底层的数组可能不一样
- 或者有可能会发生扩容,扩容之后底层的数组就会不一样
- 比较维度比较复杂,所以就不可以比较
- map和函数
- map可以包含切片
- 函数可以把切片当做参数传递
- 因为包含了切片所以不可以比较
slice的底层结构
数组指针、长度、容量
slice扩容机制
- 当长度小于1024时,每次扩容会将容量翻倍。即新容量为原容量的两倍。
- 当长度大于等于1024时,每次扩容会将容量增加25%。即新容量为原容量的1.25倍。
- 扩容后,会创建一个新的底层数组,并将原有的元素复制到新的底层数组中
slice当前的容量为256,新增元素是当前容量的两倍(512),它是怎么扩容的?
- 按照扩容机制,多次扩容
- 第一次扩容到512,512容量还不够就会扩容到1024,还不够就会扩容到1024*1.25=1280
为什么sync.map是线程安全的?怎么实现线程安全的?
- 看底层源码或者自己百度
go里面有哪些类型是线程安全的?
sync包下面的并发原语、atomic、channel
Once
介绍一下Once
用来执行且仅仅执行一次动作,常常用于单例对象的初始化场景
方法:
- Do:只有第一次调用会执行
结构体:
- done uint32 标记是否已经执行过Do方法
- m Mutex 保证只有一个 goroutine 执行Do方法
为什么有个done做标识了,还要加多一个互斥锁?
- 为了做双重检测
- done是标记是否已经执行过Do方法的参数f函数了
- 互斥锁保证只有一个 goroutine 执行Do方法
Once有使用过吗?
使用过,单例模式初始化
Pool
介绍一下Pool
来缓存一组可独立访问的临时对象,避免反复创建销毁带来的性能损耗
方法:
- New:创建新的元素
- Get:取走元素
- Put:返回元素
结构体:
- noCopy
- local:用来存储当前主要的空闲可用的元素
- localSize
- victim:用来存储空闲的元素。垃圾回收时,把 victim 中的对象移除,然后把 local 的数据给 victim,local 就会被清空
- victimSize
- New
坑:
- 内存泄露
- 内存浪费
主要是为了避免哪个方面的性能损耗?
- 内存分配开销: 在高并发的情况下,频繁地进行内存分配可能导致性能下降。
- 垃圾回收(GC)压力: 在大量对象被频繁创建和销毁的情况下,垃圾回收器可能会变得繁忙,导致应用程序的性能下降。
Cond
介绍一下Cond
等待某个条件的一组 goroutine,等条件变为 true 的时候,其中一个 goroutine或者所有的 goroutine 都会被唤醒执行
方法:
- Broadcast:唤醒等待队列中的所有waiter
- Signal:唤醒等待队列中的第一个waiter
- Wait :
- 把调用者放到等待队列中并阻塞,直到被Broadcast或者Signal唤醒并从等待队列中删除
- 必须要持有锁(一般Mutex 或者 RWMutex)
结构体:
- L 绑定的锁
- notifyList 等待 / 通知的队列
- copyChecker 辅助结构,检查 Cond 是否被复制使用
Cond结构体里面的锁是用来干什么的?
- 用来保护条件变量的状态(调用wait时必须加锁,也做条件变量检测)
- 保护等待队列的出队入队
Atomic(原子操作)和锁的区别是什么?
- 原子操作的粒度更小: 原子操作通常用于对单个变量进行原子操作,而锁通常用于对一组操作或代码块进行同步控制。
- 原子操作无需显式加锁: 原子操作是由硬件提供支持的,并不需要程序员显式地加锁和解锁,而锁需要显式地进行加锁和解锁,容易引入死锁和竞态条件。
- 锁提供更灵活的同步控制: 锁可以用于保护一段复杂的代码块,确保在同一时刻只有一个线程可以执行该代码块。
如何保证go内建的map是并发安全的,直接使用锁粒度太大,还有其他方法吗?
分片锁:分片锁是一种通过将数据划分成多个片段(分片),为每个片段分配一个独立的锁的方式,以提高并发性能
Channel
介绍一下channel
通过通信共享内存,而不是通过共享内存而实现通信
基本用法:
- 发送数据
- 接收数据
- 关闭chan
结构体:
- qcount:循环队列元素的数量
- dataqsiz:循环队列的大小。
- buf:循环队列的指针。
- elemtype 和 elemsize:chan 中元素的类型和 size。
- sendx:处理发送数据的指针在 buf 中的位置。
- recvx:处理接收请求时的指针在 buf 中的位置。
- recvq:接收者等待队列。
- sendq:发送者等待队列。
- lock:互斥锁
常见错误:
- panic
- close 为 nil 的 chan;
- send 已经 close 的 chan;
- close 已经 close 的 chan。
- goroutine 泄漏
- 缺少接收端或者发送端,或者提前结束接收端或者发送端没有close channel
无缓冲channel:
- 无缓冲的通道又称为阻塞的通道
- 使用无缓冲通道进行通信将导致发送和接收的goroutine同步化。因此,无缓冲通道也被称为同步通道。
有缓冲channel:
- 通道的容量填满才会阻塞
- 通道为空时,接收数据会阻塞
单向通道:
- 函数中只能发送或只能接收
- 在函数传参及任何赋值操作中将双向通道转换为单向通道是可以的,但反过来是不可以的
Channel内存泄露,具体是泄露什么东西?
goroutine泄露。比如goroutine里面有一个没有缓存的channel接收端,如果发送端因为其他原因没有执行到,或者是提前结束了发送端没有结束channel,会导致goroutine一直阻塞,且无法被gc回收。
单向channel可以赋值给双向channel吗?
不可以。单向不可以赋值给双向,双向可以赋值给单向
读取一个有数据的channel且是关闭的,会发生什么?
会接收到channel里面的数据,直接channel里面没有数据,返回channel对应元素的零值
读取一个有数据的channel且是关闭的,接收两个返回参数,两个参数分别代表是什么?
第一个参数:通道中的值,如果通道还有数据,那么就是通道中的下一个值。如果通道已经空了,返回通道元素的零值。
第二个参数:是否成功读取,如果成功读取了一个值,该返回值为 true;如果通道已经关闭且已经没有数据可读,返回值为 false。
select是用来做什么的?
用来监听一个或者多个channel,直到其中一个channel ready
select和switch有什么区别?
- select:用来监听一个或者多个channel
- switch:流程控制,多个表达式的比较
- 两者没有任何关系,就是长得比较像而已
分布式并发原语有没有了解过?
分布式并发原语etcd
- Leader 选举
- 选举
- 查询
- 监控
- 互斥锁
- 同一时刻,只允许其中的一个节点持有锁
- Locker
- 类似于 Go 标准库中的 sync.Locker 接口,提供了 Lock/UnLock 的机制,基于 Mutex 实现
- Mutex:提供了Lock/UnLock和查询 Mutex 的key 的信息的功能
- 读写锁:和标准库的读写锁的功能是一样的
- 类似于 Go 标准库中的 sync.Locker 接口,提供了 Lock/UnLock 的机制,基于 Mutex 实现
- 队列
- 分布式队列
- 多读多写的队列,可以启动多个写节点和多个读节点
- 优先级队列
- 和队列类似,写入元素需要提供 uint16 整数优先级,优先级高优先出队。
- 分布式队列
- 栅栏
- Barrier:分布式栅栏
- 持有 Barrier 的节点释放了它,所有等待这个 Barrier 的节点才会继续执行
- DoubleBarrier:计数型栅栏
- 初始化计数型栅栏时,就必须提供参与节点的数量,当这些数量的节点都 Enter 或者 Leave 的时候,这个栅栏就会放开
- Barrier:分布式栅栏
- STM(事务)
- 简化多个key的操作,并且提供事务功能(要么全成功,要么全失败)
- 方法:Get、Put、Receive 和 Delete
go里面是怎么保证并发的读写顺序的?
内存模型:并发环境中多goroutine 读相同变量的时候,变量的可见性条件
happens-before:
- goroutine 内部,程序的执行顺序和它们的代码指定的顺序是一样的
- go语言中保证的happens-before关系
- init 函数
- init函数一定在当前包的任何初始化代码之前执行
- goroutine
- 启动 goroutine 的 go 语句的执行,一定 发生在此 goroutine 内的代码之前执行
- 根据此规则,go 语句传入的参数是一个函数执行的结果,那么,这个函数一定先于 goroutine 内部的代码被执行。(参数结果从传入的时候就固定了)
- Channel
- 对 Channel 的第n个发送操作,一定发生在第n个接收操作之前
- close Channel 的操作,一定发生在从 Channel中读取出零值之前。
- 对无缓冲的Channel的读取操作,一定发生在此Channel的发送操作之前
- Mutex/RWMutex
- 解锁操作一定发生在下次上锁操作之前
- WaitGroup
- Wait 方法等到计数值归零之后才返回
- Once
- once.Do(f) 调用,函数 f 一定会在任何 Do 方法返回之前执行
- atomic
- Store 一定在Load之前执行,但是太过于复杂,现阶段还是不要使用 atomic 来保证顺序性。
golang 协程是否可以无限制的创建?
在理论上,是可以无限制地创建
无线创建协程会出现什么样的问题?
- 内存消耗: 每个协程都需要一定的内存来存储其状态和栈信息。如果无限制地创建协程,可能会导致大量的内存消耗,最终耗尽可用内存。
- 调度开销: 每个协程都需要进行调度和上下文切换。如果存在大量的协程,调度和切换的开销可能变得显著,导致系统性能下降。
线程和go的协程的区别是什么?
线程:
- 是进程的一个执行实体,是 CPU 调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。
- 一个线程上可以跑多个协程,协程是轻量级的线程
协程:
- 独立的栈空间,共享堆空间,调度由用户自己控制,本质上有点类似于用户级线程,这些用户级线程的调度也是自己实现的。
区别
- 内存占用:线程的内存占用比协程大。通常情况,线程创建栈大小1MB,协程创建栈大小2KB\
- 切换成本:
- 协程是在用户态切换完成的,这个过程较为轻量
- 线程是在内核态切换完成的,这个过程较为耗时
- 还有寄存器的数量也不一致
Context
介绍一下context
上下文信息传递,还提供了超时(Timeout)和取消(Cancel)的机制
1个接口,4个具体实现,6个函数:
- context.Context是个接口,定义了4个方法
- Deadline:返回 被取消的截止日期
- Done:返回一个 Channel 对象。在 Context 被取消时,此 Channel 会被 close
- Err:返回 Done 被 close 的原因
- Value:返回此 ctx 中和指定的 key 相关联的 value
- 4个具体实现
- emptyCtx:本质是个整形(int),对接口的实现只是简单的返回nil,false
- cancelCtx:可取消的context
- timerCtx:在cancelCtx基础上,封装了一个定时器和一个截止时间
- valueCtx:给context附加一个键值对信息
- 6个函数
- Background:
- 返回一个非 nil 的、空的 Context
- 一般用在主函数、初始化、测试以及创建根Context 的时候
- 类型是 emptyCtx
- TODO
- 返回一个非 nil 的、空的 Context
- 当你不清楚是否该用 Context,或者目前还不知道要传递一些什么上下文信息的时候,就可以使用这个方法。
- 类型是 emptyCtx
- WithValue
- 基于 parent Context 生成一个新的 Context,保存了一个 key-value 键值对
- 类型是 valueCtx
- WithCancel
- 返回 parent 的副本,只是副本中的 Done Channel 是新建的对象
- 类型是 cancelCtx。
- WithDeadline
- 返回一个 parent 的副本,并且设置了一个截止时间
- 类型为是timerCtx
- WithTimeout
- 和 WithDeadline 一样,只不过设置的是超时时间
- 类型为是timerCtx
- Background:
gin框架的context和go内建的context有什么区别
- gin的context
- 通过扩展 context.Context 接口而来的
- 包含了很多与 HTTP 请求相关的信息
- 主要用于 HTTP 请求的处理
- 内建context
- 用于处理上下文信息的通用接口
内建的context用来做什么?
上下文信息传递,还提供了超时(Timeout)和取消(Cancel)的机制