Go语言中goroutine的分析

Goroutine是Go里的一种轻量级线程——协程。相对线程,协程的优势就在于它非常轻量级,进行上下文切换的代价非常的小。对于一个goroutine ,每个结构体G中有一个sched的属性就是用来保存它上下文的。这样,goroutine 就可以很轻易的来回切换。由于其上下文切换在用户态下发生,根本不必进入内核态,所以速度很快。而且只有当前goroutine 的 PC, SP等少量信息需要保存。

在Go语言中,每一个并发的执行单元为一个goroutine。当我们开始运行一个Go程序时,它的入口函数 main 实际上就是运行在一个goroutine 里。

Goroutine之间的通信

Go 语言编写的程序通过不同的goroutine 运行,但是goroutine之间是相互独立的,各自运行在不同的上下文中。 每个 goroutine 之间的通信需要借助 channel ,channel 是Go 语言里的一种通信机制。Channel 也是Go语言里的一种引用类型,通过make函数,我们可以很容易的声明一个channel。


ch:=make(chanstring)

Channel 有单方向,双方向之分:

  • chan int, 双方向,可用来收发数据。

  • chan <- int,单方向,只能用来发送数据

  • <- chan int,单方向,只能用来接收数据

另外,channel还有有缓存无缓存之分。通过make函数创建一个带缓存的channel。


ch:=make(chanstring, n)

无缓存的channel保证了每次发送数据的同步接收操作。而带缓存的channel解耦了发送与接收间的操作,这样不但是影响程序的性能还有可能引起死锁的问题。

调度器sheduler

每个goroutine的运行都是由Go语言里的调度器(scheduler)决定的。

先说操作系统的线程调度。在POSIX 中有一个sheduler的内核函数,每过几ms会被执行一次。每次执行时,会挂起当前执行线程,同时保存它寄存器中信息,接着查看线程列表决定下一个线程的运行, 从内存中必复其寄存器信息和现场并开始执行。不同线程之间存在上下文切换,这包括保存一个用户线程的状态到内存,恢复另一个线程的信息到寄存器,同时还要更新sheduler相关的数据结构。这些操作都很耗时。

Go 语言的Runtime有自己的sheduler,通过它我们可以在n个操作系统的线程上调度m个goroutine。实际上Go 的sheduler与操作系统的sheduler是非常相似的,只不过它只关心goroutine的调度。与操作系统sheduler不同的是,Go的sheduler不使用硬件定时器,当一个goroutine 调用了time.Sleep、触发一个channel 操作或者使用 mutex, scheduler 会使这个 goroutine 进行睡眠,进而去唤醒另外一个goroutine,这种调度方式没有上下文之间的切换,它的代价比操作系统的线程调度要小得多。

Go的调度的实现,涉及到几个重要的数据结构。运行时库用这几个数据结构来实现goroutine的调度,管理goroutine和物理线程的运行。这些数据结构分别是结构体G,结构体M,结构体P,以及 Sched 结构体。这三个结构定义在文件 runtime/runtime.h 中,而Sched的定义在 runtime/proc.c 中。在Go语言中scheduler 通过一个GOMAXPROCS变量来决定有多少个操作系统的线程来运行Go程序,默认值为CPU的核心数。

结构体G

G 是 goroutine 的缩写,相当于操作系统中的进程控制块,在这里就是 goroutine 的控制结构,是对goroutine的抽象。其中包括 goid 是这个 goroutine 的ID, status 是这个goroutine 的状态,如 Gidle, Grunnable, Grunning, Gsyscall, Gwaiting,Gdead 等。


structG
{
    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
...
};

结构体G中的部分域如上所示。可以看到,其中包含了栈信息stackbase和stackguard,有运行的函数信息fnstart。这些就足够成为一个可执行的单元了,只要得到CPU就可以运行。

goroutine 切换时,上下文信息保存在结构体的 sched 域中。goroutine 是轻量级的线程或者称为协程,切换时并不必陷入到操作系统内核中,所以保存过程很轻量。看一下结构体 G 中的 Gobuf,其实只保存了当前栈指针,程序计数器,以及 goroutine 自身。


structGobuf
{

    // The offsets of these fields are known to (hard-coded in) libmach.
    uintptr    sp;
    byte*    pc;
    G*    g;
...
};

记录g是为了恢复当前goroutine的结构体G指针,运行时库中使用了一个常驻的寄存器extern register G* g,这个是当前goroutine的结构体G的指针。这样做是为了快速地访问goroutine中的信息,比如,Go的栈的实现并没有使用%ebp寄存器,不过这可以通过g->stackbase快速得到。”extern register”是由6c,8c等实现的一个特殊的存储。在ARM上它是实际的寄存器;其它平台是由段寄存器进行索引的线程本地存储的一个槽位。在linux系统中,对g和m使用的分别是0(GS)和4(GS)。需要注意的是,链接器还会根据特定操作系统改变编译器的输出,例如,6l/linux下会将0(GS)重写为-16(FS)。每个链接到Go程序的C文件都必须包含runtime.h头文件,这样C编译器知道避免使用专用的寄存器。

结构体M

M是machine的缩写,是对机器的抽象,每个m都是对应到一条操作系统的物理线程。M必须关联了P才可以执行Go代码,但是当它处理阻塞或者系统调用中时,可以不需要关联P。


structM
{
    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;
...
};

和G类似,M中也有alllink域将所有的M放在allm链表中。lockedg是某些情况下,G锁定在这个M中运行而不会切换到其它M中去。M中还有一个MCache,是当前M的内存的缓存。M也和G一样有一个常驻寄存器变量,代表当前的M。同时存在多个M,表示同时存在多个物理线程。结构体M中有两个G是需要关注一下的,一个是curg,代表结构体M当前绑定的结构体G。另一个是g0,是带有调度栈的goroutine,这是一个比较特殊的goroutine。普通的goroutine的栈是在堆上分配的可增长的栈,而g0的栈是M对应的线程的栈。所有调度相关的代码,会先切换到该goroutine的栈中再执行。

结构体P

Go1.1中新加入的一个数据结构,它是Processor的缩写。结构体P的加入是为了提高Go程序的并发度,实现更好的调度。M代表OS线程。P代表Go代码执行时需要的资源。当M执行Go代码时,它需要关联一个P,当M为idle或者在系统调用中时,它也需要P。有刚好GOMAXPROCS个P。所有的P被组织为一个数组,在P上实现了工作流窃取的调度器。


structP
{
    Lock;
    uint32    status;  // Pidle或Prunning等
    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中也有相应的状态:Pidle, Prunning, Psyscall, Pgcstop, Pdead。跟G不同的是,P 不存在waiting状态。跟G不同的是,P不存在waiting状态。MCache被移到了P中,但是在结构体M中也还保留着。在P中有一个Grunnable的goroutine队列,这是一个P的局部队列。当P执行Go代码时,它会优先从自己的这个局部队列中取,这时可以不用加锁,提高了并发度。如果发现这个队列空了,则去其它P的队列中拿一半过来,这样实现工作流窃取的调度。这种情况下是需要给调用器加锁的。

结构体Sched

Sched是调度实现中使用的数据结构,该结构体的定义在文件proc.c中。

structSched {
    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、G和P中,Sched结构体只是一个壳。可以看到,其中有M的idle队列,P的idle队列,以及一个全局的就绪的G队列。Sched结构体中的Lock是非常必须的,如果M或P等做一些非局部的操作,它们一般需要先锁住调度器。

Goroutine的特点

Goroutine是Go Runtime所提供的,并非操作系统层面上支持的,goroutine不是用线程实现的。goroutine就是一段代码,一个函数入口,以及在堆上为其分配的一个堆栈。这个栈通常很小,一般为2kB, 所以它非常廉价,我们可以很轻松的创建上成千万个goroutine,这是很普遍的。Goroutine 的栈大小不是固定的,这一点和操作系统的线程是不一样的,它可以动态的扩展,最大值可达1GB。

Goroutine是协作式调度的,如果goroutine会执行很长时间,而且不是通过等待读取或写入channel的数据来同步的话,就需要主动调用Gosched()来让出CPU。

Go语言封装了异步IO,所以可以写出貌似并发数很多的服务端,可即使我们通过调整GOMAXPROCS来充分利用多核CPU并行处理,其效率也不如我们利用IO事件驱动设计的、按照事务类型划分好合适比例的线程池。在响应时间上,协作式调度是硬伤。

每个goroutine是没有身份标识的,这是为了避免像Thread Local Storage那样被烂用,一个函数的行为不可能由其本身变量决定。

Goroutine最大的优点是在并发开发中实现了对线程池的动态扩展,不会由于某个任务的阻塞而导致死锁。随着其运行库的不断发展和完善及多核大行其道的年代,其优势会日益凸显。

下面来看一个实例。

实例:生产者消费者问题

通过goroutine实现生产者消费者问题,利用 channel 通信。只需要短短几行代码,我们自己根本不需要编写代码考虑线程的同步问题。

需要事先声明的变量,goods 是生产消费所共享的数据,声明为一个chan 类型,存放整型数据。接着声明一个随机数种子,根据系统时间生成伪随机数。done 也是一个 chan 类型,里面只存储一个空的struct,其作用是为了保证主线程在其它 goroutine 结束之后结束。

    var goods chan int
    var r  = rand.New(rand.NewSource(time.Now().UnixNano()))//定义一个随机数种子
    vardone chan struct{}

生产者函数,循环10次,依次向 goods里写入1到10,每写完一次后,随机睡眠1~3秒。


funcproduce()  {
    for i:=1; i<=10; i++ {
        goods <- i
        time.Sleep(time.Duration(r.Int31n(3))*time.Second)
}
    done <-struct{}{}
}

消费者函数,循环5次从 goods 里取值,每读完一次,随机睡眠1~5秒。


funcconsume() {
    for i:=0; i<5; i++ {
        good := <- goods
        fmt.Printf("The goods size is : %v\n",10-good+1)
        time.Sleep(time.Duration(r.Int31n(5))*time.Second)
    }
}

main 函数里开启一个goroutine 运行 produce 函数,两个goroutine 运行consume 函数。


funcmain()  {

    goods =make(chanint)
    done =make(chanstruct{})
    goproduce()
    goconsume()
    goconsume()
    <- done
}

output:

    The goods size is :10
    The goods size is :9
    The goods size is :8
    The goods size is :7
    The goods size is :6
    The goods size is :5
    The goods size is :4
    The goods size is :3
    The goods size is :2
    The goods size is :1

参考资料:

    原文作者:witchiman
    原文地址: https://www.jianshu.com/p/be04990895c7
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞