鸣谢
感谢PiggyXP兄的雄文《手把手叫你玩转网络编程系列之三——完成端口(Completion Port)详解》提供的思路
目录
C++11标准提出来有些年头了,十一放假没事研究了一下IOCP,想着能不能用C++11实现一个高性能的服务器。当然,目前有许多十分成熟的C++网络库,比如ACE,asio等等。但是如果想深入了解其本质,在Windows平台下就必须了解Socket结合IOCP的使用原理。
本文尽可能把笔者在使用C++11实现IOCP服务器的过程中遇到的困难和问题展现给大家,让大家学习起来少走些弯路。由于代码比较底层,所以有些细节希望大家在看本文和代码的时候能够揣摩和理解。本文假定读者总体把握了PiggyXP原文的相关内容并具有相当的Window编程的相关知识(熟悉WinSock2库基本函数的使用,Windows多线程的基本概念等)、C++11/03编程基础(STL,仿函数等)。
在每一节标题后都有箭头指向目录,文档某些位置可能会有返回箭头(返回到可能你在阅读的地方),希望能帮助大家更好的理解本文。
本文代码遵循Apache License 2.0协议,欢迎各位大神拍砖。分享带来进步,如需转载请标明作者和出处,谢谢!
温馨提示:由于笔者水平有限,虽经过仔细调试,但本文代码仍然可能存在笔者未知的Bug或者性能缺陷。请大家发现问题后能够及时联系我,让我们共同进步。
开发环境↑
表1 开发环境
软件/系统 |
版本 |
操作系统 |
Windows 10 v1607 x64 |
IDE/编译器 |
Visual Studio 2015/CL 19 |
Win SDK |
10.0.10240 |
编程语言 |
C++11 |
IOCP相关知识↑
本节参考文献
Nasarre C, Richter J. Windows® via C/C++[M]. Pearson Education, 2007: 291-316.
引入
在生活中,异步的概念是很常见的。比如你洗衣服时突然女朋友(程序员有女朋友?)来了,你从洗衣间出去招待,而洗衣机则按照你的指令继续在工作。当你招呼完女朋友回到洗衣间的时候,衣服已经洗好了。也就是在女朋友来的时间点,你与洗衣机分离,它按照你的指令在完成工作,而你却可以处理其他更需要处理的事情。当你处理完回来后,洗衣机可能早已经完成了它的工作,你只需要将衣服取出晾起来就可以了。而同步就是你家没有洗衣机,当女朋友来的时候要么中断洗衣服去招待女朋友,要么让女朋友等待自己把衣服洗完,一件事情只能在另一件事情之后发生。这样,大家就能明显看出来有台洗衣机的好处了。
不过如何知道衣服洗完了呢?Windows牌洗衣机给我们提供了这么四种方式:
表2 Windows 提供的4种异步方式
方式 |
解释 |
相关技术 |
LED灯 |
洗完一件衣服就亮灯,但只有一个灯,其他人可以帮忙处理 |
触发设备内核对象 |
高级LED灯 |
洗完一件衣服就亮灯,可以有多个灯,其他人可以帮忙处理 |
触发事件内核对象 |
发送短信 |
洗完一件衣服就发送一条短信,有一个短信列表,但只有你能够处理 |
可提醒IO(APC) |
群发短信 |
洗完一件衣服就发送一条短信,有一个短信列表,其他人可以帮忙处理 |
IO完成端口(IOCP) |
这样,大家就很明白IOCP的好处了:不需要去时刻看着灯亮不亮;短信到了可以去处理也可以不去处理;不仅你能处理,还有家人也能帮你处理。
触发设备内核对象、触发事件内核对象和可提醒IO就不展开讨论了,有兴趣的朋友可以查阅本节列出的参考文献,下面进入正题。
IOCP状态机
这一小节可能比较难,希望大家能够耐心看下去,因为要真正掌握IOCP就必须弄清楚它内在的原理。先给出IOCP的状态机,如图1所示:
图1 IOCP状态机
下面给出图中各组件的相关说明:
表3 IOCP相关组件说明
组件 |
简要解释 |
等待队列 |
当线程池中的某线程在等待IO操作时(调用GetQueuedCompletionStatus 函数),IOCP将线程加入等待队列。 IOCP在IO操作完成后将返回结果加入完成队列,由等待队列中的最后一个加入的线程处理。 |
已释放列表 |
当等待的线程处理完IO操作后或是从暂停状态被唤醒都会加入此列表。 当线程再次调用GetQueuedCompletionStatus 函数将使自己再次加入等待队列;将自身挂起将加入已暂停列表。 |
已暂停列表 |
当已释放列表中的线程挂起时将加入已暂停列表;当挂起线程被激活时线程加入已释放列表。 |
完成队列 |
IOCP完成指定IO操作后将执行结果插入完成队列。这个队列时先进先出的。 |
IOCP设备列表 |
即要进行异步IO操作的设备列表(可以是文件,也可以是套接字),所有的IO操作都围绕这些设备进行。 |
这样,整个IOCP服务器创建的流程就很明了了:↩
- 创建一个新的完成端口,处理所有的IO请求。
- 创建一个线程池,此时线程处于
已释放列表 。
- 创建一个
Socket 并将其绑定在创建的完成端口上,作为IO操作的实体。利用这个套接字进行Listen 操作,并向第1步创建的完成端口中投递Accept 消息,将第2步创建线程置于等待队列 中等待客户端连接。
- 当客户端连接后,IOCP将在
IO完成队列 插入Accept ,等待队列 中的线程将得到Accept ,并创建新的Socket 作为与客户端通信的套接字,并将其绑定在第1步创建好的完成端口上。
- 此后,无论是
Recv ,Send 都照此步骤进行即可。
这里有几个细节需要注意:
1. 最合适的线程数应当是多于处理器核心数的
多线程优化理论告诫我们,为了避免ring0 与ring3 之间的上下文切换,我们应当将线程数设置为处理器核数。但是微软在设计IOCP的时候想到了这样一个问题:考虑到线程挂起,如果按照理论值设置线程数,将有可能出现实际工作线程数小于CPU所能接受的最大工作线程数,这样就无法有效发挥多线程的优势。因此,最理想的线程数量应当多于处理器核心数的,经验值为两倍核心数。
2. 等待队列是后入先出的
之所以这样设计也是出于性能调优的考虑。当某线程处理完某批IO数据后重新加入等待队列,由于LIFO机制,当完成队列中又存在有新的IO数据时,该线程将会优先处理数据。这样可能会导致某些线程一直处于等待状态,这样Windows就可以将其换出内存节约空间。
3. 投递
所谓投递其实就是利用AcceptEx ,WSARecv 和WSASend 等函数在IO完成端口中进行异步操作。形象来说就是你向洗衣机输入参数的过程,后续工作由洗衣机(WinSock2)完成。
Windows API相关知识↑
本节参考文献
Microsoft. I/O Completion Ports[EB/OL]. https://msdn.microsoft.com/en-us/library/aa365198(VS.85).aspx
Microsoft. Windows Sockets 2[EB/OL]. https://msdn.microsoft.com/en-us/library/windows/desktop/ms740673(v=vs.85).aspx
Russinovich M E, Solomon D A, Ionescu A. Windows internals[M]. Pearson Education, 2012: 56-58.
IOCP APIs
关于常规的IO完成端口API主要有以下三个:
创建和关联IO完成端口函数CreateIoCompletionPort ,该函数在创建完成端口和关联设备(文件设备,套接字等)时使用。
HANDLE WINAPI CreateIoCompletionPort(
_In_ HANDLE FileHandle,
_In_opt_ HANDLE ExistingCompletionPort,
_In_ ULONG_PTR CompletionKey,
_In_ DWORD NumberOfConcurrentThreads
);
获取完成队列状态函数GetQueuedCompletionStatus ,该函数在线程池线程函数中使用。↩
BOOL WINAPI GetQueuedCompletionStatus(
_In_ HANDLE CompletionPort,
_Out_ LPDWORD lpNumberOfBytesTransferred,
_Out_ PULONG_PTR lpCompletionKey,
_Out_ LPOVERLAPPED * lpOverlapped,
_In_ DWORD dwMilliseconds
);
在完成队列中插入消息函数PostQueuedCompletionStatus ,该函数在给线程传递退出参数时使用。↩
BOOL WINAPI PostQueuedCompletionStatus(
_In_ HANDLE CompletionPort,
_In_ DWORD dwNumberOfBytesTransferred,
_In_ ULONG_PTR dwCompletionKey,
_In_opt_ LPOVERLAPPED lpOverlapped
);
以上函数的详细用法在参考文献及piggyXP的文章中可以找到,故不再赘述。
在编程过程中主要考虑以下几个问题:
1. CreateIoCompletionPort 函数的设计问题
按照设计模式最基础的原则即单一职责原则,这个函数设计是存在缺陷的。事实上很多Windows API都或多或少存在此问题,笔者印象比较深刻的是NetBIOS的系列函数。理想的设计是自己再抽象两个函数,即创建完成端口一个函数,绑定完成端口一个函数。可以这样设计:
创建一个新的完成端口函数CreateNewIoCompletionPort ,该函数在初始化时使用。
/**
* Create completion port
*/
inline auto CreateNewIoCompletionPort( DWORD NumberOfConcurrentThreads = 0 ) {
return CreateIoCompletionPort( INVALID_HANDLE_VALUE, nullptr, 0, NumberOfConcurrentThreads );
}
设备与完成端口绑定函数AssociateDeviceWithCompletionPort ,该函数在完成端口建立后与IO设备绑定时使用。↩
/**
* Associate device with completion port
*/
inline auto AssociateDeviceWithCompletionPort( HANDLE hCompPort, HANDLE hDevice, DWORD dwCompKey ) {
return CreateIoCompletionPort( hDevice, hCompPort, dwCompKey, 0 ) == hCompPort;
}
2. 线程池线程退出问题
由于在程序中使用了线程池,对于每一个线程而言如何不留痕迹地结束是一个很有技巧性的问题。一种优雅的方法是使用PostQueuedCompletionStatus 函数给完成端口传递退出完成键(CompletionKey)。由于线程只有可能在等待队列、已释放列表和已暂停列表中,且设计线程函数时均会循环调用GetQueuedCompletionStatus 函数,因此最终所有线程都会转移到等待队列中去。
有的读者会考虑到等待队列的LIFO特性,其实只要我们设计线程函数时首先判断传入的完成键是否为退出的特定信号,检测到自行退出即可。我们在主线程退出时在完成端口中传入创建线程数量个推出信号,由于是完成队列是顺序存取,只要线程函数设计合理,可以保证每一个线程函数都可以收到退出消息。不会发生piggyXP考虑的收不到信息的情况。
更深入的讨论高级程序员参考
笔者深入分析了GetQueuedCompletionStatus 函数(由Kernel32.dll 转发,在KernelBase.dll 中实现),发现其内部准备好各项参数后调用了NtRemoveIoCompletion 函数(由ntdll.dll 转发,在内核ntoskrnl.exe 中实现)。这样就很明白了,其实就是在完成队列中取出一个数据。
继续对NtRemoveIoCompletion 函数进行分析,发现在内部调用了IoRemoveIoCompletion ,继续深究下去发现其主要功能调用了KeRemoveQueueEx 函数,而在该函数内部进行了无锁同步:
if ( _interlockedbittestandset( ... ) ) {
do {
do
KeYieldProcessorEx( ... );
while ( ... );
} while ( _interlockedbittestandset( ... ) );
}
这样就能保证APC交付时,只有一个线程可以访问到完成队列。因此,只要在设计过程中一次只取出一个完成的数据,就不会出现问题。当然,如果想更高效的处理数据(比如调用GetQueuedCompletionStatusEx )又想通过PostQueuedCompletionStatus 方式退出的话,就可能需要特殊处理。比如像piggyXP一样设计一个信号量,或者接收到退出信号后在退出之前向完成队列中再Post一个退出信号等等。
如果想要更加深入的了解其中的运作机理,大家可以去看看WRK或者是React OS的源码。当然,这些代码时代都比较久远了,可能细节上和现在的Windows实现不太一样,但是也能说明问题。
P.S.
在Windows Vista以上操作系统,将完成端口的句柄直接关闭将取消所有关联的IO操作,关联IO端口的所有线程调用GetQueuedCompletionStatus 会放弃等待并立即返回FALSE ,这时调用GetLastError 获取错误码时,会返回ERROR_INVALID_HANDLE 。检测到这一情况就可以退出了。
小插曲
在分析Windows 10内核的时候在Explorer中可以看到ntoskrnl,而在IDA中看不到。最后只得将其复制到其他地方才进行了分析,感叹一句微软套路深。
3. 完成键(CompletionKey)和重叠结构(Overlapped)的设置问题↩
这里可能是理解完成端口的一个难点,至少笔者在学习的时候在这里停顿了一段时间。
首先说说完成键。这个参数是为了给线程池中的线程通信而设计的,也就是说当调用前文所述AssociateDeviceWithCompletionPort 时传入的完成键将会传给调用GetQueuedCompletionStatus 的线程。这样,主线程就可以通过这两个函数与线程池中的线程进行通信。同样注意到完成键是一个DWORD类型,也可以给它传入一个结构体的地址。
而重叠结构是在IO处理时传递给相应IO函数的数据载体。这个结构很有用,但本文不再展开说明,有兴趣的朋友可以查看参考文献相应部分。C/C++程序员应该都知道这样一个事实:结构体的第一个成员的地址和结构体的地址是相同的。所以,我们可以定义一个结构体(或者是一个C++类),将重叠结构作为第一个成员,在IO处理时,将我们定义的结构传入。这样,IO函数处理它自身需要的重叠结构信息,而我们可以在其中夹带私货。为什么要这么做呢?因为在我们在线程函数中可能需要一些其他的数据,这样就可以通过这种办法传进去。
于是我们就明白了:完成键与线程有关而重叠结构与IO有关。我们需要完成键给线程传递参数,需要重叠结构(以及夹带的私货)来完成IO操作。
至于这些怎样与Socket 结合,请浏览下一节内容。
更深入的讨论高级程序员参考
在piggyXP的博文中提到了一个“神奇的宏”:CONTAINING_RECORD 。这个宏广泛应用于驱动编程中,用于获取在知道结构体某成员地址的情况下推知整个结构体地址的场景中。具体定义如下:
/**
* Calculate the address of the base of the structure given its type, and an
* address of a field within the structure.
*/
#define CONTAINING_RECORD(address, type, field) ((type *)( (PCHAR)(address) - (ULONG_PTR)(&((type *)0)->field)))
这个是带有浓郁C风格、充满trick的一个宏。能进行深入讨论的朋友一看就明白,就不班门弄斧了。值得注意的是,使用这个宏的时候对成员是否是结构体的第一个成员没有限制。
WinSock2 APIs
主要使用的API有如下6个:
创建套接字函数WSASocket ,在创建OVERLAPPED 套接字时使用。
注意
WSASocket 是一个宏定义,在MBCS 环境下定义为WSASocketA ,在UNICODE 环境下定义为WSASocketW 。
SOCKET WSAAPI WSASocketW ( // WSASocketA for MBCS
_In_ int af,
_In_ int type,
_In_ int protocol,
_In_opt_ LPWSAPROTOCOL_INFOW lpProtocolInfo, // LPWSAPROTOCOL_INFOA for MBCS
_In_ GROUP g,
_In_ DWORD dwFlags
);
绑定函数bind ,在服务器初始化时使用。
int WSAAPI bind(
_In_ SOCKET s,
_In_reads_bytes_(namelen) const struct sockaddr FAR * name,
_In_ int namelen
);
监听函数listen ,在等待客户端连接监听时使用。
int WSAAPI listen(
_In_ SOCKET s,
_In_ int backlog
);
控制套接字函数WSAIoctl ,在获取函数指针时使用。
int WSAAPI WSAIoctl(
_In_ SOCKET s,
_In_ DWORD dwIoControlCode,
_In_reads_bytes_opt_(cbInBuffer) LPVOID lpvInBuffer,
_In_ DWORD cbInBuffer,
LPVOID lpvOutBuffer,
_In_ DWORD cbOutBuffer,
_Out_ LPDWORD lpcbBytesReturned,
_Inout_opt_ LPWSAOVERLAPPED lpOverlapped,
_In_opt_ LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
);
微软扩展的accept 函数AcceptEx ,用于接受用户接入并获取第一组传输的数据,代替accept 使用。↩
BOOL PASCAL AcceptEx (
_In_ SOCKET sListenSocket,
_In_ SOCKET sAcceptSocket,
PVOID lpOutputBuffer,
_In_ DWORD dwReceiveDataLength,
_In_ DWORD dwLocalAddressLength,
_In_ DWORD dwRemoteAddressLength,
_Out_ LPDWORD lpdwBytesReceived,
_Inout_ LPOVERLAPPED lpOverlapped
);
微软扩展的配合解析AcceptEx 函数返回值使用的函数GetAcceptExSockaddrs ,需要获取第一组数据的时候使用。
void GetAcceptExSockaddrs (
_In_ PVOID lpOutputBuffer,
_In_ DWORD dwReceiveDataLength,
_In_ DWORD dwLocalAddressLength,
_In_ DWORD dwRemoteAddressLength,
_Out_ LPSOCKADDR *LocalSockaddr,
_Out_ LPINT LocalSockaddrLength,
_Out_ LPSOCKADDR *RemoteSockaddr,
_Out_ LPINT RemoteSockaddrLength
);
异步接受数据函数WSARecv ,在接收数据时使用。↩
int WSAAPI WSARecv(
_In_ SOCKET s,
_In_reads_(dwBufferCount) __out_data_source(NETWORK) LPWSABUF lpBuffers,
_In_ DWORD dwBufferCount,
_Out_opt_ LPDWORD lpNumberOfBytesRecvd,
_Inout_ LPDWORD lpFlags,
_Inout_opt_ LPWSAOVERLAPPED lpOverlapped,
_In_opt_ LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
);
异步接受数据函数WSASend ,在接收数据时使用。↩
int WSAAPI WSASend(
_In_ SOCKET s,
_In_reads_(dwBufferCount) LPWSABUF lpBuffers,
_In_ DWORD dwBufferCount,
_Out_opt_ LPDWORD lpNumberOfBytesSent,
_In_ DWORD dwFlags,
_Inout_opt_ LPWSAOVERLAPPED lpOverlapped,
_In_opt_ LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
);
以上函数的详细用法在参考文献及piggyXP的文章中可以找到,故不再赘述。
在编程过程中主要考虑以下几个问题:
1. AcceptEx 和GetAcceptExSockaddrs 函数的调用问题
在实际使用中我们可以发现,调用这两个函数无一不是利用了WSAIoctl 返回的函数指针。笔者在MSDN中也找到了这样的说法:
“The function pointer for the AcceptEx / GetAcceptExSockaddrs function must be obtained at run time by making a call to the WSAIoctl function with the SIO_GET_EXTENSION_FUNCTION_POINTER opcode specified. “
因此,我们在使用这两个函数之前必须通过WSAIoctl 来获取这两个函数的指针加以调用。
更深入的讨论高级程序员参考
事实上笔者发现,在mswsock.dll 中是导出了这两个函数的。那为什么微软在MSDN中没有说到呢,非要用如此麻烦的方式去调用AcceptEx 和GetAcceptExSockaddrs 这两个函数?
mswsock.dll 其实也只是一个转发器,真实的函数在另外的地方。在AcceptEx 函数内部也会调用WSAIoctl (在ws2_32.dll 中实现)来获取真实的函数地址。
有一个非常有意思的地方,AcceptEx 函数除了寻找自己的真实函数地址以外,还回去寻找GetAcceptExSockaddrs 函数的地址,同时进行设置;在导出的GetAcceptExSockaddrs 函数内部不会再去寻找自身实现的地址,而是使用AcceptEx 函数设置的地址,如果地址为空则将后四个传入的参数全部置零,有兴趣的朋友可以尝试一下。
所以使用导出的AcceptEx 而不通过指针从理论上也是可以的,在使用导出的GetAcceptExSockaddrs 之前务必要使用导出的AcceptEx 来设置内部指针,而且并不是说使用导出的函数效率低才使用函数指针获取函数实现地址。可能的原因是每个Windows版本的实现驱动可能不同,对上的接口需要mswsock.dll 来保持一致。
另外,在使用这两个函数时要注意在传递SOCKADDR_IN 结构体大小时要加上16,与具体实现相关,原因不明。
2. 设置各函数完成键和重叠结构体的问题
接完成键(CompletionKey)和重叠结构(Overlapped)的设置问题讨论。在本文程序中,要设置完成键和重叠结构体的主要有以下6个函数,如表4所示:
表4 需要设置的函数及相关解释
大家一看就明白了,AssociateDeviceWithCompletionPort 是主线程将创建好的完成端口与IO设备绑定时调用的,只需要完成键;GetQueuedCompletionStatus 函数是线程池中工作线程调用的,因此要获取完成键和重叠结构;PostQueuedCompletionStatus 函数要传递参数和设置IO状态到完成队列中去,因此也需要两个;AcceptEx 、WSARecv 和WSASend 函数是用来进行IO操作(网络操作)的,因此只需要和网络IO设备打交道,只需设置重叠结构。
注意到前述讨论中的问题,可以设计这样一个结构体充当重叠结构夹带私货:
using IO_CONTEXT = struct _IO_CONTEXT {
/**
* data section
*/
OVERLAPPED m_olOverLapped; /**< Windows overlapped structure */
SOCKET m_sAssociatedSocket; /**< context associated socket */
WSABUF m_wsaBuffer; /**< the buffer to recieve WSASocket data */
CHAR m_cBuffer[MAX_BUFFER_SIZE]; /**< message buffer */
enum class Flag : unsigned char {
Read, /**< read( recv ) */
Write, /**< write( send ) */
Accept /**< accept socket( for AcceptEx API ) */
} m_bFlag; /**< rw flag */
/**
* operation section
*/
...
}
using PIO_CONTEXT = IO_CONTEXT*;
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
注意到完成键可以传入某结构体或类的地址,因此可以设计这样一个结构体充当完成键传递给线程池中线程:
using HANDLE_CONTEXT = struct _HANDLE_CONTEXT {
/**
* data section
*/
SOCKET m_hClientSocket; /**< socket in thread to handle */
SOCKADDR_IN m_sClientAddr; /**< sockaddr_in in thread to handle */
std::vector<PIO_CONTEXT> m_vIoContext; /**< vector of IoContext pointer */
bool m_bFinished; /**< is process finished */
/**
* operation section
*/
...
}
using PHANDLE_CONTEXT = HANDLE_CONTEXT*;
结构定义和piggyXP大同小异,主要差别就在于HANDLE_CONTEXT::m_bFinished 项,在PostQueuedCompletionStatus 传递时将其置为true ,让线程池中线程退出即可。
上下文大致运行流程如图2所示,聪明的你一定一下就明白,就不赘述了。可以参考上一节所述流程,也可以参照代码理解:
图2 各上下文运行大致流程
几个问题*↑
本节参考文献
ISO. IEC14882:2011 Information technology – Programming languages – C++ [S]. Geneva, Switzerland: International Organization for Standardization, 2011.
Meyers S. Effective modern C++: 42 specific ways to improve your use of C++ 11 and C++ 14[M]. ” O’Reilly Media, Inc.”, 2014.
提示
这一节内容和本文主体关系不大,内容也不深,对本节不感兴趣的朋友可以跳过。
function-like macro与inline function的选择
例如piggyXP给出了如下的函数样式的宏:
// 释放指针宏
#define RELEASE(x) {if(x != NULL ){delete x;x=NULL;}}
而笔者在定义时选择了内联函数:
/**
* Release memory
*/
template<typename _T>
inline void ReleaseMemory( _T*& pMemory ) {
if ( pMemory != nullptr ) {
delete pMemory;
pMemory = nullptr;
}
}
主要代码是差不多的,但是能够完成的操作是不一样的,聪明的你应该可以看出来。这个例子不一定好,那就再举一个常见的:
#define MAX(a, b) ( (a) > (b) ? (a) : (b) )
// oops
int result_oops = MAX(i++, j);
选用function-like macro 的好处只有一条:简单方便,效率高(空间换时间),缺点就不多说了,看着就明白。选用inline function 最主要的好处就是:类型检查,效率高(可能空间换时间)。
在编程过程中请尽可能减少预处理器的使用(尤其是函数样式的宏)。
macro constant与compile-time constant(constant expression)
我们可能习惯于这样定义“常量”:
#define MAX_BUFFER_SIZE 8192
当然,这是一个宏,在使用的时候替换为8192这一个字面量。考虑这样的代码:
#define N 2 + 3
// oops
int oops = N / 2; // 3
当然你也可以这样定义,不过总觉得这样定义很别扭:
#define N ( 2 + 3 )
结果不用多说。采用宏常量的理由还是:方便、效率高(字面值,在代码中成为立即数),但是没有类型检查(预处理器管理),有时候用着很麻烦。
而以往的常量const 又占用了存储空间,而且毕竟存储在内存中,也是可以变化的。考虑以下代码:
const int constant = 0;
int* evil_ptr = ( int* )&constant;
*evil_ptr = 1;
...
这样,一个常量就变化了。
更深入的讨论高级程序员参考
事实上笔者在测试的时候发现如果对constant 进行输出,会得到结果为0。反汇编后发现VS直接给输出函数赋的是0,没有从地址取值,优化的还是可以。
在C++11中引入了常量表达式constexpr 的概念,它是一个编译期的常量(字面量),由编译器负责执行。这样,又可以进行类型检查,又可以提高效率,减少资源占用,好处还是很多的。其中一个:
constexpr std::size_t N = 2 + 3;
// no oops
auto normal = N / 2; // 2
|