Bootstrap

Linux 网络编程学习笔记——九、I/O 复用

目录

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

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

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

一、select 系统调用

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

1. select API

#include<sys/select.h>
#include<typesizes.h> 
#define__FD_SETSIZE 1024 
#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;

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

int select(int nfds,fd_set*readfds,fd_set*writefds,fd_set*exceptfds,struct timeval*timeout);
  • nfds:指定被监听的文件描述符的总数。它通常被设置为 select 监听的所有文件描述符中的最大值加 1 ,因为文件描述符是从 0 开始计数的;
  • readfds 、writefds 和 exceptfds:分别指向可读、可写和异常等事件对应的文件描述符集合,应用程序调用 select 函数时,通过这 3 个参数传入自己感兴趣的文件描述符。select 调用返回时,内核将修改它们来通知应用程序哪些文件描述符已经就绪。这 3 个参数是 fd_set 结构指针类型;
  • fd_set 结构体:仅包含一个整型数组,该数组的每个元素的每一位(bit)标记一个文件描述符,fd_set 能容纳的文件描述符数量由 FD_SETSIZE 指定,这就限制了 select 能同时处理的文件描述符的总量;
  • timeout:用来设置 select 函数的超时时间。它是一个 timeval 结构类型的指针,采用指针参数是因为内核将修改它以告诉应用程序 select 等待了多久。不过我们不能完全信任 select 调用返回后的 timeout 值,比如调用失败时 timeout 值是不确定的,由以上定义可见,select 提供了一个微秒级的定时方式。如果给 timeout 变量的 tv_sec 成员和 tv_usec 成员都传递 0 ,则 select 将立即返回。如果给 timeout 传递 NULL ,则 select 将一直阻塞,直到某个文件描述符就绪;
  • select 成功时返回就绪(可读、可写和异常)文件描述符的总数。如果在超时时间内没有任何文件描述符就绪,select 将返回 0 。select 失败时返回 -1 并设置 errno 。如果在 select 等待期间,程序接收到信号,则 select 立即返回 -1 ,并设置 errno 为 EINTR 。

由于位操作过于烦琐,应该使用下面的一系列宏来访问 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是否被设置*/

2. 文件描述符就绪条件

哪些情况下文件描述符可以被认为是可读、可写或者出现异常,对于 select 的使用非常关键。在网络编程中,下列情况下 socket 可读:

  • socket 内核接收缓存区中的字节数大于或等于其低水位标记 SO_RCVLOWAT 。此时我们可以无阻塞地读该 socket ,并且读操作返回的字节数大于 0 ;
  • socket 通信的对方关闭连接。此时对该 socket 的读操作将返回 0 。
  • 监听 socket 上有新的连接请求。
  • socket 上有未处理的错误。此时我们可以使用 getsockopt 来读取和清除该错误。

下列情况下 socket 可写:

  • socket 内核发送缓存区中的可用字节数大于或等于其低水位标记 SO_SNDLOWAT 。此时我们可以无阻塞地写该 socket ,并且写操作返回的字节数大于 0 ;
  • socket 的写操作被关闭。对写操作被关闭的 socket 执行写操作将触发一个 SIGPIPE 信号;
  • socket 使用非阻塞 connect 连接成功或者失败(超时)之后;
  • socket 上有未处理的错误。此时我们可以使用 getsockopt 来读取和清除该错误。

网络程序中,select 能处理的异常情况只有一种:socket 上接收到带外数据。

3. 处理带外数据

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

#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <stdlib.h>

int main(int argc, char *argv[])
{
    if (argc < = 2)
    {
        printf("usage:%s ip_address port_number\n", basename(argv[0])) return 1;
    }
    const char *ip = argv[1];
    int port = atoi(argv[2]);
    int ret = 0;
    struct sockaddr_in address;
    bzero(&address, sizeof(address));
    address.sin_family = AF_INET;
    inet_pton(AF_INET, ip, &address.sin_addr);
    address.sin_port = htons(port);
    int listenfd = socket(PF_INET, SOCK_STREAM, 0);
    assert(listenfd > = 0);
    ret = bind(listenfd, (struct sockaddr *)&address, sizeof(address));
    assert(ret != -1);
    ret = listen(listenfd, 5);
    assert(ret != -1);
    struct sockaddr_in client_address;
    socklen_t client_addrlength = sizeof(client_address);
    int connfd = accept(listenfd, (struct sockaddr *)&client_address, &client_addrlength);
    if (connfd < 0)
    {
        printf("errno is:%d\n", errno);
        close(listenfd);
    }
    char buf[1024];
    fd_set read_fds;
    fd_set exception_fds;
    FD_ZERO(&read_fds);
    FD_ZERO(&exception_fds);
    while (1)
    {
        memset(buf, '\0', sizeof(buf)); 
        /*每次调用select前都要重新在read_fds和exception_fds中设置文件描述符 connfd,因为事件发生之后,文件描述符集合将被内核修改*/
        FD_SET(connfd, &read_fds);
        FD_SET(connfd, &exception_fds);
        ret = select(connfd + 1, &read_fds, NULL, &exception_fds, NULL);
        if (ret < 0)
        {
            printf("selection failure\n");
            break;
        } 
        /*对于可读事件,采用普通的recv函数读取数据*/
        if (FD_ISSET(connfd, &read_fds))
        {
            ret = recv(connfd, buf, sizeof(buf) - 1, 0);
            if (ret <= 0)
            {
                break;
            }
            printf("get%d bytes of normal data:%s\n", ret, buf);
        } 
        /*对于异常事件,采用带MSG_OOB标志的recv函数读取带外数据*/
        else if (FD_ISSET(connfd, &exception_fds))
        {
            ret = recv(connfd, buf, sizeof(buf) - 1, MSG_OOB);
            if (ret < = 0)
            {
                break;
            }
            printf("get%d bytes of oob data:%s\n", ret, buf);
        }
    }
    close(connfd);
    close(listenfd);
    return 0;
}

二、poll 系统调用

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

#includ<poll.h>

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

int poll(struct pollfd*fds,nfds_t nfds,int timeout);
  • fd:指定文件描述符;
  • events:告诉 poll 监听 fd 上的哪些事件,它是一系列事件的按位或:
    事件描述是否可作为输入是否可作为输出
    POLLIN数据(包括普通数据和优先数据)可读
    POLLRDNORM普通数据可读
    POLLRDBAND优先级带数据可读(Linux 不支持)
    POLLPRI高优先级数据可读,比如 TCP 带外数据
    POLLOUT数据(包括普通数据和优先数据)可写
    POLLWRNORM普通数据可写
    POLLWRBAND优先级带数据可写
    POLLRDHUPTCP 连接被对方关闭,或者对方关闭了写操作。它由 GNU 引入
    POLLERR错误
    POLLHUP挂起,比如管道的写端被关闭后,读描述符上将收到 POLLHUP 事件
    POLLNVAL文件描述符没有打开
  • revents:由内核修改,以通知应用程序 fd 上实际发生了哪些事件;
  • nfds:指定被监听事件集合 fds 的大小。其类型 nfds_t 的定义如下:
    typedef unsigned long int nfds_t;
    
  • timeout:指定 poll 的超时值,单位是毫秒。当 timeout 为 -1 时,poll 调用将永远阻塞,直到某个事件发生;当 timeout 为 0 时,poll 调用将立即返回;
  • poll 系统调用的返回值的含义与 select 相同。

上表中,POLLRDNORM 、POLLRDBAND 、POLLWRNORM 、POLLWRBAND 由 XOPEN 规范定义。它们实际上是将 POLLIN 事件和 POLLOUT 事件分得更细致,以区别对待普通数据和优先数据。但 Linux 并不完全支持它们。通常,应用程序需要根据 recv 调用的返回值来区分 socket 上接收到的是有效数据还是对方关闭连接的请求,并做相应的处理。不过,自 Linux 内核 2.6.17 开始,GNU 为 poll 系统调用增加了一个 POLLRDHUP 事件,它在 socket 上接收到对方关闭连接的请求之后触发。这为我们区分上述两种情况提供了一种更简单的方式。但使用 POLLRDHUP 事件时,我们需要在代码最开始处定义 _GNU_SOURCE 。

三、epoll 系统调用

1. 内核事件表

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

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

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

下面的函数用来操作 epoll 的内核事件表:

#include<sys/epoll.h>

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

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

int epoll_ctl(int epfd,int op,int fd,struct epoll_event*event)
  • fd:要操作的文件描述符;
  • op:指定操作类型:
    • EPOLL_CTL_ADD ,往事件表中注册 fd 上的事件;
    • EPOLL_CTL_MOD ,修改 fd 上的注册事件;
    • EPOLL_CTL_DEL ,删除 fd 上的注册事件。
  • event:指定事件,它是 epoll_event 结构指针类型:
    • events:描述事件类型。epoll 支持的事件类型和 poll 基本相 同。表 示epoll 事件类型的宏是在 poll 对应的宏前加上“E”,比如 epoll 的数据可读事件是 EPOLLIN 。但 epoll 有两个额外的事件类型 EPOLLET 和 EPOLLONESHOT 。它们对于 epoll 的高效运作非常关键。
    • data:用于存储用户数据:
      • epoll_data_t:是一个联合体,其 4 个成员中使用最多的是 fd ,它指定事件所从属的目标文件描述符;
      • ptr:可用来指定与 fd 相关的用户数据。但由于 epoll_data_t 是一个联合体,我们不能同时使用其 ptr 成员和 fd 成员,因此,如果要将文件描述符和用户数据关联起来,以实现快速的数据访问,只能使用其他手段,比如放弃使用 epoll_data_t 的 fd 成员,而在 ptr 指向的用户数据中包含 fd 。
  • epoll_ctl 成功时返回 0 ,失败则返回 -1 并设置 errno 。

2. epoll_wait 函数

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

#include<sys/epoll.h>
int epoll_wait(int epfd,struct epoll_event*events,int maxevents,int timeout);
  • timeout:含义与 poll 接口的 timeout 参数相同;
  • maxevents:指定最多监听多少个事件,它必须大于 0 ;
  • 该函数成功时返回就绪的文件描述符的个数,失败时返回 -1 并设置 errno 。

epoll_wait 函数如果检测到事件,就将所有就绪的事件从内核事件表(由 epfd 参数指定)中复制到它的第二个参数 events 指向的数组中。这个数组只用于输出 epoll_wait 检测到的就绪事件,而不像 select 和 poll 的数组参数那样既用于传入用户注册的事件,又用于输出内核检测到的就绪事件。这就极大地提高了应用程序索引就绪文件描述符的效率:

/*如何索引poll返回的就绪文件描述符*/ 
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肯定就绪,直接处理*/ 
}

3. LT 和 ET 模式

epoll 对文件描述符的操作有两种模式:LT(Level Trigger,电平 触发)模式和 ET(Edge Trigger,边沿触发)模式:

  • LT 模式是默认的工作模式,这种模式下 epoll 相当于一个效率较高的 poll 。当往 epoll 内核事件表中注册一个文件描述符上的 EPOLLET 事件时,epoll 将以 ET 模式来操作该文件描述符。对于采用 LT 工作模式的文件描述符,当 epoll_wait 检测到其上有事件发生并将此事件通知应用程序后,应用程序可以不立即处理该事件。这样,当应用程序下一次调用 epoll_wait 时,epoll_wait 还会再次向应用程序通告此事件,直到该事件被处理;
  • ET 模式是 epoll 的高效工作模式,对于采用 ET 工作模式的文件描述符,当 epoll_wait 检测到其上有事件发生并将此事件通知应用程序后,应用程序必须立即处理该事件,因为后续的 epoll_wait 调用将不再向应用程序通知这一事件。可见,ET 模式在很大程度上降低了同一个 epoll 事件被重复触发的次数,因此效率要比 LT 模式高;
  • 注意:每个使用 ET 模式的文件描述符都应该是非阻塞的。如果文件描述符是阻塞的,那么读或写操作将会因为没有后续的事件而一直处于阻塞状态(饥渴状态)。

4. EPOLLONESHOT 事件

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

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

尽管一个 socket 在不同时间可能被不同的线程处理,但同一时刻肯定只有一个线程在为它服务。这就保证了连接的完整性,从而避免了很多可能的竞态条件。

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

前面我们讨论了 select 、poll 和 epoll 三组 I/O 复用系统调用,这 3 组系统调用都能同时监听多个文件描述符。它们将等待由 timeout 参数指定的超时时间,直到一个或者多个文件描述符上有事件发生时返回,返回值是就绪的文件描述符的数量。返回 0 表示没有事件发生。现在我们从事件集、最大支持文件描述符数、工作模式和具体实现等四个方面进一步比较它们的异同,以明确在实际应用中应该选择使用哪个。

这 3 组函数都通过某种结构体变量来告诉内核监听哪些文件描述符上的哪些事件,并使用该结构体类型的参数来获取内核处理的结果:

  • select 的参数类型 fd_set 没有将文件描述符和事件绑定,它仅仅是一个文件描述符集合,因此 select 需要提供 3 个这种类型的参数来分别传入和输出可读、可写及异常等事件。这一方面使得 select 不能处理更多类型的事件,另一方面由于内核对 fd_set 集合的在线修改,应用程序下次调用 select 前不得不重置这 3 个 fd_set 集合。
  • poll 的参数类型 pollfd 则多少“聪明”一些。它把文件描述符和事件都定义其中,任何事件都被统一处理,从而使得编程接口简洁得多。并且内核每次修改的是 pollfd 结构体的 revents 成员,而 events 成员保持不变,因此下次调用 poll 时应用程序无须重置 pollfd 类型的事件集参数。由于每次 select 和 poll 调用都返回整个用户注册的事件集合(其中包括就绪的和未就绪的),所以应用程序索引就绪文件描述符的时间复杂度为 O ( n ) O(n) O(n)
  • epoll 则采用与 select 和 poll 完全不同的方式来管理用户注册的事件。它在内核中维护一个事件表,并提供了一个独立的系统调用 epoll_ctl 来控制往其中添加、删除、修改事件。这样,每次 epoll_wait 调用都直接从该内核事件表中取得用户注册的事件,而无须反复从用户空间读入这些事件。epoll_wait 系统调用的 events 参数仅用来返回就绪的事件,这使得应用程序索引就绪文件描述符的时间复杂度达到 O ( 1 ) O(1) O(1)

poll 和 epoll_wait 分别用 nfds 和 maxevents 参数指定最多监听多少个文件描述符和事件。这两个数值都能达到系统允许打开的最大文件描述符数目,即 65535(cat/proc/sys/fs/file-max)。而 select 允许监听的最大文件描述符数量通常有限制。虽然用户可以修改这个限制,但这可能导致不可预期的后果。select 和 poll 都只能工作在相对低效的 LT 模式,而 epoll 则可以工作在 ET 高效模式。并且 epoll 还支持 EPOLLONESHOT 事件。该事件能进一步减少可读、可写和异常等事件被触发的次数。

从实现原理上来说,select 和 poll 采用的都是轮询的方式,即每次调用都要扫描整个注册文件描述符集合,并将其中就绪的文件描述符返回给用户程序,因此它们检测就绪事件的算法的时间复杂度是 O ( n ) O(n) O(n) 。epoll_wait 则不同,它采用的是回调的方式。内核检测到就绪的文件描述符时,将触发回调函数,回调函数就将该文件描述符上对应的事件插入内核就绪事件队列。内核最后在适当的时机将该就绪事件队列中的内容拷贝到用户空间。因此 epoll_wait 无须轮询整个文件描述符集合来检测哪些事件已经就绪,其算法时间复杂度是 O ( 1 ) O(1) O(1) 。但是,当活动连接比较多的时候,epoll_wait 的效率未必比 select 和 poll 高,因为此时回调函数被触发得过于频繁。所以 epoll_wait 适用于连接数量多,但活动连接较少的情况。

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

五、I/O 复用的高级应用——聊天室

像 ssh 这样的登录服务通常要同时处理网络连接和用户输入,这也可以使用 I/O 复用来实现。本节以 poll 为例实现一个简单的聊天室程序,以阐述如何使用 I/O 复用技术来同时处理网络连接和用户输入。该聊天室程序能让所有用户同时在线群聊,它分为客户端和服务器两个部分。其中客户端程序有两个功能:

  • 一是从标准输入终端读入用户数据,并将用户数据发送至服务器;
  • 二是往标准输出终端打印服务器发送给它的数据。

服务器的功能是接收客户数据,并把客户数据发送给每一个登录到该服务器上的客户端(数据发送者除外)。

1. 客户端

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

#define _GNU_SOURCE 1
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <poll.h>
#include <fcntl.h>
#define BUFFER_SIZE 64
int main(int argc, char *argv[])
{
    if (argc <= 2)  // 判断参数个数,第一个为ip地址,第二个为端口号
    {
        printf("usage: %s ip_address port_number\n", basename(argv[0]));
        return 1;
    }
    const char *ip = argv[1];  // 存储字符串ip地址
    int port = atoi(argv[2]);  // 存储int型端口号
    struct sockaddr_in server_address;  // 定义sockaddr_in地址结构体
    bzero(&server_address, sizeof(server_address));  // 将server_address内存块的值清零
    server_address.sin_family = AF_INET;  // 设置地址族为AF_INET
    inet_pton(AF_INET, ip, &server_address.sin_addr);  // 字符串ip地址转为网络字节序
    server_address.sin_port = htons(port);  // 设置端口号,htons将端口号转为网络字节序
    int sockfd = socket(PF_INET, SOCK_STREAM, 0);  // 创建socket,PF_INET为ipv4协议族,SOCK_STREAM表示传输层使用tcp协议,第三个参数表示具体协议,通常为0
    assert(sockfd >= 0);  // 验证创建是否成功
    if (connect(sockfd, (struct sockaddr *)&server_address, sizeof(server_address)) < 0)  // connect发起连接,将sockaddr_in类型强制转换为sockaddr*,第三个参数是地址长度,此时判断连接是否成功
    {
        printf("connection failed\n");  // 连接失败
        close(sockfd);  // 关闭socket
        return 1;
    }
    pollfd fds[2];  // 使用poll系统调用
    /*注册文件描述符0(标准输入)和文件描述符sockfd上的可读事件*/
    fds[0].fd = 0;  // 文件描述符,0表示标准输入
    fds[0].events = POLLIN;  // 告诉poll监听fd上的哪些事件,POLLIN为数据可读
    fds[0].revents = 0;  // 实际发生的事件,由内核修改,以通知应用程序fd上实际发生了什么
    fds[1].fd = sockfd;  // 使得fds[1]为sockfd
    fds[1].events = POLLIN | POLLRDHUP;  // 数据可读、TCP连接被对方关闭、对方关闭写操作
    fds[1].revents = 0;
    char read_buf[BUFFER_SIZE];  // 读缓存区
    int pipefd[2];  // 存储管道数据
    int ret = pipe(pipefd);  // 创建管道,pipefd[0]为读出端,pipefd[1]为写入端
    assert(ret != -1);  // 判断管道是否创建成功
    while (1)  // 循环处理
    {
        ret = poll(fds, 2, -1);  // 轮询文件描述符以测试其中是否有就绪者,第二个参数表示被监听事件集合fds的大小,第三个参数为超时时间,-1表示永远阻塞直到某个事件发生
        if (ret < 0)  // 判断返回,成功时返回就绪的文件描述符总数,失败时为-1
        {
            printf("poll failure\n");
            break;
        }
        if (fds[1].revents & POLLRDHUP)  // 判断fds[1]的类型是否为POLLRDHUP,即被关闭TCP或关闭写操作
        {
            printf("server close the connection\n");
            break;
        }
        else if (fds[1].revents & POLLIN)  // 判断fds[1]是否可读,即获取服务器发送来的数据
        {
            memset(read_buf, '\0', BUFFER_SIZE);  // 初始化读缓存
            recv(fds[1].fd, read_buf, BUFFER_SIZE - 1, 0);  // 读取TCP数据,第三个参数为读缓冲区的大小,flags为数据收发的额外控制器,0表示无意义
            printf("%s\n", read_buf);  // 输出读缓存区的数据
        }
        if (fds[0].revents & POLLIN)  // 判断fds[0]是否可读,即处理用户输入的数据
        { 
            // 使用splice将用户输入的数据直接写到sockfd上(零拷贝),第一个参数为待输出数据的文件描述符,第二个参数设定数据流从何处开始读取(NULL表示从当前偏移位置读取)
            // 第三个参数为输出数据流的文件描述符,第四个参数同上,第五个参数为移动数据的长度,第六个参数表示控制数据如何移动
            // 0表示标准输入,写入到管道pipefd[1],数据长度为32768,整页移动|后续将读取更多数据
            ret = splice(0, NULL, pipefd[1], NULL, 32768, SPLICE_F_MORE | SPLICE_F_MOVE);
            // pipefd[0]表示从管道中读出,发送给sockfd,数据长度为32768,整页移动|后续将读取更多数据
            ret = splice(pipefd[0], NULL, sockfd, NULL, 32768, SPLICE_F_MORE | SPLICE_F_MOVE);
        }
    }
    close(sockfd);  // 关闭连接
    return 0;
}

2. 服务器

服务器程序使用 poll 同时管理监听 socket 和连接 socket ,并且使用牺牲空间换取时间的策略来提高服务器性能:

#define _GNU_SOURCE 1
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <stdlib.h>
#include <poll.h>
#define USER_LIMIT 5  // 最大用户数量
#define BUFFER_SIZE 64  // 读缓冲区的大小
#define FD_LIMIT 10  // 文件描述符数量限制

struct client_data  // 客户数据
{
    sockaddr_in address;  // socket地址
    char *write_buf;  // 待写入客户端的数据的位置
    char buf[BUFFER_SIZE];  // 从客户端读入的数据
};

inline int setnonblocking(int fd)  // 设定非阻塞的fd
{
    int old_option = fcntl(fd, F_GETFL);  // 提供对文件描述符的各种控制操作,F_GETFL表示获取fd的状态标志
    int new_option = old_option | O_NONBLOCK;  // 表示设置非阻塞标志
    fcntl(fd, F_SETFL, new_option);  // 设置fd的状态标志为new_option
    return old_option;  // 返回旧状态以便日后恢复使用
}

int main(int argc, char *argv[])
{
    if (argc <= 2)
    {
        printf("usage: %s ip_address port_number\n", basename(argv[0]));
        return 1;
    }
    const char *ip = argv[1];
    int port = atoi(argv[2]);
    int ret = 0;
    struct sockaddr_in address;
    bzero(&address, sizeof(address));
    address.sin_family = AF_INET;
    inet_pton(AF_INET, ip, &address.sin_addr);
    address.sin_port = htons(port);
    int listenfd = socket(PF_INET, SOCK_STREAM, 0);  // 创建socket,ipv4协议族,使用tcp协议
    assert(listenfd >= 0);  // 判断创建是否成功
    ret = bind(listenfd, (struct sockaddr *)&address, sizeof(address));  // 将address所指的socket地址分配给未命名的sockfd文件描述符,第三个参数指出地址的长度
    assert(ret != -1);  // 绑定是否成功
    ret = listen(listenfd, 5);  // 监听socket,内核监听队列的最大长度为5
    assert(ret != -1);  // 判断是否监听成功
    /*创建users数组,分配FD_LIMIT个client_data对象。可以预期:
    每个可能的socket连接都可以获得一个这样的对象,并且socket的值可以直接用来索引(作为数组的下标)socket连接对应的client_data对象,
    这是将socket和客户数据关联的简单而高效的方式*/
    client_data *users = new client_data[FD_LIMIT];  // 存有地址、写缓存、读缓存
    /*尽管我们分配了足够多的client_data对象,但为了提高poll的性能,仍然有必要限制用户的数量*/
    pollfd fds[USER_LIMIT + 1];  // 多1个,因为fds[0]要处理服务
    int user_counter = 0;
    for (int i = 1; i <= USER_LIMIT; ++i)  // 初始化USER_LIMIT个fds
    {
        fds[i].fd = -1;  // 文件描述符
        fds[i].events = 0;  // 期待的事件
    }
    fds[0].fd = listenfd;  // 0号fds设定为监听
    fds[0].events = POLLIN | POLLERR;  // 期待数据可读和错误发生
    fds[0].revents = 0;
    while (1)  // 循环处理
    {
        ret = poll(fds, user_counter + 1, -1);  // poll调用,被监听事件集合的大小为user_counter+1,阻塞等待
        if (ret < 0)  // poll失败
        {
            printf("poll failure\n");
            break;
        }
        for (int i = 0; i < user_counter + 1; ++i)  // 出线预期事件,循环判断所有用户
        {
            if ((fds[i].fd == listenfd) && (fds[i].revents & POLLIN))  // 如果fds为监听者且接收到的事件为数据可读
            {
                struct sockaddr_in client_address;  // 创建客户地址
                socklen_t client_addrlength = sizeof(client_address);  // 初始化
                int connfd = accept(listenfd, (struct sockaddr *)&client_address, &client_addrlength);  // 接收连接,监听fd,源地址,地址长度
                if (connfd < 0)  // 接收失败
                {
                    printf("errno is: %d\n", errno);
                    continue;
                } 
                if (user_counter >= USER_LIMIT)  // 用户请求太多,关闭新到的连接
                {
                    const char *info = "too many users\n";
                    printf("%s", info);
                    send(connfd, info, strlen(info), 0);  // 向connfd写数据
                    close(connfd);
                    continue;
                }

                // 对于新的连接,同时修改fds和users数组。前文已经提到,users[connfd]对应 于新连接文件描述符connfd的客户数据
                user_counter++;  // 记录连接的新用户
                users[connfd].address = client_address;  // 设定地址
                setnonblocking(connfd);  // 设定非阻塞的fd
                fds[user_counter].fd = connfd;  // 设定具体的fd
                fds[user_counter].events = POLLIN | POLLRDHUP | POLLERR;  // 期待事件:可读、关闭、错误
                fds[user_counter].revents = 0;
                printf("comes a new user, now have %d users\n", user_counter);
            }
            else if (fds[i].revents & POLLERR)  // 响应错误事件
            {
                printf("get an error from %d\n", fds[i].fd);
                char errors[100];
                memset(errors, '\0', 100);
                socklen_t length = sizeof(errors);
                if (getsockopt(fds[i].fd, SOL_SOCKET, SO_ERROR, &errors, &length) < 0)  // 读取属性:fd,ipv4协议,获取并清除错误状态,被操作选项的值,长度
                    printf("get socket option failed\n");  // 获取失败
                continue;
            }
            else if (fds[i].revents & POLLRDHUP)  // 客户端关闭连接
            {
                users[fds[i].fd] = users[fds[user_counter].fd];  // 将最后一个用户的fd放到i的缓存区位置
                close(fds[i].fd);  // 关闭连接
                fds[i] = fds[user_counter];  // 修改fds
                i--;  // 处理i,否则会忽略最后一个用户
                user_counter--;  // 处理用户个数
                printf("a client left\n");
            }
            else if (fds[i].revents & POLLIN)  // 数据可读
            {
                int connfd = fds[i].fd;  // 设定fd
                memset(users[connfd].buf, '\0', BUFFER_SIZE);  // 初始化用户数据空间
                ret = recv(connfd, users[connfd].buf, BUFFER_SIZE - 1, 0);  // 接收数据,得到数据长度
                if(memcmp(users[connfd].buf, "exit", 4) == 0) {  // 如果客户端输入exit就关闭服务器
                    delete[] users;  // 删除所有用户
                    close(listenfd);  // 关闭监听
                    return 0;
                }
                printf("get %d bytes of client data: %s from %d\n", ret, users[connfd].buf, connfd);  // 输出信息和发言用户id
                if (ret < 0)  // 错误
                {
                    if (errno != EAGAIN)  // EAGAIN表示再次尝试
                    {
                        close(connfd);  // 关闭连接
                        users[fds[i].fd] = users[fds[user_counter].fd];
                        fds[i] = fds[user_counter];
                        i--;
                        user_counter--;
                    }
                }
                else if (ret == 0)  // 表示对方关闭连接,暂不处理
                {
                }
                else  // 数据接收正常,通知其他socket写数据给其他用户
                {
                    for (int j = 1; j <= user_counter; ++j)  // 逐用户写数据
                    {
                        if (fds[j].fd == connfd)  // 不需要给发言人写数据
                        {
                            continue;
                        }
                        fds[j].events |= ~POLLIN;  // 期待事件非读
                        fds[j].events |= POLLOUT;  // 期待事件为写
                        users[fds[j].fd].write_buf = users[connfd].buf;  // 指针指向其他用户的读缓存区
                    }
                }
            }
            else if (fds[i].revents & POLLOUT)  // 可写数据
            {
                int connfd = fds[i].fd;  // 处理需要写的dfs
                if (!users[connfd].write_buf)  // 写缓存为空就跳过
                {
                    continue;
                }
                ret = send(connfd, users[connfd].write_buf, strlen(users[connfd].write_buf), 0);  // 调用send发送数据
                users[connfd].write_buf = NULL;  // 写完数据后需要重新注册fds[i]上的可读事件
                fds[i].events |= ~POLLOUT;  // 期待事件非写
                fds[i].events |= POLLIN;  // 期待事件为读
            }
        }
    }
    delete[] users;  // 删除所有用户
    close(listenfd);  // 关闭监听
    return 0;
}

六、I/O 复用的高级应用——同时处理 TCP 和 UDP 服务

至此,我们讨论过的服务器程序都只监听一个端口。在实际应用中,有不少服务器程序能同时监听多个端口,比如超级服务 inetd 和 android 的调试服务 adbd 。

从 bind 系统调用的参数来看,一个 socket 只能与一个 socket 地址绑定,即一个 socket 只能用来监听一个端口。因此,服务器如果要同时监听多个端口,就必须创建多个 socket ,并将它们分别绑定到各个端口上。这样一来,服务器程序就需要同时管理多个监听 socket ,I/O 复用技术就有了用武之地。另外,即使是同一个端口,如果服务器要同时处理该端口上的 TCP 和 UDP 请求,则也需要创建两个不同的 socket :一个是流 socket ,另一个是数据报 socket ,并将它们都绑定到该端口上。下面所示的回射服务器就能同时处理一个端口上的 TCP 和 UDP 请求:

#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <stdlib.h>
#include <sys/epoll.h>
#include <pthread.h>
#define MAX_EVENT_NUMBER 1024
#define TCP_BUFFER_SIZE 512
#define UDP_BUFFER_SIZE 1024
int setnonblocking(int fd)
{
    int old_option = fcntl(fd, F_GETFL);
    int new_option = old_option | O_NONBLOCK;
    fcntl(fd, F_SETFL, new_option);
    return old_option;
}
void addfd(int epollfd, int fd)
{
    epoll_event event;
    event.data.fd = fd;
    event.events = EPOLLIN | EPOLLET;  // 可读事件、边缘触发模式
    epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &event);  // 往事件表中注册fd上的事件event
    setnonblocking(fd);
}
int main(int argc, char *argv[])
{
    // 处理地址
    if (argc <= 2)
    {
        printf("usage:%s ip_address port_number\n", basename(argv[0]));
        return 1;
    }
    const char *ip = argv[1];
    int port = atoi(argv[2]);
    int ret = 0;
    struct sockaddr_in address;
    bzero(&address, sizeof(address));
    address.sin_family = AF_INET;
    inet_pton(AF_INET, ip, &address.sin_addr);
    address.sin_port = htons(port); 
    // 创建TCP socket,并将其绑定到端口port上
    int listenfd = socket(PF_INET, SOCK_STREAM, 0);
    assert(listenfd >= 0);
    ret = bind(listenfd, (struct sockaddr *)&address, sizeof(address));
    assert(ret != -1);
    ret = listen(listenfd, 5);
    assert(ret != -1);
    // 创建UDP socket,并将其绑定到端口port上
    bzero(&address, sizeof(address));
    address.sin_family = AF_INET;
    inet_pton(AF_INET, ip, &address.sin_addr);
    address.sin_port = htons(port);
    int udpfd = socket(PF_INET, SOCK_DGRAM, 0);
    assert(udpfd >= 0);
    ret = bind(udpfd, (struct sockaddr *)&address, sizeof(address));
    assert(ret != -1);
    // 创建epoll_event结构体
    epoll_event events[MAX_EVENT_NUMBER];
    int epollfd = epoll_create(5);  // 创建epoll文件描述符
    assert(epollfd != -1);  
    // 注册TCP socket和UDP socket上的可读事件
    addfd(epollfd, listenfd);  // TCP
    addfd(epollfd, udpfd);  // UDP
    while (1)
    {
        // 一段超时时间内等待一组文件描述符上的事件,返回就绪文件描述符的个数
        int number = epoll_wait(epollfd, events, MAX_EVENT_NUMBER, -1);
        if (number < 0)
        {
            printf("epoll failure\n");
            break;
        }
        for (int i = 0; i < number; i++)
        {
            int sockfd = events[i].data.fd;  // 获取就绪的fd
            if (sockfd == listenfd)  // 如果是TCP
            {
                struct sockaddr_in client_address;
                socklen_t client_addrlength = sizeof(client_address);
                int connfd = accept(listenfd, (struct sockaddr *)&client_address, &client_addrlength);
                addfd(epollfd, connfd);
            }
            else if (sockfd == udpfd)
            {
                char buf[UDP_BUFFER_SIZE];
                memset(buf, '\0', UDP_BUFFER_SIZE);
                struct sockaddr_in client_address;
                socklen_t client_addrlength = sizeof(client_address);
                ret = recvfrom(udpfd, buf, UDP_BUFFER_SIZE - 1, 0, (struct sockaddr *)&client_address, &client_addrlength);
                if (ret > 0)
                    sendto(udpfd, buf, UDP_BUFFER_SIZE - 1, 0, (struct sockaddr *)&client_address, client_addrlength);  // 发送udp数据
            }
            else if (events[i].events & EPOLLIN)  // 处理TCP读取
            {
                char buf[TCP_BUFFER_SIZE];
                while (1)
                {
                    memset(buf, '\0', TCP_BUFFER_SIZE);
                    ret = recv(sockfd, buf, TCP_BUFFER_SIZE - 1, 0);
                    if (ret < 0)
                    {
                        if ((errno == EAGAIN) || (errno == EWOULDBLOCK))
                            break;
                        close(sockfd);
                        break;
                    }
                    else if (ret == 0)
                    {
                        close(sockfd);
                    }
                    else
                    {
                        send(sockfd, buf, ret, 0);
                    }
                }
            }
            else
            {
                printf("something else happened\n");
            }
        }
    }
    close(listenfd);
    return 0;
}

七、超级服务 xinetd

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

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 authentication. 
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_type服务类型
wait服务采用单线程方式(wait = yes)还是多线程方式(wait = no)。单线程方式表示 xinetd 只 accept 第一次连接,此后将由子服务进程来 accept 新连接。多线程方式表示 xinetd 一直负责 accept 连接,而子服务进程仅处理连接 socket 上的数据读写
user子服务进程将以 user 指定的用户身份运行
server子服务程序的完整路径
log_on_failure定义当服务不能启动时输出日志的参数
disable是否启动该子服务

2. xinetd 工作流程

xinetd 管理的子服务中有的是标准服务,比如时间日期服务 daytime 、回射服务 echo 和丢弃服务 discard 。xinetd 服务器在内部直接处理这些服务。还有的子服务则需要调用外部的服务器程序来处理。xinetd 通过调用 fork 和 exec 函数来加载运行这些服务器程序。比如 telnet 、ftp 服务都是这种类型的子服务。下面仍以 telnet 服务为例来探讨 xinetd 的工作流程:

  • 首先,查看 xinetd 守护进程的 PID :
    $cat /var/run/xinetd.pid
    9543
    
  • 然后开启两个终端并分别使用如下命令 telnet 到本机:
    $telnet 192.168.1.109
    
  • 接下来使用 ps 命令查看与进程 9543 相关的进程:
    $ps -eo pid, ppid, pgid, sid, comm | grep 9543 
    PID PPID PGID SESS COMMAND 
    9543 1 9543 9543 xinetd 
    9810 9543 9810 9810 in.telnetd 
    10355 9543 10355 10355 in.telnetd
    
  • 由此可见,每次使用 telnet 登录到 xinetd 服务,它都创建一个子进程来为该 telnet 客户服务。子进程运行 in.telnetd 程序,这是在 /etc/xinetd.d/telnet 配置文件中定义的。每个子进程都处于自己独立的进程组和会话中。可以使用 lsof 命令进一步查看子进程都打开了哪些文件描述符:
    $sudo lsof -p 9810 #以子进程9810为例 
    in.telnet 9810 root 0u IPv4 48189 0t0 TCP Kongming20:telnet-> Kongming20:38763(ESTABLISHED) 
    in.telnet 9810 root 1u IPv4 48189 0t0 TCP Kongming20:telnet-> Kongming20.:38763(ESTABLISHED) 
    in.telnet 9810 root 2u IPv4 48189 0t0 TCP Kongming20:telnet-> Kongming20:38763(ESTABLISHED)
    
    这里省略了一些无关的输出。通过 lsof 的输出知道,子进程 9810 关闭了其标准输入、标准输出和标准错误,而将 socket 文件描述符 dup 到它们上面。因此,telnet 服务器程序将网络连接上的输入当作标准输入,并把标准输出定向到同一个网络连接上。
  • 再进一步,对 xinetd 进程使用 lsof 命令:
    $sudo lsof -p 9543 
    xinetd 9543 root 5u IPv6 47265 0t0 TCP*:telnet(LISTEN)
    
    这一条输出说明 xinetd 将一直监听 telnet 连接请求,因此 in.telnetd 子进程只处理连接 socket ,而不处理监听 socket 。这是子配置文件中的 wait 参数所定义的行为。对于内部标准服务,xinetd 的处理流程也可以用上述方法来分析:
    1
;