協程 及 Libco 介紹
libco 是騰訊開源的一個協程庫,主要應用於微信後臺RPC框架,下面我們從為什麼使用協程、如何實現協程、libco使用等方面瞭解協程和libco。
why協程
為什麼使用協程,我們先從server框架的實現說起,對於client-server的架構,server最簡單的實現:
while(1) {accept();recv();do();send();}
序列地接收連線、讀取請求、處理、應答,該實現弊端顯而易見,server同一時間只能為一個客戶端服務。
為充分利用好多核cpu進行任務處理,我們有了多程序/多執行緒的server框架,這也是server最常用的實現方式:
accept程序 - n個epoll程序 - n個worker程序
- accpet程序處理到來的連線,並將fd交給各個epoll程序
- epoll程序對各fd設定監控事件,當事件觸發時通過共享記憶體等方式,將請求傳給各個worker程序
- worker程序負責具體的業務邏輯處理並回包應答
以上框架以事件監聽、程序池的方式,解決了多工處理問題,但我們還可以對其作進一步的優化。
程序/執行緒是Linux核心最小的排程單位,一個程序在進行io操作時 (常見於分散式系統中RPC遠端呼叫),其所在的cpu也處於iowait狀態。直到後端svr返回,或者該程序的時間片用完、程序被切換到就緒態。是否可以把原本用於iowait的cpu時間片利用起來,發生io操作時讓cpu處理新的請求,以提高單核cpu的使用率?
協程在使用者態下完成切換,由程式設計師完成排程,結合對socket類/io操作類函式掛鉤子、新增事件監聽,為以上問題提供瞭解決方法。
使用者態下上下文切換
Linux提供了介面用於使用者態下儲存程序上下文資訊,這也是實現協程的基礎:
- getcontext(ucontext_t *ucp): 獲取當前程序/執行緒上下文資訊,儲存到ucp中
- makecontext(ucontext_t *ucp, void (*func)(), int argc, ...): 將func關聯到上下文ucp
- setcontext(const ucontext_t *ucp): 將上下文設定為ucp
- swapcontext(ucontext_t *oucp, ucontext_t *ucp): 進行上下文切換,將當前上下文儲存到oucp中,切換到ucp
以上函式與儲存上下文的 ucontext_t 結構都在 ucontext.h 中定義,ucontext_t 結構中,我們主要關心兩個欄位:
- struct ucontext *uc_link: 協程後繼上下文
- stack_t uc_stack: 儲存協程資料的棧空間
stack_t 結構用於儲存協程資料,該空間需要事先分配,我們主要關注該結構中的以下兩個欄位:
- void __user *ss_sp: 棧頭指標
- size_t ss_size: 棧大小
獲取程序上下文並切換的方法,總結有以下幾步:
- 呼叫 getcontext(),獲取當前上下文
- 預分配棧空間,設定 xxx.uc_stack.ss_sp 和 xxx.uc_stack.ss_size 的值
- 設定後繼上下文環境,即設定 xxx.uc_link 的值
- 呼叫 makecontext(),變更上下文環境
- 呼叫 swapcontext(),完成跳轉
Socket族函式/io非同步處理
當程序使用socket族函式 (connect/send/recv等)、io函式 (read/write等),我們使用協程切換任務前,需對相應的fd設定監聽事件,以便io完成後原有邏輯繼續執行。
對io函式,我們可以事先設定鉤子,在真正呼叫介面前,對相應fd設定事件監聽。同樣,Linux為我們設定鉤子提供了介面,以read()函式為例:
- 編寫名字為 read() 的函式,該函式先對fd呼叫epoll函式設定事件監聽
- read() 中使用dlsym(),呼叫真正的 read()
- 將編寫好的檔案打包,編譯成庫檔案:gcc -shared -Idl -fPIC prog2.c -o libprog2.so
- 執行程式時引用以上庫檔案:LD_PRELOAD=/home/qspace/lib/libprog2.so ./prog
當在prog程式中呼叫 read() 時,使用的就是我們實現的 read() 函式。
libco
有了以上準備工作,我們可以構建這樣的server框架:
accept程序 - epoll程序(n個epoll協程) - n個worker程序(每個worker程序n個worker協程)
該框架下,接收請求、業務邏輯處理、應答都可以看做單獨的任務,相應的epoll、worker協程事先分配,服務流程如下:
- mainloop主迴圈,負責 i/監聽請求事件,有請求則拉起一個worker協程處理;ii/如果timeout時間內沒有請求,則處理就緒協程(即io操作已返回)
- worker協程,如果遇到io操作則掛起,對fd加監聽事件,讓出cpu
libco 提供了以下介面:
- co_create: 建立協程,可在程式啟動時建立各任務協程
- co_yield: 協程主動讓出cpu,調io操作函式後呼叫
- co_resume: io操作完成後(觸發相應監聽事件)呼叫,使協程繼續往下執行
socket族函式(socket/connect/sendto/recv/recvfrom等)、io函式(read/write) 在libco的co_hook_sys_call.cpp中已經重寫,以read為例:
ssize_t read( int fd, void *buf, size_t nbyte ) { struct pollfd pf = { 0 }; pf.fd = fd; pf.events = ( POLLIN | POLLERR | POLLHUP ); int pollret = poll( &pf,1,timeout ); /*對相應fd設定監聽事件*/ ssize_t readret = g_sys_read_func( fd,(char*)buf ,nbyte ); /*真正呼叫read()*/ return readret; }
小結
由最簡單的單任務處理,到多程序/多執行緒(並行),再到協程(非同步),server在不斷地往極致方向優化,以更好地利用硬體效能的提升(多核cpu的出現、單核cpu效能不斷提升)。
對程式設計師而言,可時常檢視自己的程式,是否做好並行與非同步,在硬體效能提升時,程式服務能力可不可以有相應比例的提升。