本篇文章,是 對C/C++ 協程的實現。我們需要實現這兩個目標:
有同步式伺服器編程的順序思路,便於功能設計和代碼調試——我使用了 libco 中的協程部分
有非同步 I/O 的效能——我使用了 libevent 中的 event I/O apache php mysql
結構上,就是將 libco 和 libevent 兩者的功能結合起來,所以我把我的工程,命名為 libcoevent,意為 “基於 libevent 的同步協程伺服器編程架構”。名字中 co 的意思並不代表 libco,而是 coroutine。
程式設計語言上,我選擇的是 C++,主要是因為 libco 只支援基於 x86 或 x64 架構的 Linux,而這樣的架構,基本上都是 PC 機,或者是資源不缺、效能也不錯的嵌入式系統,上 C++ 完全沒有問題。本文解釋代碼實現的原理。
如果要使用該工程,請在連結選項中加入 -lco -levent -lcoevent
三個選項。
類別關係及準系統
類別關係
類繼承關係
類的基本繼承關係圖如下:
在實際調用中,只有處於繼承關係樹的葉子結點上的類才會被實際使用到,其他類均視為虛類。
類從屬關係
各類的執行個體在程式運行中是有從屬關係的,除了作為頂層的 Base 類之外,其他樹葉類都需依附於其他的類所在的運行環境中才能執行。從屬關係圖如下:
Base 類提供最基本的運行環境,並管理 Server 對象;
Procedure 對象管理 Client 對象。在圖中體現為 Server 和 Session 對象均管理 Client 對象。
Client 對象由應用程式調用 Procedure 對象的介面建立,用於與第三方服務互動。應用程式可提前調用介面要求銷毀 Client 對象,也可以待 Procedure 服務結束時自動統一銷毀。
Base 和 Event 類
Base 類用於運行 libcoevent 的各個服務。每個 Base 類的執行個體應對應著一個線程,所有的服務以協程的方式在 Base 執行個體中運行。從可知,Base 類包含一個 libevent 庫的 event_base
對象和本協程庫的一系列 Event 對象。
Event 類其實是借用了 libevent 的 struct event
名稱,因為每一個 Event 類的執行個體,對應著 libevent 的一個 event
對象。我們需要關注的重點,是 Procedure 和 Client 類。
Procedure 類
Procedure 類有兩個關鍵特點:
每個對象都擁有一個 libco 協程,即擁有自己獨立的上下文資訊,可以用於編寫一個獨立的伺服器過程(procedure);
Procesure 的子類可以建立 Client 對象與第三方伺服器通訊和互動。
Procedure 類擁有兩個子類,分別是 Server 和 Session。
Server 類
Server 類由應用程式建立並初始化到 Base 對象中運行。Server 類有三個子類:
SubRoutine:實際上不作為任何伺服器程式,但提供了最基本的 sleep()
函數,並支援 Procedure 類的建立 Client 對象的功能,因此應用程式可以用來作為臨時建立或常駐的內部程式來使用。
UDPServer:應用程式建立並初始化 UDPServer 對象後,程式會自動綁定到一個資料報 socket 介面上。應用可以通過在網路介面中收發資料包來實現網路服務。UDPServer 同時提供普通模式和會話模式。
TCPServer:應用程式建立並初始化 TCPPServer 對象後,程式會自動綁定並監聽流 socket。TCPServer 只支援會話模式。
所謂的 “普通模式”,也就是應用程式註冊 Server 對象的入口函數,並且由應用程式操作 Server 對象的行為。
所謂的 “會話模式”,指的是 UDPServer 或 TCPServer 對象,在接收到傳入資料後,自動區分用戶端,並單獨建立 Session 對象進行處理。每個 Session 對象只服務於一個用戶端。
Session 類
Session 對象不能由應用主動建立,而是由處於會話模式的 Server 類自動按需建立。Session 對象的特點是,只能與單一一個用戶端(相比起 UDPServer 對象而言)進行通訊,因此沒有 send()
函數,只有 reply()
。
在標頭檔 coevent.h
聲明的 Session 類及其子類均為純虛類,目的是防止應用程式顯式地構建 Session 對象並隱藏實現細節。
Client 類
Client 對象由 Procedure 對象建立,並且由 Procedure 對象進行回收。Client 對象的作用是主動向遠程伺服器發起通訊。由於從客戶-服務結構的角度,這個動作屬於用戶端,所以命名為 Client。
DNSClient
Client 的子類中比較特別的是 DNSClient 類,這個類的存在是為瞭解決在非同步 I/O 中的 getaddrinfo()
阻塞問題。DNSClient 的實現原理請參見代碼和我之前的文章《DNS 報文結構和個人 DNS 解析代碼實現》。
而對於 DNSClient 類而言,具體實現原理,就是封裝了一個 UDPClient 對象,通過該對象完成 DNS 報文的收發,並在類中實現報文的解析。
UDPServer——基於 libevent 的協程實現
UDPServer 類普通模式的原理,就是一個非常典型的基於 libevent 的同步協程伺服器架構。其代碼實現中,核心功能就是以下幾個函數:
_libco_routine()
,協程的入口函數,使用這個函數,轉化成為 liboevent 的統一服務入口函數
_libevent_callback()
,libevent 時間回呼函數,在這個函數裡,實現協程內容相關的恢複。
UDPServer::recv_in_timeval()
,資料接收函數,在這個函數中,實現關鍵的資料等待功能,同時實現了協程內容相關的儲存
上述三個函數的代碼總量,加上空行也不超過 200 行,我相信還是很容易看明白的。以下具體解釋實現原理:
libco 協程介面
正如前文所說,我使用的是 libco 作為協程庫。協程對於應用程式是透明的,但是對於庫的實現而言,這才是核心。
下面解釋一下 libco 的協程功能所提供的幾個介面(libco 的文檔數量簡直 “感人”,這也是網上經常被吐槽的……):
建立和銷毀協程
Libco 使用結構體 struct stCoRoutine_t *
儲存協程,通過調用 co_create()
可以建立協程對象;使用 co_release()
銷毀協程資源。
進入協程
建立了協程之後,調用 co_resume()
可以從協程函數的開頭開始執行協程。
暫停協程
當協程到了需要交出 CPU 使用權的時候,可以調用 co_yield()
釋放協程、切換掉上下文。調用之後,上下文會恢複到上一個調用 co_resume()
的協程中。調用 co_yield()
的位置可以視為一個 “斷點”。
恢複協程
恢複協程和建立協程所用的函數都是 co_resume()
,調用該函數,將當前堆棧切換為指定協程的上下文,協程會從上文提到的 “斷點” 恢複執行。
協程調度實現
從上一小節可以看到,我們使用到的 libco 協程功能函數中,雖然包含了協程的切換函數,但什麼時候切換、切換之後 CPU 如何分配,這是我們需要實現並封裝起來的工作。
建立和銷毀協程的時機,自然就是在 UDPServer 類初始化和析構的時候。下文重點解析進入、暫停和恢複協程的操作:
進入協程
進入 / 恢複協程的代碼,是在 _libevent_callback()
中,有這麼一行:
// handle control to user applicationco_resume(arg->coroutine);
如果當前協程還沒有被執行過,那麼執行了這句代碼之後,程式會切換到建立 libco 協程時指定的協程函數開始執行。對於 UDPServer,也就是 _libco_routine()
函數。這個函數非常簡單,只有三行:
static void *_libco_routine(void *libco_arg){ struct _EventArg *arg = (struct _EventArg *)libco_arg; (arg->worker_func)(arg->fd, arg->event, arg->user_arg); return NULL;}
通過傳入參數,將 libco 回呼函數轉換為應用程式指定的伺服器函數執行。
但是如何?第一次的 libevent 回調呢?這還是很簡單的,只需要在調用 libevent 的 event_add()
時,將逾時時間設定為 0 即可,這會導致 libevent 事件立即逾時。通過這個機制,我們也就實現了在 Base 運行之後立即執行各 Procedure 服務函數的目的。
暫停和恢複協程
在什麼時候調用 co_yield
是本協程實現的重點,調用 co_yield
的位置,是一個可能會導致環境切換的地方,也是將非同步編程架構轉換為同步架構的關鍵技術點。這裡可以參照 UDPServer 的 recv_in_timeval()
函數。函數的基本邏輯如下:
其中最重要的分支,就是對 libevent 事件標誌的判斷;而最重要的邏輯,就是 event_add()
和 co_yield()
函數的調用。函數片段如下:
struct timeval timeout_copy;timeout_copy.tv_sec = timeout.tv_sec;timeout_copy.tv_usec = timeout.tv_usec; ...event_add(_event, &timeout_copy);co_yield(arg->coroutine);
這裡,我們把 co_yield()
函數理解為一個斷點,當程式執行到這裡的時候,CPU 的使用權會被交出,程式回到調用 co_resume()
的上一級函數手中。這個 “上一級函數” 究竟是哪裡呢?實際上就是前文提到的 _libevent_callback()
函數。
從 _libevent_callback()
的角度來看,程式會從 co_resume()
函數返回,並且繼續往下執行。此時我們可以這麼理解:協程的調度,實際上是借用了 libevent
來進行的。這裡我們要關注一下 co_resume()
上方的幾句:
// switch into the coroutineif (arg->libevent_what_ptr) { *(arg->libevent_what_ptr) = (uint32_t)what;}
這裡將 libevent 事件 flag 值傳遞給了協程,而這是前文進行事件判斷的重要依據。當時間到來,_libevent_callback()
會在下面調用 co_resume()
的位置,將 CPU 使用權交回給協程。
銷毀協程
除了 ci_yield()
之外,協程函數調用 return
也會導致從 co_resume()
返回,所以在 _libevent_callback()
中,我們還需要判斷協程是否已經結束。如果協程結束,那麼就應當銷毀相關的協程資源了。參見 if (is_coroutine_end(arg->coroutine)) {...}
條件體內的代碼。
會話模式(Session Mode)
在本工程的實現中,提供了被稱為 “會話模式” 的一個伺服器設計模式。會話模式指的是 UDPServer 或 TCPServer 對象,在接收到傳入資料後,自動區分用戶端,並單獨建立 Session 對象進行處理。每個 Session 對象只服務於一個用戶端。
對於 TCPServer 而言,實現上述的功能比較簡單,因為監聽一個 TCP socket 之後,當有傳入串連的時候,只要調用 accept()
,就可以獲得一個新的檔案描述符,為這個檔案描述符建立一個新的 Server 的子類就行了——這就是 TCPSession 類。
但是 UDPServer 就比較麻煩了,因為 UDP 不能這麼做。我們只能自行實現所謂的 session。
UDPSession 實現
設計目標
我們需要實現 UDPSession 類的如下效果:
recv()
在工程中,UDPSession 是抽象類別,實際實現是 UDPItnlSession。但是準確而言,UDPItnlSession 的實現,密切依賴於 UDPServer。這一部分,可以參照 UDPServer 的 _session_mode_worker()
函數中的 do-while()
迴圈體代碼。程式思路如下:
複製資料的代碼,參見 UDPItnlSession 類的 forward_incoming_data()
函數實現。
reply()
發送資料其實就很簡單,直接對 UDPServer 的 fd 進行 sendto()
就可以了。
quit
對於 session mode 的 Server 對象,代碼中提供了一個可以由其 session 調用的、要求 server 退出並銷毀資源的函數:quit_session_mode_server()
。實現原理是向 server 觸發一個 EV_SIGNAL
事件。對於普通的 I/O 事件而言,這是不應當出現的,我們這裡活用來作為退出訊號。如果 server 發現了這個訊號,則觸發退出邏輯。
應用樣本
本工程的範例程式碼分為 server 和 client 兩部分,其中 server 用到了 libcoevent,而 client 只是使用 Python 寫的簡單程式。本文就不說明 client 部分的代碼了。
Server 的代碼,分別針對 Server 類的三個子類做了應用樣本。使用了包括空行、調試語句、錯誤判斷等在內的邏輯,僅使用不到 300 行,就實現了一個過程和兩個服務。應該說,邏輯還是很清晰的,而且也節省了大量代碼。
SubRoutine
通過函數 _simple_test_routine()
,展示了一個一次性的線性網路邏輯。程式中,routine 首先建立了一個 DNSClient 對象,向預設網域名稱伺服器請求了一個網域名稱,然後 connect()
該伺服器的 80 連接埠。成功後,直接返回。
這個函數展示了 SubRoutine 的使用情境,以及 Client 對象的使用方法,特別是 DNSClient 的簡易使用方法。
UDPServer
UDPServer 的入口函數是 _udp_session_routine()
,功能是為用戶端提供網域名稱查詢服務。Clients 發送一段字串作為待查詢網域名稱,然後 server 通過 DNSClient 對象請求後,將查詢結果返回給用戶端。
這個函數展示了 UDPSession 對象和 DNSClient 的(比較複雜和完整的)使用方法。
TCPServer
入口函數是 _tcp_session_routine()
,邏輯比較簡單,主要是展示 TCPSession 的用法。
後記
原理上,libcoevent 已經開發完了,實現了必須的功能,完全可以用來編寫伺服器程式。當然由於這是初版,所以很多代碼看起來還是有點亂。這個庫的意義在於,可以從教學角度,仔細地說明 C/C++ 協程更為本源的實現原理,也可以作為一個可用的協程伺服器庫來使用。
歡迎讀者針對這個庫多多批判,也歡迎讀者提出新需求——比如我就決定加幾個需求,算是 TODO 吧:
實現 HTTPServer,作為 TCPServer 的子類,提供 HTTP fcgi 服務;
實現 SSLClient 的類,處理對外的 SSL 請求。
相關文章:
C#網路編程系列文章(八)之UdpClient實現同步UDP伺服器
C語言實現php伺服器
相關視頻:
C# 教程