Linux内核33-信号量

简介: Linux内核33-信号量

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版本以后的内核中,这个函数已经被废弃。


相关文章
|
3天前
|
算法 Linux 调度
深入理解Linux内核调度器:从基础到优化####
本文旨在通过剖析Linux操作系统的心脏——内核调度器,为读者揭开其高效管理CPU资源的神秘面纱。不同于传统的摘要概述,本文将直接以一段精简代码片段作为引子,展示一个简化版的任务调度逻辑,随后逐步深入,详细探讨Linux内核调度器的工作原理、关键数据结构、调度算法演变以及性能调优策略,旨在为开发者与系统管理员提供一份实用的技术指南。 ####
21 4
|
7天前
|
缓存 算法 Linux
深入理解Linux内核调度器:公平性与性能的平衡####
真知灼见 本文将带你深入了解Linux操作系统的核心组件之一——完全公平调度器(CFS),通过剖析其设计原理、工作机制以及在实际系统中的应用效果,揭示它是如何在众多进程间实现资源分配的公平性与高效性的。不同于传统的摘要概述,本文旨在通过直观且富有洞察力的视角,让读者仿佛亲身体验到CFS在复杂系统环境中游刃有余地进行任务调度的过程。 ####
28 6
|
6天前
|
缓存 资源调度 安全
深入探索Linux操作系统的心脏——内核配置与优化####
本文作为一篇技术性深度解析文章,旨在引领读者踏上一场揭秘Linux内核配置与优化的奇妙之旅。不同于传统的摘要概述,本文将以实战为导向,直接跳入核心内容,探讨如何通过精细调整内核参数来提升系统性能、增强安全性及实现资源高效利用。从基础概念到高级技巧,逐步揭示那些隐藏在命令行背后的强大功能,为系统管理员和高级用户打开一扇通往极致性能与定制化体验的大门。 --- ###
26 9
|
5天前
|
缓存 负载均衡 Linux
深入理解Linux内核调度器
本文探讨了Linux操作系统核心组件之一——内核调度器的工作原理和设计哲学。不同于常规的技术文章,本摘要旨在提供一种全新的视角来审视Linux内核的调度机制,通过分析其对系统性能的影响以及在多核处理器环境下的表现,揭示调度器如何平衡公平性和效率。文章进一步讨论了完全公平调度器(CFS)的设计细节,包括它如何处理不同优先级的任务、如何进行负载均衡以及它是如何适应现代多核架构的挑战。此外,本文还简要概述了Linux调度器的未来发展方向,包括对实时任务支持的改进和对异构计算环境的适应性。
23 6
|
6天前
|
缓存 Linux 开发者
Linux内核中的并发控制机制:深入理解与应用####
【10月更文挑战第21天】 本文旨在为读者提供一个全面的指南,探讨Linux操作系统中用于实现多线程和进程间同步的关键技术——并发控制机制。通过剖析互斥锁、自旋锁、读写锁等核心概念及其在实际场景中的应用,本文将帮助开发者更好地理解和运用这些工具来构建高效且稳定的应用程序。 ####
22 5
|
6天前
|
算法 Unix Linux
深入理解Linux内核调度器:原理与优化
本文探讨了Linux操作系统的心脏——内核调度器(Scheduler)的工作原理,以及如何通过参数调整和代码优化来提高系统性能。不同于常规摘要仅概述内容,本摘要旨在激发读者对Linux内核调度机制深层次运作的兴趣,并简要介绍文章将覆盖的关键话题,如调度算法、实时性增强及节能策略等。
|
7天前
|
存储 监控 安全
Linux内核调优的艺术:从基础到高级###
本文深入探讨了Linux操作系统的心脏——内核的调优方法。文章首先概述了Linux内核的基本结构与工作原理,随后详细阐述了内核调优的重要性及基本原则。通过具体的参数调整示例(如sysctl、/proc/sys目录中的设置),文章展示了如何根据实际应用场景优化系统性能,包括提升CPU利用率、内存管理效率以及I/O性能等关键方面。最后,介绍了一些高级工具和技术,如perf、eBPF和SystemTap,用于更深层次的性能分析和问题定位。本文旨在为系统管理员和高级用户提供实用的内核调优策略,以最大化Linux系统的效率和稳定性。 ###
|
6天前
|
Java Linux Android开发
深入探索Android系统架构:从Linux内核到应用层
本文将带领读者深入了解Android操作系统的复杂架构,从其基于Linux的内核到丰富多彩的应用层。我们将探讨Android的各个关键组件,包括硬件抽象层(HAL)、运行时环境、以及核心库等,揭示它们如何协同工作以支持广泛的设备和应用。通过本文,您将对Android系统的工作原理有一个全面的认识,理解其如何平衡开放性与安全性,以及如何在多样化的设备上提供一致的用户体验。
|
5天前
|
缓存 运维 网络协议
深入Linux内核架构:操作系统的核心奥秘
深入Linux内核架构:操作系统的核心奥秘
22 2
|
8天前
|
监控 网络协议 算法
Linux内核优化:提升系统性能与稳定性的策略####
本文深入探讨了Linux操作系统内核的优化策略,旨在通过一系列技术手段和最佳实践,显著提升系统的性能、响应速度及稳定性。文章首先概述了Linux内核的核心组件及其在系统中的作用,随后详细阐述了内存管理、进程调度、文件系统优化、网络栈调整及并发控制等关键领域的优化方法。通过实际案例分析,展示了这些优化措施如何有效减少延迟、提高吞吐量,并增强系统的整体健壮性。最终,文章强调了持续监控、定期更新及合理配置对于维持Linux系统长期高效运行的重要性。 ####