在实际的学习golang的并发模型之前,有必要先了解下一些基础概念。什么是并行,什么是并发。然后介绍golang到底怎么控制golang的并发模型的。并发模型中是由哪些基本元素组成的。和实际的系统级别的进程线程如何进行cpu调度。如何实际使用golang,golang使用过程中需要注意哪些内容。例如如何捕获异常,如何实现golang协程的同步。
什么是并发
并发、并行。这是两个很容易让人混淆的概念。在深入学习之前是很有必要先咬文嚼字的把这两个概念区分,定义清楚。
并行:同一时刻同时处理多个任务。
并发:指在一段时间内可以同时处理多件事情。
并行的场景:例如有多个(5)司机,多辆车(5)。那么就能够同,以载多个乘客。这样我们形容这些司机能够并行载5个乘客,并行度为5。这是一个形容单瞬间的事情。
并发的场景:例如一个司机在10分钟内开车,遇到红绿灯。停下来等红绿灯的时候,刷了以下手机。然后给人家回复了信息。然后接完下一个单。红灯完了之后又继续开。 在10分钟内做了好几件事情。但是某一个时刻,他都只能完成一件事情。这样我们对司机在10分钟内能做多件事情的形容就是。司机做事情的并发量为5件事情。这形容一个时间段,可以完成的数量。 但是当我们把这段时间缩小到一个瞬间,就没有所谓的并发。非要说并发的化,那就只能说1的并发。
在日常工作中经常在说系统的并发,系统的并发。其实更准确的形容应该是,给出一个具体的时间段。而往往这个时间段,会是默认的1s时间。秒级别,是人类可感知的最小时间单元。所以一般会用1秒时间内,系统能够处理多少事情,来形容系统的能够支持的并发量。
在golang当中,并发是通过goroutine和channel来完成的。
示例图:
并发≠并行
假如我们有一段CPU密集型任务,我们创建2000个gorountine是否真的可以将其性能提高2000倍,答案必然是不能,因为我们只是进行了2000次的并发(concurrency),而并没有真正做到并行(parallelism)。
并发其实所指的是我们的程序执行逻辑,传统单线程应用的程序逻辑是顺序执行的,在任何时刻,程序只能处理同一个逻辑,而并发指的是,我们同时执行多个独立的程序逻辑,若干个程序逻辑在执行时可以是同时进行的(但并不代表同时进行处理)。实际上,不论我们并发多少个程序逻辑,若我们仅仅将其运行在一个单核单线程的CPU上,都不能让你的程序在性能上有所提升,因为最终所有任务都排队等待CPU资源(时间片)。
而并行才能让我们的程序真正的同时处理多个任务,但并行并不是编程语言能够带我们的特性,他需要硬件支持。上面说到单核CPU所有资源都要等待同一个CPU的资源,那么其实我们只要将CPU增多就能真正的让我们实现并行。我们可以使用多核CPU或用多台服务器组成服务集群,均可实现真正的并行,能够并行处理的任务数量也就是我们的CPU数量。
CPU密集&I/O密集
那么是不是说我们在单核CPU的机器上使用编程语言所提供的多线程就没有意义了呢?
如果说我们的程序属于CPU密集型,使用并发编程,可能确实无法提升我们的程序性能,甚至可能因为大量计算资源花在了创建线程本身,导致程序性能进一步下降。
但不同的是,如果说我们的程序属于IO密集型,当你在进行程序压测的过程中可能发现CPU占用率很低,但性能却到了瓶颈,原因是程序将大量的时间花在了等待IO的过程中,如果我们可以在等待IO的时候继续执行其他的程序逻辑即可提高CPU利用率,从而提高我们的程序性能,这时并发编程的好处就出来了,例如Python因为GIL的存在实际上并不能实现真正的并行,但他的多线程依旧在IO密集型的程序中依旧有种很重要的意义。
小结:
并发和并行的差异
1、当cpu为单核的时候,没有所谓的并行。因为cpu同一时刻只能够有一个进程在运行。
2、无论此时程序是单进程、多线程,还是协程的方式。此时都是不可能并行的。
多线程、协程对于并发的意义
当任务都是计算型的化,那么不管是不是并发模式。这些任务都只能够串联都形式进入到CPU执行任务。此时多线程,协程都没有提高效率。因为此时无论怎样任何都是需要排队进入CPU,才能执行计算。而为什么我们还需要程序支持并发编程方式呢?
然而,当我们的程序并不全是计算任务的时候。进程有时需要读取IO资源。因为IO读取相对较慢,而此时进程被阻塞住,那后续的所有进程任务都需要等待之前的任务完成才能进行。或需要等待这个进程占用的时间片才能释放CPU的占用。此时对于程序来说,都不能够利用CPU
Goroutine(Golang Coroutine)
上面说到了使用多核CPU实现并行处理,使应用在多核cpu实现并行处理的方案主要是多进程与多线程两种方式,多进程模型相对简单,但是有着资源开销大及进程间通信成本高的问题。多线程模型相对复杂,会有死锁,线程安全,模型复杂等问题,但却因为资源开销及易于管理等优点适用于对于性能要求较高的应用。
Golang采用的是多线程模型,更详细的说他是一个两级线程模型,但它对系统线程(内核级线程)进行了封装,暴露了一个轻量级的协程goroutine(用户级线程)供用户使用,而用户级线程到内核级线程的调度由golang的runtime负责,调度逻辑对外透明。
goroutine的优势在于上下文切换在完全用户态进行,无需像线程一样频繁在用户态与内核态之间切换,节约了资源消耗。
其他的理论基础介绍
并发
一个cpu上能同时执行多项任务,在很短时间内,cpu来回切换任务执行(在某段很短时间内执行程序a,然后又迅速得切换到程序b去执行),有时间上的重叠(宏观上是同时的,微观仍是顺序执行),这样看起来多个任务像是同时执行,这就是并发。
并行
当系统有多个CPU时,每个CPU同一时刻都运行任务,互不抢占自己所在的CPU资源,同时进行,称为并行。
进程
cpu在切换程序的时候,如果不保存上一个程序的状态(也就是我们常说的context--上下文),直接切换下一个程序,就会丢失上一个程序的一系列状态,于是引入了进程这个概念,用以划分好程序运行时所需要的资源。因此进程就是一个程序运行时候的所需要的基本资源单位(也可以说是程序运行的一个实体)。
线程
cpu切换多个进程的时候,会花费不少的时间,因为切换进程需要切换到内核态,而每次调度需要内核态都需要读取用户态的数据,进程一旦多起来,cpu调度会消耗一大堆资源,因此引入了线程的概念,线程本身几乎不占有资源,他们共享进程里的资源,内核调度起来不会那么像进程切换那么耗费资源。
协程
协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈。因此,协程能保留上一次调用时的状态(即所有局部状态的一个特定组合),每次过程重入时,就相当于进入上一次调用的状态,换种说法:进入上一次离开时所处逻辑流的位置。线程和进程的操作是由程序触发系统接口,最后的执行者是系统;协程的操作执行者则是用户自身程序,goroutine也是协程。
golang支持的并发
在golang当中同样支持多进程,多线程的并发模式。(这里为什么说是并发模式。而不是并行模式。)对于单核多cpu来说,无论什么样的模式都是单并行的。当然对于多核多cpu的硬件,就是可多并行的执行机器指令。
golang的协程
- golang协程的描述
- golang协程对应的原理
- golang协程的实际计算调度过程
golang协程的描述
协程是指函数或方法和其他函数或方法并发执行。协程可以看做是一种轻量级线程。创建协程的成本比线程要低很多。
golang协程特性
- 更低的资源成本
相比线程而言,Go 协程的成本极低。堆栈大小只有若干 kb,并且可以根据应用的需求进行增减。而线程必须指定堆栈的大小,其堆栈是固定不变的。
- 协程之下的系统线程复用
Go 协程会复用数量更少的 OS 线程。即使程序有数以千计的 Go 协程,也可能只有一个线程。如果该线程中的某一 Go 协程发生了阻塞(比如说等待用户输入),那么系统会再创建一个 OS 线程,并把其余 Go 协程都移动到这个新的 OS 线程。
- 共享数据方式改变。
Go 协程使用通道(Channel)来进行通信。通道用于防止多个协程访问共享内存时发生竞态条件(Race Condition)。通道可以看作是 Go 协程之间通信的管道。
golang协程的调度
在golang的调度中有三个比较重要的三个要素。分别是M(machine),G(goroutine),P(processer)。这三者分别对应什么关系。并且golang为什么要抽象出这样的调度模型是呢?
为什么要抽象出这样的调度模型。 因为相对依靠系统级别的线程,来实现并发。当一个线程面临等待io的时候。进行线程的切换,此时在程序内部或者是和进程的外部线程进行切换的时候,所消耗的资源过大。需要切换线程或进程的上下文。而对于并发度更高的系统的时候。就希望在整体使用计算资源的时候,能够实现程序进程的充分使用。并且能够实现最小资源的切换。实现的方法,就是实现用户级别的线程。当某个用户线程遇见io的时候,用户程序自行进行用户线程的切换。让cpu的整体计算时间,还是停留在本进程当中。减少这种没必要(不充分使用)的切换。
在golang的两级线程调度中。第一级就是系统内核线程,这是必须的在底层上的支持。另外一层是抽象出来一层用户级别的线程。而为了让用户级别的线程能够真正的在系统上运行起来,就需要将用户线程G,放到内核线程上执行。为了实现这种调度,就用了中间层(中介)来调度这两者的关系。另外golang对内核级线程做了一层封装M(machine),为了能够实现调度。
所以就有如下的一种逻辑关系。
三个要素说明:
G(Goroutine) :我们所说的协程,为用户级的轻量级线程,每个Goroutine对象中的sched保存着其上下文信息
M(Machine) :对内核级线程的封装,数量对应真实的CPU数(真正干活的对象)
P(Processor) :即为G和M的调度对象,用来调度G和M之间的关联关系,其数量可通过GOMAXPROCS()来设置,默认为核心数。P是作为中介,一个目的是为了能够正确的调度G,让G能够落到正确的M上去执行,能够实现真正的正确有效的并发。
每个Processor对象都拥有一个LRQ(Local Run Queue),未分配的Goroutine对象保存在GRQ(Global Run Queue )中,等待分配给某一个P的LRQ中,每个LRQ里面包含若干个用户创建的Goroutine对象,同时Processor作为桥梁对Machine和Goroutine进行了解耦,也就是说Goroutine如果想要使用Machine需要绑定一个Processor才行,上图中共有两个M和两个P也就是说我们可以同时并行处理两个goroutine。
调度过程
从宏观上来说,G和M因为P的存在是可以出现多对多的关系。当一个正在与M对接运行的G,出现资源等待的时候(等待IO)。调度器能够及时的发现,并把这个G和M分离开来,释放M资源。而当一个G恢复执行的时候,调度器P也会给这个G安排一个合适的M进行执行。而当M不够用的时候,P调度器也会帮我们向操作系统申请更多的M。(什么时候才会出现M不够用呢?)
实际调度场景:多个M,正常多并行运行
从上图中看,有2个物理线程M,每一个M都拥有一个处理器P,每一个也都有一个正在运行的goroutine。P的数量可以通过GOMAXPROCS()来设置,它其实也就代表了真正的并发度,即有多少个goroutine可以同时运行。图中灰色的那些goroutine并没有运行,而是出于ready的就绪态,正在等待被调度。P维护着这个队列(称之为runqueue),
Go语言里,启动一个goroutine很容易:go function 就行,所以每有一个go语句被执行,runqueue队列就在其末尾加入一个goroutine,在下一个调度点,就从runqueue中取出(如何决定取哪个goroutine?)一个goroutine执行。
实际调度场景:当一个内核线程M出现阻塞时,调度器P和M如何调度
当一个OS线程M0陷入阻塞时(如下图),P转而在运行M1,图中的M1可能是正被创建,或者从线程缓存中取出。
实际调度场景:P中队列没有G可执行。任务分配不均。
另一种情况是P所分配的任务G很快就执行完了(分配不均),这就导致了这个处理器P很忙,但是其他的P还有任务,此时如果global runqueue没有任务G了,那么P不得不从其他的P里拿一些G来执行。一般来说,如果P从其他的P那里要拿任务的话,一般就拿run queue的一半,这就确保了每个OS线程都能充分的使用。
golang协程的使用
如何运行一个goroutine
package main import ( "fmt" "time" ) func cal(a int , b int ) { c := a+b fmt.Printf("%d + %d = %d\n",a,b,c) } func main() { for i :=0 ; i<10 ;i++{ go cal(i,i+1) //启动10个goroutine 来计算 } time.Sleep(time.Second * 2) // sleep作用是为了等待所有任务完成
goroutine异常捕捉
当启动多个goroutine时,如果其中一个goroutine异常了,并且我们并没有对进行异常处理,那么整个程序都会终止,所以我们在编写程序时候最好每个goroutine所运行的函数都做异常处理,异常处理采用recover
package main import ( "fmt" "time" ) func addele(a []int ,i int) { defer func() { //匿名函数捕获错误 err := recover() //在这里捕获到。 if err != nil { fmt.Println("add ele fail") } }() a[i]=i fmt.Println(a) } func main() { Arry := make([]int,4) for i :=0 ; i<10 ;i++{ go addele(Arry,i) } time.Sleep(time.Second * 2) } //结果 add ele fail [0 0 0 0] [0 1 0 0] [0 1 2 0] [0 1 2 3] add ele fail add ele fail add ele fail add ele fail add ele fa
golang的同步
由于goroutine是异步执行的,那很有可能出现主程序退出时还有goroutine没有执行完,此时goroutine也会跟着退出。此时如果想等到所有goroutine任务执行完毕才退出,go提供了sync包和channel来解决同步问题,当然如果你能预测每个goroutine执行的时间,你还可以通过time.Sleep方式等待所有的groutine执行完成以后在退出程序(如上面的列子)。
示例一:使用sync包同步goroutine
sync大致实现方式为:WaitGroup 等待一组goroutinue执行完毕. 主程序调用 Add 添加等待的goroutinue数量. 每个goroutinue在执行结束时调用 Done ,此时等待队列数量减1.,主程序通过Wait阻塞,直到等待队列为0.
package main import ( "fmt" "sync" ) func cal(a int , b int ,n *sync.WaitGroup) { c := a+b fmt.Printf("%d + %d = %d\n",a,b,c) defer n.Done() //goroutinue完成后, WaitGroup的计数-1 } func main() { var go_sync sync.WaitGroup //声明一个WaitGroup变量 for i :=0 ; i<10 ;i++{ go_sync.Add(1) // WaitGroup的计数加1 go cal(i,i+1,&go_sync) } go_sync.Wait() //等待所有goroutine执行完毕 }
示例二:通过channel实现goroutine之间的同步。
实现方式:通过channel能在多个groutine之间通讯,当一个goroutine完成时候向channel发送退出信号,等所有goroutine退出时候,利用for循环channe去channel中的信号,若取不到数据会阻塞原理,等待所有goroutine执行完毕,使用该方法有个前提是你已经知道了你启动了多少个goroutine。
package main import ( "fmt" "time" ) func cal(a int , b int ,Exitchan chan bool) { c := a+b fmt.Printf("%d + %d = %d\n",a,b,c) time.Sleep(time.Second*2) Exitchan <- true } func main() { Exitchan := make(chan bool,10) //声明并分配管道内存 for i :=0 ; i<10 ;i++{ //使用一个计数器 go cal(i,i+1,Exitchan) } for j :=0; j<10; j++{ <- Exitchan //取信号数据,如果取不到则会阻塞 } close(Exitchan) // 关闭管道 }
goroutine之间的通讯
goroutine本质上是协程,可以理解为不受内核调度,而受go调度器管理的线程。goroutine之间可以通过channel进行通信或者说是数据共享,当然你也可以使用全局变量来进行数据共享。
示例:使用channel模拟消费者和生产者模式。
package main import ( "fmt" "sync" ) func Productor(mychan chan int,data int,wait *sync.WaitGroup) { mychan <- data fmt.Println("product data:",data) wait.Done() } func Consumer(mychan chan int,wait *sync.WaitGroup) { a := <- mychan fmt.Println("consumer data:",a) wait.Done() } func main() { datachan := make(chan int, 100) //通讯数据管道 var wg sync.WaitGroup for i := 0; i < 10; i++ { go Productor(datachan, i,&wg) //生产数据 wg.Add(1) } for j := 0; j < 10; j++ { go Consumer(datachan,&wg) //消费数据 wg.Add(1) } wg.Wait() } //结果 consumer data: 4 product data: 5 product data: 6 ....
小结:
- 并行和并发的差异。一个形容是一个时刻,一个是一段时间段的完成量。
- golang的调度三要素,GPM。M是对系统内核的封装,P为调度器,G为用户级别线程。P作为中介实现G和M的合理调度。
- golang的每个协程需要做好异常捕获,避免影响主线程。
- golang的协程同步使用两种方式:sync.WaitGroup()或是通过channel来传递信号,在主进程当中进行一个计数器。