一. 五种高级IO模型
IO就是input和output,将外设中的数据获取到内存以及将内存中的数据刷新到外设。
但是IO的效率是很慢的,当我们scanf的时候,如果不在键盘上输入数据,那么执行流就会一直阻塞在scanf上,等待用户输入,当用户从键盘输入数据之后,会将用户输入的数据,拷贝到内存。
上述可以看出,其实IO = 等待 + 拷贝,真正导致IO效率低的原因是等待,拷贝的速度还是较为可观的。
所以想要提高IO的效率,就是要减少等待的时间,提高单位时间内拷贝的数量
下面介绍五种高级IO的模型
1.1 阻塞IO
所谓阻塞IO就是和调用scanf一样,硬件没有就绪就会一直等待,等待时间完全浪费,当硬件就绪触发硬中断的时候,才会将数据从外设读取到内存,继续向下执行指令。

1.2 非阻塞IO
在等待外设就绪的时候,可以去执行其他的指令的IO模型,就叫做非阻塞IO。非阻塞IO需要使用fcntl将对应的文件描述符的属性设置为非阻塞状态。

cpp
#include <iostream>
#include <unistd.h>
#include <fcntl.h>
void SetNonBlock(int fd)
{
int fl = fcntl(fd, F_GETFL);
if (fl < 0)
{
std::cerr << "fd error" << std::endl;
return;
}
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
int main()
{
SetNonBlock(0);
char buffer[1024];
while (true)
{
ssize_t n = read(0, buffer, sizeof(buffer - 1));
if (n > 0)
{
// 成功读到数据
buffer[n - 1] = 0;
std::cout << buffer << std::endl;
}
else if (n == 0) // ctrl + d
{
std::cout << "读取结束" << std::endl;
break;
}
else
{
// 非阻塞IO没有正常读取到结果的返回值是-1,和阻塞式IO一样
// 读取错误的返回值也是-1
// 只能通过错误码来判定是错误还是没有读到消息
if (errno == EAGAIN || errno == EWOULDBLOCK)
{
std::cout << "没有正常读取到消息" << std::endl;
sleep(1);
continue;
}
else if (errno == EINTR)
{
std::cout << "被中断" << std::endl;
continue;
}
else
{
std::cerr << "读取出错" << std::endl;
}
}
}
return 0;
}
1.3 信号驱动IO
在IO的时候不阻塞等待,当硬件就绪的时候,OS会发信号通知执行流,要去读取数据了,阻塞等待的时间也可以去执行其他指令。

1.4 多路转接
一次性监听很多的外设,当有外设就绪的时候,就通知对应的执行流去读取数据,这样,用相同的等待时间,可以等到多个就绪的事件,提高了IO的效率。

1.5 异步IO
举个例子,你是一家公司的老板,你要开发一个产品,你只需要将任务派发给执行层,等着他们完成任务就好了。异步IO也是这样,进程只下达读取的命令,但是等和读取拷贝都不由进程自身完成,这种IO模型叫做异步IO模型。

1.6 同步IO VS 异步IO
同步IO,只要参与了IO的等或者拷贝的任意一个阶段的IO模型都是同步IO模型,所以阻塞IO、非阻塞IO、信号驱动IO以及多路转接都属于同步IO。
因为异步IO只负责派发命令,没有参与等或者拷贝的过程,所以是异步IO
二. 多路转接select和poll
有了上面的五种模型,实际上,多路转接模型的效率是最高的,因为在单位时间内可以等到更多的就绪事件。
就绪事件分为:读就绪(接收缓冲区中有数据)、写就绪(发送缓冲区中有空间)一般来说,一个新fd是写就绪的
IO = 等 + 拷贝 ,多路转接的方式都是参与等的过程,而不参与拷贝的过程
下面学习一下多路转接的两种方式select和poll
2.1 select
select只负责等待,一次可以监听多个文件描述符,一旦有文件描述符就绪了,就会通知上层,告诉IO该文件描述符的执行流,这个文件描述符可以进行IO了。
select是通过监听文件描述符就绪事件的通知机制。
select对应的接口:
cpp
NAME
select, pselect, FD_CLR, FD_ISSET, FD_SET, FD_ZERO - synchronous I/O multiplexing
/* According to POSIX.1-2001, POSIX.1-2008 */
#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);
int nfds: 是最大的文件描述符+1,确定底层检测遍历的右边界
cpp
typedef struct
{
/* XPG4.2 requires this member name. Otherwise avoid the name
from the global namespace. */
#ifdef __USE_XOPEN
__fd_mask fds_bits[__FD_SETSIZE / __NFDBITS];
# define __FDS_BITS(set) ((set)->fds_bits)
#else
__fd_mask __fds_bits[__FD_SETSIZE / __NFDBITS];
# define __FDS_BITS(set) ((set)->__fds_bits)
#endif
} fd_set;
fd_set是一张位图
fd_set *readfds :只关心读事件的输入输出型参数,输入时,用户告诉内核,需要监听哪些fd上的哪些读事件,比特位的位置表示fd编号,比特位的0/1表示是否要监听;在输出的时候,内核告诉用户,监听的哪些fd的读事件就绪了,比特位的位置表示fd编号,0表示没有就绪,1表示就绪了。
所以这个参数是一直被修改的,因为一直要被修改,所以每次监听的时候都需要重新设置
fd_set *writefds,fd_set *exceptfds:分别表示fd上的写事件和异常事件,使用方法和readfds的使用方法一样
struct timeval *timeout:是一个结构体,表示select监听的时间,如果是 timeout > 0就表明在timeout时间内阻塞监听,如果 timeout == 0,表示非阻塞监听,timeout < 0表示永久阻塞,只有有就绪的时间才向下执行指令。
返回值是监听事件就绪的文件描述符的个数
- 位图是输入输出的,所以,这个位图在使用的时候,一定会频繁变更
2.位图有多少比特位,就决定了select最多能监听多少个文件描述符,因为fd_set是一个数据类型,也就是其大小是固定值,因此select能同时监听的文件描述符是有上限的。
2.1.1 select的特点和缺点
select的特点:
- 可监听的文件描述符取决于
sizeof(fd_set)的值,sizeof(fd_set)*8就是最大能监听的文件描述符数量 - 将fd加入到select监听的时候,还需要维护一个数组,记录要监听哪些文件描述符
- 1.用于select返回后,数组作为原始数据检测这个文件描述符上监听的事件是否就绪(
FD_ISSET) -
- 在再次监听的时候,作为原始数据,将这些描述符重新加入到
select中监听,可以在这些值中获取最大的文件描述符值,+1作为select的第一个参数
select的缺点:
- 在再次监听的时候,作为原始数据,将这些描述符重新加入到
- 1.用于select返回后,数组作为原始数据检测这个文件描述符上监听的事件是否就绪(
- 因为
select的输入输出参数是同一个参数,所以要在每次监听时,重新设置参数 - 每次调用
select都需要在内核遍历所有传进来要监听的文件描述符,这个开销在fd很多时也很大 select返回后,需要遍历所有被监听的文件描述符,以确定事件是否就绪select内监听的文件描述符数量太小- 每次调用
select都需要将fd拷贝到内核,这个开销在fd很多的时候会很大
2.2.2 SelectServer Demo
cpp
#include <iostream>
#include <memory>
#include "Socket.hpp"
using namespace SocketModule;
using namespace LogModule;
class SelectServer
{
const static int size = sizeof(fd_set) * 8;
const static int defaultfd = -1;
public:
SelectServer(int port) : _listenfd(std::make_unique<TcpSocket>()), _isrunning(false)
{
_listenfd->BuildTcpServer(port);
for (int i = 0; i < size; i++)
_fd_array[i] = defaultfd;
_fd_array[0] = _listenfd->Fd();
}
void Start()
{
_isrunning = true;
while (_isrunning)
{
fd_set rfds;
FD_ZERO(&rfds);
// FD_SET(_listenfd->Fd(), &rfds);
// 因为每次select都会修改rfds,那怎样保证每次交给select监听的套接字是我们原有的套接字呢?
// 需要额外开一个数组,来填写需要监听的套接字,每次把这些套接字设置给rfds
// 在设置的时候,同时记录下最大的fd
int maxfd = _fd_array[0];
for (int i = 0; i < size; i++)
{
if (_fd_array[i] == defaultfd)
continue;
FD_SET(_fd_array[i], &rfds);
if (maxfd < _fd_array[i])
maxfd = _fd_array[i];
}
PrintFd();
int n = select(maxfd + 1, &rfds, nullptr, nullptr, nullptr); // 监听套接字就绪也是读就绪,要让select关心监听套接字
switch (n)
{
case -1:
LOG(LogLevel::ERROR) << "Select error";
break;
case 0:
LOG(LogLevel::INFO) << "Time out";
break;
default:
LOG(LogLevel::DEBUG) << "有事件就绪了..., n : " << n;
Dispatcher(rfds);
break;
}
}
_isrunning = false;
}
void Dispatcher(fd_set &rfds)
{
for (int i = 0; i < size; i++)
{
if (_fd_array[i] == defaultfd)
continue;
if (FD_ISSET(_fd_array[i], &rfds))
{
if (_fd_array[i] == _listenfd->Fd()) // 监听套接字读就绪
{
// 获取新连接
Accepter();
}
else
{
// 连接套接字读就绪,从文件缓冲区中读取数据
Recver(i);
}
}
}
}
void Recver(int pos)
{
LOG(LogLevel::DEBUG) << "开始读取......";
char buffer[1024];
size_t n = recv(_fd_array[pos], buffer, sizeof(buffer) - 1, 0);
if (n > 0)
{
buffer[n] = 0;
std::cout << "Client say@ " << buffer << std::endl;
}
else if (n == 0)
{
LOG(LogLevel::INFO) << " Client quit";
// 关闭套接字
close(_fd_array[pos]);
_fd_array[pos] = defaultfd;
}
else
{
LOG(LogLevel::ERROR) << "Recv error";
close(_fd_array[pos]);
_fd_array[pos] = defaultfd;
}
}
void Accepter()
{
InetAddr client;
int connfd = _listenfd->Accept(&client);
// 监听到新连接到来,但是新连接不一定读就绪,所以还需要将这个获得到的监听套接字交给select
// 因为只有select知道这个套接字是不是就绪了
if (connfd >= 0)
{
LOG(LogLevel::INFO) << "Get a new link, connfd: "
<< connfd << ", client is: " << client.StringAddr();
int pos = 0;
for (pos; pos < size; pos++)
{
if (_fd_array[pos] == defaultfd)
break;
}
if (pos == size)
{
LOG(LogLevel::WARNING) << "Select sever full";
close(connfd);
return;
}
_fd_array[pos] = connfd;
}
}
void Stop()
{
_isrunning = false;
}
void PrintFd()
{
std::cout << "_fd_array[]: ";
for (int i = 0; i < size; i++)
{
if (_fd_array[i] == defaultfd)
continue;
std::cout << _fd_array[i] << " ";
}
std::cout << "\r\n";
}
~SelectServer() {}
private:
std::unique_ptr<Socket> _listenfd;
bool _isrunning;
int _fd_array[size];
};
因为要重复设置监听的事件,所以要记录哪些描述符要被设置,就需要维护一张数组来记录这些需要监听的文件描述符。
2.2 poll
poll也是只负责等待的通知机制。
cpp
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
struct pollfd *fds:
cpp
struct pollfd {
int fd; // 文件描述符
short events; // 让poll监听的具体事件
short revents; // poll监听到的就绪的事件类型
};
设置poll监听的文件描述符以及监听的具体事件,因为poll也要监听多个文件描述符,所以这个参数是设置的监听的文件描述符以及监听事件的数组的起始地址。
nfds_t nfds:监听的文件描述符个数
int timeout:设置监听的类型,如果timeout > 0,在timeout时间内阻塞监听,timeout == 0非阻塞监听,timeout < 0永久阻塞监听
返回值是监听事件就绪的文件描述符的个数
poll监听的事件

poll解决了select什么问题
- 因为
poll的输入和输出不是使用同一个参数,所以在使用的时候poll不用每次重新设置参数了
调用的时候poll通过events告诉内核要监听这个文件描述符上的什么事件;返回的时候,内核告诉用户,这个文件描述符上要监听的事件已经就绪了 poll等待的文件描述符没有上限,如果监听的数组满了,可以动态扩容,增加监听事件的个数
2.2.1 poll的优点和缺点
poll的优点
poll做到了输入参数和输出参数的解耦,通过pollfd中的属性来设置监听事件event和返回就绪的事件reventpoll的监听数量也没有限制
poll的缺点
poll返回后,需要遍历pollfd,以确定监听的文件描述符时候就绪- 在内核中,也需要遍历所有的被监听的文件描述符,以确定是否就绪
- 同时连接的大量客户端在同一时刻可能只有很少的处于就绪状态,因此随着监视的文件描述符的增长,效率会线性下降
- 每次调用
poll的时候,都需要把pollfd拷贝到内核中
2.2.2 pollSever demo
cpp
#pragma once
#include <iostream>
#include <memory>
#include <sys/poll.h>
#include "Socket.hpp"
using namespace SocketModule;
using namespace LogModule;
class PollServer
{
const static int size = 4096;
const static int defaultfd = -1;
public:
PollServer(int port) : _listenfd(std::make_unique<TcpSocket>()), _isrunning(false)
{
_listenfd->BuildTcpServer(port);
for (int i = 0; i < size; i++)
{
_fds[i].fd = defaultfd;
_fds[i].events = 0;
_fds[i].revents = 0;
}
_fds[0].fd = _listenfd->Fd();
_fds[0].events = POLLIN;
}
void Start()
{
_isrunning = true;
while (_isrunning)
{
PrintFd();
int timeout = -1; // >0在时间片内阻塞等待,0是非阻塞等待,-1是阻塞等待
int n = poll(_fds, size, timeout);
switch (n)
{
case -1:
LOG(LogLevel::ERROR) << "Select error";
break;
case 0:
LOG(LogLevel::INFO) << "Time out";
break;
default:
LOG(LogLevel::DEBUG) << "有事件就绪了..., n : " << n;
Dispatcher();
break;
}
}
_isrunning = false;
}
void Dispatcher()
{
for (int i = 0; i < size; i++)
{
if (_fds[i].fd == defaultfd)
continue;
if (_fds[i].revents & POLLIN)
{
if (_fds[i].fd == _listenfd->Fd()) // 监听套接字读就绪
{
// 获取新连接
Accepter();
}
else
{
// 连接套接字读就绪,从文件缓冲区中读取数据
Recver(i);
}
}
}
}
void Recver(int pos)
{
LOG(LogLevel::DEBUG) << "开始读取......";
char buffer[1024];
size_t n = recv(_fds[pos].fd, buffer, sizeof(buffer) - 1, 0);
if (n > 0)
{
buffer[n] = 0;
std::cout << "Client say@ " << buffer << std::endl;
}
else if (n == 0)
{
LOG(LogLevel::INFO) << " Client quit";
// 关闭套接字
close(_fds[pos].fd);
_fds[pos].fd = defaultfd;
_fds[pos].events = 0;
_fds[pos].revents = 0;
}
else
{
LOG(LogLevel::ERROR) << "Recv error";
close(_fds[pos].fd);
_fds[pos].fd = defaultfd;
_fds[pos].events = 0;
_fds[pos].revents = 0;
}
}
void Accepter()
{
InetAddr client;
int connfd = _listenfd->Accept(&client);
// 监听到新连接到来,但是新连接不一定读就绪,所以还需要将这个获得到的监听套接字交给select
// 因为只有select知道这个套接字是不是就绪了
if (connfd >= 0)
{
LOG(LogLevel::INFO) << "Get a new link, connfd: "
<< connfd << ", client is: " << client.StringAddr();
int pos = 0;
for (pos; pos < size; pos++)
{
if (_fds[pos].fd == defaultfd)
break;
}
if (pos == size)
{
LOG(LogLevel::WARNING) << "Poll sever full";
close(connfd);
return;
}
else
{
_fds[pos].fd = connfd;
_fds[pos].events = POLLIN;
_fds[pos].revents = 0;
}
}
}
void Stop()
{
_isrunning = false;
}
void PrintFd()
{
std::cout << "_fd_array[]: ";
for (int i = 0; i < size; i++)
{
if (_fds[i].fd == defaultfd)
continue;
std::cout << _fds[i].fd << " ";
}
std::cout << "\r\n";
}
~PollServer() {}
private:
std::unique_ptr<Socket> _listenfd;
bool _isrunning;
struct pollfd _fds[size];
};