在深入理解 “lock” 指令之前,我们先来看一下 Qt 源代码中的一段 x86 汇编代码:
q_atomic_increment:movl 4(%esp), %ecxlock incl (%ecx)mov $0,%eaxsetne %alret.align 4,0x90.type q_atomic_increment,@function.size q_atomic_increment,.-q_atomic_increment
通过 Google 搜索,我知道 “lock” 指令会导致 CPU 锁住总线,但我不清楚 CPU 什么时候释放总线?另外,我不明白这段代码是如何实现“加法”的?
什么是 “lock” 指令?
“lock” 并不是一条独立的指令,而是一个指令前缀,它作用于随后的那条指令。被作用的指令通常是对内存进行读-修改-写操作的指令,比如 INC、XCHG、CMPXCHG 等。在我们示例代码中,它作用于 incl (%ecx)
指令,该指令会原子性地将 ECX 寄存器所指向内存中的值加 1。
使用 “lock” 前缀的原因
当我们使用 “lock” 前缀时,CPU 会确保该操作期间对相关缓存线的独占所有权,并提供某些额外的顺序保证。CPU 会尽量避免锁住整个总线,如果不得已需要锁住总线,那么会在该指令执行期间内完成。
在上述代码中,首先将要增加的变量地址从栈中拷贝到 ECX 寄存器,接下来用 lock incl (%ecx)
来原子性地增加该变量。随后的两条指令设置 EAX 寄存器(函数的返回值)为 0,如果该变量的新值为 0,则设置 AL 寄存器为 1,即返回值为 1,这是一种增量操作而不是加法。
深入解析代码逻辑
让我们逐行解析代码:
movl 4(%esp), %ecx ; 将要增加的变量地址从栈中加载到 ECX 寄存器
lock ; "lock" 前缀,确保以下操作的原子性
incl (%ecx) ; 将 ECX 寄存器指向的内存变量加 1
mov $0,%eax ; 将 EAX 寄存器设置为 0
setne %al ; 如果加法结果不等于 0,将 AL 寄存器(EAX 的低字节)设置为 1
ret ; 返回
为什么需要 mov $0,%eax
指令?
mov $0,%eax
不是冗余指令,它将整个 EAX 寄存器设置为 0,而随后的 setne %al
只会修改 EAX 的低字节(即 AL)。如果没有 mov $0,%eax
,EAX 的高三字节可能还包含以前操作的随机值,从而导致返回值不正确。
“lock” 前缀的实际作用
尽管某些文献(例如 “Assembler for DOS, Windows и Linux, 2000. Sergei Zukkov”)指出 “lock” 前缀会锁定数据总线,现代 CPU 通常使用更高效的方法。如果数据不跨越缓存行,CPU 核心可以内部锁定缓存行,从而避免阻塞所有其他核的读/写访问。这种机制利用了 MESI 缓存一致性协议。
一个完整示例
以下是一个使用 C++ 和内嵌汇编的例子,演示了 “lock” 前缀的实际作用:
#include <atomic>
#include <cassert>
#include <iostream>
#include <thread>
#include <vector>std::atomic_ulong my_atomic_ulong(0);
unsigned long my_non_atomic_ulong = 0;
unsigned long my_arch_atomic_ulong = 0;
unsigned long my_arch_non_atomic_ulong = 0;
size_t niters;void threadMain() {for (size_t i = 0; i < niters; ++i) {my_atomic_ulong++;my_non_atomic_ulong++;__asm__ __volatile__ ("incq %0;": "+m" (my_arch_non_atomic_ulong)::);__asm__ __volatile__ ("lock;""incq %0;": "+m" (my_arch_atomic_ulong)::);}
}int main(int argc, char **argv) {size_t nthreads;if (argc > 1) {nthreads = std::stoull(argv[1], NULL, 0);} else {nthreads = 2;}if (argc > 2) {niters = std::stoull(argv[2], NULL, 0);} else {niters = 10000;}std::vector<std::thread> threads(nthreads);for (size_t i = 0; i < nthreads; ++i)threads[i] = std::thread(threadMain);for (size_t i = 0; i < nthreads; ++i)threads[i].join();assert(my_atomic_ulong.load() == nthreads * niters);assert(my_atomic_ulong == my_atomic_ulong.load());std::cout << "my_non_atomic_ulong " << my_non_atomic_ulong << std::endl;assert(my_arch_atomic_ulong == nthreads * niters);std::cout << "my_arch_non_atomic_ulong " << my_arch_non_atomic_ulong << std::endl;
}
在 Ubuntu 19.04 amd64 上编译并运行:
g++ -ggdb3 -O0 -std=c++11 -Wall -Wextra -pedantic -o main.out main.cpp -pthread
./main.out 2 10000
可能输出结果:
my_non_atomic_ulong 15264
my_arch_non_atomic_ulong 15267
从输出结果可以看出,加了 “lock” 前缀后,增加操作是原子性的:否则,我们会在许多加法操作上出现竞争条件,最终计数结果会小于预期的 20000。
“lock” 前缀被广泛用于实现 C++11 中的 std::atomic
和 C11 中的 atomic_int
,确保线程安全的增量和其他修改操作。