总言
主要内容:介绍五种IO模型的基本概念、学习IO多路转接(select、poll编程模型)。
文章目录
- 总言
- 1、问题引入
- 1.1、网络通信与IO
- 1.2、五种IO模型
- 1.2.1、举例引入
- 1.2.2、IO模型具体含义介绍
- 1.2.2.1、阻塞式IO
- 1.2.2.2、非阻塞轮询检式IO
- 1.2.2.3、信号驱动IO
- 1.2.2.4、多路复用/多路转接IO
- 1.2.2.5、异步IO
- 1.2.3、其它说明
- 2、阻塞IO和非阻塞IO:fcntl
- 2.1、演示阻塞IO(读取标准输入)
- 2.2、演示非阻塞IO(轮询方式读取标准输入)
- 2.2.1、fcntl 函数介绍
- 2.2.2、相关演示1.0
- 2.2.3、相关演示2.0
- 3、多路转接:select
- 3.1、select 函数介绍
- 3.1.1、函数原型和返回值
- 3.1.2、参数介绍
- 3.1.2.1、int nfds
- 3.2.2.2、struct timeval *timeout
- 2.2.2.3、其它三个输入输出参数
- 3.1.3、一个使用举例
- 3.2、快速编写(读事件)
- 3.2.1、验证fd_set的大小
- 3.2.2、准备工作
- 3.2.2.1、log.hpp
- 3.2.2.2、sock.hpp
- 3.2.3、version1:先简单理解对select的使用
- 3.2.3.1、version1.0:演示如何使用select来代替accept直接读取
- 3.2.3.2、关于timeout结构体的不同设置演示
- 3.2.4、version2.0:修复与完善
- 3.2.4.1、问题说明
- 3.2.4.2、version2.0:引入第三方数组保存所有合法的文件描述符
- 3.3、select优缺点
- 4、多路转接:poll
- 4.1、poll函数介绍
- 4.1.1、函数原型与返回值
- 4.1.2、参数说明
- 4.1.2.1、fds
- 4.1.2.2、nfds和timeout
- 4.2、快速编写
- 4.2.1、使用poll监控标准输入
- 4.2.2、使用poll监控读事件(与select类同)
- 4.3、poll优缺点
- Fin、共勉。
1、问题引入
1.1、网络通信与IO
1)、网络通信的本质
网络通信,本质上是一个输入与输出(Input/Output, 简称IO)的过程。这一过程通过诸如read、recv、write、send等IO类接口调用实现,以在网络中读写数据。从冯·诺依曼体系结构的视角出发,与CPU的高速处理能力相比,访问外部设备(如网络接口卡)往往需要耗费显著更多的时间。这种时间差异凸显了网络通信中IO操作的相对重要性,若能优化IO性能,那么网络通信效率的就会得到提升。
2)、IO效率相关问题
问题:IO为什么低效?
回答:IO之所以显得低效,原因在于其核心操作涉及与操作系统(OS)缓冲区之间的数据拷贝。具体来说,当我们调用如read、recv、write、send等IO类接口时,它们本质上是在与操作系统的内部缓冲区进行数据交互。在数据读取(read/recv)过程中,如果底层缓冲区尚未准备好所需数据,这些接口将会阻塞,直至数据就绪;一旦数据就绪,它们便会执行数据拷贝操作,将数据从缓冲区转移到应用程序的缓冲区中。
由此可知,IO操作实则就是等待和数据拷贝(IO=等+数据拷贝)。由于硬件和软件的异步性,以及数据传输的延迟,单位时间内,IO类的接口大部分时间其实都处在等待状态,真正用于数据拷贝的时间相对较少。因此,当与计算密集型任务(如CPU运算)相比较时,IO操作显得相对低效。
问题:如何提高IO效率?
回答:要提高IO效率,关键在于 减少单位时间内IO操作中“等待”的时间占比,从而增加实际数据拷贝的时间占比。实际上,为了提升效率,前人已经设计了五种IO模型,这些模型通过不同的机制来优化IO操作。
1.2、五种IO模型
1.2.1、举例引入
1)、我们以钓鱼为例,介绍这五种IO模型
张三、李四、王五、赵六、田七几人相约钓鱼,假设钓鱼就分为两个基本操作:等、钓。他们各自采用不用的钓鱼方式,如下:
张三:
①、张三坐在河边,全神贯注地盯着鱼竿,等待鱼儿上钩。在这个过程中,他什么也不做,只是等待。【等】
②、一旦鱼儿上钩,张三立即拉起鱼竿,将鱼钓上来。【钓】
李四:
①、李四也在河边钓鱼,但他不想干等着不动。在等待的过程中,他会去做其他事情,比如看书、玩手机等。【等】
②、每隔一段时间,李四会回来查看鱼竿是否有鱼上钩。如果有,他就将鱼钓上来;如果没有,他继续去做其他事情。【钓】
王五:
①、王五在鱼竿上挂了一个铃铛,然后去做其他事情。【等】
②、当鱼儿上钩时,铃铛会响,王五听到铃声后回来将鱼钓上来。【钓】
赵六:
①、赵六拿了很多鱼竿,同时监控多个鱼竿是否有鱼上钩。【等】
②、当某个鱼竿有鱼上钩时,赵六会立即处理那个鱼竿,将鱼钓上来。【钓】
田七:
①、田七雇了一个助手来帮他们钓鱼。他们告诉助手:“当有鱼上钩时,你直接帮我把鱼钓上来。”然后他们去做其他事情。【等】
②、助手会一直监控鱼竿,当有鱼上钩时,助手会直接将鱼钓上来,然后通知田七。【钓】
实际上,上述描述的五种钓鱼方式,即IO的五种模型:(关于这几种模型具体含义,在后文介绍)
张三:阻塞式IO
李四:非阻塞轮询检式IO
王五:信号驱动IO
赵六:多路复用/多路转接IO
田七:异步IO
2)、细节理解(感性层面先建立一个认识)
这几种IO模型中,谁的效率高?为什么?
回答:赵六的效率最高(多路复用/多路转接IO)。
在IO操作的上下文中,多路复用/多路转接IO允许单个线程或进程同时监视多个文件描述符(如网络连接、套接字等)的状态变化。这种模型使得应用程序能够同时处理多个IO操作,而无需为每个操作都创建一个单独的线程或进程。
从钓鱼的例子来看,赵六一次使用多个鱼竿(相当于多个文件描述符),并且同时监控它们是否有鱼咬钩。如果每个鱼竿被鱼咬到的概率是恒定的,那么赵六同时监控多个鱼竿将大大增加他在单位时间内钓到鱼的概率。这是因为他的 “等”的时间(即没有鱼咬钩的时间)被多个鱼竿分摊了,从而降低了平均等待时间。
如何理解同步IO与异步IO?
根据前文描述,IO = 等 + 拷贝。
同步IO意味着在进行IO操作时,进程或线程需要亲自“参与”IO的整个过程。这里的“参与”具体指的是进程或线程要么 “等”、要么 “拷贝” 、要么 “等和拷贝” 同时参与。
异步IO则是指进程或线程在发起IO请求后,自己本身不参与IO的等待与拷贝过程。
显然,上述五种模型中,除了田七的异步IO,其余四种都属于同步IO。
为什么信号驱动也算同步IO? 这是因为虽然信号驱动IO在IO操作完成时通过信号来通知进程或线程,避免了轮询的开销和等待的过程,但进程或线程在接收到信号后仍然需要亲自执行数据的拷贝操作。
阻塞IO和非阻塞IO的异同?
阻塞IO(Blocking IO)和非阻塞IO(Non-blocking IO)都属于同步IO的范畴,这意味着在IO操作完成之前,程序(或线程)都需要等待,并且都需要自己执行数据的拷贝。只是它们选择等待的方式不同而已,前者是在阻塞的等待(在等待IO操作完成期间处于阻塞状态,当前线程/进程会被挂起,无法执行其他任务),后者是在于非阻塞的等待(允许进程或线程在等待IO操作完成期间继续执行其他任务,只需通过轮询或事件驱动的方式来检查IO操作是否完成即可)。
概念区别:IO的同步和进程/线程同步
说明:这里IO同步不要与多线程中的同步混淆。它们是完全不同的两个概念。IO的同步关注的是用户进程与I/O设备之间的数据交换过程,以及在这个过程中用户进程的等待状态;而进程/线程同步关注的是多个进程或线程之间的执行顺序和共享资源的访问控制。
在看到 “同步” 这个词,一定要先搞清楚大背景是什么。这个“同步”,是同步通信异步通信的“同步”?还是同步与互斥的“同步”? 计算机中有很多这种名称相似但含义不同的词汇,它们的应用场景是不同的。
1.2.2、IO模型具体含义介绍
1.2.2.1、阻塞式IO
阻塞式IO(Blocking I/O): 在阻塞式IO中,在内核将数据准备好之前,系统调用会一直等待,直到条件满足才进行下一步操作。 所有的套接字,默认都是阻塞方式。
1.2.2.2、非阻塞轮询检式IO
非阻塞轮询检式IO(Non-blocking I/O): 非阻塞IO中,应用进程与内核交互,如果内核还未将数据准备好,不会一直等待,系统调用会直接返回,并且返回EWOULDBLOCK
错误码。
非阻塞IO往往需要应用进程通过轮询的方式,反复尝试读写文件描述符,询问内核数据是否准备好。 这对CPU来说是较大的浪费, 一般只有特定场景下才使用。
1.2.2.3、信号驱动IO
信号驱动IO(Signal-driven I/O): 在信号驱动IO中,应用进程告诉内核:当数据准备好时,请发送一个信号给我。应用进程收到信号后,会调用相应的处理函数来处理数据。
1.2.2.4、多路复用/多路转接IO
多路复用/多路转接IO(I/O Multiplexing/Demultiplexing): 多路复用IO中,使用了select或poll函数来同时监控多个文件描述符(如多个socket连接)。当某个文件描述符就绪时,select或poll函数会返回,然后应用进程会针对那个就绪的文件描述符进行IO操作。
虽然从流程图上看起来先前的模型类似,但实际上最核心在于IO多路转接能够同时等待多个文件描述符的就绪状态。(select此类函数负责IO中“等”的操作,且一次可以等多个文件描述符,这样recvfrom此类函数每次调用时都省去了“等”的步骤,直接拷贝到数据。相当于等和拷贝分离。)
1.2.2.5、异步IO
异步IO(Asynchronous I/O): 在异步IO中,应用进程发出一个异步操作请求后,不会等待操作完成,而是继续执行其他任务。当操作完成时,内核会通过状态、通知或回调来告诉应用进程操作已完成。
需要注意,异步是内核在数据拷贝完成时, 通知应用程序。而信号驱动是告诉应用程序何时可以开始拷贝数据(即一个是完成后通知结果,一个是还需要亲自操作)。
1.2.3、其它说明
1、任何IO过程中, 都包含两个步骤: 第一是等待,第二是拷贝。而且在实际的应用场景中,等待消耗的时间往往都远远高于拷贝的时间。因此,要让IO更高效,最核心的办法就是让等待的时间尽量少。
2、非阻塞IO、纪录锁、系统V流机制、I/O多路转接(也叫I/O多路复用)、readv和writev函数以及存储映射IO(mmap),这些统称为高级IO。此处重点讨论的是I/O多路转接。
2、阻塞IO和非阻塞IO:fcntl
2.1、演示阻塞IO(读取标准输入)
1)、一个前提说明
说明:在大多数操作系统和编程环境中,如果不进行特别设置或采取特殊措施,如read、recv() 、 recvfrom()等此类IO类函数,在读取标准输入(stdin)或其他文件描述符时,默认是阻塞式读取的(blocking)。
read():从文件或设备读取数据。如果数据未准备好或没有数据可读,则调用者会被阻塞,直到有数据可读。
recv() 和 recvfrom():用于套接字编程,从套接字接收数据。默认情况下,它们是阻塞的,直到有数据可接收。
write():向文件或设备写入数据。这个函数通常不会阻塞,除非底层物理介质上的空间不足或遇到其他限制。但是,当写入的数据量大于可用空间时,它可能会阻塞直到有足够的空间。
send() 和 sendto():用于套接字编程,向套接字发送数据。这些函数在发送少量数据时通常不会阻塞,但如果发送大量数据或网络条件不佳,它们可能会阻塞。
2)、基于此,我们演示一个简单的阻塞IO:即读取标准输入
相关代码如下:
#include<iostream>
#include<unistd.h>
int main()
{
char buffer[1024];// 暂存缓冲区
while(true)
{
//ssize_t read(int fd, void *buf, size_t count);
ssize_t s = read(0, buffer, sizeof(buffer) -1);
if(s > 0) // 读取成功
{
buffer[s] = '\0';
std::cout << "echo# " << buffer << std::endl;// 将读取到的数据显示
}
else {
std::cout << "read \"error\"." << std::endl;
}
}
return 0;
}
演示结果如下:
2.2、演示非阻塞IO(轮询方式读取标准输入)
2.2.1、fcntl 函数介绍
要使read等IO类接口以非阻塞模式工作,通常是通过使用fcntl()
函数设置文件描述符为O_NONBLOCK
来完成的。
基本介绍: fcntl()
函数是Unix/Linux系统中的一个系统调用,主要用于对已经打开的文件描述符进行各种控制操作。这个函数通常用于实现对文件描述符的非阻塞设置、文件锁、获取或修改文件描述符的属性等操作。在网络编程中,它常常用于设置套接字为非阻塞模式。
man 2 fcntl
可查询相关文档。有关博文扩展学习:fcntl函数详解。
NAME
fcntl - manipulate file descriptor
SYNOPSIS
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */ );
参数说明:
fd
:需要操作的文件描述符。
cmd
:执行的操作命令,可以是 F_GETFL
(获取文件状态标志)、F_SETFL
(设置文件状态标志)等。。
arg
:该参数是可选。是否需要此参数由cmd
决定。根据cmd
的不同,可能需要一个整数或者一个指向flock
结构体的指针作为参数。
对参数cmd,传入的值不同,后面追加的参数也不相同:
复制一个现有的描述符: cmd = F_DUPFD
获得/设置文件描述符标记:cmd = F_GETFD 或 F_SETFD
获得/设置文件状态标记:cmd = F_GETFL 或 F_SETFL
获得/设置异步I/O所有权:cmd=F_GETOWN 或 F_SETOWN
获得/设置记录锁:cmd=F_GETLK,F_SETLK 或 F_SETLKW
这里,我们只使用“获取/设置文件状态标记”的功能(F_GETFL
、F_SETFD
),由此就可将一个文件描述符设置为非阻塞。
2.2.2、相关演示1.0
1)、基本演示
现在,我们基于上述2.1中代码,使用fcntl()
将其修改为非阻塞IO。
代码演示如下: 在SetNoBlock
函数中,fcntl
系统调用用于获取(F_GETFL
)和设置(F_SETFL
)文件描述符的标志。通过或运算(|
)将O_NONBLOCK
标志添加到现有的文件状态标志中。
#include <iostream>
#include <unistd.h>
#include <fcntl.h>
#include <string.h>
// 演示非阻塞式IO:fcntl
bool SetNonBlock(int fd)
{
int fl = fcntl(fd, F_GETFL); // 1、在底层获取当前fd对应文件的文件读写标志位(实则是一个位图)
if (fl < 0)// On error, -1 is returned, and errno is set appropriately.
return false;
fcntl(fd, F_SETFL, fl | O_NONBLOCK); // 2、设置为非阻塞(不改动原先的标志位状态,在其基础上添加O_NONBLOCK标志)
return true;
}
int main()
{
SetNonBlock(0); // 这里,只要设置一次,后续就都是非阻塞了。
char buffer[1024]; // 暂存缓冲区
while (true)
{
errno = 0; // 设置默认错误码
sleep(1);
// ssize_t read(int fd, void *buf, size_t count);
ssize_t s = read(0, buffer, sizeof(buffer) - 1);
if (s > 0) // 读取成功
{
buffer[s] = 0;
std::cout << "echo# " << buffer << "errno[---]: " << errno << " errstring: " << strerror(errno) << std::endl; // 将读取到的数据显示(顺带显示错误码)
}
else
{
std::cout << "read \"error\". " << "errno: " << errno << " errstring: " << strerror(errno) << std::endl;
}
}
return 0;
}
补充: 如果需要清除曾经设置的状态,如下。只要理解这里的文件描述符标志是以位图结构存储的,就能理解下述为什么使用& ~
。
// 清除O_NONBLOCK标志
fl &= ~O_NONBLOCK;
if (fcntl(fd, F_SETFL, fl) == -1) // 其实是检查操作,也可以省略。
{
perror("fcntl F_SETFL to reset blocking mode");
return 1;
}
演示结果如下: 可以看到,非阻塞的时候,是以出错的形式返回,告知上层数据没有就绪。而数据就绪时,就正常读取。
我们将其错误信息以字符串形式显示出来,发现资源部就绪时,读取到的错误码为11
,表示Resource temporarily unavailable
。
实际上,针对此处的代码逻辑,这里错误码为11不能代表严格意义上的读取出错,只是底层数据没有就绪而已。
2)、关于错误号11的一些说明(附加EINTR)
在Unix和类Unix系统中(如Linux),错误号 errno: 11
通常对应于 EAGAIN
或 EWOULDBLOCK
,这两个错误通常用于表示非阻塞I/O操作无法立即完成,而不是表示出现了真正的错误。 具体来说,这个错误通常在以下几种情况中发生:
非阻塞套接字: 当尝试在一个设置为非阻塞模式的套接字上执行一个接收(recv、read
)或发送(send、write
)操作时,如果没有数据可接收或没有足够的缓冲区空间用于发送数据,操作将立即返回,并设置 errno 为 EAGAIN 或 EWOULDBLOCK。
信号量: 在尝试获取一个信号量(sem_wait
或 sem_trywait
)时,如果没有可用的资源(信号量值大于0),并且调用是非阻塞的,则操作将返回并设置 errno 为 EAGAIN。
文件锁定: 在某些文件系统上,尝试进行非阻塞文件锁定时可能会遇到这个错误。
其他I/O资源: 某些I/O资源在达到其容量限制时,也可能返回此错误。
除了上述情况,当发生EINTR
错误时,也并非是真正的读取错误。EINTR
表示某种阻塞的操作,由于接收到的信号而被中断,造成的一种错误返回值。 当一个进程阻塞在某个可能永远阻塞的系统调用(也称为慢系统调用)上,例如read
、write
、sem_wait
、recv
等,如果该进程捕获到某个信号并且相应的信号处理函数返回时,这个系统调用可能会被中断并返回一个EINTR错误。
处理方法举例:
对于一些可以重启的系统调用,例如accept、read、write、select和open等,在遇到EINTR错误时可以进行重启。这通常通过在一个循环中重新调用这些系统调用来实现。
然而,对于某些系统调用,如connect函数,如果它返回EINTR错误,我们不能简单地重新调用它,否则将立即返回一个错误。在这种情况下,一种处理方法是使用select来等待连接完成。
总结:对于上述这两种情况,我们通常选择在代码处完善相关错误逻辑,即要甄别是真的出错了,还是仅仅是数据没有就绪。 因此,我们再将上述演示代码进一步完善一下。
2.2.3、相关演示2.0
基于上述1.0中的演示,进一步完善:
#include <iostream>
#include <unistd.h>
#include <fcntl.h>
#include <string.h>
// 演示非阻塞式IO:fcntl
bool SetNonBlock(int fd)
{
int fl = fcntl(fd, F_GETFL); // 1、在底层获取当前fd对应文件的文件读写标志位(实则是一个位图)
if (fl < 0) // On error, -1 is returned, and errno is set appropriately.
return false;
fcntl(fd, F_SETFL, fl | O_NONBLOCK); // 2、设置为非阻塞(不改动原先的标志位状态,在其基础上添加O_NONBLOCK标志)
return true;
}
int main()
{
SetNonBlock(0); // 这里,只要设置一次,后续就都是非阻塞了。
char buffer[1024]; // 暂存缓冲区
while (true)
{
errno = 0; // 设置默认错误码
sleep(1);
// ssize_t read(int fd, void *buf, size_t count);
ssize_t s = read(0, buffer, sizeof(buffer) - 1);
if (s > 0) // 读取成功
{
buffer[s] = 0;
std::cout << "echo# " << buffer << "errno[---]: " << errno << " errstring: " << strerror(errno) << std::endl; // 将读取到的数据显示(顺带显示错误码)
}
else // 错误情况分类处理
{
if (errno == EWOULDBLOCK || errno == EAGAIN)
{
std::cout << "当前数据没有就绪, 请重试。" << std::endl;
continue;
}
else if (errno == EINTR)
{
std::cout << "当前IO被信号中断, 请重试。" << std::endl;
continue;
}
else
{
// 来到此处,才是非阻塞读取真正报错,需要进行进行差错处理。(根据需求而定,这里我们只是打印做区分)
std::cout << "read \"error\". " << "errno: " << errno << " errstring: " << strerror(errno) << std::endl;
}
}
}
return 0;
}
演示结果如下:
3、多路转接:select
3.1、select 函数介绍
3.1.1、函数原型和返回值
select
是一个在 Unix-like 系统(包括 Linux)中用于监视多个文件描述符的状态变化的函数。这个函数允许程序同时等待多个文件描述符(如套接字、管道、文件等)变得可读、可写或出现异常条件。
系统提供select函数可用于实现多路复用输入/输出模型。结合之前提到的IO=等+拷贝,调用select后,程序会停在select这里等待(即完成IO中“等”的操作),直到被监视的文件描述符有一个或多个发生了状态改变。
该函数的原型如下: 其有两套头文件,这里我们使用<sys/select.h>
即可。
/* 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);
返回值:
1、若执行成功,select已经就绪的文件描符个数。(select函数主要核心工作就是“等”,只要至少一个fd数据就绪or空间就绪,该函数就可以进行返回。)
2、若返回0,通常意味着timeout参数指定的时间间隔已经过去,但是没有任何文件描述符变为就绪状态。(在这种情况下,select函数被视为超时,没有发生任何I/O事件。)
3、若返回-1,表示select调用失败,有错误发生。错误原因存于errno,此时参数readfds、writefds、exceptfds 和 timeout 的值变成不可预测。
RETURN VALUE
On success, select() and pselect() return the number of file descriptors contained in the three
returned descriptor sets (that is, the total number of bits that are set in readfds, writefds,
exceptfds) which may be zero if the timeout expires before anything interesting happens.
On error, -1 is returned, and errno is set appropriately; the sets and timeout become undefined,
so do not rely on their contents after an error.
3.1.2、参数介绍
3.1.2.1、int nfds
int nfds
:这是一个整数值,指定了被检查的文件描述符的范围。通常设置为所有文件描述符中的最大值加1。例如,假设需要监视的最大的文件描述符值为maxfd
,则我们需要设置该参数值为maxfd+1
。(加1是因为文件描述符的值从0开始)
PS:在上述5个参数中,除去该参数外,剩余4个参数均为输入输出型参数(共性)。
3.2.2.2、struct timeval *timeout
1)、介绍timeval结构体
struct timeval *timeout
:这是一个指向 timeval
结构的指针,用于指定等待的最大时间。
timeval
结构体用于表示一段时间间隔或超时值。 它通常在需要指定时间间隔或超时的系统调用中使用(如 select、pselect、poll 的 POLLRDNORM、POLLIN 等事件的超时等)。其结构如下:
#include <sys/time.h>
struct timeval {
time_t tv_sec; /* 秒 */
suseconds_t tv_usec; /* 微秒 */
};
tv_sec 是一个 time_t 类型的成员,表示秒数。
tv_usec 是一个 suseconds_t 类型的成员,表示微秒数。注意这里虽然叫 suseconds_t,但实际上它通常表示的是微秒(microseconds),而不是秒微秒(second-microseconds)
比如在gettimeofday
函数中,就使用了 timeval
结构体:其功能和time
类似、主要是获取时间。
#include <sys/time.h>
int gettimeofday(struct timeval *tv, struct timezone *tz);
tv 是一个指向 timeval 结构体的指针,用于接收当前时间。
tz 是一个指向 timezone 结构体的指针,通常被设置为 NULL,因为现在已经不再使用这个参数了。
这里我们做一个小演示:
int main()
{
while (true)
{
// 1、使用time获取时间
std::cout << "time: " << (unsigned int)time(nullptr) << std::endl;
// 2、使用gettimeofday获取时间
struct timeval currtime = {0, 0}; // timeval该结构体只要两个成员变量,这种初始化方式可行
int ret = gettimeofday(&currtime, nullptr);
assert(ret == 0); // return 0 for success, or -1 for failure
(void)ret;
std::cout << "gettimeofday: " << currtime.tv_sec << " , " << currtime.tv_usec << std::endl;
sleep(1);
}
return 0;
}
2)、该参数的意义是什么?
select
函数等待多个文件描述符(fd)时,可选择不同的等待策略,就是通过参数timeout
调控的:
1、阻塞式等待: 将timeout
参数设置为nullptr/NULL
。 这意味着select函数会一直阻塞,直到至少有一个fd变为可读、可写或有异常状态,或者接收到信号中断。
2、非阻塞式立即返回:将timeout
参数设置为一个零时值{0, 0}
。 这会使select函数立即返回,即使没有fd准备好,也不会阻塞,返回当前是否有任何fd就绪。
3、定时阻塞等待(超时返回):还可以将timeout
设置为一个具体的时间段,时间内阻塞,时间到立马返回。 例如timeout时间设置为{5, 0}
表示5s以内select函数会阻塞并等待,一旦到达设定的时间,无论fd是否就绪,都会立即返回。这种模式结合了阻塞和非阻塞的特点,提供了更灵活的等待选项。(体现timeout
参数的输入性)
4、动态调整等待时间: 如果在timeout设定的时间内,select函数因为某个fd就绪而提前返回,那么timeout参数会被更新,反映从现在起到下一个timeout周期结束的剩余时间。 例如,如果最初设置了{5, 0},但在2秒后就因为某个fd就绪而返回,那么timeout参数将会被更新为{3, 0},表明还有3秒的时间直到下一个周期结束。这一特性使得select能够有效地处理动态变化的等待需求。(体现timeout
参数的输出性)
上述这种设计,允许开发者根据实际场景灵活地控制等待行为,既可以在需要长时间等待时避免不必要的CPU消耗,也可以在需要快速响应时及时获取状态更新。
2.2.2.3、其它三个输入输出参数
1)、基本介绍
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, struct timeval *timeout);
*fd_set readfds
:这是一个指向文件描述符集的指针,用于监视哪些文件描述符是可读的。如果此参数为 NULL,则不监视任何文件描述符的可读性。
*fd_set writefds
:这是一个指向文件描述符集的指针,用于监视哪些文件描述符是可写的。如果此参数为 NULL,则不监视任何文件描述符的可写性。
*fd_set exceptfds
:这是一个指向文件描述符集的指针,用于监视哪些文件描述符处于异常条件。这通常用于套接字,以检测错误或其他非标准条件。如果此参数为 NULL,则不监视任何文件描述符的异常条件。
注意,这里fd_set
是文件描述符集。文件描述符实则是0、1、2等数字,故 fd_set
实则是一个位图结构,其中的每一个位对应一个文件描述符 。和信号集一样,fd_set
不能直接被用户程序直接操作,操作系统提供了一系列专门的宏和函数,用以管理和修改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的全部位
2)、作为输入输出参数的理解
实际上,这三个参数本就是用户和内核之间互相传递信息的参数。
1、当这些参数作为输入传递给select
函数时: 是用户通知内核需要关注哪些特定文件描述符(通常指套接字)上的事件类型。通常,用户通过填充fd_set
结构体中的位集合,来指定希望内核监测哪些文件描述符的读、写或异常状态。比如:
读事件(readfds):用户可以通过向readfds参数中添加文件描述符,要求内核监控这些fd是否变得可读。
写事件(writefds):通过向writefds参数添加文件描述符,用户可以请求内核检查这些fd是否变得可写。
异常事件(exceptfds):exceptfds参数用于监控异常条件,如网络连接断开或其他错误情况。
2、当这些参数作为输出时(select函数返回时): 内核会更新这些fd_set
参数,用以告知用户哪些文件描述符上发生的事件已经就绪。这意味着:
如果readfds中的某个文件描述符被标记,表示该fd上存在可读的数据。
如果writefds中的某个文件描述符被标记,表示该fd可以安全地进行写入操作,不会导致数据丢失或堵塞。
如果exceptfds中的某个文件描述符被标记,表示该fd上发生了异常事件,可能需要进行错误处理。
3.1.3、一个使用举例
要理解select模型,关键在于理解fd_set
的运作机制。为了便于阐述,我们不妨设想fd_set的长度仅为1字节。取fd_set
长度为1字节,fd_set
中的每一个比特位,可以对应一个文件描述符fd,则1字节长的fd_set
最大可以关联8个文件描述符。
我们以读事件(fd_set *readfds
)为例:
作为输入: 用户通过向readfds中的特定比特位赋值1,指示内核需要密切关注该位对应的文件描述符的读事件。这相当于创建了一份“关注列表”,列出所有希望内核监控读就绪状态的文件描述符。
例如,比特位表示为0000 1001
,意味着用户希望跟踪fd=0
和fd=3
的读事件。
作为输出: 当select函数返回时,内核会更新readfds中的比特位,用以反馈哪些文件描述符上的读事件已经就绪。
例如,比特位变为0000 1000
,则表示fd=3
的读事件资源已经准备妥当,用户可以立即读取此文件描述符而不会遇到阻塞。
特别说明: 输入输出型参数readfds
(以及其它与之类似的fd_set
参数)在select
调用过程中,用户和内核对同一位图结构进行操作。这意味着在每次调用select之后,fd_set的原始设置可能会被改变,尤其是那些内核标识为就绪的文件描述符。因此,如果用户希望持续监控某些文件描述符的状态,应当在每次调用前重新设置这些fd_set参数,以确保持续的关注。
例如,如果用户持续关注fd=0和fd=3的读事件,之后fd=3在某次调用后被内核标识为就绪,那么下一次调用select前,用户需要重新将readfds中对应这两个文件描述符的比特位置为1,以维持监控状态。
3.2、快速编写(读事件)
3.2.1、验证fd_set的大小
相关代码如下:
#include<iostream>
#include<sys/select.h>
int main()
{
std::cout << sizeof(fd_set)*8 << std::endl;
return 0;
}
演示结果如下: fd_set
是通过位图来存储文件描述符的,所以它的大小上限通常取决于系统中可用的最大文件描述符数量以及 fd_set
的内部实现。在大多数系统上,fd_set
能够处理几百到几千个文件描述符,但这并不是绝对的。
3.2.2、准备工作
下述演示使用的是TCP套接字编程,相关内容在先前博文中学习过,这里只做汇总。
3.2.2.1、log.hpp
#pragma once
#include<iostream>
#include<string>
#include<cstdio>
#include<ctime>
#include<cstdarg>
// 日志分等级
#define DEBUG 0
#define NORMAL 1
#define WARNING 2
#define ERROR 3
#define FATAL 4
const char* gLevelMap[] ={
"DEBUG",
"NORMAL",
"WARNING",
"ERROR",
"FATAL"
};
#define LOGFILE "./selectServer.log"
void logMessage(int level, const char* format, ...)
{
// 标准部分:固定输出的内容
char stdBuffer[1024];
time_t timestamp = time(nullptr);
snprintf(stdBuffer, sizeof stdBuffer, "[%s][%ld]", gLevelMap[level], timestamp);
// 自定义部分:允许用户根据自己的需求设置
char logBuffer[1024];
va_list args;
va_start(args,format);
vsnprintf(logBuffer, sizeof logBuffer, format, args);
va_end(args);
printf("%s%s\n", stdBuffer, logBuffer);
}
3.2.2.2、sock.hpp
此处的sock.hpp在结合了网络基础四中的讲解,补充了setsockopt
。
#pragma once
#include <iostream>
#include <string>
#include <cstring>
#include <assert.h>
#include <unistd.h>
#include <memory>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
class Sock
{
private:
const static int gbacklog = 10;
public:
Sock(){}; // 构造函数
~Sock(){}; // 析构函数
static int Socket() // 创建套接字:int socket(int domain, int type, int protocol);
{
int listensock = socket(AF_INET, SOCK_STREAM, 0);
if (listensock < 0)
exit(2);
// 防止断开连接无法立即重启(解决TIME_WAIT状态引起的bind失败)
// int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
int opt = 1;
setsockopt(listensock, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));
return listensock;
}
//int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
static void Bind(int sock, uint16_t port, std::string ip = "0.0.0.0")
{
struct sockaddr_in local;// 这里bind的是自己的ip和port
memset(&local, 0, sizeof(local)); // 清零
local.sin_family = AF_INET;
local.sin_port = htons(port); // 对端口号:主机字节序-->网络字节序
inet_pton(AF_INET, ip.c_str(), &local.sin_addr); // 对ip:点分十进制+主机字节序-->网络字节序+四字节
if (bind(sock, (struct sockaddr *)&local, sizeof(local)) < 0)
{
exit(3);
}
}
// 监听:int listen(int sockfd, int backlog);
static void Listen(int sock)
{
if(listen(sock, gbacklog) < 0)
exit(4);
}
// 获取连接:int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
static int Accept(int sock, std::string *ip, uint16_t* port)
{ // 这里后两个参数是输出型参数,故使用了指针(要将监听到的客户端ip和port返回给服务器)
struct sockaddr_in src;
bzero(&src, sizeof(src));//清零
socklen_t len = sizeof(src);
int servesock = accept(sock, (struct sockaddr*)&src, &len);// 获取连接
if(servesock < 0) exit(5);//连接获取失败,退出
if(ip) *ip = inet_ntoa(src.sin_addr);// 若ip不为空,四字节+网络字节序-->点分十进制+主机字节序
if(port) *port = ntohs(src.sin_port);// 若port不为空,网络字节序-->主机字节序
return servesock;
}
// 连接:int connect(int sockfd, const struct sockaddr *addr,socklen_t addrlen);
static bool Connect(int sock, const uint16_t &port, const std::string &ip)
{
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());// 对ip:点分十进制+主机字节序-->网络字节序
if(connect(sock, (struct sockaddr*)& server, sizeof(server))) return true;
else return false;
}
};
3.2.3、version1:先简单理解对select的使用
说明: 这里,我们重点演示select的读取。(写入、异常不做处理,后续讲解epoll时再完整演示)
3.2.3.1、version1.0:演示如何使用select来代替accept直接读取
1)、背景铺垫
1、如何看待listensock套接字?
I/O通常指的是输入/输出操作,涉及数据的读取和写入。在网络编程的上下文中,接收新的连接请求可以被视为一种特殊的输入操作,因为服务器正在从网络上接收新的数据流,即新的客户端连接。
2、如何看待accept的行为?
根据之前对三次握手的学习,accept函数并不直接处理连接请求,而是在底层完成三次握手之后,将已建立的连接从listensock上“提取”出来,并返回一个新的套接字描述符,用于与该客户端进行后续的通信。这就意味着,如果没有客户端连接请求到来时,默认情况下accept函数被设置为阻塞模式,它将会阻塞当前线程的执行,直到有新的连接请求到达。
这种阻塞行为在某些场景下可能不符合“多路转接”的需求,因为多路转接通常要求能够同时处理多个客户端的连接请求,而不是等待一个连接建立后再处理下一个。
为了解决这个问题,可以使用select
、poll
或epoll
等系统调用来实现非阻塞的服务器。由这些系统调通知服务器底层资源是否就绪,如此一来,当listensock就绪时(即有新的连接请求到来),服务器可以调用accept函数来接受新的连接,而不会阻塞其他已连接的客户端的通信。
2)、基本演示
代码如下:这里主要在于理解select的使用。
#pragma once
#include <iostream>
#include <sys/select.h>
#include "log.hpp"
#include "Sock.hpp"
class SelectServer
{
public:
SelectServer(const uint16_t &port = 8080) // 构造函数
: _port(port)
{
// 创建套接字
_listensock = Sock::Socket();
// 绑定端口号
Sock::Bind(_listensock, _port);
// 监听
Sock::Listen(_listensock);
logMessage(DEBUG, "%s", "create base socket success.");
}
~SelectServer() // 析构函数
{
if (_listensock >= 0)
close(_listensock);
}
void Start()//启动服务器
{
fd_set rfds;// 这里我们只演示读事件,因此创建一个读文件描述符集即可
FD_ZERO(&rfds);// 清空fd_set集合:void FD_ZERO(fd_set *set);
while(true)
{
FD_SET(_listensock, &rfds);// 将需要监控的文件描述符加入集合之中:void FD_SET(int fd, fd_set *set);
// 使用select等待读事件就绪:int select(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, struct timeval *timeout);
int ret = select(_listensock+1, &rfds, nullptr, nullptr, nullptr);
switch(ret)//根据select的返回值,处理不同情况
{
case 0:// 超时,给定timeout时间段内未发生IO事件
logMessage(DEBUG,"%s", "time out, please try again. ");
break;
case -1:// select调用失败,有错误发生
logMessage(WARNING,"select error: %d, %s. ", errno, strerror(errno));
break;
default:// 执行成功,返回已经就绪的文件描符个数
logMessage(DEBUG,"%s","select success, get a new link event. ");
//TODO……
break;
}
sleep(1);//为了方便演示观察(实际不需要)
}
}
private:
uint16_t _port; // 服务端端口号
int _listensock; // 监听套接字
};
在main.cc
文件中执行下述代码,调用select服务器:
#include "selectServer.hpp"
int main()
{
std::unique_ptr<SelectServer> server(new SelectServer());
server->Start();
return 0;
}
演示结果如下: 我们使用select来等待客户端连接,当连接请求到来时我们没有及时处理,那么select会不断返回并指示服务器套接字是可读的(因为这里使用了while循环,故一直打印消息),直到我们处理完所有待处理的连接请求。
因此,正确做法是,需要处理事件响应(HanderEvent
,即完善我们上述TODO
部分的内容)。
这里我们进行了一个简单的HandlerEvent演示:
3.2.3.2、关于timeout结构体的不同设置演示
插个题外话,上述演示时,为了方便观察我们将timeout
设置为了nullptr
。这里,若将其设置为其它情况呢?
设timeout
为nullptr
:
设timeout
为某一非零值:这里我们将其设置在循环外,其只等待了一次。
如果我们想要每一次都等待,可将其初始化设置在循环内:
3.2.4、version2.0:修复与完善
3.2.4.1、问题说明
1)、问题描述
之前上手写handlerEvent时,曾遗留一个问题:select获取到listensock后,我们在事件响应中使用accept连接客户端。此时能否直接使用recv等函数进行读取?
回答:不能。 获取到新连接,不代表就一定有数据到来。直接调用recv此类接口读取:IO=等+数据拷贝。若客户端一直不传输数据,那就会一直阻塞在recv这里。不满足我们的多路转接。 若直接用子进程或多线程处理,那我们何必学习select?
因此这里正确的做法是:获取到新连接时,将新的sock托管给select,让select帮助我们进行监测sock上是否有新数据到来。 (若此时有数据,select会通知我们读事件就绪,此时再使用recv等函数读取,就不会被阻塞了)。由此,这里就产生了一个问题,如何将accept获取到的新的sock托管给select?
void HandlerEvent(fd_set& rfds)
{
std::string clientip;
uint16_t clientport = 0;
if (FD_ISSET(_listensock, &rfds)) // 如果读事件就绪,表示可以读取:int FD_ISSET(int fd, fd_set *set);
{
int serversock = Sock::Accept(_listensock, &clientip, &clientport);
if (serversock < 0)
{
logMessage(WARNING, "%s", "accept error. ");
return;
}
logMessage(DEBUG, "get a new link success. [%s: %d], sock is %d. ", clientip.c_str(), clientport, serversock);
}
//TODO……
}
此外,还有其它问题:
1. nfds
:随着我们获取的sock越来越多,添加到select的sock也会越来越多,注定了select的第一个参数nfds
每一次都可能要变化,这说明我们需要对它动态计算。(而反观version1.0中的代码,直接固定为了_listensock+1,不满足其值为最大文件描述符+1的需求。故此处需要修改。)
//int select(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, struct timeval *timeout);
int ret = select(_listensock + 1, &rfds, nullptr, nullptr, nullptr);
2. rfds/writefds/exceptfds
:此三个参数是输入输出型参数,根据之前讲解,每次调用select之后,fd_set的原始设置可能会被内核修改,所以注定了我们(用户)每一次调用前都要对rfds进行重新添加。
3. timeout
: 也是输入输出型参数,如果我们需要设定时间,则意味着每次select调用后,要对其进行重置。
2)、解决思路
为了解决上述种种问题,作为上层用户,我们必须自己将这些合法的文件描述符单独全部保存起来。用以支持:①更新最大fd(这是为了给int nfds
提供正确参数);②更新位图结构(这是由于select后四个参数均为输入输出型参数。)
这里,可以使用一个第三方数组来完成(比如原生数组int rfd_array[NUM]
,又或者使用std::vector<int> rfd_array
)。
3.2.4.2、version2.0:引入第三方数组保存所有合法的文件描述符
1)、基础演示
相关代码如下:
#pragma once
#include <iostream>
#include <sys/select.h>
#include "log.hpp"
#include "Sock.hpp"
// 下述三个宏主要用于select的文件描述符集
#define BITS 8
#define NUM (sizeof(fd_set) * BITS)
#define FD_NONE -1
class SelectServer
{
public:
SelectServer(const uint16_t &port = 8080) // 构造函数
: _port(port)
{
// 创建套接字
_listensock = Sock::Socket();
// 绑定端口号
Sock::Bind(_listensock, _port);
// 监听
Sock::Listen(_listensock);
logMessage(DEBUG, "%s", "create base socket success.");
// 初始化读事件数组:该数组用于存储文件描述符,这里我们设置默认的数组元素为-1,
for (int i = 0; i < NUM; ++i)
_rfd_array[i] = FD_NONE;
_rfd_array[0] = _listensock; // 同理,这里我们设置首元素为 listensock(监听套接字)
}
~SelectServer() // 析构函数
{
if (_listensock >= 0)
close(_listensock);
}
void Start() // 启动服务器
{
while (true)
{
DebugPrint();//为方便观察
// 关于这两行代码说明:这里每回合都需要对rfds重置,定义可以放在循环外,但要记得FD_ZERO清空。(放while循环内只不过是每次重新定义一下而已)
fd_set rfds; // 这里我们只演示读事件,因此创建一个读文件描述符集即可
FD_ZERO(&rfds); // 清空fd_set集合:void FD_ZERO(fd_set *set);
int maxfd = _listensock; // 用于记录当前回合中最大的文件描述符(后续select函数的第一个参数传参需要)
for (int i = 0; i < NUM; ++i) // 将需要监控的文件描述符加入集合之中:void FD_SET(int fd, fd_set *set);
{
if (_rfd_array[i] == FD_NONE)
continue;
FD_SET(_rfd_array[i], &rfds);
if (maxfd < _rfd_array[i])
maxfd = _rfd_array[i]; // 更新maxfd的值
}
// 使用select等待读事件就绪:int select(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, struct timeval *timeout);
int ret = select(maxfd + 1, &rfds, nullptr, nullptr, nullptr); // 可根据需求设置timeout,这里为了方便演示,将其设为nullptr
switch (ret) // 根据select的返回值,处理不同情况
{
case 0: // 超时,给定timeout时间段内未发生IO事件
logMessage(DEBUG, "%s", "time out, please try again. ");
break;
case -1: // select调用失败,有错误发生
logMessage(WARNING, "select error: %d, %s. ", errno, strerror(errno));
break;
default: // 执行成功,返回已经就绪的文件描符个数
logMessage(DEBUG, "%s", "select success, get a new link event. ");
HandlerEvent(rfds);
break;
}
}
}
private:
void HandlerEvent(fd_set &rfds)
{
// 对于select读取到的文件描述符集,单论读事件,rfds未来一定会有两类套接字:listensock和普通sock,针对不同情况,需要分类处理事件响应
for (int i = 0; i < NUM; ++i)
{
if (_rfd_array[i] == FD_NONE)
continue; // 当前数组位置处的文件描述符不合法(值为-1)
if (FD_ISSET(_rfd_array[i], &rfds)) // 这是在判断合法的资源是否就绪(即使当前数组元素不为-1,但不代表select返回时监视到这个fd已经就绪,故需要判断)
{
if (_rfd_array[i] == _listensock)
Accepter();
else
Recever(i);
}
}
}
void Accepter()
{
std::string clientip;
uint16_t clientport = 0;
int serversock = Sock::Accept(_listensock, &clientip, &clientport); // 本回合中,因为有了先前的select,此处使用accept,不会再被阻塞。
if (serversock < 0)
{
logMessage(WARNING, "%s", "accept error. ");
return;
}
logMessage(DEBUG, "get a new link success. [%s: %d], sock is %d. ", clientip.c_str(), clientport, serversock);
// 来到此处,客户端成功连接上服务端,双方开启通讯。但后续recv、read仍旧有阻塞可能,这里我们需要将新得到的serversock交给select管理,由它替我们监视是否需要读取。
// 在读事件数组中找合适位置,将获取到的serversock存入数组中
int pos = 1; // 0位置处为监听套接字,故这里可以从1开始
for (; pos < NUM; ++pos)
{
if (_rfd_array[pos] == FD_NONE)
break; // 找到了合适的位置
}
if (pos == NUM) // 直到找到数组尾,仍旧没找到合适位置,说明读事件数组已经填满
{
logMessage(WARNING, "select server already full. colse: %d", serversock);
close(serversock);
}
else
{
_rfd_array[pos] = serversock;//此处添加完该套接字,在后续回合的select中,若客户端发送数据,就会被select监视到,从而通知服务器(然后,根据套接字对应的走读取流程)
}
}
//这里我们暂时处理为简易版的echo服务器(对发送过来的消息,进行显示)
//此外需要注意,这里仍旧存在一些bug:TCP是面向字节流的,如何保证读取的数据具有完整性?
void Recever(int pos)
{
logMessage(DEBUG, "message in, get IO event: %d. ", _rfd_array[pos]);
char recvbuffer[1024];// 接收缓冲区
int n = recv(_rfd_array[pos], recvbuffer, sizeof(recvbuffer)-1, 0);
if(n > 0)// 成功接收数据
{
recvbuffer[n] = 0;
logMessage(DEBUG, "client[%d]# %s",_rfd_array[pos], recvbuffer);
}
else if(n == 0)// 读取到文件尾:a、关闭文件描述符,b、将数组中对应元素置空
{
logMessage(DEBUG, "client[%d] has quit, server will close its sock. ", _rfd_array[pos]);
close(_rfd_array[pos]);
_rfd_array[pos] = FD_NONE;// 置空后,后续回合select重置时,不会再监听到此文件描述符
}
else{
logMessage(WARNING, "%d sock recv error. the errno is %d, %s. ", _rfd_array[pos], errno, strerror(errno));
close(_rfd_array[pos]);
_rfd_array[pos] = FD_NONE;
}
}
void DebugPrint()//辅助函数:用于打印每回合的读事件数组(为了方便观察)
{
std::cout << "_rfd_array[]: ";
for(int i = 0; i < NUM;++i)
{
if(_rfd_array[i] == FD_NONE) continue;
std::cout << _rfd_array[i] << " ";
}
std::cout << std::endl;
}
private:
uint16_t _port; // 服务端端口号
int _listensock; // 监听套接字
int _rfd_array[NUM]; // 用于记录读事件就绪的数组(问题:如何设置nfds的大小?可根据平台的fd_set大小而定)
};
演示结果如下:
2)、缺陷说明
1、上述我们只是演示了读事件,select还可以监听写事件和异常事件,关于这些内容,在后续epoll讲解。
2、注意,我们在网络基础中学习过,TCP是面向字节流的,上述代码中Recever接收数据是存在一定缺陷的,即:如何保证读取的数据具有完整性?关于这个问题仍旧在后续epoll中讲解。
3.3、select优缺点
优点:(实则任何一个多路转接方案,都具备下述优点)
a、高效处理: select机制允许程序同时监视多个文件描述符的状态变化,从而高效地处理多个网络连接及通信。通过将所有的文件描述符集中在一起处理,select能够显著减少CPU的轮询次数,提高系统的响应速度。
b、资源节省:在有大量连接但仅有少量活跃连接的情况下,select机制尤为适用。它允许服务器仅对活跃的连接进行处理,从而节省了大量的系统资源,如CPU、内存和网络带宽。
缺点:
a、遍历操作多: 为了维护第三方数组(一个包含所有需要监视的文件描述符的数组或类似结构),select 服务器需要进行大量的遍历操作 。这不仅增加了CPU的负担,还可能影响系统的响应速度。此外,我们上层只是调用fd_set相关函数,这说明 OS内部 因为需要帮助我们关心该fd数组, 也需要进行类似的遍历操作 。
b、参数重设: 在每次调用select函数之前,都需要对select的输出参数进行重新设定。这增加了编程的复杂性,并可能导致潜在的错误。如果忘记或错误地设定了参数,可能会导致程序无法正常工作。
c、文件描述符有上限: 根据之前的演示可知,fd_set
是有最大值的,即 select
机制能够同时管理的文件描述符个数有限。这个上限通常受到系统配置的限制,并且可能无法满足某些高并发场景的需求。当需要管理的连接数超过这个上限时,程序可能会出现性能下降或错误。
d、参数数据拷贝: select函数的参数设计使得它在处理大量数据时需要进行频繁的用户空间到内核空间、内核空间到用户空间的参数数据拷贝。这种拷贝操作不仅增加了系统的开销,还可能成为性能瓶颈。尤其是在处理大量数据时,这种开销可能变得非常明显。
考虑到这些缺点,于是有了下述的poll
。
4、多路转接:poll
4.1、poll函数介绍
4.1.1、函数原型与返回值
说明: poll
函数是Linux系统中的一个系统调用,主要用于监视并等待多个文件描述符的状态变化。其与select
类似,poll
同样采用轮询机制来管理多个文件描述符,并根据描述符的状态进行处理,但与select
区别的是,poll
没有最大文件描述符数量的限制。
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
poll
函数的返回值:
>0
:表示数组fds
中准备好读、写或出错状态的那些文件描述符的总数量。
0
:表示在指定的时间内,数组fds
中没有任何文件描述符准备好读、写或出错,即poll
超时。
-1
:表示poll
函数调用失败,此时会设置全局变量errno
来表示错误原因。
4.1.2、参数说明
4.1.2.1、fds
fds
:指向一个struct pollfd
结构类型的数组,该数组用于存放需要检测其状态的文件描述符。 其结构体如下:
The set of file descriptors to be monitored is specified in the fds argument, which is an array of structures of the following form :
struct pollfd
{
int fd; /* file descriptor:需要被检测或选择的文件描述符 */
short events; /* requested events:等待的事件(用户-->内核) */
short revents; /* returned events:实际发生的事件(内核-->用户) */
};
对于struct pollfd
结构体:
1、fd
:是用于记录描述符的固定整数,无论是用户还是内核,都不会修改这个值。
2、events
:主要用于用户向内核传达需求。用户通知内核,需要内核帮助其监管哪些fd及其事件。
3、revents
:主要用于内核向用户返回结果。由内核通知用户,哪些fd及其事件已经就绪。
PS:由上可知,通过 events
和 revents
成员,poll
函数实现了输入和输出参数的分离(相比 select
不需要进行大量的重置)。events 成员作为输入参数,用于指定每个 pollfd 结构体中需要监控的事件。当 poll 调用返回时,内核会更新每个 pollfd 结构体中的 revents 成员,以指示哪些事件已经就绪。
一个问题:events、revents都是short类型,它们是如何表示不同事件的?
主要通过标记位/掩码位: events
和 revents
成员在 struct pollfd
结构体中都是 short
类型,它们通过位掩码(标记位)的方式来表示不同的文件描述符上的事件。每个事件都由一个特定的位(bit)来表示,因此可以通过组合这些位来指定多个事件。
用户可以通过 使用按位或(|
)将这些宏组合起来,从而设置 events
成员,以告诉内核需要监控哪些事件。例如,若用户想要监控一个文件描述符是否可读或可写,他们可以这样做:
struct pollfd fds[1];
fds[0].fd = some_file_descriptor;
fds[0].events = POLLIN | POLLOUT; // 监控可读和可写事件
当 poll
系统调用返回时,内核会更新 revents
成员,以指示哪些事件已经就绪。用户 可以通过按位与(&
)和事件宏来检查 revents
中的位,以确定哪些事件已经发生:
int nfds = poll(fds, 1, -1); // 调用 poll 系统调用
if (nfds == -1) {
// 错误处理
} else if (nfds > 0) {
if (fds[0].revents & POLLIN) {
// 文件描述符可读
}
if (fds[0].revents & POLLOUT) {
// 文件描述符可写
}
// ... 检查其他事件
}
pool函数的输入event和返回revents:
扩展细节:仔细观察,可以看到这里有一个POLLPRI
,为什么会存在优先级数据?
回答: 还记得我们在TCP/IP中曾介绍到TCP报头中的六个标志位吗?其中就有一个URG
紧急标志位,用于指示紧急数据。
详细解释: POLLPRI
是 poll
系统调用中的一个事件标志,它用于指示有优先级数据(priority data)可读。优先级数据是一种特殊类型的数据,它在某些类型的通信中(如套接字)具有比正常数据更高的优先级。当这种数据到达时,应用程序通常会被立即通知,以便能够优先处理它 。在CP/IP 协议栈中,当 TCP 套接字上收到带外数据时,URG 指针会被设置为指向紧急数据的末尾。这意味着,即使正常数据队列中还有未处理的数据,应用程序也可以通过读取紧急数据来优先处理它。POLLPRI 事件就是用来通知应用程序有紧急数据可读的。
4.1.2.2、nfds和timeout
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
关于nfds
nfds_t nfds
:这是一个无符号整数,指定了fds
数组中元素的数量。注意,它并不是最大的文件描述符值加一,而仅仅是数组中元素的数量。
问题:如何理解poll解决了select中fd_set上限问题?
1、select
中,fd_set
的大小通常是基于系统定义的宏(限制了可以同时监控的文件描述符的数量)。
2、与之相比,poll
使用pollfd
结构体数组来存储需要监控的文件描述符,其 允许用户程序自己分配pollfd
数组的大小 ,因此它更具扩展性。 当需要监控的文件描述符数量增加时,用户程序可以简单地分配一个更大的pollfd数组 (即用户程序可以动态分配结构体数组的大小),而无需修改系统级别的代码或配置。
关于timeout
int timeout
:这是一个指定等待时间的整数,单位是毫秒 (1秒 =1000 毫秒)。
timeout > 0
,poll
会等待指定的毫秒数。如果在这段时间内没有任何文件描述符的状态发生变化,poll
将返回0
(即超时返回)。
timeout = 0
,poll
将立即返回,不会阻塞等待文件描述符的状态变化(即非阻塞式等待)。
timeout < 0
(通常是-1),poll
将无限期地等待,直到有文件描述符的状态发生变化(即阻塞式等待)。
4.2、快速编写
4.2.1、使用poll监控标准输入
相关代码如下: 这里,我们使用poll
系统调用来监控标准输入(通常是键盘输入)。主要在于初步理解poll的各参数接口如何使用,方便理解后续的代码编写。
#include<poll.h>
#include<unistd.h>
#include<stdio.h>
int main()
{
struct pollfd poll_fd;
poll_fd.fd= 0;// 文件描述符fd是0,也就是标准输入(stdin)。
poll_fd.events = POLLIN;// 读事件
while(true)//和select函数一样,poll返回后,需要轮询pollfd来获取就绪的文件描述符
{
//int poll(struct pollfd *fds, nfds_t nfds, int timeout);
int ret= poll(&poll_fd, 1,1000);//监控一个文件描述符(poll_fd),等待时间为1000毫秒(即1秒)。
if(ret < 0)
{
perror("poll");
continue;
}
if(ret == 0){
printf("poll timeout. \n");
continue;
}
if(poll_fd.revents & POLLIN)
{
char buff[1024];
int n = read(0, buff, sizeof(buff)-1);
buff[n] = 0;//这里省去了read
printf("stdin# %s",buff);
}
}
}
使用演示如下:
4.2.2、使用poll监控读事件(与select类同)
相关代码如下: 这里主要是pollServer.hpp
中的内容,其它的Sock.hpp
、log.hpp
、main.cc
和之前select
中使用一样。
1、根据下述内容,可以看到使用poll
相比于select
在Start()中要简化很多,省去了部分遍历和重置操作。
2、除了对比select
,这里要主要学习poll
的使用。
#pragma once
#include <iostream>
#include <poll.h>
#include <string>
#include "Sock.hpp"
#include "log.hpp"
#define FD_NONE -1
class PollServer
{
static const int nfds = 100; // 可自定义
public:
PollServer(const uint16_t &port = 8080)
: _port(port), _nfds(nfds)
{
// 创建套接字
_listensock = Sock::Socket();
// 绑定套接字
Sock::Bind(_listensock, _port);
// 监听套接字
Sock::Listen(_listensock);
// 初始化poll相关数据
_fds = new struct pollfd[_nfds]; // 开辟_nfds大小的结构体数组
for (int i = 0; i < _nfds; ++i)
{
_fds[i].fd = FD_NONE; // 文件描述符初始化为-1
_fds[i].events = _fds[i].revents = 0; // short类型,这里先初始化为0
}
_fds[0].fd = _listensock; // 规定首元素为设置为listensock
_fds[0].events = POLLIN; // 对listensock,只用关心其读事件
_timeout = 2500; // 自定义:设等待时间(注意这里的单位是毫秒)
logMessage(DEBUG, "%s", "create base socket success. ");
}
~PollServer()
{
if (_listensock >= 0) // 关闭套接字
close(_listensock);
if (_fds) // 释放开辟的空间
delete[] _fds;
}
void Start()
{
while (true)
{
DebugPrint();//用于演示
// int poll(struct pollfd *fds, nfds_t nfds, int timeout);
int ret = poll(_fds, _nfds, _timeout);
switch (ret)
{
case 0: // 在指定的时间内,数组fds中没有任何文件描述符就绪
logMessage(DEBUG, "%s", "time out, please try again. ");
break;
case -1: // poll函数调用失败
logMessage(WARNING, "poll error: %d, %s. ", errno, strerror(errno));
break;
default: // 等待成功,返回已经就绪的文件描述符个数
logMessage(DEBUG, "%s", "poll success, get a new link event.");
HandlerEvent();
break;
}
}
}
private:
void HandlerEvent()
{
for (int i = 0; i < _nfds; ++i)
{
if (_fds[i].fd == FD_NONE) // 判断是否合法
continue;
if (_fds[i].revents & POLLIN) // 判断是否就绪:注意这里的写法(这里我们只演示读事件,故只用按位与上相关标记位即可)
{
if (_fds[i].fd == _listensock)
Accepter(); // 读事件就绪:连接事件到来
else
Recever(i); // 读事件就绪:连接事件到来
}
}
}
void Accepter()
{
std::string clientip;
uint16_t clientport = 0;
int servicesock = Sock::Accept(_listensock, &clientip, &clientport);// 本回合中,因为有了先前的poll,此处使用accept,不会再被阻塞。
if(servicesock < 0)
{
logMessage(WARNING, "%s", "accept error. ");
return;
}
logMessage(DEBUG, "get a new link success. [%s: %d], sock is %d. ", clientip.c_str(), clientport, servicesock);
// 获得新的sock,需要将新得到的servicesock交给poll管理,由它替我们监视是否需要读取。
int pos = 1;
for(; pos < _nfds; ++pos)
{
if(_fds[pos].fd == FD_NONE) break;
}
if(pos == _nfds)// 直到找到数组尾,仍旧没找到合适位置,说明读事件数组已经填满
{
//这里可以不必退出,由于poll的文件描述符集合大小非固定值(无上限限制),可根据需要对struct pollfd进行自动扩容
//扩容内容类似于vector等STL扩容,此处不演示
logMessage(WARNING,"select server already full. colse: %d", servicesock);
close(servicesock);
}
else
{ //数组中还有空间,将新得到的servicesock交给poll管理(在之后回合中,如果客户端发送数据,就能被poll监视到,从而走对应的sock流程)
_fds[pos].fd = servicesock;
_fds[pos].events = POLLIN;// 这里只关心读事件
}
}
void Recever(int pos)
{
// 存在缺陷:这里我们只是对客户端发送过来的数据进行显示(TCP面向字节流,如何保证读取到的数据是完整的?之后在epoll中讲解)
char recvbuffer[1024];
int n = recv(_fds[pos].fd, recvbuffer, sizeof(recvbuffer)-1, 0);
if(n > 0)
{
recvbuffer[n] = 0;
logMessage(DEBUG,"client[%d]# %s", _fds[pos].fd, recvbuffer);
}
else if(n == 0)// 读取倒文件末
{
logMessage(DEBUG,"client[%d] has quit, server will close its sock. ", _fds[pos].fd);
close(_fds[pos].fd);// 关闭对应的文件描述符
_fds[pos].fd = FD_NONE; // 清空数据(告诉poll,后续不必再关注此文件描述符了)
_fds[pos].events = 0;
}
else{
logMessage(WARNING,"%d sock recv error. the errno is %d, %s. ", _fds[pos].fd, errno, strerror(errno));
close(_fds[pos].fd);
_fds[pos].fd = FD_NONE;
_fds[pos].events = 0;
}
}
void DebugPrint()
{
std::cout << "_fds[]: ";
for(int i = 0; i < _nfds; ++i)
{
if(_fds[i].fd == FD_NONE) continue;
std::cout << _fds[i].fd << " ";
}
std::cout << std::endl;
}
private:
int _listensock; // 监听套接字
uint16_t _port; // 端口号
struct pollfd *_fds; // poll:用于存放文件描述符的结构体数组
nfds_t _nfds; // poll:fds数组中元素的数量
int _timeout; // poll:用于指定等待时间
};
演示结果如下:
4.3、poll优缺点
优点:
高效性: poll 在处理大量连接时表现高效,尤其是当连接中只有少量是活跃的时,它能有效节省系统资源。
资源优化: 其输入输出参数分离的设计避免了大量的重置操作,使得资源使用更加优化。
无上限的文件描述符: poll 不设置可管理的文件描述符(fd)的上限,允许程序监控任意数量的文件描述符。
缺点:
遍历开销: 虽然poll在处理大量连接时相比select有所改进,但它仍然需要在用户层遍历整个文件描述符集合来检测哪些fd是就绪的,这增加了用户态的开销。
数据拷贝: 与select类似,poll也需要内核到用户空间的数据拷贝,这增加了系统调用的开销。
代码复杂性: 尽管poll在设计上比select更先进,但其代码实现仍相对复杂,需要开发者具备一定的系统编程知识和经验。