http://www.gesoftfactory.com/ge/windowsviacpp/Chapter08.htm#_Toc208043820
第8章 用户模式下的线程同步
本章概览:
原子访问:Interlocked系列函数
高速缓存行
高级线程同步
需要避免使用的一种方法
当所有的线程都能够独自运行而不需要相互通信的时候,Microsoft Windows将进入最佳运行状态。但是,很少有线程能够总是独自运行。通常创建线程是为了处理某些任务,当任务完成的时候,另一个线程可能想要得到通知。
系统中所有的线程必须访问系统资源,比如堆、串口、文件、窗口、以及无数其他资源。如果一个线程独占了对某个资源的访问,那么其他线程就无法完成它们的工作。另一方面,我们也不能让任何线程在任何时刻都能访问任何资源。设想有一个线程正在写入一块内存,而同时另一个线程正在从同一块内存中读取数据。这就好比是一个人在另一个人读书的时候修改书中的文字一样,书中的内容将变得乱七八糟,毫无用处。
在两种基本的情况下,线程之间需要相互通信:
§ 需要让多个线程同时访问一个共享资源,同时不能破坏资源的完整性。
§ 一个线程需要通知其他线程某项任务已经完成。
线程同步包括许多方面,我们会在下面的几章中进行讨论。好消息是Microsoft Windows提供了许多基础设施,可以让线程同步变得容易。但坏消息是想要预见一堆线程在任一时刻打算做什么是极其困难的。我们大脑的工作方式不是异步的,我们习惯一次一步地按次序考虑问题,但这不是多线程环境的运作方式。
笔者最早开始使用多线程大概是在1992年。一开始,我在编写程序时犯了许多错误,甚至还出版了一些书和杂志文章,其中不乏与线程同步有关的缺陷。现在,我已经比当时要熟练得多了,虽然还谈不上完美,但我相信本书中的一切都不存在缺陷。想要熟练掌握线程同步的唯一途径就是实际使用。在下面几章中,我们会解释系统的运作方式,并展示如何以正确的方式在线程间进行同步。现在让我们面对现实,在积累经验的过程中我们会犯这样那样的错误,但这并没有什么大不了的。
原子访问:Interlocked系列函数
线程同步的一大部分与原子访问(atomic access)有关。所谓原子访问,指的是一个线程在访问某个资源的同时能够保证没有其他线程会在同一时刻访问同一资源。现在让我们来看一个简单的例子:
// Define a global variable.
long g_x = 0;
DWORD WINAPI ThreadFunc1(PVOID pvParam) {
g_x++;
return(0);
}
DWORD WINAPI ThreadFunc2(PVOID pvParam) {
g_x++;
return(0);
}
代码中声明了一个全局变量g_x并将它初始化为0。现在假设我们创建了两个线程,一个线程执行ThreadFunc1,另一个线程执行ThreadFunc2。这两个函数中的代码完全相同:它们都把全局变量g_x加1。因此当两个线程都停止运行的时候,我们可能认为g_x的值会是2。但真的是这样吗?答案是——有可能。根据代码的编写方式,我们无法确切地知道g_x最终会等于几,下面就是原因。假设编译器在编译将g_x递增的那行代码时,生成了下面的汇编代码:
MOV EAX, [g_x] ; Move the value in g_x into a register.
INC EAX ; Increment the value in the register.
MOV [g_x], EAX ; Store the new value back in g_x.
两个线程不太可能在完全相同的时刻执行上面的代码。因此如果一个线程先执行,另一个线程随后执行,那么下面将是执行的结果:
MOV EAX, [g_x] ; Thread 1: Move 0 into a register.
INC EAX ; Thread 1: Increment the register to 1.
MOV [g_x], EAX ; Thread 1: Store 1 back in g_x.
MOV EAX, [g_x] ; Thread 2: Move 1 into a register.
INC EAX ; Thread 2: Increment the register to 2.
MOV [g_x], EAX ; Thread 2: Store 2 back in g_x.
当两个线程把g_x递增的操作完成后,g_x的值是2。这非常好,和我们预计的完全相同:先等于0,然后加1两次,最终的答案是2。漂亮!但等一会,由于Windows是一个抢占式的多线程环境,因此系统可能会在任一时刻暂停执行一个线程,切换到另一个线程并让新的线程继续执行。因为这个原因,前面的代码可能不会严格按照前面显示的顺序执行,而可能会按照下面的顺序执行:
MOV EAX, [g_x] ; Thread 1: Move 0 into a register.
INC EAX ; Thread 1: Increment the register to 1.
MOV EAX, [g_x] ; Thread 2: Move 0 into a register.
INC EAX ; Thread 2: Increment the register to 1.
MOV [g_x], EAX ; Thread 2: Store 1 back in g_x.
MOV [g_x], EAX ; Thread 1: Store 1 back in g_x.
如果代码按照这种顺序来执行的话,那么g_x最终的值将是1——而不是我们预计的2!这听起来非常吓人,尤其是我们几乎无法对线程调度器进行控制。事实上,如果我们有100个线程,每个线程都执行类似的函数,那么当所有线程都结束运行后,g_x的值可能还是1!显然,软件开发人员不能在这样的环境中开发。我们希望在所有的时候对0进行两次加1操作的结果都是2。另外,我们不要忘记,编译器如何生成代码、代码在什么CPU上运行、机器上装配了多少个CPU,所有这些都可能会导致不同的结果。这就是开发和运行环境,我们无法对它们进行控制。但是,Windows也确实提供了一些函数,只要使用得当,就能够保证得到我们预期的结果。
为了解决刚才的问题,我们需要一些简单的方案。我们需要有一种方法能够保证对一个值的递增操作是原子操作——也就是说,不会被打断。Interlocked系列函数提供了我们需要的解决方案。虽然这些Interlocked函数非常有用,也很容易理解,但大多数软件开发人员对它们心存畏惧,并没有充分地利用它们。所有这些函数会以原子方式来操控一个值。让我们来看看InterlockedExchangeAdd以及它用来对LONGLONG类型进行操控的兄弟函数InterlockedExchangeAdd64:
LONG InterlockedExchangeAdd(
PLONG volatile plAddend,
LONG lIncrement);
LONGLONG InterlockedExchangeAdd64(
PLONGLONG volatile pllAddend,
LONGLONG llIncrement);
还有什么方法能比这更简单吗?只要调用这个函数,传一个长整型变量的地址和另一个增量值,函数就会保证递增操作是以原子方式进行的。因此我们可以把前面的代码改写成下面的代码:
// Define a global variable.
long g_x = 0;
DWORD WINAPI ThreadFunc1(PVOID pvParam) {
InterlockedExchangeAdd(&g_x, 1);
return(0);
}
DWORD WINAPI ThreadFunc2(PVOID pvParam) {
InterlockedExchangeAdd(&g_x, 1);
return(0);
}
经过这个微小的改动,对g_x的递增会以原子方式进行,我们也因此能够保证g_x最终的值将等于2。注意,如果只想以原子方式给一个值加1的话,也可以使用InterlockedIncrement函数。现在是不是已经感觉好些了?要注意的是,所有线程都应该调用这些函数来修改共享变量的值,任何一个线程都不应该使用简单的C++语句来修改共享变量:
// The long variable shared by many threads
LONG g_x; ...
// Incorrect way to increment the long
g_x++; ...
// Correct way to increment the long
InterlockedExchangeAdd(&g_x, 1);
那么这些Interlocked函数又是如何工作的呢?答案取决于代码运行的CPU平台。如果是x86系列CPU,那么Interlocked函数会在总线上维持一个硬件信号,这个信号会阻止其他CPU访问同一个内存地址。
我们并不需要理解Interlocked函数具体是如何工作的。重要的是无论编译器如何生成代码,无论机器上装配了多少个CPU,这些函数都能够保证对值的修改是以原子方式进行的。我们必须确保传给这些函数的变量地址是经过对齐的,否则这些函数可能会失败。(我们会在第13章中对此进行讨论。)
注意 C运行库提供了一个_aligned_malloc函数,我们可以用这个函数来分配一块对齐过的内存。下面是函数的原型:
void * _aligned_malloc(size_t size, size_t alignment);
参数size表示要分配的字节数,参数alignment表示要对齐到的字节边界。传给alignment参数的值必须是2的整数幂次方。
关于Interlocked函数我们需要知道的另一个要点就是它们执行得极快。调用一次Interlocked函数通常只占用几个CPU周期(通常小于50),而且也不需要在用户模式和内核模式之间进行切换(这个切换通常需要占用1000个周期以上)。
当然,我们也可以用InterlockedExchangeAdd来做减法——只要在第二个参数中传一个负值就行了。InterlockedExchangeAdd会返回*plAddend中原来的值。
下面是其他三个Interlocked函数:
LONG InterlockedExchange(
PLONG volatile plTarget,
LONG lValue);
LONGLONG InterlockedExchange64(
PLONGLONG volatile plTarget,
LONGLONG lValue);
PVOID InterlockedExchangePointer(
PVOID* volatile ppvTarget,
PVOID pvValue);
InterlockedExchange和InterlockedExchangePointer会把第一个参数所指向的内存地址的当前值,以原子方式替换为第二个参数指定的值。对32位应用程序来说,这两个函数都用一个32位值替换另一个32位值,但对64位应用程序来说,InterlockedExchange替换的是32位值,而InterlockedExchangePointer替换的是64位值。这两个函数都会返回原来的值。在实现旋转锁[1]的时候,InterlockedExchange极其有用:
// Global variable indicating whether a shared resource is in use or not
BOOL g_fResourceInUse = FALSE; ...
void Func1() {
// Wait to access the resource.
while (InterlockedExchange (&g_fResourceInUse, TRUE) == TRUE)
Sleep(0);
// Access the resource.
...
// We no longer need to access the resource.
InterlockedExchange(&g_fResourceInUse, FALSE);
}
while循环不停地运行,把g_fResourceInUse的值设为TRUE并检查原来的值是否为TRUE。如果原来的值为FALSE,那说明资源尚未被使用,于是调用线程立刻就能将它设成“使用中”,然后退出循环。如果原来的值是TRUE,那说明有其他的线程正在使用该资源,于是while循环会继续执行。
如果另一个线程也执行类似的代码,那么它会一直执行while循环,直到g_fResourceInUse被改回FALSE。函数最后对InterlockedExchange的调用展示了如何将g_fResourceInUse设回FALSE。
在使用这项技术的时候要极其小心,这是因为旋转锁会耗费CPU时间。CPU必须不断地比较两个值,直到另一个线程“神奇地”改变了其中一个值为止。而且,这里的代码假定所有使用旋转锁的线程都以相同的优先级运行。对那些用到旋转锁的线程来说,我们可能想要(调用SetProcessPriorityBoost或SetThreadPriorityBoost来)禁用线程优先级提升[2]。
此外,我们必须确保锁变量和锁所保护的数据位于不同的高速缓存行(本章稍后会介绍)中。如果锁变量和数据共享同一高速缓存行,那么使用资源的CPU就会与任何试图访问资源的CPU发生争夺,而这会影响性能。
在单CPU的机器上应该避免使用旋转锁。如果一个线程不停地循环,那么这不仅会浪费宝贵的CPU时间,而且会阻止其他线程改变锁的值。前面的代码在while循环中使用了Sleep,这在某种程度上改善了这一状况。如果使用Sleep,那么可以休眠一段随机的时间,当每次对资源的访问被拒绝的时候,可以进一步增加休眠的时间。这可以避免让线程浪费CPU时间。取决于实际情况,把Sleep调用完全去掉可能会更好。或者可以将之替换为对SwitchToThread的调用。虽然笔者讨厌这么说,但反复试验可能是找到最优方案的最佳途径。
旋转锁假定被保护的资源始终只会被占用一小段时间。与切换到内核模式然后等待相比,在这种情况下以循环的方式进行等待的效率会更高。许多开发人员会循环指定的次数(比如4000),如果届时仍然无法访问资源,那么线程会切换到内核模式,并一直等到资源可供使用为止(此时它不消耗CPU时间)。这就是关键段[3]的实现方式。
在多处理器的机器上旋转锁比较有用,这是因为当一个线程在一个CPU上运行的时候,另一个线程可以在另一个CPU上循环等待。但是,即使在这种情况下,我们还是必须小心。我们并不希望一个线程循环很长一段时间,不然会浪费更多的CPU时间。本章稍后我们还会进一步讨论旋转锁。
下面是最后两个Interlocked交换函数:
PVOID InterlockedCompareExchange(
PLONG plDestination,
LONG lExchange,
LONG lComparand);
PVOID InterlockedCompareExchangePointer(
PVOID* ppvDestination,
PVOID pvExchange,
PVOID pvComparand);
这两个函数以原子方式执行一个测试和设置操作。对32位应用程序来说,这两个函数都对32位值进行操作,但在64位应用程序中,InterlockedCompareExchange对32位值进行操作而InterlockedCompareExchangePointer对64位值进行操作。下面的伪代码描述了InterlockedCompareExchange函数到底做了些什么:
LONG InterlockedCompareExchange(PLONG plDestination,
LONG lExchange, LONG lComparand) {
LONG lRet = *plDestination; // Original value
if (*plDestination == lComparand)
*plDestination = lExchange;
return(lRet);
}
函数会将(参数plDestination指向的)当前值与参数lComparand的值进行比较。如果两个值相同,那么函数会将*plDestination修改为lExchange参数的值。如果*plDestination的值不等于lComparand,那么*plDestination将保持不变。函数会返回*plDestination原来的值。记住,所有这些操作是作为一个原子执行单元来完成的。注意现在这两个函数还有另外一个版本,可以用来处理已对齐的64位值:
LONGLONG InterlockedCompareExchange64(
LONGLONG pllDestination,
LONGLONG llExchange,
LONGLONG llComparand);
没有哪个Interlocked函数可以仅用来读取一个值(但又不修改它),这是因为这样的功能没有必要。如果线程只需要读取一个值的内容,而这个值始终是通过Interlocked函数修改的,那么读到的值不会有任何问题。虽然我们并不知道读到值的是原始值还是更新后的值,但我们知道它肯定是其中之一。对大多数应用程序来说,这已经足够了。此外,当多个进程需要对访问一个共享内存段(比如内存映射文件)中的值进行同步时,也可以使用Interlocked函数。(第9章包括几个示例程序,展示了如何使用Interlocked函数。)
Windows还提供了其他一些Interlocked函数,但前面介绍的函数已经提供了其他函数能够提供了所有功能,甚至还更多。下面是其他两个函数:
LONG InterlockedIncrement(PLONG plAddend);
LONG InterlockedDecrement(PLONG plAddend);
InterlockedExchangeAdd可以代替这两个旧函数。新函数可以加减任何值,而旧函数则只能加减1。还有另一组基于InterlockedCompareExchange64 的OR、AND和XOR辅助函数可供我们使用。它们使用了我们在前面已经见到过的旋转锁,下面这段代码就是其中一个函数的实现,所有这些函数的实现都可以在WinBase.h中找到:
LONGLONG InterlockedAnd64(
LONGLONG* Destination,
LONGLONG Value) {
LONGLONG Old;
do {
Old = *Destination;
} while (InterlockedCompareExchange64(Destination, Old & Value, Old) != Old);
return Old;
}
从Windows XP开始,除了能对整数或布尔值进行这些原子操作外,我们还能使用一系列其他的函数来对一种被称为Interlocked单向链表的栈进行操作。栈中的每个操作,比如入栈或出栈,必定是以原子方式进行的。表18-1列出了Interlocked单向链表提供的函数。
表8-1 Interlocked单向链表函数
函数
描述
InitializeSListHead
创建一个空栈
InterlockedPushEntrySList
在栈顶添加一个元素
InterlockedPopEntrySList
移除位于栈顶的元素并将它返回
InterlockedFlushSList
清空栈
QueryDepthSList
返回栈中元素的数量
高速缓存行
如果想为装配有多处理器的机器构建高性能应用程序,那么应该注意高速缓存行[4]。当CPU从内存中读取一个字节的时候,它并不只是从内存中取回一个字节,而是取回一个高速缓存行。高速缓存行可能包含32字节(老式CPU),64字节,甚至是128字节(取决于CPU),它们始终都对齐到32字节边界,64字节边界,或128字节边界。高速缓存行存在的目的是为了提高性能。一般来说,应用程序会对一组相邻的字节进行操作。如果所有字节都在高速缓存中,那么CPU就不必访问内存总线,后者耗费的时间比前者耗费的时间要多得多。
但是,在多处理器环境中,高速缓存线使得对内存的更新变得更加困难。我们可以从下面的例子中看到这一点:
1. CPU1读取一个字节,这使得该字节以及与它相邻的字节被读到CPU1的高速缓存行中。
2. CPU2读取同一个字节,这使得该字节被读到CPU2的高速缓存行中。
3. CPU1对内存中的这个字节进行修改,这使得该字节被写入到CPU1的高速缓存行中。但这一信息还没有写回到内存。
4. CPU2再次读取同一个字节。由于该字节已经在CPU2的高速缓存行中,因此CPU2不需要再访问内存。但CPU2将无法看到该字节在内存中新的值。
这种情形非常糟糕。当然,CPU芯片的设计者非常清楚这个问题,并做了专门的设计来对它进行处理。明确地说,当一个CPU修改了高速缓存行中的一个字节时,机器中的其他CPU会收到通知,并使自己的高速缓存行作废。因此在刚才的情形中,当CPU1修改该字节的值时,CPU2的高速缓存就作废了。在第4步中,CPU1必须将它的高速缓存写回到内存中,CPU2必须重新访问内存来填满它的高速缓存行。我们可以看到,虽然高速缓存行能够提高性能,但在多处理器的机器上它们同样能够损伤性能。
这一切都意味着我们应该根据高速缓存行的大小来将应用程序的数据组织在一起,并将数据与缓存行的边界对齐。这样做的目的是为了确保不同的CPU能够各自访问不同的内存地址,而且这些地址不在同一个高速缓存行中。此外,我们应该把只读数据(或不经常读的数据)与可读写数据分别存放。我们还应该把差不多会在同一时间访问的数据组织在一起。
下面这个例子是一个设计得非常糟糕的数据结构:
struct CUSTINFO {
DWORD dwCustomerID; // Mostly read-only
int nBalanceDue; // Read-write
wchar_t szName[100]; // Mostly read-only
FILETIME ftLastOrderDate; // Read-write
};
确定CPU的高速缓存行的大小的最简单方法就是调用Win32的GetLogicalProcessorInformation函数。这个函数会返回一个SYSTEM_LOGICAL_PROCESSOR_INFORMATION结构数组。我们可以检查每个结构的Cache字段,该成员是一个CACHE_DESCRIPTOR结构,其中的LineSize字段表示CPU的高速缓存行的大小。一旦有了这一信息,我们就可以使用C/C++编译器的__declspec(align(#))指示符来对字段对齐加以控制。下面是刚才的结构经过改进后的版本:
#define CACHE_ALIGN 64
// Force each structure to be in a different cache line.
struct __declspec(align(CACHE_ALIGN)) CUSTINFO {
DWORD dwCustomerID; // Mostly read-only
wchar_t szName[100]; // Mostly read-only
// Force the following members to be in a different cache line.
__declspec(align(CACHE_ALIGN))
int nBalanceDue; // Read-write
FILETIME ftLastOrderDate; // Read-write
};
有关如何使用__declspec(align(#))指示符的详细信息,请参阅http://msdn2.microsoft.com/en-us/library/83ythb65.aspx。
注意 最好是始终只让一个线程访问数据(函数参数和局部变量是确保这一点的最简单方式),或者始终只让一个CPU访问数据(使用线程关联[5])。只要能做到其中任何一条,就可以完全避免高速缓存行的问题了。
高级线程同步
如果只需要以原子方式修改一个值,那么Interlocked系列函数非常好用,我们当然应该优先使用它们。但大多数实际的编程问题需要处理的数据结构往往要比一个简单的32位值或64位值复杂得多。为了能够以“原子”方式来访问复杂数据结构,我们必须超越Interlocked系列函数,转而使用Windows提供的一些其他特性。
前面一节强调了在配备单处理器的机器上不应该使用旋转锁,即使在配备多处理器的机器上,在使用旋转锁的时候也应该谨慎。原因很简单,浪费CPU时间是件非常糟糕的事情。因此,我们需要一种机制,它既能让线程等待共享资源的访问权,又不会浪费CPU时间。
当线程想要访问一个共享资源或者想要得到一些“特殊事件”的通知时,线程必须调用操作系统的一个函数,并将线程正在等待的东西作为参数传入。如果操作系统检测到资源已经可供使用了,或者特殊事件已经发生了,那么这个函数会立即返回,这样线程将仍然保持可调度状态。(线程可能并不会立即运行,它是可调度的,系统会根据前一章中描述的规则来给它分配CPU。)
如果无法取得对资源的访问权,或者特殊事件尚未发生,那么系统会将线程切换到等待状态,使线程变得不可调度,从而避免了让线程浪费CPU时间。当线程在等待的时候,系统会充当它的代理。系统会记住线程想要访问什么资源,当资源可供使用的时候,它会自动将线程唤醒——线程的执行与特殊事件是同步的。
实际情况是,大多数线程在大部分情况下都处于等待状态。当系统检测到所有线程都已经在等待状态中度过了好几分钟的时候,系统的电源管理器将会介入。
需要避免使用的一种方法
如果没有同步对象,如果操作系统不能对特殊事件进行监测,那么线程将不得不使用下面介绍的技术来在自己和特殊事件之间进行同步。但是,由于操作系统内建了对线程同步的支持,因此我们在任何时候都不应该使用这种方法。
在这种方法中,两个线程共享一个变量,其中一个线程不断地读取变量的值,直到另一个线程完成它的任务为止。下面这段代码展示了这种方法:
volatile BOOL g_fFinishedCalculation = FALSE;
int WINAPI _tWinMain(...) {
CreateThread(..., RecalcFunc, ...);
...
// Wait for the recalculation to complete.
while (!g_fFinishedCalculation)
;
...
}
DWORD WINAPI RecalcFunc(PVOID pvParam) {
// Perform the recalculation.
... g_fFinishedCalculation = TRUE;
return(0);
}
正如我们所看到的那样,当主线程(执行_tWinMain)需要等待RecalcFunc函数完成的时候,它并没有让自己进入休眠状态。由于主线程没有进入休眠状态,因此CPU不断地给它调度CPU时间,这就从其他线程手中夺走了宝贵的CPU时间。
刚才这段代码还存在另一个问题,那就是BOOL变量g_fFinishedCalculation可能永远不会被设为TRUE。如果主线程的优先级比RecalcFunc函数所在线程的优先级高,那么就可能发生这种情况。在这种情况下,系统不会分配任何时间片给RecalcFunc所在线程,因此把g_fFinishedCalculation设为TRUE的语句永远不会执行。如果_tWinMain函数所在线程不进行轮询,而是进入休眠,那么系统就不必给它调度时间,从而有机会把时间调度给低优先级的线程,比如RecalcFunc所在线程,让它们得以执行。
不可否认的是轮循有时候还是非常方便的,毕竟旋转锁就是这么做的。但即便如此,也还是有正确的方式和不正确的方式。一个通用的规则是,我们既不应该使用旋转锁,也不应该进行轮循,而应该调用函数把线程切换到等待状态,直到线程想要访问的资源可供使用为止。我们会在下一节对正确的方式进行介绍。
首先,还有一点必须指出:在刚才那段代码的顶部,我们可以注意到使用了volatile关键字。为了使上面这段代码能够正常工作,volatile类型限定符是不可或缺的。它告诉编译器这个变量可能会被应用程序之外的其他东西修改,比如操作系统、硬件或者一个并发执行的线程。确切地说,volatile限定符告诉编译器不要对这个变量进行任何形式的优化,而是始终从变量在内存中的位置读取变量的值。现在假设编译器为刚才那段代码中的while循环生成了下面的伪代码:
MOV Reg0, [g_fFinishedCalculation] ; Copy the value into a register
Label: TEST Reg0, 0 ; Is the value 0?
JMP Reg0 == 0, Label ; The register is 0, try again
... ; The register is not 0 (end of loop)
如果不给布尔变量加上volatile限定符,编译器可能会对C++代码进行优化,就像这里显示的那样。在这个优化中,编译器把BOOL变量的值载入到CPU寄存器中,这个操作只需要进行一次。然后它重复对CPU寄存器中的值进行测试。与每次都从变量所在的内存地址取得变量的值并进行测试相比,这样做当然能够得到更好的性能。因此,一个编译器可能会把代码优化成刚才显示的这样。但是,如果编译器进行这样的优化,那么线程会陷入无限循环。顺便提一下,给一个结构加volatile限定符等于给结构中所有的成员都加volatile限定符,这样可以确保任何一个成员都始终是从内存中读取的。
读者可能会感到迷惑,那么(在前面旋转锁代码中用到的)旋转锁变量g_fResourceInUse是不是也应该声明为volatile呢?回答是否定的。这是因为我们传给Interlocked函数的是变量的地址,而不是变量的值。如果传一个变量的地址给函数,那么函数必须从内存中读取它的值,编译器的优化器不会对此产生影响。
本文转自cnn23711151CTO博客,原文链接: http://blog.51cto.com/cnn237111/525252,如需转载请自行联系原作者