海康威视 AI Infra 实习 一面
Q: 互斥锁和无锁操作的区别?使用场景?
互斥锁(Mutex)和无锁操作(Lock-free)是并发编程中保证数据一致性的两种机制,核心区别在于”是否阻塞线程”:
互斥锁:
- 机制:线程尝试获取锁 -> 成功则进入临界区执行 -> 完成后释放锁。其他线程获取不到锁时阻塞(睡眠等待)
- 原语:
pthread_mutex_lock、std::mutex、Linux futex - 语义保证:互斥(同一时刻只有一个线程在临界区)
- 开销:锁获取/释放本身的原子操作(
25ns)+ 竞争时的上下文切换(1-10us)
无锁操作(Lock-free):
- 机制:基于 CAS(Compare-And-Swap)等原子指令实现,不阻塞任何线程。如果 CAS 失败则重试(spin/retry loop)
- 原语:
std::atomic、__sync_compare_and_swap、x86lock cmpxchg - 语义保证:系统级进展性——至少有一个线程能在有限步内完成操作
- 开销:CAS 指令本身(~5-15ns)+ 失败时重试。高竞争时重试次数增加但不会阻塞
使用场景对比:
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 临界区很大(>1us) | 互斥锁 | 无锁的重试开销会超过锁的开销 |
| 临界区极小(计数器++) | 无锁 atomic | 一条原子指令完成,无需加锁 |
| 高并发读多写少 | 读写锁/RCU | 读不阻塞,写时才同步 |
| 实时性要求高 | 无锁 | 避免优先级反转和不确定的等待时间 |
| 数据结构操作(队列/栈) | 无锁数据结构 | 吞吐量远高于加锁版本 |
AI Infra 中的应用:CUDA 中的 atomicAdd 是 GPU 上的无锁操作,用于 reduction 的最终汇总;推理服务的请求队列通常用无锁队列(SPSC/MPMC lock-free queue)减少调度延迟。
Q: 互斥锁的风险?
互斥锁虽然简单易用,但有多种潜在风险需要注意:
1. 死锁(Deadlock):
- 定义:两个或多个线程互相等待对方持有的锁,导致所有线程永久阻塞
- 四个必要条件:互斥、持有并等待、不可剥夺、循环等待
- 经典场景:线程 A 持有锁 1 等待锁 2,线程 B 持有锁 2 等待锁 1
- 预防:固定加锁顺序(锁排序)、使用
try_lock超时机制、层次锁(hierarchical locking) - 检测工具:ThreadSanitizer、Valgrind Helgrind
2. 优先级反转(Priority Inversion):
- 场景:低优先级线程持有锁 -> 中优先级线程抢占 CPU -> 高优先级线程等待锁被低优先级持有 -> 高优先级被中优先级间接阻塞
- 经典案例:Mars Pathfinder 火星探测器因优先级反转导致系统重启
- 解决:优先级继承协议(持锁的低优先级线程临时提升到等待者的最高优先级)、优先级天花板协议
3. 性能开销:
- 获取/释放锁的原子操作本身有开销(cache line bouncing)
- 竞争时的上下文切换:从当前线程切到其他线程再切回来,典型开销 5-20us
- 被唤醒后重新调度的延迟不可预测(OS 调度器依赖)
4. 饥饿(Starvation):
- 某些线程因为调度策略或运气差,长期无法获取锁
- 原因:非公平锁(unlock 后任何等待线程都可能抢到,而非 FIFO)
- 解决:使用公平锁(FIFO 排队)或 ticket lock
5. 惊群效应(Thundering Herd):
- 锁释放时唤醒所有等待线程,但只有一个能获取锁,其余再次阻塞
- 浪费大量 CPU 周期在无用的唤醒和阻塞上
- 解决:只唤醒一个线程(condition variable 的 signal vs broadcast)
Q: 自旋锁和互斥锁的区别?使用场景?
核心区别:获取锁失败时的行为不同——自旋锁”忙等”,互斥锁”睡眠”。
自旋锁(Spinlock):
- 获取不到锁时在 CPU 上循环检测(
while(!try_lock()) {}),不让出 CPU 时间片 - 优点:无上下文切换开销,锁持有时间短时总延迟最低
- 缺点:等待期间 CPU 空转浪费算力;长时间自旋导致其他线程无法运行
互斥锁(Mutex):
- 获取不到锁时线程进入休眠状态,让出 CPU 给其他线程
- 优点:不浪费 CPU,适合长等待场景
- 缺点:上下文切换开销大(用户态 -> 内核态 -> 保存/恢复上下文),唤醒延迟不确定
使用场景决策:
| 条件 | 选择 | 原因 |
|---|---|---|
| 临界区 < 1us(几百 ns) | 自旋锁 | 上下文切换(5-20us)远大于自旋等待时间 |
| 临界区 > 10us | 互斥锁 | 长时间自旋浪费过多 CPU |
| 单核 CPU | 绝不用自旋锁 | 持锁线程得不到 CPU 无法释放锁,自旋线程永远等待 |
| 中断上下文/内核态 | 自旋锁 | 不能睡眠(睡眠可能导致内核死锁) |
| 多核 + 短临界区 | 自旋锁 | 锁在另一个核上很快释放,自旋几十 ns 就能获取 |
混合策略(Adaptive Mutex):先自旋一小段时间(如 1000 次循环),如果还没获取到则切换为睡眠等待。Linux 的 pthread_mutex 在多核系统上通常使用这种自适应策略。
Linux 内核中的 spinlock:保护中断处理和短临界区,配合关中断(spin_lock_irqsave)防止中断中重入。
Q: 线程拿不到互斥锁时操作系统做什么?
当线程调用 pthread_mutex_lock 无法获取锁时,OS 内核执行以下完整流程:
1. 状态转换(Running -> Blocked):
- 将线程从”运行态”(Running)转为”阻塞态”(Blocked/Waiting)
- 从运行队列(run queue)中移除该线程
- 将线程加入该互斥锁的等待队列(wait queue)
2. 保存上下文:
- 保存当前线程的 CPU 寄存器状态(PC、SP、通用寄存器等)到其线程控制块(TCB/task_struct)
- 保存浮点/SIMD 寄存器状态(如 AVX 寄存器较多时采用 lazy save)
3. 调度器选择新线程:
- 调度器(CFS/RT scheduler)从就绪队列选择下一个最高优先级/最需运行的线程
- 恢复被选中线程的上下文,切换页表(如果跨进程)
- 跳转到新线程的执行位置继续运行
4. 锁释放时的唤醒过程:
- 当持锁线程调用
unlock时,OS 检查该锁的等待队列 - 选择一个等待线程(通常是等待最久的,FIFO 公平性)
- 将其从阻塞态转为就绪态,放回运行队列
- 被唤醒线程重新竞争锁(在 futex 实现中还需要再做一次原子 CAS)
Linux 实现细节(futex):
- 无竞争路径:纯用户态原子操作完成,无系统调用,极快(~25ns)
- 有竞争路径:
futex(FUTEX_WAIT)系统调用进入内核态,在内核等待队列上阻塞 - 唤醒:
futex(FUTEX_WAKE)唤醒等待者
性能数据:
- 无竞争的 lock/unlock:~25ns(用户态原子操作)
- 有竞争的阻塞+唤醒:~5-20us(两次上下文切换)
- 这就是为什么短临界区(<1us)应该用自旋锁——两次上下文切换的开销远超自旋等待时间