大家好,我是蟹老板~
在Linux内核开发中,并发控制是一个绕不开的槛。当多个进程或线程同时访问同一块共享资源时,如果没有一套合理的秩序机制,数据乱套、系统崩溃就是分分钟的事。
那么问题来了——Linux内核是如何保证“文明排队”的?
这就是今天我们要聊的并发控制领域重量级选手——信号量(Semaphore) 。
想象一个场景:市中心有一个超火的地下停车场,只有 5 个车位。门口有个保安大爷,手里拿着 5 张停车牌。
这就是信号量。
在Linux内核中,信号量是由计算机科学家Dijkstra(对,就是那个最短路径算法的大佬)发明的。它本质上是一个包含计数器的睡眠锁。与自旋锁“得不到锁就疯狂占用CPU空转”不同,信号量在拿不到资源时,会主动把CPU让出来,让当前进程进入睡眠状态,实现同步与互斥。
那么,这位“大爷”在内核里究竟长什么样呢?咱们接着往下看。
信号量分为两种
明白了“是什么”,接下来看“怎么做到的”。
信号量核心构成有三点:
整数计数器:记录当前可用的共享资源数量,初始值由用户设定;
等待队列:当计数器为0(无资源可用)时,申请资源的线程会被放入等待队列,进入阻塞状态;
原子操作:对计数器的增减操作是原子的(不可打断),避免并发修改计数器导致的错乱。
这里必须区分一个常见误区:信号量 vs 互斥锁(mutex)。很多人会混淆两者,其实核心区别很简单:
互斥锁只能实现“一对一”互斥(同一时刻只有一个线程访问资源),计数器只能是0或1(二元信号量);信号量可以实现“多对多”同步(允许N个线程同时访问资源),计数器可以是任意非负整数。
要真正懂一个机制,必须看它的数据结构,在Linux内核源码中,信号量的定义位于include/linux/semaphore.h:
struct semaphore { raw_spinlock_t lock; // 保护信号量本身的自旋锁 unsigned int count; // 核心资源计数器 struct list_head wait_list; // 睡眠等待队列(那些熄火等车位的人)};
解读一下这三个成员:
count(车位):这就是资源的数量。如果 count = 1,它就退化成了二值信号量(类似互斥锁Mutex);如果 count > 1,就是计数信号量。
wait_list(等候区):一个双向链表。当申请不到资源时,内核会把当前进程打包成一个等待节点(waiter),挂到这个链表上,然后让进程休眠。
lock(保安的警棍):为了防止多个进程同时修改 count 或 wait_list 造成数据混乱,内核用了一个极其轻量级的底层的自旋锁来保护这俩兄弟。
结构如此清晰,那么它是如何运转的呢?
这就不得不提到操作系统课本上大名鼎鼎的 P/V 操作了。
在Linux内核中,P操作(申请资源)被称为 down(),V操作(释放资源)被称为 up()。这名字非常直观:资源数量下降和上升。
3.1 下降:down() 的底层逻辑
当你调用 down(&sem) 时,内核会发生什么?
(注:实战中我们更推荐用 down_interruptible(),它允许进程被信号打断,避免变僵尸进程。)
3.2 上升:up() 的唤醒魔法
当某个进程用完资源,调用 up(&sem):
关键点:当一个进程拿不到信号量时,它会“睡觉”——即被移出运行队列,CPU可以去执行别的任务。等到信号量被释放时,再被唤醒。这就是信号量区别于自旋锁的核心特征。
聊完原理,该上手实操了。Linux内核提供了丰富的信号量API,我们先看初始化。
4.1 初始化信号量
信号量的初始化分为两种方式:静态初始化和动态初始化,根据信号量的定义位置(全局/局部)选择。
(1)静态初始化(推荐全局信号量)
使用宏DEFINE_SEMAPHORE(),直接定义并初始化信号量,计数器初始值为1(默认互斥模式)。
#include<linux/semaphore.h>// 静态初始化信号量,name为信号量名称,计数器初始值=1DEFINE_SEMAPHORE(my_sem);
适用于信号量定义在全局,无需手动释放,内核会自动管理其生命周期的场景。
(2)动态初始化(推荐局部信号量)
使用sema_init()函数,手动初始化信号量,可自定义计数器初始值。
#include<linux/semaphore.h>// 定义信号量(局部变量)struct semaphore my_sem;// 动态初始化:第一个参数是信号量指针,第二个参数是计数器初始值sema_init(&my_sem, 5); // 计数器初始值=5,允许5个线程同时访问
注意事项:动态初始化的信号量,若定义在栈上,需确保其生命周期覆盖使用场景,避免野指针。
4.2 核心操作函数
信号量的核心操作只有两个:获取信号量(P操作,计数器减1)和释放信号量(V操作,计数器加1),内核提供了不同函数适配不同场景。
(1)获取信号量(P操作)
常用三个函数,重点区分阻塞/非阻塞、可中断/不可中断:
// 1. 不可中断阻塞(不推荐使用)// 计数器减1,若为0则一直阻塞,直到有信号量释放,无法被信号中断down(&my_sem);// 2. 可中断阻塞(推荐使用)// 计数器减1,若为0则阻塞,可被信号(如Ctrl+C)中断,返回非0值int ret = down_interruptible(&my_sem);// 3. 非阻塞(尝试获取,不阻塞)// 计数器减1,若为0则直接返回非0值(获取失败),不阻塞线程int ret = down_trylock(&my_sem);
(2)释放信号量(V操作)
只有一个常用函数up(),无论哪种获取方式,释放时统一调用:
// 计数器加1,若有线程在等待队列中,唤醒其中一个线程up(&my_sem);
注意:释放信号量的线程,必须是之前获取过该信号量的线程,否则会导致计数器错乱,引发系统异常。
4.3 函数返回值详解(避坑关键)
down()函数无返回值(一直阻塞),而down_interruptible()和down_trylock()有返回值,必须根据返回值判断操作结果,否则会踩坑。
// 示例:正确使用down_interruptible()int ret = down_interruptible(&my_sem);if (ret != 0) { // 被信号中断,获取失败,需释放已占资源,返回错误码 printk("获取信号量被中断\n"); return -ERESTARTSYS; // 内核推荐的中断返回码}// 临界区:访问共享资源(如设备寄存器、全局变量)// ...// 释放信号量up(&my_sem);
返回值说明:
理论讲再多,不如实战练一遍。下面两个案例,都是驱动开发中高频用到的信号量场景。
案例1:简单的字符设备互斥访问
需求:实现一个字符设备,多个线程同时读写设备时,保证同一时刻只有一个线程访问(互斥),用信号量实现。
#include<linux/module.h>#include<linux/fs.h>#include<linux/semaphore.h>// 定义设备号、文件操作结构体、信号量dev_t dev_num;struct file_operations fops;DEFINE_SEMAPHORE(dev_sem); // 静态初始化,计数器=1(互斥)// 读设备函数ssize_tdev_read(struct file *filp, char __user *buf, size_t count, loff_t *f_pos){ int ret; // 获取信号量(可中断) ret = down_interruptible(&dev_sem); if (ret != 0) { return -ERESTARTSYS; } // 临界区:模拟读设备操作(实际开发中替换为真实读写逻辑) printk("设备读操作:正在读取数据\n"); msleep(1000); // 模拟耗时操作 // 释放信号量 up(&dev_sem); return count;}// 写设备函数ssize_tdev_write(struct file *filp, constchar __user *buf, size_t count, loff_t *f_pos){ int ret; // 获取信号量(可中断) ret = down_interruptible(&dev_sem); if (ret != 0) { return -ERESTARTSYS; } // 临界区:模拟写设备操作 printk("设备写操作:正在写入数据\n"); msleep(1000); // 释放信号量 up(&dev_sem); return count;}// 初始化文件操作结构体struct file_operations fops = { .read = dev_read, .write = dev_write, .owner = THIS_MODULE,};// 模块初始化staticint __init dev_init(void){ // 申请设备号 alloc_chrdev_region(&dev_num, 0, 1, "my_dev"); // 注册字符设备 cdev_init(&cdev, &fops); cdev_add(&cdev, dev_num, 1); printk("字符设备初始化成功,信号量生效\n"); return 0;}// 模块卸载staticvoid __exit dev_exit(void){ cdev_del(&cdev); unregister_chrdev_region(dev_num, 1); printk("字符设备卸载成功\n");}module_init(dev_init);module_exit(dev_exit);MODULE_LICENSE("GPL");
信号量dev_sem计数器初始值为1,确保read和write函数同一时刻只有一个被执行,避免多线程读写冲突。
案例2:限制并发连接数的网络驱动
需求:实现一个网络驱动,限制最大并发连接数为3,超过3个连接时,新连接阻塞等待,用信号量实现。
#include<linux/module.h>#include<linux/netdevice.h>#include<linux/semaphore.h>// 定义信号量,计数器=3(限制3个并发连接)struct semaphore conn_sem;#define MAX_CONN 3// 模拟网络连接函数intnet_connect(void){ int ret; // 尝试获取信号量(非阻塞,避免长期阻塞) ret = down_trylock(&conn_sem); if (ret != 0) { printk("并发连接数已达上限,等待空闲连接\n"); // 非阻塞失败,转为可中断阻塞等待 ret = down_interruptible(&conn_sem); if (ret != 0) { return -ERESTARTSYS; } } // 临界区:建立网络连接 printk("建立网络连接,当前并发数:%d\n", MAX_CONN - conn_sem.count); return 0;}// 模拟网络断开函数voidnet_disconnect(void){ // 释放信号量,增加并发名额 up(&conn_sem); printk("断开网络连接,当前并发数:%d\n", MAX_CONN - conn_sem.count);}// 模块初始化staticint __init net_dev_init(void){ // 动态初始化信号量,计数器=MAX_CONN sema_init(&conn_sem, MAX_CONN); printk("网络驱动初始化成功,最大并发连接数:%d\n", MAX_CONN); return 0;}module_init(net_dev_init);MODULE_LICENSE("GPL");
信号量conn_sem计数器初始值为3,每建立一个连接获取信号量(计数器减1),断开连接释放信号量(计数器加1),从而限制最大并发连接数。
6.1 信号量使用的黄金法则
优先使用down_interruptible():避免使用down()(不可中断),否则线程会一直阻塞,即使收到中断信号也无法退出,容易导致系统死锁;
保持临界区最小化:获取信号量后,只执行必要的共享资源操作,尽快释放信号量,减少其他线程的等待时间;
信号量与资源一一对应:一个信号量控制一个共享资源,避免多个资源共用一个信号量,导致逻辑混乱;
避免嵌套获取信号量:不要在一个信号量的临界区中,再获取另一个信号量,容易引发死锁(比如线程A持有信号量1,等待信号量2;线程B持有信号量2,等待信号量1)。
6.2 常见错误及解决方法
错误1:忘记释放信号量,导致死锁
场景:获取信号量后,临界区中出现错误,直接return,未释放信号量,导致其他线程一直等待。
解决方法:使用goto语句,错误时跳转到释放信号量的位置,确保无论是否出错,都能释放信号量。
int ret = down_interruptible(&my_sem);if (ret != 0) { return -ERESTARTSYS;}// 临界区操作ret = do_something();if (ret != 0) { goto out; // 出错时跳转到释放信号量的位置}out:up(&my_sem); // 确保释放信号量return ret;
错误2:信号中断处理不当
场景:down_interruptible()被信号中断后,未做错误处理,直接继续执行临界区操作。
解决方法:判断返回值,若为非0,立即退出,不执行临界区操作,避免非法访问共享资源。
错误3:计数器初始值设置错误
场景:需要互斥访问时,计数器初始值设为大于1,导致多个线程同时进入临界区。
解决方法:互斥场景用静态初始化(默认计数器=1),或动态初始化时设为1;同步场景根据实际并发数设置计数器。