一条指令看起来可能是一个不可分割的动作,而实际上根本不是。以给内存中的某个值加上 5 为例:
add qword [rel counter], 5
在底层,处理器并不能直接对内存做加法。 它把这一条指令拆成三个更小的步骤,称为微操作:
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 提供了一组指令来解决这个问题,方法是让一条指令不可分割。 它不仅对处理它的核心表现为一个整体,对其他所有核心也是如此。 这种始终作为一个整体、其他核心无法拆分的操作,称为原子操作。
人们常把运行在多个核心上的应用程序称为多线程。 然而,线程并不等同于核心。 两个线程可以在同一个核心上并发运行,相互交错,也可以在不同核心上并行运行。
如果一次读取-修改-写入被拆成多条指令,交错运行的线程本身就可能产生竞争,因为操作系统可以在任意两条指令之间切换线程。 不过,_单条_指令内部的窗口只有真正的并行代码才会暴露出来。 因为操作系统只在指令之间切换线程,从不在指令内部切换,所以单条指令在单核上本身是安全的。
下面这些指令提供的,正是跨_多个_核心的真正原子性。
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_总是_原子的。
当其中一个操作数是内存位置时,xchg会自动具备原子性。
这也意味着在这种情况下,该操作要慢得多。
如果你不需要原子性,就改用普通的mov指令,通过一个空闲寄存器来完成交换。
在 x86-64 中,让一条指令具备原子性最常见的方式,就是加上lock前缀。
它把读取、修改和写入融合成一个不可分割的步骤。
这意味着核心在整个过程中独占该内存,因此其他核心无法在这期间读取或写入那个位置。
lock add qword [rel counter], 5 ; the read, the modify, and the write are one step
lock只在目标是内存时才有效,而且只适用于对该内存执行读取-修改-写入的指令:
add、sub、inc、dec、neg;and、or、xor、not;bts、btr、btc;xadd和cmpxchg。独占一个位置,并把其他所有核心挡在外面,是有代价的。
带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 在普通的加载和存储之间保持强内存排序,因此在每个核心上:
唯一可能的重排是:一次存储看起来在针对_不同_地址的后续加载之后才完成。
带lock前缀的指令,或者带内存操作数的xchg,是一道完整的屏障:没有任何东西看起来能跨越它向任一方向移动。
这就是为什么在大多数情况下,它们足以保证完整的顺序。
pause
这类在设置标志的同时返回它先前状态的指令,称为测试并设置。 它们可以作为自旋锁的基础,确保某个核心对代码的某一部分拥有独占访问权。
下面是用xchg指令配合一个二值标志的完整算法:
0。1交换。1,说明锁已被另一个核心_持有_。
当前核心于是等待,并再次尝试获取锁。0,说明锁是空闲的。
xchg现在已把它设为1,其他核心会一直等到这个核心释放它。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 上,每条普通存储都具备这一点。
任何以原子方式测试并设置某个内存位置的指令,都可以用来实现自旋锁。
例如,可以用lock bts代替xchg,在设置某个特定位的同时检查它是否已经被设置。
注意,像被bts修改的CF这样的标志,是寄存器rflags的一部分。
这意味着它们对每个线程都是独有的。
首映之夜到了,Grand Marquee 的全体员工都在同时忙碌。 人群从每一扇门涌进来,好几个售票窗口卖着同一卷票,首映场快要售罄,而楼上的放映室里,胶片盘必须一刻不停地转下去。
员工共享的每一样东西都存放在一个内存地址上,而测试会从多个线程同时调用你的函数。 在那种负载下,一个值如果读取、修改、写回分成了几步,就会丢失更新,所以每个函数都必须让自己的更新是原子的。
本练习中所有共享的值都是 64 位有符号整数。
你有五个任务。
本练习中的更新应当用_原子_指令完成。 测试会并发运行你的函数,并检查没有更新丢失,所以非原子的读-改-写很可能会让测试失败。
门开了,每一扇门前都有一个引座员在撕票。 每一批被放进来的人都计进同一个入场总人数,而所有门都在同时计数,最终的全场人数仍必须准确无误。
实现admit_group函数,它原子地把一个团体的人数加到总人数上。
这个函数按顺序接收以下实参:
admissions:共享入场总人数的内存地址,一个 64 位有符号整数。group_size:团体中的人数,一个 64 位有符号整数,总是大于0。admissions = 120, group_size = 4
// admissions is now 124
测试会同时从好几扇门放人进来。 最终的总人数必须等于所有被放进来的人数之和。
这个函数没有返回值。 总人数应当原地更新。
放映机上只装一个胶片盘。 装上下一个胶片盘和取下当前这个,必须是一气呵成的动作:如果两位放映员同时操作机器,分成两步就可能把同一个胶片盘交给两个人,或者把其中一个掉在地上。
实现change_reel函数,它原子地装上下一个胶片盘,并返回原先装着的那个。
这个函数按顺序接收以下实参:
mounted:已装胶片盘标识符的内存地址,一个 64 位有符号整数。next_reel:要装上的胶片盘的标识符,一个 64 位有符号整数。mounted = 7, next_reel = 9
// => 7, and mounted is now 9
这个函数返回先前装着的胶片盘的标识符,是一个 64 位有符号整数。 测试会让多个线程同时通过同一个安装点更换胶片盘,并检查每一个交进去的胶片盘都恰好出来一次。
每个窗口卖的票都来自同一个已售票数。 像门那样往一个计数上累加,在这里还不够:每一次售卖还必须知道分给自己的号码。 任何两次售卖都不得共用同一个号码,而且在抢票高峰过后,这个计数必须恰好等于卖出的票数。
实现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 位有符号整数。 测试会同时从好几个窗口售票:每一个返回的号码都必须互不相同,最终计数必须等于售卖的次数。
首映场有固定数量的座位,每个窗口都在卖。 只有当还有座位时才允许售票,而且放映绝不能超卖,即使在所有窗口都同时抢最后一个座位的瞬间也不行。 把检查计数和往计数上加当成两步,中间就会留出空隙,所以检查和更新必须一起成功或一起失败。
实现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 位有符号整数。
放映室里只容得下一个人。
内存里有一个字表示它是否有人占用:0表示空闲,1表示占用。
整晚都有员工过来,每个人都在里面有一项要做的事:在日志本上签名、给放映机穿胶片、归档一个胶片盘。
不管是什么任务,放映室的规矩都一样,而执行这条规矩就是你的职责:同一时间只能有一个人在里面,从他任务开始到任务结束。
实现visit_booth函数,它会:
当放映室被别人占着时,它应当用pause礼貌地等待。
这个函数按顺序接收以下实参:
booth:占用标志字的内存地址,一个 64 位有符号整数,取值为0或1。task:来访者的任务,一个不接收形参、没有返回值的函数。注意,测试运行的任务会查看放映室,也会查看一个共享的日志本。 这意味着放映室在整个任务期间必须一直处于被占用状态,每次来访都必须恰好运行一次它的任务,而且在多个来访者同时进行时,日志本必须准确无误。
来访者的任务是一个遵循调用约定的普通函数:
违反其中任何一条,都可能表现为崩溃,而不是一次失败的检查。