Linux IPC之Socket网络编程I/O多路复用相关模型及区别

本文详细介绍了Linux下的I/O多路复用技术,包括select、poll、epoll等,重点阐述了epoll的工作原理、边缘触发与水平触发的区别,并探讨了边缘触发可能导致的饥饿问题及其解决方案。通过对各种I/O模型的比较,强调了epoll在处理大量文件描述符时的高性能优势。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

导言:大部分程序使用的I/O模型(传统的阻塞式I/O模型)都是单个进程每次只在一个文件描述符上执行I/O操作,每次I/O系统调用都会阻塞直到完成数据传输。但是,有些场景需要:

  • 以非阻塞的方式检查文件描述符上是否可进行I/O操作。
  • 同时检查多个文件描述符,看它们中的任何一个是否可以执行I/O操作。

对应的解决方法分别是,使用非阻塞式I/O多进程(多线程)

  • 在打开文件时设定O_NONBLOCK标志,会以非阻塞方式打开文件,如果I/O系统调用不能立刻完成,则会返回错误而不是阻塞进程。非阻塞式I/O可以让我们周期性地检查(轮询)某个文件描述符上是否可以执行I/O操作,但是这种人为的轮询很难控制。
  • 而多进程(多线程)可以满足同时检查多个文件描述符,但是开销大且复杂(编程复杂)。

因此上述两种方法都有各自的局限性,是否有更好的方法?使用I/O多路复用技术。

I/O多路复用的目标:就是同时检查多个文件描述符的状态,查看I/O系统调用是否可以非阻塞地执行。文件描述符就绪状态的转化是通过一些I/O事件来触发的,而同时检查多个文件描述符的操作,不会执行实际的I/O操作,它只是告诉进程某个文件描述符已经处于就绪状态了,需要调用其他的系统调用来完成实际的I/O操作。

  1. I/O多路复用允许一个进程同时检查多个文件描述符,以找出它们中的任何一个是否可执行I/O操作。select()和poll()等系统调用可以用来执行I/O多路复用。
  2. 信号驱动I/O,例如,当有写事件发生时,内核向进程发送一个信号。相比select()和poll()在检查大量的文件描述符时可以提升性能。
  3. Linux专有的epoll(2.6+内核)。当同时检查大量文件描述符时,epoll能提供更好的性能。
  4. POSIX异步I/O(AIO),允许进程将I/O操作排列到一个文件中,当操作完成后得到通知。目前,Linux在glibc中提供有基于线程的POSIX AIO实现。

选择哪种I/O多路复用技术和原因

  • 系统调用select()和poll()在UNIX系统中已经存在了很长的时间,同其他技术相比,它们的优势在于可移植性,而缺点是当同时检查大量文件描述符时性能延展性不好。
  • epoll的优点是可以高效地检查大量的文件描述符,但缺点是它是专属于Linux系统的API。
  • 信号驱动I/O也可以支持检查大量的文件描述符,但是epoll有一些其没有的优点:
    • 避免了信号处理的复杂性
    • 可以指定想要检查的事件类型(读就绪或写就绪)
    • 可以选择水平触发或边缘触发来通知进程
  • Libevent库,提供了检查文件描述符I/O事件的抽象,底层支持select(), poll(), 信号驱动I/O, epoll, 以及Solaris专有的/dev/poll接口和BSD系统的kqueue接口,对应用程序透明。

水平触发和边缘触发

水平触发通知:如果文件描述符上可以非阻塞地执行I/O系统调用,此时认为它已经就绪。
特点:当采用水平触发通知时,可以在任意时刻检查文件描述符的就绪状态。即,此模式允许我们在任意时刻重复检查I/O状态,没有必要每次当文件描述符就绪后需要尽可能多地执行I/O。

边缘触发通知:如果文件描述符自上次状态检查以来有了新的I/O活动,此时需要触发通知。
特点:当采用边缘触发时,只有当I/O事件发生时才会收到通知,在另一个I/O事件到来前不会收到任何新的通知。当文件描述符收到I/O事件通知时,通常并不知道要处理多少I/O,因此,采用边缘触发通知的程序通常要按照如下规则来设计:

  1. 在接收到一个I/O事件通知后,进程在某个时刻应该在文件描述符上尽可能多地执行I/O,否则就可能失去执行I/O的机会,因为直到产生另一个I/O事件为止,都不会再接收到通知,将导致数据丢失。(问题:如果仅对一个文件描述符执行大量的I/O操作,可能会让其他文件描述符处于饥饿状态,如何解决?)
  2. 如果程序采用循环来对文件描述符执行尽可能多的I/O,因此需要将每个被检查的文件描述符设置为非阻塞模式,在得到I/O事件通知后重复执行I/O操作,直到相应的系统调用(比如,read()/write())以错误码EAGAIN或EWOULDBLOCK返回。
I/O模式水平触发边缘触发
select()/poll()YN
信号驱动I/ONY
epollYY

文件描述符何时就绪

SUSv3:如果对I/O函数的调用不会阻塞,而不论该函数是否能够实际传输数据,此时文件描述符被认为是就绪的。

select()和poll()在套接字上通知的事件:

条件或事件select()poll()
有输入rPOLLIN
可输出wPOLLOUT
在监听套接字上建立连接rPOLLIN
流套接字的对端关闭连接,或执行了shutdown(SHUT_WR)rwPOLLIN|POLLOUT|POLLRDHUP

epoll编程接口

epoll是Linux系统专有的,在2.6版中新增,主要优点有:

  1. 当检查大量文件描述符时,epoll的性能延展性比select()和poll()高很多。
  2. epoll即支持水平触发,也支持边缘触发。而select()和poll()只支持水平触发,而信号驱动I/O只支持边缘触发。
  3. 可以避免复杂的信号处理流程(比如,信号队列溢出时的处理)。
  4. 灵活性高,可以指定希望检查的事件类型。

epoll的核心数据结构:epoll实例,它和一个打开的文件描述符相关联,这个文件描述符不是用来做I/O操作的,它是内核数据结构的句柄,这些内核数据结构实现了两个目的

  1. 记录了在进程中声明过的感兴趣的文件描述符列表(interest list,兴趣列表)
  2. 维护了处于I/O就绪状态的文件描述符列表(ready list,就绪列表)

其中,ready list中的成员是interest list的子集。

对于由epoll检查的每一个文件描述符,可以指定一个位掩码来表示我们感兴趣的事件。

epoll由以下3个系统调用组成:

  1. epoll_create()创建一个epoll实例,返回代表该实例的文件描述符。
  2. epoll_ctl()操作同epoll实例相关联的兴趣列表,通过epoll_ctl()可以增加/移除/修改文件描述符。
  3. epoll_wait()返回与epoll实例相关联的就绪列表中的成员。

深入探究epoll的语义

当通过epoll_create()创建了一个epoll实例时,内核在内存中创建了一个新的i-node并打开文件描述(抽屉,表示的是一个打开文件的上下文信息),随后在调用进程中为打开的这个文件描述(抽屉)分配了一个新的文件描述符(抽屉的把手,即句柄)。同epoll实例的兴趣列表相关联的是打开的文件描述,而不是epoll文件描述符。

文件描述(抽屉)实际是是内核中的一个数据结构,而用户空间的文件描述符(抽屉的把手)只不过是一个整数,epoll的兴趣列表实际关注的是内核中的数据结构。

边缘触发通知

默认情况下,epoll提供的是水平触发通知。这同select()和poll()所提供的通知类型相同。epoll还能以边缘触发方式进行通知,在语义上类似信号驱动I/O(区别是,如果有多个I/O事件发生的话,epoll会将它们合并成一次单独的通知,通过epoll_wait()返回,而信号驱动I/O则可能会产生多个信号)。

要使用边缘触发通知,在调用epoll_ctl()时在ev.events字段中指定EPOLLET标志。

struct epoll_event ev;
ev.data.fd = fd;

ev.events = EPOLLIN | EPOLLET;
if (epoll_ctl(epfd, EPOLL_CTL_ADD, fd, ev) == -1) {
    // err op
}

水平触发和边缘触发的区别

  1. 套接字上有输入到来。
  2. 调用一次epoll_wait(),无论采用的是水平触发还是边缘触发通知,该调用都会告诉我们套接字已经处于就绪状态。
  3. 再次调用epoll_wait()。
    如果采用的是水平触发,那么第二个epoll_wait()调用将告诉我们套接字处于就绪状态;而如果采用边缘触发通知,那么第二个epoll_wait()调用将阻塞,因为自从上一次调用epoll_wait()以来并没有新的输入到来。

边缘触发通知机制的程序基本框架

  1. 让所有待监视的文件描述符都成为非阻塞的。
  2. 通过epoll_ctl()构建epoll的兴趣列表。
  3. 通过如下的循环处理I/O事件。
    3.1 通过epoll_wait()取得处于就绪状态的描述符列表。
    3.2 针对每一个处于就绪状态的文件描述符,不断进行I/O处理直到相关系统调用(如read()/write()/recv()/send()/accept()等)返回EAGAIN或EWOULDBLOCK错误。

边缘触发通知时的饥饿问题

假设采用边缘触发通知监视多个文件描述符,其中一个处于就绪的文件描述符上有着大量的输入存在。如果在检测到该文件描述符处于就绪状态后,我们将尝试通过非阻塞的读操作将所有的输入都读取,那么此时就会使其他的文件描述符处于饥饿状态(即,在我们再次检查这些文件描述符是否处于就绪并执行I/O操作前会有很长的一段处理时间)。

该问题的一种解决方案是让应用程序维护一个列表,列表中存放着已经被通知为就绪状态的文件描述符,通过一个循环按照如下方式不断处理:

  1. 调用epoll_wait()监视文件描述符,并将处于就绪状态的描述符添加到应用程序维护的列表中。如果这个文件描述符已经注册到应用程序维护的列表中了,那么这次监视操作的超时时间应该设为较小的值或者是0。这样如果没有新的文件描述符成为就绪状态,应用程序就可以迅速进行到下一步,去处理那些已经处于就绪状态的文件描述符。
  2. 在应用程序维护的列表中,只在那些已经注册为就绪状态的文件描述符上进行一定限度的I/O操作。当相关的非阻塞I/O系统调用出现EAGAIN或EWOULDBLOCK错误时,文件描述符就可以从应用程序维护的列表中移除了。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值