欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

Go并发初探

程序员文章站 2022-06-20 14:32:15
...

1,前言

在这里我们不再赘述进程和线程的同步方式、并行程序和并发程序的关系、亦或是多进程与多线程的开销等基础内容,直接切入Go语言的并发线程模型和内部实现。

(1)多线程模型

多线程模型即用户级线程和内核级线程的不同连接方式。

① 多对一模型(M : 1)

将多个用户级线程映射到一个内核级线程,线程管理在用户空间完成。
此模式中,用户级线程对操作系统不可见(即透明)。
优点:
    这种模型的好处是线程上下文切换都发生在用户空间,避免的模态切换(mode switch),从而对于性能有积极的影响。

缺点:

    所有的线程基于一个内核调度实体即内核线程,这意味着只有一个处理器可以被利用,在多处理环境下这是不能够被接受的,本质上,用户线程只解决了并发问题,但是没有解决并行问题。

如果线程因为 I/O 操作陷入了内核态,内核态线程阻塞等待 I/O 数据,则所有的线程都将会被阻塞,用户空间也可以使用非阻塞的I/O,但是还是有性能及复杂度问题。

② 一对一模型(1:1)

将每个用户级线程映射到一个内核级线程。
每个线程由内核调度器独立的调度,所以如果一个线程阻塞则不影响其他的线程。

优点:

    在多核处理器的硬件的支持下,内核空间线程模型支持了真正的并行,当一个线程被阻塞后,允许另一个线程继续执行,所以并发能力较强。

缺点:

    每创建一个用户级线程都需要创建一个内核级线程与其对应,这样创建线程的开销比较大,会影响到应用程序的性能。

③ 多对多模型(M : N)

内核线程和用户线程的数量比为 M : N,内核用户空间综合了前两种的优点。

这种模型需要内核线程调度器和用户空间线程调度器相互操作,本质上是多个线程被绑定到了多个内核线程上,这使得大部分的线程上下文切换都发生在用户空间,而多个内核线程又可以充分利用处理器资源。

(2)并发机制的调度实现   

golang中的协程实现了M:N的线程模型,golang内置的调度器,可以让多核CPU中每一个CPU执行一个协程,理解goroutine机制的原理,关键是理解Go语言scheduler的实现。

① 调度器是如何工作的

Go语言中支撑整个scheduler实现的主要有4个重要结构,分别是G,P,M,调度器,
前三个定义在runtime.h中,调度器定义在proc.c中。

· G是goroutine实现的核心结构,它包含了栈,指令指针,以及其他对调度goroutine很重要的信息,例如其阻塞的channel。

· P结构是Processor,处理器,它的主要用途就是用来执行goroutine的,它维护了一个goroutine队列,即runqueue。Processor是让我们从N:1调度到N:M调度的重要部分。

· M结构是Machine,系统线程,它由操作系统管理的,goroutine就是跑在M之上的;M是一个很大的结构,里面维护小对象内存cache(mcache)、当前执行的goroutine、随机数发生器等等非常多的信息。

· Sched结构就是调度器,它维护有存储M和G的队列以及调度器的一些状态信息等。

在这里我们介绍一下runtime.GOMAXPROC函数,这个函数是指定P的数量而不是M的数量,这点需要注意。

2,G

一个G就相当于一个Goroutine,也与我们使用go语句欲并发执行的一个匿名或命名的函数相对应,我们使用go语句想Go语言的运行时系统提交了一个并发执行任务,而Go语言的运行时系统则会按照我们的要求并发地执行并完成这一任务。

在runtime.h中定义了这样一个结构体

struct G
{
    uintptr    stackguard;    // 分段栈的可用空间下界
    uintptr    stackbase;    // 分段栈的栈基址
    Gobuf    sched;        //进程切换时,利用sched域来保存上下文
    uintptr    stack0;
    FuncVal*    fnstart;        // goroutine运行的函数
    void*    param;        // 用于传递参数,睡眠时其它goroutine设置param,唤醒时此goroutine可以获取
    int16    status;        // 状态Gidle,Grunnable,Grunning,Gsyscall,Gwaiting,Gdead
    int64    goid;        // goroutine的id号
    G*    schedlink;
    M*    m;        // for debuggers, but offset not hard-coded
    M*    lockedm;    // G被锁定只能在这个m上运行
    uintptr    gopc;    // 创建这个goroutine的go表达式的pc
    ...
};

3,P

P是使G能够在M中运行的关键。Go语言的运行时系统会适时地让P与不同的M建立或断开关联,以使P中的那些可运行的G能在需要的时候及时获得运行时机。

前文说过,可以通过runtime.GOMAXPROCS改变单个Go程序间接拥有P的最大数量。每个P都需要关联一个M才能使其中的可运行的G得到执行。当M因系统调用进而被阻塞的时候,运行时系统会将M和与之关联的P分离开来。这时,如果这个P的可运行G队列中还有未被运行的G,那么运行时系统会找到一个空闲M,或创建出一个新的M,并与该P关联已满足这些G的运行需要。(p的最大数值为256)

struct P
{
    Lock;
    uint32    status;  // Pidle、Prunning、Psyscall、Pgcstop和Pdead
    P*    link;
    uint32    schedtick;   // 每次调度时将它加一
    M*    m;    // 链接到它关联的M (nil if idle)
    MCache*    mcache;

    G*    runq[256];
    int32    runqhead;
    int32    runqtail;

    // Available G's (status == Gdead)
    G*    gfree;
    int32    gfreecnt;
    byte    pad[64];
};
容器:P的可运行G队列、P的*G队列

4,M

一个M代表了一个内核线程,创建一个M的原因都是由于没有足够的M来关联P并运行其中的可运行的G。

M在被创建之初会被加入到全局的M列表中。紧接着,它的起始函数和准备关联的P会被设置。最后,运行时系统会为他们创建一个新的内核线程并与之相关联。为执行G做好了准备。

struct M
{
    G*    g0;        // 带有调度栈的goroutine
    G*    gsignal;    // signal-handling G 处理信号的goroutine
    void    (*mstartfn)(void);
    G*    curg;        // M中当前运行的goroutine
    P*    p;        // 关联P以执行Go代码 (如果没有执行Go代码则P为nil)
    P*    nextp;
    int32    id;
    int32    mallocing; //状态
    int32    throwing;
    int32    gcing;
    int32    locks;
    int32    helpgc;        //不为0表示此m在做帮忙gc。helpgc等于n只是一个编号
    bool    blockingsyscall;
    bool    spinning;
    Note    park;
    M*    alllink;    // 这个域用于链接allm
    M*    schedlink;
    MCache    *mcache;
    G*    lockedg;
    M*    nextwaitm;    // next M waiting for lock
    GCStats    gcstats;
    ...
};
g0是带有调度栈的goroutine,这是一个比较特殊的goroutine。普通的goroutine的栈是在堆上分配的可增长的栈,而g0的栈是M对应的线程的栈。

5,调度器

proc.c中有如下结构体

struct Sched {
    Lock;

    uint64    goidgen;

    M*    midle;     // idle m's waiting for work
    int32    nmidle;     // number of idle m's waiting for work
    int32    nmidlelocked; // number of locked m's waiting for work
    int3    mcount;     // number of m's that have been created
    int32    maxmcount;    // maximum number of m's allowed (or die)

    P*    pidle;  // idle P's
    uint32    npidle;  //idle P的数量
    uint32    nmspinning;

    // Global runnable queue.
    G*    runqhead;
    G*    runqtail;
    int32    runqsize;

    // Global cache of dead G's.
    Lock    gflock;
    G*    gfree;

    int32    stopwait;
    Note    stopnote;
    uint32    sysmonwait;
    Note    sysmonnote;
    uint64    lastpoll;

    int32    profilehz;    // cpu profiling rate
}

调度器容器:调度器的空闲M列表、调度器的空闲P列表、调度器的可运行G队列和调度器的*G列表

运行时系统容器:全局M列表、全局P列表和全局G列表。


6,总结

(1)调度详情

我们分别用三角形,矩形和圆形表示Machine Processor和Goroutine。

Go并发初探

在单核处理器的场景下,所有goroutine运行在同一个M系统线程中,每一个M系统线程维护一个Processor,任何时刻,一个Processor中只有一个goroutine,其他goroutine在runqueue中等待。一个goroutine运行完自己的时间片后,让出上下文,回到runqueue中。

Go并发初探

多核处理器的场景下,为了运行goroutines,每个M系统线程会持有一个Processor。

在正常情况下,scheduler会按照上面的流程进行调度,但是线程会发生阻塞等情况,看一下goroutine对线程阻塞等的处理。

① 线程阻塞

当正在运行的goroutine阻塞的时候,例如进行系统调用,会再创建一个系统线程(M1),当前的M线程放弃了它的Processor,P转到新的线程中去运行。
Go并发初探

② runqueue执行完成

当其中一个Processor的runqueue为空,没有goroutine可以调度。它会从另外一个上下文偷取一半的goroutine。

Go并发初探

7,参考

http://morsmachine.dk/go-scheduler