I/O复用
文章目录
I/O 复用使得程序能同时监听多个文件描述符,这对提高程序的性能至关重要。通常,网络程序在下列情况下需要使用 I/O 复用技术
- 客户端程序要同时处理多个 socket。比如本章将要讨论的非阻塞 connect 技术
- 客户端程序要同时处理用户输入和网络连接。比如本章将要讨论的聊天室程序口TCP 服务器要同时处理监听 socket 和连接 socket。这是I/0 复用使用最多的场合。后
续章节将展示很多这方面的例子。 - 服务器要同时处理 TCP 请求和 UDP 请求。比如回射服务器。
- 服务器要同时监听多个端口,或者处理多种服务。比如 xinetd 服务器。
需要指出的是,IO 复用虽然能同时监听多个文件描述符,但它本身是阻塞的。并且当多个文件描述符同时就绪时,如果不采取额外的措施,程序就只能按顺序依次处理其中的每一个文件描述符,这使得服务器程序看起来像是申行工作的。如果要实现并发,只能使用多进程或多线程等编程手段。
Linux 下实现 I/O 复用的系统调用主要有 select、poll 和epoll
1.1、select系统调用
select 系统调用的用途是:在一段指定时间内,监听用户感兴趣的文件描述符上的可读可写和异常等事件
1.1.1 select API
select 系统调用的原型如下:
#include <sys/select.h>
int select( int nfds, fd_set* readfds, fd set* writefds, fd_set* exceptfds,
struct timeval* timeout );
1)nfds 参数指定被监听的文件描述符的总数。它通常被设置为 slect 监听的所有文件描述符中的最大值加 1,因为文件描述符是从0开始计数的
2)readfds、writefds 和 exceptfds 参数分别指向可读、可写和异常等事件对应的文件描述符集合。应用程序调用 select 函数时,通过这3 个参数传自已感兴趣的文件描述符。select 调用返回时,内核将修改它们来通知应用程序哪些文件描述符已经就绪。这3个参数是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 能容纳的文件描述符数由 FD SETSIZE 指定,这就限制了select 能同时处理的文件描述符的总量。
由于位操作过于烦琐,我们应该使用下面的一系列宏来访问 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结构类型的指针采用指针参数是因为内核将修改它以告诉应用程序 select 等待了多久。不过我们不能完全信任sclect 调用返回后的 timeout 值,比如调用失败时 timeout 值是不确定的。timeval 结构体的定义如下:
struct timeval
{
long tv_sec; /*秒数*/
long tv_usec;/* 微秒数 */
}
由以上定义可见,select 给我们提供了一个微秒级的定时方式。如果给 timeout 变量的tv_sec 成员和 tv_usec 成员都传递0,则select将立即返回。如果给timeout传递 NULL,则select 将一直阻寨,直到某个文件描述符就绪。
select 成功时返回就绪(可读、可写和异常)文件描述符的总数。如果在超时时间内没有任何文件描述符就绪,select 将返回 0。select 失败时返回 -1并设置 errno。如果在 select等待期间,程序接收到信号,则 select 立即返回-1,并设置errno 为 EINTR。
1.1.2 文件描述符就绪条件
哪些情况下文件描述符可以被认为是可读、可写或者出现异常,对于 select 的使用非常关键。在网络编程中,下列情况下 socket 可读:
-
socket 内核接收缓存区中的字节数大于或等于其低水位标记SO_RCVLOWAT。此时我们可以无阻塞地读该 socket,并且读操作返回的字节数大于 0,
-
socket 通信的对方关闭连接。此时对该 socket 的读操作将返回0 监听 socket 上有新的连接请求。
-
socket 上有未处理的错误。此时我们可以使用 getsockopt 来读取和清除该错误。
-
下列情况下 socket 可写:
- socket 内核发送缓存区中的可用字节数大于或等于其低水位标记S OSNDLOWAT此时我们可以无阻寨地写该 socket,并且写操作返回的字节数大于0。
- socket 的写操作被关闭。对写操作被关闭的 sockct 执行写操作将触发一个SIGPIPE信号
- socket 使用非阻塞 connect 连接成功或者失败《超时)之后。
- socket 上有未处理的错误。此时我们可以使用 getsockopt 来读取和清除该错误。
网络程序中,select 能处理的异常情况只有一种:socket 上接收到带外数据
带外数据: 有些传输层协议具有带外(Out Of Band,OOB)数据的概念,用于迅速通告对方本端发生的重要事件。 因此,带外数据比普通数据(也称为带内数据)有更高的优先级,它应该总是立即被发送,而不论发送缓冲区中是否有排队等待发送的普通数据
1.1.3 处理带外数据
同时处理普通数据和带外数据
#include <sys/types .h>
#include <sys/socket .h>
#include <netinet/in.h>
#inelude<arpa/inet .h>
#include<assert.h>
#include<stdio.h>
#include<unistd.h>
#include<errno.h>
#include<string.h>
#include<fentl.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;
}
}
1.1.4 示例代码
#include<iostream>
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<string.h>
#include<sys/select.h>
#include<time.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include<arpa/inet.h>
#include<list>
using namespace std;
int socket_init();
int main()
{
int sockfd = socket_init();
if(sockfd == -1)
{
cout<<"socket init err\n"<<endl;
exit(1);
}
list<int> fds;
fds.push_back(sockfd);
fd_set fdset;//读事件
fd_set fdexpcetion;//异常事件
while(true)
{
FD_ZERO(&fdset);
int maxfd = -1;
for(auto iter = fds.begin();iter !=fds.end();++iter)
{
if(*iter<0)
continue;
FD_SET(*iter,&fdset);
if(*iter <maxfd)
{
maxfd = *iter;
}
}
struct timeval tv = {5,0};
int n = select(maxfd+1,&fdset,NULL,NULL,&tv);
if(n < 0)
{
cout<<"select err"<<endl;
}
else if( n == 0)//超时
{
cout<<"time over"<<endl;
}
else
{
for(auto iter = fds.begin();iter != fds.end();++iter)
{
if(*iter <0)
{
continue;
}
if(FD_ISSET(*iter,&fdset))//有读事件就绪
{
if(*iter == sockfd)//accept 监听套接字
{
struct sockaddr_in caddr;
socklen_t len = sizeof(caddr);
int c = accept(*iter,(struct sockaddr*)&caddr,&len);
if(c<0)
{
continue;
}
fds.push_back(c);
cout<<"accept:c = "<<c<<endl;
}
else if
{
char buff[128];
int num = recv(*iter,buff,num,0);
if(num <= 0)
{
close(*iter);
cout<<"client close"<<endl;
fds.erase(iter);
continue;
}
cout<<"c = "<<*iter<<"buff = "<<buff<<endl;
send(*iter,"ok",2,0);
}
}
/* 对于异常事件,采用带 MSG_OOB 标志的 recv 图数读取带外数据“ */
if(FD_ISSET(*iter,&fdexception))
{
char buff[128];
int num = recv(*iter,buff,num,MSG_OOB);
if(num <= 0)
{
close(*iter);
cout<<"client close"<<endl;
fds.erase(iter);
continue;
}
cout<<"c = "<<*iter<<"buff = "<<buff<<endl;
send(*iter,"no",2,0);
}
}
}
}
int socket_init()
{
int sockfd = socket(AF_INET,SOCK_STREAM,0);
if(sockfd == -1)
{
printf("socket err\n");
exit(0);
}
struct sockaddr_in saddr;
saddr.sin_family = AF_INET;
saddr.sin_port = htons(6000);
saddr.sin_addr.s_addr = inet_addr("127.0.0.1");
//绑定
int res = bind(sockfd,(struct sockaddr*)&saddr,sizeof(saddr));
if(res == -1)
{
printf("bind err\n");
return(-1);
}
//创建监听队列
res = listen(sockfd,0);
if(res == -1)
{
return(-1);
}
return(sockfd);
}
1.2 poll 系统调用
poll 系统调用和 select 类似,也是在指定时间内轮询一定数量的文件描述符,以测试其中是否有就绪者。poll 的原型如下:
#include <pol1.h>
int poll( struct pollfd* fds, nfds_t nfds, int timeout );
fds 参数是一个 pollfd 结类型的组,它指定所有我们感兴趣的文件描述符上发生的可读、可写和异常等事件。pollfd 结构体的定义如下:
struct pollfd
{
int fd; /*文件描述符 */
short events; /*注册的事件 */
short revents;/* 实际发生的事件,由内核填充 */
}
其中,fd 成员指定文件描述符:
events 成员告诉 poll 监听 fd 上的哪些事件,它是一系列事件的按位或;
revcnts 成员则由内核修改,以通知应用程序fd 上实际发生了哪些事件。
poll支持的事件类型如表9-1 所示。
表9-1中,POLLRDNORM、POLLRDBAND、POLLWRNORM、POLLWRBAND由XOPEN 规范定义。它们实际上是将 POLLIN 事件和 POLLOUT 事件分得更细致,以区别对待普通数据和优先数据。但 Linux 并不完全支持它们。
通常,应用程序需要根据 recv 调用的返回值来区分 socket 上接收到的是有效数据还是对方关闭连接的请求,并做相应的处理。不过,自 Linux 内核 2.6.17 开始,GNU 为 pol 系统调用增加了一个POLLRDHUP 事件,它在 socket 上接收到对方关闭连接的请求之后触发。这为我们区分上述两种情况提供了一种更简单的方式。但使用 POLLRDHUP 事件时,我们需要在代码最开始处定义 GNU SOURCE。
- nfds 参数指定被监听事件集合 fds 的小。其类nfds t的定如下typedef unsigned long int nfds t;
- timcout 参数指定 poll 的超时值,单位是毫秒。当timeout 为-1 时,poll 调用将永远阻塞,直到某个事件发生;当 timcout 为0时,poll 调用将立即返回。poll 系统调用的返回值的含义与 select 相同。
1.2.1示例代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <time.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <poll.h>
#define MAXFD 10
int socket_init();
void fds_init(struct pollfd fds[])
{
for(int i = 0; i < MAXFD; i++ )
{
fds[i].fd = -1;
fds[i].events = 0;//注册
fds[i].revents = 0;//内核返回的事件
}
}
void fds_add(struct pollfd fds[], int fd)
{
for( int i = 0; i < MAXFD; i++ )
{
if( fds[i].fd == -1 )
{
fds[i].fd = fd;
fds[i].events = POLLIN;
fds[i].revents = 0;
break;
}
}
}
void fds_del(struct pollfd fds[], int fd)
{
for( int i = 0; i < MAXFD; i++)
{
if ( fds[i].fd == fd)
{
fds[i].fd = -1;
fds[i].events = 0;
fds[i].revents = 0;
break;
}
}
}
void accept_client(struct pollfd fds[], int sockfd)
{
int c = accept(sockfd,NULL,NULL);
if ( c < 0 )
{
return ;
}
printf("accept c=%d\n",c);
fds_add(fds,c);
}
void recv_data(struct pollfd fds[], int c)
{
char buff[128] = {0};
int n = recv(c,buff,1,0);
if ( n <= 0 )
{
close(c);
fds_del(fds,c);
printf("client close\n");
return;
}
printf("recv:%s\n",buff);
send(c,"ok",2,0);
}
int main()
{
int sockfd = socket_init();
if ( sockfd == -1 )
{
exit(1);
}
struct pollfd fds[MAXFD];
fds_init(fds);
fds_add(fds,sockfd);//
while( 1 )
{
int n = poll(fds,MAXFD,5000);//阻塞
if ( n == -1 )
{
printf("poll err\n");
}
else if ( n == 0 )
{
printf("time out\n");
}
else
{
for( int i = 0; i < MAXFD; i++)
{
if ( fds[i].fd == -1)
{
continue;
}
if ( fds[i].revents & POLLIN)
{
if ( fds[i].fd == sockfd )
{
accept_client(fds,fds[i].fd);
}
else
{
recv_data(fds,fds[i].fd);
}
}
//if ( fds[i].revents & POLLOUT)
//{
//}
}
}
}
}
int socket_init()
{
int sockfd = socket(AF_INET,SOCK_STREAM,0);
if ( sockfd == -1)
{
return -1;
}
struct sockaddr_in saddr;
memset(&saddr,0,sizeof(saddr));
saddr.sin_family = AF_INET;
saddr.sin_port = htons(6000);
saddr.sin_addr.s_addr = inet_addr("127.0.0.1");
int res = bind(sockfd,(struct sockaddr*)&saddr,sizeof(saddr));
if( res == -1)
{
printf("bind err\n");
return -1;
}
res = listen(sockfd,5);
if ( res == -1 )
{
return -1;
}
return sockfd;
}
1.3 epoll 系统调用
1.3.1 内核事件表
epoll 是 Linux 特有的I/0 复用函数。它在实现和使用上与 select、poll 有很大差异。首先,epoll 使用一组函数来完成任务,而不是单个函数。其次,epoll 把用户关心的文件描述符上的事件放在内核里的一个事件表中,从而无须 slet 和 pol 那每次用都要重复传人文件描述符集或事件集。但 epoll需要使用一个额外的文件描述符,来唯一识内核中的这个事件表。这个文件描述符使用如下 epoll create 函数来创建:
#include <sys/epo11.h>
int epoll create( int size )
size 参数现在并不起作用,只是给内核一个提示,告诉它事件表需要多大。该函数返回的文件描述符将用作其他所有 poll 系统调用的第一个参数,以指定要访问的内核事件表。
//下面的函数用来操作 cpol1 的内核事件表:
#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_CTT_MOD,修改 fd 上的注册事件。
- EPOLL CTL DEL,删除 fd 上的注册事件。
event 参数指定事件,它是epoll_event结构指针类型epoll_event 的定义如下
struct epoll_event
{
_uint32_t events;/*epoll 事件 */
epoll_data_t data;/*用户数据*/
}
其中events 成员描述事件类型。epoll 支持的事件类型和 poll 基相同表示pll事件类型的宏是在 poll 对应的宏前加上“E”,比如epoll 的数据可读事件是 EPOLLIN。 epoll有两个额外的事件类型一-EPOLLET 和EPOLLONESHOT。它们对于epoll的高效运作非常关键。
data 成员用于存储用户数据,其类型 epoll data t的定义如下
typedef union epoll_data
{
void* ptr;
int fd;
uint32_t u32;
uint64_t u64;
}epoll_data_t;
epoll_data_t是一个联合体,其 4 个成员中使用最多的是 fd,
- fd指定事件所从属的目标文件描述符。
- ptr 成员可用来指定与 fd 相关的用户数据。于 poll_data_t 是一个联合体,我们不能同时使用其 ptr 成员和 f 成员,因此,如果要将文件描述符和用户数据关联起来(将句柄和事件处器绑定一样),以实现快速的数据访问,只能使用其他手段,比如放弃使用 epoll_data_t 的 fd 成员,而在 ptr 指的用户数据中包含 fd
- epoll ctl 成功时返回0,失败则返-1并设值errno
1.3.2 epoll_wait 函数
epoll系列系统调用的主要接口是 epoll_wait 函。它在一超时时间内等待一组文件描述符上的事件,其原型如下:
#include <sys/epoll.h>
int epoll_wait( int epfd, struct epoll event* events, int maxevents,int timeout );
该函数成功时返回就绪的文件描述符的个数,失败时返回-1 并设置errno。
关于该函数的参数,。timeout 参数的含义与 poll 接口的 timeout 参数相同。maxevents 参数指定最多监听多少个事件,它必须大于0。
epoll_wait 函数如果检测到事件,就将所有就绪的事件从内核事件表(由epfd 参数指定)中复制到它的第二个参数 events 指向的数组中。这个数组只用于输出 epoll_wait 检测到的就绪事件,而不像 select 和 poll 的数组参数那样,即用于传用户注册的事件,又用于输出内核检测到的就绪事件。这就极大地提高了应用程序索引就绪文件描述符的效率
1.3.3 LT 模式 与ET 模式
epoll对文件描述符的操作有两种模式:LT(Level Trigger,电平触发)模式和 ET(EdgeTrigger,边沿触发)模式。LT 模式是默认的工作模式,这种模式下 epoll相当于一个效率较高的 epoll。当往 epoll内核事件表中注册一个文件描述符上的 EPOLLET 事件时,epoll 将以ET模式来操作该文件描述符。ET 式是 epoll 的高效工作模式。
对于采用 LT 工作模式的文件描述符,当pol1_wait 检测到其上有事件发生并将此事件通知应用程序后,应用程序可以不立即处理该事件。这样,当应用程序下一次调用epoll_wait 时,cpol1_wait 还会再次向应用程序通告此事件,直到该事件被处理。而对于采用 ET 工作模式的文件描述符,当 epoll wait 检测到其上有事件发生并将此事件通知应用程序后,应用程序必须立即处理该事件,因为后续的 epoll wait 调用将不再向应用程序通知这一事件。可见,ET 模式在很大程度上降低了同一个 epoll 事件被重复触发的次数因此效率要比 LT 模式高
1.3.4 示例代码
注意:每个使用 ET 模式的文件描述符都应该是非阻塞的。如果文件描述符是阻塞的,那么读或写操作将会因为没有后续的事件而一直处于阻塞状态 (饥渴状态)。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <time.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/epoll.h>
#include <fcntl.h>
#include <errno.h>
#define MAXFD 10
int socket_init();
void setnonblock(int fd)
{
int oldfl = fcntl(fd, F_GETFL);
int newfl = oldfl | O_NONBLOCK;
if (fcntl(fd, F_SETFL, newfl) == -1)
{
printf("set nonblock err\n");
}
}
void epoll_add(int epfd, int fd)
{
struct epoll_event ev;
ev.data.fd = fd;
ev.events = EPOLLIN | EPOLLET; // 开启ET模式
setnonblock(fd); // 设置非阻塞
if (epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev) == -1)
{
printf("epoll ctl err\n");
}
}
void epoll_del(int epfd, int fd)
{
if (epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL) == -1)
{
printf("epoll del err\n");
}
}
void accept_client(int epfd, int sockfd)
{
int c = accept(sockfd, NULL, NULL);
if (c < 0)
{
return;
}
printf("accept c=%d\n", c);
epoll_add(epfd, c);
}
void recv_data(int epfd, int c)
{
while (1)
{
char buff[128] = {0};
int n = recv(c, buff, 1, 0);
if ( n == -1 )
{
if ( errno != EAGAIN && errno != EWOULDBLOCK)
{
printf("recv err\n");
}
else
{
send(c,"ok",2,0);
}
break;
}
else if ( n == 0 )//对方关闭,-》移除描述符,并关闭
{
epoll_del(epfd,c);
close(c);
printf("client close\n");
break;
}
else
{
printf("read(%d):%s\n",c,buff);
}
}
/*
char buff[128] = {0};
int n = recv(c,buff,1,0);
if ( n <= 0 )
{
epoll_del(epfd,c);
close(c);
printf("client close\n");
return;
}
printf("recv(%d)=%s\n",c,buff);
send(c,"ok",2,0);
*/
}
int main()
{
int sockfd = socket_init();
if (sockfd == -1)
{
exit(1);
}
// 创建内核事件表(红黑树)
int epfd = epoll_create(MAXFD); // 参数大于0
if (epfd == -1)
{
exit(1);
}
// 向内核事件表添加sockfd
epoll_add(epfd, sockfd);
struct epoll_event evs[MAXFD]; // 收集就绪描述符
while (1)
{
int n = epoll_wait(epfd, evs, MAXFD, 5000);
if (n == -1)
{
printf("epoll wait err\n");
}
else if (n == 0)
{
printf("time out\n");
}
else
{
for (int i = 0; i < n; i++)
{
int fd = evs[i].data.fd;
if (evs[i].events & EPOLLIN)
{
if (fd == sockfd)
{
accept_client(epfd, fd);
}
else
{
recv_data(epfd, fd);
}
}
// if ( evs[i].events & EPOLLOUT)
//{
//}
}
}
}
}
int socket_init()
{
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd == -1)
{
return -1;
}
struct sockaddr_in saddr;
memset(&saddr, 0, sizeof(saddr));
saddr.sin_family = AF_INET;
saddr.sin_port = htons(6000);
saddr.sin_addr.s_addr = inet_addr("127.0.0.1");
int res = bind(sockfd, (struct sockaddr *)&saddr, sizeof(saddr));
if (res == -1)
{
printf("bind err\n");
return -1;
}
res = listen(sockfd, 5);
if (res == -1)
{
return -1;
}
return sockfd;
}
1.3.5 EPOLLONESHOT 事件
即使我们使用 ET 模式,一个 socket 上的某个事件还是可能被触发多次。这在并发程序中就会引起一个问题。比如一个线程(或进程,下同)在读取完某个 socket 上的数据后开始处理这些数据,而在数据的处理过程中该 socket 上又有新数据可读(EPOLLIN再次被触发,此时另外一个线程被唤醒来读取这些新的数据。于是就出现了两个线同时操作一个sockct 的局面。这当然不是我们期望的。我们期望的是一个 socket 连接在任一时刻都只被一个线程处理。这一点可以使用epoll 的 EPOLLONESHOT 事件实现。对于注册了 EPOLLONESHOT 事件的文件描述符,操作系统最多触发其上注册的一个可读、可写或者异常事件,且只触发一次,除非我们使用 po1 ctl 函重置该文件描述符上注册的 EPOLLONESHOT 事件。这样,当一个线程在处理某个 socket 时,其他线是不可能有机会操作该 socket 的。但反过来思考,注册了 EPOLLONESHOT事件的 socket一且被某个线程处理完毕,该线程就应该立即重置这个 socket 上的 EPOLLONESHOT事件,以确保这个socket 下一次可读时,其 EPOLLIN 事件能被触发,进而让其他工作线程有机会继续处理这个 socket。
具体使用可见 《Linux 高性能服务器编程》 9.3.4