6.S081——并发与锁部分(自旋锁的实现)——xv6源码完全解析系列(9)

news2024/12/28 3:03:32

0.briefly speaking

并发(Concurrency)指的是在多处理器系统(multiprocessor)中的并行,线程切换和中断导致的多个指令流交互错杂的情况,再和现代处理器体系结构中的多发射、乱序执行、Cache一致性等话题结合起来,这几乎变成了一个相当相当复杂的话题。在Xv6运行的平台SiFive_Unleashed上,也有5个核心(1个S51 Moniter Core和4个U54 Application Core),这说明在Xv6系统运行的过程中考虑并发控制(Concurrency Control)是非常必要的。锁机制(lock mechanism)是一种最常见的管理并发程序正确性的方案,它通过将对临界区的访问串行化(serialize)来保证并发操作的正确性。Xv6中在如下地方使用了锁机制,可以作为研究对象自己去一一体悟锁的重要性(比如画画这些情况下的锁链):
在这里插入图片描述
这篇博客中将要研究一下Xv6内核中自旋锁的实现,而至于它的使用,我们很难一一在内核中去分析之。它涉及的地方非常多且零碎,只能自己慢慢体悟锁的使用才可以慢慢熟悉它的用法和时机。不过,Xv6 book大纲中为我们提供了一些需要注意的话题,如下所示,我简单做了个概括

  • 死锁与锁定序:如果一个代码路径可能会在同一时间持有多个锁,那么所有代码路径都应该按照相同的次序获取这些锁,否则就会有死锁的风险。不过这个很难,尤其是锁链非常长的时候,保持所有进程持有锁的顺序是非常艰难的。
  • 锁与中断处理程序:有些时候中断处理程序(interrupt handler)需要锁来保证并发安全,那么在中断处理程序中它会尝试获取这把锁,如果进程恰好在进入中断处理程序之前持有这把锁,那么这个进程就会出现自己等待自己的死锁。所以如果中断处理程序中需要持有锁,在CPU持有锁的期间必须保证不能被中断。而Xv6的策略更加保守Xv6在申请锁之前,必须先关中断。
  • 指令与内存定序:即使正确安排了锁次序,也有可能会出现错误,这是因为编译器和CPU可能会对指令的执行顺序做重新排序以提升指令执行速度,这种重新排序的规则叫做访存模型(memory rules),但是错误的调整动作会导致并发控制错误,所以必须加入一些内存屏障指令(memory barrier),比如fence指令,来避免这些重排序对并发造成的影响

下面进入正题,看看Xv6系统中自旋锁的实现,代码量不大,我们要阅读的代码文件如下:
1.kernel/spinlock.h
2.kernel/spinlock.c

1.自旋锁的结构与初始化

首先来看自旋锁结构体中有什么,自旋锁结构体定义在spinlock.h中,示意如下:

// Mutual exclusion lock.
// 译:互斥锁
struct spinlock {
  // 锁是否已被持有的标志位
  uint locked;       // Is the lock held?

  // For debugging:
  // 译:用于debug的一些附加信息
  char *name;        // Name of lock.
  struct cpu *cpu;   // The cpu holding the lock.
};

我们可以看到,一个自旋锁的实现非常简单,其实只需要一个标志来记录锁当前是否被占用即可,另外附加的一些信息是用来debug时提示的。

同样的,对自旋锁的初始化过程也非常简单,代码位于kernel/spinlock.c:11,它只给锁起了个名字,并将锁是否被持有以及当前持有锁的CPU标志置为空,代码如下:

// 对自旋锁的初始化
// 给锁起了个名字,并且将锁中的标志位置为空
void
initlock(struct spinlock *lk, char *name)
{
  lk->name = name;
  lk->locked = 0;
  lk->cpu = 0;
}

2.关中断——push/pop off

我们在briefly speaking部分说过,当并发和中断交织在一起的时候可能会爆发意想不到的死锁。Xv6为了避免这种情况采取了一种保守的策略:在一个进程尝试获取锁(还不一定成功呢…)之前就把中断关掉。

实现这个功能的一对函数是push off和pop off,它们是配对使用的。你可能会有疑问,为什么要提供这么一套函数来控制中断的开关,我们明明使用两个宏intr_on和intr_off(kernel/riscv.h:273, 280)就可以实现这些功能了呀?

这是因为在处理并发的过程中,我们可能会在一条代码路径上多次获取和释放不同的自旋锁(这些锁按照获取的先后次序排成了一个锁链),又因为Xv6内核采取了上述的保守策略,在尝试获取锁之前就把中断关闭,这样一来开关中断的动作也就随着加锁、解锁动作形成多次嵌套,所以开关中断的动作一定要随着加锁解锁操作配对起来,这就需要我们额外记录一些信息。

为了提供这种支持,在保存处理器信息的结构体cpu(kernel/proc.h:22)中添加了两个变量noff和intena来分别记录这种嵌套操作的深度,和进入锁链之前CPU的中断开关情况,一旦身处锁链之中,中断一定保证是关闭的,但是最终进程一定要逐层从锁链中退出,并且恢复到进入锁链之前的中断状态。

// Per-CPU state.
struct cpu {
  struct proc *proc;          // The process running on this cpu, or null.
  struct context context;     // swtch() here to enter scheduler().
  int noff;                   // <push_off操作的嵌套深度>,其实也就是当前的锁链长度
  int intena;                 // <在首次push_off操作之前,中断的开关状态>
};

好了,至此我们算是比较详细地分析了为什么需要有push_off和pop_off函数,现在就来看看分别它们的实现,首先是push_off函数,它主要做的事情就是:关中断、递增中断动作嵌套深度,如果当前是在尝试获取锁链中的第一把锁,则将进入锁链之前的中断状态保存到intena变量中

void
push_off(void)
{
  // 获取当前CPU的中断开关状态
  int old = intr_get();
  
  // 无论如何,都关闭中断
  // 事实上在初次获取到锁之后,中断就已经是关闭状态了
  intr_off();
  
  // 如果是刚刚进入锁链
  // 就将进入锁链之前的中断状态,也就是上面的old
  // 保存在intena中
  if(mycpu()->noff == 0)
    mycpu()->intena = old;
  
  // 嵌套深度+1
  mycpu()->noff += 1;
}

然后就是pop_off函数,它几乎是push_off函数的逆动作,唯一加入的就是一些异常状态的检测,详见下面的代码和注释:

void
pop_off(void)
{
  // 获取当前的CPU
  struct cpu *c = mycpu();
  
  // 错误情况检测与判断:
  // 1.如果中断没关,这是不符合常理的,处于锁链之中的CPU中断一定是关掉的
  // 2.中断嵌套深度<1(也就是0),说明已经退出锁链,没有理由再调用pop_off
  // 两种情况之一发生,内核陷入panic
  if(intr_get())
    panic("pop_off - interruptible");
  if(c->noff < 1)
    panic("pop_off");
  
  // 嵌套深度-1
  c->noff -= 1;
  
  // 如果当前已经完全退出锁链,且未进入锁链时中断打开
  // 则恢复CPU在进入锁链之前的开中断状态
  if(c->noff == 0 && c->intena)
    intr_on();
}

3.加锁与解锁操作

3.1 加锁动作——acquire

当一个进程想要对共享内存做操作且要保证正确性时,它要尝试去争夺保护这块内存区域的锁。这个操作我们简单称为加锁,在Xv6中它对应的函数叫做acquire(kernel/spinlock.c:21),代码如下:

// Acquire the lock.
// Loops (spins) until the lock is acquired.
// 译:获取锁
// 在原地循环直到锁被获取
void
acquire(struct spinlock *lk)
{
  // 调用push_off关闭中断,增加迭代深度
  push_off(); // disable interrupts to avoid deadlock.
  
  // 如果已经持有了要获取的锁,则内核陷入panic
  // Xv6不允许可重入锁(re-entrant lock)
  if(holding(lk))
    panic("acquire");

  // On RISC-V, sync_lock_test_and_set turns into an atomic swap:
  //   a5 = 1
  //   s1 = &lk->locked
  //   amoswap.w.aq a5, a5, (s1)
  
  // amoswap.w.aq(原子交换字大小的变量值:atomic swap.word.acquire)
  // acquire标志保证在此操作之后的访存操作不可以被搬运到此操作之前
  // 相当于隐含了内存定序(memory ordering)的语义
  // 使用sync_lock_test_and_set 函数,可以原子式的完成test&set操作
  // __sync_lock_test_and_set函数的第一个参数是一个指针,指向一个内存位置
  // __sync_lock_test_and_set函数的第二个参数是要往上述内存区域写入的值
  // 返回值是第一个参数指向的内存位置被改写之前的值
  // 如果返回值是1,表示这把锁正在被其他进程持有,我们继续向其中写入1,不会影响到其他进程
  // 如果返回值是0,那么说明这把锁是空闲状态,当前进程成功获取了锁,于是可以退出循环
  // Test&Set指令必须是原子的,因为多个进程尝试获取锁的过程
  // 本质上也是对临界区(lcoked标志)的访问,如果不是原子化的也会出错
  while(__sync_lock_test_and_set(&lk->locked, 1) != 0)
    ;

  // Tell the C compiler and the processor to not move loads or stores
  // past this point, to ensure that the critical section's memory
  // references happen strictly after the lock is acquired.
  // On RISC-V, this emits a fence instruction.
  // 译:告知C编译器和处理器,不要让load/store命令随意翻越此处
  // 这是为了保证对临界区内存的访问严格发生在锁被获取之后
  // 在RISC-V架构上,这会生成一个fence指令
  // fence指令将会保证位于这条指令前后的访存指令,都互相不越界
  __sync_synchronize();

  // Record info about lock acquisition for holding() and debugging.
  // 译:将当前获取锁的PCU信息记录在锁结构体中,方便调试
  lk->cpu = mycpu();
}

加锁代码中涉及到一些编译器的内置函数,比如__sync_lock_test_and_set和__sync_synchronize,这些都是编译器对开发者管理访存模型时提供的一些工具函数。__sync_lock_test_and_set函数的含义我们在上面已经简单介绍过,操作系统原理教材中一般也都会对所谓的Test&Set操作进行介绍,这里不再过多赘述。

值得注意的是__sync_lock_test_and_set在翻译为汇编指令时会有一条amoswap.w.aq指令, 这条指令除了原子语义之外(atomic semantic),其实还兼具内存屏障的语义(barrier semantic)。aq标志位的使用保证了在这条汇编之后的访存指令不会被移到这条指令的前面,其实这和下面的fence指令语义有所重复,详见RISC-V原子指令这篇博客。而fence指令既不允许位于本条指令之前的访存指令移到后面,也不允许位于本条指令之后的指令移到前面约束是双向的,就像是一个屏障(fence),牢牢将界限划分开来。这是为了防止指令的乱序执行导致锁机制失效,我们在briefly speaking部分已经提到过了。

3.2 解锁动作——release

解锁操作是和加锁操作对应的,用于在锁链中释放一个锁,它的代码和注释如下:

// Release the lock.
// 译:释放锁
void
release(struct spinlock *lk)
{
  // 如果没有持有锁,那么没必要释放
  // 这是一个错误,内核会陷入panic
  if(!holding(lk))
    panic("release");
  
  // 首先将锁中的CPU信息清除
  lk->cpu = 0;

  // Tell the C compiler and the CPU to not move loads or stores
  // past this point, to ensure that all the stores in the critical
  // section are visible to other CPUs before the lock is released,
  // and that loads in the critical section occur strictly before
  // the lock is released.
  // On RISC-V, this emits a fence instruction.
  // 此处不译,和上面的说明差不多
  __sync_synchronize();

  // Release the lock, equivalent to lk->locked = 0.
  // This code doesn't use a C assignment, since the C standard
  // implies that an assignment might be implemented with
  // multiple store instructions.
  // On RISC-V, sync_lock_release turns into an atomic swap:
  //   s1 = &lk->locked
  //   amoswap.w zero, zero, (s1)
  // 译:释放锁,等同于lk->locked = 0
  // 这里的代码没有使用C语言里的赋值运算符,因为C标准会用
  // 多个store指令来实现一个赋值操作
  // 在RISC-V中,sync_lock_release则会变成一个原子交换操作amoswap.w
  __sync_lock_release(&lk->locked);
  
  // 调用pop_off回到锁链的上一层
  pop_off();
}

这段代码中的__sync_synchronize函数我们在之前已经介绍过了,这里不再赘述。这段代码还会调用__sync_lock_release函数来原子地对锁进行释放,其实就是将锁的被占用标志原子地置为0。所以逻辑还是比较简单的,在阅读上述代码时,请多注意这些原子访存指令和内存屏障指令,它们是构成锁机制的核心。

以上就是有关自旋锁的实现,可以看到自旋锁在尝试获取锁时会在一个死循环中持续调用Test&Set原子指令,这样的好处在于进程在锁释放的第一时间可以获得锁,但坏处在于空转盲目等待会浪费大量的CPU运算。为此Xv6中实现了睡眠锁来规避锁的获取过程中出现的空等状态,这个话题和后续的进程调度强相关,所以我们放到下一个专题深入研究,敬请期待!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/710254.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

装饰器设计模式应⽤-JDK源码⾥⾯的Stream IO流

装饰器设计模式在JDK源码⾥⾯应⽤场景 抽象组件&#xff08;Component&#xff09;&#xff1a;InputStream 定义装饰⽅法的规范被装饰者&#xff08;ConcreteComponent) : FileInputStream、ByteArrayInputStream Component的具体实现&#xff0c;也就是我们要装饰的具体对…

09-属性描述符Object.getOwnPropertyDescriptor(),原始数据不可重写

把原始数据作为属性值传入新对象中&#xff0c;发生原始数据修改丢失的问题怎么办&#xff1f; 应该使用Object.defineProperty()设置该属性用Object.defineProperty()设置的属性&#xff0c;默认writable、enumerable、configurable均为false并且自定义提醒该属性设置了不可重…

掌握Python的X篇_6_常量与变量、常见运算符、字符型变量

文章目录 1. 常量与变量1.1 常量与变量定义1.2 数字型变量 2. 常见运算符3. 字符型变量( 字符 )3.1 字符串变量的格式化 1. 常量与变量 简单理解&#xff0c;直接使用的数据&#xff0c;就是常量&#xff0c;最常见的常量有数字和字符串 采用ipython进行交互式编程 1.1 常量…

5.1阻塞和非阻塞、同步和异步 5.2Unix、Linux上的五种IO模型

5.1阻塞和非阻塞、同步和异步 典型的一次IO的两个阶段是什么&#xff1f;数据就绪和数据读写 数据就绪&#xff1a;根据IO操作的就绪状态 阻塞非阻塞 数据读写&#xff1a;根据应用程序和内核的交互方式 同步异步 陈硕&#xff1a;在处理IO的时候&#xff0c;阻塞和非阻塞都…

FPGA时序约束--实战篇(时序收敛优化)

目录 一、模块运行时钟频率 二、HDL代码 1、HDL代码风格 2、HDL代码逻辑优化 三、组合逻辑层数 1、插入寄存器 2、逻辑展平设计 3、防止变量被优化 四、高扇出 1、使用max_fanout 2、复位信号高扇出 五、资源消耗 1、优化代码逻辑&#xff0c;减少资源消耗。 2、…

基于图层自动识别算法在CAD图纸基础上快速创建Revit BIM模型 - VS2022 + AutoCAD2024 + ObjectARX环境搭建

引言 CAD 技术将建筑师、工程师们从手工绘图推向计算机辅助制图&#xff0c;实现了工程设计领域的第一次信息革命。从 CAD 技术普及以来&#xff0c;设计成果一般都是 CAD 图纸形式表达。但是近些年来&#xff0c;随着建筑的复杂程度日益增加&#xff0c;专业间的配合更加紧密…

行业观察 | 芯片设计产业链:上游至下游

本文简单总结、介绍芯片设计产业链全流程。 更新&#xff1a;2023 / 7 / 2 文章目录 集成电路产业链常见的芯片架构图数字IC设计流程概略版详解版前后端前端&#xff1a;RTL -> Netlist后端&#xff1a;Netlist -> Layout 示例&#xff1a;基于标准单元&#xff08;STD …

通过串口控制LED-单片机

1.输入数据控制LED灯状态00-ff。同时会接收输入的数据。 中断和定时器配置 void UART_Init() //4800bps11.0592MHz { SCON0X50;//sm00,sm11,ren1 PCON & 0x7F; //波特率不倍速 TMOD & 0x0F; //设置定时器模式 T1 TMOD | 0x20; …

【人工智能与机器学习】决策树ID3及其python实现

文章目录 1 决策树算法1.1 特征选择1.2 熵&#xff08;entropy&#xff09;1.3 信息增益 2 ID3算法的python实现总结 1 决策树算法 决策树&#xff08;Decision Tree)是一类常见的机器学习方法&#xff0c;是一种非常常用的分类方法&#xff0c;它是一种监督学习。常见的决策树…

ModaHub魔搭社区:向量数据库MIlvus服务端配置(一)

目录 服务端配置 配置概述 Milvus 文件结构 配置修改 编辑配置文件 运行时修改 server_config.yaml 参数说明 cluster 区域 general 区域 network 区域 服务端配置 配置概述 以下配置说明可同时应用于单机或者分布式场景。 Milvus 文件结构 成功启动 Milvus 服务后…

加速优化WooCommerce跨境电商网站的15种简单方法

Neil Patel和 Google所做的研究表明&#xff0c;如果加载时间超过三秒&#xff0c;将近一半的用户会离开网站。页面加载时间每增加一秒&#xff08;最多5秒&#xff09;&#xff0c;您的收入可能就会减少。在本教程中&#xff0c;我们将学习如何优化加速WooCommerce商店。 目录…

【20220605】文献翻译:高维数据动态可视化研究综述

A Review of the State-of-the-Art on Tours for Dynamic Visualization of High-dimensional Data Visualization of High-dimensional Data) Lee, Stuart, et al. “A Review of the State-of-the-Art on Tours for Dynamic Visualization of High-dimensional Data.” arXiv…

【书评】一本Android系统性能优化的新书

Android性能优化&#xff0c;是一个合格的Android程序员必备的技能&#xff0c;现如今几乎所有的Android面试内容都会或多或少涉及性能优化方面的话题。 学习Android性能优化可以让我们在简历上展示自己的专业技能和项目经验&#xff0c;证明自己具备高效开发和优化Android应用…

java jwt生成token并在网关设置全局过滤器进行token的校验

1、首先引入jjwt的依赖 <dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt</artifactId><version>0.9.1</version> </dependency>2、编写生成token的工具类 package com.jjw.result.util;import com.jjw.res…

【UnityDOTS 三】Component的理解

Component的理解 文章目录 Component的理解前言一、托管Component与非托管Component1.非托管Component2.托管Component 二、各功能的Component三、在Editor中的Component的区分总结 前言 Component作为ECS中承载数据的结构&#xff0c;了解他相关内容是非常必要的&#xff0c;…

基于Jsp+Servlet+Mysql学生信息管理系统

基于JspServletMysql学生信息管理系统 一、系统介绍二、功能展示1. 系统的部署2.导入数据库3. 系统介绍 四、其它1.其他系统实现五.获取源码 一、系统介绍 项目类型&#xff1a;Java web项目/Java EE项目/ 项目名称&#xff1a;基于sevelet的学生信息管理系统 当前版本&…

用Python制作一个简单时间、日期显示工具

Python是一款强大的编程软件&#xff0c;可以轻松实现我们的多种开发需求。今天我们拿Python中自带的tkinter来开发一个时钟显示器。如下图所示&#xff1a; 时间显示器 一、编程要求 用tkinter写一个漂亮、五彩的时间显示器&#xff0c;要求显示时、分、秒&#xff0c;即时变…

【JAVA】十分钟带你了解java的前世今生

个人主页&#xff1a;【&#x1f60a;个人主页】 系列专栏&#xff1a;【初始JAVA】 文章目录 前言JAVA介绍诞生&#x1f52c;名字与图标&#x1f916;发展&#x1f6e9;️未来&#x1fa84; 前言 玩过我的世界的朋友想必对JAVA以及它的图标都很熟悉&#xff0c;在游戏开始画面…

Java程序所在机器性能监控

Java程序所在机器性能监控 背景 问题单&#xff1a;程序故障&#xff08;OOM、网络不通、操作卡顿&#xff09;问题单&#xff1a;服务连接不上需求 1、监控本地机器性能 告警日志UI2、监控服务接口服务 告警日志UI方案 固定间隔获取机器网络CPU内存数据设置阈值&#xff0c;告…

自定义starter实现接口或方法限流功能

本文的思路是利用AOP技术自定义注解实现对特定的方法或接口进行限流。目前通过查阅相关资料&#xff0c;整理出三种类型限流方法&#xff0c;分别为基于guava限流实现、基于sentinel限流实现、基于Semaphore的实现。 一、限流常用的算法 1.1令牌桶算法 令牌桶算法是目前应用…