前言
hello,我是成都的小孟兄。
本文尽量避免枯燥的说教,力图通过前端日常工作 + js写的代码,引出学习操作系统的知识点。最后会总结操作系统常见相关的面试问题,最终希望大家都可以在自己的简历上加上一条技能 --- "熟悉并理解操作系统的基本概念,并能用于理解一些常见的前端操作"
为什么要写这篇文章,主要是因为我明年的大目标除了完善自己的react组件库,还有一个目标就是在k8s和devops上有大的突破,其中需要对操作系统知识需要有一些更深入的学习。
实际上,你如果能像本文一样,对操作系统讲的一些基础知识能串在一起,有自己的理解,而不是死记硬背,最终,形成一个基本的知识体系。
这不仅仅对写前端代码运行有帮助,更对你后续学习nodejs(例如服务端对内存管理很敏感,你不了解内存基本分配和回收机制是不太合适的,这又恰恰是前端很缺少的知识,而且市面上很少有结合操作系统的运行机制来解释的文章)。
当然这还对学习devops知识大有裨益,例如我们现在都是容器化部署,大多数都是用docker作为容器运行时,早年docker使用的文件系统叫overlay,会产生inode用尽的问题(简单来说就是文件系统格式化后,inode占用的磁盘空间是确定的,一旦占用完了,就用尽了,也就无法打开别的文件了),这里就涉及到可能对于很多前端同学,尤其是非科班的,inode表这个概念会不太了解,也很难知道这个问题是什么。
废话不多说。开始发车!
从开机开始
首先,我们开机的时候,启动的是操作系统。可是你有没有想过,操作系统也是软件,运行软件都是要把程序装载到内存,才能被cpu调度的。
所以我们要启动操作系统,是需要把启动程序从硬盘读取到内存才可以被cpu执行的。
这里就有问题了:
为什么要把程序代码读到内存才能执行?直接读取硬盘数据执行不行吗?
原因是首先因为磁盘读取速度太慢了,cpu执行速度太快,所以应用程序需要加载到读取速度更快的物理介质中。
然后,你可能会问为什么不把所有程序都装在内存里呢?就不用从磁盘把程序读到内存了啊。这里又涉及到一个知识点,就是数据存储器分为RAM和ROM。 RAM 代表随机存取内存,特点是掉电数据就没了。ROM 代表只读内存,掉电还能保存数据,比如硬盘。
最终,简而言之,通过一系列操作读取到启动操作系统的程序,然后cpu执行这由这个程序帮助我们最终完成开机操作。
额外小知识:更深入的开机流程,有兴趣的同学可以自行搜索,例如什么是MBR启动分区,什么是BIOS等等,这里顺便提一个很有趣的知识点,我们在用电脑的时候,尤其是台式电脑,一般启动的时候会短促有 “嘀”的一声,这是BIOS硬件自检通过的标志(简单理解硬件自检就是,启动电脑,硬盘、内存、cpu等这些硬件是否能正常工作,比如你硬盘都没了,你咋加载操作系统软件呢,更别谈开机了)。
开机后,我们找到自己的前端项目,准备开始今天的任务,一般都是打开代码编辑器,比如vscode,此时vscode将我们的存放代码加载进来,此时又有问题了
我们的代码,也就是文件是如何从硬盘加载到内存?
我要加载文件,按道理来说,我直接去操作系统的磁盘里找到这个文件就行了,可现实却不是这样的。
无论windows还是mac,都是可以多人用自己的账号登录,那么有可能我的文件我并不想让别的用户访问,所以打开文件我们不能直接打开,起码要看看你有没有打开的权限。
所以当我们打开文件的时候首先是去某个目录下寻找该文件,所以我们的目录就应该存储在硬盘里,并且因为目录访问是非常频繁的,一般开机的时候,目录表就已经加载到内存了。
问题又来了?
目录中保存了哪些信息帮助我们去找文件呢?
回答这个问题之前,我们需要先介绍一下保存文件的物理存储设备,磁盘(我们逻辑上的数据块映射到的物理设备就是磁盘,或者更精确的说是磁盘上的扇区)。
首先磁盘是块设备,什么意思呢?就是读取信息的时候并不是一个字节一个字节读取,而是一整块一整块的读取,我们假设每个块大小是512kb。
这里有要引申出一个问题,数据块我们一般都是逻辑上的叫法,真实映射到物理磁盘上,我们称之为扇区,什么是扇区呢?
这里我们不得不来看看磁盘的物理结构了
上面的是黑色部分,分别是磁头和磁臂,然后磁头读取盘面上的信息。然后磁盘上会有扇区划分,如下图:
上面可以看到,一个盘面被划分为多个相等的扇区。然后我们可以给扇区编上号,这样当我们要查找某个文件的时候,操作系统会指名这个文件在几号数据块上,数据块又映射到物理上的扇区上,讲真正的数据提取到内存中供CPU调度。
所以回到上面的问题:目录中保存了哪些信息帮助我们去找文件呢?肯定要包含数据块的信息,比如文件存放到1号数据块里,这样操作系统内部在映射到磁盘扇区上。
我们可以再想想,目录中保存的文件信息,起码要有文件名对吧,我们就是靠文件名去目录表里搜索到底是哪个文件的,当然还有我们之前提到的权限,就是权限信息,例如你有可读,可写,还是可以执行的文件的权限。文件大小,文件路径是不是按道理也需要。
我们在命令行可以输入,ls -l命令,就可以看到目录的详细信息。如下图
简而言之,每条文件信息在目录里,我们称之为FCB,文件控制块(file control block)。在linux中,又被称之为inode表,inode指的是index node,也就是索引节点的意思。
这就回答了上面的问题,就是数据如何从磁盘读取到内存,其实因为磁盘读取速度比较慢,一般磁盘的数据会存储到一个缓存缓冲区里,然后缓冲区再存到内存里,缓冲区对于我们理解node.js中流的使用至关重要,在操作系统中,缓冲区(Buffer)是一种用于临时存储数据的区域,通常用于在两个不同的设备或进程之间进行数据传输。
文件加载进去了,此时,我们前端需要新建一个组件,我们就打开代码编辑器,在里面通过GUI(也就是可视化界面)来创建一个新文件。
此时,又有几个问题来了,首先,你创建新文件其实是需要将新文件创建的基本信息存放到目录表里,还有实际上在磁盘里给这个文件划分使用的扇区,这样就有了物理空间存储数据。但是你怎么知道扇区里那些地方有空闲的磁盘块呢?
如何知道哪里有空闲的磁盘块呢?
其实操作系统会在你格式化的文件系统里,有一部分存储空间专门存储空闲磁盘块有哪些的信息。
例如空间磁盘块的管理方法有一种叫bit map(位图)。
也就是说假如有我们实际上只有4个数据块可以使用,bit map就存储了 0000,这4个比特位,4对应了4个数据块,0代表着目前4个数据块都是没有使用的,1代表使用了。
这样的管理方式有什么缺点呢,就是我们要扫瞄一遍才能知道有哪个空闲块,优点是标识数据块已经有数据和没有数据非常方便,只要改下0或者1就行了。
所以还有一些其他管理方式,比如链表法,把每个空闲块用链表的方式存储起来,这样查找空闲块就很方便,从链表头部就知道哪些是空闲的。这些方法各有优缺点,不同的文件系统选择不同的管理方式。
好了,创建好文件之后,你有没有想过,我们存放的代码文件在内存里是如何存放的呢?
有人会说了,这还不简单,连续存放的呗,比如我有一个js文件,可能比较大,占据数据块1号和2号,存放的时候,直接1号和2号存到物理扇区里。
但是你有没有想过,假如一个文件A占据10个数据块,另一个文件B也占10个数据块,它们是挨着的,如下:
A文件占据的数据块 | B文件占据的数据块 | C文件占据的数据块
那么问题来了,A文件变大了,那么为了给A文件提供更多连续的存储空间,不得不把b,c文件数据块往后移,这就可怕了,数据文件多了,这种文件管理方式的弊端就非常明显了。
但是如果你的文件都是只读的,不能写,其实这种方式也可以。
也就是说,实际上落到磁盘的数据,如果物理上都要求连续存储,其实使用场景是非常有限的,所以我们需要一种非连续分配的方式,我这里直接说一下现代操作系统常用的一种方式吧,就是索引分配
这里可以看到,目录表,一般存放的是这个文件存储的物理块号在哪里,但索引分配是存储了索引表,在索引表上存储里对应物理块号的哪一个。(还有多级索引表,这里不引申了)
它的优点是什么,我们可以进行随机访问,因为存储索引的数据结构在linux的ext4系统里是b树,b树支持随机查找。B树示意图如下(这个不深究了,属于数据结构和算法的内容)。
其实这一部分主要想介绍的就是文件系统,接下来我们用一个完整案例,来把上面的知识串到一起。
首先,磁盘最开始什么都没有,卖磁盘的厂家会先对磁盘进行低级格式化,也就是划分扇区:
然后装操作系统的过程,会对磁盘进行高级格式化,高级格式化主要目的是在磁盘上装载文件系统,同时会安装引导操作系统启动的引导程序。
可以看到,在第一个扇区,存放着主引导记录,主要包含了磁盘引导程序和分区表。分区表不用说了,这个大家能理解,尤其是windows,我们划分为c盘,d盘什么的。磁盘引导程序一个主要作用就是扫描分区,然后执行主分区里引导程序,在windows一下一般都是c盘,linux则是情况而定。如下:
然后执行主分区里的系统初始化程序,完成“开机”等一系列动作。
上图可以看到,c盘里的第二个灰色块是超级块,简单来说就是存放硬盘已用空间、数据块可用空间信息等等,说白了就是描述整个文件系统基本信息的。
c盘里的第三个灰色块,就是空闲块管理信息,比如bit map存储。
c盘里的第五个灰色块,对应下面的目录表,和第四个灰色块对应下方的索引表
然后打开文件 -> 去目录表(索引表或者叫inode表)查这个文件 -> 目录表上有索引信息,然后拿着索引信息去索引表找到真实文件的逻辑地址 ->
去磁盘上找到对应的文件信息 -> 读取到内存等待cpu读取
最后,再提一个关键知识点,在linux系统中,其实所有打开的文件都会有记录在系统的打开文件表里,如下图
每一个进程都维护了一个自己打开哪些文件的表,例如,在node.js中,调用fs模块的open方法,会返回一个文件描述符,如上图最左侧,文件描述是操作文件很关键的一个凭证,其实它在知识进程打开表的一个索引而已。
好了,至此,我们简单了解了文件系统和磁盘。不知道有多少人坚持到这里了,送你一个赞
接着来,我们在写前端页面,也就是输入代码的时候,此时其实是键盘这个I/O设备在一个字符一个字符在输入,这期间
计算机是如何让键盘上输入的字符显示到显示器上的呢?
整个操作系统的目的就是管理硬件资源的, 这里的键盘和显示器都是常见的I/O设备(I/O设备就是能往计算里输入或者输出数据的设备),它们是如何被计算机控制的呢?
这里有个隐藏的小知识点,也就是上面我们谈到磁盘也是I/O设备,而且是块设备,也就读取数据是按数据块来的,而键盘是另一种I/O设备,叫字符设备,它输入数据是按字符来的。cpu其实并不是直接操作I/O设备的,而是通过I/O控制器来控制, 所以这里就有问题了
为什么CPU控制I/O设备要通过I/O控制器呢?
这就需要提到人类第一台计算机埃尼阿克,它也要接入I/O设备来输入数据和输出结果,但它连接的线非常多。
因为每一种I/O设备控制的方法不一样,不得不给每一个I/O设备专门写控制它们的程序。
现在有了I/O控制器,那么
CPU怎么控制I/O设备
最简单的思路就是,轮询,cpu每次过1秒去问I/O设备,有没有数据啊,有的话我就拿。但这有什么问题吗?可以想象,现代的操作系统同时要处理很多任务的,键盘输入频繁,那么是不是CPU要花大量的时间跟键盘耗在一起了,这就没办法处理别的任务了。
所以后面产生另一种CPU控制I/O设备的方式,叫中断。
中断是一个非常非常非常重要的知识点,所以我们需要消息描述一下
为什么需要中断?
这就要谈到一些必须具备的基础概念了。所以先学习下面的概念后,我们回答这个问题
两种指令、两种处理器状态、两种程序
假如说一个用户可以随意把服务器上的所有文件删光,这是很危险的。所以有些指令普通用户是不能使用的,只能是权限较高
的用户能使用。此时指令就分为了两种,如下图:
这就引出一个问题:CPU如何判断
当前是否可以执行特权指令
? 如下图:
CPU通常有两种工作模式即:内核态
和用户态
,而在PSW(这个不用管,就知道有一个寄存器的标志位0表示用户态,1表示核心态)中有一个二进制位控制这两种模式。
对于应用程序而言,有的程序能执行特权指令,有的程序只能执行非特权指令。所以操作系统里的程序又分为两种:
所以说,我们需要一种机制,让用户态的程序能进入内核态执行一些特权指令,这个就是中断其中的一个作用。中断也是用户态到内核态唯一的办法。
回到我们开始讨论的问题,CPU怎么控制I/O设备,我们说轮询的方式很低效,所以产生了中断。
键盘输入字符的时候,会引发外中断,外中断是指外部I/O设备引起的中断。
此时中断发生,cpu就马上回去处理这个中断。虽然比轮询更强,但是频繁中断也是很低效的,假如我们此时正在打游戏,游戏里的队友很坑,你就不停的打字喷他。
此时就会产生大量中断,也就是cpu要控制把你喷的字从i/o设备里放到内存中,然后从内存显示到显示器上。
你发现没,有时候打字到时候游戏就会卡,这是因为中断会陷入内核态,陷入之前需要把当前运行的程序信息保存起来,等回到用户态,再把之前运行程序的程序信息恢复
上面的方式主要适用于字节设备,比如键盘,是按字节为单位进行数据传输的。
对于块设备的数据传输,有一种方式叫DMA,如下图
DMA控制器是如何跟CPU交互的呢,它允许外部设备直接跟内存进行数据传输,而无需CPU的干预。
cpu指明此次要进行的操作,如读操作,并说明要读入多少数据,数据要存放在内存什么位置等等信息。
DMA控制器会根据cpu 提出的要求完成数据的读写工作,整块数据传输完成后,才向CPU发出中断信号。
我们用键盘输入的字符主要是跟后端联调的fetch请求的代码。如下
fetch(url).then( response=>console.log(response))
其实这也牵扯到常见的面试题,就是tcp的三次握手,这次我们从更底层的角度去看,如何通过网卡这个i/o设备去建立tcp连接。
上图左边是主机1,也就是我们前端的电脑,右边是主机2,也就是后端的电脑
我们发起请求的时候,先是我们调用fecth请求,浏览器会调用sokcet系统调用,创建一个套接字,网络套接字你可以理解为申请一片内存空间,用来接收和发送数据
socket系统调用会给用户返回一个fd,也就是文件描述符(指向套接字的一个引用),然后浏览器调用bind函数(操作系统提供的系统调用),用来绑定本机的一个端口,不同的端口意味着不同的应用在提供服务,需要通过端口号来区分不同的应用。
最后浏览器会调用connect函数(也是操作系统提供的系统调用),把fd传进去,例如connect(fd,ip地址等等参数),最后会跟服务器建立连接。
最后浏览器通过write调用(把我们fetch函数里传的数据传输出去)数据存通过write调用传输到网卡上,网卡再传输到网络中去。
我们总结一下用户层软件调用I/O设备的过程
算是把上面关于io设备的知识做一个串联。
如上图,我们简单描述一下浏览器调用io设备(如上图,用户层软件我们假设位是浏览器)的流程,浏览器肯定可以调用网卡这个设备的,因为我们需要http请求跟后端交互。
那么最终浏览器调用的是操作系统提供的write函数,这个write函数就是上图的设备独立性软件提供的,也是操作系统提供的。同时它还负责调用相应的驱动程序。
为什么需要驱动程序呢(如上图第三层),因为网卡是有不同的产品的,比如用A厂家的网卡和B厂家的网卡,可能在一些实现细节上不一样,比如发送数据A厂家调用自己的writeA函数,B厂家叫writeB,所以最终还需要驱动程序来实现真正最后调用硬件的细节代码。
所以为了屏蔽这些差异,外部接入设备一般都要写自己的驱动程序。
这就是一个用户从软件层面再到真实调用物理io设备的流程,当然,我们最终让操作系统完成写网卡这个操作是需要发出中断请求的,从而从用户态进入内核态,让操作系统去完成。相同,当io设备返回数据的时候,比如后端的数据又经过我们的网卡返回的时候,也是需要中断告诉操作系统,此时有数据来了,cpu需要马上安排一下。
好了,到此为止上面简单的介绍了i/o设备的管理,看到这里再给你点个赞
上面一直说,无论是i/o设备还是从磁盘读取的数据,最终都要先到内存,才能被cpu调度。我们先来看一个很直接的案例,到底我们写的前端代码是如何存储在内存的。
例如代码如下:
const global = 100;
function f(x, y) {
const p = {};
return;
}
function g(a){
f(a, a+1);
return;
}
function main(){
const i = 100;
g(100);
return;
}
main();
在内存里,我们代码存放主要分为
- text:代码段
- data:全局和静态变量数据
- stack:栈用于存放局部变量,函数返回地址
- heap:堆用于程序运行时动态分配内存
如下图:
之前我们写的前端代码要开始运行了,我们的前端代码是存储在text区域的,对于一些静态语言来说,text是2进制的binary code,而对于javascript这种解释性语言,存储的就是我们写的前端js代码,只有在执行的时候才会去解释和编译为2进制的机器码,再执行。
我们从上到下执行之前写的前端代码,首先
const global = 100;
function f(x, y) {
const p = {};
return;
}
function g(a){
f(a, a+1);
return;
}
function main(){
const i = 100;
g(100);
return;
}
这是全局变量,存放到data区域,f、g和main也是全局函数,所以也会存放在data区域。
然后,开始执行main函数
main();
main函数执行,里面代码就开始执行了,只要执行函数,就会把它的返回地址写入到stack中,这样方便我们执行完毕后,再返回到这个函数向下执行。所以此时stack区域是
然后执行main函数里的内容
function main(){
const i = 100;
g(100);
return;
}
i变量是局部变量,所以存放在stack区域,然后继续执行g(100),此时stack区域是这样的
然后接着g函数执行,g函数执行后,f函数又开始执行,f函数中声明了一个对象,对象在js语言里是被存放到堆,也就是heap区域,所以此时heap区域就有了数据。此时stack如下图:
每次函数调用的时候,都会把返回地址压入栈(stack)中,那么函数执行完毕,则根据返回地址弹出stack。
可是这里有一个很重要的点,就是局部变量x、y和i瞬间就销毁了,但是在heap区域里的数据不是瞬间销毁的,是需要靠垃圾回收机制销毁。
这就涉及到内存的回收,我们的操作系统如何回收heap里的数据呢?
这里我们延伸一下,看看node.js中的v8引擎是用了什么回收算法(下面主要介绍了分带回收和标记清除算法)。
如何查看V8的内存使用情况
使用process.memoryUsage(),返回如下
{ rss: 4935680, heapTotal: 1826816, heapUsed: 650472, external: 49879}
heapTotal 和 heapUsed 代表V8的内存使用情况。也就是之前我们提到的heap区域的使用情况。
external代表V8管理的,绑定到Javascript的C++对象的内存使用情况。
rss, 其实就是占用的所有物理内存的大小,是给这个进程分配了多少物理内存,也就是我们上面 提到的,这些物理内存中包含堆,栈,和代码段等等。
V8的内存分代和回收算法请简单讲一讲
在V8中,主要将内存分为新生代和老生代两代。新生代中的对象存活时间较短的对象,老生代中的对象存活时间较长,或常驻内存的对象。
新生代
新生代中的对象主要通过Scavenge算法进行垃圾回收。这是一种采用复制的方式实现的垃圾回收算法。它将堆内存一分为二,每一部分空间成为semispace。在这两个semispace空间中,只有一个处于使用中,另一个处于闲置状态。处于使用状态的semispace空间称为From空间,处于闲置状态的空间称为To空间。
- 当开始垃圾回收的时候,会检查From空间中的存活对象,这些存活对象将被复制到To空间中,而非存活对象占用的空间将会被释放。完成复制后,From空间和To空间发生角色对换。
- 因为新生代中对象的生命周期比较短,就比较适合这个算法。
- 当一个对象经过多次复制依然存活,它将会被认为是生命周期较长的对象。这种新生代中生命周期较长的对象随后会被移到老生代中。
老生代
老生代主要采取的是标记清除的垃圾回收算法。与Scavenge复制活着的对象不同,标记清除算法在标记阶段遍历堆中的所有对象,并标记活着的对象,只清理死亡对象。活对象在新生代中只占叫小部分,死对象在老生代中只占较小部分,这是为什么采用标记清除算法的原因。
标记清楚算法的问题
主要问题是每一次进行标记清除回收后,内存空间会出现不连续的状态
-
这种内存碎片会对后续内存分配造成问题,很可能出现需要分配一个大对象的情况,这时所有的碎片空间都无法完成此次分配,就会提前触发垃圾回收,而这次回收是不必要的。
-
为了解决碎片问题,标记整理被提出来。就是在对象被标记死亡后,在整理的过程中,将活着的对象往一端移动,移动完成后,直接清理掉边界外的内存。
到这里,我们了解对前端代码在内存的简单分配和回收有了大致的了解,那么我们现在就要更深入的了解一些关于内存管理的其他知识了。
首先
内存是如何存储数据的呢?
最后,我们要来讲讲进程和线程了,还是拿我们熟知的前端代码,我们上面已经知道前端代码运行时在内存的表现形式,那么cpu内部是如何一条一条取指令来运行的,这里首先就会有一个问题了,就是指令是什么?
指令是什么?
它是指计算机执行某种操作的命令,是计算机运行的最小功能单位。一台计算机的所有指令的集合构成该机的指令系统,也称为指令集。比如著名的x86架构(intel的pc)和ARM架构(手机)的指令集是不同的。
一条指令就是机器语言的一个语句,它是一组有意义的二进制代码。一条指令通常包括操作码(OP) + 地址码(A)
-
操作码简单来说就是我要进行什么操作,比如我要实现1+1,加法操作,停机操作等等
-
地址码就是比如实现加法操作的数据地址在哪,通常是内存地址。
cpu是如何去内存取指令,然后一步一步执行的呢?
首先,是取指令的过程如下
-
我们简单描述,就是CPU要知道下一条指令是什么,就必须去存储器(内存)去拿,
PC
去了存储器的MAR
拿要执行的指令地址,MAR
(存储器里专门存指令地址的地方) -
第二步和第三步,
MAR
去存储体内拿到指令之后,将指令地址放入MDR
(存储器里专门存数据的地方) -
然后MDR里的数据会返回给CPU
-
比如这条指令是计算1+1等于几,CPU会把这个任务交给内部的运算器,计算完毕,指令会告诉你把计算结果放到内存的哪个位置,上面我们知道内存逻辑上是按块存储的,比如告诉我们放到20号内存块,cpu就放过去,这样一条指令执行完毕,cpu就会接着执行程序的下一条指令。
我们接着说内存是如何存储数据的
首先,我们要知道内存也是分块的,我们都知道酒店会有很多房间,房间会有很多编号,其实内存也类似,如下图
然后就是具体存放数据了,一般情况下,大家都会想,当然连续存放呗,例如a程序占据内存块1号,b程序占据内存块2号,依次类推。
这其实是连续存放的思想,连续存放我们在讲磁盘存数据的时候也讲过,连续存放会导致很多严重的问题,例如,a,b,c程序在内存的排放方式如下:
A文件占据的内存块 | B文件占据的内存块 | C文件占据的内存块
此时,如果A程序产生了很多局部变量,也就是要存放到A程序占有内存的stack区域,因为ABC文件占据内存是连续存放的,我们不得不把B和C往后移动。
所以往往会采取分连续分配内存块的算法。分连续分配有分段和分页算法。这里我们简单介绍一下分页算法,分段算法就不多说了,思想是一样的,在linux操作系统中,其实采取的是类似分页的算法,叫buddy算法,buddy的意思是伙伴,好朋友的意思,所以也叫伙伴算法。
分页算法是把内存空间分为一个个大小相等的分区,一般每个分区是4k,我们称之为页框。
同时讲进程的逻辑地址空间也分为与内存页框相等的一个个部分,我们把每个部分称为“页”,如下图
所以我们把进程逻辑上的数据分为一个个4kb的块,在内存上任意地方存放,从而实现数据在内存的非连续存放。
有的同学肯定想,分散后,我咋知道内存上哪些部分是存放进程A数据的呢?所以我们还需要一个表,记录进程上的逻辑块号,跟内存上的块号的映射关系。
其实真实的分页处理要比这个复杂很多,例如linux的伙伴算法,可以有效的减少外部碎片,为了减少内部碎片,还采用了slab分配机制。(更详细内容建议搜索谷歌,不过对我们前端来说不了没啥影响)
这里再简单介绍一个知识点,例如我们的计算机内存是8GB,但是一个大型游戏可能有10GB大,显然,内存是不够大,加上我们的电脑同时还运行着很多别的程序,所以我们的计算机到底是怎么做到呢?
这也是非常非常重要的一个概念,叫虚拟内存
什么是虚拟内存?为什么需要它?
虚拟内存 使得应用程序认为它拥有连续的可用的内存(一个连续完整的地址空间),而实际上,它通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换。
举个例子,我们玩的游戏假如有10G,但是目前内存只有4G,实际上我们的计算机只会加载部分游戏数据到内存中,因为全部的游戏数据,在我们当前的画面不会全部用到,我们只把用到的加载进来,这叫做局部性原理,它是指处理器在访问某些数据时短时间内存在重复访问,某些数据或者位置访问的概率极大,大多数时间只访问局部的数据。
如果要加载新的页面,例如你玩游戏进入了新的场景,需要加载新的数据,同时也需要把已经在内存的老数据置换出去,就会触发缺页中断,告诉操作系统要置换数据了。
此时涉及到置换算法,这里就有一个很常见的置换算法叫LRU页面置换算法,也算是前端面试很常见的leetcode面试题了。
最后,我们来讲一讲cpu和进程,首先
为什么需要进程
因为我们的计算机是多道程序并发执行(多核cpu可以并行),假如我们现在只有一个cpu,那么为什么我们能同时打开qq聊天,还可以听着网易云音乐呢,这是两个不同的app,我们用起来好像它们就是并行的。
可实际上,微观层面,cpu是并发执行,也就是cpu会有一个时间片,先给qq程序一小段时间使用,然后马上切换到网易云音乐程序,只是快到我们使用者层面觉得是两个程序同时在运行。
这里面就涉及到两个问题?一是cpu时间片如何设计调度算法,比如有很多紧急的任务,我们一般都要先执行紧急的任务,但是如果紧急任务特别多,其他一些不紧急的任务就得不到时间片,就无法执行,这是问题。二是,时间片运行的程序实际上在操作系统层面是以进程为单位调度的,这些进程是如何表示的。
我们先来看第一个问题
CPU调度进程的算法
其实有很多算法,比如先来先服务,短作业优先等等,这些我们都不讲,对于我们前端来说,其实大概知道,cpu执行程序的指令,是以时间片的形式,时间到了,会有外中断(操作系统提供的时钟管理强制打断目前的执行的程序)然后切换为另一个程序执行。
对于我们前端而言,了解进程和线程本身的运行机制,尤其对于后续做nodejs开发尤为重要,到底进程包含了哪些东西。
之前我们知道,一个程序运行的时候在内存实际上包含
- text:代码段
- data:全局和静态变量数据
- stack:栈用于存放局部变量,函数返回地址
- heap:堆用于程序运行时动态分配内存
其实还有一个更重要的东西,叫进程控制块(PCB),为什么需要这么一个东西呢,一个程序运行的标志是有进程正在被调度,当一个程序时间片到了切换到另一个时间片到时候,我们是不是保存上一个程序的状态,比如代码在内存哪些地方,引用的文件有哪些(当前进程打开的文件,回顾文件系统篇的内容),进程状态,pid(进程id)等等信息。
并且实际进程切换的时候,就是PCB的切换,因为有了PCB,进程的所有信息都可以找到。
到此为止,我们知道操作系统调度程序的基本单位是进程。这有什么问题,进程增加的操作系统的并发能力,但是并没有增加单个程序的并发能力,什么意思呢?比如我用qq的时候,是既可以聊天,又可以视频,这是单个程序的并发能力,如何实现呢?
这时,我们的线程就登场了,一个进程有多个线程,这样单个程序也具备并发能力了。如下图:如果只有一个线程就入下图的左侧部分,多线程的话就是下图右侧部分。
我们可以看到,线程共享了代码段,数据,files(当前打开的哪些文件),pcb等信息。
而每个线程单独拥有registers(寄存器空间),stack(栈,放局部变量)
最后,我再简单介绍进程间通信,这里涉及到node.js进程间通信的问题,也是操作系统一个重要知识点,nodejs在 Windows 下用命名管道实现,*nix 系统则采用 Unix Domain Socket实现。
管道
在学习 Linux 命令时,我们经常看到「|
」这个竖线。
$ ps aux | grep node
上面命令行里的「|
」竖线就是一个管道,它的功能是ps aux得到的结果传给grep node,需要注意的是管道传输数据是单向的,如果想相互通信,我们需要创建两个管道才行。
上面的管道没有名字,所以「|
」表示的管道称为匿名管道,用完了就销毁。
还有一种管道叫命名管道,也被叫做 FIFO
,因为数据是先进先出的传输方式。
匿名管道只能在父子关系的进程中使用,命名管道可以在不相关的进程间使用。我们使用的shell中的管道是匿名管道,使用匿名管道的命令实际上会被shell生成两个子进程。
domain sokcet
跟我们上面介绍的网络socket基本一致。只是本地 socket 在 bind 的时候,不像 TCP 和 UDP 要绑定 IP 地址和端口,而是绑定一个本地文件
好了,全文就说到这里,顺便推广一下我的react组件库教程,如果对你有帮助,感谢star,以下是常见的面试题,有兴趣的同学可以看看。
以下是常见面试题,先列出来,后续更新答案(部分知识点文章没有介绍)
进程、线程和协程的区别是什么
进程间通信是什么
中断和系统调用的区别
进程有哪几种状态,状态之间是如何转换的
进程的调度策略和流程
什么是虚拟内存?什么是共享内存?
有什么常见的页面置换算法
文件系统中文件是如何组织的
磁盘调度算法以及磁盘空间存储管理
内存分配有哪些机制
注:
本文绝大多数资料来源于以下的视频资料
- Linux 操作系统(UP主 Y4NPY)
- 操作系统_清华大学(陈渝)
- 2019 王道考研 操作系统