這篇文章分析網絡服務的系統響應速度,閱讀這篇文章需要一定的操作系統基礎和網絡編程基礎,建議閱讀《操作系統概念》、《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