性能和可伸缩性
为了可以伸缩,您必须了解这对于您特定的方案意味着什么。例如,对于 Web 服务器,可伸缩性意味着可以为与所连接的用户数量相关的页面提供服务。请将其考虑为线图表。
|
图1.线性比例示例 |
随着用户数量的增加,每秒的页面数量也应该增加。上图显示了一个线性比例。当用户数量达到 3 倍时,每秒提供的页面也相应增加。
另一个比例的定义与硬件相关。如果我将系统上的处理器数量增加一倍,那么我的 Web 服务器是否也会将输出增加一倍呢?RAM 或磁盘等情况如何呢?应用程序也需要根据这个想法设计。您所创建的线程数量应该基于系统中的处理器数量以及每个线程进行的工作类型。用于缓存网页内容的内存量应该与可用于应用程序的内存量成一定比例,等等。这个概念通常被称为“向上扩展”。如果我将框构建得越来越大,那么可以相应的生成越来越多吗?
伸缩的其他形式是当您谈论分布式计算或服务器场时。这通常被称为“向外伸缩”。如果我将服务器场中的计算机数量增加一倍,那么我的输出也会增加一倍吗?
当设计可伸缩的系统时,需要考虑这些方案。当前,硬件变得越来越大(Itanium 最多支持 64 个处理器计算机),因此向上扩展需要在开发人员头脑中处于最重要的位置。如果您的图表转为水平、甚至随着您增加资源开始下降,这尤为正确。如果整个系统的某个部分无法伸缩,它可能会对整个系统产生负面的影响。
线程:如何有效地使用它们
在线程间分割您的工作可以简化代码,在多处理器系统上可以使您的代码更有效,但是如果您不知道自己在做什么的话,还会降低性能和可伸缩性。例如,如果应用程序中的所有线程都需要获得相同的全局关键部分,那么对该关键部分的争用可能会使您的线程花费其大部分休眠时间。它还可能导致发生过多的上下文转换,进而可能会引起应用程序占用系统内核中相当比例的处理时间,甚至根本没有运行您的代码。如果在多处理器的系统上,这些问题会尤其糟糕,您额外的处理器可能会结束当前闲置,等待访问共享数据。
要使用的线程的理想数量等于系统中处理器的数量。如果您的线程相互独立并受到处理器的限制,那么它们应该能够每次都消耗掉其整个时间片。如果您具有可能执行阻止操作的线程,那么您可能希望增加线程的数量,以便当一个线程休眠时,另一个线程可以取代其位置。您将要确定线程阻塞的位置及频率。意识到这一点后,您就可以知道应该运行的线程数量。您始终要为每个处理器准备好一个线程。否则,您就浪费了处理能力。当然,这些仅仅是指导原则,并且确定应用程序是否以尽可能高的效率运行的唯一方法就是对其进行分析和测试。
异步 I/O:不会阻塞等待数据
基于 NT 内核的 Windows 系统支持异步 I/O,又称重叠的 I/O.大多数形式的 I/O 都可以异步完成。这包括文件 I/O 和网络 I/O.对于文件 I/O,您可以使用 ReadFile/WriteFile API.当读/写时,通过借助 FILE_FLAG_OVERLAPPED 标记打开文件并指定 OVERLAPPED 结构,您将使系统在 I/O 完成时通知您。这使您可以在等待的过程中完成其他工作。对于使用 Windows Socket (WinSock) 的网络 I/O,您可以使用 WSASocket 创建套接字,并指定 WSA_FLAG_OVERLAPPED 标记,然后当调用 WSARecv/WSASend API 时,您可以指定一个 OVERLAPPED 结构或一个回调函数。当您编写网络服务器时,异步 I/O 尤为有效。您可以将多个接收请求“排入队列”,然后去休息,等待其中一个完成。当一个完成时,就会处理传入的数据,然后将另一个接收“排入队列”。这比使用 select API 来轮询数据好得多,并且它可以更有效地使用系统资源。
等待异步 I/O 请求完成有几种选项:
调用 GetOverlappedResult API
在发出异步 I/O 请求后,您可以使用 GetOverlappedResult API 来轮询请求的状态,或者仅仅等待请求的完成。当请求完成时,GetOverlappedResult 将返回请求过程所传输的字节数。
使用 HasOverlappedIoCompleted 宏
您可以使用 HasOverlappedIoCompleted 宏来有效地进行轮询与 OVERLAPPED 结构相关联的请求是否已经完成。请求完成后,您就可以使用 GetOverlappedResult API 来获得有关请求的更多信息(例如传输的字节数)。
指定 OVERLAPPED 结构中的事件
通过在 OVERLAPPED 结构的 hEvent 字段中指定一个事件,您可以执行自己的轮询或等待请求的完成,方法是在对 WaitForSingleObject 或 WaitForMultipleObjects 的调用中指定一个事件。当重叠操作完成时,内核将发信号通知该事件。
将内核对象绑定到 I/O完成端口
I/O 完成端口是系统提供的非常有用的工具。有关信息,请参阅下面的部分。对于事件驱动的系统(例如网络服务器等待输入),I/O 完成端口提供了用于等待和处理传入事件的完美机制。
I/O 完成端口:事件驱动 I/O
大多数 Windows 开发人员都熟悉窗口消息和消息队列。将 I/O 完成端口理解为高性能、高可伸缩性的超级消息队列。如果您具有一个事件驱动的系统,则您需要使用完成端口。完成端口从根本上设计用于提供性能。如果您从头开始编写代码,您绝对应该使用 I/O 完成端口。它们要求进行一些尝试才能正确完成,但是只要您熟悉了它们的工作方式,使用起来就会非常简单。如果从另一个系统或使用异步 I/O 的代码库迁移应用程序,那么您必须提前完成一些工作,但由此带来的好处证明所做的努力是完全值得的。
您可以使用 CreateIoCompletionPort API 来创建完成端口。这也是您用于关联内核对象与完成端口的 API.在文件句柄或套接字句柄与完成端口相关联后,在该句柄上完成的所有 I/O 请求都将排列到完成端口队列中。
通知可以被排列到完成端口队列中,或者按照先进先出 (FIFO) 顺序进行处理。您还可以使用 PostQueuedCompletionStatus API 将自定义的通知排列到完成端口队列中。使用这个自定义通知方法是一个很好的方法,用于向线程发出信号通知其关机或插入任何其他自定义外部事件。在下面的示例代码中,PostQueuedCompletionStatus 用于通知工作线程退出:
HRESULT StopCompletionThreads() |
请注意,为 dwNumberOfBytesTransferred 和 dwCompletionKey 参数传递零,而为 OVERLAPPED 参数传递 NULL。这些组合的值是工作线程检查用于关机的值:
UINT __stdcall CompletionThread(PVOID param) |
I/O 完成方法的核心是 OVERLAPPED 结构。OVERLAPPED 结构包含特定于每个 I/O 请求的上下文信息。通常情况下,将结构进行扩展以添加自己的上下文信息。当处理完成通知时,可以获得对该结构(以及您的上下文数据)的访问。
通过从 OVERLAPPED 结构继承或将其包括为自己结构的第一个字段来扩展 OVERLAPPED 结构,如下所示:
//C++ |
OVERLAPPED 结构包含下列字段:
typedef struct _OVERLAPPED { |
当读取文件或写入文件时,Offset 和 OffsetHigh 字段用于指定偏移量。Internal 字段包含操作的状态(或错误)。InternalHigh 字段包含在 I/O 请求过程中传输的字节数。在 GetOverlappedResult 返回 TRUE(或者完成通知排列到完成端口队列中)之前,Internal 和 InternalHigh 字段都是无效的。
可以扩展该结构以包括您可能需要的任何其他字段。但是,请牢记,结构必须在 I/O 请求的生存期中保持可用。
下面的代码片段显示了 OVERLAPPED 和 OverlappedBase 结构是如何为网络 I/O 操作进行扩展的:
#define SOCKET_BUFFER_SIZE 128 |
这允许每个请求信息可以与已启动的每个 I/O 请求一起存储。op 字段存储正在启动、发送、接收或接受的操作。numberOfBytes 字段包含有效的(用于发送或接收)buffer 字段中的字节数。
划分和征服:让线程独立工作
可伸缩性的弊端在于争用。例如,当一个线程必须等待另一个线程以获取锁定时,该线程就在浪费时间,并且潜在地可以完成的工作必须等待。这会引起线程关系和非一致的内存访问 (NUMA)。如果您的处理可以在线程之间进行分割(在线程之间没有实际的依存关系),那么可以将每个线程锁定到其自己的处理器上。在 NUMA 系统上,您还可以分割每个线程使用的内存,这样对于 NUMA 节点,内存是本地的。
线程关系
Windows Server 2003 使您可以指定允许某个线程在哪个处理器上运行。这称为设置线程的处理器关系。您可以使用 CODE>SetThreadAffinityMask/GetThreadAffinityMask 函数来进行设置并检查特定线程的关系。设置线程的关系在降低处理器间总线通讯方面很有用。当线程从一个处理器移动到另一个处理器时,当前处理器的缓存必须与新的处理器进行同步。处理器之间的线程跳转可能会引起性能问题。另外,某些系统使您可以将特定的设备中断绑定到特定的处理器。在您的软件中,您可以将特定的线程“绑定”到该处理器,并且从该线程发出/处理该设备的所有 I/O,因此通过增加潜在的系统并发(即,在多处理器之间快速传播如网卡这样的活动设备)。
NUMA
NUMA 表示非一致的内存访问。在传统的对称多处理 (SMP) 系统上,系统中的所有处理器对整个范围的物理内存具有相同的访问权限。传统 SMP 系统的问题在于添加越多的处理器和内存,总线通讯量就会越高。也就会抑止性能。在 NUMA 系统上,处理器分组成较小的系统,每个小系统都有其自己的“本地”内存。访问“本地”内存成本很低,然而访问另一个节点上的内存代价可能会非常昂贵。Windows 将尝试在正在使用内存的节点上计划线程,但是可以使用 NUMA 函数来改进线程计划和内存使用情况来帮助 Windows.使用下列功能来确定哪个处理器属于哪个节点,以及为特定的处理器/节点设置线程的关系:
GetNumaHighestNodeNumber
GetNumaProcessorNode
GetNumaNodeProcessorMask
SetThreadAffinityMask
另外,大量利用内存的应用程序可以使用以下函数来改进它们在 NUMA 系统上的内存使用情况:
GetNumaAvailableMemoryNode
开始考虑 NUMA 和大型多处理器系统以及从头开始为它们进行设计是非常重要的。大多数最初的 64 位部署都将用于大型多处理器系统,该系统的处理器多于 8 个,运行诸如 Secure Audio Path (SAP) 这样的巨型企业应用程序。NUMA 对于整体可伸缩性和性能非常关键。
WinSock Direct
在大型的数据中心中,服务器之间的通信量可能会超出传统基于 TCP/IP 网络的带宽。通过卸载一些来自 CPU 的网络协议处理,系统区域网 (SAN) 设计用于解决这个问题。在服务器之间提供更快速的通讯对服务器应用程序很有好处,这样就改进了向外扩展解决方案的性能。大多数 SAN 要求直接针对供应商的 API 编写应用程序,这就导致很少有应用程序可以用于在 SAN 环境中进行部署。Microsoft 开发的 Windows Sockets (WinSock) Direct 针对低级 SAN 实现提供了一个通用编程接口。WinSock Direct 位于标准 WinSock API 下,但是绕过了内核网络层直接与 SAN 硬件进行对话。因为 WinSock Direct 位于现有的 WinSock API 下,所以 IT 部门可以在 SAN 环境中部署应用程序,而无需对应用程序进行修改。
SAN 通过两种标准的传输模式,提供了可靠的、顺序的数据提交,这两种模式是:消息和远程直接内存访问 (RDMA)。消息很像传统的网络协议,其中数据包发送到某个对等方,而该对等方会从网络中请求数据包。RDMA 允许指定数据包的目标缓冲区。
通常情况下,SAN 硬件将直接在硬件中实现大部分其数据传输功能。这使得 SAN 实现可以完成诸如绕过内核这样的操作。通常由内核提供的处理直接卸载到硬件中。
WinSock Direct 避免了应用程序直接编程到 SAN 特定的 API 的要求。只通过安装 SAN 硬件以及为硬件安装 WinSock Direct 驱动程序,现有的应用程序就可以利用由 SAN 提供的更高的性能。