鸿蒙内核源码分析(用户态锁篇) | 如何使用快锁Futex(上)

鸿蒙内核源码分析(用户态锁篇) | 如何使用快锁Futex(上)

码农世界 2024-05-18 前端 77 次浏览 0个评论

快锁上下篇

鸿蒙内核实现了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 
      #include 
      #include 
      #include 
      #include 
      #include 
      #include 
      #include 
      #include 
      #include 
      #include 
      #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; // 锁二可申请
        

        如此futex1和futex2有初始值并都是共享变量,想详细了解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的关键所在,它的含义是

        在头文件中定义
        _Bool atomic_compare_exchange_strong(volatile A * obj,C * expected,C desired);
        

        将所指向的值obj与所指向的值进行原子比较expected,如果相等,则用前者替换前者desired(执行读取 - 修改 - 写入操作)。否则,加载实际值所指向的obj进入*expected(进行负载操作)。

        什么意思 ? 来个直白的解释 :

        • 如果 futexp == 1 则 atomic_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个,除去两个内核进程外,剩下的都归属用户进程,理论上用户进程可以创建很多快锁,这些快锁可以用于进程间(共享快锁)也可以用于线程间(私有快锁),在快锁的生命周期中该如何保存 ?
          • 无锁时,前面已经有进程在申请锁时,如何处理好新等锁进程和旧等锁进程的关系 ?
          • 释放锁时,需要唤醒已经在等锁的进程,唤醒的顺序由什么条件决定 ?

            鸿蒙全栈开发全新学习指南

            也为了积极培养鸿蒙生态人才,让大家都能学习到鸿蒙开发最新的技术,针对一些在职人员、0基础小白、应届生/计算机专业、鸿蒙爱好者等人群,整理了一套纯血版鸿蒙(HarmonyOS Next)全栈开发技术的学习路线【包含了大厂APP实战项目开发】。

            本路线共分为四个阶段:

            第一阶段:鸿蒙初中级开发必备技能

            第二阶段:鸿蒙南北双向高工技能基础:gitee.com/MNxiaona/733GH

            第三阶段:应用开发中高级就业技术

            第四阶段:全网首发-工业级南向设备开发就业技术:gitee.com/MNxiaona/733GH

            《鸿蒙 (Harmony OS)开发学习手册》(共计892页)

            如何快速入门?

            1.基本概念

            2.构建第一个ArkTS应用

            3.……

            开发基础知识:gitee.com/MNxiaona/733GH

            1.应用基础知识

            2.配置文件

            3.应用数据管理

            4.应用安全管理

            5.应用隐私保护

            6.三方应用调用管控机制

            7.资源分类与访问

            8.学习ArkTS语言

            9.……

            基于ArkTS 开发

            1.Ability开发

            2.UI开发

            3.公共事件与通知

            4.窗口管理

            5.媒体

            6.安全

            7.网络与链接

            8.电话服务

            9.数据管理

            10.后台任务(Background Task)管理

            11.设备管理

            12.设备使用信息统计

            13.DFX

            14.国际化开发

            15.折叠屏系列

            16.……

            鸿蒙开发面试真题(含参考答案):gitee.com/MNxiaona/733GH

            鸿蒙入门教学视频:

            美团APP实战开发教学:gitee.com/MNxiaona/733GH

            写在最后

            • 如果你觉得这篇内容对你还蛮有帮助,我想邀请你帮我三个小忙:
            • 点赞,转发,有你们的 『点赞和评论』,才是我创造的动力。
            • 关注小编,同时可以期待后续文章ing🚀,不定期分享原创知识。
            • 想要获取更多完整鸿蒙最新学习资源,请移步前往小编:gitee.com/MNxiaona/733GH

转载请注明来自码农世界,本文标题:《鸿蒙内核源码分析(用户态锁篇) | 如何使用快锁Futex(上)》

百度分享代码,如果开启HTTPS请参考李洋个人博客
每一天,每一秒,你所做的决定都会改变你的人生!

发表评论

快捷回复:

评论列表 (暂无评论,77人围观)参与讨论

还没有评论,来说两句吧...

Top