【精通内核】Linux内核P-V原语获取信号量

1,782 阅读5分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第2天,点击查看活动详情

前言

📫作者简介小明java问道之路,专注于研究 Java/ Liunx内核/ C++及汇编/计算机底层原理/源码,就职于大型金融公司后端高级工程师,擅长交易领域的高安全/可用/并发/性能的架构设计与演进、系统优化与稳定性建设。

📫热衷分享,喜欢原创~ 关注我会给你带来一些不一样的认知和成长

🏆InfoQ签约作者、CSDN专家博主/后端领域优质创作者/内容合伙人、阿里云专家/签约博主、51CTO专家🏆

🔥如果此文还不错的话,还请👍关注 、点赞 、收藏三连支持👍一下博主~


本文导读

本文深入Linux内核源码,从核心源码入口讲起,详细对信号量、互斥量的内核代码讲解。

其中对P-V操作实现逐行剖析,Linux内核并发控制原理的锁实现和原理在后续文章中一一讲解,本文深入浅出Linux中断控制的实现原理。

一、Linux内核P-V原语

获取信号量 P 操作,通过内联汇编原子性对 counte 操作。首先通过 decl 同时根据是否是多处理器加 lock 前缀,保证了单条指令的原子性,然后根据递减后的值是否为负数来判断获取信号量是否成功,如果失败,那么需要将线程进行睡眠,此时调用 _down_failed 函数完成此操作。

下面我们来看,如何 获取信号量 P 操作原理解析与# void__down最终实现函数。

二、获取信号量 P 操作原理解析

此时调用 _down_failed 函数完成此操作,具体实现原理如下。

通过 lock 前缀实现原子性的-sem->count操作,decl指令相当于对操作数自减

    static inline void down(struct semaphore*sem) {
        
        _asm__volatile_( // 通过 lock 前缀实现原子性的-sem->count操作,decl指令相当于对操作数自减 
            LOCK "decl %O" // 如果减完后发现sign标志位为1,则表明count值为负,往前跳到标号2处,调用 __down_failed处理,否则获取成功,直接退出
            "js 2f"
            "1: "
            LOCK_SECTION_START("") 
            "2:call__down_failed" 
            "jmp 1b"
            // 这里采用了 LOCK SECTION START 和LOCK SECTION END 宏定义,将call
            // __down_failed 和 jmp 1b的汇编代码放到.textlock段中
            // 所以如果执行完 __down_failed 方法后调用jmp 1b
            // 会回到 LOCK SECTION START之前的段中,即退出down方法
                
            LOCK SECTION END:
            : "=m" (sem -> count)
            :"c" (sem)
            :"memory");
    }

    // 通过汇编声明了 __down_failed的代码地址
    asm(
        ".text"
        ".align 4"         // 4字节对齐
        ".globl___down_failed"
        "__down failed:"
        #if defined(CONFIG_FRAME_POINTER)  // 如果定义了栈帧指针,那么开辟新的方法帧
            "pushl %ebp"
            "movl %esp, %ebp"

        #endif
        // 保存影响的寄存器值,因为随后要调用_down 函数, 可能会影响 eax、edx、ecx 寄存器,
        // 所以这里需要先对其进行保存,在方法返回后再还原
        "pushl %eax"
        "pushl %edx"
        "pushl %ecx"
        "call __down"     // 调用 __down来执行当counter为0时的操作
        "popl %ecx"    // 调用返回后恢复保存的寄存器
        "popl %edx"
        "popl %eax"

        #if defined(CONFIG_FRAME_POINTER)    //还原方法帧
            "movl %ebp,%esp"
            "popl %ebp"
        #endif 
        "ret"
    );

我们最终是调用函数 __ down 来执行最终的 __down_failed 操作。

三、void__down最终实现函数

下面是 void__down 函数源码,通过current 宏获取当前任务结构体,获取到了任务PCB,初始化wait_queuet,也就是等待线程代表,宏定义为;wait_queue_t name = {.task = tsk, .func = defauft_wake_function, .tasklist = {NULL, NULL}}

设置任务状态为TASK_UNINTERRUPTIBLE,表明不可中断的阻塞,获取自旋锁,将等待任务节点插入等待链表的队尾处,增加等待计数,循环等待释放信号量,对等待线程减1后与当前信号量的counter值相加

1、任务状态TASK_RUNNING

如果结果等于0则结束循环,这里等于0的条件就是等待信号量足够容纳更多的线程,所以不需要阻塞,设置等待任务数为1,释放自旋锁,唤醒调度器执行其他任务,当前任务就被阻塞在了等待队列里

当任务重新被唤醒时,将重新获取自旋锁,重新设置任务状,唤醒等待任务,释放自旋锁,设置当前任务状态为TASK_RUNNING。

2、Liunx内核实现原理

    void__down(struct semaphore *sem) {
        // 通过current 宏获取当前任务结构体,获取到了任务PCB
        struct task_struct *tsk = current;
        // 初始化wait_queue t,也就是等待线程代表
        // 宏定义为;wait_queue_t name = {.task = tsk, .func = defauft_wake_function, .tasklist = {NULL, NULL}}
        DECLARE_WAITQUEUE(wait, tsk);
        unsigned long flags;
        tsk->state = TASK UNINTERRUPTIBLE;           // 设置任务状态为TASK_UNINTERRUPTIBLE,表明不可中断的阻塞 
        spin_lock_irqsave(&sem -> waitlock, flags); // 获取自旋锁
        add_wait_queue_exclusive_locked(&sem -> wait, &wait); // 将等待任务节点插入等待链表的队尾处
        sem -> sleepers++;          // 增加等待计数
        for (; ; ) {                // 循环等待释放信号量
            int sleepers = sem -> sleepers;
            // 对等待线程减1后与当前信号量的counter值相加
            // 如果结果等于0则结束循环,这里等于0的条件就是等待信号量足够容纳更多的线程,所以不需要阻塞
            if (!atomic_add_negative(sleepers - 1, & sem -> count)){
                sem -> sleepers = 0;
                break;
            }
            sem -> sleepers = 1;    // 设置等待任务数为1
            spin_unlock_irqrestore( & sem -> wait.lock, flags); // 释放自旋锁
            // 唤醒调度器执行其他任务,当前任务就被阻塞在了等待队列里
            schedule();
            spin_lock_irqsave( & sem -> wait.lock flags);  // 当任务重新被唤醒时,将重新获取自旋锁 
            tsk -> state = TASK UNINTERRUPTIBLE;           // 重新设置任务状态为不可中断状态,继续循环 
        }
        
        // 至此任务已经获取了信号量,等待线程从队列中移出来 
        remove_wait_queue_locked( & sem -> wait, &wait);
        wake_up_locked( & sem -> wait);                     // 唤醒等待任务
        spin_unlock_irqrestore( & sem -> wait.lock, flags); // 释放自旋锁
        tsk->state = TASK_RUNNING;                          // 设置当前任务状态为TASK_RUNNING 
    }

上面代码我们可以看到,使用了自旋锁、P-V操作,并增加了阻塞队列实现信号量。如果读者对Linux进程调度原理不清楚,这里面方法 schedule ,其作用就是释放 CPU 的控制权,交给调度程序

然后由调度程序切换到其他进程执行,直到信号量释放后,再由其他进程将其状态设置为 RUNNABLE 后,交由调度进程重新调度执行。