文章目录
- 一、五种 IO 模型
- 1.阻塞 IO
- 2.非阻塞 IO
- 3.信号驱动 IO
- 4. IO 多路转接
- 5.异步 IO
- 二、高级 IO 重要概念
- 1.同步通信和异步通信
- 2.阻塞和非阻塞
- fcntl 系统调用
- 3.其他高级 IO
- 三、I/O 多路转接之 select
- 1.函数原型
- socket 就绪的条件
- 2.理解 select 的执行过程
- 3.使用示例
- 4. select 的特点
- 5. select 的缺点
- 四、I/O 多路转接之 poll
- 1.函数原型
- 2.使用示例
- 3. poll 的优点
- 4. poll 的缺点
- 五、I/O 多路转接之 epoll
- 1.函数原型
- epoll_create 系统调用
- epoll_ctl 系统调用
- epoll_wait 系统调用
- 2. epoll 工作原理
- 3.使用示例
- 4. epoll 的优点
- 5. epoll 工作方式
- 对比 LT 和 ET
- 理解 ET 模式和非阻塞文件描述符
- 6. epoll 的使用场景
- 六、使用基于 ET 模式的 epoll 设计 Reactor 服务器
- 1.整体框架
- 2.具体实现
- Reactor.hpp
- epoll_server.cc
- Service.hpp
- Accepter.hpp
- Util.hpp
- Sock.hpp
- 3.运行测试
- 4.改进方案
几乎所有的 IO 函数,核心工作其实是两类:
① 等待。
② 拷贝。
IO = 等待 + 拷贝。
就好比钓鱼,钓鱼 = 等鱼上钩 + 把鱼钓上来。
IO 的话题:
① 改变等待的方式。
② 减少单位时间内 “等” 的比重。
高效的 IO 的本质,就是减少单位时间内 “等” 的比重!
一、五种 IO 模型
1.阻塞 IO
阻塞 IO:在内核将数据准备好之前,系统调用会一直等待。所有的套接字,默认都是阻塞方式。
阻塞 IO 是最常见的 IO 模型。
2.非阻塞 IO
非阻塞 IO:如果内核还未将数据准备好,系统调用仍然会直接返回,并且设置 EAGAIN 或 EWOULDBLOCK 错误码。
非阻塞 IO 往往需要程序员以循环的方式反复尝试读写文件描述符,这个过程称为轮询。这对 CPU 来说是较大的浪费,一般只有特定场景下才使用。
3.信号驱动 IO
信号驱动 IO:内核将数据准备好的时候,使用 SIGIO 信号通知进程进行 IO 操作(可以开始拷贝数据)。
4. IO 多路转接
IO 多路转接:实际上,最核心在于 IO 多路转接能够同时等待多个文件描述符就绪。
让
select
去等待,让recv
去拷贝,相当于把 IO 的两个步骤拆分了,让两个不同的函数去完成。
select
接口就叫做多路转接/多路复用,一旦数据准备好,让recv
来读就行。
5.异步 IO
异步 IO:内核在数据拷贝完成时,通知进程。
信号驱动 IO 是告知进程何时可以开始拷贝数据。
异步 IO 是告知进程数据拷贝已经完成。
不需要参与 IO 的任何过程(等待 + 拷贝),只需要处理数据就可以了。
总结:
- 任何的 IO 过程,都包含两个步骤,第一是等待,第二是拷贝。而且在实际的应用场景中,等待消耗的时间往往远远高于拷贝的时间。
- 让 IO 更高效,最核心的方法就是让等待的时间尽量少。
二、高级 IO 重要概念
1.同步通信和异步通信
同步通信和异步通信,关注的是消息通信机制。
-
同步通信:发出一个调用之后,在没有得到结果之前,该调用就不返回。但是一旦调用返回,就得到返回值了。换句话说,就是由调用者主动等待这个调用的结果。
-
异步通信:发出一个调用之后,这个调用就直接返回了,所以没有返回结果。换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果,而是在调用发出后, 被调用者通过状态、通知来通知调用者,或通过回调函数处理这个调用。
同步通信中的 “同步” ,跟进程/线程同步与互斥中的 “同步” ,是两个完全不相干的概念。
进程/线程同步:
- 是进程/线程之间直接的制约关系。
- 为完成某种任务而建立的两个或多个线程,需要在某些位置上协调他们的工作次序而等待、传递信息所产生的制约关系。尤其是在访问临界资源的时候。
2.阻塞和非阻塞
阻塞和非阻塞,关注的是进程在等待调用结果(消息,返回值)时的状态。
- 阻塞调用,是指调用结果返回之前,当前线程会被挂起。调用线程只有在得到结果之后才会返回。
- 非阻塞调用,是指在不能立刻得到结果之前,该调用不会阻塞当前线程。
一个文件描述符,默认是阻塞 IO 。
将文件由阻塞设置为非阻塞,其中一种方法就是调用fcntl
系统调用。
fcntl 系统调用
fcntl
的作用:控制一个打开的文件描述符。
fcntl
的参数:
① fd:文件描述符。
② cmd:指定对文件描述符 fd 所做的操作。
复制一个现有的文件描述符:F_DUPFD
获取/设置文件描述符标记:F_GETFD / F_SETFD
获取/设置文件状态标记:F_GETFL / F_SETFL
获取/设置异步I/O所有权:F_GETOWN / F_SETOWN
获取/设置记录锁:F_GETLK, F_SETLK / F_SETLKW
③ … :可变参数。是否需要,由 cmd 来决定。
fcntl
的返回值:
① 成功,返回值取决于对应的操作。
② 错误,返回 -1 。
我们此处只是使用该函数的第三种功能,获取/设置文件状态标记,这样就可以将文件描述符由阻塞设置为非阻塞。
在非阻塞情况下,调用read
读取数据时,如果数据没有就绪,系统是以出错的形式返回的(实际上不是真正的错误,只是以出错的形式返回)。没有就绪和真正的出错,都是出错返回,那么如何进一步区分它们呢?这就需要判断 errno 的值了。
在非阻塞情况下,调用
read
时,若数据没有就绪,read
会出错返回 -1 ,并且 errno 会被设置为 EAGAIN 或 EWOULDBLOCK 。
EAGAIN的值是 11 。
测试代码:将标准输入文件描述符设置为非阻塞,以轮询方式读取标准输入。
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
// 将一个fd设置为非阻塞
void SetNonBlock(int fd)
{
// 1. 先将当前的fd属性(位图)取出来
int fl = fcntl(fd, F_GETFL);
if(fd < 0)
{
perror("fcntl");
return;
}
// 2. 再将fd属性连同O_NONBLOCK设置回fd中
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
int main()
{
SetNonBlock(0); // 将标准输入文件描述符设置为非阻塞
while(1){
char buffer[1024];
// 重点是read
ssize_t s = read(0, buffer, sizeof(buffer)-1);
if(s > 0)
{
buffer[s] = 0;
write(1, buffer, strlen(buffer));
printf("read success, s: %d, errno: %d\n", s, errno);
}
else{
if(errno == EAGAIN || errno == EWOULDBLOCK)
{
printf("数据没有准备好,再试试吧!\n");
printf("read failed, s: %d, errno: %d\n", s, errno);
// 做做其它事情
sleep(1);
continue;
}
}
}
return 0;
}
运行结果:
3.其他高级 IO
非阻塞 IO 、纪录锁、系统 V 流机制、I/O 多路转接(I/O 多路复用)、readv
和writev
函数以及存储映射 IO(mmap),这些统称为高级 IO 。
我们此处重点讨论的是 I/O 多路转接。
三、I/O 多路转接之 select
1.函数原型
select
的作用:允许一个进程去监视多个(打开的)文件描述符,等到被监视的文件描述符中有一个或多个就绪。
换言之,select
只负责等待,等到 fd 就绪,就通知上层进行读取或者写入。
select
本身没有所谓的读取和写入数据的功能。
虽然
read
、write
、recv
、send
本身也有等待的功能,但它们只能传入一个 fd 。
而select
能够同时等待多个 fd !那么至少有一个 fd 就绪的概率增加了。
select
的参数:
① nfds:所等待的文件描述符中最大的文件描述符值 + 1 ,表示底层需要遍历的文件描述符个数。
② readfds:输入输出型参数,输入:需要监视读事件就绪的 fd 集合,输出:读事件就绪的 fd 。
③ writefds:输入输出型参数,输入:需要监视写事件就绪的 fd 集合,输出:写事件就绪的 fd 。
④ exceptfds:输入输出型参数,输入:需要监视异常事件就绪的 fd 集合,输出:异常事件就绪的 fd 。
⑤ timeout:输入输出型参数,输入:设置函数的等待时间(单位是秒 + 微秒),输出:函数返回后的剩余时间。
select
的返回值:
① 成功,返回关心的 fd 中有多少个 fd 就绪(若为 0 ,表示超时,在 timeout 时间内没有 fd 就绪)。
② 错误,返回 -1 。
fd_set 类型是一个位图结构(其大小在每台机器上都不一样):
① 比特位的位置:代表 fd 的编号。
② 比特位的内容:输入时,用户告诉内核,你要帮我关心的 fd 集合;输出时,内核告诉用户,你关心的那些 fd 上面的事件已经就绪。
有专门的接口对 fd_set 类型的变量进行相关的操作:
socket 就绪的条件
“等” 事件就绪(IO 事件就绪),分为读事件就绪、写事件就绪、异常事件就绪。我们一般只考虑读事件就绪和写事件就绪。
读就绪:
- 监听的 socket 上有新的连接请求。
- socket 内核中,接收缓冲区中的字节数,大于等于低水位标记 SO_RCVLOWAT 。此时可以无阻塞地读该文件描述符,并且返回值大于 0 。
- socket TCP 通信中,对端关闭连接,此时对该 fd 读,则返回 0 。
- socket 上有未处理的错误。
写就绪:
- socket 使用非阻塞 connect 连接成功或失败之后。
- socket 内核中,发送缓冲区中的可用字节数(发送缓冲区的空闲位置大小),大于等于低水位标记 SO_SNDLOWAT ,此时可以无阻塞地写,并且返回值大于 0 。
- socket 的写操作被关闭(close 或者 shutdown)。对一个写操作被关闭的 socket 进行写操作,会触发 SIGPIPE 信号。
- socket 上有未读取的错误。
select
的三种等待策略:
① 只要不就绪,就不返回。(即阻塞)
② 只要不就绪,就立马返回。(即非阻塞)
③ 设置好一定的时间,在这个时间内是 ① ,在这个时间外是 ② 。
这三种等待策略,都是只要就绪立马返回。
关于 struct timeval:
成员说明:
① tv_sec:秒。
② tv_usec:微妙。
可以将select
的 timeout 参数设为:
① NULL ,表示阻塞式等待。函数阻塞等待,直到某个 fd 上有事件就绪。
② { 0, 0 } ,表示非阻塞等待。函数立即返回,不会阻塞。
③ { x, y } ,表示经过 x + y 时间后超时。函数若在等待的 x + y 时间内没有 fd 就绪,就会超时返回。
若将select
的 timeout 参数设为 NULL ,则select
不会返回 0 ,因为是阻塞等待。
select
的核心功能:
- 用户告知内核(输入型),你要帮我关心哪些 fd 上的哪些事件就绪。
- 内核告知用户(输出型),你所关心的那些 fd 上的那些事件已经就绪。
select
因为使用输入输出型参数表示不同的含义,所以意味着后面每一次,都需要对 fd_set 进行重新设置!
调用select
时,把需要关心的 fd 设置进 fd 集合中,但select
返回之后,fd 集合中没有事件就绪的 fd 被清空了,那么之前需要关心的 fd 就不知道了,所以用户必须使用数组或者其它容器,来把历史的合法的 fd 全部保存起来,当再次设置 fd 集合时,就可以通过遍历该数组或容器,把 fd 设置进 fd 集合中。
2.理解 select 的执行过程
理解 select 模型,关键在于理解 fd_set ,为了说明方便,取 fd_set 长度为 1 字节,fd_set 中的每一个 bit 可以对应一个文件描述符 fd 。则 1 字节长的 fd_set ,最多可以对应 8 个 fd 。
(1)fd_set set; FD_ZERO(&set); 则 set 用位表示就是 0000,0000 。
(2)若 fd= 5 ,执行 FD_SET(fd, &set); 后,set 变为 0001,0000(第 5 位置为 1 )。
(3)若再加入 fd= 2,fd = 1 ,则 set 变为 0001,0011 。
(4)执行 select(6, &set, NULL, NULL, NULL) 阻塞等待。 【用户告知内核】
(5)若 fd = 1,fd = 2 上都发生读事件,则 select 返回,此时 set 变为 0000,0011 。(注意:没有读事件发生的 fd = 5 对应的第 5 位被清空)【内核告知用户】
3.使用示例
select
能以单进程的方式处理多个请求。
程序说明:client 给 server 发送字符串消息,server 收到后将其打印出来。
下面程序包含两个文件:
① Sock.hpp:基本通信函数的实现。
② select_server.cc:服务端。
- Sock.hpp:
#pragma once
#include <iostream>
#include <string>
#include <cstring>
#include <cstdlib>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
using namespace std;
class Sock
{
public:
static int Socket()
{
int sock = socket(AF_INET, SOCK_STREAM, 0);
if(sock < 0){
cerr << "socket error!" << endl;
exit(2);
}
// 地址复用
int opt = 1;
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
return sock;
}
static void Bind(int sock, uint16_t port)
{
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(port);
local.sin_addr.s_addr = INADDR_ANY;
if(bind(sock, (struct sockaddr*)&local, sizeof(local)) < 0){
cerr << "bind error!" << endl;
exit(3);
}
}
static void Listen(int sock)
{
if(listen(sock, 5) < 0)
{
cerr << "listen error!" << endl;
exit(4);
}
}
static int Accept(int sock)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int fd = accept(sock, (struct sockaddr*)&peer, &len);
if(fd >= 0){
return fd;
}
return -1;
}
static void Connect(int sock, std::string ip, uint16_t port)
{
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());
if(connect(sock, (struct sockaddr*)&server, sizeof(server)) == 0)
{
cout << "Connect Success!" << endl;
}
else
{
cout << "Connect Failed!" << endl;
exit(5);
}
}
};
- select_server.cc:
#include <iostream>
#include <string>
#include <sys/select.h>
#include "Sock.hpp"
#define NUM (sizeof(fd_set)*8) // fd_set类型中fd个数最多是NUM
int fd_array[NUM]; // 内容>=0,合法的fd;内容是-1,该位置没有fd
static void Usage(std::string proc)
{
std::cout << "Usage: " << proc << " port" << std::endl;
}
// ./select_server port
int main(int argc, char *argv[])
{
if (argc != 2)
{
Usage(argv[0]);
exit(1);
}
uint16_t port = (uint16_t)atoi(argv[1]);
int listen_sock = Sock::Socket();
Sock::Bind(listen_sock, port);
Sock::Listen(listen_sock);
for(int i = 0; i < NUM; i++) // 数组初始化
{
fd_array[i] = -1;
}
// accept用于获取listen_sock上的新连接,是阻塞式等待,会影响服务器的效率
// 站在多路转接的视角,连接到来,对于listen_sock,就是读事件就绪!
// 对于所有的服务器,最开始的时候,只有listen_sock
// 事件循环
fd_set rfds; // 关心读事件就绪的 fd 集合
fd_array[0] = listen_sock;
for (;;)
{
FD_ZERO(&rfds); // 清空rfds中的所有比特位
int max_fd = fd_array[0]; // max_fd用于select的第一个参数
for(int i = 0; i < NUM; ++i) // 以fd_array的视角
{
if(fd_array[i] == -1) continue;
// 下面的都是合法的fd
FD_SET(fd_array[i], &rfds); // 所有要关心读事件的fd,添加到rfds中
if(max_fd < fd_array[i])
{
max_fd = fd_array[i]; // 更新最大fd
}
}
struct timeval timeout = { 0, 0 }; // 可用于select的第五个参数
// 服务器上的所有fd(包括listen_sock),都要交给select进行检测!
// recv, read, write, send, accept:只负责自己最核心的工作,真正的读写
int n = select(max_fd+1, &rfds, nullptr, nullptr, nullptr); // select阻塞等待
switch (n)
{
case -1:
std::cerr << "select error" << std::endl;
break;
case 0:
std::cout << "select timeout" << std::endl;
break;
default:
std::cout << "有fd对应的事件就绪啦!" << std::endl;
for(int i = 0; i < NUM; ++i) // 以fd_array的视角
{
if(fd_array[i] == -1) continue;
// 下面的fd都是合法的fd,但是合法的fd不一定是就绪的fd
if(FD_ISSET(fd_array[i], &rfds))
{
std::cout << "sock: " << fd_array[i] << " 上面有了读事件,可以读取了" << std::endl;
// 一定是读事件就绪了
// 就绪的fd就在fd_array[i]保存!
// read, recv时,一定不会被阻塞!
// 读事件就绪,不一定可以recv, read,有可能是accept!
if(fd_array[i] == listen_sock)
{
std::cout << "listen_sock: " << listen_sock << " 有了新的连接到来" << std::endl;
// accept
int sock = Sock::Accept(listen_sock);
if(sock >= 0)
{
std::cout << "listen_sock: " << listen_sock << " 获取新的连接成功" << std::endl;
// 获取成功
// 新连接到来,不意味着有数据到来!
// 什么时候数据到来,我们不清楚
// 所以托管给select
// 我们有fd_array[],待下一次循环添加进rfds
int pos = 1;
for(; pos < NUM; pos++)
{
if(fd_array[pos] == -1)
break;
}
// 1. 找到了一个位置没有被使用
if(pos < NUM)
{
std::cout << "新连接: " << sock << " 已经被添加到了数组[" << pos << "]的位置" << std::endl;
fd_array[pos] = sock;
}
// 2. 找完了,都没有找到没有被使用的位置
else
{
// 说明服务器已经满载,没法处理新的请求了
std::cout << "服务器已经满载了,关闭新的连接" << std::endl;
close(sock);
}
}
}
else
{
// 普通的sock,读事件就绪啦
// 可以进行读取了,recv, read
// 可是,本次读取不一定能读完,即便读完了,也不一定没有数据包粘包问题
// 但是,我们现在没法解决,因为没有场景,就没有办法针对场景定制协议!
// 仅仅用来测试
std::cout << "sock: " << fd_array[i] << " 上面有普通读取" << std::endl;
char recv_buffer[1024] = { 0 };
ssize_t s = recv(fd_array[i], recv_buffer, sizeof(recv_buffer)-1, 0);
if(s > 0)
{
recv_buffer[s] = '\0';
std::cout << "client[" << fd_array[i] << "]# " << recv_buffer << std::endl;
}
else if(s == 0)
{
std::cout << "sock: " << fd_array[i] << " 关闭了,client退出了" << std::endl;
// 对端关闭了连接
close(fd_array[i]);
std::cout << "已经在数组下标fd_array[" << i << "]中,去掉了sock: " << fd_array[i] << std::endl;
fd_array[i] = -1;
}
else
{
// 读取失败
close(fd_array[i]);
std::cout << "已经在数组下标fd_array[" << i << "]中,去掉了sock: " << fd_array[i] << std::endl;
fd_array[i] = -1;
}
}
}
}
break;
}
}
return 0;
}
运行测试:
多个 client 连上 server 后,给 server 发消息,然后 client 退出。
4. select 的特点
- 可监控的文件描述符个数取决于 sizeof(fd_set) * 8 的值。sizeof(fd_set) 是 fd_set 类型的大小,单位是字节数。每一个 bit 表示一个文件描述符,则服务器上支持的最多的文件描述符个数是 sizeof(fd_set) * 8 。
- 将 fd 加入
select
监控集的同时,还需要再使用一个数据结构 array 保存放到select
监控集中的 fd:
一是用于在select
返回后,array 作为数据源和 fd_set 进行FD_ISSET
判断,来判断哪些 fd 上有事件就绪。
二是select
返回后会把监控集中以前加入的但并无事件发生的 fd 清空,则每次开始select
前都要重新从 array 取得 fd 逐一加入到监控集(FD_ZERO
最先),扫描 array 的同时取得 fd 最大值 maxfd ,用于select
的第一个参数。
5. select 的缺点
- fd_set 能够让
select
同时检测的 fd 的数目太小。 - 每次调用
select
,都需要重新手动设置 fd 集合,从接口使用角度来说非常不便。 - 每次调用
select
,都需要在内核遍历检测传递进来的所有 fd 中哪些 fd 就绪,这个开销在 fd 很多时会很大。 - 每次调用
select
,都需要把 fd 集合从用户拷贝到内核和从内核拷贝到用户,这个开销在 fd 很多时会很大。 - 每次
select
返回后,都需要遍历检测哪些 fd 就绪。
四、I/O 多路转接之 poll
1.函数原型
poll
对select
做了改良。
poll
的作用:允许一个进程去监视多个(打开的)文件描述符,等到被监视的文件描述符中有一个或多个就绪。
poll
的参数:
① fds:需要让函数监视的 fd 集合,该 fd 集合是一个 struct pollfd 结构类型的数组。
② nfds:fds 数组的长度。
③ timeout:设置函数的等待时间,单位是毫秒。
poll
的返回值:
① 成功,返回关心的 fd 中有多少个 fd 就绪(若为 0 ,表示超时,在 timeout 时间内没有 fd 就绪)。
② 错误,返回 -1 。
关于 struct pollfd:
成员说明:
① fd:文件描述符。
② events:输入型参数,表示要关心这个文件描述符上的哪些事件。【用户告知内核】
③ revents:输出型参数,表示 events 关心的事件中有哪些事件就绪。【内核告知用户】
若不需要检测该 struct pollfd 中的 fd ,只需将 fd 设为 < 0 即可。
events 和 revents 可以是以下几个宏的集合:
通过 | 运算可以把关心的事件添加进 events ,通过 & 运算可以检测 revents 中有哪些事件已经就绪。
可以将
poll
的 timeout 参数设为:
① y (y < 0) ,表示阻塞式等待。函数阻塞等待,直到某个 fd 上有事件就绪。
② 0 ,表示非阻塞等待。函数立即返回,不会阻塞。
③ x (x > 0) ,表示经过 x 毫秒后超时。函数若在等待的 x 毫秒内没有 fd 就绪,就会超时返回。
测试代码:将标准输入文件描述符托管给poll
进行等待。
#include <iostream>
#include <unistd.h>
#include <poll.h>
int main()
{
struct pollfd rfds;
rfds.fd = 0;
rfds.events = POLLIN;
rfds.revents = 0;
while (true)
{
int n = poll(&rfds, 1, -1); // 阻塞等待
switch (n)
{
case 0:
std::cout << "time out ..." << std::endl;
break;
case -1:
std::cerr << "poll error" << std::endl;
break;
default:
std::cout << "有事件发生..." << std::endl;
if(rfds.revents & POLLIN)
{
std::cout << rfds.fd << ": 读事件发生了" << std::endl;
char buffer[128];
ssize_t s = read(0, buffer, sizeof(buffer)-1);
if(s > 0)
{
std::cout << "says# " << buffer << std::endl;
}
}
break;
}
}
return 0;
}
运行结果:
2.使用示例
将上面select
的使用示例改为poll
版本:
#include <iostream>
#include <string>
#include <sys/poll.h>
#include "Sock.hpp"
#define NUM 128 // 自己设置struct pollfd数组的最大长度
struct pollfd fd_array[NUM]; // 内容>=0,合法的fd;内容是-1,该位置没有fd
static void Usage(std::string proc)
{
std::cout << "Usage: " << proc << " port" << std::endl;
}
// ./poll_server port
int main(int argc, char *argv[])
{
if (argc != 2)
{
Usage(argv[0]);
exit(1);
}
uint16_t port = (uint16_t)atoi(argv[1]);
int listen_sock = Sock::Socket();
Sock::Bind(listen_sock, port);
Sock::Listen(listen_sock);
for(int i = 0; i < NUM; i++) // 数组初始化
{
fd_array[i].fd = -1;
fd_array[i].events = 0;
fd_array[i].revents = 0;
}
// 对于所有的服务器,最开始的时候,只有listen_sock
fd_array[0].fd = listen_sock;
fd_array[0].events = POLLIN;
fd_array[0].revents = 0;
for (;;)
{
int timeout = 1000; // 可用于poll的第三个参数
// 服务器上的所有fd(包括listen_sock),都要交给poll进行检测!
// recv, read, write, send, accept:只负责自己最核心的工作,真正的读写
int n = poll(fd_array, NUM, -1); // poll阻塞等待
switch (n)
{
case -1:
std::cerr << "poll error" << std::endl;
break;
case 0:
std::cout << "poll timeout" << std::endl;
break;
default:
std::cout << "有fd对应的事件就绪啦!" << std::endl;
for(int i = 0; i < NUM; ++i) // 以fd_array的视角
{
if(fd_array[i].fd == -1) continue;
// 下面的fd都是合法的fd,但是合法的fd不一定是就绪的fd
if(fd_array[i].revents & POLLIN)
{
std::cout << "sock: " << fd_array[i].fd << " 上面有了读事件,可以读取了" << std::endl;
// 一定是读事件就绪了
// 就绪的fd就在fd_array[i]保存!
// read, recv时,一定不会被阻塞!
// 读事件就绪,不一定可以recv, read,有可能是accept!
if(fd_array[i].fd == listen_sock)
{
std::cout << "listen_sock: " << listen_sock << " 有了新的连接到来" << std::endl;
// accept
int sock = Sock::Accept(listen_sock);
if(sock >= 0)
{
std::cout << "listen_sock: " << listen_sock << " 获取新的连接成功" << std::endl;
// 获取成功
// 新连接到来,不意味着有数据到来!
// 什么时候数据到来,我们不清楚
// 所以托管给poll
// 我们有fd_array[]
int pos = 1;
for(; pos < NUM; pos++)
{
if(fd_array[pos].fd == -1)
break;
}
// 1. 找到了一个位置没有被使用
if(pos < NUM)
{
std::cout << "新连接: " << sock << " 已经被添加到了数组[" << pos << "]的位置" << std::endl;
fd_array[pos].fd = sock;
fd_array[pos].events = POLLIN;
fd_array[pos].revents = 0;
}
// 2. 找完了,都没有找到没有被使用的位置
else
{
// 说明服务器已经满载,没法处理新的请求了
std::cout << "服务器已经满载了,关闭新的连接" << std::endl;
close(sock);
}
}
}
else
{
// 普通的sock,读事件就绪啦
// 可以进行读取了,recv, read
// 可是,本次读取不一定能读完,即便读完了,也不一定没有数据包粘包问题
// 但是,我们现在没法解决,因为没有场景,就没有办法针对场景定制协议!
// 仅仅用来测试
std::cout << "sock: " << fd_array[i].fd << " 上面有普通读取" << std::endl;
char recv_buffer[1024] = { 0 };
ssize_t s = recv(fd_array[i].fd, recv_buffer, sizeof(recv_buffer)-1, 0);
if(s > 0)
{
recv_buffer[s] = '\0';
std::cout << "client[" << fd_array[i].fd << "]# " << recv_buffer << std::endl;
}
else if(s == 0)
{
std::cout << "sock: " << fd_array[i].fd << " 关闭了,client退出了" << std::endl;
// 对端关闭了连接
close(fd_array[i].fd);
std::cout << "已经在数组下标fd_array[" << i << "]中,去掉了sock: " << fd_array[i].fd << std::endl;
fd_array[i].fd = -1;
}
else
{
// 读取失败
close(fd_array[i].fd);
std::cout << "已经在数组下标fd_array[" << i << "]中,去掉了sock: " << fd_array[i].fd << std::endl;
fd_array[i].fd = -1;
}
}
}
}
break;
}
}
return 0;
}
运行测试:
多个 client 连上 server 后,给 server 发消息,然后 client 退出。
3. poll 的优点
不同与select
使用三个位图来表示三个 fd_set 的方式,poll
使用一个 struct pollfd 的指针实现。
- struct pollfd 中包含了要监视的 events 和发生的 revents ,不再使用
select
输入输出型参数的方式,接口使用比select
更方便。 poll
并没有最大 fd 数量限制(但是数量过大性能也会下降)。
4. poll 的缺点
- 每次调用
poll
都需要把 struct pollfd 从用户拷贝到内核中,这个开销在 fd 很多时会很大。 - 和
select
函数一样,每次poll
返回后,都需要遍历 struct pollfd 数组来检测哪些 fd 就绪。 - 同时连接的大量客户端在一个时刻可能只有很少数处于就绪状态,因此随着监视的 fd 数目的增长,其效率也会线性下降。
五、I/O 多路转接之 epoll
1.函数原型
按照 man 手册的说法:epoll
是为了监视大批量的文件描述符而作了改进的poll
。
它是在 Linux 内核 2.5.44 中被引进的。
它几乎具备了之前所说的一切优点,被公认为 Linux 下性能最好的多路 I/O 就绪通知方法。
epoll 有 3 个相关的系统调用:epoll_create
、epoll_ctl
、epoll_wait
。
epoll 在接口上就把用户告知内核和内核告知用户这两项工作分开了。
epoll_create 系统调用
epoll_create
的作用:创建一个 epoll 实例。
epoll_create
的参数:
① size:自从 Linux 2.6.8 之后,该参数是被忽略的,但一定要大于 0 。尽量写成 128、256 之类的,主要是为了兼容之前的代码。
epoll_create
的返回值:
① 成功,返回一个关于 epoll 实例的 fd 。
② 错误,返回 -1 。
该 fd 用完之后,必须调用
close
关闭。
epoll_ctl 系统调用
epoll_ctl
的作用:对一个 epoll 实例执行控制操作。
含义是用户告知内核,内核要帮用户关心哪些 fd 上的哪些事件就绪。
epoll_ctl
的参数:
① epfd:指定一个关于 epoll 实例的 fd 。
② op:指定操作的种类。
EPOLL_CTL_ADD:注册一个 fd 到 epfd 中。
EPOLL_CTL_MOD:修改已经注册的 fd 的 event 。
EPOLL_CTL_DEL:从 epfd 中删除一个 fd 。
③ fd:需要监视的文件描述符。
④ event:与 fd 关联,内有成员 events(表示要关心该 fd 上的哪些事件就绪)。
epoll_ctl
的返回值:
① 成功,返回 0 。
② 错误,返回 -1 。
epoll_ctl
不同于select
和poll
的一点是,只要调用一次,内核就永远记住本次设置的 fd 及其事件了。
关于 struct epoll_event:
成员说明:
① events:表示需要关心的事件。
② data:用户数据变量,是一个联合(共用体),由用户进行维护。
events 可以是以下几个宏的集合:
EPOLLIN:表示对应的文件描述符可以读(包括对端 socket 正常关闭)。
EPOLLOUT:表示对应的文件描述符可以写。
EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来)。
EPOLLERR:表示对应的文件描述符发生错误。
EPOLLHUP:表示对应的文件描述符被挂断。
EPOLLET:将 epoll 设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个 socket 的话,需要再次把这个 socket 加入到 epoll 队列里。
epoll_wait 系统调用
epoll_wait
的作用:等待事件就绪。
含义是内核告知用户,用户所关心的那些 fd 上的那些事件已经就绪。
epoll_wait
的参数:
① epfd:指定一个关于 epoll 实例的 fd 。
② events:输出型参数,用户提供的缓冲区,用于接收就绪的事件。
③ maxevents:表明 events 的长度。
④ timeout:设置函数的等待时间,单位是毫秒。
该函数的 timeout 参数的含义,跟
poll
的完全一样,没有区别。
epoll_wait
的返回值:
① 成功,返回监视的 fd 中有多少个 fd 就绪(若为 0 ,表示超时,在 timeout 时间内没有 fd 就绪)。
② 错误,返回 -1 。
- 用户需要自己定义 events 缓冲区,内核只负责把就绪事件数据拷贝到这个 events 数组中。
epoll_wait
不像select
和poll
返回后需要遍历数组来检测哪些 fd 就绪,而是直接把就绪事件按 fd 顺序放到用户提供的 events 缓冲区中,函数返回后直接遍历返回值个即可。- 若 events 接收就绪事件时接收满了(到达了 maxevents),
epoll_wait
会返回。- 返回的就绪事件 epoll_event 中的 data ,内核不会进行修改,当初用户设置成什么样,返回时就是什么样。
2. epoll 工作原理
- 当某一进程调用
epoll_create
方法时, Linux 内核会创建一个描述 epoll 对象的 eventpoll 结构体,这个结构体中有两个成员与 epoll 的使用方式密切相关,这两个成员就是 rbr 和 rdlist 。 - 每一个 epoll 对象都有一个独立的 eventpoll 结构体,用于存放通过
epoll_ctl
方法向 epoll 对象中添加进来的事件。 - 在 epoll 对象中,对于每一个事件,都会建立一个 epitem 结构体。
- 这些事件都会挂载在红黑树中,如此,重复添加的事件就可以通过红黑树而高效地识别出来(红黑树的插入时间效率是 lgn,其中 n 为树的高度)。
- 而所有添加到 epoll 对象中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,当响应的事件发生时会调用这个回调方法。
- 这个回调方法在内核中叫 ep_poll_callback ,它会将发生的事件添加到 rdlist 双链表中。
- 当调用
epoll_wait
检查是否有事件发生时,只需要检查 eventpoll 对象中的 rdlist 双链表中是否有 epitem 元素即可。 - 如果 rdlist 不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户。这个操作的时间复杂度是 O(1) 。
epoll_create
,本质就是在内核中创建一个 epoll 模型,包括创建红黑树、创建就绪队列、使用回调机制。换言之,创建 epoll 模型,内核需要维护对应的红黑树、就绪队列、回调机制。
红黑树、就绪队列、回调机制中的回调方法,它们都是数据结构,这些数据结构会被整合进文件当中,用一个文件描述符去对应这个文件。进程通过这个文件描述符,就能找到对应的 epoll 模型。
一个进程,也可以调用多次epoll_create
,创建多个 epoll 模型。
使用红黑树数据结构,利于管理,同时也更好地去设计epoll_ctl
接口中创建、修改和删除的功能。
epoll_ctl
添加事件时,本质就是插入节点(KV 键值对,Key 是 fd)到红黑树中,建立该 fd 对应的回调策略。
epoll_wait
,本质就是以 O(1) 的时间复杂度,检测就绪队列是否为空,以此来判断是否有事件就绪。
- 由于资源被 OS 管理,所以资源一定有对应的内核数据结构,内核数据结构中一定存在一个进程队列(等待队列)。不同的描述资源的结构体中一定有对应的进程队列(等待队列)。
- 某个资源没有就绪,一个进程要在某个资源下等待,本质就是把该进程的 PCB 状态设为 S 或 D ,然后将它链到描述该资源的结构体中的进程队列里。
- 当资源就绪时,OS 识别到资源就绪,就会在描述该资源的结构体中的进程队列里找到该进程,然后把该进程的状态由 S 或 D 设为 R ,再将它放到运行队列里。这样进程才被唤醒,才拿到对应的资源。
总结一下,epoll
的使用过程就是三部曲:
- 调用
epoll_create
,创建一个 epoll 实例。 - 调用
epoll_ctl
,将要监控的文件描述符进行注册。 - 调用
epoll_wait
,等待文件描述符对应的事件就绪。
3.使用示例
将上面poll
的使用示例改为epoll
版本:
#include <iostream>
#include <unistd.h>
#include <sys/epoll.h>
#include "Sock.hpp"
#define SIZE 128
#define NUM 64
static void Usage(std::string proc)
{
std::cout << "Usage: " << proc << " port" << std::endl;
}
// ./epoll_server port
int main(int argc, char *argv[])
{
if (argc != 2)
{
Usage(argv[0]);
exit(1);
}
// 1. 建立TCP监听socket
uint16_t port = (uint16_t)atoi(argv[1]);
int listen_sock = Sock::Socket();
Sock::Bind(listen_sock, port);
Sock::Listen(listen_sock);
// 2. 创建epoll模型,获得epfd(文件描述符)
int epfd = epoll_create(SIZE);
// 3. 先将listen_sock和它所关心的事件,添加到内核
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = listen_sock;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_sock, &ev); // KV
// 4. 事件循环
volatile bool quit = false;
struct epoll_event revs[NUM];
while (!quit)
{
int timeout = -1;
// 这里传入的数组,仅仅是尝试从内核中拿回来已经就绪的事件
int n = epoll_wait(epfd, revs, NUM, timeout);
switch (n)
{
case 0:
std::cout << "time out ..." << std::endl;
break;
case -1:
std::cerr << "epoll error ..." << std::endl;
break;
default: // 有事件就绪
std::cout << "有事件就绪啦!" << std::endl;
// 5. 处理就绪事件
for (int i = 0; i < n; i++)
{
int sock = revs[i].data.fd;
std::cout << "文件描述符:" << sock << " 上面有事件就绪啦" << std::endl;
if (revs[i].events & EPOLLIN)
{
std::cout << "文件描述符:" << sock << " 有读事件就绪" << std::endl;
if (sock == listen_sock)
{
std::cout << "文件描述符:" << sock << " 有新连接就绪" << std::endl;
// 5.1 处理连接事件
int fd = Sock::Accept(listen_sock);
if (fd >= 0)
{
std::cout << "获取新连接成功啦:" << fd << std::endl;
// 不能立即读取,因为不一定就绪
// 把新的fd托管给epoll!
struct epoll_event _ev;
_ev.events = EPOLLIN;
_ev.data.fd = fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &_ev); // KV
std::cout << "已经将" << fd << " 托管给epoll啦" << std::endl;
}
else
{
// Do nothing!
}
}
else
{
// 5.2 正常的读取处理
std::cout << "文件描述符:" << sock << " 正常数据就绪" << std::endl;
char buffer[1024];
ssize_t s = recv(sock, buffer, sizeof(buffer)-1, 0);
if(s > 0)
{
buffer[s] = 0;
std::cout << "client [" << sock << "]# " << buffer << std::endl;
// 将我们关心的事件更改为EPOLLOUT
// struct epoll_event _ev;
// _ev.events = EPOLLOUT;
// _ev.data.fd = sock;
// epoll_ctl(epfd, EPOLL_CTL_MOD, sock, &_ev);
}
else if(s == 0)
{
// 对端关闭了连接
std::cout << "client quit " << sock << std::endl;
close(sock);
epoll_ctl(epfd, EPOLL_CTL_DEL, sock, nullptr); // Key
std::cout << "sock: " << sock << " delete from epoll success" << std::endl;
}
else
{
// 读取失败
std::cout << "recv error" << std::endl;
close(sock);
epoll_ctl(epfd, EPOLL_CTL_DEL, sock, nullptr); // Key
std::cout << "sock: " << sock << " delete from epoll success" << std::endl;
}
}
}
else if (revs[i].events & EPOLLOUT)
{
// 处理写事件
}
else
{
// TODO
}
}
break;
}
}
close(epfd);
close(listen_sock);
return 0;
}
运行测试:
多个 client 连上 server 后,给 server 发消息,然后 client 退出。
4. epoll 的优点
- 接口使用方便:虽然拆分成了三个函数,但是使用起来反而更方便高效。不需要每次循环都设置关注的 fd ,也做到了将输入输出参数分离开。
- 数据拷贝轻量:只在合适的时候调用
epoll_ctl
将文件描述符结构拷贝到内核中,这个操作并不频繁(而select
和poll
每次循环都要进行拷贝)。 - 事件回调机制:避免使用遍历,而是使用回调函数的方式,将就绪事件加入到就绪队列中。
epoll_wait
在返回时直接访问就绪队列就能拿到所有的就绪事件。 - 没有数量限制:监视 fd 的数目无上限。
5. epoll 工作方式
epoll 有两种工作方式:
- LT(Level Triggered,水平触发):只要底层缓冲区有数据,读事件就会一直就绪。
- ET(Edge Triggered,边缘触发):只有底层缓冲区中的数据变多时,读事件才会就绪。也就是说,若底层缓冲区中的数据没有变多,读事件就不会就绪,即便底层缓冲区中存在数据。
简单来说,epoll 有两种不同的通知方式:
- LT 模式下,只要底层有数据,就会一直通知。
- ET 模式下,只有底层数据变多时,才会通知一次,除此之外不会进行通知。所以通过这种通知策略,就会倒逼程序员一旦每次开始读取数据时,就要一直读,直到读完。
假如有这样一个例子:
我们已经把一个 TCP socket 添加到 epfd 中,这个时候 socket 的另一端被写入了 2KB 的数据。调用epoll_wait
,并且它会返回,说明它已经准备好读取操作。然后调用recv
,只读取了 1KB 的数据,继续调用epoll_wait
…
LT 模式下(epoll
默认):
- 当 epoll 检测到 socket 上事件就绪时,可以不立刻进行处理,或者只处理一部分。
- 如上面的例子,由于只读了 1KB 数据,底层缓冲区中还剩 1KB 数据,在第二次调用
epoll_wait
时,epoll_wait
仍然会立刻返回并通知 socket 读事件就绪。- 直到底层缓冲区中所有的数据都被处理完,
epoll_wait
才不会立刻返回。
ET 模式下(使用
epoll_ctl
添加事件时使用了 EPOLLET 标志):
- 当 epoll 检测到 socket 上事件就绪时,必须立刻处理。
- 如上面的例子,虽然只读了 1KB 数据,底层缓冲区中还剩 1KB 数据,在第二次调用
epoll_wait
时,epoll_wait
不会再返回了。- 也就是说,ET 模式下,文件描述符上的事件就绪后,只有一次处理机会。
- 一般情况下,ET 的性能比 LT 性能更高(
epoll_wait
返回的次数少了很多)。
select
、poll
和epoll
(默认)的工作方式都是 LT 。但epoll
的工作方式可以被设置为 ET 。
对比 LT 和 ET
- LT是 epoll 的默认行为。使用 ET 能够减少 epoll 的触发次数,代价就是强逼着程序员在一次响应就绪过程中就把所有的数据都处理完。
- 使用 ET ,就相当于一个文件描述符就绪之后,不会反复被提示就绪,看起来就比 LT 更高效一些。但是在 LT 下,如果也能做到每次对就绪的文件描述符都立刻处理,不让这个就绪被重复提示的话,其实性能也是一样的。
- 另一方面,ET 的代码复杂程度更高了。
通过实验现象对比 LT 和 ET:
① LT:把使用示例中的对 listen_sock 获取连接的代码注释掉,打印信息保留。此时epoll
的工作方式是 LT ,每次调用epoll_wait
时,只要底层存在还未获取的新连接,返回的就绪事件中都会有 listen_sock 对应的读就绪事件。
实验现象:只要底层存在还未获取的新连接,疯狂打印。
② ET:仍然是把使用示例中的对 listen_sock 获取连接的代码注释掉,打印信息保留。给 listen_sock 对应的 events 中添加上 EPOLLET 。此时epoll
的工作方式是 ET ,每次调用epoll_wait
时,只有底层还未获取的新连接变多时,返回的就绪事件中才会有 listen_sock 对应的读就绪事件。
实验现象:只有底层还未获取的新连接变多时,才会打印消息,且只打印一次。
理解 ET 模式和非阻塞文件描述符
当epoll
的工作方式是 ET 时,由于只有在底层数据变多时才通知一次,所以每次读取数据时,必须保证将本次的数据全部读完,所以就需要使用循环读取。但是,可能会在读取的最后一次阻塞住(因为套接字基本都是阻塞式接口),导致这个单进程被挂起,无法正常对外提供服务。为了解决这个问题,需要将 ET 模式下的所有 fd 都设置为非阻塞,这样在最后一次读取时,就会因为数据未就绪而以出错的形式返回,这样就不会阻塞住。
以上就是 ET 模式下的 fd 必须设置为非阻塞的原因。
使用 ET 模式的epoll
,需要将文件描述符设置为非阻塞。这个不是接口本身的要求,而是工程实践的要求。
在 ET 模式下,读取数据就是要循环读取,直到读出错返回。
假设有这样的一个场景:
服务器接受到一个 10KB 的请求,才会向客户端返回一个应答数据。如果客户端收不到应答,就不会发送第二个 10KB 的请求。服务器(ET 模式)写的代码没有循环读取。
- 服务器接收到了一个 10KB 的请求,
epoll_wait
就会因为读事件就绪而返回。- 服务端写的代码是
read
,并且一次只读 1KB 数据,剩下的 9KB 数据就会待在缓冲区中。- 此时由于 epoll 是 ET 模式,并不会认为文件描述符读就绪。
epoll_wait
就不会再次返回。剩下的 9KB 数据会一直在缓冲区中。直到下一次客户端再给服务器发数据,epoll_wait
才会返回。- 但客户端要接收到服务器的响应,才会发送下一个请求。
- 而服务器要接收到客户端的请求,
epoll_wait
才会返回,才能去读缓冲区中剩余的数据。- 所以双方就会处于这样的一种永久等待的状态。
因此,为了避免像上面这种问题的出现,ET 模式下的服务器必须循环读取,同时为了避免在最后一次读取时阻塞住,还要将文件描述符设为非阻塞。
6. epoll 的使用场景
epoll 的高性能,是有一定的特定场景的。如果选择的场景不适宜,epoll 的性能可能适得其反。
对于多连接,且多连接中只有一部分连接比较活跃时,比较适合使用 epoll 。
例如,典型的一个需要处理上万个客户端的服务器,例如各种互联网 APP 的入口服务器,这样的服务器就很适合 epoll 。
如果只是系统内部,服务器和服务器之间进行通信,只有少数的几个连接,这种情况下使用 epoll 就并不合适。
需要根据具体的需求和场景来决定使用哪种 IO 模型。
六、使用基于 ET 模式的 epoll 设计 Reactor 服务器
Reactor(反应堆模式):通过多路转接的方案,被动地采用事件派发的方式,去反向地调用对应的回调函数。
下面通过一个网络程序(单进程版)来较为详细地说明,如何使用基于 ET 模式的 epoll 来设计一个 Reactor 服务器。
1.整体框架
程序说明:网络版加法计算器。
- client 给 server 发送加法计算请求,server 收到后对其进行计算,计算完成后将响应返回给 client 。
- 用 “X” 字符串来作为请求之间的分隔符,也作为响应之间的分隔符。(定制协议)
- 用 telnet 来模拟 client 。
2.具体实现
下面程序包含六个文件:
① Reactor.hpp:Event 和 Reactor 类的实现。
② epoll_server.cc:服务端。
③ Service.hpp:Recver 、Sender 、Errorer 回调函数的实现。
④ Accepter.hpp:Accepter 回调函数的实现。
⑤ Util.hpp:工具类函数的实现。
⑥ Sock.hpp:基本通信函数的实现。
- 每个 fd ,在应用层都要有专属于自己的输入输出缓冲区。
- 虽然已经对等和拷贝在接口层面上进行了分离,但是在代码逻辑上,依旧是耦合在一起的。通过回调的方式,将就绪事件和 IO 真正读取进行解耦。
epoll
最大的优势在于,就绪事件通知机制。
Reactor.hpp
#pragma once
#include <iostream>
#include <string>
#include <unordered_map>
#include <cstdlib>
#include <sys/epoll.h>
#include <unistd.h>
// 一般处理IO的时候,我们只有三种接口需要处理
// 处理读取
// 处理写入
// 处理异常
#define SIZE 128
#define NUM 64
class Event;
class Reactor;
typedef int (*callback_t)(Event *ev); // 函数指针类型
// 需要让epoll管理的基本节点
class Event
{
public:
// 对应的文件描述符
int sock;
// 定义应用层的收发缓冲区,还是为了解耦,方便管理数据的收发
// 对应的sock,对应的输入缓冲区
std::string inbuffer;
// 对应的sock,对应的输出缓冲区
std::string outbuffer;
// sock设置回调函数
callback_t recver; // 读方法
callback_t sender; // 写方法
callback_t errorer; // 错误方法
// 设置Event回指Reactor的指针
Reactor *R; // 为了方便调用Reactor的成员函数
public:
Event()
{
sock = -1;
recver = nullptr;
sender = nullptr;
errorer = nullptr;
R = nullptr;
}
// 注册回调函数
void RegisterCallback(callback_t _recver, callback_t _sender, callback_t _errorer)
{
recver = _recver;
sender = _sender;
errorer = _errorer;
}
~Event()
{
}
};
// 不需要关心任何sock的类型(listen_sock,普通的sock),都是Event
// 使用该类对Event进行管理
// Reactor : Event = 1 : n
class Reactor
{
private:
int epfd; // epfd -> epoll模型
// 使fd与Event产生关联
std::unordered_map<int, Event *> events; // Reactor类管理的所有的Event的集合
// 不用哈希表,使用struct epoll_event.data.ptr也行,不过应该比较麻烦
public:
Reactor() : epfd(-1)
{
}
// 创建epoll模型
void InitReactor()
{
epfd = epoll_create(SIZE);
if (epfd < 0)
{
std::cerr << "epoll_create error" << std::endl;
exit(2);
}
std::cout << "InitReactor success" << std::endl;
}
// 在epoll中注册Event对应的sock
bool InsertEvent(Event *evp, uint32_t evs)
{
// 1. 将evp指向的sock插入到epoll中
struct epoll_event ev;
ev.events = evs;
ev.data.fd = evp->sock;
if (epoll_ctl(epfd, EPOLL_CTL_ADD, evp->sock, &ev) < 0)
{
std::cerr << "epoll_ctl add event failed" << std::endl;
return false;
}
// 2. 将evp本身插入到unordered_map中
events.insert({evp->sock, evp});
return true;
}
// 在epoll中删除Event对应的sock,并且释放Event
void DeleteEvent(Event *evp)
{
int sock = evp->sock;
auto iter = events.find(sock);
if (iter != events.end())
{
// 1. 将evp指向的sock从epoll中删除它
epoll_ctl(epfd, EPOLL_CTL_DEL, sock, nullptr);
// 2. 将evp从unordered_map中移除
events.erase(iter);
// 3. close
close(sock);
// 4. 释放Event(这步只能交给Reactor去做了)
delete evp;
}
}
// 使能读写(修改epoll中sock关心的事件)
bool EnableRW(int sock, bool enbread, bool enbwrite)
{
struct epoll_event ev;
ev.events = EPOLLET | (enbread ? EPOLLIN : 0) | (enbwrite ? EPOLLOUT : 0);
ev.data.fd = sock;
if (epoll_ctl(epfd, EPOLL_CTL_MOD, sock, &ev) < 0)
{
std::cerr << "epoll_ctl mod event failed" << std::endl;
return false;
}
return true;
}
// 检查哈希表中是否存在sock
bool IsSockOk(int sock)
{
auto iter = events.find(sock);
return iter != events.end();
}
// 就绪事件的派发器逻辑
void Dispatcher(int timeout)
{
struct epoll_event revs[NUM];
int n = epoll_wait(epfd, revs, NUM, timeout);
// 只关心n>0,只有n>0才进入for循环
for (int i = 0; i < n; i++)
{
// 先将这两个变量拷贝一份
int sock = revs[i].data.fd;
uint32_t revents = revs[i].events;
// 代表差错处理,将所有的错误问题全部转化成为让IO函数去解决
if (revents & EPOLLERR)
revents |= (EPOLLIN | EPOLLOUT);
if (revents & EPOLLHUP)
revents |= (EPOLLIN | EPOLLOUT);
// 读数据就绪
if (revents & EPOLLIN)
{
// 直接调用回调方法,执行对应的读取
// 先检查哈希表中是否存在Event对应的sock(因为有可能已经删除了sock)
// 再检查Event的recver方法是否存在(检查是否已注册,因为有的Event是不需要注册的)
if (IsSockOk(sock) && events[sock]->recver)
events[sock]->recver(events[sock]);
}
// 写数据就绪
if (revents & EPOLLOUT)
{
// 直接调用回调方法,执行对应的写入
// 先检查哈希表中是否存在Event对应的sock(因为有可能已经删除了sock)
// 再检查Event的sender方法是否存在(检查是否已注册,因为有的Event是不需要注册的)
// 比如listen_sock,它是不需要注册sender的
if (IsSockOk(sock) && events[sock]->sender)
events[sock]->sender(events[sock]);
}
}
}
~Reactor() {}
};
epoll_server.cc
#include "Reactor.hpp"
#include "Sock.hpp"
#include "Accepter.hpp"
#include "Util.hpp"
static void Usage(std::string proc)
{
std::cout << "Usage: " << proc << " port" << std::endl;
}
int main(int argc, char *argv[])
{
if (argc != 2)
{
Usage(argv[1]);
exit(1);
}
// 1. 创建socket,监听
int listen_sock = Sock::Socket();
SetNonBlock(listen_sock); // 将fd设为非阻塞,ET要求
Sock::Bind(listen_sock, (uint16_t)atoi(argv[1]));
Sock::Listen(listen_sock);
// 2. 创建Reactor对象
// Reactor(反应堆模式):通过多路转接的方案,被动地采用事件派发的方式,去反向地调用对应的回调函数
// 1. 检测到事件 -- epoll
// 2. 派发事件 -- Dispatcher(事件派发)+ IO + 业务处理
// 3. 连接 -- Accepter
// 4. IO -- recver, sender
Reactor *R = new Reactor();
R->InitReactor();
// 3. 封装成Event,给Reactor反应堆添加柴火
// 3.1 有柴火
Event *evp = new Event;
evp->sock = listen_sock;
evp->R = R;
// listen_sock只需要注册读方法,不需要注册其它方法
// 回调方法:Accepter(连接管理器)
// 对应listen_sock来说,它的读取方法就是Accepter
evp->RegisterCallback(Accepter, nullptr, nullptr);
// 3.2 将准备好的柴火放入反应堆Reactor中
R->InsertEvent(evp, EPOLLIN | EPOLLET);
// 4. 开始进行事件派发
int timeout = 1000;
for (;;)
{
R->Dispatcher(timeout);
}
return 0;
}
Service.hpp
#pragma once
#include "Reactor.hpp"
#include "Util.hpp"
#include <cerrno>
#include <string>
#include <vector>
#define ONCE_SIZE 128
// 1: 本轮读取全部完成
//-1: 读取出错
// 0: 对端关闭连接
static int RecverCore(int sock, std::string &inbuffer)
{
while (true) // ET要求
{
char buffer[ONCE_SIZE];
ssize_t s = recv(sock, buffer, sizeof(buffer) - 1, 0);
if (s > 0)
{
buffer[s] = '\0';
// 读取成功
// buffer中可能存在'\0'的有效内容,这里不考虑
inbuffer += buffer; // 放到用户定义的应用层接收缓冲区中
}
else if (s < 0)
{
if (errno == EINTR)
{
// IO被信号打断,虽然非阻塞下概率特别低
continue;
}
if (errno == EAGAIN || errno == EWOULDBLOCK)
{
// 1. 读完,底层没数据了,非阻塞返回到这里
return 1; // success
}
// 2. 真的出错了
return -1;
}
else
{
// s == 0
return 0;
}
}
}
int Recver(Event *evp)
{
std::cout << "Recver has been called" << std::endl;
// 1. 真正地读取(代码逻辑上的解耦)
int result = RecverCore(evp->sock, evp->inbuffer);
// 若result == 0
// 表示对端关闭了连接,那么本端也要关闭连接
// 这跟差错处理的目的一样,所以交给Errorer处理
if (result <= 0)
{
// 差错处理
if (evp->errorer) // 先检查Event的errorer方法是否存在(检查是否已注册)
{
// 将所有的错误源都统一到这个函数里面
evp->errorer(evp);
}
return -1;
}
// 1+2X3+4X5+6X
// 2. 分包 -- 分为一个或者多个报文 -- 解决粘包问题
std::vector<std::string> tokens; // 存放分包之后的一个个报文
std::string sep = "X"; // 分隔符
SplitSegment(evp->inbuffer, &tokens, sep); // inbuffer -> tokens
// 3. 反序列化 -- 针对一个报文 -- 提取有效参与计算或者存储的信息
for (auto &seg : tokens) // 1+2 3+4
{
std::string data1, data2;
// 就是和业务强相关啦
if (Deserialize(seg, &data1, &data2)) // 提取
{ // 只处理有效报文
// 4. 业务逻辑 -- 得到结果
int x = atoi(data1.c_str());
int y = atoi(data2.c_str());
int z = x + y;
// 5. 构建响应 -- 添加到evp->outbuffer
// 2+3X -> 2+3=5X
// 序列化
std::string res = data1;
res += "+";
res += data2;
res += "=";
res += std::to_string(z);
res += sep;
evp->outbuffer += res; // 放到用户定义的应用层发送缓冲区中
}
}
// 6. 尝试直接间接进行发送
// 必须条件成熟了(写事件就绪),才能发送
// 一般只要将报文处理完毕,才需要发送
// 写事件一般都是就绪的,但是用户不一定是就绪的
// 对于写事件,我们通常是按需设置(Recver读到数据就有需求,只有它最懂什么时候开启写事件)
if (!(evp->outbuffer).empty())
{
// evp->outbuffer有数据,就需要发送数据,关注写事件
// 写打开的时候,默认就是就绪的!即便是evp->outbuffer已经满了
// epoll 只要用户重新设置了EPOLLOUT事件,EPOLLOUT至少会再触发一次!
evp->R->EnableRW(evp->sock, true, true); // 关心写事件,读不关闭
}
return 0;
}
// 1: 全部将数据发送完成
// 0: 数据没有发完,但是不能再发了
//-1: 发送失败
int SenderCore(int sock, std::string &outbuffer)
{
int total = 0; // 累计发送了的数据量
const char *start = outbuffer.c_str();
int size = outbuffer.size();
while (true) // ET要求
{
// 从上次的total位置开始发,每次都期望把剩下的全部发完
// 这就是优秀的设计
ssize_t curr = send(sock, start + total, size - total, 0); // 本次成功发了的数据量
if (curr > 0)
{
total += curr; // 说明本次已经成功把数据拷贝到底层缓冲区,total可以右移
if (total == size)
{
// 将数据全部发送完成
outbuffer.clear(); // 删除已拷贝的数据
return 1;
}
}
else
{
if (errno == EINTR)
{
// IO被信号打断,虽然非阻塞下概率特别低
continue;
}
// 数据没有发完,但是不能再发了!
if (errno == EAGAIN || errno == EWOULDBLOCK)
{
outbuffer.erase(0, total); // 删除已拷贝的数据
return 0;
}
// 真的出错了
return -1;
}
}
}
int Sender(Event *evp)
{
// 只有它最懂发送完成与否,所以它知道什么时候关闭事件
std::cout << "Sender has been called" << std::endl;
// 真正地发送(代码逻辑上的解耦)
int result = SenderCore(evp->sock, evp->outbuffer);
if (result == 1)
{
// 已经将evp->outbuffer中的数据全部发完了,所以不关注写事件
// 因为写事件一旦被关注,基本都是就绪的,所以发完数据后就要关闭写事件
evp->R->EnableRW(evp->sock, true, false); // 按需设置
}
else if (result == 0)
{
// 可以什么都不做
// 还需要关注写事件,先不关闭写事件
// 因为evp->outbuffer中还有数据,我们还需要等下一次发送
evp->R->EnableRW(evp->sock, true, true);
}
else
{
// 差错处理
if (evp->errorer) // 先检查Event的errorer方法是否存在(检查是否已注册)
{
// 将所有的错误源都统一到这个函数里面
evp->errorer(evp);
}
}
}
int Errorer(Event *evp)
{
// 所有的差错处理,都集中到Errorer
std::cout << "Errorer has been called" << std::endl;
// 出错就不能怎么办了,只能从反应堆中不再关注,并移除和关闭该evp->sock,并释放Event
evp->R->DeleteEvent(evp);
}
Accepter.hpp
#pragma once
#include "Reactor.hpp"
#include "Sock.hpp"
#include "Service.hpp"
#include "Util.hpp"
// int Accepter(Event *evp)
// {
// std::cout << "有新的连接到来了,就绪的sock是:" << evp->sock << std::endl;
// while(true) // ET要求
// {
// // accept返回值的判断逻辑跟recv和send一样。只不过这里随意写了,并不严谨
// // 其实这里应该单独搞一个AccepterCore函数(把accept放在里面)
// // 让AccepterCore做到真正地获取(代码逻辑上的解耦)
// int sock = Sock::Accept(evp->sock);
// if(sock < 0)
// {
// std::cout << "Accept Done!" << std::endl;
// break;
// }
// std::cout << "Accept success: " << sock << std::endl;
// SetNonBlock(sock); // 将fd设为非阻塞,ET要求
// // 获取连接成功,IO socket,封装成Event,给Reactor反应堆添加柴火
// Event *other_ev = new Event;
// other_ev->sock = sock;
// other_ev->R = evp->R;
// // 对应IO socket来说,它的方法就是读、写、错误处理方法
// // Recver, Sender, Errorer,就是我们代码中的较顶层,只负责真正的读取
// other_ev->RegisterCallback(Recver, Sender, Errorer);
//
// evp->R->InsertEvent(other_ev, EPOLLIN|EPOLLET);
// }
// }
int AccepterCore(Event *evp)
{
while (true) // ET要求
{
int new_sock = Sock::Accept(evp->sock);
if (new_sock >= 0)
{
std::cout << "Accept success: " << new_sock << std::endl;
SetNonBlock(new_sock); // 将fd设为非阻塞,ET要求
// 获取连接成功,IO socket,封装成Event,给Reactor反应堆添加柴火
Event *other_ev = new Event;
other_ev->sock = new_sock;
other_ev->R = evp->R;
// IO socket需要注册三种方法
// Recver, Sender, Errorer,就是我们代码中的较顶层,只负责真正的读取
other_ev->RegisterCallback(Recver, Sender, Errorer);
evp->R->InsertEvent(other_ev, EPOLLIN | EPOLLET);
}
else
{
if (errno == EINTR)
{
// IO被信号打断,虽然非阻塞下概率特别低
continue;
}
if (errno == EAGAIN || errno == EWOULDBLOCK)
{
// 获取完,底层没新连接了,非阻塞返回到这里
return 1; // success
}
// 真的出错了
return -1;
}
}
}
int Accepter(Event *evp)
{
std::cout << "有新的连接到来了,就绪的sock是:" << evp->sock << std::endl;
// 真正地获取(代码逻辑上的解耦)
int result = AccepterCore(evp);
if (result < 0)
{
// 出错处理
std::cerr << "Accept error!" << std::endl;
return -1;
}
std::cout << "Accept Done!" << std::endl;
return 0;
}
Util.hpp
#pragma once
#include <iostream>
#include <string>
#include <vector>
#include <unistd.h>
#include <fcntl.h>
// 工具类
// 设置一个sock成为非阻塞
void SetNonBlock(int sock)
{
// 1. 先将当前的sock属性(位图)取出来
int fl = fcntl(sock, F_GETFL);
if (fl < 0)
{
std::cerr << "fcntl failed" << std::endl;
return;
}
// 2. 再将fd属性连同O_NONBLOCK设置回fd中
fcntl(sock, F_SETFL, fl | O_NONBLOCK);
}
// 分包(解决粘包问题)
// 1+2X3+4X5+6X
// 1+
void SplitSegment(std::string &inbuffer, std::vector<std::string> *tokens, std::string sep)
{
while (true)
{
std::cout << "inbuffer: " << inbuffer << std::endl;
// 先找分隔符
auto pos = inbuffer.find(sep);
if (pos == std::string::npos)
{
// 找不到分隔符,说明已经将inbuffer中的完整报文全部分包完成
// inbuffer中剩下一个残缺报文,或者是没有报文
break;
}
// 分隔符前面的就是一个报文
std::string sub = inbuffer.substr(0, pos); //[) for(int i=5;i<10;i++)
// 截取子串放进tokens中
tokens->push_back(sub);
// 在inbuffer中删除分包后已存储的报文
inbuffer.erase(0, pos + sep.size()); // sep是字符串
}
}
// 提取
bool Deserialize(const std::string &seg, std::string *out1, std::string *out2)
{
// 11+23
std::string op = "+"; // 运算符有可能是字符串
// 先找运算符
auto pos = seg.find(op);
if (pos == std::string::npos)
{
// 找不到运算符,说明这个报文无效
return false;
}
// 提取运算符两侧的数
*out1 = seg.substr(0, pos);
*out2 = seg.substr(pos + op.size());
return true;
}
Sock.hpp
#pragma once
#include <iostream>
#include <string>
#include <cstring>
#include <cstdlib>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
using namespace std;
class Sock
{
public:
static int Socket()
{
int sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock < 0)
{
cerr << "socket error!" << endl;
exit(2);
}
// 地址复用
int opt = 1;
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
return sock;
}
static void Bind(int sock, uint16_t port)
{
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(port);
local.sin_addr.s_addr = INADDR_ANY;
if (bind(sock, (struct sockaddr *)&local, sizeof(local)) < 0)
{
cerr << "bind error!" << endl;
exit(3);
}
}
static void Listen(int sock)
{
if (listen(sock, 5) < 0)
{
cerr << "listen error!" << endl;
exit(4);
}
}
static int Accept(int sock)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int fd = accept(sock, (struct sockaddr *)&peer, &len);
if (fd >= 0)
{
return fd;
}
return -1;
}
static void Connect(int sock, std::string ip, uint16_t port)
{
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());
if (connect(sock, (struct sockaddr *)&server, sizeof(server)) == 0)
{
cout << "Connect Success!" << endl;
}
else
{
cout << "Connect Failed!" << endl;
exit(5);
}
}
};
3.运行测试
4.改进方案
可以把业务处理模块拎出来,交给线程池去处理,在代码逻辑上将业务模块与 IO 模块进行解耦。