協程系統優化
libcopp很早就實作完成了v2版本,現在遷移進atsf4g-co/tree/sample_solution以後也把v2分支正式并入了主幹。原來的版本切出到v1分支并且停止維護了。
libcopp v2記憶體布局
開發libcopp v2版本的最大目的是優化allocator的接口和記憶體碎片。
原來的allocator雖然是可定制的,但是是内置的。每次建立一個allocator對象,不同allocator之間共享資料隻能通過全局資料或者TLS資料。現在則可以傳入allocator了。這也是為後續的共享棧池做準備。
其次就是優化結構布局以優化記憶體碎片問題。在v1版本裡,一個很重要的設計要點是各項元件可拆卸,就是說一些設計模式層面的東西比如協程任務、任務管理、等待和依賴關系等是可選的。同時棧配置設定器也可以是多種選擇,采用系統位址映射加保護幀、采用malloc或者自定義配置設定器。為了各項元件盡可能解耦和易于組合,子產品間較少采用組合關系,較多采用了引用關系,原來的協程任務結構大緻上是這樣的。
可以看得出來一個協程運作的時候對象數量很多。這樣的話碎片也很多,雖然現代化的malloc實作能大幅緩解碎片問題,但是終歸是有一些開銷。上面圖裡是一個比較完整的結構關系,實際使用中有些元件如果不需要是可以移除掉的,比如 cotask 相關的部分。
v2版本在這方面就有了一些優化。基本思路是每個協程執行的時候都必然會配置設定一個執行棧,那麼其實我們在執行棧上開辟一塊空間放這些對象(執行棧是自頂向下增長,是以可以放到棧空間的最後面)。同時還可以開辟出一塊使用者私有資料塊,用于友善使用者可以存放一些 TrivialType 的私有資料,也不需要額外的動态配置設定。當然,各項元件的可自由組裝和裁剪的特性也是必須保留的,我們的組織結構如下圖所示:
棧池
在壓力測試過程中,我們發現其實相對于業務邏輯,協程的建立和切換的開銷占比非常小。但是有一樣的開銷很高,那就是缺頁中斷。我們知道在Linux中,在記憶體位址被實際使用前,是不會有實體記憶體頁映射進來的。在第一次通路未映射位址的時候(特别是協程第一次切入到執行棧),會觸發一次缺頁中斷,然後由作業系統把實際實體頁映射上去,然後再繼續執行。這個缺頁中斷引起的開銷是其他協程建立流程總和的大約10倍左右。是以為了減緩這種開銷,我們引入了一個新的stack allocator - 棧池allocator。目前棧池的實作是一個比較簡單的但基本可用版本,并且初步實作了可以根據負載自動調整池子大小的功能。這樣在低負載服務中可以有效減少記憶體消耗,在高負載服務中也能提高複用率。
性能對比
從壓測結果上看,v2版本對v1的CPU L1緩存命中率是有下降的。我們分析這是因為v1版本中對象是碎片化的關系。因為碎片化的對象底層有 jemalloc 的加持,導緻即便是在手動構造的棧cache miss的壓力測試中,由于我們壓測的CPU的L1 cache是32KB=8way*64sets*64B的,導緻通路前一個對象的時候,後一個對象還是有部分資料被加載到了緩存中,小對象的緩存命中率還是比較高。這其實也是不符合實際應用場景的。
因為一般情況下我們并不是為了跑分而優化,協程接口一般在切換上下文後邏輯會更加複雜。是以為了盡可能貼近實際應用,我們盡量構造cache miss的用例來評估性能。同時我們大部分項目上線的時候編譯選項都是-O2,是以在壓力測試的時候我們也盡量使用O2級别的編譯優化(有些系統自帶包會用O3),編譯選項是
-O2 -g -DNDEBUG -ggdb -Wall -Werror -fPIC
。項目中一般會啟用安全性較高的方案,是以我們的壓力測試中,隻要是可以定義棧配置設定的庫,棧都使用mmap出來4K對齊的頁和使用mprotect尾部的方式來維護棧空間。
壓力測試機環境
環境名稱 | 值 |
---|---|
系統 | Linux kernel 3.10.104(Docker) |
CPU | E5-2630 v2 @ 2.60GHz * 12 |
L1 Cache | 64Bytes*64sets*8ways=32KB |
系統負載 | 1.27 1.29 1.17 |
記憶體占用 | 2.86GB(used)/2.84GB(cached)/2GB(free) |
CMake | 3.11.3 |
GCC版本 | 8.1.0 |
Golang版本 | 1.10.2 (20180216) |
Boost版本(libgo依賴) | 1.67.0 |
libco版本 | 0af0b89998f2f691208f530cacb799ed033098f6 (20180605) |
libcopp | 8ce6dfef26ccf6a1ecb55336dde18a6526f76666 (20170423) |
壓力測試對比
元件(Avg) | 協程數:1 切換開銷 | 協程數:1000 建立開銷 | 協程數:1000 切換開銷 | 協程數:30000 建立開銷 | 協程數:30000 切換開銷 |
---|---|---|---|---|---|
棧大小(如果可指定) | 16 KB | 2 MB | 2 MB | 64 KB | 64 KB |
libcopp | 60 ns | 3.7 us | 91 ns | 3.5 us | 239 ns |
libcopp+動态棧池 | 60 ns | 109 ns | 90 ns | 261 ns | 238 ns |
libcopp+libcotask | 79 ns | 4.2 us | 124 ns | 3.8 us | 338 ns |
libcopp+libcotask+動态棧池 | 80 ns | 246 ns | 126 ns | 340 ns | 335 ns |
libco+靜态棧池 | 94 ns | 7.1 us | 180 ns | 5.7 us | 451 ns |
libco(共享棧4K占用) | 94 ns | 3.8 us | 173 ns | 4.0 us | 558 ns |
libco(共享棧8K占用) | 95 ns | 3.8 us | 1021 ns | 3.8 us | 1810 ns |
libco(共享棧32K占用) | - | 3.8 us | 6275 ns | 4.0 us | 6429 ns |
libgo with boost | 197 ns | 5.3 us | 124 ns | 2.3 us | 441 ns |
libgo with ucontext | 539 ns | 7.0 us | 482 ns | 2.7 us | 921 ns |
goroutine(golang) | 464 ns | 578 ns | 538 ns | 1.4 us | 799 ns |
linux ucontext | 356 ns | 4.0 us | 431 ns | 4.5 us | 946 ns |
libcopp+libcotask比單純的libcopp多了程序内唯一ID的配置設定、狀态轉換和維護、可調用對象和跳轉函數直接的轉換和事件響應,并且保證了線程安全。工程上一般會用libcotask,但是功能上libcopp才是和其他對比項類似的部分。在壓力測試中,也沒有包含libco和libgo裡系統函數hook的部分。
libgo的作者已經不再建議使用共享棧是以我們沒有壓測libgo的共享棧性能。libgo采用鎖實作了線程安全,我們壓測過程中沒有啟動多線程是以測試結果也不包含線程等待的消耗。
libco僅支援靜态棧池,并且靜态棧池隻是為了減少共享棧時的copy開銷,是以libco也分别對模拟棧使用量為4K、8K和32K時模拟棧池耗盡時的壓力測試,可以看到棧使用量較大時,切換的memcpy開銷較大。但是在工程中,libco的棧池也不會隻配置設定一個,是以項目中的真實消耗還是要看最終的命中率,如果棧池剩餘量越大,性能更向第一組靠近,否則根據棧使用量向後面靠近。
- libcopp/libcotask測試代碼:https://github.com/owt5008137/libcopp/tree/v2/sample
- goroutine 測試代碼: https://gist.github.com/owt5008137/1842b56ac1edd5a7db54590d41af1c44#file-goroutine_benchmark-go
- libco 測試代碼: https://gist.github.com/owt5008137/1842b56ac1edd5a7db54590d41af1c44#file-libco_benchmark-cpp
- ucontext 測試代碼: https://gist.github.com/owt5008137/1842b56ac1edd5a7db54590d41af1c44#file-ucontext_benchmark-cpp
- libgo 測試代碼: https://gist.github.com/owt5008137/1842b56ac1edd5a7db54590d41af1c44#file-libgo_benchmark-cpp
【協程數:1,棧大小16KB】 - 切換耗時對比
【協程數:1000,棧大小2MB】 - 建立耗時對比
【協程數:1000,棧大小2MB】 - 切換耗時對比
【協程數:30000,棧大小64KB】 - 建立耗時對比
【協程數:30000,棧大小64KB】 - 切換耗時對比
總體來說,libcopp和libcotask還是很有優勢的。特别是動态棧池,幾乎可以讓建立開銷逼近上下文切換,可以放心地無腦建立協程了。這裡面需要特别說明一下的是goroutine和libco。goroutine的切換并沒有優勢,但是建立性能還是挺高的,原因之一是我對go不是很熟,不太清楚怎麼讓go的cachemiss掉,看起來它自帶一些池子的功能。而libco是支援靜态棧池的,如果靜态的棧池足夠大或者使用量不大的時候,也能有不錯的切換性能(不需要memcpy棧),隻是池子用完以後波動會很大。