這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
select可以用來管理多個channel的讀寫,以及實現channel讀寫timeout等。select並不是以庫的形式提供,而是語言級支援的文法特性,因此select的實現主要由編譯器和runtime共同完成,本文將重點關注runtime部分。
select語句的執行主要由4個階段組成,依次是建立select對象,註冊所有的case條件,執行select語句,最後釋放select對象。這裡提到的select對象是底層runtime維護的一個Select結構,這個對象對Go程式來說基本是透明的。後面的內容中,我將稱這個select對象為選取器
。
選取器記憶體模型(Select)
這裡記憶體模型主要是描述的選取器在記憶體是如何布局的,是什麼樣的資料結構來維護的。源碼位於runtime/chan.c中,描述記憶體模型的函數主要是newselect
。newselect就是在記憶體上建立一個選取器。
描述選取器記憶體模型最重要的兩個結構體定義如下:
structScase{SudoGsg;// must be first member (cast to Scase)Hchan*chan;// chanbyte*pc;// return pcuint16kind;uint16so;// vararg of selected boolbool*receivedp;// pointer to received bool (recv2)};structSelect{uint16tcase;// total count of scase[]uint16ncase;// currently filled scase[]uint16*pollorder;// case poll orderHchan**lockorder;// channel lock orderScasescase[1];// one per case (in order of appearance)};
Scase
描述Go程式select語句中定義的case條件,也就是說Go程式中的一個case在runtime中就是用Scase這個結構來維護的。可以看到Scase
中有一個Hchan *chan
欄位,這個顯然就是每個case條件上操作的channel了。
Select
就是定義“選取器”的核心結構了,每個欄位當然都很重要,不過可以重點關注pollorder、lockorder、scase三個欄位。這裡先看一下Scase scase[1]
這個欄位的定義,可以猜到scase欄位就是用來儲存所有case條件的,但這裡卻只是定義了一個只有一個元素的數組,這怎麼夠儲存多餘1個case的情況呢???
此圖就是整個選取器的記憶體模型了,這一整塊記憶體結構其實也是由頭部結構
+資料結構
組成,頭部就是Select那一部分,對應上面提到的struct Select
,資料結構部分都是由數組構成。
scase
就是一個數組,數組元素為Scase類型,儲存每個case條件。
lockorder
指標指向的也是一個數組,元素為Hchan *
類型,儲存每個case條件中操作channel。
pollorder
是一個uint16的數組.
從頭部開始這一整塊記憶體是由一次類malloc(為什麼是類malloc,因為Go有自己的記憶體管理介面,不是採用的普通malloc)調用分配的,然後再將Select頭部結構中的lockorder和pollorder兩個指標分別指向正確的位置即可。當然,在一口氣分配這塊記憶體前,是事先算好了所有需要的記憶體的大小的。這裡特彆強調一次malloc分配所有需要的記憶體,就是想表達除了C/C++外還有哪門語言有這麼強的記憶體控制能力?其他語言(包括Go)在處理這種情況,手法應該差不多都是先New一個主要的對象,然後New這個主要對象欄位中需要的對象。當然,你可能會告訴我,這門語言有很好的記憶體管理系統,不在乎這樣的對象建立…呵呵。擁有記憶體的完全控制能力,也是系統軟體大量採用C/C++編寫的原因,也是其他語言的實現基本採用C/C++的原因吧。這個問題不能繼續扯下去了。
scase欄位被定義為1個元素的數組的問題還沒有解決。展示的是一個有6個case條件的選取器記憶體模型,可以看到lockorder、pollorder以及scase(黑色部分)都是6個單元的數組。注意,黑色部分的scase的第一個單元位於Select頭部結構記憶體空間中,這個單元就是struct Select
中定義的那個只有一個元素的scase數組了,在malloc分配這塊記憶體的時候,scase就只需要少分配一個單元就可以了,所以中可以看出只是多加了5個scase的儲存單元。這樣一來,scase欄位和lockorder、pollorder就沒有什麼兩樣了,殊途同歸。其實,Scase scase[1]
欄位完全可以定義為Scase *scase
嘛,但這樣要多浪費一個指標的記憶體空間。我還是很傾向這種扣位元組式的實現方式。
在newselect函數中建立好這塊記憶體空間後,就再也找不到填充scase、lockorder和pollorder三個數組的過程了,也就是建立好記憶體模型就結束了,還沒填資料呢,這是怎麼回事?填充選取器其實就是註冊case的過程。
到這裡,選取器就被建立好了,剩下的就是選取器如何工作了。
註冊case條件
瞭解了選取器的記憶體布局,也就是建立好了一個選取器,再看如何把所有case條件資料註冊到選取器中,重點看一下兩個函數吧:
static voidselectsend(Select *sel, Hchan *c, void *pc, void *elem, int32 so){i = sel->ncase;……………..sel->ncase = i+1;cas = &sel->scase[i];cas->pc = pc;cas->chan = c;cas->so = so;cas->kind = CaseSend;cas->sg.elem = elem;}
這個selectsend函數就是在碰到case條件是寫資料到channel的時候會調用。它會將Go程式中此case上的資料以及channel等資訊傳給選取器,填充在具體的Scase結構中,完成寫channel的case註冊。
static voidselectrecv(Select *sel, Hchan *c, void *pc, void *elem, bool *received, int32 so){i = sel->ncase;sel->ncase = i+1;cas = &sel->scase[i];cas->pc = pc;cas->chan = c;cas->so = so;cas->kind = CaseRecv;cas->sg.elem = elem;cas->receivedp = received;}
selectrecv和selectsend很像,它是在碰到case條件是從一個channel讀取資料的時候會被調用,以完成對讀channel的case註冊。同樣,也是事先將channel以及存放資料的記憶體傳遞給選取器,填充在一個Scase中。這裡由於是等待讀取資料,所以是把儲存資料的記憶體位址交給選取器,然後選取器在從channel取到資料後,將資料拷貝到這個記憶體裡。
case條件的註冊過程特別簡單,沒什麼複雜的內容,但這部分其實和編譯器很相關,比如只有一個case的select可以最佳化成直接操作channel等。
執資料列選取器
這部分是select的核心,主要包含選取器是如何管理case條件,如何讀寫對應的channel。
選取器和channel的互動是由selectgo()
這個函數實現的,這個函數有一點小長,不過過程其實很簡單的。下面貼一個此函數的代碼骨架。
static void*selectgo(Select **selp){sel = *selp;// 這裡是一個很重要的地方,pollorder數組依次填上每個case的編號[0, n],然後第二個for就// 是一個洗牌操作了,將pollorder數組中的編號給隨機打亂。目的當然就是為了case條件執行的// 隨機性。for(i=0; incase; i++)sel->pollorder[i] = i;for(i=1; incase; i++) {……….}// 這裡又來兩個遍曆所有case的迴圈,好蛋疼啊。這次做的事情就是將lockorder中的元素給排序// 一下。注意,lockorder數組中的元素是每個case對應的channel的地址。for(i=0; incase; i++) {…………..}for(i=sel->ncase; i-->0; ) {………}// sellock就是遍曆lockorder數組,然後將數組中的每個channel給加上鎖,因為後面不知道將// 操作哪個channel,乾脆就全部給加上好了???真暴力啊。上面對lockorder排序的目的也出來// 了,就是方便此處加鎖的時候,對lockorder中的channel去重。因為兩個case完全可能同時操// 作同一個channel,所以lockorder中可能儲存重複的channel了。sellock(sel);// 走到這裡,總算把準備工作給幹完了,將開始真正幹活了。loop:// 這個for迴圈就是按pollorder的順序去遍曆所有case,碰到一個可以執行的case後就中斷循// 環。pollorder中的編號在初始化階段就已經被洗牌了,所以是隨機挑了一個可以執行的case。for(i=0; incase; i++) {o = sel->pollorder[i];cas = &sel->scase[o];…..switch(cas->kind) {case CaseRecv:……..case CaseSend:…….case CaseDefault:……}}// 沒有找到可以執行的case,但有default條件,這個if裡就會直接退出了。if(dfl != nil) {…..}// 到這裡,就是沒有找到可以執行的case,也沒有default條件的情況了。// 把當前goroutine給入隊到每個case對應的channel的等待隊列中去。channel的等待隊列在// channel實現中已經詳細介紹了。for(i=0; incase; i++) {………..switch(cas->kind) {case CaseRecv:enqueue(&c->recvq, sg);break;case CaseSend:enqueue(&c->sendq, sg);break;}}// 入隊完後,就把當前goroutine給掛起等待發生一個可以執行的case為止。這裡同時也把所有// channel上的加鎖給解開了。runtime·park((void(*)(Lock*))selunlock, (Lock*)sel, "select");// 當前goroutine被喚醒開始執行了,再次把所有channel加鎖。還是暴力。sellock(sel);// 這一個遍曆case的for迴圈,很有意思。這裡就是本次select不會執行的那些case對應的// channel給出隊當前goroutine。就是不管它們了,已經找到了一個執行的目標case了。for(i=0; incase; i++) {cas = &sel->scase[i];if(cas != (Scase*)sg) {c = cas->chan;if(cas->kind == CaseSend)dequeueg(&c->sendq);elsedequeueg(&c->recvq);}}// 還是沒找到case,重新迴圈執行一遍。這種情況應該是goroutine被其他一些因素給喚醒了。if(sg == nil)goto loop;………..// 解鎖退出,完成了select的執行了。selunlock(sel);goto retc;// 這些goto的tag,都是針對每個case具體操作channel的過程。和channel的實現中介紹的差不多。asyncrecv:………goto retc;asyncsend:…………….goto retc;syncrecv:………………..goto retc;syncsend:……………..// select執行完退出的時候,不光是釋放選取器對象,還會返回pc。這個pc就是本次select執行的case// 的地址。只有把這個棧地址返回,才能繼續執行case條件中的語句。retc:pc = cas->pc;runtime·free(sel);return pc;}
雖然只是一個代碼骨架,也挺長的,估計也只有對照源碼才能更好的理解了。總之,select的執行邏輯還是有一點小複雜的。初看的時候,不是特別好理解。再總結一下我認為的幾個應該知道的地方:
- select語句的執行將會對涉及的所有channel加鎖,並不是只加鎖需要操作的channel。
- 對所有channel加鎖之前,存在一個對涉及到的所有channel進行堆排序的過程,目的就是為了去重。
- select並不是直接隨機播放一個可執行檔case,而是事先將所有case洗牌,再從頭到尾選擇第一個可執行檔case。
- 如果select語句是放置在for迴圈中,長期執行,會不會每次迴圈都經曆選取器的建立到釋放的4個階段???我可以明確的告訴你,目前必然是這樣子的,所以select的使用是有代價的,還不低。
select的實現核心部分其實就完了,最佳化的空間應該還是挺多的。
編譯器最佳化
雖然不懂編譯器,但還是大概掃了一眼編譯器中對select的實現部分。大概看一下cmd/gc/select.c代碼中的注釋,就可以瞭解到,編譯器其實對select有一定的最佳化。比如你寫的select沒有任何的case條件,那還建立選取器幹嘛呢;再或者你只有一個case條件,這顯然可以不用select嘛;即使有一個case+default,也是可以最佳化為非阻塞的直接操作channel啊。
這部分編譯器相關的最佳化可以詳細看walkselect()
函數。
註:本文是基於go1.1.2版本代碼。