GIL/进程线程协程切换

GIL

Global interperter Lock全局解释器锁,并不是Python特性,CPython引入的概念,python完全可以不依赖GIL

为什么使用GIL?

每个线程在执行过程中都要先获取GIl,保证同一时刻只有一个线程运行,目的是解决多线程之间的数据完整性和状态同步,

并且因为使用引用技术管理内存,所以某个对象的引用计数不能被两个线程同时增加和减少,不然造成内存泄露,GIL对线程间共享的所有数据结构加锁可以保证引用计数变量的安全性

导致python的多线程在多核CPU上,只对IO密集型产生正面效果,对应CPU密集型,多线程效率会因为GIL而大幅下降

GIL锁的释放

>1.协同式多任务处理(IO密集型任务)
>在较长的或者不确定的时间(IO阻塞,python标准库中所有阻塞性I/O和time.sleep()都会释放),没有运行Python代码的需要,线程便会让出GIL

>2.抢占式多任务(CPU密集型任务)
>解释器运行一段时间就主动释放GIL,这种机制叫间隔式检查(check_interval),每隔一段时间Python解释器就会强制当前线程释放GIL而不需要正在执行代码线程调度允许(python3中,这个时间间隔是15毫秒)

GIL缺陷

>1.抢占式多任务处理(CPU密集型):(每个线程在多个cpu交替执行:cpu调度线程唤醒->去拿GIL->没拿到->在等待:1.线程上下文切换,2.争抢不到GIL会让cpu等待,都浪费cpu时间)
>2.Python的每个版本中也在逐渐改进GIL和线程调度之间的互动关系。例如先尝试持有GIL在做线程上下文切换,在IO等待时释放GIL等尝试。
>3.但是无法改变的是GIL的存在使得操作系统线程调度的这个本来就昂贵的操作变得更奢侈了

CPU上下文

CPU 寄存器和程序计数器就是 CPU 上下文,因为它们都是 CPU 在运行任何任务前,必须的依赖环境

  1. CPU寄存器是CPU内置的小容量高速的内存(用来存放指令和数据等内容的一块内存),速度很快,可以存储一些计算过程中的信息
x64架构下一共有16个通用寄存器,以'r'开头
列举几个重要的寄存器的使用:
rax: 用于存放函数返回值和中间件计算结果
rsprbp: 栈顶,栈低寄存器,用于存放当前函数栈的栈顶,栈低地址
rdirsirdxrcxr8r9: 调用函数时依次存放第1个到第6个参数,多余6个参数则会被压入栈
rip: 用于存放下一条指令地址,CPU会取此寄存器地址去找到下一条指令并执行

函数调用过程

函数是在一块栈空间运行的,这个栈顶和栈低地址存放在rsp,rbp里面,且函数的局部变量也会存在栈中某一快内存中
函数调用就是从一个函数栈跳转到相领另一个函数栈罢了,调用返回后还需要恢复原函数栈的状态,必须在调用时通过寄存器和栈空间的配合来存储一些数据,方便调用完成后恢复
重点:具有调用关系的两个函数栈空间一定是相邻的,也就是说主调方的函数栈空间与被调方的函数栈空间一点是相邻的
  1. 程序计数器用来存储CPU正在执行的指令位置,或者将执行的下一条指令位置

CPU上下文切换

1.把前一个CPU上下文(CPU寄存器和程序计数器)保存起来

2.加载新任务的上下文到CPU寄存器和程序计数器

3.调到程序计数器所指位置,运行新任务

CPU上下文切换类型

进程上下文线程上下文中断上下文

内核空间和用户空间

内核空间(Ring 0): 具有最高权限,直接访问所有资源

用户控件(Ring 3): 只能访问受限资源,不能直接访问内存等硬件设备,必须通过系统调用到内核空间才能访问这些特权资源

进程既可以在用户空间运行,又可以在内核空间中运行。进程在用户空间运行时,被称为进程的用户态,而陷入内核空间的时候,被称为进程的内核态

用户态内陷内核态三种方式

系统调用,异常,中断

系统调用

比如查看文件内容就需要系统调用:open()打开文件,read()读取文件内容,write()将文件内容写到标准输出

过程:

>1.保存CPU寄存器原来用户态的指令位
>2.为了执行内核态代码,CPU 寄存器需要更新为内核态指令的新位置
>3.跳转到内核态运行内核任务
>4.系统调用结束后,CPU 寄存器需要恢复原来保存的用户态,然后再切换到用户空间,继续运行进程
>一次系统调用的过程,其实是发生了两次 CPU 上下文切换。(用户态-内核态-用户态)
>要注意的是:系统调用过程中,并不会涉及到虚拟内存等进程用户态的资源,也不会切换进程。这跟我们通常所说的进程上下文切换是不一样的:进程上下文切换,是指从一个进程切换到另一个进程运行;而系统调用过程中一直是同一个进程在运行
>所以,系统调用过程通常称为特权模式切换,而不是上下文切换。系统调用属于同进程内的 CPU 上下文切换。

进程上下文切换

虚拟内存:当内存耗尽时,自动调用硬盘充当内存
Linux 通过 TLB(Translation Lookaside Buffer)来管理虚拟内存到物理内存的映射关系

首先:进程是由内核来管理和调度的,进程的切换只能发生在内核态

进程的上下文:不仅包括了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的状态

因此:进程的上下文切换就比系统调用时多了一步

在保存内核态资源(当前进程的内核状态和 CPU 寄存器)之前,需要先把该进程的用户态资源(虚拟内存、栈等)保存下来;而加载了下一进程的内核态后,还需要刷新进程的虚拟内存和用户栈

导致进程上下文切换的情况

>1.CPU时间被耗尽
>2.系统资源不足,需要等到资源满足才可以运行
>3.进程通过sleep这样的方法主动挂起
>4.有更高的优先级进程
>5.硬件中断

进程状态:
1.运行running
2.就绪ready
3.等待wait

线程上下文切换

线程本身是共享进程的虚拟内存和全局变量等资源,这些资源在上下文切换时不需要修改,不涉及虚拟地址的变化,这就是为什么进程切换比线程切换开销大的原因

所以线程的切换就只包括线程上下文的切换,就是替换线程放在处理器寄存器中的相关私有数据,但是线程的调度也需要到内核空间完成,同样需要从用户态转向内核态

线程状态
1.创建new(分配资源,初始化)
2.就绪ready
3.执行running(获取CPU时间,执行代码)
4.阻塞blocked(放弃CPU,暂停运行,常见阻是原因:等待I/O操,等待锁,等待其他线程通知)
5.终止terminated()

协程切换

协程:是一种比线程更加轻量级的微线程,一个线程也可以拥有多个协程
协程与函数调用栈是密切相关的,协程拥有自己的上下文(函数栈状态/寄存器值)

协程上下文

协程是一段子程序(其实就是函数), 只要保存当前函数栈状态和寄存器指,就可以描述这个协程的全部状态
- 函数栈状态
- 寄存器值
struct coctx_t
{
	void *regs[ 14 ];       // 一个数组,保存了14个寄存器的值 
	size_t ss_size;         // 协程栈大小
	char *ss_sp;            // 协程栈指针
};

1.保存当前协程的CPU寄存器的值保存到协程上下文中的regs数组中

2.将新协程上下文的regs数组中值取出来赋值给对应的寄存器

切换在用户空间不涉及内核空

进程线程区别

1根本区别:资源分配的基本单位,cpu调度执行的基本单位
2地址空间:空间资源独立,共享本进程的空间和资源
3键壮性:崩溃不影响其他进程,一个线程崩溃整个进程崩掉
4执行过程:进程有(执行入口/顺序执行/执行开销大),线程不能独立运行(依附于进程,执行开销小)
5切换:进程切换资源消耗大,线程切换消耗小
(进程切换需要切换页表,页表切换后,TLB失效,地址转化时需要重新查找页表。线程切换不需要切换页表)

切换对比

程序=指令序列+上下文 # 信息=位+上下文

**指令:**就是寄存器地址指向的值,也就是CPU要执行的命令

上下文:

>CPU上下文:
操作数寄存器,栈寄存器,状态寄存器等各类寄存器
>进程上下文:
寄存器,信号,分配的内存空间,文件描述符等各类由CPU抽象出来的资源
>线程上下文: 
寄存器,线程堆栈
>函数上下文:
当前的命名空间
>协程上下文:
	寄存器

进程切换:需要切换系统资源和指令,操作最重

线程切换:只切换线程堆栈,不需要切换系统资源

协程切换: 都在用户空间进行,不需要进行系统调用


GIL/进程线程协程切换
https://centyuan.github.io/2023/11/22/Python从入门到放弃/GIL(进程线程协程)/
作者
hlyuan
发布于
2023年11月22日
许可协议