一、什么是进程?
通俗来说, 进程可以理解为 运行着的程序。
我们需要运行一个程序, 总是要有条件的。 常见的编程语言分为 编译型语言 和 解释型语言 。 编译型语言写出来的程序最终得到的程序是一个二进制可执行文件, 里面包含 数据段(定义的各种变量) 和 代码段(非变量部分) 。 代码段里面放的是cpu可以直接执行的机器指令。 解释型语言写出来的程序需要依赖解析器来执行。 解析器一般是一个C/C++写的可执行文件, 解析器可以被cpu直接执行。 我们写的代码再被解析器读取、解析, 最后才被解析器翻译成cpu可执行的机器指令。
程序只有在被运行的时候才是进程。 这个运行的过程涉及到cpu、内存等。
操作系统本身也是一个程序, 他主要用来管理其他的进程的生命周期、为他们分配各种硬件资源。 在没有操作系统干预的情况下, 一个程序的运行过程如下 (操作系统本身可能就是这样运行起来的):
1、cpu申请一块内存, 在内存中划分出 代码段、 数据段、 堆、 栈 四部分(还有一部分空余的供堆栈伸缩) 2、cpu将磁盘上可执行文件中的 机器指令 加载到代码段, 将变量等加载到数据段。 3、cpu开始执行机器指令(这个时候如果遇到数据段中的变量,变量如果是在函数里面定义的, 则在 栈 空间里给该变量分配内存,函数执行完后该内存被释放。 如果是全局变量或者程序员申请内存的变量, 则在堆空间分配内存,只有进程挂了或者程序员主动释放这部分内存才会被释放)
当然程序执行的任务可能会需要其他的硬件资源,更加复杂。
在【linux操作系统中, 进程是资源分配的最小单位】。
Linux 系统在启动一个进程时, 大概是这样的: 1、键入程序名字后,操作系统根据程序名字找到磁盘中的可执行文件。 2、操作系统通过这个可执行文件定义一个 task_struct 。 这个 task_struct 主要包含如下部分 :?
type task_struct struct { ?? ?pid?? ??? ?// 进程id ?? ?... ?? ?*mm?? ??? ?// 进程包含的内存资源(一个内存结构体) ?? ?*fs?? ??? ?// 文件资源(进程的root在哪里,当前路径在哪里等) ?? ?*files?? ?// 进程打开的文件等 ?? ?*signal?? ?// 进程挂起的信号等 ?? ?... } linux 系统给这个要运行的程序分配完这些资源之后, 这个程序才可以运行,才能变成一个进程。 每个进程都有自己独立的资源。 但是既然他是一个结构体, 那么我自然可以创建另外一个结构体, 结构体里面的资源指针指向其他进程的资源。 如果创建的新 task_struct 中所有的资源都指向创建它的进程的资源, 那么说明他共享了创建他的进程的资源,? 我们就可以说新创建的 task_struct 是一个【线程】!!! 这样下来, 一个进程里面的资源可以被多个线程所共享。
二、进程是怎么运行的?
在进程的生命周期里面有6种状态 :? 就绪态 : 进程中的机器指令等待被cpu执行 运行态: 进程中的机器指令正在被cpu执行 暂停态: 进程因为外部原因被暂停(一些控制进程cpu使用率的工具就是用的这个原理) 浅睡眠态:能被资源和信号唤醒进入就绪态 深睡眠态:只能被资源唤醒 僵尸态: 子进程退出后因为要保存自己的退出状态而没有释放资源,但是父进程也还没有回收子进程的资源时 其中睡眠是因为需要等待资源而主动让出cpu后进入的一种状态, 而暂停是被动停止的一种状态。
一般我们使用 fork 创建一个进程之后, 他会将父进程的资源拷贝一份给新创建的子进程(pid是自己的), 子进程会进入就绪态,?然后等待cpu上的核调度到他的时候, 他就变成了运行态。 等指令运行到需要进行io操作的时候(这个时候不需要使用cpu), 进程进入睡眠态。 等io操作完成之后进程重新进入就绪态等待调度。 如果在运行态的时候收到外界的暂停信号, 则进程进入暂停态, 等收到开始信号后重新进入就绪态。 进程中的机器指令全部运行完毕之后, 或者收到什么信号机器指令不再执行之后, 进程进入僵尸态。
三、什么是线程?
如上, 进程是资源分配的最新单位, 但是 linux 在运行的时候, 只认 task_struct 结构体。 如果我创建的 task_struct 结构体没有自己独立的资源, 那他是什么呢? 此时的 task_struct 还是可以被linux系统调度的,但是因为他跟父进程共享所有的资源,?所以我们把他成为 ?【线程】。
其实除了这种完全跟父进程共享资源的线程之外,?我们还可以创建只共享一部分资源的、 介于进程核线程之间的 可以被 linux系统调度的 task_struct 。 比如用 vfork 创建的 task_struct 只共享内存。
四、 线程的调度。
在系统层面,系统的性能主要体现在 吞吐率 和 响应率 这两个点上。 而这两个点又是有所取舍的。 响应率讲究一个优先响应的速度(原来在调度A线程, 现在马上要开始优先调度B线程, 这个上下文切换的过程越快越好) 吞吐率讲究的是让整个系统的时间都尽量花在做有用功上(因为响应的时候进程间的上下文切换需要时间,?主要不是切换本身花时间,而是新进程需要重新加载缓存资源(cache miss), 而切换这个过程中花费的时间是在做无用功, 所以吞吐和响应是矛盾的。) linux操作系统在编译阶段就可以选择内核的调度模式, 有些模式主要考虑吞吐(比如服务器), 有些则主要考虑响应(比如手机系统)。
我们在真正调度的时候, 不关心你 task_struct 里面的资源, 只关心你是不是 task_struct 。 什么是调度呢? 就是每个核从就绪态的一堆 task_struct 中按照特定算法每次挑选出一个进程, 来执行他的机器指令, 等这个 task_struct 进入休眠态或者其他态,再重复以上流程,重新挑选一个进程来跑。 而具体在调度的时候 每个 task_struct 结构体中有一个 优先级, 优先级分成两种 :? 0~99属于 RT 优先级, 对于这类优先级的 task_struct , Linux的调度器根据优先级的高低决定调度的先后顺序,高优先级的 task_struct 会被先调度,但是遇到相同优先级进程时有两种调度算法 :? 1、 先进先出。 2、 轮转。
这里面有一个bug, 就是如果高优先级的 task_struct 里面有个 task_struct 存在bug(进入死循环, 那么这个核永远也不可能被低优先级的 task_struct 使用,也就是说低优先级的进程永远也无法被调度。 后面通过限制每秒RT进程的运行时间不能超过95%解决了这个问题)
?
而对于普通优先级的进程, 优先级的高低不决定他们的调度顺序, 只影响他们分到的cpu时间片的数量, 优先级约高,分到的时间片越多。 早期的调度算法中, 普通进程有个 nice 值可以进行设置。 nice 值越大, 优先级越低。? 而且有一个奖惩机制, 程序运行的越勤快, nice值越高, 优先级越低【【为了让io密集型进程也能被调度】】。 但是后面采用了基于红黑树的完全公平调度算法取代了这个奖惩机制。
五、为什么要使用多进程?
程序在运行的时候,除了运行态会使用cpu,其他状态都不会使用cpu,如果一个核每次只运行一个进程,等这个进程消失了再去运行其他进程,那么就回造成CPU资源的浪费,效率也非常低。 因为线程共享进程的资源,而且可以被操作系统调度,所以使用多线程比使用多进程更节约资源(不需要分配额外的内存七七八八的)。 协程的出现也是为了节约系统资源,提高cpu使用率。进程、线程是操作系统级别的,一个进程占用4G虚拟内存,一个线程占用假设1M内存,而协程更加轻量级,是用户态的(线程调度),可能只需要2KB。进程、线程在上下文切换的时候需要花费的时间比较多。
多线程、多进程操作系统实现多个线程同时运行的核心思想是将cpu的执行时间分层一段一段的时间片。?然后给每个线程分配一定的时间片,等这个线程的时间片用完了之后, 马上进入休眠, 然后开始下一个线程的调度。
这个时候虽然减少了cpu因为阻塞带来的浪费, 但是线程切换的时候还是需要成本的。 比如cpu在跑两个线程的时候,?切换这部分无用功能浪费的时间可能占40%。
六、协程在解决什么问题?
我们怎么把这个线程上下文切换的时间也省掉呢? 将一个线程的时间片再细分成n份, 然后每一份相当于一个 “微线程”。 这样我们系统级别的线程上下文切换就不需要了, 也不需要为每个“微线程”申请独立的内存, 他们共享线程的内存就行了。 这样的 内存 开销非常小的 “微线程” 就是我们的 【协程】。
我们要实现协程, 就要自己实现操作系统里面的那套 调度 逻辑。 在实现协程的时候, 我们将线程空间分成 内核空间 和 用户空间。? 内核空间放线程, 用来最终执行机器指令。 用户空间放协程, 用来代替操作系统中原来的线程来定义各种机器指令。
这样我们就可以抽象的认为 :? 内核空间中的线程 就是 原来操作系统中 “cpu的核” 。 而用户空间中的协程 就是 原来操作系统中被 cpu的核调度的 “task_struct” 。 各种编程语言要实现协程, 主要要实现自己的 “调度器”。
七、协程的调度策略
这种调度线程和协程有三种策略 :? 1:n策略? ---- 内核空间只有一个线程,一个线程调度多个协程, 类似以前的单核cpu, 进程容易被某个协程阻塞。 1:1策略? ---??内核空间只有一个线程,一个线程调度一个协程,类似以前的单线程 cpu。 m : n 策略? ---??内核空间中可以有m个线程(一般大于等于cpu的核数,充分利用cpu的多核优势), 去调度用户空间中的 n 个协程,?类似现在的多核cpu执行多线程。
目前最能充分利用cpu的是 m:n策略。 但是这个策略的效率依赖于调度器的优化和算法。 各大语言中协程的性能好坏基本上都由这个调度器决定。
八、go语言是怎么实现协程的
go语言的调度器使用 GMP 模型的思想。 G : goroutine协程(用户空间里面的) P : processor处理器(保存了每个进程的全部资源,包括堆、栈、数据等, 每个P 上面还有一个本地队列, 用来存放需要管理的G) M : thred线程(内核空间里面的)
一个协程的运行可能是这样的 :? 被某个 M 创建, 然后可能被放在了M当前获取到的 P 的 本地队列中,如果这个P满了, 则可能被放到 全局队列中。 某个 M 获取到了包含新创建的这个G的 P , 然后从P的队列中获取这个G进行执行。
除了各个P的本地队列可以存放自己当前将要执行的G(一般不超过256个), 还有一个全局队列也可以存放G。
需要注意的是 : P 的数量默认等于cpu的核数,可以设置。 M的数量可以大于cpu的核数, 因为我们要考虑线程正在执行的协程被阻塞导致线程进入休眠态。 这个时候就会出现 cpu的浪费, 为了避免这种浪费,此时M会和他绑定的P进行分离, 然后创建一个新的线程来跟这个P进行绑定。? ?被阻塞的这个线程进入休眠态, 新线程去执行这个P里面的G。 等阻塞完成后, M 会去看是否有P可以进行绑定, 如果有, 则进行绑定并将这个G加入到P的本地队列。 如果没有, 则M进入休眠线程队列, G被放到全局队列中。
如果一个M发现自己绑定的P里面没有G了, 那么他就会去其他的P里面偷G过来执行。
go里面的 cpu抢占通过限制每个 G 的cpu时间片来实现, 这个G执行了一段时间后必须让出cpu给其他协程(避免死循环带来的问题)。 然后他自己又重新进入P的本地队列中等待下次执行。
M0 和 G0 go 程序在启动的时候, 会启动一个编号为0的主线程。 这个主线程负责启动第一个G, 第一个G启动之后,这个M0通过创建的G0执行初始化操作(创建多个P和全局队列等),执行完后 M0跟其他的M就没有区别了。
每次启动一个M, 都会为这个M创建一个G0, 这个G0用来调度需要在这个M上执行的其他的G。 相当于M先通过G0从P里面获取一个G(这个阶段是在执行G0), 然后G0进入休眠,M开始执行获取到的G,等这个G的时间片执行完或者什么的, M开始重新执行G0来调度其他的G。
自旋线程 :? 当 G 在被 M 执行的时候, 创建了一个新的 G, 被执行的G会尝试去休眠队列中唤醒一个线程M2,?然后查看是否有空闲的P2可以跟这个被唤醒的M2进行绑定, 如果没有, 被唤醒的M2重新进入休眠队列。 如果有, 则M2跟P2进行绑定,M2开始启动它的G0来调度P2中的G。? 如果P2的本地队列中有 G 可以执行,则开始调度。 如果P2的本地队列这没有 G 可以执行, 则M2进入【自旋】状态, 变成【自旋线程】。 所谓的自旋线程, 就是自己当前没有可以调度的任务,然后不断的去寻找G来进行调度(从全局队列中找,或者从其他的P中偷)的线程。 此时虽然这个线程是在浪费cpu做无用功, 但是相比直接销毁, 浪费的资源还是相对较小。 ?
九、chan与协程的阻塞和唤醒
一个协程被chan阻塞之后,正在执行这个协程的 M(线程)会进入休眠态,然后M跟P解绑,M等待被唤醒。M被唤醒之后(协程需要往chan读取或者写入的数据来了,被往chan读取或者写入的G唤醒),G被放到全局队列或者M新绑定的P的本地队列中,等待被M调度。 chan里面的协程队列只是记录了哪些协程被自己阻塞了,以便找到需要被唤醒的协程。
------------------------------------------
参考 :?
https://www.bilibili.com/video/BV1D5411p7Nx
https://www.bilibili.com/video/BV19r4y1w7Nx
|