这篇文章分析网络服务的系统响应速度,阅读这篇文章需要一定的操作系统基础和网络编程基础,建议阅读《操作系统概念》、《Windows网络编程》、《UNIX高级网络编程》。
网络服务的系统响应速度就是提交一个处理请求给网络服务系统开始计时,直到网络服务系统返回处理结果为止的时间间隔。系统响应速度越快表明服务器处理效率越高,用户满意度也越高。
push(fd,fdlist);//通知系统有新的连接//on_connect通常需要进行数据库和日志操作
pop(fd);//通知系统连接断开//on_disconnect通常需要进行数据库和日志操作
首先wait_event等待监听套接字lsfd和连接套接字队列fdlist上的事件,对于lsfd,通常关注新连接到来的事件,对于fdlist的套接字连接,通常关注数据接收和连接断开事件。
如果是lsfd的事件,那么通过ept接收新的连接,然后把连接通过push添加到fdlist中,最后调用on_connect通知系统新连接建立,on_connect通常会向数据库或者日志系统写入连接日志。
如果是fdlist中某个或者某些连接的事件,先找出有事件的连接。如果是数据接收事件is_read为true,那么使用recv接收数据,然后调用proc_data处理接收的数据,proc_data通常会查询数据库进行业务逻辑处理,也会向日志系统写入处理日志,最后调用send向客户端反馈一些处理结果。如果是连接断开事件is_connect_为true,首先调用close关闭连接,然后调用pop从fdlist中把fd删除,最后调用on_disconnect通知系统连接断开,on_disconnect可能会向数据库或日志系统写入断开日志。
先考察逻辑处理响应速度,接收数据、处理、回复结果,在上面的基础结构里面是recv、proc_data、send部分。
假设网络服务用来计算1
2 … n等差数列的和,n由客户端传送过来,服务端计算完成后,将计算结果写入数据库,然后写入本地文件日志,最后将结果返回给客户端,伪代码如下:
如果有N个连接,他们同时提交处理请求,如图
1,这些请求将被顺序执行,那么最先被处理的请求等待时间最短为:
系统最快响应既T(min),系统最慢响应既T(max),随便选择一个连接观察,得到的响应速度介于T(min)和T(max)之间。
可以做一个估算,N=1000,Tr=Ts=100ns,Te=10ns,Tdb=10ms,Tlog=100ns,那么T(min)=10.31ms,T(max)=10s,等待10秒是大多数人无法忍受的。
观察系统调度图,蓝色部分Te为CPU运行时间,可以看到有大部分时间CPU处于空闲状态,这些空闲时间的CPU可以用来处理更多的任务,一个可行的方案就是使用多线程。
先考察为每个连接建立一个处理线程的情况,同样假设系统是理想系统,线程的创建和销毁不需要时间,伪代码如下:
如图
3,当线程数N较小时,Tr、Tdb、Tlog、Ts接近常数,当N变大时,Tr、Tdb、Tlog、Ts急速增长。
红色曲线是T(min)变化曲线,蓝色曲线是T(max)变化曲线,当N较小时,T(min)和T(max)很小并且变化不大,可以为每个连接开一个处理线程;当N变大时,T(min)和T(max)也急速变大,如果N成千上万,T(min)和T(max)甚至可能比单线程时还大,不能再为每个连接开一个线程。
当N变得很大时,导致T(min)和T(max)急速变大的原因是因为操作系统使用了大量的CPU时间来调度线程,所以应该保持线程数在一个范围之内,让调度程序尽量少占用CPU时间,一个线程处理多个连接的请求,这就是线程池模型。
图5中M为
4,各个线程之间依然必须满足CPU时间上的顺序,当M很小时,可以看到,一个线程处理完一个连接请求后,可以立即处理下一个连接的请求,因为CPU已经空闲,如图5所示的CPU时间空洞,这样每个处理线程处理请求都是连续的。
T(max)依然为最后一个被处理的连接的等待时间,因为CPU要么处于Te运行状态,要么处于T(idle)的空闲状态,所以到了处理最后一个连接的请求时,需要等待的时间是CPU运行时间总和和CPU空闲时间的总和,而最后一个请求需要Tr Te Tdb Tlog Ts的时间来处理,所以T(max)由三部分组成:一是CPU运行时间,二是CPU空闲时间,三是请求处理时间。
CPU空闲时间是(请求分批数-1)*T(idle),请求分批数为(N-1-(N-1)%M)/M,%表示取余数,分批数可以理解为一次处理M个请求的情况下处理N-1个请求需要多少次。
T(max)=(N-1)*Te ((N-1-(N-1)%M)/M)*T(idle) (Tr Te Tdb Tlog Ts)
T(max)=(N-1)*Te ((N-1-(N-1)%M)/M)*((Tr Te Tdb Tlog Ts)-M*Te) (Tr Te Tdb Tlog Ts)
很显然,T(max)并不是最小的,因为CPU有空闲状态,这些CPU时间可以被用来处理更多的请求,直到CPU不再有空闲状态,这就要求((N-1-(N-1)%M)/M)*((Tr Te Tdb Tlog Ts)-M*Te)为
0,因为((N-1-(N-1)%M)/M)与N相关,通常不为
0,所以只能(Tr Te Tdb Tlog Ts)-M*Te为
0,既T(idle)=
0,可以计算出M:
M=(Tr Te Tdb Tlog Ts)/Te=
1 (Tr Tdb Tlog Ts)/Te
上面是在M较小并且T(idle)>=0的情况下得出的结论,下面继续增大
M,看看系统有何变化。
M继续增大到大于
1 (Tr Tdb Tlog Ts)/Te时,T(max)的计算式不再有效,因为T(idle)不再存在,CPU满负荷运转,画出这时的系统调度图:
这时如果Tr Te Tdb Tlog Ts较大,每个线程处理两次请求可能有时间间隔,也就是调度延迟T(delay),因为M越大,处理M个请求就需要更多的时间,所以处理下一批M个请求就需要等待更多的时间。
T(max)也可以直观得出,从图中可知CPU没有空闲,那么处理前N-1个请求需要(N-1)*Te的时间,处理第N个请求是紧接着进行的,所以:
可见当M增大到大于
1 (Tr Tdb Tlog Ts)/Te的时候,T(min)和T(max)并不会减少,相反,跟N个线程处理N个请求一样的道理,M增大后,Tr、Tdb、Tlog、Ts会变大,T(min)和T(max)不减反增,所以M=
1 (Tr Tdb Tlog Ts)/Te是较为合理的线程数。
Tio为请求处理过程所有IO操作时间总和,Te为请求处理过程所有CPU操作时间总和,就像《程序性能分析》(参考:/u1/52224/showart_417513.html)中分析的一样,任何一个处理过程都可以看作是CPU操作和IO操作的复合体,所以M=
1 Tio/Te是普适用的。
如果Tio为
0,也就是没有IO操作,那么M=
1,也就是使用单线程处理和使用多线程处理有同样的系统响应速度,没有IO操作也称作计算密集型的处理。
如果Te为
0,也就是没有CPU操作,那么M=∞,也就是应该使用尽可能多的线程来处理,在实际的系统中没有一个系统Te是为0的,因为任何处理过程总是要消耗CPU时间,包括IO处理本身,只是CPU时间相对较少,这也称作IO密集型的处理,在IO设备能力许可的情况下,线程越多系统响应速度会越快。
在实际的系统中,Tio和Te都不是常数,受到IO设备、CPU使用率、算法设计等多方面影响,可能在一定范围内波动,所以实际使用的线程池一般都有动态调节功能,能根据系统情况动态调整线程池的线程数,来优化系统响应时间。
IO复用也可以称作异步IO操作,也就是IO操作不会阻塞等待直到操作完成,而是首先提交一个IO请求立即返回,IO完成后通过事件通知处理过程,处理过程接收到IO完成通知后可以进行相应处理,这时CPU可以尽可能多的执行Te,画出系统调度图:
但是对存在数据库操作的系统,因为数据库接口由数据库供应商提供,目前大多数数据库供应商没有提供IO复用的数据库访问接口,所以使用IO复用方式难于进行数据库访问操作。
如果任何一个CPU没有满负荷运转而系统响应时间最小的话,总可以找一段空闲的CPU时间来处理最后的第N个请求,也就是系统响应时间可以变得更小,与系统响应时间最小矛盾,所以系统响应时间最小的时候一定是所有CPU满负荷运转的时候。这个道理跟上面的CPU时间空洞时可以增加线程来提高CPU利用率的道理一样。
在所有CPU都满负荷运转的情况下,虽然不能确定哪个CPU处理哪个请求,但是既然都是满负荷运转,究竟谁处理哪个请求并不重要,对于第一个被处理的请求,依然要经过Tr、Te、Tdb、Tlog、Ts的流程,所以:
对第N个请求来说,最关心的就是前面N-1个花费的CPU时间,一个CPU的时候,前面N-1个请求需要花费(N-1)*Te的CPU时间,如果有P个CPU,那么前面N-1个请求需要花费(N-1)*Te/P的时间,而处理第N个请求本身需要花费T(min)的时间,所以:
不管使用多线程方式还是使用IO复用方式,都是基于CPU满负荷运转的考虑,所以上面的系统响应时间是普遍适用的,并且对于IO复用方式,必须使用P个线程才能使所有CPU都满负荷运转。
可见,对于IO密集型系统,多CPU并不能明显提高系统响应速度,而对于计算密集型系统,多CPU能成倍的提高系统响应速度。
再看看合理的线程数
M,因为在单CPU的时候线程数M为
1 Tio/Te,所以显然,在P个CPU的时候为:
因为如果再增加线程数,因为没有空闲CPU时间来执行这些线程,所以增加线程数只会增加系统负担而不会提高系统响应速度,相反会降低系统响应速度。
如果减少线程数,CPU使用率不能达到100%,在单位时间内能处理的请求数减少,最后一个请求被处理的时间延长,系统响应速度降低。
连接建立和连接断开也是需要考虑的过程,分析过程类似请求处理过程,考虑连接建立过程,在前面的伪代码中假设on_connect有数据库和日志操作两个过程,用来把连接状态记录下来,分别用时Tdb和Tlog,假设N个客户端同时连接,这时系统响应:
跟单线程处理请求的道理一样,这时T(max)可能有10多秒,这样后面的连接请求可能被操作系统因为超时而强制断开,出现连接被拒绝的情况,连接断开的处理是IO密集型操作时也同理,所以如果连接建立和连接断开的处理过程是IO密集型操作时应该放到连接池或者用IO复用来处理,当然,应该尽量避免连接建立和连接断开的处理过程有计算密集型的操作。
在较早的网络服务设计中,大多数使用select,select主要存在两个个问题:一是有套接字数上限,一般是1024个;二是当套接字太多时,select反应迟钝,如果select的等待时间为Tw,系统最快响应时间:
系统最慢响应时间必须考虑最坏的情况,虽然N个请求被同时发出,但是wait_event并不一定会同时触发事件,最坏的情况是wait_event一个一个触发这N个请求的事件,所以系统最慢响应时间:
如果N接近select的极限,Tw与N成线性增长,N*Tw与N就成平方增长,所以N*Tw往往比(N-1)*Te (Tr Te Tdb Tlog Ts)大很多而成为影响系统响应速度的主要因素。
Windows使用完成端口模型IOCP(IOCompletePort),这个模型基本原理是操作系统维护一个套接字的Hash表,就像伪代码里面的fdlist,Hash表由对组成,fd是套接字本身,ptr是程序的数据或者对象指针,IOCP本身是基于IO复用思想的,当程序要接收或发送数据时,只是告诉IOCP需要进行的操作,具体的接收和发送数据操作由操作系统进行,程序通过调用系统查询函数来查询该操作是否完成。IOCP内置了多CPU的支持,查询函数通常由线程池的工作线程来执行,使用多线程的伪代码如下: