深入理解GO語言之併發機制

奇犽發表於2019-03-04

前言:可以說GO真正吸引到我的就是併發這塊了,深入理解這個機制後讓我收益匪淺,接下來就用自己薄弱的認知來談談GO的併發機制。

一,初始化過程

在這之前,先看下asm_arm64.s中的彙編程式碼關於啟動這塊的邏輯

CALL    runtime·args(SB)
CALL    runtime·osinit(SB)
CALL    runtime·hashinit(SB)
CALL    runtime·schedinit(SB)

// create a new goroutine to start program
PUSHQ    $runtime·main·f(SB)        // entry
PUSHQ    $0            // arg size
CALL    runtime·newproc(SB)
POPQ    AX
POPQ    AX

// start this M
CALL    runtime·mstart(SB)複製程式碼

接下來就進入分析環節

1,通過osinit函式還獲取cpu個數和page的大小,這塊挺簡單的
2,接下來看看schedinit函式(跟本節相關的重要程式碼)

func schedinit() {
    //獲取當前的G
    _g_ := getg()
    if raceenabled {
        _g_.racectx, raceprocctx0 = raceinit()
    }
    //設定M的最大數量
    sched.maxmcount = 10000
    //初始化棧空間
    stackinit()
    //記憶體空間初始化操作
    mallocinit()
    //初始化當前的M
    mcommoninit(_g_.m)

    //將P的數量調整為CPU數量
    procs := ncpu
    if n, ok := atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 {
        procs = n
    }
    if procs > _MaxGomaxprocs {
        procs = _MaxGomaxprocs
    }
    //初始化P
    if procresize(procs) != nil {
        throw("unknown runnable goroutine during bootstrap")
    }

}複製程式碼

3,上面我們可以看到呼叫了procresize函式來初始化P,那麼我們來看下procresize函式。這塊程式碼過長,分幾個部分解析(只貼重要的程式碼)
(1) 初始化新的P

for i := int32(0); i < nprocs; i++ {
        pp := allp[i]
        if pp == nil {
            //新建一個P物件
            pp = new(p)
            pp.id = i
            pp.status = _Pgcstop
            //儲存到allp陣列(負責儲存P的陣列)
            atomicstorep(unsafe.Pointer(&allp[i]), unsafe.Pointer(pp))
        }
        //如果P還沒有cache,那麼進行分配
        if pp.mcache == nil {
            if old == 0 && i == 0 {
                if getg().m.mcache == nil {
                    throw("missing mcache?")
                }
                pp.mcache = getg().m.mcache // bootstrap
            } else {
                pp.mcache = allocmcache()//分配cache
            }
        }
    }複製程式碼

(2) 釋放沒被使用的P

for i := nprocs; i < old; i++ {
        p := allp[i]
        // 將本地任務新增到全域性佇列中
        for p.runqhead != p.runqtail {
            p.runqtail--
            gp := p.runq[p.runqtail%uint32(len(p.runq))].ptr()
            // 插入全域性佇列的頭部
            globrunqputhead(gp)
        }
        //釋放P所繫結的cache
        freemcache(p.mcache)
        p.mcache = nil
        //將當前的P的G複用連結到全域性
        gfpurge(p)
        p.status = _Pdead
        // can`t free P itself because it can be referenced by an M in syscall
    }複製程式碼

經過這兩個步驟後,那麼我們就建立了一批的P,閒置的P會被放進排程器Sched的空閒連結串列中

二,建立G的過程

從上面的彙編程式碼可以看出接下來會去呼叫newproc函式來建立主G,然後用這個主函式去執行runtime.main,然後建立一個執行緒(這個執行緒在執行期間專門負責系統監控),接下來就進入GO程式中的main函式去執行了。
先看下newproc程式碼

func newproc(siz int32, fn *funcval) {
    argp := add(unsafe.Pointer(&fn), sys.PtrSize)//獲取引數的地址
    pc := getcallerpc(unsafe.Pointer(&siz))//獲取呼叫方的PC支
    systemstack(func() {
        newproc1(fn, (*uint8)(argp), siz, 0, pc)//真正建立G的地方
    })
}複製程式碼

接下來看下newpro1的主要程式碼

func newproc1(fn *funcval, argp *uint8, narg int32, nret int32, callerpc uintptr) *g {
    //從當前P複用連結串列來獲取G
    _p_ := _g_.m.p.ptr()
    newg := gfget(_p_)
    //如果獲取失敗,則新建一個
    if newg == nil {
        newg = malg(_StackMin)
        casgstatus(newg, _Gidle, _Gdead)
        allgadd(newg) 
    }
    //將得到的G放入P的執行佇列中
    runqput(_p_, newg, true)
    //下面三個條件分別為:是否有空閒的P;M是否處於自旋狀態;當前是否建立runteime.main
    if atomic.Load(&sched.npidle) != 0 && atomic.Load(&sched.nmspinning) == 0 && runtimeInitTime != 0 {
        wakep()
    }

}複製程式碼

這個wakep()函式的程式碼也是值得一看的,這個思想可以用到平時的程式碼程式設計中去

func wakep() {
    //執行緒被喚醒後需要繫結一個P,這裡使用cas操作,可以避免喚醒過多執行緒,這裡也對應了上面的三個判斷條件之一
    if !atomic.Cas(&sched.nmspinning, 0, 1) {
        return
    }
    startm(nil, true)
}複製程式碼

startm的程式碼就留給讀者自己去看了,不然感覺整個博文都是程式碼,主要的思想是:獲取一個空閒的P(如果傳入的P為空),然後先嚐試獲取空閒M(空閒的M被排程器schedt管理,這個結構體也可以去看下),獲取不到再去建立一個M等。

三,Channel

這塊就稍微比較簡單了,程式碼也不多,但是看下來收穫還是很多的

1,建立Channel

先看下結構體定義(有刪減)

type hchan struct {
    qcount   uint           // 佇列中資料個數
    dataqsiz uint           // 緩衝槽大小
    buf      unsafe.Pointer // 指向緩衝槽的指標
    elemsize uint16         // 資料大小
    closed   uint32         // 表示 channel 是否關閉
    elemtype *_type // 資料型別
    sendx    uint   // 傳送位置索引
    recvx    uint   // 接收位置索引
    recvq    waitq  // 接收等待列表
    sendq    waitq  // 傳送等待列表
    lock mutex      // 鎖
}
type sudog struct {
    g          *g
    selectdone *uint32 // CAS to 1 to win select race (may point to stack)
    next       *sudog
    prev       *sudog
    elem       unsafe.Pointer // data element (may point to stack)
    waitlink    *sudog // g.waiting list or semaRoot
    waittail    *sudog // semaRoot
    c           *hchan // channel
}複製程式碼

上面的recvq其實是讀操作阻塞在channel的G列表,sendq其實是寫操作阻塞在channel的G列表,那麼G可以同時阻塞在不同的channel上,那麼如何解決呢?這時候就引入了sudog,它其實是對G的一個包裝,代表在等待佇列上的一個G。

接下來看看建立過程

func makechan(t *chantype, size int64) *hchan {
    elem := t.elem

    // 大小不超過64K
    if elem.size >= 1<<16 {
        throw("makechan: invalid channel element type")
    }
    var c *hchan
    // 整個建立過程還是簡單明瞭的
    if elem.kind&kindNoPointers != 0 || size == 0 {
        //一次性分配記憶體
        c = (*hchan)(mallocgc(hchanSize+uintptr(size)*elem.size, nil, true))
        if size > 0 && elem.size != 0 {
            c.buf = add(unsafe.Pointer(c), hchanSize)
        } else {
            c.buf = unsafe.Pointer(c)
        }
    } else {
        c = new(hchan)
        c.buf = newarray(elem, int(size))
    }
    //設定資料大小,型別和緩衝槽大小
    c.elemsize = uint16(elem.size)
    c.elemtype = elem
    c.dataqsiz = uint(size)

    return c
}複製程式碼

2,傳送

send函式的程式碼有點長,接下來就拆分進行說明
(1) 如果recvq有G在阻塞,那麼就從該佇列取出該G,將資料給該G

if sg := c.recvq.dequeue(); sg != nil {
        // Found a waiting receiver. We pass the value we want to send
        // directly to the receiver, bypassing the channel buffer (if any).
        send(c, sg, ep, func() { unlock(&c.lock) }, 3)
        return true
    }複製程式碼

(2) 如果hchan.buf還有可用的空間,那麼就將資料放入

//通過比較qcount和datasiz來判斷是否還有可用空間
if c.qcount < c.dataqsiz {
        // 將資料放入buf中
        qp := chanbuf(c, c.sendx)
        if raceenabled {
            raceacquire(qp)
            racerelease(qp)
        }
        typedmemmove(c.elemtype, qp, ep)
        c.sendx++
        if c.sendx == c.dataqsiz {
            c.sendx = 0
        }
        c.qcount++
        unlock(&c.lock)
        return true
    }複製程式碼

(3) hchan.buf滿了,那麼就會阻塞住了

// Block on the channel. Some receiver will complete our operation for us.
gp := getg()
mysg := acquireSudog()
mysg.releasetime = 0
if t0 != 0 {
    mysg.releasetime = -1
}
//初始化一些引數
mysg.elem = ep         
mysg.waitlink = nil
mysg.g = gp
mysg.selectdone = nil
mysg.c = c
gp.waiting = mysg
gp.param = nil
// 將當前 goroutine加入等待佇列
c.sendq.enqueue(mysg)   
goparkunlock(&c.lock, "chan send", traceEvGoBlockSend, 3)複製程式碼

這裡我們就可以看到了,如果滿了,那麼sudog就會出現了,通過初始化後代表當前G進入等待佇列

3,接收

同理,接收也分為三種情況

(1) 當前有傳送goroutine阻塞在channel上,buf滿了

if sg := c.sendq.dequeue(); sg != nil {
        recv(c, sg, ep, func() { unlock(&c.lock) }, 3)
        return true, true
    }複製程式碼

(2) buf中有資料

if c.qcount > 0 {
        // 直接從佇列中接收
        qp := chanbuf(c, c.recvx)
        if ep != nil {
            typedmemmove(c.elemtype, ep, qp)
        }
        typedmemclr(c.elemtype, qp)
        c.recvx++
        if c.recvx == c.dataqsiz {
            c.recvx = 0
        }
        c.qcount--
        unlock(&c.lock)
        return true, true
    }複製程式碼

(3) buf中無資料了,那麼則會阻塞住

    gp := getg()
    mysg := acquireSudog()
    mysg.releasetime = 0
    if t0 != 0 {
        mysg.releasetime = -1
    }
    // 同樣的,由sudog代表G去排隊
    mysg.elem = ep
    mysg.waitlink = nil
    gp.waiting = mysg
    mysg.g = gp
    mysg.selectdone = nil
    mysg.c = c
    gp.param = nil
    c.recvq.enqueue(mysg)
    goparkunlock(&c.lock, "chan receive", traceEvGoBlockRecv, 3)複製程式碼

總結:雖然這塊程式碼邏輯不復雜,但是設計的東西很多,還是用了很多時間,現在對M執行G的邏輯是懂了,但是還不清楚細節,後面會繼續研究。總的讀下來,首先第一是對併發的機制可以說是很瞭解了,對以後在編寫相關程式碼肯定很有幫助。第二,學習到了一些程式設計思想,例如cas操作,如何更好的進行封裝和抽象等。

相關文章