《Linux高性能服务器编程》第九章 I/O复用

Github代码地址

第九章 I/O 复用

I/O复用使程序能同时监听多个文件描述符,这对提高程序的性能至关重要。通常,网络程序在下列情况下需要使用 I/O 复用计数

  • 客户端程序要同时处理多个 socket。 比如接下来将要讨论的非阻塞connect技术
  • 客户端程序要同时处理用户输入和网络连接。比如聊天室程序
  • TCP 服务器要同时处理监听 socket 和连接 socket。这是 I/O 复用使用最多的场合。
  • 服务器要同时处理 TCP 和 UDP 请求。比如回射服务器
  • 服务器要同时监听多个端口,或者处理多种服务,比如 xinetd 服务器

9.1 select 系统调用

select :在一段指定时间内,监听用户感兴趣的文件描述符上的可读、可写和异常等事件。

9.1.1 select API

原型:

#include <sys/select.h>
int select( int nfds, fd_set* readfds, fd_set* writefds, fd_set* exceptfds, struct timeval* timeout );
  • nfds 指定被监听的文件描述符的总数。它通常被设置为 select 监听的所有文件描述符中的最大值加 1 ,因为文件描述符是从 0 开始计数的。

  • readfds, writefdsexceptfds 分别指向可读、可写和异常等事件对应的文件描述符集合。这三个参数是 fd_set 结构指针类型。fd_set 结构体定义如下

    #include <typesizes.h>
    #define __FD_SETSIZE 1024
    
    #include <sys/select.h>
    #define FD_SETSIZE __FD_SETSIZE
    typedef long int __fd_mask;
    #undef __NFDBITS
    #define __NFDBITS ( 8 * (int) sizeof (__fd_mask ) )
    typedef struct
    {
    #ifdef __USE_XOPEN
        __fd_mask fds_bits[ __FD_SETSIZE / __NFDBITS ];
    #define __FDS_BITS(set) ((set)->fds_bits)
    #else
        __fd_mask __fds_bits[ __FD_SETSIZE / __NFDBITS ];
    #define __FDS_BITS(set) ((set)->__fds_bits)
    #endif
    } fd_set;
    

    fd_set结构体仅包含一个整型数组,该数组的每个元素的每一位(bit)标记一个文件描述符。我们使用下面的一系列宏来访问 fd_set 结构体中的位:

    #include <sys/select.h>
    FD_ZERO( fd_set* fdset); 				/* 清除 fdset 的所有位 */
    FD_SET( int fd, fd_set* fdset ); 		 /* 设置 fdset 的位 fd */
    FD_CLR( int fd, fd_set* fdset );		 /* 清除 fdset 的位 fd */
    int FD_ISSET( int fd, fd_set* fdset );	  /* 测试 fdset 的位 fd 是否被设置 */
    
  • timeout 设置 select 的超时时间。是一个 timeval 结构类型的指针。timeval 结构体的定义如下:

    struct timeval{
        long tv_sec;	/* 秒数 */
        long tv_usec;	/* 微秒数 */
    }
    

    如果给 timeout 变量的两个成员都传递 0, 则 select 将立即返回。如果给 timeout 传递 NULL ,则 select 将一直阻塞,直到某个文件描述符就绪。

    9.1.3 处理带外数据

    socket上接收到普通数据和带外数据都将使select返回,但socket处于不同的就绪状态:前者处于可读状态,后者处于异常状态。

    select.c

9.2 poll 系统调用

poll 系统调用和 select 类似,也是在指定时间内轮询一定数量的文件描述符,以测试其中是否有就绪者。原型如下:

#include <poll.h>
int poll ( struct pollfd* fds, nfds_t nfds, int timeout );
  • fds 是一个 pollfd 结构类型的数组,它指定所有我们感兴趣的文件描述符上发生的可读、可写和异常事件。定义如下:

    struct pollfd{
        int fd;				/* 文件描述符 */
        short events;		 /* 注册的事件 */
        short revents;		 /* 实际发生的事件,由内核填充 */
    }
    

    其中,fd 成员指定文件描述符:events 成员告诉 poll 监听 fd 上的哪些事件,它是一系列事件的按位或;revents 成员由内核修改,以通知应用程序 fd 上实际发生了哪些事件。poll 支持的事件类型如下表所示:

    事件描述是否可作为输入是否可作为输出
    POLLIN数据(包括普通数据和优先数据)可读
    POLLRDNORM普通数据可读
    POLLRDBAND优先级带数据可读(Linux不支持)
    POLLPRI高优先级数据可读,比如 TCP 带外数据
    POLLOUT数据(包括普通数据和优先数据)可写
    POLLWRNORM普通数据可写
    POLLWRBAND优先级带数据可写
    POLLRDHUPTCP连接被对方关闭,或者对方关闭了写操作,它由GNU引入
    POLLERR错误
    POLLHUP挂起。比如管道的写端被关闭后,读端操作符上将收到POLLHUP事件
    POLLNVAL文件描述符没有打开

    上表中, POLLRDNORM、POLLRDBAND、POLLWRNORM、POLLWRBANDXOPEN 规范定义。

  • nfds 指定被监听事件集合 fds 的大小。类型定义如下

    typedef unsigned long int nfds_t;
    
  • timeout 指定 poll 的超时值,单位是毫秒。当 timeout == -1 时,poll 调用将永远阻塞,直到某个时间发生;当 timeout == 0 时,poll 调用将立即返回

    poll 系统调用的返回值的含义与 select 相同

9.3 epoll 系列系统调用

注意: listen fd, 有连接请求会触发 EPOLLIN

9.3.1 内核事件表

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

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

size 现在并不起作用,只是给内核一个提示,告诉它事件表需要多大。该函数返回的文件描述符将用作其他所有 epoll 系统调用的第一个参数,以指定要访问的内核事件表,用以下函数来操作 epoll 的内核事件表

#include <sys/epoll.h>
int epoll_ctl( int epfd, int op, int fd, struct epoll_event* event)
  • fd 是要操作的文件文件描述符

  • op 指定操作类型。操作类型有如下 3 种:

    • EPOLL_CTL_ADD 往事件表中注册 fd 上的事件
    • EPOLL_CTL_MOD 修改 fd 上的注册事件
    • EPOLL_CTL_DEL 删除 fd 上的注册事件
  • event 指定事件,它是 epoll_event 结构指针类型。定义如下

    struct epoll_event{
        __uint32_t events;	/* epoll事件 */
        epoll_data_t data;	/* 用户数据 */
    };
    

    其中 events 成员描述事件类型。data 成员用于存储用户数据,其类型 epoll_data_t 定义如下:

    typedef union epoll_data{
        void* ptr;
        int fd;
        unit32_t u32;
        uint64_t u64;
    } epoll_data_t;
    

    epoll_data_t 是一个联合体,其 4 个成员中使用最多的是 fd, 它指定事件所从属的目标文件描述符。

    ptr 可用来指定与 fd 相关的用户数据。由于联合体的特性,ptrfd 成员不能同时使用,解决办法之一是:放弃 fd 成员,而在 ptr 指向的用户数据中包含 fd

9.3.2 epoll_wait 函数

epoll 系列系统调用的主要接口是 epoll_wait 函数。它在一段超时事件内等待一组文件描述符上的事件,原型如下:

#include <sys/epoll.h>
int epoll_wait( int epfd, struct epoll_event* events, int maxevents, int timeout );

该函数成功时返回就绪的文件描述符的个数。maxevents 指定最多监听多少个事件,必须大于 0 。

epoll_wait 函数如果检测到事件,就将所有就绪的事件从内核事件表 ( 由 epfd 参数指定 ) 中复制到它的第二个参数 events 指向的数组中。这个数组只用于输出 epoll_wait 检测到的就绪事件,而不像 selectpoll 的数组参数那样既用于传入用户注册的事件,又用于输出内核检测到的就绪事件。

poll & epoll --- difference

/* 如何索引 epoll 返回的就绪文件描述符 */
int ret = poll( fds, MAX_EVENT_NUMBER, -1 );
/* 必须遍历所有已注册文件描述符并找到其中的就绪者(可以利用 ret 稍作优化) */
for( int i = 0; i < MAX_EVENT_NUMBER; ++i){
    if( fds[i].revents & POLLIN )	/* 判断第 i 个文件描述符是否就绪 */
    {
        int sockfd = fds[i].fd;
        /* 处理 sockfd */
    }
}

/* 如何索引 epoll 返回的就绪文件描述符 */
int ret = epoll_wait( epollfd, events, MAX_EVENT_NUMBER, -1 );
/* 仅遍历就绪的 ret 个文件描述符 */
for( int i = 0; i < ret; i++ ){
    int sockfd = events[i].data.fd;
    /* sockfd 肯定就绪,直接处理 */
}

9.3.3 LT 和 ET 模式

epoll 对文件描述符的操作有两种模式: LT (Level Trigger, 电平触发) 模式和 ET( Edge Trigger, 边沿触发) 模式。LT 是默认的工作模式,这种模式下 epoll 相当于一个效率较高的 poll。当往 epoll 内核事件表中注册一个文件描述符上的 EPOLLET 事件时,epoll 将以 ET 模式来操作该文件描述符。

对于 LT ,当 epoll_wait 检测到有事件发生并将此事件通知应用程序后,应用程序可以不立即处理该事件。而对于 ET , 当 epoll_wait 检测到有事件发生并通知后,应用程序必须理解处理该事件,因为后续的 epoll_wait 调用不再向应用程序通知这一事件。由此可见 ETLT 效率要高

注意: 每个 ET 模式的文件描述符都应该是非阻塞的。如果文件描述符时阻塞的,那么读或写操作将会因为没有后续的事件而一直处于阻塞状态(饥渴状态)

LTandET.cpp

g++ -o LTandET LTandET.cpp
./LTandET 192.***.***.*** 8088

addfd( epollfd, listenfd, true) //将 listenfd 这个监听文件符也加进了事件表,所以当新的连接要求加入时,也在触发 EPOLLIN 事件

9.3.4 EPOLLONESHOT 事件

在使用 ET 时,一个线程或进程在读取完某个 socket 上的数据后开始处理数据,而在这个过程中该 socket 上又有新数据可读(EPOLLIN再次触发),此时另外一个线程被唤醒来读取这些新的数据。于是就出现了两个线程同时操作一个 socket 的局面。所以,可以通过 epollEPOLLONESHOT 事件来实现

对于注册了 EPOLLONESHOT 事件的文件描述符,操作系统最多触发其上注册的一个可读、可写或者异常事件,且只触发一次,除非我们使用 epoll_ctl 函数重置该文件描述符上注册的 EPOLLONESHOT 事件。所以,当该 socket 被某个线程处理完毕,就应该立即重置 EPOLLONESHOT 事件。

EPOLLONESHOT.cpp

g++ -o EPOLLONESHOT EPOLLONESHOT.cpp
./EPOLLONESHOT 192.***.***.*** 8088

9.4 三组 I/O 复用函数的比较

select、poll、epoll 3 组函数都通过某种结构体变量来告诉内核监听哪些文件描述符上的哪些事件,并使用该结构体类型的参数来获取内核处理的结果。select 的函数类型 fd_set 没有将文件描述符和事件绑定,它仅仅是一个文件描述符集合,因此 select 需要提供 3 个这种类型的参数来分别传入和输出可读、可写及集合的在线修改,应用程序下次调用 select 前不得不重置这 3 个 fd_set 集合。poll 参数类型 pollfd 把文件描述符和事件都定义其中,任何时间都被统一处理,从而使得编程结构简介得多。并且内核每次修改的时 epollfd 结构体的 revents 成员,而 event 成员保持不变,因此下次调用 poll 时应用程序无须重置 pollfd 类型的事件集参数。每次 selectpoll 调用都返回整个用户注册的事件集和,复杂度高。epoll 则采用与 selectpoll 完全不同的方式来管理用户注册的事件。在内核中维护一个事件表,并提供一个独立的系统调用 epoll_ctl 来控制往其中添加、删除、修改事件。这样,无须反复从用户空间读入这些事件。epoll_wait 系统调用的 events 参数仅用来返回就绪的事件,这使得应用程序所以就绪文件描述符的时间复杂度达到 O(1)。

select、poll 采用的是轮询的方式来执行事件。

epoll_wait 采用回调的方式。当内核检测到就绪的文件描述符时,将触发回调函数,回调函数就将该文件描述符上对应的事件插入内核就绪事件队列。

所以在选择使用 select 还是 epoll 上,当场景为连接量少,并且都比较活跃时,选择 select | poll 会比 epoll更好

系统调用selectpollepoll
事件集合用户通过 3 个参数分别传入感兴趣的可读、可写及异常等事件,内核通过对这些参数的在线修改来反馈其中的就绪事件。这使得用户每次调用 select 都要重置这 3 个参数同一处理所有事件类型,因此只需要一个事件集参数。用户通过 pollfd.events 传入感兴趣的事件,内核通过修改 pollfd.revents 反馈其中就绪的事件内核通过一个事件表直接管理用户感兴趣的所有事件。因此每次调用 epoll_wait 时,无须反复传入用户感兴趣的事件。epoll_wait 系统调用的参数 events 仅用来反馈就绪的事件
应用程序索引就绪文件描述符的事件复杂度O(n)O(n)O(1)
最大支持文件描述符数一般有最大值限制6553565535
工作模式LTLT支持 ET 高效模式
内核实现和工作效率采用轮询方式来检测就绪事件,算法时间复杂度为 O(n)采用轮询方式来检测就绪事件,算法时间复杂度为 O(n)采用回调方式来检测就绪事件,算法时间复杂的为 O(1)

9.5 I/O复用的高级应用一:非阻塞 connect

connect 出错时有一种 errno 值:EINPROGRESS。这种错误发生在对非阻塞的 socket 调用 connect ,而连接又没有立即建立时。根据 man 文档的解释,在这种情况下,我们可以调用 select、poll 等函数来监听跟这个连接失败的 socket 上的可写事件。当 select、poll 等函数返回后,再利用 getsockopt 来读取错误码并清除该 socket 上的错误。

通过上面描述的非阻塞 connect 方式,就可以同时发起多个连接并一起等待。

connect.cpp

然,这种方法存在几个移植性问题。首先,非阻塞的 socket 可能导致 connect 始终失败。其次, select 对处于 EINPROGRESS 状态下的 socket 可能不起作用。最后,对于出错的 socket,getsockopt 在有些系统( 比如 Linux ) 上返回 -1,而有些返回 0.

9.6 I/O复用的高级应用二:聊天室程序

9.6.1 客户端

客户端程序使用 poll 同时监听用户输入和网络连接,并利用 splice 函数将用户输入内容直接定向到网络连接上以发送之,从而实现数据零拷贝,提高了程序执行效率。

源代码在文章开头链接

client.cpp

server.cpp

9.7 I/O 复用的高级应用三:同时处理 TCP 和 UDP 服务

在实际应用中,有不少服务器程序能同时监听多个端口,比如超级服务 inetdandroid 的调试服务 adbd

bind 系统调用的参数来看,一个 socket 只能与一个 soscket 地址绑定,即一个 socket 只能用来监听一个端口。因此,服务器如果要同时监听多个端口,就必须创建多个 socket, 并将他们分别绑定到各个端口上。这样一来,服务器程序就需要同时监听处理该端口上的 TCP 和 UDP 请求,则也需要创建两个不同的 socket :一个是流 socket ,另一个是数据报 socket ,并将他们都绑定到该端口上。

TCP_UDP.cpp

9.8 超级服务 xinetd

Linux 因特网服务 inetd 是超级服务,它同时管理着多个子服务,即监听多个端口。现在 Linux 系统上使用 inetd 服务程序通常是其升级版本 xinetdxinetd 程序的原理与 inetd 相同,但增加了一些控制选项,并提高了安全性。

9.8.1 xinetd 配置文件

xinetd 采用 /etc/xinetd.conf 主配置文件和 etc/xinetd.d 目录下的子配置文件来管理所有服务。主配置文件包含的是通用选项,这些选项将被所有子配置文件继承,并且可以被子配置文件覆盖。每个子配置文件用于设置一个自服务的参数。比如 telnet 子服务的配置文件 /etc/xinetd.d/telnet 的典型内容如下:

# default: on
# description: The telnet server serves telnet sessions; it uses \
# unencrypted username/password pairs for oauthentication.
service telnet {
    flags                = REUSE
    socket_type          = stream
    wait                 = no
    user                 = root
    server               = /usr/sbin/in.telnetd
    log_on_failure       = USERID
    disable              = no
}

上面每一项的含义如下:

项目含义
service服务名
flags设置连接的标志。REUSE 表示复用 telnet 连接的 socket 。该标志已经过时。每个连接都默认启用 REUSE 标志
socket_types服务类型
wait服务采用单线程方式 ( wait = yes ) 还是多线程方式 ( wait = no )。单线程方式表示 xinetd 只 accept 第一次连接,此后将由子服务进程来 accept 新连接。多线程方式表示 xinetd 一直负责 accept 连接,而子服务进程仅处理连接 socket 上的数据读写
user子服务进程将以 user 指定的用户身份运行
server子服务程序的完整路径
log_on_failure定义当服务不能启动时输出日志的参数
disable是否启动该子服务

9.8.3 xinetd 工作流程

xinetd 管理的子服务中有的是标准服务,比如时间日期服务 daytime, 回射服务 echo 和 丢弃服务 discardxinetd 服务器在内部直接处理这些服务。还有的子服务则需要调用外部的服务器程序来处理。xinetd 通过调用 forkexec 函数来加载运行这些服务器程序。比如 telnet、ftp 服务都是这种类型的子服务。

查看 xinetd 守护进程的 PID

$ cat /var/run/xinetd.pid
4862

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值