並發伺服器(一):簡介
這是關於並發網路伺服器編程的第一篇教程。我計劃測試幾個主流的、可以同時處理多個客戶端請求的伺服器並發模型,基於可擴展性和易實現性對這些模型進行評判。所有的伺服器都會監聽套接字連接,並且實現一些簡單的協議用於與客戶端進行通訊。
該系列的所有文章:
協議
該系列教程所用的協議都非常簡單,但足以展示並發伺服器設計的許多有趣層面。而且這個協議是 有狀態的 —— 伺服器根據客戶端發送的數據改變內部狀態,然後根據內部狀態產生相應的行為。並非所有的協議都是有狀態的 —— 實際上,基於 HTTP 的許多協議是無狀態的,但是有狀態的協議也是很常見,值得認真討論。
在伺服器端看來,這個協議的視圖是這樣的:
總之:伺服器等待新客戶端的連接;當一個客戶端連接的時候,伺服器會向該客戶端發送一個 *
字元,進入「等待消息」的狀態。在該狀態下,伺服器會忽略客戶端發送的所有字元,除非它看到了一個 ^
字元,這表示一個新消息的開始。這個時候伺服器就會轉變為「正在通信」的狀態,這時它會向客戶端回送數據,把收到的所有字元的每個位元組加 1 回送給客戶端 注1 。當客戶端發送了 $
字元,伺服器就會退回到等待新消息的狀態。^
和 $
字元僅僅用於分隔消息 —— 它們不會被伺服器回送。
每個狀態之後都有個隱藏的箭頭指向 「等待客戶端」 狀態,用於客戶端斷開連接。因此,客戶端要表示「我已經結束」的方法很簡單,關掉它那一端的連接就好。
顯然,這個協議是真實協議的簡化版,真實使用的協議一般包含複雜的報文頭、轉義字元序列(例如讓消息體中可以出現 $
符號),額外的狀態變化。但是我們這個協議足以完成期望。
另一點:這個系列是介紹性的,並假設客戶端都工作的很好(雖然可能運行很慢);因此沒有設置超時,也沒有設置特殊的規則來確保伺服器不會因為客戶端的惡意行為(或是故障)而出現阻塞,導致不能正常結束。
順序伺服器
這個系列中我們的第一個服務端程序是一個簡單的「順序」伺服器,用 C 進行編寫,除了標準的 POSIX 中用於套接字的內容以外沒有使用其它庫。伺服器程序是順序,因為它一次只能處理一個客戶端的請求;當有客戶端連接時,像之前所說的那樣,伺服器會進入到狀態機中,並且不再監聽套接字接受新的客戶端連接,直到當前的客戶端結束連接。顯然這不是並發的,而且即便在很少的負載下也不能服務多個客戶端,但它對於我們的討論很有用,因為我們需要的是一個易於理解的基礎。
這個伺服器的完整代碼在這裡;接下來,我會著重於一些重點的部分。main
函數裡面的外層循環用於監聽套接字,以便接受新客戶端的連接。一旦有客戶端進行連接,就會調用 serve_connection
,這個函數中的代碼會一直運行,直到客戶端斷開連接。
順序伺服器在循環里調用 accept
用來監聽套接字,並接受新連接:
while (1) {
struct sockaddr_in peer_addr;
socklen_t peer_addr_len = sizeof(peer_addr);
int newsockfd =
accept(sockfd, (struct sockaddr*)&peer_addr, &peer_addr_len);
if (newsockfd < 0) {
perror_die("ERROR on accept");
}
report_peer_connected(&peer_addr, peer_addr_len);
serve_connection(newsockfd);
printf("peer donen");
}
accept
函數每次都會返回一個新的已連接的套接字,然後伺服器調用 serve_connection
;注意這是一個 阻塞式 的調用 —— 在 serve_connection
返回前,accept
函數都不會再被調用了;伺服器會被阻塞,直到客戶端結束連接才能接受新的連接。換句話說,客戶端按 順序 得到響應。
這是 serve_connection
函數:
typedef enum { WAIT_FOR_MSG, IN_MSG } ProcessingState;
void serve_connection(int sockfd) {
if (send(sockfd, "*", 1, 0) < 1) {
perror_die("send");
}
ProcessingState state = WAIT_FOR_MSG;
while (1) {
uint8_t buf[1024];
int len = recv(sockfd, buf, sizeof buf, 0);
if (len < 0) {
perror_die("recv");
} else if (len == 0) {
break;
}
for (int i = 0; i < len; ++i) {
switch (state) {
case WAIT_FOR_MSG:
if (buf[i] == '^') {
state = IN_MSG;
}
break;
case IN_MSG:
if (buf[i] == '$') {
state = WAIT_FOR_MSG;
} else {
buf[i] += 1;
if (send(sockfd, &buf[i], 1, 0) < 1) {
perror("send error");
close(sockfd);
return;
}
}
break;
}
}
}
close(sockfd);
}
它完全是按照狀態機協議進行編寫的。每次循環的時候,伺服器嘗試接收客戶端的數據。收到 0 位元組意味著客戶端斷開連接,然後循環就會退出。否則,會逐位元組檢查接收緩存,每一個位元組都可能會觸發一個狀態。
recv
函數返回接收到的位元組數與客戶端發送消息的數量完全無關(^...$
閉合序列的位元組)。因此,在保持狀態的循環中遍歷整個緩衝區很重要。而且,每一個接收到的緩衝中可能包含多條信息,但也有可能開始了一個新消息,卻沒有顯式的結束字元;而這個結束字元可能在下一個緩衝中才能收到,這就是處理狀態在循環迭代中進行維護的原因。
例如,試想主循環中的 recv
函數在某次連接中返回了三個非空的緩衝:
^abc$de^abte$f
xyz^123
25$^ab$abab
服務端返回的是哪些數據?追蹤代碼對於理解狀態轉變很有用。(答案見 注 2 )
多個並發客戶端
如果多個客戶端在同一時刻向順序伺服器發起連接會發生什麼事情?
伺服器端的代碼(以及它的名字 「順序伺服器」)已經說的很清楚了,一次只能處理 一個 客戶端的請求。只要伺服器在 serve_connection
函數中忙於處理客戶端的請求,就不會接受別的客戶端的連接。只有當前的客戶端斷開了連接,serve_connection
才會返回,然後最外層的循環才能繼續執行接受其他客戶端的連接。
為了演示這個行為,該系列教程的示例代碼 包含了一個 Python 腳本,用於模擬幾個想要同時連接伺服器的客戶端。每一個客戶端發送類似之前那樣的三個數據緩衝 注3 ,不過每次發送數據之間會有一定延遲。
客戶端腳本在不同的線程中並發地模擬客戶端行為。這是我們的序列化伺服器與客戶端交互的信息記錄:
$ python3.6 simple-client.py -n 3 localhost 9090
INFO:2017-09-16 14:14:17,763:conn1 connected...
INFO:2017-09-16 14:14:17,763:conn1 sending b'^abc$de^abte$f'
INFO:2017-09-16 14:14:17,763:conn1 received b'b'
INFO:2017-09-16 14:14:17,802:conn1 received b'cdbcuf'
INFO:2017-09-16 14:14:18,764:conn1 sending b'xyz^123'
INFO:2017-09-16 14:14:18,764:conn1 received b'234'
INFO:2017-09-16 14:14:19,764:conn1 sending b'25$^ab0000$abab'
INFO:2017-09-16 14:14:19,765:conn1 received b'36bc1111'
INFO:2017-09-16 14:14:19,965:conn1 disconnecting
INFO:2017-09-16 14:14:19,966:conn2 connected...
INFO:2017-09-16 14:14:19,967:conn2 sending b'^abc$de^abte$f'
INFO:2017-09-16 14:14:19,967:conn2 received b'b'
INFO:2017-09-16 14:14:20,006:conn2 received b'cdbcuf'
INFO:2017-09-16 14:14:20,968:conn2 sending b'xyz^123'
INFO:2017-09-16 14:14:20,969:conn2 received b'234'
INFO:2017-09-16 14:14:21,970:conn2 sending b'25$^ab0000$abab'
INFO:2017-09-16 14:14:21,970:conn2 received b'36bc1111'
INFO:2017-09-16 14:14:22,171:conn2 disconnecting
INFO:2017-09-16 14:14:22,171:conn0 connected...
INFO:2017-09-16 14:14:22,172:conn0 sending b'^abc$de^abte$f'
INFO:2017-09-16 14:14:22,172:conn0 received b'b'
INFO:2017-09-16 14:14:22,210:conn0 received b'cdbcuf'
INFO:2017-09-16 14:14:23,173:conn0 sending b'xyz^123'
INFO:2017-09-16 14:14:23,174:conn0 received b'234'
INFO:2017-09-16 14:14:24,175:conn0 sending b'25$^ab0000$abab'
INFO:2017-09-16 14:14:24,176:conn0 received b'36bc1111'
INFO:2017-09-16 14:14:24,376:conn0 disconnecting
這裡要注意連接名:conn1
是第一個連接到伺服器的,先跟伺服器交互了一段時間。接下來的連接 conn2
—— 在第一個斷開連接後,連接到了伺服器,然後第三個連接也是一樣。就像日誌顯示的那樣,每一個連接讓伺服器變得繁忙,持續了大約 2.2 秒的時間(這實際上是人為地在客戶端代碼中加入的延遲),在這段時間裡別的客戶端都不能連接。
顯然,這不是一個可擴展的策略。這個例子中,客戶端中加入了延遲,讓伺服器不能處理別的交互動作。一個智能伺服器應該能處理一堆客戶端的請求,而這個原始的伺服器在結束連接之前一直繁忙(我們將會在之後的章節中看到如何實現智能的伺服器)。儘管服務端有延遲,但這不會過度佔用 CPU;例如,從資料庫中查找信息(時間基本上是花在連接到資料庫伺服器上,或者是花在硬碟中的本地資料庫)。
總結及期望
這個示例伺服器達成了兩個預期目標:
- 首先是介紹了問題範疇和貫徹該系列文章的套接字編程基礎。
- 對於並發伺服器編程的拋磚引玉 —— 就像之前的部分所說,順序伺服器還不能在非常輕微的負載下進行擴展,而且沒有高效的利用資源。
在看下一篇文章前,確保你已經理解了這裡所講的伺服器/客戶端協議,還有順序伺服器的代碼。我之前介紹過了這個簡單的協議;例如 串列通信分幀 和 用協程來替代狀態機。要學習套接字網路編程的基礎,Beej 的教程 用來入門很不錯,但是要深入理解我推薦你還是看本書。
如果有什麼不清楚的,請在評論區下進行評論或者向我發送郵件。深入理解並發伺服器!
- 注1:狀態轉變中的 In/Out 記號是指 Mealy machine。
- 注2:回應的是
bcdbcuf23436bc
。 - 注3:這裡在結尾處有一點小區別,加了字元串
0000
—— 伺服器回應這個序列,告訴客戶端讓其斷開連接;這是一個簡單的握手協議,確保客戶端有足夠的時間接收到伺服器發送的所有回復。
via: https://eli.thegreenplace.net/2017/concurrent-servers-part-1-introduction/
作者:Eli Bendersky 譯者:GitFuture 校對:wxy
本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive