通过调用这个函数可以委托内核帮助我们检测若干个文件描述符的状态,其实就是检测这些文件描述符对应的读写缓冲区的状态:
读缓冲区:检测里边有没有数据,如果有数据该缓冲区对应的文件描述符就绪
写缓冲区:检测写缓冲区是否可以写 (有没有容量),如果有容量可以写,缓冲区对应的文件描述符就绪
读写异常:检测读写缓冲区是否有异常,如果有该缓冲区对应的文件描述符就绪。
#include
struct timeval {
time_t tv_sec; /* seconds */
suseconds_t tv_usec; /* microseconds */
};
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval * timeout);
// 将文件描述符fd从set集合中删除 == 将fd对应的标志位设置为0
void FD_CLR(int fd, fd_set *set);
// 判断文件描述符fd是否在set集合中 == 读一下fd对应的标志位到底是0还是1
int FD_ISSET(int fd, fd_set *set);
// 将文件描述符fd添加到set集合中 == 将fd对应的标志位设置为1
void FD_SET(int fd, fd_set *set);
// 将set集合中, 所有文件文件描述符对应的标志位设置为0, 集合中没有添加任何文件描述符
void FD_ZERO(fd_set *set);
在 select() 函数中第 2、3、4 个参数都是 fd_set 类型,它表示一个文件描述符的集合,都是原值的拷贝(保护原值不被修改),类似于信号集 sigset_t,这个类型的数据有 128 个字节,也就是 1024 个标志位,和内核中文件描述符表中的文件描述符个数是一样的。
sizeof(fd_set) = 128 字节 * 8 = 1024 bit // int [32]
这并不是巧合,而是故意为之。这块内存中的每一个 bit 和 文件描述符表中的每一个文件描述符是一一对应的关系,这样就可以使用最小的存储空间将要表达的意思描述出来了。
下图中的 fd_set 中存储了要委托内核检测读缓冲区的文件描述符集合。
如果集合中的标志位为 0 代表不检测这个文件描述符状态。
如果集合中的标志位为 1 代表检测这个文件描述符状态。
内核在遍历这个读集合的过程中,如果被检测的文件描述符对应的读缓冲区中没有数据,内核将修改这个文件描述符在读集合 fd_set 中对应的标志位,改为 0,如果有数据那么这个标志位的值不变,还是 1。
注:在select中的三个fd_set类型的参数都是原值的拷贝,原值中记录着哪些文件描述符是连着的,哪些文件描述符是没有连接的。
以读数据为例:
在select中,内核扫描文件描述符对应的读缓冲区,对于标志位为0的文件描述符跳过,对于标志位为1的文件描述符,扫描器读缓冲区,如果读缓冲区为空,则将标志位改为0,否则不改变其标志位。一轮扫描过后,所有读缓冲区中有数据的文件描述符的标志位都被置位1了,服务端读取标志位为1的文件描述符对应的缓冲区即可。
1.创建监听的套接字lfd = socket()
2.将lfd与本地的ip和端口绑定bind()
3.将lfd监听listen()
4.创建文件描述集合fd_set,用于存储需要检测读事件的所有文件描述符
#include
#include
#include
#include
#include
int main()
{
// 1. 创建监听的fd
int lfd = socket(AF_INET, SOCK_STREAM, 0);
// 2. 绑定
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(9999);
addr.sin_addr.s_addr = INADDR_ANY;
bind(lfd, (struct sockaddr*)&addr, sizeof(addr));
// 3. 设置监听
listen(lfd, 128);
// 将监听的fd的状态检测委托给内核检测
int maxfd = lfd;
// 初始化检测的读集合
fd_set rdset;
fd_set rdtemp;
// 清零
FD_ZERO(&rdset);
// 将监听的lfd设置到检测的读集合中
FD_SET(lfd, &rdset);
// 通过select委托内核检测读集合中的文件描述符状态, 检测read缓冲区有没有数据
// 如果有数据, select解除阻塞返回
// 应该让内核持续检测
while(1)
{
// 默认阻塞
// rdset 中是委托内核检测的所有的文件描述符
rdtemp = rdset;
int num = select(maxfd+1, &rdtemp, NULL, NULL, NULL);
// rdset中的数据被内核改写了, 只保留了发生变化的文件描述的标志位上的1, 没变化的改为0
// 只要rdset中的fd对应的标志位为1 -> 缓冲区有数据了
// 判断
// 有没有新连接
if(FD_ISSET(lfd, &rdtemp))
{
// 接受连接请求, 这个调用不阻塞
struct sockaddr_in cliaddr;
int cliLen = sizeof(cliaddr);
int cfd = accept(lfd, (struct sockaddr*)&cliaddr, &cliLen);
// 得到了有效的文件描述符
// 通信的文件描述符添加到读集合
// 在下一轮select检测的时候, 就能得到缓冲区的状态
FD_SET(cfd, &rdset);
// 重置最大的文件描述符
maxfd = cfd > maxfd ? cfd : maxfd;
}
// 没有新连接, 通信
for(int i=0; i<maxfd+1; ++i)
{
// 判断从监听的文件描述符之后到maxfd这个范围内的文件描述符是否读缓冲区有数据
if(i != lfd && FD_ISSET(i, &rdtemp))
{
// 接收数据
char buf[10] = {0};
// 一次只能接收10个字节, 客户端一次发送100个字节
// 一次是接收不完的, 文件描述符对应的读缓冲区中还有数据
// 下一轮select检测的时候, 内核还会标记这个文件描述符缓冲区有数据 -> 再读一次
// 循环会一直持续, 知道缓冲区数据被读完位置
int len = read(i, buf, sizeof(buf));
if(len == 0)
{
printf("客户端关闭了连接...\n");
// 将检测的文件描述符从读集合中删除
FD_CLR(i, &rdset);
close(i);
}
else if(len > 0)
{
// 收到了数据
// 发送数据
write(i, buf, strlen(buf)+1);
}
else
{
// 异常
perror("read");
}
}
}
}
return 0;
}
rdset 用于保存要检测的原始数据,这个变量不能作为参数传递给 select 函数,如同上面黄色字体讲的一样,因为在函数内部这个变量中的值会被内核修改,函数调用完毕返回之后,里边就不是原始数据了,大部分情况下是值为 1 的标志位变少了,不可能每一轮检测,所有的文件描述符都是就行的状态。因此需要通过 rdtemp 变量将原始数据传递给内核,select () 调用完毕之后再将内核数据传出,这两个变量的功能是不一样的。
#include
#include
#include
#include
#include
int main()
{
// 1. 创建用于通信的套接字
int fd = socket(AF_INET, SOCK_STREAM, 0);
if(fd == -1)
{
perror("socket");
exit(0);
}
// 2. 连接服务器
struct sockaddr_in addr;
addr.sin_family = AF_INET; // ipv4
addr.sin_port = htons(9999); // 服务器监听的端口, 字节序应该是网络字节序
inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr.s_addr);
int ret = connect(fd, (struct sockaddr*)&addr, sizeof(addr));
if(ret == -1)
{
perror("connect");
exit(0);
}
// 通信
while(1)
{
// 读数据
char recvBuf[1024];
// 写数据
// sprintf(recvBuf, "data: %d\n", i++);
fgets(recvBuf, sizeof(recvBuf), stdin);
write(fd, recvBuf, strlen(recvBuf)+1);
// 如果客户端没有发送数据, 默认阻塞
read(fd, recvBuf, sizeof(recvBuf));
printf("recv buf: %s\n", recvBuf);
sleep(1);
}
// 释放资源
close(fd);
return 0;
}
缺点:
1.待检测集合(第 2、3、4 个参数)需要频繁的在用户区和内核区之间进行数据的拷贝,效率低。这是需要检测文件描述符对应的读缓冲区,用户态是没有这个权限的,因此要陷入到内核态。
2.内核对于 select 传递进来的待检测集合的检测方式是线性的
将“点分十进制” -> “二进制整数”。
poll通过轮询包含文件操作符的结构体集合,修改结构体中的revents成员,来告诉程序结构体中对应的fd发生了什么。服务器程序通过轮询集合中的结构体,对revents符合要求的进行连接或读取。
相同点:
.1.查询方式:内核对应文件描述符的检测也是以线性的方式进行轮询,根据描述符的状态进行处理
2.是否进行有用户态到内核态的拷贝:poll 和 select 检测的文件描述符集合会在检测过程中频繁的进行用户区和内核区的拷贝,它的开销随着文件描述符数量的增加而线性增大,从而效率也会越来越低。
不同点:
1.文件描述符上限:select检测的文件描述符个数上限是1024,poll没有最大文件描述符数量的限制,这是由于在poll中,文件描述符的信息是使用链表来存储的。
2.是否可跨平台:select可以跨平台使用,poll只能在Linux平台使用。
3.文件描述符信息储存方式:select使用位图的方式标记委托内核检测的文件描述符,每个比特位对应一个唯一的文件描述符。poll直接将要检测的文件描述符的信息封装到结构体struct pollfd中,用户态和内核态下都可直接读写结构体变量。
#include
// 每个委托poll检测的fd都对应这样一个结构体
struct pollfd {
int fd; /* 委托内核检测的文件描述符 */
short events; /* 委托内核检测文件描述符的什么事件 */
short revents; /* 文件描述符实际发生的事件 -> 传出 */
};
struct pollfd myfd[100];
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
fds: 这是一个 struct pollfd 类型的数组,里边存储了待检测的文件描述符的信息,这个数组中有三个成员:
fd:委托内核检测的文件描述符。
events:委托内核检测的 fd 事件(输入、输出、错误),每一个事件有多个取值。
revents:这是一个传出参数,数据由内核写入,存储内核检测之后的结果。
nfds: 这是第一个参数数组中最后一个有效元素的下标 + 1(也可以指定参数 1 数组的元素总个数)
timeout: 指定 poll 函数的阻塞时长
-1:一直阻塞,直到检测的集合中有就绪的文件描述符(有事件产生)解除阻塞
0:不阻塞,不管检测集合中有没有已就绪的文件描述符,函数马上返回
大于 0:阻塞指定的毫秒(ms)数之后,解除阻塞
函数返回值:
失败: 返回 - 1
成功:返回一个大于 0 的整数,表示检测的集合中已就绪的文件描述符的总个数。
poll 和 select 进行 IO 多路转接的处理思路是完全相同的。
#include
#include
#include
#include
#include
#include
#include
int main()
{
// 1.创建套接字
int lfd = socket(AF_INET, SOCK_STREAM, 0);
if(lfd == -1)
{
perror("socket");
exit(0);
}
// 2. 绑定 ip, port
struct sockaddr_in addr;
addr.sin_port = htons(9999);
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;
int ret = bind(lfd, (struct sockaddr*)&addr, sizeof(addr));
if(ret == -1)
{
perror("bind");
exit(0);
}
// 3. 监听
ret = listen(lfd, 100);
if(ret == -1)
{
perror("listen");
exit(0);
}
// 4. 等待连接 -> 循环
// 检测 -> 读缓冲区, 委托内核去处理
// 数据初始化, 创建自定义的文件描述符集
struct pollfd fds[1024];
// 初始化
for(int i=0; i<1024; ++i)
{
fds[i].fd = -1;
fds[i].events = POLLIN;
}
fds[0].fd = lfd;
int maxfd = 0;
while(1)
{
// 委托内核检测
ret = poll(fds, maxfd+1, -1);
if(ret == -1)
{
perror("select");
exit(0);
}
// 检测的度缓冲区有变化
// 有新连接
if(fds[0].revents & POLLIN)
{
// 接收连接请求
struct sockaddr_in sockcli;
int len = sizeof(sockcli);
// 这个accept是不会阻塞的
int connfd = accept(lfd, (struct sockaddr*)&sockcli, &len);
// 委托内核检测connfd的读缓冲区
int i;
for(i=0; i<1024; ++i)
{
if(fds[i].fd == -1)
{
fds[i].fd = connfd;
break;
}
}
maxfd = i > maxfd ? i : maxfd;
}
// 通信, 有客户端发送数据过来
for(int i=1; i<=maxfd; ++i)
{
// 如果在集合中, 说明读缓冲区有数据
if(fds[i].revents & POLLIN)
{
char buf[128];
int ret = read(fds[i].fd, buf, sizeof(buf));
if(ret == -1)
{
perror("read");
exit(0);
}
else if(ret == 0)
{
printf("对方已经关闭了连接...\n");
close(fds[i].fd);
fds[i].fd = -1;
}
else
{
printf("客户端say: %s\n", buf);
write(fds[i].fd, buf, strlen(buf)+1);
}
}
}
}
close(lfd);
return 0;
}
#include
#include
#include
#include
#include
int main()
{
// 1. 创建用于通信的套接字
int fd = socket(AF_INET, SOCK_STREAM, 0);
if(fd == -1)
{
perror("socket");
exit(0);
}
// 2. 连接服务器
struct sockaddr_in addr;
addr.sin_family = AF_INET; // ipv4
addr.sin_port = htons(9999); // 服务器监听的端口, 字节序应该是网络字节序
inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr.s_addr);
int ret = connect(fd, (struct sockaddr*)&addr, sizeof(addr));
if(ret == -1)
{
perror("connect");
exit(0);
}
// 通信
while(1)
{
// 读数据
char recvBuf[1024];
// 写数据
// sprintf(recvBuf, "data: %d\n", i++);
fgets(recvBuf, sizeof(recvBuf), stdin);
write(fd, recvBuf, strlen(recvBuf)+1);
// 如果客户端没有发送数据, 默认阻塞
read(fd, recvBuf, sizeof(recvBuf));
printf("recv buf: %s\n", recvBuf);
sleep(1);
}
// 释放资源
close(fd);
return 0;
}
IO 多路转接的意思是在一个操作里同时监听多个输入输出源,在其中一个或多个输入输出源可用的时候返回,然后对其的进行读写操作。
select/poll 低效的原因之一是将 “添加 / 维护待检测任务” 和 “阻塞进程 / 线程” 两个步骤合二为一。每次调用 select 都需要这两步操作,然而大多数应用场景中,需要监视的 socket 个数相对固定,并不需要每次都修改。epoll 将这两个操作分开,先用 epoll_ctl() 维护等待队列,再调用 epoll_wait() 阻塞进程(解耦)。
#include
// 创建epoll实例,通过一棵红黑树管理待检测集合
int epoll_create(int size);
// 管理红黑树上的文件描述符(添加、修改、删除)
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// 检测epoll树中是否有就绪的文件描述符
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
epoll_create() 函数的作用是创建一个红黑树模型的实例,用于管理待检测的文件描述符的集合。返回值为一个文件描述符int epfd。该文件描述符指向红黑树的根节点。
结构体上树,设置监听。
epoll_ctl() 函数的作用是管理红黑树实例上的节点,可以进行添加、删除、修改操作。
// 联合体, 多个变量共用同一块内存
typedef union epoll_data {
void *ptr;
int fd; // 通常情况下使用这个成员, 和epoll_ctl的第三个参数相同即可
uint32_t u32;
uint64_t u64;
} epoll_data_t;
struct epoll_event {
uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
返回就绪的文件描述符。
epoll_wait() 函数的作用是检测创建的 epoll 实例中有没有就绪的文件描述符。
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
函数参数:
函数返回值:
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
// server
int main(int argc, const char* argv[])
{
// 创建监听的套接字
int lfd = socket(AF_INET, SOCK_STREAM, 0);
if(lfd == -1)
{
perror("socket error");
exit(1);
}
// 绑定
struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(9999);
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 本地多有的IP
// 设置端口复用
int opt = 1;
setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// 绑定端口
int ret = bind(lfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
if(ret == -1)
{
perror("bind error");
exit(1);
}
// 监听
ret = listen(lfd, 64);
if(ret == -1)
{
perror("listen error");
exit(1);
}
// 现在只有监听的文件描述符
// 所有的文件描述符对应读写缓冲区状态都是委托内核进行检测的epoll
// 创建一个epoll模型
int epfd = epoll_create(100);
if(epfd == -1)
{
perror("epoll_create");
exit(0);
}
// 往epoll实例中添加需要检测的节点, 现在只有监听的文件描述符
struct epoll_event ev;
ev.events = EPOLLIN; // 检测lfd读读缓冲区是否有数据
ev.data.fd = lfd;
int ret = epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev);
if(ret == -1)
{
perror("epoll_ctl");
exit(0);
}
struct epoll_event evs[1024];
int size = sizeof(evs) / sizeof(struct epoll_event);
// 持续检测
while(1)
{
// 调用一次, 检测一次
int num = epoll_wait(epfd, evs, size, -1);
for(int i=0; i<num; ++i)
{
// 取出当前的文件描述符
int curfd = evs[i].data.fd;
// 判断这个文件描述符是不是用于监听的
if(curfd == lfd)
{
// 建立新的连接
int cfd = accept(curfd, NULL, NULL);
// 新得到的文件描述符添加到epoll模型中, 下一轮循环的时候就可以被检测了
ev.events = EPOLLIN; // 读缓冲区是否有数据
ev.data.fd = cfd;
ret = epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev);
if(ret == -1)
{
perror("epoll_ctl-accept");
exit(0);
}
}
else
{
// 处理通信的文件描述符
// 接收数据
char buf[1024];
memset(buf, 0, sizeof(buf));
int len = recv(curfd, buf, sizeof(buf), 0);
if(len == 0)
{
printf("客户端已经断开了连接\n");
// 将这个文件描述符从epoll模型中删除
epoll_ctl(epfd, EPOLL_CTL_DEL, curfd, NULL);
close(curfd);
}
else if(len > 0)
{
printf("客户端say: %s\n", buf);
send(curfd, buf, len, 0);
}
else
{
perror("recv");
exit(0);
}
}
}
}
return 0;
}
同。
#include
#include
#include
#include
#include
int main()
{
// 1. 创建用于通信的套接字
int fd = socket(AF_INET, SOCK_STREAM, 0);
if(fd == -1)
{
perror("socket");
exit(0);
}
// 2. 连接服务器
struct sockaddr_in addr;
addr.sin_family = AF_INET; // ipv4
addr.sin_port = htons(9999); // 服务器监听的端口, 字节序应该是网络字节序
inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr.s_addr);
int ret = connect(fd, (struct sockaddr*)&addr, sizeof(addr));
if(ret == -1)
{
perror("connect");
exit(0);
}
// 通信
while(1)
{
// 读数据
char recvBuf[1024];
// 写数据
// sprintf(recvBuf, "data: %d\n", i++);
fgets(recvBuf, sizeof(recvBuf), stdin);
write(fd, recvBuf, strlen(recvBuf)+1);
// 如果客户端没有发送数据, 默认阻塞
read(fd, recvBuf, sizeof(recvBuf));
printf("recv buf: %s\n", recvBuf);
sleep(1);
}
// 释放资源
close(fd);
return 0;
}
1.最大连接数:
select:单个进程所能打开的最大连接数有FD_SETSIZE宏定义。
poll:它没有最大连接数的限制,原因是它是基于链表来存储的。
epoll:虽然连接数有上限,但是上限很大。
2.FD剧增后带来的IO效率问题
select:每次调用时select都会对文件描述符进行线性遍历,所以随着FD的增加会造成遍历速度慢的“线性下降性能问题”。
poll:与select相同
epoll:事件驱动I/O,mmap(一种内存映射方法)实现内核与用户空间的消息传递,epoll内核中实现是根据每个fd上的callback函数来实现的,只有活跃的socket才会主动调用callback。
3.消息传递方式
select:每次调用都需要在用户空间和内核空间来回的拷贝文件描述符集合,而且每次调用select需要重新初始化 集合fd_set,内核需要将消息传递到用户空间,都需要内核拷贝动作。
poll:和select相同
epoll:epoll通过mmap,内核和用户空间共享一块内存来实现消息传递。
1.连接数少并且连接都十分活跃的情况下,select和poll的性能可能比epoll好,毕竟epoll的通知机制需要很多函数回调。
2.连接很多,则epoll更快。
epoll的设计和实现与select完全不同。epoll通过在Linux内核中申请一个简易的文件系统(文件系统一般用什么数据结构实现?B+树)。把原先的select/poll调用分成了3个部分:
1)调用epoll_create()建立一个epoll对象(在epoll文件系统中为这个句柄对象分配资源)
2)调用epoll_ctl向epoll对象中添加这100万个连接的套接字
3)调用epoll_wait收集发生的事件的连接
epoll_create方法时,Linux内核会创建一个eventpoll结构体
struct eventpoll{
....
/*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/
struct rb_root rbr;
/*双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/
struct list_head rdlist;
....
};
用于存放通过epoll_ctl方法向epoll对象中添加进来的事件。这些事件都会挂载在红黑树中(红黑树的插入时间效率是lgn,其中n为节点数量),红黑树存储在内核中,用来长久的维护一个装有文件描述符的集合。
所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,当相应的事件发生时会调用这个回调方法。这个回调方法在内核中叫ep_poll_callback,它会将发生的事件添加到rdlist双链表中。
在epoll中,对于每一个事件,都会建立一个epitem结构体
struct epitem{
struct rb_node rbn;//红黑树节点
struct list_head rdllink;//双向链表节点
struct epoll_filefd ffd; //事件句柄信息
struct eventpoll *ep; //指向其所属的eventpoll对象
struct epoll_event event; //期待发生的事件类型
}
当调用epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可。如果rdlist不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户。
第一步:epoll_create()系统调用。此调用返回一个句柄,之后所有的使用都依靠这个句柄来标识。
第二步:epoll_ctl()系统调用。通过此调用向epoll对象中添加、删除、修改感兴趣的事件,返回0标识成功,返回-1表示失败。
第三部:epoll_wait()系统调用。通过此调用收集收集在epoll监控中已经发生的事件。