海康威视 AI Infra 实习 一面


Q: 互斥锁和无锁操作的区别?使用场景?

互斥锁(Mutex)无锁操作(Lock-free)是并发编程中保证数据一致性的两种机制,核心区别在于”是否阻塞线程”:

互斥锁

  • 机制:线程尝试获取锁 -> 成功则进入临界区执行 -> 完成后释放锁。其他线程获取不到锁时阻塞(睡眠等待)
  • 原语pthread_mutex_lockstd::mutex、Linux futex
  • 语义保证:互斥(同一时刻只有一个线程在临界区)
  • 开销:锁获取/释放本身的原子操作(25ns)+ 竞争时的上下文切换(1-10us)

无锁操作(Lock-free)

  • 机制:基于 CAS(Compare-And-Swap)等原子指令实现,不阻塞任何线程。如果 CAS 失败则重试(spin/retry loop)
  • 原语std::atomic__sync_compare_and_swap、x86 lock 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)应该用自旋锁——两次上下文切换的开销远超自旋等待时间