文章目录
- 1 :peach:五种IO模型:peach:
- 1.1 :apple:阻塞IO:apple:
- 1.2 :apple:非阻塞IO:apple:
- 1.3 :apple:信号驱动IO:apple:
- 1.4 :apple:IO多路转接:apple:
- 1.5 :apple:异步IO:apple:
- 1.6 :apple:同步通信&异步通信:apple:
- 1.7 :apple:阻塞&非阻塞:apple:
- 1.8 :apple:总结:apple:
- 1.9 :apple:其他高级IO:apple:
- 2 :peach:非阻塞IO:peach:
- 3 :peach:I/O多路转接之select:peach:
- 3.1 :apple:select函数原型:apple:
- 3.2 :apple:第一版本的SelectServer:apple:
- 3.3 :apple:第二版本的SelectServer:apple:
- 3.3 :apple:socket就绪条件:apple:
- 3.3.1 :lemon:读就绪:lemon:
- 3.3.2 :lemon:写就绪:lemon:
- 3.4 :apple:select缺点:apple:
1 🍑五种IO模型🍑
1.1 🍎阻塞IO🍎
在内核将数据准备好之前,系统调用会一直等待。所有的套接字, 默认都是阻塞方式。
1.2 🍎非阻塞IO🍎
如果内核还未将数据准备好,系统调用仍然会直接返回,并且返回EWOULDBLOCK
错误码。非阻塞IO往往需要程序员循环的方式反复尝试读写文件描述符, 这个过程称为轮询。这对CPU来说是较大的浪费, 一般只有特定场景下才使用。
1.3 🍎信号驱动IO🍎
内核将数据准备好的时候, 使用SIGIO
信号通知应用程序进行IO操作。(注意在这里的拷贝是应用程序的拷贝而不是内核的拷贝)
1.4 🍎IO多路转接🍎
虽然从流程图上看起来和阻塞IO类似,但实际上最核心在于IO多路转接能够同时等待多个文件描述符的就绪状态。
1.5 🍎异步IO🍎
由内核在数据拷贝完成时, 通知应用程序。(而信号驱动是告诉应用程序何时可以开始拷贝数据)
1.6 🍎同步通信&异步通信🍎
- 所谓同步,就是在发出一个调用时,在没有得到结果之前,该调用就不返回. 但是一旦调用返回,就得到返回值了; 换句话说,就是由调用者主动等待这个调用的结果;
- 异步则是相反,调用在发出之后,这个调用就直接返回了,所以没有返回结果; 换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果; 而是在调用发出后,被调用者通过状态、通知来通知调用者,或通过回调函数处理这个调用。
另外, 我们回忆在讲多进程多线程的时候, 也提到同步和互斥。但这里的同步通信和进程之间的同步是完全不想干的概念。
1.7 🍎阻塞&非阻塞🍎
阻塞和非阻塞关注的是程序在等待调用结果(如消息,返回值)时的状态。
- 阻塞调用是指调用结果返回之前,当前线程会被挂起,调用线程只有在得到结果之后才会返回;
- 非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程。
1.8 🍎总结🍎
任何IO过程中, 都包含两个步骤:第一是等待,第二是拷贝。而且在实际的应用场景中, 等待消耗的时间往往都远远高于拷贝的时间。让IO更高效, 最核心的办法就是让等待的时间尽量少。
1.9 🍎其他高级IO🍎
非阻塞IO,纪录锁,系统V流机制,I/O多路转接(也叫I/O多路复用),readv
和writev
函数以及存储映射IO(mmap),这些统称为高级IO。
我们此处重点讨论的是I/O多路转接。
2 🍑非阻塞IO🍑
我们知道,一个文件描述符默认是阻塞IO的。
那我们应该怎样设置为非阻塞的呢?
可以使用fcntl
函数:
SYNOPSIS
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */ );
传入的cmd
的值不同, 后面追加的参数也不相同.
fcntl
函数有5种功能:
- 复制一个现有的描述符(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)
我们此处只是用第三种功能,获取/设置文件状态标记, 就可以将一个文件描述符设置为非阻塞。
void SetNoBlock(int fd)
{
int fc=fcntl(fd, F_GETFL);
if(fc<0)
{
std::cout<<"fcntl fail errno:"<<errno<<strerror(errno)<<std::endl;
return;
}
fcntl(fd, F_SETFL, fc|O_NONBLOCK);
}
- 使用
F_GETFL
将当前的文件描述符的属性取出来(这是一个位图) - 然后再使用
F_SETFL
将文件描述符设置回去. 设置回去的同时, 加上一个O_NONBLOCK
参数
案例:轮询方式读取标准输入
void SetNoBlock(int fd)
{
int fc=fcntl(fd, F_GETFL);
if(fc<0)
{
std::cout<<"fcntl fail errno:"<<errno<<strerror(errno)<<std::endl;
return;
}
fcntl(fd, F_SETFL, fc|O_NONBLOCK);
}
int main()
{
SetNoBlock(0);
while(true)
{
printf(">>>");
fflush(stdout);
char buffer[100];
int n=read(0, buffer, sizeof(buffer)-1);
if(n<0)
{
if(errno == EAGAIN)
{
std::cout<<"please try agagin"<<std::endl;
sleep(1);
continue;
}
else
{
std::cout << "read fail errno:" << errno << " " << strerror(errno) << std::endl;
return 2;
}
}
else if(n==0)
{
std::cout<<"read file end"<<std::endl;
}
else
{
buffer[n]=0;
std::cout<<buffer;
}
}
return 0;
}
代码中注意点:
当我们使用非阻塞方式进行读取时,由于我们没有输入数据所以read调用返回值会小于0(以出错形式返回),所以当返回值小于0时还得判断是否是真正的错误。
验证:
3 🍑I/O多路转接之select🍑
系统提供select
函数来实现多路复用输入/输出模型:
select
系统调用是用来让我们的程序监视多个文件描述符的状态变化的;- 程序会停在
select
这里等待,直到被监视的文件描述符有一个或多个发生了状态改变.
3.1 🍎select函数原型🍎
/* 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);
参数解释:
- 参数
nfds
是需要监视的最大的文件描述符值+1; readfds,writefds,exceptfds
分别对应于需要检测的可读文件描述符的集合,可写文件描述符的集合及异常文件描述符的集合;(均是输入输出型参数)- 参数
timeout
为结构timeval,用来设置select()的等待时间;
我们先来理解下这三个输入输出型参数的作用,拿readfds
为例,用户假定要让操作系统帮助我们关心文件描述符3,6,8,9就绪状态,当select函数返回后,就会将已经就绪的文件描述符通过readfds
参数传递出来,比如此时只有文件描述符3和8就绪,此时在readfds
结构中对应的比特位就会被置1,而没有就绪的事件在位图中就会被置为0(6和9对应的比特位就被置为0)。
参数timeout
取值:
NULL
:则表示select()没有timeout,select将一直被阻塞,直到某个文件描述符上发生了事件;0
:仅检测描述符集合的状态,然后立即返回,并不等待外部事件的发生;- 特定的时间值:如果在指定的时间段里没有事件发生,
select
将超时返回
fd_set
结构:
其实这个结构就是一个整数数组, 更严格的说, 是一个 "位图"结构,使用位图中对应的位来表示要监视的文件描述符。
我们可以计算下它的大小:
OS提供了一组操作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的全部位
timeval
结构:
timeval结构用于描述一段时间长度,如果在这个时间内,需要监视的描述符没有事件发生则函数返回,返回值为0。
select
函数返回值:
- 执行成功则返回文件描述词状态已改变的个数;
- 如果返回0代表在描述词状态改变前已超过timeout时间,没有返回;
- 当有错误发生时则返回-1,错误原因存于errno,此时参数readfds,writefds, exceptfds和timeout的值变成不可预测;
3.2 🍎第一版本的SelectServer🍎
有了上面的认识后我们来简单的编写下select服务器:
大家先看看下面的写法有什么问题?
- 问题1:根据输入输出型参数的特点我们可以知道,当
select
函数返回时会将已经准备就绪的文件描述符设置进rfds
中,同时将没有准备就绪的文件描述符从rfds
清除,但是用户想要继续关心之前关心的文件描述符应该怎么办呢? - 问题2:当已经有n个连接到来了,此时我们能够直接
Accept
吗?很明显是不能够的。因为此时我们要区分文件描述符是不是_listensock
,是的话用_listensock进行accept,否则的话我们使用该文件描述符进行数据IO。
有了上面的问题,我们可以选择合适的解决方式:比如我们可以创建fd数组
来记录我们想要内核帮助关心的文件描述符,每次select
前都用fd数组
数组初始化一下rfds
:
const uint16_t g_port = 8899;
const int N = sizeof(fd_set) * 8;
const int default_fd = -1;
class SelectServer
{
private:
Sock _listensock;
uint16_t _port;
int _fdarr[N];
public:
SelectServer(const uint16_t port = g_port)
: _port(port)
{
}
~SelectServer()
{
_listensock.Close();
}
void init()
{
_listensock.Socket();
_listensock.Bind(_port);
_listensock.Listen();
for (int i = 0; i < N; ++i)
_fdarr[i] = default_fd;
}
void run()
{
_fdarr[0] = _listensock.Fd();
while (true)
{
fd_set rfds;
FD_ZERO(&rfds);
int max_fd = _fdarr[0];
for (int i = 0; i < N; ++i)
{
if (_fdarr[i] != default_fd)
FD_SET(_fdarr[i], &rfds);
max_fd = max(max_fd, _fdarr[i]);
}
// struct timeval timeout = {2, 0};
int n = select(max_fd + 1, &rfds, nullptr, nullptr, nullptr);
if (n > 0)
{
cout << "有一个就绪事件发生了" << endl;
// 表示已经有n个连接到来了,此时我们能够直接accept吗?
hand_event(rfds);
printf_fd();
}
else if (n == 0)
{
cout << "time out" << endl;
}
else
{
cout << "select errno:" << errno << ":" << strerror(errno) << endl;
}
}
}
private:
void accepter()
{
string clientip;
uint16_t clientport;
int sock = _listensock.Accept(&clientip, &clientport);
cout << "[ip:port]:" << clientip << ":" << clientport << endl;
int pos = 1;
while (pos < N)
{
if (_fdarr[pos] == default_fd)
{
_fdarr[pos] = sock;
break;
}
++pos;
}
if (pos > N)
{
cout << "_fdarr full" << endl;
close(sock);
}
}
void serverio(int fd, int i)
{
char buffer[1024];
ssize_t n = read(fd, buffer, sizeof(buffer) - 1);
if (n < 0)
{
cout << "read fail" << endl;
return;
}
else if (n == 0)
{
cout << "client close,me too" << endl;
close(fd);
_fdarr[i]=default_fd;
}
else
{
buffer[n - 1] = 0;
cout << "client:" << buffer << endl;
string echo = buffer;
echo += " [select server echo]";
send(fd, echo.c_str(), echo.size(), 0);
}
}
void hand_event(const fd_set &rfds)
{
for (int i = 0; i < N; ++i)
{
if (_fdarr[i] == _listensock.Fd() && FD_ISSET(_listensock.Fd(), &rfds))
{
accepter();
}
else if (_fdarr[i] != _listensock.Fd() && FD_ISSET(_fdarr[i], &rfds))
{
serverio(_fdarr[i], i);
}
}
}
void printf_fd()
{
for (int i = 0; i < N; ++i)
{
if (_fdarr[i] != default_fd)
cout << _fdarr[i] << " ";
}
cout<<endl;
}
};
上面的程序进行serverio
时还会存在着下面的两个问题:
- 我们在
read
和write
时并没有自定义协议读取或者发送数据,由于我们采用的是TCP协议,读取或者发送数据时都是以字节流的形式进行的,所以会存在着粘包问题,因为我们并没有定制协议读取/发送一个完整的报文; - 在进行read读取完数据后,使用write发送数据时也要将fd交给select管理,因为写事件是不一定就绪的。
此时我们可以来验证下:
当我们使用另外一个用户再发起请求:
然后让wjb用户退出:
3.3 🍎第二版本的SelectServer🍎
从select函数原型我们知道select等待的条件不仅有可读,还有可写与异常,假如我们想要同时处理可写与异常应该咋办呢?
其实并不难,我们可以将fd数组替换成一个带有事件方式的自定义类型的数组。
比如参考下面的这种方式:
修改后的版本:
const uint16_t g_port = 8899;
const int N = sizeof(fd_set) * 8;
const int default_fd = -1;
#define READ_EVENT 0X1
#define WRITE_EVENT 0X1 << 1
#define EXPECT_EVENT 0x1 << 2
const uint8_t default_event=READ_EVENT;
struct FdEvent
{
int fd;
uint8_t event;
string clientip;
uint16_t clientport;
};
class SelectServer
{
private:
Sock _listensock;
uint16_t _port;
FdEvent _fdarr[N];
public:
SelectServer(const uint16_t port = g_port)
: _port(port)
{
}
~SelectServer()
{
_listensock.Close();
}
void init()
{
_listensock.Socket();
_listensock.Bind(_port);
_listensock.Listen();
for (int i = 0; i < N; ++i)
{
_fdarr[i].fd = default_fd;
_fdarr[i].event=default_event;
}
}
void run()
{
_fdarr[0].fd = _listensock.Fd();
while (true)
{
fd_set rfds;
FD_ZERO(&rfds);
int max_fd = _fdarr[0].fd;
for (int i = 0; i < N; ++i)
{
if (_fdarr[i].fd != default_fd)
FD_SET(_fdarr[i].fd, &rfds);
max_fd = max(max_fd, _fdarr[i].fd);
}
// struct timeval timeout = {2, 0};
int n = select(max_fd + 1, &rfds, nullptr, nullptr, nullptr);
if (n > 0)
{
cout << "有一个就绪事件发生了" << endl;
// 表示已经有n个连接到来了,此时我们能够直接accept吗?
hand_event(rfds);
printf_fd();
}
else if (n == 0)
{
cout << "time out" << endl;
}
else
{
cout << "select errno:" << errno << ":" << strerror(errno) << endl;
}
}
}
private:
void accepter()
{
string clientip;
uint16_t clientport;
int sock = _listensock.Accept(&clientip, &clientport);
cout << "[ip:port]:" << clientip << ":" << clientport << endl;
int pos = 1;
while (pos < N)
{
if (_fdarr[pos].fd == default_fd)
{
_fdarr[pos].fd = sock;
break;
}
++pos;
}
if (pos > N)
{
cout << "_fdarr full" << endl;
close(sock);
}
}
void serverio(int fd, int i)
{
char buffer[1024];
ssize_t n = read(fd, buffer, sizeof(buffer) - 1);
if (n < 0)
{
cout << "read fail" << endl;
return;
}
else if (n == 0)
{
cout << "client close,me too" << endl;
close(fd);
_fdarr[i].fd=default_fd;
}
else
{
buffer[n - 1] = 0;
cout << "client:" << buffer << endl;
string echo = buffer;
echo += " [select server echo]";
send(fd, echo.c_str(), echo.size(), 0);
}
}
void hand_event(const fd_set &rfds)
{
for (int i = 0; i < N; ++i)
{
if ((_fdarr[i].event & READ_EVENT) && FD_ISSET(_listensock.Fd(), &rfds))
{
if (_fdarr[i].fd == _listensock.Fd())
{
accepter();
}
else if (_fdarr[i].fd != _listensock.Fd())
{
serverio(_fdarr[i].fd, i);
}
else
{
}
}
else if((_fdarr[i].event & WRITE_EVENT) && FD_ISSET(_listensock.Fd(), &rfds))
{
}
}
}
void printf_fd()
{
for (int i = 0; i < N; ++i)
{
if (_fdarr[i].fd != default_fd)
cout << _fdarr[i].fd << " ";
}
cout<<endl;
}
};
注意上面的代码中serverio
还是有着第一个版本的两个问题:粘包问题和没有将write的fd也交给select管理(写事件不一定就绪)。
3.3 🍎socket就绪条件🍎
3.3.1 🍋读就绪🍋
- socket内核中, 接收缓冲区中的字节数, 大于等于低水位标记
SO_RCVLOWAT
. 此时可以无阻塞的读该文件描述符, 并且返回值大于0; - socket TCP通信中, 对端关闭连接, 此时对该socket读, 则返回0;
- 监听的socket上有新的连接请求;
- socket上有未处理的错误;
3.3.2 🍋写就绪🍋
- socket内核中, 发送缓冲区中的可用字节数(发送缓冲区的空闲位置大小), 大于等于低水位标记
SO_SNDLOWAT
, 此时可以无阻塞的写, 并且返回值大于0; - socket的写操作被关闭(close或者shutdown). 对一个写操作被关闭的socket进行写操作, 会触发SIGPIPE信号;
- socket使用非阻塞connect连接成功或失败之后;
- socket上有未读取的错误;
3.4 🍎select缺点🍎
- 每次调用select, 都需要手动设置fd集合, 从接口使用角度来说也非常不便;
- 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大;
- 同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大;
- select支持的文件描述符数量太小;