Go并发编程之Goroutine和Channel
Go语言基础之并发
并发是编程里面一个非常重要的概念,Go语言在语言层面天生支持并发,这也是Go语言流行的一个很重要的原因。
在学习Go的并发编程之前,首先要了解进程、线程、协程三者的区别,以及为什么需要协程。
进程、线程、协程三者的区别详情见文章:进程、线程、协程 | Lemon-CS
1. 前言
1.1 只执行一个任务
早期的单进程的操作系统,对于进程的执行,有以下的执行特点,就比如有多个进程,他们都是按照顺序进行执行的,当然执行线程也是, 对于 CPU 来说,他是无法区分进程和线程的。
同一时刻,单核 CPU只能处理一个任务。但是这种方式存在两个问题:
- 单一执行流程,计算机只能一个任务一个任务的执行处理
- 继承阻塞所带来的 CPU 浪费时间,因为 CPU 是单核,没有什么切换能力,所以只能等待。
1.2 能不能宏观的执行多个任务呢?
这时候,出现了一个 CPU 调度器。让这个 CPU 调度器进行轮询调度。
举个例子,假设现在有多个进程,CPU 调度器先调度进程A
进行执行。
然后再调用进程B
执行:
依次类推,但上面的问题依旧存在
- 我的
进程 A
是否已经跑完然后切换到进程 B
,还是没有跑完切换到了进程 B - 要在宏观上展示出来我们是在同时执行多个任务
所以,调度器做了一个时间片的切分,如下图:
一个进程允许执行的最大时间不能超过我的时间片长度,如果超过时间片,则强制切换进程,也就是说,一个进程在 CPU 的执行时间长度是一定的,比如是 10 毫秒,那当 10 毫秒之后,不管这个进程有没有执行结束,CPU 调度器都会终止当前进程的运算,而把 CPU 给下一个进程使用。
如果进程A
在被切换之前,没有运算完成,那么当 CPU 调度器轮询一圈后,则继续执行进程A
的计算。
然后再执行进程B
再次执行进程C
此时,在宏观上看,三个进程一起在往前走,一起在执行,****从而实现并发执行的效果。
所以,多进程/多线程解决了阻塞问题,即使其中一个阻塞,但也无需等待,CPU 始终是在执行中的。
但也同时存在新的问题:
如图,CPU 从一个进程,切换到另一个进程,是存在切换成本的,如果当前线程还未执行结束,那么在切换下一个线程的时候,CPU 是要去计算和保存当前进程的状态,中间会存在很多的系统调用,上下文切换等操作,这一定会浪费一部分时间的。CPU 不是在计算我们的业务,而是在计算切换的中间状态。
所以,这种弊端,就会导致 进程/线程的数量越多,切换成本就越大,也就越浪费。
故而 CPU 看着是百分之百的利用率,而实际情况是只有60%的利用率在执行程序,而40%的利用率在执行切换状态的计算。
并且随着开发设计变得越来越复杂,因为多线程或者多进程存在 同步竞争,比如锁,竞争资源冲突等,随着任务的增多,CPU 的切换频率也相对增加,就会造成高消耗调度 CPU。
一个进程占用内存为 4GB 左右,一个线程占用 4MB 大小的内存,故而进程线程越多,还会造成高内存的占用。
1.3 线程的实现模型
Go并发编程模型在底层是由操作系统所提供的线程库支撑的,这里先简要介绍一下线程实现模型的相关概念。
线程的实现模型主要有3个,分别是:用户级线程模型、内核级线程模型和两级线程模型。它们之间最大的差异在于用户线程与内核调度实体(KSE)之间的对应关系上。内核调度实体就是可以被操作系统内核调度器调度的对象,也称为内核级线程,是操作系统内核的最小调度单元。
1. 用户级线程模型
用户线程与KSE为多对一(N:1)的映射关系
。此模型下的线程由用户级别的线程库全权管理,线程库存储在进程的用户空间之中,这些线程的存在对于内核来说是无法感知的,所以这些线程也不是内核调度器调度的对象。
一个进程中所有创建的线程都只和同一个KSE在运行时动态绑定,内核的所有调度都是基于用户进程的。对于线程的调度则是在用户层面完成的,相较于内核调度不需要让CPU在用户态和内核态之间切换,这种实现方式相比内核级线程模型可以做的很轻量级,对系统资源的消耗会小很多,上下文切换所花费的代价也会小得多。许多语言实现的协程库基本上都属于这种方式。但是,此模型下的多线程并不能真正的并发运行。
例如,如果某个线程在I/O操作过程中被阻塞,那么其所属进程内的所有线程都被阻塞,整个进程将被挂起。
2. 内核级线程模型
用户线程与KSE为一对一(1:1)的映射关系
。此模型下的线程由内核负责管理,应用程序对线程的创建、终止和同步都必须通过内核提供的系统调用来完成,内核可以分别对每一个线程进行调度。
所以,一对一线程模型可以真正的实现线程的并发运行,大部分语言实现的线程库基本上都属于这种方式。但是,此模型下线程的创建、切换和同步都需要花费更多的内核资源和时间,如果一个进程包含了大量的线程,那么它会给内核的调度器造成非常大的负担,甚至会影响到操作系统的整体性能。
3. 两级线程模型
用户线程与KSE为多对多(N:M)的映射关系。两级线程模型吸收前两种线程模型的优点并且尽量规避了它们的缺点,区别于用户级线程模型,两级线程模型中的进程可以与多个内核线程KSE关联,也就是说一个进程内的多个线程可以分别绑定一个自己的KSE,这点和内核级线程模型相似;其次,又区别于内核级线程模型,它的进程里的线程并不与KSE唯一绑定,而是可以多个用户线程映射到同一个KSE,当某个KSE因为其绑定的线程的阻塞操作被内核调度出CPU时,其关联的进程中其余用户线程可以重新与其他KSE绑定运行。
所以,两级线程模型既不是用户级线程模型那种完全靠自己调度的也不是内核级线程模型完全靠操作系统调度的,而是一种自身调度与系统调度协同工作的中间态,即用户调度器实现用户线程到KSE的调度,内核调度器实现KSE到CPU上的调度。
1.4 并发和并行
并发:同一时间段内执行多个任务,一个cpu上能同时执行多项任务,在很短时间内,cpu来回切换任务执行(在某段很短时间内执行程序a,然后又迅速得切换到程序b去执行),有时间上的重叠(宏观上是同时的,微观仍是顺序执行),这样看起来多个任务像是同时执行,这就是并发。
并行:同一时刻执行多个任务,当系统有多个CPU时,每个CPU同一时刻都运行任务,互不抢占自己所在的CPU资源,同时进行,称为并行。
Go语言的并发通过goroutine
实现。goroutine
类似于线程,属于用户态的线程,我们可以根据需要创建成千上万个goroutine
并发工作。goroutine
是由Go语言的运行时(runtime)调度完成,而线程是由操作系统调度完成。
Go语言还提供channel
在多个goroutine
间进行通信。goroutine
和channel
是 Go 语言秉承的 CSP(Communicating Sequential Process)并发模式的重要实现基础。
1.5 协程
协程, 我们又称为微线程,协程它不像线程和进程那样,需要进行系统内核上的上下文切换,协程的上下文切换是由开发人员决定的。
协程是一种用户级的轻量级线程。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈。因此:
协程能保留上一次调用时的状态(即所有局部状态的一个特定组合),每次过程重入时,就相当于进入上一次调用的状态,换种说法:进入上一次离开时所处逻辑流的位置。
协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈。因此,协程能保留上一次调用时的状态(即所有局部状态的一个特定组合),每次过程重入时,就相当于进入上一次调用的状态,换种说法:进入上一次离开时所处逻辑流的位置。线程和进程的操作是由程序触发系统接口,最后的执行者是系统;协程的操作执行者则是用户自身程序,goroutine也是协程。同时协程也称为微线程,它的开销比线程更小,因此更适合用来做高并发的任务。
协程相对于多线程的优点:
多线程编程是比较困难的, 因为调度程序任何时候都能中断线程, 必须记住保留锁, 去保护程序中重要部分, 防止多线程在执行的过程中断。
而协程默认会做好全方位保护, 以防止中断。我们必须显示产出才能让程序的余下部分运行。对协程来说, 无需保留锁, 而在多个线程之间同步操作, 协程自身就会同步, 因为在任意时刻, 只有一个协程运行。
总结下大概下面几点:
- 无需系统内核的上下文切换,减小开销,节省CPU,避免系统内核级的线程频繁切换,造成的CPU资源浪费。好钢用在刀刃上。而协程是用户态的线程,用户可以自行控制协程的创建于销毁,极大程度避免了系统级线程上下文切换造成的资源浪费。
- 无需原子操作锁定及同步的开销,不用担心资源共享的问题;
- 单线程即可实现高并发,单核 CPU 即便支持上万的协程都不是问题,所以很适合用于高并发处理,尤其是在应用在网络爬虫中
- 节约内存,在64位的Linux中,一个线程需要分配8MB栈内存和64MB堆内存,系统内存的制约导致我们无法开启更多线程实现高并发。而在协程编程模式下,可以轻松有十几万协程,这是线程无法比拟的。
1.6 为什么需要协程?
Goroutines相对于线程的优势:
1. 动态栈
- 修改固定的大小可以提升空间的利用率,允许创建更多的线程。
一个OS线程都有一个固定大小的内存块(一般会是2MB)来做栈,这个栈会用来存储当前正在被调用或挂起(指在调用其它函数时)的函数的内部变量。2MB的栈对于一个小小的goroutine来说是很大的内存浪费,对于很大的goroutine来说又不够。
- 许更深的递归调用。
固定大小的栈对于更复杂或者更深层次的[递归函数]调用来说显然是不够的。
- 具体实现
一个goroutine会以一个很小的栈开始其生命周期,一般只需要2KB。一个goroutine的栈和OS线程不太一样的是,一个goroutine的栈大小并不是固定的;栈的大小会根据需要动态地伸缩。最大为1GB
2. 调度的性能更好
- 切换开销更小
使用操作系统的 threads 的最大能力一般在万级别,而Goroutine却能有上百万个。很大原因是在上下文切换的延迟不同。线程的调度方式是抢占式的,如果一个线程的执行时间超过了分配给它的时间片,就会被其它可执行的线程抢占。在线程切换的过程中需要保存/恢复所有的寄存器信息。这几步操作很慢,因为其局部性很差需要几次内存访问,并且会增加运行的cpu周期。
而goroutine的调度是协同式的,它不会直接地与操作系统内核打交道。多个 goroutine 在发生切换的时候,由于是在同一个 thread 下面,切换的时候只会保存/恢复三个寄存器当中的内容:Program Counter, Stack Pointer and DX。并且同一时刻同一个 thread 只会执行一个 goroutine,未被执行但是已经准备好的 goroutine 都是放在一个 queue 中的,他们是被串行处理的。
所以,即使一个程序创建了成千上万的 goroutine 也不会对上下文的切换造成什么影响。最重要的是,golang scheduler 在切换不同 goroutine 的操作上基本上达到了 O(1) 的时间复杂度。这就使得上下文切换的时间已经和 goroutine 的规模完全不相关了。
Go 的行为有何不同:在一个操作系统线程上运行多个 Goroutines。
- 更好的支持高并发
支持真正的高并发需要另外一种优化思路:
当你知道这个线程能做有用的工作的时候,才去调度这个线程!如果你正在运行多线程,其实无论何时,只有少部分的线程在做有用的工作。最好的并发不是利用共享内存来通信,而是用通信来共享内存。Go 语言引入了 channel 的机制来协助这种调度机制。如果一个 goroutine 正在一个空的 channel 上等待,那么调度器就能看到这些,并不再运行这个 goroutine 。同时 Go 语言更进了一步。它把很多个大部分时间空闲的 goroutines 合并到了一个自己的操作系统线程上。这样可以通过一个线程来调度活动的 Goroutine(这个数量小得多),而是数百万大部分状态处于睡眠的 goroutines 被分离出来。这种机制也有助于降低延迟。
3. Goroutine没有显示暴露ID号
在大多数支持多线程的操作系统和程序语言中,当前的线程都有一个独特的身份(id)。
goroutine不可以被程序员很容易获取到身份(id)。这一点是设计上故意而为之,在一定程度上防止thread-local storage被滥用。
2. Goroutine
在Go的并发编程模型中,不受操作系统内核管理的独立控制流不叫用户线程或线程,而称为Goroutine。Goroutine通常被认为是协程的Go实现,实际上Goroutine并不是传统意义上的协程,传统的协程库属于用户级线程模型,而Goroutine结合Go调度器的底层实现上属于两级线程模型。
2.1 介绍
Go语言的并发是基于 goroutine 的,中文称协程,goroutine 类似于线程,但并非线程。可以将 goroutine 理解为一种虚拟线程。
Go语言运行时会调度 goroutine,并将 goroutine 合理地分配到每个 CPU 中,最大限度地使用 CPU 性能。
多个 goroutine 中,Go语言使用通道(channel)进行通信,通道是一种内置的数据结构,可以让用户在不同的 goroutine 之间同步发送具有类型的消息。这让编程模型更倾向于在 goroutine 之间发送消息,而不是让多个 goroutine 争夺同一个数据的使用权。
如果希望让 goroutine 并行,必须使用多于一个逻辑处理器。当有多个逻辑处理器时,调度器会将 goroutine 平等分配到每个逻辑处理器上。这会让 goroutine 在不同的线程上运行。不过要想真的实现并行的效果,用户需要让自己的程序运行在有多个物理处理器的机器上。否则,哪怕 Go语言运行时使用多个线程,goroutine 依然会在同一个物理处理器上并发运行,达不到并行的效果。
我们先来介绍如何使用goroutine再来介绍它的调度模型。
2.2 使用goroutine
- 任何函数只需要加上 go 就能送给调度器运行
- 一个
goroutine
必定对应一个函数,可以创建多个goroutine
去执行相同的函数。 - 不需要再定义时区分是否是异步函数
- 调度器会在合适点切换
- 使用 go 关键字创建 goroutine 时,被调用函数的返回值会被忽略。如果需要在 goroutine 中返回数据,请使用后面介绍的通道(channel)特性,通过通道把数据从 goroutine 中作为返回值传出。
- 所有 goroutine 在 main() 函数结束时会一同结束。
1. 启动单个goroutine
启动goroutine的方式非常简单,只需要在调用的函数(普通函数和匿名函数)前面加上一个go
关键字。
举个例子如下:
func hello() { |
这个示例中hello函数和下面的语句是串行的,执行的结果是打印完Hello Goroutine!
后打印main goroutine done!
。
接下来我们在调用hello函数前面加上关键字go
,也就是启动一个goroutine去执行hello这个函数。
func main() { |
这一次的执行结果只打印了main goroutine done!
,并没有打印Hello Goroutine!
。为什么呢?
在程序启动时,Go程序就会为main()
函数创建一个默认的goroutine
。
当main()函数返回的时候该goroutine
就结束了,所有在main()
函数中启动的goroutine
会一同结束,所以我们要想办法让main函数等一等hello函数,最简单粗暴的方式就是time.Sleep
了。
func main() { |
执行上面的代码你会发现,这一次先打印main goroutine done!
,然后紧接着打印Hello Goroutine!
。
首先为什么会先打印main goroutine done!
是因为我们在创建新的goroutine的时候需要花费一些时间,而此时main函数所在的goroutine
是继续执行的。
2. 启动多个goroutine
在Go语言中实现并发就是这样简单,我们还可以启动多个goroutine
。让我们再来一个例子:
这里使用了
sync.WaitGroup
来实现goroutine的同步
var wg sync.WaitGroup |
多次执行上面的代码,会发现每次打印的数字的顺序都不一致。这是因为10个goroutine
是并发执行的,而goroutine
的调度是随机的。
3. GOMAXPROCS
Go运行时的调度器使用GOMAXPROCS
参数来确定需要使用多少个OS线程来同时执行Go代码。默认值是机器上的CPU核心数。例如在一个8核心的机器上,调度器会把Go代码同时调度到8个OS线程上(GOMAXPROCS是m:n调度中的n)。
Go语言中可以通过runtime.GOMAXPROCS()
函数设置当前程序并发时占用的CPU逻辑核心数。
Go1.5版本之前,默认使用的是单核心执行。Go1.5版本之后,默认使用全部的CPU逻辑核心数。
我们可以通过将任务分配到不同的CPU逻辑核心上实现并行的效果,这里举个例子:
import ( |
2.3 Goroutine的调度模型(GMP模型)
1. 简介
groutine能拥有强大的并发实现是通过GMP调度模型实现,下面就来解释下goroutine的调度模型。
Go的调度器内部三个重要的结构:M,P,G
G
表示Goroutine。每个Goroutine对应一个G结构体,G存储Goroutine的运行堆栈、状态以及任务函数,可重用。当Goroutine被调离CPU时,调度器代码负责把CPU寄存器的值保存在G对象的成员变量之中,当Goroutine被调度起来运行时,调度器代码又负责把G对象的成员变量所保存的寄存器的值恢复到CPU的寄存器。M
代表内核级线程,goroutine就是跑在M之上的;OS底层线程的抽象,它本身就与一个内核线程进行绑定,每个工作线程都有唯一的一个M结构体的实例对象与之对应,它代表着真正执行计算的资源,由操作系统的调度器调度和管理。M结构体对象除了记录着工作线程的诸如栈的起止位置、当前正在执行的Goroutine以及是否空闲等等状态信息之外,还通过指针维持着与P结构体的实例对象之间的绑定关系。
P
全称是Processor,表示逻辑处理器,默认与机器的核心数相同,它的主要用途就是用来执行goroutine的。对G来说,P相当于CPU核,G只有绑定到P(在P的local runq中)才能被调度。对M来说,P提供了相关的执行环境(Context),如内存分配状态(mcache),任务队列(G)等。它维护一个局部Goroutine可运行G队列,工作线程优先使用自己的局部运行队列,只有必要时才会去访问全局运行队列,这可以大大减少锁冲突,提高工作线程的并发性,并且可以良好的运用程序的局部性原理。
一个G的执行需要P和M的支持。一个M在与一个P关联之后,就形成了一个有效的G运行环境(内核线程+上下文)。每个P都包含一个可运行的G的队列(runq)。该队列中的G会被依次传递给与本地P关联的M,并获得运行时机。
M与KSE之间总是一一对应的关系,一个M仅能代表一个内核线程。M与KSE之间的关联非常稳固,一个M在其生命周期内,会且仅会与一个KSE产生关联,而M与P、P与G之间的关联都是可变的,M与P也是一对一的关系,P与G则是一对多的关系。
本地队列与全局队列:
- 相同点:都是用来存储待运行的goroutine;
- 不同点:本地队列有大小限制,最多能存放256个,并且在创建goroutine时回会优先存储在P的本地队列,如果P的本地队列满了,则拿一半放到全局队列中。
2. G
运行时,G在调度器中的地位与线程在操作系统中差不多,但是它占用了更小的内存空间,也降低了上下文切换的开销。它是Go语言在用户态提供的线程,作为一种粒度更细的资源调度单元,使用得当,能够在高并发的场景下更高效地利用机器的CPU。
g结构体部分源码(src/runtime/runtime2.go):
type g struct { |
gobuf中保存的内容会在调度器保存或恢复上下文时使用,其中栈指针和程序计数器会用来存储或恢复寄存器中的值,改变程序即将执行的代码。
atomicstatus字段存储了当前Goroutine的状态,Goroutine主要可能处于以下几种状态:
Goroutine的状态迁移是一个十分复杂的过程,触发状态迁移的方法也很多。这里主要介绍一下比较常见的五种状态_Grunnable、_Grunning、_Gsyscall、_Gwaiting和_Gpreempted。
可以将这些不同的状态聚合成三种:等待中、可运行、运行中,运行期间会在这三种状态来回切换:
等待中:Goroutine正在等待某些条件满足,例如:系统调用结束等,包括_Gwaiting、_Gsyscall和_Gpreempted几个状态;
可运行:Goroutine已经准备就绪,可以在线程运行,如果当前程序中有非常多的Goroutine,每个Goroutine就可能会等待更多的时间,即_Grunnable;
运行中:Goroutine正在某个线程上运行,即_Grunning。
G常见的状态转换图:
进入死亡状态的G可以重新初始化并使用。
3. M
Go语言并发模型中的M是操作系统线程。调度器最多可以创建10000个线程,但是最多只会有GOMAXPROCS(P的数量)个活跃线程能够正常运行。在默认情况下,运行时会将 GOMAXPROCS设置成当前机器的核数,我们也可以在程序中使用runtime.GOMAXPROCS来改变最大的活跃线程数。
例如,对于一个四核的机器,runtime会创建四个活跃的操作系统线程,每一个线程都对应一个运行时中的runtime.m结构体。在大多数情况下,我们都会使用Go的默认设置,也就是线程数等于CPU数,默认的设置不会频繁触发操作系统的线程调度和上下文切换,所有的调度都会发生在用户态,由Go语言调度器触发,能够减少很多额外开销。
m结构体源码(部分):
type m struct { |
g0表示一个特殊的Goroutine,由Go运行时系统在启动之处创建,它会深度参与运行时的调度过程,包括Goroutine的创建、大内存分配和CGO函数的执行。curg是在当前线程上运行的用户Goroutine。
4. P
调度器中的处理器P是线程和Goroutine的中间层,它能提供线程需要的上下文环境,也会负责调度线程上的等待队列,通过处理器P的调度,每一个内核线程都能够执行多个Goroutine,它能在Goroutine进行一些I/O操作时及时让出计算资源,提高线程的利用率。
P的数量等于GOMAXPROCS,设置GOMAXPROCS的值只能限制P的最大数量,对M和G的数量没有任何约束。当M上运行的G进入系统调用导致M被阻塞时,运行时系统会把该M和与之关联的P分离开来,这时,如果该P的可运行G队列上还有未被运行的G,那么运行时系统就会找一个空闲的M,或者新建一个M与该P关联,满足这些G的运行需要。因此,M的数量很多时候都会比P多。
p结构体源码(部分):
type p struct { |
P可能处于的状态如下:
2.4 Goroutine的调度过程
首先介绍一下在GMP调度模型中,如果没有P的话会怎么样?
1. 没有P层会发生什么?
如果没有P层,调度器的实现流程如下:
M 想要执行、放回 G 都必须访问全局 G 队列,并且 M 有多个,即多线程访问同一资源需要加锁进行保证互斥 / 同步,所以全局 G 队列是有互斥锁进行保护的。
缺点:
- 创建、销毁、调度 G 都需要每个 M 获取锁,这就形成了激烈的锁竞争。
- M 转移 G 会造成延迟和额外的系统负载。比如当 G 中包含创建新协程的时候,M 创建了 G’,为了继续执行 G,需要把 G’交给 M’执行,也造成了很差的局部性,因为 G’和 G 是相关的,最好放在 M 上执行,而不是其他 M’。
- 系统调用 (CPU 在 M 之间的切换) 导致频繁的线程阻塞和取消阻塞操作增加了系统开销。
2. 调度过程
创建
- Goroutine的创建:
在调用go func()的时候,会调用runtime.newproc来创建一个goroutine,这个goroutine会新建一个自己的栈空间,同时在G的sched中维护栈地址与程序计数器这些信息(备注:这些数据在goroutine被调度的时候会被用到。准确的说该goroutine在放弃cpu之后,下一次在重新获取cpu的时候,这些信息会被重新加载到cpu的寄存器中。) 创建好的这个goroutine会被放到,它所对应的内核线程M所使用的上下文P中的runqueue中。等待调度器来决定何时取出该goroutine并执行,通常调度是按时间顺序被调度的,这个队列是一个先进先出的队列。 - P的创建 :
runqueue P 指定GOMAXPROCS之后,会在程序运行之初创建好对应数目的P
- M的创建 :
当满足以下三个条件以后,M就会被创建:- 队列中G太多
- 系统级线程M太少
- 有空闲的P
调度
goroutine在创建好了之后,调度器会决定何时执行这个goroutine,这个过程就叫做调度。
新建好的goroutine,最开始都会存储在某一个线程M,所关联的上下文P的runqueue中,但是在后续的调度中,有些goroutine因为调用了runtime.gosched,会被放到全局队列中。
线程M的选择过程,按照下面的顺序执行:
从M对应的P中的runqueue中取出goroutine,来执行,没有的话,执行2。
从全局队列里面尝试取出一个goroutine来执行,有的话,执行!没有的话,执行3。
从其他的线程M的P中,偷出一些goroutine来执行,偷失败了,执行4。(备注:这里偷的话,一偷就偷一半,使用的算法叫做work stealing。)
线程M发现无事可做,就去休息了,也就是线程的sleep,放入线程缓存,它等待被唤醒。
当一个OS线程也就是一个M陷入阻塞的时候,会释放出P,P转而寻找另一个M(M可能是被新创建,也可能来自于线程缓存),继续执行其他G
如果没有其他的空闲 M,但是P的Local Runqueue(本地队列)中仍有G需要执行,就会创建一个新的M。
当上述阻塞完成后,G会尝试寻找一个空闲的P进入它的Local Runqueue中恢复执行,如果没有找到,G就会进入Global Runqueue,等待其他P从队列中取出。
P调度G的时候,首先从P的Local Runqueue中获取G,如果Local Queue中没有的话,就从Global Runqueue中获取,如果Global Runqueue中也没有的话,就随机从其他P的Local Runqueue中偷一半的G出来。
3. 调度器原理
- 复用线程:避免频繁的创建、销毁线程,而是对线程的复用。
work stealing 机制
当本线程无可运行的 G 时,则P随机选择一个的其他处理器(P)并尝试从其队列中窃取一半可运行的goroutine。,而不是销毁线程。hand off 机制
当本线程因为 G 进行系统调用阻塞时,线程释放绑定的 P,把 P 转移给其他空闲的线程执行。
- 利用并行: GOMAXPROCS 设置 P 的数量,最多有 GOMAXPROCS 个线程分布在多个 CPU 上同时运行。GOMAXPROCS 也限制了并发的程度,比如 GOMAXPROCS = 核数/2,则最多利用了一半的 CPU 核进行并行。
- 抢占: 在 coroutine 中要等待一个协程主动让出 CPU 才执行下一个协程,在 Go 中,一个 goroutine 最多占用 CPU 10ms,防止其他 goroutine 被饿死,这就是 goroutine 不同于 coroutine 的一个地方。
- 旋转线程: 线程自旋相对于线程阻塞,表现为循环执行指定的逻辑,而不进入阻塞状态。在go的调度逻辑中,为了实现高性能的并发,如果全局队列和本地队列都为空,绑定P的M没有G可以执行,会进入自旋状态等待新的G,不会进入阻塞状态休眠,减少了经常性的抢占和M的上下文切换成本。 在任何时候,都有最多 GOMAXPROCS 个线程在旋转。当一个旋转的线程找到工作后,它就会脱离自旋状态。
- 全局 G 队列:在新的调度器中依然有全局 G 队列,但功能已经被弱化了,当 M 执行 work stealing 从其他 P 偷不到 G 时,它可以从全局 G 队列获取 G。
4. 用户态阻塞和系统调用阻塞
用户态阻塞
当goroutine因为channel操作或者network I/O而阻塞时(实际上golang已经用netpoller实现了goroutine网络I/O阻塞不会导致M被阻塞,仅阻塞G,这里仅仅是举个例子),对应的G会被放置到某个wait队列(如channel的waitq
),该G的状态由_Gruning
变为_Gwaitting
,而M会跳过该G尝试获取并执行下一个G,如果此时没有runnable的G供M运行,那么M将解绑P,并进入sleep状态;当阻塞的G被另一端的G2唤醒时(比如channel的可读/写通知),G被标记为runnable
,尝试加入G2所在P的runnext,然后再是P的Local队列和Global队列。
系统调用阻塞
- 当G被阻塞在某个系统调用上时,此时G会阻塞在
_Gsyscall
状态,M也处于block on syscall
状态,
- 此时的M可被抢占调度:执行该G的M会与P解绑,而P则尝试与其它空闲的M绑定,继续执行其它G。
- 如果没有其它空闲的M,但P的Local队列中仍然有G需要执行,则创建一个新的M;当系统调用完成后,G会重新尝试获取一个空闲的P进入它的Local队列恢复执行,如果没有空闲的P,G会被标记为runnable加入到Global队列。
5. 线程自旋
线程自旋相对于线程阻塞,表现为循环执行指定的逻辑,而不进入阻塞状态。在go的调度逻辑中,为了实现高性能的并发,如果全局队列和本地队列都为空,绑定P的M没有G可以执行,会进入自旋状态等待新的G,不会进入阻塞状态休眠,减少了M的上下文切换成本。
注意只有绑定了P的M会进入自旋状态,因此最多会有GOMAXPROCS个自旋线程,避免了浪费过多系统资源,其余未绑定的空闲M依然会进入休眠状态。
6. 抢占式调度逻辑:
M绑定的P首先有1/61概率从全局队列获取G,60/61概率从本地队列获取G;
全局队列情况下如果没有获取到G,那么从本地队列获取G;
如果本地队列没有G,那么P从其他P的本地队列窃取G;
如果窃取不到G,那么从全局队列中获取一部分G到本地队列,获取n = min(len(GQ)/GOMAXPROCS + 1, len(GQ/2))个;
P获取到G后,绑定的M负责执行G,M必须是运行状态的线程,否则不会真正执行。
2.5 Goroutine如何停止
讲完了goroutine的调度之后,我们便要考虑一个问题,正在被执行的goroutine何时停止,停止了之后会发生什么?而挂在M对应的P后面的runqueue中的goroutine该怎么办?
1. 情况1: runtime·park
当调用了runtime·park函数之后,goroutine会被设置成waiting状态,线程M会放弃它自身关联的上下文P,而系统会分配一个新的线程M1来接管这个上下文P,(备注:当然这里面的M1也有可能是本来就创建好的,处于闲置状态中的)。
原来的线程M0则会与上下文断开连接,M0因为无事可做,就去sleep了,等待下次被唤醒。如下图所示:
channel的读写操作,定时器中,网络poll等都有可能park goroutine。
2. 情况2: runtime·gosched
调用runtime·gosched函数也可以让当前goroutine放弃cpu,这种情况下会将goroutine设置称runnable,放置到全局队列中。
备注:这个也就是为什么全局变量的queue里面会有goroutine的原因。
3. Goroutine 的其他操作
3.1 Goroutine异常捕捉
当启动多个goroutine时,如果其中一个goroutine异常了,并且我们并没有对进行异常处理,那么整个程序都会终止,所以我们在编写程序时候最好每个goroutine所运行的函数都做异常处理,异常处理采用recover。
func addele(a []int ,i int) { |
3.2 Goroutine 中的 panic
一般来说,Go 应用程序中的 panic 是违反最佳实践的,应该避免。代替 panic,你应该返回并处理函数中的错误。但是,如果有必要使用 panic
,必须知道,在 goroutine 如果没有 defer 的 recover,panic 会导致整个应用程序崩溃。
最佳实践:不要 Panic!
4. Channel
4.1 介绍
Go中经常被人提及的一个设计模式:不要通过共享内存的方式进行通信,而是应该通过通信的方式共享内存。Goroutine之间会通过 channel传递数据,作为Go语言的核心数据结构和Goroutine之间的通信方式,channel是支撑Go语言高性能并发编程模型的重要结构。
channel在运行时的内部表示是runtime.hchan,该结构体中包含了用于保护成员变量的互斥锁,从某种程度上说,channel是一个用于同步和通信的有锁队列。hchan结构体源码:
type hchan struct { |
waitq
中连接的是一个sudog
双向链表,保存的是等待中的Goroutine。
channel 是Go语言在语言级别提供的 goroutine 间的通信方式。我们可以使用 channel 在多个 goroutine 之间传递消息。
channel 是进程内的通信方式,因此通过 channel 传递对象的过程和调用函数时的参数传递行为比较一致,比如也可以传递指针等。如果需要跨进程通信,我们建议用分布式系统的方法来解决,比如使用 Socket 或者 HTTP 等通信协议。Go语言对于网络方面也有非常完善的支持。
channel 是类型相关的,也就是说,一个 channel 只能传递一种类型的值,这个类型需要在声明 channel 时指定。如果对 Unix 管道有所了解的话,就不难理解 channel,可以将其认为是一种类型安全的管道。
Go语言提倡使用通信的方法代替共享内存。多个 goroutine 为了争抢数据,势必造成执行的低效率,使用队列的方式是最高效的,channel 就是一种队列一样的结构。
在任何时候,同时只能有一个 goroutine 访问通道进行发送和获取数据。通道像一个传送带或者队列,总是遵循先入先出(First In First Out)的规则,保证收发数据的顺序。
通道的内部实现代码在Go语言开发包的 src/runtime/chan.go 中,经过分析后大概了解到通道也是用常见的互斥量等进行同步。因此通道虽然是一个语言级特性,但也不是被神化的特性,通道的运行和使用都要比传统互斥量、等待组(sync.WaitGroup)有一定的消耗。
4.2 Channel的使用
1. 创建
带缓冲区channel:定义声明时候制定了缓冲区大小(长度),可以保存多个数据。用于通信
channel :=make(chan int,3)
不带缓冲区channel:只能只能存一个数据,用于两个groutine的同步,阻塞式
channel :=make(chan bool)
只读管道 read_only := make (<-chan int)
只写管道 rite_only := make (chan<- int)
func main() { |
2. 发送
// 通道变量 <- 值 |
把数据往通道中发送时,如果接收方一直都没有接收,那么发送操作将持续阻塞。Go 程序运行时会发现一些永远无法发送成功的语句并抛出 panic:fatal error: all goroutines are asleep - deadlock!
3. 接收
通道的收发操作在不同的两个 goroutine 间进行。
接收将持续阻塞直到收到发送方发送的数据。
通道一次只能接收一个数据元素。
通道的数据接收一共有以下 4 种写法:
阻塞接收数据
data := <-ch |
非阻塞接收数据
非阻塞的通道接收方法可能造成高的 CPU 占用,因此使用非常少。如果需要实现接收超时检测,可以配合 select 和计时器 channel 进行,可以参见后面的内容。
// data:表示接收到的数据。未接收到数据时,data 为通道类型的零值。 |
接收任意数据,忽略接收的数据
执行该语句时将会发生阻塞,直到接收到数据,但接收到的数据会被忽略。这个方式实际上只是通过通道在 goroutine 间阻塞收发实现并发同步。
<-ch |
并发同步:
func main() { |
遍历式接收
- 使用 for range 遍历,同go程中编译器发现channel未被关闭,则会引发deadlock错误
- 如果channel无数据可读,那么for range会处于等待状态,只有当channel关闭,for range循环才会退出
func main() { |
- 如果采用for循环已经被关闭的管道,当管道没有数据时,读取的数据是管道的默认值,并且循环不会退出。res,ok :=<- channel,其中ok为false
func main() { |
4.3 注意事项
管道只能存放指定类型数据
非缓冲通道上如果发生了流入无流出,或者流出无流入,就会引起死锁。
当一个协程存一个协程取,虽然存的快而取得慢,导致容量占满,也不会发生错误,而是负责存的协程会堵塞在channel <- num
当一个协程存一个协程取,虽然存的慢而取得快,导致channel为空,也不会发生错误,而是负责取的协程会堵塞在 num := <- channel
存满了的channel就不能继续存了,当从存满了的channel取出数据后还可以继续存
当要取某一个值时,要将它前面的值剔除
4.4 关闭
1. 普通使用
通道是一个引用对象,在没有任何外部引用时,Go语言程序在运行时(runtime)会自动对内存进行垃圾回收(Garbage Collection, GC)。当然,通道也可以被主动关闭。
close(ch) |
2. 判断关闭
如何判断一个 channel 是否已经被关闭?我们可以在读取的时候使用非阻塞式方式来判断。
close(ch) |
3. 给被关闭通道发送数据将会触发 panic
被关闭的通道不会被置为 nil。如果尝试对已经关闭的通道进行发送,将会触发 panic:panic: send on closed channel
,代码如下:
|
4. 从已关闭的通道接收数据时将不会发生阻塞
从已经关闭的通道接收数据或者正在接收数据时,将会接收到通道类型的零值,然后停止阻塞并返回。
func main() { |
0 true |
4.5 Channel类型
1. 单向Channel
Go语言的类型系统提供了单方向的 channel 类型,顾名思义,单向 channel 就是只能用于写入或者只能用于读取数据。所谓的单向 channel 概念,其实只是对 channel 的一种使用限制,没有实际意义。因此,单向通道只是有利于代码接口的严谨性。
// var 通道实例 chan<- 元素类型 // 只能写入数据的通道 |
上面的例子中,chSendOnly 只能写入数据,如果尝试读取数据,将会抛出panic:invalid operation: <-chSendOnly (receive from send-only type chan<- int)
。同理,chRecvOnly 也是不能写入数据的。
例子:
time 包中的计时器会返回一个 timer 实例,代码如下:
type Timer struct { |
2. 无缓冲 channel
- Go语言中无缓冲的通道(unbuffered channel)是指在接收前没有能力保存任何值的通道。这种类型的通道要求发送 goroutine 和接收 goroutine 同时准备好,才能完成发送和接收操作。
- 如果两个 goroutine 没有同时准备好,通道会导致先执行发送或接收操作的 goroutine 阻塞等待。这种对通道进行发送和接收的交互行为本身就是同步的。其中任意一个操作都无法离开另一个操作单独存在。
- 阻塞指的是由于某种原因数据没有到达,当前协程(线程)持续处于等待状态,直到条件满足才解除阻塞。
- 同步指的是在两个或多个协程(线程)之间,保持数据内容一致性的机制。
3. 缓冲 channel
介绍
Go语言中有缓冲的通道(buffered channel)是一种在被接收前能存储一个或者多个值的通道。这种类型的通道并不强制要求 goroutine 之间必须同时完成发送和接收。
通道阻塞的条件发送和接收也不同。
- 通道为空时,尝试接收数据时发生阻塞。
- 通道填满时,尝试发送数据时发生阻塞。
有缓冲的通道和无缓冲的通道之间的一个很大的不同:
- 无缓冲的通道保证进行发送和接收的 goroutine 会在同一时间进行数据交换
- 有缓冲的通道没有这种保证,是一个异步过程。
带缓冲通道在很多特性上和无缓冲通道是类似的。无缓冲通道可以看作是长度永远为 0 的带缓冲通道。
为什么Go语言对通道要限制长度而不提供无限长度的通道?
- 我们知道通道(channel)是在两个 goroutine 间通信的桥梁。使用 goroutine 的代码必然有一方提供数据,一方消费数据。当提供数据一方的数据供给速度大于消费方的数据处理速度时,如果通道不限制长度,那么内存将不断膨胀直到应用崩溃。因此,限制通道的长度有利于约束数据提供方的供给速度,供给数据量必须在消费方处理量+通道长度的范围内,才能正常地处理数据。
使用
// 通道实例 := make(chan 通道类型, 缓冲大小) |
4.6 Select 多路复用
- 多路复用是通信和网络中的专业术语。多路复用通常表示在一个信道上传输多路信号或数据流的过程和技术
- go 提供了select 来实现多通道复用,可以同时处理接收和发送多个通道的数据。
select 语句实现了一种监听模式,通常用在(无限)循环中。由 select 开始一个新的选择块,每个选择条件由 case 语句来描述。在某种情况下,通过 break 或 goto 语句使循环退出。
select 的用法与 switch 语言非常类似:
default 语句是可选的;
fallthrough 是不允许的;
只要其中有一个 case 已经完成,程序就会继续往下执行,而不会考虑其他 case 的情况。
在任何一个 case 中执行 break 或者 return,select 就结束了,每一个case默认最后带有break。
select 有比较多的限制,其中最大的一条限制就是每个 case 语句里必须是一个 IO 操作。
在一个 select 语句中,Go语言会按顺序从头至尾评估每一个发送和接收的语句。如果其中的任意一语句可以继续执行(即没有被阻塞),那么就从那些可以执行的语句中任意选择一条来使用。
如果没有任意一条语句可以执行(即所有的通道都被阻塞),那么有如下两种可能的情况:
- 如果给出 default 语句,就会执行 default 语句,然后程序会跳出 select 语句,执行后面的程序。
- 如果没有 default 语句,那么 select 语句将被阻塞,直到至少有一个通信可以进行下去。
select { |
4.7 channel 超时
Go语言没有提供直接的超时处理机制。我们可以使用 select 来设置超时,虽然 select 机制不是专门为超时而设计的,却能很方便的解决超时问题。
func main() { |
num = 0 |
5. Goroutine和Channel案例
5.1 死锁现场一:
func main() { |
注意:groutine一定要在主线程对channel操作前开启,不然主线程阻塞在channel操作造成死锁,如果上面的管道是有缓冲的,则不会堵死。
5.2 死锁现场二:
(无缓冲channel的陷阱)
func main() { |
5.3 生产者与消费者
func producter(apples chan int){ |
参考感谢
Goroutines 与线程 ,并发原理,GPM模型,调度过程_菜菜今天学习了吗的博客-CSDN博客
Go并发(二):goroutine的实现原理 - 知乎 (zhihu.com)
Golang中Goroutine的调度流程 - 知乎 (zhihu.com)
goroutine的调度过程 - 搜索 (bing.com)
【深度知识】GO语言的goroutine并发原理和调度机制 - 简书 (jianshu.com)
关于Go并发编程,你不得不知的“左膀右臂”——并发与通道! (qq.com)
Golang 并发编程指南 (qq.com)
Go 原生并发原语和最佳实践 (qq.com)