多线程并发
进程管理
1.进程和线程区别 进程是操作系统进行资源调度和分配的基本单位,早期的程序都是单进程的,而到了后期随着多核CPU的发展,进程中包括了至少一个线程的存在,而多个线程共享进程的堆内存和方法区,线程是进程的一个实体,是CPU调度的分派的基本单位,是比进程更小的能独立运行的基本单位,线程基本上不拥有系统资源,只拥有(程序计数器,寄存器和栈),但是它可以和其他线程共享进程所拥有的资源
2.进程通信的方式 管道,信号量,共享内存,消息队列,套接字
3.并发的几种方式
多进程,多线程,协程,异步
3.1 多进程:c的fork,python的multiprocessing
3.2 多线程:java的thread
3.3 协程:Gorountine,gevent
3.4 异步:select,poll,epoll
3.进程,线程,协程的区别 协程是一种基于线程之上,比线程更加轻量级的存在,这种由程序员自己写程序管理的线程叫做用户线程,是不需要内核进行管理的,同时在当今互联网环境下,为每一个任务都创建线程是不现实的,会消耗大量的内存
同时线程的实现模型也会分为:内核级线程模型,用户级线程模型和混合级线程模型
3.1 用户级线程模型
用户线程与内核线程(Linux的PCB进程控制块)KSE是多对一(N:1)的,多个用户线程一般从属于一个进程,线程的创建,销毁,协作都是由用户自己的线程库来实现,而无需系统调用,一个进程中创建的线程都只能和同一个内核线程动态绑定,内核的所有调度都是基于用户线程,但是这样有个很大的问题,并不能做到真正意义上的并发,原因是一个内核线程绑定了一个用户进程,如果用户进程出现了中断,那么进程中的某个线程可能会出现阻塞,这时整个进行都会阻塞在这里,即使是多核机器,都没有用,可以理解为这个情况下,CPU的调度单位是用户进程,所以一些协程会将一些阻塞的操作换成非阻塞的,然后在阻塞的时候,主动让出CPU,从而唤醒其他用户线程执行,避免了内核调度器做上下文切换
例子:python的gevent库
3.2 内核级线程模型
用户线程和内核线程是1对1的模型,也就是一个用户线程绑定一个实际的内核线程,线程的调度完全交给内核去做,应用程序对线程的创建,终止和同步都是基于内核完成,比如说java的Thread都是操作系统对内核级线程的封装,创造出的每个线程与一个独立的调度器绑定,他的优势是实现简单,直接借助OS内核以及调度器,所以CPU可以快速切换调度线程,真正实现了并行处理,但是劣势是借助了OS内核来操作,线程的创建,销毁,以及多个线程之间的上下文切换和调度成本大大增加,影响性能
例子:java的thread
3.3混合线程模型
用户线程和内核线程是N:M,多对多的,一个进程可以和多个内核线程关联,也就是一个进程内部的多个线程可以分别绑定一个自己的内核线程,但是一个进程里的线程并不会和KSE直接绑定,这种方式又可以表现为多个用户线程映射到同一个内核线程,当某个内核线程因为绑定的线程阻塞操作被内核调度出CPU时,其关联的进程中其余用户线程可以重新和其他内核线程绑定运行,所以这种是协同调度,但是比较复杂,一般用作第三方库
golang的GMP调度器
在这里可以讲一讲,每个OS线程都会有一个固定大小的内存块,一般是2mb作为栈,用来存储当前正在被调用或者挂起的本地函数内部变量(操作数,局部变量,方法数),golang协程相比于java线程来说,开销更小,栈的大小初始时大小只为2kb,随着任务的执行,最大可以达到1GB,并且完全由golang自己的调度器进行调度,GC线程会周期性的回收不用的内存,收缩栈空间,因此这就是golang能够同时并发成千上万个协程的关键
同时,goroutine G也是完全基于用户态的线程,并不会直接绑定OS线程,而是由他的逻辑处理器P进行执行,逻辑处理器是一个抽象的资源,一个处理器可以绑定一个OS线程,在golang内部,将OS线程抽象为了一个数据结构,所以M,M,G实际上都是由P进行调度的,P可以视为运行他们的CPU
在golang中,线程才是运行goroutine的实体,P的功能就是将可运行的G分配到工作线程上
GO的调度器基本结构
- G:每个goroutine绑定一个G结构体,G存储goroutine的运行堆栈,状态和任务函数,需要绑定P才可以被调度执行
- P:逻辑处理器,G只有绑定在P上才可以被调度,对M来说,P提供了执行环境和内存状态,任务队列等,P的数量决定了可以并行运行G的数量
- M:OS的线程抽象,代表真正执行计算的资源,绑定P后,才能进入调度状态,M不保留G的状态,所以G可以跨M调度,同时M的数量是由Go Runtime动态调整的,防止调度数太多造成系统瓶颈
- 全局队列(Global Queue):存放等待运行的G。
- P的本地队列:同全局队列类似,存放的也是等待运行的G,存的数量有限,不超过256个。新建G’时,G’优先加入到P的本地队列,如果队列满了,则会把本地队列中一半的G移动到全局队列。
- P列表:所有的P都在程序启动时创建,并保存在数组中,最多有
GOMAXPROCS (可配置)个。 - M:线程想运行任务就得获取P,从P的本地队列获取G,P队列为空时,M也会尝试从全局队列拿一批G放到P的本地队列,或从其他P的本地队列偷一半放到自己P的本地队列。M运行G,G执行之后,M会从P获取下一个G,不断重复下去。
Go调度器的设计策略
复用线程:避免频繁的创建、销毁线程,而是对线程的复用。
1)work stealing(工作窃取)机制
当本线程无可运行的G时,尝试从其他线程绑定的P偷取G,而不是销毁线程。
2)hand off(任务转移)机制
当本线程因为G进行系统调用阻塞时,线程释放绑定的P,把P转移给其他空闲的线程执行。
利用并行:GOMAXPROCS 设置P的数量,最多有GOMAXPROCS 个线程分布在多个CPU上同时运行。GOMAXPROCS 也限制了并发的程度,比如GOMAXPROCS = 核数/2 ,则最多利用了一半的CPU核进行并行。
抢占:在coroutine中要等待一个协程主动让出CPU才执行下一个协程,在Go中,一个goroutine最多占用CPU 10ms,防止其他goroutine被饿死,这就是goroutine不同于coroutine的一个地方。
全局G队列:在新的调度器中依然有全局G队列,但功能已经被弱化了,当M执行work stealing从其他P偷不到G时,它可以从全局G队列获取G。
Go func()调度流程
(1) 通过使用go func创建一个goroutine
(2) 有两个存储G的队列,一个是局部调度器P的本地队列,一个是全局G的队列,新创建的G首先会进入到P的本地队列中,如果本地队列已满,新创建的G就会保存在全局的队列中
(3) G只能运行在M中,一个M必须持有一个P,M和P是1比1的关系,M会从P的本地队列中弹出一个可执行状态的G来执行,如果P的本地队列为空,就会从其他MP的组合中窃取一个可执行的G来执行
(4) 一个M调度G的执行过程是在一个事件循环机制下完成的,具体来说,当M执行某一个G时,M发生了中断从而进行了syscall或者其余阻塞事件的发生,M就会阻塞,如果当前有一些G在执行,调度器会将这个阻塞的M从P中摘除,然后创建一个新的操作系统线程(如果有空闲的线程可以复用)来服务这个P
(5) 当M的syscall结束时,G会尝试获取一个空闲的P执行,并且放入到这个P的本地队列,如果获取不到P,那么这个线程M就会变成休眠模式,加入到空闲线程中,然后G会进入到全局队列里面
调度过程例子: 以一个hello world为例
package main
import "fmt"
func main() {
fmt.Println("Hello world")
}
(1) goruntime(go的执行环境,goruntime和用户代码一起编译到一个可执行文件中)创建最初的线程M0和goroutineG0,并将二者关联
- 这里的M0是启动程序后的编号为0的主线程,不需要在堆上分配,负责初始化第一个G,此后的角色和其他M是一样的,G0则是伴随着M创建而创建,G0主要用于负责调度,不指向任何可执行的函数,每一个M都有一个自己的G0
(2)调度器初始化m0,栈,垃圾回收,以及创建和初始化指定数目的由P构成的P列表
(3) 示例代码经过编译之后,runtime会调用main函数,程序启动后会为runtime创建goroutine, 然后将main goroutine加入到P的本地队列
(4) 启动m0,m0已经绑定了P,会从P的本地队列获取G,获取到main goroutine
(5) G拥有自己的栈,M根据G汇总的栈信息和调度信息设置运行环境
(6) M运行G
(7) G的cpu使用时间用完,G退出了M的调度,M会再次获取可运行的G,不断重复下去,知道main函数执行完退出,runtime执行defer和panic处理,或者直接exit退出程序
|