现代计算机系统由一个或多个处理器、主存、打印机、键盘、鼠标、显示器、网络接口以及各种输入输出设备构成。然而,程序员不会直接和这些硬件打交道,在硬件的基础之上,计算机安装了一层软件,这层软件能够通过响应用户输入的指令达到控制硬件的效果,从而满足用户需求,这种软件称之为操作系统,它的任务就是为用户程序提供一个更好、更简单、更清晰的计算机模型。(说白了就是能直接与硬件打交道而让用户能方便使用电脑的一种软件 )
我们一般常见的操作系统主要有Windows、Linux、FreeBSD或OsX,这种带有图形界面的操作系统被称为图形用户界面(Graphical User Interface,GUI),而基于文本、命令行的通常称为Shel1。
操作系统简化图如上,最下面的是硬件,在硬件之上是软件。驱动是从操作系统中细化出来的,操作系统通过驱动和硬件进行交互。大部分计算机有两种运行模式:
内核态(也称为管态和核心态)和用户态,操作系统运行在内核态中,具有硬件的访问权,可以执行机器能够运行的任何指令。软件的其余部分运行在用户态下。
Windows和Linux可以说是我们比较常见的两款操作系统的。Windows基本占领了电脑时代的市场,商业上取得了很大成功,但是它并不开源,所以要想接触源码得加入Windows的开发团队中。对于服务器使用的操作系统基本上都是Linux,而且内核源码也是开源的,任何人都可以下载,并增加自己的改动或功能,Linux最大的魅力在于,全世界有非常多的技术大佬为它贡献代码。这两个操作系统各有千秋,不分伯仲。操作系统核心的东西就是内核
计算机是由各种外部硬件设备组成的,比如内存、CPU、硬盘等,如果每个应用都要和这些硬件设备对接通信协议,那这样太累了。所以,这个中间人就由内核来负责,让内核作为应用连接硬件设备的桥梁,应用程序只需关心与内核交互,不用关心硬件的细节。
内核能力:
现代操作系统,内核一般会提供4个基本能力:
●管理进程、线程,决定哪个进程、线程使用CPU,也就是进程调度的能力;
(PS:进程一般指各个打开的应用,而线程是指打开应用里面的各个页面,如Google浏览器运行是一个进程,而Google浏览器里的各个页面则是线程)
●管理内存,决定内存的分配和回收,也就是内存管理的能力;
●管理硬件设备,为进程与硬件设备之间提供通信能力,也就是硬件通信能力;
●提供系统调用,如果应用程序要运行更高权限运行的服务,那么就需要有系统调用,它是用户程序与操作系统之间的接口。
内核具有很高的权限,可以控制CPU、内存、硬盘等硬件,而应用程序具有的权限很小,因此大多数操作系统,把内存分成了两个区域:
●内核空间,这个内存空间只有内核程序可以访问;
●用户空间,这个内存空间专门给应用程序使用;
用户空间的代码只能访问一个局部的内存空间,而内核空间的代码可以访问所有内存空间。
因此,当程序使用用户空间时,我们常说该程序在用户态执行,而当程序使用内核空间时,程序则在内核态执行。
应用程序如果需要进入内核空间,就需要通过「系统调用」,下面来看看系统调用的过程:
内核程序执行在内核态,用户程序执行在用户态。当应用程序使用系统调用时,会产生一个中断。发生中断后,CPU会中断当前在执行的用户程序,转而跳转到中断
处理程序,也就是开始执行内核程序。内核处理完后,主动触发中断,把CPU执行权限交回给用户程序,回到用户态继续工作。
Liux内核由如下几部分组成:内存管理、进程管理、设备驱动程序、文件系统和网络管理等。
当今Windows7、Windows10使用的内核叫Nindows NT,NT全称叫New Technology。
(PS:这里大家需要知道一点就是Liux和window的执行文件不一样,因为他们的内核、机制不一样。)
加油ヾ(◍°∇°◍)ノ゙ 加油ヾ(◍°∇°◍)ノ゙你已经看到一半了!坚持!
主板一般为矩形电路板,上面安装了组成计算机的主要电路系统,一般有BIOS芯片、I/O控制芯片。在主板上,有一个东西叫ROM (Read Only Memory,只读存储器)。这和咱们平常说的内存RAM (Random Access Memory,随机存取存储器)不同。
咱们平时买的内存条是可读可写的,这样才能保存计算结果。而ROM是只读的,上面早就固化了一-些初始化的程序, 也就是BIOS(Basic Input and Output System,基本输入输出系统)。
如果你自己安装过操作系统,刚启动的时候,按某个组合键,显示器会弹出一个蓝色的界面。能够调整启动顺序的系统,就是我说的BIOS,然后我们就可以先执行它。
然后操作系统会询问BIOS获取配置信息。对于每个设备来说,会检查是否有设备驱动程序。如果没有,则会向用户询问是否需要插入CD-ROM 驱动(由设备制造
商提供)或者从Internet上下载。一旦有了设备驱动程序,操作系统会把它们加载到内核中,然后初始化表,创建所需的后台进程,并启动登录程序或GUI。
你会发现,一个项目要想顺畅进行,需要用到公司的各种资源,比如说盖个公章、开个证明、申请个会议室.打印个材料等等。这里有个两难的权衡,一方面,资源毕竟是有限的,甚至是涉及机密的,不能由项目组滥职滥用:另方面,就是效率,咱是一个私营企业,保证项目申请资源的时候只跑一次,这样才能比较高效。
为了平衡这一点, 一方面涉及核心权限的资源,还是应该被公司严格把控,审批了才能用;另外一方面,为了提高效率,最好有个统一的办事大厅, 明文列出提供哪些服务,谁需要可以来申请,然后就会有回应。在操作系统中,也有同样的问题,例如多个进程都要往打印机上打印文件,如果随便乱打印进程,就会出现同样一张纸, 第一行是A进程输出的文字,第二行是B进程输出的文字,乱套了。所以,打印机的直接操作是放在操作系统内核里面的,进程不能随便操作。但是操作系统也提供一个办事大厅, 也就是系统调用(System Cl).系统调用也能列出来提供哪些接口可以调用,进程有需要的时候就可以去调用。这其中,立项是办事大厅提供的关键服务之一。同样,任何一个程序要想运行起来,就需要调用系统调用,创建进程。如果一个进程在用户态下运行用户程序,例如从文件中读取数据。那么如果想要把控制权交给操作系统控制,那么必须执行一个异常指令或者 系统调用指令。操作系统紧接着需要参数检查找出所需要的调用进程。
发源时间1991年10月
内核是操作系统最基本的部分。它是为众多应用程序提供对计算机硬件的安全访问的一部分软件,这种访问是有限的,并且内核决定一个程序在什么时候对某部分硬件操作多长时间。内核的分类可分为单内核和双内核以及微内核。严格地说,内核并不是计算机系统中必要的组成部分。
内核,是一个操作系统的核心。是基于硬件的第一层软件扩充,提供操作系统的最基本的功能,是操作系统工作的基础,它负责管理系统的进程、内存、设备驱动程序、文件和网络系统,决定着系统的性能和稳定性。
现代操作设计中,为减少系统本身的开销,往往将一些与 紧密相关的(如中断处理程序、设备驱动程序等)、基本的、公共的、运行频率较高的模块(如时钟管理、进程调度等)以及关键性数据结构独立开来,使之常驻内存,并对他们进行保护。通常把这一部分称之为操作系统的内核。
内核体系结构如下图:
单内核(Monolithic kernel),是个很大的进程。它的内部又能够被分为若干模块(或是层次或其他)。但是在运行的时候,它是个单独的二进制大映象。(二进制文件是可以直接执行的文件,映像文件是需要仿真的文件。)其模块间的通讯是通过直接调用其他模块中的函数实现的,而不是消息传递。
单内核结构在硬件之上定义了一个高阶的抽象界面,应用一组原语(或者叫系统调用来实现操作系统的功能,例如进程管理,文件系统,和存储管理等等,这些功能由多个运行在核心态的模块来完成。
尽管每一个模块都是单独地服务这些操作,内核代码是高度集成的,而且难以编写正确。因为所有的模块都在同一个内核空间上运行,一个很小的bug都会使整个系统崩溃。然而,如果开发顺利,单内核结构就可以从运行效率上得到好处。
很多现代的单内核结构内核,如Linux和FreeBSD内核,能够在运行时将模块调入执行,这就可以使扩充内核的功能变得更简单,也可以使内核的核心部分变得更简洁。
微内核(Microkernelkernel)结构由一个非常简单的硬件抽象层和一组比较关键的原语或系统调用组成,这些原语仅仅包括了建立一个系统必需的几个部分,如线程管理,地址空间和进程空间通信等。
微核的目标是将系统服务的实现和系统的基本操作规则分离开来。例如,进程的输入/输出锁定服务可以由运行在微核之外的一个服务组件来提供。这些非常模块化的用户态服务器用于完成操作系统中比较高级的操作,这样的设计使内核中最核心的部分的设计更简单。一个服务组件的失效并不会导致整个系统的崩溃,内核需要做的,仅仅是重新启动这个组件,而不必影响其它的部分。
微内核将许多OS服务**(操作系统 Operating System,简称OS)**放入分离的进程,如文件系统,设备驱动程序,而进程通过消息传递调用OS服务。微内核结构必然是多线程的,第一代微内核,在核心提供了较多的服务,因此被称为’胖微内核’,它的典型代表是MACH。它既是GNU HURD也是APPLE SERVER OS的核心,可以说,蒸蒸日上.第二代为微内核只提供最基本的OS服务,典型的OS是QNX(QNX是一个微内核实时操作系统,其核心仅提供4种服务: 进程调度 、 进程间通信 、底层 网络通信 和 中断处理 ,其进程在独立的 地址空间 运行。 所有其它OS服务,都实现为协作的用户进程,因此QNX核心非常小巧,QNX4.x大约为12Kb,而且运行速度极快),QNX在理论界很有名,被认为是一种先进的OS。
微内核只提供了很小一部分的硬件抽象,大部分功能由一种特殊的用户态程序:服务器来完成。微核经常被用于机器人和医疗器械的嵌入式设计中,因为它的系统的关键部分都处在相互分开的,被保护的存储空间中。这对于单核设计来说是不可能的,就算它采用了运行时加载模块的方式。
微内核的例子:AIX,BeOS,L4微内核系列。
混合内核它很像微内核结构,只不过它的的组件更多的在核心态中运行,以获得更快的执行速度。
混合内核实质上是微内核,只不过它让一些微核结构运行在用户空间的代码运行在内核空间,这样让内核的运行效率更高些。这是一种妥协做法,设计者参考了微内核结构的系统运行速度不佳的理论。然而后来的实验证明,纯微内核的系统实际上也可以是高效率的。大多数现代操作系统遵循这种设计范畴,微软公司开发的Windows操作系统就是一个很好的例子。另外还有XNU,运行在苹果Mac OS X上的内核,也是一个混合内核。
混合内核的例子: BeOS 内核 ,DragonFly BSD,ReactOS 内核Windows NT、Windows 2000、Windows XP、Windows Server 2003以及Windows Vista等基于NT技术的操作系统。
外内核系统,也被称为纵向结构操作系统,是一种比较极端的设计方法 。
外内核这种内核不提供任何硬件抽象操作,但是允许为内核增加额外的运行库,通过这些运行库应用程序可以直接地或者接近直接地对硬件进行操作。
它的设计理念是让用户程序的设计者来决定硬件接口的设计。外内核本身非常的小,它通常只负责系统保护和系统资源复用相关的服务。
传统的内核设计(包括单核和微核)都对硬件作了抽象,把硬件资源或设备驱动程序都隐藏在硬件抽象层下。比方说,在这些系统中,如果分配一段物理存储,应用程序并不知道它的实际位置。
而外核的目标就是让应用程序直接请求一块特定的物理空间,一块特定的磁盘块等等。系统本身只保证被请求的资源当前是空闲的,应用程序就允许直接存取它。既然外核系统只提供了比较低级的硬件操作,而没有像其他系统一样提供高级的硬件抽象,那么就需要增加额外的运行库支持。这些运行库运行在外核之上,给用户程序提供了完整的功能。
理论上,这种设计可以让各种操作系统运行在一个外核之上,如Windows和Unix。并且设计人员可以根据运行效率调整系统的各部分功能。
外核设计还停留在研究阶段,没有任何一个商业系统采用了这种设计。几种概念上的操作系统正在被开发,如剑桥大学的Nemesis,格拉斯哥大学的Citrix系统和瑞士计算机科学院的一套系统。麻省理工学院也在进行着这类研究。
(ps:最重要的没有说明,单内核和微内核最重要的区别就是单内核是所有模块高度聚合的,编写难度高,但效率也高,局部错误整体就会错误,而微内核的模块是分散的,编写难道简单一些,效率稍低,局部不影响整天。百度百科给的比较偏历史发展,相关度不高,可略过 ヽ(ー_ー)ノ )
加油加油!已经看了一大半了!知识的力量汹涌澎湃!(✧◡✧)
单内核结构是非常有吸引力的一种设计,由于在同一个地址空间上实现所有复杂的低阶操作系统控制代码的效率会比在不同地址空间上实现更高些。
20世纪90年代初,单内核结构被认为是过时的。把Linux设计成为单内核结构而不是微内核,引起了无数的争议。
单核结构正倾向于设计不容易出错,所以它的发展会比微内核结构更迅速些。两个阵营中都有成功的案例。
尽管Mach是众所周知的多用途的微内核,人们还是开发了除此之外的几个微内核。L3是一个演示性的内核,只是为了证明微内核设计并不总是低运行速度。它的后续版本L4,甚至可以将Linux内核作为它的一个进程,运行在单独的地址空间。
QNX是一个从20世纪80年代,就开始设计的微内核系统。它比Mach更接近微内核的理念。它被用于一些特殊的领域;在这些情况下,由于软件错误,导致系统失效是不允许的。例如航天飞机上的机械手,还有研磨望远镜镜片的机器,一点点失误就会导致上千美元的损失。
很多人相信,由于Mach不能够解决一些提出微内核理论时针对的问题,所以微内核技术毫无用处。Mach的爱好者表明这是非常狭隘的观点,遗憾的是似乎所有人都开始接受这种观点。
内核提供一种硬件抽象的方法来完成对硬件操作,因为这些操作是非常复杂的,硬件抽象隐藏了复杂性,为应用软件和硬件提供了一套简洁,统一的接口,使程序设计更为简单。
历史上,从来没有出现过用于Linux内核的正式的源代码管理或修正控制系统。实际上,很多开发者实现了他们自己的修正控制器,但是并没有官方的LinuxCVS档案库,让LinusTorvalds检查加入代码,并让其他人可以由此获得代码。修正控制器的缺乏,常常会使发行版本之间存在“代沟”,没有人真正知道加入了哪些改变,这些改变是否能很好地融合,或者在即将发行的版本中哪些新内容是值得期待的。通常,如果更多的开发者可以像了解他们自己所做的改变一样了解到那些变化,某些问题就可以得到避免。
非常有必要使用一个实时的、集中的档案库来保存对Linux内核的最新更新。(防止 源代码更新混乱,所以有一个档案库来保存新的代码,跟游戏补丁一样,需要新版本就下载新版本或者其他功能的补丁)每一个被内核接受的改变或者补丁都被作为一个改变集被追踪。终端用户和开发者可以保存他们自己的源文件档案库,并根据需要可以通过一个简单的命令用最新的改变集进行更新。对开发者来说,这意味着可以始终使用最新的代码拷贝。测试人员可以使用这些逻辑的改变集合来确定哪些变化导致了问题的产生,缩短调试所需要的时间。甚至那些希望使用最新内核的用户也可以直接利用实时的、集中的档案库库,因为一旦他们所需要的部件或缺陷修复加入到内核中,他们就可以马上进行更新。当代码融合到内核时,任何用户都可以提供关于这些代码的即时反馈和缺陷报告。
随着Linux内核的成长,变得更加复杂,而且吸引更多开发者将注意力集中到内核的特定方面的专门开发上来,出现了另一个开发Linux方法的有趣改变。在2.3内核版本的开发期间,除了由LinusTorvalds发行的主要的一个内核树之外,还有一些其他的内核树。
在2.5的开发期间,内核树出现了爆炸式的增长。由于使用源代码管理工具可以保持开发的同步并行进行,这样就可能实现开发的部分并行化。为了让其他人在他们所做的改变被接受之前可以进行测试,有一些开发需要并行化。那些保持自己的树的内核维护者致力于特定的组件和目标,比如内存管理、NUMA部件、改进扩展性和用于特定体系结构的代码,还有一些树收集并追踪对许多小缺陷的纠正。
这种并行开发模型的优点是,它使得需要进行重大改变的开发者,或者针对一个特定的目标进行大量类似改变的那些开发者可以自由地在一个受控环境中开发,而并不影响其他人所用内核的稳定性。当开发者完成工作后,他们可以发布针对Linux内核当前版本的补丁,以实现到此为止他们所完成的改变。这样,社区中的测试人员就可以方便地测试这些改变并提供反馈。当每一部分都被证明是稳定的之后,那些部分可以单独地,或者甚至同时全部地,融合到主要Linux内核中。
新工具为内核提供了代码覆盖分析的功能。覆盖分析表明,在一个给定的测试运行时,内核中哪些行代码被执行。更重要的是,覆盖分析提示了内核的哪些部分还根本没有被测试到。这个数据是重要的,因为它指出了需要再编写哪些新测试来测试内核的那些部分,以使内核可以得到更完备的测试。
在为将来的2.6Linux内核进行开的过程中,除了这些自动化的信息管理方法之外,开放源代码社区的不同成员还收集和追踪了数量惊人的信息。
例如,在KernelNewbies站点上创建了一个状态列表,来保持对已经提出的内核新部件的追踪。这个列表包含了以状态排序的条目,如果它们已经完成了,则说明它们已经包含在哪个内核中,如果还没有完成,则指出还需要多长时间。列表上很多条目的链接指向大型项目的Web站点,或者当条目较小时,链接指向一个解释相应部件的电子邮件信息的拷贝。
下面是我用康奈尔笔记法总结的部分截图,康奈尔笔记法在上篇文章有介绍过,真的好用的,大家可以尝试一下!(≖ᴗ≖)✧
先来了解一下单片机,单片机是没有操作系统的,所以每次写完代码,都需要借助工具把程序烧录进去,这样程序才能跑起来。另外,单片机的CPU是直接操作内存的物理地址。在这种情况下,要想在内存中同时运行两个程序是不可能的。因为第一个程序在2000的位置写入一个新的值,将会擦掉第二个程序存放在相同位置上的所有内容。
我们可以把进程所使用的地址隔离开来,即让操作系统为每个进程分配独立的一套虚拟地址,人人都有,大家可以在自己的地址玩,互不干涉。但是有个前提每个进程都不能访问物理地址,虚拟地址由操作系统安排到物理内存里。
操作系统会提供一种机制, 将不同进程的虚拟地址和不同内存的物理地址映射起来。
如果程序要访问虚拟地址的时候,由操作系统转换成不同的物理地址,这样不同的进程运行的时候,写入的是不同的物理地址,这样就不会冲突了。
于是,这里就引出了两种地址的概念:
● 我们程序所使用的内存地址叫做 虚拟内存地址 (Virtual Memory Address)
● 实际存在硬件里面的空间地址叫 **物理内存地址 ** (Physical Memory Address)。
操作系统引入了虚拟内存,进程持有的虚拟地址会通过CPU芯片中的内存管理单元(MMU) 的映射关系,来转换变成物理地址,然后再通过物理地址访问内存,如下图所示:
操作系统管理虚拟内存和物理地址之间的关系主要有两种方式,分别是内存分段和内存分页,分段是比较早提出的,我们先来看看内存分段。
(这是一个比较早的解决方案)
程序是由若干个逻辑分段组成的,如可由代码分段、数据分段、栈段、堆段组成。不同的段是有不同的属性的,所以就用分段(Segmentation) 的形式把这些段分离出来。
分段机制下的虚拟地址由两部分组成,段选择子和段内偏移量。(下图看不懂可跳,知道地址是分成一段一段的就行(ŎдŎ;))
● 段选择子就保存在段寄存器里面。段选择子里面最重要的是段号,用作段表的索引。段表里面保存的是这个段的基地址、段的界限和特权等级等。
● 虚拟地址中的 段内偏移量 应该位于0和段界限之间,如果段内偏移量是合法的,就将段基地址加上段内偏移量得到物理内存地址。
在上面,知道了虚拟地址是通过段表与物理地址进行映射的,分段机制会把程序的虚拟地址分成4个段,每个段在段表中有一个项,在这一项找到段的基地址,再加上偏移量,于是就能找到物理内存中的地址,如下图:
如果要访问段3中偏移量500的虚拟地址,我们可以计算出物理地址为,段3基地址7000 +偏移量500 = 7500。
分段的办法很好,解决了程序本身不需要关心具体的物理内存地址的问题,但它缺点也明显:
● 第一个就是内存碎片的问题。
● 第二个就是内存交换的效率低的问题。
接下来,说说为什么会有这两个问题。
我们来看这样一个例子。一台电脑,有1GB(1024M)的内存。我们先启动一个图形渲染程序,占用了512MB的内存,接着启动一个Chrome浏览器,占用了128MB 内存,再启动一个Python 程序,占用了256MB 内存。这个时候,我们关掉Chrome,于是空闲内存还有1024 - 512 - 256 = 256MB。按理来说,我们有足够的空间再去装载一个200MB的程序。但是,这256MB的内存空间不是连续的,而是被分成了两段128MB的内存。因此,实际情况是,我们的程序没办法加载进来。
如上图所示,两处白色的“空余内存”,看出内存时不连续的,会拒绝一些稍大的内存占用程序的进入(比如图中192M的程序)
当然,这个也有办法解决。解决的办法叫内存交换(Memory Swapping)。
我们可以把Python程序占用的那256MB内存写到硬盘上,然后再从硬盘上读回来到内存里面。不过读回来的时候,我们不再把它加载到原来的位置,而是紧紧跟在那已经被占用了的512MB内存后面。这样,我们就有了连续的256MB内存空间,就可以去加载一个新的200MB的程序。如果你自己安装过Linux操作系统,你应该遇到过分配一个swap硬盘分区的问题。这块分出来的磁盘空间,其实就是专门给Linux操作系统进行内存交换用的。
虚拟内存、分段,再加上内存交换,看起来似乎已经解决了计算机同时装载运行很多个程序的问题。不过,这三者的组合仍然会遇到一个性能瓶颈。硬盘的访问速度要比内存慢很多,而每一次内存交换,我们都需要把一大段连续的内存数据写到硬盘上。所以,如果内存交换的时候,交换的是一个很占内存空间的程序,这样整个机器都会显得卡顿。
于是,为了解决内存分段的内存碎片和内存交换效率低的问题,就出现了内存分页。
分段的好处就是能产生连续的内存空间,但是会出现内存碎片和内存交换的空间太大的问题。
要解决这些问题,那么就要想出能少出现一些内存碎片的办法。另外,当需要进行内存交换的时候,让需要交换写入或者从磁盘装载的数据更少-点这样就可以解决问题了。这个办法,也就是内存分页(Paging) 。
分页是把整个虚拟和物理内存空间切成一段段固定尺寸的大小。这样一个连续并且尺寸固定的内存空间, 我们叫页(Page)。在Linux下,每一页的大小为4KB。
虚拟地址与物理地址之间通过页表来映射,如下图:
页表实际上存储在CPU的内存管理单元(MMU) 中, 于是CPU就可以直接通过MMU,找出要实际要访问的物理内存地址。
而当进程访问的虚拟地址在页表中查不到时,系统会产生一个缺页异常,进入系统内核空间分配物理内存、更新进程页表,最后再返回用户空间,恢复进程的运行。
分页怎么解决分段的内存碎片、内存交换效率低的问题?
由于内存空间都是预先划分好的,也就不会像分段会产生间隙非常小的内存,这正是分段会产生内存碎片的原因。而采用了分页,那么释放的内存都是以页为单位释放的,也就不会产生无法给进程使用的小内存。
如果内存空间不够,操作系统会把其他正在运行的进程中的「最近没被使用」的内存页面给释放掉,也就是暂时写在硬盘上,称为换出(Swop Out)。一旦需要的时候,再加载进来,称为换入(Swapln) 。所以,一次性写入磁盘的也只有少数的一个页或者几个页,不会花太多时间,内存交换的效率就相对比较高。
更进一步地,分页的方式使得我们在加载程序的时候,不再需要一次性都把程序加载到物理内存中。 我们完全可以在进行虚拟内存和物理内存的页之间的映射之后,并不真的把页加载到物理内存里,而是只有在程序运行中,需要用到对应虚拟内存页里面的指令和数据时,再加载到物理内存里面去。
在分页机制下,虚拟地址分为两部分,页号和页内偏移。页号作为页表的索引,页表包含物理页每页所在物理内存的基地址,这个基地址与页内偏移的组合就形成了物理内存地址,见下图。(ps:与内存分段类似的原理)
说白了就是,对于一个内存地址转换,其实就是这样三个步骤:
● 把虚拟内存地址,切分成页号和偏移量;
● 根据页号,从页表里面,查询对应的物理页号;
● 直接拿物理页号,加上前面的偏移量,就得到了物理内存地址。
下面举个例子,虚拟内存中的页通过页表映射为了物理内存中的页,如下图:
(上面图表介绍的就是简单分页)
简单分页有空间上的缺陷。
因为操作系统是可以同时运行非常多的进程的,那这不就意味着页表会非常的庞大。
在32位的环境下,虚拟地址空间共有4GB,假设一个页的大小是 4KB (2^12) ,那么就需要大约100万(2^20) 个页, 每个页表项需要4个字节大小来存储,那么整个4GB空间的映射就需要有4MB的内存来存储页表。
这4MB大小的页表,看起来也不是很大。但是要知道每个进程都是有自己的虚拟地址空间的,也就说都有自己的页表。
那么,100 个进程的话,就需要400MB的内存来存储页表,这是非常大的内存了,更别说64位的环境了。(ps:单单是一张映射表就要占这么多空间,所以肯定要用算法啥的来优化咯,所以出现了下面的多级页表,层层套娃来了,o(╥﹏╥)o)
要解决上面的问题,就需要采用的是一种叫作多级页表(Multi-Level Page Table)的解决方案。
在前面我们知道了,对于单页表的实现方式,在32位和页大小4KB 的环境下,一个进程的页表需要装下100多万个页表项,并且每个页表项是占用4字节大小的,于是相当于每个页表需占用4MB大小的空间。
我们把这个100多万个页表项的单级页表再分页,将页表(一级页表)分为1024个页表(二级页表),每个表(二级页表)中包含1024个页表项,形成二级分页。如下图所示:
你可能会问,分了二级表,映射4GB地址空间就需要4KB (一级页表) + 4MB (二 级页表)的内存,这样占用空间不是更大了吗?
当然如果4GB的虚拟地址全部都映射到了物理内上的,二级分页占用空间确实是更大了,但是,我们往往不会为一个进程分配那么多内存。
其实我们应该换个角度来看问题,例如计算机组成原理里面无处不在的局部性原理。
每个进程都有4GB的虚拟地址空间,而显然对于大多数程序来说,其使用到的空间远未达到4GB,因为会存在部分对应的页表项都是空的,根本没有分配,对于已分配的页表项,如果存在最近一定时间未访问的页表, 在物理内存紧张的情况下,操作系统会将页面换出到硬盘,也就是说不会占用物理内存。
如果使用了二级分页,一级页表就可以覆盖整个4GB虛拟地址空间,但**如果某个一级页表的页表项没有被用到,也就不需要创建这个页表项对应的二级页表了,即可以在需要时才创建二级页表。**做个简单的计算,假设只有20%的一级页表项被用到了,那么页表占用的内存空间就只有4KB (一级页表) + 20% *4MB (二 级页表) = 0.804MB,这对比单级页表的4MB 就是一个巨大的节约。
那么为什么不分级的页表就做不到这样节约内存呢?我们从页表的性质来看,保存在内存中的页表承担的职责是将虚拟地址翻译成物理地址。假如虚拟地址在页表中找不到对应的页表项,计算机系统就不能工作了。所以页表一定要覆盖全部虚拟地址空间,不分级的页表就需要有100多万个页表项来映射,而二级分页则只需要1024个页表项(此时一级页表覆盖到了全部虚拟地址空间,二级页表在需要时创建)。
我们把二级分页再推广到多级页表,就会发现页表占用的内存空间更少了,这一切都要归功于对局部性原理的充分应用。
对于64位的系统,两级分页肯定不够了,就变成了四级目录,分别是:
● 全局页目录项PGD (Page Global Directory) ;
● 上层页目录项PUD (Page Upper Directony) ;
● 中间页目录项PMD (Page Middle Directory) ;
● 页表项PTE (Page Table Entry) ;
TLB(Translation Lookaside Buffer)
多级页表虽然解决了空间上的问题,但是虚拟地址到物理地址的转换就多了几道转换的工序,这显然就降低了这俩地址转换的速度,也就是带来了时间上的开销。
程序是有局部性的,即在一段时间内, 整个程序的执行仅限于程序中的某一部分。 相应地,执行所访问的存储空间也局限于某个内存区域。
我们就可以利用这一特性, 把最常访问的几个页表项存储到访问速度更快的硬件,于是计算机科学家们,就在CPU芯片中,加入了一个专门存放程序最常访问的页表项的Cache,这个Cache就是TLB (Translation Lookaside Buffer) , 通常称为页表缓存、转址旁路缓存、快表等。
在CPU芯片里面,封装了内存管理单元(Memory Management Unit)芯片,它用来完成地址转换和TLB的访问与交互。
有了TLB后,那么CPU在寻址时,会先查TLB,如果没找到,才会继续查常规的页表。
TLB的命中率其实是很高的,因为程序最常访问的页就那么几个。
逻辑地址和线性地址:
●程序所使用的地址,通常是没被段式内存管理映射的地址,称为逻辑地址;
●通过段式内存管理映射的地址,称为线性地址,也叫虚拟地址;
逻辑地址是「段式内存管理J转换前的地址,线性地址则是页式内存管理转换前的地址。
Linux内存主要采用的是页式内存管理,但同时也不可避免地涉及了段机制。
这主要是上面Intel 处理器发展历史导致的,因为Intel X86 CPU 一律对程序中使用的地址先进行段式映射,然后才能进行页式映射。既然CPU的硬件结构是这样,Linux 内核也只好服从Intel的选择。
但是事实上, Linux内核所采取的办法是使段式映射的过程实际上不起什么作用。也就是说,“ 上有政策,下有对策”,若惹不起就躲着走。
Linux系统中的每个段都是从0地址开始的整个4GB虚拟空间(32位环境下),也就是所有的段的起始地址都是一样的。这意味着,Linux 系统中的代码,包括操作系统本身的代码和应用程序代码,所面对的地址空间都是线性地址空间(虚拟地址),这种做法相当于屏蔽了处理器中的逻辑地址概念,段只被用于访问控制和内存保护。
我们再来瞧一瞧, Linux 的虚拟地址空间是如何分布的?
在Linux操作系统中,虚拟地址空间的内部又被分为内核空间和用户空间两部分,不同位数的系统,地址空间的范围也不
同。比如最常见的32位和64位系统,如下所示:
通过这里可以看出:
●32位系统的内核空间占用1G,位于最高处,剩下的3G是用户空间;
●64位系统的内核空间和用户空间都是128T,分别占据整个内存空间的最高和最低处,剩下的中间部分是未定义的。
再来说说,内核空间与用户空间的区别:
●进程在用户态时,只能访问用户空间内存;
●只有进入内核态后,才可以访问内核空间的内存;
虽然每个进程都各自有独立的虚拟内存,但是**每个虚拟内存中的内核地址,其实关联的都是相同的物理内存。**这样,进程切换到内核态后,就可以很方便地访问内核空间内存。
接下来,进一步了解虚拟空间的划分情况,用户空间和内核空间划分的方式是不同的,内核空间的分布情况就不多说了。
我们看看用户空间分布的情况,以32位系统为例,我画了一-张图来表示它们的关系:
通过这张图你可以看到,用户空间内存,从低到高分别是7种不同的内存段:
●程序文件段,包括二进制可执行代码;
●已初始化数据段,包括静态常量;
●未初始化数据段,包括未初始化的静态变量;
●堆段,包括动态分配的内存,从低地址开始向上增长;
●文件映射段,包括动态库、共享内存等,从低地址开始向.上增长(跟硬件和内核版本有关)
●栈段,包括局部变量和函数调用的上下文等。栈的大小是固定的,一般是8 MB.当然系统也提供了参数,以便我们自定义大小;
在这7个内存段中,堆和文件映射段的内存是动态分配的。比如说,使用C标准库的malloc()或者mmap() ,就可以分别在堆和文件映射段动态分配内存。
我们编写的代码只是一个存储在硬盘的静态文件,通过编译后就会生成二进制可执行文件。当我们运行这个可执行文件后,它会被装载到内存中,接着CPU会执行程序中的每一条指令, 那么这个运行中的程序,就被称为进程。
我们把操作系统做某件事,抽象成一种概念,称之为一个任务。一个进程可以对应一个任务,也可以对应多个任务。
早期的计算机只有一个CPU,多个任务需要运行怎么办?需要依次排队等待。串行执行,一个任务执行完毕,才能执行下一个。这种方式存在着明显的弊端,假设排在前面的A任务需要执行5小时,而排后面的B任务仅需要1分钟,那么B任务必须等待A任务5小时完成后,才能执行,这种方式显得极其不灵活。
后来就有了多任务系统,在CPU同一时间只能处理一个任务的前提下, 每个任务有一定的执行时长,比如任务A执行0.001s, 切换到任务B执行0.05s,再切换到任务C执行0.1…不断循环。这种机制也就可以在一定程度 上解决上述任务B需要长时间等待的问题。
由于CPU速度非常快,这种多个任务不断切换,会给用户一种任务并行执行的错觉,这种也被称为是 伪并行调度。既然有伪并行,那么也会有真并行。
在现代计算机中,常见的CPU核数可以达到8核(ps:核数即一个CPU由多少个核心组成,核心数越多,代表这个CPU的运转速度越快,性能越好。对于同一个数据处理,一核CPU相当于1个人处理数据,双核CPU相当于2个人处理同一个数据,4核CPU相当于4个人去处理同一个数据,因此处理核心数越多,CPU的工作效率也就越高。)甚至更多,操作系统可将每一个核视为一个CPU,那么 8 核CPU就可以真并行执行8个任务。
并发 (伪并行)和并行如下图:
伪并行虽然可以解决上述任务等待的问题,但是依然还存在一系列未解之谜:
为了解决上面一系列谜题,我们需要一种模型对任务进行详尽的描述记录。
那么什么原因会导致进程会被创建,从而生成PCB呢?常见的有以下几种
1.系统初始化
2.用户通过系统提供的API创建新进程
3.批处理作业初始化(什么是批处理作业)
4.由现有进程派生子进程
一个进程因为某种原因被创建了,那么它可以按照以下步骤进行一系列的初始化
1.给新进程分配一个进程ID
2.分配内存空间
3.初始化PCB
4.进入就绪队列
如图,进入就绪队列,其状态就会变为就绪态。各个状态之间的关系描述如下:
就绪->运行:当操作系统内存在着调度程序,当需要运行一个新进程时, 调度程序选择一个就绪态的进程,让其进入运行态。
运行->就绪:运行态的进程,会占有CPU 。每个进程会被分配一定的执行时间,当时间结束后,重新回到就绪态。
运行->阻塞:进程请求调用系统的某些服务,但是操作系统没法立即给它(比如这种服务可能要耗时初始化,比如/0资源需要等待),那么它就会进入阻塞态。
阻塞->就绪:当等待结束了,就由阻塞态进入就绪态。
运行->终止:当进程表示自己已经完成了,它会被操作系统终止。
当存在多个进程时,由于同一时间只能有一个进程在执行,那么如何去管理这一系列的处于阻塞态和就绪态的进程?
一般来说,会使用就绪队列,和阻塞队列,让处于阻塞态和就绪态的进程进入队列,排队执行。
(与五状态模型一样,只是描述状态的说法不同)
一旦排队的进程多了,对于有限的内存空间将会是极大的考验。为了解决内存占用问题,可以将一部分内存中的进程交换到磁盘中,这些被交换到磁盘的进程,会进入挂起状态
挂起状态可以分为两种:
这两种挂起状态加上前面的五种状态,就变成了七种状态变迁,见如下图:
对于一个被执行的程序,操作系统会为该程序创建一个进程。 进程作为一种抽象概念, 可将其视为一个容器,该容器聚集了相关资源,包括地址空间,线程,打开的文件,保护许可等。而操作系统本身是一个程序,有一句经典的话程序=算法+数据结构,因此对于单个进程,可以基于一种数据结构来表示它,这种数据结构称之为进程控制块(PCB)。每创建一个线程,就会有这样一个PCB。
在操作系统中,是用进程控制块(process control block, PCB) 数据结构来描述进程的。
PCB是进程存在的唯一标识,这意味着一个进程的存在,必然会有一个PCB,如果进程消失了,那么PCB也会随之消失。
PCB具体包含什么信息呢?
每个PCB是如何组织的呢?
通常是通过链表的方式进行组织,把具有相同状态的进程链在一起,组成各种队列。比如:
除了链接的组织方式,还有索引方式,它的工作原理:将同一状态的进程组织在一个索引表中,索引表项指向相应的PCB,不同状态对应不同的索引表。
一般会选择链表,因为可能面临进程创建,销毁等调度导致进程状态发生变化,所以链表能够更加灵活的插入和删除。
当一个正在运行中的进程被中断,操作系统指定另一个就绪态的进程进入运行态,这个过程就是进程切换,也可以叫上下文切换。该切换过程一般涉及以下步骤:
1.保存处理器上下文环境:将CPU程序计数器和寄存器的值保存到当前进程的私有堆栈里。
2.更新当前进程的PCB (包括状态更变)。
3.将当前进程移到就绪队列或者阻塞队列。
4.根据调度算法,选择就绪队列中一个合适的新进程,将其更改为运行态。
5.更新内存管理的数据结构(分段/分页)。
6.新进程内对堆栈所保存的上下文信息载入到CPU的寄存器和程序计数器,占有CPU。
发生进程上下文切换有哪些场景?
以上,就是发生进程上下文切换的常见场景了。
在早期的操作系统中都是以进程作为独立运行的基本单位,直到后面,计算机科学家们又提出了更小的能独立运行的基本单位,也就是线程。
线程是进程当中的一条执行流程。
同一个进程内多个线程之间可以共享代码段、数据段、打开的文件等资源,但每个线程各自都有一套独立的寄存器和栈,这样可以确保线程的控制流是相对独立的。
(多线程)
我们一开始提及过,操作系统底层存在调度程序,调度程序可调度任务,而单线程进程,每个进程可以对应一个任务。现在,对于多线程的进程,每一个线程最终对于调度程序来说,都是一个任务,如下图(Linux系统)。因此也有一种流行的说法线程是CPU调度的基本单位。
线程与进程最大的区别在于:线程是调度的基本单位,而进程则是资源拥有的基本单位。
所以,所谓操作系统的任务调度,实际上的调度对象是线程,而进程只是给线程提供了虚拟内存、全局变量等资源。
对于线程和进程,我们可以这么理解:
另外,线程也有自己的私有数据,比如栈和寄存器等,这些在上下文切换时也是需要保存的。
线程上下文切换的是什么?
这还得看线程是不是属于同一个进程
所以,线程的上下文切换相比进程,开销要小很多。
进程都希望自己能够占用CPU进行工作,那么这涉及到前面说过的进程上下文切换。
一旦操作系统把进程切换到运行状态,也就意味着该进程占用着CPU在执行,但是当操作系统把进程切换到其他状态时,那就不能在CPU中执行了,于是操作系统会选择下一个要运行的进程。
选择一个进程运行这一功能是在操作系统中完成的,通常称为调度程序(scheduler) 。
那到底什么时候调度进程,或以什么原则来调度进程呢?
在进程的生命周期中,当进程从一个运行状态到另外一状态变化的时候,其实会触发一次调度。
比如,以下状态的变化都会触发操作系统的调度:
因为,这些状态变化的时候,操作系统需要考虑是否要让新的进程给CPU运行,或者是否让当前进程从CPU上退出来而换另一个进程运行。
另外,如果硬件时钟提供某个频率的周期性中断,那么可以根据如何处理时钟中断
把调度算法分为两类:
以什么原则来调度进程
五种调度原则:
说白了,这么多调度原则,目的就是要使得进程要快。_
常见的进程调度算法有:
先来先服务(First Come First Serverd, FCFS)。先进就绪队列,则先被调度,先来先服务是最简单的调度算法。
先来先服务存在上面谈论过的问题:当前面任务耗费很长时间执行,那么后面的任务即使只需要执行很短的时间,也必须一直等待。属于非抢占式。
每一个进程会被分配一个时间片,表示允许该进程在这个时间段运行,如果时间结束了,进程还没运行完毕,那么会通过抢占式调度,将CPU分配给其他进程,该进程回到就绪队列。这是一种最简单最公平的调度算法,但是有可能会存在问题。由于进程的切换,需要耗费时间,如果时间片太短,频繁进行切换,会影响效率。
如果进程时间片太长,有可能导致排后面的进程等待太长时间。因此时间片的长度,需要有大致合理的数值。( 《现代操作系统》的观点是建议时间片长度在20ms~50ms)。
最短作业优先(Shortest Job First, SJF),顾名思义即进程按照作业时间长短排队,作业时间段的排前面先执行,如下图。
这显然对长作业不利,很容易造成一种极端现象。
比如,一个长作业在就绪队列等待运行,而这个就绪队列有非常多的短作业,那么就会使得长作业不断的往后推,周转时间变长,致使长作业长期不会被运行。
最短剩余时间优先(Shortest Remaining Time Next),从就绪队列中选择剩余时间最短的进程进行调度。该算法可以理解为最短作业优先和时间片轮转的结合。如果没有时间片,那么最短剩余时间其实就是最短作业时间,因为每个进程都是从头执行到尾。
假设就绪队列中有如下进程
按照优先级调度,执行顺序为p1->p3- >p2。如果多个进程优先级相同,则按照先来先服务的方式依次执行。
优先级调度可以进一步细分为抢占式和非抢占式。
非抢占式:和上面提及的非抢占式类似,一旦该进程占有CPU就将一直执行到结束或者阻塞。
抢占式:进程执行期间,一旦有更高优先级的进程进入就绪队列,那么该进程就会被暂停,重回就绪队列,让更高优先级的进程执行。但是为了防止最高优先级进程一直执行,每个进程依然有自己的时间片,每次时间片结束后,会根据一定规则降低该进程优先级,避免某些最高优先级长作业进程一直占用CPU。
但是依然有缺点,可能会导致低优先级的进程永远不会运行。
多级反馈队列调度基于时间片轮转和优先级调度,设置多个就绪队列,赋予每个就绪队列优先级,优先级越高的队列进程的时间片越短。如下图,第1级就绪队列优先级最高,进程的时间片长度最短,第2级就绪队列次之,以此类推。
当有新的进程创建时,先进入第1级就绪队列,如果时间片结束之前就运行完毕,则终止,否则进入第2级队列等待下一次调度。 在n级队列之前,进程按照先到先服务规则依次调度,到了第n级队列**(最后一级)采用时间片轮转调度**。仅当第1级队列为空时,才调度第2级队列的进程,如果第1级队列的进程正在运行,此时有一个更高优先级的进程进入,则会停下第 i 级的进程,让它回到第 i 级队列尾部,转而执行更高优先级的进程,即满足优先级调度算法的原则。
可以发现:
对于短作业:可能可以在第一级队列很快被处理完。
对于长作业:如果在第一级队列处理不完,可以移入下次队列等待被执行,虽然等待的时间变长了,但是运行时间也会更长了,所以该算法很好的兼顾了长短作业,同时有较好的响应时间。
拿去银行办业务的例子,把上面的调度算法串起来。
办理业务的客户相当于进程,银行窗口工作人员相当于CPU。
现在,假设这个银行只有一个窗口(单核 CPU),那么工作人员一次只能处理一个业务。
那么最简单的处理方式,就是先来的先处理,后面来的就乖乖排队,这就是先来先服务(FCFS) 调度算法。但是万一先来的这位老哥是来贷款的,这一谈就好几个小时,一直占用着窗口,这样后面的人只能干等,或许后面的人只是想简单的取个钱,几分钟就能搞定,却因为前面老哥办长业务而要等几个小时,你说气不气人?
有客户抱怨了,那我们就要改进,我们干脆优先给那些几分钟就能搞定的人办理业务,这就是短作业优先(SJF)调度算法。听起来不错,但是依然还是有个极端情况,万一办理短业务的人非常的多,这会导致长业务的人一直得不到服务,万一这个长业务是个大客户,那不就捡了芝麻丢了西瓜。
那就公平起见,现在窗口工作人员规定,每个人我只处理10分钟。如果10分钟之内处理完,就马上换下一个人。如果没处理完,依然换下一个人,但是客户自己得记住办理到哪个步骤了。这个也就是时间片轮转(RR)调度算法。但是如果时间片设置过短,那么就会造成大量的上下文切换,增大了系统开销。如果时间片过长,相当于退化成先来先服务(FCFS)算法了。
既然公平也可能存在问题,那银行就对客户分等级,分为普通客户、VIP 客户、SVIP 客户。只要高优先级的客户一来,就第一时间处理这个客户,这就是最高优先级(HPF)调度算法。但依然也会有极端的问题,万一当天来的全是高级客户,那普通客户不是没有被服务的机会,不把普通客户当人是吗?那我们把优先级改成动态的,如果客户办理业务时间增加,则降低其优先级,如果客户等待时间增加,则升高其优先级。
那有没有兼顾到公平和效率的方式呢?这里介绍一种算法,考虑的还算充分的,多级反馈队列(MFQ)调度算法,它是时间片轮转算法和优先级算法的综合和发展。
它的工作方式:
可以发现,对于要办理短业务的客户来说,可以很快的轮到并解决。对于要办理长业务的客户,一下子解决不了,就可以放到下一个队列,虽然等待的时间稍微变长了,但是轮到自己的办理时间也变长了,也可以接受,不会造成极端现象,可以说是综合上面几种算法的优点。
每个进程的用户地址空间都是独立的,一般而言是不能互相访问的,但内核空间是每个进程都共享的,所以进程之间要通信必须通过内核。
进程间通信目的一般有共享数据,数据传输,消息通知,进程控制等。以Unix/Linux为例,介绍几种重要的进程间通信方式:共享内存、管道、消息队列、信号量、信号。
如果你学过Linux命令,那你肯定很熟悉用“|”这个竖线。
1 $ ps auxf | grep mysql
上面命令行里的用竖线就是一个管道,它的功能是将前一个命令(ps auxf)的输出,作为后一个命令(grep mysql)的输入,从这功能描述,可以看出管道传输数据是单向的,如果想相互通信,我们需要创建两个管道才行。
同时,我们得知上面这种管道是没有名字的,所以用“|”表示的管道称为匿名管道,用完了就销毁。
管道还有另外一个类型是命名管道,也被叫做FIFO,因为数据是先进先出的传输方式。
在使用命名管道前,先需要通过mkfifo命令来创建,并且指定管道名字:
1 $ mkfifo myPipe
myPipe就是这个管道的名称,基于Linux一切皆文件的理念,所以管道也是以文件的方式存在,我们可以用ls看一下,这个文件的类型是p,也就是pipe (管道)的意思:
1 $ ls-1
2 prw-r–r--,1 root root 0Jul 17 02:45 myPipe
接下来,我们往myPipe这个管道写入数据:
1 $ echo “hello” > myPipe 1 //将数据写进管道
2
//停住了…
你操作了后,你会发现命令执行后就停在这了,这是因为管道里的内容没有被读取,只有当管道里的数据被读完后,命令才可以正常退出。
于是,我们执行另外一个命令来读取这个管道里的数据:
1 $ cat < myPipe //读取管道里的数据
2 hello
可以看到,管道里的内容被读取出来了,并打印在了终端上,另外一方面,echo那个命令也正常退出了。
我们可以看出,**管道这种通信方式效率低,不适合进程间频繁地交换数据。**当然它的好处,自然就是简单,同时我们也很容易得知管道里的数据已经被另一个进程读取了。
我们可以得知,对于匿名管道,它的通信范围是存在父子关系的进程。因为管道没有实体,也就是没有管道文件,只能通过 fork 来复制父进程fd文件描述符,来达到通信的目的。
在shell里面执行A | B命令的时候, A进程和B进程都是shell创建出来的子进程,A和B之间不存在父子关系,它俩的父进程都是shell。
另外,对于命名管道,它可以在不相关的进程间也能相互通信。因为命名管道,提前创建了一个类型为管道的设备文件,在进程里只要使用这个设备文件,就可以相互通信。
前面说到管道的通信方式是效率低的,因此管道不适合进程间频繁地交换数据。
对于这个问题,消息队列的通信模式就可以解决。比如,A进程要给B进程发送消息,A进程把数据放在对应的消息队列后就可以正常返回了,B进程需要的时候再去读取数据就可以了。同理,B进程要给A进程发送消息也是如此。
再来,消息队列是保存在内核中的消息链表,在发送数据时,会分成一个一个独立的数据单元,也就是消息体(数据块),消息体是用户自定义的数据类型,消息的发送方和接收方要约定好消息体的数据类型,所以每个消息体都是固定大小的存储块,不像管道是无格式的字节流数据。如果进程从消息队列中读取了消息体,内核就会把这个消息体删除。
消息队列生命周期随内核,如果没有释放消息队列或者没有关闭操作系统,消息队列会一直存在,而前面提到的匿名管道的生命周期,是随进程的创建而建立,随进程的结束而销毁。
缺点:
消息队列通信过程中,存在用户态与内核态之间的数据拷贝开销,因为进程写入数据到内核中的消息队列时,会发生从用户态拷贝数据到内核态的过程,同理另一进程读取内核中的消息数据时,会发生从内核态拷贝数据到用户态的过程。
消息队列的读取和写入的过程,都会有发生用户态与内核态之间的消息拷贝过程。那共享内存的方式,就很好的解决了这一问题。
现代操作系统,对于内存管理,采用的是虚拟内存技术,也就是每个进程都有自己独立的虚拟内存空间,不同进程的虚拟内存映射到不同的物理内存中。所以,即使进程A和进程B的虚拟地址是一样的,其实访问的是不同的物理内存地址,对于数据的增删查改互不影响。
共享内存的机制,就是拿出一块虚拟地址空间来,映射到相同的物理内存中。这样这个进程写入的东西,另外一个进程马上就能看到了,都不需要拷贝来拷贝去,传来传去,大大提高了进程间通信的速度。
用了共享内存通信方式,带来新的问题,那就是如果多个进程同时修改同一个共享内存,很有可能就冲突了。例如两个进程都同时写一个地址,,那先写的那个进程会发现内容被别人覆盖了。
为了防止多进程竞争共享资源,而造成的数据错乱,所以需要保护机制,使得共享的资源,在任意时刻只能被一个进程访问。正好,信号量就实现了这一保护机制。
信号量其实是一个整型的计数器,主要用于实现进程间的互斥与同步,而不是用于缓存进程间通信的数据。
信号量表示资源的数量,控制信号量的方式有两种原子操作:
P操作是用在进入共享资源之前,V操作是用在离开共享资源之后,这两个操作是必须成对出现的。
接下来,举个例子,如果要使得两个进程互斥访问共享内存,我们可以初始化信号量为1。
具体的过程如下:
可以发现,信号初始化为1,就代表着是互斥信号量,它可以保证共享内存在任何时刻只有一个进程在访问,这就很好的保护了共享内存。
另外,在多进程里,每个进程并不一定是顺序执行的,它们基本是以各自独立的、不可预知的速度向前推进,但有时候我们又希望多个进程能密切合作,以实现一个共同的任务。
例如,进程A是负责生产数据,而进程B是负责读取数据,这两个进程是相互合作、相互依赖的,进程A必须先生产了数据,进程B才能读取到数据,所以执行是有前后顺序的。
那么这时候,就可以用信号量来实现多进程同步的方式,我们可以初始化信号量为0。
具体过程:
可以发现,信号初始化为0,就代表着是同步信号量,它可以保证进程A应在进程B之前执行。
信号一般用于一些异常情况下的进程间通信,是一种异步通信,它的数据结构一般就是一个数字在Linux操作系统中,为了响应各种各样的事件,提供了几十种信号,分别代表不同的意义。我们可以通过kill -l 命令,查看所有的信号:
运行在shell终端的进程,我们可以通过键盘输入某些组合键的时候,给进程发送信号。例如
如果进程在后台运行,可以通过kill 命令的方式给进程发送信号,但前提需要知道运行中的进程PID号,例如:
所以,信号事件的来源主要有硬件来源(如键盘CItr+C )和软件来源(如kill命令)。
信号是进程间通信机制中唯一的异步通信机制。
进程需要为信号设置相应的监听处理,当收到特定信号时,执行相应的操作,类似很多编程语言里的通知机制。
前面提到的管道、消息队列、共享内存、信号量和信号都是在同一台主机上进行进程间通信,那要想跨网络与不同主机上的进程之间通信,就需要Socket通信了。
实际上,Socket 通信不仅可以跨网络与不同主机的进程间通信,还可以在同主机上进程间通信。
(看到这上面的都忘了吧 (=´ω`=),那就看看总表回忆一下咯)
对于运行的进程来说,内存就像一个纸箱子, 仅仅是一个暂存数据的地方, 而且空间有限。如果我们想要进程结束之后,数据依然能够保存下来,就不能只保存在内存里,而是应该保存在外部存储中。就像图书馆这种地方,不仅空间大,而且能够永久保存。
我们最常用的外部存储就是硬盘,数据是以文件的形式保存在硬盘上的。为了管理这些文件,我们在规划文件系统的时候,需要考虑到以下几点。
第一点:文件系统要有严格的组织形式,使得文件能够以块为单位进行存储。这就像图书馆里,我们会给设置一排排书架,然后再把书架分成一个个小格子,有的项目存放的资料非常多,一个格子放不下,就需要多个格子来进行存放。我们把这个区域称为存放原始资料的仓库区。
第二点:文件系统中也要有索引区,用来方便查找一个文件分成的多个块都存放在了什么位置。这就好比,图书馆的书太多了,为了方便查找,我们需要专门设置一排书架,这里面会写清楚整个档案库有哪些资料,资料在哪个架子的哪个格子上。这样找资料的时候就不用跑遍整个档案库,在这个书架上找到后,直奔目标书架就可以了。
第三点:如果文件系统中有的文件是热点文件,近期经常被读取和写入,文件系统应该有缓存层。这就相当于图书馆里面的热门图书区,这里面的书都是畅销书或者是常常被借还的图书。因为借还的次数比较多,那就没必要每次有人还了之后,还放回遥远的货架,我们可以专门开辟一个区域, 放置这些借还频次高的图书。这样借还的效率就会提高。
第四点:文件应该用文件夹的形式组织起来,方便管理和查询。这就像在图书馆里面,你可以给这些资料分门别类,比如分成计算机类、文学类、历史类等等。这样你也容易管理,项目组借阅的时候只要在某个类别中去找就可以了。
在文件系统中,每个文件都有一个名字,这样我们访问一个文件,希望通过它的名字就可以找到。文件名就是一个普通的文本。 当然文件名会经常冲突,不同用户取相同的名字的情况还是会经常出现的。
要想把很多的文件有序地组织起来,我们就需要把它们成为目录或者文件夹。这样,一个文件夹里可以包含文件夹,也可以包含文件,这样就形成了一种树形结构。而我们可以将不同的用户放在不同的用户目录下,就可以一定程度上避免了命名的冲突问题。
第五点:Linux内核要在自己的内存里面维护一套数据结构,来保存哪些文件被哪些进程打开和使用。这就好比,图书馆里会有个图书管理系统,记录哪些书被借阅了,被谁借阅了,借阅了多久,什么时候归还。
文件系统是操作系统中负责管理持久数据的子系统,说简单点,就是负责把用户的文件存到磁盘硬件中,因为即使计算机断电了,磁盘里的数据并不会丢失,所以可以持久化的保存文件。
文件系统的基本数据单位是文件,它的目的是对磁盘上的文件进行组织管理,那组织的方式不同,就会形成不同的文件系统。
Linux最经典的一句话是:“一切皆文件”,不仅普通的文件和目录,就连块设备、管道、socket 等,也都是统一交给文件系统管理的。
Linux文件系统会为每个文件分配两个数据结构:索引节点(index node) 和 目录项(directory entry),它们主要用来记录文件的元信息和目录层次结构。
由于索引节点唯一标识一个文件,而目录项记录着文件的名,所以目录项和索引节点的关系是多对一,也就是说,一个文件可以有多个别字。比如,硬链接的实现就是多个目录项中的索引节点指向同一个文件。
注意,目录也是文件,也是用索引节点唯一标识,和普通文件不同的是,普通文件在磁盘里面保存的是文件数据,而目录文件在磁盘里面保存子目录或文件。
目录项和目录是一个东西吗?
虽然名字很相近,但目录是个文件。持久化存储在磁盘,而目录项是内核一个数据结构,缓存在内存。
如果查询目录频繁从磁盘读,效率会很低,所以内核会把已经读过的 目录 用 目录项 这个数据结构缓存在内存,下次再次读到相同的目录时,只需从内存读就可以,大大提高了文件系统的效率。
目录项这个数据结构不只是表示目录,也是可以表示文件的。
那文件数据是如何存储在磁盘的呢?
磁盘读写的最小单位是扇区,扇区的大小只有512B大小,很明显,如果每次读写都以这么小为单位,那这读写的效率会非常低。
所以,文件系统把多个扇区组成了一个逻辑块,每次读写的最小单位就是逻辑块(数据块),Linux中的逻辑块大小为4KB,也就是一次性读写 8个扇区,这将大大提高了磁盘的读写的效率。
以上就是索引节点、目录项以及文件数据的关系,下面这个图就很好的展示了它们之间的关系:
即索引节点存在磁盘上,目录项存在内存中,目录项指向索引节点。
索引节点是存储在硬盘上的数据,那么为了加速文件的访问,通常会把索引节点加载到内存中。
另外,磁盘进行格式化的时候,会被分成三个存储区域,分别是:超级块、索引节点区和数据块区。
我们不可能把超级块和索引节点区全部加载到内存,这样内存肯定撑不住,所以只有当需要使用的时候,才将其加载进内存,它们加载进内存的时机是不同的。
文件系统的种类众多,而操作系统希望对用户提供一个统一的接口,于是在用户层与文件系统层引入了中间层,这个中间层就称为虚拟文件系统(Virtual FileSystem, VFS)。
VFS定义了一组所有文件系统都支持的数据结构和标准接口,这样程序员不需要了解文件系统的工作原理,只需要了解VFS提供的统一接口即可。
在Linux文件系统中,用户空间、系统调用、虚拟机文件系统、缓存、文件系统以及存储之间的关系如下图:
Linux支持的文件系统也不少,根据存储位置的不同,可以把文件系统分为三类:
文件系统首先要先挂载到某个目录才可以正常使用,比如Linux系统在启动时,会把文件系统挂载到根目录。
在操作系统的辅助之下,磁盘中的数据在计算机中都会呈现为易读的形式,并且我们不需要关心数据到底是如何存放在磁盘中,存放在磁盘的哪个地方等等问题,这些全部都是由操作系统完成的。
那么,文件数据在磁盘中究竟是怎么样的呢?我们来一探究竟!
磁盘中的存储单元会被划分为一个个的“块”,也被称为扇区,扇区的大小一般都为512byte。这说明即使一块数据不足512byte,那么它也要占用512byte的磁盘空间。
而几乎所有的文件系统都会把文件分割成固定大小的块来存储,通常一个块的大小为4K。如果磁盘中的扇区为512byte,而文件系统的块大小为4K,那么文件系统的存储单元就为8个扇区。这也是前面提到的一个问题,文件大小和占用空间之间有什么区别?文件大小是文件实际的大小,而占用空间则是因为即使它的实际大小没有达到那么大,但是这部分空间实际也被占用,其他文件数据无法使用这部分的空间。所以我们写入1byte的数据到文本中,但是它占用的空间也会是4K。
这里要注意在Windows下的NTFS文件系统中,如果一开始文件数据小于 1K,那么则不会分配磁盘块来存储,而是存在一个文件表中。但是一旦文件数据大于1K,那么不管以后文件的大小,都会分配以4K为单位的磁盘空间来存储。
与内存管理一样,为了方便对磁盘的管理,文件的逻辑地址也被分为一个个的文件块。于是文件的逻辑地址就是(逻辑块号,块内地址)。用户通过逻辑地址来操作文件,操作系统负责完成逻辑地址与物理地址的映射。
不同的文件系统为文件分配磁盘空间会有不同的方式,这些方式各自都有优缺点。
连续分配要求每个文件在磁盘上有一组连续的块,该分配方式较为简单。
通过上图可以看到,文件的逻辑块号的顺序是与物理块号相同的,这样就可以实现随机存取了,只要知道了第一个逻辑块的物理地址,那么就可以快速访问到其他逻辑块的物理地址。那么操作系统如何完成逻辑块与物理块之间的映射呢?实际上,文件都是存放在目录下的,而目录是一种有结构文件,所以在文件目录的记录中会存放目录下所有文件的信息,每一个文件或者目录都是一个记录。 而这些信息就包括文件的起始块号和占有块号的数量。
那么操作系统如何完成逻辑块与物理块之间的映射呢?
(逻辑块号, 块内地址) -> (物理块号, 块内地址),只需要知道逻辑块号对应的物理块号即可,块内地址不变。
用户访问一个文件的内容,操作系统通过文件的标识符找到目录项FCB,物理块号=起始块号+逻辑块号。当然,还需要检查逻辑块号是否合法,是否超过长度等。因为可以根据逻辑块号直接算出物理块号,所以连续分配支持顺序访问和随机访问。
因为读/写文件是需要移动磁头的,如果访问两个相隔很远的磁盘块,移动磁头的时间就会变长。使用连续分配来作为文件的分配方式,会使文件的磁盘块相邻,所以文件的读/写速度最快。
连续空间存放的方式虽然读写效率高,但是有磁盘空间碎片和文件长度不易扩展的缺陷。
如下图,如果文件B被删除,磁盘上就留下一块空缺,这时,如果新来的文件小于其中的一个空缺,我们就可以将其放在相应空缺里。但如果该文件的大小大于所有的空缺,但却小于空缺大小之和,则虽然磁盘上有足够的空缺,但该文件还是不能存放。当然了,我们可以通过将现有文件进行挪动来腾出空间以容纳新的文件,但是这个在磁盘挪动文件是非常耗时,所以这种方式不太现实。
另外一个缺陷是文件长度扩展不方便,例如上图中的文件A要想扩大一下,需要更多的磁盘空间,唯一的办法就只能是挪动的方式,前面也说了,这种方式效率是非常低的。
那么有没有更好的方式来解决上面的问题呢?
答案当然有,既然连续空间存放的方式不太行,那么我们就改变存放的方式,使用非连续空间存放方式来解决这些缺陷。
非连续空间存放方式分为链表方式和索引方式。
链式分配采取离散分配的方式,可以为文件分配离散的磁盘块。它有两种分配方式:显式链接和隐式链接。
隐式链接是指目录项中只会记录文件所占磁盘块中的第一块的地址和最后一块磁盘块的地址, 然后每一个磁盘块中存放一个指向下一个磁盘块的指针, 从而可以根据指针找到下一块磁盘块。如果需要分配新的磁盘块,则使用最后一块磁盘块中的指针指向新的磁盘块,然后修改新的磁盘块为最后的磁盘块。
我们来思考一个问题,采用隐式链接如何将实现逻辑块号转换为物理块号呢?
用户给出需要访问的逻辑块号i,操作系统需要找到所需访问文件的目录项FCB。从目录项中可以知道文件的起始块号,然后将逻辑块号0的数据读入内存,由此知道1号逻辑块的物理块号,然后再读入1号逻辑块的数据进内存,此次类推,最终可以找到用户所需访问的逻辑块号i。访问逻辑块号i,总共需要i + 1次磁盘I/O操作。
得出结论:隐式链接分配只能顺序访问,不支持随机访问,查找效率低。
我们来思考另外一个问题,采用隐式链接是否方便文件拓展?
我们知道目录项中存有结束块号的物理地址,所以我们如果要拓展文件,只需要将新分配的磁盘块挂载到结束块号的后面即可,修改结束块号的指针指向新分配的磁盘块,然后修改目录项。
得出结论:隐式链接分配很方便文件拓展。所有空闲磁盘块都可以被利用到,无碎片问题,存储利用率高。
显示链接是把用于链接各个物理块的指针显式地存放在一张表中,该表称为文件分配表(FAT, File Allocation Table)。
由于查找记录的过程是在内存中进行的,因而不仅显著地提高了检索速度,而且大大减少了访问磁盘的次数。但也正是整个表都存放在内存中的关系,它的主要的缺点是不适用于大磁盘。
比如,对于200GB的磁盘和1KB大小的块,这张表需要有2亿项,每一项对应于这2亿个磁盘块中的一个块,每项如果需要4个字节,那这张表要占用800MB内存,很显然FAT方案对于大磁盘而言不太合适。
链表的方式解决了连续分配的磁盘碎片和文件动态拓展的问题,但是不能有效支持直接访问(FAT除外),索引的方式可以解决这个问题。
索引的实现是为每个文件创建一个索引数据块,里面存放的是指向文件数据块的指针列表,说白了就像书的目录一样,要找哪个章节的内容,看目录查就可以。
另外,文件头需要包含指向索引数据块的指针,这样就可以通过文件头知道索引数据块的位置,再通过索引数据块里的索引信息找到对应的数据块。
创建文件时,索引块的所有指针都设为空。当首次写入第 i 块时,先从空闲空间中取得一个块, 再将其地址写到索引块的第 i 个条目。
索引的方式优点在于:
由于索引数据也是存放在磁盘块的,如果文件很小,明明只需一块就可以存放的下,但还是需要额外分配一块来存放索引数据,所以缺陷之一就是存储索引带来的开销。
如果文件很大,大到一个索引数据块放不下索引信息,这时又要如何处理大文件的存放呢?我们可以通过组合的方式,来处理大文件的存储。
先来看看链表+索引的组合,这种组合称为链式索引块,它的实现方式是在索引数据块留出一个存放下一个索引数据块的指针,于是当一个索引数据块的索引信息用完了,就可以通过指针的方式,找到下一个索引数据块的信息。那这种方式也会出现前面提到的链表方式的问题,万一某个指针损坏了,后面的数据也就会无法读取了。
还有另外一种组合方式是索引+索引的方式,这种组合称为多级索引块,实现方式是通过一个索引块来存放多个索引数据块,一层套一层索引, 像极了俄罗斯套娃是吧๑乛◡乛๑
前面说到的文件的存储是针对已经被占用的数据块组织和管理,接下来的问题是,如果我要保存一个数据块, 我应该放在硬盘上的哪个位置呢?难道需要将所有的块扫描一遍,找个空的地方随便放吗?
那这种方式效率就太低了,所以针对磁盘的空闲空间也是要引入管理的机制,接下来介绍几种常见的方法:
空闲表法
空闲表法就是为所有空闲空间建立一张表,表内容包括空闲区的第一个块号和该空闲区的块个数,注意,这个方式是连续分配的。如下图:
当请求分配磁盘空间时,系统依次扫描空闲表里的内容,直到找到一个合适的空闲区域为止。当用户撤销一个文件时,系统回收文件空间。这时,也需顺序扫描空闲表,寻找一个空闲表条目并将释放空间的第一个物理块号及它占用的块数填到这个条目中。
这种方法仅当有少量的空闲区时才有较好的效果。因为,如果存储空间中有着大量的小的空闲区,则空闲表变得很大,这样查询效率会很低。另外,这种分配技术适用于建立连续文件。
空闲链表法
我们也可以使用链表的方式来管理空闲空间,每一个空闲块里有一个指针指向下一个空闲块,这样也能很方便的找到空闲块并管理起来。如下图:
当创建文件需要一块或几块时,就从链头上依次取下一块或几块。反之,当回收空间时,把这些空闲块依次接到链头上。
这种技术只要在主存中保存一个指针, 令它指向第一个空闲块。其特点是简单,但不能随机访问,工作效率低,因为每当在链上增加或移动空闲块时需要做很多I/O操作,同时数据块的指针消耗了一定的存储空间。
空闲表法和空闲链表法都不适合用于大型文件系统,因为这会使空闲表或空闲链表太大。
位图法
位图是利用二进制的一位来表示磁盘中一个盘块的使用情况,磁盘上所有的盘块都有一个二进制位与之对应。
当值为0时,表示对应的盘块空闲,值为1时,表示对应的盘块已分配。它形式如下:
11110111110011111111100111 …
在Linux文件系统就采用了位图的方式来管理空闲空间,不仅用于数据空闲块的管理,还用于inode空闲块的管理,因为inode也是存储在磁盘的,自然也要有对其管理。
前面提到Linux是用位图的方式管理空闲空间,用户在创建一个新文件时, Linux 内核会通过inode的位图找到空闲可用的inode,并进行分配。要存储数据时,会通过块的位图找到空闲的块,并分配,但仔细计算一下还是有问题的。
数据块的位图是放在磁盘块里的,假设是放在一个块里,一个块4K,每位表示一个数据块,共可以表示4 * 1024 * 8 = 215个空闲块,**由于1个数据块是4K大小,那么最大可以表示的空间为**215 * 4 * 1024 = 2^27个byte,也就是128M。
也就是说按照上面的结构,如果采用(一个块的位图+ 一系列的块),外加一(个块的inode的位图+一系列的inode)的结构能表示的最大空间也就128M,这太少了,现在很多文件都比这个大。
在Linux文件系统,把这个结构称为一个块组,那么有N多的块组,就能够表示N大的文件。
最终,整个文件系统格式就是下面这个样子。
最前面的第一个块是引导块,在系统启动时用于启用引导,接着后面就是一个一个连续的块组了,块组的内容如下:
你可以会发现每个块组里有很多重复的信息,比如超级块和块组描述符表,这两个都是全局信息,而且非常的重要,这么做是有两个原因:
不过,Ext2 的后续版本采用了稀疏技术。该做法是,超级块和块组描述符表不再存储到文件系统的每个块组中,而是只写入到块组0、块组1和其他ID可以表示为3、5、7的幂的块组中。
在前面,我们知道了一个普通文件是如何存储的,但还有一个特殊的文件,
经常用到的目录,它是如何保存的呢?
基于Linux 一切皆文件的设计思想,目录其实也是个文件,你甚至可以通过vim打开它,它也有inode,inode 里面也是指向一些块。
和普通文件不同的是,普通文件的块里面保存的是文件数据,而目录文件的块里面保存的是目录里面一项一项的文件信息。
在目录文件的块中,最简单的保存格式就是列表,就是一项一项地将目录下的文件信息(如文件名、文件inode.文件类型等)列在表里。
列表中每一项就代表该目录下的文件的文件名和对应的inode,通过这个inode,就可以找到真正的文件。
通常,第一项是「则」,表示当前目录,第二项是.,表示上一级目录, 接下来就是一项一项的文件名和inode。
如果一个目录有超级多的文件,我们要想在这个目录下找文件,按照列表一项一项的找,效率就不高了。
于是,保存目录的格式改成哈希表,对文件名进行哈希计算,把哈希值保存起来,如果我们要查找一个目录下面的文件名,可以通过名称取哈希。如果哈希能够匹配上,就说明这个文件的信息在相应的块里面。
Linux系统的ext文件系统就是采用了哈希表,来保存目录的内容,这种方法的优点是查找非常迅速,插入和删除也较简单,不过需要一些预备措施来避免哈希冲突。
目录查询是通过在磁盘上反复搜索完成,需要不断地进行/0操作,开销较大。所以,为了减少/0操作,把当前使用的文件目录缓存在内存,以后要使用该文件时只要在内存中操作,从而降低了磁盘操作次数,提高了文件系统的访问速度。
我们的电脑设备可以接非常多的输入输出设备,就跟天元大人一样华丽的有三个老婆_,比如键盘、鼠标、显示器、网卡、硬盘、打印机、音响等等,每个设备的用法和功能都不同,那操作系统是如何把这些输入输出设备统一管理。
为了屏蔽设备之间的差异,每个设备都有一个叫设备控制器(Device Control)的组件,比如硬盘有硬盘控制器、显示器有视频控制器、二乔有西撒、炭治郎有炎柱大哥,而你有我 ( ̄(∞) ̄) 。
因为这些控制器都很清楚的知道对应设备的用法和功能,所以CPU是通过设备控制器来和设备打交道的。
设备控制器里有芯片,它可执行自己的逻辑,也有自己的寄存器,用来与CPU进行通信,比如:
实际上,控制器是有三类寄存器。它们分别是状态寄存器(Status Reqister),命令寄存器(Command Reqister)以及数据寄存器(Data Reqister),如下图:
这三个寄存器的作用:
CPU通过读、写设备控制器中的寄存器来控制设备,这可比CPU直接控制输入输出设备,要方便和标准很多。
另外,输入输出设备可分为两大类:块设备(Block Device)和字符设备(Character Device)。
块设备通常传输的数据量会非常大,于是控制器设立了一个可读写的数据缓冲区。
这样做是为了:减少对设备的操作次数。
那CPU是如何与设备的控制寄存器和数据缓冲区进行通信的?
存在两个方法:
在前面我知道,每种设备都有一个设备控制器,控制器相当于一个小CPU,它可以自己处理一些事情, 但有个问题是,当CPU给设备发送了一个指令,让设备控制器去读设备的数据,它读完的时候,要怎么通知CPU呢?
控制器的寄存器一般会有状态标记位,用来标识输入或输出操作是否完成。于是,我们想到第一种轮询等待的方法,让CPU一直查寄存器的状态,直到状态标记为完成,很明显,这种方式非常的傻瓜,它会占用CPU的全部时间。
那我们就想到第二种方法——中断,通知操作系统数据已经准备好了。我们一般会有一个硬件的中断控制器,当设备完成任务后触发中断到中断控制器,中断控制器就通知CPU,一个中断产生了,CPU需要停下当前手里的事情来处理中断。
另外,中断有两种,一种软中断,例如代码调用INT指令触发,一种是硬件中断,就是硬件通过中断控制器触发的。
但中断的方式对于频繁读写数据的磁盘,并不友好,这样CPU容易经常被打断,会占用CPU大量的时间。对于这一类设备的问题的解决方法是使用直接内存访问 DMA (Direct Memory **Access)**功能,它可以使得设备在CPU不参与的情况下,能够自行完成把设备I/0数据放入到内存。那要实现DMA功能要有「DMA 控制器」硬件的支持。
DMA的工作方式如下:
当磁盘控制器把数据传输到内存的操作完成后,磁盘控制器在总线上发出一个确认成功的信号到DMA控制器;
可以看到,CPU 当要读取磁盘数据的时候,只需给DMA控制器发送指令,然后返回去做其他事情,当磁盘数据拷贝到内存后,DMA控制机器通过中断的方式,告诉CPU数据已经准备好了,可以从内存读数据了。仅仅在传送开始和结束时需要CPU干预。
虽然设备控制器屏蔽了设备的众多细节,但每种设备的控制器的寄存器、缓冲区等使用模式都是不同的,所以为了屏蔽「设备控制器」的差异,引入了设备驱动程序。
设备控制器不属于操作系统范畴,它是属于硬件,而设备驱动程序属于操作系统的一部分,操作系统的内核代码可以像本地调用代码一样使用设备驱动程序的接口,而设备驱动程序是面向设备控制器的代码,它发出操控设备控制器的指令后,才可以操作设备控制器。
不同的设备控制器虽然功能不同,但是设备驱动程序会提供统一的接口给操作系统,这样不同的设备驱动程序,就可以以相同的方式接入操作系统。如下图:
前面提到了不少关于中断的事情,设备完成了事情,则会发送中断来通知操作系统。那操作系统就需要有一个地方来处理这个中断,这个地方也就是在设备驱动程序里,它会及时响应控制器发来的中断请求,并根据这个中断的类型调用响应的中断处理程序进行处理。
通常,设备驱动程序初始化的时候,要先注册一个该设备的中断处理函数。
我们来看看,中断处理程序的处理流程:
1、在I/O时,设备控制器如果已经准备好数据,则会通过中断控制器向CPU发送中断请求;
2、保存被中断进程的CPU上下文;
3、转入相应的设备中断处理函数;
4、进行中断处理;
5、恢复被中断进程的上下文;
对于块设备,为了减少不同块设备的差异带来的影响,Linux 通过一个统一的通用块层,来管理不同的块设备。
通用块层是处于文件系统和磁盘驱动中间的一个块设备抽象层,它主要有两个功能:
Linux内存支持5种I/O调度算法,分别是:
第一种:没有调度算法,是的,你没听错,它不对文件系统和应用程序的I/O做任何处理,这种算法常用在虚拟机I/O中,此时磁盘I/O调度算法交由物理机系统负责。
第二种,先入先出调度算法,这是最简单的I/O调度算法,先进入I/O调度队列的I/O请求先发生。
第三种,完全公平调度算法,大部分系统都把这个算法作为默认的I/O调度器,它为每个进程维护了一个I/O调度队列,并按照时间片来均匀分布每个进程的I/O请求。
第四种,优先级调度算法,顾名思义,优先级高的I/O请求先发生,它适用于运行大量进程的系统,像是桌面环境、多媒体应用等。
第五种,最终期限调度算法(看I/O请求的剩余时间来决定),分别为读、写请求创建了不同的I/O队列,这样可以提高机械磁盘的吞吐量,并确保达到最终期限的请求被优先处理,适用于在I/O压力比较大的场景,比如数据库等。
前面说到了不少东西,设备、设备控制器、驱动程序、通用块层,现在再结合文件系统原理,我们来看看Linux 存储系统的I/O 软件分层。
可以把Linux存储系统的I/O由上到下可以分为三个层次,分别是文件系统层、通用块层、设备层。他们整个的层次关系如下图:
我们先来看看CPU的硬件架构图:
CPU里面的内存接口,直接和系统总线通信,然后系统总线再接入一个I/O桥接器,这个I/O桥接器,另一边接入了内存总线,使得CPU和内存通信。再另一边,又接入了一个I/O总线,用来连接I/O设备,比如键盘、显示器等。
1、那当用户输入了键盘字符,键盘控制器就会产生扫描码数据,并将其缓冲在键盘控制器的寄存器中,紧接着键盘控制器通过总线给CPU发送中断请求。
2、CPU收到中断请求后,操作系统会保存被中断进程的CPU上下文,然后调用键盘的中断处理程序。
3、键盘的中断处理程序是在键盘驱动程序初始化时注册的,那键盘中断处理函数的功能就是从键盘控制器的寄存器的缓冲区读取扫描码,再根据扫描码找到用户在键盘输入的字符,如果输入的字符是显示字符,那就会把扫描码翻译成对应显示字符的ASCII 码,比如用户在键盘输入的是字母A,是显示字符,于是就会把扫描码翻译成A字符的ASCII码。
4、得到了显示字符的ASCII码后,就会把ASCII码放到读缓冲区队列,接下来就是要把显示字符显示屏幕了,显示设备的驱动程序会定时从读缓冲区队列读取数据放到写缓冲区队列,最后把写缓冲区队列的数据一个一个写入到显示设备的控制器的寄存器中的数据缓冲区,最后将这些数据显示在屏幕里。显示出结果后,恢复被中断进程的上下文。
一台机器将自己想要表达的内容,按照某种约定好的格式发送出去,当另外一台机器收到这些信息后,也能够按照约定好的格式解析出来,从而准确、可靠地获得发送方想要表达的内容。这种约定好的格式就是网络协议(Networking Protocol)。
网络为什么要分层?
我们这里先构建一个相对简单的场景,之后几节内容,我们都要基于这个场景进行讲解。
我们假设这里就涉及三台机器。Linux 服务器A和Linux服务器B处于不同的网段,通过中间的Linux服务器作为路由器进行转发。
说到网络协议,我们还需要简要介绍一下两种网络协议模型,一种是OSI的标准七层模型,一种是业界标准的TCP/IP模型。它们的对应关系如下图所示:
用多个层次和组合,来满足不同服务器和设备的通信需求。
我们这里简单介绍一下网络协议的几个层次。
网络层
我们从哪一个层次开始呢?从第三层,网络层开始,因为这一层有我们熟悉的IP地址。也因此,这一层我们也叫IP层。
我们通常看到的IP地址都是这个样子的:192.168.1.100/24. 斜杠前面是IP地址,这个地址被点分隔为四个部分,每个部分8位,总共是32位。斜线后面24的意思是,32位中,前24位是网络号,后8位是主机号。
为什么要这样分呢?我们可以想象,虽然全世界组成一张大的互联网,美国的网站你也能够访问的,但是这个网络不是一整个的。 你们小区有一个网络,你们公司也有一个网络,联通、移动、电信运营商也各有各的网络,所以一个大网络是被分成个小的网络。那如何区分这些网络呢?这就是网络号的概念。一个网络里面会有多个设备,这些设备的网络号一样,主机号不一样。不信你可以观察一下你家里的手机、 电视、电脑。连接到网络上的每一个设备都至少有一个IP地址,用于定位这个设备。无论是近在咫尺的你旁边同学的电脑,还是远在天边的电商网站,都可以通过IP 地址进行定位。因此,IP 地址类似互联网上的邮寄地址,是有全局定位功能的。
就算你要访问美国的一个地址,也可以从你身边的网络出发,通过不断的打听道儿,经过多个网络,最终到达目的地址,,和快递员送包裹的过程差不多。打听道儿的协议也在第三层,称为路由协议(Routing protocol) ,将网络包从一个网络转发给另一个网络的设备称为路由器。
PS:总而言之,第三层干的事情,就是网络包从一个起始的IP地址,沿着路由协议指的道儿,经过多个网络,通过多次路由器转发,到达目标IP地址。
数据链路层
从第三层,我们往下看,第二层是数据链路层。有时候我们简称为二层或者MAC层。所谓MAC,就是每个网卡都有的唯一的硬件地址(不绝对唯一,相对大概率唯一即可)。这虽然也是一个地址,但是这个地址是没有全局定位功能的。
MAC地址的定位功能局限在一个网络里面,也即同一个网络号下的IP地址之间,可以通过MAC进行定位和通信。从IP地址获取MAC地址要通过ARP协议,是通过在本地发送广播包,也就是“外卖员吼”,获得的MAC地址。
由于同一个网络内的机器数量有限,通过MAC地址的好处就是简单。匹配上MAC地址就接收,匹配不上就不接收,没有什么所谓路由协议这样复杂的协议。当然坏处就是,MAC地址的作用范围不能出本地网络,所以一旦跨网络通信,虽然IP地址保持不变,但是MAC地址每经过一个路由器就要换一次。
我们看前面的图。服务器A发送网络包给服务器B,原IP地址始终是192.168.1.100,目标IP地址始终是192.168.2.100,但是在网络1里面,原MAC地址是MAC1,目标MAC地址是路由器的MAC2,路由器转发之后,原MAC地址是路由器的MAC3,目标MAC地址是MAC4。
所以第二层干的事情,就是网络包在本地网络中的服务器之间定位及通信的机制。
物理层
我们再往下看,第一层,物理层,这一层就是物理设备。例如连着电脑的网线,我们能连上的WiFi
传输层
从第三层往上看,第四层是传输层,这里面有两个著名的协议TCP和UDP。尤其是TCP,更是广泛使用,在IP层的代码逻辑中,仅仅负责数据从一个IP地址发送给另一个IP地址,丢包、乱序、重传、拥塞,这些IP层都不管。外理这些问题的代码逻辑写在了传输层的TCP协议里面。
我们常称,TCP 是可靠传输协议,也是难为它了。因为从第一层到第三层都不可靠,网络包说丢就丢,是TCP这一层通过各种编号、 重传等机制,让本来不可靠的网络对于更上层来讲,变得“看起来”可靠。哪有什么应用层岁月静好,只不过TCP层帮你负重前行。
应用层
传输层再往上就是应用层,例如咱们在浏览器里面输入的HTTP,Java 服务端写的Servlet,都是这一层的。二层到四层都是在Linux内核里面处理的,应用层例如浏览器、Nginx、 Tomcat 都是用户态的。内核里面对于网络包的处理是不区分应用的。
从四层再往上,就需要区分网络包发给哪个应用。在传输层的TCP和UDP协议里面,都有端口的概念,不同的应用监听不同的端口。例如,服务端Nginx监听80、Tomcat监听8080;再如客户端浏览器监听一个随机端口,FTP 客户端监听另外一个随机端口。
应用层和内核互通的机制,就是通过Socket系统调用。所以经常有人会问,Socket 属于哪一层,其实它哪一层都不属于 ,它属于操作系统的概念,而非网络协议分层的概念。只不过操作系统选择对于网络协议的实现模式是,二到四层的处理代码在内核里面,七层的处理代码让应用自己去做,两者需要跨内核态和用户态通信,就需要一个系统调用完成这个衔接,这就是Socket。
网络分完层之后,对于数据包的发送,就是层层封装的过程。
就像下面的图中展示的一样, 在Linux服务器B上部署的服务端Nginx和Tomcat,都是通过Socket监听80和8080端口。这个时候,内核的数据结构就知道了。如果遇到
发送到这两个端口的,就发送给这两个进程。
在Linux服务器A上的客户端,打开一个Firefox连接Ngnix。也是通过Socket,客户端会被分配一个随机端口12345。同理,打开一个Chrome连接Tomcat,同样通过
Socket分配随机端口12346。
在客户端浏览器。我们将请求封装为HTTP协议,通过Socket发送到内核。
内核的网络协议栈里面,在TCP层创建用于维护连接、序列号、重传、拥塞控制的数据结构,将HTTP包加上TCP头,发送给IP层,IP 层加上IP头,发送给MAC层,MAC层加上MAC头,从硬件网卡发出去。
网络包会先到达网络1的交换机。我们常称交换机为二层设备,这是因为,交换机只会处理到第二层, 然后它会将网络包的MAC头拿下来,发现目标MAC是在自己右面的网口,于是就从这个网口发出去。
应用层通过Socket监听某个端口,因而读取的时候,内核会根据TCP头中的端口号,将网络包发给相应的应用。
HTTP层的头和正文,是应用层来解析的。通过解析,应用层知道了客户端的请求,例如购买一个商品,还是请求一个网页。 当应用层处理完HTTP的请求,会将结果仍然封装为HTTP的网络包,通过Socket接口发送给内核。
内核会经过层层封装,从物理网口发送出去,经过网络2的交换机,Linux 路由器到达网络1,经过网络1的交换机,到达Linux服务器A。在Linux服务器A上,经过层层解封装,通过socket接口,根据客户端的随机端口号,发送给客户端的应用程序,浏览器。于是浏览器就能够显示出一个绚丽多彩的页面了。
即便在如此简单的一个环境中,网络包的发送过程,竟然如此的复杂。
在没有DMA技术前,I/O的过程是这样的:
为了方便你理解,我画了一副图:
可以看到,整个数据的传输过程,都要需要CPU亲自参与搬运数据的过程,而且这个过程,CPU 是不能做其他事情的。
简单的搬运几个字符数据那没问题,但是如果我们用千兆网卡或者硬盘传输大量数据的时候,都用CPU来搬运的话,肯定忙不过来。
计算机科学家们发现了事情的严重性后,于是就发明了DMA技术,也就是直接内存访问(Direct Memory Access) 技术。
什么是DMA技术?简单理解就是,在进行I/O设备和内存的数据传输的时候,数据搬运的工作全部交给DMA控制器,而CPU不再参与任何与数据搬运相关的事情,这样CPU就可以去处理别的事务。
那使用DMA控制器进行数据传输的过程究竟是什么样的呢?下面我们来具体看看。
具体过程:
可以看到,整个数据传输的过程,CPU不再参与数据搬运的工作,而是全程由DMA完成,但是CPU在这个过程中也是必不可少的,因为传输什么数据,从哪里传输到哪里,都需要CPU来告诉DMA控制器。
早期DMA只存在在主板上,如今由于I/O设备越来越多,数据传输的需求也不尽相同,所以每个I/O设备里面都有自己的DMA控制器。
如果服务端要提供文件传输的功能,我们能想到的最简单的方式是:将磁盘上的文件读取出来,然后通过网络协议发送给客户端。
传统I/O的工作方式是,数据读取和写入是从用户空间到内核空间来回复制,而内核空间的数据是通过操作系统层面的I/O接口从磁盘读取或写入。
代码通常如下,一般会需要两个系统调用:
read(file, tmp. buf, 1en);
write(socket, tmp. buf, len);
代码很简单,虽然就两行代码,但是这里面发生了不少的事情。
首先,期间共发生了4次用户态与内核态的上下文切换,因为发生了两次系统调用,一次是read() ,一次是write(),每次系统调用都得先从用户态切换到内核态,等内核完成任务后,再从内核态切换回用户态。
上下文切换的成本并不小,一次切换需要耗时几十纳秒到几微秒,虽然时间看上去很短,但是在高并发的场景下,这类时间容易被累积和放大,从而影响系统的性能。
其次,还发生了4次数据拷贝,其中两次是DMA的拷贝,另外两次则是通过CPU拷贝的,下面说一 下这个过程:
我们回过头看这个文件传输的过程,我们只是搬运一份数据,结果却搬运了4次,过多的数据拷贝无疑会消耗CPU资源,大大降低了系统性能。
这种简单又传统的文件传输方式,存在冗余的上文切换和数据拷贝,在高并发系统里是非常糟糕的,多了很多不必要的开销,会严重影响系统性能。
所以,要想提高文件传输的性能,就需要减少用户态与内核态的上下文切换和内存拷贝的次数。
先来看看,如何减少用户态与内核态的上下文切换的次数呢?
读取磁盘数据的时候,之所以要发生上下文切换,这是因为用户空间没有权限操作磁盘或网卡,内核的权限最高,这些操作设备的过程都需要交由操作系统内核来完成,所以一般要通过内核去完成某些任务的时候,就需要使用操作系统提供的系统调用函数。
而一次系统调用必然会发生2次上下文切换:首先从用户态切换到内核态,当内核执行完任务后,再切换回用户态交由进程代码执行。
所以,要想减少上下文切换到次数,就要减少系统调用的次数。
再来看看,如何减少数据拷贝的次数?
在前面我们知道了,传统的文件传输方式会历经4次数据拷贝,而且这里面,从内核的读缓冲区拷贝到用户的缓冲区里, 再从用户的缓冲区里拷贝到socket的缓冲区里,这个过程是没有必要的。
因为文件传输的应用场景中,在用户空间我们并不会对数据「再加工」,所以数据实际上可以不用搬运到用户空间,因此用户的缓冲区是没有必要存在的。
零拷贝技术实现的方式通常有2种:
下面就谈一谈,它们是如何减少上下文切换和数据拷则的次数。
mmap + write
在前面我们知道,read() 系统调用的过程中会把内核缓冲区的数据拷贝到用户的缓冲区里,于是为了减少这一步开销, 我们可以用mmap()替换read()系统调用函数。
1 buf = mmap(fle, len);
2 write(sockfd, buf, len);
mmap()系统调用函数会直接把内核缓冲区里的数据映射到用户空间,这样,操作系统内核与用户空间就不需要再进行任何的数据拷贝操作。
具体过程如下:
我们可以得知,通过使用mmap()来代替read(),可以减少一次数据拷贝的过程。
但这还不是最理想的零拷贝,因为仍然需要通过CPU把内核缓冲区的数据拷贝到socket缓冲区里,而且仍然需要4次上下文切换,因为系统调用还是2次。
在Linux内核版本2.1中,提供了一个专门发送文件的系统调用函数sendfile(),函数形式如下:
1 #include
2 ssize_t sendfile(int out_fd, int in_fd, off_t *offset,size_t count);
它的前两个参数分别是目的端和源端的文件描述符,后面两个参数是源端的偏移量和复制数据的长度,返回值是实际复制数据的长度。
首先,它可以替代前面的read()和write()这两个系统调用,**这样就可以减少一次系统调用,也就减少了2次上下文切换的开销。
其次,该系统调用,可以直接把内核缓冲区里的数据拷贝到socket缓冲区里,不再拷贝到用户态,这样就只有2次上下文切换,和3次数据拷贝。如下图:
但是这还不是真正的零拷贝技术,如果网卡支持 SG-DMA (The Scatter- Gather Direct Memory Acess)技术(和普通的DMA有所不同),我们可以进一步减少通过 CPU 把内核缓冲区里的数据拷贝到 socket 缓冲区的过程。
你可以在你的Linux系统通过下面这个命令,查看网卡是否支持scatter-gather特性:
1 $ ethtool -k ethe | grep scatter-gather
2 scatter-gather: on
于是,从Linux 内核2.4版本开始起,对于支持网卡支持SG-DMA技术的情况下,sendfile( )系统调用的过程发生了点变化,具体过程如下:
所以,这个过程之中,只进行了2次数据拷贝,如下图:
这就是所谓的零拷贝(Zero-copy) 技术,因为我们没有在内存层面去拷贝数据,也就是说全程没有通过CPU来搬运数据,所有的数据都是通过DMA来进行传输的。
零拷贝技术的文件传输方式相比传统文件传输的方式,减少了2次上下文切换和数据拷贝次数,只需要2次上下文切换和2次数据拷贝次数,就可以完成文件的传输,而且2次的数据拷贝过程,都不需要通过CPU,2次都是由DMA来搬运。
所以,总体来看,零拷贝技术可以把文件传输的性能提高至少一倍以上。
回顾前面说道文件传输过程,其中第一步都是先需要把磁盘文件数据拷贝内核缓冲区里,这个内核缓冲区实际上是磁盘高速缓存(PageCache)。PageCache可以缓存最近刚被访问的数据。
由于零拷贝使用了PageCache技术,可以使得零拷贝进一步提升了性能,我们接下来看看PageCache是如何做到这一点的。
读写磁盘相比读写内存的速度慢太多了,所以我们应该想办法把读写磁盘替换成读写内存。于是, 我们会通过DMA把磁盘里的数据搬运到内存里,这样就可以用读内存替换读磁盘。
但是,内存空间远比磁盘要小,内存注定只能拷贝磁盘里的一小部分数据。
那问题来了,选择哪些磁盘数据拷贝到内存呢?
我们都知道程序运行的时候,具有局部性,所以通常,刚被访问的数据在短时间内再次被访问的概率很高,于是我们可以用PageCache来缓存最近被访问的数据,当空间不足时淘汰最久未被访问的缓存。
所以,读磁盘数据的时候,优先在PageCache找,如果数据存在则可以直接返回;如果没有,则从磁盘中读取,然后缓存PageCache中。
还有一点,读取磁盘数据的时候,需要找到数据所在的位置,但是对于机械磁盘来说,就是通过磁头旋转到数据所在的扇区,再开始顺序读取数据,但是旋转磁头这个物理动作是非常耗时的,为了降低它的影响,PageCache使用了预读功能。
比如,假设read方法每次只会读 32 KB的字节,虽然 read 刚开始只会读0 ~ 32 KB的字节,但内核会把其后面的 32 ~ 64 KB也读取到PageCache,这样后面读取 32 ~ 64 KB的成本就很低,如果在 32 ~ 64 KB淘汰出PageCache前,进程读取到它了,收益就非常大。
所以,PageCache的优点主要是两个:
这两个做法,将大大提高读写磁盘的性能。
但是,在传输大文件**(GB 级别的文件)的时候,PageCache 会不起作用**,那就白白浪费DMA多做的一次数据拷贝,造成性能的降低,即使使用了PageCache的零拷贝也会损失性能。
这是因为如果你有很多GB级别文件需要传输,每当用户访问这些大文件的时候,内核就会把它们载入PageCache中,于是PageCache空间很快被这些大文件占满。
另外,由于文件太大,可能某些部分的文件数据被再次访问的概率比较低,这样就会带来2个问题:
所以,针对大文件的传输,不应该使用PageCache,也就是说不应该使用零拷贝技术,因为可能由于PageCache被大文件占据,而导致热点小文件无法利用到PageCache,这样在高并发的环境下,会带来严重的性能问题。
那针对大文件的传输,我们应该使用什么方式呢?
我们先来看看最初的例子,当调用read 方法读取文件时,进程实际上会阻塞在read方法调用,因为要等待磁盘数据的返回,如下图:
具体过程:
对于阻塞的问题,可以用异步I/O来解决,它工作方式如下图:
它把读操作分为两部分:
而且,我们可以发现,异步I/O并没有涉及到PageCache,所以使用异步I/O就意味着要绕开PageCache。
绕开PageCache的I/O叫 直接I/O,使用PageCache的I/O则叫缓存I/O。通常,对于磁盘,异步I/O只支持直接I/O。
前面也提到,大文件的传输不应该使用PageCache,因为可能由于PageCache被大文件占据,而导致热点小文件无法利用到PageCache。
于是,在高井发的场景下,针对大文件的传输的方式,应该使用 异步I/O+直接I/O 来替代零拷贝技术。
直接I/O应用场最常见的两种:
另外,由于直接I/O绕过了PageCache,就无法享受内核的这两点的优化(简单来说就是缓存和预读):
于是,传输大文件的时候,使用异步I/O +直接I/O了,就可以无阻塞地读取文件了。
所以,传输文件的时候,我们要根据文件的大小来使用不同的方式:
在Nginx中,我们可以用如下配置,来根据文件的大小来使用不同的方式:
当文件大小大于directio值后,使用异步I/O +直接I/O,否则使用零拷贝技术。