高级IO之多路转接

发布时间:2024年01月03日

目录

理解IO

五种IO模型

同步IO与异步IO

非阻塞接口

多路转接

select

struct timeval 结构体

fd_set

select 编程

select 的优缺点

poll

struct pollfd


理解IO

文件系统的时候我们已经学习过一次IO了,那么我们现在继续理解一下IO。

根据冯诺依曼体系结构的话,只要是访问外设那么就是IO,那么访问外设有一些什么呢? 前面我们学习文件系统,我们理解了linux下一切皆文件,不论是显示器,还是键盘等,那么都是一切皆文件,但是在文件操作的时候,我们最基本只和磁盘在打交道,而且磁盘还是本机的硬件,那么现在到了网络,我们还需要和其他的主机进行通信,通信的时候也就是创建 socket 文件描述符,而我们认为 linux 下一切皆文件,那么这也是文件,虽然是网络通信,但是实际上我们还是在和本机的网卡进行文件操作,将数据交给网卡之后,数据还会在网络中流动,所以在网络这里的IO,不仅需要和外盒打交道,还需要数据在网络中转发,所以在网络这里的IO是比本机和磁盘上访问是更慢的!

既然我们现在也可以理解网络也是IO了,我们也知道网络上IO是更慢的,因为还需要数据在网络中转发,而且还可能会出现丢包等问题,所以可能就会更慢!

那么我们一直在说IO,那么IO是什么呢?我们现在理解了吗?

当我们调用一个C语言的接口,scanf 或者 C++的接口 cin 或者 python 的接口 input 那么会发生什么呢? 那么此时程序就会阻塞住,但是当标准输入一旦有了数据,那么此时就会读取到数据。那么对于一个进行来说,阻塞是什么呢?就是将程序挂起了,那么也就是让这个进程在等待!而且我们知道像 read/write 等函数实际上就是数据拷贝的函数,一般从内核拷贝到用户态,或者从用户态拷贝到内核里面,所以等进程等待到数据后,需要怎么做呢?拷贝!所以IO我们可以怎么理解呢?

IO = 等 + 数据拷贝

上面这个对于IO的理解是很重要的。

既然有了上面的这个理解,那么我们还说IO是很慢的,那么IO为什么慢呢? IO的慢就是因为需要等待! 那么我们有什么方法可以让IO快起来呢? 我们可以减少单位时间内IO等的时间!所以只需要减少IO等的时间,那么我们就可以提高IO的效率!

五种IO模型

既然我们现在有让IO提高效率的方法了,那么我们具体应该怎么做呢?

实际上操作系统已经帮我们做好了,但是在说之前,我们先理解一下五种IO模型,我们将这几种理解了,那么也就可以理解”多路转接了“,多路转接就是提高IO效率的一种方法!

现在我们讲一个故事!

首先说我们想要讲什么故事,我们想要讲一个钓鱼的故事,我们呢讲钓鱼这个过程给简化,我们呢认为钓鱼只有两个过程: 1.等 2.钓 我们认为只有等、钓两个过程!

现在在湖边,有一名同志叫张三,张三来钓鱼了,张三钓鱼呢是比较有特点的,对于张三而言,张三钓鱼的时候他就一直盯着鱼漂,如果鱼漂不动,那么张三就不动,如果鱼漂动了,也就是有与上钩了,那么此时张三就钓鱼。

现在又来了一名同志,叫做李四,李四也来钓鱼,但是呢李四就不像张三一样,如果鱼漂不动,那么就一直盯着鱼漂,李四呢他钓鱼的时候他坐不住,他看一眼鱼漂,如果没有鱼上钩的话,那么他就去干自己的事情了,他刷一会抖音,在刷一会快手,在看一眼鱼漂,鱼漂还没上钩,他就又去干自己的事情了,聊一会天什么的。

旁边还有一名钓鱼的同志,他呢不想一直看着鱼漂,也不想有事没事还需要分心去看鱼漂,他就像做自己的事情,如果鱼上钩了提醒他钓鱼就可以了,这个人呢叫王五,王五呢就在鱼竿上挂了一个铃铛,如果铃铛响了,那么就代表鱼上钩了,此时王五才会去掉鱼,否则其他时间王五就一直做自己的事情,也就是说如果铃铛不响,那么王五就一直做自己的事情。

现在有一名同志开皮卡来到了湖边,叫赵六,赵六呢装了一车的鱼竿,他就把鱼竿全部摆满了,他呢就看哪一个鱼竿上面有鱼上钩,此时他就跑过去钓鱼了,所以此时赵六一次性等待100根鱼竿看有没有鱼上钩

最后来了一个老板,这个老板叫田七,田七呢他不想钓鱼,但是他想吃鱼,所以田七就叫他的司机,小李去给他钓鱼,然后小李就跑过去钓鱼了,等田七忙完了此时就把小李手里钓到的鱼取走了。

那么现在对于这几名老同志而言,哪一名老同志钓鱼的效率最高呢?

赵六最高!为什么?因为赵六一次性等待一百条鱼竿,所以赵六钓鱼的时候上杆的概率就越大,所以赵六的效率越高!

那么上面几种钓鱼方式有什么不同吗?而上面五种钓鱼方式就对应五种IO模型!

其中对于田七而言,田七就是异步的!也就是没有参与IO! 我们认为参与IO也就是参与IO里面的一个就可以,或者参与等,或者参与掉,或者两个都参与,但是田七没有参与,田七只是取走了鱼!

而其他的几种都是同步的,也就是参与了钓鱼。

张三的钓鱼方式就是阻塞式,阻塞呢就表示的是,如果没有鱼上钩,那么就一直等。

李四的钓鱼方式就是非阻塞轮询,因为他如果看到没有鱼上钩并不会阻塞也就是一直等,而是他就去做自己的事情,过一会再来检查一下,而这种就是非阻塞轮询。

王五呢,他并不关心鱼是否上钩,因为鱼上钩的话铃铛会提醒他,而他也知道铃铛响了之后,自己需要做什么事情,而这种就叫做信号驱动。

赵六呢,他一次性等待多根鱼竿,所以讲钓鱼等待的时间重叠了,而赵六这种方式就叫做多路转接。

田七就是异步!

同步IO与异步IO

上面我们说了五种IO模型,同时也简单的说了一下五种IO的区别,下面我们详细说一下五种IO模型的区别,以及同步IO和异步IO。

同步IO: 同步IO呢,在我们认为就是参与了IO的过程,也就是等+拷贝,但是也不一定说要全部参加,而是只需要参加等或者数据拷贝即可,那么我们就认为这就是同步IO。 在上面说的五种IO模型中,我们说只有田七是异步IO,为什么呢?因为田七并媒体又参加IO的过程,等也没有数据拷贝也没有,而田七只是等数据拷贝好后,田七取拿数据,所以田七是异步IO! 对于张三而言,张三不仅参与了等还参与了数据拷贝,因为张三在鱼没有上钩的时候,是一直盯着鱼漂的,也就是在一直等,当鱼上钩的时候,张三还自己进行了钓鱼,所以张三这种IO模型是同步IO。 李四这种IO模型也是同步的,因为李四实际上也是在等,虽然李四的等待并不是只是等着鱼上钩,而是李四当看到鱼没有上钩的时候,李四就去做其他的事情,但是当鱼上钩的时候,李四也是需要自己去钓鱼的。 王五这种IO模型我们可以理解为没有等待,因为王五是当鱼上钩后,有人通知的,而通知之后,王五才会去执行当鱼上钩后的事情,但是王五还是参与了钓鱼的过程,而王五的这种模型叫做信号驱动。 赵六一次性拿了100根鱼竿,而赵六他也是需要等待的并且还是同时等待100根鱼竿,而当鱼上钩后,赵六也是自己去钓鱼的。 IO模型的同步与非同步就说完了,但是我们现在有一个问题,王五他是信号驱动,但是我们在学习信号的时候,信号不是异步的吗?为什么王五却是同步IO呢?既然信号是异步的,那么王五不应该也是异步的吗? 首先我们要阐述的是,信号的异步是属于信号的,但是IO的异步表示的是没有参与IO的过程,我们叫做异步的,所以信号虽然是异步的,但是王五是自己参与了钓鱼的这个过程的,所以王五就是同步IO。

那么这五种IO模型的区别是什么呢? 其实这五种IO模型的区别也就是钓鱼的故事: 张三,如果没有鱼上钩的话,那么就是一直等着,也就是阻塞等待。 李四,如果没有鱼上钩的话,那么就去做自己的事情,然后过一会在检测,这个也就是非阻塞轮询。 王五,他不关心鱼上没有上钩,因为当鱼上钩的话,信号会通知他,这个就是信号驱动,通知的信号就是SIGIO。 赵六,他一次性等待100根鱼竿,这个也就是多路转接。 田七,也就是异步IO。

上面说的这些IO我们最常用的还是阻塞等待,并不是复杂的IO模型我们就使用的越多,但是多路转接我们还是使用的不少。

非阻塞接口

一般而言,我们平时默认创建出来的文件描述符,一般都是阻塞的,以及我能使用的 read 还有write,这一类接口也一般都是阻塞的,也就是说当我们读取的时候,当缓冲区里面没有数据,我们也就会被阻塞起来,所以我们先看一下阻塞读取!

阻塞的接口是比较简单的,而且我们还是一直在用的,所以阻塞的写法还是很简单的,我们下面就让 read 读取标准输入。

int main()
{
 ? ?char buffer[128];
 ? ?// 阻塞接口
 ? ?while (true)
 ?  {
 ? ? ? ?ssize_t s = read(0, buffer, 128);
 ? ? ? ?if(s > 0)
 ? ? ?  {
 ? ? ? ? ? ?buffer[s-1] = 0; 
 ? ? ? ? ? ?printf("读取成功 buffer: %s\n", buffer);
 ? ? ?  }
 ? ? ? ?else
 ? ? ?  {
 ? ? ? ? ? ?printf("读取失败\n");
 ? ? ?  }
 ?  }
 ? ?return 0;
}

对于张三而言,张三不仅参与了等

当我们这样写的时候,如果我们在标准输入不输入数据,那么就会阻塞在这里,如果我们输入数据后,就会读取到数据。

那么如果是不阻塞的写法呢? 我们怎么样可以不阻塞的调用呢?我们使用 read 接口,好像只能用阻塞的方式来写!其实在我们创建套接字,或者文件描述符的时候,是可以设置文件描述符的打开方式,我们之前只设置了 O_WRONLY 这样的,其实还有 O_NONBLOCK 这样的方式,也就是非阻塞的方式!

那么我们需要重新常见文件按描述符吗?其实不是的,我们还是有一个函数,可以在创建好后也可以设置非阻塞的。

NAME
 ? ? ? fcntl - manipulate file descriptor
?
SYNOPSIS
 ? ? ? #include <unistd.h>
 ? ? ? #include <fcntl.h>
?
 ? ? ? int fcntl(int fd, int cmd, ... /* arg */ );
  • 设置文件描述符的属性

  • 第一个参数:想要设置的文件描述符

  • 第二个参数:想要的方法

  • 第三个参数:可变参数列表

下面我们看一下如何使用:

void SetNonBlock(int fd)
{
 ? ?int f1 = fcntl(fd, F_GETFL);
 ? ?if(f1 < 0)
 ?  {
 ? ? ? ?perror("fcntl");
 ? ? ? ?exit(1);
 ?  }
 ? ?int r = fcntl(fd, F_SETFL, f1 | O_NONBLOCK);
 ? ?if(r < 0)
 ?  {
 ? ? ? ?perror("fcntl");
 ? ? ? ?exit(1);
 ?  }
}

这里就是对fd设置非阻塞,首先如果讲fcntl函数的第二个参数设置为 F_GETFL 的话,那么就是获取 fd 的mode,因为我们想要在 fd 原有的基础上,添加上 O_NONBLOCK 的属性,所以我们需要先获取fd之前的属性。 当获取到fd之前的属性之后,然后我们讲fcnl的第二个参数设置为F_SETFL也就是设置fd的属性,我们因为想要在fd原有的基础上添加上 O_NONBLOCK 的属性,所以我们使用 f1 | O_NONBLOCK,这样就可以在fd之前的属性上添加上O_NONBLOCK。

那么下面我们调用这个函数,然后我们还是read标准输入!

int main()
{
 ? ?char buffer[128];
 ? ?// 非阻塞
 ? ?SetNonBlock(0);
 ? ?while (true)
 ?  {
 ? ? ? ?ssize_t s = read(0, buffer, 128);
 ? ? ? ?if(s > 0)
 ? ? ?  {
 ? ? ? ? ? ?buffer[s-1] = 0; 
 ? ? ? ? ? ?printf("读取成功 buffer: %s\n", buffer);
 ? ? ?  }
 ? ? ? ?else
 ? ? ?  {
 ? ? ? ? ? ?printf("读取失败\n");
 ? ? ?  }
 ?  }
 ? ?return 0;
}

如果我们没有设置非阻塞的话,那么我们在读取的时候,如果我们不输入数据,那么就是阻塞到命令行窗口的,但是如果我们输入了,那么就可以读取到数据,那么当我们设置了非阻塞的属性,那么我们如果还是不输入数据,那么会怎么办呢? 结果:

这里就会一直打印读取失败,那么我们看一下输入后是什么效果呢?为了防止打印过快,看不清,所以我们在读取失败这里添加一跳 sleep 代码让读取失败后,休眠一秒!

此时的结果就是这样,我们看到如果读取成功,那么就可以正常显示出来,但是如果没有数据,那么就会以出错的形式返回,那么我们怎么样可以区分是真的读取出错了,还是因为非阻塞读取错误返回了呢?

我们下面打印一下这个错误码看一下:

int main()
{
 ? ?char buffer[128];
 ? ?// 阻塞接口
 ? ?// 非阻塞
 ? ?SetNonBlock(0);
 ? ?while (true)
 ?  {
 ? ? ? ?errno = 0;
 ? ? ? ?ssize_t s = read(0, buffer, 128);
 ? ? ? ?if(s > 0)
 ? ? ?  {
 ? ? ? ? ? ?buffer[s-1] = 0; 
 ? ? ? ? ? ?printf("读取成功 buffer: %s 错误码: %d 错误码描述: %s\n", buffer, errno, strerror(errno));
 ? ? ?  }
 ? ? ? ?else
 ? ? ?  {
 ? ? ? ? ? ?printf("读取失败 错误码: %d 错误码描述: %s\n", errno, strerror(errno));
 ? ? ? ? ? ?sleep(1);
 ? ? ?  }
 ?  }
 ? ?return 0;
}

这里我们看到如果是非阻塞读取的话,然后错误返回,那么这时候的返回码就是11。

多路转接

上面介绍简单的非阻塞读写我们以后是需要用到的,所以我们上面先简单的介绍一下,但是我们主要是多路转接的编写,我们下面介绍一下多路转接。

前面我们说IO的效率低下,而多路转接可以解决这一问题,因为多路转接就可以一次性等待多个文件描述符,如果等待的文件描述符有数据了,那么就返回。

多路转接有三种接口:

  1. select

  2. poll

  3. epoll

有上面三个接口,而最好的同时最简单的也就是epool,但是我们还是需要感受一下 select 但是我们不打算说 poll,我们先说 select。

select

我们先看一下select的接口,然后我们介绍一下select的参数:

NAME
 ? ? ? select, pselect, FD_CLR, FD_ISSET, FD_SET, FD_ZERO - synchronous I/O multiplexing
?
SYNOPSIS
 ? ? ? /* According to POSIX.1-2001 */
 ? ? ? #include <sys/select.h>
?
 ? ? ? /* According to earlier standards */
 ? ? ? #include <sys/time.h>
 ? ? ? #include <sys/types.h>
 ? ? ? #include <unistd.h>
?
 ? ? ? int select(int nfds, fd_set *readfds, fd_set *writefds,
 ? ? ? ? ? ? ? ? ?fd_set *exceptfds, struct timeval *timeout);

多路转接呢,他并不会帮我们拷贝数据,而多路转接只会帮我们等待,我们只需要完成数据拷贝的动作就可以了。

  • select 就是可以一次帮我们等待多个文件描述符

  • 第一个参数就是需要等待的文件描述符里面最大的文件描述符+1

  • 返回值:表示有几个文件描述符就绪,如果是0,表示没有文件描述符就绪

  • 最后一个参数:select等待的时间,如果是 nullptr 表示阻塞等待、如果是0,表示非阻塞,如果是非0,表示等待指定的时间,在指定的时间内阻塞,超出时间后,那么就返回,而且这个参数为输入输出型参数。

struct timeval 结构体

我们先看一下 struct timeval 这个结构体:

struct timeval 
{
    time_t ? ? ?tv_sec; ? ? /* seconds */
    suseconds_t tv_usec; ? ?/* microseconds */
};

这个结构就是select里面的最后一个参数:

  • 第一个参数:tv_sec 表示的是秒

  • 第二个参数:tv_usec 表示的是微秒

而假设我们现在需要等待5秒,但是在第2秒的时候,文件描述符里面就有数据了,那么此时最后一个参数就是3秒,这就是输入输出型参数,输入的时候,表示阻塞等待几秒,输出的时候,表示剩余几秒。

fd_set

还有剩下的三个参数,剩下的三个参数也是输入输出形参数,其中着三个参数表示的关心的事件。

这三个参数都是 fd_set 也就是文件描述符集,其中这个也是一个位图,其实这我们在信号的时候也是相同的,而信号那里是 sigset_t 类型的,同时这里对于位图的操作,也是需要使用定义好的函数,而不能我们自己对位图进行操作。

我们先看一下对于 fd_set 操作的函数:

NAME
 ? ? ? select, pselect, FD_CLR, FD_ISSET, FD_SET, FD_ZERO - synchronous I/O multiplexing
?
SYNOPSIS
 ? ? ? /* According to POSIX.1-2001 */
 ? ? ? #include <sys/select.h>
?
 ? ? ? /* According to earlier standards */
 ? ? ? #include <sys/time.h>
 ? ? ? #include <sys/types.h>
 ? ? ? #include <unistd.h>
?
 ? ? ? void FD_CLR(int fd, fd_set *set);
 ? ? ? int ?FD_ISSET(int fd, fd_set *set);
 ? ? ? void FD_SET(int fd, fd_set *set);
 ? ? ? void FD_ZERO(fd_set *set);
?

这些其实就是对于 fd_set 操作的函数,我相信不需要仔细介绍。

  • FD_CLR:将一个文件描述符从文件描述符集去除。

  • FD_ISSET:查看一个文件描述符是否在一个文件描述符集中。

  • FD_SET:将一个文件描述符设置到一个文件描述符集中。

  • FD_ZERO:将一个文件描述符集清空。

上面的三个 fd_set 类型的参数,就是表示对哪种事件的关心:

  • readfds:就是对该文件描述符的读关心

  • writefds:就是对文件描述符的写关心

  • exceptfds:就是对文件描述符的异常关心

那么既然 fd_set 是一个位图,那么他的大小是多少呢:

int main()
{
    cout << sizeof fd_set << endl;
    return 0;
}

上面输出了是 128 ,那么意思是只能关注 128 个文件描述符吗?并不是! sizeof 的单位是字节,而一个文件描述符,只需要比特就好了,所以应该还要乘8.

int main()
{
    cout << (sizeof fd_set)*8 << endl;
    return 0;
}

所以我们需要哪一个我们就将哪一个设置到对于的文件描述符集中。

select 编程

首先我们打算写一个select 的服务器,因为 select 可以一次等待多个文件描述符,所以我们可以将TCP的监听套接字设置到select中,然后当该文件描述符有了对于的数据,那么我们就可以 accept 接收到对应的数据,我们先这样写,等而我们在写的过程中,遇到问题我们在说对应的问题!

这次的编写中,我们还是回使用到之前使用的一些头文件,但是我们只要的还是看服务器编写的时候 select 的编写。

和前面一样的是,我们还是需要先做好准备工作,也就是创建套接字、绑定‘设置监听套接字:

class SelectServer
{
public:
 ? ?SelectServer(uint16_t port = 8080)
 ? ? ?  :_port(port)
 ?  {
 ? ? ? ?// 创建套接字
 ? ? ? ?_listensock = _sock.Socket();
 ? ? ? ?// bind
 ? ? ? ?_sock.Bind(_listensock, _port);
 ? ? ? ?// 设置监听套接字
 ? ? ? ?_sock.Listen(_listensock);
 ?  }
?
private:
 ? ?Sock _sock;
 ? ?uint16_t _port;
 ? ?int _listensock;
 ? ?static Log log;
};
?
Log SelectServer::log;

上面就是做好了准备工作,在之前我们就之间开始写 Run 方法了,Run方法里面会死循环的调用 accept 函数,接收想要连接的客户端,但是如果是之前的写法,那么我们这个执行流只能一直在接收 accept 因为我们之前的写法是阻塞等待的写法,那么如果我们现在的写法就是可以将很多的文件描述符放进去,同时等待多个文件描述符就绪,一旦有文件描述符就绪,那么就可以去做对应的任务,所以现在即使是一个执行流,并且也是阻塞等待的方式,那么我们也可以很高效的进行任务处理了。

所以我们就需要将 listen 套接字加入到一个文件描述符集中,然后使用 select 帮我们等待:

 ? ?
void Run()
 ?  {
 ? ? ? ?// 常见 fd_set 类型
 ? ? ? ?fd_set reader;
 ? ? ? ?// 清空 fd_set
 ? ? ? ?FD_ZERO(&reader);
 ? ? ? ?// 将监听套接字添加到 reader 中
 ? ? ? ?FD_SET(_listensock, &reader);
 ? ? ? ?// 设置 select 阻塞等待5秒
 ? ? ? ?struct timeval t = {5, 0};
 ? ? ? ?while(true)
 ? ? ?  {
 ? ? ? ? ? ?// _sock.Accept(); 之前的做法
 ? ? ? ? ? ?// 如果最后一个参数是 nullptr 那么就是阻塞等待
 ? ? ? ? ? ?// int r = select(_listensock + 1, &reader, nullptr, nullptr, nullptr);
 ? ? ? ? ? ?// 如果最后一个参数是非0,那么就是在事件内阻塞,超出后返回,并且设置 t
 ? ? ? ? ? ?int r = select(_listensock + 1, &reader, nullptr, nullptr, &t);
 ? ? ? ? ? ?if(r == 0)
 ? ? ? ? ?  {
 ? ? ? ? ? ? ? ?log(DEBUG, "等待失败");
 ? ? ? ? ?  }
 ? ? ?  }
 ?  }

我们目前先写成这样,我们现在启动服务器,我们看一下是什么效果:

#include "selectServer.hpp"
?
?
int main()
{
 ? ?SelectServer sev = SelectServer();
 ? ?sev.Run();
 ? ?return 0;
}

这个函数里面我们只需要调用 Run 方法即可,因为我们将端口号以及写入到 SelectServer的构造函数的缺省参数了,所以即使我们不给也可以!

编辑好运行看一下结果:

这里效果不是很明显,这里我阐述一下,就是在5秒中之内是阻塞的,但是5秒后就疯狂打印,也就是说只等待了一次5秒,为什么呢? 因为别忘了struct timeval 这个参数是输入输出的参数,当我们输入5秒后,那么就等待5秒,但是当5秒后还没有等到,那么输出的时候,表示剩余的时间,因为剩余的时间为0,所以 struct timeval 就是0,既然是0,那么我们又没有重新设置这个参数,所以表示下一次等待的时间是0,也就是非阻塞等待,既然是这样,那么就是每次都读取到是没有就绪,那么 select 的返回值就是0,也就表示的是没有文件描述符就绪!

那么我们如果每次都想要等5秒的话,那么就是我们需要每一次都设置!

 ? 
?void Run()
 ?  {
 ? ? ? ?// 常见 fd_set 类型
 ? ? ? ?fd_set reader;
 ? ? ? ?// 清空 fd_set
 ? ? ? ?FD_ZERO(&reader);
 ? ? ? ?// 将监听套接字添加到 reader 中
 ? ? ? ?FD_SET(_listensock, &reader);
 ? ? ? ?// 设置 select 阻塞等待5秒
 ? ? ? ?while (true)
 ? ? ?  {
 ? ? ? ? ? ?struct timeval t = {5, 0};
 ? ? ? ? ? ?// _sock.Accept();
 ? ? ? ? ? ?// int r = select(_listensock + 1, &reader, nullptr, nullptr, nullptr);
 ? ? ? ? ? ?int r = select(_listensock + 1, &reader, nullptr, nullptr, &t);
 ? ? ? ? ? ?if (r == 0)
 ? ? ? ? ?  {
 ? ? ? ? ? ? ? ?log(DEBUG, "等待失败");
 ? ? ? ? ?  }
 ? ? ?  }
 ?  }

这样就是每一次都会等待5秒,我们看一下结果:

这样就是每一次都会等待指定的时间!

如果是 nullptr 的话,那么就是不会返回0 的。

我们以后基本都会使用 nullptr ,也就是阻塞方式等待!

? ?void Run()
 ?  {
 ? ? ? ?// 常见 fd_set 类型
 ? ? ? ?fd_set reader;
 ? ? ? ?// 清空 fd_set
 ? ? ? ?FD_ZERO(&reader);
 ? ? ? ?// 将监听套接字添加到 reader 中
 ? ? ? ?FD_SET(_listensock, &reader);
 ? ? ? ?// 设置 select 阻塞等待5秒
 ? ? ? ?while (true)
 ? ? ?  {
 ? ? ? ? ? ?int r = select(_listensock + 1, &reader, nullptr, nullptr, nullptr);
 ? ? ? ? ? ?if(r > 0)
 ? ? ? ? ?  {
 ? ? ? ? ? ? ? ?log(DEBUG, "有事件到达");
 ? ? ? ? ?  }
 ? ? ?  }
 ?  }

下面我们启动服务器,然后使用 telnet 连接一下看一下结果:

这里当 telnet 连接上,就开始疯狂打印有事件到达,那么为什么会一直打印有事件达到呢?也就是一等待,那么就是有事件到达的? 因为有一个连接到达了,但是我们并没有去取出来,所以一直给我们打印有事件到达,只要我们把事件取了就好了。

这里有事件到达,那么就需要处理对应的事件,要不然我们只要一旦 select 那么就会提示有事件到达,所以我们将对应的事件处理了之后,那么就可以了。

但是其实我们是知道的,这个事件是一个连接的事件,下面的测试代码我们就不写了,这里理一下思路就好了,如果现在一个连接到达了,那么我们可以之间 accept 吗?可以的!因为select已经告诉我们说有连接到达了,那么我们就可以直接 accept 此时我们就可以获取到一个新的连接。

那么获取到新的连接后,我们可以对这个连接进行读取吗?那么如果我们一旦读取,如果对方不发数据呢?可不要忘记了,我们现在是一个单进程,如果我们直接读取新的套接字,此时对方不发数据,那么我们就会阻塞,这样的话,即使后面再有连接,那么我们也无法进行处理了,因为我们已经阻塞了,所以当获取了一个新的连接的时候,我们刚开始是不可以直接进行读取的。

所以我们应该怎么做呢?不要忘了,select是帮我们等待事件就绪的,所以我们只需要将这个连接添加到select中,这样 seletc也会帮我们关注这个连接,当连接再一次有数据到达的时候,我们就可以读取,那么现在有一个普通的事件到达,也就是数据并不是连接到达,那么我们可以直接读取对应的套接字吗?可以的! 因为有数据到达,我们直接读取那么就不会阻塞了!

那么当新的连接到达的时候,我们怎么让select关注呢?别忘了select需要 fd_set 里面来放入需要关注的文件描述符,而且当select返回后,fd_set 就是就绪的文件描述符,也就是fd_set 会被修改,那么我们下一次去哪里找我们需要关注的文件描述符呢?所以我们是需要一个第三方数组将需要关注的文件描述符保存起来的,而当有了新的文件描述符,我们也是需要放到这个数组中的,当每一次 select 之前我们是需要重新设置 fd_set 的。

其实到了这里,那么我们后面的写法就可以改一下了,我们前面这样写只是为了做示范,下面我们就开始正常写了,我们也会对前面的代码也做修改!

首先我们看一下调整后的成员遍历:

class SelectServer
{
public:
privaet:
 ? ?Sock _sock;
 ? ?uint16_t _port;
 ? ?int _listensock;
 ? ?// 第三方数据,维护已有的文件描述符
 ? ?int _readerNum[NUM];
    // select 参数
 ? ?fd_set _reader;
 ? ?static Log log;
}

当我们构造好之后,我们就需要将listen套接字添加到第三方数组中:

   SelectServer(uint16_t port = 8080)
 ? ? ?  : _port(port)
 ?  {
 ? ? ? ?// 创建套接字
 ? ? ? ?_listensock = _sock.Socket();
 ? ? ? ?// bind
 ? ? ? ?_sock.Bind(_listensock, _port);
 ? ? ? ?// 设置监听套接字
 ? ? ? ?_sock.Listen(_listensock);
 ? ? ? ?// 初始化
 ? ? ? ?for (int i = 0; i < NUM; ++i)
 ? ? ?  {
 ? ? ? ? ? ?_readerNum[i] = FD_NONE;
 ? ? ?  }
 ? ? ? ?// 这里约定,第一位位置存放的是监听套接字
 ? ? ? ?FD_ZERO(&_reader);
 ? ? ? ?_readerNum[0] = _listensock;
 ?  }

还有就是到了 Run 函数里面,我们就不可以直接 accept 而是需要 select 了:

 ? ?void Run()
 ?  {
 ? ? ? ?while (true)
 ? ? ?  {
 ? ? ? ? ? ?int fdMax = _listensock;
 ? ? ? ? ? ?// 第一步将fd设置到我呢见描述符集中,并找到最大的
 ? ? ? ? ? ?check();
 ? ? ? ? ? ?FD_ZERO(&_reader);
 ? ? ? ? ? ?for (int i = 0; i < NUM; ++i)
 ? ? ? ? ?  {
 ? ? ? ? ? ? ? ?if (_readerNum[i] != FD_NONE)
 ? ? ? ? ? ? ?  {
 ? ? ? ? ? ? ? ? ? ?FD_SET(_readerNum[i], &_reader);
 ? ? ? ? ? ? ? ? ? ?if (fdMax < _readerNum[i])
 ? ? ? ? ? ? ? ? ? ? ? ?fdMax = _readerNum[i];
 ? ? ? ? ? ? ?  }
 ? ? ? ? ?  }
 ? ? ? ? ? ?// 第二步,调用 select 函数
 ? ? ? ? ? ?int r = select(fdMax + 1, &_reader, nullptr, nullptr, nullptr);
 ? ? ? ? ? ?if (r < 0)
 ? ? ? ? ?  {
 ? ? ? ? ? ? ? ?// 调用失败
 ? ? ? ? ? ? ? ?log(ERROR, "select 失败!");
 ? ? ? ? ? ? ? ?sleep(1);
 ? ? ? ? ?  }
 ? ? ? ? ? ?else if (r == 0)
 ? ? ? ? ?  {
 ? ? ? ? ? ? ? ?// 在非阻塞下不会超时
 ? ? ? ? ? ? ? ?log(ERROR, "超时!");
 ? ? ? ? ?  }
 ? ? ? ? ? ?else
 ? ? ? ? ?  {
 ? ? ? ? ? ? ? ?log(DEBUG, "有事件到达!");
 ? ? ? ? ? ? ? ?handlerEvent(_reader);
 ? ? ? ? ?  }
 ? ? ?  }
 ?  }
  • 因为 select 的三个 fd_set 类型的参数是输入输出型的参数,所以我们是需要每次都向 fd_set 里面设置的。

  • 因为还有第一个参数是最大的文件描述符加1,所以我们同时还需要找到最大值。

  • 等将 fd_set 类型的对象设置好后,调用 select 函数。

  • select函数我们也可以选则是阻塞还是非阻塞方式,一般采用阻塞。

  • 等 select 返回后,那么看是否有事件到达,有事件到达的话,我们就处理事件!

那么下面我们看一下处理事件:

 ? ?void handlerEvent(const fd_set &reader)
 ?  {
 ? ? ? ?// 遍历 _reader
 ? ? ? ?for(int i = 0; i < NUM; ++i)
 ? ? ?  {
 ? ? ? ? ? ?if(_readerNum[i] != FD_NONE)
 ? ? ? ? ?  {
 ? ? ? ? ? ? ? ?if(FD_ISSET(_readerNum[i], &reader))
 ? ? ? ? ? ? ?  {
 ? ? ? ? ? ? ? ? ? ?// 到了这里,说明有事件就绪了
 ? ? ? ? ? ? ? ? ? ?// 但是事件并非只有一种,而是可能是连接,也可能是普通
 ? ? ? ? ? ? ? ? ? ?if(_readerNum[i] == _listensock) Accepter();
 ? ? ? ? ? ? ? ? ? ?else Reader(i);
 ? ? ? ? ? ? ?  }
 ? ? ? ? ?  }
 ? ? ?  }
 ?  }
  • 这就是处理事件,我们首先需要判断哪些事件好了,也就是看我们关心的文件描述符是否早 fd_set 类型的对象中。

  • 再的话,我们就看是不是 _listensock 套接字,如果是的话,那么就直接 accept,如果是普通事件的话,那么我们就进行读取,因为这里我们只关心了读取!

  • 那么当事件到达了的话,我们可以直接进行读取吗?可以的,因为这时候 select 已经帮我们等待好了。

下面先看一下 Accepter 方法:

 ? ?void Accepter()
 ?  {
 ? ? ? ?// 建立连接
 ? ? ? ?struct sockaddr_in peer;
 ? ? ? ?socklen_t len;
 ? ? ? ?int sockfd = _sock.Accept(_listensock, (struct sockaddr*)&peer, &len);
 ? ? ? ?// 获取到通信文件描述符后,添加到 readderNum 中
 ? ? ? ?int pos = 0;
 ? ? ? ?for(pos = 0; pos < NUM; ++pos)
 ? ? ?  {
 ? ? ? ? ? ?if(_readerNum[pos] == FD_NONE)
 ? ? ? ? ?  {
 ? ? ? ? ? ? ? ?_readerNum[pos] = sockfd;
 ? ? ? ? ? ? ? ?break;
 ? ? ? ? ?  }
 ? ? ?  }
 ? ? ? ?if(pos == NUM)
 ? ? ?  {
 ? ? ? ? ? ?log(ERROR, "文件描述符集已满!");
 ? ? ? ? ? ?// 关闭创建的文件描述符
 ? ? ? ? ? ?close(sockfd);
 ? ? ?  }
 ?  }
  • 到了这里,我们就直接建立连接,获取一个新的连接!

  • 获取到后,我们可以直接进行读取这个连接吗?不可以!因为直接读取就会阻塞住!

  • 所以我们需要将这个连接添加到第三方数组中,到了下一次 select 的时候,会自动将这个文件描述符设置被 select 关心!

  • 不过可不是任何时候都可以设置进去,因为 fd_set 是一个位图,所以是有大小限制的,所以我们最多只能关心 fd_set 个数的文件描述符。

下面我们再看一下 Reader 方法:

    void Reader(int pos)
 ?  {
 ? ? ? ?char buffer[1024] = {0};
 ? ? ? ?ssize_t s = recv(_readerNum[pos], buffer, 1024 - 1, 0);
 ? ? ? ?if(s < 0)
 ? ? ?  {
 ? ? ? ? ? ?// 读取出错,因为这里没有非阻塞读取,如果是非阻塞读取,那么就会以出错的形式返回
 ? ? ? ? ? ?log(ERROR, "读取错误!");
 ? ? ?  }
 ? ? ? ?else if(s == 0)
 ? ? ?  {
 ? ? ? ? ? ?log(DEBUG, "%d 号连接关闭,我也关闭!", _readerNum[pos]);
 ? ? ? ? ? ?// 将创建的套接字关闭
 ? ? ? ? ? ?close(_readerNum[pos]);
 ? ? ? ? ? ?// 将需要关心的这个套接字置为空
 ? ? ? ? ? ?_readerNum[pos] = FD_NONE;
 ? ? ?  }
 ? ? ? ?else
 ? ? ?  {
 ? ? ? ? ? ?buffer[s] = 0;
 ? ? ? ? ? ?std::cout << _readerNum[pos] << " 号连接有消息到达: " << buffer << std::endl;
 ? ? ?  }
 ?  }
  • 到了 Reader 方法里面,我们也是可以直接进行读取的。

  • 所以我们就之际进行读取,如果 recv 的返回值大于0,表示读取到数据了。

  • 如果是小于0,表示 recv 调用失败了。

  • 如果是等于0,那么就表示对方关闭连接了,那么我们怎么办呢?

  • 如果对方关闭连接,那么我们是不是就不需要关心这个文件描述符了?是的!那么欧美就需要关闭这个文件描述符。

  • 关闭文件描述符后,那么欧美也当然需要从第三方数据中,将这个套接字从第三方数组中去掉!

但是上面的编写并没有完,因为这里买呢还是有很多漏洞的,我们并没有处理数据粘包等问题,所以这还是一个问题代码!但是我们主要是介绍 select 所以我们并不需要很详细的写出来完整的处理过程!

select 的优缺点

优点

  • 效率高:因为之前的代码都是需要创建多将进行,或者线程的,所以效率比起单进程要低的多,因为这里主要是IO。

  • 应用场景:再有大量连接,但是活跃的连接只有少量的时候!

缺点

  • 需要大量的遍历,需要消耗大量的时间,而操作系统帮我们关心的时候,也是需要大量遍历的。

  • 需要另外维护第三方数组。

  • 每次都需要重新设置关心的文件描述符, 所以充需要一直从用户到内核的拷贝,还有内核到用户的拷贝。

  • 关注的文件描述符大小有限。

  • 编码复杂!

poll

首先我们先看一下 poll 的接口:

NAME
 ? ? ? poll, ppoll - wait for some event on a file descriptor
?
SYNOPSIS
 ? ? ? #include <poll.h>
?
 ? ? ? int poll(struct pollfd *fds, nfds_t nfds, int timeout);
  • poll其实和 select基本差不多,但是 poll没有上限!

  • 返回值大于0 表示有返回值的个数个文件描述符已经有数据了,返回值等于0表示超时,返回值小于0表示poll失败了。

  • 第一个参数就是我们需要关心的文件描述符和事件。

  • 第二个参数表示第一个参数有多少个。

  • 第三个参数和select一样,也是表示的是时间不过这里表示的是微秒!

struct pollfd

在 select 里面我们是 fd_set 来告诉内核,让内核帮我们关心对应的文件描述符,但是每一次都需要设置,是很麻烦的,但是在 poll 里面第一个参数也是用来管理文件描述符的,这个是一个指针,是没有上限的。

下面我们看一下 struct pollfd 这个结构体:

 ? ? ? ? ? struct pollfd {
 ? ? ? ? ? ? ? int ? fd; ? ? ? ? /* file descriptor */
 ? ? ? ? ? ? ? short events; ? ? /* requested events */
 ? ? ? ? ? ? ? short revents; ? ?/* returned events */
 ? ? ? ? ? };
  • fd:就是我们需要关心的文件描述符

  • events:就是我们需要关心的事件

  • revent:就是当poll返回的时候,会将对应fd好了的事件放到revents里面

那么这事件是一个 short 类型的,那么怎么样将对应的事件设置进去呢?不知道还记不记得 open 函数!这里和open函数一样!

下面就是对应的事件:

 ? ? ? ? ? ? ?POLLIN There is data to read.
?
 ? ? ? ? ? ? ?POLLPRI
 ? ? ? ? ? ? ? ? ? ? There is urgent data to read (e.g., out-of-band data on TCP socket; pseudoterminal master in
 ? ? ? ? ? ? ? ? ? ? packet mode has seen state change in slave).
?
 ? ? ? ? ? ? ?POLLOUT
 ? ? ? ? ? ? ? ? ? ? Writing now will not block.
?
 ? ? ? ? ? ? ?POLLRDHUP (since Linux 2.6.17)
 ? ? ? ? ? ? ? ? ? ? Stream socket peer closed ?connection, ?or ?shut ?down ?writing ?half ?of ?connection. ? The
 ? ? ? ? ? ? ? ? ? ? _GNU_SOURCE ?feature test macro must be defined (before including any header files) in order
 ? ? ? ? ? ? ? ? ? ? to obtain this definition.
?
 ? ? ? ? ? ? ?POLLERR
 ? ? ? ? ? ? ? ? ? ? Error condition (output only).
?
 ? ? ? ? ? ? ?POLLHUP
 ? ? ? ? ? ? ? ? ? ? Hang up (output only).
?
 ? ? ? ? ? ? ?POLLNVAL
 ? ? ? ? ? ? ? ? ? ? Invalid request: fd not open (output only).
  • POLLIN:关心读事件

  • POLLOUT:写事件

  • POLLERR:错误事件

一般我们常用的就是这三个。

poll的代码我们就不说了,因为和select是一样的。

poll最后一个参数,最后一个参数就是一个时间,这个时间的单位是微秒!

  • time > 0 表示在time内阻塞,超出时间返回

  • time = 0 表示非阻塞

  • time < 0 表示阻塞

文章来源:https://blog.csdn.net/m0_73455775/article/details/135354072
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。