学习此篇文章之前,建议先点击👉五种I/O模型进行知识储备。
select只负责等待文件描述符就绪(可以同时等待多个),然后通知应用程序进行I/O读写操作。
就绪的三种情况:
- 读就绪
- 写就绪
- 异常就绪
select认识
函数原型
#include <sys/select.h>
//函数原型
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
- 参数nfds, 它的值是select监听的最大文件描述符数 + 1
参数readfds、writefds、exceptfds。
它们都是一个fd_set的结构体指针。
是一个输入输出型参数,通过位图中的比特位内容获得信息:
- 作为输入:用户告诉内核,内核需要帮用户关心的文件描述符。
- 作为输出:内核告诉用户,用户需要的文件描述符上面的等待事件就绪。
以readfds为例:
作为输入:用户告诉内核,内核需要帮用户关心的文件描述符。
作为输出:内核告诉用户,用户需要的文件描述符已经“读就绪”。
readfds只关心读,writefds只关心写,exceptfds只关心异常。
位图的操作是比较繁琐的,所以我们可以使用下面一系列宏来访问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,等待就绪可以设置成三种模式:
(1)只要不就绪,就不返回。(阻塞)
(2)只要不就绪,立马返回。(非阻塞)
(3)设置deadline,deadline之内就是(1),deadline之外就是(2)。
参数timeout就是用来设置deadline的,也就是设置select()的等待时间。
关于timeout的取值:
- NULL:表示select()没有timeout, select会一直被阻塞,直到等待的某个文件描述符就绪。
- 0:deadline为0S,仅仅检测文件描述符集合是否就绪,然后立刻返回,并不进行等待。
- 特定的时间值:
(1)如果在指定的时间段内没有文件描述符就绪,select等待超时了,就会返回。
(2)如果在指定的时间段内有文件描述符就绪,会立刻返回,并将timeout设置成还剩下的时间。
返回值
- select执行成功,返回就绪文件描述符的总个数。
- select超时返回,返回0。
- select失败,返回-1并设置errno, 此时readfds、writefds、exceptfds、timeout的值不可信。
errno可能被设置:
- EINTR:select等待期间收到信号而中断。
- EBADF:等待的文件描述符无效/该文件已经关闭。
- ENOMEM:核心内存不足。
理解select执行过程
理解select的执行过程,关键在于fd_set。为了方便说明,取fd_set长度为1字节(8个比特位)。fd_set中的每一bit可以对应一个文件描述符fd。则1字节长度的fd_set可以对应8个文件描述符。
(1)执行fd_set fdset;FD_ZERO(&set); 则fdset用位表示0000 0000.
(2)假设fd = 5, 执行FD_SET(fd, &fdset); 后fdset变为 0010 0000(下标为5的位置为1)
(3)如果再加入fd = 2, fd = 1, 则set变为0010 0110
(4)执行select(6, &fdset, 0, 0, 0)阻塞等待
(5)若fd = 1, fd = 2上都可读,则select返回,此时fdset变为000 0110。
上面仅模拟了一次,由于select后面的几个参数是输入输出型参数,因此后面的每一次都需要对fdset重新设置。
文件描述符就绪条件
文件描述符在哪些情况下读就绪、写就绪、异常就绪(可读、可写、出现异常)对select的使用是非常关键的。
读就绪:
- socket内核接收缓存区中的字节数>=低水位标记SO_RCVLOWAT。此时可以无阻塞读socket,并且读操作返回的字节数大于0.
- socket TCP通信的对端关闭了连接。此时对socket的读操作将返回0
- 监听socket上有新的连接请求。
- socket上有未处理的错误。此时可以用getsockopt读取和清除该错误。
写就绪
- socket内核发送缓存区中的字节数>=低水位标记SO_RCVLOWAT.此时可以无阻塞地写socket,并且写操作返回的字节数大于0
- socket的写操作被关闭。对写操作被关闭的socket执行写操作将触发一个SIGPIPE信号。
- socket使用非阻塞connect连接成功/失败(超时)之后。
- socket上有未处理的错误。此时可以用getsockopt读取和清除该错误。
异常就绪
- socket上接收到带外数据。
编码环节
准备环节
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
//命令格式:./server port
int main(int argc, char *argv[])
{
int listen_sock = socket(AF_INET, SOCK_STREAM, 0);
if(listen_sock < 0)
{
std::cerr << "create listen socket failed" << std::endl;
return 1;
}
//描述要绑定的套接字地址
struct sockaddr_in ServerAddr;
ServerAddr.sin_family = AF_INET;
ServerAddr.sin_port = htons(atoi(argv[1]));
ServerAddr.sin_addr.s_addr = INADDR_ANY;
if(bind(listen_sock, (struct sockaddr*)&ServerAddr, sizeof(ServerAddr)) < 0)
{
std::cerr << "listen sock bind failed" << std::endl;
return 2;
}
if(listen(listen_sock, 5) < 0)
{
std::cout << "server listen failed" << std::endl;
return 3;
}
return 0;
}
在没有引入I/O多路复用之前,我们在此之后的步骤就是: 监听socket监听,然后从全连接队列里面获取到来的新连接。
while(true)
{
//输入输出型参数, 用于获取新连接的套接字地址和长度
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int sock = accept(listen_sock, (struct sockaddr*)&peer, &len);
if(sock < 0)
{
//获取失败
continue;
}
}
accept()阻塞式地等待全连接队列里连接的到来,如果有连接到来,就从全连接队列里获取出来。
引入多路复用之后。站在多路复用的角度我们发现新连接到来,放入了监听socket的全连接队列内,全连接队列内就有数据了,简而言之就是监听socket的读事件就绪。当获取连接后,产生新的socket文件描述符
accept只能阻塞等待,而且不能够同时监听多个文件描述符,服务器开始的时候都只有监听socket,获取新的连接后就开始增加新的socket文件描述符,所以我们可以使用多路复用同时等待多个文件描述符,提高效率。
#define NUM (sizeof(fd_set) * 8)
//命令格式:./server port
int main(int argc, char *argv[])
{
int listen_sock = socket(AF_INET, SOCK_STREAM, 0);
if(listen_sock < 0)
{
std::cerr << "create listen socket failed" << std::endl;
return 1;
}
//描述要绑定的套接字地址
struct sockaddr_in ServerAddr;
ServerAddr.sin_family = AF_INET;
ServerAddr.sin_port = htons(atoi(argv[1]));
ServerAddr.sin_addr.s_addr = INADDR_ANY;
if(bind(listen_sock, (struct sockaddr*)&ServerAddr, sizeof(ServerAddr)) < 0)
{
std::cerr << "listen sock bind failed" << std::endl;
return 2;
}
if(listen(listen_sock, 5) < 0)
{
std::cout << "server listen failed" << std::endl;
return 3;
}
//使用一个数组存放该应用程序中可能会产生的文件描述符
int fd_array[NUM];
for(int i = 0; i < NUM; i++)
{
fd_array[i] = -1; //起初设置成为-1,表示这个位置不是文件描述符值。
}
//开始的时候都只有监听socket,将它的文件描述符值往数组里存放
fd_array[0] = listen_sock;
fd_set rset;
while(true)
{
//找到此时程序中存在的最大文件描述符值,用于select()
//同时可以把存在的fd都设置进rset
int MaxFd = fd_array[0];
FD_ZERO(&rset);
for(int i = 0; i < NUM;i++)
{
if(fd_array[i] == -1)
{
//该位置没有文件描述符
continue;
}
FD_SET(fd_array[i], &rset);
if(MaxFd < fd_array[i])
{
MaxFd = fd_array[i];
}
}
int n = select(MaxFd + 1, &rset, nullptr, nullptr, nullptr);
}
return 0;
}
把这个程序中所有的fd都交给select检测。
我们需要对select()的返回值做一个判断,因为它的返回值不同发生的情况就不同
//......
while(true)
{
//找到此时程序中存在的最大文件描述符值,用于select()
//同时可以把存在的fd都设置进rset
int MaxFd = fd_array[0];
FD_ZERO(&rset);
for(int i = 0; i < NUM;i++)
{
if(fd_array[i] == -1)
{
//该位置没有文件描述符
continue;
}
FD_SET(fd_array[i], &rset);
if(MaxFd < fd_array[i])
{
MaxFd = fd_array[i];
}
}
int n = select(MaxFd + 1, &rset, nullptr, nullptr, nullptr);
switch(n)
{
case -1:
std::cerr << "select error" << std::endl;
break;
case 0:
std::cout << "select timeout" << std::endl;
break;
default:
std::cout << "有fd对应的事件就绪了!" << std::endl;
//
break;
}
}
//......
经过select后,参数rset的位图设置有“哪些文件描述符读就绪了”。
所以我们可以使用FD_ISSET去判定此时该程序中的文件描述符哪些被设置进了rset的位图。
//......
default:
std::cout << "有fd对应的事件就绪了!" << std::endl;
for(int i = 0; i < NUM; i++)
{
if(fd_array[i] == -1)
{
continue; //这个位置没有文件描述符
}
//下面的就是该程序中合法存在的文件描述符
//判断某存在的文件描述符是否读就绪(经过select后,被设置进了rset)
if(FD_ISSET(fd_array[i], &rset))
{
}
}
break;
//......
应用程序中存在的某文件描述符已经就绪,还要根据就绪的文件描述符分成两种情况:
- (1)监听socket就绪。意味着有新的连接到来,可以直接使用accept获取。
- (2)普通socket就绪。意味着可以对该socket进行读取操作了。
//......
if(FD_ISSET(fd_array[i], &rset))
{
if(fd_array[i] == listen_sock)
{
//监听socket就绪,意味着有新连接到来
std::cout << "listen_sock:" << listen_sock << " 新连接到来" << std::endl;
//获取
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int sock = accept(listen_sock, (struct sockaddr*)&peer, &len);
if(sock < 0)
{
continue;
}
std::cout << "获取新连接成功" << std::endl;
}
else
{
//普通socket,可以对该socket进行读取操作
}
}
//......
先关注监听socket就绪的情况。
当我们获取到新的连接,产生了新的文件描述符,我们仍然将这个文件描述符交予select做检测。
但是rset经过select过后,表示的含义就改变了。不再是应用程序中存在并且需要交给select做检测的文件描述符。但不要忘记fd_array数组,我们可以将新的fd放入fd_array数组中,下次循环的时候,rset的含义又变回去了,我们写的程序会帮助我们把这个fd设置进rset。
if (fd_array[i] == listen_sock)
{
//监听socket就绪,意味着有新连接到来
std::cout << "listen_sock:" << listen_sock << " 新连接到来" << std::endl;
//获取
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int sock = accept(listen_sock, (struct sockaddr *)&peer, &len);
if (sock < 0)
{
continue;
}
std::cout << "获取新连接成功" << std::endl;
//这次将新产生的fd放入fd_array,目的是把新的fd也交给select检测
int pos = 1;
for(pos = 1; pos < NUM; pos++)
{
//在fd_array中找一个空位置用于存放新的fd
if(fd_array[pos] == -1)
{
break;
}
}
//判断是否为一个合法的位置
if(pos < NUM)
{
std::cout << "新链接: " << sock << " 已经被添加到了数组[" << pos << "]的位置" << std::endl;
fd_array[pos] = sock;
}
else
{
//fd_array数组此时满了
//表示服务器此时满载了,无法处理新的连接
std::cout << "服务器已经满载了,关闭新的链接" << std::endl;
close(sock);
}
}
再来关注普通socket读就绪的情况。
普通socket读就绪,意味着可以对该socket进行读操作了。
//......
close(sock);
}
}
else
{
//普通socket,可以对该socket进行读操作
char recv_buffer[1024] = {0};
ssize_t s = recv(fd_array[i] , recv_buffer, sizeof(recv_buffer) -1 , 0);
if(s > 0)
{
//读成功
}
else if(s == 0)
{
//通信对方关闭了连接
}
else
{
//读失败
}
}
//......
完善一下
#include <unistd.h> //新增头文件
//...
else
{
//普通socket,可以对该socket进行读操作
char recv_buffer[1024] = {0};
ssize_t s = recv(fd_array[i] , recv_buffer, sizeof(recv_buffer) -1 , 0);
if(s > 0)
{
//读成功
//在服务器标准输出流中打印
recv_buffer[s] = '\0';
std::cout << "client[ " << fd_array[i] << "]# " << recv_buffer << std::endl;
}
else if(s == 0)
{
//通信对方关闭了连接
//关闭该fd,并在fd_array数组中去掉该fd
std::cout << "sock: " << fd_array[i] << "关闭了, client退出啦!" << std::endl;
close(fd_array[i]);
std::cout << "已经在数组下标fd_array[" << i << "]"
<< "中,去掉了sock: " << fd_array[i] << std::endl;
fd_array[i] = -1;
}
else
{
//读失败
close(fd_array[i]);
std::cout << "已经在数组下标fd_array[" << i << "]"
<< "中,去掉了sock: " << fd_array[i] << std::endl;
fd_array[i] = -1;
}
}
//......
最终的代码
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <sys/select.h>
#include <unistd.h>
#define NUM (sizeof(fd_set) * 8)
//命令格式:./server port
int main(int argc, char *argv[])
{
int listen_sock = socket(AF_INET, SOCK_STREAM, 0);
if (listen_sock < 0)
{
std::cerr << "create listen socket failed" << std::endl;
return 1;
}
//描述要绑定的套接字地址
struct sockaddr_in ServerAddr;
ServerAddr.sin_family = AF_INET;
ServerAddr.sin_port = htons(atoi(argv[1]));
ServerAddr.sin_addr.s_addr = INADDR_ANY;
if (bind(listen_sock, (struct sockaddr *)&ServerAddr, sizeof(ServerAddr)) < 0)
{
std::cerr << "listen sock bind failed" << std::endl;
return 2;
}
if(listen(listen_sock, 5) < 0)
{
std::cout << "server listen failed" << std::endl;
return 3;
}
//使用一个数组存放该应用程序中可能会产生的文件描述符
int fd_array[NUM];
for (int i = 0; i < NUM; i++)
{
fd_array[i] = -1; //起初设置成为-1,表示这个位置不是文件描述符值。
}
//开始的时候都只有监听socket,将它的文件描述符值往数组里存放
fd_array[0] = listen_sock;
fd_set rset;
while (true)
{
//找到此时程序中存在的最大文件描述符值,用于select()
//同时可以把存在的fd都设置进rset
int MaxFd = fd_array[0];
FD_ZERO(&rset);
for (int i = 0; i < NUM; i++)
{
if (fd_array[i] == -1)
{
//该位置没有文件描述符
continue;
}
FD_SET(fd_array[i], &rset);
if (MaxFd < fd_array[i])
{
MaxFd = fd_array[i];
}
}
int n = select(MaxFd + 1, &rset, nullptr, nullptr, nullptr);
switch (n)
{
case -1:
std::cerr << "select error" << std::endl;
break;
case 0:
std::cout << "select timeout" << std::endl;
break;
default:
std::cout << "有fd对应的事件就绪了!" << std::endl;
for (int i = 0; i < NUM; i++)
{
if (fd_array[i] == -1)
{
continue; //这个位置没有文件描述符
}
//下面的就是该程序中合法存在的文件描述符
//判断存在的文件描述符是否读就绪(经过select后,被设置进了rset)
if (FD_ISSET(fd_array[i], &rset))
{
if (fd_array[i] == listen_sock)
{
//监听socket就绪,意味着有新连接到来
std::cout << "listen_sock:" << listen_sock << " 新连接到来" << std::endl;
//获取
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int sock = accept(listen_sock, (struct sockaddr *)&peer, &len);
if (sock < 0)
{
continue;
}
std::cout << "获取新连接成功" << std::endl;
//这次将新产生的fd放入fd_array,目的是把新的fd也交给select检测
int pos = 1;
for(pos = 1; pos < NUM; pos++)
{
//在fd_array中找一个空位置用于存放新的fd
if(fd_array[pos] == -1)
{
break;
}
}
//判断是否为一个合法的位置
if(pos < NUM)
{
std::cout << "新链接: " << sock << " 已经被添加到了数组[" << pos << "]的位置" << std::endl;
fd_array[pos] = sock;
}
else
{
//fd_array数组此时满了
//表示服务器此时满载了,无法处理新的连接
std::cout << "服务器已经满载了,关闭新的链接" << std::endl;
close(sock);
}
}
else
{
//普通socket,可以对该socket进行读操作
char recv_buffer[1024] = {0};
ssize_t s = recv(fd_array[i] , recv_buffer, sizeof(recv_buffer) -1 , 0);
if(s > 0)
{
//读成功
//在服务器标准输出流中打印
recv_buffer[s] = '\0';
std::cout << "client[ " << fd_array[i] << "]# " << recv_buffer << std::endl;
}
else if(s == 0)
{
//通信对方关闭了连接
//关闭该fd,并在fd_array数组中去掉该fd
std::cout << "sock: " << fd_array[i] << "关闭了, client退出啦!" << std::endl;
close(fd_array[i]);
std::cout << "已经在数组下标fd_array[" << i << "]"
<< "中,去掉了sock: " << fd_array[i] << std::endl;
fd_array[i] = -1;
}
else
{
//读失败
close(fd_array[i]);
std::cout << "已经在数组下标fd_array[" << i << "]"
<< "中,去掉了sock: " << fd_array[i] << std::endl;
fd_array[i] = -1;
}
}
}
}
break;
}
}
return 0;
}
注意,如果你忘记去监听,并且让select等待监听socket,它总是立即返回1。
select的特点
- 可监控的文件描述符个数取决去sizeof(fd_set)的值。
- 将fd加入select监控集的还要使用一个数据结构array保存到select监控集中的fd
(1)用于select返回后,array作为数据源和fd_set进行FD_ISSET判断
(2)select返回后会把以前加入的但并无事件发生的fd清空,则每次开始select前都需要重新从array取得fd逐一加入,扫描array的同时取得fd最大值maxfd,用于select得第一个参数
select的缺点
- 每次调用select,都需要手动设置fd集合,从接口使用角度看来非常的不方便
- 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很大时也会很大
- 每次调用select,都需要在内核遍历传递进来的所有fd,这个开销在fd很大时也会很大
- select支持的文件描述符数量,相对于系统支持我们同时打开的文件符数量来说比较小