1. 前置知识

1.1 进程阻塞

进程的阻塞是指一个进程等待某个条件满足或某个事件发生,这个进程的执行被暂停进入一种不可执行的状态,直到满足该条件重新获得运行的机会

进程的生命周期:

  • 就绪:进程具备执行条件,等待CPU调度
  • 运行:进程正在被CPU执行
  • 阻塞:进程因为某种原因无法继续执行,只能等待某个事件或资源

当一个进程进入阻塞状态时,操作系统会将其从就绪队列中移除,并加入到等待队列中,直到被某个事件(如I/O操作完成、信号到达)唤醒后,重新进入就绪状态等待调度。

导致进程阻塞的原因

  1. IO操作
    进程在执行文件读写操作时,需要等待硬盘或网络数据,这些操作速度通常比CPU速度慢很多,因此进程等待IO操作完成会被阻塞
  2. 请求资源
    进程再运行过程中请求的资源被别的进程占用时会进入阻塞状态:锁,内存页,共享变量
  3. 进程之间的同步
    在多进程环境中,进程之间可能需要同步操作,比如通过信号量或条件变量进行同步。如果一个进程等待另一个进程的通知(如信号),它会进入阻塞状态。
  4. 等待子进程完成
    一个父进程可能需要等待其子进程完成后再继续执行,比如调用 wait() 系统调用等待子进程结束。如果子进程尚未完成,父进程会进入阻塞状态。

1.2 文件描述符

文件描述符(File Descriptor,简称FD) 是一个非负整数,用于标识和访问系统中的文件或其他I/O资源。文件描述符是操作系统内核为进程分配的用于表示打开的文件、管道、套接字等I/O资源的标识符。每个文件描述符指向一个文件表项,其中包含文件的位置、状态和属性等信息。

1.3 缓存I/O

缓存 I/O(Buffered I/O),也称为 标准 I/O,是指在进行文件或 I/O 设备读写操作时,操作系统使用内存中的缓冲区来暂存数据,以提高 I/O 操作的效率和性能。这是一种最常见的 I/O 方式,广泛应用于 Linux/Unix 系统和大多数编程语言的标准库中。

缓存 I/O 的核心理念是通过减少磁盘和内存之间频繁的数据传输来提高效率。磁盘 I/O 的速度通常远低于 CPU 和内存的速度,而使用缓存可以减少直接的磁盘访问次数,进而提高整体系统的性能。

  • 缓冲区:在缓存 I/O 中,系统会在内存中开辟一个称为缓冲区的区域,用来存储读写数据的中间状态。

    • 当执行读操作时,数据会先从磁盘读取到内核的缓冲区,然后再从缓冲区传输到用户空间,这样可以减少频繁的磁盘操作。
    • 当执行写操作时,数据会先被写入缓冲区,然后再由系统异步地将缓冲区中的数据刷到磁盘。
  • 异步 I/O 操作:由于缓存 I/O 是异步操作,进程在将数据写入缓冲区后,I/O 操作并不会立即发生,而是由操作系统负责将缓冲区中的数据在适当的时候写入磁盘。这种方式能够减少等待时间,提高 I/O 效率。

2. IO模式

对于一次IO操作,数据会先被copy到操作系统内核态的缓存区,然后从操作系统缓冲区再copy到应用程序的地址空间。
不同场景中,IO操作的效率和性能需求不同,操作系统支持多种IO模式。

  1. 阻塞IO
  2. 非阻塞IO
  3. IO多路复用
  4. 异步IO

2.1 阻塞I/O

客户端向Linux服务器发送一段数据的C语言实现。以及Linux服务器如何通过C语言代码接收客户端的连接和发送数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int main(){
int fd = socket(); //建立socket结构体
connect(fd, ...); //通过TCP三次握手建立TCP连接
send(fd,...); //将数据写入到TCP连接
close(fd); //关闭TCP连接
}

int main(){
fd = socket(...);        // 创建一个网络通信的socket结构体
     bind(fd, ...);           // 绑定通信端口
     listen(fd, 128);       // 监听通信端口,判断TCP连接是否可以建立
     while(1) {
         connfd = accept(fd, ...);           // 阻塞建立连接
         int n = recv(connfd, buf, ...);      // 阻塞读数据
         doSomeThing(buf);               // 利用读到的数据做些什么
         close(connfd);              // 关闭连接,循环等待下一个连接
    }
}

阻塞IO的数据接收流程:

  1. 用户进程A创建Socket,调用socket()函数陷入内核态调用socket系统调用创建socket内核对象,包括两个结构体,进程等待队列和数据接收队列
  2. 进程A调用recv()函数接收数据,进入recvfrom系统调用,发现socket数据接收队列没有他所需要的数据到达时,让出CPU进入阻塞状态,进程A的fd和回调函数被放进进程等待队列
  3. 客户端发送数据到服务器网卡
  4. 网卡将数据通过DMA复制到环形缓存区中存储
  5. 网卡向CPU发出硬中断
  6. CPU向内核中断进程发出软中断
  7. 内核终端进程收到软中断后,将网卡复制到内存的数据,根据IP地址和端口号拷贝到socket数据接收队列
  8. 进程A被回调函数唤醒,继续执行recvfrom函数将数据拷贝到用户态
  9. 回到用户态执行用户程序,解除阻塞
    一次数据道德会进行两次进程切换,一次数据读取有两处阻塞,不适合高并发

2.2 非阻塞I/O

进程在发起IO请求后,无论数据是否准备好,操作系统都会从返回,不会阻塞。进程需要不断地重复轮询数据是否准备好。如果数据已经到达内核空间的socket等待队列,用户进程还是要等待将数据从内核空间拷贝到用户空间。
两次进程切换,一处阻塞。在高并发场景下依旧性能不好

2.3 I/O多路复用

IO多路复用允许单个进程处理多个TCP连接,也就是同时管理多个IO操作,可以降低服务器处理请求的进程数,不需要再像之前一样每一个进程的数据到达时就进行进程切换,进程通过一个系统调用同时监视多个文件描述符

工作流程

  1. 进程通过 selectpollepoll 发起一个请求来监视多个文件描述符的状态。
  2. 如果没有描述符准备好,进程可以被阻塞,直到有描述符准备好(也可以选择不阻塞)。
  3. 当有描述符准备好时,进程被通知,进而对相应的文件描述符执行实际的 I/O 操作。

3. IO多路复用: Select, poll, epoll

3.1 select

1
2
3
4
5
6
7
int select(
int nfds, //nfds是文件描述符集合中所有文件描述符的最大值加1
fd_set *readfds, //指定要监视的可读文件描述符
fd_set *writefds, //指定要监视的可写文件描述符
fd_set *exceptfds, //指定要监视的异常情况文件描述符
struct timeval *timeout //指定select等待的超时时间
)

select函数的返回值是一个整数,用于指示准备好的文件描述符数量:

  • 返回正数:表示有多少个文件描述符准备好(可读,可写或有异常)
  • 返回0:表示在超时时间内没有任何文件描述符变为可用
  • 返回-1:表示出错

工作原理:

  • select使用一个固定大小的bitmap表示所监视的文件描述符集合,每一位代表每个文件描述符的状态
  • 调用select函数时:
    • 用户态将文件描述符集合传递给内核态
    • 内核态通过遍历的方式扫描每个文件描述符,检查其状态
    • 如果有任何一个文件描述符变为可读/可写状态,内核更新bitmap,并将控制权交给用户进程

优点
1. 简单易用,实现简单,API容易理解
2. 几乎所有平台都支持,是一种标准化的机制
缺点
1. 最大文件描述符限制: 由于bitmap的大小限制,select的文件描述符数量上限为1024,在编译内核时就确定了并且无法更改
2. 性能开销大
1. 每次调用都需要在用户态和内核态之间传递文件描述符集合,会带来较大的内存拷贝开销
2. 进程被唤醒后,不知道哪些连接已就绪为收到了数据的状态,需要遍历整个传进来的bitmap,时间复杂度为O(n)

3.2 poll

poll是对select的改进,去除了select中对文件描述符数量的限制,使用链表替代bitmap。从而可以处理更多的文件描述符
在工作原理方面和select没有什么区别。只是将fd_set改为了pollfd结构

1
2
3
4
5
struct pollfd {  
    int fd;           // 要监听的文件描述符
    short events;     // 要监听的事件
    short revents;    // 文件描述符fd上实际发生的事件
};

3.3 epoll

epollLinux特有的IO多路复用机制,解决了selectpoll在高并发场景下的性能问题,epoll可以高效解决大量并发连接
epoll特点:

  1. 使用红黑树结构存储一份文件描述集合,每个文件描述符只需要在添加时传入,不需要每次用户使用都传入。解决了select中重复拷贝到内核的问题
  2. 不再使用遍历的手段找到就绪的文件描述符,而是使用异步IO事件
  3. 不再返回所有的文件描述符,而是使用队列存储就绪的文件描述符,并且按需返回,不需要用户态再次遍历

核心数据结构

  • 红黑树用于管理所有需要监控的文件描述符,可以提供高效的CURD操作
  • 就绪链表用于存储有IO事件就绪的文件描述符

相关函数

  1. epoll_create 创建一个epoll实例,返回一个文件描述符,标识这个epoll实例
  2. epoll_ctl 管理文件描述符,向epoll实例中添加,修改和删除需要监视的文件描述符和事件
  3. epoll_wait 等待文件描述符变为就绪状态,返回有时间发生的文件描述符集合

** epoll 的工作流程**

  1. 创建 epoll 实例

    • 调用 epoll_create 创建一个 epoll 实例,用于管理和监视多个文件描述符,得到一个 epoll 文件描述符(如 **epfd**)。
  2. 注册文件描述符

    • 通过 epoll_ctl 函数,使用 EPOLL_CTL_ADD 操作将需要监视的文件描述符(例如 socket)注册到 epoll 实例中,并指定需要监视的事件(如可读、可写)。
  3. 事件发生时

    • 内核通过 红黑树 管理注册到 epoll 实例中的所有文件描述符。
    • 当某个文件描述符的状态发生变化(如有数据到达时变为可读状态),内核会将该文件描述符加入就绪链表
    • 由于所有就绪的文件描述符都放在链表中,epoll 只需在链表中查找,而无需遍历所有文件描述符,大幅度提高了查询效率。
  4. 等待就绪事件

    • 调用 epoll_wait 函数阻塞等待,直到有文件描述符变为就绪,或等待的超时时间到达。
    • epoll_wait 返回就绪的文件描述符集合,开发者可以根据需要进行相应的 I/O 操作。
  5. 事件处理

    • 对返回的就绪文件描述符集合逐一进行处理(如读取或写入操作),然后可以通过 epoll_ctl 继续更新或删除这些文件描述符。