Go语言专门针对多处理器系统应用程序的编程进行了优化,使用Go编译的程序可以媲美C或C++代码的速度,而且更加安全、支持并行进程。

    因而一直想的是自己可以根据自己学习和使用Go语言编程的心得,写一本Go的书可以帮助想要学习Go语言的初学者快速入门开发和使用!

    Goroutine并发处理

    在了解Goroutine并发之前,我们需要先了解下进程和线程,并发与并行 (Concurrency and Parallelism)的概念。

    • 进程

    进程是操作系统中进行保护和资源分配的基本单位,操作系统分配资源以进程为基本单位。

    cpu在切换程序的时候,如果不保存上一个程序的状态(也就是我们常说的context—上下文),直接切换下一个程序,就会丢失上一个程序的一系列状态,于是引入了进程这个概念,用以划分好程序运行时所需要的资源。因此进程就是一个程序运行时候的所需要的基本资源单位(也可以说是程序运行的一个实体)。

    线程是进程的组成部分,它代表了一条顺序的执行流。

    cpu切换多个进程的时候,会花费不少的时间,因为切换进程需要切换到内核态,而每次调度需要内核态都需要读取用户态的数据,进程一旦多起来,cpu调度会消耗一大堆资源,因此引入了线程的概念,线程本身几乎不占有资源,他们共享进程里的资源,内核调度起来不会那么像进程切换那么耗费资源。

    • 协程

    协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈。因此,协程能保留上一次调用时的状态(即所有局部状态的一个特定组合),每次过程重入时,就相当于进入上一次调用的状态,换种说法:进入上一次离开时所处逻辑流的位置。线程和进程的操作是由程序触发系统接口,最后的执行者是系统;协程的操作执行者则是用户自身程序,goroutine也是协程。

    进程是从操作系统获得基本的内存空间,所有的线程共享着进程的内存地址空间。此外,每个线程也会拥有自己私有的内存地址范围,其他线程不能访问。然而由于所有的线程共享进程的内存地址空间,所以线程间的通信就非常容易,通过共享进程级全局变量就可以实现线程间的通信。

    • 并发

    并发是指程序的逻辑结构,交替做不同事的能力,在这里通常是不同程序交替执行的性能。

    如果某个系统支持两个或者多个动作(Action)同时存在,那么这个系统就是一个并发系统。如果某个系统支持两个或者多个动作同时执行,那么这个系统就是一个并行系统

    并发系统与并行系统这两个定义之间的关键差异在于“存在”这个词。在并发程序中可以同时拥有两个或者多个线程。这意味着,如果程序在单核处理器上运行,那么这两个线程将交替地换入或者换出内存。这些线程是同时“存在”的——每个线程都处于执行过程中的某个状态。如果程序能够并行执行,那么就一定是运行在多核处理器上。

    此时,程序中的每个线程都将分配到一个独立的处理器核上,因此可以同时运行。这里相信你已经能够得出结论——“并行”概念是“并发”概念的一个子集。也就是说,你可以编写一个拥有多个线程或者进程的并发程序,但如果没有多核处理器来执行这个程序,那么就不能以并行方式来运行代码。因此,凡是在求解单个问题时涉及多个执行流程的编程模式或者执行行为,都属于并发编程的范畴。

    理解了并发和并行后,我们在看看Goroutine.

    Goroutine 的概念类似于线程,但 Goroutine 由 Go 程序运行时的调度和管理。Go 程序会将Goroutine 中的任务合理地分配给每个 CPU。Go 程序从 main 包的 main() 函数开始,在程序启动时,Go 程序就会为 main() 函数创建一个默认的 Goroutine。

    Goroutine是Go语言原生支持并发的具体实现,在Go中的代码都是运行在Goroutine中的。Goroutine占用的资源非常小(Go 1.4将每个Goroutine stack的size默认设置为2k),goroutine调度的切换也不用陷入(trap)操作系统内核层完成,代价很低。因此,一个Go程序中可以创建成千上万个并发的goroutine。

    所有的Go代码都在goroutine中执行,即使是go的runtime也不例外。我们可以启动成千上万的goroutine,但是Go的runtime负责对goroutine进行调度。这里的调度就是决定何时哪个goroutine将获得资源开始执行、哪个goroutine应该停止执行让出资源、哪个goroutine应该被唤醒恢复执行等.

    但是很多人其实并没有深入的了解过Goroutine的调度模型和原理,那么Goroutine是怎么实现调度的呢?

    Go的调度器内部有三个重要的结构:G P M.

    • G: 表示goroutine,存储了goroutine的执行stack信息、goroutine状态以及goroutine的任务函数等;另外G对象是可以重用的。 结构体G中的部分域如上所示。其中包含了栈信息stackbase和stackguard,还有运行的函数信息fnstart。这样就可以成为一个可执行的单元了,只要得到CPU就可以运行。goroutine切换时,上下文信息保存在结构体的sched域中。goroutine是轻量级的线程或者称为协程,切换时并不必陷入到操作系统内核中,所以保存过程很轻量。

    而G中的Gobuf,只保存了当前栈指针,程序计数器,以及goroutine自身。

    这里g是为了恢复当前goroutine的结构体G指针,运行时库中使用了一个常驻的寄存器extern register G* g,这个是当前goroutine的结构体G的指针。这样做是为了快速地访问goroutine中的信息.

    • P: 表示逻辑processor 代表cpu,P的数量决定了系统内最大可并行的G的数量(系统的物理cpu核数>=P的数量);P的最大作用还是其拥有的各种G对象队列、链表、一些cache和状态。

    在P中有一个Grunnable的goroutine队列,这是一个P的局部队列。当P执行Go代码时,它会优先从自己的这个局部队列中取,这时可以不用加锁,提高了并发度。如果发现这个队列空了,则去其它P的队列中拿一半过来,这样实现工作流窃取的调度。这种情况下是需要给调用器加锁的。

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

    和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的栈中再执行。

    Goroutine并发处理 - 图1

    G 代表 goroutine,M 可以看做真实的资源(OS Threads)。P是 G-M 的中间层,P是一个“逻辑Proccessor”,组织多个Goroutine跑在同一个 OS Thread 上。

    对于G来说,P就是运行它的“CPU”,可以说:G的眼里只有P。但从Go scheduler视角来看,真正的“CPU”是M,只有将P和M绑定才能让P的runq中G得以真实运行起来。这样的P与M的关系,就好比Linux操作系统调度层面用户线程(user thread)与核心线程(kernel thread)的对应关系那样(N x M)。

    一个 P上会挂着多个G,当一个G执行结束时,P会选择下一个 Goroutine 继续执行。而当一个Goroutine执行太久没有结束,这样就需要调度给后面的 Goroutine 运行的机会。所以,Go scheduler 除了在一个 Goroutine 执行结束时会调度后面的 Goroutine 执行,还会在正在被执行的 Goroutine 发生以下情况时让出当前 goroutine 的执行权,并调度后面的 Goroutine 执行:IO 操作,Channel 阻塞,system call,运行较长时间.

    对于运行时间较长的Goroutine,scheduler会在其 G对象上打上一个标志( preempt),当这个 goroutine 内部发生函数调用的时候,会先主动检查这个标志,如果为 true ,就需要主动调用Gosched()来让出CPU。

    然而如果G被阻塞在某个channel操作或network I/O操作上时,G会被放置到某个wait队列中,而M会尝试运行下一个runnable的G;如果此时没有runnable的G供m运行,那么m将解绑P,并进入sleep状态。当I/O available或channel操作完成,在wait队列中的G会被唤醒,标记为runnable,放入到某P的队列中,绑定一个M继续执行。

    果G被阻塞在某个system call操作上,那么不光G会阻塞,执行该G的M也会解绑P(实质是被sysmon抢走了),与G一起进入sleep状态。如果此时有idle的M,则P与其绑定继续执行其他G;如果没有idle M,但仍然有其他G要去执行,那么就会创建一个新M。