前言: select用的少现在,而且写起来很复杂,本篇所讲的select IO模型主要是用于处理读就绪事件的,至于写就绪不关心。
1. select的概念
select函数来实现多路复用输入/输出模型:
- select系统调用是用来让我们的程序监视多个文件描述符的状态变化的;
- 程序会停在select这里等待,直到被监视的文件描述符有一个或多个发生了状态改变
select函数它是负责等这个过程的,等成功了就通知上层来缓冲区拷贝,这个等的方式是可以自己设置的,比如:阻塞等,非阻塞等都行。注意:它只负责等,不负责拷贝或者写入。
2. select的函数接口
- int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
函数参数:
- nfds 是要监测的fd中,最大的fd+1,它相当于一个边界控制。
- fd_set *readfds, fd_set *writefds, fd_set *exceptfds,这三个参数是输入输出型参数,输入:你告诉内核你要关心那些fd。输出:你关心的fd有谁就绪了。总共有三个参数,它们分别是可读文件描述符的集合,可写文件描述符的集 合及异常文件描述符的集合。
- timeout这个结构体就是一个时间线。就用它来控制等的方式,如果将它给成null,那么就是阻塞式等。如果给成{0,0},那就是非阻塞式等。如果给成{5,0},那就是5秒前为阻塞式等,五秒后为非阻塞式等(相当于超时处理)。
函数返回值:
- 大于 0:成功,返回集合中已就绪的文件描述符的总个数
- 等于 - 1:函数调用失败
- 等于 0:超时,没有检测到就绪的文件描述符
fd_set: 这个类型它就是一个位图,可以通过操作位图来表示你要关心哪些fd。它的大小是128字节。128*8=1024个比特位,也就是说可以检测1024个文件描述符,这个范围其实不是很大。
看一下它的结构:
typedef struct
{
/* XPG4.2 requires this member name. Otherwise avoid the name
from the global namespace. */
#ifdef __USE_XOPEN
__fd_mask fds_bits[__FD_SETSIZE / __NFDBITS];
# define __FDS_BITS(set) ((set)->fds_bits)
#else
__fd_mask __fds_bits[__FD_SETSIZE / __NFDBITS];
# define __FDS_BITS(set) ((set)->__fds_bits)
#endif
} fd_set;
但这个位图,不能直接操作,必须使用函数接口:
- void FD_CLR(int fd, fd_set *set); // 这是用于去除位图set中的某个fd
- int FD_ISSET(int fd, fd_set *set);// 用来检测某个fd是否在位图set中
- void FD_SET(int fd, fd_set *set); // 把某个fd加入到位图set中
- void FD_ZERO(fd_set *set);//清空位图
举个例子,就取fd_set的最后8个比特位:fd_set i
;
(1)先是利用FD_ZERO(&i)清空位图。
(2)将fd = 5,设置到位图i中,FD_SET(5,&i)。那就是将第五个比特位设置为1。
(3)再把fd = 1,fd =2 ,都设置进去:
(4)那么就可以利用select进行检测,假如这些描述符组合,我只关心读事件就绪:
select(6,&i,null,null,null),为啥是6,最大文件描述符fd是5,5+1 = 6。最后一个参数设置为null,就是阻塞式等待。
(5)假如现在fd=5的读事件就绪了,但是2和1都不就绪,那也返回了,位图就变为:
注意了:2和1处都被抹除,只有5处为1,因为只有5的读事件就绪了。那么肯定有疑问了,我本来要关心fd=1,2,5,三个文件描述符,虽然只有5事件读就绪,你把1,2都给抹除了,那么下次我还要重新设置位图吗?还得把1和2重新设置进位图?答案是:需要,这就是select难的地方。得有一个数组,提前把你要关心的文件描述符存进去。
那么我是如何知道5事件就绪了呢?还得操作位图:FD_ISSET(5, &i),这就是帮助检测的,如果返回值是>0,那么就说明关系的fd事件已经就绪,<0,就表示未就绪。
(6) 要是不关心某个fd了,就可以用void FD_CLR(int fd, fd_set *set);
3. 基于select的简易服务器实现
那么知道了select的基本操作函数,我们就基于select来完成一个简易的读取数据服务器,客户端就不写了,我们主要看的是服务器中使用select的基本逻辑。
#include <iostream>
#include <sys/select.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string>
这是在定义一个存放fd集合的数组,记忆功能
#define NUM (sizeof(fd_set) * 8)
int fd_array[NUM];
void useage()
{
std::cout << "please use"
<< "./select_sever"
<< "+"
<< "端口号" << std::endl;
}
int main(int argv, char *argc[])
{
启动服务器的方式
if (argv != 2)
{
useage();
exit(1);
}
使服务器进去listen状态
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd < 0)
{
std::cerr << "listen_fd failed" << std::endl;
exit(2);
}
std::cout << "listen_fd:" << listen_fd << std::endl;
struct sockaddr_in my_sock;
my_sock.sin_family = AF_INET;
my_sock.sin_port = htons(atoi(argc[1]));
my_sock.sin_addr.s_addr = INADDR_ANY;
if (bind(listen_fd, (const struct sockaddr *)&my_sock, sizeof(my_sock)) < 0)
{
std::cerr << "bind errno" << std::endl;
exit(3);
}
if (listen(listen_fd, 5) < 0)
{
std::cerr << "listen errno" << std::endl;
exit(4);
}
请问这里可以accept吗?绝对不可以,因为accept它是从listen_fd中拿连接,这也是读取事件,所以要用select进行管理。
初始化一下 fd_arry
for (int i = 0; i < NUM; i++)
{
fd_array[i] = -1;
}
我们只关心读事件,那么先得有一个位图fd_Set
fd_set rfds;
fd_array[0] = listen_fd; // 这个是不变的,一上来就只有一个套接字要监听读事件,那就是listen_fd
while (true)
{
清空位图
FD_ZERO(&rfds);
把关心的事件设置进位图,并且更新MAX_fd(select的第一个参数)
int MAX_fd = fd_array[0];
for (int i = 0; i < NUM; i++)
{
if (fd_array[i] == -1)
continue;
FD_SET(fd_array[i], &rfds);
if (MAX_fd < fd_array[i])
{
MAX_fd = fd_array[i];
}
}
开始select等待
int n = select(MAX_fd + 1, &rfds, NULL, NULL, NULL);
switch (n)
{
case -1:
std::cerr << "select error" << std::endl;
break;
case 0:
std::cout << "time out" << std::endl;
break;
default:
std::cout << "有读事件就绪" << std::endl;
std::cout<<"********************************************"<<std::endl;
// 检测有哪些事件就绪
for (int i = 0; i < NUM; i++)
{
if (fd_array[i] == -1)
continue;
if (FD_ISSET(fd_array[i], &rfds))
{
if (fd_array[i] == listen_fd)
{
std::cout << "有新的连接" << std::endl;
// 进行连接
struct sockaddr_in user;
socklen_t size_sockaddr = sizeof(user);
int sock = accept(listen_fd, (struct sockaddr *)&user, &size_sockaddr);
if (sock < 0)
{
std::cerr << "accept errno" << std::endl;
exit(5);
}
std::cout<<"连接成功:"<<"fd = "<<sock<<std::endl;
std::cout<<"********************************************"<<std::endl;
// 那么连接成功了,可以读数据吗?不可以,注意这里不可以进行读,因为 建立好连接并不表明数据来了,
// 本质就是要用select进行监测sock套接字,监测它读事件是否就绪
// 那么怎么就绪select进行检测呢?毫无疑问,把sock添加到fd_array中,下次循环就可以检测了。
// 必须要找到插入的位置
int pos = 1;
for (; pos < NUM; pos++)
{
if (fd_array[pos] == -1)
break;
}
// 对pos位置进行管理,看pos位置是否合法:
if (pos < NUM)
{
fd_array[pos] = sock;
}
else
{
std::cout << "服务器满载,关闭此连接" << std::endl;
std::cout<<"********************************************"<<std::endl;
close(sock);
}
}
else
{
走到这里,说明就是普通的读取事件,普通套接字的读事件就绪。
到这可以读吗?当然可以,人家都通知你读了
std::cout << "套接字为" << fd_array[i] << "有读取事件就绪" << std::endl;
char buffer[1024] = {0};
ssize_t N = recv(fd_array[i], buffer, sizeof(buffer) - 1, 0);
if (N > 0)
{
buffer[N] = 0;
std::cout << "client[" << fd_array[i] << "]#" << buffer << std::endl;
std::cout<<"********************************************"<<std::endl;
}
else if (N == 0)
{
std::cout << "对端连接关闭" << std::endl;
close(fd_array[i]);
// 注意,我们要把它在数组中的存储一并去除,这件事很重要
fd_array[i] = -1;
std::cout<<"********************************************"<<std::endl;
}
else
{
std::cout << "读取失败"
<< "主动关闭连接" << std::endl;
close(fd_array[i]);
// 注意,我们要把它在数组中的存储一并去除,这件事很重要
fd_array[i] = -1;
std::cout<<"********************************************"<<std::endl;
}
}
}
}
break;
}
}
return 0;
}
看看结果:
(1) 服务器起来
(2) telnet 连接
(3) 看现象,这是连接成功了,并且被select管理起来的
(4) 客户端发送数据
(5) 服务器接收
(6) 客户端退出,服务端:
至于这个代码的讲解,我都在注释里讲清楚了,不好理解。
其实,里面的select默认用的是阻塞等待,如果想要实验 select 的等待方式,其实只需要把函数的最后参数 timeout 改改就可以了。
4. select优缺点分析
分析一下select IO模型,它非常依赖第三方数组,原因有两点:
- fd_set 位图每一次都需要重新设置,这是很尴尬的,我让你关心这些fd事件,你返回的时候只返回哪些就绪了的,其余都给我抹除了,我还得搞个第三方数组,提前把关心的事件保存起来。
- 你要想关心别的fd,或者不再关心某个fd,都需要第三方数组进行管理。
那么就可以总结一下它的优缺点:
- 优点:一次可以等待多个fd,一定程度上提高了IO效率
- 缺点:每次都要重新设置位图,还得遍历检测。select从用户层到内核,多次切换,这个频率有点高,这是有开销的。文件描述符1024个,这个数量有点小。