前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Linux内核33-信号量

Linux内核33-信号量

作者头像
Tupelo
发布2022-08-15 16:08:33
1.6K0
发布2022-08-15 16:08:33
举报
文章被收录于专栏:嵌入式ARM和Linux

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之前),专门提供了一组函数:

代码语言:javascript
复制
init_MUTEX()            // 将count设为1
init_MUTEX_LOCKED()     // 将count设为0

用它们来初始化信号量,实现独占访问。init_MUTEX()函数将互斥信号设为1,允许进程使用这个互斥信号量加锁访问资源。init_MUTEX_LOCKED()函数将互斥信号量设为0,说明资源已经被锁住,进程想要访问资源需要先等待别的地方解锁,然后再请求锁独占访问该资源。这种初始化方式一般是在该资源需要其它地方准备好后才允许访问,所以初始状态先被锁住。等准备后,再释放锁允许等待进程访问资源。

另外,还分别有两个静态初始化方法:

代码语言:javascript
复制
DECLARE_MUTEX
DECLARE_MUTEX_LOCKED

这两个宏的作用和上面的初始化函数一致,但是静态分配信号量变量。当然了,count还可以被初始化为一个整数值n(n大于1),这样的话,可以允许多达n个进程并发访问资源。

但是,从Linux内核2.6.37版本之后,上面的函数和宏已经不存在。这是为什么呢?因为大家发现在Linux内核的设计实现中通常使用互斥信号量,而不会使用信号量。那既然如此,为什么不直接使用自旋锁和一个int型整数设计信号量呢?这样的话,因为自旋锁本身就有互斥性,代码岂不更为简洁?这样设计,还有一个原因就是之前使用atomic原子变量表示count,但是等待该信号量的进程队列还是需要自旋锁进行保护,有点重复。于是,2.6.37版本内核开始,就使用自旋锁和count设计信号量了。代码如下:

代码语言:javascript
复制
struct semaphore {
    raw_spinlock_t      lock;
    unsigned int        count;
    struct list_head    wait_list;
};

这样的设计使用起来更为方便简单。当然了,结构体的变化必然导致操作信号量的函数发生设计上的改变。

3 如何获取和释放信号量

前面我们已经知道,信号量实现在内核发展的过程中发生了更变。所以,其获取和释放信号量的过程必然也有了改变。为了更好的理解信号量,也为了尝试理解内核在设计上的一些思想和机制。我们还是先了解一下早期版本内核获取和释放信号量的过程。

因为信号量的释放过程比获取更为简单,所以我们先以释放信号量的过程为例进行分析。如果一个进程想要释放内核信号量,会调用up()函数。这个函数,本质上等价于下面的代码:

代码语言:javascript
复制
    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()函数了。该函数的代码如下:

代码语言:javascript
复制
__attribute__((regparm(3))) void __up(struct semaphore *sem)
{
    wake_up(&sem->wait);
}

反过来,如果一个进程想要请求一个内核信号量,调用down()函数,也就是实施p操作。该函数的实现比较复杂,但是大概内容如下:

代码语言:javascript
复制
    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语言函数,内容如下:

代码语言:javascript
复制
__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版本以后的内核中,这个函数已经被废弃。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2020-04-14,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 嵌入式ARM和Linux 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 1 什么是信号量?
  • 2 信号量的实现
  • 3 如何获取和释放信号量
  • 3 请求信号量的其它函数版本
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档