深入理解:阻塞IO、非阻塞IO、水平触发与边缘触发
在网络编程和并发处理中,理解不同的 I/O 模型和事件通知机制至关重要。本文将深入探讨阻塞IO(Blocking IO)、非阻塞IO(Non-Blocking IO)、水平触发(Level Triggering)以及边缘触发(Edge Triggering)这四个核心概念,帮助开发者更好地选择和使用合适的 I/O 模型。
一、阻塞IO(Blocking IO)
定义: 阻塞IO是最简单也是最常见的IO模型。当应用程序发起一个IO操作(例如,读取数据)时,如果数据尚未准备好,操作系统会将该线程或进程 阻塞 起来,直到数据准备就绪并被拷贝到用户空间后,该线程或进程才会继续执行。
工作方式:
- 用户进程发起一个读操作。
- 操作系统内核检查数据是否准备好。
- 如果数据没有准备好,内核会将该进程/线程挂起(阻塞)。
- 一旦数据准备好,内核将数据从内核空间拷贝到用户空间。
- 内核唤醒被挂起的进程/线程。
- 用户进程继续执行,完成IO操作。
优点: 编程模型简单直观,易于理解和实现。
缺点: 在等待IO完成期间,进程或线程会被阻塞,无法执行其他任务。这在高并发场景下会导致大量的线程被阻塞,效率低下。
示例: 默认情况下,socket
的 recv()
函数就是一个阻塞调用。如果接收缓冲区中没有数据,recv()
会一直等待直到有数据到达。
二、非阻塞IO(Non-Blocking IO)
定义: 非阻塞IO与阻塞IO相反。当应用程序发起一个IO操作时,如果数据尚未准备好,操作系统会立即返回一个错误(通常是 EAGAIN
或 EWOULDBLOCK
),而不会阻塞该线程或进程。应用程序需要不断地轮询(polling)内核,检查数据是否已经准备好。
工作方式:
- 用户进程将 socket 设置为非阻塞模式。
- 用户进程发起一个读操作。
- 操作系统内核检查数据是否准备好。
- 如果数据没有准备好,内核会立即返回一个错误。
- 用户进程不会被阻塞,可以继续执行其他任务,并在稍后再次尝试读取数据。
- 一旦数据准备好,内核将数据拷贝到用户空间,并且下次用户进程尝试读取时会成功返回。
优点: 进程或线程在等待IO操作完成时不会被阻塞,可以执行其他任务,提高了并发处理能力。
缺点: 需要应用程序不断地轮询内核,检查IO操作是否完成,这会消耗大量的CPU资源,尤其是在大多数轮询都返回"数据未准备好"的情况下。
示例: 可以通过设置 socket
的 O_NONBLOCK
标志将其设置为非阻塞模式。此时,调用 recv()
如果没有数据会立即返回错误。
三、I/O 多路复用(The Need for)
非阻塞IO虽然避免了线程阻塞,但其轮询机制效率低下。为了更高效地处理多个连接的IO事件,出现了I/O多路复用技术,例如 select
、poll
和 epoll
。这些技术允许一个进程或线程同时监视多个文件描述符(例如,socket),一旦某个或某些文件描述符上的IO事件就绪(例如,有数据可读),内核就会通知应用程序。
在使用 I/O 多路复用时,我们需要关注事件的触发方式,这就是水平触发和边缘触发的概念。
四、水平触发(Level Triggering,LT)
定义: 水平触发是一种事件通知机制。当内核检测到文件描述符上的某个条件满足时(例如,socket 接收缓冲区中有数据可读),就会通知应用程序。 只要该条件持续满足,内核就会重复通知应用程序。
工作方式:
- 当使用
select
或poll
时,如果一个文件描述符就绪(例如,可读),select
或poll
会返回该文件描述符。即使应用程序没有完全读取完所有的数据,下次再次调用select
或poll
时,如果该文件描述符仍然处于就绪状态(缓冲区中还有数据),它仍然会被报告为就绪。 - 在使用
epoll
时,如果以水平触发模式注册了一个文件描述符的读事件,只要该文件描述符的读缓冲区中还有数据,epoll_wait
就会一直返回该文件描述符,直到所有数据都被读取完毕。
特点:
- 可靠性高:只要条件满足,就会一直通知,不容易丢失事件。
- 处理方式灵活:应用程序可以根据自己的节奏处理数据,不必一次性读取所有数据。
- 效率相对较低:可能会因为条件持续满足而产生不必要的重复通知。
适用场景: 对数据完整性要求较高,但对实时性要求不是特别苛刻的场景。select
和 poll
默认采用水平触发。epoll
默认也采用水平触发,但可以通过设置标志来使用边缘触发。
五、边缘触发(Edge Triggering,ET)
定义: 边缘触发是另一种事件通知机制。当内核检测到文件描述符上的状态 发生变化 时,才会通知应用程序。例如,当 socket 接收到新的数据时,会产生一个读事件的边缘触发。 只有在状态发生变化时才会通知一次。
工作方式:
- 在使用
epoll
并以边缘触发模式注册了一个文件描述符的读事件时,只有当新的数据到达该文件描述符的读缓冲区时,epoll_wait
才会返回该文件描述符。即使缓冲区中仍然有未读取的数据,如果后续没有新的数据到达,epoll_wait
不会再次返回该文件描述符。
特点:
- 效率高:只有在状态发生变化时才通知,减少了不必要的重复通知。
- 要求高:应用程序需要及时地处理所有触发的事件,否则可能会丢失后续的事件。对于读事件,需要一次性读取所有可读的数据,对于写事件,需要在可写状态发生变化后尽可能多地写入数据。
适用场景: 对性能要求非常高的场景,需要应用程序能够快速且完整地处理事件。Nginx 等高性能服务器通常会选择使用 epoll
的边缘触发模式。
注意事项: 在使用边缘触发时,需要特别小心,确保在每次事件触发后都能够完全处理所有的数据,避免数据丢失或遗漏。通常会配合使用非阻塞IO,循环读取或写入数据直到返回错误(例如 EAGAIN
)。
六、总结与比较
特性 | 阻塞IO | 非阻塞IO | 水平触发(LT) | 边缘触发(ET) |
---|---|---|---|---|
行为 | 等待IO完成才返回 | 立即返回,可能出错或返回部分数据 | 只要条件满足(例如,有数据),就持续通知 | 只有在状态发生变化时才通知一次 |
CPU 消耗 | 低(等待时不占用) | 高(需要轮询) | 适中 | 高(需要及时处理所有事件) |
编程复杂度 | 低 | 较高(需要处理错误和轮询) | 相对简单 | 较高(需要确保完整处理事件) |
可靠性 | 高 | 取决于轮询策略 | 高(不易丢失事件) | 较高,但需要正确处理,否则可能丢失事件 |
效率 | 低(并发处理能力差) | 较低(轮询开销) | 相对较低(可能重复通知) | 较高(减少了重复通知) |
常见应用 | 简单的客户端程序,单线程服务器 | 需要并发处理但对实时性要求不高的场景 | select ,poll ,epoll (默认) |
epoll (通过设置 EPOLLET 标志) |
理解这四种概念对于进行高效的网络编程至关重要。在选择合适的IO模型和触发机制时,需要根据具体的应用场景、性能要求以及编程复杂度进行权衡。例如,对于需要处理大量并发连接且对性能要求极高的服务器,epoll
的边缘触发模式通常是一个不错的选择,但同时也需要开发者具备更高的编程技巧来确保程序的正确性。