Go并发初探

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,调度器

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

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

(1)调度详情

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

our_cast

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

in_motion

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

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

① 线程阻塞

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

② runqueue执行完成

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


6,总结


7,参考

http://morsmachine.dk/go-scheduler

猜你喜欢

转载自blog.csdn.net/sjy8207380/article/details/80044923
今日推荐