I/O多路复用是指:内核监视多个描述符,一旦某个描述符读就绪或者写就绪,就通知程序进行相应的读写操作。
多进程、 多线程、 进程池、 线程池每一个执行序列在同一时刻只能处理一个 socket(监听、 链接)。 以线程池为例: 如果创建 N 个线程,同一时刻只能处理 N 个客户连接,而I/O 复用在一个进程或者一个线程中,能同时监听多个 socket。 当有 socket 上有事件发生时, 程序才会接受数据。
与多进程和多线程技术相比,I/O多路复用技术的最大优势是系统开销小,系统不必创建进程或者线程,也不必维护这些进程和线程,大大减小了系统的开销。
1、select函数:
该函数准许进程指示内核等待多个事件中的任何一个发送,并只在有一个或多个事件发生或经历一段指定的时间后才唤醒。
int n = select(int nfds,fd_set *read,fd_set *write,fd_set *except,struct timeval *timeout)
①nfds:监听的最大文件描述符值+1(因为文件描述符是从0开始的)。
②read、write、except:分别对应select监听文件描述符上的可读、可写、异常事件。如果对某一个的条件不感兴趣,就可以把它设为空指针。
③struct fd_set可以理解为一个集合,这个集合中存放的是文件描述符,可通过以下四个宏进行设置:
void FD_ZERO(fd_set *fdset);//清空集合
void FD_SET(int fd, fd_set *fdset);//将一个给定的文件描述符加入集合之中
void FD_CLR(int fd, fd_set *fdset);//将一个给定的文件描述符从集合中删除
int FD_ISSET(int fd, fd_set *fdset);// 检查集合中指定的文件描述符是否可以读写
④timeout:告知内核等待所指定描述字中的任何一个就绪可花多少时间。其timeval结构用于指定这段时间的秒数和微秒数。
struct timeval
{
long tv_sec; //秒
long tv_usec; //微秒
};
这个参数有三种可能:
(1)永远等待下去:仅在有一个描述字准备好I/O时才返回。为此,把该参数设置为空指针NULL。
(2)等待一段固定时间:在有一个描述字准备好I/O时返回,但是不超过由该参数所指向的timeval结构中指定的秒数和微秒数。
(3)根本不等待:检查描述字后立即返回,这称为轮询。为此,该参数必须指向一个timeval结构,而且其中的定时器值必须为0。
⑤返回值:就绪描述符的数目,超时返回0,出错返回-1
写一个TCP程序:客户端向服务器发送信息,服务器接收并发送给客户端ok,代码实现:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unsitd.h>
#include <assert.h>
#include <sys/select.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
void main()
{
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
assert(sockfd != -1);
struct sockaddr_in ser, cli;
memset(&ser, 0, sizeof(ser));
ser.sin_family = AF_INET;
ser.sin_port = htons(6000);
ser.sin_addr.s_addr = inet_addr("127.0.0.1");//回环地址
int res = bind(sockfd, (struct sockaddr*)&ser, sizeof(ser));
assert(res != -1);
listen(sockfd, 5);
int nfds = sockfd + 1;
struct fd_set read;
FD_ZERO(&read);
int fds[128];//fds[]存储所有的文件描述符
memset(fds, -1, 128);
fds[0] = sockfd;
while(1)
{
int i = 0;
for(; i < 128; ++i)//每次select之前必须重新将所有的文件描述符设置到read中
{
if(fds[i] != -1);
{
FD_SET(fds[i], &read);
}
}
int n = select(nfds, &read, NULL, NULL, NULL);//read存储所有需要监听的文件描述符
if(n < 0)
{
printf("error\n");
exit(0);
}
if(n == 0) // 超时
{
printf("time out\n");
continue;
}
int i = 0;
for(; i < 128; ++i)
{
if(fds[i] == -1)
continue;
if(FD_ISSET(fds[i], &read))//判断文件描述符fds[i]是否有就绪事件
{
if(fds[i] == sockfd)
{
int len = sizeof(cli);
int c = accept(sockfd, (struct sockaddr*)&cli, &len);//将c放入fds
int i = 0;
for(; i < 128; ++i)
{
if(fds[i] == -1)
{
fds[i] = c;
break;
}
}
}
else
{
char buff[128] = {0};
int n = recv(fds[i], buff, 128, 0);
if(n <= 0)
{
close(fds[i]);
fds[i] = -1;
continue;
}
printf("%s\n", buff);
send(fds[i], "ok", 2, 0);
}
}
}
}
}
2、poll函数
poll的机制与select类似,在本质上没有什么区别,管理多个描述符也是进行轮询,根据描述符的状态进行处理,但是poll没有最大文件描述符数量的限制。poll和select同样存在一个缺点就是,包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间,而不论这些文件描述符是否就绪,它的开销随着文件描述符数量的增加而线性增大。
和select对比,poll将监听的文件描述符和其关注的事件分开表示; poll不需要用三个结构体来表示不同的事件类型;poll将用户注册的关注事件与内核修改的事件分开表示;poll不要要每次调用前重新设置。
int poll ( struct pollfd * fds, unsigned int nfds, int timeout);
①fds : 数组, 数组中记录所有监听的文件描述符以及关注的事件类型。
struct pollfd
{
int fd; //文件描述符
short events; //关注的事件类型 POLLIN | POLLRDHUP
short revents; //由内核填充,指定文件描述符上发生了什么事件
}
revents域可能返回下列事件:
POLLIN 有数据可读。
POLLRDNORM 有普通数据可读。
POLLRDBAND 有优先数据可读。
POLLPRI 有紧迫数据可读。
POLLOUT 写数据不会导致阻塞。
POLLWRNORM 写普通数据不会导致阻塞。
POLLWRBAND 写优先数据不会导致阻塞。
POLLMSGSIGPOLL 消息可用。
POLLER 指定的文件描述符发生错误。
POLLHUP 指定的文件描述符挂起事件。
POLLNVAL 指定的文件描述符非法。
②nfds: 数组元素个数
③timeout: 超时时间, 单位毫秒, -1 表示永远阻塞 0 表示立即返回。
④返回值: -1表示失败、出错,0表示超时,>0表示就绪文件描述符的个数。
代码实现:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <assert.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <poll.h>
#define MAX 128
void Init(struct pollfd *fds, int len)
{
int i = 0;
for(; i < len; ++i)
{
fds[i].fd = -1;
fds[i].events = 0;
}
}
void AddFd(struct pollfd *fds, int len, int fd)
{
int i = 0;
for(; i < len; ++i)
{
if(fds[i].fd == -1)
{
fds[i].fd = fd;
fds[i].events = POLLIN;
break;
}
}
}
void main()
{
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
assert(listenfd != -1);
struct sockaddr_in ser, cli;
memset(&ser, 0, sizeof(ser));
ser.sin_family = AF_INET;
ser.sin_port = htons(6000);
ser.sin_addr.s_addr = inet_addr("192.168.1.120");
int res = bind(listenfd, (struct sockaddr*)&ser, sizeof(ser));
assert(res != -1);
listen(listenfd, 5);
struct pollfd fds[MAX];
Init(fds, MAX);
AddFd(fds, MAX, listenfd);
while(1)
{
int n = poll(fds, MAX, -1);
assert(n != -1);
if(n == 0)
{
printf("time out\n");
continue;
}
int i = 0;
for(; i < MAX; ++i)
{
if(fds[i].fd == -1)
{
continue;
}
if(fds[i].revents & POLLIN)
{
int fd = fds[i].fd;
if(fd == listenfd)
{
int len = sizeof(cli);
int c = accept(fd, (struct sockaddr *)&cli, &len);
assert(c != -1);
printf("one client link\n");
AddFd(fds, MAX, c);
}
else
{
char buff[128] = {0};
int n = recv(fd, buff, 127, 0);
if(n <= 0)
{
printf("client unlink\n");
close(fd);
fds[i].fd = -1;
fds[i].events = 0;
continue;
}
printf("%d : %s\n", fd, buff);
send(fd, "OK", 2, 0);
}
}
}
}
}
3、epoll函数、
epoll是select和poll的增强版本。相对于select和poll来说,epoll更加灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的复制只需一次。
epoll操作过程需要三个接口:
①int epoll_create(int size); // 创建一个内核事件表,返回内核事件表的标示符id
②int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);//epoll的事件注册函数,在这里先注册要监听的事件类型。
epfd: 内核事件表的id,epoll_create 的返回值
op:操作,EPOLL_CTL_ADD EPOLL_CTL_MOD EPOLL_CTL_DEL
event:事件类型
struct epoll_event
{
int events;
epoll_data_t data;
};
union epoll_data_t
{
void *ptr;
int fd;
int u32;
int u64;
}
③int epoll_wait(int epfd, struct epoll_event *revents,
int maxevents, int timeout);//等待事件的产生,类似于select()调用。参数events用来从内核得到事件的集合,maxevents告之内核这个events有多大,这个maxevents的值不能大于创建epoll_create()时的size,参数timeout是超时时间(毫秒,0会立即返回,-1将不确定,也有说法说是永久阻塞)。该函数返回需要处理的事件数目,如返回0表示已超时。revents 是一个用于记录内核就绪事件的数组。
epoll对文件描述符的操作有两种模式:LT和ET。LT模式是默认模式。
LT模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用epoll_wait时,会再次响应应用程序并通知此事件。
ET模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件。
ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。