【系統編程】並發伺服器(一):簡介

鏈接:https://linux.cn/article-8993-1.html

這是關於併發網絡服務器編程的第一篇教程。我計劃測試幾個主流的、可同時處理多個客戶端請求的服務器併發模型,基於可擴展性和易實現性對這些模型進行評判。所有的服務器都會監聽套接字連接,並且實現一些簡單的協議用於與客戶端進行通訊。

協議

該系列教程所用的協議都非常簡單,但足以展示併發服務器設計的許多有趣層面。而且這個協議是有狀態的—— 服務器根據客戶端發送的數據改變內部狀態,然後根據內部狀態產生相應的行為。並非所有的協議都是有狀態的 —— 實際上,基於 HTTP 的許多協議是無狀態的,但是有狀態的協議也是很常見,值得認真討論。

在服務器端看來,這個協議的視圖是這樣的:

【系統編程】併發服務器(一):簡介

總之:服務器等待新客戶端的連接;當一個客戶端連接的時候,服務器會向該客戶端發送一個 * 字符,進入“等待消息”的狀態。在該狀態下,服務器會忽略客戶端發送的所有字符,除非它看到了一個 ^ 字符,這表示一個新消息的開始。這個時候服務器就會轉變為“正在通信”的狀態,這時它會向客戶端回送數據,把收到的所有字符的每個字節加 1 回送給客戶端(注:狀態轉變中的In/Out 記號是指Mealy machine) 。當客戶端發送了 $ 字符,服務器就會退回到等待新消息的狀態。^$ 字符僅僅用於分隔消息 —— 它們不會被服務器回送。

每個狀態之後都有個隱藏的箭頭指向 “等待客戶端” 狀態,用於客戶端斷開連接。因此,客戶端要表示“我已經結束”的方法很簡單,關掉它那一端的連接就好。

顯然,這個協議是真實協議的簡化版,真實使用的協議一般包含複雜的報文頭、轉義字符序列(例如讓消息體中可以出現

$ 符號),額外的狀態變化。但是我們這個協議足以完成期望。

另一點:這個系列是介紹性的,並假設客戶端都工作的很好(雖然可能運行很慢);因此沒有設置超時,也沒有設置特殊的規則來確保服務器不會因為客戶端的惡意行為(或是故障)而出現阻塞,導致不能正常結束。

順序服務器

這個系列中我們的第一個服務端程序是一個簡單的“順序”服務器,用 C 進行編寫,除了標準的 POSIX 中用於套接字的內容以外沒有使用其它庫。服務器程序是順序,因為它一次只能處理一個客戶端的請求;當有客戶端連接時,像之前所說的那樣,服務器會進入到狀態機中,並且不再監聽套接字接受新的客戶端連接,直到當前的客戶端結束連接。顯然這不是併發的,而且即便在很少的負載下也不能服務多個客戶端,但它對於我們的討論很有用,因為我們需要的是一個易於理解的基礎。

這個服務器的完整代碼地址在文末給出;接下來,我會著重於一些重點的部分。main 函數里面的外層循環用於監聽套接字,以便接受新客戶端的連接。一旦有客戶端進行連接,就會調用 serve_connection,這個函數中的代碼會一直運行,直到客戶端斷開連接。

順序服務器在循環裡調用 accept 用來監聽套接字,並接受新連接:

【系統編程】併發服務器(一):簡介

accept 函數每次都會返回一個新的已連接的套接字,然後服務器調用 serve_connection;注意這是一個阻塞式的調用 —— 在 serve_connection 返回前,accept 函數都不會再被調用了;服務器會被阻塞,直到客戶端結束連接才能接受新的連接。換句話說,客戶端按順序 得到響應。

這是 serve_connection 函數:

【系統編程】併發服務器(一):簡介

它完全是按照狀態機協議進行編寫的。每次循環的時候,服務器嘗試接收客戶端的數據。收到 0 字節意味著客戶端斷開連接,然後循環就會退出。否則,會逐字節檢查接收緩存,每一個字節都可能會觸發一個狀態。

recv 函數返回接收到的字節數與客戶端發送消息的數量完全無關(^...$ 閉合序列的字節)。因此,在保持狀態的循環中遍歷整個緩衝區很重要。而且,每一個接收到的緩衝中可能包含多條信息,但也有可能開始了一個新消息,卻沒有顯式的結束字符;而這個結束字符可能在下一個緩衝中才能收到,這就是處理狀態在循環迭代中進行維護的原因。

例如,試想主循環中的 recv 函數在某次連接中返回了三個非空的緩衝:

1:^abc$de^abte$f

2:xyz^123

3:25$^ab$abab

服務端返回的是哪些數據?追蹤代碼對於理解狀態轉變很有用。答案:返回的是 bcdbcuf23436bc。

多個併發客戶端

如果多個客戶端在同一時刻向順序服務器發起連接會發生什麼事情?

服務器端的代碼(以及它的名字 “順序服務器”)已經說的很清楚了,一次只能處理 一個 客戶端的請求。只要服務器在 serve_connection 函數中忙於處理客戶端的請求,就不會接受別的客戶端的連接。只有當前的客戶端斷開了連接,serve_connection 才會返回,然後最外層的循環才能繼續執行接受其他客戶端的連接。

為了演示這個行為,該系列教程的示例代碼 包含了一個 Python 腳本,用於模擬幾個想要同時連接服務器的客戶端。每一個客戶端發送類似之前那樣的三個數據緩衝,不過每次發送數據之間會有一定延遲。

客戶端腳本在不同的線程中併發地模擬客戶端行為。這是我們的序列化服務器與客戶端交互的信息記錄:

【系統編程】併發服務器(一):簡介

這裡要注意連接名:conn1 是第一個連接到服務器的,先跟服務器交互了一段時間。接下來的連接 conn2—— 在第一個斷開連接後,連接到了服務器,然後第三個連接也是一樣。就像日誌顯示的那樣,每一個連接讓服務器變得繁忙,持續了大約 2.2 秒的時間(這實際上是人為地在客戶端代碼中加入的延遲),在這段時間裡別的客戶端都不能連接。

顯然,這不是一個可擴展的策略。這個例子中,客戶端中加入了延遲,讓服務器不能處理別的交互動作。一個智能服務器應該能處理一堆客戶端的請求,而這個原始的服務器在結束連接之前一直繁忙(我們將會在之後的章節中看到如何實現智能的服務器)。儘管服務端有延遲,但這不會過度佔用 CPU;例如,從數據庫中查找信息(時間基本上是花在連接到數據庫服務器上,或者是花在硬盤中的本地數據庫)。

總結及期望

這個示例服務器達成了兩個預期目標:

1:首先是介紹了問題範疇和貫徹該系列文章的套接字編程基礎。

2:對於併發服務器編程的拋磚引玉 —— 就像之前的部分所說,順序服務器還不能在非常輕微的負載下進行擴展,而且沒有高效的利用資源。

在看下一篇文章前,確保你已經理解了這裡所講的服務器/客戶端協議,還有順序服務器的代碼。

完整代碼地址:https://github.com/eliben/code-for-blog/blob/master/2017/async-socket-server/sequential-server.c


分享到:


相關文章: