Jochen的golang小抄-进阶篇-初窥并发编程(原理篇)
小抄系列进阶篇涉及的概念较多,知识点重要,故每块知识点独立成篇,方便日后笔记的查询
本篇的主题是:并发编程
go语言在当初推出时,最大的亮点就是其高并发的便捷性,其实现需要依靠协程(有的需要需要靠线程、进程)
什么是并发
我们前面写的程序都是从上至下按顺序执行的,像这样的程序如果遇到了需要一些耗时操作,就会傻不棱噔的等着操作结束后再往下执行,这样的程序执行方式我们称之为“串行”或者“同步”
反之,如果让程序遇到耗时程序时,不傻傻等待,而是一边让它去执行,一边让它接着往下做别的事情,这样的程序执行方式我们称之为“并发”或者"异步"
并发编程就是学习怎么让程序一边做一件事,又一边做另外一件事的方法
首先要理解进程、线程和协程的概念,这里只简单介绍下它们
-
进程:可以简单理解为“正在执行的程序”,系统资源分配的最小单位
进程一般由程序(我们代码写的就是进程要完成哪些功能已经怎么去完成)、数据集(程序执行过程所需要的资源,如内存分配等)、进程控制块(系统感知进程存在的唯一表示,用来记录进程的状态过程,系统利用其控制控制和管理进程)组成
ps: 创建、销毁和切换进程的开销是很大的 -
线程:轻量级进程,是系统的最小执行单位
一个进程可以包含多个线程,也就是说一个进程内的线程是可以共享内部资源的,大大节省了程序并发执行的开销。但是线程没有自己的系统资源,只能拥有在运行时必不可少的资源
进程好比一个流水线车间,多个程序在不同的流水线车间上并行(同时)流转着,生产不同的产品,每个车间内的工人就是线程,每个工人只能使用其所在流水线车间内的资源 -
协程(Coroutine):轻量级线程,也叫做微线程
这是一种用户态的轻量级线程,用户态线程可以简单理解为协程的一些和信息记录、状态控制(如上下文切换)需要由用户自己去管理,所以协程的调度完全由用户控制
协程最大的优势就是轻量级,因为其调度与子程序(函数)的切换完全由用户(程序自身)去分配和管理(用代码控制),所以几乎不耗费系统资源
协程一般与子程序(函数)比较理解,函数调用总有一个入口,一次返回,一旦退出,就完成了子程序的执行
go语言并发是依靠协程实现的。与进程和线程相比,协程的优势在与其轻量级,可以轻松的创建上百万个协程而不会导致系统资源的衰减(进程和线程通常最多不会创建超过1w个)
并发性(Concurrency)
go语言是并发语言,而不是并行语言,因为其实现异步的方式是通过协程(在go被称为Goroutine
)
并发与并行的区别:
- 并发和并行都是指的同时处理许多事情的能力,这里的同时指定是一个时间段内同时处理多个任务的意思
- 并发性,指的是同一时间点上只执行一个任务。但是在同一时间段来看,其同时的在处理多个任务,这是因为任务在一段时间内是交替执行的(cpu执行速度太快让我们感觉任务就是一起执行的)。并发主要针对的是cpu单核上执行的任务
- 并行性,指的是同一时间点可以同时执行多个任务。在同一段时间,其也是同时在处理多个任务,但是任务是一直地同时执行的。不同的任务并行执行需要由cpu不同核心同时执行
所以可以看到,真正的并行
是需要靠多核的硬件支持的,如果电脑是单核的就谈不上并行
并行并非意味着更快的执行时间,因为并行运行的程序之间往往需要通信,在多个核心上运行的程序之间进行通信开销是远远大于单核并发执行的程序之间的通信
Go的并发模型
go语言最大的又是就是可以方便的编写并发程序,程序中内置了
goroutine
机制,使用协程可以快速地开发并发程序,充分的利用系统资源,下面了解下go语言的并发原理
线程模型
Go的并发模型在底层是由操作系统所的线程库支撑的,因此,首先得从线程说起
无论语言层面的哪种并发模型,到了操作系统层面都是以线程的形式去表现出来,因为"线程是操作系统的最小执行单元",操作系统压根不认识我们所说的协程,笔者认为协程是用户自己使用程序去控制的整个控制流,线程则是由操作系统自动的帮我们管理整个控制流
用户态和内核态
操作系统根据资源访问权限不同分为用户态和内核态,简单来说就是用户空间和内核空间
- 内核空间主要负责CPU、IO、内存等硬件资源的访问、控制和调度,为上层(用户层)应用程序通提供最基本的基础服务
上面所说的这些工作其实就是操作系统的工作,操作系统没那么复杂,简单理解就是一个软件,一个负责管理硬件资源的,对上层提供资源服务的软件,而这个软件被隔离在一块叫做“内核态”的空间里运行 - 用户空间主要就是刚才说提到的上层应用程序的固定活动空间(如微信,百度云这些程序)。用户空间不可以直接访问,而只能通过系统调用、库函数或Shell脚本来调度内核态提供的资源
ps:我们的所使用的计算机语言可以狭义的认为是一种软件,它们中所谓的“线程”,其实说的是用户态的线程,这和操作系统本身内核态是有区别的。本小节介绍的线程模型是用户线程模型,简单理解就是用户线程和内核级线程的对应关系
再谈线程
线程可以看作是进程的控制流,一个进程中至少包含一个线程(第一个线程随进程创建而自动创建,也叫做主线程)。进程中可以拥有多个线程,这些线程是由当前进程中的已存在的线程通过系统调用创建出来的,而拥有多个线程的进程可以并发的执行多个任务
用户线程的实现模型
用户线程的模型有三个:用户级线程模型,内核级线程模型和两级线程模型,它们之间最大的差异在于线程与KSE
(内核调度实体即内核级线程)之间的对应关系
- 内核级线程模型:
- 用户线程和
KSE
是一对一的关系(大部分编程语言的线程库如Java
的Thread
和C++的std::thread
都是对操作系统线程(内核级线程)的一层封装) - 这样创建出来的每个用户线程都与一个不同的内核级线程关联,线程的调度就完全由操作系统来控制(所以一个线程阻塞是不会影响其他线程的),直接使用操作系统提供的线程管理功能,方便的一批
- 但是线程的创建、销毁和线程直接的上下文切换都由操作系统亲自做,这事情不多还好,一旦大量线程任务需要管理,操作系统的负荷就会非常大,以至于影响性能
- 在多核心处理器的支持下,内核级线程模型可以实现并行(不同线程可以在不同核心上执行)
- 用户线程和
- 用户级线程模型:
- 用户线程和
KSE
是多对一的关系,这样的对应方式需要要求用户自己去写应用程序库实现线程的管理(线程创建、销毁和多个线程之间的协调调度) - 这些用户线程对于操作系统来说是透明的(它们自己管理自己),操作系统压根不认识它们,它只认识内核级线程。现许多语言中(如
Python
)实现的协程就是使用的这种对应模型 - 用户级线程模型优势在于极其轻量级,线程在上下文切换的时候都是发生在用户空间,因为其对系统的资源消耗很小
- 用户级线程模型有个致命的缺点在于如果某个用户线程调用阻塞式的IO操作(如read网络的IO),此时
KSE
就会因为阻塞被操作系统休眠调出cpu,KSE
上的全部用户线程就会都一起变为阻塞状态,因为只对应一个KSE
,所以体现出来的就是整个进程被block
- 为了解决上面所述的问题,许多实现了协程的语言的协程库会把自己的一些阻塞操作封装为完全非阻塞的方式(如设置socket连接为非阻塞),然后在碰到阻塞时,主动让出资源,通过通知的方式唤醒其他待执行的用户线程在该
KSE
上运行,避免操作系统由于KSE
阻塞而切换上下文的操作,资源利用率可想而知 - 用户级线程模型因为所有的用户线程都对应到一个内核级线程上,这意味着只有一个处理器被利用,无法利用多核处理器的能力,实现并行操作(只能实现并发),这是非常难受的
- 用户线程和
- 两级线程模型(混合型线程模型):
- 用户线程和内核线程多对多的关系,这种实现就是结合内核级线程和用户级线程模型两者的优点,一个进程当中会创建多个内核线程,每个内核线程(
KSE
)又可以和用户线程在程序运行时进行动态的关联 - 当某一个内核线程由于上面的用户线程而阻塞而被内核调度出cpu时,此时当前和该
KSE
关联的其余用户线程又可以和其他KSE
进行关联,达到爽歪歪的效果(但是实现起来是比较复杂的,用户线程和内核线程的动态关联是需要用户自己去实现的,不过语言层面早已经设计好了) - Go语言的并发使用就是两级线程的实现方式,Go语言中实现了一个运行时调度器(用户调度器)专门负责“go中的线程(
GoRoutine
)”与KSE
的动态关联 - 该模型实现原理总结就是:用户调度器实现了用户线程到
KSE
的调度,内核调度器实现KSE
到cpu上的调度 - 本质上多个用户线程被绑定在了多个内核线程上,这能使得大部分的线程之间的上下文切换发生在用户空间中。而可对应的多个内核线程又可以充分的利用处理器的资源(多核处理器),实现并行操作
- 用户线程和内核线程多对多的关系,这种实现就是结合内核级线程和用户级线程模型两者的优点,一个进程当中会创建多个内核线程,每个内核线程(
Go并发调度(G-M-P模型)
在上面的线程模型中,我们知道go语言使用的并发模型为两级线程模型,其需要语言自己去实现调度器控制线程和
KSE
的动态关联和上下文切换go语言自己实现了用户态并发调度系统,可以自己管理和调度自己的并发任务,所以说原生支持并发。也就是说go语言自己实现了一个调度器负责把并发任务分配到不同的内核线程上面去运行,然后操作系统自己的内核调度器(与语言无关)接管内核线程在cpu上面的执行和调度
下面我们来了解下go语言的调度器
Go语言内置的调度器,可以让多核CPU中的每个核心执行一个协程
Go语言中把其中创建的"线程(协程)"称为goroutine
,其是用户线程,也被叫做协程
在Go语言中,使用goroutine
机制实现了并发操作,goroutine
机制是协程的一种实现,其实现了两级线程模型
调度器如何工作
go语言调度器是用户态的线程调度系统的实现,其是通过G-P-M模型实现的
要理解goroutine
机制的实现首先要认识下go语言scheduler
(调度器)的实现
scheduler
(调度器)实现主要分为四个重要结构:M
,G
,P
和Sched
其中M
、G
、P
定义在runtime.h
中,而Sched
定义在proc.c
内
重点要了解下M
、P
、G
,它们是Go语言运行时系统抽象出来的概念和数据结构对象,其中包含了内存分配器、并发调度器、垃圾收集器等提供语言运行环境的一系列组件(可以理解为Java当中的JVM
)
Sched
就是一个调度器,它维护着调度器的一些状态信息以及存储M
和G
的队列G
是goroutine
的缩写,我们下面会介绍的创建goroutine
的语法创建的就是一个G
对象(go
关键字加函数调用的代码),G
是对一个并发任务执行过程的封装(它包含了栈,指令指针)
这个G
也可以理解为是用户态线程,属于用户级资源,对操作系统是透明的,十分轻量级,可大量创建,上下文切换成本极低M
结构是Machine,是利用系统调用创建出来的操作系统线程实体
其作用就是执行G
当中包装的并发任务
M
是一个很大的结构,里面维护了很多小对象内存缓存(mcache
)、当前执行的goroutine
、随机数发生器等一大堆资源和状态记录信息
这个M
可以简单理解一个M是上面说的内核线程P
是Processor的缩写,其是一个逻辑处理器,主要作用就是用来调度goroutine
的
一个程序执行需要提供所需的资源,P
为G
对象在M
上的运行提供了本地化资源,它维护了一个groutine
队列(runqueue
)
这个P
可以理解为一个go代码片段所必须的资源,可以把他叫做上下文环境(context
)
正常情况下调度器会按下面的流程进行调度:
在单核心的处理器情况下:
所有的goroutine
(用户线程)运行在同一个M
系统(内核线程)中
M
系统(内核线程)会维护一个Processor
(上下文环境)
任何时刻一个Processor
中只有一个goroutine
在执行,其他goroutine
在runqueue
中等待
一个goroutine
运行完自己的时间片后,会让出上下文回到runqueue
中
ps:之前说过,再提醒一次!多个M在一个核心上运行叫做并发,在多个核心上运行叫做并行
在多核心处理器情况下:
为了运行多个同一时刻可以运行多个goroutine
,每个M系统都会持有一个Processor
程序的运行过程中,线程往往会遇到发生阻塞的情况,此时看一下调度器是如何进行处理的
线程阻塞时如何工作
当正在M1
上运行的goroutine
阻塞时(如读取文件,数据库操作等等),此时若P
中的runqueue
当中的其他goroutine
还在等待执行操作
则会创建一个新的系统线程(M2
),阻塞的M1
线程会放弃了它的Processor
,然后把runqueue
里面待执行的G
转到新的M2
中去运行
而M1
线程下面的goroutine
仍然会进行阻塞操作,从而导致M1
线程被操作系统挂起
待M1
中的goroutine
不阻塞后,M1
会被操作系统放到空闲线程队列中继续等待被cpu执行
这样的M-P-G调度模式可以既让阻塞的协程执行,同时也不会让队列当中的协程一直阻塞,从而实现并发或者是并行的执行
上下文(Processor)空闲时如何工作
若某个Processor
中的goroutine
全部都执行完时,此时runqueue
为空,表示没有goroutine
可以调度,它会从其他的忙碌中的Processor
中偷取一半的goroutine
拓展:
早期go语言运行时系统(go1.0)是没有P对象存在,go语言当中的调度器直接把G
分配到适当的M
当中去运行
但是这样会带来很多问题,例如:不同的G
在不同的M
上并发运行时,可能都需要向系统申请堆栈内存等资源。由于系统资源是全局的,此时就会因为资源竞争造成很多系统性能的损耗
为了解决这样的问题,从go1.1
开始在运行时系统加入了P
对象,用以管理G
对象。M
若想运行G
就必须首先去绑定一个P
,然后才能运行这个P
所管理的G
们。这样就可以在P
对象当中优先申请一些系统资源,G
在需要这些资源的时候,先去向自己的P
进行申请,若不够用或没有再向全局申请(向全局拿的时候会多拿一部分, 以便后面的G
可能会用到不必再向全局要)
这其实就是一个预先缓存的思路。就像我们要去政府办事,首先会去当地的机关部门看下能否搞定,如果搞不定再去中央解决,从而提高办事的效率
由于P
解偶了M
和G
的关系,假如在M
上面运行了一个阻塞式的G
,其余和P
相关联的G
也可以随这个P
迁移到其他活跃的M
上继续去运行,这样就可以保证每个G
能找到合适的M
高效的运行起来,从而提高系统的并发能力
GoRoutine
协程的英文名为Coroutine,go语言中的为自己的“协程”命名为Goroutine,它是go语言的专有名词
也就是说GoRoutine就是go语言的协程,go语言是通过Goroutine来实现并发的
ps:部分人从别的语言转过来的开发者也喜欢把它们叫做“线程”,其实也是可以的,因为其实际上属于用户线程
- 使用并发往往是同时执行几个任务,每个任务对应到程序中就是某个函数的代码,所有可以理解为
Goroutine
的作用就是执行某个函数和某个方法 Goroutines
是一个函数或方法,它与其他函数或方法同时运行Goroutine
是轻量级的线程,与线程相比,创建Goroutine
的成本极低:它就是一段代码,一个函数的入口。只需要在堆上为其分配一个堆栈(初始大小为4k,随着程序的执行可自动增删),而线程堆栈的大小必须指定并且固定- Go应用程序可以并发运行数个
Goroutines
主goroutine
main函数中若调用了goroutine
,则该main函数称为主goroutine
,也叫做主协程(不是执行main函数的goroutine
就叫做子协程)
主协程不单只是简单执行main函数如此简单:
- 首先它会设定
goroutine
所能申请的最大栈空间(32位os为250M,64位为1G,若goroutine
的栈空间大于最大尺寸限制,运行时会引发stack overflow
的运行时恐慌,程序终止) - 接下来主
goroutine
的会进行初始化操作:- 创建特殊的defer语句,用于在主协程非正常退出时做善后处理
- 启用一个用于在后台清扫内存垃圾的
goroutine
,设置GC可用的标识 - 执行
main
包中的init
函数 - 执行
main
函数,执行结束后主协程结束自己和当前进程的运行(此时若有子协程还在运行,则强制结束)
使用goroutine
go语言把并发编程简直简化成了傻瓜式操作,舒服的一批,通过代码看吧:
package main
import (
"fmt"
"time"
)
func main() {
/*
使用Goroutines:
在函数或方法调用前加上关键字go,就会同时运行一个新的Goroutine
Goroutine执行的函数往往是没有返回值的,即使有也往往会被舍弃
*/
//案例:使用一个goroutine打印英文,另一个goroutine打印中文,观察运行结构
//1.创建子协程,执行printEnglish()函数
go printEnglish()
//2.main函数中打印中文,因为main函数中使用了goroutine,所以此时main函数叫做主goroutine
for i := 0; i < 50; i++ {
fmt.Println("主goroutine打印中文:", "帅", i)
}
/*
输出结果发现:
1.主协程和子协程交替执行(每次执行都不一样,因为什么时候执行哪个协程是不缺地你过的)
2.若主协程执行结束,则子协程直接提前拉闸(结束)
*/
time.Sleep(2 * time.Second) //主函数结束后睡个两秒再结束,避免主协程执行完提前终止子协程
fmt.Println("main over..")
}
func printEnglish() int {
for i := 0; i < 100; i++ {
fmt.Println("子goroutine打印英文:", "golang is the best language!", i)
}
return 1
}
func init() {
fmt.Println("执行init")
}
Goroutine
规则须知:
- 当一个新的
goroutine
开始时,goroutine调用立即返回
。与普通函数调用不同的是:goroutine
的所有返回值都会被忽略,且go不等待goroutine
执行结束,当调用后立即继续往下执行调用处后面的代码 - main函数
goroutine
应该为其他的goroutines
执行,如果主协程终止,则程序终止,其他的子协程将提前会全部拉闸
本系列学习资料参考:
https://www.bilibili.com/video/BV1jJ411c7s3?p=15
https://books.studygolang.com/The-Golang-Standard-Library-by-Example/chapter01/01.1.html