無棧協(xié)程:用戶態(tài)的Linux進程調度
?協(xié)程(coroutine),是為了把epoll異步事件變成同步的一種編程模式。
它的出現(xiàn)也就近幾年的事,是隨著go語言而提出的一種編程模式。
因為異步事件編程的可讀性比較差,然后就有了協(xié)程。
協(xié)程,也被稱為用戶態(tài)的進程。
協(xié)程的調度,跟Linux內(nèi)核對進程的調度是類似的。
1,不管是協(xié)程、進程、線程,它們都有一個要運行的函數(shù),以及相關的上下文。
函數(shù)是它們要運行的代碼,上下文是它們的運行狀態(tài)。
pthread庫對線程函數(shù)的定義是void* (*run)(void*),它是一個參數(shù)和返回值都是void*的函數(shù)指針:
這么定義的線程函數(shù),可以給它傳遞任何類型的參數(shù),也可以從它獲取任何類型的返回值。
這個函數(shù),就是線程要運行的函數(shù)。
如果是進程的話,main()函數(shù)就是它要運行的進程函數(shù)。
任何不使用fork()系統(tǒng)調用的進程,都是從main()函數(shù)開始運行的。
fork()系統(tǒng)調用之后的(父)子進程,會運行fork()返回之后的代碼,例如:
協(xié)程也跟進程、線程類似,也有一個要運行的函數(shù)。
另外,無論進程、線程、協(xié)程都有一個運行的狀態(tài)上下文:
這個上下文里最重要的數(shù)據(jù),就是棧!?
Linux內(nèi)核的進程的內(nèi)存布局
函數(shù)的局部變量是分配在棧上的,函數(shù)調用的返回地址也是在棧上的,各種寄存器也是保存在棧上的。
對于一個正在運行的函數(shù)來說,棧必須是獨立的,不能與其他函數(shù)共享:因為運行著的函數(shù)會隨時修改棧上的數(shù)據(jù)。
不管是線程、進程、協(xié)程,都是這樣。
同一個進程內(nèi)的不同線程之間雖然會共享全局變量和堆內(nèi)存,但棧是不能共享的。
在Linux上,線程和進程除了共享全局變量和堆之外,基本上是一回事。
在Linux內(nèi)核里,它們都用上圖的數(shù)據(jù)結構描述:
1)最早是4096字節(jié)(1個內(nèi)存頁),后來擴展到8k字節(jié)(2個頁)。
2)這8k內(nèi)存的低地址是進程的描述結構,也就是main()函數(shù)運行時需要的信息。
這8k內(nèi)存的高地址,是進程在內(nèi)核里運行時(例如執(zhí)行系統(tǒng)調用時)的(內(nèi)核)棧。
這兩部分加起來,就是進程的上下文。
所以,在給Linux內(nèi)核寫模塊時,代碼里不能使用很大的局部變量,以免把進程的描述結構給覆蓋了!
這樣的代碼是不能寫在內(nèi)核里的,因為局部變量的內(nèi)存是分配在棧上的,而內(nèi)核給每個進程配備的棧都很?。?k)。
這一個buf數(shù)組就占了4k,那函數(shù)調用稍微復雜一點,就可能把低地址的進程結構給覆蓋了。
Linux內(nèi)核在調度進程的時候,就是不斷地切換上圖的數(shù)據(jù)結構,從而讓多個進程可以交替運行。
因為調度間隔遠小于人眼能察覺的時間間隔,所以即使在單核CPU上,在人看來也是多進程同時運行的。
2,協(xié)程的實現(xiàn)
多個協(xié)程要想在用戶態(tài)交替運行,也必須為每個協(xié)程配備不同的棧。
多個協(xié)程都隸屬于同一個進程,而進程棧的位置是被操作系統(tǒng)提前分配好了的。
所以,為每個協(xié)程配備棧的時候,每個棧的內(nèi)存范圍必須在進程棧的范圍內(nèi)。
有棧協(xié)程的內(nèi)存布局
如上圖:
你說要在“進程”的棧上給協(xié)程提前開多大的空間?
每個協(xié)程的棧又要預留多大?
預留小了,協(xié)程函數(shù)的局部變量把協(xié)程的描述結構覆蓋了的事,也會發(fā)生的。
預留大了,同一個進程所能支持的總協(xié)程數(shù)就會減少。
而且,程序員的用戶態(tài)代碼一般都比內(nèi)核代碼更粗放。
寫個用戶態(tài)代碼,還不讓我這么開緩沖區(qū) char buf[1024*1024],能行嗎??
沒有哪個程序員愿意,寫個用戶代碼還像寫內(nèi)核驅動一樣戰(zhàn)戰(zhàn)兢兢的。
所以,有棧協(xié)程的劣勢非常明顯!
1)首先,每個進程支持的協(xié)程個數(shù)是有限的,而不是無限的。
大多數(shù)情況下,雖然用戶代碼要開的協(xié)程個數(shù)也不至于突破上限,但畢竟它是個有限集,不是個可數(shù)集。
這對用戶代碼的限制還是比較大的。
有這么個限制,在創(chuàng)建協(xié)程的時候就要每次都檢查是否成功。
代碼就是這樣的:
而不是這樣的:
否則代碼就不完善,因為沒有處理異常情況。
2)萬一協(xié)程函數(shù)里有復雜的遞歸,協(xié)程的棧溢出了,那么就可能覆蓋多個協(xié)程的數(shù)據(jù),導致程序掛了。
可以預見,這種掛的位置幾乎肯定不是第一現(xiàn)場!
這種BUG查起來,還是非常麻煩的。
不掛在第一現(xiàn)場的內(nèi)存BUG,都是C語言里很難查的BUG,它很大可能是隨機的?
然后,就有了無棧協(xié)程。
3,無棧協(xié)程
無棧協(xié)程的實現(xiàn)也很簡單,只要在切換協(xié)程之前,把當前協(xié)程的棧數(shù)據(jù)保存到堆上就可以了。
每個協(xié)程的上下文都是用malloc()申請的堆內(nèi)存,在上下文里預留一個空間,在切換協(xié)程時把(當前協(xié)程的)棧數(shù)據(jù)保存到這個預留空間里。
當協(xié)程再次被調度運行時,把上次的棧數(shù)據(jù)從(協(xié)程的)上下文里復制到進程棧上,協(xié)程就可以再次運行了。
無棧協(xié)程的內(nèi)存布局
如上圖,協(xié)程0掛起,協(xié)程1被調度運行:
1)先把進程棧上的數(shù)據(jù)復制到協(xié)程0的上下文里。
這時進程棧上的數(shù)據(jù),全是協(xié)程0的棧數(shù)據(jù)。
協(xié)程的上下文是malloc()申請的堆內(nèi)存,如果棧數(shù)據(jù)太大的話,是可以用realloc()再次分配更大的內(nèi)存的。
這就打破了協(xié)程棧的大小固定的缺陷。
每個協(xié)程可以使用的棧大小,只受制于進程的棧的大小。
2)當協(xié)程的棧不再受到限制之后,可以創(chuàng)建的協(xié)程數(shù)量也只受制于進程的堆的大小。
只有整個進程的堆內(nèi)存被耗盡之后,協(xié)程的創(chuàng)建和運行才會沒法進行。
我在scf編譯器框架里附帶的那個協(xié)程的實現(xiàn),就是無棧協(xié)程?
它在scf/coroutine目錄。
2021年的5月份我就想到了這些問題,并且給了解決的代碼,在github和gitee的scf代碼都有。
2022年以來,我沒往github上更新代碼,目前gitee上的scf是最新的。