进程与线程
- cpu与进程的关系
1.cpu不是同时在执行进程,而是同一时间段,只能执行一个进程,但是这个时间段很短,cpu在很多进程之间快速的切换执行
-
进程的执行状态图
-
进程间的切换过程图
-
进程的上下文切换
1. cpu的上下文是指:cpu的寄存器和程序的计数器
2. cpu的寄存器:cpu的内置容量小,速度极快的缓存
3. 程序的计数器:用来存储cpu正在运行的指令位置或者即将执行的下一条指令的位置
- 线程
1.在早期操作系统都是以 进程 为单位进行独立运行,后续提出更小的能独立运行的基本单位线程
2.现在操作系统:进程是最小的资源分配单位,线程是最小的运行单位一个进程下面具有一个或多个线程,每个线程都有独立的寄存器和栈,这样可以确保线程的控制是相对独立的
- 线程的优缺点
优点:
1.进程中可以存在多个线程
2.多线程可以让进程具备多任务并行处理的能力
3.线程的切换比进程切换的时候开销小,并且更加轻量
缺点:
1.线程之间会共享进程的资源,会产生资源的争抢,需要锁机制来解决
2.进程中一个线程崩溃,让导致其他线程崩溃
- 进程与线程对比
1.进程包含线程,一个进程可以包含多个线程
2.进程拥有一个完整的资源平台,而线程拥有必不可少的资源,如:寄存器和栈
3.线程和进程具有相同的切换状态
4.线程的创建、终止、切换比进程快
5.线程的切换上下文和进程相似
用户态和内核态
- cpu的指令
1.主要功能就是提供软件对硬件系统的执行桥梁,每一条汇编语言都会对应一条cpu指令,而多个指令合到一起就是cpu指令集
2.而对CPU指令则会存在权限分级,限制操作硬件的指令;这样的设定就是为了避免不规范的CPU指令出现问题而影响整个计算机系统,甚至到崩溃
- CPU对指令的权限划分为4个级别
1.ring0 --- 权限级最高,可以运用所有CPU指令
2.ring1
3.ring2
4.ring3 --- 权限最低仅仅为常规指令;不能使用操作硬件资源的CPU指令,比如IO读写、网卡、申请内存等
- 用户态和内核态的理解
用户态和内核态概念上就是指令权限上的区别
1.ring0 :就是内核态,完成在操作系统内核中运行;当一个任务(进度)执行系统调用在内核代码中执行时我们称之为内核态
2.ring3 :就是用户态,在应用程序运行 ;当一个任务任务执行用户自己的代码时,则处于用户态
- 内核空间和用户空间
1.用户空间:就是用户进程所在内存区域
2.内核空间:就是系统进程占据的内存空间
- 空间与运行态的关系
对于用户态和内核态,主要是指进程在不同空间的运行的称呼
1.用户态:当进程执行用户自己的代码的时候,(ring3)就是用户态,而用户态的程序只能访问用户空间
2.内核态:当进程在执行系统调用而在内核代码中执行时,(ring0)就是内核态,而处于内核态的程序可以访问到内核空间和用户空间
GPM
- 协程在程序阻塞的时候才会执行和切换的,这种阻塞是非密集型阻塞才会执行(io阻塞),只写一个for的死循环不符合非密集型阻塞,但是如果代码里面有些for{}的死循环,但是go func(){}还是会被执行,这是为什么???
// 在如下代码中
func main() {
go func() {
fmt.PrintLn("协程")
}
for { // 这个位置会阻塞main的程序,但是for是非密集型阻塞,不会进行协程的切换,但是为什么在执行这个程序的时候,会执行上面的协程呢?这个是1.14版本之后才有的情况
}
}
// 原因:在go中新增的一个信号的内容
// 程序在编译==》运行==》结束 程序在监听整个运行期间,是否存在死循环,不是特意的去监听死循环的代码,而是监听超过某一个执行的时间,超过了底层就会自动去切换协程(切换的方式就是通过信号),处理的方法在 runtime.doSingPreempt()
- GPM是go的调度器模型
// G :是指协程
// P : 是指调度器
// M : 是指用户态的线程
- GPM的理解
1.在cpu运行时,通过内核态的线程和用户态的线程进行绑定,而这种绑定就是通过系统的调度器来实现的,这个了解就好
2.M和G的绑定,其实是M和P进行绑定,再通过P去取G给到M来执行
3.在整个程序中,会存在两个队列(存储G的地方),一个是全局队列,一个是P的本地队列
4.P的数量可以通过runtime.GOMAXPROCS设置,默认是cpu的核数
5.P的本地队列可以最多存放256个G
- GPM的具体实现
1.在go程序中,执行到要通过协程来执行代码时,go func(){} 会将G加入P的本地队列,如果P的队列满了,才会存入全局队列
2.M会去P的队列里面将G取出,如果P的队列为空时,则会去别的p的队列中抢一半过来,存到自己的队列中,或者去全局队列里面取出一部分,放到自己队列里面,再将G给到M,在M中取执行。
- GPM的好处
1.协程的目的:可以理解为纯用户态的线程,其通过协作而不是抢占来进行切换。相对于进程或者线程,协程所有的操作都可以在用户态完成,创建和切换的消耗更低,而且协程的占用空间也很小 4K左右
2.GPM的目的:通过调度器来绑定用户态的线程和协程,GMP这个模型实际上是在对自己创建的协程,做调度的切换,这个模型除了在go中引用以外也在系统里面的,这个模型在内核态的线程和用户态的线程绑定中使用。
3.GPM的优势:最大的好处在于可以充分利用cpu自由的通过构建多个P,做好对多个协程的切换,该模型可以做到多对个协程/内核线程的切换而这个切换通过调度器对运行的程序监控来做到
channal应用
- 1.通道同步
package main
import "fmt"
func main() {
ch := make(chan int, 0)
go revice(ch)
ch <- 1 // 如果在 make的时候 chan有空间,这个位置则不会阻塞,当前状态会阻塞,因为make的时候容量为0
fmt.Println("结束")
}
func revice(ch chan int) {
c := <-ch
fmt.Println(c)
}
- 任务定时和停止任务
package main
import (
"fmt"
"time"
)
var done = make(chan bool, 0) // 创建一个来判断是否关闭定时任务的chan
var i int // 只是该文件,到了一个条件来关闭chan
func main() {
i = 0
ticker(int(time.Second * 2), send)
}
// 定义一个定时任务的方法
func ticker(t int, f func()) {
ticker := time.Tick(time.Duration(t))
for {
select {
case <-done: // 如果done发生变化就执行这个,当close(done)也会执行这个
return;
case <-ticker: // 正常的定时任务方法
f()
}
}
}
func send() {
if i > 10 {
close(done) // 根据条件去关闭done
}
i++
fmt.Println("定时执行send")
}
- 并发控制
package main
import (
"fmt"
"time"
)
var limit = make(chan int, 2)
func main() {
for i := 0; i < 20; i++ {
go func(i int) {
limit <- 1 // 如果通道满了 , 这是会阻塞
fmt.Println(i)
time.Sleep(time.Millisecond * 1000)
<- limit
}(i)
}
time.Sleep(10e9)
}
- 生产者与消费者--(扩展任务队列)
package main
import (
"fmt"
"sync"
"time"
)
var wg sync.WaitGroup
func main() {
task := make(chan int, 10)
// 创建一个工作池 ==》 消费者
workerPool(5, task) // 创建的G要与任务的数的增加而增加
// 生产者 =》 任务的投递
for i := 0; i < 10; i++ {
wg.Add(1)
task <- i
}
//如果某一个任务超时了不好解决, 判断程序是否超时,超时的G,提示
if !waitTimeout(1e9) {
fmt.Println("waring, process job timeout")
}
fmt.Println("结束")
}
func waitTimeout(timeout time.Duration) bool {
// 定义一个新的通道,作用就是判断程序是否超时
done := make(chan struct{})
// 不会阻塞运行
go func() {
wg.Wait()
close(done)
}()
// 必须要有
// select 会阻塞程序的运行-》它的特点就是执行一个准备好的通道
select {
case <- done: // 正常结束
return true
case <- time.After(timeout): // 超时了
return false
}
}
func workerPool(workNum int, task chan int) {
// 通过 workNum 来创建协程的数量
for i := 0; i < workNum; i++ {
go func(id int) {
// 执行工作者
worker(id, task)
}(i)
}
}
// 工作者
func worker(id int, task chan int) {
// 遍历 chan
for job := range task {
// 真正的执行逻辑
Process(id, job)
}
}
func Process(id int, job int) {
defer wg.Done()
if job%3 == 0 { // 模拟特殊的任务可能会出现阻塞的问题
time.Sleep(3e9)
}
fmt.Println("worker - id ===>> ", id, " job ===>> ", job)
}
理解channel实现
- channel的数据结构
// Channel的底层数据结构源码在runtime/chan.go:hchan
type hchan struct {
qcount uint // 当前队列中剩余元素个数
dataqsiz uint // 环形队列长度 = 可存放的元素个数(缓存区大小)
buf unsafe.Pointer // 环形队列指针 = 缓存区指针
elemsize uint16 // 元素大小
closed uint32 // 标识关闭状态
elemtype *_type // 元素类型
sendx uint // 发送队列下标,元素存放的下标位置
recvx uint // 读取队列下标,元素读取的下标位置
recvq waitq // 等待读取消息的协程队列
sendq waitq // 等待写入消息的协程队列
lock mutex // 互斥锁,chan不允许并发读写
}
- channel数据结构—缓存区
// chan内部实现了一个环形队列作为其缓冲区,队列的长度是创建chan时指定的。
ch := make(chan int, 5)
ch <- 1
ch <- 1
// qcount = 2
// dataqsiz = 5
// buf 指向队列的内存
// elemsize int所占的大小,因为创建的是 int类型的chan
// sendx = 2 因为现在添加了两个元素,下一个要添加的位置是第三位,下标从0开始所以是2
// recvx = 0 因为当前还没有被读取,第一位就有元素
// recvq 和 sendq 在下面解释
- channel数据结构-等待队列
// 在chan中存在recvq与sendq队列,在一般情况下至少是有一个为空,唯一特例情况,同一个协程使用select语句向channel一边写一边读数据
// 重点:一般情况下一定会有一个是空的 *****
// 接下来是重点:(注意:channel的缓冲区是make创建时候定义的值)
// 1.当在channel中读取数据时,如果channel的缓冲区为0或者不存在时,当前的G会阻塞,会唤醒sendq(写入队列)中的G,并向recvq (读取队列)加入数据G,
// 2.当向channel中写入数据时,如果channel的缓冲区满了或者不存在时,当前的G会阻塞,会唤醒recvq(读取队列)中的G,并向sendq(写入的队列)加入G
// 被阻塞的goroutine将会挂在channel的等待队列中
- channel数据结构-类型信息/锁
// 对于channel来说只能传递一种类型的值,类型信息存在hchan数据结构中
// elemtype代表类型,用于数据传递过程中的赋值
// elemsize代表类型大小,用于在buf中定位元素位置
// 在channel中还利用了锁机制,一个channel同时仅允许一个goroutine读写
- channel写入时,具体流程
// 1.当recvq不为空时,证明:缓冲区没有数据或者没有缓冲区,所以从recvq中取出G,将数据写入G中,等待读取操作的channel来唤醒
// 2.当recvq为空时.证明:缓冲区里面有数据,当前判断缓存区的数据是否已经满了,如果满了,则阻塞当前G,将数据加入sendq中,等待channel读取发生阻塞时,唤醒当前sendq,将数据读出
// 3.在2的基础上,如果说缓存区没有满,则将写入的数据放入缓冲区的尾部
- 读取channel时,具体流程
// 1.先判断sendq是否存在数据,如果为空,则证明:缓冲区没有满,存在数据(如果满了或者不存在会向sendq写入数据),然后判断缓冲区是否有数据,没有则将数据写入到recvq中,等待被唤醒;缓冲区有数据,则从缓冲区头部取出数据
// 2.sendq不为空时,判断是否有缓冲区,如果没有,则在阻塞,并在sendq中取出数据
// 3.在2的基础上,如果缓冲区存在数据,则在缓冲区的头部取出一个数据,然后将sendq中取出一个数据放入缓冲区的尾部
select
- 在go中不存在select的结构体,只有select中的case的结构体;在runtime/select.go:scase中定义表示case语句的数据结构:
type scase struct {
c *hchan // 表示为当前case语句所操作的channel指针
elem unsafe.Pointer // data element
}
- 编译器对select的改写主要是发生在cmd/compile/internal/gc/select.go.walkselectcases函数中
- 在编译期间go会对select进行优化,根据下面四种情况对select语句进行重写优化;
- 第1种情况只有一个select也就是不含任何的case
// 经过cmd/compile/internal/gc/select.go:walkselectcases的调整,会直接将select{}转换为runtime/select.go:block函数
func walkselectcases(cases *Nodes) []*Node {
ncas := cases.Len()
if ncas == 0 {
return []*Node{mkcall("block", nil, nil)}
}
}
// 在runtime/select.go:block函数中比较简单,利用的就是runtime/proc.go:gopark函数让出当前G对处理器使用权并传递等待的原因waitReasonSelectNoCases
func block() {
gopark(nil, nil, waitReasonSelectNoCases, traceEvGoStop, 1)
}
- 第2种情况只有一个case, 在cmd/compile/internal/gc/select.go:walkselectcases中会判断ncase== 1,并对这种情况进行改写
func walkselectcases(cases *Nodes) []*Node {
ncas := cases.Len()
if ncas == 1 {
。。。
}
}
// 改写的方式比较简单,就是将select改写成if语句,并且在处理单操作select语句时候,会根据channel的接收情况生成不同语句;如果channel为空则直接休眠
- 第3种情况;存在case对参数接收,并且还设有default;编译器对它的改写方式都是差不多的,和情况2差不多
- 第4种情况;可以理解为是默认的情况,编译器会根据下面的流程处理select,存在多个case但是没有default
// 1. 将所有的case转换成包含channel以及类型等信息的runtime/select.go:scase
// 2. 调用runtime/select.go:selectgo函数从多个准备好的channel中获取一个一个runtime/select.go:scase
// 3. 利用for循环生成一组if,在语句中判断自己是否被选中的case
Mutex(互斥锁)实现分析
- Mutex结构
// 互斥锁是并发程序对共享资源进行访问控制的主要手段,对此都提供的非常简洁的易用的Mutex,Mutex本质是一个结构体,对外提供两个方法Lock和Unlock,加锁和解锁
type Mutex struct {
state int32 // 表示互斥锁的状态,是否被锁定
sema uint32 // 表示信号量,协程阻塞等待该信号量,解锁的协程通过信号量从而唤醒等待信息号量的协程
}
- Mutex内存布局
// Mutex.state 的结构:
// 1.Locked: 表示是否被锁定,0=为加锁 1=已加锁
// 2.Woken: 表示是否有协程已经被唤醒,0=没有协程被唤醒 1=已有协程被唤醒,正在加锁的过程
// 3.Starving: 表示是否是饥饿状态 0=非饥饿状态, 1=饥饿状态,饥饿状态说明协程阻塞超过了一定时间,比如1ms
// 4.Waiter: 表示阻塞等待锁的协程个数,协程解锁时通过此值来判断是否需要释放信号量
- Mutex加锁解锁
// 加锁过程:
// 1.加锁时,判断locked是否等于1,如果等于0,则证明可以加锁,将locked变成1,其他不变
// 2.如果加锁时,locked已经等于1(当前其他的协程在加锁),会阻塞当前协程,将Waiter+1,等待locked变成0,变成0后会发出信号量,然后被阻塞的协程去加锁,将locked再次变成1,Waiter-1
- 下图是正常加锁,没有阻塞的过程
- 下图是加锁时,有阻塞的情况
// 解锁过程
// 分为两种情况,无阻塞和有阻塞(就是判断waiter是否>0)
// 1.无阻塞时,直接将locked变成0即可(waiter = 0)
// 2.有阻塞时,将locked变成0,再释放一个信号量,唤醒一个被阻塞的协程,将locked变成1,然后waiter-1(waiter >0)
- 自旋过程
// 什么是自旋过程?
// 当go对协程加锁时,如果当前locked=1,证明该锁正在由其他协程使用,而这个时候协程并不会立即进入阻塞状态,而是持续的判断locked是否等于0一段时间,这个过程就是自旋过程
// 自旋的时间很短,但是如果在自旋的过程中,发现锁已经被释放,则立刻获取锁,此时即便有协程被唤醒也无法获取锁,只能再次阻塞
// 自旋的好处?
// 自旋的好处是,当加锁失败的时候就不会立即进入阻塞状态,有一定机会获取到锁,这样可以避免协程的切换
- 自旋过程的条件
// 加锁程序会先判断是否可以自旋,因为无限制的自旋会给CPU带来比较大的压力
// 自旋必须满足的条件:
// 1.自旋次数足够小,一般为4次;
// 2.CPU核数大于1
// 3.协程调度器中的Process数量要大于 1
// 4.至少存在一个正在运行的处理器并且处理的运行队列为空
- Mutex模式
sync.Mutex 有两种模式 — 正常模式和饥饿模式:
// 1.在正常模式下,锁的等待会按照先进先出的顺序获取锁,但是刚刚被唤醒的G与新创建的Goroutine竞争时,大概率会获取不到锁,为了减少这种情况的出现,一旦Goroutine超过 1ms 没有获取到锁,它就会将当前互斥锁切换饥饿模式,防止部分 Goroutine 被『饿死』
// 2.在饥饿模式中,互斥锁会直接交给等待队列最前面的 Goroutine。新的 Goroutine 在该状态下不能获取锁、也不会进入自旋状态,它们只会在队列的末尾等待。如果一个 Goroutine 获得了互斥锁并且它在队列的末尾或者它等待的时间少于 1ms,那么当前的互斥锁就会切换回正常模式
// 与饥饿模式相比,正常模式下的互斥锁能够提供更好地性能,饥饿模式的能避免 Goroutine 由于陷入等待无法获取锁而造成的高尾延时。
RWMutex(读写锁)实现分析
-
读写锁rwmutex是互斥锁mutex的升级版本、在底层中也运用到了mutex进行实现、主要用于读大于写的
场景 -
而读写锁需要注意如下几个问题:冲突情况
-
Rwmutex的底层源码在sync/rwmutex.go:RWMutex 的结构体中进行定义
type RWMutex struct {
w Mutex // 运用互斥锁的能力实现写锁
writerSem uint32 // 写阻塞等待的信号量
readerSem uint32 // 读阻塞等待的信号量
readerCount int32 // 记录读的个数,有读锁 +1 ,解读锁 -1
readerWait int32 // 表示当写锁操作被阻塞时等待的读操作个数
}
func (rw *RWMutex) RLock() // 堵锁
func (rw *RWMutex) RUnlock() // 解读锁
func (rw *RWMutex) Lock() // 写锁
func (rw *RWMutex) Unlock() // 解写锁
- RWMutex 读写锁 – 简单流程
// RWMute.Lock() 写锁;主要操作
// 1. 获取互斥锁,
// 2. 判断是否存在读锁,有则需要等待读的结束
// RWMute.UnLock() 写解锁;主要操作
// 1. 唤醒因读锁定而被阻塞的协程
// 2. 解锁互斥锁
// RWMute.RLock() 读锁;主要操作
// 1. 增加读操作计数,readerCount++
// 2. 阻塞等待写操作结束(如果有的话)
// RWMute.RUnLock() 读解锁;主要操作
// 1. 减少读操作计数,readerCount--
// 2. 唤醒等待写操作的协程(如果有的话)
-
RWMutex 读写锁 – 了解情况
- 写操作对写操作的阻塞
// 读写锁中运用互斥锁mutex、写锁必须先获取互斥锁,如果其他协程获取到锁则会被阻塞
// 写锁的实现主要是依赖互斥锁来实现的
2.写操作对读操作的阻塞
// 关于RWMutex.readerCount是整数值int32,表示读锁数量,在不考虑写锁的情况,每次读锁都会readerCount++,解锁会readerCount—及范围就是2^23而go用sync/rwmutex.go:rwmutexMaxReaders表示
// 当写锁定进行时,先将readerCount减去rwmutexMaxReaders,从而readerCount为负值,读写进来后对readerCount++ ,判断为负说明有写锁在执行,会阻塞等待。而真正的读操作个数后续只需要再对readerCount + rwmutexMaxReaders 即可,因此 写操作利用readerCount为负值阻塞读操作
3.读操作对写操作的阻塞
// 读锁会讲RWMutex.readerCount ++,此时写操作会判断该值不为0,因此会阻塞等待读操作的结束读锁通过readerCount来组织写操作
4.为什么写操作与读操作不会被“饿死”
// 因为写操作必须等待所有的读操作结束才可以获取锁,写锁等待期间会存在新的读锁,因不断新增的读锁从而会导致写锁可能饿死
// RWMutex中运用readerWait解决这个问题,在有写锁的时候会在当前的时间点复制readerCount的值,标记在写之前的读锁数量,而写前面的读锁结束后会对 readerWait, readerCount 进行 -- ;当readerCount为0则唤醒写操作
代码中容易出现的坑
- 协程超过上限 / 内存申请超标,协程的数量和后期内存并发的情况考虑下
- 协程用协程池(channel的应用里面有)
- 连接数 mysql和redis的链接数 为它们建立连接池
- 1<< 3 这个代表 2的3次方 2<<3 = 2 * 2 ^3