操作系统地址空间
操作系统的地址空间是一个抽象概念,表示操作系统在运行时能够管理和访问的内存范围。它提供了一种将物理内存映射到逻辑地址的机制,使得程序能够在一个独立于物理硬件的虚拟地址空间中执行。
1. 用户空间与内核空间
操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操心系统将地址空间划分为两部分,一部分为内核空间,一部分为用户空间。
对于32位操作系统而言,寻址空间(虚拟存储空间)为4G(2的32次方),linux系统将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF)供内核使用,称为内核空间,而将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF),供各个进程使用,称为用户空间。
对于64位操作系统而言,寻址空间为256TB(2的64次方),用户空间(应用程序)的虚拟地址范围为:从0x0000000000000000到0x00007fffffffffff,共128TB。内核空间的虚拟地址范围为:从0xffff800000000000到0xffffffffffffffff,共128TB。实际可用的物理内存规模也远小于该数字,但具备了极大扩展能力。64位系统通过将内核空间和用户空间的虚拟地址隔离到高低非连续区域,实现了安全的内存隔离。同时也提供了超大的寻址容量来支撑未来需求。
1.1. 内核空间
- 内核空间是操作系统专用的内存区域,包含操作系统的内核代码、数据结构和驱动程序等。
- 在内核空间运行的代码拥有最高的特权级别,可以执行特权指令,直接访问硬件资源。
- 用户程序无法直接访问内核空间,需要通过系统调用请求内核执行一些特权操作。
1.2. 用户空间
- 用户空间是为用户进程分配的内存区域,包含用户程序的代码、数据、堆和栈等。
- 用户程序在用户空间内执行,具有较低的特权级别。
- 用户程序通过系统调用向内核发出请求,以便执行一些需要特权级别的操作。
1.3. 进程切换
为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换。因此可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的。
从一个进程的运行转到另一个进程上运行,这个过程中经过下面这些变化:
- 保存处理机上下文,包括程序计数器和其他寄存器。
- 更新PCB信息。
- 把进程的PCB移入相应的队列,如就绪、在某事件阻塞等队列。
- 选择另一个进程执行,并更新其PCB。
- 更新内存管理的数据结构。
- 恢复处理机上下文。
1.3.1. PCB
PCB(Process Control Block,进程控制块)是操作系统中用于管理和维护进程信息的数据结构。每个正在运行的进程都有一个对应的 PCB,其中存储了该进程的各种状态、上下文信息以及控制信息。PCB 通常由操作系统内核维护和管理,包含的主要信息有:
- 进程状态(Process State):
- 存储进程的当前状态,如运行(Running)、就绪(Ready)、阻塞(Blocked)等。
- 状态的变化由操作系统内核根据进程的执行情况进行管理。
- 程序计数器(Program Counter):
- 记录了进程下一条要执行的指令的地址。
- 在进程切换时,操作系统保存和恢复程序计数器的值。
- 寄存器集合(Register Set):
- 包括通用寄存器、程序状态寄存器等。
- 保存了进程在执行过程中的各种寄存器的当前值。
- 进程标识符(Process ID):
- 唯一标识一个进程的标识符。
- 操作系统使用进程标识符来管理和识别进程。
- 优先级(Priority):
- 进程的优先级信息,用于调度算法中的进程调度决策。
- 进程调度信息:
- 包括进程的调度状态、等待时间、运行时间等。
- 用于操作系统进行进程调度和资源分配。
- 进程控制信息:
- 包括进程所拥有的资源、权限等控制信息。
- 用于管理进程对系统资源的访问。
- 内存管理信息:
- 包括进程的内存分配情况,页表信息等。
- 用于操作系统进行内存管理。
- 文件描述符表(File Descriptor Table):
- 记录了进程打开的文件及其属性。
- 用于文件的读写和管理。
- 进程间通信信息:
- 记录了进程与其他进程之间进行通信的相关信息,如消息队列、共享内存等。
- 信号和处理器状态:
- 记录了进程当前对信号的处理方式和相关状态。
- 用于处理进程收到的信号。
PCB 提供了操作系统对进程的抽象和控制,允许操作系统在多任务环境中有效地管理和调度进程。当操作系统进行进程切换时,会保存当前运行进程的状态到其对应的 PCB 中,然后加载下一个要执行的进程的 PCB,从而实现进程的无缝切换。
2. 分页和分页
2.1. 分段(Segmentation):
- 采用分段机制将地址空间划分为不同的段,如代码段、数据段、堆、栈等。
- 每个段都有不同的权限和属性,以实现对程序和数据的不同保护和访问控制。
2.2. 分页(Paging):
- 采用分页机制将地址空间划分为固定大小的页面,通常为4KB。
- 操作系统通过页表来映射虚拟地址到物理地址,实现虚拟内存管理。
3. 栈和堆
3.1. 栈(Stack):
- 栈是一种后进先出(LIFO)的数据结构,用于存储函数调用时的局部变量、返回地址等。
- 栈空间由操作系统自动管理,通过栈指针进行操作。
3.2. 堆(Heap):
- 堆是一块用于动态分配内存的区域,由程序员手动管理。
- 通过堆管理函数(如
malloc、free)进行内存的分配和释放
4. 共享内存和内存映射
4.1. 共享内存:
- 用于实现进程间通信,允许多个进程共享同一块物理内存。
- 进程通过共享内存区域直接读写数据,避免了复制数据的开销。
4.2. 内存映射:
- 允许文件或设备映射到进程的地址空间,使得对文件的读写可以通过内存访问来完成。
- 通过
mmap等系统调用实现内存映射。
5. 文件描述符(File Descriptor)
linux系统中一切都可以看成文件,文件分为:普通文件、目录文件、链接文件、字符设备文件、块设备文件和套接口文件。分别通过字符-/d/l/c/b/s指代。
文件描述符是内核为了高效管理已经被打开的文件所创建的索引。其值通常为一个非负整数,用于指代被打开的文件,所有执行I/O操作的系统调用都通过文件描述符。
每个文件描述符会与一个打开的文件相对应
不同文件描述符也可能指向同一个文件
相同的文件可以被不同的进程打开,也可以在同一个进程中被打开多次
linux提供了三个表来维护文件描述符,分别是:进程级的文件描述符表,系统及的文件描述符表,文件系统的i-node表
在进程A中,文件描述符1和30都指向了同一个打开的文件句柄(#23),这可能是该进程多次对执行打开操作
进程A中的文件描述符2和进程B的文件描述符2都指向了同一个打开的文件句柄(#73),这种情况有几种可能
- 进程A和进程B可能是父子进程关系
- 进程A和进程B打开了同一个文件,且文件描述符相同(低概率事件)
- A、B中某个进程通过UNIX域套接字将一个打开的文件描述符传递给另一个进程。
- 进程A的描述符0和进程B的描述符3分别指向不同的打开文件句柄,但这些句柄均指向i-node表的相同条目(#1936),换言之,指向同一个文件。发生这种情况是因为每个进程各自对同一个文件发起了打开请求。同一个进程两次打开同一个文件,也会发生类似情况。
5.1.1. 缓存I/O
缓存I/O又被称作标准I/O,大多数文件系统的默认I/O操作都是缓存I/O。在Linux的缓存I/O机制中,数据先从磁盘复制到内核空间的缓冲区,然后从内核空间缓冲区复制到应用程序的地址空间。
读操作:操作系统检查内核的缓冲区有没有需要的数据,如果已经缓存了,那么就直接从缓存中返回;否则从磁盘中读取,然后缓存在操作系统的缓存中。
写操作:将数据从用户空间复制到内核空间的缓存中。这时对用户程序来说写操作就已经完成,至于什么时候再写到磁盘中由操作系统决定,除非显示地调用了sync同步命令
5.1.2. 阻塞与同步
阻塞、非阻塞说的是调用者。同步、异步说的是被调用者。调用blocking IO会一直block住对应的进程直到操作完成,而non-blocking IO在kernel还准备数据的情况下会立刻返回。synchronous IO做”IO operation”的时候会将process阻塞。按照这个定义,blocking IO,non-blocking IO,IO multiplexing都属于synchronous IO。asynchronous IO则不一样,当进程发起IO 操作之后,就直接返回再也不理睬了,直到kernel发送一个信号,告诉进程说IO完成。在这整个过程中,进程完全没有被block。non-blocking IO和asynchronous IO的区别还是很明显的。在non-blocking IO中,虽然进程大部分时间都不会被block,但是它仍然要求进程去主动的check,并且当数据准备完成以后,也需要进程主动的再次调用recvfrom来将数据拷贝到用户内存。而asynchronous IO则完全不同。它就像是用户进程将整个IO操作交给了他人(kernel)完成,然后他人做完后发信号通知。在此期间,用户进程不需要去检查IO操作的状态,也不需要主动的去拷贝数据。
5.1.2.1. 同步与异步
同步请求
A调用B,B的处理是同步的,在处理完之前他不会通知A,只有处理完之后才会明确的通知A。
异步请求
A调用B,B的处理是异步的,B在接到请求后先告诉A我已经接到请求了,然后异步去处理,处理完之后通过回调等方式再通知A。
同步和异步最大的区别就是被调用方的执行方式和返回时机。 同步指的是被调用方做完事情之后再返回,异步指的是被调用方先返回,然后再做事情,做完之后再想办法通知调用方。
5.1.2.2. 阻塞与非阻塞
阻塞请求
A调用B,A一直等着B的返回,别的事情什么也不干。
非阻塞请求
A调用B,A不用一直等着B的返回,先去忙别的事情了。
所以说,阻塞和非阻塞最大的区别就是在被调用方返回结果之前的这段时间内,调用方是否一直等待。 阻塞指的是调用方一直等待别的事情什么都不做。非阻塞指的是调用方先去忙别的事情。
5.1.3. Unix中的五种I/O模型
对于一次I/O访问read操作分为两个阶段
- 等待数据准备,数据被拷贝到操作系统内核的缓冲区
- 数据从内核缓冲区拷贝到用户态缓冲区
对于socket流而言 - 通常涉及到等待网络上的数据分组到达,也就是被复制到内核的某个缓冲区
- 把数据从内核缓冲区复制到应用进程缓冲区
5.1.3.1. 同步阻塞I/O
分为两个阶段,这两个阶段都必须完成后才能继续下一步操作,blocking IO的特点就是IO执行的两个阶段都被block了。
- 等待数据就绪。网络I/O中就是等待远端数据陆续抵达。数据从网络中或者从磁盘上被复制到内核缓冲区中。
- 数据拷贝。出于系统安全考虑,用户态的程序没有权限直接读取内核态内存,因此内核负责把内核态内存中的数据拷贝一份到用户态内存中

send函数是应用程序用来向TCP连接的另一端发送数据
recvfrom或recv函数是应用程序用来从TCP连接的另一端接收数据
5.1.3.2. 同步非阻塞I/O
非阻塞是对于主调方来说的,用户进程可以在阶段1的时候选择去做其他事情,通过轮询的方式看看内核缓冲区是否就绪。如果数据就绪,再执行阶段2,第2阶段的拷贝数据的整个过程,进程仍然是属于阻塞状态的。nonblocking IO的特点就是用户进程需要不断的主动轮询kernel数据好了没有。

5.1.3.3. I/O多路复用
I/O多路复用也称为时间驱动模型。I/O多路复用是指内核一旦发现进程指定的一个或者多个IO条件准备读取,它就通知该进程。目前支持I/O多路复用的系统调用有select、pselect、poll、epoll,一个进程可以监听多个描述符,一旦某个文件描述符fd就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。当用户进程调用了select,那么整个进程会被block,而同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。select/pselect/poll/epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。相比如同步非阻塞I/O,它的改进在于原本需要用户进程去轮询的事情交给了内核线程帮你完成,而且这个内核线程可以等待多个socket,能实现同时对多个I/O端口进行监听。所以,如果处理的连接数不是很高的话,使用select/epoll的web server不一定比使用多线程 + 阻塞IO的web server性能更好,可能延迟还更大。 也就是说,select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。高并发的程序一般使用同步非阻塞方式而非多线程 + 同步阻塞方式。在IO multiplexing Model中,实际中,对于每一个socket,一般都设置成为non-blocking,但是,如下图所示,整个用户的process其实是一直被block的。只不过process是被select这个函数block,而不是被socket IO给block。
- select: select可以先对要操作的描述文件符进行查询,查看是否目标描述符可以进行读、写或者错误操作,然后当文件描述符满足操作条件的时候才进行真正的I/O操作。函数select()返回值为0,-1或者一个大于1的整数值,当监视的文件集中有文件描述符符合要求,即读文件描述符集中的文件可读,写文件描述符集中的文件可写或者错误文件描述符中的文件发生错误时,返回值为大于0的正值;当超时的时候返回0;当发生错误的时候返回-1。
- pselect: 与select函数一致,除了超时时间结构是纳秒级的结构。不过Linux平台下内核调度的精度为10毫秒级,所以根本达不到设置的精度。
- poll: poll解决了select中fds集合大小1024的限制。但是,它并没改变大量描述符数组被整体复制于用户态和内核态的地址空间之间,以及个别描述符就绪触发整体描述符集合的遍历的低效问题。poll随着监控的socket集合的增加性能线性下降,poll不适合用于大并发场景。
- epoll: 是select和poll的增强版。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。

5.1.4. 信号驱动I/O
信号驱动式I/O是指进程预先告知内核,使得当某个描述符上发生某事时,内核使用信号通知相关进程。信号驱动式I/O对于TCP套接字近乎无用,因为该信号产生得过于频繁,不能区分具体是哪种事件
- 监听套接字上某个连接请求请求已经完成
- 某个断连接请求已经发起
- 某个连接之半已经关闭
- 数据到达套接字
- 数据已经从套接字发送走(即输出缓冲区有空闲空间)
- 发生某个异步错误
5.1.5. 异步I/O
相对于同步IO,异步IO不是顺序执行。用户进程进行aio_read系统调用之后,无论内核数据是否准备好,都会直接返回给用进程,然后用户态进程可以去做别的事情。等到socket数据准备好了,内核直接复制数据给进程,然后从内核向进程发送通知。IO两个阶段, 进程都是非阻塞的。Linux提供了AIO库函数实现异步,但是用的很少。目前有很多开源的异步IO库,例如libevent、libev、libuv。

6. 参考文献
Linux IO模式及select、poll、epoll详解
文件描述符简介
Linux中的文件描述符与打开文件之间的关系
缓存IO与直接IO
[详解 Java 中 4 种 I/O 模型](<https://mp.weixin.qq.com/s?__biz=MzI3ODcxMzQzMw==&mid=2247488064&idx=2&sn=56b6f87cb4e99107737c73f7ed1e5e8e&chksm=eb539776dc241e60f88f7185da4b7fb46bf41f10a66a53898ca672e8942e8b6a8d47ec7d3d3d&scene=21#wechat_redirect>)
Linux网络编程--select()和pselect()函数
7. select和poll
Epoll是Linux内核提供的一种高效的I/O多路复用机制,可以高效地处理大量socket,提供高并发的网络通信。其高效的原理主要在于以下几点:
- 基于事件(event)驱动,避免了select/poll需轮询所有FD的模型。
- 将用户空间FD相关数据内核化,减少了上下文切换开销。
- 使用红黑树作为FD存储结构,加速查找速度。
- 采用水平触发,避免了重复通知的效率问题。
- 支持边缘触发,减少无效中断。
- 一定程度上实现了"反应堆"模式,不直接调用回调函数,进一步提高效率。
总的来说,epoll通过内核化、锁优化、减少复制以及高效的数据结构访问等手段,极大提升了I/O的并发处理能力,使其可以支撑海量并发连接,这就是epoll的高效设计原理。