日韩性视频-久久久蜜桃-www中文字幕-在线中文字幕av-亚洲欧美一区二区三区四区-撸久久-香蕉视频一区-久久无码精品丰满人妻-国产高潮av-激情福利社-日韩av网址大全-国产精品久久999-日本五十路在线-性欧美在线-久久99精品波多结衣一区-男女午夜免费视频-黑人极品ⅴideos精品欧美棵-人人妻人人澡人人爽精品欧美一区-日韩一区在线看-欧美a级在线免费观看

歡迎訪問 生活随笔!

生活随笔

當前位置: 首頁 > 编程资源 > 编程问答 >内容正文

编程问答

万字长文 | 漫谈libco协程设计及实现

發布時間:2024/2/28 编程问答 40 豆豆
生活随笔 收集整理的這篇文章主要介紹了 万字长文 | 漫谈libco协程设计及实现 小編覺得挺不錯的,現在分享給大家,幫大家做個參考.




libco簡介

libco是微信后臺大規模使用的c/c++協程庫,2013年至今穩定運行在微信后臺的數萬臺機器上,使得微信后端服務能同時hold大量請求,被譽為微信服務器穩定性的基石。libco在2013年的時候作為騰訊六大開源項目首次開源。libco源碼地址。


libco首先能解決CPU利用率與IO利用率不平衡,比用多線程解決IO阻塞CPU問題更高效。因為用戶態協程切換比線程切換性能高:線程切換保存恢復的數據更多,需要用戶態和內核態切換。其次libco又避免了異步調用和回調分離導致的代碼結構破碎。


libco采用epoll多路復用使得一個線程處理多個socket連接,采用鉤子函數hook住socket族函數,采用時間輪盤處理等待超時事件,采用協程棧保存、恢復每個協程上下文環境。


為了讓大家更容易閱讀libco源碼,本文以源碼為主介紹libco,內容偏底層細節。更多個人文章,歡迎關注作者博客。


設計思想

1.?協程切換

1.1?函數棧

首先復習下進程的地址空間,如圖1所示,與本文相關的有代碼段、堆、棧。代碼段包含應用程序的匯編代碼,指令寄存器eip存的是代碼段中某一條匯編指令地址,cpu從eip中取出匯編指令的地址,并在代碼段中找到對應匯編指令開始執行。CPU執行指令時在棧里存參數、局部變量等數據。代碼通過malloc、new在堆上申請內存空間。

圖1


圖2所示C代碼,通過gcc -m32 test.c -o test.o在i386下編譯,然后執行gdb test.o。disas main可看到圖3所示的main函數匯編碼,disas sum可看到圖4所示sum函數的匯編代碼。調用sum時,main和sum的函數棧如圖5所示。圖5的表共有兩列,第一列為內存地址,第二列為該地址存的內容,除了用“...”省略的內存地址,其他每一行均比上一行低4byte,因為棧地址從高到低增長。


從圖5可以看出:一,每個函數的棧在ebp棧底指針和esp棧頂指針之間;二,存在調用關系的兩個函數的棧內存地址是相鄰的;三,ebp指針指的位置存儲的是上級函數的ebp地址,例如sum的ebp 0xffffd598位置存的是main的ebp0xffffd5c8,目的是sum執行后可恢復main的ebp,而main的esp可通過sum的ebp + 4恢復;四,sum的ebp + 4位置,即main的esp位置存的是sum執行后的返回地址0x08048415,該地址不在圖1中的棧(Stack)里,而在圖1中的代碼段里,sum執行后,leave指令恢復ebp、esp,ret指令將esp處的內容0x08048415放到寄存器eip,cpu從eip里取出下一條待執行的指令地址,并根據指令地址從代碼段里獲取指令執行;五,sum的參數y、x按高地址到低地址,依次存在sum的ebp + 12、ebp + 8位置處。


圖2


圖3 main函數匯編碼


圖4 sum函數匯編碼


圖5 32位系統函數棧


1.2?協程棧

共享棧下文介紹,此處介紹非共享棧。在非共享棧模式下,每個非主協程有自己的棧,而該棧是在堆上分配的,并不是系統棧,但主協程的棧仍然是系統棧,每兩個協程的棧地址不相鄰。協程棧切換分為第1次、第k次(k>=2)換到目的協程TargetCoroutine。


因為主協程即當前線程的第1次運行是系統調度的,后續才由用戶調度,而非主協程每次都由用戶調度。所以每次主協程切回的行為都一樣,且和非主協程第k次(k>=2)的切回行為一致。


第1次切到TargetCoroutine之前, coctx_make(圖6)將函數地址pfn寫入協程變量regs[ kEIP ],pfn即為CoRoutineFunc的指針。CoRoutineFunc函數(圖7)在第448行調進用戶自定義的協程函數UserCoRoutineFunc(圖8)。圖6中ss_sp為128K協程棧低地址,ss_size為128K將ss_sp+ss_size – sizeof(coctx_param_t)–sizeof(void*)作為esp開始位置,記錄在regs[kESP]。因為棧從高到低增長,所以真正的棧空間從高地址ss_sp + ss_size?– sizeof(void*) – sizeof(coctx_param_t)增長到低地址ss_sp。這部分空間雖然是協程棧,但實際是通過stack_mem->stack_buffer= (char*)malloc(stack_size)申請的堆空間。CoRoutineFunc、其調用的函數、其調用的函數再調用的函數…的函數棧均在該128K的堆空間里。

圖6


圖7


圖8

?

第1次切換到TargetCoroutine時。圖9第50行將esp指向TargetCoroutine.coctx_t.regs,此時esp指向的地址如圖10所示。第54行從regs[0]彈出返回地址,即CoRoutineFunc函數地址0x08043212。第65行彈出esp地址即ss_sp+ss_size–sizeof(coctx_param_t)–sizeof(void*)。第67行pushl %eax做兩件事情:

一,將esp地址減4,即esp = ss_sp + ss_size – sizeof(coctx_param_t) – sizeof(void*) – 4;

二,在esp新位置壓入CoRoutineFunc函數地址。第71行ret從esp取出CoRoutineFunc函數地址放入eip寄存器,cpu從eip寄存器取出CoRoutineFunc函數第一條指令地址開始執行指令,CoRoutineFunc函數第一條指令的地址就是CoRoutineFunc函數地址。


圖6中沒有對regs[kEBP]賦初值,因此切到TargetCoroutine時彈出的ebp是0,導致CoRoutineFunc函數棧存的上級函數的ebp是0,但沒有影響。CoRoutineFunc(圖7)只能執行到第454行:co_yield_env(env),然后切到其他協程,不會執行到456行。上級函數的ebp是在CoRoutineFunc執行后,用于恢復上級函數的esp,但在這里CoRoutineFunc函數在return 0之前已經切到其他協程,因此上級函數的ebp是0不會導致錯誤。

圖9


圖10?第1次切到TargetCoroutine


TargetCoroutine在第k-1次被coctx_swap切出時,TargetCoroutine是圖2.3.2.4的源協程curr。切出TargetCoroutine時會調進coctx_swap,在執行圖9第34行之前,函數棧如圖11所示。然后將co_swap(注意不是coctx_swap,因為這里沒有像圖4的第3、4、5行修改ebp和esp)棧頂地址+4即0xffd2dc14通過第38行寫入到regs[kESP]。將co_swap(不是coctx_swap,原因同上)棧底地址ebp即0xffd2dc3c通過第40行寫入regs[kEBP]。將stCoRoutineEnv_t* curr_env = co_get_curr_thread_env()的第一條匯編指令地址0x08043212通過第47行寫入regs[ kEIP ]。此時regs數組內容如圖12所示,在此期間esp的變化如圖12左側所示。

圖11 co_swap函數棧


圖12?第k(k>=2)次切到TargetCoroutine


第k(k>=2)次切回TargetCoroutine時,圖9第54行彈出eax,即stCoRoutineEnv_t* curr_env = co_get_curr_thread_env()的第一條匯編指令地址0x08043212。第61行彈出ebp即圖2.3.2.6所示的0xffd2dc3c。圖2.3.2.4第65行彈出esp,即圖11所示的0xffd2dc14(不是0xffd2dc10,因為圖2.3.2.4第38行的壓入的eax是0xffd2dc10 + 4)。第67行做兩件事情,首先esp減4得到切出時的棧地址0xffd2dc10,再將eax存的匯編指令地址0x08043212寫到esp即0xffd2dc10處。最后ret從esp取出匯編指令地址0x08043212放入eip寄存器,cpu從eip寄存器取出指令地址開始執行指令。因為TargetCoroutine切回時,首先執行 stCoRoutineEnv_t*? curr_env=co_get_curr_thread_env(),而TargetCoroutine在之前切出時,最后執行的代碼是coctx_swap(&(curr->ctx),&(pending_co->ctx) ),因此協程在切回后能接著之前切出時的代碼繼續執行。


對比圖10和12發現:第1次切到TargetCoroutine時ebp、esp、返回地址、以及其他寄存器和第k(k>=2)次均不同。


libco在stCoRoutineEnv_t定義了pCallStack數組,大小為128,數組里的每個元素均為協程。pCallStack用于獲取當前協程pCallStack[iCallStackSize - 1];獲取當前協程掛起后該切到的協程pCallStack[iCallStackSize - 2]。pCallStack存的是遞歸調用(暫且稱之為遞歸,并不是遞歸)的協程,pCallStack[0]一定是主協程。例如主協程調用協程1,協程1調用協程2...協程k-1調用協程k,這種遞歸關系的k最大為127,調到協程127時,此時pCallStack[0]存主協程,pCallStack[1]存協程1...pCallStack[k]存協程k..pCallStack[127]存協程127。但遞歸如此之深的協程實際中不會遇到,更多的場景應該是主協程調用協程1,協程1掛起切回主協程,主協程再調用協程2,協程2掛起切回主協程,主協程再調用協程3...因此主協程調到協程k時,pCallStack[0]是主協程,pCallStack[1]是協程k,其他元素為空;協程k掛起切回主協程時,pCallStack[0]是主協程,其他元素為空。因此128大小的pCallStack足夠上萬甚至更多協程使用。


1.3?主協程

主協程即為當前線程,用stCoRoutine_t. cIsMain標記。主協程的棧在圖1所示的棧(Stack)區,而其他協程的棧在圖1所示的堆(Heap)區。圖9中切出主協程時38-47行寄存器存在regs數組里(不在128K的協程棧里,另外申請的堆空間)。切回主協程時,第61、65行彈出的ebp、esp指向的是系統棧里的內存,因此主協程的棧始終在系統棧上,用不到128K的協程棧。


那是否有必要將當前協程標記為主協程?圖2.1.3.1的第1024、1033行是代碼僅有的兩處需要判斷主協程。如果聲明了協程私有變量,但沒有創建其他協程時,co為NULL,此時通過1026行獲取主協程私有變量。但在程序運行到某段代碼時開始創建協程,如果不標記主協程,因為co不為NULL,代碼會通過第1028行獲取主協程私有變量,此時因為拿不到以前設置的主協程私有變量而導致錯誤。例如若將圖2.1.3.1第1024、1033行的co->cIsMain條件刪掉,圖2.1.3.2第24行輸出的主協程私有變量為11,而第30行輸出0。

圖13


圖14

?

1.4?共享棧

每個協程申請一個固定128K的棧,在協程數量很大時,存在內存壓力。因此libco引入共享棧模式,示例代碼可參看example_copystack.cpp。共享棧對主協程沒有影響,共享棧仍然是在堆上,而主協程的棧在系統棧上。


采用共享棧時,每個協程的棧從共享棧拷出時,需要分配空間存儲,但按需分配空間。因為絕大部分協程的棧空間都遠低于128K,因此拷出時只需分配很小的空間,相比私有棧能節省大量內存。共享棧可以只開一個,但為了避免頻繁換入換出,一般開多個共享棧。每個共享棧可以申請大空間,降低棧溢出的風險。


假設開10個共享棧,每個協程模10映射到對應的共享棧。假設協程調用順序為主協程、協程2、協程3、協程12。協程2切到協程3時,因為協程2、3使用的共享棧分別是第2、3個共享棧,沒有沖突,所以協程2的棧內容仍然保留在第2個共享棧,并不拷出來,但協程2的寄存器會被coctx_swap保存在regs數組。調用到協程12時,協程12和協程2都映射到第2個共享棧,因此需要將協程2的棧內容拷出,并將協程12的棧內容拷貝到第2個共享棧中。所以共享棧多了拷出協程2的棧、拷進協程12的棧兩個操作,即用拷貝共享棧的時間換取每個協程棧的空間。


圖15在609行將curr的stack_sp指向c的地址,記錄當前協程棧的低位置,當前協程棧的高位置是ss_sp + ss_size – sizeof(coctx_param_t) – sizeof(void*),存的是CoRoutineFunc函數第一條匯編指令。curr協程拷出協程時,需要拷貝從curr->stack_sp(即&c)到ss_sp + ss_size?–?sizeof(void*) – sizeof(coctx_param_t)的棧內容。以后從其他協程再切換回curr協程時,如果共享棧里有curr協程,則只通過coctx_swap恢復寄存器即可;否則如圖15第657行所示,需要將curr保存在curr->save_buffer的協程棧復制到從curr->stack_sp到ss_sp + ss_size?– sizeof(void*) – sizeof(coctx_param_t)的內存空間。


圖15從curr協程切到pending_co協程時,如果是共享棧模式,先拿到pending_co的共享棧stack_mem里已有的協程occupy_co,如果occupy_co非空且不是pending_co,則保存已有的協程save_stack_buffer(occupy_co),將stack_mem指向的協程換為pending_co。并將pending_co和occupy_co均保存在env里,不能用局部變量記錄,因為局部變量在coctx_swap之前屬于curr協程,但coctx_swap后協程棧已經被切換,curr的所有局部變量無法被pending_co訪問。如果occupy_co和pending_co不是同一個協程,需要將occupy_co在共享棧里的數據拷貝到occupy_co->save_buffer。協程的數據除了在棧里還分布在寄存器,如果occupy_co不是curr,則在occupy_co之前被切換到其他協程時寄存器已經被coctx_swap保存;否則,則其寄存器在本次執行coctx_swap被保存。

圖15


1.5?線程切換VS?協程切換

IO密集時也可以使用多個線程。但線程有兩個不足:一,切換代價大(保存恢復上下文、線程調度);二,占用資源多。


線程往往需要對公共數據加鎖,鎖會導致線程調度。因為用戶的線程是在用戶態執行,而線程調度和管理是在內核態實現,所以線程調度需要從用戶態轉到內核態,再從內核態轉到用戶態。切到內核態時需要保存用戶態上下文,再切到用戶態時,需要恢復用戶態上下文,而線程的用戶態上下文比協程上下文大得多。另外線程調度也需要耗時。


線程棧默認為1M大于協程棧128K,另外線程還需要各種struct存一些狀態,實測每個pthread_create創建的線程大約占8M左右內存,因此線程占用資源也遠比協程多。

?

2.?協程控制

2.1 epoll多路復用IO模型

協程使用epoll多路復用IO模型。常見的同步調用是同步阻塞模型,異步調用是異步IO模型。以read為例說明以下三種IO模型的區別,read分為兩個階段:一,等待數據;二,將數據從kernel拷貝到用戶線程。


同步調用read使用同步阻塞IO,kernel等待數據到達,再將數據拷貝到用戶線程,這兩個階段用戶線程都被阻塞。異步調用read使用異步IO模型,用戶線程調用read后,立刻可以去做其它事, kernel等待數據準備完成,然后將數據拷貝到用戶內存,都完成后,kernel通知用戶read完成,然后用戶線程直接使用數據,兩個階段用戶線程都不被阻塞。而協程調用read使用多路復用IO模型,用戶線程調用read后,第一階段也不會被阻塞,但第二個階段會被阻塞,epoll多路復用IO模型可以在一個線程管理多個socket。


同步調用在兩個階段都會阻塞用戶線程,因此效率低。雖然可以為每個連接開個線程,但連接數多時,線程太多導致性能壓力,也可以開固定數目的線程池,但如果存在大量長連接,線程資源不被釋放,后續的連接得不到處理。異步調用時,因為兩個階段都不阻塞用戶線程,因此效率最高,但異步的調用邏輯和回調邏輯需要分開,在異步調用多時,代碼結構不清晰。而協程的epoll多路復用IO模型,雖然會阻塞第二個階段,但因為第二階段讀數據耗時很少,因此效率略低于異步調用。協程最大的優點是在接近異步效率的同時,可以使用同步的寫法(僅僅是同步的寫法,不是同步調用)。例如read函數的調用代碼后,緊接著可以寫處理數據的邏輯,不用再定義回調函數。調用read后協程掛起,其他協程被調用,數據就緒后在read后面處理數據。


系統select/poll、epoll函數都提供多路IO復用方案,協程使用的是epoll。select、poll類似,監視writefds、readfds、exceptfds文件描述符(fd)。調用select/poll會阻塞,直到有fd就緒(可讀、可寫、有except),或超時。select/poll返回后,通過遍歷fd集合,找到就緒的fs,并開始讀寫。poll用鏈表存儲fd,而select用fd標記位存儲,因此poll沒有最大連接數限制,而select限制為1024。select/poll共有的缺點是:一,返回后需要遍歷fd集合找到就緒的fd,但fd集合就緒的描述符很少;二,select/poll均需將fd集合在用戶態和內核態之間來回拷貝。epoll的引入是為了解決select/poll上述兩個缺點,epoll提供三個函數epoll_create、epoll_ctl、epoll_wait。epoll_create在內核的高速cache中建一棵紅黑樹以及就緒鏈表(activeList)。epoll_ctl的add在紅黑樹上添加fd結點,并且注冊fd的回調函數,內核在檢測到某fd就緒時會調用回調函數將fd添加到activeList。epoll_wait將activeList中的fd返回。epoll_ctl每次只往內核添加紅黑樹節點,不需像select/poll拷貝所有fd到內核,epoll_wait通過共享內存從內核傳遞就緒fd到用戶,不需像select/poll拷貝出所有fd并遍歷所有fd找到就緒的。


2.2?非阻塞IO

協程的epoll多路復用IO模型使用的是非阻塞IO,發起read操作后,可立即掛起協程,并調度其他協程。非阻塞IO是通過fcntl函數設置O_NONBLOCK,影響socket族read、write、connect、sendto、recvfrom、send、recv函數。因為read、recv、recvfrom實現類似,write、send實現類似。因此接下來介紹read、write、connect三個函數。


2.2.1 鉤子函數read

如圖16所示,用戶自定義的read函數hook住系統read函數。Read時分三種情況:一,用戶未開啟hook,直接在337行調用系統read;二,如果用戶指定了O_NONBLOCK,也直接在343行調用系統read,此時是非阻塞的;三,如果用戶既開啟了hook,又沒有指定O_NONBLOCK,如果libco不做任何處理,即使通過353行poll了,但如果協程是超時返回,第355行還是會被阻塞。所以只要用戶開啟hook,socket函數(圖17)會在234行調用fcntl函數,最終調用圖18的第696行,悄悄的設置O_NONBLOCK,但user_flag并沒有記錄O_NONBLOCK,這樣即可和第二種情況區分。然后圖16第353行調用poll將當前協程掛起,直到有數據可讀或者超時,協程才會重新調度。在第二種情況下,不能先將協程掛起,等待就緒后再切回,因為用戶顯示的設置O_NONBLOCK是為了立即返回,如果掛起,就緒或超時后再切回,與用戶需要立即獲得返回結果的初衷違背。

圖16


圖17


圖18


2.2.2 鉤子函數write

非阻塞write在發送緩沖區沒有空間時直接返回,發送緩沖區有空間時,則拷貝全部或部分(空間不夠)數據,返回實際拷貝的字節數。


如圖19所示,分三種情況:一,用戶未開啟hook,在372行調用系統write;二,如果用戶指定了O_NONBLOCK,在378行調用系統write,此時是非阻塞的,這種情況與第三種情況區分的原因見2.2.2.1;三,如果用戶既開啟了hook,又沒有指定O_NONBLOCK,由2.2.2.1可知,libco已悄悄設置O_NONBLOCK,只要數據沒有完全寫入緩沖區,就通過第402行poll將協程掛起,等待有空余空間喚醒協程或者超時喚醒。writeret記錄本次調用寫入的字節數,wrotelen記錄總共寫入的字節數,如果本次寫入沒有空間或出錯,則直接返回。因為write明確知道要寫入數據的長度nbyte,而一次可能無法寫入全部數據,所以write在while循環里不斷寫數據,直到數據寫完、寫出錯,才會停止寫數據。而2.2.2.1的read不知要讀多少數據,因此讀一次就返回。

圖19


2.2.3 鉤子函數connect

圖20所示為libco自定義connect函數片段。如果用戶啟用hook,且未設置O_NONBLOCK,libco悄悄幫用戶設置了O_NONBLOCK,但調用connect后不能立即返回,因為connect有三次握手的過程,內核中對三次握手的超時限制是75秒,超時則會失敗。libco設置O_NONBLOCK后,立即調用系統connect可能會失敗,因此在圖20中循環三次,每次設置超時時間25秒,然后掛起協程,等待connect成功或超時。

圖20


2.3 epoll激活協程

協程hook住了底層socket族函數,設置了O_NONBLOCK,調用socket族函數后,調用poll注冊epoll事件并掛起協程,讓其他協程執行,所有協程都掛起后通過epoll,在主協程里檢查注冊的IO事件,若就緒或超時則切到對應協程。


?poll最終會調進co_poll_inner,圖21、圖22分別為co_poll_inner函數上、下部分。該函數的參數ctx為epoll的環境變量,包含:epoll描述符iEpollFd,超時隊列pTimeOut,已超時隊列pstTimeoutList,就緒隊列pstActiveList,epoll_wait就緒事件集合result。其余參數fds為socket文件描述符集合,nfds為socket文件描述符數目,timeout超時時間,pollfunc為系統poll函數。


第908行epfd是epoll_create創建的epoll描述符,相當于紅黑樹、就緒鏈表的標識,后文通過epfd管理所有fd。919到927行是在nfds少于3個時直接在棧上分配空間(更快),否則在堆上分配。第930行記錄就緒fd的回調函數OnPollProcessEvent,該回調函數會切回對應的協程。第940行記錄切回協程之前的預處理函數OnPollPreparePfn。第948行將每個fd通過epoll_ctl加入紅黑樹。第968行將arg加入超時隊列pTimeOut,在980行掛起協程。等到所有協程均掛起,主協程開始運行。

圖21


圖22


圖23為主協程調用的co_eventloop函數片段。co_epoll_wait找出所有就緒fd,調用pfnPrepare即OnPollPreparePfn,將就緒fd從超時隊列pTimeOut移除,并加入就緒隊列pstActiveList。801行用不到。TakeAllTimeout拿出超時隊列里所有超時元素,并在第817行加入pstActiveList。824行到833行將807行取出的未超時事件再加回超時隊列,因為TakeAllTimeout拿出的不一定都是超時事件,超時隊列底層實現是60000大小的循環數組,存放每毫秒(共60000毫秒)的超時事件,每個數組的元素均是一條鏈表,循環數組的目的是便于通過下標找到所有超時鏈表。例如超時時間是10毫秒的所有事件均記錄在數組下標為9(在循環數組實際的下標可能不是9,僅舉個例子)的鏈表里,所有超時時間大于60000毫秒的事件均記錄在數組下標為59999的鏈表里。如果取出超時時間是60000毫秒的事件,TakeAllTimeout會把超時時間大于60000毫秒的也取出來,因此需要再把超時時間大于60000毫秒的重新加回超時隊列。在第836行是在協程超時或fd就緒時調用pfnProcess即OnPollProcessEvent切回協程。協程切回后,由上文2.1.2可知,協程接著co_yield_env里co_swap里的stCoRoutineEnv_t* curr_env = co_get_curr_thread_env()繼續執行,co_yield_env執行完后從圖22的第981行繼續執行。第986行應該是多余的,因為pfnPrepare已經從超時隊列刪除事件。992行調用epoll刪除當前協程的就緒fd。


注意到co_poll_inner傳入的fd數組,而arg只是鏈表中的一個元素。假設co_poll_inner傳入10個文件描述符,如果只有1個fd就緒,OnPollPreparePfn從pTimeOut刪除arg,則10個文件fd都從超時隊列刪除,在圖22切回協程時在第987到995行將10個描述符都從紅黑樹刪除,然后應用層需要將9個未就緒的fd重新調用co_poll_inner再加入紅黑樹。如果每次只就緒一個fd,這樣共需要加入紅黑樹:10 + 9+ 8 +… +1次,效率低于10次poll,每次只poll一個fd。co_poll_inner提供傳入fd數組的原因是,co_poll_inner是poll調用的,而poll是hook的系統函數,不能改變系統的語義。系統poll支持數組的原因是,調用系統poll一次,可檢查多個fd,比調用系統poll多次,每次檢查一個fd,效率更高。因此系統poll適合一次poll多個fd,但libco自定義的鉤子函數poll不適合一次poll多個fd,所以libco使用poll時需避免一次poll多個fd。

圖23


2.4?信號量激活協程

?libco提供信號量機制,類似golang的channel。example_cond.cpp舉了生產者和消費者的例子,一個協程做生產者,另一個做消費者,生產者產生數據后,喚醒消費者。


主協程首先創建消費者協程,并通過co_resume切換到消費者協程函數Consumer,co_resume會調用到coctx_swap保存主協程的棧內容,并將消費者協程初始化在regs里的esp、ebp、返回地址等數據彈到寄存器和消費者協程棧里,此時開始調度消費者協程。Consumer在檢查到task_queue為空時,將消費者協程通過co_cond_timedwait,加入超時隊列pTimeout,并加入信號量隊列env->cond,然后通過co_yield_ct切換回主協程,co_yield_ct調用到coctx_swap函數,保存消費者協程,并恢復主協程。主協程接著創建生產者協程,通過co_resume切換到生產者協程。生產者協程函數Producer在task_queue里添加數據后,通過co_cond_signal從pTimeOut、env->cond里刪掉消費者協程,并加入就緒隊列pstActiveList,然后通過poll掛起將生產者協程加入超時隊列pTimeout,并在co_poll_inner通過co_yield_env切回到主協程。主協程在co_eventloop遍歷就緒隊列pstActiveList,調度消費者協程消費task_queue里數據。


2.5?超時激活協程

當前協程通過語句poll(NULL, 0, duration),可設置協程的超時時間間隔duration。poll是被hook住的函數,執行poll之后,當前協程會被加到超時隊列pTimeOut,并被切換到其他協程,所有協程掛起后,主協程掃描超時隊列,找到超時的協程,并切換。因此可用poll實現協程的睡眠。注意不可用sleep,因為sleep會睡眠線程,線程睡眠了,協程無法被調度,所有的協程也都不會執行了。


2.6?回調激活協程

使用libco需要hook住socket族函數,但業務代碼一般使用現成的非libco網絡庫,如果改該網絡庫使其hook住socket族函數,工作量太大。因此業務側可在協程里異步調用,異步調用后掛起協程,所有的異步回調使用同一函數,在同一回調函數里,根據異步調用時的標記決定喚醒哪個協程。該方案也可做到不分離異步調用和處理異步調用返回的數據。但需要業務側添加一個統一的異步回調函數,并在該函數里根據標識調度協程。


3.?協程池

協程池的好處是不用每次使用協程時都創建新的協程。創建新協程主要開銷有兩個:一,需要malloc協程環境stCoRoutine_t,stCoRoutine_t有4K大小的協程私有變量數組;二,協程棧128K。每次創建新的協程要分配這么大的空間需要有時間開銷,另外頻繁申請、銷毀會導致內存碎片的產生。即使在共享棧模式下不用為每個協程申請協程棧,也會有第一部分stCoRoutine_t的開銷。每次從協程池取出協程后,將stCoRoutine_t.pfn重新初始化為用戶自定義的協程函數即可。


4.?協程私有變量

協程私有變量為每個協程獨享,不會被其他協程修改,可參看example_specific.cpp。協程私有變量通過宏CO_ROUTINE_SPECIFIC定義,然后重載操作符->實現set/get。該宏首先定義線程私有變量,聲明pthread_key_t類型的變量,并通過pthread_once_t保證pthread_key_t類型變量只被create一次。如果當前沒有創建過協程,或者當前協程是主協程,則通過co_pthread_setspecific/pthread_getspecific來set/get線程私有變量。否則在當前協程的aSpec數組里set/get協程私有變量,pthread_key_t類型的變量作為數組下標。


pthread_once控制pthread_key_create在多線程環境中只會執行一次。_routine_init_##name控制一個線程的多個協程只會調用pthread_once一次,避免每次set、get變量時均調用pthread_once。


因為協程私有變量需要重載操作符->,因此CO_ROUTINE_SPECIFIC第一個參數必須為結構體或類。雖然aSpec有1024個容量,但通過pthread_key_create創建的key是從1開始,因此協程私有變量實際可容納1023個元素。


注意事項

1.?共享棧下內容篡改

圖24所示代碼,協程RoutineFuncA、RoutineFuncB共用一個共享棧。運行代碼,第7行輸出1,第14行輸出2,但在第14行之前只在RoutineFuncA里將global_ptr指向的內容設置為1。原因是在RoutineFuncA里global_ptr指向routineidA的地址,而在RoutineFuncB里,因為是共享棧,所以routineidB和routineidA的地址一樣,而該地址處的值被第13行修改為2,因此第14行打印出來2。所以共享棧模式下,需避免協程之前傳遞地址,因為地址的內容會被篡改。

圖24


2. poll效率

libco自定義的鉤子函數poll,雖然支持傳入fd數組,但一次poll多個fd的效率,不如多次poll每次poll一個fd的效率,原因見2.3 epoll激活協程


3.棧溢出

每個協程棧使用128K的堆內存,128K是malloc使用brk和mmap分配堆內存的分界線。但128K的空間可能不夠一個協程使用,導致協程棧溢出。協程棧溢出問題,有如下三種解決方案。


3.1?堆內存

大內存不在協程棧上分配,通過malloc、new在堆上分配,可以使用編譯參數-Wstack-usage檢查使用較大棧空間的變量。另外棧以外空間的非法讀寫不一定會Crash,因此在128K協程棧上下添加保護頁,并通過mprotect設置保護頁權限,非法讀寫保護頁時,程序會立即Crash。推薦使用該方案


3.2?自動擴容協程棧

在協程調用的所有函數入口檢查棧使用率,如果棧使用率超過閾值,就自動擴容協程棧。在進入函數時,聲明一個臨時變量,獲取該變量的地址varaddr,因為協程棧空間高地址為stack_mem->stack_bp,低地址為stack_mem->stack_buffer,因此使用率為(stack_mem->stack_bp -varaddr) / (stack_mem->stack_bp - stack_mem->stack_buffer)。如果使用率超過閾值,重新申請大空間的新協程棧,并將舊協程棧拷貝到新協程棧。?


但如果協程函數里使用了指針,比如指針ptr指向舊協程棧內存地址0xffd344c0,棧拷貝后,訪問ptr的內容仍然是訪問舊協程棧0xffd344c0,導致非法訪問。在協程函數里使用指針的概率很大,比如聲明數組,因此該方案風險較大。


?golang支持協程棧的自動擴容,1.3之前是分段棧,即老棧保留,另外再開辟新棧,老棧新棧一起使用,超出老棧的數據用新棧保存。使用分段棧存在hot split問題,所以1.3及之后采用連續棧,老棧不夠用時,申請大空間的新棧,并將老棧數據拷貝到新棧。拷貝老棧到新棧時,golang也面臨指針失效的問題,原文參考,但golang的編譯器會管理每個指針的位置,原文參考?。只要知道每個指針在老棧的位置,算出相對協程棧頂偏移量,即可將指針遷移到新棧的位置。


3.3?虛擬內存

因為malloc使用的是虛擬內存,而物理內存按需分配,協程占用的內存并不是malloc的內存大小,而是實際使用到的內存大小,因此可將malloc(128K)改為malloc(8M)。但在非協程池模式下,頻繁mmap 8M的堆內存會導致大量缺頁中斷。在協程池模式下,假設池子有10個協程,10個協程輪流處理同一個用戶自定義的協程函數,而該函數若最終會使用8M的物理內存,最終導致協程池里的所有協程實際使用內存都為8M,在協程池大的情況下,會迅速耗盡內存。因此在回收協程池里的協程時,需要檢測物理內存實際使用量(方法同3.2.2),超過128K時,需要銷毀協程,重建128K的協程加入協程池



總結

以上是生活随笔為你收集整理的万字长文 | 漫谈libco协程设计及实现的全部內容,希望文章能夠幫你解決所遇到的問題。

如果覺得生活随笔網站內容還不錯,歡迎將生活随笔推薦給好友。