2020-04-12-Linux内核33-信号量

layouttitlesubtitledateauthorheader-imgcatalogtags
post
Linux内核33-信号量
Linux-信号量的工作原理以及应用场合
2020-04-12
Tupelo Shen
img/post-bg-unix-linux.jpg
true
Linux
Linux内核
信号量
semaphore

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还提供了其它版本的请求信号量的函数:

  1. down_trylock()

    可以被中断和延时函数调用。基本上与down()函数的实现一致,除了当信号量不可用时立即返回,而不是将进程休眠外。

  2. down_interruptible()

    广泛的应用在驱动程序中,因为它允许当信号量忙时,允许进程可以接受信号,从而中止请求信号量的操作。如果正在休眠的进程在取得信号量之前被其它信号唤醒,这个函数将信号量的count值加1,并且返回-EINTR。正常返回0。驱动程序通常判断返回-EINTR后,终止I/O操作。

其实,通过上面的分析,很容易看出down()函数有点鸡肋。它能实现的功能,down_interruptible()函数都能实现。而且down_interruptible()还能满足中断处理程序和延时函数的调用。所以,在2.6.37版本以后的内核中,这个函数已经被废弃。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值