duduru

Locking

6 Locking

在了解锁之前,我们要熟悉这原子性这个概念。

什么是原子性

“原子”是物质的最小组成,即原子是不可分割的。虽然到现在科学家已经发现在原子内部有更小的成分,但是在广义上原子仍然保持“不可分割”的语义。那么在芯片中的原子性是什么呢?它延续了“不可分割”这个含义,表示该系列指令的执行是不可分割的,完成的操作不会被其它外部事件打断,我们就说这系列指令遵循原子性。

为什么需要原子性

提到原子性,就不得不提到并发了,可以说原子性就是为了解决并发。

关于并发,引用书中原话:
Most kernels, including xv6, interleave the execution of multiple activities. One source of interleaving is multiprocessor hardware: computers with multiple CPUs executing independently, such as xv6’s RISC-V. These multiple CPUs share physical RAM, and xv6 exploits the sharing to maintain data structures that all CPUs read and write. This sharing raises the possibility of one CPU reading a data structure while another CPU is mid-way through updating it, or even multiple CPUs updating the same data simultaneously; without careful design such parallel access is likely to yield incorrect results or a broken data structure. Even on a uniprocessor, the kernel may switch the CPU among a number of threads, causing their execution to be interleaved. Finally, a device interrupt handler that modifies the same data as some interruptible code could damage the data if the interrupt occurs at just the wrong time. The word concurrency refers to situations in which multiple instruction streams are interleaved, due to multiprocessor parallelism, thread switching, or interrupts

译文:
大多数内核,包括xv6,会交错执行多个活动。交错的一个来源是多处理器硬件:具有多个cpu独立执行的计算机,例如xv6的RISC-V。这些多个cpu共享物理RAM,并且xv6利用共享来维护所有cpu读写的数据结构。这种共享增加了一个CPU读取数据结构而另一个CPU正在更新数据结构的可能性,甚至多个CPU同时更新相同的数据;如果不仔细设计,这种并行访问很可能产生不正确的结果或损坏的数据结构。即使在单处理器上,内核也可能在多个线程之间切换CPU,导致它们的执行交错。最后,如果中断发生在错误的时间,设备中断处理程序修改与某些可中断代码相同的数据可能会损坏数据。并发一词指的是由于多处理器并行性、线程切换或中断而导致多条指令流交错的情况。

总之,并发的机制虽好,但其特性注定会把某些指令分割开,造成错误。解决办法就是让这些指令具有原子性,避免这种错误。

下面举一些由于并发引发错误的例子:

//全局变量A
int A = 0;
//线程A执行的函数
void thread_a()
{
    A++;
    printf("ThreadA A is:%d\n",A);
    return;
}
//线程B执行的函数
void thread_b()
{
    A++;
    printf("ThreadB A is:%d\n",A);
    return;
}

以上两个函数,分别由不同的线程运行,都是对全局变量A加1后打印出来。让我们暂停一下想想看,程序的打印结果是什么?

也许你的判断是两种情况,即输出A值1、 2;A值:2、2。但把代码跑一下试试,就会发现还多了一个可能:A值:1、1。这就很奇怪了,为什么出现这种情况呢?

我们把A++转化为指令形式:

load reg,A	   #加载A变量到寄存器
Add reg,1	   #对寄存器+1
store A,reg   #储存寄存器到A变量

我们已经看到了,A++被转换成了三条指令,有可能线程A执行了上面第一行指令,线程B也执行了上面第一行指令,这时就会出现线程A、B都输出1的情况。其本质原因是,这三条指令是独立、可分割的。

解决这个问题的方案不止一种。我们可以使用操作系统的线程同步机制, 让线程A和线程B串行执行,即thread_a函数执行完成了,再执行thread_b函数。另一种方案是使用原子指令, 利用原子指令来保证对变量A执行的操作,也就是加载、计算、储存这三步是不可分割的,即一条指令能原子地完成这三大步骤。

怎样实现原子性

我们看一下书中示例的代码:

void
acquire(struct spinlock *lk) // does not work!
{
 for(;;) {
  if(lk->locked == 0) {		//语句1
   lk->locked = 1;			//语句2
   break;
  }
 }
}

这是一段软件实现锁机制的代码。某种意义上来说,它确实能实现acquire()和release()中间代码的原子性,但它忽略了acquire()函数里面代码的原子性,也就是语句1和语句2仍是可分割的。这就导致线程A和线程B可以同时运行到语句1,然后运行语句2获得锁,而锁只能由一个线程获得,故此错误。

所以现在我们的工作就是让语句1和语句2具有原子性,实现了这一步我们的锁机制就完成了。

RISC-V为我们提供了源自指令集(A),分别是LR/SC和AMO,下面将分别介绍这两者类型的原子指令。

LR/SC

首先RISC-V提供了LR/SC指令。LR指令是个缩写,全名是Load Reserved,即保留加载;而SC指令的缩写展开是Store Conditional,即条件存储。这虽然是两条指令,但却是一对好“搭档”,它们需要配合才能实现原子操作,缺一不可。看到后面,你就会知道这是为什么了,我们先从这两条指令用在哪里说起。

在原子的比较并交换操作中,常常会用到LR/SC指令,这个操作在各种加锁算法中应用广泛。我们先来看看这两条指令各自执行了什么操作。

我们先来看看它们在汇编代码中的书写形式,如下所示:

lr.{w/d}.{aqrl} rd,(rs1)
#lr是保留加载指令
#{可选内容}W(32位)、D(64位)
#aqrl为内存顺序,一般使用默认的
#rd为目标寄存器
#rs1为源寄存器1

sc.{w/d}.{aqrl} rd,rs2,(rs1)
#sc是条件储存指令
#{可选内容}W(32位)、D(64位)
#aqrl为内存顺序,一般使用默认的
#rd为目标寄存器
#rs1为源寄存器1
#rs2为源寄存器2

上述代码中,rd、rs1、rs2可以是任何通用寄存器。“{}"中的内容不是必须填写的,汇编器能根据当前的运行环境自动设置。

LR指令和SC指令完成的操作,用伪代码可以这样描述:

//lr指令
rd = [rs1]
reservation_set(cur_hart)
//sc指令
if (is_reserved(rs1)) {
    *rs1 = rs2
    rd = 0
} else
    rd = 1
clean_reservation_set(cur_hart)

我们先看看LR指令做了什么:rs1寄存器的数据就是内存地址,指定了LR指令从哪里读取数据。LR会从该地址上加载一个32位或者64位的数据,存放到rd寄存器中。这个地址需要32位或者64位对齐,之后会让当前CPU hart(RISC-V中的核心)在该地址置位。

而SC指令则是先判断rs1中对应地址里的保留位(reservation set)有没有被设置。如果被设置了,则把rs2的数据写入rs1为地址上的内存中,并在rd中写入0;否则将向rd中写入一个非零值,这个值并不一定是1,最后清除当前对应CPU hart(RISC-V中的核心)在该地址上设置的保留位。

而对于 LR/SC 来说,它的原子性体现在当目前这个 hart 保留有标记时,不允许其他 hart 对标记进行修改,只有执行完这对 LR/SC,才能执行其他的操作。

AMO

LR/SC就简单带过,因为在xv6中acquire()使用amoswap指令实现的,所以现在着重讲解amo指令。

AMO类的指令也是一类原子指令,它们相比LR/SC指令用起来更方便。因为也属于原子指令,所以每个指令完成的操作同样是不可分割,不能被外部事件打断的。

AMO 是 Atomic Memory Operation 的缩写,即原子内存操作。AMO 指令又分为几类,分别是原子交换指令、原子加法指令、原子逻辑指令和原子取大小值指令。

首先我们来看看原子交换指令(amoswap),它能执行寄存器和内存中的数据交换,并保证该操作的原子性,其汇编代码形式如下所示:

amoswap.{w/d}.{aqrl} rd,rs2,(rs1)
#amoswap是原子交换指令
#{可选内容}W(32位)、D(64位)
#aqrl为内存顺序,一般使用默认的
#rd为目标寄存器
#rs1为源寄存器1
#rs2为源寄存器2

上述代码中rd、rs1、rs2可以是任何通用寄存器。“{}"中的可以不必填写,汇编器能根据当前的运行环境自动设置。

我们用伪代码来描述一下amoswap指令完成的操作会看得更清楚。

//amoswap
tmp = *rs1
*rs1 = rs2
rd = tmp

观察上述伪代码,amoswap指令是把rs1中的数据当成内存地址,加载了该地址上一个32位或者64位的数据到rd寄存器中。然后把rs2中的数据,写入到rs1指向的内存单元中,实现rs2与内存单元的数据交换,该地址需要32位或者64位对齐。需要特别强调的是,这两步操作是原子的、不可分割的(原理是将rs1保存的地址锁住,其它任何对该地址的操作都将无效,硬件上实现了原子性)。

再来看看amoadd指令及其伪指令:

amoadd.{w/d}.{aqrl} rd,rs2,(rs1)
//amoadd
rd = *rs1
*rs1 = *rs1 + rs2

可以总结amo类指令的共同点:

rd = *rs1
*rs1 = xxx

都是把rs1保存地址指向的内容赋值给rd,之后在执行amo后缀的操作

acquire函数实现

// Acquire the lock.
// Loops (spins) until the lock is acquired.
void acquire(struct spinlock *lk)
{
  push_off(); // disable interrupts to avoid deadlock.
  if (holding(lk))	// avoid deadlock
    panic("acquire");

  __sync_fetch_and_add(&(lk->n), 1); // lk->n++,记录获得锁次数

  // On RISC-V, sync_lock_test_and_set turns into an atomic swap:
  //   a5 = 1
  //   s1 = &lk->locked
  //   amoswap.w.aq a5, a5, (s1)
  while (__sync_lock_test_and_set(&lk->locked, 1) != 0)
  {
    __sync_fetch_and_add(&lk->nts, 1); // lk->nts++,记录竞争次数
  }

  // 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.
  __sync_synchronize();

  // Record info about lock acquisition for holding() and debugging.
  lk->cpu = mycpu();
}

这里我们主要关注__sync_lock_test_and_set(&lk->locked, 1)和__sync_synchronize()这两个函数。

首先是__sync_lock_test_and_set(&lk->locked, 1),反汇编结果如下:

while (__sync_lock_test_and_set(&lk->locked, 1) != 0)
    80000c84:	87ba                	mv	a5,a4
    80000c86:	0cf4a7af          		amoswap.w.aq	a5,a5,(s1)
    80000c8a:	2781                	sext.w	a5,a5
    80000c8c:	f7f5                	bnez	a5,80000c78 <acquire+0x42>

我们看到amoswap.w.aq a5,a5,(s1)这条指令,其中

a5 = 1
s1 = &lk->locked

其次是__sync_synchronize(),它是为了设置一个内存屏障,其反汇编为:

__sync_synchronize();
    80000ca8:	0ff0000f          	fence

引用书中的原文:
It is natural to think of programs executing in the order in which source code statements appear. That’s a reasonable mental model for single-threaded code, but is incorrect when multiple threads interact through shared memory [2, 4]. One reason is that compilers emit load and store instructions in orders different from those implied by the source code, and may entirely omit them (for example by caching data in registers). Another reason is that the CPU may execute instructions out of order to increase performance. For example, a CPU may notice that in a serial sequence of instructions A and B are not dependent on each other. The CPU may start instruction B first, either because its inputs are ready before A’s inputs, or in order to overlap execution of A and B.

译文:
很自然地认为程序按照源代码语句出现的顺序执行。
对于单线程代码来说,这是一个合理的模型,但是当多个线程通过共享内存交互时就不正确了[2,4]。一个原因是编译器发出的加载和存储指令的顺序与源代码所暗示的顺序不同,并且可能完全忽略它们(例如通过在寄存器中缓存数据)。另一个原因是CPU可能为了提高性能而乱序执行指令。例如,CPU可能会注意到在一个指令序列a和B是互不依赖的。CPU可能首先启动指令B,要么是因为它的输入在A的输入之前准备好了,要么是为了重叠A和B的执行。

也就是说,编译器可能并不会按照我们期望的那样去顺序排序指令,而是出于优化等原因对指令进行修改(换序,增删)。对于acquire和release里面的代码,如果编译器将里面的代码重排到他们外面,那无疑是错误的。所以我们设置内存屏障,告诉编译器这里面的代码是不能修改的。

其中amoswap.w.aq的aq就是在这条汇编之后的访存指令不会移到前边,fence指令就是这条汇编之前之后的访存指令都不能移动,即双向约束。

还有一个push_off()/pop_off函数也不要忘记,它的主要功能是关/开中断。

push_off:

void push_off(void)
{
  int old = intr_get();
  if (old)
    intr_off();		// 关闭中断
  if (mycpu()->noff == 0)
    mycpu()->intena = old;	// 首次加锁,记录下前一刻的中断状态
  mycpu()->noff += 1;		// 嵌套深度+1
}

pop_off(用在release里):

void pop_off(void)
{
  if (intr_get())
    panic("pop_off - interruptible");
  struct cpu *c = mycpu();
  if (c->noff < 1)
    panic("pop_off");
  c->noff -= 1;			// 嵌套深度-1
  if (c->noff == 0 && c->intena)
    intr_on();			// 只有在满足嵌套深度==0和加锁前一刻中断是打开的才能重新打开中断
}

这里提几个push_off/pop_off实现的一些问题,解决了我们就知道为什么要这么设计了。

  1. 是为什么acquire()需要关中断?
    为了避免中断可能发生的各种意外情况(比如又获得了一次锁,造成死锁),xv6索性把中断关掉。

  2. 为什么不直接使用intr_on/intr_off?
    因为在处理并发的过程中,我们可能会在一条代码路径上多次获得和释放不同的自旋锁,这样我们的中断也随着加锁和解锁形成了多次嵌套,开关中断的动作也要随着加锁解锁配对起来,这就需要我们记录一些额外的信息。

xv6使用的是noff和intena两个变量记录。

// 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的实现

什么时候需要原子性

原子性是为了解决并发产生的错误,我们将这种错误成为竞争(race),所以我们需要的是在会产生竞争的地方加入锁,而不是随意使用锁,否则高性能的并发操作就会变成序列化操作。

书中总结的会发生竞争的情景是:
A race is a situation in which a memory location is accessed concurrently, and at least one access is a write.

译文:
竞争是指一个内存位置被并发访问,并且至少有一次是写访问。

posted on 2024-01-20 14:09  duduru  阅读(0)  评论(0)    收藏  举报  来源

导航