这篇文章分析网络服务的系统响应速度,阅读这篇文章需要一定的操作系统基础和网络编程基础,建议阅读《操作系统概念》、《Windows网络编程》、《UNIX高级网络编程》。
网络服务的系统响应速度就是提交一个处理请求给网络服务系统开始计时,直到网络服务系统返回处理结果为止的时间间隔。系统响应速度越快表明服务器处理效率越高,用户满意度也越高。
网络服务结构
先看一个典型的网络服务,使用类C的伪代码来描述。
// 服务监听套接字
int lsfd;
// 当前连接套接字
int fd;
// 连接队列
list fdlist;
while (true)
{
// 等待套接字事件
wait_event(lsfd, fdlist);
// 检查是否是监听套接字的事件
if (event(lsfd))
{
// 接收新的连接.
fd = accept(lsfd);
// 把新的连接放入连接队列
push(fd, fdlist);
// 通知系统有新的连接
// on_connect通常需要进行数据库和日志操作
on_connect(fd);
}
while (-1 != (fd = event(fdlist)))
{
// 连接上有可接收的数据
if (is_read(fd))
{
// 接收数据
recv(fd);
// 处理数据
// proc_data通常需要进行数据库和日志操作
proc_data(fd);
// 发送数据
send(fd);
}
// 连接已经断开
if (is_disconnect(fd))
{
// 关闭连接
close(fd);
// 把连接从连接队列清除
pop(fd);
// 通知系统连接断开
// on_disconnect通常需要进行数据库和日志操作
on_disconnect(fd);
}
}
}
|
首先wait_event等待监听套接字lsfd和连接套接字队列fdlist上的事件,对于lsfd,通常关注新连接到来的事件,对于fdlist的套接字连接,通常关注数据接收和连接断开事件。
如果是lsfd的事件,那么通过accept接收新的连接,然后把连接通过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由客户端传送过来,服务端计算完成后,将计算结果写入数据库,然后写入本地文件日志,最后将结果返回给客户端,伪代码如下:
// 连接上有可接收的数据
if (is_read(fd))
{
// 接收等差数列尾数
int n = recv(fd);
// 调用处理函数
int m = proc_data(n);
// 回复计算结果到客户端
send(fd, m);
}
// 处理等差数列计算
int proc_data(int n)
{
// 计算等差数列
int m = 0;
for (int i = 1;i <= n;i ++)
{
m += i;
}
// 把M写入数据库
db_query(m);
// 把M写入日志
log(m);
// 返回
return m;
}
|
假设recv需要处理时间Tr,send需要处理时间Ts,计算等差数列和需要时间Te,写入数据库需要时间Tdb,写日志需要时间Tlog,为了便于分析,假设系统是理想系统,既Tr、Ts、Tdb、Tlog只与IO相关,Te只与CPU相关,画出处理过程系统调度图:
500)this.width=500;" border="0" width="500">
图1
如果有N个连接,他们同时提交处理请求,如图1,这些请求将被顺序执行,那么最先被处理的请求等待时间最短为:
T(min) = (Tr+Te+Tdb+Tlog+Ts)
最后被处理的请求等待时间最长为:
T(max) = N*(Tr+Te+Tdb+Tlog+Ts)
系统最快响应既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可以用来处理更多的任务,一个可行的方案就是使用多线程。
先考察为每个连接建立一个处理线程的情况,同样假设系统是理想系统,线程的创建和销毁不需要时间,伪代码如下:
// 连接上有可接收的数据
if (is_read(fd))
{
// 触发线程执行
trigger_thread(fd, thread_proc);
}
// 线程处理函数
void thread_proc(fd)
{
// 接收等差数列尾数
int N = recv(fd);
// 调用处理函数
int E = proc_data(N);
// 回复计算结果到客户端
send(fd, E);
}
// 处理等差数列计算
int proc_data(int N)
{
// 计算等差数列
int M = 0;
for (int i = 1;i <= N;i ++)
{
M += i;
}
// 把M写入数据库
db_query(M);
// 把M写入日志
log(M);
// 返回
return M;
}
|
可以画出系统调度图:
500)this.width=500;" border="0" width="500">
图2
如果有N个连接,就有N个处理线程,如果N个连接同时提交处理请求,CPU同时其中一个连接的Te,这些连接请求的Te保持时间上的连贯性。
从图2可以得出系统最快响应T(min)和系统最慢响应T(max):
T(min) = (Tr+Te+Tdb+Tlog+Ts)
T(max) = (N-1)*Te+ (Tr+Te+Tdb+Tlog+Ts)
这时T(min)和单线程处理时T(min)一致,T(max)比单线程处理时减少了(N-1)* (Tr+Te+Tdb+Tlog+Ts)。
再次使用上面的估算数据,N=1000,Tr = Ts = 100ns,Te = 10ns,Tdb = 10ms,Tlog = 100ns,那么T(min)=10.31ms,T(max)=20.31ms,等待时间介于10ms和20ms之间,属于正常等待范围。
这是在理想情况下得出的结论,在上面的理想系统中假设了Tr、Tdb、Tlog、Ts只与IO相关而与CPU无关,在实际的系统中,Tr、Tdb、Tlog、Ts也需要消耗微量的CPU时间来完成运算,如果线程数N小,这些微量的CPU时间相对于系统可用的CPU时间来说是高阶无穷小,可以忽略不计,但是如果N变得很大时,操作系统需要使用大量的CPU时间来处理线程调度,留给系统可用的CPU时间变少,这时Tr、Tdb、Tlog、Ts消耗的CPU时间相对于系统可用的CPU时间来说在数量级上比较接近,变成必须考虑的因素,Tr、Tdb、Tlog、Ts与线程N有如下近似关系:
500)this.width=500;" border="0">
图3
如图3,当线程数N较小时,Tr、Tdb、Tlog、Ts接近常数,当N变大时,Tr、Tdb、Tlog、Ts急速增长。
可以根据图3画出T(min)、T(max)对N的变化趋势图:
500)this.width=500;" border="0">
图4
红色曲线是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时间,一个线程处理多个连接的请求,这就是线程池模型。
假设有M个处理线程,M较小,画出系统调度图:
500)this.width=500;" border="0" width="500">
图5
图5中M为4,各个线程之间依然必须满足CPU时间上的顺序,当M很小时,可以看到,一个线程处理完一个连接请求后,可以立即处理下一个连接的请求,因为CPU已经空闲,如图5所示的CPU时间空洞,这样每个处理线程处理请求都是连续的。
从图5可以得出:
T(min) = Tr+Te+Tdb+Tlog+Ts
T(max)依然为最后一个被处理的连接的等待时间,因为CPU要么处于Te运行状态,要么处于T(idle)的空闲状态,所以到了处理最后一个连接的请求时,需要等待的时间是CPU运行时间总和和CPU空闲时间的总和,而最后一个请求需要Tr+Te+Tdb+Tlog+Ts的时间来处理,所以T(max)由三部分组成:一是CPU运行时间,二是CPU空闲时间,三是请求处理时间。
CPU运行时间就是处理N-1个请求需要消耗的CPU时间为(N-1)*Te。
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(idle) = (Tr+Te+Tdb+Tlog+Ts)-M*Te
所以:
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(max)最小为:
T(max) = (N-1)*Te+(Tr+Te+Tdb+Tlog+Ts)
与为使用N个线程处理N个请求时一致。
上面是在M较小并且T(idle) >= 0的情况下得出的结论,下面继续增大M,看看系统有何变化。
M继续增大到大于1+(Tr+Tdb+Tlog+Ts)/Te时,T(max)的计算式不再有效,因为T(idle)不再存在,CPU满负荷运转,画出这时的系统调度图:
500)this.width=500;" border="0" width="500">
图6
这时如果Tr+Te+Tdb+Tlog+Ts较大,每个线程处理两次请求可能有时间间隔,也就是调度延迟T(delay),因为M越大,处理M个请求就需要更多的时间,所以处理下一批M个请求就需要等待更多的时间。
从图6可以得出:
T(min) = Tr+Te+Tdb+Tlog+Ts
T(max)也可以直观得出,从图中可知CPU没有空闲,那么处理前N-1个请求需要(N-1)*Te的时间,处理第N个请求是紧接着进行的,所以:
T(max) = (N-1)*Te+(Tr+Te+Tdb+Tlog+Ts)
可见当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是较为合理的线程数。
由于Tr+Tdb+Tlog+Ts只与IO相关,Te只与CPU相关,所以:
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复用的方式来提高CPU使用率,减少系统响应时间。
IO复用也可以称作异步IO操作,也就是IO操作不会阻塞等待直到操作完成,而是首先提交一个IO请求立即返回,IO完成后通过事件通知处理过程,处理过程接收到IO完成通知后可以进行相应处理,这时CPU可以尽可能多的执行Te,画出系统调度图:
500)this.width=500;" border="0" width="500">
图7
系统调度图类似N个线程处理N个请求的情况,从图7中可以得出系统响应时间:
T(min) = (Tr+Te+Tdb+Tlog+Ts)
T(max) = (N-1)*Te+ (Tr+Te+Tdb+Tlog+Ts)
与用线程池处理一致。
但是对存在数据库操作的系统,因为数据库接口由数据库供应商提供,目前大多数数据库供应商没有提供IO复用的数据库访问接口,所以使用IO复用方式难于进行数据库访问操作。
多CPU的系统响应速度
多CPU系统,首先论证只有所有CPU满负荷运转时系统响应时间最小。
如果任何一个CPU没有满负荷运转而系统响应时间最小的话,总可以找一段空闲的CPU时间来处理最后的第N个请求,也就是系统响应时间可以变得更小,与系统响应时间最小矛盾,所以系统响应时间最小的时候一定是所有CPU满负荷运转的时候。这个道理跟上面的CPU时间空洞时可以增加线程来提高CPU利用率的道理一样。
在所有CPU都满负荷运转的情况下,虽然不能确定哪个CPU处理哪个请求,但是既然都是满负荷运转,究竟谁处理哪个请求并不重要,对于第一个被处理的请求,依然要经过Tr、Te、Tdb、Tlog、Ts的流程,所以:
T(min) = 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)的时间,所以:
T(max) = (N-1)*Te/P+(Tr+Te+Tdb+Tlog+Ts)
不管使用多线程方式还是使用IO复用方式,都是基于CPU满负荷运转的考虑,所以上面的系统响应时间是普遍适用的,并且对于IO复用方式,必须使用P个线程才能使所有CPU都满负荷运转。
可见,对于IO密集型系统,多CPU并不能明显提高系统响应速度,而对于计算密集型系统,多CPU能成倍的提高系统响应速度。
再看看合理的线程数M,因为在单CPU的时候线程数M为1+Tio/Te,所以显然,在P个CPU的时候为:
M = P*(1+Tio/Te)
因为如果再增加线程数,因为没有空闲CPU时间来执行这些线程,所以增加线程数只会增加系统负担而不会提高系统响应速度,相反会降低系统响应速度。
如果减少线程数,CPU使用率不能达到100%,在单位时间内能处理的请求数减少,最后一个请求被处理的时间延长,系统响应速度降低。
连接和断开处理的系统响应速度
连接建立和连接断开也是需要考虑的过程,分析过程类似请求处理过程,考虑连接建立过程,在前面的伪代码中假设on_connect有数据库和日志操作两个过程,用来把连接状态记录下来,分别用时Tdb和Tlog,假设N个客户端同时连接,这时系统响应:
T(min) = Tdb+Tlog
T(max) = N*(Tdb+Tlog)
跟单线程处理请求的道理一样,这时T(max)可能有10多秒,这样后面的连接请求可能被操作系统因为超时而强制断开,出现连接被拒绝的情况,连接断开的处理是IO密集型操作时也同理,所以如果连接建立和连接断开的处理过程是IO密集型操作时应该放到连接池或者用IO复用来处理,当然,应该尽量避免连接建立和连接断开的处理过程有计算密集型的操作。
事件通知的设计分析
事件通知过程是伪代码wait_event(lsfd, fdlist)的部分:
while (true)
{
// 等待套接字事件
wait_event(lsfd, fdlist);
|
在较早的网络服务设计中,大多数使用select,select主要存在两个个问题:一是有套接字数上限,一般是1024个;二是当套接字太多时,select反应迟钝,如果select的等待时间为Tw,系统最快响应时间:
T(min) = Tw+Tr+Te+Tdb+Tlog+Ts
系统最慢响应时间必须考虑最坏的情况,虽然N个请求被同时发出,但是wait_event并不一定会同时触发事件,最坏的情况是wait_event一个一个触发这N个请求的事件,所以系统最慢响应时间:
T(max) = N*Tw+(N-1)*Te+(Tr+Te+Tdb+Tlog+Ts)
如果N接近select的极限,Tw与N成线性增长,N*Tw与N就成平方增长,所以N*Tw往往比(N-1)*Te+(Tr+Te+Tdb+Tlog+Ts)大很多而成为影响系统响应速度的主要因素。
主流操作系统都提供了自己的解决方案。
Windows使用完成端口模型IOCP(IO Complete Port),这个模型基本原理是操作系统维护一个套接字的Hash表,就像伪代码里面的fdlist,Hash表由<fd, ptr>对组成,fd是套接字本身,ptr是程序的数据或者对象指针,IOCP本身是基于IO复用思想的,当程序要接收或发送数据时,只是告诉IOCP需要进行的操作,具体的接收和发送数据操作由操作系统进行,程序通过调用系统查询函数来查询该操作是否完成。IOCP内置了多CPU的支持,查询函数通常由线程池的工作线程来执行,使用多线程的伪代码如下:
// 监听线程
void listen_thread()
{
// IOCP对象
HANDLE iocp;
// 监听套接字
int lsfd;
// 用户对象指针
void * ptr;
while (true)
{
// 接收新的连接
int fd = accept(lsfd);
// 放入iocp队列
iocp_push(iocp, fd, ptr);
}
}
// 请求处理线程
void proc_thread()
{
// IOCP对象
HANDLE iocp;
// 连接套接字
int fd;
// 用户对象
void * ptr;
// 接收的数据
char * data;
while (iocp_event(&fd, & ptr))
{
// 如果是连接事件
if (is_connect(fd))
{
// 通知系统要接收数据
// 这个过程不阻塞,下次接收完毕会通知
trigger_recv(iocp, data);
}
// 如果是接收到数据的事件
else if (is_recved(fd, &data))
{
// 处理接收的数据
proc_data(data, ptr);
// 回复处理结果
send(data);
}
// 如果是断开事件
else if (is_disconnect(fd))
{
}
}
}
|
监听线程负责处理监听套接字的连接,连接成功就将新的连接套接字放入IOCP的Hash表;处理线程轮询IOCP等待连接套接字的事件。如果是连接事件,那么向IOCP提交一个接收数据的请求,也就是等待客户端发送的数据,如果客户端有数据发送过来,IOCP会自动接收并写入提交接收数据请求的缓冲区data。如果是接收事件,那么data已经被填充数据,可以进行请求处理。ptr是用户对象指针,IOCP支持fd和ptr绑定的好处是程序不再需要维护一个<fd, ptr>的Hash表,这个Hash表由操作系统维护,这样在fd上接收到数据时,可以立即获取ptr关联的对象和资源进行处理,以前使用select时,操作系统维护了一个<fd>的链表,程序要想获取与fd关联的对象和资源,必须自己维护<fd, ptr>,效率很低。
IOCP是典型的边缘触发事件模式,也就是有了事件以后只通知一次,如果程序不处理,不会再次触发事件。
IOCP支持更多的套接字,并且具有接近常数的很短的响应时间用Tw表示,那么:
T(min) = Tw+Tr+Te+Tdb+Tlog+Ts
T(max) = N*Tw+(N-1)*Te+(Tr+Te+Tdb+Tlog+Ts)
因为Tw很小并且可以看作是常数,所以N*Tw对于N线性增长,系统可以有很短的响应时间。
Linux的epoll和FreeBSD的kqueue提供了IOCP类似的功能,不同的是epoll和kqueue只是通知程序有数据可接收而并不负责接收数据,而且epoll和kqueue都是状态触发,也就是事件会一直通知直到进行了响应的处理。
同样,使用epoll或kqueue,能保证T(max)对于N的线性增长。
网络延迟的影响
实际的网络系统都有延迟,延迟包括从客户端到服务器的延迟和服务器返回处理结果的延迟,这两个延迟通常一样,用Td表示,所以实际的网络服务系统响应为:
T(min) = 2*Td+Tw+Tr+Te+Tdb+Tlog+Ts
T(max) = 2*Td+N*Tw+(N-1)*Te+(Tr+Te+Tdb+Tlog+Ts)
这就是为什么进行数据库操作时,通常执行批量查询比一条一条执行查询效率要高,因为批量查询减少了多次网络往返时间。
结论
影响网络服务的系统响应速度的因素有CPU速度、IO速度、处理流程和结构、事件机制、网络延迟等,在CPU速度、IO速度和网络延迟确定的情况下,使用多线程或IO复用改善处理流程和结构能加速系统响应速度,使用多线程时合理的线程数与处理过程的CPU操作时间和IO操作时间的比例有关,使用高效的事件机制如IOCP、epoll或kqueue也能改善系统响应速度。
转自:http://blog.chinaunix.net/u1/52224/showart_425281.html