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

参考资料:

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,013评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,205评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,370评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,168评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,153评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,954评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,271评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,916评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,382评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,877评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,989评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,624评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,209评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,199评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,418评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,401评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,700评论 2 345

推荐阅读更多精彩内容

  • http://skoo.me/go/2013/11/29/golang-schedule?hmsr=studygo...
    baboon阅读 2,247评论 0 3
  • 轻量级进程模型: 用同步IO的方法写程序的逻辑,第二点是用尽可能多的并发进程来提升IO并发的能力。 核心思想,第...
    lifesoul阅读 2,681评论 4 1
  • 导语 Go语言(也称为Golang)是google在2009年推出的一种编译型编程语言。相对于大多数语言,gola...
    star24阅读 8,975评论 5 21
  • 操作系统的调度模型是大致上有两种N:1和1:1. N:1模型中用户态的线程运行在一个内核线程上,这种方式上下文切换...
    ieasy_tm阅读 712评论 0 4
  • 一、思维固化的人。这样的人脑袋里有一种判断人的固有模式,好比一个又一个套子,别人的一句话一个行动,就像是一个标本一...
    助心阅读 451评论 1 2