什么是I/O多路复用:
- I/O:就是指我们的网络I/O,即网络接收数据和发送数据
- 多路:指多个TCP连接(多个套接字)
- 复用:指复用一个或少量线程
结合在一起就是很多个TCP连接的数据传输使用一个或者少量线程来实现,这就是所谓I/O多路复用
需要使用I/O多路复用的情况:
- 客户端程序要同时处理多个socket
- 客户端要同时处理用户输入和网络连接
- TCP服务器要同时处理监听socket和连接socket,这时I/O复用使用最多的场合
- 服务器要同时处理TCP请求和UDP请求
- 服务器要同时监听多个端口,或者处理多种服务
实现I/O多路复用的系统调用:
1、select系统调用
select系统调用的用途是:在一段指定时间内,监听用户感兴趣的文件描述符上的可读,可写和异常事件,它的实现原理是:内核对文件描述符数组中的文件描述符以轮询的方式来确定该文件描述符上的事件是否就绪(轮询的个数由select第一个参数指定,时间由最后一个参数指定),即内核不断在这个数组中循环检测,直到有事件就绪,就将其结构体中对应的下标所在的位进行修改(一般情况下没有事件发生时为0,发生时被改为1),并返回
#include<sys/select.h>
int select(int nfds, fd_set* readfds, fd_set* writefds, fd_set* exceptfds, struct timeval* timeout);
- nfds: 指定被监听的文件描述符的总数,它通常被设置为select监听的所有文件描述符中的最大值加1,因为文件描述符是从0开始计数的
- readfs、writefds、exceptfds:分别指向可读,可写和异常等事件对应的文件描述符集合,应用程序调用select函数时,通过这三个参数传入自己感兴趣的文件描述符,select调用返回时,内核将修改它们来通知应用程序哪些文件描述符已经就绪。这三个参数是fd_set结构体指针类型
由该结构体的定义我们可以看出fd_set结构体仅包含一个整型数组,该数组的每一个位(bit)标记一个文件描述符,fd_set能容纳 的文件描述符数量由FD_SETSIZE指定,这限制了select能同时处理的文件描述符的总量不超过1024个(下标从0~1023)。
从上面的结构体我们知道,select对于文件描述符的操作是通过位操作来进行的,但是位操作过于繁琐,系统给我们提供下面的一 系列宏来访问结构体中的位:
#include<sys/select.h>
FD_ZERO(fd_set *fdset); /*清除fdset的所有位*/
FD_SET(int fd, fd_set* fdset); /*设置fdset的位fd(下标fd)*/
FD_CLR(int fd, fd_set* fdset); /*清除fdset的位fd(下标)*/
int FD_ISSET(int fd, fd_set* fdset); /*测试fdset的位fd(下标fd)是否被设置*/
- timeout:用来设置select函数的超时时间,它是一个timeval结构类型的指针,采用指针参数是因为内核将修改它以告诉应用程序select等待了多久。注意:如果timeout赋给NULL,则select将一直阻塞,直到某个文件描述符就绪。
- 返回值:成功时返回就绪文件描述符个数,如果在timeout时间内没有事件发生,则返回0, 失败返回-1.
文件描述符的就绪条件:在网络编程中,下列情况socket可读:
- socket内核接收缓存区中的字节数大于或等于其低水位标记SO_RCVLOWAT,此时我们可以无阻塞地读该socket,并且返回的字节数大于0
- socket通信的对方关闭连接,此时对该socket的操作将返回0
- 监听socket上有新的连接请求
- socket上有未处理的错误
使用select编写高性能服务器代码实现:
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<string.h>
#include<assert.h>
#include<sys/select.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include<arpa/inet.h>
/*初始化文件描述符数组*/
void InitFds(int *fds, int len)
{
int i = 0;
for(; i < len; i++)
{
fds[i] = -1;
}
}
/*插入新的文件描述符*/
void InsertFds(int *fds, int fd, int len)
{
int i = 0;
for(; i < len; i++)
{
if(fds[i] == -1)
{
fds[i] = fd;
break;
}
}
}
/*删除文件描述符*/
void DeleteFds(int *fds, int fd, int len)
{
int i = 0;
for(; i < len; i++)
{
if(fds[i] == fd)
{
fds[i] = -1;
break;
}
}
}
int main()
{
int sockfd = socket(PF_INET, SOCK_STREAM, 0);
assert(sockfd != -1);
struct sockaddr_in ser, cli;
ser.sin_family = AF_INET;
ser.sin_port = htons(6500);
ser.sin_addr.s_addr = inet_addr("127.0.0.1");
int res = bind(sockfd, (struct sockaddr*)&ser, sizeof(ser));
assert(res != -1);
res = listen(sockfd, 5);
assert(res != -1);
fd_set readfds; //定义关注的事件为读事件
int fds[16]; //定义文件描述符数组
InitFds(fds, 16);
InsertFds(fds, sockfd, 16);
while(1)
{
int maxfd = -1;
FD_ZERO(&readfds);//每次都要重新设置这些文件描述符,因为内核会修改
/*找到文件描述符的最大值*/
int i = 0;
for(; i < 16; i++)
{
if(fds[i] > -1)
{
if(fds[i] > maxfd)
{
maxfd = fds[i];
}
FD_SET(fds[i], &readfds);
}
}
int res = select(maxfd+1, &readfds, NULL, NULL, NULL);
if(res <= 0)
{
printf("Select error\n");
continue;
}
/*找到就绪的文件描述符*/
for(i = 0; i < 16; i++)
{
if(fds[i] != -1&&FD_ISSET(fds[i], &readfds) == 1)
{
if(fds[i] == sockfd)
{
socklen_t len = sizeof(cli);
int c = accept(sockfd,(struct sockaddr*)&cli, &len);
InsertFds(fds, c, 16);
}
else
{
int c = fds[i];
char buff[128] = {0};
res = recv(c, buff, 127,0);
if(res <= 0)
{
close(c);
DeleteFds(fds, c, 1024);
continue;
}
printf("%s\n",buff);
res = send(c, "OK", 2, 0);
assert(res != -1);
}
}
}
}
}
2、poll系统调用
poll系统调用和select类似,也是在指定时间内轮询一定数量的文件描述符,以测试其中是否有就绪者。注意这里的fds应该是一个结构体数组,每一个元素表示一个文件描述符
#include<poll.h>
int poll(struct pollfd* fds, nfds_t nfds, int timeout);
2.1、fds:一个pollfd结构类型的数组,指定所有我们感兴趣的文件描述符上发生的事件,结构体定义如下
fd成员指定我们关注的文件描述符,events成员告诉poll监听fd上的哪些事件,它是一系列事件位或做成的,revents成员由内核修改,如果某个结构体所监听的文件描述符上的事件就绪,那么系统将把该事件写入revents中,以通知应用程序fd上实际发生的事件,poll支持的事件类型如下
注意:自从Linux内核2.6.17开始,GNU为poll系统调用增加了POLLRDHUP事件,它在socket上接收到对方关闭连接的请求之后触发,使用该事件时,我们需要在代码最开始处定义_GNU_SOURCE
2.2、nfds:指定被监听事件集合fds的大小。
2.3、timeout:指定poll的超时值,单位是毫秒,当timeout为-1时,poll调用将永远阻塞,直到某个系统事件发生,当timeout为0是,poll一经调用将立即返回(无法监听到就绪事件)
使用poll编写高性能服务器代码实现:
#define _GNU_SOURCE //为了监听POLLRHDUP事件所定义
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<string.h>
#include<assert.h>
#include<poll.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include<arpa/inet.h>
#define SIZE 100
/*初始化结构体数组*/
void Init_fds(struct pollfd *fds)
{
int i = 0;
for(; i < SIZE; i++)
{
fds[i].fd = -1;
fds[i].events = 0;
fds[i].revents = 0;
}
}
/*往结构体数组中加入新的文件描述符,和关注的事件*/
void Insert_fds(struct pollfd *fds, int fd)
{
int i = 0;
for(; i < SIZE; i++)
{
if(fds[i].fd == -1)
{
fds[i].fd = fd;
fds[i].events = POLLIN|POLLRDHUP;
return;
}
}
}
/*删除文件描述符*/
void Delete_fds(struct pollfd *fds, int fd)
{
int i = 0;
for(; i < SIZE; i++)
{
if(fds[i].fd == fd)
{
fds[i].fd = -1;
return;
}
}
}
int main()
{
int sockfd = socket(PF_INET, SOCK_STREAM, 0);
assert(sockfd != -1);
struct sockaddr_in ser, cli;
ser.sin_family = AF_INET;
ser.sin_port = htons(6500);
ser.sin_addr.s_addr = inet_addr("127.0.0.1");
int ret = bind(sockfd, (struct sockaddr*)&ser, sizeof(ser));
assert(ret != -1);
listen(sockfd, 5);
struct pollfd fds[SIZE];
Init_fds(fds);
Insert_fds(fds, sockfd);
while(1)
{
int ret = poll(fds, SIZE, -1);
if(ret == -1)
{
printf("poll error!\n");
continue;
}
/*判断每个文件描述符是否有事件就绪*/
int i = 0;
for(; i < SIZE; i++)
{
if(fds[i].fd != -1)
{
int fd = fds[i].fd;
if(fds[i].revents & POLLRDHUP) //有文件描述符断开连接
{
printf("%d closed\n",fd);
close(fd);
Delete_fds(fds, fd);
}
else if(fds[i].revents & POLLIN) //有文件描述符读事件就绪
{
if(fd == sockfd)
{
socklen_t len = sizeof(cli);
int c = accept(sockfd, (struct sockaddr*)&cli, &len);
if(c == -1)
{
printf("accept error\n");
continue;
}
Insert_fds(fds, c);
}
else
{
char buff[128] = {0};
ret = recv(fd, buff, 127, 0);
if(ret <= 0)
{
printf("%dclose\n",fd);
continue;
}
printf("%d: %s\n", fd, buff);
send(fd, "OK", 2, 0);
}
}
else
{
continue;
}
}
else
{
continue;
}
}
}
}
3、epoll系统调用
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_ctl函数来操作epoll的内核事件表
#include<sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event* event);
3.1、epfd:epoll维护的内核事件表
3.2、fd参数是要操作的文件描述符,op参数指定操作类型,有如下三种操作类型:
- EPOLL_CTL_ADD:往事件表中注册fd上的事件
- EPOLL_CTL_MOD:修改fd上注册的事件
- EPOLL_CTL_DEL:删除fd上注册的事件
3.3、event:该参数指定要传入的文件描述符上关注的事件,它是epoll_event结构体指针类型,epoll_event定义如下
- events:描述事件类型。epoll支持的事件类型和poll基本相同,表示epoll事件类型的宏是在poll对应的宏前加上“E”。但epoll有两个额外的事件类型——EPOLLET和EPOLLONESHOT。它们对于epoll的高校运作起到了非常关键的作用。
- data:用于存储用户数据,它是一个共用体,我们一般情况下最常使用的是fd成员,指定事件所从属的目标文件描述符
上面我们讲了两个系统调用分别是用来,维护内核事件表和注册事件到该内核事件表,那当我们注册的文件描述符上有事件发生时,epoll会怎样告诉用户应用程序呢?这里要用到epoll_wait函数
#include<sys/epoll.h>
int epoll_wait(int epfd, struct_event* events, int maxevents, int timeout);
- epfd:内核事件表
- events:用户指定的写入就绪事件的结构体数组,由内核来修改
- maxevents:最大监听事件个数
- timeout:阻塞时间,含义与poll中相同
- 返回值:就绪文件描述符个数
当epoll_wait函数检测到事件发生时,就将所有的就绪事件从内核事件表中复制到它第二个参数指定的events数组中,这个数组只用于输出epoll_wait检测到的就绪事件,而不像select和poll那样及用于传入用户注册的事件,又用于输出内核检测到的就绪事件。这极大地提高了应用程序索引就绪文件描述符的效率(不用每个文件描述符都去判断是否就绪)。
epoll_wait函数检测事件是否就绪时,不再采用像select和poll那样的cpu一直轮询的方式,而是有一个回调机制,当一个文件描述符就绪时,就会触发它自身所绑定的回调,这时epoll_wait函数就会检测到该事件的发生,将其写入events并返回。不再采用轮询的方式判断文件描述符上是否有事件发生也是epoll之所以高效的原因之一。
使用epoll编写高性能服务器代码实现
#define _GNU_SOURCE
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<string.h>
#include<assert.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include<arpa/inet.h>
#include<sys/epoll.h>
int main()
{
int sockfd = socket(PF_INET, SOCK_STREAM, 0);
assert(sockfd != -1);
struct sockaddr_in ser, cli;
ser.sin_family = AF_INET;
ser.sin_port = htons(6500);
ser.sin_addr.s_addr = inet_addr("127.0.0.1");
int ret = bind(sockfd, (struct sockaddr*)&ser, sizeof(ser));
assert(ret != -1);
listen(sockfd, 5);
int epfd = epoll_create(5); //获取一个内核事件表
struct epoll_event event;
event.events = EPOLLIN;
event.data.fd = sockfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &event); //将监听文件描述符注册到该事件表
while(1)
{
struct epoll_event events[100];
int num = epoll_wait(epfd, events, 100, -1);
printf("%d\n", num);
/*判断事件类型并进行处理*/
int i = 0;
for(; i < num; i++)
{
int fd = events[i].data.fd;
if(events[i].events & EPOLLRDHUP)
{
printf("%d closed\n", fd);
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
}
else if(events[i].events & EPOLLIN)
{
if(fd == sockfd)
{
socklen_t len = sizeof(cli);
int c = accept(sockfd, (struct sockaddr*)&cli, &len);
if(c == -1)
{
printf("accept error\n");
continue;
}
event.events = EPOLLIN|EPOLLRDHUP;
event.data.fd = c;
epoll_ctl(epfd, EPOLL_CTL_ADD, c, &event);
}
else
{
char buff[128] = {0};
ret = recv(fd, buff, 127, 0);
printf("%d: %s\n", fd, buff);
send(fd, "OK", 2, 0);
}
}
}
}
}
select&poll&epoll三者之间的区别
select&poll
- poll不限定监听的文件描述符的数量,select最多监听1023个文件描述符(1024个元素,但是没有文件描述符为0)
- poll中的文件描述符不再按位表示,直接使用int类型
- poll将就绪的事件写在一个另外的revents的数组中,不对注册事件的events数组进行更改,而select的注册文件描述符数组会被改动来告知用户应用程序哪个文件描述符上有事件发生
- 和select相同,poll返回时,也是将所有文件描述符返回,需要用户循环检测哪个文件描述符上有事件发生
poll&epoll
- 用户关注的事件和文件描述符由内核维护,调用epoll_wait时,不需要将用户空间的数据拷贝到内核空间
- epoll只返回就绪的文件描述符,poll返回所有文件描述符
- 用户不用再判断哪些文件描述符上有事件发生,只需根据epoll_wait的返回来判断事件的类型
- epoll在内核中检测事件的机制是回调,不再是轮询,比poll更加高效
- epoll支持高效的ET模式
(select和epoll的区别请根据上述两种自行补全。。。。)