文章目录
- 内容回顾及铺垫
- 五种IO模型
- 不同类型IO的区别
- 非阻塞IO
- fcntl( )
- 多路转接 - select( )
- select( ) 的基本使用 - SelectServer服务器
内容回顾及铺垫
在博客『 Linux 』基础IO/文件IO (万字)中介绍了对IO的认识;
IO实际上为Input/Output
,输入输出;
以网络协议栈的视角来看,操作系统分为四层,分别为应用层,传输层,网络层及数据链路层;
假设在网络通信过程中调用read()/write()
系统调用接口,对于应用层而言并不是真正的在进行IO
操作;
当应用层在网络通信过程中通过调用read()/write()
系统调用接口来进行网络通信,本质上是在进行一个拷贝操作;
-
read()
将数据由内核缓冲区拷贝至应用层缓冲区;
-
write()
将应用层缓冲区内数据拷贝至内核缓冲区;
因为这些函数本质只是拷贝函数,而真正对于数据是否进行发送的决策交由各层协议来决定;
而这些拷贝函数的拷贝行为是在缓冲区内资源(数据)已经就绪的前提下,若是资源未就绪则可能进行等待;
如在调用一些输入函数时,如从键盘中进行输入的函数scanf()
,在调用该函数时执行流将会进行阻塞,原因为需要等待资源就绪,当用户使用键盘输入完毕后即表示资源已经就绪,届时执行流将继续执行;
再举例在进行网络通信过程中(以TCP
协议为例),双方可以通过调用read()/write()
或send()/recv()
函数进行网络通信,而TCP
协议中, 双方都具有相同的缓冲区,即一个输入缓冲区和一个输出缓冲区;
而调用recv()
或是read()
进行对内核缓冲区内数据进行读取时若是内核缓冲区资源未就绪,则该执行流可能会阻塞一段时间;
因此本质上IO
操作即为 “拷贝 + 等” ;
而要进行拷贝,必须先判断条件是否成立,此处的条件通常代指读写事件;
-
读事件
读事件表示内核缓冲区中的数据已经就绪,可以拷贝至上层缓冲区;
-
写事件
写事件表示上层缓冲区中的数据已经就绪,可以拷贝至内核缓冲区;
有一个关于IO
的概念为,单位时间内单次IO
吞吐量越高效率越高,实际上这也被称为高效的IO
;
也可以理解为单位时间内的IO
过程中,等的比重越小,IO
的效率也就越高;
几乎所有提高IO
效率的策略本质上就是降低IO
过程中 “等” 的时间比重;
五种IO模型
IO模型主要分为五种,分别为阻塞IO,非阻塞IO,多路复用,信号驱动式IO以及异步IO;
该章节介绍IO模型以调用recvfrom()
函数,即触发 “读事件” 为例;
-
阻塞式IO
阻塞IO是最常见的IO模型,通常情况下阻塞IO将在内核数据准备好前对应的系统调用将会一直处于等待状态;
同时所有的套接字默认都为阻塞方式;
当上层调用
recvfrom()
函数进行读取操作时,执行流将会阻塞,内核将会等待直至读事件就绪;当读事件就绪后执行流将继续,数据将由内核缓冲区拷贝至应用层缓冲区,再由上层进行对数据报的处理;
-
非阻塞式IO
非阻塞式IO同样会判断事件是否已就绪,与阻塞式IO模型不同,非阻塞式IO模型并不会阻塞执行流并等待事件就绪,该模型将会判断一次事件是否已经就绪,若是事件就绪则进行后续操作,若是事件未就绪则直接返回;
当使用
recvfrom()
函数进行非阻塞IO操作时,若是事件未就绪将直接返回EWOULDBLOCK
;因此通常在使用非阻塞式IO模型时都会采用轮询的方式,即反复调用对应的非阻塞式IO操作;
通过轮询的方式,通常情况下,由于
recvfrom()
函数的非阻塞轮询操作是由上层调用的,这意味着上层可以通过轮询的策略使得在事件未就绪前能够进行其他操作; -
信号驱动IO
信号驱动IO是一种利用
SIGIO
信号进行驱动的一种IO模型;通过注册信号处理函数告诉操作系统当事件就绪时发送对应的信号以进行通知,当注册好后对应的执行流可以进行其他任务的处理;
当事件就绪后操作系统将会发送
SIGIO
信号给进程,对应的信号处理函数中将会调用recvfrom()
等相关操作将数据由内核缓冲区拷贝至用户缓冲区;最终将数据交由上层进行数据报处理;
信号驱动IO犹如收外卖,当外卖员将外卖送到了你的门前将会敲门,按门铃或是以打电话的方式,这些方式都是一种信号的递交方式;
当你接收到信号后将要放下手中的活去开门拿外卖,或是付钱等操作;
当然也可以利用多线程来完成这些操作,如让家里的其他家人代你去签收外卖一样;
-
IO多路转接(多路复用)
多路转接(多路复用)类似于阻塞IO模型,也可以说该模型是阻塞模型的一种优化版本;
传统的阻塞IO模型只能对一个IO的位置进行观察与等待;
而通常情况下IO是通过文件描述符进行的,这意味着传统的阻塞式IO模型只能观察一个文件描述符,而多路复用则可以观察多个文件描述符以保证效率上的提升;
-
异步IO模型
异步IO模型与信号驱动型IO模型类似但不同,在使用信号驱动型IO模型进行IO操作时,当应用程序接收到对应的信号后,将要阻塞当前的执行流从而去处理IO操作中的拷贝操作或其他操作;
而异步IO模型只需调用
aio_read()
函数即可通知内核当读事件就绪时直接让内核进行完整的IO操作而不必预先通知上层,当IO操作处理完毕后再使用指定的信号通知上层;
不同类型IO的区别
IO的本质是 " 等 + 拷贝 ";
-
阻塞IO与非阻塞IO
实际上无论是阻塞IO还是非阻塞IO其两者单凭IO效率而言两者的效率是相当的;
在上文中所提到的五种模型中,单凭IO而言都是等待事件就绪再进行拷贝操作;
而常常提到的非阻塞IO效率高的原因是在使用非阻塞IO模型时(可能是轮询也可能是信号驱动),在进行等待的过程中进程不会阻塞在原地一直等待至事件就绪,在非阻塞等待事件就绪的这个时间段进程可以去完成一些其他的任务,在总体的效率上提升效率;
-
同步IO与异步IO
上文中介绍的物种IO模型,除了异步IO模型以外都属于同步IO模型;
其本质上的区别为是否需要参与IO过程;
上述中的模型中除了异步IO外,其他所有的IO模型执行流都将在事件就绪后去进行数据的拷贝操作;
而异步IO并不需要参与该过程,当调用对应的
aio_read()
函数后IO将全权交由内核进行处理,包括等待与拷贝的过程;当整个IO过程完成后内核将发送特定的信号或是采用回调函数的方式告诉上层IO已经完成;
换句话来说就是异步IO只发起IO,不参与IO过程;
非阻塞IO
- 如何进行非阻塞IO
在一些系统调用接口中,如read()/write()
与recv()/send()
等函数进行IO操作时,默认采用的是阻塞IO的方式;
当然recv()/send()
系统调用接口可根据flag
参数来设置使用阻塞还是非阻塞的IO方式;
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
这两个函数中的flag
参数选项有许多,但用来设置非阻塞IO的选项为MSG_DONTWAIT
;
但这种方式在实际的使用过程中并不方便,即使当需要设置一个文件描述符为非阻塞式IO时都要使用对应的函数并设置的选项,且使用这种方式进行非阻塞IO时只是在此操作中进行了一次非阻塞IO;
相比之下使用fcntl()
系统调用接口将会在IO中使IO操作更加灵活;
fcntl( )
NAME
fcntl - manipulate file descriptor
SYNOPSIS
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */ );
DESCRIPTION
fcntl() performs one of the operations described below on the open file descriptor fd. The operation is determined by cmd.
通常情况下,每一个文件描述符的IO操作都是属于阻塞式IO的;
而fcntl()
系统调用接口可以设置一个文件描述符的IO属性为非阻塞模式;
当调用该函数将一个文件描述符的IO属性设置为非阻塞模式时,至手动解除非阻塞模式或是该文件描述符的生命周期结束前,所有关于该文件描述符的IO操作都将视为非阻塞IO操作(如read()/write()
,recv()/send()
等);
这种方式比每次调用send()/recv()
函数时都传递MSG_DONTWAIT
参数更加方便和灵活;
假设存在一个程序,这个程序将调用read()
系统调用接口来获取0
号文件描述符(键盘文件)中的数据并进行打印;
int main()
{
char buff[1024];
while (true)
{
printf("Please Enter # ");
fflush(stdout);
ssize_t n = read(0, buff, sizeof(buff) - 1);
if (n > 0)
{
buff[n - 1] = 0;
cout << "Echo: " << buff << endl;
}
else if (0 == n)
{
cout << "Read Done" << endl;
break;
}
else
{
cout << "Read Error" << endl;
break;
}
}
return 0;
}
以这段代码为例,定义了一个char
类型的数组作为字符串来接收由0
号文件描述符中所接收到的数据,并进行打印;
当程序运行时,进程阻塞并等待键盘文件中的资源就绪,即等待读事件就绪;
当读事件就绪后read()
系统调用函数将数据读出而后进行打印;
在上文中提到,任何文件描述符的默认IO属性都是为阻塞式IO,而通过fcntl()
系统调用接口可以设置一个文件描述符的IO属性;
void SetNonBlock(int fd)
{
int flags = fcntl(fd, F_GETFL); // 获取标记位
if (flags < 0)
{ // 获取标记位失败
perror("fcntl");
return;
}
fcntl(fd, F_SETFL, flags | O_NONBLOCK); // 利用或来增加属性
}
int main()
{
char buff[1024];
SetNonBlock(0); // 调用
while (true)
{
// ...
}
return 0;
}
在原有的代码中增加了一个SetNonBlock()
函数,这个函数是利用fcntl()
系统调用接口实现的;
首先先调用fcnt()
系统调用接口传递F_GETFL
获取对应文件描述符中的标记位fd
;
int flags = fcntl(fd, F_GETFL); // 获取标记位
而后判断标记位是否获取成功,如果获取失败则返回;
随后调用fcnt()
系统调用接口传递F_SETFL
参数并添加O_NONBLOCK
来将标记位以属性的方式设置进对应的文件描述符中;
fcntl(fd, F_SETFL, flags | O_NONBLOCK); // 利用或来增加属性
当进程运行过后将进入非阻塞的轮询状态,不断调用read()
系统调用接口,由于未读到任何有效数据(读事件未就绪),将一直走判断中的else
部分;
键盘的输入与进程中的非阻塞轮询状态并不冲突,当键盘输入并按下回车时表示读事件已经就绪,对应的键盘所输入的信息将被打印出来;
而通常情况下read()
系统调用接口默认是阻塞式IO方式,也验证了可以通过使用fcntl()
系统调用接口来设置一个文件描述符的IO属性为非阻塞IO;
同时为了验证该处所对应的"Read Error"
表示资源未就绪,可以使用strerror()
函数来打印出对应的错误码及错误码信息;
if (n > 0)
{
// ...
}
else if (0 == n)
{
// ...
}
else
{
cout << "Read Error , n = " << n << " errno code: " << errno << " " << strerror(errno) << endl;
}
运行结果为:
表示资源未就绪;
通常情况下可以通过errno
以及strerror(errno)
等方式来判断出错原因;
当然在程序当中最好根据错误码来识别是否出错,因此可以将代码完善一下:
while (true)
{
printf("Please Enter # ");
fflush(stdout);
ssize_t n = read(0, buff, sizeof(buff) - 1);
if (n > 0)
{
buff[n - 1] = 0;
cout << "Echo: " << buff << endl;
}
else if (0 == n)
{
cout << "Read Done" << endl;
break;
}
else
{
if (errno == EWOULDBLOCK)
{
cout << "Resource temporarily unavailable" << endl;
// TODO 可完成其他任务
}
else
{
cout << "Read Error , n = " << n << " errno code: " << errno << " " << strerror(errno) << endl;
}
}
sleep(1);
}
这是典型的非阻塞轮询IO模型,当判断事件未就绪时,执行流可以根据需求完成其他任务(TODO
部分)随后再进行下一次轮询从而提高整体效率;
多路转接 - select( )
select()
函数是用来进行多路转接的一个接口;
IO可以分为 “等 + 拷贝” 两个部分,而select()
函数真正做到的是其中的 “等” 的部分,其可以设置一个进程在IO过程中通过该接口阻塞等待多个文件描述符;
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, struct timeval *timeout);
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);
该函数的返回值(n
表返回值)通常有三种情况:
-
n > 0
返回值大于
0
则表示有n
个文件描述符就绪; -
n == 0
表示超时,没有错误也没有文件描述符就绪;
-
n < 0
表示出错;
select()
函数参数为如下:
-
int nfds
该参数用于标示可能需要检查的文件描述符个数;
通常需要传入 当前进程最大文件描述符
+1
,即maxfd + 1
; -
struct timeval *timeout
该参数类型是一个结构体类型,其定义如下:
The timeout argument for select() is a structure of the following type: struct timeval { time_t tv_sec; /* seconds */ suseconds_t tv_usec; /* microseconds */ };
该类型为系统提供的一个时间结构体;
其中
time_t tv_sec;
表示时间戳,单位为秒,suseconds_t tv_usec;
表示微秒;该变量本质是为
select()
设置等待方式,也是一个超时事件,假设该参数传入[5,0]
,即struct timeval timeout = [5,0]
,则表示当5s
过后将会返回(timeout)一次;若是设置为
[0,0]
则表示立马返回(典型的非阻塞式IO);需要澄清一下的是该参数所设置的等待方式是只用作于一次调用的;
若是该参数设置为
NULL
则表示阻塞等待;同时当使用
select()
并设置等待方式后,该参数就会成为一个输入输出型参数;假设使用
select()
并设置等待方式为[5,0]
,但若是在2s
后有文件描述符的事件就绪,对应的select()
将立即返回,同时其timeout
参数将会变为[3,0]
;
除了上两个参数以外,select()
系统调用接口还有三个类型相同的参数,分别为readfds
,writefds
以及exceptfds
;
这三个参数的类型都为fd_set
,而该类型实际上即为一个位图类型,是有操作系统内核提供,与sigset
类似;
这三个参数分别代表使用select()
系统调用接口所关心的事件类型,即:
- 读事件
- 写事件
- 异常事件
三种事件,其中用户所传入位图中的各个位标识着select()
需要关心哪些文件描述符需要关心哪些事件;
如在读事件对应的参数传入0001
,即表示关心0
号文件描述符的读事件;
同时这三个fd_set
类型的参数同样是输入输出型参数,当用户因调用select()
传入对应的fd_set
类型时,其中位图中的位标识对应文件描述符需要关心的对应事件是否就绪,而当该函数因某些文件描述符中的某些事件就绪后而返回,对应的事件类型中的位图将会被修改为事件已经就绪的文件描述符;
这表明了在使用select()
函数时需要大量的进行位图操作,而这里的位图结构是由操作系统内核提供,为了保证内核安全性,系统不会直接让用户来修改对应的位图结构,因此为用户提供了一系列的位图操作;
即在上文中提到的:
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);
-
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)
清空一个位图集合;
select( ) 的基本使用 - SelectServer服务器
为了验证select()
的功能及其基础的使用,这里实现了一个SelectServer
服务器;
在之前的博客中对Socket
直接进行了封装,这里直接套用原有的代码不进行赘述(参考博客『 Linux 』协议的定制中 " 套接字接口的封装 " 部分,同样的这里还涉及到之前实现的日志系统的demo
);
对于main()
函数而言,只需要实例化一个服务器对象,并且调用服务器对象中的初始化方法Init()
与服务器对象中的启动方法即Start()
;
/* Main.cc */
int main()
{
std::unique_ptr<SelectServer> svr(new SelectServer()); // 防拷贝智能指针
svr->Init();
svr->Start();
return 0;
}
其中SelectServer
即为服务器类型;
同样的,服务器需要提供初始化与启动的方法API;
/* SelectServer.hpp */
static const uint16_t defaultport = 8050;
class SelectServer
{
public:
SelectServer(uint16_t port = defaultport) : _port(port) {}
bool Init()
{
_listensock.Socket();
_listensock.Bind(_port);
_listensock.Listen();
return true;
}
void Start()
{
for (;;){
// ...
}
}
~SelectServer()
{
_listensock.Close();
}
private:
NetSocket _listensock;
uint16_t _port;
};
在这个类中提供了初始化的方法和启动的方法,其中初始化的方法包括监听套接字的创建初始化,绑定以及设置监听;
通常情况下,启动服务器时就可以调用accept()
来获取新的连接;
而在使用select()
时这里不能直接使用accept()
来获取新连接,因为本质上accept()
就是检测并获取_listensock
监听套接字上的读事件,只能在这上面进行二选一;
若是使用select()
时需要一个位图结构,这个位图结构即为内核提供的fd_set
类型;
/* SelectServer.hpp */
class SelectServer
{
public:
void Start()
{
int listensock = _listensock.GetFd();
fd_set rfds; // 读文件描述符集
for (;;)
{
// 不能直接accept 本质上accept就表示检测并获取listensock上的事件
// 新连接的到来等价于读事件的就绪
FD_ZERO(&rfds); // 清空描述符集
FD_SET(listensock, &rfds); // 将文件描述符写入至读文件描述符集中
struct timeval timeout = {5, 0};
int n = select(listensock + 1, &rfds, nullptr, nullptr, &timeout);
switch (n)
{
case 0:
printf("Time out, timeout: %ld . %ld\n", timeout.tv_sec, timeout.tv_usec);
break;
case -1:
printf("Select Error\n");
break;
default:
printf("%d fd Even Ready, timeout: %ld . %ld\n", n, timeout.tv_sec, timeout.tv_usec);
sleep(2);
break;
}
}
}
};
这段代码为SelectServer
服务器中的Start()
启动方法,使用select()
进行事件的查看,首先定义了一个fd_set rfds
文件描述符集(位图);
-
随后循环进行以下操作:
以此调用
FD_ZERO()
方法,将文件描述符集进行一次清空;随后调用
FD_SET()
方法传入_listensock.GetFd()+1
,设置文件描述符中最大值+1
(根据文件描述符的规律为 “顺序向上,有空补空” ,因此SelectServer
服务器中的监听套接字描述符是最大的);创建
timeval
时间结构体并设置timeout
等待方式(超时事件),此处设置为[5,0]
,表示5s
后返回一次(timeout
被设置时为输入输出型参数,每次都在自减,因此需要在循环中循环重新进行设置);最后调用
select()
将文件描述符集传入对应位置;int n = select(listensock + 1, &rfds, nullptr, nullptr, &timeout);
最后根据返回值
n
判断其他操作;
对于返回值n
的判断使用了switch()case
语句的方式,当n > 0
时表示n
个文件描述符已经就绪,当n == 0
时表示超时事件就绪(timeout
一次),当n < 0
时表示select()
出错;
运行程序,并使用telnet
工具配合环回地址测试;
从结果可以看出,当程序运行时,在还没有使用telnet
工具进行连接时,由于select()
没有检测到读事件,在5s
后timeout
一次,对应的timeout
的时间戳已经被减到了[0.0]
;
在第二次调用时等待了两秒作用使用telnet
工具进行本地连接,select()
立即返回,此时也可以观察到timeout
的时间戳发生了变化;
第三次以及后续的timeout
都返回了[4 . 99...]
类似的值,本质是连接一直是存在的,而情况读文件描述符集与将文件描述符集通过select()
以及该函数的调用与返回是有时间上的开销的,即使速度很快;
若是此处将timeout
设置为[ 0 , 0 ]
则说明使用非阻塞的方式进行,这里配合循环显然是一个典型的轮询机制,当然如果使用select()
依旧使用轮询的话将会加大消耗,通常不建议这么使用;
同样的若是timeout
设置为nullptr
则表示使用阻塞式;
当程序运行时却没有连接(事件未就绪)将出于阻塞状态,直至有连接;
通常情况下当使用seletc()
后返回时事件就绪后应该立即对就绪时间进行处理,若上层未处理select()
将一直通知(原因在上文中有介绍);