myserver-helloword-epoll
I/O
I/O模型是是操作系统中处理输⼊输出操作的⼀种机制。它描述了应⽤程序如何与操作系统内核交互,以完成对外部设备(如⽹络、磁盘等)的数据读写操作。I/O 操作通常是阻塞的,可能导致应⽤程序等待数据的到来或传输完成,进⽽影响性能。
I/O 操作⼀般分为两个阶段:
等待数据准备就绪:
- 外部设备与内核交互:当应⽤程序请求 I/O 操作时(例如,读取⽂件或接收⽹络数据),外部设备会将数据传输到操作系统内核中的缓冲区。
- 等待数据到达:在⽹络 I/O 中,内核可能需要等待⽹络接⼝控制器(NIC)接收数据包,将数据包放⼊⽹络缓冲区中;在磁盘 I/O 中,内核则需要等待磁盘驱动器将数据加载到内核的磁盘缓冲区中。等待时间取决于设备的响应速度。
- 阻塞和⾮阻塞 I/O:应⽤程序发起请求后,如果采⽤阻塞 I/O,应⽤程序会暂停执⾏,等待数据到达;如果采⽤⾮阻塞 I/O,应⽤程序在等待数据期间可以继续执⾏其他操作。内核会持续检查数据是否到达缓冲区。
将数据从内核空间复制到⽤⼾空间:
- 内核和⽤⼾空间的分离:现代操作系统中,内存空间通常分为内核空间和⽤⼾空间。内核空间具有更⾼的权限和安全性,⽽⽤⼾空间则为应⽤程序所⽤。为了保证安全,应⽤程序不能直接访问内核空间的数据。
- 数据拷⻉:当数据到达内核缓冲区且准备好后,内核会将数据从内核空间复制到应⽤程序的⽤⼾空间缓冲区中。这个过程称为数据拷⻉。
- 效率问题:这种数据拷⻉增加了 I/O 操作的开销,因为数据在⽤⼾空间和内核空间之间传输可能会涉及上下⽂切换和数据复制。⼀些优化⽅式(如零拷⻉)可以减少这种开销。
I/O 操作的整体流程
- 应⽤程序发起 I/O 请求:应⽤程序向操作系统发出读取或接收数据的请求(如 read() 或recv() )。
- 数据进⼊内核缓冲区:外部设备(⽹络、磁盘等)将数据传输到内核空间的缓冲区中,此时需要等待数据到达。
- 检查数据是否就绪:操作系统通过设备驱动和中断机制通知内核数据已就绪。
- 数据拷⻉到⽤⼾空间:数据到达内核缓冲区后,内核将数据从内核缓冲区复制到应⽤程序的⽤⼾空间缓冲区。
- 通知应⽤程序:数据传输完成后,操作系统通知应⽤程序可以读取⽤⼾空间中的数据,I/O 操作结束。
优化⽅式:零拷⻉
为了减少 I/O 操作中的数据拷⻉次数,许多现代系统引⼊了零拷⻉(zero-copy) 技术。这种⽅式通过
直接将数据从内核缓冲区传输到⽹络接⼝或磁盘接⼝,减少了内核空间到⽤⼾空间的拷⻉操作,提⾼了传输效率。
硬盘和⽹络速度慢于内存和 CPU:
- 磁盘 I/O 慢:硬盘读取和写⼊数据的速度远远低于内存和 CPU 的处理速度,尤其是机械硬盘(HDD)。即使是速度较快的固态硬盘(SSD),其读写速度也⽐内存和 CPU 慢很多。
- ⽹络 I/O 慢:⽹络数据的发送和接收涉及路由、传输和协议处理,可能会经过多次中间传输。即使在局域⽹环境下,⽹络数据的传输速度也⽐内存或 CPU 处理速度要慢。
对⽐分析
- CPU 处理:⼀般是最迅速的,时间在纳秒级别(1 纳秒 = 10^-9 秒),可以快速完成请求准备和响应处理。通常只是⼏微秒或更短时间。
- 内存操作:内存的读取和写⼊速度⽐磁盘快得多,通常在⼏⼗纳秒到⼏百纳秒。它的速度⼤约是 CPU的⼏⼗倍慢,但仍然⽐ I/O 快很多。
- ⽹络 I/O:互联⽹请求中最耗时的部分通常是⽹络 I/O,包括 DNS 查询、建⽴ TCP 连接、发送和接收HTTP 请求与响应。由于涉及⽹络传输、协议处理等,这些操作通常需要数⼗到数百毫秒(1 毫秒 =10^-3 秒)。⽹络延迟和带宽限制是影响⽹络 I/O 时间的主要因素。
- 磁盘 I/O:磁盘读取在 SSD 上可能需要⼏毫秒,⽽在机械硬盘(HDD)上可能需要⼏⼗毫秒。磁盘速度⽐内存慢很多,特别是机械硬盘,因为存在机械运动的寻道时间和旋转延迟。
相关概念
套接字 (Socket):
- 套接字是进程间通信(IPC)和⽹络通信的基本抽象。在Linux系统中,创建套接字后,会得到⼀个⽂件描述符(file descriptor),它是进程访问⽹络资源的句柄句柄(Handle)在计算机科学中是⼀个抽象的概念,它代表了⼀个对系统资源(如⽂件、窗⼝、数据库连接、⽹络套接字等)的唯⼀标识符。句柄不是⼀个实际的数据值,⽽是⼀个指向系统内部分配给特定资源的数据结构或对象的引⽤。通过句柄,应⽤程序可以间接地访问和控制这些资源,⽽不是直接操作底层资源的具体地址。
进程上下⽂切换:
- 在不同的I/O模型中,可能涉及进程上下⽂切换。例如,在阻塞I/O模型中,当进程被I/O操作阻塞时,操作系统可能会挂起当前进程并调度其他进程运⾏;⽽在异步I/O或⾮阻塞I/O模型中,进程能够更⾼效地利⽤CPU时间,减少不必要的上下⽂切换。进程上下⽂切换(Process Context Switch)是指操作系统在多任务环境下,从⼀个进程的执⾏环境(即上下⽂)切换到另⼀个进程的执⾏环境的过程。具体来说:当CPU需要暂停当前正在运⾏的进程转⽽执⾏另⼀个进程时,操作系统必须做以下⼏件事情:
- 保存当前进程的状态:包括程序计数器(PC,指⽰下⼀条要执⾏的指令地址)、寄存器状态、内存管理信息(如⻚表指针)、打开的⽂件描述符、信号处理设置等所有相关资源和状态。
- 恢复下⼀个进程的状态:将被选中要运⾏的下⼀个进程的上下⽂信息从进程控制块(PCB,Process Control Block)中取出,并加载到相应的处理器寄存器和内存管理单元中。
- 切换线程调度策略决定的进程:根据操作系统的调度策略(如时间⽚轮转、优先级调度等),选择⼀个新的进程来执⾏。这个过程涉及到内核态与⽤⼾态之间的转换,如果新进程是从⽤⼾态切换过来的,还需要通过系统调⽤返回到⽤⼾态并开始执⾏新的进程代码。
上下⽂切换开销较⼤,因为它涉及到了对硬件状态的保存和恢复以及可能的内存交换(如果涉及到虚拟内存且有⻚⾯不在物理内存中)。频繁的上下⽂切换会消耗⼤量的CPU时间,降低系统的整体性能。因此,在设计和优化多进程或多线程应⽤程序时,应尽量减少不必要的上下⽂切换。
缓冲区管理:
- 内核维护着⽤于存储⽹络数据的缓冲区。当⽹络数据到达时,先放⼊内核空间的缓冲区,然后根据I/O模型的不同策略,决定何时以及如何将数据复制到⽤⼾空间的缓冲区供进程使⽤,或者反之,将进程要发送的数据从⽤⼾空间复制到内核空间的缓冲区,再由内核发送出去。
事件通知机制:
- 对于信号驱动I/O和异步I/O,内核通过特定机制通知进程数据已准备就绪。在信号驱动I/O中,是通过发送信号;在异步I/O中,则可能是通过完成队列或回调函数。
常⻅的五种I/O模型
Unix⽹络编程中,经典地提出了五种I/O模型,它们分别是:
- 阻塞I/O(Blocking I/O)
- ⾮阻塞I/O(Non-blocking I/O)
- I/O多路复⽤(I/O Multiplexing)
- 信号驱动I/O(Signal-driven I/O)
- 异步I/O(Asynchronous I/O)
事件驱动模型
事件驱动模型定义
事件驱动模型是⼀种编程范式,服务器在这种模型下通过监听事件来驱动程序运⾏,⽽不是顺序执⾏或者等待某个操作完成。这些事件通常包括⽹络IO事件(如可读、可写),定时器事件等。上述这五种模型主要描述的是操作系统如何管理和调度进程与I/O设备之间的交互。它们都涉及到⽹络编程中读写数据时进程状态的变化以及对系统调⽤的响应⽅式。
事件驱动下的服务器处理流程
在事件驱动模型中,服务器处理客⼾端请求的过程正是利⽤事件循环和⾮阻塞 I/O 来避免阻塞,提⾼并发和效率。可以具体分解如下:
- 新连接的可读事件:当服务器主线程监听到有新的连接到来时(通常通过 accept 系统调⽤),这个连接的可读事件(表⽰有数据可读)会被触发。此时,服务器不会⽴即阻塞等待数据,⽽是将这个事件注册到事件循环中,通常是通过 I/O 多路复⽤机制(如 epoll、select、kqueue)。
- 异步读取数据:注册好事件后,服务器主线程会⽴即返回,继续监听其他事件或处理其他任务。与此同时,内核负责从⽹络设备读取数据(⽹络数据从⽹卡进⼊服务器),直到数据准备好被读取。这种设计确保了主线程不会阻塞在等待⽹络数据上,⽽是继续执⾏其他请求或任务。事件准备好加⼊就绪列表:当数据从⽹络设备到达并被读⼊到内核缓冲区时,内核会通知事件循环(通过 epoll_wait 等),将这个连接的事件放⼊就绪列表。这个时候,事件循环会检测到该事件已经准备好,可以开始处理。
- 服务器处理数据:服务器主线程从就绪列表中取出事件,然后通过⾮阻塞 I/O 函数(如 read 或 recv)读取内核缓冲区的数据,并进⾏处理(例如解析 HTTP 请求、查询数据库、⽣成响应等)。
- 核⼼优势:避免等待 I/O 完成的过程。这个流程的核⼼优势在于,服务器不⽤等待从⽹络数据区(⽹卡)读取到内核缓冲区的过程。主线程在等待期间可以正常执⾏其他任务(如处理其他连接、维护⼼跳、后台任务等),只有在数据准备好后才开始读取和处理,从⽽⼤⼤提⾼了服务器的并发性能和资源利⽤率。
技术层⾯解释(重要理解,能讲清楚事件驱动的逻辑)
假设有⼀个简单的事件驱动型Web服务器,采⽤单线程模型处理客⼾端请求。服务器创建⼀个主循环
(事件循环),并设置为监听套接字的读就绪事件。
- 初始化阶段:
- 服务器启动时,⾸先创建⼀个监听套接字并将其设置为⾮阻塞模式。
- 然后将监听套接字注册到事件循环中,以便在有新的客⼾端连接请求时收到通知。
- 事件循环:
- 主线程进⼊事件循环,调⽤系统提供的I/O复⽤函数如 epoll_wait() (Linux)或kqueue() (BSD系统)等,这些函数会阻塞等待指定⽂件描述符集合上的事件发⽣。
- 当有新客⼾端连接请求(即监听套接字变为可读状态),事件循环⽴即返回,并得到⼀个表⽰已就绪的⽂件描述符列表。
- 事件处理:
- 对于每⼀个就绪的⽂件描述符,事件循环调⽤相应的回调函数来处理事件。
- 如果是监听套接字,那么事件处理器可能执⾏ accept() 接受新的连接,并为新连接创建⼀个新的⾮阻塞套接字,同时将新套接字也注册到事件循环中监听读写事件。
- 如果是已连接的客⼾端套接字,事件处理器可能是读取请求数据,解析HTTP请求头和正⽂,然后调⽤处理请求的回调函数,该函数⽣成响应并将其写⼊相应套接字。
- 异步IO操作:
- 在整个过程中,所有⽹络I/O都是⾮阻塞的,例如读取客⼾端数据时,如果当前没有⾜够的数据可供读取, read() 不会阻塞⽽是⽴刻返回,事件循环继续检查其他待处理的事件。
- 当有数据写⼊客⼾端时,同样使⽤⾮阻塞的 write() 调⽤,若不能⼀次性写出全部数据,则下次事件循环迭代时再次尝试写⼊。
- 通过这种⽅式,事件驱动型线程可以在单个线程内⾼效地并发处理多个客⼾端连接,每个客⼾端的请求、响应过程都变成了独⽴的事件,由事件循环统⼀调度管理,⽽不是为每个客⼾端创建单独的线程或进程,从⽽降低了资源消耗,提⾼了系统的并发能⼒
epoll原理详细解释
⽤⼾态和内核态
- ⽤⼾态(User Mode):
- ⽤⼾态是指操作系统中的⼀种运⾏模式,通常是指⽤⼾应⽤程序运⾏的环境。
- 在⽤⼾态下,应⽤程序只能访问有限的资源和执⾏有限的操作,例如⽂件读写、⽹络通信等。
- 应⽤程序运⾏在⽤⼾态时,它们不能直接访问底层硬件资源或进⾏特权操作。
- ⽤⼾态下的程序执⾏受到严格的权限控制,以确保它们不能⼲扰操作系统的正常运⾏。
- 内核态(Kernel Mode):
- 内核态是操作系统的核⼼运⾏模式,也被称为特权模式。
- 在内核态下,操作系统内核具有最⾼的权限,可以访问所有的硬件资源和执⾏所有特权操作。
- 内核态下的代码能够执⾏关键的系统管理任务,如进程管理、内存管理、设备驱动程序等。
- 操作系统内核通常运⾏在内核态下,以便执⾏系统级任务并提供服务给⽤⼾态的应⽤程序。
epoll⼯作原理
- Linux特有的IO多路复⽤机制:epoll是专⻔为Linux系统设计的⼀种⾼效的IO多路复⽤技术。它通过⼀个⽂件描述符来跟踪和管理多个socket。
- ⽂件描述符管理:在epoll模型中,所有需要监视的socket都会被加⼊到⼀个由epoll实例管理的内部数据结构中。这个实例由⼀个⽂件描述符代表,通过这个描述符可以对这些socket进⾏各种操作。
- 系统调⽤:使⽤ epoll_wait 等系统调⽤,可以询问epoll实例哪些socket处于就绪状态,即准备好执⾏读取、写⼊或其他操作。
epoll的模式
- LT(⽔平触发)模式:
- 在此模式下,只要满⾜某个条件(例如,有数据可读),epoll就会不断地通知应⽤程序,直到该事件被处理。
- ⽔平触发模式更容易理解和使⽤,但在⾼负载情况下可能会导致性能问题。
- ET(边缘触发)模式:
- 边缘触发模式只在被监视的socket状态发⽣变化时(例如,从⽆数据到有数据)通知应⽤程序⼀次。
- 这种模式通常更⾼效,因为它减少了事件通知的次数,但处理起来更复杂。
epoll 使⽤的主要数据结构:
- 红⿊树:epoll 内部使⽤⼀种称为红⿊树的平衡⼆叉搜索树来存储所有注册的⽂件描述(socket)。每个节点代表⼀个⽂件描述符及其相关的事件和数据。红⿊树保证了插⼊、除和查找操作的⾼效性,即使在管理成千上万的⽂件描述符时也能保持⾼效。
- 就绪列表:当某个⽂件描述符上的事件就绪(如可读或可写)时,它会被添加到⼀个就绪列表中。这个列表仅包含那些状态发⽣变化,需要处理的⽂件描述符。
epoll实例(理解+能复述)
创建epoll实例
- 创建epoll实例代码
1
int epollfd = epoll_create1(0);
- 这⾏代码创建⼀个epoll实例,⽤于后续管理socket的事件。 epoll_create1(0) 是创建epoll实例的系统调⽤,其中的 0 表⽰没有特殊的标志。
- epoll_create1 会向操作系统请求创建⼀个新的epoll句柄。
- 函数返回的 epollfd 是⼀个有效的⽂件描述符,它代表了这个新创建的epoll实例,类似于打开⼀个⽂件或创建⼀个socket时得到的⽂件描述符。
- 这个epoll实例就像是⼀个事件表或者容器,可以添加、删除或查询关注的⽂件描述符及其发⽣的事件类型。
- 参数 0 意味着不设置任何标志,特别是没有指定 EPOLL_CLOEXEC 标记,这意味着该epoll句柄不会在执⾏exec系列系统调⽤时⾃动关闭。
设置⾮阻塞socket
• 设置⾮阻塞的代码
1 | setNonBlocking(server_fd); |
- 将socket设置为⾮阻塞意味着在进⾏读写操作时,如果没有数据或者不能⽴即发送数据,操作会⽴即返回⽽不是挂起等待,这是实现⾼效事件驱动模型的关键。
- 这个函数调⽤将服务器 socket server_fd 设置为⾮阻塞模式。在⾮阻塞模式下,IO 操作(如读、写)将不会导致线程挂起等待操作完成,⽽是⽴即返回,从⽽使服务器能够在等待数据时继续处理其他任务。
注册事件到epoll
注册事件代码
1
2
3
4struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = server_fd;
epoll_ctl(epollfd, EPOLL_CTL_ADD, server_fd, &ev);通过 epoll_ctl 将socket注册到epoll实例中,并指定关注的事件类型。这⾥的 EPOLLIN表⽰关注可读事件, EPOLLET 表⽰使⽤边缘触发模式。epoll_ctl 函数
Linux系统中⽤于操作epoll实例的接⼝,它可以执⾏以下三种不同的操作:
- EPOLL_CTL_ADD
- 作⽤:向epoll实例中添加⼀个⽂件描述符,并注册要监控的事件类型。
- 语法:
1
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
- 在这个操作中,op应设为 EPOLL_CTL_ADD ,fd参数是你想要添加到epoll实例中的⽂件描述符(例如socket),event指向⼀个已初始化好的 epoll_event 结构体,其中包含了你希望在该⽂件描述符上监听的事件。
- EPOLL_CTL_MOD
- 作⽤:修改已经添加到epoll实例中的⽂件描述符所对应的事件类型。
- 当需要更改某个已监控的⽂件描述符上的事件时,可以使⽤此操作码。这通常⽤于动态改变对特定⽂件描述符的事件关注类型,⽽不需要先删除再重新添加。
- EPOLL_CTL_DEL
- 作⽤:从epoll实例中删除⼀个之前添加过的⽂件描述符,停⽌监控其事件。
- 当不再需要监控某个⽂件描述符上的事件时,可以调⽤此操作来释放资源。之后对于该⽂件描述符发⽣的相应事件,epoll_wait将不会再返回相关信息。
事件循环
- 事件循环代码
1
2
3
4
5
6while (true) {
int nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1);
for (int n = 0; n < nfds; ++n) {
// 处理事件
}
} - 使⽤
epoll_wait
等待事件的发⽣,它会阻塞直到有事件发⽣,然后处理这些事件。这是事件驱动模型中循环监听并处理事件的关键部分。参数详解:1
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
- int epfd :这是通过调⽤ epoll_create() 或 epoll_create1() 创建的epoll实例的⽂件描述符。
- struct epoll_event *events :这是⼀个⽤⼾空间预先分配好的数组,⽤来接收由内核复制过来的已发⽣的事件结构体。每个结构体包含了触发事件的⽂件描述符及其相关的事件类型(例如读就绪、写就绪等)。
- int maxevents :此参数指定了 events 数组可以接收的最⼤事件数量。当有多个⽂件描述符同时准备好时, epoll_wait() 最多会填充这么多数量的事件到数组中。
- int timeout :等待时间(以毫秒为单位)。该值指定 epoll_wait() 函数阻塞的最⻓时间:◦ 若设置为 -1 ,表⽰将⽆限期地等待事件发⽣,即函数直到有⾄少⼀个事件发⽣才会返回。
- 若设置为 0 ,则 epoll_wait() 将⽴即返回,⽆论是否有待处理的事件,这通常⽤于⾮阻塞模式。
- 若设置为⼤于 0 的值,则函数最多会阻塞等待指定的毫秒数,即使在这段时间内没有事件发⽣也会返回。
处理新的连接请求和IO事件1
2
3
4
5
6
7
8
9
10
11
12
13
14if (events[n].data.fd == server_fd) {
// 处理新连接
new_socket = accept(server_fd, (struct sockaddr *)&address,(socklen_t*)&addrlen);
setNonBlocking(new_socket); // 设置新连接为⾮阻塞模式
ev.events = EPOLLIN | EPOLLET; // 监听新连接的可读事件和边缘触发
ev.data.fd = new_socket;
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, new_socket, &ev) == -1) {
LOG_ERROR("epoll_ctl: new_socket"); // 注册新连接事件失败,记录错误⽇志
exit(EXIT_FAILURE);
}
} else {
// TODO:处理已连接socket的IO事件
// ... (读取请求,处理请求,发送响应) ...
} - 事件循环中,根据⽂件描述符判断事件类型:若为服务器 socket server_fd ,则表⽰有新的连接请求;否则,为已建⽴连接的 socket 上的 IO 事件。对于新连接,使⽤ accept 接受连接,设置为⾮阻塞,并将其注册到 epoll 实例中
资源管理和错误处理
- 重要性
- 在服务器编程中,正确的资源管理和错误处理是⾮常关键的。这包括在出错时释放资源,以及确保在连接关闭时,从epoll实例中移除socket并关闭它们。
代码
1 |
|
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Ysmmm的快乐小屋!