本篇文章,是 對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 物件。
Server 物件由應用程式建立並初始化到 Base 物件中運行。當伺服器結束或當其從屬的 Base 物件銷毀時,可設定自動銷毀 Server 物件。
Session 物件由處於會話模式(session mode)的Server 物件自動創建,並呼叫應用程式指定的程式入口執行;當會話結束時(函數呼叫return
)或其從屬的Server 物件服務結束時,由Server 物件自動銷毀。
Client 物件由應用程式呼叫 Procedure 物件的介面創建,用於與第三方服務互動。應用程式可提前呼叫介面要求銷毀 Client 對象,也可以待 Procedure 服務結束時自動統一銷毀。
#Base 類別用於執行 libcoevent 的各個服務。每個 Base 類別的實例應對應著一個線程,所有的服務以協程的方式在 Base 實例中運行。從上圖可知,Base 類別包含一個 libevent 函式庫的 event_base
物件和本協程函式庫的一系列 Event 物件。
Event 類別其實是藉用了libevent 的struct event
名稱,因為每一個Event 類的實例,對應libevent 的一個event
物件。我們需要專注的重點,是 Procedure 和 Client 類別。
Procedure 類別有兩個關鍵特點:
每個物件都有一個libco 協程,即擁有自己獨立的上下文訊息,可以用於編寫一個獨立的伺服器過程(procedure);
#Procesure 的子類別可以建立Client 物件與第三方伺服器通信和互動。
Procedure 類別擁有兩個子類,分別是 Server 和 Session。
Server 類別由應用程式建立並初始化到 Base 物件中運行。 Server 類別有三個子類別:
SubRoutine:實際上不作為任何伺服器程序,但提供了最基本的sleep()
函數,並支援Procedure 類別的建立Client 對象的功能,因此應用程式可以用來作為臨時創建或常駐的內部程式來使用。
UDPServer:應用程式建立並初始化 UDPServer 物件後,程式會自動綁定到一個資料封包 socket 介面上。應用程式可以透過在網路介面中收發封包來實現網路服務。 UDPServer 同時提供普通模式和會話模式。
TCPServer:應用程式建立並初始化 TCPPServer 物件後,程式會自動綁定並監聽流 socket。 TCPServer 只支援會話模式。
所謂的 “普通模式”,也就是應用程式註冊 Server 物件的入口函數,並且由應用程式操作 Server 物件的行為。
所謂的“會話模式”,指的是UDPServer 或TCPServer 對象,在接收傳入資料後,自動區分客戶端,並單獨建立Session對象進行處理。每個 Session 物件只服務一個客戶端。
Session 物件不能由應用程式主動創建,而是由處於會話模式的 Server 類別自動按需建立。 Session 物件的特點是,只能與單一一個客戶端(相較於 UDPServer 物件而言)進行通信,因此沒有 send()
函數,只有 reply()
。
在頭檔coevent.h
宣告的Session 類別及其子類別皆為純虛類,目的是防止應用程式明確地建構 Session 物件並隱藏實作細節。
Client 物件由 Procedure 物件創建,並由 Procedure 物件進行回收。 Client 物件的作用是主動向遠端伺服器發起通訊。由於從客戶-服務結構的角度,這個動作屬於客戶端,所以命名為 Client。
Client 的子類別中比較特別的是DNSClient 類,這個類別的存在是為了解決在異步I/O 中的getaddrinfo( )
阻塞問題。 DNSClient 的實作原則請參考程式碼和我之前的文章《DNS 封包結構與個人 DNS 解析程式碼實作》。
而對於 DNSClient 類別而言,具體實作原理,就是封裝了一個 UDPClient 對象,透過該對象完成 DNS 封包的收發,並在類別中實作封包的解析。
UDPServer 類別普通模式的原理,就是一個非常典型的基於 libevent 的同步協程伺服器框架。在其程式碼實作中,核心功能就是以下幾個函數:
_libco_routine()
,協程的入口函數,使用這個函數,轉換成為liboevent 的統一服務入口函數
_libevent_callback()
,libevent 時間回呼函數,在這個函式裡,實作協程上下文的恢復。
UDPServer::recv_in_timeval()
,資料接收函數,在這個函數中,實作關鍵的資料等待功能,同時實作了協程上下文的保存
上述三個函數的程式碼總量,加上空白行也不超過200 行,我相信還是很容易看懂的。以下具體解釋實作原理:
#如前文所說,我使用的是 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 application co_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 coroutine if (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)) {...}
條件體內的程式碼。
在本工程的實作中,提供了被稱為 「會話模式」 的一個伺服器設計模式。會話模式指的是 UDPServer 或 TCPServer 對象,在接收到傳入資料後,自動區分客戶端,並單獨建立 Session 物件進行處理。每個 Session 物件只服務一個客戶端。
對於TCPServer 而言,實作上述的功能比較簡單,因為監聽一個TCP socket 之後,當有傳入連線的時候,只要呼叫accept()
,就可以取得一個新的檔案描述符,為這個檔案描述符建立一個新的Server 的子類別就行了——這就是TCPSession 類別。
但 UDPServer 就比較麻煩了,因為 UDP 不能這麼做。我們只能自行實作所謂的 session。
我們需要實作UDPSession 類別的以下效果:
類當呼叫recv 函數時,只會接收到對應的遠端客戶端發送的資料
類別呼叫send 函數(實際實作是reply()
)時,可以使用UDPServer 的連接埠來回覆
在工程中,UDPSession 是抽象類,實際實作是 UDPItnlSession。但準確而言,UDPItnlSession 的實現,密切依賴 UDPServer。這一部分,可以參考 UDPServer 的 _session_mode_worker()
函數中的 do-while()
迴圈體程式碼。程式想法如下:
UDPServer 維護一個 UDPSession 字典,以遠端 IP 連接埠名稱的組合作為 key。
當資料到來時,判斷遠端IP 連接埠的組合是否在字典中,如果在,那麼就把資料複製給對應的session;如果不存在,則建立session
複製資料的程式碼,請參閱UDPItnlSession 類別的forward_incoming_data()
函數實作。
發送資料其實就很簡單,直接對 UDPServer 的 fd 進行 sendto()
就可以了。
對於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 行,就實作了一個流程和兩個服務。應該說,邏輯還是很清楚的,而且也節省了大量程式碼。
透過函數 _simple_test_routine()
,展示了一個一次性的線性網路邏輯。程式中,routine 首先建立了一個 DNSClient 對象,向預設域名伺服器請求了一個域名,然後 connect()
該伺服器的 80 埠。成功後,直接返回。
這個函數展示了 SubRoutine 的使用場景,以及 Client 物件的使用方法,特別是 DNSClient 的簡易使用方法。
UDPServer 的入口函數是 _udp_session_routine()
,功能是為客戶端提供網域查詢服務。 Clients 傳送一段字串作為待查詢域名,然後 server 透過 DNSClient 物件請求後,將查詢結果傳回給客戶端。
這個函數展示了 UDPSession 物件和 DNSClient 的(比較複雜和完整的)使用方法。
入口函數是 _tcp_session_routine()
,邏輯比較簡單,主要是展示 TCPSession 的用法。
原理上,libcoevent 已經開發了,實作了必須的功能,完全可以用來寫伺服器程式。當然由於這是初版,所以很多程式碼看起來還是有點亂。這個函式庫的意義在於,可以從教學角度,仔細地說明 C/C 協程更為本源的實作原理,也可以作為一個可用的協程伺服器函式庫來使用。
歡迎讀者針對這個函式庫多多批判,也歡迎讀者提出新需求-例如我就決定加幾個需求,算是TODO 吧:
實作HTTPServer,作為TCPServer 的子類,提供HTTP fcgi 服務;
實作SSLClient 的類,處理對外的SSL請求。
相關文章:
C#網頁程式設計系列文章(八)之UdpClient實作同步UDP伺服器
相關影片:
以上是基於彙編的 C/C++ 協程(用於伺服器)的實現的詳細內容。更多資訊請關注PHP中文網其他相關文章!