本文分享的是IO多路转接中的select,其中包括select函数如何去使用,以及使用相关代码实现客户端向服务端发送消息的服务,从而更好地理解多路转接的select。
多路转接
多路转接是IO模型的一种,这种IO模型通过select函数进行IO等待,并且select函数能够同时等待多个文件描述符的就绪状态,单个文件描述符的等待与阻塞IO类似。
select
系统提供select函数来实现多路复用输入/输出模型。select系统调用是用来让我们的程序监视多个文件描述符的状态变化的。程序会停在select这里等待,直到被监视的文件描述符有一个或多个发生了状态改变。
通俗的来讲,select函数,就是负责等待,得到文件描述符就绪后,通知上层进行读取或写入。select没有读取或写入数据的功能,并且select能够同时等待多个文件描述符。
select函数原型
select的函数原型:
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
参数解释:
①参数nfds:需要监视的最大的文件描述符值+1。
要解释readfds、writefds和exceptfds前,先解释它们的类型fd_set类型。
fd_set类型
fd_set是一个整数数组, 更严格的说, 是一个 "位图"。使用位图中对应的位来表示要监视的文件描述符。
在fd_set位图结构中,使用比特位的“位置”来表示某一个sock。
而对于比特位的“内容”,首先我们需要知道的是,readfds、writefds和exceptfds三个参数都是输入输出型参数。
以readfds读为例:
用户在使用该参数进行输入时,实质上是用户告诉内核,内核你要帮我关心一下哪些文件描述符上的读事件就绪。
内核进行输出时,实质上是告诉用户,用户你所关心的那些文件描述符上的读事件已经就绪。
于是,对于比特位的“内容”,首先是输入时,是用户想要内核帮忙关心的文件描述符的合集。在输出时,是内核要告诉用户已经就绪的文件描述符的合集。
比如,输入时,我们规定用户想要关心的文件描述,在位图结构中,其比特位的位置位1,3,5,于是在输入时,将其内容置为1。那么在输出时,假设这些文件描述符都已经就绪,输出回来时,这个合集中的1,3,5比特位的位置上的内容已经变成0。需要注意的是,输入输出的都是同一个位图,是同一个!
提供了一组操作fd_set的接口, 来比较方便的操作位图:
void FD_CLR(int fd, fd_set *set); // 用来清除描述词组set中相关fd 的位。
int FD_ISSET(int fd, fd_set *set); // 用来测试描述词组set中相关fd 的位是否为真。
void FD_SET(int fd, fd_set *set); // 用来设置描述词组set中相关fd的位。
void FD_ZERO(fd_set *set); // 用来清除描述词组set的全部位。
②readfds、writefds和exceptfds三个参数:分别对应于需要检测的可读文件描述符的集合,可写文件描述符的集 合及异常文件描述符的集合。
在解释参数timeout前,我们先来解释struct timeval结构。
timeval结构
timeval结构用于描述一段时间长度,如果在这个时间内,需要监视的描述符没有事件发生则函数返回,返回值为0。
函数返回值:
执行成功则返回文件描述词状态已改变的个数。
如果返回0代表在描述词状态改变前已超过timeout时间,没有返回。
当有错误发生时则返回-1,错误原因存于errno,此时参数readfds, writefds, exceptfds和timeout的值变成不可预测。
错误值可能为:
EBADF 文件描述词为无效的或该文件已关闭。
EINTR 此调用被信号所中断。
EINVAL 参数n 为负值。
ENOMEM 核心内存不足。
③参数timeou:参数timeout为结构timeval,用来设置select()的等待时间。一般timeou参数的取值有三种:
NULL:则表示select()没有timeout, select将一直被阻塞,直到某个文件描述符上发生了事件,即只要不就绪,就不返回。
0:仅检测描述符集合的状态,然后立即返回,并不等待外部事件的发生,即只要不就绪,立马返回。
特定的时间值:如果在指定的时间段里没有事件发生, select将超时返回。
理解select执行过程
理解select模型的关键在于理解fd_set,为说明方便,取fd_set长度为1字节, fd_set中的每一bit可以对应一个文件描述符fd。则1字节长的fd_set最大可以对应8个fd。
*(1)执行fd_set set; FD_ZERO(&set);则set用位表示是0000,0000。
*(2)若fd= 5,执行FD_SET(fd,&set).后set变为0001,0000(第5位置为1)。
*(3)若再加入fd= 2, fd=1,则set变为0001,0011。
*(4)执行select(6,&set,0,0,0)阻塞等待。
*(5)若fd=1,fd=2上都发生可读事件,则select返回,此时set变为0000,0011。
* 注意:没有事件发生的fd=5被清空。
需要注意的是,因为select使用输入输出型参数标识不同的含义,因此每一此都会被清空,这意味着,每一次都需要对fd_set进行重新设置!并且,因为需要重新设置,我们需要通过第三方数组来对这些文件描述符进行保存!
代码简单实现多路转换
使用select实现一个简单服务器,客户端可以向服务端发送消息,服务端读取数据。
代码思路:代码分五步:
①创建监听套接字,端口号,绑定,进入监听状态一系列动作。进入监听状态后,不能马上进行accept,因为accept便是阻塞状态,监听套接字本身就可以看作是读事件就绪了。
②准备好一个数组,用于存放套接字。
③select等待前的准备:创建fd_ser类型的变量,并设置相关参数。
④使用select进行等待。在等待后,需要分情况,其返回值是如何。
⑤如果select成功返回读事件已经就绪的文件描述符个数,那么开始进行读取。当然,到达这一步,就证明现在的文件描述符是合法的,然而需要查看在数组中,哪些文件描述符是就绪的了。
找到已经就绪的文件描述符后,还不能马上进行读取,因为有可能该文件描述符是监听套接字,需要进行accept。
确定是用于通信的套接字后,就可以进行读取了。
#include <iostream>
#include <string>
#include <sys/select.h>
#include "Sock.hpp"
//一、创建监听套接字,端口号,绑定,进入监听状态一系列动作!
//NUM为数组的大小,含义是能够包含NUM个fd,一个fd一个bit
#define NUM (sizeof(fd_set) * 8)//fd_set类型大小为128字节
int fd_array[NUM]; //内容>=0,合法的fd,如果是-1,该位置没有fd
static void Usage(std::string proc)
{
std::cout << "Usage: " << proc << " port" << std::endl;
}
//需要输入格式: ./select_server 8080
int main(int argc, char *argv[])
{
//不符合格式
if (argc != 2)
{
Usage(argv[0]);
exit(1);
}
//符合格式
uint16_t port = (uint16_t)atoi(argv[1]);//端口号
int listen_sock = Sock::Socket();//创建监听套接字
Sock::Bind(listen_sock, port);//绑定端口号
Sock::Listen(listen_sock);//服务器进入监听状态
//二、准备好存放fd的数组
//先将存放fd的数组,全部置为-1。-1表示不合法
for (int i = 0; i < NUM; i++)
{
fd_array[i] = -1;
}
// 不会在这里进行accept,accept的本质叫做通过listen_sock获取新链接
//accept是阻塞式等待
//站在多路转接的视角,我们认为,链接到来,对于listen_sock,就是读事件就绪!!!
//对于所有的服务器,最开始的时候,只有listen_sock
//三、select等待前的准备:创建fd_ser类型的变量,并设置相关参数
//事件循环
//创建fd_set结构的位图:使用位图中对应的位来表示要监视的文件描述符
fd_set rfds;
//将fd数组中的第一个元素,存放为监听套接字
fd_array[0] = listen_sock;
//进入循环
for (;;)
{
//用来清除描述词组set的全部位:将位图全部置0,全部清除。
FD_ZERO(&rfds);
//创建最大的文件描述符,用于后续select中的第一个参数的设置
int max_fd = fd_array[0];
for (int i = 0; i < NUM; i++)
{
//不合法,继续
if (fd_array[i] == -1)
continue;
//下面的都是合法的fd
//FD_SET:用来设置描述词组set中相关fd的位
FD_SET(fd_array[i], &rfds); //所有要关心读事件的fd,添加到rfds中
if (max_fd < fd_array[i])
{
max_fd = fd_array[i]; //更新最大fd
}
}
struct timeval timeout = {0, 0}; // 5s
// 我们的服务器上的所有的fd(包括listen_sock),都要交给select进行检测!!
// recv,read,write,send,accept : 只负责自己最核心的工作:真正的读写(listen_sock:accept)
//四、使用select进行等待
int n = select(max_fd + 1, &rfds, nullptr, nullptr, nullptr); //暂时阻塞
switch (n)
{
case -1: //错误发生时则返回-1
std::cerr << "select error" << std::endl;
break;
case 0: //返回0代表在描述词状态改变前已超过timeout时间,没有返回
std::cout << "select timeout" << std::endl;
break;
default: //执行成功则返回文件描述词状态已改变的个数
std::cout << "有fd对应的事件就绪啦!" << std::endl;
//五、成功返回个数,开始进行
//5.1查看是否是就绪fd
for (int i = 0; i < NUM; i++)
{
if (fd_array[i] == -1)
continue;
//下面的fd都是合法的fd,合法的fd不一定是就绪的fd
//FD_ISSET:用来测试描述词组set中相关fd 的位是否为真
if (FD_ISSET(fd_array[i], &rfds))
{
std::cout << "sock: " << fd_array[i] << " 上面有了读事件,可以读取了" << std::endl;
// 一定是读事件就绪了!!!
// 就绪的fd就在fd_array[i]保存!
// read, recv时,一定不会被阻塞!
// 读事件就绪,就一定是可以recv,read吗??不一定!!
//看看数组中的文件描述符,是属于监听套接字还是普通套接字。
//如果是监听套接字,那就需要accept
if (fd_array[i] == listen_sock)//
{
std::cout << "listen_sock: " << listen_sock << " 有了新的链接到来" << std::endl;
// accept
int sock = Sock::Accept(listen_sock);
if (sock >= 0)
{
std::cout << "listen_sock: " << listen_sock << " 获取新的链接成功" << std::endl;
// 获取成功
// recv,read了呢?绝对不能!
// 新链接到来,不意味着有数据到来!!直接读的话被阻塞!什么时候数据到来呢?不知道
// 可是,谁可以最清楚的知道那些fd,上面可以读取了?select!
// 无法直接将fd设置进select,但是,好在我们有fd_array[]!
int pos = 1;
for (; pos < NUM; pos++)
{
if (fd_array[pos] == -1)
break;
}
// 1. 找到了一个位置没有被使用
if (pos < NUM)
{
std::cout << "新链接: " << sock << " 已经被添加到了数组[" << pos << "]的位置" << std::endl;
fd_array[pos] = sock;
}
else
{
// 2. 找完了所有的fd_array[],都没有找到没有被使用位置
// 说明服务器已经满载,没法处理新的请求了
std::cout << "服务器已经满载了,关闭新的链接" << std::endl;
close(sock);
}
}
}
else //用于通信的套接字,可以读了
{
// 普通的sock,读事件就绪啦!
// 可以进行读取啦,recv,read
// 可是,本次读取就一定能读完吗?读完,就一定没有所谓的数据包粘包问题吗?
// 但是,我们今天没法解决!我们今天没有场景!仅仅用来测试
std::cout << "sock: " << fd_array[i] << " 上面有普通读取" << std::endl;
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)
{
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;
}
封装套接字相关接口:
#pragma once
#include <iostream>
#include <string>
#include <cstring>
#include <cstdlib>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
using namespace std;
class Sock
{
public:
static int Socket()
{
int sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock < 0)
{
cerr << "socket error" << endl;
exit(2);
}
int opt = 1;
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
return sock;
}
static void Bind(int sock, uint16_t port)
{
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(port);
local.sin_addr.s_addr = INADDR_ANY;
if (bind(sock, (struct sockaddr *)&local, sizeof(local)) < 0)
{
cerr << "bind error!" << endl;
exit(3);
}
}
static void Listen(int sock)
{
if (listen(sock, 5) < 0)
{
cerr << "listen error !" << endl;
exit(4);
}
}
static int Accept(int sock)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int fd = accept(sock, (struct sockaddr *)&peer, &len);
if(fd >= 0){
return fd;
}
return -1;
}
static void Connect(int sock, std::string ip, uint16_t port)
{
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(port);
server.sin_addr.s_addr = inet_addr(ip.c_str());
if(connect(sock, (struct sockaddr*)&server, sizeof(server)) == 0)
{
cout << "Connect Success!" << endl;
}
else
{
cout << "Connect Failed!" << endl;
exit(5);
}
}
};
select的特点
可监控的文件描述符个数取决与sizeof(fd_set)的值. 我这边服务器上sizeof(fd_set)= 512,每bit表示一个文件描述符,则我服务器上支持的最大文件描述符是512*8=4096。将fd加入select监控集的同时,还要再使用一个数据结构array保存放到select监控集中的fd,一是用于再select 返回后, array作为源数据和fd_set进行FD_ISSET判断。二是select返回后会把以前加入的但并无事件发生的fd清空,则每次开始select前都要重新从array取得fd逐一加入(FD_ZERO最先),扫描array的同时取得fd最大值maxfd,用于select的第一个参数。
select缺点
每次调用select, 都需要手动设置fd集合, 从接口使用角度来说也非常不便。
每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大。
同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大。
select支持的文件描述符数量太小。