并发与同步:自旋锁、信号量、互斥锁

并发与同步:自旋锁、信号量、互斥锁
并发与同步自旋锁、信号量、互斥锁Linux 是多任务多 CPU 的系统驱动里的资源可能被同时访问导致竞态。这篇讲驱动开发中常用的同步机制原子操作、自旋锁、信号量、互斥锁各自的适用场景和注意事项。大家好我是黒漂技术佬。单线程写代码不用考虑并发但 Linux 内核是多任务、多 CPU 的多个进程同时调用驱动的 read/write中断和进程同时访问同一块数据多核上同一个函数在不同 CPU 同时执行这些都会导致竞态race condition数据错乱、链表崩、内核 panic。所以驱动里必须做同步保护共享资源。这篇讲最常用的几种同步机制。一、竞态是怎么产生的常见的并发场景多进程并发多个应用同时 open 同一个设备同时 read/writeSMP 多核同一个驱动代码在两个 CPU 上同时跑中断与进程进程正在操作数据中断来了也操作同一份内核抢占进程执行到一半被调度走另一个进程进来也操作底半部tasklet/workqueue 和进程上下文并发后果全局变量值错乱链表结构破坏硬件寄存器操作乱序内存泄漏、重复释放内核 panic解决方案用同步机制保护共享资源保证同一时间只有一个执行者访问临界区。二、原子操作最简单的同步针对单个整数变量的原子读写。原子整型#includelinux/atomic.hatomic_tcounterATOMIC_INIT(0);// 读intvalatomic_read(counter);// 写atomic_set(counter,10);// 加/减atomic_inc(counter);atomic_dec(counter);atomic_add(5,counter);atomic_sub(3,counter);// 减并测试是否为0if(atomic_dec_and_test(counter)){// 减完变成0了}// 比较并交换atomic_cmpxchg(counter,old,new);原子位操作unsignedlongflags0;set_bit(0,flags);// 置位clear_bit(1,flags);// 清位change_bit(2,flags);// 翻转test_and_set_bit(3,flags);// 测试并置位返回原来的值适用场景单个计数器、标志位引用计数简单的状态标记不适用复杂数据结构链表、结构体多个字段需要多步操作一起保护的原子操作只能保护单个变量复杂的要用锁。三、自旋锁spinlock特点拿不到锁的时候原地自旋等待忙等不睡眠不调度中断上下文里可以用锁持有时间要非常短多核才有意义单核就是关抢占定义和初始化#includelinux/spinlock.hspinlock_tlock;spin_lock_init(lock);加锁解锁spin_lock(lock);// 临界区spin_unlock(lock);中断里也会访问的情况如果中断处理函数里也要拿这个锁用普通 spin_lock 会死锁进程拿到锁了中断来了也等锁中断不返回进程没法释放。要用保存中断状态的版本unsignedlongflags;spin_lock_irqsave(lock,flags);// 加锁 关本地中断 保存状态// 临界区spin_unlock_irqrestore(lock,flags);// 解锁 恢复中断状态其他变体spin_lock_irq(lock);// 加锁关中断spin_unlock_irq(lock);// 解锁开中断spin_lock_bh(lock);// 加锁关底半部软中断spin_unlock_bh(lock);// 解锁开底半部自旋锁的使用规则持有时间要短忙等等待占着 CPU 不干活临界区不能睡眠睡眠了别人就一直自旋等死锁不能递归自己拿了锁再拿一次死锁中断共享的锁要用 irqsave 版本防止死锁保护数据不是代码想清楚要保护什么资源最小化临界区适用场景中断上下文和进程共享的数据锁持有时间很短的场景多核同步四、信号量semaphore特点拿不到锁就睡眠让出 CPU进程上下文才能用中断上下文不能睡眠可以允许多个持有者计数信号量持有时间可以长一点定义和初始化#includelinux/semaphore.hstructsemaphoresem;sema_init(sem,1);// 初始值1相当于互斥锁P 操作获取down(sem);// 拿不到就睡眠不可中断down_interruptible(sem);// 可被信号打断down_trylock(sem);// 尝试拿拿不到立刻返回不睡眠V 操作释放up(sem);计数信号量初始值设成 N就允许 N 个持有者同时进入sema_init(sem,5);// 最多5个同时访问适合限制资源数量的场景比如缓冲区只有 5 个。五、互斥锁mutex特点信号量的特殊情况初始值1但更简单、更高效专门做互斥用的同一时间只有一个持有者睡眠等待进程上下文用内核推荐用 mutex 而不是二值信号量定义和初始化#includelinux/mutex.hstructmutexmy_mutex;mutex_init(my_mutex);加锁解锁mutex_lock(my_mutex);// 临界区mutex_unlock(my_mutex);其他mutex_lock_interruptible(lock);// 可中断mutex_trylock(lock);// 尝试加锁不等待mutex_is_locked(lock);// 判断是否被锁mutex 使用规则谁加锁谁解锁不能在 A 加锁 B 解锁不能递归加锁会死锁进程上下文使用中断里不能用持有期间可以睡眠但尽量别太久mutex vs 二值信号量功能差不多mutex 更优化专门为互斥场景设计的。一般互斥场景优先用 mutex。六、怎么选一张表总结机制等待方式上下文能否睡眠持有时间适用场景原子操作无任意--单个变量、计数器自旋锁忙等任意不能很短中断共享、短临界区信号量睡眠进程能可较长计数、资源限制互斥锁睡眠进程能可较长互斥、大部分场景选型原则单个变量原子操作中断里也要访问 / 时间很短自旋锁irqsave 版本进程上下文互斥mutex首选需要计数允许多个持有者信号量不确定用什么大部分情况用 mutex 就行七、完成量completion作用一个线程等另一个线程完成某件事类似「事件通知」。定义#includelinux/completion.hstructcompletionmy_comp;init_completion(my_comp);等待wait_for_completion(my_comp);// 一直等wait_for_completion_timeout(my_comp,HZ);// 超时wait_for_completion_interruptible(my_comp);// 可中断完成通知complete(my_comp);// 唤醒一个等待者complete_all(my_comp);// 唤醒所有等待者重新初始化reinit_completion(my_comp);适用场景等待硬件操作完成等待中断处理完一个线程等另一个线程做完事比信号量更适合「一次性事件通知」的场景。八、等待队列wait_queue作用进程等待某个条件成立条件不满足就睡眠满足了被唤醒。驱动里非常常用。定义#includelinux/wait.hwait_queue_head_tmy_wq;init_waitqueue_head(my_wq);等待条件满足才返回// 等待 condition 为真可中断wait_event_interruptible(my_wq,condition);// 带超时wait_event_interruptible_timeout(my_wq,condition,msecs_to_jiffies(1000));// 不可中断wait_event(my_wq,condition);condition 是一个 C 表达式为真就返回为假就睡眠等待。唤醒wake_up(my_wq);// 唤醒所有wake_up_interruptible(my_wq);// 唤醒可中断等待的wake_up_nr(my_wq,nr);// 唤醒N个典型用法// read 里没数据就等ssize_tmy_read(...){wait_event_interruptible(my_wq,has_data);// 有数据了拷贝给用户}// 中断里数据来了唤醒等待的进程irqreturn_tmy_irq(...){has_data1;wake_up_interruptible(my_wq);}驱动里 read 阻塞等待数据基本都用等待队列。九、死锁什么是死锁两个线程各拿一把锁又等对方的锁永远等下去。线程A拿锁1 → 等锁2 线程B拿锁2 → 等锁1怎么避免按固定顺序加锁所有线程都先锁1再锁2不要反过来减少锁的数量能不用就不用能一把锁解决就不用两把用 trylock拿不到就退避免死等不要递归加锁锁的粒度清晰每个锁保护什么要明确十、常见坑坑 1中断里用 mutexmutex 会睡眠中断上下文不能用。中断里要用自旋锁irqsave 版本。坑 2自旋锁持有期间睡眠自旋锁临界区里调用了会睡眠的函数kmalloc GFP_KERNEL、copy_from_user、mutex_lock 等要么直接 panic要么死锁。坑 3没考虑中断并发进程用了普通 spin_lock但中断里也访问数据导致竞态。要用 spin_lock_irqsave。坑 4锁的粒度太大一把大锁保护所有东西并发性能差。该拆的拆不同资源不同锁。坑 5忘了释放锁错误路径 goto 回滚的时候漏了解锁下次再拿就死锁。坑 6等待队列条件判断不对wait_event 的 condition 要在拿到锁之后判断或者本身是原子变量。条件判断和唤醒要配合好不然丢唤醒。十一、本篇小结竞态来源多进程、多核、中断、抢占、底半部驱动必须考虑同步原子操作保护单个整型/位变量最简单自旋锁忙等待任意上下文可用持有时间要短中断共享要用 irqsave互斥锁 mutex睡眠等待进程上下文用互斥场景首选信号量可以计数允许多个持有者资源限制场景用完成量 completion一个线程等另一个完成事件适合「等某件事做完」等待队列进程等待条件成立驱动 read 阻塞等数据常用选型原则单变量用原子短中断用自旋锁进程互斥用 mutex等事件用等待队列避免死锁固定加锁顺序、减少锁数量、锁粒度清晰下一篇讲平台设备驱动与设备树platform 总线、设备树语法、驱动和设备的匹配方式。我是黒漂技术佬。