根据用户态和内核态的划分,用户态的进程是不能直接访问各种硬件资源的,只能向内核发起系统调用,由内核完成一系列操作再切回用户进程。
用户进程每次想要访问硬件资源(包括读和写)就叫做一次IO。
IO共有5大类,分别是阻塞IO,非阻塞IO,IO多路复用,信号驱动IO以及异步IO,其中前四种都属于同步IO,在进行实际的IO操作时进程都会陷入阻塞状态。
在正式介绍IO之前,我们需要先清楚文件描述符这一定义。简单来说,文件描述符就是一个数字,这个数字是内存中文件表的索引,用户进程可以根据这一数字在内核中定位到具体想要访问的文件。
Linux系统中一共有三个与fd相关的表,分别是file descriptor table,system file table以及inode table。
每个用户进程都会维护着一个属于自己的file descriptor table,代表当前进程所关注的fd,他的key是文件描述符fd,value是该文件描述符在system file table中的索引。该表的前3位0,1,2分别代表标准输入,标准输出和错误输出,这是每个用户进程都会维护的。
system file table位于内核,系统会在其中为每次IO操作创建一个属于它自己的entry,即使是同一进程连续打开一个文件多次的情况,也会生成多个对应的system file table entry。该entry中保存着对应的实际文件在inode table中的地址,同时还保存着一个offset。
之所以要每次IO一个entry,是因为每次IO都有自己独立的offset。
inode table位于内核,其中保存着文件在磁盘中的具体位置,由于Linux系统万物皆文件的特性,每个硬件(包括键盘、鼠标、网卡等)都抽象为了一个对应的文件,因此该文件系统可以涵盖所有IO操作。
之所以要有文件描述符,是因为只有内核有直接操作文件系统的权限。在用户想要访问一个文件时,需要向内核发起一次系统调用,内核首先会在system file table中创建一个对应文件的entry,接着会将对应的索引,即文件描述符返回给用户。用户如果想对指定的文件进行后续操作,需要将对应的文件描述符再传给内核,内核就知道用户想要操作哪个文件了。
总之文件描述符相当于内核给用户提供的一种类似于通信信号一样的东西。
在Linux架构中,无论哪种IO方式,其工作流程都是差不多的,下面以读文件举例。
最简单也是最常见的IO操作,通常读取磁盘文件使用的都是阻塞IO,例如open,read,writer,close函数。调用这些函数的进程在函数返回之前会一直阻塞在调用处。
对于本地IO来说,阻塞IO通产是很快的,但对于网络IO来说,由于网络的不可控性,阻塞IO通常就不能直接使用了。
阻塞IO的优缺点都很明显,优点就是简单,方便,缺点也是过于简单,没有进行任何优化。
在这里插入图片描述
与阻塞IO相对应的就是非阻塞IO,非阻塞IO在进入内核态之后首先会判断对应资源是否准备完成,如果准备则进行对应操作并返回结果,如果没准备完成会立即返回一个EWOULDBLOCK错误。
用户进程可以在稍后再次发起IO请求,直到IO操作成功。
非阻塞IO的优点是可以让用户在数据准备完成之前先进行其他操作,但缺点也很明显,需要多次发起系统调用,而系统调用恰恰又是特别消耗CPU资源的行为。
所谓的IO多路复用本质上还是阻塞IO,只不多可以在一次阻塞中同时监控多个文件描述符,并在任意文件描述符可以操作时进行返回。
Linux为我们提供了三种IO多路复用的方式,分别是select,poll,epoll,其在使用时对应的时间复杂度分别是O(n), O(n)和O(1),其中n是需要监控的文件描述符的数量。
在需要监控少量文件描述符时,select和poll的效率会更高,因为二者时间复杂度中的常数项更低,但当需要监控大量文件描述符时,epoll的效率会更高。
下面将对这三种方式做简单介绍:
select内部会一直循环遍历所有文件描述符的状态,有一个fd满足监听条件或者超时为止。内部循环的时间复杂度就是O(n)。
此外在用户态获取返回值之后,如果有fd可操作,还需要再遍历一遍所有的fd从中找出可操作的fd,这也需要O(n)的操作时间。
为了提高效率,当一次遍历没有成功之后,当前线程会进入一段时间的休眠,直到被定时器或者状态变化的fd唤醒。
Poll的算法与Select类似,但在实现上有所不同,最大的区别在于Select使用的是定长数组,而Poll使用的是链表,可以同时监听更多的fd。
Epoll与Select和poll的算法完全不同,其原理简单来说就是为每个要监听的fd指定一个回调函数,在fd状态发生变更时将其主动推送给用户。
epoll_create函数可以创建一个负责注册监听事件的entry,再通过epoll_ctl逐个注册想要监听的fd,最后调用epoll_wait进行等待,直到有可以操作的fd退给用户。
epoll提供两类监控事件,分别是EPOLLLT(水平触发)和EPOLLET(边沿触发),水平触发指的是如果当前fd内还有数据可读,那么就会一直将其返回给用户,而边沿触发则是只在有新数据可读时才会将其返回给用户。
边沿触发让用户可以暂时先不处理一些数据量较大且不是很重要的fd,而专注于更敏感的fd,但需要注意的是在读取fd时需要将所有buffer读空。
epoll的优点:每个fd只会被注册一次,降低了用户态和内核态之前的拷贝数据。epoll_wait只会返回可以操作的文件描述符,在用户态减少一次全部遍历。在监听时只是将有事件发生的fd加入其可以返回的链表,减少遍历次数。
使用场景和相关资料都很少,暂时不研究
异步IO的关键点在于内核的数据准备和数据拷贝工作都是在后台进行的,用户进程在向内核发起IO相关的系统调用信号后便会直接返回。
内核在收到信号后会驱动DMA将数据拷贝至内核态缓存,再将数据拷贝至用户态缓存,在一切准备就绪之后才会通知用户进程,用户进程在收到信号通知后可以直接访问已经准备好的数据。
当前Linux系统内部共有四种存储空间,分别是寄存器(Register),高速缓存(Cache),主存/内存(Memory),磁盘(Disk)。
从左到右速度和价格都依次降低,越快的肯定越贵嘛。
其中寄存器为每个CPU核独享,速度非常快,但容量也很小,64位CPU通常只有64x64bit大小。一般软件开发也不会优化到这种程度,了解即可。
Cache又分为L1,L2和L3三种,也是速度和价格依次下降。其中L1每核独享,L3每个CPU的核内共享,L2取决于具体实现,一般Intel为共享,ARM为独享。
内存我们就很熟悉了,当前用RAM实现,大型服务器通常都有几百G了。内存分为内核态和用户态,其中用户态又使用虚拟内存的方式进行寻址。
磁盘存储空间就很大了,但访问速度与内存相比低了3个数量级,一般程序都不会跑到磁盘上。但磁盘上有个特殊的分区,swap分区。由于
Cache是我们在做系统优化时一般所能考虑到的最高级存储了。由于Cache本身就有三层,每层又有着不同的同步策略,Cache和Cache之间,Cache和内存之间都有着复杂的同步关系,深究起来非常非常复杂。
这里我只介绍自己了解过的一些针对Cache的开发点,其他就一点一点积累吧。
这应该是我们听过的跟Cache相关的最多的词了,简单来说就是CPU在想要访问一个数据时,会先在自己的三层Cache中查找其是否存在,如果存在就拿来用,此时称为Cache Hit,如果不存在就会根据地址访问内存,将其从内存加载到Cache中,再进行访问,此时称为Cache Miss。
从内存加载到Cache的过程中,CPU是无法继续执行当前指令的,如果有其他可以执行的指令CPU会切换到其他指令,否则就会陷入阻塞状态。降低Cache Miss率可以有效提升CPU效率。
由于Cache的容量是有限的,因此需要一种算法来更新Cache中的数据,一般使用的都是LRU(最近最少使用)算法,即将使用频率最低的部分从Cache中剔除。LRU算法在内存中可以通过链表加哈希表的形式实现,Cache中的实现方法未知。
如果说Cache Miss是频率最高的词,那么Cache Line应该就是第二了。所谓的Cache Line其实就是Cache中的一行,也是Cache操作的最小单元,在64位操作系统重Cache Line一般长度为64bit。在程序开发时,为了尽可能的提高效率,通常需要Cache Line对齐。
所谓的Cache Line对齐就是让一个结构体中的成员占用的内存尽量是32bit的整数倍,当一个结构体的长度接近64或者32时,可以通过在结构体中添加padding的方法将其长度填充至32或64bit,这样可以使CPU在访问下一个相同数据时不需要跨Cache Line访问。
由于L1 Cache和L2 Cache通常都是每核独享的,那么不可避免的会出现同步上的问题。同时,由于CPU会对执行的指令顺序做优化,导致指令实际的执行顺序与程序中预期的顺序不一致,这也称为CPU乱序。
内存屏障就是为了解决上述问题产生的。内存屏障可以分为读屏障和写屏障,根据实现方式又可以分为CPU屏障和编译器屏障。
在现在的x86系统中,及时没有读写屏障也不会有同步之间的严重问题了,具体实现我也不是很清楚,但如果在某些特殊场景下,为了避免CPU乱序,还是要加写屏障的(防止Store Load乱序)。
内存按照使用功能可以分为内核态和用户态,而用户态又需要区分虚拟内存和物理内存,这里重点介绍虚拟内存以及与其相关的TLB Miss。
由于用户态的所有进程共用一块物理内存,因此如何对物理内存就行分配就是首先需要考虑的问题。经过多次架构演变,Linux系统最终使用虚拟内存的方式来为各个进程分配物理内存。
虚拟内存相当于是进程和物理内存之间的一个中间层,每个进程都会维护着一个属于自己的页表,一个页表就相当于一个目录,其key为虚拟内存地址,value为物理内存地址,提供了一个从虚拟内存到物理内存的映射。
对于当前常见的64位操作系统,其所使用的寻址空间为48位,已经能够提供256T的寻址能力。Linux系统默认的一页大小为4K,所对应的页偏移(即虚拟内存和物理内存相同的地址空间)为12位,因此还需要36位的大小的页表提供查询。
为了尽可能降低页表的大小,Linux使用多级页表,一般为4级,每级提供9位的寻址能力。在使用时,会从高到低地逐次查询页表,最终定位到需要的物理内存地址。
这样做的问题也显而易见,页表默认都保存在内存中,如果四级页表都没有在Cache中,那么CPU仅仅是想简单的查询一个数据就需要访问5次内存(四次定位物理地址,一次读取数据),这样会大大降低效率。
为了解决这一问题,TLB(Translation Lookaside Buffer)应运而生。TLB相当于是一个专门为虚拟地址向物理地址映射提供服务的寄存器,其效率相当快,且每核独享。但与寄存器一样,其造价很贵,因此空间很小,如何高效利用这些空间也是系统优化中需要经常考虑的问题。
与Cache Miss类似,CPU在想要获取一个虚拟地址对应的物理地址时,会先查询其TLB中的数据,如果其中没有对应的虚拟地址,也就称为TLB Miss。此时从Cache和内存中的页表查询对应地址,并将其添加到TLB中。
由于TLB大小有限,因此也要制定对应的更新算法,一般也是LRU。
关于虚拟内存还有一个问题,那就是每个进程都有自己的一份虚拟内存,总的虚拟内存大小是远大于实际的物理内存大小的,那么当各个进程同时使用的虚拟内存大于实际物理内存时该怎么办呢,Swap区间就是为了解决这一问题。
Swap是磁盘上的一个特殊区间,在安装系统时就已经指定了。当物理内存不足时,系统会根据更新算法将最少用到的物理内存地址暂时放到磁盘的Swap区间上,并将空闲下来的物理内存给新的程序使用。
除此之外,在系统休眠时,会将全部的内存数据存入Swap区间中,等到下次系统被唤醒时会重新从Swap区间加载全量内存数据。
用户态(User Space)和内核态(Kernel Space)可以说是所有Linux开发人员都绕不开的话题,那么他们究竟是什么?有什么区别?有什么关系?又为什么这么设计?
首先需要理解什么是内核,所谓内核可以简单理解为一个特殊的软件,它支撑起了整个操作系统,有着最高的管理权限,并且可以直接与各种设备通信,可以执行ring 0级别的CPU指令集。
而内核态,就是系统为内核专门划分的一块物理内存,除内核外的软件对该内存都没有直接访问的权限,一般是物理内存的高位。
与之对应的用户态就是其他所有软件共享的一块物理内存,共享通过虚拟内存实现。
用户态可以通过三种方式与内核进行通信:
那操作系统为什么要这么设计呢?
主要有两点,首先为了给内核划分一片专用内存,保证在任何情况下操作系统都能跑,其次是因为有许多高度敏感的CPU指令集如果不当使用的后果是灾难性的,所以只能由提前设计好的内核调用,对外只提供对应的库函数。
这一过程被称为系统调用。每次系统调用需要两次上下文切换,分别是调用的用户态进程切换至内核态,等内核处理完成再从内核态切换至用户态。
根据用户态和内核态对内存进行分区带来了许多优点,但传统架构中用户态的进程如果想要访问文件资源(磁盘和网口)需要多次的上下文切换和CPU参与的拷贝工作,这大大限制了CPU效率,为此催生了一系列的零拷贝技术。
进程和线程算是计算机系统里老生常谈的问题了,特别是在面试中,面试官很有可能问出的一个问题就是,说一下进程和线程的区别?
这篇文章就从概念上简单阐述一个现阶段我对这一问题的理解。
想要完整的回答这个问题,可以从是什么,为什么,怎么做的角度入手,对应的具体问题就是,进程和线程分别是什么?为什么要这么设计?以及Linux系统是怎么实现的?
进程和线程本质上都是一种对程序的抽象,每个人都可能有自己的一套理解,并且各个操作系统的具体实现也会有所区别,最常见的一个定义就是,进程是资源分配的最小单位,线程是系统调用的最小单位。
对于计算机来说,资源无非就是内存和CPU,上面的定义如果说的更通俗一点就是操作系统以进程为单位为程序分配内存,以线程为单位为程序分配CPU时间。
在Linux系统中,内存被分为用户态和内核态,为了充分利用用户态的空间,Linux系统会为每个程序分配独立的虚拟内存,同时有一个对应的页表保存虚拟地址和物理地址之间的映射关系,在程序运行时不断的通过缺页中断来为其分配实际的物理地址。
这一虚拟地址空间,按照功能的不同又会被划分成如下几个区域:
操作系统通常会按照线程为单位为程序分配时间片,时间片的大小取决于线程的优先级,优先级越高的线程分配的时间片大小就越大。Linux系统中通常会为IO操作密集的线程分配更高的优先级,这是因为用户对有IO操作的线程通常更为敏感。
对于用户线程来说,其分配的时间片大小可从5ms~800ms,具体取决于调度算法。
在Linux系统中,进程和线程会统一使用task_struct进行表示,并不会做明显的区分。我们可以将一个task_struct视为一个线程,共用同一个虚拟空间的多个线程视为一个进程。
如果一个虚拟空间只有一个对应的线程,那么此线程就是一个进程,如果一个虚拟空间对应着多个task_struct,那么就可以将这一线程组视为一个进程。
我们都知道进程和线程切换时需要进行上下文的切换,这也是频繁切换进程或线程时的主要性能损耗原因,那么进程和线程的上下文都有什么呢?
对于进程来说,其上下文的切换包含两方面,一是空间地址的切换,需要切换到新的虚拟地址空间,也就是切换到新的页表。二是处理器状态的切换,又叫硬件上下文切换,也就是保存当前的寄存器状态,并切换到下一个将要执行的进程的寄存器状态。
对于线程来说,如果切换前和切换后的线程属于同一个进程,那就说明他们有着相同的虚拟空间,也就只需要进行处理器状态的切换了。这也是多线程程序比多进程程序快的原因。
在Linux系统中,切换虚拟地址空间就是改变task_struct结构中一级页表的物理地址对应的字段,这一切换是很快的,但是在虚拟地址空间切换之后,TLB中的所有字段都需要失效,不然会让CPU访问到错误的物理地址。而这会已发大量的TLB Miss,这才是进程切换的最大开销。为了应对这一情况,处理器引入了ASID机制,大概就是为TLB中的表项打上一个进程的标记,这样就可以保留一部分进程对应的表项,利用这一机制可以降低TLB Miss发生的概率。
上一篇:git回退版本 简单易懂