libaco: 一個極速的輕量級 C 非對稱協程庫 ??

libaco: 一個極速的輕量級 C 非對稱協程庫 ??

來自專欄 libaco: 高性能協程與網路編程4 人贊了文章

libaco的代號是Arkenstone ??

Asymmetric COroutine 和 Arkenstone 是 aco 的名稱來源。

當前支持Sys V ABI Intel386和Sys V ABI x86-64。

libaco的源碼倉庫?

github.com圖標

下面是這個項目的簡要介紹:

  • 除了一個生產級別的C協程庫實現,還包含了一個詳細的文檔描述了如何實現一個 最快且正確 的協程庫以及其嚴格的數學證明;
  • 核心實現不超過 700 行代碼,但包含了一個協程庫應該有的全部功能;
  • 在AWS c5d.large機器上的性能測試結果指出,一次協程間上下文切換僅耗時 10 ns (獨立執行棧);
  • 用戶在創建新的協程時,可以選擇其擁有一個獨佔的執行棧,或者是與其它任意數量的協程一起共享一個執行棧;
  • 擁有極致的內存使用效率:一千萬個協程並發執行僅消耗2.8GB的物理內存(tcmalloc,每一個協程使用120B的複製棧)。

上文中的"最快"指的是在滿足Sys V ABI Intel386或者AMD64約束下最快的上下文切換實現。

Table of Contents

(由於知乎專欄不支持錨點,TOC中的鏈接是鏈向libaco的Github倉庫文檔的)

  • Name
  • Table of Contents
  • Status
  • Synopsis
  • Description
  • Build and Test
    • CFLAGS
    • Build
    • Test
  • Tutorials
  • API
    • aco_thread_init
    • aco_share_stack_new
    • aco_share_stack_new2
    • aco_share_stack_destroy
    • aco_create
    • aco_resume
    • aco_yield
    • aco_get_co
    • aco_get_arg
    • aco_exit
    • aco_destroy
    • MACROS
  • Benchmark
  • Proof of Correctness
    • Running Model
    • Mathematical Induction
    • Miscellaneous
      • Red Zone
      • Stack Pointer
  • Best Practice
  • Donation
  • Copyright and License

Status

可以用於生產環境。

Synopsis

#include "aco.h" #include <stdio.h>// this header would override the default C `assert`;// you may refer the "API : MACROS" part for more details.#include "aco_assert_override.h"void foo(int ct) { printf("co: %p: yield to main_co: %d
", aco_get_co(), *((int*)(aco_get_arg()))); aco_yield(); *((int*)(aco_get_arg())) = ct + 1;}void co_fp0() { printf("co: %p: entry: %d
", aco_get_co(), *((int*)(aco_get_arg()))); int ct = 0; while(ct < 6){ foo(ct); ct++; } printf("co: %p: exit to main_co: %d
", aco_get_co(), *((int*)(aco_get_arg()))); aco_exit();}int main() { aco_thread_init(NULL); aco_t* main_co = aco_create(NULL, NULL, 0, NULL, NULL); aco_share_stack_t* sstk = aco_share_stack_new(0); int co_ct_arg_point_to_me = 0; aco_t* co = aco_create(main_co, sstk, 0, co_fp0, &co_ct_arg_point_to_me); int ct = 0; while(ct < 6){ assert(co->is_end == 0); printf("main_co: yield to co: %p: %d
", co, ct); aco_resume(co); assert(co_ct_arg_point_to_me == ct); ct++; } printf("main_co: yield to co: %p: %d
", co, ct); aco_resume(co); assert(co_ct_arg_point_to_me == ct); assert(co->is_end); printf("main_co: destroy and exit
"); aco_destroy(co); co = NULL; aco_share_stack_destroy(sstk); sstk = NULL; aco_destroy(main_co); main_co = NULL; return 0;}

Build:

# default build$ gcc -g -O2 acosw.S aco.c test_aco_synopsis.c -o test_aco_synopsis$ ./test_aco_synopsismain_co: yield to co: 0x1887120: 0co: 0x1887120: entry: 0co: 0x1887120: yield to main_co: 0main_co: yield to co: 0x1887120: 1co: 0x1887120: yield to main_co: 1main_co: yield to co: 0x1887120: 2co: 0x1887120: yield to main_co: 2main_co: yield to co: 0x1887120: 3co: 0x1887120: yield to main_co: 3main_co: yield to co: 0x1887120: 4co: 0x1887120: yield to main_co: 4main_co: yield to co: 0x1887120: 5co: 0x1887120: yield to main_co: 5main_co: yield to co: 0x1887120: 6co: 0x1887120: exit to main_co: 6main_co: destroy and exit# i386$ gcc -g -m32 -O2 acosw.S aco.c test_aco_synopsis.c -o test_aco_synopsis# share fpu and mxcsr env$ gcc -g -D ACO_CONFIG_SHARE_FPU_MXCSR_ENV -O2 acosw.S aco.c test_aco_synopsis.c -o test_aco_synopsis # with valgrind friendly support$ gcc -g -D ACO_USE_VALGRIND -O2 acosw.S aco.c test_aco_synopsis.c -o test_aco_synopsis$ valgrind --leak-check=full --tool=memcheck ./test_aco_synopsis

關於構建的更多信息請查閱"Build and Test"部分。

Description

一個用戶空間的執行狀態(一般為OS線程)有四個基本要素:{cpu_registers, code, heap, stack}

由於二進位程序的代碼執行位置信息由({E|R})?IP寄存器決定,且從堆中分配出的內存地址信息一般會間接或者直接的保存在運行棧中,所以,我們可以將這個四個元素最終化簡為{cpu_registers, stack}

我們定義main co(主協程)為獨佔使用當前運行線程默認執行棧的協程。由於main co是這個執行棧的唯一用戶,所以,在與main co相關的協程上下文切換中,我們僅需要對main co的某些必須的寄存器進行保存和恢復即可。

接著,我們定義non-main co(非主協程)為執行棧不是當前運行線程默認執行棧(而是它自己創建的,且有可能會與其他non-main co一起共享這個執行棧)的協程。所以,non-main co會有一個私有的保存棧,當它被切換進來(或者切換出去)時,會使用它的私有保存棧進行執行棧的恢復(或者保存),因為當它被切換進來(或者切換出去)時,之前的(或者之後的)運行協程可能已經使用了(或者可能將會使用)這個執行棧(在libaco實現中,私有保存棧的保存策略是惰性的最優方案,具體請參見aco_resume的源碼實現細節)。

這是一個non-main co的特殊情況,在libaco中我們稱之為standalone non-main co(獨立非主協程),即獨佔一個執行棧的非主協程。在與standalone non-main co相關的上下文切換中,對其只需要進行一些必須寄存器的保存或恢復即可(因為它的執行棧是獨佔的,在它被切換出的時間裡,它的執行棧的狀態是不變的)。

最終,我們得到了libaco的全局鳥瞰圖。

如果你想要實現自己的協程庫或者更加深入的了解libaco的實現,"Proof of Correctness" 部分將會非常有用。

接下來,可以閱讀教程或者性能測試部分。性能測試的報告令人印象深刻同時發人深省。

Build and Test

CFLAGS

  • -m32

編譯器選項-m32能夠幫助用戶在AMD64平台上構建libaco的i386二進位程序。

  • C macro: ACO_CONFIG_SHARE_FPU_MXCSR_ENV

如果用戶的程序在運行期間不會更改FPU和MXCSR的控制字,那麼可以選擇定義全局C宏 ACO_CONFIG_SHARE_FPU_MXCSR_ENV 以輕微地加快協程間上下文切換的速度。如果該宏沒有被定義,每一個協程將會維護一份屬於自己的獨立FPU和MXCSR控制字環境。由於更改FPU或者MXCSR控制字的應用代碼是非常少見的,用戶可以選擇總是全局定義該宏,但是如果並不能保證這個約束,用戶應該選擇不定義該宏。

  • C macro:ACO_USE_VALGRIND

如果用戶想要使用valgrind的memcheck工具對libaco的應用程序進行測試,則需要在構建時定義全局C宏 ACO_USE_VALGRIND 以使能libaco對valgrind memcheck時的支持。 由於性能的原因,在最終的生產二進位構建中並不推薦使用此宏。在全局定義了此宏的libaco應用構建之前,用戶需要安轉valgrind的C頭文件(以Centos為例,這個開發包的名稱為"valgrind-devel")。valgrind的memcheck現在只支持擁有獨立運行棧的協程,memcheck在對使用共享棧的協程進行檢測時會輸出很多的誤報。更多的信息可以查看"test_aco_tutorial_6.c"。

Build

$ mkdir output$ bash make.sh

make.sh腳本中有一些更加詳細的構建參數:

$bash make.sh -hUsage: make.sh [-o <no-m32|no-valgrind>] [-h]Example: # default build bash make.sh # build without the i386 binary output bash make.sh -o no-m32 # build without the valgrind supported binary output bash make.sh -o no-valgrind # build without the valgrind supported and i386 binary output bash make.sh -o no-valgrind -o no-m32

簡而言之,如果系統中沒有valgrind的C頭文件,可以選擇使用參數 -o no-valgrind進行測試集的構建;如果系統為AMD64平台並且沒有安裝32位的C編譯器開發工具鏈,可以選擇使用參數 -o no-m32 進行測試集的構建。

Test

$ cd output$ bash ../test.sh

Tutorials

文件test_aco_tutorial_0.c中包含了libaco的基本使用示例。在這個示例中,只包含了一個 main co 和一個 standalone non-main co,另外,代碼中的注釋也很有用。

文件test_aco_tutorial_1.c中包含了libaco協程的運行統計信息的使用示例。類型aco_t的定義在aco.h中並且清晰易懂。

在文件test_aco_tutorial_2.c中,包含了一個standalone non-main co和兩個共享同一個執行棧的non-main co。

文件test_aco_tutorial_3.c展示了如何在多線程環境中使用libaco。從根本上講,為了獲得最好的協程間上下文切換性能,在設計時一個libaco的運行實例應該僅僅工作在一個固定的線程中。這樣,如果你想在多線程中使用libaco,只需要分別在各個線程中像在單線程中那樣使用libaco一樣使用它即可。在libaco內部沒有任何的線程間數據共享;在多線程場景下,用戶需要自己處理好自己的數據競爭問題(就像此實例中gl_race_aco_yield_ct線程間共享變數做的那樣)。

在libaco中,請調用API aco_exit()來進行終結non-main co的執行,而不要直接使用默認的C關鍵字return進行返回(否則libaco會將這種行為當做異常事件並觸發默認的protector流程:輸出錯誤信息至stderr並立即調用abort來終結進程的執行)。源文件test_aco_tutorial_4.c中示範了一個違背了此規則的協程實例。

同時,用戶也可以選擇定製自己想要的protector處理邏輯(比如去做一些自定義的"last words"即「遺囑」任務)。但是無論如何,當protector被執行完畢後,當前進程一定會被abort。源文件test_aco_tutorial_5.c中描述了如何自定義protector。

源文件test_aco_tutorial_6.c中示範了一個簡單的協程調度器的實例。

API

在閱讀下面的API文檔時,建議也可以同時閱讀對應源碼中的實現,因為源碼非常的清晰易讀。同時,在閱讀API文檔之前,推薦先閱讀教程部分。

另外,在開始寫libaco的應用之前,強烈建議先進行閱讀「Best Practice」章節,此章節中除了描述如何應用libaco以讓其性能發揮到極致,也描述了一些libaco編程時的注意事項。

注意:libaco的版本控制遵從Semantic Versioning 2.0.0標準。所以,下面列出的所有API均有標準中所描述的兼容性保證(請注意,沒有在下面API列表中的函數調用則沒有如此的保證)。

aco_thread_init

typedef void (*aco_cofuncp_t)(void);void aco_thread_init(aco_cofuncp_t last_word_co_fp);

在當前運行線程中初始化libaco的執行環境。

此API會將當前FPU與MXCSR的控制字保存到一個TLS全局變數中。

  • 如果全局C宏 ACO_CONFIG_SHARE_FPU_MXCSR_ENV 沒有被定義,保存的控制字接下來會被用來初始化新協程(aco_create)的FPU與MXCSR的控制字,然後每一個協程都將會在以後的協程上下文切換中獨立維護這一份屬於自己的FPU與MXCSR的控制字配置。
  • 如果全局C宏 ACO_CONFIG_SHARE_FPU_MXCSR_ENV 被定義了,所有的協程將會共享同一份FPU與MXCSR的控制字配置。如果在這方面想了解更多,請查閱 "Build and Test" 部分。

就像在 "Tutorials" 中關於 test_aco_tutorial_5.c 部分所陳述的那樣,API的第一個入參last_word_co_fp為用戶自定義的 "last words" 函數指針, 如果它的值非NULL,將會取代默認的protector handler(在進程abort之前做一些 "last words" 相關的事情)。在這樣的 "last word" 函數中,用戶可以調用API aco_get_co 以獲得當前協程的指針。可以通過閱讀源文件test_aco_tutorial_5.c以獲得與此相關的更多信息。

aco_share_stack_new

aco_share_stack_t* aco_share_stack_new(size_t sz);

等價於調用aco_share_stack_new2(sz, 1)

aco_share_stack_new2

aco_share_stack_t* aco_share_stack_new2(size_t sz, char guard_page_enabled);

創建一個新的執行棧,入參sz是對要創建執行棧的大小的一個建議性位元組值,入參guard_page_enabled決定了要創建的執行棧是否會擁有一個只讀的 "guard page" (可以用來檢測執行棧的溢出)。

當第一入參sz為0時,表示選擇使用默認的大小值(2MB)。經過一系列關於內存對齊和保留的運算後,該API保證最終創建出的執行棧滿足下列所有條件:

  • final_valid_sz >= 4096
  • final_valid_sz >= sz
  • final_valid_sz % page_size == 0 if the guard_page_enabled == 0

並且儘可能的接近入參sz的值。

當第二入參guard_page_enabled的值為1時,創建的執行棧將會擁有一個只讀的用來檢測執行棧溢出的 "guard page",為0時則不會擁有這樣的 "guard page" 。

此函數總是成功地返回一個可用的執行棧。

aco_share_stack_destroy

void aco_share_stack_destroy(aco_share_stack_t* sstk);

銷毀執行棧sstk

在銷毀執行棧sstk之前,請確定所有使用這個執行棧的協程已經全部被銷毀。

aco_create

typedef void (*aco_cofuncp_t)(void);aco_t* aco_create(aco_t* main_coaco_share_stack_t* share_stack, size_t save_stack_sz, aco_cofuncp_t co_fp, void* arg);

創建一個新的協程。

如果想創建一個main co,直接調用:aco_create(NULL, NULL, 0, NULL, NULL)。Main co是一個特殊的standalone coroutine,它的執行棧是當前線程默認的執行棧。在一個線程中,main co 是被第一個創建並且是在所有其他non-main coroutine之前就已經開始運行了的協程。

如果想使用此API創建一個non-main co:

  • 第一個入參main_co指向當前線程中的main co,創建出的non-main co以後在調用API aco_yield時將會將執行流程轉交給入參main_co指向的main co,入參main co必然非NULL;
  • 第二個入參share_stack指向要創建的non-main co以後要使用的執行棧。share_stack 必然非NULL。
  • 第三個入參save_stack_sz指定要創建的non-main co的私有保存棧的初始大小,其單位為位元組。值0表示使用默認的初始大小64位元組。由於在以後的non-main co執行過程中,如果其私有保存棧不夠大時將會進行自動地大小調整,所以一般情況下,用戶不需要擔心它的值。但是,如果有巨量的協程(比如一千萬個)相繼的進行大小調整,將會給內存分配器帶來一些性能衝擊,所以一個更加明智的選擇是,給入參save_stack_sz賦予一個協程運行期間保存棧需要的最大值(即co->save_stack.max_cpsz的值),查閱 "最佳實踐" 部分以獲得與此相關的更多優化信息。
  • 第四個入參co_fp是要創建non-main co的入口函數指針。co_fp必然非NULL。
  • 最後一個入參arg為一個指針值,將會設置為要創建non-main co的co->arg的值,co->arg一般用來作為協程的輸入參數。

此API將會永遠地成功返回一個可用的協程。同時,我們定義aco_create返回的non-main co處於 "init" 狀態。

aco_resume

void aco_resume(aco_t* co);

從調用者處Yield出來並開始或者繼續協程co的執行。

此API的調用者必須是main co並且必須是co->main_co,入參co必須是non-main co。

第一次Resume協程co時,將會開始co的執行(函數指針co->fp指向的函數)。如果協程co已經Yielded,aco_resume將會繼續co的執行。

在API aco_resume被調用之後,我們定義調用者 -- main co 的狀態為 "yielded" 。

aco_yield

void aco_yield();

從調用者co處Yield出來並且Resume co->main_co的執行。

此API的調用者必須為non-main co,co->main_co必須非NULL。

在API aco_yield被調用之後,我們定義co的狀態為 "yielded" 。

aco_get_co

aco_t* aco_get_co();

返回當前non-main co的指針。此API的調用者必須是non-main co。

aco_get_arg

void* aco_get_arg();

等價於(aco_get_co()->arg)。同樣的,此API的調用者必須是non-main co。

aco_exit

void aco_exit();

除了與aco_yield()一樣的功能之外,aco_exit()會另外設置co->is_end為1,以標誌co的狀態為 "end" 。

aco_destroy

void aco_destroy(aco_t* co);

銷毀協程co。入參co必須非NULL。如果co是一個non-main co,此API也會同時銷毀co的私有保存棧。

MACROS

Version

#define ACO_VERSION_MAJOR 1#define ACO_VERSION_MINOR 2#define ACO_VERSION_PATCH 2

這三個關於libaco版本值的宏定義在頭文件aco.h中,它們的值遵守標準:Semantic Versioning 2.0.0。

aco_assert_override.h

// provide the compiler with branch prediction information#define likely(x) aco_likely(x)#define unlikely(x) aco_unlikely(x)// override the default `assert` for convenience when coding#define assert(EX) aco_assert(EX)// equal to `assert((ptr) != NULL)`#define assertptr(ptr) aco_assertptr(ptr)// assert the successful return of memory allocation#define assertalloc_bool(b) aco_assertalloc_bool(b)#define assertalloc_ptr(ptr) aco_assertalloc_ptr(ptr)

像源文件test_aco_synopsis.c 所做的那樣,用戶可以選擇在自己的應用源碼中include頭文件"aco_assert_override.h"來替換掉C默認的 "assert" 以及定義除了assert之外的其它五個宏(如上所示)。因為C的 "assert" 也是一個宏定義,所以在include頭文件 "aco_assert_override.h" 時,應該將它放到源文件中所有include指令中的最後一個。如果在一個源文件中,用戶想要在某個源文件中使用默認的C "assert",請不要在其中include這個頭文件。

閱讀源文件aco_assert_override.h以獲得關於此的更多信息。

Benchmark

Date: Sat Jun 30 UTC 2018.

Machine: c5d.large on AWS.

OS: RHEL-7.5 (Red Hat Enterprise Linux 7.5).

下面是關於性能測試部分的一個摘要描述:

  • 一次協程間上下文切換僅耗時 10.29 ns (協程擁有獨立的運行棧,並且協程間共享FPU與MXCSR控制字配置的情況下);
  • 一次協程間上下文切換僅耗時 10.38 ns (協程擁有獨立的運行棧,並且各協程均維護一份屬於各自的FPU與MXCSR控制字配置的情況下);
  • 極致的內存使用率:一千萬個協程並發執行僅消耗2.8GB的物理內存(tcmalloc,每一個協程使用120B的複製棧)。

libaco性能測試的詳細數據報告?

github.com

Proof of Correctness

首先,在開始實現或者證明一個協程庫之前,必備的條件是要對Sys V ABI of intel386 and x86-64標準非常的熟悉,以及一些基礎的彙編知識。

接下來的證明中並沒有包含關於IP(指令指針),SP(堆棧指針)和協程的私有保存棧與共享執行棧之間的保存與恢復的直接描述,因為相比於ABI約束的保證,這些東西是相當微不足道且容易實現和理解的。

Running Model

在一個OS線程中,主協程main_co是被第一個創建並且是在所有其他non-main coroutine之前就已經開始運行了的協程。

下圖是協程main co與co之間上下文切換的簡單圖示。

在這個證明中,我們假定我們的二進位程序要滿足Sys V ABI intel386標準,因為Sys V ABI intel386與Sys V ABI x86-64之間沒有根本的不同。為了簡化描述,我們還假定二進位程序中沒有會更改FPU或MXCSR控制字的代碼存在。

下圖實際上是對稱協程的運行模型圖(擁有不限量個non-main co和一個main co)。因為非對稱協程僅僅是對稱協程的一種特殊情況,所以我們如果證明了對稱協程的正確性也就等於證明了非對稱協程的正確性,如此會多些挑戰性同時也會多些樂趣(libaco當前只實現了非對稱協程的API,因為非對稱協程的API語義遠遠比對稱協程的API語義更容易理解和掌控)。

因為main co是在當前OS線程中第一個開始運行的協程,那麼第一次協程間上下文切換一定是以acosw(main_co, co)這種形式存在的(這裡,acosw的第二個入參co是一個non-main co)。

Mathematical Induction

容易證明,在上圖中只存在兩類協程間的狀態遷移:

  • yielded state co → init state co
  • yielded state co → yielded state co

要證明協程上下文切換函數void* acosw(aco_t* from_co, aco_t* to_co)的正確性,就等於要證明所有的協程在調用acosw前後都一直滿足Sys V ABI規範的約束。我們假定協程中除了acosw之外的所有二進位均已經滿足了ABI規範(它們一般是由編譯器正確地生成的)。

下面是Sys V ABI Intel386函數調用約定中寄存器用法的總結:

Registers usage in the calling convention of the Intel386 System V ABI: caller saved (scratch) registers: C1.0: EAX At the entry of a function call: could be any value After the return of `acosw`: hold the return value for `acosw` C1.1: ECX,EDX At the entry of a function call: could be any value After the return of `acosw`: could be any value C1.2: Arithmetic flags, x87 and mxcsr flags At the entry of a function call: could be any value After the return of `acosw`: could be any value C1.3: ST(0-7) At the entry of a function call: the stack of FPU must be empty After the return of `acosw`: the stack of FPU must be empty C1.4: Direction flag At the entry of a function call: DF must be 0 After the return of `acosw`: DF must be 0 C1.5: others: xmm*,ymm*,mm*,k*... At the entry of a function call: could be any value After the return of `acosw`: could be any value callee saved registers: C2.0: EBX,ESI,EDI,EBP At the entry of a function call: could be any value After the return of `acosw`: must be the same as it is at the entry of `acosw` C2.1: ESP At the entry of a function call: must be a valid stack pointer (alignment of 16 bytes, retaddr and etc...) After the return of `acosw`: must be the same as it is before the call of `acosw` C2.2: control word of FPU & mxcsr At the entry of a function call: could be any configuration After the return of `acosw`: must be the same as it is before the call of `acosw` (unless the caller of `acosw` assume `acosw` may change the control words of FPU or MXCSR on purpose like `fesetenv`)

(對於Intel386,寄存器的用途定義在Sys V ABI Intel386 V1.1的 "P13 - Table 2.3: Register Usage" 表中,對於AMD64則定義在Sys V ABI AMD64 V1.0的 "P23 - Figure 3.4: Register Usage" 的圖中。)

Proof:

  1. yielded state co -> init state co:

上圖詳細地描繪了第一類狀態遷移的過程: "yielded state co -> init state co" .

約束: C 1.0, 1.1, 1.2, 1.5 (滿足 ? )

下面列出的Scratch Registers在一個函數的入口點時其值可以為任意值:

EAX,ECX,EDXXMM*,YMM*,MM*,K*...status bits of EFLAGS,FPU,MXCSR

約束: C 1.3, 1.4 (滿足 ? )

由於在acosw被調用之前,FPU棧必然已空並且DF必然已為0(因為協程co的二進位代碼已經滿足ABI規範),所以,acosw滿足約束C1.3和1.4。

約束: C 2.0, 2.1, 2.2 (滿足 ? )

約束C2.0和2.1已經被滿足。由於我們已假定FPU與MXCSR的控制字在程序運行過程中不會被更改,所以約束C2.2也已經被acosw滿足。

2. yielded state co -> yielded state co:

上圖詳細地描繪了第二類狀態遷移的過程: yielded state co -> yielded state co.

約束: C 1.0 (滿足 ? )

很顯然,當acosw返回到to_co時EAX中已經保存了預期的返回值。

約束: C 1.1, 1.2, 1.5 (滿足 ? )

下面列出的Scratch Registers在一個函數的入口點時以及在acosw返回後其值皆可為任意值:

ECX,EDXXMM*,YMM*,MM*,K*...status bits of EFLAGS,FPU,MXCSR

約束: C 1.3, 1.4 (滿足 ? )

由於在acosw被調用之前,FPU棧必然已空並且DF必然已為0(因為協程co的二進位代碼已經滿足ABI規範),所以,acosw滿足約束C1.3和1.4。

約束: C 2.0, 2.1, 2.2 (滿足 ? )

acosw調用者的角度來看,由於在acosw被調用(或返回)時,所有的callee saved registers都做了對應的保存(或恢復)工作,則約束C2.0與2.1被acosw滿足。由於我們已假定FPU與MXCSR的控制字在程序運行過程中不會被更改,所以約束C2.2也已經被acosw滿足。

3. Mathematical induction:

顯然,在當前OS線程中,第一次acosw必然屬於第一類狀態遷移:yielded state co -> init state co,並且接下來的所有acosw必然屬於這兩類狀態遷移的其中一類。順序地用上面得到兩個結論依次證明,最終得到「所有的協程在調用acosw前後都一直滿足Sys V ABI規範的約束」結論。如此,證明結束。

Miscellaneous

Red Zone

在System V ABI x86-64中描述red zone的概念:

The 128-byte area beyond the location pointed to by %rsp is considered to be reserved and shall not be modified by signal or interrupt handlers. Therefore, functions may use this area for temporary data that is not needed across function calls. In particular, leaf functions may use this area for their entire stack frame, rather than adjusting the stack pointer in the prologue and epilogue. This area is known as the red zone.

由於red zone "not preserved by the callee" ,所以我們在協程的上下文切換的實現中無需考慮它(因為acosw是一個葉子函數,即leaf function)。

Stack Pointer

The end of the input argument area shall be aligned on a 16 (32 or 64, if __m256 or __m512 is passed on stack) byte boundary. In other words, the value (%esp + 4) is always a multiple of 16 (32 or 64) when control is transferred to the function entry point. The stack pointer, %esp, always points to the end of the latest allocated stack frame.

— Intel386-psABI-1.1:2.2.2 The Stack Frame

The stack pointer, %rsp, always points to the end of the latest allocated stack frame.

— Sys V ABI AMD64 Version 1.0:3.2.2 The Stack Frame

這是騰訊libco中的一個bug。ABI規範中規定用戶空間程序的棧指針必須時刻指到運行棧的棧頂,而coctx_swap.S中卻使用棧指針直接對位於堆中的數據結構進行定址內存操作,這違反了ABI約定。

By default, the signal handler is invoked on the normal process stack. It is possible to arrange that the signal handler uses an alternate stack; see sigalstack(2) for a discussion of how to do this and when it might be useful.

— man 7 signal : Signal dispositions

當coctx_swap正在用棧指針對位於堆中的數據結構進行定址內存操作時,若此時執行線程收到了一個信號,接著內核搶佔了該執行線程並開始準備接下來用戶空間線程的信號處理執行環境,由於在默認情況下,內核將會選擇主棧作為信號處理函數的執行棧,但此時棧已經被指向了堆中(用戶空間的程序違反ABI約定在先),那麼信號處理函數的執行棧就會被錯誤的放置到堆中,這樣,堆中的數據結構在接下來就極有可能會被破壞(更詳細的bug復現請參見此issue)。

Best Practice

總的來說,如果你想把libaco的性能發揮到極致,一定要保證 "non-standalone non-main co" 在調用aco_yield時的執行棧使用儘可能的小。另外,當你想把一個協程的局部變數的地址傳遞到另一個協程時一定要非常小心,因為如果這個變數是在共享棧上時,將可能會發生內存數據混亂,因此,總是從堆中分配需要在協程間共享的內存是一個非常明智的選擇。

詳細地說,有五點建議:

co_fp / / f1 f2 / / / f4 yield f3 f5

  1. Main co的執行棧使用大小對協程間上下文切換的性能沒有直接影響(因為main co獨佔了線程的默認執行棧);
  2. Standalone non-main co的執行棧使用大小對協程間上下文切換的性能沒有直接影響(因為它獨佔了一個執行棧)。但是創建海量的standalone non-main co將會消耗海量的虛擬內存(因為海量執行棧的創建),因此,應用中並不推薦在一個線程中創建海量的standalone non-main co;
  3. Non-standalone non-main co(與其他協程共享執行棧的非主協程)在調用aco_yield時執行棧的使用大小將會對協程間上下文切換的性能產生直接的影響,性能測試部分已經清楚的展示了這一點。在上圖中,函數f2,f3,f4與f5的棧使用量對上下文切換的性能沒有影響,這是因為在它們執行的過程中並沒有aco_yield函數的來中斷它們。然而,函數co_fp與f1的棧使用量之和將會決定co->save_stack.max_cpsz(協程運行期間私有保存棧的最大保存大小)的值,同時會對上下文切換的性能產生直接的影響;

讓一個函數擁有儘可能低的棧使用量的關鍵是儘可能地從堆中分配局部變數(尤其是佔用內存較大的變數)並手動地管理它們的生命周期(malloc/free),而非默認地從堆棧上分配和自動釋放它們。C編譯器gcc的選項-fstack-usage對此非常有用。

int* gl_ptr;void inc_p(int* p){ (*p)++; }void co_fp0() { int ct = 0; gl_ptr = &ct; // line 7 aco_yield(); check(ct); int* ptr = &ct; inc_p(ptr); // line 11 aco_exit();}void co_fp1() { do_sth(gl_ptr); // line 16 aco_exit();}

4. 在上面的代碼片段中,我們假定協程co_fp0與co_fp1共享同一個執行棧,它們均是non-main co,它們的執行順序為 "co_fp0 -> co_fp1 -> co_fp0" 。因為它們共享同一個執行棧,在代碼第16行gl_ptr中的指針值與代碼第7行gl_ptr中的指針值二者的語義是不同的,這樣的用法很可能會破壞協程co_fp1的執行棧。而代碼第11行則是正確的,因為此時局部變數ct與函數inc_p的執行是在同一個協程上下文中的。從堆中分配需要在協程間共享的內存能夠很簡單地解決這類問題:

int* gl_ptr;void inc_p(int* p){ (*p)++; }void co_fp0() { int* ct_ptr = malloc(sizeof(int)); assert(ct_ptr != NULL); *ct_ptr = 0; gl_ptr = ct_ptr; aco_yield(); check(*ct_ptr); int* ptr = ct_ptr; inc_p(ptr); free(ct_ptr); gl_ptr = NULL; aco_exit();}void co_fp1() { do_sth(gl_ptr); aco_exit();}

Donation

我是一位自由的全職開源項目開發者,任何數量的捐贈對我都將會是莫大的鼓勵 ;-)

捐贈?

github.com圖標

Copyright and License

Copyright (C) 2018, by Sen Han 00hnes@gmail.com.

Under the Apache License, Version 2.0.

See the LICENSE file for details.


推薦閱讀:

性能測試筆記(一):吞吐量與並發數
Python操作rabbitmq系列(三):多個接收端消費消息
高並發和高性能系統中鎖的影響
互聯網高並發大流量訪問的處理及解決方法
高並發和高性能系統中進程、線程、協程、隊列的詳解,以及各運行模式的對比

TAG:協程 | 高性能 | 高並發 |