操作系统:
CPU及其工作状态
特权指令:仅供OS内核程序使用的指令。(如:启动外设、清空内存、加载PSW、加载PC等敏感操作)
普通指令:除特权指令以外的指令。
管态:
可执行指令全集、访问全部内存和所有系统资源。
【系统态(kernel mode):
可以简单的理解系统态运行的进程或程序几乎可以访问计算机的任何资源,不受限制。】
目态:
只能执行规定的指令、访问指定寄存器和指定存储区域。
【用户态(user mode) : 用户态运行的进程或可以直接读取用户程序的数据。】
用户态,内核态:
从宏观上来看,Linux操作系统的体系架构分为用户态和内核态(或者用户空间和内核)。内核从本质上看是一种软件——控制计算机的硬件资源,并提供上层应用程序运行的环境。用户态即上层应用程序的活动空间,应用程序的执行必须依托于内核提供的资源,包括CPU资源、存储资源、I/O资源等。为了使上层应用能够访问到这些资源,内核必须为上层应用提供访问的接口:即系统调用。
当一个进程在执行用户自己的代码时处于用户运行态(用户态),此时特权级最低,为3级,是普通的用户进程运行的特权级,大部分用户直接面对的程序都是运行在用户态。Ring3状态不能访问Ring0的地址空间,包括代码和数据;当一个进程因为系统调用陷入内核代码中执行时处于内核运行态(内核态),此时特权级最高,为0级。执行的内核代码会使用当前进程的内核栈,每个进程都有自己的内核栈。
总结一下,用户态的应用程序可以通过三种方式来访问内核态的资源(切换到内核态):
1)系统调用
2)库函数
3)Shell脚本
系统调用是操作系统的最小功能单位,这些系统调用根据不同的应用场景可以进行扩展和裁剪,现在各种版本的Unix实现都提供了不同数量的系统调用,如Linux的不同版本提供了240-260个系统调用,FreeBSD大约提供了320个(reference:UNIX环境高级编程)。我们可以把系统调用看成是一种不能再化简的操作(类似于原子操作,但是不同概念),有人把它比作一个汉字的一个“笔画”,而一个“汉字”就代表一个上层应用,我觉得这个比喻非常贴切。因此,有时候如果要实现一个完整的汉字(给某个变量分配内存空间),就必须调用很多的系统调用。如果从实现者(程序员)的角度来看,这势必会加重程序员的负担,良好的程序设计方法是:重视上层的业务逻辑操作,而尽可能避免底层复杂的实现细节。库函数正是为了将程序员从复杂的细节中解脱出来而提出的一种有效方法。它实现对系统调用的封装,将简单的业务逻辑接口呈现给用户,方便用户调用,从这个角度上看,库函数就像是组成汉字的“偏旁”。这样的一种组成方式极大增强了程序设计的灵活性,对于简单的操作,我们可以直接调用系统调用来访问资源,如“人”,对于复杂操作,我们借助于库函数来实现,如“仁”。显然,这样的库函数依据不同的标准也可以有不同的实现版本,如ISO C 标准库,POSIX标准库等。
Shell是一个特殊的应用程序,俗称命令行,本质上是一个命令解释器,它下通系统调用,上通各种应用,通常充当着一种“胶水”的角色,来连接各个小功能程序,让不同程序能够以一个清晰的接口协同工作,从而增强各个程序的功能。同时,Shell是可编程的,它可以执行符合Shell语法的文本,这样的文本称为Shell脚本,通常短短的几行Shell脚本就可以实现一个非常大的功能,原因就是这些Shell语句通常都对系统调用做了一层封装。为了方便用户和系统交互,一般,一个Shell对应一个终端,终端是一个硬件设备,呈现给用户的是一个图形化窗口。我们可以通过这个窗口输入或者输出文本。这个文本直接传递给shell进行分析解释,然后执行。
系统调用
我们运行的程序基本都是运行在用户态,如果我们调用操作系统提供的系统态级别的子功能咋办呢?那就需要系统调用了!
也就是说在我们运行的用户程序中,凡是与系统态级别的资源有关的操作(如文件管理、进程控制、内存管理等),都必须通过系统调用方式向操作系统提出服务请求,并由操作系统代为完成。
这些系统调用按功能大致可分为如下几类:
设备管理。完成设备的请求或释放,以及设备启动等功能。
文件管理。完成文件的读、写、创建及删除等功能。
进程控制。完成进程的创建、撤销、阻塞及唤醒等功能。
进程通信。完成进程之间的消息传递或信号传递等功能。
内存管理。完成内存的分配、回收以及获取作业占用内存区大小及地址等功能。
用户程序中对操作系统的调用称为系统调用。使用户程序通过简单的调用来实现一些硬件相关,应用无关的工作,从而简化了用户程序。
独立程序:不需要操作系统帮助的程序;
非独立程序:需要。
处理过程:
(1)将处理机状态由用户态转为系统态;之后,由硬件和内核程序进行系统调用的一般性处理,即首先保护被中断进程的CPU环境,将处理机状态字PSW、程序计数器PC、系统调用号、用户找指针以及通用寄存器内容等压入堆栈;然后,将用户定义的参数传送到指定的地方保存起來。
(2)分析系统调用类型,转入相应的系统调用处理子程序。为使不同的系统调用能方便地转向相应的系统调用处理子程序,在系统中配置了一张系统调用入口表。表中的每个表目都对应一条系统调用,其中包含该系统调用自带参数的数目、系统调用处理子程序的入口地址等。内核可利用系统调用号去查找该表,即可找到相应处理子程序的入口地址而转去执行它。
(3)在系统调用处理子程序执行完后,恢复被中断的或设置新进程的CPU现场,然后返冋被中断进程或新进程,继续往下执行。
系统调用是动态连接的。
静态连接:程序在编译时,将被调用的程序嵌入到自身中。如:库函数调用。
动态连接:程序在执行过程中,执行到调用指令时,才连接到被调用的程序进行执行。如:动态连接库,系统调用等。
中断:
是指计算机在执行程序过程中,当遇到需要立即处理的事件时,暂停当前运行的程序,转去执行有关服务程序,处理完后自动返回原程序。
发生中断的原因:系统调用,程序异常,IO事件完成,时间片结束,等等。
可以归结为两大方面,一任务间切换的时候发生中断,二由用户态进入系统态时发生中断。
中断的执行过程:保存现场,将PSW等现场信息放入堆栈中,然后转去相应的屮断处理程序。中断结束返回时,恢复现场,从堆栈屮取出PSW等现场信息。继续执行原程序。
中断请求,中断响应,中断点(暂停当前任务并保存现场),中断处理例程,中断返回(恢复中断点的现场并继续原有任务)
操作系统最为重要的硬件基础是硬件的中断机构:
CPU
CPU是中央处理器的简称,它可以从内存和缓存中读取指令,放入指令寄存器,并能够发出控制指令来完成一条指令的执行。但是CPU并不能直接从硬盘中读取程序或数据。
内存
内存作为与CPU直接进行沟通的部件,所有的程序都是在内存中运行的。其作用是暂时存放CPU的运算数据,以及与硬盘交换的数据。也是相当于CPU与硬盘沟通的桥梁。只要计算机在运行,CPU就会把需要运算的数据调到内存中进行运算,运算完成后CPU再将结果传出来。
缓存
缓存是CPU的一部分,存在于CPU里。由于CPU的存取速度很快,而内存的速度很慢,为了不让CPU每次都在运行相对缓慢的内存中操作,缓存就作为一个中间者出现了。有些常用的数据或是地址,就直接存在缓存中,这样,下一次调用的时候就不需要再去内存中去找了。因此,CPU每次回先到自己的缓存中寻找想要的东西(一般80%的东西都可以找到),找不到的时候再去内存中获取。
最初的缓存生产成本很高,价格昂贵,所以为了存储更多的数据,又不希望成本过高,就出现了二级缓存的概念,他们采用的并不是一级缓存的SRAM(静态RAM),而是采用了性能比SRAM稍差一些,但是比内存更快的DRAM(动态RAM)
硬盘
我们都知道内存是掉电之后数据就消失的部件,所以,长期的数据存储更多的还是依靠硬盘这种本地磁盘作为存储工具。
简单的概括:
CPU主要用于计算,运行时首先会去自身的缓存中寻找数据,如果没有再去内存中找。
硬盘中的数据会先写入内存才能被CPU使用。
缓存会记录一些常用的数据等信息,以免每次都要到内存中,节省了时间,提高了效率。
内存+缓存 -> 内存储空间
硬盘 -> 外存储空间
读写速度:
CPU缓存速度>内存速度>硬盘速度
CPU三级缓存:
1、 一级缓存,是CPU的第一层高速缓存,主要分为数据缓存和指令缓存,这是对CPU性能影响最大的一层;
一级缓存基本上都是内置在cpu的内部和cpu一个速度进行运行,能有效的提升cpu的工作效率。一级缓存越多,cpu的工作效率就会越来越高,是cpu的内部结构限制了一级缓存的容量大小,使一级缓存的容量都是很小的。
2、 二级缓存主要作用是协调一级缓存和内存之间的工作效率。cpu首先用的是一级内存,当cpu的速度慢慢提升之后,一级缓存就不够cpu的使用量了,这就需要用到二级内存。
二级缓存,是CPU的第二层高速缓存,分内部和外部两种芯片,内部芯片速度基本上与CPU主频相同,而外部芯片只有主频的一半。
3、 三级缓存和一级缓存与二级缓存的关系差不多,是为了在读取二级缓存不够用的时候而设计的一种缓存手段,在有三级缓存cpu之中,只有大约百分之五的数据需要在内存中调取使用,这能提升cpu不少的效率,从而cpu能够高速的工作。
三级缓存,离CPU较远,读取速度没一级二级快,但一般三级缓存容量比前面两级大很多。
4、 二级缓存Intel的CPU是很重要,Intel的CPU的二级缓存越大性能提升非常明显,而AMD的CPU虽然二级缓存也很重要,但是二级缓存大小对AMD的CPU的性能提升不是很明显。
CPU,核,线程
单核cpu和多核cpu
• 都是一个cpu,不同的是每个cpu上的核心数。
• 一个CPU可以有单核,也可以多核。
• 多核cpu是多个单核cpu的替代方案,多核cpu减小了体积,同时也减少了功耗。
• 一个核心只能同时执行一个线程。
串行,并发与并行
串行
多个任务,执行时一个执行完再执行另一个。
比喻:吃完饭再看球赛。
并发
多个线程在单个核心运行,同一时间一个线程运行,系统不停切换线程,看起来像同时运行,实际上是线程不停切换。
比喻: 一会跑去食厅吃饭,一会跑去客厅看球赛。
并行
每个线程分配给独立的核心,线程同时运行。
比喻:一边吃饭一边看球赛。
进程的引入:在多道程序系统下,内存中可以装入多个程序(程序是一组有序指令的集合,并存放在某种介质上,是静态的),它们共享系统资源、并发执行。但这样一来程序就会失去了其封闭性,并具有间断性,以及其运行结果不可再现的特征,那这样的话程序的运行就失去了意义。
在批处理系统中:但是问题也来了,当程序在运算的时候,会一直占用着CPU资源,有可能某个时间在写磁盘数据、读取网络设备数据,一直霸占着CPU会造成资源的浪费,其实CPU空闲着也是浪费,这时候完全可以把CPU的计算资源让给其他程序,直到数据读写准备就绪后再切换回来。
因此,为了使程序能够并发进行,并且可以对并发执行的程序加以描述和控制,人们引入了“进程”的概念。
怎么控制和管理多个程序间的计算机资源呢?划分资源管理的最小单元啊:进程,也就是上面说的内存中加载指令的最小单位。有了进程,操作系统才好在内存中加载程序的执行指令,方便调度程序在有限资源情况下更有效的利用资源,提高资源利用率。
进程实体:由三部分构成:程序段、相关的数据段、PCB(进程控制块),所谓创建/撤销进程,实际上就是创建/撤销进程实体中的PCB。
进程是指在系统中正在运行的一个应用程序,每个进程都有自己独立的内存空间,比如用户点击桌面的IE浏览器,就启动了一个进程,操作系统就会为该进程分配独立的地址空间。资源分配的最小单位。
线程的引入: 在引入了进程的概念后,此后长达20年的时间里,在多道程序OS中一直把进程作为能拥有资源和独立调度运行的基本单位,但是后来渐渐发现这样并发执行后造成的时空开销非常大,因为系统要进行并发执行需要做一系列的操作:创建进程、撤销进程、进程切换。这样一来就限制了系统中所设置的进程的数目,于是人们开始追寻新的方法来改善这一点。即使划分了资源管理的最小单元,但是一个进程在运行的过程中,也不可能一直占据着CPU进行逻辑运算,运行过程中很可能在进行磁盘I/O或者网络I/O,资源还是有些浪费。为了更加充分利用CPU运算资源,提高资源利用率,有人设计了线程的概念。
为何非要设计出线程呢?将进程再划分小一点(争取在进程空闲时划分为小的进程),开多个进程也可以提升CPU的利用率,但是开多个进程的话,进程间通信又是个麻烦的事情,毕竟进程之间地址空间是独立的,没法像线程那样做到数据的共享,需要通过其他的手段来解决,比如管道等。
一般操作系统都会分为内核态和用户态,用户态线程之间的地址空间是隔离的,而在内核态,所有线程都共享同一内核地址空间。不管是用户线程还是内核线程,都和进程一样,均由操作系统的调度器来统一调度(至少在Linux中是这样子)。
因此,为了使多个程序能够更好地并发执行,又能减少程序在并发执行时所付出的时空开销,人们引入了“线程”的概念。
一个进程可以拥有多个线程–>这就是我们常说的多线程编程。
线程是由进程创建的(寄生在进程)。
线程是进程中的一个实体,是CPU调度和分派的基本单位,线程自己不拥有系统资源,只拥有一点在运行中必不可少的资源,但它可与同属一个进程的其它线程共享进程所拥有的全部资源。一个线程可以创建和撤消另一个线程,同一进程中的多个线程之间可以并发执行。
线程没有独立的地址空间(内存空间);
一个线程只能属于一个进程,而一个进程可以有多个线程,但至少有一个线程。
区别:
进程有自己的独立地址空间,线程没有
进程是资源分配的最小单位,线程是CPU调度和分配的最小单位
进程和线程通信方式不同(线程之间的通信比较方便。同一进程下的线程共享数据(比如全局变量,静态变量),通过这些数据来通信不仅快捷而且方便,当然如何处理好这些访问的同步与互斥正是编写多线程程序的难点。而进程之间的通信只能通过进程通信的方式进行。
进程上下文切换开销大,线程开销小
一个进程挂掉了不会影响其他进程,而线程挂掉了会影响其他线程
对进程进程操作一般开销都比较大,对线程开销就小了
协程的引入:
在多核场景下,如果是I/O密集型场景,就算开多个线程来处理,也未必能提升CPU的利用率,反而会增加线程切换的开销。另外,多线程之间假如存在临界区或者共享数据,那么同步的开销也是不可忽视的。协程恰恰就是用来解决该问题的。协程是轻量级线程,在一个用户线程上可以跑多个协程,这样可以提升单核的利用率。
为了提升用户线程的最大利用效率,又提出了协程的概念,可以充分提高单核的CPU利用率,降低调度的开销(协程因不受操作系统资源管理的自动调度,如果需要可以手工或写代码调度)。
在实际场景下,假如CPU有N个核,就只要开N+1个线程,然后在这些线程上面跑协程就行了。
协程不像进程或线程那样可以让系统负责相关的调度工作,协程处于一个线程当中,系统是无感知的,如果需要在该线程中阻塞某个协程的话,需要手工进行调度,如图所示。
要在用户线程上实现协程是一件很难受的事情,原理类似于调度器根据条件的改变不停地调用各个协程的callback机制,但前提是大家都在一个用户线程下。要注意,一旦有一个协程阻塞,其他协程也都不能运行了。
协程:
协程(Coroutines)是一种比线程更加轻量级的存在,正如一个进程可以拥有多个线程一样,一个线程可以拥有多个协程。
协程不是被操作系统内核所管理的,而是完全由程序所控制,也就是在用户态执行。这样带来的好处是性能大幅度的提升,因为不会像线程切换那样消耗资源。
协程不是进程也不是线程,而是一个特殊的函数,这个函数可以在某个地方挂起,并且可以重新在挂起处外继续运行。所以说,协程与进程、线程相比并不是一个维度的概念。
比如生产者消费者里面,让消费者等待,可以用协程。让协程暂停,和线程的阻塞是有本质区别的。协程的暂停完全由程序控制,线程的阻塞状态是由操作系统内核来进行切换。协程的开销远远小于线程的开销。
一个进程可以包含多个线程,一个线程也可以包含多个协程。简单来说,一个线程内可以由多个这样的特殊函数在运行,但是有一点必须明确的是,一个线程的多个协程的运行是串行的。如果是多核CPU,多个进程或一个进程内的多个线程是可以并行运行的,但是一个线程内协程却绝对是串行的,无论CPU有多少个核。毕竟协程虽然是一个特殊的函数,但仍然是一个函数。一个线程内可以运行多个函数,但这些函数都是串行运行的。当一个协程运行时,其它协程必须挂起。
进程、线程、协程的对比
• 协程既不是进程也不是线程,协程仅仅是一个特殊的函数,协程它进程和进程不是一个维度的。
• 一个进程可以包含多个线程,一个线程可以包含多个协程。
• 一个线程内的多个协程虽然可以切换,但是多个协程是串行执行的,只能在一个线程内运行,没法利用CPU多核能力。
• 协程与进程一样,切换是存在上下文切换问题的。
总结:
多进程的出现是为了提升CPU的利用率,特别是I/O密集型运算,不管是多核还是单核,开多个进程必然能有效提升CPU的利用率。但进程间依然有资源利用优化空间,以及进程间通信的麻烦问题。
多线程则可以共享同一进程地址空间上的资源,能在资源的空闲时刻更好的利用,且不存在进程间通信的麻烦。但线程的创建和销毁会造成资源的浪费。
为了降低线程创建和销毁的开销,又出现了线程池的概念,在一开始就创建批量的线程。虽然减少了部分创建和销毁线程所消耗的资源,但调度的开销依然存在。
为了提升用户线程的最大利用效率,又提出了协程的概念,可以充分提高单核的CPU利用率,降低调度的开销(协程因不受操作系统资源管理的自动调度,如果需要可以手工或写代码调度)。
线程的实现(3):
I. 内核级线程:
内核支持线程,是在内核的支持下运行的,即无论是用户进程中的线程,还是系统进程中的线程,他们的创建、撤消和切换等,是依靠内核实现的。
在内核空间中为每一个内核支持线程设置了一个线程控制块TCB, 内核是根据该控制块而感知某线程的存在的,并对其加以控制。所有线程管理由内核完成,虽没有线程库,但内核提供API。
优点:
在多处理器系统中,内核能够同时调度同一进程中多个线程并行执行;
如果进程中的一个线程被阻塞了,内核可以调度该进程中的其它线程占有处理器运行,也可以运行其它进程中的线程;
内核支持线程具有很小的数据结构和堆栈,线程的切换比较快,切换开销小;
内核本身也可以采用多线程技术,可以提高系统的执行速度和效率。
缺点:
对于线程切换而言,其模式切换的开销较大,在同一个进程中,从一个线程切换到另一个线程时,需要从用户态转到内核态进行,这是因为用户进程的线程在用户态运行,而线程调度和管理是在内核实现的,系统开销较大。
内核级线程一对一实现方式:
由内核来完成线程切换,内核通过操纵调度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上。每个内核线程可以视为内核的一个分身,这样操作系统就有能力同时处理多件事情,支持多线程的内核就叫做多线程内核。
程序一般不会直接去使用内核线程,而是去使用内核线程的一种高级接口——轻量级进程(我们通常意义上所讲的线程)
(schedule调度器,LWP轻量级线程,KLT内核级线程,P程序)
由于内核线程的支持,每个轻量级进程都成为一个独立的调度单元,即使有一个轻量级进程在系统调用中阻塞了,也不会影响整个进程继续工作,但是轻量级进程具有它的局限性:
首先,由于是基于内核线程实现的,所以各种线程操作,如创建、析构及同步,都需要进行系统调用。而系统调用的代价相对较高,需要在用户态(User Mode)和内核态(Kernel Mode)中来回切换。
其次,每个轻量级进程都需要有一个内核线程的支持,因此轻量级进程要消耗一定的内核资源(如内核线程的栈空间),因此一个系统支持轻量级进程的数量是有限的。
II. 用户级线程:
用户级线程仅存在于用户空间中。对于这种线程的创建、撤消、线程之间的同步与通信等功能,都无须内核来实现。
由应用程序完成所有线程的管理;
线程库(用户空间):通过一组管理线程的函数库来提供一个线程运行管理系统(运行系统);
线程切换不需要核心态特权,因而使线程的切换速度特别快;
用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知线程存在的实现。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。如果程序实现得当,这种线程不需要切换到内核态(在程序运行中没有这个必要),因此操作可以是非常快速且低消耗的,也可以支持规模更大的线程数量,部分高性能数据库中的多线程就是由用户线程实现的。这种进程与用户线程之间1:N的关系称为一对多的线程模型。
UT用户级线程
使用用户线程的优势在于不需要系统内核支援,劣势也在于没有系统内核的支援,所有的线程操作都需要用户程序自己处理。线程的创建、切换和调度都是需要考虑的问题,而且由于操作系统只把处理器资源分配到进程,那诸如“阻塞如何处理”、“多处理器系统中如何将线程映射到其他处理器上”这类问题解决起来将会异常困难,甚至不可能完成。
优点:
线程切换不调用内核;
调度是应用程序特定的:可以选择最好的算法;
可运行在任何操作系统上(只需要线程库),可以在一个不支持线程的OS上实现。
缺点:
大多数系统调用会引起进程阻塞,并且是进程中所有线程将被阻塞;
内核只将处理器分配给进程,同一进程中的两个线程不能同时运行于两个处理器上;
但是注意:对于设置了用户级线程的系统,其调度仍是以进程为单位进行。在采用时间片轮转调度算法时,各进程间是公平的,但各进程的线程间是不公平的。
用户级线程是在用户空间实现的。所有用户级线程都具有相同的数据结构,它们都运行在一个中间系统上。当前有两种方式实现的中间系统:
1运行时系统
是用于管理和控制线程的函数的集合,又称为线程库。包括创建、撤消线程函数、线程同步和通信函数、线程调度函数等。用户级线程不能直接利用系统调用,必须通过运行时系统间接利用系统调用。
2内核控制线程
这种线程又称为轻型进程LWP(Light Weight Process)。每个进程都可拥有多个LWP,每个LWP都有自己的TCB,其中包括线程标识符、优先级、状态、栈和局部存储区等。如下图所示:
III. 组合方式:
有些OS把内核支持线程和用户级线程两种方式进行组合,在这种组合方式线程系统中,内核支持多个内核支持线程的创建、调度和管理,同时,也允许用户应用程序创建、调度和管理用户级线程。
同一个进程内的多个线程可以同时在多处理器上并行执行,而且在阻塞一个线程时并不需要将整个进程阻塞。有三种不同的模型:多对一模型、一对一模型和多对多模型。
用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发。而操作系统提供支持的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过轻量级线程来完成,大大降低了整个进程被完全阻塞的风险。
Java线程的实现 与 调度模型
对于Sun JDK来说,它的Windows版与Linux版都是使用一对一的线程模型实现的,一条Java线程就映射到一条轻量级进程之中,因为Windows和Linux系统提供的线程模型就是一对一的。
Java线程调度
线程调度是指系统为线程分配处理器CPU使用权的过程,主要调度方式有两种,分别是协同式线程调度(Cooperative Threads-Scheduling)和抢占式线程调度(Preemptive Threads-Scheduling)。JAVA中是抢占式调度。
协同式调度
如果使用协同式调度的多线程系统,线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另外一个线程上。协同式多线程的最大好处是实现简单,而且由于线程要把自己的事情干完后才会进行线程切换,切换操作对线程自己是可知的,所以没有什么线程同步的问题。Lua语言中的“协同例程”就是这类实现。它的坏处也很明显:线程执行时间不可控制,甚至如果一个线程编写有问题,一直不告知系统进行线程切换,那么程序就会一直阻塞在那里。很久以前的Windows 3.x系统就是使用协同式来实现多进程多任务的,相当不稳定,一个进程坚持不让出CPU执行时间就可能会导致整个系统崩溃。
抢占式调度
如果使用抢占式调度的多线程系统,那么每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定(在Java中,Thread.yield()可以让出执行时间,但是要获取执行时间的话,线程本身是没有什么办法的)。在这种实现线程调度的方式下,线程的执行时间是系统可控的,也不会有一个线程导致整个进程阻塞的问题,Java使用的线程调度方式就是抢占式调度。在JDK后续版本中有可能会提供协程(Coroutines)方式来进行多任务处理。与前面所说的Windows 3.x的例子相对,在Windows 9x/NT内核中就是使用抢占式来实现多进程的,当一个进程出了问题,我们还可以使用任务管理器把这个进程“杀掉”,而不至于导致系统崩溃。
线程优先级
虽然Java线程调度是系统自动完成的,但是我们还是可以“建议”系统给某些线程多分配一点执行时间,另外的一些线程则可以少分配一点——这项操作可以通过设置线程优先级来完成。Java语言一共设置了10个级别的线程优先级(Thread.MIN_PRIORITY至Thread.MAX_PRIORITY),在两个线程同时处于Ready状态时,优先级越高的线程越容易被系统选择执行。不过,线程优先级并不是太靠谱,原因是Java的线程是通过映射到系统的原生线程上来实现的,所以线程调度最终还是取决于操作系统,虽然现在很多操作系统都提供线程优先级的概念,但是并不见得能与Java线程的优先级一一对应,如Solaris中有2147483648(232)种优先级,但Windows中就只有7种,比Java线程优先级多的系统还好说,中间留下一点空位就可以了,但比Java线程优先级少的系统,就不得不出现几个优先级相同的情况了,表12-1显示了Java线程优先级与Windows线程优先级之间的对应关系,Windows平台的JDK中使用了除THREAD_PRIORITY_IDLE之外的其余6种线程优先级。
Linux实现进程线程
我们知道系统调用fork()可以新建一个子进程,函数pthread()可以新建一个线程。
但无论线程还是进程,都是用task_struct结构表示的,唯一的区别就是共享的数据区域不同。
一般操作系统都会分为内核态和用户态,用户态线程之间的地址空间是隔离的,而在内核态,所有线程都共享同一内核地址空间。不管是用户线程还是内核线程,都和进程一样,均由操作系统的调度器来统一调度(至少在Linux中是这样子)。
fork()
在Linux中fork函数是非常重要的函数,它的作用是从已经存在的进程中创建一个子进程,而原进程称为父进程。
调用fork(),当控制转移到内核中的fork代码后,内核开始做:
1.分配新的内存块和内核数据结构给子进程。
2.将父进程部分数据结构内容拷贝至子进程。
3.将子进程添加到系统进程列表。
4.fork返回开始调度器,调度。
•调用一次,返回两次
fork函数被父进程调用一次,但是却返回两次;一次是返回到父进程,一次是返回到新创建的子进程。
•并发执行
子进程和父进程是并发运行的独立进程。内核能够以任意的方式交替执行他们的逻辑控制流中的指令。在我们的系统上运行这个程序时,父进程先运行它的printf语句,然后是子进程。
•相同但是独立的地址空间
因为父进程和子进程是独立的进程,他们都有自己私有的地址空间,当父进程或者子进程单独改变时,不会影响到彼此,类似于c++的写实拷贝的形式自建一个副本。
•fork的返回值
1.fork的子进程返回为0;
2.父进程返回的是子进程的pid。
•fork的常规用法
1.一个父进程希望复制自己,使得子进程同时执行不同的代码段,例如:父进程等待客户端请求,生成一个子进程来等待请求处理。
2.一个进程要执行一个不同的程序。
•fokr调用失败的原因
1.系统中有太多进程
2.实际用户的进程数超过限制
在当前bash下运行一个程序发生了什么?
首先什么是bash?
对于一个操作系统来说,shell相当于内核kernel外的一层外壳,作为用户接口。
一般来说,操作系统的接口分为两类:
CLI:command line interface命令行接口
常见的有:sh csh ksh zsh bash tcsh
GUI:graphical user interface 图形化用户接口
常见的有:Gnome KDE Xfce
bash可理解为一种shell的版本(linux默认版本)
bash及其特性:
1、bash实质上是一个可执行程序,一个用户的工作环境。
2、在每一个shell下可以再打开一个shell,新打开的shell可以称为子shell,每一个shell之间是相互独立的。
3、可以使用pstree命令查看当前shell下的子shell个数。
bash也是一个程序,是操作系统和用户交互的一个进程。
写在bash这个大程序里的小程序叫做内部程序
和bash 互相独立的程序叫做外部程序
所以bash也有自己的环境变量
环境变量又分为自定义变量和环境变量,自定义变量只是适用于当前进程,环境变量是子进程可以继承的环境变量。
当我们运行一个程序时,这个程序是在bash中运行的。其实是bash用fork函数创建了一个子进程,子进程复制了bash的映像,然后在子进程的映像中调用了exec系列的函数,将我们写的程序的可执行文件(映像)替换为子进程从父进程那里复制来的映像。然后执行我们写的程序的可执行文件。
fork之后bash下建立了一个子进程,开始这个子进程和bash是共用一个PCB的,当子进程发生改变时,子进程复制了父进程的PCB并调用了exec系列函数,将你要运行的程序的进程的PCB替换掉你从父进程那里复制来的PCB。然后执行你的程序。
如何保证服务进程只有一个实例在运行:
操作系统中前台进程与后台进程(适用于Linux)
后台进程也叫守护进程(Daemon),是运行在后台的一种特殊进程。它独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件。
一般用作系统服务,可以用crontab提交,编辑或者删除相应得作业。
守护的意思就是不受终端控制。Linux的大多数服务器就是用守护进程实现的。比如,Internet服务器inetd,Web服务器httpd等。同时,守护进程完成许多系统任务。比如,作业规划进程crond,打印进程lpd等。
前台进程就是用户使用的有控制终端的进程。
两种进程的主要区别:
1.前台进程用户可以操作,和用户交互,需要较高的响应速度,优先级别稍微高一点;
后台进程用户不能操作(除了把它关闭),基本上不和用户交互,优先级别稍微低一点。
2.前台进程不全是由计算机自动控制,后台进程全都是由计算机自动控制.
3.后台进程一般用作系统服务,可以用crontab提交(Linux下),编辑或者删除相应得作业.
主要特征:
1.前台进程可以以窗口,对话匡的形式在系统中显示.后台进程不行.
2.在任务栏中点亮的进程都可以称为前台进程.没点亮的为后台进程.
3.前台进程和后台进程有时候可以互相转换.(linux终端中执行的命令后加上&表示后台命令)
4.一般把较为费时间的命令转到后台执行.
进程的几种状态:
创建状态(new) :进程正在被创建,尚未到就绪状态。
就绪状态(ready) :进程已处于准备运行状态,即进程获得了除了处理器之外的一切所需资源,一旦得到处理器资源(处理器分配的时间片)即可运行。
运行状态(running) :进程正在处理器上上运行(单核 CPU 下任意时刻只有一个进程处于运行状态)。
阻塞状态(waiting) :又称为等待状态,进程正在等待某一事件而暂停运行如等待某资源为可用或等待 IO 操作完成。即使处理器空闲,该进程也不能运行。
结束状态(terminated) :进程正在从系统中消失。可能是进程正常结束或其他原因中断退出运行。
线程间的通信方式:
线程间的同步方式
1临界区
2互斥量
互斥与临界区很相似,但是使用时相对复杂一些(互斥量为内核对象),不仅可以在同一应用程序的线程间实现同步,还可以在不同的进程间实现同步,从而实现资源的安全共享。
3信号量
信号量的用法和互斥的用法很相似,不同的是它可以同一时刻允许多个线程访问同一个资源,PV操作
4事件
事件分为手动置位事件和自动置位事件。事件Event内部它包含一个使用计数(所有内核对象都有),一个布尔值表示是手动置位事件还是自动置位事件,另一个布尔值用来表示事件有无触发。由SetEvent()来触发,由ResetEvent()来设成未触发。
Java中的线程通信的方式有如下:
1.volatile关键字 实现共享变量
基于 volatile 关键字来实现线程间相互通信是使用共享内存的思想,大致意思就是多个线程同时监听一个变量,当这个变量发生变化的时候 ,线程能够感知并执行相应的业务。这也是最简单的一种实现方式
2.Object类的wait() notify()notifyAll()方法
Object类提供了线程间通信的方法:wait()、notify()、notifyaAl(),它们是多线程通信的基础,而这种实现方式的思想自然是线程间通信。
注意: wait和 notify必须配合synchronized使用,wait方法释放锁,notify方法不释放锁
3.CountDownLatch 并发组件 中的wait() 和down()方法
jdk1.5之后在java.util.concurrent包下提供了很多并发编程相关的工具类,简化了我们的并发编程代码的书写,CountDownLatch基于AQS框架,相当于也是维护了一个线程间共享变量state。
countDownLatch这个类使一个线程等待其他线程各自执行完毕后再执行。是通过一个计数器来实现的,计数器的初始值是线程的数量。只不过这个计数器的操作是原子操作,同时只能有一个线程去操作这个计数器,也就是同时只能有一个线程去减这个计数器里面的值。每当一个线程执行完毕后,计数器的值就-1,当计数器的值为0时,表示所有线程都执行完毕,然后在闭锁上等待的线程就可以恢复工作了。可以向CountDownLatch对象设置一个初始的数字作为计数值,任何调用这个对象上的await()方法都会阻塞,直到这个计数器的计数值被其他的线程减为0为止。
CountDownLatch的一个非常典型的应用场景是:有一个任务想要往下执行,但必须要等到其他的任务执行完毕后才可以继续往下执行。假如我们这个想要继续往下执行的任务调用一个CountDownLatch对象的await()方法,其他的任务执行完自己的任务后调用同一个CountDownLatch对象上的countDown()方法,这个调用await()方法的任务将一直阻塞等待,直到这个CountDownLatch对象的计数值减到0为止。
4.ReentrantLock和Condition 结合使用
condition.signal();唤醒指导的线程
condition.await();对指定的线程要求等待中
5.LockSupport 类中的park()和unpark()方法
LockSupport 是一种非常灵活的实现线程间阻塞和唤醒的工具,使用它不用关注是等待线程先进行还是唤醒线程先运行,但是得知道线程的名字。
进程间通信方式
1.管道( pipe )
通常指无名管道,是 UNIX 系统IPC最古老的形式。
无名管道只能用于具有亲缘关系的进程之间,这是它与有名管道的最大区别。有名管道叫named pipe或者FIFO(先进先出),可以用函数mkfifo()创建。
特点:
它是半双工的(即数据只能在一个方向上流动),具有固定的读端和写端。
它只能用于具有亲缘关系的进程之间的通信(也是父子进程或者兄弟进程之间)。
它可以看成是一种特殊的文件,对于它的读写也可以使用普通的read、write 等函数。但是它不是普通的文件,并不属于其他任何文件系统,并且只存在于内存中。
单个进程中的管道几乎没有任何用处。所以,通常调用 pipe 的进程接着调用 fork,这样就创建了父进程与子进程之间的 IPC 通道。
若要数据流从父进程流向子进程,则关闭父进程的读端(fd[0])与子进程的写端(fd[1]);反之,则可以使数据流从子进程流向父进程。
pipe()函数创建了管道,并返回了两个描述符:fd[0]用来从管道读数据,fd[1]用来向管道写数据。
管道的结构
在Linux 中,管道的实现并没有使用专门的数据结构,而是借助了文件系统的file结构和VFS的索引节点inode。
将两个file 结构指向同一个临时的VFS 索引节点,而这个VFS引节点又指向一个物理页面而实现的。
管道实际上就是一个file结构和一个VFS的索引。也就是说管道是一个虚拟的文件。那在Java中就可以通过直接读写这个文件,从而实现PIPE通讯的效果。
File pipe1 = new File(namedPipe1);
BufferedReader reader = new BufferedReader(new FileReader(pipe2));
2.有名管道 (named pipe)
FIFO,也称为命名管道,它是一种文件类型。
特点
4.消息队列( message queue ) :
消息队列是由消息的链表,存放在内核中并由消息队列标识符(即队列ID)标识。
消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。
特点
5.信号 ( signal ) :
信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。
6.[共享内存( shared memory )] :下面有介绍,最快的方式
共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。
共享内存并未提供同步机制,也就是说,在第一个进程结束对共享内存的写操作之前,并无自动机制可以阻止第二个进程开始对它进行读取,所以我们通常需要用其他的机制来同步对共享内存的访问,例如信号量。它往往与其他通信机制,如信号量,配合使用,来实现进程间的同步和通信。
共享内存,指两个或多个进程共享一个给定的存储区。
特点:
7.套接字( socket ) :
一种进程间通信机制,与其他通信机制不同的是,它可用于不同机器间的进程通信。
套接字是一种通信机制,凭借这种机制,客户/服务器(即要进行通信的进程)系统的开发工作既可以在本地单机上进行,也可以跨网络进行。也就是说它可以让不在同一台计算机但通过网络连接计算机上的进程进行通信。也因为这样,套接字明确地将客户端和服务器区分开来。
套接字通信的方式非常多,有Unix域套接字、TCP套接字、UDP套接字、链路层套接字等等。但最常用的肯定是TCP套接字。
Unix域套接字
Unix域套接字是套接字的一种,用于本机进程间通信,一般用来实现双向通信的管道。Unix域套接字是比网络套接字轻量级且高效的多,因为它不涉及网络通信,不需要监听连接,不需要绑定地址,不需要关心协议类型,等等。
创建Unix域套接字后返回两个文件描述符,这两个文件描述符均对套接字可读、可写,从而实现全双工的双向通信。
同样的,为了避免使用单个文件描述符同时读、写造成的数据错乱,Unix域套接字也有两个buffer空间。
共享内存
为什么实现共享内存?
采用共享内存通信的一个显而易见的好处是效率高,因为进程可以直接读写内存,而不需要任何数据的拷贝。
是最快的一种进程间通信的方式。
对于像管道和消息队列等通信方式,则需要在内核和用户空间进行四次的数据拷贝,
而共享内存则只拷贝两次数据:一次从输入文件到共享内存区,另一次从共享内存区到输出文件。实际上,进程之间在共享内存时,并不总是读写少量数据后就解除映射,有新的通信时,再重新建立共享内存区域。而是保持共享区域,直到通信完毕为止,这样,数据内容一直保存在共享内存中,并没有写回文件。共享内存中的内容往往是在解除映射时才写回文件的。
内存共享:
两个不同进程A、B共享内存的意思是,同一块物理内存被映射到进程A、B各自的进程地址空间。进程A可以即时看到进程B对共享内存中数据的更新,反之亦然。由于多个进程共享同一块内存区域,必然需要某种同步机制,互斥锁和信号量都可以。
共享内存实现机制
共享内存是通过把同一块内存分别映射到不同的进程空间中实现进程间通信。而共享内存本身不带任何互斥与同步机制,但当多个进程同时对同一内存进行读写操作时会破坏该内存的内容,所以,在实际中,同步与互斥机制需要用户来完成。
共享内存的特点:
(1)共享内存就是允许两个不想关的进程访问同一个内存
(2)共享内存是两个正在运行的进程之间共享和传递数据的最有效的方式
(3)不同进程之间共享的内存通常安排为同一段物理内存
(4)共享内存不提供任何互斥和同步机制,一般用信号量对临界资源进行保护。
(5)接口简单
linux实现共享内存同步的四种方法:
为了在多个进程间交换信息,内核专门留出了一块内存区,可以由需要访问的进程将其映射到自己的私有地址空间。进程就可以直接读写这一内存区而不需要进行数据的拷贝,从而大大提高的效率。
同步(synchronization)指的是多个任务(线程)按照约定的顺序相互配合完成一件事情。由于多个进程共享一段内存,因此也需要依靠某种同步机制,如互斥锁和信号量等 。
信号灯(semaphore),也叫信号量。它是不同进程间或一个给定进程内部不同线程间同步的机制。信号灯包括posix有名信号灯、 posix基于内存的信号灯(无名信号灯)和System V信号灯(IPC对象)
方法一、利用POSIX有名信号灯实现共享内存的同步
有名信号量既可用于线程间的同步,又可用于进程间的同步。
两个进程,对同一个共享内存读写,可利用有名信号量来进行同步。
一个进程写,另一个进程读,利用两个有名信号量semr, semw。semr信号量控制能否读,初始化为0。 semw信号量控制能否写,初始为1。
方法二、利用POSIX无名信号灯实现共享内存的同步
POSIX无名信号量是基于内存的信号量,可以用于线程间同步也可以用于进程间同步。若实现进程间同步,需要在共享内存中来创建无名信号量。
方法三、利用System V的信号灯实现共享内存的同步
System V的信号灯是一个或者多个信号灯的一个集合。其中的每一个都是单独的计数信号灯。而Posix信号灯指的是单个计数信号灯
System V 信号灯由内核维护,主要函数semget,semop,semctl 。
一个进程写,另一个进程读,信号灯集中有两个信号灯,下标0代表能否读,初始化为0。 下标1代表能否写,初始为1。
方法四、利用信号实现共享内存的同步
信号是在软件层次上对中断机制的一种模拟,是一种异步通信方式。利用信号也可以实现共享内存的同步。
思路:
reader和writer通过信号通信必须获取对方的进程号,可利用共享内存保存双方的进程号。
reader和writer运行的顺序不确定,可约定先运行的进程创建共享内存并初始化。
利用pause, kill, signal等函数可以实现该程序(流程和前边类似)。
进程调度
处理机调度的层次:
I. 在多道程序系统中,一个作业从提交到执行要经历多级调度:
作业(JOB):是用户在一次算题过程中或一次事务处理中,要求计算机系统所做的工作的集合。作业是比进程更广泛的概念,不仅包含了通常的程序和数据,而且还配有一份作业说明书,系统根据作业说明书对程序运行进行控制。在批处理系统中,以作业为单位从外存调入内存。作业提交给系统进入后备状态后,系统将为每个作业建立一个作业控制块JCB。JCB在作业的整个运行过程中始终存在,并且其内容与作业的状态同步地动态变化。只有当作业完成并退出系统时,JCB才被撤消。可以说,JCB是一个作业在系统中存在的唯一标志,系统根据JCB才感知到作业的存在。
高级调度:作业调度。调度对象是作业,将外存上处于后备队列的作业调入内存,将它们放入就绪队列中。
低级调度:进程调度。调度对象是进程,让某些就绪队列中的进程获得处理机。
中级调度:内存调度。对换功能,将暂时不能运行的进程,调至外存等待,此时进程的状态为就绪驻外存状态(或挂起状态)。当它们已具备运行条件且内存有空间时,就把它从外存重新调入内存,并修改其状态为就绪状态,挂在就绪队列上等待。
引起进程调度的因素可归结为:
正在执行的进程执行完毕,或因发生某事件而不能再继续执行(包括:当前执行进程被中断、时间片用完了、挂起自己、退出等);
执行中的进程因提出I/O请求而暂停执行;
在进程通信或同步过程中执行了某种原语操作,如P、V操作原语,Block原语, Wakeup原语等。
进程调度算法
1) 先来先服务调度算法:
按照作业/进程进入系统的先后次序进行调度,先进入系统者先调度;即启动等待时间最长的作业/进程。
2) 时间片轮转调度法
系统将所有就绪进程按先来先服务原则,排成一个队列,每次调度时,把CPU分配给队首进程,并令其执行一个时间片。当时间片用完时,由一个计时器发出时钟中断请求,调度程序便根据此信号来停止该进程的执行,并将它送往就绪队列的末尾;然后,再把处理机分配给就绪队列中新的队首进程,同时也让它执行一个时间片。保证就绪队列中的所有进程,在一给定的时间内,均能获得一个时间片的处理机执行时间,换言之,系统能在给定的时间内,响应所有用户的请求。
3) 短作业(SJF)优先调度算法
以要求运行时间长短进行调度,即启动要求运行时间最短的作业。可以分别用于作业调度和进程调度。
短作业优先(SJF)的调度算法:是从后备队列中选择一个或若干个估计运行时间最短的作业,将它们调入内存运行;
短进程优先(SPF)调度算法:是从就绪队列中选出一个估计运行时间最短的进程,将处理机分配给它,使它立即执行并一直执行到完成,或发生某事件而被阻塞放弃处理机时,再重新调度。
4)最短剩余时间优先(抢占)
5)高响应比优先调度算法:
R=(w+s)/s (R为响应比,w为等待处理的时间,s为预计的服务时间)
HRRN是一种动态优先权机制,即:随进程的推进或随其等待时间的增加而改变,以获得更好的调度性能。
6)优先级调度算法
7)多级反馈队列调度算法的实现思想如下:
应设置多个就绪队列,并为各个队列赋予不同的优先级,第1级队列的优先级最高,第2级队列次之,其余队列的优先级逐次降低。
赋予各个队列中进程执行时间片的大小也各不相同,在优先级越高的队列中,每个进程的运行时间片就越小。例如,第2级队列的时间片要比第1级队列的时间片长一倍, ……第i+1级队列的时间片要比第i级队列的时间片长一倍。
当一个新进程进入内存后,首先将它放入第1级队列的末尾,按FCFS原则排队等待调度。当轮到该进程执行时,如它能在该时间片内完成,便可准备撤离系统;如果它在一个时间片结束时尚未完成,调度程序便将该进程转入第2级队列的末尾,再同样地按FCFS 原则等待调度执行;如果它在第2级队列中运行一个时间片后仍未完成,再以同样的方法放入第3级队列……如此下去,当一个长进程从第1级队列依次降到第 n 级队列后,在第 n 级队列中便釆用时间片轮转的方式运行。
仅当第1级队列为空时,调度程序才调度第2级队列中的进程运行;仅当第1 ~ (i-1)级队列均为空时,才会调度第i级队列中的进程运行。如果处理机正在执行第i级队列中的某进程时,又有新进程进入优先级较高的队列(第 1 ~ (i-1)中的任何一个队列),则此时新进程将抢占正在运行进程的处理机,即由调度程序把正在运行的进程放回到第i级队列的末尾,把处理机分配给新到的更高优先级的进程。
多级反馈队列的优势有:
终端型作业用户:短作业优先。
短批处理作业用户:周转时间较短。
长批处理作业用户:经过前面几个队列得到部分执行,不会长期得不到处理。
孤儿进程
一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被init进程(进程号为1)所收养,并由init进程对它们完成状态收集工作。
僵尸状态
是一个比较特殊的状态,当进程退出父进程(使用wait()系统调用)没有读取到子进程退出的返回代码时就会产生僵尸进程。僵尸进程会在以终止状态保持在进程表中,并且会一直等待父进程读取退出状态代码。
僵尸进程与孤儿进程的区别:
孤儿进程是子进程还在运行,而父进程挂了,子进程被init进程收养。
僵尸进程是父进程还在运行但是子进程挂了,但是父进程却没有使用wait来清理子进程的进程信息,导致子进程虽然运行实体已经消失,但是仍然在内核的进程表中占据一条记录,这样长期下去对于系统资源是一个浪费。僵尸进程将会导致资源浪费,而孤儿则不会。
僵尸进程
子进程结束了,父进程没有对其进行回收,这时它就是僵尸进程。
如果子进程先结束而父进程后结束,即子进程结束后,父进程还在继续运行但是并未调用wait/waitpid那子进程就会成为僵尸进程。
但如果子进程后结束,即父进程先结束了,但没有调用wait/waitpid来等待子进程的结束,此时子进程还在运行,父进程已经结束。那么并不会产生僵尸进程。因为每个进程结束时,系统都会扫描当前系统中运行的所有进程,看看有没有哪个进程时刚刚结束的这个进程的子进程,如果有,就有init来接管它,成为它的父进程。
同样的在产生僵尸进程的那种情况下,即子进程结束了但父进程还在继续运行(并未调用wait/waitpid)这段期间,假如父进程异常终止了,那么该子进程就会自动被init接管。那么它就不再是僵尸进程了。应为intit会发现并释放它所占有的资源。
这样就导致了一个问题,如果没有调用wait/waitpid的话,那么保留的信息就不会释放。比如进程号就会被一直占用了。但系统所能使用的进程号的有限的,如果产生大量的僵尸进程,将导致系统没有可用的进程号而导致系统不能创建进程。所以我们应该避免僵尸进程。
僵尸进程的避免:
⒈如果父进程并不是很繁忙我们就可以通过直接调用wait/waitpid来等待子进程的结束。当然这会导致父进程被挂起。比如:子进程先结束,父进程调用wait等待子进程,父进程循环结束后并不会结束,而是被挂起等待子进程的结束。
⒉ 如果父进程很忙,那么可以用signal函数为SIGCHLD安装handler,因为子进程结束后, 父进程会收到该信号,可以在handler中调用wait回收。
使用信号函数sigaction为SIGCHLD设置wait处理函数。这样子进程结束后,父进程就会收到子进程结束的信号并调用wait回收子进程的资源
⒊ 如果父进程不关心子进程什么时候结束,那么可以用signal(SIGCHLD,SIG_IGN) 通知内核,自己对子进程的结束不感兴趣,那么子进程结束后,内核会回收, 并不再给父进程发送信号。
⒋ 还有一些技巧,就是fork两次,父进程fork一个子进程,然后继续工作,子进程fork一 个孙进程后退出,那么孙进程被init接管,孙进程结束后,init会回收。不过子进程的回收 还要自己做。
原理是将子进程成为孤儿进程,从而其的父进程变为init进程,通过init进程可以处理僵尸进程
如果一组进程中的每一个进程都在等待仅由该组进程中的其他进程才能引发的事件,那么该组进程就是死锁的。或者在两个或多个并发进程中,如果每个进程持有某种资源而又都等待别的进程释放它或它们现在保持着的资源,在未改变这种状态之前都不能向前推进,称这一组进程产生了死锁。通俗地讲,就是两个或多个进程被无限期地阻塞、相互等待的一种状态。
产生死锁的原因
竟争资源
进程间推进顺序不当
产生死锁的必要条件(4)
如何避免死锁
由于在避免死锁的策略中,允许进程动态地申请资源。因而,系统在进行资源分配之前预先计算资源分配的安全性。若此次分配不会导致系统进入不安全的状态,则将资源分配给进程;否则,进程等待。其中最具有代表性的避免死锁算法是银行家算法。
银行家算法:首先需要定义状态和安全状态的概念。系统的状态是当前给进程分配的资源情况。因此,状态包含两个向量Resource(系统中每种资源的总量)和Available(未分配给进程的每种资源的总量)及两个矩阵Claim(表示进程对资源的需求)和Allocation(表示当前分配给进程的资源)。安全状态是指至少有一个资源分配序列不会导致死锁。当进程请求一组资源时,假设同意该请求,从而改变了系统的状态,然后确定其结果是否还处于安全状态。如果是,同意这个请求;如果不是,阻塞该进程知道同意该请求后系统状态仍然是安全的。
临界资源
对于某些资源来说,其在同一时间只能被一个进程所占用。这些一次只能被一个进程所占用的资源就是所谓的临界资源。对于临界资源的访问,必须是互斥进行。
临界区
进程内访问临界资源的代码被成为临界区。
PV
P是通过(-1),V是释放(+1)
PV操作是一种实现进程互斥与同步的有效方法。
管程
管程在功能上和信号量及PV操作类似,属于一种进程同步互斥工具,但是具有与信号量及PV操作不同的属性。
管程,指的是管理共享变量以及对其操作过程,让它们支持并发访问。
它是将共享的变量和对于这些共享变量的操作封装起来,形成一个具有一定接口的功能模块,进程可以调用管程来实现进程级别的并发控制。
进程只能互斥得使用管程,即当一个进程使用管程时,另一个进程必须等待。当一个进程使用完管程后,它必须释放管程并唤醒等待管程的某一个进程。
在管程入口处的等待队列称为入口等待队列,由于进程会执行唤醒操作,因此可能有多个等待使用管程的队列,这样的队列称为紧急队列,它的优先级高于等待队列。
线程安全
如果你的代码所在的进程中有多个线程在同时运行,而这些线程可能会同时运行这段代码。如果每次运行结果和单线程运行的结果是一样的,而且其他的变量的值也和预期的是一样的,就是线程安全的。或者说:一个类或者程序所提供的接口对于线程来说是原子操作或者多个线程之间的切换不会导致该接口的执行结果存在二义性,也就是说我们不用考虑同步的问题。
线程安全问题都是由全局变量及静态变量引起的。
若每个线程中对全局变量、静态变量只有读操作,而无写操作,一般来说,这个全局变量是线程安全的;若有多个线程同时执行写操作,一般都需要考虑线程同步,否则的话就可能影响线程安全。
内存分配:
为了能将用户程序装入内存,必须为它分配一定大小的内存空间。
内存管理有哪几种方式?
存储器分配方式有
连续分配存储管理方式 和 离散分配存储管理方式 两种。
连续分配
是指为一个用户程序分配连续的内存空间。
连续分配有单一连续存储管理和分区式储管理两种方式。
分区式存储管理:
为了支持多道程序系统和分时系统,支持多个程序并发执行,引入了分区式存储管理。分区式存储管理是把内存分为一些大小相等或不等的分区,操作系统占用其中一个分区,其余的分区由应用程序使用,每个应用程序占用一个或几个分区。分区式存储管理虽然可以支持并发,但难以进行内存分区的共享。
分区式存储管理引人了两个新的问题:内碎片和外碎片。
内部碎片是已经被分配出去的的内存空间大于请求所需的内存空间。
外部碎片是指还没有分配出去,但是由于大小太小而无法分配给申请空间的新进程的内存空间空闲块。
离散分配:
在前面的几种存储管理方法中,为进程分配的空间是连续的,使用的地址都是物理地址。如果允许将一个进程分散到许多不连续的空间,就可以避免内存紧缩,减少碎片。基于这一思想,通过引入进程的逻辑地址,把进程地址空间与实际存储空间分离,增加存储管理的灵活性。
为了解决碎片问题,又要进行紧凑等高开销的活动,人们引入了离散分配方式,即:程序在内存中不一定连续存放。这是一种基于离散分配方式的分页和分段机制的虚拟内存机制。
根据离散分配的基本单位不同,分为:分页存储管理、分段存储管理、段页式存储管理。
地址空间:将源程序经过编译后得到的目标程序,存在于它所限定的地址范围内,这个范围称为地址空间。地址空间是逻辑地址的集合。
存储空间:指主存中一系列存储信息的物理单元的集合,这些单元的编号称为物理地址存储空间是物理地址的集合。
目的:为了利用和管理好计算机的资源–内存
根据分配时所采用的基本单位不同,可将离散分配的管理方式分为以下三种:
页式存储管理、段式存储管理和段页式存储管理。其中段页式存储管理是前两种结合的产物。
分页存储管理:
分段就是将一个程序分成代码段,数据段,堆栈段什么的;
分页就是将这些段,例如代码段分成均匀的小块(页),然后这些给这些小块编号,然后就可以放到内存中去,由于编号了的,所以也不怕顺序乱。
然后我们就能通过段号,页号,页内偏移找到程序的地址。
分页系统能有效地提高内存的利用率,而分段系统能反映程序的逻辑结构,便于段的共享与保护,将分页与分段两种存储方式结合起来,就形成了段页式存储管理方式。
将程序的逻辑地址空间划分为固定大小的页(page),而物理内存划分为同样大小的页框(page frame)。程序加载时,可将任意一页放人内存中任意一个页框,这些页框不必连续,从而实现了离散分配。
在分页存储管理方式中,任一个逻辑地址都可转变为:页号+页内偏移量。
分页系统中处理机每次存取指令或数据至少需要访问两次物理内存:
第一次访问页表,以得到物理地址
第二次访问物理地址,以得到数据。
逻辑地址=逻辑页号x页的大小+页偏移量
物理地址=物理块号x页的大小+页偏移量
存取速度几乎降低了一倍,代价太高!(引入快表)
逻辑(虚拟)地址和物理地址
我们编程一般只有可能和逻辑地址打交道,比如在 C 语言中,指针里面存储的数值就可以理解成为内存里的一个地址,这个地址也就是我们说的逻辑地址,逻辑地址由操作系统决定。物理地址指的是真实物理内存中地址,更具体一点来说就是内存地址寄存器中的地址。物理地址是内存单元真正的地址。
快表和多级页表
在分页内存管理中,很重要的两点是:
虚拟地址到物理地址的转换要快。
解决虚拟地址空间大,页表也会很大的问题。
快表:
为了解决虚拟地址到物理地址的转换速度,操作系统在 页表方案 基础之上引入了 快表 来加速虚拟地址到物理地址的转换。
快表的工作原理类似于系统中的 数据高速缓存,其中专门保存当前进程 最近访问过的一组页表项。进程最近访问过的页面在不久的将来还可能被访问。
我们可以把块表理解为一种特殊的高速缓冲存储器(Cache),其中的内容是页表的一部分或者全部内容。作为页表的 Cache,它的作用与页表相似,但是提高了访问速率。由于采用页表做地址转换,读写内存数据时 CPU 要访问两次主存。有了快表,有时只要访问一次高速缓冲存储器,一次主存,这样可加速查找并提高指令执行速度。
使用快表之后的地址转换流程是这样的:
看完了之后你会发现快表和我们平时经常在我们开发的系统使用的缓存(比如 Redis)很像,的确是这样的,操作系统中的很多思想、很多经典的算法,你都可以在我们日常开发使用的各种工具或者框架中找到它们的影子。
多级页表:
引入多级页表的主要目的是为了避免把全部页表一直放在内存中占用过多空间,特别是那些根本就不需要的页表就不需要保留在内存中。
总结
为了提高内存的空间性能,提出了多级页表的概念;但是提到空间性能是以浪费时间性能为基础的,因此为了补充损失的时间性能,提出了快表(即 TLB)的概念。 不论是快表还是多级页表实际上都利用到了程序的局部性原理,局部性原理在后面的虚拟内存这部分会介绍到。
页面置换算法(缺页调度算法):
I. OPT - 最佳置换算法
算法思想:从主存中移出永远不再需要的页面;如无这样的页面存在,则应选择最长时间不需要访问的页面。
最佳置换策略本身不是一种实际的方法,因为页面访问的未来顺序是不知道的,但是,可将其它的实用方法与之比较来评价这些方法的优劣。所以,这种最佳策略具有理论上的意义。
II. FIFO - 先进先出页面置换算法
算法思想:总是选择作业中驻留时间最长(即最老)的一页淘汰。即:先进入主存的页面先退出主存 。
III. LRU - 最近最久未使用置换算法
算法思想:利用局部性原理,根据一个作业在执行过程中过去的页面访问踪迹来推测未来的行为。它认为过去一段时间里不曾被访问过的页面,在最近的将来可能也不会再被访问。即:当需要置换一页面时,选择在最近一段时间内最久不用的页面予以淘汰。
IV. LFU - 最近最少使用置换算法
算法思想:选择到当前时间为止被访问次数最少的页面被置换。
LRU实现:
一个哈希表和一个双向链表
LRU 缓存机制可以通过哈希表辅以双向链表实现,我们用一个哈希表和一个双向链表维护所有在缓存中的键值对。
双向链表按照被使用的顺序存储了这些键值对,靠近头部的键值对是最近使用的,而靠近尾部的键值对是最久未使用的。
哈希表即为普通的哈希映射(HashMap),通过缓存数据的键映射到其在双向链表中的位置。
这样以来,我们首先使用哈希表进行定位,找出缓存项在双向链表中的位置,随后将其移动到双向链表的头部,即可在 O(1) 的时间内完成 get 或者 put 操作。具体的方法如下:
对于 get 操作,首先判断 key 是否存在:
如果 key 不存在,则返回 -1;
如果 key 存在,则 key 对应的节点是最近被使用的节点。通过哈希表定位到该节点在双向链表中的位置,并将其移动到双向链表的头部,最后返回该节点的值。
对于 put 操作,首先判断 key 是否存在:
如果 key 不存在,使用 key 和 value 创建一个新的节点,在双向链表的头部添加该节点,并将 key 和该节点添加进哈希表中。然后判断双向链表的节点数是否超出容量,如果超出容量,则删除双向链表的尾部节点,并删除哈希表中对应的项;
如果 key 存在,则与 get 操作类似,先通过哈希表定位,再将对应的节点的值更新为 value,并将该节点移到双向链表的头部。
上述各项操作中,访问哈希表的时间复杂度为 O(1),在双向链表的头部添加节点、在双向链表的尾部删除节点的复杂度也为 O(1)。而将一个节点移到双向链表的头部,可以分成「删除该节点」和「在双向链表的头部添加节点」两步操作,都可以在 O(1) 时间内完成。
缺页中断
指的是当软件试图访问已映射在虚拟地址空间中,但是并未被加载在物理内存中的一个分页时,由中央处理器的内存管理单元所发出的中断。
请求分页的系统当中,可以查询页表当前的状态位来查询当前页是否在内存当中,如果不在内存当中可以通过页表当中的外存地址将缺的一页读到内存当中。
步骤:
保护cpu现场
分析中断原因
转入缺页中断处理函数
恢复cpu现场,继续执行
缺页中断与一般中断的区别;
1、一般中断只需要保护现场然后就直接跳到需及时处理的地方。
2、缺页中断除了保护现场之外,还要判断内存中是否有足够的空间存储所需的页或段,然后再把所需页调进来再使用。
系统抖动
含义:在请求分页存储管理中,从主存(DRAM)中刚刚换出(Swap Out)某一页面后(换出到Disk),根据请求马上又换入(Swap In)该页,这种反复换出换入的现象,称为系统颠簸,也叫系统抖动。产生该现象的主要原因是置换算法选择不当。
如果系统花费大量的时间把程序和数据 频繁地换入和换出 内存而不是执行用户指令,那么,称系统出现了抖动。
出现抖动现象时,系统显得非常繁忙,但是吞吐量很低,甚至产出为零。
根本原因:选择的页面或段不恰当。
1.如果分配给进程的存储块数量小于进程所需要的最小值,进程的运行将很频繁地产生缺页中断,这种频率非常高的页面置换现象称为抖动。解决方案优化置换算法。
2.在请求分页存储管理中,可能出现这种情况,即对刚被替换出去的页,立即又要被访问。需要将它调入,因无空闲内存又要替换另一页,而后者又是即将被访问的页,于是造成了系统需花费大量的时间忙于进行这种频繁的页面交换,致使系统的实际效率很低,严重导致系统瘫痪,这种现象称为抖动现象。
分段存储管理:
在段式存储管理中,将程序的地址空间划分为若干个段(segment),这样每个进程有一个二维的地址空间。在前面所介绍的动态分区分配方式中,系统为整个进程分配一个连续的内存空间。而在段式存储管理系统中,则为每个段分配一个连续的分区,而进程中的各个段可以不连续地存放在内存的不同分区中。程序加载时,操作系统为所有段分配其所需内存,这些段不必连续,物理内存的管理采用动态分区的管理方法。
在分段系统中,任意一个逻辑地址则由所在段的段名和段内地址组成。
理解:
分段就是将一个程序分成代码段,数据段,堆栈段什么的;
分页就是将这些段,例如代码段分成均匀的小块(页),然后这些给这些小块编号,然后就可以放到内存中去,由于编号了的,所以也不怕顺序乱。
然后我们就能通过段号,页号,页内偏移找到程序的地址。
分页系统能有效地提高内存的利用率,而分段系统能反映程序的逻辑结构,便于段的共享与保护,将分页与分段两种存储方式结合起来,就形成了段页式存储管理方式。
分页分段的区别:
可见与不可见
分页是系统活动,用户无法介入,页的大小固定;
分段是用户可见的,段大小可变。
物理单位与逻辑单位
页是信息的物理单位,不是完整的逻辑单位;
段是完整的逻辑信息单位。
地址空间
分页的用户程序地址空间是一维的,是单一线性空间;
分段的用户程序地址空间是二维的。
分页 ––– 是为了提高内存利用率,是系统管理的需要。
分段 ––– 是为了更好地满足用户需要。
分页 ––– 用户不关心(页的长度由机器地址结构决定)
分段 ––– 用户或编辑程序确定(段的最大长度由位移量字段的位数决定)
分页——实现程序段的共享较为困难。
分段——易于实现段的共享和段的保护。
共同点 :
分页机制和分段机制都是为了提高内存利用率,较少内存碎片。
页和段都是离散存储的,所以两者都是离散分配内存的方式。但是,每个页和段中的内存是连续的。
区别 :
页的大小是固定的,由操作系统决定;而段的大小不固定,取决于我们当前运行的程序。
分页仅仅是为了满足操作系统内存管理的需求,而段是逻辑信息的单位,在程序中可以体现为代码段,数据段,能够更好满足用户的需要。
段页式存储管理:
分页管理内存管理效率高,没有外部碎片,内部碎片小;
分段管理符合模块化思想,每个分段都具备完整的功能,方便代码共享、保护,没有内部碎片,存在外部碎片。
段页式存储管理的原理:分段和分页相结合。
先将用户程序分段,每段内再划分成若干页,每段有段名(段号),每段内部的页有一连续的页号。
内存划分:按页式存储管理方案。
内存分配:以页为单位进行离散分配。
逻辑地址结构:由于段页式系统给作业地址空间增加了另一级结构,现在地址空间是由段号、段内页号和页内偏移量构成。
虚拟内存:
物理内存
物理内存指通过物理内存条而获得的内存空间。
虚拟内存
这个在我们平时使用电脑特别是 Windows 系统的时候太常见了。很多时候我们使用点开了很多占内存的软件,这些软件占用的内存可能已经远远超出了我们电脑本身具有的物理内存。为什么可以这样呢? 正是因为 虚拟内存 的存在,通过 虚拟内存 可以让程序可以拥有超过系统物理内存大小的可用内存空间。另外,虚拟内存为每个进程提供了一个一致的、私有的地址空间,它让每个进程产生了一种自己在独享主存的错觉(每个进程拥有一片连续完整的内存空间)。这样会更加有效地管理内存并减少出错。
虚拟内存是计算机系统内存管理的一种技术,我们可以手动设置自己电脑的虚拟内存。不要单纯认为虚拟内存只是“使用硬盘空间来扩展内存“的技术。虚拟内存的重要意义是它定义了一个连续的虚拟地址空间,并且 把内存扩展到硬盘空间。
虚拟内存是计算机系统内存管理的一种技术。 它使得应用程序认为它拥有连续的可用的内存(一个连续完整的地址空间)。而实际上,虚拟内存通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换,加载到物理内存中来。 目前,大多数操作系统都使用了虚拟内存,如 Windows 系统的虚拟内存、Linux 系统的交换空间等等。
离开进程谈虚拟内存没有任何意义,不同进程里的同一个虚拟地址指向的物理地址是不一样的。每个用户进程维护了一个单独的页表(Page Table),虚拟内存和物理内存就是通过这个页表实现地址空间的映射的,页表(Page Table)里面的数据由操作系统维护。
引入虚拟内存的好处
在进程和物理内存之间,加了一层虚拟内存的概念,好处有:
局部性原理
局部性原理是虚拟内存技术的基础,正是因为程序运行具有局部性原理,才可以只装入部分程序到内存就开始运行。
局部性原理表现在以下两个方面:
时间局部性 :如果程序中的某条指令一旦执行,不久以后该指令可能再次执行;如果某数据被访问过,不久以后该数据可能再次被访问。产生时间局部性的典型原因,是由于在程序中存在着大量的循环操作。
空间局部性 :一旦程序访问了某个存储单元,在不久之后,其附近的存储单元也将被访问,即程序在一段时间内所访问的地址,可能集中在一定的范围之内,这是因为指令通常是顺序存放、顺序执行的,数据也一般是以向量、数组、表等形式簇聚存储的。
时间局部性是通过将近来使用的指令和数据保存到高速缓存存储器中,并使用高速缓存的层次结构实现。空间局部性通常是使用较大的高速缓存,并将预取机制集成到高速缓存控制逻辑中实现。虚拟内存技术实际上就是建立了 “内存一外存”的两级存储器的结构,利用局部性原理实现髙速缓存。
实现:
虚拟内存的实现需要建立在离散分配的内存管理方式的基础上。
虚拟内存的实现有以下三种方式:
不管是上面那种实现方式,我们一般都需要:
一定容量的内存和外存:在载入程序的时候,只需要将程序的一部分装入内存,而将其他部分留在外存,然后程序就可以执行了;
缺页中断:如果需执行的指令或访问的数据尚未在内存(称为缺页或缺段),则由处理器通知操作系统将相应的页面或段调入到内存,然后继续执行程序;
虚拟地址空间 :逻辑地址到物理地址的变换。
内核空间和用户空间:
为了避免用户直接操作内核「可以操作一切,牛逼得很」,保证内核安全,所以将虚拟内存划分为用户空间和内核空间,进程在访问到这两个空间的时候需要进行状态的转变(内核态、用户态)。
这里不考虑物理内存怎么划分的,因为进程一般直接访问虚拟内存!
像我们在 jvm 中谈到的,堆、栈、方法区等等都是默认是用户空间,因为我们可以直接访问的到。
内核态 & 用户态
内核态可以执行任意命令,调用系统的一切资源,而用户态只能执行简单的运算,不能直接调用系统资源。用户态必须通过系统接口(System Call),才能向内核发出指令。比如,当用户进程启动一个 bash 时,它会通过 getpid() 对内核的 pid 服务发起系统调用,获取当前用户进程的 ID;当用户进程通过 cat 命令查看主机配置时,它会对内核的文件子系统发起系统调用。
我们都知道unix(like)世界里,一切皆文件,而文件是什么呢?文件就是一串二进制流而已,不管socket,还是FIFO、管道、终端,对我们来说,一切都是文件,一切都是流。在信息 交换的过程中,我们都是对这些流进行数据的收发操作,简称为I/O操作(input and output),往流中读出数据,系统调用read,写入数据,系统调用write。不过话说回来了 ,计算机里有这么多的流,我怎么知道要操作哪个流呢?对,就是文件描述符,即通常所说的fd,一个fd就是一个整数,所以,对这个整数的操作,就是对这个文件(流)的操作。我们创建一个socket,通过系统调用会返回一个文件描述符,那么剩下对socket的操作就会转化为对这个描述符的操作。不能不说这又是一种分层和抽象的思想。
一个输入操作通常包括两个阶段:
• 等待数据准备好
• 从内核向进程复制数据
对于一个套接字上的输入操作,第一步通常涉及等待数据从网络中到达。当所等待数据到达时,它被复制到内核中的某个缓冲区。第二步就是把数据从内核缓冲区复制到应用进程缓冲区。
通俗的讲,将IO分为两步:
1.等;
2.数据搬迁。
如果要想提高IO效率,需要将等的时间降低。
Unix 有五种 I/O 模型:
• 阻塞式 I/O
• 非阻塞式 I/O
• I/O 复用(select 和 poll)
• 信号驱动式 I/O(SIGIO)
• 异步 I/O(AIO)
同步 I/O:
将数据从内核缓冲区复制到应用进程缓冲区的阶段(上述过程中第二阶段),应用进程会阻塞。
同步 I/O 包括阻塞式 I/O、非阻塞式 I/O、I/O 复用和信号驱动 I/O ,它们的主要区别在第一个阶段。
非阻塞式 I/O 、信号驱动 I/O 和异步 I/O 在第一阶段不会阻塞。
异步 I/O:
第二阶段应用进程不会阻塞。
五种IO总结:
阻塞式I/O
我们最熟悉的I/O模型就是阻塞式I/O模型,在上图中,应用进程系统调用recvfrom接收数据,但是此时内核缓冲区中数据报还未准备好,所以应用进程会一直阻塞直到内核缓冲区有数据报到达且被复制到应用进程缓冲区
应用进程被阻塞,直到数据从内核缓冲区复制到应用进程缓冲区中才返回。
这里我们提一下这里的内核缓冲区和应用进程缓冲区所处的阶段
一个输入操作通常包括两个不同的阶段:
非阻塞式I/O
应用进程执行系统调用之后,内核返回一个错误码。应用进程可以继续执行,但是需要不断的执行系统调用来获知 I/O 是否完成,这种方式称为轮询(polling)。
由于 CPU 要处理更多的系统调用,因此这种模型的 CPU 利用率比较低。
举例:B也在河边钓鱼,但是B不想将自己的所有时间都花费在钓鱼上,在等鱼上钩这个时间段中,B也在做其他的事情,但每隔一个固定的时间检查鱼是否上钩。一旦检查到有鱼上钩,就停下手中的事情,把鱼钓上来。
查看上图可知,在设置连接为非阻塞时,当应用进程系统调用recvfrom没有数据返回时,内核会立即返回一个EWOULDBLOCK错误,而不会一直阻塞到数据准备好。如上图在第四次调用时有一个数据报准备好了,所以这时数据会被复制到应用进程缓冲区,于是recvfrom成功返回数据
当一个应用进程这样循环调用recvfrom时,我们称之为轮询polling。这么做往往会耗费大量CPU时间,实际使用很少。
I/O 复用
IO多路转接是多了一个select函数,select函数有一个参数是文件描述符集合,对这些文件描述符进行循环监听,当某个文件描述符就绪时,就对这个文件描述符进行处理。
I/O多路复用就是通过一种机制,一个进程可以监视多个文件描述符,一旦某个描述符就绪(读就绪或写就绪),能够通知程序进行相应的读写操作 。
其中,select只负责等,recvfrom只负责拷贝。
IO多路转接是属于阻塞IO,但可以对多个文件描述符进行阻塞监听,所以效率较阻塞IO的高。
• (1)当用户进程调用了select,那么整个进程会被block;
• (2)而同时,kernel会“监视”所有select负责的socket;
• (3)当任何一个socket中的数据准备好了,select就会返回;
• (4)这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程(空间)。
select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。
Linux I/O复用模型提供了select poll epoll三组系统调用可做选择,进程通过将一个或多个文件描述符(fd)传递给select或poll或epoll系统调用,通过它们来监测多个fd是否处于就绪状态。select或poll是顺序扫描fd是否就绪,而且支持的fd数量有限,因此使用上有制约。epoll调用基于事件驱动,因此性能更高,当fd就绪时会立即回调rollback
解释一下文件描述符
Linux 内核将所有外部设备都看做一个文件来操作,对一个文件的读写操作会调用内核提供的系统命令,返回一个file descriptor(fd 文件描述符)。而对一个socket的读写也会有相应的描述符,称为socket fd。
现在再来看一下上图,上图以select为例。不难发现进程会阻塞于select调用,直到所关注的某一个文件描述符(套接字)变为可读状态
信号驱动I/O
信号驱动I/O的意思就是我们现在不用傻等着了,也不用去轮询。而是让内核在数据就绪时,发送信号通知我们。
调用的步骤是,我们通过系统调用sigaction,并注册一个信号处理的回调函数,该调用会立即返回,但是当内核数据就绪时,内核会为该进程产生一个SIGIO信号,并回调我们注册的信号回调函数,这样我们就可以在信号回调函数中系统调用recvfrom获取数据
信号驱动IO模型,应用进程告诉内核:当数据报准备好的时候,给我发送一个信号,对SIGIO信号进行捕捉,并且调用我的信号处理函数来获取数据报。
异步I/O
异步I/O 与 信号驱动I/O最大区别在于,信号驱动是内核通知我们何时开始我们I/O操作,而异步I/O是由内核通知我们I/O操作何时完成,两者有本质区别
应用进程执行 aio_read 系统调用会立即返回,应用进程可以继续执行,不会被阻塞,内核会在所有操作完成之后向应用进程发送信号。
当应用程序调用aio_read时,内核一方面去取数据报内容返回,另一方面将程序控制权还给应用进程,应用进程继续处理其他事情,是一种非阻塞的状态。
当内核中有数据报就绪时,由内核将数据报拷贝到应用程序中,返回aio_read中定义好的函数处理程序。
阻塞程度:阻塞IO>非阻塞IO>多路转接IO>信号驱动IO>异步IO,效率是由低到高的。
I/O 复用
select/poll/epoll 都是 I/O 多路复用的具体实现,select 出现的最早,之后是 poll,再是 epoll。
select、poll、epoll都是I/O多路复用的机制。I/O多路复用就是通过一种机制,一个进程可以监视多个文件描述符,一旦某个描述符就绪(读就绪或写就绪),能够通知程序进行相应的读写操作 。
但是,select,poll,epoll本质还是同步I/O(I/O多路复用本身就是同步IO)的范畴,因为它们都需要在读写事件就绪后线程自己进行读写,读写的过程阻塞的。而异步I/O的实现是系统会把负责把数据从内核空间拷贝到用户空间,无需线程自己再进行阻塞的读写,内核已经准备完成。
linux中 socket 的 fd 是什么?
这个FD就是File Discriptor 中文翻译为文件描述符。
Socket起源于unix,Unix中把所有的资源都看作是文件,包括设备,比如网卡、打印机等等,所以,针对Socket通信,我们在使用网卡,网卡又处理N多链接,每个链接都需要一个对应的描述,也就是惟一的ID,即对应的文件描述符。简单点说也就是 int fd = socket(AF_INET,SOCK_STREAM, 0); 函数socket()返回的就是这个描述符。在传输中我们都要使用这个惟一的ID来确定要往哪个链接上传输数据。
select
说的通俗一点就是各个客户端连接的文件描述符也就是套接字,都被放到了一个集合中,调用select函数之后会一直监视这些文件描述符中有哪些可读,如果有可读的描述符那么我们的工作进程就去读取资源。
基本原理:
select 函数监视的文件描述符分3类,分别是writefds、readfds、和exceptfds。调用后select函数会阻塞,直到有描述符就绪(有数据 可读、可写、或者有异常),或者超时(timeout指定等待时间,如果立即返回设为null即可),函数返回。当select函数返回后,可以通过遍历fdset,来找到就绪的描述符。
int select (int __nfds,
fd_set *__restrict __readfds,
fd_set *__restrict __writefds,
fd_set *__restrict __exceptfds,
struct timeval *__restrict __timeout);
fd_set
其中有一个很重要的结构体fd_set,该结构体可以看作是一个描述符的集合,可以将fa_set看作是一个位图,类似于操作系统中的位图,其中每个整数的每一bit代表一个描述符,。
举个简单的例子,fd_set中元素的个数为2,初始化都为0,则fd_set中含有两个整数0,假设一个整数的长度8位(为了好举例子),则展开fd_set的结构就是 00000000 0000000,如果这个时候添加一个描述符为3,则对应fd_set编程 00000000 00001000,可以看到在这种情况下,第一个整数标记描述符07,第二个整数标记815,依次类推
select函数中存在三个fd_set集合,分别代表三种事件,__readfds表示读描述符集合,__writefds表示写描述符集合,__exceptfds表示异常描述符集合,当对应的fd_set = NULL时,表示不监听该类描述符。
__nfds
__nfds是fd_set中最大的描述符+1,当调用select的时候,内核态会判断fd_set中描述符是否就绪,__nfds告诉内核最多判断到哪一个描述符。
timeval
struct timeval {
long tv_sec; //秒
long tv_usec; //微秒
}
参数__timeout指定select的工作方式:
• __timeout= NULL,表示select永远等待下去,直到其中至少存在一个描述符就绪
• __timeout结构体中秒或者微妙是一个大于0的整数,表示select等待一段固定的事件,若该短时间内未有描述符就绪则返回
• __timeout= 0,表示不等待,直接返回
函数返回
select函数返回产生事件的描述符的数量,如果为-1表示产生错误
值得注意的是,比如用户态要监听描述符1和3的读事件,则将readset对应bit置为1,当调用select函数之后,若只有1描述符就绪,则readset对应bit为1,但是描述符3对应的位置为0,这就需要注意,每次调用select的时候,都需要重新初始化并赋值readset结构体,将需要监听的描述符对应的bit置为1,而不能直接使用readset,因为这个时候readset已经被内核改变了。
select目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点。
select的一个缺点在于单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024,可以通过修改宏定义甚至重新编译内核的方式提升这一限制,但是这样也会造成效率的降低。
select本质上是通过设置或者检查存放fd标志位的数据结构来进行下一步处理。这样所带来的缺点是:(见下方总结)
poll
select中,每个fd_set结构体最多只能标识1024个描述符,在poll中去掉了这种限制。
区别:
• select 会修改描述符,而 poll 不会;
• select 的描述符类型使用数组实现,FD_SETSIZE 大小默认为 1024,因此默认只能监听少于 1024 个描述符。如果要监听更多描述符的话,需要修改 FD_SETSIZE 之后重新编译;而 poll 没有描述符数量的限制;
• poll 提供了更多的事件类型,并且对描述符的重复利用上比 select 高。
• 如果一个线程对某个描述符调用了 select 或者 poll,另一个线程关闭了该描述符,会导致调用结果不确定。
select 和 poll 速度都比较慢,每次调用都需要将全部描述符从应用进程缓冲区复制到内核缓冲区。
基本原理:
poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历。
它没有最大连接数的限制,原因是它是基于链表来存储的,但是同样有一个缺点:
注意:
从上面看,select和poll都需要在返回后,通过遍历文件描述符来获取已经就绪的socket。事实上,同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降。
int poll (struct pollfd *__fds, nfds_t __nfds, int __timeout);
struct pollfd {
int fd; // poll的文件描述符
short int events; // poll关心的事件类型
short int revents; // 发生的事件类型
};
Poll使用结构体pollfd来指定一个需要监听的描述符,结构体中fd为需要监听的文件描述符,events为需要监听的事件类型,而revents为经过poll调用之后返回的事件类型,在调用poll的时候,一般会传入一个pollfd的结构体数组,数组的元素个数表示监控的描述符个数,所以pollfd相对于select,没有最大1024个描述符的限制。
__fds
__fds的作用同select中的__nfds,表示pollfd数组中最大的下标索引
__timeout
__timeout = -1:poll阻塞直到有事件产生
__timeout = -0:poll立刻返回
__timeout != -1 && __timeout != 0:poll阻塞__timeout对应的时候,如果超过该时间没有事件产生则返回
函数返回
poll函数返回产生事件的描述符的数量,如果返回0表示超时,如果为-1表示产生错误
epoll
epoll是在2.6内核中提出的,是之前的select和poll的增强版本。
相对于select和poll来说,epoll更加灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。
基本原理:
epoll支持水平触发和边缘触发,最大的特点在于边缘触发,它只告诉进程哪些fd刚刚变为就绪态,并且只会通知一次。还有一个特点是,epoll使用“事件”的就绪通知方式,通过epoll_ctl注册fd,一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fd,epoll_wait便可以收到通知。
epoll的优点:
工作模式
epoll 的描述符事件有两种触发模式:LT(level trigger)和 ET(edge trigger)。
三者区别:
三者比较
总结:
select与poll中,每次调用都要把fd集合从用户态往内核态拷贝一次,(创建一个待处理事件列表,然后把这个列表发送给内核),返回的时候再去轮询这个列表,以判断事件是否发生。调用多次的话,就会重复将fd拷贝进内核。在描述符比较多的时候,效率极低。
epoll将文件描述符列表的管理交给内核负责,每次注册新的事件时,将fd拷贝进内核,epoll保证fd在整个过程中仅被拷贝一次,避免了反复拷贝重复fd的巨大开销。此外,一旦某个事件发生时,设备就绪时,调用回调函数,把就绪fd放入就绪链表中,并唤醒在epoll_wait中进入睡眠的进程(内核就把发生事件的描述符列表通知进程,避免对所有描述符列表进行轮询),只要判断一下就绪链表是否为空就行了。
在 windows 下,只支持 select,不支持 epoll,而 linux 2.6是支持 epoll的.
epoll 系统调用「复杂度 O(1)」
select 「复杂度 O(n)」
应用场景
很容易产生一种错觉认为只要用 epoll 就可以了,select 和 poll 都已经过时了,其实它们都有各自的使用场景。
磁盘调度在多道程序设计的计算机系统中,各个进程可能会不断提出不同的对磁盘进行读/写操作的请求。由于有时候这些进程的发送请求的速度比磁盘响应的还要快,因此我们有必要为每个磁盘设备建立一个等待队列。
一次磁盘读/写操作需要的时间
寻找时间(寻道时间)Ts:在读/写数据前,需要将磁头移动到指定磁道所花费的时间。
寻道时间分两步:
(1) 启动磁头臂消耗的时间:s。
(2) 移动磁头消耗的时间:假设磁头匀速移动,每跨越一个磁道消耗时间为m,共跨越n条磁道。
则寻道时间 Ts = s + m * n。
延迟时间TR:通过旋转磁盘,使磁头定位到目标扇区所需要的时间。设磁盘转速为r(单位:转/秒,或转/分),则平均所需延迟时间TR = (1/2)*(1/r) = 1/2r。
1/r就是转一圈所需的时间。找到目标扇区平均需要转半圈,因此再乘以1/2。
传输时间TR:从磁盘读出或向磁盘中写入数据所经历的时间,假设磁盘转速为r,此次读/写的字节数为b,每个磁道上的字节数为N,则传输时间TR = (b/N) * (1/r) = b/(rN)。
每个磁道可存N字节数据,因此b字节数据需要b/N个磁道才能存储。而读/写一个磁道所需的时间刚好是转一圈的时间1/r。
总的平均时间Ta = Ts + 1/2r + b/(rN),由于延迟时间和传输时间都是与磁盘转速有关的,且是线性相关。而转速又是磁盘的固有属性,因此无法通过操作系统优化延迟时间和传输时间。所以只能优化寻找时间。
磁盘调度算法:
1 先来先服务算法(FCFS)
算法思想:根据进程请求访问磁盘的先后顺序进行调度。
假设磁头的初始位置是100号磁道,有多个进程先后陆续地请求访问55、58、39、18、90、160、150、38、184号磁道。
按照先来先服务算法规则,按照请求到达的顺序,磁头需要一次移动到55、58、39、18、90、160、150、38、184号磁道。
优点:公平;如果请求访问的磁道比较集中的话,算法性能还算可以。
缺点:如果大量进程竞争使用磁盘,请求访问的磁道很分散,FCFS在性能上很差,寻道时间长。
2 最短寻找时间优先(SSTF)
算法思想:优先处理的磁道是与当前磁头最近的磁道。可以保证每次寻道时间最短,但是不能保证总的寻道时间最短。(其实是贪心算法的思想,只是选择眼前最优,但是总体未必最优)。
假设磁头的初始位置是100号磁道,有多个进程先后陆续地请求访问55、58、39、18、90、160、150、38、184号磁道。
缺点:可能产生饥饿现象。
本例中,如果在处理18号磁道的访问请求时又来了一个38号磁道的访问请求,处理38号磁道的访问请求又来了一个18号磁道访问请求。如果有源源不断的18号、38号磁道访问请求,那么150、160、184号磁道请求的访问就永远得不到满足,从而产生饥饿现象。这里产生饥饿的原因是磁头在一小块区域来回移动。
3 扫描算法(SCAN)
SSTF最短寻找时间优先算法会产生饥饿的原因在于:磁头有可能再一个小区域内来回得移动。为了防止这个问题,可以规定:磁头只有移动到请求最外侧磁道或最内侧磁道才可以反向移动,如果在磁头移动的方向上已经没有请求,就可以立即改变磁头移动,不必移动到最内/外侧的磁道。这就是扫描算法的思想。由于磁头移动的方式很像电梯,因此也叫电梯算法。
假设某磁盘的磁道为0~200号,磁头的初始位置是100号磁道,且此时磁头正在往磁道号增大的方向移动,有多个进程先后陆续的访问55、58、39、18、90、160、150、38、184号磁道。
优点:性能较好,寻道时间较短,不会产生饥饿现象。
缺点:SCAN算法对于各个位置磁道的响应频率不平均。(假设此时磁头正在往右移动,且刚处理过90号磁道,那么下次处理90号磁道的请求就需要等待低头移动很长一段距离;而响应了184号磁道的请求之后,很快又可以再次响应184号磁道请求了。)
4 循环扫描算法(C-SCAN)
SCAN算法对各个位置磁道的响应频率不平均,而C-SCAN算法就是为了解决这个问题。规定只有磁头朝某个特定方向移动时才处理磁道访问请求,而返回时直接快速移动至最靠边缘的并且需要访问的磁道上而不处理任何请求。
通俗理解就是SCAN算在改变磁头方向时不处理磁盘访问请求而是直接移动到另一端最靠边的磁盘访问请求的磁道上。
假设某磁盘的磁道为0~200号,磁头的初始位置是100号磁道,且此时磁头正在往磁道号增大的方向移动,有多个进程先后陆续的访问55、58、39、18、90、160、150、38、184号磁道。
优点:相比于SCAN算法,对于各个位置磁道响应频率很平均。
缺点:相比于SCAN算法,平均寻道时间更长。