v79.01 鸿蒙内核源码分析(用户态锁篇) | 如何使用快锁Futex(上) | 百篇博客分析OpenHarmony源码

百篇博客分析|本篇为:(用户态锁篇) | 如何使用快锁Futex(上)

进程通讯相关篇为:

快锁上下篇

鸿蒙内核实现了Futex,系列篇将用两篇来介绍快锁,主要两个原因:

  • 网上介绍Futex的文章很少,全面深入内核介绍的就更少,所以来一次详细整理和挖透。
  • 涉及用户态和内核态打配合,共同作用,既要说用户态的使用又要说清楚内核态的实现。
    本篇为上篇,用户态下如何使用Futex,并借助一个demo来说清楚整个过程。

基本概念

Futex(Fast userspace mutex,用户态快速互斥锁),系列篇简称 快锁 ,是一个在Linux上实现锁定和构建高级抽象锁如信号量和POSIX互斥的基本工具,它第一次出现在linux内核开发的2.5.7版;其语义在2.5.40固定下来,然后在2.6.x系列稳定版内核中出现,是内核提供的一种系统调用能力。通常作为基础组件与用户态的相关锁逻辑结合组成用户态锁,是一种用户态与内核态共同作用的锁,其用户态部分负责锁逻辑,内核态部分负责锁调度。

当用户态线程请求锁时,先在用户态进行锁状态的判断维护,若此时不产生锁的竞争,则直接在用户态进行上锁返回;反之,则需要进行线程的挂起操作,通过Futex系统调用请求内核介入来挂起线程,并维护阻塞队列。

当用户态线程释放锁时,先在用户态进行锁状态的判断维护,若此时没有其他线程被该锁阻塞,则直接在用户态进行解锁返回;反之,则需要进行阻塞线程的唤醒操作,通过Futex系统调用请求内核介入来唤醒阻塞队列中的线程。

存在意义

  • 互斥锁(mutex)是必须进入内核态才知道锁可不可用,没人跟你争就拿走锁回到用户态,有人争就得干等 (包括 有限时间等和无限等待两种,都需让出CPU执行权) 或者放弃本次申请回到用户态继续执行。那为何互斥锁一定要陷入内核态检查呢? 互斥锁(mutex) 本质是竞争内核空间的某个全局变量(LosMux结构体)。应用程序也有全局变量,但其作用域只在自己的用户空间中有效,属于内部资源,有竞争也是应用程序自己内部解决。而应用之间的资源竞争(即内核资源)就需要内核程序来解决,内核空间只有一个,内核的全局变量当然要由内核来管理。应用程序想用内核资源就必须经过系统调用陷入内核态,由内核程序接管CPU,所谓接管本质是要改变程序状态寄存器,CPU将从用户态栈切换至内核态栈运行,执行完成后又要切回用户态栈中继续执行,如此一来栈间上下文的切换就存在系统性能的损耗。没看明白的请前往系列篇 (互斥锁篇) 翻看。

  • 快锁 解决思路是能否在用户态下就知道锁可不可用,因为竞争并不是时刻出现,跑到内核态一看其实往往没人给你争,白跑一趟来回太浪费性能。那问题来了,用户态下如何知道锁可不可用呢? 因为不陷入内核态就访问不到内核的全局变量。而自己私有空间的变量对别的进程又失效不能用。越深入研究内核越有一种这样的感觉,内核的实现可以像数学一样推导出来,非常有意思。数学其实是基于几个常识公理推导出了整个数学体系,因为不如此逻辑就无法自洽。如果对内核有一定程度的了解,这里自然能推导出可以借助 共享内存 来实现!

使用过程

看个linux futex官方demo详细说明下用户态下使用Futex的整个过程,代码不多,但涉及内核的知识点很多,通过它可以检验出内核基本功扎实程度。

//futex_demo.c
#define _GNU_SOURCE
#include <stdio.h>
#include <errno.h>
#include <stdatomic.h>
#include <stdint.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/mman.h>
#include <sys/syscall.h>
#include <linux/futex.h>
#include <sys/time.h>
#define errExit(msg)    do { perror(msg); exit(EXIT_FAILURE); \
                        } while (0)
static uint32_t *futex1, *futex2, *iaddr;
/// 快速系统调用
static int futex(uint32_t *uaddr, int futex_op, uint32_t val,
        const struct timespec *timeout, uint32_t *uaddr2, uint32_t val3)
{
    return syscall(SYS_futex, uaddr, futex_op, val,
                    timeout, uaddr2, val3);
}
/// 申请快锁
static void fwait(uint32_t *futexp)
{
    long s;
    while (1) {
        const uint32_t one = 1;
        if (atomic_compare_exchange_strong(futexp, &one, 0))
            break; //申请快锁成功
        //申请快锁失败,需等待
        s = futex(futexp, FUTEX_WAIT, 0, NULL, NULL, 0);
        if (s == -1 && errno != EAGAIN)
            errExit("futex-FUTEX_WAIT");
    }
}
/// 释放快锁
static void fpost(uint32_t *futexp)
{
    long s;
    const uint32_t zero = 0;
    if (atomic_compare_exchange_strong(futexp, &zero, 1)) {//释放快锁成功
        s = futex(futexp, FUTEX_WAKE, 1, NULL, NULL, 0);//唤醒等锁 进程/线程
        if (s  == -1)
            errExit("futex-FUTEX_WAKE");
    }
}
/// 父子进程竞争快锁
int main(int argc, char *argv[])
{
    pid_t childPid;
    int nloops;
    setbuf(stdout, NULL);
    nloops = (argc > 1) ? atoi(argv[1]) : 3;
    iaddr = mmap(NULL, sizeof(*iaddr) * 2, PROT_READ | PROT_WRITE,
                MAP_ANONYMOUS | MAP_SHARED, -1, 0);//创建可读可写匿名共享内存
    if (iaddr == MAP_FAILED)
        errExit("mmap");
    futex1 = &iaddr[0]; //绑定锁一地址
    futex2 = &iaddr[1]; //绑定锁二地址
    *futex1 = 0; // 锁一不可申请 
    *futex2 = 1; // 锁二可申请
    childPid = fork();
    if (childPid == -1)
        errExit("fork");
    if (childPid == 0) {//子进程返回
        for (int j = 0; j < nloops; j++) {
            fwait(futex1);//申请锁一
            printf("子进程  (%jd) %d\n", (intmax_t) getpid(), j);
            fpost(futex2);//释放锁二
        }
        exit(EXIT_SUCCESS);
    }
    // 父进程返回执行
    for (int j = 0; j < nloops; j++) {
        fwait(futex2);//申请锁二
        printf("父进程 (%jd) %d\n", (intmax_t) getpid(), j);
        fpost(futex1);//释放锁一
    }
    wait(NULL);
    exit(EXIT_SUCCESS);
}

代码在wsl2上编译运行结果如下:

root@DESKTOP-5PBPDNG:/home/turing# gcc ./futex_demo.c -o futex_demo
root@DESKTOP-5PBPDNG:/home/turing# ./futex_demo
父进程 (283) 0
子进程 (284) 0
父进程 (283) 1
子进程 (284) 1
父进程 (283) 2
子进程 (284) 2

解读

  • 通过系统调用mmap 创建一个可读可写的共享内存iaddr[2]整型数组,完成两个futex锁的初始化。内核会在内存分配一个共享线性区(MAP_ANONYMOUS | MAP_SHARED),该线性区可读可写( PROT_READ | PROT_WRITE)
    futex1 = &iaddr[0]; //绑定锁一地址
    futex2 = &iaddr[1]; //绑定锁二地址
    *futex1 = 0; // 锁一不可申请 
    *futex2 = 1; // 锁二可申请
    
    如此futex1futex2有初始值并都是共享变量,想详细了解mmap内核实现的可查看系列篇 (线性区篇)(共享内存篇) 有详细介绍。
  • childPid = fork(); 创建了一个子进程,fork会拷贝父进程线性区的映射给子进程,导致的结果就是父进程的共享线性区到子进程这也是共享线性区,映射的都是相同的物理地址。对fork不熟悉的请前往翻看,系列篇 (fork篇)| 一次调用,两次返回 专门说它。
  • fwait(申请锁)与fpost(释放锁)成对出现,单独看下申请锁过程
    /// 申请快锁
    static void fwait(uint32_t *futexp)
    {
        long s;
        while (1) {
            const uint32_t one = 1;
            if (atomic_compare_exchange_strong(futexp, &one, 0))
                break; //申请快锁成功
            //申请快锁失败,需等待
            s = futex(futexp, FUTEX_WAIT, 0, NULL, NULL, 0);
            if (s == -1 && errno != EAGAIN)
                errExit("futex-FUTEX_WAIT");
        }
    }
    
    死循环的break条件是 atomic_compare_exchange_strong为真,这是个原子比较操作,此处必须这么用,至于为什么请前往翻看系列篇 (原子操作篇)| 谁在为完整性保驾护航 ,注意它是理解Futex的关键所在,它的含义是
    在头文件<stdatomic.h>中定义
    _Bool atomic_compare_exchange_strong(volatile A * obj,C * expected,C desired);
    
    将所指向的值obj与所指向的值进行原子比较expected,如果相等,则用前者替换前者desired(执行读取 - 修改 - 写入操作)。否则,加载实际值所指向的obj进入*expected(进行负载操作)。
    什么意思 ? 来个直白的解释 :
    • 如果 futexp == 1atomic_compare_exchange_strong返回真,同时将 futexp的值变成0,1代表可以持有锁,一旦持有立即变0,别人就拿不到了。所以此处甚秒。而且这发生在用户态。
    • 如果futexp == 0 atomic_compare_exchange_strong返回假,没有拿到锁,就需要陷入内核态去挂起任务等待锁的释放
    futex(futexp, FUTEX_WAIT, 0, NULL, NULL, 0) //执行一个等锁的系统调用
    
    参数四为NULL代表不在内核态停留直接返回用户态,后续将在内核态部分详细说明。
  • childPid == 0是子进程的返回。不断地申请futex1 释放futex2
    if (childPid == 0) {//子进程返回
        for (int j = 0; j < nloops; j++) {
            fwait(futex1);
            printf("子进程  (%jd) %d\n", (intmax_t) getpid(), j);
            fpost(futex2);
        }
        exit(EXIT_SUCCESS);
    }
    
  • 最后的父进程的返回,不断地申请futex2 释放futex1
    // 父进程返回执行
    for (int j = 0; j < nloops; j++) {
        fwait(futex2);
        printf("父进程 (%jd) %d\n", (intmax_t) getpid(), j);
        fpost(futex1);
    }
    wait(NULL);
    exit(EXIT_SUCCESS);
    
  • 两把锁的初值为 *futex1 = 0; *futex2 = 1; ,父进程在 fwait(futex2)所以父进程的printf将先执行,*futex2 = 0;锁二变成不可申请,打印完成后释放fpost(futex1)使其结果为*futex1 = 1; 表示锁一可以申请了,而子进程在等fwait(futex1),交替下来执行的结果为
      父进程 (283) 0
      子进程 (284) 0
      父进程 (283) 1
      子进程 (284) 1
      父进程 (283) 2
      子进程 (284) 2
    

几个问题

以上是个简单的例子,只发生在两个进程抢一把锁的情况下,如果再多几个进程抢一把锁时情况就变复杂多了。
例如会遇到以下情况:

  • 鸿蒙内核进程池默认上限是64个,除去两个内核进程外,剩下的都归属用户进程,理论上用户进程可以创建很多快锁,这些快锁可以用于进程间(共享快锁)也可以用于线程间(私有快锁),在快锁的生命周期中该如何保存 ?
  • 无锁时,前面已经有进程在申请锁时,如何处理好新等锁进程和旧等锁进程的关系 ?
  • 释放锁时,需要唤醒已经在等锁的进程,唤醒的顺序由什么条件决定 ?

这些工作在用户态下肯定没办法完成,需要内核处理,请查看 (内核态锁篇) | 如何实现快锁Futex(下),详细解构其实现过程。

百文说内核 | 抓住主脉络

  • 百文相当于摸出内核的肌肉和器官系统,让人开始丰满有立体感,因是直接从注释源码起步,在加注释过程中,每每有心得处就整理,慢慢形成了以下文章。内容立足源码,常以生活场景打比方尽可能多的将内核知识点置入某种场景,具有画面感,容易理解记忆。说别人能听得懂的话很重要! 百篇博客绝不是百度教条式的在说一堆诘屈聱牙的概念,那没什么意思。更希望让内核变得栩栩如生,倍感亲切。
  • 与代码需不断debug一样,文章内容会存在不少错漏之处,请多包涵,但会反复修正,持续更新,v**.xx 代表文章序号和修改的次数,精雕细琢,言简意赅,力求打造精品内容。
  • 百文在 < 鸿蒙研究站 | 开源中国 | 博客园 | 51cto | csdn | 知乎 | 掘金 > 站点发布,公众号回复 百文 可方便阅读。

按功能模块:

前因后果 基础工具 加载运行 进程管理
总目录
调度故事
内存主奴
源码注释
源码结构
静态站点
参考文档
双向链表
位图管理
用栈方式
定时器
原子操作
时间管理
ELF格式
ELF解析
静态链接
重定位
进程映像
进程管理
进程概念
Fork
特殊进程
进程回收
信号生产
信号消费
Shell编辑
Shell解析
编译构建 进程通讯 内存管理 任务管理
编译环境
编译过程
环境脚本
构建工具
gn应用
忍者ninja
自旋锁
互斥锁
进程通讯
信号量
事件控制
消息队列
共享内存
消息封装
消息映射
用户态锁
内核态锁
内存分配
内存管理
内存汇编
内存映射
内存规则
物理内存
时钟任务
任务调度
任务管理
调度队列
调度机制
线程概念
并发并行
CPU
系统调用
任务切换
文件系统 硬件架构
文件概念
文件系统
索引节点
挂载目录
根文件系统
VFS
文件句柄
管道文件
汇编基础
汇编传参
工作模式
寄存器
异常接管
汇编汇总
中断切换
中断概念
中断管理

百万注源码 | 处处扣细节

  • 百万汉字注解内核目的是要看清楚其毛细血管,细胞结构,等于在拿放大镜看内核。内核并不神秘,带着问题去源码中找答案是很容易上瘾的,你会发现很多文章对一些问题的解读是错误的,或者说不深刻难以自圆其说,你会慢慢形成自己新的解读,而新的解读又会碰到新的问题,如此层层递进,滚滚向前,拿着放大镜根本不愿意放手。

  • < gitee | github | coding | gitcode > 四大码仓推送 | 同步官方源码,公众号中回复 百万 可方便阅读。

关注不迷路 | 代码即人生

posted @ 2022-02-18 16:22  鸿蒙内核源码分析  阅读(742)  评论(0编辑  收藏  举报