本文主要介绍操作系统相关内容, 进程和线程, 并发和并行。
目录
文章目录
前言
二、进程和线程
1.进程
2.线程
3.进程的管理
3.1 PCB中的一些属性
3.2 并发和并行
3.3 进程的调度
3.4 内存管理
3.5 进程间的通信
3.6 并发编程
4. 进程与线程的区别
本文主要介绍操作系统相关内容, 进程和线程, 并发和并行
一、操作系统是什么?
操作系统本质上是一个软件, 发挥的是管理作用, 可以管理软件和硬件, 让其有条不紊的运行和使用.
对于操作系统下层, 操作系统需要管理好硬件; 对于操作系统上层要给软件提供稳定的运行环境, 所以操作系统是软件、硬件、用户之间交互的媒介.
硬件设备: 实体设备, 比如电脑后盖打开看到的都是硬件设备.
驱动程序: 硬件厂商在开发硬件的同时会提供驱动程序, 电脑安装了对应的驱动程序, 才能让系统正确的识别硬件设备.
操作系统内核: 是操作系统的核心功能, 承上启下.
系统调用: 操作系统给应用程序提供的一些API接口, 比如有个程序想操作一下硬件设备, 就需要先通过系统调用, 把操作命令告诉给系统内核, 内核调用驱动程序, 进一步的操作硬件设备.
应用程序:QQ , 所写的Java代码等都是应用程序.
常见的操作系统:
Windows:windows98, 2000, xp, vista,win7, win10, win11(最熟悉的操作系统).
Linux: 特别适合进行开发和部署(程序员必须掌握的系统).
Mac: 苹果电脑用的系统, 和Linux有一些相似之处.
还有一些手机上运行系统;Android ,IOS等.
简单来说进程就是正在运行的程序, 通过双击一个可执行文件(.exe), 操作系统就会把这个可执行文件加载到内存上, 并在CPU上执行可执行文件中的指令, 此时这个exe就跑起来了, 就成为一个正在运行的程序, 也称作一个进程(process), 也叫做一个任务.
现在的很多电脑都是多进程模式的, 宏观上, 可以有很多个进程同时执行.
上面一部分进程是我们自己去执行应用的可执行文件, 而另一部分是操作系统自动启动的进程.
线程是进程内部的一部分, 将进程类比作一个工厂, 那么线程就是工厂中的生产线, 所以一个进程可以包括很多个线程, 一个进程至少拥有一个线程.
我们日常所写的java代码, 其实最终都是通过java进程(jvm)跑起来的.
进程是一个重要的"软件资源", 是由操作系统内核负责管理的, 进程中含有一个或者多个线程, 在进程中开启第一个线程时, 需要进行资源的分配, 之后进程中开启的其他线程复用的都是同一份资源.
线程是通过结构体(C语言)来描述的(操作系统基本上都是通过C/C++来实现的), 这个结构体也被称为进程控制块(PCB), 这若干个结构体就描述了进程, 对进程进行描述后, 然后就是组织线程, 线程的本质上也是数据, 那么对数据的组织需要通过数据结构来进行组织, 在Linux系统中是通过双链表来组织线程的, 也就是通过一个双向链表将多个PCB给串到一起.
创建一个线程, 本质上就是创建一个PCB这样的结构体对象, 把它插入到链表中.
销毁一个线程, 本质上就是把链表上的 PCB节点删除掉.
任务管理器查看到进程列表, 本质上就是遍历这个PCB链表.
所以对线程的增删查改本质上就是对双链表进行增删查改, 当然这个双链表是复杂的双链表, 但是对线程的操作的基本思路和操作双链表是一样的.
PCB里包含的属性是非常多的, 下面介绍的是一些核心属性.
进程id(pid): 进程的一个身份标识符, 是一个唯一的数字.
内存指针: 指明了自己分配到的内存.
文件描述符表: 硬盘上的文件等其他资源.
一个进程一运行, 操作系统就会自动打开至少3个文件: 标准输入(System.in), 标准输出(System.out), 标准错误(System.err).
想要一个进程正常工作就需要为这个进程分配资源, 包括但不限于内存, 硬盘, CPU.
分配资源时, 对于其他资源都比较好分配, 但CPU资源是不太好分配的: 电脑中进程可能有几十上百个, 电脑的CPU(现在电脑大多是多核CPU)资源是很有限的, 远不够给这些进程同时分配.
但我们又希望进程都是同时运行的, 上面说过, 一个进程中可以有多个线程, 我们可以让多个线程去并发执行(分时复用), 那么什么是并发呢?
并行性和并发性是既相似又有区别的两个概念, 并行性是指两个或多个事件在同一时刻发生, 而并发性是指两个或多个事件在同一时间间隔内发生.
从宏观上来讲, 并行与并发没有区别, 或者说是我们肉眼感知不到.
从微观上来讲, 并行表示两个CPU核心同时运行两个任务的代码, 并发表示一个CPU核心先运行任务A的代码,再运行任务B的代码, 也就是多个任务能够在CPU核心上快速的切换运行; 只要运行得足够快, 就可以认为多个任务在同时运行.
线程是系统调度执行的最基本单位, 为了方便描述进程的调度, 这里假定此处的进程只有一个线程, 这样就可以将线程的调度视为进程的调度, 这里我们谈到的进程调度是基于多任务操作系统, 就是从宏观上看, 同一时间能够同时运行多个进程.
进程的状态:
优先级:
多个进程, 操作系统进行调度的时候, 并不是完全平等, 而是有一定的优先级的.
上下文:
表示上一次进程被调出CPU的时候, 程序的运行状态(将进程的 “中间状态” 保存下来), 这样下一次这个进程调度到CPU时就能接着上次的状态继续运行.
进程的上下文, 就是CPU中的各个寄存器的值, 所以进程调出CPU之前, 需要先把CPU上寄存器上的数据保存到内存中,相当于存档; 下次该进程再被调进CPU时, 就可以根据上次储存在内存中的数据恢复到寄存器中, 相当于读档.
记账信息:
操作系统统计每个进程在CPU上的占用的时间以及指令数目, 根据这个来决定下一阶段如何调度.
举一个形象的场景来进行理解:
比如有三个进程A, B, C, 不妨将操作系统比作一个妹子, 将线程A, B, C比作三位小哥哥, A特别帅, B特别有钱, C特别会舔.
这位妹子同时与A, B, C 一起谈恋爱, 那么这里妹子如何同时谈三个男朋友呢, 其实也很简单, 妹子只需要做好对ABC的时间管理即可, 让ABC互相都不知道对方的存在.
所以妹子需要妥善地安排与三位小哥哥谈恋爱的时间, 确保时间不能重合, 比如:
因为妹子喜欢帅的, 其次是有钱的, 最后是会舔的, 所以妹子对A, B, C的安排有了优先级, 给A安排的时间多一点, B次之, C最后。
此时妹子做了一周时间的规划表, 属实是时间管理大师了.
此时妹子这个时间规划, 就是可以理解为进程的调度, 谈恋爱期间, 如果B需要出差一个月, 那么在妹子眼中, B这种状态就称为阻塞或睡眠状态, 如果正常,C可以随叫随到, 则在妹子眼中就称这种状态为就绪状态, 妹子正在与A谈恋爱的状态称为运行状态.
妹子与三位小哥哥同时谈恋爱, 总会有时候"串戏", 比如A需要带妹子回家见家长, 让妹子准备一份礼物, 而B想要带妹子去三亚旅游, 要妹子做好准备,但是有一天A问妹子准备好没?妹子说我买了两件泳衣, 这样就 “串戏” 了, 妹子最后总算将A忽悠过去了, 吸取了教训, 之后每次都会将上次和某人进行到啥程度了, 是否有啥未完成的任务之类的记录下来, 下次约会的时候, 能够恢复出之前的状态, 这样妹子在之后就不会串戏了, 这个场景就可以看作是操作系统中的上下文了.
妹子和ABC都已经相处了一段时间了, 就需要去想一想, 也就是每隔一段时间,做个总结, 统计一下在每个人身上大概花费了多少精力, 然后根据这个总结做出下一个阶段的时间安排, 要保证和每个人要始终保持好一个合适的尺度, 不能太远也不能太近, 这便可以看作是进程调度中的记账信息了.
我们通过代码程序所获取的内存地址, 其实不是硬件上真真实实的地址, 而是经过一些计算整合出来的虚拟地址.
内存(物理上是一个内存条), 其中可以存很多数据, 内存就可以想象成是一个大走廊, 走廊非常长, 有很多房间, 每个房间大小是1Byte, 每个房间还有个编号, 从0开始依次累加, 这个内存编号就是"地址", 这个地址也就"物理地址".
下面张图就是真实的内存条.
内存有个了不起的特性就是随机访问, 访问内存上的任意地址的数据, 速度都极快, 时间上都差不多, 正是这个特点造就了数组取下标操作的时间复杂度是 O(1).
我们的程序如果直接访问的就是物理地址, 如果程序正常运行还好, 但如果代码出bug了, 就可能出现越界访问的情况, 就可能会对其他进程的数据做出一些操作, 比如进程1的指针变量因为bug变成了0x8000, 也就是说这里明明是进程1的bug却把进程给搞坏了.
为了避免这种问题的出现, 针对进程使用的内存空间进行了"隔离", 引入了虚拟地址空间.
代码里不再直接使用真实的物理地址了, 而是使用虚拟的地址; 由操作系统和专门的硬件设备负责进行虚拟地址到物理地址的转换.
这里引入的虚拟空间地址, 对物理地址进行隐藏和隔离, 避免进程之间的相互影响, MMU硬件设备(很多时候是集成在CPU里的), 可以让虚拟地址和物理地址的转换速度更快.
当我们操作系统内核发现转换后的地址超过了该进程物理地址的访问范围, 就会直接向进程反馈一个错误, 进而引发该进程崩溃, 不会影响到其他进程.
上面引入的虚拟空间地址使进程之间相互隔离, 这就导致每个进程只能访问自己的那一块地址空间的数据, 无法访问其他进程的地址空间的数据, 这样当一个进程崩溃时, 另外的进程不会受到影响.
但是, 在实际开发工作中, 有些时候进程间需要进行数据的交互的, 为了实现进程间的通信, 操作系统提供了一块多个进程都能访问到的 “公共空间” , 进程A可以先把数据存入 “公共空间” , 然后进程B可以到 “公共空间” 取出数据, 这样就实现了进程间的通信.
操作系统提供的 “公共空间/通信方式” 有很多种具体的体现形式, 现在最常见的进程间通信机制有:
CPU进入了多核心的时代, 要想进一步提高程序的执行速度, 就需要充分的利用CPU的多核资源, 这个时候并发编程就应用而生了; 引入进程的概念, 最主要的目的是为了解决"并发编程" 这样的问题, 让代码程序能够把这些CPU核心充分利用.
其实多进程编程已经可以将cpu的多核资源利用起来了, 可以解决并发编程的问题了, 但还是存在一些问题, 进程的创建需要分配资源, 进程的销毁需要回收资源, 而分配和回收资源的效率相对来说是比较低的, 也就是说进程的创建,销毁,调度的开销都是比较大的, 成本是比较高的.
问题解决:
方案1:
使用进程池, 与字符串常量池类似, 将已经创建的进程存入常量池, 后面需要使用时直接加载资源即可, 但是也存在问题, 那就是闲置的进程也会占用资源, 相当于空间换时间, 消耗空间来提升效率.
方案2:
使用多线程来实现并发编程, 因为进程包含线程, 一个进程中包含多个线程,所以线程比进程更轻量,因此也将线程称为 “轻量级进程”, 线程把申请资源/释放资源的操作给省下了, 这就致使线程的创建,销毁,调度的成本很低, 速度更快, 所以使用多线程实现并发编程比多进程更加合适.
类比以下场景进行理解多线程:
我们把进程看做一个工厂, 线程看作工厂中的生产线, 比如有一个任务需要生产一些硬件设备, 想要生产速度更快, 有两个方案:
1. 建造两个工厂进行生产.
2.只建造一个工厂, 多增加一批生产线.
上面两种方案都以相同的效率生产设备, 但是方案1需要多建造一个工厂; 方案2只需要增加一批生产线, 复用同一个工厂的资源即可(原料, 运输资源等), 而方案1较之方案2的付出的代价更大, 类比一下方案1表示就是多进程实现并发编程, 方案2表示的是多线程实现并发编程.