一、基本概念
五种IO模型
- 阻塞IO:在内核将数据准备好之前,系统调用会一直等待,所有的套接字,默认都是阻塞方式
- 非阻塞IO:如果内核还未将数据准备好,系统调用仍然会直接返回,并且返回EWOULDBLOCK错误码
- 信号驱动IO:内核将数据准备好的时候,使用SIGIO信号通知应用程序进行IO操作
- IO多路转接:虽然从流程图上看起来和阻塞IO类似,实际上最核心在于IO多路转接能够同时等待多个文件描述符的就绪状态
- 异步IO:由内核在数据拷贝完成时,通知应用程序(而信号驱动是告诉应用程序何时可以开始拷贝数据)
任何IO过程中,都包含两个步骤:第一是等待,第二是拷贝。而且在实际的应用场景中, 等待消耗的时间往往都远高于拷贝的时间。让IO更高效,最核心的办法就是让等待的时间尽量少上面五种IO方案可以用钓鱼的例子来理解:
- 阻塞IO,即钓鱼时,就坐在那里盯着水面,看鱼是否上钩
- 非阻塞IO,即钓鱼时,可以玩玩手机,聊聊天,只要时不时关注一下是否有鱼上钩即可
- 信号驱动IO,即在鱼竿上放置一个铃铛,铃铛响了再去收杆,其他时间自由分配
- IO多路转接,即一个人拿上几十个鱼竿在钓鱼
- 异步IO,即我让其他人来钓鱼,最后钓完鱼,我来收鱼即可
同步通信 vs 异步通信
同步和异步关注的是消息通信机制
- 所谓同步,就是在发出一个调用时,在没有得到结果之前,该调用就不返回. 但是一旦调用返回,就得到返回值了; 换句话说,就是由调用者主动等待这个调用的结果
- 异步则是相反,调用在发出之后,这个调用就直接返回了,所以没有返回结果,换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果,而是在调用发出后,被调用者通过状态、通知来通知调用者,或通过回调函数处理这个调用
注意:这里说的同步/异步和进程线程的同步/异步没有任何关系!!!
进程/线程的同步和异步是根据事件的先后顺序决定的,这里可以理解为在IO时,我们是否主动去接收数据,即最终进行IO的是自己还是其他人。
二、非阻塞IO
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */ );
fcntl
的功能取决于cmd
参数的值,包括但不限于以下几种:
F_DUPFD:复制文件描述符
F_GETFD/F_SETFD:获取文件描述符标志 / 设置文件描述符标志
F_GETFL/F_SETFL:获取文件状态标志 / 设置文件状态标志
F_GETOWN/F_SETOWN:获取/设置异步I/O所有权。
F_GETLK/F_SETLK/F_SETLKW:获取/设置/等待记录锁。
我们只需要用第三个功能就能实现让一个文件描述符变为非阻塞。
#include <iostream>
#include <unistd.h>
#include <fcntl.h>
// 设置非阻塞
void SetNonBlock(int fd)
{
int fl = fcntl(fd, F_GETFL);
if (fl < 0)
{
std::cout << "fcntl err" << std::endl;
exit(1);
}
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] = 0;
std::cout << buffer << std::endl;
}
else if (n == 0)
{
std::cout << "end stdin" << std::endl;
break;
}
else
{
// 非阻塞 --- 如果数据没有准备好,返回值会按照出错的方式返回 -1
// 数据没有准备好 vs 真的出错了 如何分辨?
// 如果数据没有准备好,read函数调用完成后,OS会将errno设置为EWOULDBLOCK / EAGAIN
if (errno == EWOULDBLOCK) // 没有数据
{
std::cout << "缓冲区中没有数据,error:" << errno << std::endl;
}
else if (errno == EINTR) // 被中断了,也不算出错
{
std::cout << "读过程被中断" << std::endl;
}
else // 读出错
{
std::cout << "read err" << std::endl;
break;
}
}
sleep(1);
}
return 0;
}
三、I/O多路转接
1、select
1)接口介绍
#include <sys/select.h> int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
参数
- nfds:指定被监听的文件描述符的总数。通常设置为所有文件描述符中的最大值加1,因为文件描述符是从0开始计数的。
- readfds:指向可读事件对应的文件描述符集合的指针。当
select
返回时,该集合将包含已准备好读取的文件描述符。- writefds:指向可写事件对应的文件描述符集合的指针。当
select
返回时,该集合将包含已准备好写入的文件描述符。- exceptfds:指向异常事件对应的文件描述符集合的指针。当
select
返回时,该集合将包含发生异常的文件描述符。- timeout:指定
select
的超时时间。如果设置为NULL,则select
将一直阻塞,直到有文件描述符就绪;如果设置为0,则select
将立即返回,无论文件描述符是否就绪;如果设置为一个非零的时间值,则select
将在指定的时间内阻塞,直到有文件描述符就绪或超时。返回值
- 在正常情况下,
select
返回满足条件的文件描述符的个数- 如果超时,则返回0
- 如果出错或被某个信号中断,则返回-1
2)代码
// SelectServer.hpp
#pragma once
#include "Socket.hpp" // 我在https://blog.csdn.net/V_zjs/article/details/137426801?spm=1001.2014.3001.5501 中有写过,有需要可以看看
#include "Log.hpp" // 我在https://blog.csdn.net/V_zjs/article/details/138088701?spm=1001.2014.3001.5501 中有写过,有需要可以看看
#include <iostream>
#include <algorithm>
using namespace zxws;
const int defaultbackage = 5;
const int defaultport = 8080;
const int num = sizeof(fd_set) * 8;
class SelectServer
{
public:
SelectServer(int port = defaultbacklog) : _port(port), _listensocketfd(new TcpSocket()), _isrunning(false)
{
}
~SelectServer()
{
}
void Init()
{
_listensocketfd->BuildListenSocket(_port, defaultbackage);
for (auto &sock : _rfds_array)
{
sock = nullptr;
}
_rfds_array[0] = _listensocketfd.get();
}
void Loop()
{
_isrunning = true;
while (_isrunning)
{
fd_set rds; // 输入输出型参数,rds需要每次都被设置
FD_ZERO(&rds);
int max_fd = _listensocketfd->GetSockfd();
for (int i = 0; i < num; i++)
{
if (_rfds_array[i] == nullptr)
continue;
FD_SET(_rfds_array[i]->GetSockfd(), &rds);
max_fd = std::max(max_fd, _rfds_array[i]->GetSockfd());
}
Debug();
struct timeval time = {5, 0};
int n = select(max_fd + 1, &rds, nullptr, nullptr, &time /*nullptr*/);
switch (n)
{
case 0:
lg(Info, "没有数据, time: %u.%u s", time.tv_sec, time.tv_usec);
break;
case -1:
lg(Fatal, "select err");
break;
default:
HandleEven(rds);
break;
}
}
_isrunning = false;
}
void Debug()
{
std::cout << "文件描述符有:";
for (auto sock : _rfds_array)
{
if (!sock)
continue;
std::cout << sock->GetSockfd() << " ";
}
std::cout << "\n";
}
private:
void HandleEven(fd_set &rds)
{
for (int i = 0; i < num; i++)
{
if (_rfds_array[i] == nullptr)
continue;
int fd = _rfds_array[i]->GetSockfd();
// fd 读事件就绪
if (FD_ISSET(fd, &rds))
{
// 如果是监听套接字
if (fd == _listensocketfd->GetSockfd())
{
std::string ip;
uint16_t port;
Socket *sock = _listensocketfd->AcceptConnection(&ip, &port);
if (!sock)
{
lg(Fatal, "获取数据失败");
return;
}
lg(Info, "接收到客户端 ip: %s, port: %d, fd: %d", ip.c_str(), port, sock->GetSockfd());
int pos = 0;
for (; pos < num; pos++)
{
if (_rfds_array[pos] == nullptr)
{
_rfds_array[pos] = sock;
break;
}
}
if (pos == num)
{
sock->CloseSockfd();
delete sock;
lg(Info, "数组已满,无法在添加");
}
}
else // 如果是正常的套接字
{
std::string message;
bool res = _rfds_array[i]->Recv(&message, 1024); // 这里的读取是有问题的,需要序列化和反序列化,定制协议的
if (res == false) // 读写失败
{
// 关闭当前的描述符
_rfds_array[i]->CloseSockfd();
delete _rfds_array[i];
_rfds_array[i] = nullptr;
}
else
{
lg(Info, "client # %s", message.c_str());
}
}
}
}
}
private:
std::unique_ptr<Socket> _listensocketfd;
bool _isrunning;
uint16_t _port;
Socket *_rfds_array[num];
};
#include <iostream>
#include "SelectServer.hpp"
int main(int argc, char* argv[])
{
if(argc!=2)
{
std::cout << " Usrage: " << argv[0] << " port\n" << std::endl;
return 1;
}
uint16_t port = std::stoi(argv[1]);
std::unique_ptr<SelectServer> svr(new SelectServer(port));
svr->Init();
svr->Loop();
return 0;
}
从上面的代码和测试来看,多路转接能够用单进程实现与多个客户端的通信
3)优缺点
优点
- slect只负责等待,可以等待多个fd,IO的时候效率高
缺点
- 每次都需要对select的参数进行重置
- 编写代码的时候,select要使用数据结构对与客户端通信的fd进行记录,所以很多时候都需要遍历,影响select效率
- 用户到内核,内核到用户,每次select调用和返回,都要对位图进行重新设置,用户和内核之间,要一直进行数据拷贝
- select让OS在底层关注所有被置为1的fd时,需要对位图进行遍历,这就是select的第一个参数是max_fd+1的原因,OS需要轮询检测被置为1的fd是否有事件就绪
- fd_set是一个系统提供的类型,fd_set大小是固定的为128字节,即select能够检测的fd总数有上限
总的来说:select的优点是所有多路转接都会有的优点,缺点则主要是select的输入输出型参数导致的。
2、poll
1)接口介绍
#include <poll.h> int poll(struct pollfd *fds, nfds_t nfds, int timeout);
参数
- fds:该结构体数组用于指定程序关心的文件描述符集,每个
struct pollfd
代表一个被监视的文件描述符。- nfds:指定
fds
数组的长度,即要监视的文件描述符的数量。- timeout:指定
poll
函数等待事件发生的超时时间(以毫秒为单位)。
- 特定时间值:表示
poll
调用将在指定的时间内进行阻塞等待,如果超时时间内没有文件描述符上的事件就绪,则poll
将超时返回。0
:表示poll
调用将进行非阻塞等待,无论是否有文件描述符上的事件就绪,都会立即返回。-1
:表示poll
调用后将阻塞等待,直到至少有一个文件描述符上的事件就绪。返回值
- 如果函数调用成功,则返回有事件就绪的文件描述符个数。
- 如果
timeout
时间耗尽且没有文件描述符上的事件就绪,则返回0
。- 如果函数调用失败,则返回
-1
,并设置相应的错误码。struct pollfd { int fd; /* 文件描述符 */ short events; /* 等待的事件集合,例如POLLIN(读就绪)、POLLOUT(写就绪)等 */ short revents; /* 实际发生的事件集合,由内核在调用返回时设置 */ }; // 将输入、输出分离,不用每次都取重新设置参数
这些事件类型由一组位掩码(bitmask)表示,可以使用按位或运算符组合多个事件。以下是一些常用的事件选项:
POLLIN
:数据可读。当指定的文件描述符上有数据可读时,此事件被触发。这通常用于非阻塞套接字,以检测何时可以读取新数据。
POLLOUT
或POLLWRNORM
:数据可写。当指定的文件描述符准备好写数据时,此事件被触发。这可以用于检查套接字是否准备好发送数据。
POLLRDNORM
:等价于POLLIN
。这是POSIX规范中定义的别名,与POLLIN
完全相同。
POLLWRBAND
:优先带外数据可写。这个选项用于带外数据(out-of-band data)的写操作,但现代系统中这个特性已经很少使用。
POLLPRI
:优先数据可读。当指定的文件描述符上有优先(例如带外)数据可读时,此事件被触发。这通常用于处理如SIGURG信号等紧急数据。
POLLERR
:错误发生。当指定的文件描述符发生错误时,此事件被触发。这可以用于检测套接字错误,例如连接被对方关闭。
POLLHUP
:挂起(hang up)。当指定的文件描述符被关闭,或者远程连接关闭时,此事件被触发。这可以用于检测连接断开。
POLLNVAL
:无效请求。当指定的文件描述符无效时,此事件被触发。这通常意味着fd
的值超出了文件描述符的范围,或者文件描述符已经被关闭。
2)代码
// PollServer.hpp
#pragma once
#include "Socket.hpp"
#include "Log.hpp"
#include <poll.h>
#include <iostream>
#include <algorithm>
using namespace zxws;
const int defaultbackage = 5;
const int defaultport = 8080;
const int gnum = 1024;
class PollServer
{
public:
PollServer(int port = defaultbacklog) : _port(port), _listensocketfd(new TcpSocket()), _isrunning(false), _num(gnum),_rfds(nullptr)
{
}
~PollServer()
{
delete []_rfds;
}
void Init()
{
_listensocketfd->BuildListenSocket(_port, defaultbackage);
_rfds = new struct pollfd[_num];
for (int i = 0; i < _num; i++)
{
_rfds[i].fd = -1;
_rfds[i].events = 0;
_rfds[i].revents = 0;
}
_rfds[0].fd = _listensocketfd->GetSockfd();
_rfds[0].events |= POLLIN;
}
void Loop()
{
_isrunning = true;
while (_isrunning)
{
int time = 1000; // 1s
int n = poll(_rfds, _num, time);
switch (n)
{
case 0:
lg(Info, "没有数据");
break;
case -1:
lg(Fatal, "poll err");
break;
default:
HandleEven();
break;
}
}
_isrunning = false;
}
private:
void HandleEven()
{
for (int i = 0; i < _num; i++)
{
if (_rfds[i].fd == -1)
continue;
int fd = _rfds[i].fd;
short event = _rfds[i].revents; // 注意这里要的是 输出型参数 revents 不是 events,别写错了!!!
// 读事件就绪
if (event & POLLIN)
{
// 如果是监听套接字
if (fd == _listensocketfd->GetSockfd())
{
std::string ip;
uint16_t port;
int sock = _listensocketfd->AcceptConnection(&ip, &port);
if (sock == -1)
{
lg(Fatal, "获取数据失败");
return;
}
lg(Info, "接收到客户端 ip: %s, port: %d, fd: %d", ip.c_str(), port, sock);
int pos = 0;
for (; pos < _num; pos++)
{
if (_rfds[pos].fd == -1)
{
_rfds[pos].fd = sock;
_rfds[pos].events = POLLIN;
break;
}
}
if (pos == _num)
{
lg(Info, "数组已满,无法在添加");
}
}
else // 如果是正常的套接字
{
char buffer[1024]{};
ssize_t n = recv(fd, buffer, 1023, 0); // 这里的读取是有问题的,需要序列化和反序列化,定制协议的
if (n <= 0) // 读写失败
{
// 关闭当前的描述符
close(fd);
_rfds[i].fd = -1;
_rfds[i].events = _rfds[i].revents = 0;
}
else
{
buffer[n] = 0;
lg(Info, "client # %s", buffer);
}
}
}
}
}
private:
std::unique_ptr<Socket> _listensocketfd;
bool _isrunning;
uint16_t _port;
struct pollfd *_rfds;
int _num;
};
#include <iostream>
#include "PollServer.hpp"
int main(int argc, char* argv[])
{
if(argc!=2)
{
std::cout << " Usrage: " << argv[0] << " port\n" << std::endl;
return 1;
}
uint16_t port = std::stoi(argv[1]);
std::unique_ptr<PollServer> svr(new PollServer(port));
svr->Init();
svr->Loop();
return 0;
}
3)优缺点
优点
- 可以等待多个fd,效率高
- 输入,输出参数分离(events和revents),不用对poll参数进行重置
- poll关心的fd没有上限,可以根据需要创建数组大小
缺点
- 用户到内核空间,要有数据拷贝 --- 必要开销
- poll应用层,要遍历。在内核层面,要遍历检测,关心的fd是否有对应的事件就绪---OS需要轮询检测被置为1的fd是否有事件就绪
3、epoll
1)接口介绍
#include <sys/epoll.h> int epoll_create(int size);
参数
size
:这是 epoll 实例的大小,即可以监听的文件描述符数量的一个估计值。然而,在实际使用中,这个参数在很多系统上已经不再具有实际的含义,可以将其设置为一个大于0的任意值。返回值
- 如果成功,
epoll_create
返回一个非负整数,代表 epoll 实例的文件描述符。这个描述符将在后续的 epoll 操作中使用,如添加、删除、修改监听的文件描述符,以及等待 I/O 事件的发生。- 如果失败,
epoll_create
返回 -1,并设置 errno 表示错误原因。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
参数
- epfd:这是通过
epoll_create
创建的epoll实例的文件描述符。- op:操作类型,用于指定要进行的操作,有以下三种取值:
- EPOLL_CTL_ADD:向epoll实例中添加一个文件描述符及其关联的事件。
- EPOLL_CTL_MOD:修改已经存在于epoll实例中的文件描述符的监听事件内容。
- EPOLL_CTL_DEL:从epoll实例中删除一个文件描述符。
- fd:这是要进行操作的目标文件描述符,即要注册、修改或删除的文件描述符。
- event:这是一个指向
epoll_event
结构体的指针,用于描述所监听事件的相关信息。如果是删除操作(即op
为EPOLL_CTL_DEL
),该参数可以为NULL。typedef union epoll_data { void *ptr; int fd; uint32_t u32; uint64_t u64; } epoll_data_t; struct epoll_event { __uint32_t events; /* Epoll events */ epoll_data_t data; /* User data variable */ };
返回值
- 如果操作成功,
epoll_ctl
函数返回0。- 如果操作失败,
epoll_ctl
函数返回-1,并设置errno
表示错误原因。
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);
参数
- epfd:这是通过
epoll_create
或epoll_create1
创建的 epoll 实例的文件描述符。- events:这是一个指向
epoll_event
结构体数组的指针,用于存储epoll_wait
返回的就绪事件。- maxevents:告诉内核这个
events
数组有多大,即最大可以返回多少个就绪事件。- timeout:指定等待事件的超时时间(毫秒)。如果
timeout
是 -1,那么epoll_wait
将一直阻塞,直到有文件描述符就绪。如果timeout
是 0,epoll_wait
将立即返回,不管是否有文件描述符就绪。返回值
- 如果成功,
epoll_wait
返回就绪事件的数量。这些事件被存储在events
数组中。方便用户在遍历时,只会遍历需要处理的事件。- 如果调用被中断(比如收到了一个信号),
epoll_wait
返回 -1,并设置errno
为 EINTR。- 如果出错,
epoll_wait
返回 -1,并设置errno
为其他值(比如 EBADF 表示 epfd 不是一个有效的文件描述符)。
2)原理
OS如何知道网卡上的数据是否就绪?硬件中断
OS是如何把外设上面的数据,拿到内存中?根据中断号,执行中断向量表中的方法
所以我们根本没必要像select和poll一样,让OS去轮询的检查是否有文件描述符的事件就绪,我们可以让硬件去通知OS关心的文件描述符上的事件就绪,这样就大大提高了效率。
epoll和poll,select的主要区别在于epoll对于事件的维护是由OS帮助用户完成的,不需要用户进行维护,而poll和select则需要用户对事件手动进行维护,所以epoll原理复杂,但是代码写起来比较简单,同时效率也更高
3)epoll的两种工作模式 --- LT模式 & ET模式
- 1、水平触发Level Triggered 工作模式,epoll默认状态下就是LT工作模式
- 当文件描述符上的事件就绪时,上层可以选择不处理,也可以只处理一部分,epoll会再次通知你,直到数据被处理完
- 2、边缘触发Edge Triggered工作模式
- 当文件描述符上的事件就绪时,上层必须要全部处理完,否则只能等待下次该文件描述符上有新的事件就绪,epoll才会通知你(简单来说就是只有当前文件描述符关心的事件出现变化时---有新的数据到来,epoll才会通知你处理),不然你将无法处理之前剩余的没有处理的数据
其中ET模式的效率会更高,为什么?
从整体上来说,通知一次就处理好数据,和通知多次才把数据处理好,显然前者更优,而ET模式就是倒逼上层要一次性将数据全部处理完,具体到原理,上层将数据接收完,使得接收方的缓冲区会更大,让tcp在通信时,返回给发送方更大的滑动窗口大小,更有可能让发送方发送更多的数据(这里之所以说可能,是因为发送的数据大小还和拥塞窗口有关),从而提高效率
当然LT模式我们也可以让上层一次性将数据读完,但是相较于ET模式的"倒逼"策略,终归是有一定的选择空间的,就跟我们写作业一样,有deadline和没deadline那是两种完全不同的情况。
那么采用ET模式,我们如何保证一次性将数据全部取完呢?我们肯定就需要循环读取数据了,所以阻塞式IO肯定是不行的,一旦它阻塞住,我们将无法往下执行,所以我们只能选择非阻塞式等待,一旦读取的数据量小于缓冲区的大小,就说明数据被读取完了 --- ET模式只能采用非阻塞IO。
4)代码
namespace zxws
{
const static int defaultepfd = -1;
const static int size = 1024;
class Epoller
{
public:
Epoller() : _epfd(defaultepfd) {}
void Init()
{
_epfd = epoll_create(size);
if (defaultepfd == _epfd)
{
lg(Fatal, "epoll_create error, %s : %d", strerror(errno), errno);
exit(-1);
}
lg(Info, "create epoll success , epfd :%d", _epfd);
}
void AddEvent(int sockfd, int events)
{
struct epoll_event ev;
ev.events = events;
ev.data.fd = sockfd;
int n = ::epoll_ctl(_epfd, EPOLL_CTL_ADD, sockfd, &ev);
if (n < 0)
{
lg(Error, "epoll_ctl add error, %s : %d", strerror(errno), errno);
}
}
void ModifyEvent(int sockfd, int events)
{
struct epoll_event ev;
ev.data.fd = sockfd;
ev.events = events;
int n = ::epoll_ctl(_epfd, EPOLL_CTL_MOD, sockfd, &ev);
if (n < 0)
{
lg(Error, "epoll_ctl modify error, %s : %d", strerror(errno), errno);
}
}
void DelEvent(int sockfd)
{
int n = ::epoll_ctl(_epfd, EPOLL_CTL_DEL, sockfd, nullptr);
if (n < 0)
{
lg(Error, "epoll_ctl delete error, %s : %d", strerror(errno), errno);
}
}
int Wait(struct epoll_event *rev, int maxevents, int timeout)
{
int n = ::epoll_wait(_epfd, rev, maxevents, timeout);
return n;
}
~Epoller()
{
if (_epfd >= 0)
close(_epfd);
}
private:
int _epfd;
};
// 服务端TcpServer的写法和之前的select和epoll类似,这里就不赘诉了,有兴趣的可以自己实现一下
}