Linux高性能服务器编程第九章IO复用

I/O复用使得程序能够同时监听多个文件描述符,这对提高程序的性能至关重要

  • 客户端程序程序要同时处理多个socket
  • 要同时处理用户输入和网络连接
  • TCP服务器要同时处理监听socket和监听

需要指出的是,I/O复用虽然能同时监听多个文件描述符,但它本身是阻塞的。并且当多个文件描述符同时就绪时,如果不采取额外的措施,程序就只能按顺序依次处理其中的每一个文件描述符,这使得服务器程序看起来像是串行工作的。如果要实现并发,只能使用多进程或多线程等编程手段。

本章只讨论epoll

epoll系列系统调用

内核事件表

epoll是Linux特有的I/O复用函数。它在实现和使用上与select、poll有很大差异。首先,epoll使用一组函数来完成任务,而不是单个函数。其次,epoll把用户关心的文件描述符上的事件放在内核里的一个事件表中,从而无须像select和poll那样每次调用都要重复传人文件描述符集或事件集。但epoll需要使用一个额外的文件描述符,来唯一标识内核中的这个事件表。这个文件描述符使用如下epoll_create函数来创建:

1
2
#include <sys/epoll.h>
int epoll_create (int size)

size参数告诉内核事件表需要多大,该函数返回的文件描述符用作其他所有的epoll系统调用的第一个参数,以指定要访问的内核事件表:

1
2
#include <sys/epoll.h>
int epoll_ctl( int epfd, int op, int fd,struct epoll_event *event )
  • epfd 事件表的文件描述符

  • op 指定操作类型

    • EPOLL_CTL_ADD 往事件表注册fd事件
    • EPOLL_CTL_MOD 修改fd上注册事件
    • EPOLL_CTL_DEL 删除fd上注册事件
  • fd 要操作的文件描述符

  • event 指定事件,定义如下:

    1
    2
    3
    4
    5
    struct epoll_event
    {
    _uint32_t events;/*epoll事件*/
    epoll_data_t data;/*用户数据* /
    };

    image-20231109101735962

    image-20231109101747306

    其中events成员描述事件类型。epoll支持的事件类型和poll基本相同。表示epoll事件类型的宏是在poll对应的宏前加上“E”,比如 epoll 的数据可读事件是EPOLLIN。但epoll有两个额外的事件类型——EPOLLET和 EPOLLONESHOT。它们对于epoll的高效运作非常关键,我们将在后面讨论它们。data成员用于存储用户数据,其类型epoll_data_t的定义如下:

    1
    2
    3
    4
    5
    6
    7
    typedef union epoll_data
    {
    void* ptr;
    int fd;
    uint32_t u32;
    uint64_t u64;
    }epoll_data_t;

    注意这是个联合体,只能使用一个成员。

epoll_wait

epoll系列系统调用的主要接口是epoll_wait函数

1
2
#include <sys/epoll.h>
int epoll_wait( int epfd, struct epoll_event*events,int maxevents,int timeout);
  • timeout 指定超时值,timeout为-1时,将永远阻塞直到某个事件发生
  • maxevents 最多监听多少个事件
  • *events 事件数组

LT和ET模式

epoll对文件描述符的操作有两种模式:LT电平触发和ET边沿触发。

LT模式是默认的模式,ET需要注册EPOLLET事件。

LT检测到有事件发生,应用程序可以不立即处理,下一次调用epoll_wait时还会再次通告,直到该事件被处理。

ET检测到有事件发生必须立即处理该事件,后续的epoll_wait调用不再向应用程序通知这一事件

EPOLLONESHOT

即使我们使用ET模式,一个socket上的某个事件还是可能被触发多次。这在并发程序中就会引起一个问题。比如一个线程(或进程,下同〉在读取完某个socket上的数据后开始处理这些数据,而在数据的处理过程中该socket上又有新数据可读(EPOLLIN再次被触发),此时另外一个线程被唤醒来读取这些新的数据。于是就出现了两个线程同时操作一个socket的局面。这当然不是我们期望的。我们期望的是一个socket连接在任一时刻都只被一个线程处理。这一点可以使用epoll 的 EPOLLONESHOT事件实现。

对于注册了EPOLLONESHOT事件的文件描述符,操作系统最多触发其上注册的一个可读、可写或者异常事件,且只触发一次,除非我们使用epoll_ctl函数重置该文件描述符上注册的EPOLLONESHOT 事件。这样,当一个线程在处理某个socket时,其他线程是不可能有机会操作该socket的。但反过来思考,注册了EPOLLONESHOT事件的socket一旦被某个线程处理完毕,该线程就应该立即重置这个socket上的 EPOLLONESHOT事件,以确保这个socket下一次可读时,其EPOLLIN事件能被触发,进而让其他工作线程有机会继续处理这个socket。