1
内存
什么是内存
内存可存放数据,程序执行前放入内存中才能被CPU 处理。内存地址从0
开始,每个地址对应一个存储单元。按字节编址,每个存储单元占1个字
节,按字编址每个存储单元占1个字,16位
编译阶段
- 预处理阶段:处理以 # 开头的预处理命令
- 编译阶段:翻译成汇编文件
- 汇编阶段:将汇编文件翻译成可重定位目标文件
- 链接阶段:将可重定位目标文件和 printf.o
- 等单独预编译好的目标文件进行合并,得到最终的可执行目标文件
静态链接和动态链接的区别
- 静态链接就是在编译期间,由编译器和连接器将静态库集成到应用程序内
,并制作成目标文件以及可以独立运作的可执行文件。静态库一般是一些外
部函数与变量的集合 - 动态链接可以在首次载入的时候执行,也可以在程序开始执行的时候完成
。这个是由动态链接器完成,比方标准 C 库(libc.so) 通常就是动态链接
的,这样所有的程序可以共享同一个库,而不用分别进行封装
内存管理的作用
操作系统的内存管理主要负责内存的分配与回收(malloc 函数:申请内存,
free 函数:释放内存),另外地址转换也就是将逻辑地址转换成相应的物理
地址、内存空间的扩展等功能也是操作系统内存管理做的事情
内存管理机制
简单分为连续分配管理方式和非连续分配管理方式这两种。连续分配管理方式
是指为一个用户程序分配一个连续的内存空间,常见的如块式管理 。同样地
非连续分配管理方式允许一个程序使用的内存分布在离散或者说不相邻的内
存中,常见的如页式管理和段式管理
- 块式管理 :远古时代的计算机操系统的内存管理方式。将内存分为几个固
定大小的块,每个块中只包含一个进程。如果程序运行需要内存的话,操作系
统就分配给它一块,如果程序运行只需要很小的空间的话,分配的这块内存
很大一部分几乎被浪费了。这些在每个块中未被利用的空间,我们称之为碎
片 - 页式管理 :把主存分为大小相等且固定的一页一页的形式,页较小,相对
相比于块式管理的划分力度更大,提高了内存利用率,减少了碎片。页式管理
通过页表对应逻辑地址和物理地址 - 段式管理 : 页式管理虽然提高了内存利用率,但是页式管理其中的页实
际并无任何实际意义。 段式管理把主存分为一段段的,每一段的空间又要比
一页的空间小很多 。但是,最重要的是段是有实际意义的,每个段定义了一
组逻辑信息,例如有主程序段 MAIN、子程序段 X、数据段 D 及栈段 S 等。
段式管理通过段表对应逻辑地址和物理地址
动态分区分配的四种算法
- 首次适应算法
- 最佳适应算法
- 最坏适应算法
- 临近适应算法
分页系统
程序产生的地址是虚拟地址,构成了一个地址空间。虚拟地址不是被送到
内存总线上而是送到内存管理单元MMU,MMU把虚拟地址映射为物理地址。
虚拟空间被划分为若干页面,物理地址中页面对应的空间叫做页框。
MMU管理地址空间和物理空间的转换,如果一个页面没有被映射就是缺页
中断或缺页错误
分页
分页机制的思想是:通过映射,可以使连续的线性地址与物理地址相关联,
逻辑上连续的线性地址对应的物理地址可以不连续。 分页的作用- 将线
性地址转换为物理地址 - 用大小相同的页替换大小不同的段
快表和多级页表
在分页内存管理中,很重要的两点是:
- 虚拟地址到物理地址的转换要快
- 解决虚拟地址空间大,页表也会很大的问题
快表
为了解决虚拟地址到物理地址的转换速度,操作系统在页表方案基础之上引
入了快表来加速虚拟地址到物理地址的转换。我们可以把快表理解为一种特
殊的高速缓冲存储器,其中的内容是页表的一部分或者全部内容。作为页表
的Cache,它的作用与页表相似,但是提高了访问速率。由于采用页表做地
址转换,读写内存数据时CPU 要访问两次主存。有了快表,有时只要访问
一次高速缓冲存储器,一次主存,这样可加速查找并提高指令执行速度。
使用快表之后的地址转换流程是这样的:
- 根据虚拟地址中的页号查快表
- 如果该页在快表中,直接从快表中读取相应的物理地址
- 如果该页不在快表中,就访问内存中的页表,再从页表中得到物理地址,
同时将页表中的该映射表项添加到快表中 - 当快表填满后,又要登记新页时,就按照一定的淘汰策略淘汰掉快表中
的一个页
逻辑地址和物理地址
比如在C 语言中,指针里面存储的数值就可以理解成为内存里的一个地址,这个
地址也就是我们说的逻辑地址,逻辑地址由操作系统决定。物理地址指的是真实
物理内存中地址,更具体一点来说就是内存地址寄存器中的地址。物理地址是内
存单元真正的地址
CPU寻址
现代处理器使用的是一种称为虚拟寻址的寻址方式。使用虚拟寻址,CPU 需要将
虚拟地址翻译成物理地址,这样才能访问到真实的物理内存。 实际上完成虚拟
地址转换为物理地址转换的硬件是CPU 中含有一个被称为内存管理单元的硬件
为什么要有虚拟地址空间
没有虚拟地址空间的时候,程序都是直接访问和操作的都是物理内存
- 用户程序可以访问任意内存,寻址内存的每个字节,这样就很容易破坏操作
系统,造成操作系统崩溃 - 想要同时运行多个程序特别困难
通过虚拟地址访问内存有以下优势
- 程序可以使用一系列相邻的虚拟地址来访问物理内存中不相邻的大内存缓冲区
- 程序可以使用一系列虚拟地址来访问大于可用物理内存的内存缓冲区。当物
理内存的供应量变小时,内存管理器会将物理内存页(通常大小为 4 KB)保
存到磁盘文件。数据或代码页会根据需要在物理内存与磁盘之间移动 - 不同进程使用的虚拟地址彼此隔离。一个进程中的代码无法更改正在由另一
进程或操作系统使用的物理内存
虚拟内存
虚拟内存
虚拟内存:每个程序有自己的地址空间,把这个空间分割为多块,每一块称为
页,每一页都有连续的地址范围,执行时这些页映射到物理内存。但并不是所
有的页都必须在内存中才能运行程序。当程序引用到一部分在物理内存中的地
址空间时,由硬件立刻进行必要的映射,当程序引用到一部分不在物理内存
中的地址空间时,由操作系统负责将缺失的部分装入物理内存并重新执行失
败的指令。虚拟内存允许程序不用将地址空间中的每一页都映射到物理内存
,也就是说一个程序不需要全部调入内存就可以运行,这使得有限的内存
运行大程序成为可能
地址空间
引入地址空间的概念就是为了在内存中同时运行多个程序并且互不影响,解决
了保护和重定位问题。地址空间是一个进程可以用于寻址内存的一套地址集合
。每个进程都有自己的地址空间,并且这个地址空间独立于其他进程的地址空
间
分页系统地址映射
内存管理单元(MMU)管理着地址空间和物理内存的转换,其中的页表存储着
页(程序地址空间)和页框(物理内存空间)的映射表。默认页和页框都是4
KB。一个虚拟地址分成两个部分,一部分存储页面号,一部分存储偏移量,
12位的偏移量可以为一个页内的全部4096个字节
页表
虚拟地址被划分为虚拟页号和偏移量。对于16位地址和4KB的页面来说,高4位
可以指定16个虚拟页面中的一页,低12位确定所选页面中的字节偏移量(0到
4095)。虚拟页号可以作为页表的索引,以找到虚拟页面对应的页表项,由页
表项找到页框号,把页框号拼接到偏移地址的高位形成物理地址。页表的目的
就是把虚拟页面映射为页框。页表项的结构如下
- 页框号:该页需要映射到的物理页
- 在/不在位:如果是1表示该表项有效可以使用,如果是0表示对应的虚拟
页面不在内存中,访问该页面会引起一个缺页中断 - 保护位:一个页运行什么类型的访问,可以使用3位,对应读、写或修改
- 修改位:重新分配页框时如果该页已经被修改需要写回磁盘,如果没有
被修改直接丢弃即可,有时也叫脏位 - 访问位:不管读还是写该页面被访问时都会设置访问位,不再使用的页
面比正在使用的页面更适合淘汰 - 高速缓存禁止位:禁止该页面被高速缓存,对于那些映射到设备寄存器
而不是常规内存的页面而言,比如操作系统等待IO设备对它刚发出的命令
做出响应,保证硬件是不断从设备总读取数据而不是访问旧的被高速缓存
的副本时使用
加速分页
加速分页需要解决两个问题
- 虚拟地址到物理地址的映射必须非常快,使用快表解决
- 如果虚拟地址空间很大,页表也会很大,使用多级页表或倒排页表解决
快表TLB
为了加快虚拟地址到物理地址的转换速度,操作系统在页表方案基础之上引
入了快表来加速虚拟地址到物理地址的转换。我们可以把快表理解为一种特
殊的高速缓冲存储器,其中的内容是页表的一部分或者全部内容。作为页表
的Cache,它的作用与页表相似,但是提高了访问速率。由于采用页表做地
址转换,读写内存数据时CPU 要访问两次主存。有了快表,有时只要访问
一次高速缓冲存储器,一次主存,这样可加速查找并提高指令执行速度。
使用快表之后的地址转换流程是这样的:
- 根据虚拟地址中的页号查快表
- 如果该页在快表中,直接从快表中读取相应的物理地址
- 如果该页不在快表中,就访问内存中的页表,再从页表中得到物理地址,
同时将页表中的该映射表项添加到快表中。软失效是页不在TLB在内存中,
硬失效是页不在内存在磁盘中 - 当快表填满后,又要登记新页时,就按照一定的淘汰策略淘汰掉快表中
的一个表项
多级页表
引入多级页表是避免把全部页表一直保存在内存中,通过一个顶级页表为真
正有用的页表提供索引,对于没有用到的页表就没有保存在内存中,比如页
有上百万个,顶级页表有1024个表项,每个表项表示一个4M的地址范围,
每级页表负责若干位的地址转换,这里只需要保存3个二级页表,顶级页
表中其余表项都被设置为0,当访问它们时强制产生一个缺页中断
倒排页表
每个页框对应一个表项,而不是每个虚拟页面对应一个表项,在这种方法中
,虚拟地址的页号部分使用一个简单的散列函数映射到散列表中。散列表包
含一个指向倒排表的指针,而倒排表中含有页表项。表项记录哪一个(进程
,虚拟页面)对定位于该页框,这样可以节省大量空间,但是从虚拟地址
到物理地址的转换会非常困难,当进程n访问虚拟页面p时,硬件不再能通
过把p当作指向页表的一个索引来查找物理页框。取而代之的是,它必须搜
索整个倒排页表来查找某一个表项(n,p)。此外该搜索必须对每一个内
存访问操作都要执行一次,而不仅仅是在发生缺页中断时执行。
一种解决方法是使用TLB。如果TLB能够记录所有频繁使用的页面,地址转换
就可能变得像通常的页表一样快。但是,当发生TLB 失效时,需要用软件搜
索整个倒排页表。一个可行的实现该搜索的方法是建立一张散列表,用虚拟
地址来散列。当前所有在内存中的具有相同散列值的虚拟页面被链接在一起
,如果散列表中的槽数与机器中物理页面数一样多,那么散列表的冲突链
的平均长度将会是1个表项,这将会大大提高映射速度。一旦页框号被找
到,新的(虚拟页号,物理页框号)对就会被装载到TLB中
局部性原理
- 时间上的局部性:最近被访问的页在不久的将来还会被访问
- 空间上的局部性:内存中被访问的页周围的页也很可能被访问
- 高速缓存技术:频繁使用的数据放到更高速的缓存器中
虚拟存储器
虚拟存储器又叫做虚拟内存,属于同一个概念。
基于局部性原理,在程序装入时,可以将程序的一部分装入内存,而将其他部
分留在外存,就可以启动程序执行。由于外存往往比内存大很多,所以我们运
行的软件的内存大小实际上是可以比计算机系统实际的内存大小大的。在程序
执行过程中,当所访问的信息不在内存时,由操作系统将所需要的部分调入
内存,然后继续执行程序。另一方面,操作系统将内存中暂时不使用的内容
换到外存上,从而腾出空间存放将要调入内存的信息。这样,计算机好像为
用户提供了一个比实际内存大的多的存储器——虚拟存储器
虚拟内存技术的实现
虚拟内存的实现需要建立在离散分配的内存管理方式的基础上。 虚拟内存的
实现有以下三种方式
- 请求分页存储管理 :建立在分页管理之上,为了支持虚拟存储器功能而
增加了请求调页功能和页面置换功能。请求分页是目前最常用的一种实现虚
拟存储器的方法。请求分页存储管理系统中,在作业开始运行之前,仅装入
当前要执行的部分段即可运行。假如在作业运行的过程中发现要访问的页面
不在内存,则由处理器通知操作系统按照对应的页面置换算法将相应的页
面调入到主存,同时操作系统也可以将暂时不用的页面置换到外存中 - 请求分段存储管理 :建立在分段存储管理之上,增加了请求调段功能、分
段置换功能。请求分段储存管理方式就如同请求分页储存管理方式一样,在作
业开始运行之前,仅装入当前要执行的部分段即可运行;在执行过程中,可
使用请求调入中断动态装入要访问但又不在内存的程序段;当内存空间已满
,而又需要装入新的段时,根据置换功能适当调出某个段,以便腾出空间而
装入新的段 - 请求段页式存储管理
页面置换算法
在发送缺页中断时,需要选择一个页面,将其换出内存,如果每次都选择
不常使用的页面会提升系统性能,如果一个频繁使用的页面被置换出去会
带来不必要的开销,此时如果内存已无空闲空间,系统必须从内存中调出
一个页面到磁盘对换区中来腾出空间
- 最佳置换算法OPT 所选择的被换出的页面将是最长时间内不再被访问,
通常可以保证获得最低的缺页率,是一种理论上的算法,因为无法知道一个
页面多长时间不再被访问 - 先进先出置换算法FIFO 选择换出的页面是最先进入的页面。该算法会
将那些经常被访问的页面换出,导致缺页率升高 - 最近最久未使用置换算法LRU 需要在内存中维护一个所有页面的链表。
当一个页面被访问时,将这个页面移到链表表头。这样就能保证链表表尾
的页面是最近最久未访问的 - 最近未使用NRU 每个页面都有两个状态位:R 与M,当页面被访问时设置
页面的R=1,当页面被修改时设置 M=1。其中R 位会定时被清零。优先换出被
修改并且没有再被访问的页面 - 第二次机会置换算法 FIFO算法可能会把经常使用的页面置换出去,为了
避免这一问题,对该算法做一个简单的修改:当页面被访问(读或写)时设
置该页面的R 位为1。需要替换的时候,检查最老页面的R 位。如果R 位是0
,那么这个页面既老又没有被使用,可以立刻置换掉;如果是1,就将R 位
清0,并把该页面放到链表的尾端,修改它的装入时间使它就像刚装入的一
样,然后继续从链表的头部开始搜索 - 时钟置换算法CLOCK 第二次机会算法需要在链表中移动页面,降低了效
率。时钟算法使用环形链表将页面连接起来,再使用一个指针指向最老的页
面。当发生缺页中断时首先检查表针指向的页面,如果R 位是0就淘汰该页
面,将新页面插入这个位置,然后将表针前移一个位置,如果R为是1就清
除并继续前移,直到找到R位为0的页面
分段
虚拟内存采用的是分页技术,也就是将地址空间划分成固定大小的页,每一
页再与内存进行映射。分段的做法是把每个表分成段,一个段构成一个独立
的地址空间。每个段的长度可以不同,并且可以动态增长。分段情况一个地
址分为两部分,一个段号和一个段内地址。段是一个逻辑实体,会产生外
部碎片
分页和分段的区别
相同点
- 分页机制和分段机制都是为了提高内存利用率,较少内存碎片
- 页和段都是离散存储的,所以两者都是离散分配内存的方式。但是每个页
和段中的内存是连续的
不同点
- 段是信息的逻辑单位,它是根据用户的需要划分的,因此段对用户是可见的
- 页是信息的物理单位,是为了管理主存的方便而划分的,对用户是透明的
- 分页主要用于实现虚拟内存,从而获得更大的地址空间,将用户程序地址空
间分为若干固定大小的区域 - 分段主要是为了使程序和数据可以被划分为逻辑上独立的地址空间并且有助
于共享和保护 - 段的大小不固定,由它所完成的功能决定,页大大小固定,由系统决定
- 分段是二维的,如代码段,数据段,堆栈段,这样每个进程有一个二维地址
空间,相互独立,互不干扰
文件系统
IO模型
当应用程序发起I/O 调用后,会经历两个步骤:
- 内核等待I/O 将数据加载到内核缓存
- 内核将数据从内核空间拷贝到用户空间
同步和异步,阻塞和非阻塞
- 同步和异步的概念描述的是用户线程与内核的交互方式
- 阻塞和非阻塞的概念描述的是用户线程调用内核IO操作的方式
同步和异步
- 同步
同步方法调用一旦开始,调用者必须等到方法调用返回后,才能继续后续的行为 - 异步
异步方法调用更像一个消息传递,一旦开始,方法调用就会立即返回,调用者就
可以继续后续的操作。而异步方法通常会在另外一个线程中,“真实”地执行着。
整个过程,不会阻碍调用者的工作。被调用者通过状态来通知调用者,或者通过
回调函数来处理这个调用
阻塞和非阻塞
强调的是程序在等待调用结果(消息,返回值)时的状态. 阻塞调用是指调用
结果返回之前,当前线程会被挂起。调用线程只有在得到结果之后才会返回。非
阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程。 对于同步调
用来说,很多时候当前线程还是激活的状态,只是从逻辑上当前函数没有返回
而已,即同步等待时什么都不干,白白占用着资源。
用户空间和内核空间
我们知道现在操作系统都是采用虚拟存储器,那么对32 位操作系统而言,它
的寻址空间(虚拟存储空间)为4G(2的32次方)。操心系统的核心是内核,
独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设
备的所有权限。为了保证用户进程不能直接操作内核,保证内核的安全,操
心系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。
将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF),供内核使用,
称为内核空间,将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF)
,供各个进程使用,称为用户空间。每个进程可以通过系统调用进入内核,
因此Linux内核由系统内的所有进程共享。于是从具体进程的角度来看,每
个进程可以拥有4G字节的虚拟空间。
有了用户空间和内核空间,整个linux内部结构可以分为三部分,从最底层
到最上层依次是:硬件–>内核空间–>用户空间。
- 内核空间中存放的是内核代码和数据,而进程的用户空间中存放的是用
户程序的代码和数据。不管是内核空间还是用户空间,它们都处于虚拟空间
中。用户态其所处于占有的处理器是可被抢占的,内核态所占有的处理器是
不允许被抢占的 - Linux使用两级保护机制:0级供内核使用,3级供用户程序使用。
常见的IO 模型
为了 OS 的安全性等的考虑,进程是无法直接操作 I/O 设备的,其必须通过系
统调用请求内核来协助完成I/O动作,而内核会为每个I/O设备维护一个buffer。
整个请求过程为: 用户进程发起请求,内核接受到请求后,从I/O设备中获取数
据到buffer中,再将buffer中的数据copy到用户进程的地址空间,该用户进程
获取到数据后再响应客户端。
在整个请求过程中,数据输入至buffer需要时间,而从buffer复制数据至进程
也需要时间。因此根据在这两段时间内等待方式的不同,I/O动作可以分为以下
五种模式:
- 阻塞式 I/O
- 非阻塞式 I/O
- I/O 复用(select 和 poll)
- 信号驱动式 I/O(SIGIO)
- 异步 I/O(AIO)
IO 多路复用模型,通过减少无效的系统调用,减少了对CPU 资源的消耗。Java中
的NIO,有一个非常重要的选择器 (Selector) 的概念,也可以被称为多路复用
器。通过它只需要一个线程便可以管理多个客户端连接。当客户端数据到了之后,
才会为其服务,如果有一个文件描述符就绪,则返回,否则阻塞直到超时
阻塞式I/O
当用户进程调用了recvfrom这个系统调用,内核就开始了IO的第一个阶段:
等待数据准备。对于network io 来说,很多时候数据在一开始还没有到达
(比如,还没有收到一个完整的UDP包),这个时候内核就要等待足够的数
据到来。而在用户进程这边,整个进程会被阻塞。当内核一直等到数据准备
好了,它就会将数据从内核中拷贝到用户内存,然后内核返回结果,用户
进程才解除block的状态,重新运行起来。所以blocking IO 的特点就是
在IO执行的两个阶段都被block了。
在阻塞的过程中,其它应用进程还可以执行,因此阻塞不意味着整个操作系
统都被阻塞
非阻塞式I/O
当用户进程调用recvfrom 时,系统不会阻塞用户进程,而是立刻返回一个
ewouldblock错误,从用户进程角度讲 ,并不需要等待,而是马上就得到
了一个结果。用户进程判断标志是ewouldblock时,就知道数据还没准备好
,于是它就可以去做其他的事了,于是它可以再次发送recvfrom,一旦内
核中的数据准备好了。并且又再次收到了用户进程的system call,那么
它马上就将数据拷贝到了用户内存,然后返回。
当一个应用程序在一个循环里对一个非阻塞调用recvfrom,我们称为轮询。
应用程序不断轮询内核,看看是否已经准备好了某些操作。这通常是浪费
CPU时间,但这种模式偶尔会遇到。
I/O 多路复用
IO multiplexing这个词可能有点陌生,但是如果我说select,epoll,大
概就都能明白了。有些地方也称这种IO 方式为event driven IO。我们都知
道,select/epoll的好处就在于单个process 就可以同时处理多个网络连
接的IO。它的基本原理就是select/epoll这个function会不断的轮询所负
责的所有socket,当某个socket有数据到达了,就通知用户进程。
当用户进程调用了select,那么整个进程会被block,而同时内核会“监视”
所有select负责的socket,当任何一个socket中的数据准备好了,select
就会返回。这时候用户进程再调用read操作,将数据从内核拷贝到用户进程。
这个图和blocking IO 的图其实并没有太大的不同,事实上,还更差一些。
因为这里需要使用两个system call (select和recvfrom),而blocking
IO只调用了一个system call (recvfrom)。但是用select的优势在于它可
以同时处理多个connection。(多说一句。所以如果处理的连接数不是很高
的话,使用select/epoll的web server不一定比使用multi-threading
+blocking IO的web server性能更好,可能延迟还更大。select/epoll
的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。)
在IO multiplexing Model中,实际中对于每一个socket,一般都设置成
为non-blocking,但是,如上图所示,整个用户的process其实是一直被
block的。只不过process是被select这个函数block,而不是被socket
IO给block。
recvfrom一般用于UDP协议中,但是如果在TCP中connect函数调用后也可以
用。用于从(已连接)套接口上接收数据,并捕获数据发送源的地址。
I/O就是指的我们网络I/O,多路指多个TCP连接(或多个Channel),复用指复
用一个或少量线程。串起来理解就是很多个网络I/O复用一个或少量的线程来
处理这些连接。发明它的目的是尽量多的提高服务器的吞吐能力。实现一个线
程监控多个IO请求,哪个IO有请求就把数据从内核拷贝到进程缓冲区,拷贝
期间是阻塞的。
文件描述符fd
Linux的内核将所有外部设备都可以看做一个文件来操作。那么我们对与外
部设备的操作都可以看做对文件进行操作。我们对一个文件的读写,都通过
调用内核提供的系统调用;内核给我们返回一个fd 文件描述符。而对一个
socket的读写也会有相应的描述符,称为socketfd(socket描述符)。描
述符就是一个数字,指向内核中一个结构体(文件路径,数据区,等一些
属性)。那么我们的应用程序对文件的读写就通过对描述符的读写完成。
select
基本原理:select函数监视的文件描述符分3类,分别是writefds、readfds
、和exceptfds。调用后select 函数会阻塞,直到有描述符就绪(有数据 可
读、可写、或者有except),或者超时(timeout指定等待时间,如果立即返
回设为null即可),函数返回。成功调用返回结果大于0,出错返回结果为-1
,超时返回结果为0。当select函数返回后,可以通过遍历fdset,来找到就
绪的描述符。缺点如下
- select最大的缺陷就是单个进程所打开的FD是有一定限制的,它由FDSETSIZE
设置,32位机默认是1024个,64位机默认是2048。一般来说这个数目和系统内存
关系很大,”具体数目可以cat /proc/sys/fs/file-max察看”。32位机默认是
1024个。64位机默认是2048 - 对socket进行扫描时是线性扫描,即采用轮询的方法,效率较低。当套接
字比较多的时候,每次select()都要通过遍历FDSETSIZE个Socket来完成调
度,不管哪个Socket是活跃的,都遍历一遍。这会浪费很多CPU时间。”如果
能给套接字注册某个回调函数,当他们活跃时,自动完成相关操作,那就避
免了轮询”,这正是epoll与kqueue做的。 - 需要维护一个用来存放大量fd的数组,这样会使得用户空间和内核空间在
传递该结构时复制开销大。 - select 会修改描述符,而 poll 不会
poll
基本原理:poll本质上和select没有区别,它将用户传入的数组拷贝到内核
空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中
加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前
进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。这个过程
经历了多次无谓的遍历。
它没有最大连接数的限制,原因是它是基于链表来存储的,但是同样有一个
缺点:
- 大量的fd的数组被整体复制于用户态和内核地址空间之间,而不管这
样的复制是不是有意义。 - poll还有一个特点是“水平触发”,如果报告了fd后,没有被处理,那么
下次poll时会再次报告该fd。 - 如果一个线程对某个描述符调用了 select 或者 poll,另一个线程关闭
了该描述符,会导致调用结果不确定
注意:从上面看,select和poll都需要在返回后,通过遍历文件描述符来获
取已经就绪的socket。事实上同时连接的大量客户端在一时刻可能只有很少
的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降。
epoll
epoll是在2.6内核中提出的,是之前的select和poll的增强版本。相对于
select和poll来说,epoll更加灵活,没有描述符限制。epoll使用一个文
件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的
一个事件表中,这样在用户空间和内核空间的copy只需一次。
基本原理:epoll支持水平触发和边缘触发,最大的特点在于边缘触发,它
只告诉进程哪些fd刚刚变为就绪态,并且只会通知一次。还有一个特点是,
epoll使用“事件”的就绪通知方式,通过epollctl注册fd,一旦该fd就绪
,内核就会采用类似callback的回调机制来激活该fd,epollwait便可以
收到通知。
epoll_ctl() 用于向内核注册新的描述符或者是改变某个文件描述符的状态
。已注册的描述符在内核中会被维护在一棵红黑树上,通过回调函数内核会将
I/O 准备好的描述符加入到一个链表中管理,进程调用epoll_wait() 便可
以得到事件完成的描述符。
从上面的描述可以看出,epoll 只需要将描述符从进程缓冲区向内核缓冲区
拷贝一次,并且进程不需要通过轮询来获得事件完成的描述符。
epoll的优点:
- 没有最大并发连接的限制,能打开的FD的上限远大于1024(1G的内存上
能监听约10万个端口) - 效率提升,不是轮询的方式,不会随着FD数目的增加效率下降。
- 只有活跃可用的FD才会调用callback函数;即Epoll最大的优点就在于
它只管你“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,
Epoll的效率就会远远高于select和poll。 - 内存拷贝,利用mmap()文件映射内存加速与内核空间的消息传递;即epoll
使用mmap减少复制开销 - epoll 是线程安全的,不仅告诉你sock组里面数据,还会告诉你具体哪
个sock有数据,你不用自己去找了。一个线程调用了epoll_wait() 另一个
线程关闭了同一个描述符也不会产生像select 和 poll 的不确定情况
工作模式
epoll 的描述符事件有两种触发模式:LT(level trigger)和ET(edge
trigger)
- LT 模式
当 epoll_wait() 检测到描述符事件到达时,将此事件通知进程,进程可以
不立即处理该事件,下次调用 epoll_wait() 会再次通知进程。是默认的一
种模式,并且同时支持 Blocking 和 No-Blocking。 - ET 模式
和LT模式不同的是通知之后进程必须立即处理事件,下次再调用epoll_wait()
时不会再得到事件到达的通知。
很大程度上减少了 epoll 事件被重复触发的次数,因此效率要比 LT 模式高。
只支持 No-Blocking,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理
多个文件描述符的任务饿死
信号驱动I/O
应用进程使用 sigaction 系统调用,内核立即返回,应用进程可以继续
执行,也就是说等待数据阶段应用进程是非阻塞的。内核在数据到达时向
应用进程发送 SIGIO 信号,应用进程收到之后在信号处理程序中调用
recvfrom 将数据从内核复制到应用进程中
异步I/O
一般来说,这些函数通过告诉内核启动操作并在整个操作(包括内核的数
据到缓冲区的副本)完成时通知我们。这个模型和前面的信号驱动 I/O模
型的主要区别是,在信号驱动的I/O中,内核告诉我们何时可以启动I/O
操作,但是异步I/O时,内核告诉我们何时I/O操作完成。
当用户进程向内核发起某个操作后,会立刻得到返回,并把所有的任务都
交给内核去完成(包括将数据从内核拷贝到用户自己的缓冲区),内核完
成之后,只需返回一个信号告诉用户进程已经完成就可以了。
异步一定是非阻塞的。应用无需等待数据和复制数据到指定内存中,全都是
由后台完成,然后通过回调函数告诉应用。只有用户线程在操作IO的时候根
本不去考虑IO的执行全部都交给CPU去完成,而自己只等待一个完成信号的
时候,才是真正的异步IO
应用场景
- select 应用场景
select 的timeout 参数精度为微秒,而poll 和epoll 为毫秒,因此select
更加适用于实时性要求比较高的场景,比如核反应堆的控制。select 可移植性
更好,几乎被所有主流平台所支持 - poll 应用场景
poll 没有最大描述符数量的限制,如果平台支持并且对实时性要求不高,应该
使用 poll 而不是 select - epoll 应用场景
只需要运行在 Linux 平台上,有大量的描述符需要同时轮询,并且这些连接最
好是长连接。需要同时监控小于1000 个描述符,就没有必要使用 epoll,因为
这个应用场景下并不能体现 epoll 的优势。
需要监控的描述符状态变化多,而且都是非常短暂的,也没有必要使用 epoll。
因为 epoll 中的所有描述符都存储在内核中,造成每次需要对描述符的状态改
变都需要通过epoll_ctl()进行系统调用,频繁系统调用降低效率。并且epoll
的描述符存储在内核,不容易调试
阻塞式I/O和I/O复用的区别
- 阻塞式I/O和I/O复用,两个阶段都阻塞,等待数据和将数据复制到用户进
程这两个阶段都是阻塞的 - 虽然第一阶段都是阻塞,但是阻塞式I/O如果要接收更多的连接,就必须创
建更多的线程。I/O复用模式下在第一个阶段大量的连接统统都可以过来直接注
册到Selector复用器上面,同时只要单个或者少量的线程来循环处理这些连
接事件就可以了,一旦达到“就绪”的条件,就可以立即执行真正的I/O操作。
这就是I/O复用与传统的阻塞式I/O最大的不同
缓存 IO
缓存 IO 又被称作标准 IO,大多数文件系统的默认 IO 操作都是缓存 IO。
在 Linux 的缓存 IO 机制中,操作系统会将 IO 的数据缓存在文件系统的
页缓存( page cache )中,也就是说,数据会先被拷贝到操作系统内核
的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。
缓存 IO 的缺点:数据在传输过程中需要在应用程序地址空间和内核进行多
次数据拷贝操作,这些数据拷贝操作所带来的CPU以及内存开销是非常大的。
异步是什么
对于异步来说,用户进行读或者写后,将立刻返回,由内核去完成数据读取
以及拷贝工作,完成后通知用户,并执行回调函数(用户提供的callback
),此时数据已从内核拷贝到用户空间,用户线程只需要对数据进行处理
即可,不需要关注读写,用户不需要等待内核对数据的复制操作,用户在
得到通知时数据已经被复制到用户空间
同步的异步的区别
- 同步跟异步的区别在于数据从内核空间拷贝到用户空间是否由用户线程
完成,这里又分为同步阻塞跟同步非阻塞两种 - 同步阻塞 此时一个线程维护一个连接,该线程完成数据到读写跟处理
到全部过程,数据读写时时线程是被阻塞的 - 同步非阻塞 非阻塞的意思是用户线程发出读请求后,读请求不会阻塞
当前用户线程,不过用户线程还是要不断的去主动判断数据是否准备OK了
。此时还是会阻塞等待内核复制数据到用户进程。他与同步BIO区别是使用
一个连接全程等待 - 同步在发出一个调用时,在没有得到结果之前,该调用就不返回。但是一
旦调用返回,就得到返回值了。换句话说,就是由调用者主动等待这个调用
的结果 - 异步调用在发出之后,这个调用就直接返回了,所以没有返回结果。换
句话说,当一个异步过程调用发出后,调用者不会立刻得到结果。而是在
调用发出后,被调用者通过状态、通知来通知调用者,或通过回调函数处
理这个调用
多路复用的那三种方式的区别
- select
- select能监控的描述符个数由内核中的FD_SETSIZE限制,仅为1024,这也
是select最大的缺点,因为现在的服务器并发量远远不止1024 - 每次调用select都会线性扫描所有描述符的状态,在select结束后,用户
也要线性扫描fd_set数组才知道哪些描述符准备就绪,等于说每次调用复杂
度都是O(n)的,在并发量大的情况下,每次扫描都是相当耗时的,很有可
能有未处理的连接等待超时 - 每次调用select都要在用户空间和内核空间里进行内存复制fd描述符等信息
- poll
- poll使用pollfd结构来存储fd,突破了select中描述符数目的限制
- 与select的后两点类似,poll仍然需要将pollfd数组拷贝到内核空间,之
后依次扫描fd的状态,整体复杂度依然是O(n)的,在并发量大的情况下服
务器性能会快速下降
- epoll
- epoll维护的描述符数目不受到限制,而且性能不会随着描述符数目的增加
而下降 - epoll在传递内核与用户空间的消息时使用了内存共享,而不是内存拷贝,
这也使得epoll的效率比poll和select更高
零拷贝
考虑这样一种常用的情形:你需要将静态内容(类似图片、文件)展示给用户。
那么这个情形就意味着你需要先将静态内容从磁盘拷贝出来放到一个内存buf
中,然后将这个buf通过socket传输给用户,进而用户或者静态内容的展示。
这看起来再正常不过了,但是实际上这是很低效的流程,我们把上面的这种
情形抽象成下面的过程:
- read(file, tmp_buf, len);
- write(socket, tmp_buf, len);
在这个过程中文件A的经历了4次copy的过程:
- 首先,调用read时,文件A拷贝到了kernel模式;DMA过程中CPU不需要参
与数据的读写,而是DMA处理器直接将硬盘数据通过总线传输到内存中 - 之后,CPU控制将kernel模式数据copy到user模式下;
- 调用write时,先将user模式下的内容copy到kernel模式下的socket的
buffer中; - 最后将kernel模式下的socket buffer的数据copy到网卡设备中传送;
从上面的过程可以看出,数据白白从kernel模式到user模式走了一圈,浪费
了2次copy(第一次,从kernel模式拷贝到user模式;第二次从user模式再
拷贝回kernel模式,即上面4次过程的第2和3步骤。)。
而且上面的过程中kernel和user模式的上下文的切换也是4次。
你可以用一种叫做Zero-Copy的技术来去掉这些无谓的copy。应用程序用零拷
贝来请求kernel 直接把disk的data传输给socket,而不是通过应用程序传
输。Zero-Copy大大提高了应用程序的性能,并且减少了kernel和user模式
上下文的切换。
“零拷贝”是指计算机操作的过程中,CPU不需要为数据在内存之间的拷贝消耗
资源。而它通常是指计算机在网络上发送文件时,不需要将文件内容拷贝到用
户空间(User Space)而直接在内核空间(Kernel Space)中传输到网络
的方式。这里的零拷贝其实是根据内核状态划分的,在这里没有经过CPU的拷
贝,数据在用户态的状态下,经历了零次拷贝,所以才叫做零拷贝,但不是
说不拷贝。
内存映射方式I/O
mmap 是使用的系统调用方法,这种方式的I/O原理就是将用户缓冲区(user
buffer)的内存地址和内核缓冲区(kernel buffer)的内存地址做一个映
射,也就是说系统在用户态可以直接读取并操作内核空间的数据。
- mmap()系统调用首先会使用DMA的方式将磁盘数据读取到内核缓冲区,然后
通过内存映射的方式,使用户缓冲区和内核读缓冲区的内存地址为同一内存地
址,也就是说不需要CPU再将数据从内核读缓冲区复制到用户缓冲区。 - 当使用write()系统调用的时候,cpu将内核缓冲区(等同于用户缓冲区)
的数据直接写入到网络发送缓冲区(socket buffer),然后通过DMA的方式
将数据传入到网卡驱动程序中准备发送。
可以看到这种内存映射的方式减少了CPU的读写次数,但是用户态到内核态的切
换(上下文切换)依旧有四次,同时需要注意在进行这种内存映射的时候,有
可能会出现并发线程操作同一块内存区域而导致的严重的数据不一致问题,所
以需要进行合理的并发编程来解决这些问题。
零拷贝概述
Zero-Copy技术省去了将操作系统的read buffer拷贝到程序的buffer,以及
从程序buffer 拷贝到socket buffer 的步骤,直接将read buffer 拷贝到
socket buffer. Java NIO中的FileChannal.transferTo()方法就是这样
的实现,这个实现是依赖于操作系统底层的sendFile()实现的
1 | public void transferTo(long position, long count, WritableByteChannel target); |
使用了Zero-Copy技术之后,整个过程如下
- transferTo()方法使得文件A的内容直接拷贝到一个read buffer(kernel
buffer)中; - 然后数据(kernel buffer)拷贝到socket buffer中。
- 最后将socket buffer中的数据拷贝到网卡设备(protocol engine)中传
输;这显然是一个伟大的进步:这里把上下文的切换次数从4次减少到2次,同时
也把数据copy的次数从4次降低到了3次。
进阶
Linux 2.1内核开始引入了sendfile函数,用于将文件通过socket传送
1 | sendfile(socket, file, len); |
该函数通过一次系统调用完成了文件的传送,减少了原来read/write方式的模
式切换。此外更是减少了数据的copy,通过sendfile传送文件只需要一次系统
调用,当调用sendfile时
- 首先(通过DMA)将数据从磁盘读取到kernel buffer中;
- 然后将kernel buffer拷贝到socket buffer中;
- 最后将socket buffer中的数据copy到网卡设备中发送
sendfile与read/write模式相比,少了一次copy。但是从上述过程中也可以发
现从kernel buffer中将数据copy到socket buffer是没有必要的。Linux2.4
内核对sendfile做了改进
- 将文件拷贝到kernel buffer中;
- 向socket buffer中追加当前要发生的数据在kernel buffer中的位置和
偏移量; - 根据socket buffer中的位置和偏移量直接将kernel buffer的数据copy
到网卡设备中;
经过上述过程,数据只经过了2次copy就从磁盘传送出去了。这个才是真正的
Zero-Copy(这里的零拷贝是针对kernel 来讲的,数据在kernel 模式下是
Zero-Copy)。正是Linux2.4的内核做了改进,Java中的TransferTo()实现
了Zero-Copy,在这种模式下,是没有一次CPU进行数据拷贝的
零拷贝技术分类
Linux 中的零拷贝技术主要有下面这几种:
- 直接 I/O:对于这种数据传输方式来说,应用程序可以直接访问硬件存储
,操作系统内核只是辅助数据传输:这类零拷贝技术针对的是操作系统内核并
不需要对数据进行直接处理的情况,数据可以在应用程序地址空间的缓冲区和
磁盘之间直接进行传输,完全不需要Linux操作系统内核提供的页缓存的支持。 - 在数据传输的过程中,避免数据在操作系统内核地址空间的缓冲区和用户
应用程序地址空间的缓冲区之间进行拷贝。有的时候,应用程序在数据进行传
输的过程中不需要对数据进行访问,那么将数据从 Linux 的页缓存拷贝到用
户进程的缓冲区中就可以完全避免,传输的数据在页缓存中就可以得到处理。
在某些特殊的情况下,这种零拷贝技术可以获得较好的性能。Linux中提供类
似的系统调用主要有 mmap(),sendfile() 以及 splice()。 - 对数据在 Linux 的页缓存和用户进程的缓冲区之间的传输过程进行优化。
该零拷贝技术侧重于灵活地处理数据在用户进程的缓冲区和操作系统的页缓存
之间的拷贝操作。这种方法延续了传统的通信方式,但是更加灵活。在Linux中
,该方法主要利用了写时复制技术。
文件系统
文件就是一组有意义的信息集合
文件属性
- 文件名 同一目录不允许重名
- 标识符 唯一用来区分文件
- 类型 文件的类型
- 位置 文件的存放路径
- 大小 文件大小
- 时间 创建时间、上次修改时间
- 保护信息 对文件进行保护的访问控制信息