goroutine

執行緒歷史

執行緒有兩種實現方式:核心態執行緒和使用者態執行緒。早期,核心態執行緒由於概念清晰,對開發者友好,在與使用者態執行緒的競爭中勝出。但隨著網際網路的發展,使用者態執行緒憑藉其執行緒切換成本低、競態少等特點重新迴歸開發者視野,並逐步發展成最新的併發模型——協程。下面我們從執行緒切換和競態兩個方面介紹一下核心態執行緒和使用者態執行緒。

從執行緒切換的角度來講,程序與執行緒基本原理是一樣的。下圖展示了核心態執行緒切換的一個大概的過程:

goroutine

當前時刻,執行緒A正在執行。此時,來了一個時鐘中斷,系統由ring3的執行緒A跳轉到ring0的時鐘中斷handler中。當handler認為需要切換執行緒時,會將執行緒A的上下文儲存到執行緒A的控制塊中。

然後,handler根據排程演算法從就緒執行緒中選擇一個來執行,假設handler選擇了執行緒F來執行。handler會將執行緒F的上下文從其控制塊中載入到當前執行緒。

完成上下文儲存/載入工作後,handler退出,並跳轉到ring3。此時,ring3中執行的就是執行緒F了。

從上述流程我們可以發現,核心態執行緒有以下特點:

執行緒切換的時機由作業系統決定(搶佔式),執行緒無法對切換時機做任何假設。因此,多執行緒程式開發時必須考慮競態

執行緒切換時涉及到特權級的跳轉和執行緒上下文的儲存/載入

這就造成了核心態執行緒切換時的成本非常高,執行緒數量多時,執行緒切換的開銷甚至能超過業務程式碼。

下面,我們看一下使用者態執行緒的切換過程。下圖展示了使用者態執行緒切換的一個大致過程:

goroutine

當前時刻,執行緒A正在執行。執行緒A執行一段時間後主動退出,將其上下文儲存到執行緒A的控制塊中。

然後,執行緒A根據使用者程式碼從其他執行緒中選擇一個來執行。假設使用者程式碼要求執行緒A退出後執行緒F繼續執行。執行緒A會將的執行緒F的上下文載入到當前執行緒中,並跳轉到執行緒F的程式碼中執行。

各使用者態執行緒不斷的執行、退出,形成這樣一個序列:

A執行緒執行

A執行緒退出,選擇F來執行

F執行緒執行

F執行緒退出,選擇D來執行

D執行緒執行

D執行緒退出,選擇E來執行

。。。

A執行緒執行

A執行緒退出,選擇B來執行

從以上介紹中我們可以看到,沒有了時鐘中斷,某個執行緒執行時無法被強制退出,只有主動退出,其他執行緒才有執行機會。使用者態執行緒的排程就依靠各執行緒在合適的時機主動退出,讓其他執行緒獲得執行機會來進行。各使用者態執行緒彼此協作,推動程式的執行,因此,使用者態執行緒又稱作協程。

從上述流程我們可以看出,使用者態執行緒有以下特點:

各使用者態執行緒本質上是在一個單執行緒程序上執行的,執行緒排程的時機由使用者程式碼完全控制,因此不用考慮競態

執行緒切換過程不涉及特權級的跳轉

執行緒切換時也涉及到上下文的儲存/載入,但是各使用者態執行緒是在一個單執行緒程序上執行的,可以共享許多資料,因此使用者態執行緒上下文的資料量遠遠小於核心態執行緒上下文

從以上特點我們可以看到,使用者態執行緒切換的開銷非常低,且系統不會限制使用者態執行緒的數量,非常適合高併發。

ucontext

linux提供了ucontext庫用於實現使用者態執行緒,ucontext的意思為user context。ucontext庫定義的資料結構與宣告的函式在ucontext。h標頭檔案中。

ucontext庫使用結構體ucontext_t表示使用者上下文,ucontext_t的定義如下:

typedef struct ucontext{ unsigned long int uc_flags; struct ucontext *uc_link; stack_t uc_stack; mcontext_t uc_mcontext; __sigset_t uc_sigmask;} ucontext_t;

我們不考慮ucontext_t各欄位的具體含義,只從應用的角度考慮其包含了哪些資訊。ucontext_t包含了如下資訊:

使用者態執行緒執行時各暫存器的值,其中就包括eip和esp,eip指向程式碼執行到何處,esp指向棧指標指向何處

使用者態執行緒使用的棧資訊,當某個程序中有多個使用者態執行緒時,各執行緒使用獨立的棧,以使彼此互不影響

暫存器與棧資訊構成了一個基本的使用者態執行緒上下文。ucontext。h同時聲明瞭操作使用者上下文的函式,宣告如下:

int getcontext(ucontext_t *ucp);int setcontext(const ucontext_t *ucp);void makecontext(ucontext_t *ucp, void (*func)(), int argc, 。。。);int swapcontext(ucontext_t *oucp, ucontext_t *ucp);

各函式的用途如下:

getcontext用於獲取當前的使用者上下文,並儲存進ucp中

setcontext用於將ucp設定為當前上下文

makecontext用於修改getcontext獲得的使用者上下文ucp

swapcontext將當前的使用者上下文儲存到oucp,將ucp指向的使用者上下文設定為當前上下文

下面,我們透過一個程式演示一下ucontext的基本用法。程式碼如下:

#include #include ucontext_t uc;int main(int argc, char **argv){ int i = 0; getcontext(&uc); printf(“hello: %d\n”, i++); if (i < 3) { setcontext(&uc); }}

可能會有些困惑,但是我們依然可以猜測這段程式的輸出。沒錯,這段程式輸出了一下內容:

hello: 0hello: 1hello: 2

getcontext(&uc)呼叫時將當前使用者上下文儲存進uc變數。當前使用者上下文的eip指向了緊跟著getcontext的語句,也就是printf行。當然了,此時printf行還沒執行。

程式繼續執行,當執行到setcontext(&uc)時,程式的執行環境都恢復到了呼叫printf行之前的那一刻。於是我們的程式又繼續從printf行開始運行了。

goroutine

這裡有個問題,當我們呼叫setcontext(&uc)恢復到呼叫printf行前的那一刻時,棧變數i會不會恢復成0。

答案是不會的,因為我們的示例比較簡單,不是多執行緒的,所有的程式碼共享同一個棧,沒有切換棧,棧中的內容自然不會被改變了。

ucontext示例

下面,我們將用ucontext實現一個多執行緒的生產者-消費者模型,以此來演示使用者態執行緒的用法。程式的倉庫地址如下:

https://github。com/pandengyang/ucontext_demo。git

該程式分為三部分:主程式、生產者、消費者。主程式準備生產者和消費者的使用者上下文,然切換到生產者中執行。主程式程式碼如下:

#include #include ucontext_t uc_main; /* 主程式使用者上下文 */ucontext_t uc_producer; /* 生產者使用者上下文 */ucontext_t uc_consumer; /* 消費者使用者上下文 */int product;char stack_producer[1024 * 16]; /* 生產者棧 */char stack_consumer[1024 * 16]; /* 消費者棧 */void producer(void); /* 生產者宣告 */void consumer(void); /* 消費者宣告 */int main(int argc, char **argv){ getcontext(&uc_producer); uc_producer。uc_stack。ss_sp = stack_producer; uc_producer。uc_stack。ss_size = sizeof stack_producer; makecontext(&uc_producer, producer, 0); getcontext(&uc_consumer); uc_consumer。uc_stack。ss_sp = stack_consumer; uc_consumer。uc_stack。ss_size = sizeof stack_consumer; makecontext(&uc_consumer, consumer, 0); swapcontext(&uc_main, &uc_producer);}

我們建立一個使用者上下文時,只能透過修改已有使用者上下文的方式來進行,無法從零開始建立。因此,首先用getcontext獲取了當前的使用者上下文並將其賦值給生產者的上下文變數uc_producer。程式碼如下:

getcontext(&uc_producer);

因為生產者需要在一個單獨的執行緒中執行,所以生產者需要一個獨立的棧。下面的程式碼將為生產者執行緒設定一個獨立的棧:

uc_producer。uc_stack。ss_sp = stack_producer;uc_producer。uc_stack。ss_size = sizeof stack_producer;

uc_producer包含的資訊大多是主程式的使用者上下文,除了修改uc_producer的棧之外,還需要修改當前執行到何處的資訊(可以簡單地認為該資訊儲存在eip暫存器)。此時,生產者還沒有執行,所以只需要將生產者的入口函式設定到eip暫存器即可。設定eip暫存器的程式碼如下:

/* makecontext 是一個變參函式 * 第二個引數為入口函式 * 第三個引數為引數個數 * 剩餘引數為入口函式的引數 * * void makecontext(ucontext_t *ucp, void (*func)(), int argc, 。。。); */makecontext(&uc_producer, producer, 0);

然後,我們用同樣的方法為消費者配置使用者上下文。程式碼如下:

getcontext(&uc_consumer);uc_consumer。uc_stack。ss_sp = stack_consumer;uc_consumer。uc_stack。ss_size = sizeof stack_consumer;makecontext(&uc_consumer, consumer, 0);

最後,我們呼叫swapcontext將當前(主程式執行緒)使用者上下文儲存到uc_main中,然後切換到生產者的使用者上下文中。程式碼如下:

swapcontext(&uc_main, &uc_producer);

呼叫swapcontext後,程式將進入生產者中執行。

生產者用一個遞增的變數為product賦值,每賦值一次,相當於生產了一個數據。生產者執行緒每生產一個數據將切換到消費者。生產者程式碼如下:

#include #include #include extern ucontext_t uc_producer;extern ucontext_t uc_consumer;extern int product;void producer(void){ for (int i = 1; i < 10000; i++) { sleep(1); product = i; printf(“[producer] produce %d\n”, product); swapcontext(&uc_producer, &uc_consumer); }}

生產者的關鍵程式碼如下,該行程式碼將當前(生產者執行緒)使用者上下文儲存到uc_producer變數,然後切換到消費者的使用者上下文中。

swapcontext(&uc_producer, &uc_consumer);

生產者是一個函式,當呼叫swapcontext時,該函式還沒有結束,函式在沒有執行完的情況下就切換到其他函式中運行了。這是關鍵所在,大部分的程式(單核心態執行緒的程式),函式在返回之前是不會切換到其他函式中執行的。

當呼叫swapcontext行後,程式將進入消費者中執行。

消費者在一個迴圈中輸出product變數,每輸出一次,相當於消費了一個數據。消費者執行緒每消費一個數據將切換到生產者。消費者程式碼如下:

#include #include #include extern ucontext_t uc_producer;extern ucontext_t uc_consumer;extern int product;void consumer(void){ while (1) { sleep(1); printf(“[consumer] consume %d\n”, product); swapcontext(&uc_consumer, &uc_producer); }}

消費者的關鍵程式碼如下,該行程式碼將當前(消費者執行緒)使用者上下文儲存到uc_consumer變數,然後切換到生產者的使用者上下文中。

swapcontext(&uc_consumer, &uc_producer);

當呼叫swapcontext行後,程式將進入生產者中執行。

生產者和消費者透過主動讓出執行權並讓對方來執行的方式實現了使用者態多執行緒。程式的輸出如下:

[producer] produce 1[consumer] consume 1[producer] produce 2[consumer] consume 2[producer] produce 3[consumer] consume 3[producer] produce 4[consumer] consume 4[producer] produce 5[consumer] consume 5

goroutine

Golang的語法和執行時直接內建了對協程的支援,其底層是使用ucontext庫實現的,在Golang中,協程被稱作goroutine。

Golang執行時有一個複雜的排程器,能管理所有goroutine併為其分配執行時間。這個排程器在作業系統之上,將作業系統的執行緒與語言執行時的邏輯處理器繫結,並在邏輯處理器上執行goroutine。排程器在任何給定的時間,都會全面控制哪個goroutine要在哪個邏輯處理器上執行。

Golang執行時抽象出了三個概念用於描述其排程原理:

M 核心態執行緒

P 邏輯處理器

G goroutine

G為goroutine,也就是使用者上下文,包含程式碼、暫存器、棧等資訊。

M為核心態執行緒,G中的程式碼就是在M上執行的。

P為邏輯處理器,是一個聯通M與G的橋樑。M只有在關聯了P時才可以執行,因此我們可以認為G是在P中執行的。P中包含了一些G的資訊,這些G在該P中被排程執行。

P、G、M的關係示意圖如下:

goroutine

系統中有GOMAXPROCS(該值通常與CPU核心數相同)個P。

M從P中取出一個G,並執行該G。若P中的G空了,M嘗試從全域性G佇列中取出一個G來執行。若全域性G佇列中也沒有G可用,則從其他P中偷取一半G來執行。若其他P中也沒有G了,M將其P置為空閒狀態,M進入執行緒池睡眠。

若M發現其有很多G需要執行,處理不過來,而且有閒置的P。此時M將建立或者喚醒(從執行緒池)一個M,並將該M與閒置的P繫結來執行G。

當G執行channel讀寫、網路poll、定時器等操作會觸發排程,將當前G置為waiting狀態,該G不再執行,而P則繼續執行其他的G。當channel讀寫、網路poll、定時器等操作有結果時,對應的G會被放入全域性G佇列,等待排程。

當G執行阻塞系統呼叫時,當前M會與P脫離關係。P與其他的M關聯繼續執行G,當前M等待系統呼叫返回。

網路poll操作

Golang提供的網路介面,在使用者層是阻塞的,這樣符合開發者的思維。在執行時層面,是用epoll實現的非阻塞IO,為效能提供了保障。例如,下面的程式碼演示了一個網路操作,程式碼如下:

conn。Read(buf)Something()

這段程式碼從某個socket讀取資料,conn。Read在返回之前,Something不會被呼叫,這種同步的程式碼符合開發者的思維。同時,該讀取操作的底層使用epoll實現,不會引起當前核心態執行緒的阻塞。

網路操作中,所有檔案描述符都被設定成非阻塞的。當進行IO操作時,如果IO還沒準備好,則對應的G將被置waiting狀態,並被移到網路輪詢器。一旦某個網路IO操作完成,對應的G將放入全域性G佇列,等待排程。示意圖如下:

goroutine

阻塞系統呼叫

如果goroutine執行時進入到一個阻塞的系統呼叫,這樣M就會阻塞。阻塞的系統呼叫返回時無法像網路poll操作或讀寫channel那樣通知排程器,因此只能等待其返回。等待系統呼叫返回的過程中,與該M關聯的G將無法執行。

為了保證併發,Golang完全封裝了系統呼叫,保證進/出系統呼叫時會觸發排程。當進入系統呼叫時會執行以下操作:

會建立或喚醒(從執行緒池中)一個新的M

解除P與當前M的繫結關係

P與新的M繫結

透過以上操作,P中的其他G就可以繞過系統呼叫的等待繼續執行了。而老的M與G將等待系統呼叫的返回。

當系統呼叫返回時,老M發現自己沒有P了,無法繼續工作,於是執行如下操作:

將G放回全域性G佇列

進入執行緒池睡眠

下圖演示了當執行G3進入阻塞系統呼叫時排程器的執行的操作。如下所示:

goroutine

上圖中,當執行G3中的程式碼時進入了阻塞系統呼叫。此時,會新建或喚醒(從執行緒池)一個新的M2,M2將與P5繫結,繼續執行P5中的其他G。M1將阻塞以等待系統呼叫的返回,當系統呼叫返回時,M1將進入執行緒池進行休眠,G3將進入全域性G佇列,等待被排程。

搶佔式排程

Golang執行時能夠監測各個G的執行時間,當發現某個G執行時間過長時,會給該G打上一個標記。當G執行函式呼叫時會檢查是否有這個標記,如果有,則觸發排程,讓出執行權。

從原理上看,Golang的搶佔式排程還很初級。如果一個G長時間執行且沒有呼叫函式的操作,則該G不會被剝奪執行權。當然了,一個執行很久卻不呼叫函式的程式碼很少見。

覺得不錯,就請您轉發