前言
本文源于笔者在公司内部的一个分享。几月前为了搞懂这些知识花费了大量的时间调查研究,最终的理解算是全面而透彻了。而现在学习其他技术时,间或会遇到与此类似的话题,于是把先前的总结记录下来,以作备忘,并启发自己触类旁通。文中图片都取自当时的Slides。
为何要关注多线程和异步
服务器的计算分为IO计算和CPU计算。IO计算指计算任务中以IO为主的计算模型,比如文件服务器、邮件服务器等,混合了大量的网络IO和文件IO;CPU计算指计算任务中没有或很少有IO,比如加密/解密,编码/解码,数学计算等等。
需要关心的是IO计算,一般的网络服务器程序往往伴随着大量的IO计算。提高性能的途径在于要避免等待IO 的结束,造成CPU空闲,要尽量利用硬件能力,让一个或多个IO设备与CPU并发执行。
另一方面,CPU密集的计算是我们无法控制的。如果是CPU计算出现了瓶颈,那只能给服务器增加CPU,或者增加服务器。而IO操作,实际上是空等别的硬件,这里面的优化就大有可为。
大部分Web服务的大部分操作都是IO密集型的。无非是读磁盘、查数据库、访问网络调用别的API,而这些都是通过操作系统的IO。
多线程是必须的,但线程并非越多越好
为了降低请求等待时间,对每个请求都建立一个线程来处理可以吗?这是很多早期服务器的方式。而这种方式存在很大的问题。
首先,创建线程/进程和销毁线程/进程的代价非常高昂,尤其是在服务器采用TCP“短连接”方式或UDP方式通讯的情况下,例如,HTTP协议中,客户端发起一个连接后,发送一个请求,服务器回应了这个请求后,连接也就被关闭了。如果采用经典方式设计HTTP服务器,那么过于频繁地创建线程/销毁线程对性能造成的影响是很恶劣的。
线程还会占用内存。线程的内核对象占几M到几时M。普通的计算机上千个线程就会耗尽内存。
线程切换也会消耗时间,最终导致服务器性能急剧下降。如果客户端并发请求量很高,同一时刻有很多客户端等待服务器响应的情况下,将会有过多的线程并发执行,频繁的线程切换将用掉一部分计算能力。
对于一个需要应付同时有大量客户端并发请求的网络服务器来说,线程池是唯一的解决方案。线程池不光能够避免频繁地创建线程和销毁线程,而且能够用数目很少的线程就可以处理大量客户端并发请求。
异步IO是关键
只有线程池,而继续同步等待IO请求等于没用,可达到的吞吐量将非常有限。异步IO则可以让线程池中的每个线程用很短的时间处理请求中的计算任务,然后把任务交给IO,线程继续处理别的请求。当IO返回时,一个线程池中的线程再被指派来完成剩下的工作,直到完成请求的响应。如此一来,就可以压榨出最大的硬件性能。
这也是为何Nodejs将异步作为核心卖点,各大主流语言和框架都有类似的异步支持和async/await句式了。
从IO讲起
Windows IO的基本过程如下:
其中要用到中断和APC,简要介绍一下:
中断
CPU在执行指令的间隙可以被中断打断,进入内核态。内核态共享一个地址空间,没有进程线程之分。CPU将执行中断分发例程,保存执行现场,执行中断服务例程,再恢复现场,继续执行原来的的指令。
中断还有优先级的概念。处于高优先级时,低优先级的中断不能被处理,直到CPU中断优先级降下来。
APC
是操作系统提供的一种异步回调机制。可以把一段任务代码放到某个用户线程的内核结构的某个队列中,程序正常运行时不会执行。只有当线程发起某些调用,使自己成为Alertable
(可唤醒)时,才会检查APC队列,把其中的任务都执行了。这些进入可唤醒状态的调用有:
WaitForSingleObjectEx()
-
SleepEx()
等等。
再来看第一幅图。通过SYSCALL
指令进行中断调用,进入内核态。内核调用驱动程序去完成IO。而调用是否立即返回(异步),还是阻塞等待(同步),取决于传入的参数。另一张.NET IO的图则可以提供更多理解。
Windows IO的几种模式
-
同步模式
- 调用
CreateFile()
,ReadFile()
,WriteFile()
等API的默认方式就是同步阻塞。
- 调用
-
异步模式
-
Overlapped
结构体-
CreateFile(..., FILE_FLAG_OVERLAPPED,...)
打开文件时加入此标志启动异步模式,API调用会立即返回。 - 使用
GetOverlappedResult/WaitForMultipleObjects
等待Overlapped结构体,可以等待IO完成。
-
-
APC完成例程
- 如此调用
WriteFile(..., lpCompletionRoutine)
,传入一个IO完成例程,内核会在IO完成时将此例程放入线程APC队列。此线程再在适时调用SleepEx()
等进入可唤醒状态,执行IO完成例程。
- 如此调用
-
IOCP(IO 完成端口)
-
CreateIoCompletionPort()
创建IO完成端口。 - 调用
GetQueuedCompletionStatus()
等待IO完成端口上面的IO完成信号。IO完成端口会保证只触发合适数量的线程(约等于CPU核心数)来处理IO完成后的工作。 - 调用
PostQueuedCompletionStatus()
来关闭IO完成端口。
-
-
以上讲解对于不了解Windows核心编程的读者来说肯定无法形成什么认识。以下通过一个例子来介绍这几种模式。
一个Socket服务器的示例
这里用一个Winsock2(Socket的Windows版)的程序举例。只作图示,具体代码未必完全契合。
红色表示阻塞。
- 同步模型。单线程,一次只能处理一个请求。
- 多线程。每次请求都另起线程处理,能同时处理多个请求了。但是线程也越变越多,抗不住。
- 使用Overlapped结构体实现异步。只有一个线程,调用
GetOverlappedResult()
等待多个Overlapped
结构,有完成的IO就解决处理,然后继续等待。一个线程就可以处理所有请求!
可惜它也有缺点。等待多个对象相对于WaitForMultipleObjects()
,而这个函数有64个等待对象的上限。所以只能用多线程来等待。当请求量很大时,线程数量也会变大。 - 使用APC。同样是一个线程,在调用IO函数时传入回调,然后不停地进入可唤醒态,等待IO完成后触发APC回调。
缺点:线程执行的主动性不在自己;负载均衡不易控制。如今已极少使用。 - 使用IOCP。Windows的大杀器。IOCP可以激活合适数量的线程来执行IO完成后的任务。
可以把IOCP理解成一种内核同步机制。