开幕之夜

开幕之夜

学习练习

简介

原子操作

一条指令看起来可能是一个不可分割的动作,而实际上根本不是。以给内存中的某个值加上 5 为例:

add qword [rel counter], 5

在底层,处理器并不能直接对内存做加法。 它把这一条指令拆成三个更小的步骤,称为微操作:

  1. 读取内存中的当前值;
  2. 在寄存器中修改这个值,把它加上 5;
  3. 把结果写回。
mov rax, qword [rel counter] ; 1. load the current value
add rax, 5                   ; 2. add five
mov qword [rel counter], rax ; 3. store the result back

这是一种常见的模式,称为读取-修改-写入(RMW)。

注意,读取(加载)和写入(存储)是两个独立的事件,因此它们之间存在一个时间窗口。 我们通常不会注意到这一点,因为在单核上,每条指令都保证在开始下一条之前完整地产生效果。 因此这个窗口是看不见的,add表现得就像一个整体。

然而,现代 CPU 很少是单核的,应用程序常常同时运行在多个核心上,它们之间没有任何先后顺序。 当多个核心在同一瞬间运行时,另一个核心可以在那个窗口内读取或写入counter,也就是在本核心加载之后、存储之前。 两个线程各自加载相同的旧值,各自加上 5,再各自存储自己的结果。 发生了两次加法,但值却只增加了 5。 有一次更新悄无声息地丢失了。

这就是数据竞争,多线程代码中的常见问题。

并不是每个值都会以这种方式被暴露出来。 每个线程都有自己的寄存器和自己的栈,因此保存在寄存器中的值,或者线程栈上的局部变量,都是该线程独有的,不会发生竞争。 只有线程共享的内存,比如上面的counter,才需要保护。

x86-64 提供了一组指令来解决这个问题,方法是让一条指令不可分割。 它不仅对处理它的核心表现为一个整体,对其他所有核心也是如此。 这种始终作为一个整体、其他核心无法拆分的操作,称为原子操作。

Note

人们常把运行在多个核心上的应用程序称为多线程。 然而,线程并不等同于核心。 两个线程可以在同一个核心上并发运行,相互交错,也可以在不同核心上并行运行。

如果一次读取-修改-写入被拆成多条指令,交错运行的线程本身就可能产生竞争,因为操作系统可以在任意两条指令之间切换线程。 不过,_单条_指令内部的窗口只有真正的并行代码才会暴露出来。 因为操作系统只在指令之间切换线程,从不在指令内部切换,所以单条指令在单核上本身是安全的。

下面这些指令提供的,正是跨_多个_核心的真正原子性。

原子交换

xchg指令交换两个操作数。 目标操作数会变为源操作数之前的值,而源操作数会变为目标操作数之前的值。 从概念上讲,可以把它看成两条mov指令同时发生。

和往常一样,它既可以用两个寄存器操作数,也可以用一个内存操作数加一个寄存器操作数:

mov  eax, 1
xchg dword [rdi], eax ; [rdi] = 1, eax = the old value of [rdi]
mov ecx, 2
mov edx, 3
xchg edx, ecx         ; edx = 2, ecx = 3

当与内存操作数一起使用时,xchg_总是_原子的。

Caution

当其中一个操作数是内存位置时,xchg会自动具备原子性。 这也意味着在这种情况下,该操作要慢得多。

如果你不需要原子性,就改用普通的mov指令,通过一个空闲寄存器来完成交换。

lock 前缀

在 x86-64 中,让一条指令具备原子性最常见的方式,就是加上lock前缀。 它把读取、修改和写入融合成一个不可分割的步骤。 这意味着核心在整个过程中独占该内存,因此其他核心无法在这期间读取或写入那个位置。

lock add qword [rel counter], 5 ; the read, the modify, and the write are one step

lock只在目标是内存时才有效,而且只适用于对该内存执行读取-修改-写入的指令:

  1. 算术运算,例如add、sub、inc、dec、neg;
  2. 位运算,例如and、or、xor、not;
  3. 位操作bts、btr、btc;
  4. 其他一些专用指令,例如下面会介绍的xadd和cmpxchg。
Caution

独占一个位置,并把其他所有核心挡在外面,是有代价的。 带lock前缀的操作明显比它的普通形式慢,当多个核心争用同一个位置时就更是如此。

这个前缀应该留给预期会被多个线程修改的内存。 如果内存不是共享的,或者只会被读取,就避免使用它。

交换并相加

普通的lock add会更新内存,但会把旧值丢掉。 很多时候,旧值恰恰是想要的东西,比如给每个线程分配一个不同的号码。

xadd指令(x表示 exchange)在相加的同时返回之前的值。 它把和写入目标,并把目标原来的值留在源寄存器中。

mov  rax, 1
lock xadd qword [rdi], rax ; [rdi] = [rdi] + rax = [rdi] + 1
                           ; rax = the old value of [rdi]

加上lock前缀,这就是原子的获取并相加。 当多个线程在同一个计数器上运行时,每次调用都会返回一个不同的旧值。

和lock add一样,计数器最终会正好等于调用的次数。 不过,与lock add不同的是,每一个中间值也都会被返回,分别交给各个调用者。

比较并交换

xadd做加法,xchg做覆盖,但两者都无法让新值依赖于当前值,并且只在底层没有发生变化时才应用它。 cmpxchg(比较并交换)提供的正是这种条件更新,它是这些原语中最通用的一个。

cmpxchg dest, src以rax作为隐式累加器,并将它与dest比较:

  • 如果dest == rax,则dest = src,并且ZF = 1。
  • 如果dest != rax,则rax = dest,并且ZF = 0。

注意,只有当dest等于之前加载到rax中的预期值时,才会更新dest。 这种相等性确保dest仍然保存着新值所依据的那个值,因此基于过期读取的更新永远不会被应用。 这使得cmpxchg成为原子更新的基础构件,也称为比较并交换(CAS):

    mov rax, qword [rdi]          ; rax = the value we expect to find
.retry:
    lea rcx, [rax + 10]           ; rcx = the new value we want to install
    lock cmpxchg qword [rdi], rcx ; if [rdi] still equals rax, store rcx and set ZF
                                  ; otherwise reload rax with the current value, clear ZF
    jnz  .retry                   ; ZF is cleared, so another thread won the race. Recompute and retry

这个重试循环是无锁更新的核心。 读取与比较并交换之间的那个窗口,正是另一个线程可能插进来的时机,而cmpxchg通过拒绝存储基于过期读取算出的值来抓住这一点。

内存排序

到目前为止,每个操作都只涉及一个位置。 当线程通过多个位置进行协调时,就出现了一个新问题:一个线程的写入以什么顺序对另一个线程可见。 回答这个问题的规则,就是处理器的内存排序。

无分支代码的概念提到过这样一个想法:现代核心不会一次一条地慢慢执行指令。 它会同时让许多指令处于执行中,并在可能的地方提前运行。 这意味着一次写入变得对其他核心可见的时间,可能比程序所暗示的更晚,而它之后的指令却已经提前执行了。

x86-64 在普通的加载和存储之间保持强内存排序,因此在每个核心上:

  1. 加载永远不会被重排到更晚的加载之后;
  2. 存储永远不会被重排到更晚的存储之后;
  3. 加载永远不会被重排到更晚的存储之后。

唯一可能的重排是:一次存储看起来在针对_不同_地址的后续加载之后才完成。

带lock前缀的指令,或者带内存操作数的xchg,是一道完整的屏障:没有任何东西看起来能跨越它向任一方向移动。 这就是为什么在大多数情况下,它们足以保证完整的顺序。

自旋与pause

这类在设置标志的同时返回它先前状态的指令,称为测试并设置。 它们可以作为自旋锁的基础,确保某个核心对代码的某一部分拥有独占访问权。

下面是用xchg指令配合一个二值标志的完整算法:

  1. 标志一开始是0。
  2. 为了获取锁,核心把标志中的值与1交换。
  3. 如果返回的值是1,说明锁已被另一个核心_持有_。 当前核心于是等待,并再次尝试获取锁。
  4. 如果返回的值是0,说明锁是空闲的。 xchg现在已把它设为1,其他核心会一直等到这个核心释放它。
  5. 当前核心完成工作后,把标志更新为0就释放了锁。
acquire:
    mov  eax, 1
    xchg dword [rdi], eax ; try to take the lock; eax = its old value
    test eax, eax
    jnz  .held            ; old value was 1: someone else holds it
    ret                   ; old value was 0: the lock is ours
.held:
    pause                 ; wait before trying again
    jmp  acquire

等待循环中的pause指令不会改变代码计算出的结果。 它向处理器提示这是一次自旋等待。 CPU 于是可以降低等待线程的功耗,并让位给共享同一核心的兄弟线程。 没有pause的自旋循环依然是正确的,只是浪费。

线程完成工作后,可以用mov以普通的存储写入0来释放锁。 需要的不过是一条mov而已,因为在 x86-64 上,加载和存储永远不会被重排到更晚的存储之后。 一次存储如果从不越过它之前的访问,就说它具有释放排序;在 x86 上,每条普通存储都具备这一点。

Note

任何以原子方式测试并设置某个内存位置的指令,都可以用来实现自旋锁。 例如,可以用lock bts代替xchg,在设置某个特定位的同时检查它是否已经被设置。

注意,像被bts修改的CF这样的标志,是寄存器rflags的一部分。 这意味着它们对每个线程都是独有的。

说明

首映之夜到了,Grand Marquee 的全体员工都在同时忙碌。 人群从每一扇门涌进来,好几个售票窗口卖着同一卷票,首映场快要售罄,而楼上的放映室里,胶片盘必须一刻不停地转下去。

员工共享的每一样东西都存放在一个内存地址上,而测试会从多个线程同时调用你的函数。 在那种负载下,一个值如果读取、修改、写回分成了几步,就会丢失更新,所以每个函数都必须让自己的更新是原子的。

本练习中所有共享的值都是 64 位有符号整数。

你有五个任务。

Note

本练习中的更新应当用_原子_指令完成。 测试会并发运行你的函数,并检查没有更新丢失,所以非原子的读-改-写很可能会让测试失败。

1. 统计入场人数

门开了,每一扇门前都有一个引座员在撕票。 每一批被放进来的人都计进同一个入场总人数,而所有门都在同时计数,最终的全场人数仍必须准确无误。

实现admit_group函数,它原子地把一个团体的人数加到总人数上。

这个函数按顺序接收以下实参:

  • admissions:共享入场总人数的内存地址,一个 64 位有符号整数。
  • group_size:团体中的人数,一个 64 位有符号整数,总是大于0。
admissions = 120, group_size = 4
// admissions is now 124

测试会同时从好几扇门放人进来。 最终的总人数必须等于所有被放进来的人数之和。

这个函数没有返回值。 总人数应当原地更新。

2. 更换胶片盘

放映机上只装一个胶片盘。 装上下一个胶片盘和取下当前这个,必须是一气呵成的动作:如果两位放映员同时操作机器,分成两步就可能把同一个胶片盘交给两个人,或者把其中一个掉在地上。

实现change_reel函数,它原子地装上下一个胶片盘,并返回原先装着的那个。

这个函数按顺序接收以下实参:

  • mounted:已装胶片盘标识符的内存地址,一个 64 位有符号整数。
  • next_reel:要装上的胶片盘的标识符,一个 64 位有符号整数。
mounted = 7, next_reel = 9
// => 7, and mounted is now 9

这个函数返回先前装着的胶片盘的标识符,是一个 64 位有符号整数。 测试会让多个线程同时通过同一个安装点更换胶片盘,并检查每一个交进去的胶片盘都恰好出来一次。

3. 卖出一张票

每个窗口卖的票都来自同一个已售票数。 像门那样往一个计数上累加,在这里还不够:每一次售卖还必须知道分给自己的号码。 任何两次售卖都不得共用同一个号码,而且在抢票高峰过后,这个计数必须恰好等于卖出的票数。

实现sell_ticket函数,它原子地把计数加一,并返回刚刚卖出的票号。 票号是这次售卖_之后_的计数:当计数是41时,这次售卖的就是42号票。

这个函数接收一个实参:

  • tickets_sold:共享已售票数的内存地址,一个 64 位有符号整数。
tickets_sold = 0
// => 1, and tickets_sold is now 1

tickets_sold = 41
// => 42, and tickets_sold is now 42

这个函数返回票号,是一个 64 位有符号整数。 测试会同时从好几个窗口售票:每一个返回的号码都必须互不相同,最终计数必须等于售卖的次数。

4. 坐满首映场

首映场有固定数量的座位,每个窗口都在卖。 只有当还有座位时才允许售票,而且放映绝不能超卖,即使在所有窗口都同时抢最后一个座位的瞬间也不行。 把检查计数和往计数上加当成两步,中间就会留出空隙,所以检查和更新必须一起成功或一起失败。

实现claim_seat函数,它原子地在还有空位时占下一个座位。 占到一个座位时,函数返回座位号,也就是这次占座之后已占座位的计数。 放映已经售罄时,它返回0,并且计数保持不变。

这个函数按顺序接收以下实参:

  • seats_taken:已占座位计数的内存地址,一个 64 位有符号整数,永远不会超过capacity。
  • capacity:放映的座位数,一个 64 位有符号整数,总是大于0。
seats_taken = 0, capacity = 150
// => 1, and seats_taken is now 1

seats_taken = 82, capacity = 150
// => 83, and seats_taken is now 83

seats_taken = 149, capacity = 150
// => 150, and seats_taken is now 150

seats_taken = 150, capacity = 150
// => 0, and seats_taken stays 150

这个函数返回座位号,或在放映售罄时返回0,都是 64 位有符号整数。

5. 看守放映室

放映室里只容得下一个人。 内存里有一个字表示它是否有人占用:0表示空闲,1表示占用。 整晚都有员工过来,每个人都在里面有一项要做的事:在日志本上签名、给放映机穿胶片、归档一个胶片盘。 不管是什么任务,放映室的规矩都一样,而执行这条规矩就是你的职责:同一时间只能有一个人在里面,从他任务开始到任务结束。

实现visit_booth函数,它会:

  1. 等到放映室空闲;
  2. 占用放映室;
  3. 运行来访者的任务;
  4. 在任务之后、返回之前重新开放放映室。

当放映室被别人占着时,它应当用pause礼貌地等待。

这个函数按顺序接收以下实参:

  • booth:占用标志字的内存地址,一个 64 位有符号整数,取值为0或1。
  • task:来访者的任务,一个不接收形参、没有返回值的函数。

注意,测试运行的任务会查看放映室,也会查看一个共享的日志本。 这意味着放映室在整个任务期间必须一直处于被占用状态,每次来访都必须恰好运行一次它的任务,而且在多个来访者同时进行时,日志本必须准确无误。

Caution

来访者的任务是一个遵循调用约定的普通函数:

  • 它可以覆盖任何调用者保存的寄存器,所以任务结束之后你仍然需要的东西,应当放在调用碰不到的地方;
  • 它还要求调用发生的那一刻栈是 16 字节对齐的。

违反其中任何一条,都可能表现为崩溃,而不是一次失败的检查。

通过 GitHub 编辑 链接将在新窗口或新标签页中打开
x86-64 Assembly Exercism

准备好开始 开幕之夜 了吗?

注册 Exercism,借助 22 个概念130 个练习 和真人导师指导,学习并掌握 x86-64 Assembly,全部免费。