百度后端开发一面
mutex, rwmutex
在Go语言中,Mutex
(互斥锁)和RWMutex
(读写锁)是用于管理并发访问共享资源的核心工具。以下是它们的常见问题、使用场景及最佳实践总结:
1. Mutex 与 RWMutex 的区别
- Mutex:
- 互斥锁,保证同一时刻只有一个 goroutine 访问共享资源。
- 适用于读写操作都需要独占的场景(如计数器)。
- RWMutex:
- 读写锁,允许多个读操作并行,但写操作完全独占。
- 适用于读多写少的场景(如配置信息读取)。
2. 常见问题及解决方案
2.1 死锁
- 原因: 未释放锁、重复加锁或锁竞争导致永久阻塞。
- 解决:
- 使用
defer mu.Unlock()
确保释放锁。 - 避免在同一个 goroutine 中重复加锁(不可重入)。
- 使用
2.2 数据竞争
- 原因: 未对共享资源的所有访问路径加锁。
- 解决:
- 明确锁的保护范围,确保所有访问共享数据的操作都被锁覆盖。
- 使用
go test -race
检测数据竞争。
2.3 锁拷贝
- 原因: 复制包含锁的结构体导致锁状态异常。
- 解决:
- 通过指针传递包含锁的结构体。
- 避免直接复制
sync.Mutex
或sync.RWMutex
。
2.4 写饥饿(RWMutex)
- 原因: 大量读操作阻塞写操作。
- 解决:
- 评估场景是否需要 RWMutex,或通过优先级队列优化写操作。
- Go 1.18+ 的 Mutex 支持饥饿模式,避免长时间等待。
3. 使用场景
- Mutex:
var counter int var mu sync.Mutexfunc increment() {mu.Lock()defer mu.Unlock()counter++ }
- RWMutex:
var config map[string]string var rwmu sync.RWMutexfunc readConfig(key string) string {rwmu.RLock()defer rwmu.RUnlock()return config[key] }func updateConfig(key, value string) {rwmu.Lock()defer rwmu.Unlock()config[key] = value }
4. 最佳实践
- 锁的作用域:
- 锁应保护数据而非代码,确保所有访问共享资源的路径都被覆盖。
- 优先使用
defer
:- 避免忘记解锁,尤其在复杂逻辑或异常处理中。
- 替代方案:
- 对简单数值操作(如计数器)使用
atomic
包。 - 通过 Channel 实现“通过通信共享内存”。
- 对简单数值操作(如计数器)使用
- 性能优化:
- 减少锁的持有时间(如仅在读写共享数据时加锁)。
- 在高并发场景中,评估锁竞争是否成为瓶颈。
5. 注意事项
- 不可重入: Go 的锁不支持重入,同一 goroutine 重复加锁会导致死锁。
- 零值可用:
sync.Mutex
和sync.RWMutex
的零值可直接使用,无需初始化。 - 避免嵌套锁: 多个锁的嵌套使用可能导致复杂死锁,需按固定顺序加锁。
通过合理选择 Mutex
或 RWMutex
,并遵循最佳实践,可以有效避免并发问题,编写高效且安全的 Go 代码。
协程线程区别
协程(Coroutine)和线程(Thread)都是用于实现并发执行的机制,但它们在调度方式、资源消耗、通信机制等方面有显著区别。线程是操作系统级别的并发单位,由内核调度;而协程是用户态的轻量级线程,由程序自身调度。
- 解答思路:
- 首先明确两者的基本定义和使用场景。
- 对比它们的调度机制:线程由操作系统调度器管理,而协程由用户程序或运行时系统管理。
- 比较它们的开销:线程切换代价高,需要操作系统参与;协程切换快,仅需保存寄存器状态。
- 分析资源占用:线程拥有独立的栈空间,内存占用较大;协程共享线程资源,更节省内存。
- 总结适用场景:CPU密集型适合多线程,IO密集型或多任务协作适合协程。
- 深度知识讲解:
一、基本概念
-
线程(Thread)
线程是进程内的一个执行单元,多个线程共享同一进程的地址空间和资源。每个线程有自己独立的栈和寄存器上下文。线程由操作系统负责创建、销毁和调度。 -
协程(Coroutine)
协程是一种用户态的非抢占式多任务机制,可以看作是“轻量级线程”。它不像线程那样被操作系统调度,而是由程序员显式控制其切换。协程之间通常是协作式的,即当前协程主动让出控制权给下一个协程。
二、核心区别
- 调度机制不同
- 线程是抢占式的,操作系统根据优先级、时间片等策略决定哪个线程运行。
- 协程是协作式的,必须由当前协程主动 yield 控制权,才能切换到下一个协程。
- 上下文切换开销
- 线程切换涉及内核态与用户态的切换,需要保存/恢复更多的寄存器和状态信息,开销大。
- 协程切换完全在用户态进行,只需保存少量寄存器,开销极小。
- 资源占用
- 线程通常默认分配较大的栈空间(如1MB),因此不能大量创建。
- 协程栈空间较小(可配置为几KB),支持同时运行成千上万个协程。
- 同步与通信
- 线程间通信需要互斥锁、信号量等机制,容易引发竞态条件。
- 协程可以通过通道(channel)、事件循环等方式进行安全高效的通信。
- 并发模型
- 多线程属于并行模型,适用于 CPU 密集型任务。
- 协程属于异步/协作式并发模型,适用于 IO 密集型任务(如网络请求、文件读写)。
GMP调度
Go语言的GPM调度模型是Go运行时中用于处理并发的核心机制之一,它将Goroutine(轻量级线程)有效地映射到系统线程上,以最大化并发性能。GPM模型主要由三个部分组成:G(Goroutine)、P(Processor)、M(Machine)。让我们逐一详细介绍:
1. G(Goroutine)
- Goroutine 是Go语言中用于并发执行的轻量级线程,每个Goroutine都有自己的栈和上下文信息。
- Goroutine相对于操作系统的线程更加轻量级,可以在同一时间内运行成千上万的Goroutine。
2. P(Processor)
- P 是处理Goroutine的调度器的上下文,每个P包含一个本地运行队列(Local Run Queue),用于存储需要运行的Goroutine。
- P的数量由
GOMAXPROCS
设置决定,它决定了并行执行的最大线程数。 - P不仅管理Goroutine,还负责与M协作,将Goroutine分配给M执行。
3. M(Machine)
- M 代表操作系统的线程,负责执行Goroutine。一个M一次只能执行一个Goroutine。
- M是实际执行代码的工作单元,M与P绑定后才能执行Goroutine。
- M可以通过调度器从全局运行队列中拉取新的Goroutine,也可以与其他M协作完成工作。
4. GPM模型的调度过程
- 调度器工作机制:Goroutine创建后会被放入P的本地队列,P会从该队列中选择Goroutine,并将其分配给M执行。如果本地队列为空,P可以从全局运行队列或其他P的队列中窃取任务。
- 工作窃取机制:如果一个P的本地队列为空,而另一个P的本地队列中有多个Goroutine,前者可以从后者中窃取任务,从而保持系统的高效利用率。
- 阻塞与调度:当M执行的Goroutine阻塞(例如I/O操作)时,M会释放当前的P并等待P重新分配任务,从而避免资源浪费。
5. 模型优点
- 高效的并发调度:GPM模型使得Go语言可以高效地管理数百万个Goroutine的并发执行。
- 可伸缩性:通过P与M的动态调度,GPM模型可以充分利用多核处理器的性能。
- 轻量级:Goroutine非常轻量,创建和切换的成本比系统线程要低得多。
redis 常见数据类型
压缩列表
连续内存块组成的顺序型数据结构
O(1)定位首尾元素,其他需要遍历,不适合存储太多数据。
整数集合
跳表
跳表的优势是能支持平均O(logN)复杂度的节点查找
zset存储member和score
quicklist代替双向链表
listpack代替压缩列表
redis跳表的增删改查复杂度
O(logN)
redis跳表数据结构,高度创建,怎么删改
跳跃表(Skip List)的删除和修改操作需要结合其多层链表结构的特点进行调整,以下是具体实现步骤和原理:
一、删除操作
删除节点的核心步骤是:找到目标节点在所有层的引用,并更新这些层的指针以跳过该节点。
1. 删除流程
-
查找目标节点:
- 从最高层开始,向右遍历,直到找到等于或大于目标值的节点。
- 如果当前层的下一个节点等于目标值,记录该层的前驱节点(即指向目标节点的节点)。
- 逐层向下重复此过程,直到最底层(Level 0)。
- 时间复杂度:O(log n),与查找操作相同。
-
调整指针:
- 对每一层(从最高层到最底层):
- 如果该层存在指向目标节点的前驱节点,将其指针指向目标节点的下一个节点。
- 例如,若前驱节点在 Level 2 指向目标节点,则将前驱节点的 Level 2 指针指向目标节点的 Level 2 后继节点。
- 操作示例:
原结构(删除节点 30): Level 2: 10 --------------------------> 50 Level 1: 10 -------> 30 -------> 50 Level 0: 10 -> 20 -> 30 -> 40 -> 50删除后: Level 2: 10 --------------------------> 50 Level 1: 10 -------> 50 Level 0: 10 -> 20 -> 40 -> 50
- 对每一层(从最高层到最底层):
-
释放内存:
- 删除节点后,释放该节点的内存(在 Redis 等语言中可能由 GC 自动处理)。
2. 关键注意事项
- 多线程安全:如果跳跃表支持并发操作,删除时需加锁(如 Redis 单线程模型无需考虑)。
- 更新最大层高:若删除的节点是最高层的唯一节点,需降低跳跃表的最大层高。
二、修改操作
修改操作分为两种情况:仅修改值(Value) 或 修改键(Score)。
1. 仅修改值(Value)
- 流程:
- 查找目标节点:时间复杂度 O(log n)。
- 直接更新值:无需调整指针,直接修改节点的值字段。
- 时间复杂度:O(log n)(仅查找时间)。
2. 修改键(Score)
由于跳跃表是按键(Score)有序排列的,修改键后需保证顺序性,因此需要先删除旧节点,再插入新节点。
-
流程:
- 删除旧节点:O(log n)。
- 插入新节点:按新键值插入,O(log n)。
-
总时间复杂度:O(log n) + O(log n) = O(log n).
-
示例:
原结构(修改节点 30 的 Score 为 35): Level 1: 10 -------> 30 -------> 50 Level 0: 10 -> 20 -> 30 -> 40 -> 50修改后: Level 1: 10 --------------------------> 50 Level 0: 10 -> 20 -> 40 -> 50 新插入节点 35: Level 1: 10 ----------> 35 -------> 50 Level 0: 10 -> 20 -> 35 -> 40 -> 50
redis持久化AOF怎么做
数组中重复的数据
https://leetcode.cn/problems/find-all-duplicates-in-an-array/description/
func findDuplicates(nums []int) []int {n := len(nums)ans := []int{}for i:=0;i<n;i++{x := nums[i]if x<0{x = -x}if nums[x-1]<0{ans = append(ans, x)}else{nums[x-1] = -nums[x-1]}}return ans
}