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(chan string)
    Channel 有单方向,双方向之分:

    • chan int, 双方向,可用来收发数据。
    • chan <- int,单方向,只能用来发送数据
    • <- chan int,单方向,只能用来接收数据
      另外,channel还有有缓存无缓存之分。通过make函数创建一个带缓存的channel。

    ch := make(chan string, n)

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

    调度器scheduler

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

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

    Go 语言的Runtime有自己的scheduler,通过它我们可以在n个操作系统的线程上调度m个goroutine。实际上Go 的scheduler与操作系统的scheduler是非常相似的,只不过它只关心goroutine的调度。与操作系统scheduler不同的是,Go的scheduler不使用硬件定时器,当一个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 等。

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

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

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

    struct Gobuf
    {
        // 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)。需要注意的是,链接器还会根据特定操作系统改变编译器的输出,例如,linux下会将0(GS)重写为-16(FS)。每个链接到Go程序的C文件都必须包含runtime.h头文件,这样C编译器知道避免使用专用的寄存器。

    结构体M

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

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

    和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上实现了工作流窃取的调度器。

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

    结构体Sched

    Sched是调度实现中使用的数据结构,该结构体的定义在文件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、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())) //定义一个随机数种子
    var done chan struct{}
    生产者函数,循环10次,依次向 goods里写入1到10,每写完一次后,随机睡眠1~3秒。

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

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

    func consume() {
    	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 函数。

    func main()  {
    	goods = make(chan int)
    	done = make(chan struct{})
    	go produce()
    	go consume()
    	go consume()
    	<- 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
    
    

    原文链接:http://witchiman.top/2017/04/24/go-goroutine/


登录后回复
 

与 区块链大学 | qkldx.net 的连接断开,我们正在尝试重连,请耐心等待