当前位置: 首页 > news >正文

19.并发编程

    在学习并发编程之前,我们先来了解一下一些相关的术语:进程线程并行并发同步异步阻塞非阻塞协程等概念。

19.1 进程/线程

    进程是计算机中的程序在某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。例如一个运行的QQ软件就是一个进程。

    线程是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中实际运作单位。一个线程指的是进程中一个单一顺序的控制流。一个进程中可以钦多个线程,每个线程执行不同的任务。

    在面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的运行实例。

    进程与线程的关系示意图如下所示:

1901-进程与线程关系图.png

    以上关系图中各功能说明如下所示:

  • 进程:可以理解为操作系统中正在运行的一个应用程序
  • 内存空间:是指当进程启动后,会分配内存寻址空间。每个进程的内存空间相互独立
  • 网络资源句柄和文件资源句柄:是指程序可操作的资源,例如网络上的数据和存储在硬盘上的数据,是所有进程可以访问
  • 线程:是操作系统进行运算和调度的最小单位,线程由程序计数器线程本地存储构成。
  • 栈:是内存上的一种存储单元,当程序运行时,会由主线程不断进行函数调用,每次调用时,都会把参数地址和返回地址压入栈中
  • 程序计数器:也称PC(Program Conunter),是存储线程中当前正在运行的指令地址
  • 线程本地存储:是线程中的一块独立内存空间,用来存储线程独有的数据

    进程之间通过IPC实现通信,线程之间通过共享内存实现通信。同一个进程中至少有一个线程,进程中的多个线程可以并发执行。

    线程常见的状态如下所示:

1902-线程的状态转换.png

19.2 协程

    协程(Corouting)也称之为微线程,是一种用户态轻量级线程,也是一种非抢占式实现多任务的方式。它允许在多个任务之间切换和共享计算资源。与进程/线程不同,协程的调度是由程序或运行时系统主动控制的。而不是由操作系统的内核线程调度的。协程通常用于实现并发任务,不同于传统的线程或进程并行模型,协程是通过协作的方式共享控制权,并在需要时手动让出控制权。

    协程最显著的特点是可以在执行过程中暂停,然后在稍后的时刻恢复运行。这个功能使协程非常适用于I/O密集型和高并发场景。

    协程的特点如下所示:

  • 轻量级:协程是由程序控制的。所需的系统资源非常少。与操作系统的线程相比,协程具有较小的栈内存,占用资源更少,能够同时创建上万个甚至更多的协程
  • 非抢占式调度:与线程的抢占式调度不同,协程是通过主动让出控制权实现任务切换。协程在执行时不会被操作系统中断,只有协程自己能决定何时暂停执行并切换到另一个协程上
  • 持久性:协程可以保存其执行上下文(包含局部变量、程序计数器等),当它被重新激活时,可以从上次暂停的地方继续执行。协程可以在任意位置暂停和恢复。
  • 并发模型:通过协程,程序可以在处理一个任务时,在某个时间点暂停执行,切换到其他任务。当一个任务处于I/O操作时,协程可以切换到其他任务继续执行,不必等待I/O操作完成。

    协程的核心工作原理是控制转移,即协程在执行过程中可以主动将控制权让出给调度器,从而让调度器可以切换其他协程运行。协程有两个关键操作:

  • 暂停(yield): 协程在某个时刻主动放弃执行,保存当前执行状态、等待以后恢复
  • 恢复(resume):协程被调度器重新激活,从之前暂停的地方继续运行。

    协程的调度由用户代码或运行时系统负责管理,而不是依赖操作系统的线程管理机制。通过这种方式,协程切换开销极低,因为不涉及内核状态切换,通常只需要保存寄存器、堆栈等状态。

    协程的类型主要有对称协程非对称协程

  • 对称协程:在对称协程中,任何协程都可以在执行时,将控制权交给其他协程。所有协程的调度平等
  • 非对称协程:非圣物协程中,采用的是类似于函数调用的模式,一个主协程调用其他子协程,子协程只能将控制权交给主协程,而不能直接交给其他协程。

    以上都是协程的优势,但协程也存在一些劣势的,如下所示:

  • 一旦一个协程阻塞,也会阻塞当前所在的线程和其他协程
  • 协程必须主动让出,才能轮到线程中另一个协程运行

19.3 并行/并发

    并发描述的是程序的组织结构,指程序被设计成多个可独立运行的子任务,主要以利用有限的计算机资源使多个任务可以被实时或接受实时执行为目的。它主要强调的是在同一个时间段内做了几件事,强调的是CPU单核能力。例如高速公路中,匝道上的车汇入主干道时,采用就是交替汇入。

    并行描述的是程序的执行状态,指多个任务同时被执行。主要以利用更多的计算资源快速完成多个任务为目的。它主要强调的是在同一个时该内,可以互不干扰的做几件事,强制的是CPU多核同时运行的能力。例如高速公路中的车道,双向8车道,所有车道可以在互不干扰的情况,在各自的车道奔跑。

    示意图如下所示:

1903-并发与并行示意图.png

    针对以下区别,总结如下所示:

描述
并行 强调在同一时刻,有多个事情同时在做且互不干扰
并发 强调在同一个时间段内,有多个事情在做,但在某一时刻,仅有一个事情在做,多个事情是交替执行完成的

19.4 同步/异步

    函数或方法在被调用的时候,以被调用者是否得到最终结果来看,直接得到结果,就是同步调用,不能直接最终结果的就是异步调用。例如,我们去餐厅吃饭,最终的结果就是我要吃到饭;如果到达餐厅之后,直接就能进去吃饭,就属于同步,如果到达餐厅之后被告知,暂时没有座位,需要等一会儿,就属于异步。

    对应到进程/线程中,同步是指为了完成某个操作,多个线程必须按照特定的通信方式协调一致,按顺序执行。异步是指为了完成某个操作,无需特定的通信方式协调也可以完成任务的方式。

    同步、异步的区别主要在于调用者是否得到了最终想要的结果,同步就是一直要执行到返回结果,异步就是直接返回了,但不是最终结果。调用者不能通过这种调用得到结果,还要通过被调用,使用其他方式通知调用者来获取最终的结果。

19.5 阻塞/非阻塞

    函数或方法调用的时候,以是否立即返回来看,立即返回就是非阻塞调用,如果不是立即返回,则是阻塞调用。还是以去餐厅吃饭为例;到达餐厅之后被告知,暂时没有座位,我就一直等待在那,什么事也不能做,就属于阻塞调用;我们也可以先取一个号,然后可以玩手机、再去其他地方逛逛,就属于非阻塞。

    对应到进程/线程中,阻塞就是一个线程所访问资源被其他线程占用时,需要等待其他线程完成操作,在等待期间该线程自身也无法继续其他操作。常见的阻塞有网络IO阻塞、磁盘IO阻塞、用户输入阻塞等。非阻塞就是指线程在等待其他线程的过程中,自身不被阻塞,可以继续执行其他操作。

    阻塞与非阻塞的区别主要在于调用者是否还可以干其他的事,如果调用者不能干其他的事,只能等待就属于阻塞,如果调用者还可以去做其他的事,而不用一直等待,则属于非阻塞。

    同步/异步、阻塞/非阻塞还可以相互组合,还是以餐厅吃饭为例

  • 同步阻塞:到达餐厅之后,告知没有座位,我就站在前台那一直等,且在这个等待期间,我什么事也不做,就傻傻的等
  • 同步非阻塞:到达餐厅之后,告知没有座位,我就站在前台那一直等,但在这个等待期间,我可以刷刷手机
  • 异步阻塞:到达餐厅之后,告知没有座位,餐厅让等叫号,然后我就找个座位坐下,在这个等待期间,我什么事也不做,就坐在那傻傻的等
  • 异步非阻塞:到达餐厅之后,告知没有座位,餐厅让等叫号,然后我就找个座位坐下,在这个等待期间,我可以刷刷手机

19.6 Goroutine

    Goroutine是Go语言中特有的术语,它指的是由Go运行时管理的轻量级执行线程。Goroutine用于并发任务,是Go语言实现并发和并行编程的核心。在使用Goroutine时,需要先了解一下GMP模型。

19.5.1 GMP 调度模型的设计思想

    在现代操作系统中,为了提高并发处理能力,一个CPU核心上通常运行多个线程,多个线程的创建、切换使用、销毁开销通常比较大,主要以以下几个原因:

  • 一个内核线程的大小通常达到1M,因为需要分配内存来存放用户栈和内核栈的数据
  • 在一个线程执行系统调用(发生IO事件,如网络请求或读写文件时)不占用CPU时,需要及时让出CPU,交给其他线程执行,这时会发生线程之间的切换
  • 线程在CPU上进行切换时,需要保持当前线程的上下文、将待执行的线程上下文恢复到寄存器中,还要向操作系统内核申请资源

    在高并发的情况下,大量线程的创建、使用、切换、销毁会占用大量的内存,并浪费较多的CPU时间在非工作任务的执行上,导致程序并发处理事务的能力降低。

19.5.2 Go语言早期引入的GM模型

    为了解决传统内核级的线程创建、切换、销毁开销较大的问题,Go语言将线程分为了两种类型内核级线程M(Machine)轻量级用户态协程Goroutine

  • M:Machine的缩写,代表了内核线程OS Thread,CPU调度的基本单元
  • G:Goroutine的缩写,用户态、轻量级的协程,一个G代表了对一段需要被执行的Go语言的封装,每个Goroutine都有自己独立的栈存放自己程序的运行状态;分配的栈大小2KB,可以按需扩容,示意图如下所示:

1904-Goroutine中的G和M.png

    在早期,Go将传统线程拆分为了M和G之后,为了充分利用轻量级G的低内存占用、低开销的优点,会在当前一个M绑定多个G,某个正在运行的G执行完成后,Go调度器会将该G切换走,将其他可以运行的G放入M上执行,这时一个Go程序中只有一个线程M。示意图如下所示:

1905-多个G对应一个M.png

    这个方案的优点是用户态的G可以快速切换,而不会陷入内核态,缺点是每个Go程序都用不了硬件的多核加速能力,并且G阻塞会导致跟G绑定的M阻塞,其他G也用不了M去执行自己的程序了。为了解决这些不足,Go后来快速上线了多线程调度器,如下所示:

1906-多个G对应一个M.png

    每个 Go 程序,都有多个M线程对应多个G,该方案拥有以下缺点:

  • 全局锁、中心化状态带来的锁竞争导致的性能下降
  • M会频繁交G,导致额外开销、性能下降,每个M都得能执行任意的runable的状态G
  • 每个M都需要处理内存缓存,导致大量的内存占用并影响数据局部性
  • 系统调用频繁阻塞和解除阻塞正在运行的线程,增加了额外开销

19.5.3 GMP模型

    为了解决多线程调度器的问题,Go在已有G和M的基础上,引入了P处理器,由此产生了经典的GMP模型。

    P:Process的缩写,代表一个虚拟的处理器,它维护一个局部的可运行G队列,可以通过CAS的方式无锁访问工作线程M,优先使用自己的局部队列中的G,只有必要时才会访问全局运行队列,这样就大大减少了锁冲突,提高了大量G的并发性。每个G要想真正运行起来,首先需要被分配一个P。示意图如下所示:

1906-GMP模型.png

    当前Go采用的GMP调度模型如上图所示。可运行的G是通过处理器P和线程M绑定起来的。M的执行是由操作系统调度器将M分配到CPU上实现的,Go运行时调度器负责调度G到M上运行,主要在用户态运行,跟操作系统调度器在内核态运行相对应。

    Go调度器也叫Go运行时调度器或Goroutine调度器,指的是由运行时在用户态提供的多个函数组成的一种机制,目的是为了高效地调度G到M上去执行。可以跟操作系统的调度器OS Scheduler对比来看,后者负责将M调度到CPU上执行。从操作系统层面来看,运行在用户态的Go程序只是一个请求和运行在多个线程M的普通进程,操作系统不会直接跟上层的G打交道。

为什么不直接将本地队列放在M上,而是放在P上?是因为当一个线程M阻塞时候,可以将和它绑定的P上面的G转换到其他线程去执行,如果直接把可运行G组成的本地队列绑定到M,则当M阻塞,它拥有的G就不能给到其他M去执行了。

    基于GMP模型的Go调度器的核心思想如下所示:

  • 尽可能复用线程M:避免频繁的线程创建和销毁
  • 利用多核并行能力:限制同时运行(不包含阻塞)M的线程为N,其中N等于CPU的核心数目,可通过设置P处理器的个数为GOMAXPROCS来保证,GOMAXPROCS一般为CPU核数,因为M和P是一一绑定的,没有找到P的M会放入空闲M列表,没有找到M的P会放入空闲P列表
  • Work Stealing任务窃取机制:M优先执行其绑定的P本地队列G,如果本地队列为空,可以从全局队列中获取G运行,也可以从其他M偷取G来运行,为了提高并发执行的效率,M可以从其他M绑定的P的运行队列中偷取G来执行,这种GMP调度模型称为任务窃取机制
  • Hand Off 机制:M阻塞,会将M上P的运行队列交给其他M运行,交接效率要高才能提高Go程序整体的并发度
  • 基于协作的抢占机制: 每个真正运行的G,如果不被打断,将会一直运行下去,为了保证公平,防止新创建的G一直获取不到M执行造成的饥饿机问题,Go程序会保证每个G运行10ms就要让出M,交给其他G运行
  • 基于信号的抢占机制:尽管基于协作的抢占机制能够缓解长时间GC导致整个程序无法工作和大多数Goroutine饥饿问题,但还有部分情况下,Go调度器有无法被抢占的情况。例如,for循环或垃圾回收长时间占用线程,为解决这些问题,Go引入了基于信号的抢占式调度机制,能够解决GC垃圾回收和栈扫描时存在的问题。

参考资料:https://zhuanlan.zhihu.com/p/586236582

19.5.4 协程创建

    在Go语言中创建协程是非常简单的,即使用go关键字就可以把一个函数定义为一个协程。示例如下所示:

go funcName(parameters)

19.5.4.1 创建一个 Goroutine

    示例代码如下所示:

package mainimport ("fmt""runtime"
)func add(x, y int) int {var c intdefer func() {fmt.Printf("func c=%+v\n", c)}()defer fmt.Printf("c=%+v\n", c)fmt.Printf("调用add函数,x=%d,y=%d\n", x, y)c = x + yreturn c
}// go 主线程,主协程,main函数
// go main函数无事可做,则把主线程结束,则进程结束了
func main() {fmt.Printf("%+v\n", runtime.NumCPU())fmt.Println("main start")// 协程,需要调度,需要些时间go add(4, 5)fmt.Println("main end")
}

    输出结果如下所示:

20
main start
main end

    在Go语言中,如果主线程结束,则进程就结束了。因此在上面的输出中,并没有相应的Goroutine的输出结果。来看看以下示例:

package mainimport ("fmt""runtime""time"
)func add(x, y int) int {var c intdefer func() {fmt.Printf("func c=%+v\n", c)}()defer fmt.Printf("c=%+v\n", c)fmt.Printf("调用add函数,x=%d,y=%d\n", x, y)c = x + yreturn c
}// go 主线程,主协程,main函数
// go main函数无事可做,则把主线程结束,则进程结束了
func main() {fmt.Printf("%+v\n", runtime.NumCPU())fmt.Println("main start")fmt.Printf("Goroutine数量:%+v\n", runtime.NumGoroutine())// 协程,需要调度,需要些时间go add(4, 5)// 主协程进入阻塞态time.Sleep(2 * time.Second)fmt.Println("main end")fmt.Printf("Goroutine数量:%+v\n", runtime.NumGoroutine())
}

    输出结果如下所示:

20
main start
Goroutine数量:1
调用add函数,x=4,y=5
c=0
func c=9
main end
Goroutine数量:1

19.5.4.2 创建多个 Goroutine

    在Go语言允许创建多个Goroutine,以实现任务的并发执行,示例代码如下所示:

package mainimport ("fmt""time"
)func printNumbers() {for i := 5; i < 9; i++ {time.Sleep(100 * time.Millisecond)fmt.Printf("i=%d ", i)}
}func printChars() {for i := 'a'; i < 'd'; i++ {time.Sleep(200 * time.Millisecond)fmt.Printf("i=%c ", i)}
}func main() {fmt.Println("main start")go printNumbers()go printChars()time.Sleep(5 * time.Second)fmt.Println("main end")
}

    输出结果如下所示:

main start
i=5 i=a i=6 i=7 i=b i=8 i=c main end

在上述示例中,同时启动了两个Goroutine,它们分别执行 printNumbers 和 printChars 。由于Goroutine是并发的,所以输出结果也是基本交替执行。在主协程使用阻塞函数,以保证子协程能够执行完毕。

19.5.4.3 创建父子协程

    父子协程即一个协程A中创建了另一个协程B,A称作父协程,B称为子协程。示例代码如下所示:

package mainimport ("fmt""sync""time"
)func main() {var wg sync.WaitGroupfmt.Println("main start")count := 6wg.Add(count)go func() {fmt.Println("父协程开始,准备启动子协程")defer func() {wg.Done()fmt.Println("父协程结束了...")}()for i := 1; i <= count; i++ {go func(id int) {defer wg.Done()fmt.Printf("子协程 %d 运行中\n", id)time.Sleep(5 * time.Second)fmt.Printf("子协程 %d 结束\n", id)}(i)}}()wg.Wait()fmt.Println("main end")
}

    运行结果如下所示:

main start
父协程开始,准备启动子协程
父协程结束了...
子协程 6 运行中
子协程 1 运行中
子协程 4 运行中
子协程 3 运行中
子协程 2 运行中
子协程 5 运行中
子协程 2 结束
子协程 1 结束
子协程 4 结束
子协程 3 结束
子协程 6 结束
main end

通过输出结果可以看出,父协程结束执行,子协程不会有任何影响。当然子协程结束执行,也不会对父协程有什么影响。父子协程没有什么特别的依赖关系。各自独立运行。只有主协程特殊,它结束则程序结束

19.7 等待组

    在前面示例代码中,为了让主协程等待子协程执行完毕,我们使用了time.Sleep()强制阻塞主协程。但在实际开发中,子协程什么时候能执行完成是不可预知的。那如何让主协程优雅等待协程执行结束呢?由此便引出了等待组。因为要等待子协程执行完成,因此会涉及到同步技术。在Go语言中,使用sync.WaitGroup包,其主要方法如下所示:

方法名称 功能
Add 等几个
Wait 开始等,剩余为0个时,不再等待
Done 完成一个,减一个

    示例代码如下所示:

package mainimport ("fmt""runtime""sync""time"
)func printNumbers(wg *sync.WaitGroup) {defer wg.Done()for i := 5; i < 9; i++ {time.Sleep(100 * time.Millisecond)fmt.Printf("i=%d ", i)}}func printChars(wg *sync.WaitGroup) {defer wg.Done()for i := 'a'; i < 'd'; i++ {time.Sleep(200 * time.Millisecond)fmt.Printf("i=%c ", i)}
}func main() {var wg sync.WaitGroupwg.Add(2)fmt.Println("main start")fmt.Printf("当前协程数量:%+v\n", runtime.NumGoroutine())go printNumbers(&wg)go printChars(&wg)fmt.Printf("当前协程数量:%+v\n", runtime.NumGoroutine())// 阻塞等待到0为止wg.Wait()fmt.Println("main end")fmt.Printf("当前协程数量:%+v\n", runtime.NumGoroutine())
}

    输出结果

main start
当前协程数量:1
当前协程数量:3
i=5 i=a i=6 i=7 i=b i=8 i=c main end
当前协程数量:1
```![1903-并发与并行示意图.png](https://upload-images.jianshu.io/upload_images/3349421-6b9ecc607823b97b.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)**本文同步在微信订阅号上发布,如各位小伙伴们喜欢我的文章,也可以关注我的微信订阅号:woaitest,或扫描下面的二维码添加关注:**
![](https://img2020.cnblogs.com/blog/1107960/202110/1107960-20211031233553291-2147432520.jpg)
http://www.hskmm.com/?act=detail&tid=16309

相关文章:

  • 复健
  • 苍穹外卖-day10(spring Task,WebSocket,来单提醒客户催单) - a
  • 在CodeBolcks下wxSmith的C++编程教程——使用wxPanel资源
  • 大龄程序员35岁后职业发展出路:认知与思路转变
  • Python安装与Anaconda环境搭建:新手完整教程
  • Unicode 标准 17.0版已经于2025.9.9发布
  • 美女壁纸 纯欲风 清纯
  • AC自动机
  • 虚拟机开机网络连接失败
  • unprofitable25,3
  • 随机过程学习笔记
  • Easysearch 国产替代 Elasticsearch:8 大核心问题解读
  • 9.24 闲话
  • AGC023F 题解
  • 个人介绍
  • C#学习2
  • AGC203F 题解
  • 高级的 SQL 查询技巧
  • 25,9.24日报
  • 在台风天找回了生活的本貌
  • 第二周第三天2.3
  • 欧几里得算法
  • Error response from daemon: could not select device driver nvidia with capabilities: [[gpu]]
  • 全内存12306抢票系统设计:基于位运算的高效席位状态管理
  • 第三天
  • adobe illustrator中如何打出度数的上标
  • day003
  • newDay03
  • 9.24总结