1 什么是信号量?
对于信号量我们并不陌生。信号量在计算机科学中是一个很容易理解的概念。本质上,信号量就是一个简单的整数,对其进行的操作称为PV操作。进入某段临界代码段就会调用相关信号量的P操作;如果信号量的值大于0,该值会减1,进程继续执行。相反,如果信号量的值等于0,该进程就会等待,直到有其它程序释放该信号量。释放信号量的过程就称为V操作,通过增加信号量的值,唤醒正在等待的进程。
注:
信号量,这一同步机制为什么称为PV操作。原来,这些术语都是来源于狄克斯特拉使用荷兰文定义的。因为在荷兰文中,通过叫
passeren
,释放叫vrijgeven
,PV操作因此得名。这是在计算机术语中不是用英语表达的极少数的例子之一。
事实上,Linux提供了两类信号量:
- 内核使用的信号量
- 用户态使用的信号量(遵循
System V IPC
信号量要求)
在本文中,我们集中研究内核信号量,至于进程间通信使用的信号量以后再分析。所以,后面再提及的信号量指的是内核信号量。
信号量与自旋锁及其类型,不同之处是使用自旋锁的话,获取锁失败的时候,进入忙等待状态,也就是一直在自旋。而使用信号量的话,如果获取信号量失败,则相应的进程会被挂起,知道资源被释放,相应的进程就会继续运行。因此,信号量只能由那些允许休眠的程序可以使用,像中断处理程序和可延时函数等不能使用。
2 信号量的实现
信号量的结构体是semaphore
,包含下面的成员:
- count
是一个atomic_t
类型原子变量。该值如果大于0,则信号量处于释放状态,也就是可以被使用。如果等于0,说明信号量已经被占用,但是没有其它进程在等待信号量保护的资源。如果是负值,说明被保护的资源不可用且至少有一个进程在等待这个资源。 - wait
休眠进程等待队列列表的地址,这些进程都是要访问该信号保护的资源。当然了,如果count大于0,这个等待队列是空的。 - sleepers
标志是否有进程正在等待该信号。
虽然信号量可以支持很大的count,但是在linux内核中,大部分情况下还是使用信号量的一种特殊形式,也就是互斥信号量(MUTEX)
。所以,在早期的内核版本(2.6.37
之前),专门提供了一组函数:
init_MUTEX() // 将count设为1
init_MUTEX_LOCKED() // 将count设为0
用它们来初始化信号量,实现独占访问。init_MUTEX()函数将互斥信号设为1,允许进程使用这个互斥信号量加锁访问资源。init_MUTEX_LOCKED()函数将互斥信号量设为0,说明资源已经被锁住,进程想要访问资源需要先等待别的地方解锁,然后再请求锁独占访问该资源。这种初始化方式一般是在该资源需要其它地方准备好后才允许访问,所以初始状态先被锁住。等准备后,再释放锁允许等待进程访问资源。
另外,还分别有两个静态初始化方法:
DECLARE_MUTEX
DECLARE_MUTEX_LOCKED
这两个宏的作用和上面的初始化函数一致,但是静态分配信号量变量。当然了,count还可以被初始化为一个整数值n(n大于1),这样的话,可以允许多达n个进程并发访问资源。
但是,从Linux内核2.6.37版本之后,上面的函数和宏已经不存在。这是为什么呢?因为大家发现在Linux内核的设计实现中通常使用互斥信号量,而不会使用信号量。那既然如此,为什么不直接使用自旋锁和一个int型整数设计信号量呢?这样的话,因为自旋锁本身就有互斥性,代码岂不更为简洁?这样设计,还有一个原因就是之前使用atomic原子变量表示count,但是等待该信号量的进程队列还是需要自旋锁进行保护,有点重复。于是,2.6.37版本内核开始,就使用自旋锁和count设计信号量了。代码如下:
struct semaphore { raw_spinlock_t lock; unsigned int count; struct list_head wait_list; };
这样的设计使用起来更为方便简单。当然了,结构体的变化必然导致操作信号量的函数发生设计上的改变。
3 如何获取和释放信号量
前面我们已经知道,信号量实现在内核发展的过程中发生了更变。所以,其获取和释放信号量的过程必然也有了改变。为了更好的理解信号量,也为了尝试理解内核在设计上的一些思想和机制。我们还是先了解一下早期版本内核获取和释放信号量的过程。
因为信号量的释放过程比获取更为简单,所以我们先以释放信号量的过程为例进行分析。如果一个进程想要释放内核信号量,会调用up()函数。这个函数,本质上等价于下面的代码:
movl $sem->count,%ecx lock; incl (%ecx) jg 1f // 标号1后面的f字符表示向前跳转,如果是b表示向后跳转 lea %ecx,%eax pushl %edx pushl %ecx call __up popl %ecx popl %edx 1:
上面的代码实现的过程大概是,先把信号量的count拷贝到寄存器ecx中,然后使用lock指令原子地将ecx寄存器中的值加1。如果eax寄存器中的值大于0,说明没有进程在等待这个信号,则跳转到标号1处开始执行。使用加载有效地址指令lea
将寄存器ecx中的值的地址加载到eax寄存器中,也就是说把变量sem->count的地址(因为count是第一个成员,所以其地址就是sem变量的地址)加载到eax寄存器中。至于两个pushl指令把edx和ecx压栈,是为了保存当前值。因为后面调用__up()
函数的时候约定使用3个寄存器(eax,edx和ecx)传递参数,虽然此处只有一个参数。为此调用C函数的内核栈准备好了,可以调用__up()
函数了。该函数的代码如下:
__attribute__((regparm(3))) void __up(struct semaphore *sem) { wake_up(&sem->wait); }
反过来,如果一个进程想要请求一个内核信号量,调用down()
函数,也就是实施p操作。该函数的实现比较复杂,但是大概内容如下:
down: movl $sem->count,%ecx lock; decl (%ecx); jns 1f lea %ecx, %eax pushl %edx pushl %ecx call __down popl %ecx popl %edx 1:
上面代码实现过程:移动sem->count到ecx寄存器中,然后对ecx寄存器进行原子操作,减1。然后检查它的值是否为负值。如果该值大于等于0,则说明当前进程请求信号量成功,可以执行信号量保护的代码区域;否则,说明信号量已经被占用,进程需要挂起休眠。因而,把sem->count的地址加载到eax寄存器中,并将edx和ecx寄存器压栈,为调用C语言函数做好准备。接下来,就可以调用__down()
函数了。
__down()
函数是一个C语言函数,内容如下:
__attribute__((regparm(3))) void __down(struct semaphore * sem) { DECLARE_WAITQUEUE(wait, current); unsigned long flags; current->state = TASK_UNINTERRUPTIBLE; spin_lock_irqsave(&sem->wait.lock, flags); add_wait_queue_exclusive_locked(&sem->wait, &wait); sem->sleepers++; for (;;) { if (!atomic_add_negative(sem->sleepers-1, &sem->count)) { sem->sleepers = 0; break; } sem->sleepers = 1; spin_unlock_irqrestore(&sem->wait.lock, flags); schedule(); spin_lock_irqsave(&sem->wait.lock, flags); current->state = TASK_UNINTERRUPTIBLE; } remove_wait_queue_locked(&sem->wait, &wait); wake_up_locked(&sem->wait); spin_unlock_irqrestore(&sem->wait.lock, flags); current->state = TASK_RUNNING; }
__down()
函数改变进程的运行状态,从TASK_RUNNING到TASK_UNINTERRUPTIBLE,然后把它添加到该信号量的等待队列中。其中sem->wait中包含一个自旋锁spin_lock,使用它保护wait等待队列这个数据结构。同时,还要关闭本地中断。通常,queue操作函数从队列中插入或者删除一个元素,都是需要lock保护的,也就是说,有一个请求、释放锁的过程。但是,__down()函数还使用这个queue的自旋锁保护其它成员,所以扩大了锁的保护范围。所以调用的queue操作函数都是带有_locked
后缀的函数,表示锁已经在函数外被请求成功了。
__down()
函数的主要任务就是对信号量结构体中的count计数进行减1操作。sleepers如果等于0,则说明没有进行在等待队列中休眠;如果等于1,则相反。
以MUTEX信号量为例进行说明。
- 第1种情况:count等于1,sleepers等于0。
也就是说,信号量现在没有进程使用,也没有等待该信号量的进程在休眠。down()
直接通过自减指令设置count为0,满足跳转指令的条件是一个非负数,直接调转到标签1处开始执行,也就是请求信号量成功。那当然也就不会再调用__down()
函数了。 - 第2种情况:count等于0,sleepers也等于0。
这种情况下,会调用__down()
函数进行处理(count等于-1),设置sleepers等于1。然后判断atomic_add_negative()
函数的执行结果:因为在进入for循环之前,sleepers先进行了自加,所以,sem->sleepers-1
等于0。所以,if条件不符合,不跳出循环。那么此时count等于-1,sleepers等于0。也就是说明请求信号量失败,因为已经有进程占用信号量,但是没有进程在等待这个信号量。然后,循环继续往下执行,设置sleepers等于1,表示当前进程将会被挂起,等待该信号量。然后执行schedule(),切换到那个持有信号量的进程执行,执行完之后释放信号量。也就是将count设为1,sleepers设为0。而当前被挂起的进程再次被唤醒后,继续检查if条件是否符合,因为此时count等于1,sleepers等于0。所以if条件为真,将sleepers设为0之后,跳出循环。请求锁失败。 - 第3种情况:count等于0,sleepers等于1。
进入__down()
函数之后(count等于-1),设置sleepers等于2。if条件为真,所以设置sleepers等于0,跳出循环。说明已经有一个持有信号量的进程在等待队列中。所以,跳出循环后,尝试唤醒等待队列中的进程执行。 - 第4种情况:count是-1,sleepers等于0。
这种情况下,进入__down()
函数之后,count等于-2,sleepers临时被设为1。那么atomic_add_negative()
函数的计算结果小于0,返回1。if条件为假,继续往下执行,设置sleepers等于1,表明当前进程将被挂起。然后,执行schedule(),切换到持有该信号的进程运行。运行完后,释放信号量,唤醒当前的进程继续执行。而当前被挂起的进程再次被唤醒后,继续检查if条件是否符合,因为此时count等于1,sleepers等于0。所以if条件为真,将sleepers设为0之后,跳出循环。请求锁失败。 - 第5种情况:count是-1,sleepers等于1。
这种情况下,进入__down()
函数之后,count等于-2,sleepers临时被设为2。if条件为真,所以设置sleepers等于0,跳出循环。说明已经有一个持有信号量的进程在等待队列中。所以,跳出循环后,尝试唤醒等待队列中的进程执行。
通过上面几种情况的分析,我们可知不管哪种情况都能正常工作。wake_up()每次最多可以唤醒一个进程,因为在等待队列中的进程是互斥的,不可能同时有两个休眠进程被激活。
3 请求信号量的其它函数版本
在上面的分析过程中,我们知道down()函数的实现过程,需要关闭中断,而且这个函数会挂起进程,而中断服务例程中是不能挂起进程的。所以,只有异常处理程序,尤其是系统调用服务例程可以调用down()函数。基于这个原因,Linux还提供了其它版本的请求信号量的函数:
- down_trylock()
可以被中断和延时函数调用。基本上与down()函数的实现一致,除了当信号量不可用时立即返回,而不是将进程休眠外。 - down_interruptible()
广泛的应用在驱动程序中,因为它允许当信号量忙时,允许进程可以接受信号,从而中止请求信号量的操作。如果正在休眠的进程在取得信号量之前被其它信号唤醒,这个函数将信号量的count值加1,并且返回-EINTR
。正常返回0。驱动程序通常判断返回-EINTR
后,终止I/O操作。
其实,通过上面的分析,很容易看出down()函数有点鸡肋。它能实现的功能,down_interruptible()函数都能实现。而且down_interruptible()还能满足中断处理程序和延时函数的调用。所以,在2.6.37版本以后的内核中,这个函数已经被废弃。