“協程”(coroutine),就是把linux epoll的異步IO機制通過長跳轉(long jmp)封裝起來,形成一個在用戶看來“連續的”流程。
所有操作系統的異步IO,都分為啟動函數和回調函數。
以Linux為例,啟動函數負責往epoll框架里添加讀寫事件。
在事件觸發之后,再通過回調函數去進行下半部的處理。
整個事件處理過程,與Linux內核里的中斷處理差不多。
一個完整的IO流程需要回調好幾次,而且讀代碼時到處查找回調函數在哪里設置的。
甚至,有的程序員會中途修改回調函數的指針[捂臉]
C的函數指針比C++的虛函數更“靈活”的地方是,C++的虛函數表在編譯時就固定了,但C的函數指針可以在運行時修改(它就是個普通變量)。
然后,就真有人半截里修改它,讓代碼的可讀性急劇下降。
再然后,就出現了coroutine,看上去至少是同步的了。
程序流程在一個函數里跳轉,就是普通的goto語句。
程序流程在2個函數的半截里跳轉,就是長跳轉(long jmp)。
協程的原理如下:
1,當某個文件描述符需要IO等待的時候,通過長跳轉回到epoll的主框架函數,讓其他的IO可以運行。
2,當這個文件描述符的IO再次就緒之后,再通過長跳轉從主框架函數跳回來,接著上次的位置繼續運行:
這個位置,是函數上一次放棄運行的位置,它是函數內的某個點。
在函數的半截里放棄CPU之后還能回來,就需要保存函數的運行上下文:棧信息、寄存器信息。
保存到哪里?
只能保存到堆上,因為棧和寄存器都會隨著代碼的運行而不斷地覆蓋,只有堆是受用戶控制的。
用戶測試代碼
上圖是“用戶代碼”,雖然兩個函數__async_connect()和__async_write()的內部是異步執行的,但它們都在一個函數_async_test()里,整個流程看上去是同步的。
__async_connect()函數,上半部
__async_connect()分為上半部和下半部,以__asm_co_task_yield()為分隔點。
上半部調用異步的connect(),下半部調用getsockopt()讀取結果。
為了避免阻塞線程,需要在異步connect()之后讓出CPU,讓主框架函數可以做別的。
這個讓出CPU的函數__asm_co_task_yield(),是“協程庫”的關鍵。
它讓出了CPU之后,在事件觸發之后再次恢復運行:這時函數__asm_co_task_yield()才會返回,然后接著運行下圖的代碼。
__async_connect()函數,下半部
當異步connect()成功時,getsockopt()獲取的錯誤碼err是0。
__async_write()函數
__async_write()函數的流程與__async_connect()類似,也是在文件描述符變得不可寫時放棄CPU,等待下次可寫時再恢復運行。
epoll主框架函數
epoll的主框架函數是一個while循環:使用epoll_wait()系統調用去監控事件的觸發。
它會同時處理IO事件和定時器。
定時器的精度受限于epoll_wait()的等待時間。
epoll主框架函數
__scf_co_task_run(),可以讓“協程任務”首次運行,或者再次恢復運行。
_scf_co_task_run()函數
這個函數只是調用了__asm_co_task_run(),具體的長跳轉在匯編里實現。
因為長跳轉涉及到細致的內存控制,只能用匯編實現。
運行結果:
要在本機上用命令nc -vv -l 2000當服務端。
打印的日志,是長跳轉時的棧信息的變化。
兩個匯編函數的大概功能,如下面的3張圖。
細節就不說了,這種代碼,時間久了連作者都快看不懂了[捂臉]