IO复用
我们首先来看看服务器编程的模型,客户端发来的请求服务端会产生一个进程来对其进行服务,每当来一个客户请求就产生一个进程来服务,然而进程不可能无限制的产生,因此为了解决大量客户端访问的问题,引入了IO复用技术。
即:一个进程可以同时对多个客户请求进行服务。
也就是说IO复用的“介质”是进程(准确的说复用的是select和poll,因为进程也是靠调用select和poll来实现的),复用一个进程(select和poll)来对多个IO进行服务,虽然客户端发来的IO是并发的但是IO所需的读写数据多数情况下是没有准备好的,因此就可以利用一个函数(select和poll)来监听IO所需的这些数据的状态,一旦IO有数据可以进行读写了,进程就来对这样的IO进行服务。
IO多路复用指内核一旦发现进程指定的一个或者多个IO条件准备读取,它就通知该进程。
IO多路复用适用如下场合:
1.当客户处理多个描述字时(一般是交互式输入和网络套接口),必须使用I/O复用。
2.当一个客户同时处理多个套接口时,而这种情况是可能的,但很少出现。
3.如果一个TCP服务器既要处理监听套接口,又要处理已连接套接口,一般也要用到I/O复用。
4.如果一个服务器即要处理TCP,又要处理UDP,一般要使用I/O复用。
5.如果一个服务器要处理多个服务或多个协议,一般要使用I/O复用。
与多进程和多线程技术相比,I/O多路复用技术的最大优势是系统开销小,系统不必创建进程/线程,也不必维护这些进程/线程,从而大大减小了系统的开销。
select函数
该函数允许进程指示内核等待多个事件中的任何一个发生,并只在有一个或多个时间发生或经历一段指定的时间后才唤醒他。
int select(int maxfd, fd_set *readfds, fd_set *writefds, fe_set *exceptfds, const struct timeval *timeout)
//maxfd: 文件描述符的范围,比待检的最大文件描述符大1
//readfds:监听是否可读的文件描述符集,
//writefds:监听是否可写的文件描述符集
//exceptfds:监听是否有异常的文件描述符集
//timeout:定时器
const struct timeval *timeout:
Timeout值为0,不管是否有文件满足要求,都立刻返回,无文件满足要求返回0,有文件满足要求返回一个正值。
Timeout为NULL,select将阻塞进程,直到某个文件满足要求
Timeout值为正整数,就是等待的最长时间,即select在timeout时间内阻塞进程
返回值:
若有就绪描述符返回其数目,若超时则为0,若出错则为-1
中间的三个参数readset、writeset和exceptset指定我们要让内核测试读、写和异常条件的描述字。
如果对某一个的条件不感兴趣,就可以把它设为空指针。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);
// 检查集合中指定的文件描述符是否可以读写
select睡眠和唤醒过程
select巧妙的利用等待队列机制让用户进程适当在没有资源可读/写时睡眠,有资源可读/写时唤醒。
select睡眠过程
select会循环遍历它所监测的fd_ set内的所有文件描述符对应的驱动程序的poll函数。
驱动程序提供的poll函数首先会将调用select的用户进程插入到该设备驱动对应资源的等待队列(如读/写等待队列),然后返回一个bitmask告诉select当前资源哪些可用。
当select循环遍历完所有fd_set内指定的文件描述符对应的poll函数后,如果没有一个资源可用(即没有一个文件可供操作),则select让该进程睡眠,一直等到有资源可用为止,进程被唤醒(或者timeout)继续往下执行。
select唤醒过程
唤醒该进程的过程通常是在所监测文件的设备驱动内实现的。
驱动程序维护了针对自身资源读写的等待队列。当设备驱动发现自身资源变为可读写并且有进程睡眠在该资源的等待队列上时,就会唤醒这个资源等待队列上的进程。
select的缺点
1.每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
2.同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
3.select支持的文件描述符数量太小了,默认是1024
使用select函数写的服务器代码如下:
SelsctServer.c:
#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h> //使用select系统调用的作用:
#include <stdlib.h> //可以让服务器检查是否有客户在等待连接,
#include <string.h> //有就接受连接,否则就继续做其他事情,
#include <sys/socket.h> //
#include <netinet/in.h> //除此之外,select还可以同时监视多个套接字
#include <arpa/inet.h>
#include <sys/select.h>
#include <sys/time.h> //客户端向服务器发消息,服务器使用select 判断接收到的套接字
#include <sys/types.h> //如果是sockfd可读(被置为一),表明有客户端发起连接
#include <unistd.h> //如果是fd[i]可读(被置为一),表明有客户端发送消息
//可实现多个客户端向服务器通信
#define SIZE 1024
int main()
{
int sockfd, ret, fd[SIZE] = {0}, i;
struct sockaddr_in server_addr; //保存服务器信息
struct sockaddr_in client_addr; //保存客户端信息
int length = sizeof(struct sockaddr_in);
char buf[32] = {0};
//创建socket 1、地址族2、套接字类型3、具体的协议
sockfd = socket(PF_INET, SOCK_STREAM, 0);
if (-1 == sockfd)
{
perror("socket");
exit(1);
}
//配置服务器信息
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = PF_INET; //地址族 跟socket第一个参数保持一样
server_addr.sin_port =htons(8000); //端口号
server_addr.sin_addr.s_addr = inet_addr("127.0.0.1"); //ip地址 127.0.0.1回环ip 表示本机
//服务器信息绑定到socket 1、字节流套接字 2、服务器结构体的地址 3、结构体字节长
ret = bind(sockfd, (struct sockaddr *)&server_addr, length);
if (-1 == ret)
{
perror("bind");
exit(1);
}
ret = listen(sockfd, 10); //监听端口,设置监听队列
if(-1 == ret)
{
perror("listen");
exit(1);
}
fd_set readfd, tmpfd; //定义文件描述符集
FD_ZERO(&readfd); //把集合清空
FD_SET(sockfd, &readfd); //sockfd添加到集合
int maxfd = sockfd; //此处的maxfd 应该就是待检的最大文件描述符
while (1) //TCP循环服务器
{
tmpfd = readfd;
//int select(int maxfd, fd_set *readfds, fd_set *writefds, fe_set *exceptfds, const struct timeval *timeout)
//maxfd: 文件描述符的范围,比待检的最大文件描述符大1
//readfds:监听是否可读的文件描述符集,
//writefds:监听是否可写的文件描述符集
//exceptfds:监听是否有异常的文件描述符集
//timeout:定时器
//有消息可读,select会清空tmpfd集合 最后一个参数:NULL表示阻塞状态,即一直检测
ret = select(maxfd + 1, &tmpfd, NULL, NULL, NULL);
if (-1 == ret)
{
perror("select");
}
//void FD_ISSET(int fd, fd_set *fdset)
//在调用select后使用FD_ISSET来检测文件描述符集fdset中的文件fd发生了变化
if (FD_ISSET(sockfd, &tmpfd)) //如果是sockfd可读(被置为一),表明有客户端发起连接
{
for (i = 0; i < SIZE; i++)
{
if (0 == fd[i])
{
break;
}
}
//接收连接 1、字节流套接字 2、客户端结构体的地址,存放客户端的信息 3、结构体字节长的地址,存放客户端结构体的字节长
fd[i] = accept(sockfd, (struct sockaddr *)&client_addr, &length);
if (-1 == fd[i])
{
perror("accept");
}
printf("accept client %d\n", fd[i]);
FD_SET(fd[i], &readfd); //新的文件描述符添加到集合中
if (maxfd < fd[i])
{
maxfd = fd[i]; //更新最大文件描述符
}
}
else //通过循环判断哪个fd可读(被置为一)
{
for (i = 0; i < SIZE; i++)
{
if (FD_ISSET(fd[i], &tmpfd)) //如果是fd[i]可读(被置为一),表明有客户端发送消息
{
ret = recv(fd[i], buf, sizeof(buf), 0);
if (-1 == ret)
{
perror("recv");
}
printf("receive from %d %s!\n", fd[i], buf);
memset(buf, 0, sizeof(buf));
break;
}
}
}
}
return 0;
}
SelectClient.c:
#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
int main()
{
int sockfd;
struct sockaddr_in server_addr; //向服务器发起连接,保存服务器信息
int length = sizeof(struct sockaddr_in);
//创建socket 1、地址族2、套接字类型3、具体的协议
sockfd = socket(PF_INET, SOCK_STREAM, 0);
if (-1 == sockfd)
{
perror("socket");
exit(1);
}
//配置目标服务器信息
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = PF_INET; //地址族 跟socket第一个参数保持一样
server_addr.sin_port = htons(8000); //端口号
server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");//ip地址 127.0.0.1回环ip 表示本机
//发起连接 1、字节流套接字 2、服务器结构体的地址,存放服务器的信息 3、服务器结构体结构体字节长
int ret = connect(sockfd, (struct sockaddr *)&server_addr, length);
if (-1 == ret)
{
perror("connect");
exit(1);
}
char buf[32] = {0};
while (1)
{
scanf("%s", buf);
//参数三:发送的数据长度 参数四:flags,一般设为 0
ret = send(sockfd, buf, strlen(buf), 0);
if (-1 == ret)
{
perror("send");
}
memset(buf, 0, sizeof(buf));
}
return 0;
}
poll
基本原理:poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历。
它没有最大连接数的限制,原因是它是基于链表来存储的,但是同样有一个缺点:
1)大量的fd的数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义。
2)poll还有一个特点是“水平触发”,如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd。
注意:从上面看,select和poll都需要在返回后,通过遍历文件描述符来获取已经就绪的socket。事实上,同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降。
epoll
epoll是在2.6内核中提出的,是之前的select和poll的增强版本。相对于select和poll来说,epoll更加灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。
基本原理:epoll支持水平触发和边缘触发,最大的特点在于边缘触发,它只告诉进程哪些fd刚刚变为就绪态,并且只会通知一次。还有一个特点是,epoll使用“事件”的就绪通知方式,通过epoll_ctl注册fd,一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fd,epoll_wait便可以收到通知。
epoll的优点:
1、没有最大并发连接的限制,能打开的FD的上限远大于1024(1G的内存上能监听约10万个端口)。
2、效率提升,不是轮询的方式,不会随着FD数目的增加效率下降。
只有活跃可用的FD才会调用callback函数;即Epoll最大的优点就在于它只管你“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,Epoll的效率就会远远高于select和poll。
3、内存拷贝,利用mmap()文件映射内存加速与内核空间的消息传递;即epoll使用mmap减少复制开销。
epoll对文件描述符的操作有两种模式:LT(level trigger)和ET(edge trigger)。LT模式是默认模式,LT模式与ET模式的区别如下:
LT模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用epoll_wait时,会再次响应应用程序并通知此事件。
ET模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件。
1、LT模式
LT(level triggered)是缺省的工作方式,并且同时支持block和no-block socket。在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的。
2、ET模式
ET(edge-triggered)是高速工作方式,只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了(比如,你在发送,接收或者接收请求,或者发送接收的数据少于一定量时导致了一个EWOULDBLOCK 错误)。但是请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once)。
ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。
3、在select/poll中,进程只有在调用一定的方法后,内核才对所有监视的文件描述符进行扫描,而epoll事先通过epoll_ctl()来注册一个文件描述符,一旦基于某个文件描述符就绪时,内核会采用类似callback的回调机制,迅速激活这个文件描述符,当进程调用epoll_wait()时便得到通知。(此处去掉了遍历文件描述符,而是通过监听回调的的机制。这正是epoll的魅力所在。)
注意:如果没有大量的idle-connection或者dead-connection,epoll的效率并不会比select/poll高很多,但是当遇到大量的idle-connection,就会发现epoll的效率大大高于select/poll。
三、select、poll、epoll区别
1、支持一个进程所能打开的最大连接数
2、FD剧增后带来的IO效率问题
3、消息传递方式
综上,在选择select,poll,epoll时要根据具体的使用场合以及这三种方式的自身特点:
1、表面上看epoll的性能最好,但是在连接数少并且连接都十分活跃的情况下,select和poll的性能可能比epoll好,毕竟epoll的通知机制需要很多函数回调。
2、select低效是因为每次它都需要轮询。但低效也是相对的,视情况而定,也可通过良好的设计改善。