概述
什么是优化?优化就是为了得到更高效的程序。换而言之,就是更快以及(或者)更小的程序。
Optimization = more efficient code = faster code and/or compact code.
优化可以在四个层面上进行:算法层(Algorithm) ,语言层(Language),汇编层(Assembly),处理器层(Processor)。
算法层(Algorithmic):
时间复杂度(Order of growth,大O)可以衡量一个算法的性能,包含常数阶(constant,O(1)),对数阶(logarithmic, O(log2n)),线性阶(linear,O(n)),线性对数阶(log-linear, O(n log2n)),K次方阶(to the power of K, O(nk)),指数阶(exponential, O(kn))。
语言层(Language):
语言的选择——解释型语言(Interpreters):PHP、Perl、Ruby、JS;框架式语言(Frameworks):.Net、Java;面向对象语言(Objective):C++、Objective-C;低级语言(Low Level):C、fortran……;汇编(Assemblers)。语言的执行效率与开发难度都顺次递增。
利用语言的特性,比如关键字以及#pragma指令。
汇编层(Assembly)
一般交给编译器(Compiler)优化,不同的优化选项会得到不同的汇编。开发人员也可以自己编写汇编,比如在C语言中使用__asm{}直接编写汇编。不同的CPU支持不同的指令集:MMX(Pentium)、MMX2(Pentium-II)、SSE(Pentium-II、Pentium-III)、SSE2(Pentium-III、Pentium-IV)、SSE3(Pentium-IV)。开发者可以根据目标CPU选择合适的指令。
处理器层(Processor)
绝大多数情况下由CPU完成优化,对开发者透明。CPU可以通过使用缓存(Caches)、寄存器(Registers)、分支(branching)、管道(piplines)等方式优化程序。CPU也可以预取指令(pre-fetch)以及重新组织(re-organize)指令执行顺序来提高效率。因为CPU包含许多子组件,比如ALU(Arithmetic Logic Unit, 算术逻辑单元)和FPU(Float Point Unit,浮点运算单元),各组件是可以并行工作的,所以指令执行的顺序也影响程序的性能。
大多数程序员认为程序只能通过算法来优化,并且坚信算法复杂度是最重要的因素,算法复杂度越低则程序越高效。其实这种想法有两个误区,其一是优化可以在多个层面进行;其二是复杂度只取最高指数项并省略掉前面的常数,设想n不超过1000时,一个复杂度为10000*n与复杂度为3*n2的算法哪个效率更高些?
优化的一个实例:排序(Sorting)
----------算法层-----------
首先是算法的选择:冒泡排序法(Bubble Sort)、堆排序法(Heap Sort)、基数排序法(Radix Sort)还是快速排序法(Quick Sort)?
算法 | 时间复杂度 | 空间复杂度 |
Bubble Sort | O(n2) | 1 |
Heap Sort | n log2n | 1 |
Radix Sort | n | n |
Quick Sort | n log2n | log2n |
如果我们选择第一种冒泡排序,并用C语言实现:
void bubbleSort(void *A) {
bool swapped = false;
do {-
lastSwap = 0
for (i = 0; i <= len(n) - 2; i++) {
if (greater(A[i], A[i+1])) {
swap(A[i], A[i+1]);
swapped = true;
} /* end if */
} /* end for each */
} while (swapped);
} /* end bubbleSort */
上述代码仍然可以在算法层进行优化:
void bubbleSort(void *A) {
bool swapped = false;
n = len(A); /* Saves n operations per iteration */
do {
lastSwap = 0;
for (i = 0; i <= len(n) - 2 n - 2; i++) {
if (greater(A[i], A[i+1])) {
swap(A[i], A[i+1]);
lastSwap = i + 1; /* Optimizes 50% Iterations */
swapped = true;
} /* end if */
} /* end for each */
n = lastSwap;
} while (swapped);while(n > 1);
} /* end bubbleSort */
上述代码做了两个优化:1、把len(A)从循环中提取出来,这样可以省去n次迭代;2、用lastSwap代替swapped,这样可以省去50%的迭代。(注:每次迭代中被交换到最后的最大值不需要进行下次迭代。)
----------语言层-----------
继续对上述代码优化:
inlinevoid bubbleSort(void *A) {
register n = len(A);
do {
lastSwap = 0;
for (i = 0; i <= n - 2; i++) {
if (greater(A[i], A[i+1])) {
swap(A[i], A[i+1]);
lastSwap = i + 1;
} /* end if */
} /* end for each */
n = lastSwap;
} while(n > 1);
} /* end bubbleSort */
利用C语言的关键字inline(严格意义上是C++的关键字)和register同样可以优化程序。inline关键字可以把函数展开而省去函数调用的开销,而register关键字可以保证n的值每次都是从寄存器中读取/写入,从而省去读取内存的开销。(注:与register相对的是volatile,告诉编译器不能把这个变量放入寄存器中,必须从内存中读取。)
swap函数的传统实现方式如下:
swap (void **a, void **b) {
void *t = *a;
*a = *b;
*b = t;
}
(注:此时调用者应该这么写:swap(&A[i], &A[i+1]);)
利用C语言的特性,我们可以这样改写:
Swap (void **a, void **b)
{
*a += *b; /* sets a to be (a + b) */
*b -= *a; /* b is b-(a+b) = -a */
*b = -*b; /* b is now a */
*a -= *b; /* a is now a + b - a = b */
}
这样的改写可以省去一个局部变量的开销,但是上面的代码有一个bug:因为指针是32位的值(在32位机上),两个32位值相加可能会有overflow的情况。解决这个bug的方法如下:
Swap (void **a, void **b)
{
*a ^= *b; /* sets a to be (a xor b) */
*b ^= *a; /* b is b xor (a xor b) = a */
*a ^= *b; /* a is (a xor b) xor a = b */
}
----------汇编层-----------
使用visual studio 2010的cl.exe对函数
swap (void **a, void **b) {
void *t = *a;
*a = *b;
*b = t;
}
进行汇编。(注:栈地址向下增长。)
若选项设为/O0(不优化),得到的汇编为:
PUBLIC _swap
; Function compile flags: /Odtp
_TEXT SEGMENT
_t$ = -4 ; size = 4
_a$ = 8 ; size = 4
_b$ = 12 ; size = 4
_swap PROC
; Line 2
push ebp
mov ebp, esp
push ecx
; Line 3
mov eax, DWORD PTR _a$[ebp] ; 把参数a放入寄存器eax
mov ecx, DWORD PTR [eax] ; 把eax所指值(*a)放入寄存器ecx
mov DWORD PTR _t$[ebp], ecx ; 把ecx的值(*a)放入局部变量t里,即t=*a
; Line 4
mov edx, DWORD PTR _a$[ebp] ; 把参数a放入寄存器edx
mov eax, DWORD PTR _b$[ebp] ; 把参数b放入寄存器eax
mov ecx, DWORD PTR [eax] ; 把(*b)放入寄存器ecx
mov DWORD PTR [edx], ecx ; 把ecx的值写入(*a),即*a=*b
; Line 5
mov edx, DWORD PTR _b$[ebp] ; 把参数b放入寄存器edx
mov eax, DWORD PTR _t$[ebp] ; 把局部变量t放入寄存器eax
mov DWORD PTR [edx], eax ; 把eax的值(t)写入edx所指内存(*b),即*b=t
; Line 6
mov esp, ebp
pop ebp
ret 0
_swap ENDP
_TEXT ENDS
END
如果设置优化选项为/O1(实际上设置为/O2、/Ox得到的结果是一样的),生成的汇编为:
PUBLIC _swap
; Function compile flags: /Ogspy
; COMDAT _swap
_TEXT SEGMENT
_a$ = 8 ; size = 4
_b$ = 12 ; size = 4
_swap PROC ; COMDAT
; Line 2
; 省略了push ebp以及mov ebp, esp
; Line 4
mov edx, DWORD PTR _b$[esp-4] ; 把参数b存入edx。因为没有把ebp压栈(需要4个字节),所以esp-4相当于优化前的ebp
mov eax, DWORD PTR _a$[esp-4] ; 把a存入eax
mov ecx, DWORD PTR [eax] ; 把*a存入ecx
push esi
mov esi, DWORD PTR [edx] ; 把*b存入esi
mov DWORD PTR [eax], esi ; 把*b存入*a,即*a=*b
; Line 5
mov DWORD PTR [edx], ecx ; 把*a(未赋值前)放入*b,即*b=*a。此处ecx充当了t的作用
pop esi
; Line 6
ret 0
_swap ENDP
_TEXT ENDS
END
可以看到优化后省略了一个ebp压栈操作,以及省略了局部变量t。生成的汇编指令更少,自然就更高效。
----------处理器层-----------
大多数情况下这层的优化是透明的,程序员很难预料CPU会作做什么优化,而且在多线程程序里CPU的一些优化可能会导致一些问题。
尽管如此,程序员仍然可以做一些优化,因为不同的CPU支持不同的指令集,所以可以根据目标CPU选择要生成的汇编指令集.如果目标CPU不定,比如某些客户用PetiumIV,而有些用PetiumII,可以生成两个不同的dll,一个专门为PetiumIV进行优化,而另一个只运行在PetiumII上而不做优化.在程序运行时,根据目标机的CPU类型(在Windows可以调用GetSystemInfo)调用合适的dll.
内存
当代计算机使用分层存储结构--寄存器->CPU缓存->内存->外存.其中内存是当前程序性能最重要的资源,是程序最大的瓶颈。
计算机里运行的每个程序都拥有独立完整连续的虚拟内存,而数据实际存储在物理内存中。物理内存内的数据是不连续的,以页(Page)为最小单元,大小一般为4K。大多数情况下,物理内存要比虚拟内存小.所以并不能装下虚拟内存中的的所有数据,这时就需要把一些数据暂时存放在外存中。当数据需要被使用而不在物理内存中时(页面失效),则先把一些暂时不用的数据交换到外存中,再把需要的数据从外存中交换到物理内存中。
32位虚拟内存映射到32位物理内存和64位虚拟内存映射到64位虚拟内存的情况比较常见,但还有一种情况是32位虚拟内存映射到64位物理内存的情况。这种情况出现在当CPU寄存器为32位,而物理内存超过4G时,为了取到物理内存超过4G的部分,需要对物理内存64位取址。
当CPU执行一个从内存读取数据的指令时,它得到一个虚拟地址。虚拟地址需要通过MMU(Memory Management Unit)转换为物理内存的地址,才能从实际的内存中读取数据。32虚拟内存地址以10+10+12的形式分为三部分。前面10位用来索引PDE(Page Directory Entry),中间10位用来索引PTE(Page Table Entry),最后的12位用来索引页面(Page)从而得到真正的内存地址。
PDE
PDE(Page Directory Entry)是存放在内存中的一张表,通过它可以索引到PTE(Page Table Entry)。10位的PDE最多可以存放210项PTE。PDE的地址存放在寄存器CR3中。当进程切换时,CR3的值也跟着改变,这也意味着每个进程都拥有自己的PDE,这样便保证了每个进程都有自己独立的内存空间而互不干扰。因为每项都存放一个指针(4个字节),所以表的大小是4K。
PTE
PTE(Page Table Entry)也是存放在内存中的一张表,通过它可以索引到页面(Page)。10位的PTE最多可以存放210项页面。PTE的大小也是4K。
Page
页面是物理内存中的最小单元,大小在32位是4K,64位下可以是4K,2M甚至是1G。每次分配的内存大小必须是页面大小的整数倍。比如4K的页面,当程序需要新的内存时,不超过4K也会以4K的大小分配,如果需要6K则会分配8K。页面是可被交换出物理内存,暂存到外存中的。(注:PDE和PTE是始终存放在物理内存中,不会被交换的。)当程序需要的数据所在的页面不在物理内存中时,就会产生页面失效(Page Fault),继而需要把它从外存中交换进来。页面失效是非常耗时的,为了提高程序的性能,应该尽可能地避免页面失效。
利用这种分层结构,最终可以将虚拟内存映射到物理内存。这种分层结构有两个优点:一个是确保了每个进程都有自己独立的虚拟内存空间而不会互相干扰;二是节省内存空间,因为一个程序一般是不能用完所有的页面,只有需要时才会在内存创建新的页面,如果没有分层结构,则页面的索引表将会非常大,其中包含了大量的NULL项,而分层结构解决了这个问题。不仅如此,因为有些进程是可以共享一些内存的(比如同一程序的不同实例可以共享可执行代码),所以它们只要令PDE或PTE中的项中的指针值相同就行。
PAE
考虑到32位虚拟内存映射到64位物理内存的情况,需要将PTE的项扩展为64位(8字节),这时PTE的大小变为8K,在计算机里,4K是一个非常便利的大小,所以为了保持PTE4K的大小,可以将PTE的项数减半(为29=512),同样的,PDE里的每项也是64位,项数减半。因为PTE和PDE只需要9位来索引,这时虚拟地址的布局变为2+9+9+12。前面多出的2位用来索引一张新表PDPT(Page Directory Pointer Table),一共有四项,每项指向一张PDE(1G地址空间),这样虚拟内存的大小仍然是4G,但是可以映射到超过4G的物理内存中。这时寄存器CR3再存放PDE,而是PDPT的地址,这种物理内存的扩展就称为PAE(Physical Address Extensions)。
操作系统往往会共享大量的内存空间,比如Windows有2G的内存共享,而Linux有1G的内存共享,有了PAE后,只需要共享PDPT中的项就可以,从而节省了大量的内存。
PTE的后12位
注意到PTE中存放的每项的值,与虚拟内存地址的后12位合并,就得到了真实的内存地址,因此PTE每项的后12位其实是无用的。但是这12位是不会被浪费的,页面的状态,比如是否在物理内存中,是属于操作系统内核还是任何进程都能访问等信息,都可以存放在这12位里。
Flag | Meaning |
Global | Page belongs to Kernel, and is thus global across all processes |
Dirty | Page has been modified and cannot be reused until ommitted |
Accessed | Page has been recently accessed (for LRU "clock" algorithm) |
Acahe Disable | Page may not be cached |
Write Through | Write this page to disk (disables write caching) |
Owner | User-mode (Ring 3) page of Kernel-Mode (Ring 0) page |
Writable | Is page writable or read only |
Valid | Page is a valid page, mapping to a physical. Always set to "1" |
64位虚拟内存
64位虚拟内存地的分布为9+9+9+9+12,即4张索引表和4K的页面;也可以是9+9+9+21,即3张索引表和2M的页面;也可以是9+9+30,即2张索引表和1G的页面。一般1G的页面不常见,因为页面太大页面失效的代价也会很大。
注意到64位地址前面的16位并没有被用到,这样做的原因是后面的48位已经足够索引现在的物理内存。就目前的内存大小来看,把虚拟内存的取址空间支持到几T是没有意义的,而且过多的分层会影响效率。正因为层数比32位多,地址映射的时间更长,所以64位机在某些情况下要比32位机慢。不要迷信64位机一定比32位机快。
Lazy Allocation
对于Lunix和Windows 7,操作系统采取了Lazy Allocation的方式分配内存。比如用户用malloc申请一块1M的内存,这时操作系统仅仅是创建了索引表,而不真正地创建页面,这样物理内存实际只增加了4K,当真正访问这块内存时,操作系统才真正地创建页面,并填充数据。但是Windows XP并不是这样,当用户申请一块内存时,它便立即分配这块内存。微软也发现这种方式的缺陷,所以在Windows 7加以改进。
动态内存和静态内存:
进程中有堆和栈,堆中动态分配内存,栈用来给局部变量分配内存,当局部变量离开它的作用域时,就会被自动释放。C语言标准里有个函数alloca,可以在栈上分配不定大小(运行时确定)的内存,同值得注意的是,用这个函数申请的内存会在函数返回时自动释放,和局部变量一样。
页面失效(Page Fault):
页面失效的原因可以是:
Access denied: page is in memory, but is ring0 and you are in ring3;
Access delayed: page is not in memory but should be:
-> Page is declared, allocated but swapped;
-> Page is declared, allocated but unitiaiized;
-> Page is not declared (and not allocated);
对CPU而言所有类型的页面失效都是一样的,而且CPU可以从寄存器CR2中获取页面失效的地址。但是操作系统内核决定怎么处理页面失效:
if access denied -> Process is killed;
if page is not declared -> Process is killed;
if page is declared/allocated/swapped ->process is suspended, kernel page process gets control, gets page;
if page is declared/allocated/uninitialized -> kernal actully inialize.
TLB(Translation Lookaside Buffer)
大多数CPU除了MMU外,还有TLB(页表缓冲,与L1,L2缓存没有本质区别,只不过TLB存储页表数据,L1、L2缓存存储实际数据)。如果页表已经缓存在TLB中,那么CPU只要1个时钟周期就能取到实际的地址,否则通过MMU要耗用30个时钟周期,64位下MMU要耗用50+个时钟周期。
CPU的并行技术
CPU的基本架构 (Basic CPU Architecture)
CPU(Central Processing Units)其实是一个复杂的包含多个子组件的系统.
Compent | Use |
ALU | Arithmetic Logical Unit: Perform integer math and conditionals |
FPU | Floating Point Unit: floating point operations |
Load | Reads from memory into register |
Store | Writes to memory from register |
Prefetch/Decoder | Instruction prefetch from memory, and decode into micor-ops |
Scheduler | Schedules instructions on other components |
ALU处理整型运算和逻辑运算,是CPU里最重要的组件。因为ALU最常被使用,一般CPU里会有两个ALU;
FPU处理浮点数运算。因为更昂贵,所以CPU一般只有一个FPU;
Load从内存中读取数据;
Store向内存中写入数据;
Prefetch/Decoder可以从内存中取指令,并解释为可执行的微指令(micro-ops);
Scheduler负责调度指令,把指令发送给执行该指令的组件。
CPU对指令的操作流程为:
Fetch -> Decode -> Execute -> Memory -> Write
其实哪怕是单个CPU也存在并行。因为各个组件是独立的,所以每个组件在完成前一指令的“加工”后,可以开始“加工”下一个 指令。比如Fetch从内存中取出指令交给Decode去解析后,就可以开始从内存取出下一条指令。这就是Pipelining,类似于流水线。
超线程技术(HyperThreading)
为了解决单个CPU效率低下的问题,有人提出超线程技术的解决方案。其实就是在原有基础上,加入另外的寄存器组。这样的好处是CPU可以同时指行多条指令。比如FPU使用一组寄存器进行浮点运算而同时ALU使用另一组寄存器进行整型运算。使用超线程技术时也可能同时会相应地增加ALU的数量。
尽管这样可以达到一定程度上的并行,但ALU,FPU以及Load/Store这些组件仍然是共用的,仍然可能成为瓶颈。
多核技术(Multi Core)
为了达多更大程度的并行,多核技术随之产生。CPU把ALU,FPU,Instruction fetch/decode,Scheduler这些组件看成是一个Core。而多核(Multi Core)就是在同一个CPU里同时存在多组这些组件。各个Core可以并行地执行指令,并拥有各自的一级缓存(L1 Cache),但各个核之间仍然共享二级缓存(L2 Cache)和总线(bus)。
优点(相对于多处理器):
共享缓存,所以可以避免从内存中装载同一数据到缓存中数次,从而更高效。而多处理器不能共享缓存;
因为时钟周期一致,所以多核访问缓存的速度更快;
更便宜、且更省电。
缺点:
因为共享缓存,别的线程可能会破坏缓存中的数据;
因为共享总线,总线带宽可能会成为瓶颈。
多处理器技术(Multi Processor)
这种技术直接使用多个CPU。各个CPU有独立的组件,包括L2缓存和总线,所以它们可以并行地对内存进行操作。不同的CPU还可以有不同的时钟周期(Clock speeds)。
多处理器的架构有两种:
Symmetric Multi Process(SMP)——它使用Uniform Memory Architecture(UMA)。在这种架构下,所有的CPU都是等同的,它们可以各自独立地对内存进行操作。
Asymmetric Multi Process(AMP)——它使用Non-Uniform Memory Architecture(NUMA)。在这种架构下,CPU0被视为地位最高的,由它来控制其它CPU的操作。
优点(相对于多核):
各处理器完全独立,性能的瓶颈会更少;
独立的缓存,所以整体缓存空间更多;
可扩展。
缺点:
因为可以独立存取内存,需要同步。性能为因此受到很大影响;
各CPU需要数据时,即使这个数据存在另一个CPU的缓存中,它仍然不能使用那个缓存中的数据,必须从内存中读取;
更昂贵、且更耗电。
多线程
操作系统里有进程(Process)的概念。进程是一个拥有独立的虚拟空间,独一无二的id和资源(文件、socket)句柄的可以在CPU中执行的程序。操作系统为每个进程分配一个时间片(time slice),快速地在各进程中切换,达到各程序并行的效果。
每个进程多数情况下并不能完整地用完所得到的时间片(因为中断,比如I/O)而不得不放弃对CPU的使用权,所以线程(Thread)的概念由此诞生。当进程中的某个线程等待I/O操作时,另外的线程可以继续运行,直到用完所得到的时间片。对进程来说,CPU的资源就像钱对人类一样珍贵。
随着硬件技术的发展,多核和多处理器技术可以让线程真正地并发运行。
线程共享进程中的内存空间以及资源,但拥有独立的栈。线程的实质是保存在寄存器中的一些状态——程序指针(IP)和栈指针(SP)。当线程切换时,发生改变的仅仅是这两个存放在寄存器中的指针值,被激活的线程便根据新的IP值开始执行程序,并始用当前SP所指的栈,所以线程切换是非常迅速的。相对而言,进程的切换非常慢,因为进程的切换要切换虚拟内存空间,导致页表切换。
线程在内存中只有栈是私有的,有时它需要在栈外维护一个私有存储空间(Thread Local Storage, TLS),比如全局变量。Windows下可以调用TlsAlloc来获得这块存储区域。相关的函数分别是:TlsAlloc反回一个index,TlsGetValue(index)返回这个存储区里的值,TlsSetValue(index, value)设置存储的值,TlsFree(index)释放这个存储区(注:这个函数会释放会令这个index在所有线程中失效,所以要确保最后退出进程的线程调用该函数)。另一种在栈外存储私有数据的方式是私有堆(Private Heap),相关的Windows函数HeapCreate, HeapAlloca, HeapFree和HeapDestory。
线程同步
Mutex:当线程获得Mutex时,其它尝试得到这个Mutex的线程都会被挂起,直到持有这个Mutex的线程释放该Mutex;
Semaphore: Mutex的进化版,通过计数允许多个线程获得这个资源,而不像Mutex一次只能被一个线程获得。当获取Semaphore的线程数达到最大值时,则尝试获取Semaphore的线程就会挂起,直到有线程释放Semaphore。
Event: 当某个Event发生时,唤醒等待该事件的线程。有两种方式:Sychronization和Notification。前者只唤醒最先等待该事件的线程,后者唤醒所有待待该事件的线程。
Waitable Timer: 相当于Time Event,当计时器时间到后,就会唤醒线程。
Critical Section: 类似于Mutex,当一个线程进入临界区(enter critical section)后,其它尝试进入的线程都会被挂起,直到该线程离开临界(leave critical section)区。与Mutex不同,进入临界区的线程再次进入临界区(在递归函数里会出现这种情况),而Lock Mutex的线程再试尝试Lock同一个Mutex,会造成死锁(Dead Lock)。
Slim Reader/Writer: 当某个线程尝试读操作,如果有别的线程正在进行写操作,则挂起;否则即使有别的线程正在进行读操作。当线程尝试写操作,只要有别的线程在进行读操作或写操作,则挂起。
Condition Variables: 这个Variable必须与Mutex或Critical Section一起使用。当一个线程获得了一个Mutex或进入了临界区,当它block住(比如等待I/O)时,可以放弃这个Mutex或离开临界区,同时挂起;当block这个线程的原因消失后(比如I/O操作完成),并且Mutex或临界区没有被其它线程占用,它可以重新获得这个Mutex或临界区。
同步造成的问题
Starvation:某一线程可能无法得到Mutex而始终无法运行。比如Priority太低Mutex总是被更高优先级的线程占据。
Dead lock:当两个线程同时占有对方想要的Mutex时,会导致死锁。比如一个线程得到了A,等待B,而另一线程得到了B,等待A,这时两个线程因为得不到想要的Mutex,而无法运行下去。
Live lock:解决Dead lock可以有一个timeout的方法。当一个线程等待一个Mutex超时后,释放掉自己占有的Mutex。这种方案可能会出现这种情况:线程获得A,等待B超时后释放A;而另一个线程获得B,等待A超时释放B。之后两个线程又同时获得了对方想要的Mutex,等待超时后又再次释放已获得的Mutex。如此反复。
Interlocked Operations
有些原子操作是可以在用户模式(User Mode)下完成的,从而省去了进入操作系统内核的开销,更高效:
Interlocked[In|De]crement(&a) <==> a++ 或 a--
InterlockedAdd(&a, b) <==> a += b
InterlockedExchanged(&a, b) <==> a = b
Interlocked[xor|and|or](&a, b) <==> a ^= b 或 a &= b 或 a |= b