IO可以分为两个步骤,等待+实际的读写。
比如调用read时,指定套接字文件始终没有数据可以读取(读事件没有就绪),那么read将一直阻塞,直到数据的到来
多路转接是一种高效的IO方式,可以同时监听多个套接字文件,但是不是一个一个的阻塞等待,而是等待多个文件,当有文件就绪时,立即进行IO操作。这样就可以使IO的等待时间重叠,提高效率,减少CPU的空闲时间。常见的多路转接技术有select,poll,epoll,它们都是系统调用接口,具体实现被系统封装与隐藏了。
select是一个多路复用(转接)输入/输出模型,它可以让程序监听多个套接字,在其中的一个或多个套接字准备好读写或者发送异常时通知程序
select函数原型如下:
#include
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout)
select出错返回-1,并设置errno。返回0表示没有事件发生。有事件发生时,返回值表示发生事件的文件描述符数量
设置了超时时间,select就会返回在这段时间内,发生事件的文件描述符数量吗? 答案是不一定,发生了一个或多个事件,select可能也会返回。所以select可能提前返回,并且发生事件的文件描述符数量是不确定的,可能为1,可能大于1。我们要用FD_ISSET宏对每个文件描述符进行判断并处理
至于说fd_set结构体,这是一个long int类型(长度与操作系统和编译器有关,32位及以前的系统,长度为4字节,64位系统,长度为8字节。可以用sizeof检查你的平台上long int的大小)的数组。用来存放文件描述符,每一比特位表示一个文件描述符,1/0表示该文件描述符的某一事件是否发生的状态。有以下4个宏可以处理fd_set结构体
可以直接位操作fd_set,但推荐使用宏来操作fd_set,因为fd_set的内部实现因平台而异,直接位操作不仅破坏其封装性和可移植性,还可能引发错误
关于最后一个参数timeout,它是struct timeval类型的指针,以下是struct timeval结构体成员的具体信息
// tv_sec表示秒,tv_usec表示微秒
struct timeval {
time_t tv_sec; /* seconds */
suseconds_t tv_usec; /* microseconds */
};
使用tcp协议的四个步骤:socket,bind,listen,accept。为提高IO效率,使用select函数,
所以select可以根据监听套接字文件的状态变化,检测是否有读事件发生:如果文件从LISTEN->READABLE,就表示发生了读事件。除了LISTEN和READABLE状态,还有一些常见状态
select根据文件的状态,来判断是否有读事件,写事件或者异常事件发生,以返回发生事件的数量
#include "Socket.hpp"
#include
void usage(char *process_name)
{
cout << "usage: " << process_name << " port"
<< endl;
}
// 历史套接字数组和它的长度,长度为1024,表示可以同时运行的套接字数量
// 其实select只能同时监听1024个套接字
int fd_array[sizeof(fd_set) * 8] = {0};
int arr_num = (sizeof(fd_array) / sizeof(fd_array[0]));
// 数组初始值
#define DFL -1
#define BUF_SIZE 1024
// select监听到了事件的发生,调用HandlerEvent处理事件
void HandlerEvent(int listen_sock, fd_set& readfds)
{
for (int i = 0; i < arr_num; ++i)
{
// 跳过默认值,寻找需要监听的套接字
if (fd_array[i] == DFL)
continue;
// 如果发生了读事件
if (FD_ISSET(fd_array[i], &readfds))
{
if (fd_array[i] == listen_sock)
{
// 有新连接了,判断是否能获取该连接
int j = 0;
for (j = 0; j < arr_num; ++j)
{
if (fd_array[j] == DFL)
break;
}
if (j == arr_num)
{
cerr << "当前队列已满" << endl;
}
else
{
// 处理事件
uint16_t peer_port;
string peer_ip;
int server_sock = tcpSock::Accept(listen_sock, &peer_ip, &peer_port);
if (server_sock < 0)
{
cerr << errno << ": " << strerror(errno) << endl;
// accept失败,暂时不管这个连接了
continue;
}
// 将其添加到历史数组中
fd_array[j] = server_sock;
cout << peer_ip << "[" << peer_port << "] 连接..." << endl;
}
} // end of if (fd_array[i] == listen_sock)
else
{
// 创建读缓冲区
char read_buffer[BUF_SIZE] = {0};
// 普通IO事件就绪,此时读取不会阻塞
int ret = recv(fd_array[i], (void*)&read_buffer, sizeof(read_buffer) - 1, 0);
// 读取出错
if (ret < 0)
{
cerr << errno << ": " << strerror(errno) << endl;
// 注意,程序不要直接退出,应该关闭该服务套接字
close(fd_array[i]);
fd_array[i] = DFL;
}
// 对端关闭
else if (0 == ret)
{
cout << "peer close..." << endl;
close(fd_array[i]);
fd_array[i] = DFL;
}
// 读取成功
else
{
read_buffer[ret] = '\0';
// 这里需要对读取的信息进行处理,暂时用打印替代
cout << read_buffer;
}
}
}
}
}
int main(int argc, char *argv[])
{
// 判断调用者是否传入了端口号
if (argc != 2)
{
usage(argv[0]);
exit(-1);
}
// 创建套接字
int listen_sock = tcpSock::Socket();
// 将用户传入的端口绑定到套接字上
tcpSock::Bind(listen_sock, atoi(argv[1]));
// 使监听套接字处于监听状态
tcpSock::Listen(listen_sock);
// 初始化历史套接字数组
for (int i = 0; i < arr_num; ++i)
{
fd_array[i] = DFL;
}
// 默认将listen套接字设置进fd数组
fd_array[0] = listen_sock;
// 不断地检测事件的发生
while (true)
{
fd_set readfds = {0};
int max_fd = DFL;
// 添加读事件监听集
for (int i = 0; i < arr_num; ++i)
{
// 默认值不需要监听,直接跳过,找要监听的套接字
if (fd_array[i] == DFL)
continue;
// 设置套接字到监听事件集中
FD_SET(fd_array[i], &readfds);
// 需要维护select的第一个参数
if (fd_array[i] > max_fd)
max_fd = fd_array[i];
}
// 设置超时时间为5秒
struct timeval timeout = {5, 0};
// 只关心读事件
int n = select(max_fd + 1, &readfds, nullptr, nullptr, &timeout);
switch (n)
{
case 0:
cout << "没有事件发生,但超时了..." << endl;
break;
case -1:
cerr << errno << ":" << strerror(errno) << endl;
break;
default:
HandlerEvent(listen_sock, readfds);
break;
}
}
return 0;
}
poll也是一个用于实现多路转接的函数,其原型如下
#include
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
fds:struct pollfd类型的指针,可以想象成数组,存储了多个struct pollfd
nfds:表示fds的长度,需要监听的套接字数量,可以存在无效套接字
timeout:单位为毫秒,超时时间的设置
函数执行成功,返回监听的描述符集合中,发生事件的描述符个数。如果超时且没有任何事件发生,就返回0。如果调用失败,poll返回-1,并设置errno
poll比select使用简单,select有两个主要问题
poll就是为解决select的这两个问题而生的。这里有一个结构体:struct pollfd
// pollfd结构
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};
该结构体指明了需要监听的文件描述符,需要监听的事件(用户告诉内核),以及监听到的事件(内核告诉用户)。events和revents字段可以用以下常量来指定或测试不同类型的事件
关于这些字段或者更多的字段,需要用到时可以上网查。
这一小节所谈论的poll是指poll系统调用
poll系统调用是用户向内核发起的一种请求,使用poll系统调用可以监听多个文件描述符的状态,等待其中一个或多个就绪或超时。poll系统调用会调用poll函数,poll函数是文件描述符所属的设备或对象提供的一种检查状态的接口,它通常是一个设备驱动程序中实现的函数。poll系统调用在执行过程中,会遍历fds数组中的每个元素,并且调用其对应文件描述符的poll函数来检查其状态,将结果保存在revents域中。如果没有任何文件描述符就绪,poll系统调用会将当前进程挂起到等待队列中,并进入休眠状态。当有设备发生IO事件时,内核会唤醒等待队列中的进程,并重新检查fds数组中的每个元素。
不同类型的文件描述符,如套接字,终端,管道,可能含有不同的poll函数实现。但poll函数主要有两个功能
所以poll系统调用是用户和内核的一种交互方式,而poll函数则是poll系统调用执行过程中所需的一个接口函数
#include "Socket.hpp"
#include
void usage(char *process_name)
{
cout << "usage: " << process_name << " port"
<< endl;
}
#define FDS_SIZE 1024
struct pollfd fds[FDS_SIZE] = {0};
nfds_t fds_count = 1;
#define DFL -1
#define BUF_SIZE 1024
// select监听到了事件的发生,调用HandlerEvent处理事件
void HandlerEvent(int listen_sock)
{
for (int i = 0; i < fds_count; ++i)
{
// 跳过默认值,寻找需要监听的套接字
if (fds[i].fd == DFL)
continue;
// 如果发生了读事件
if (fds[i].revents & POLLIN)
{
// 有新连接了,判断是否能获取该连接
if (fds[i].fd == listen_sock)
{
int j = 0;
for (j = 0; j < fds_count; ++j)
{
if (fds[i].fd == DFL)
break;
}
if (j == FDS_SIZE)
{
cerr << "当前队列已满" << endl;
}
else
{
// 处理事件
uint16_t peer_port;
string peer_ip;
int server_sock = tcpSock::Accept(listen_sock, &peer_ip, &peer_port);
if (server_sock < 0)
{
cerr << errno << ": " << strerror(errno) << endl;
// accept失败,暂时不管这个连接了
continue;
}
// 将其添加到监听队列中
fds[j].fd = server_sock;
// 监听读事件
fds[j].events |= POLLIN;
fds[j].revents = 0;
++fds_count;
cout << peer_ip << "[" << peer_port << "] 连接..." << endl;
}
} // end of if (fds[i].fd == listen_sock)
else
// 普通IO事件就绪,此时读取不会阻塞
{
// 创建读缓冲区
char read_buffer[BUF_SIZE] = {0};
// 读取数据
int ret = recv(fds[i].fd, (void*)&read_buffer, sizeof(read_buffer) - 1, 0);
// 读取出错
if (ret < 0)
{
cerr << errno << ": " << strerror(errno) << endl;
// 注意,程序不要直接退出,应该关闭该服务套接字
close(fds[i].fd);
fds[i].fd = DFL;
}
// 对端关闭
else if (0 == ret)
{
cout << "peer close..." << endl;
close(fds[i].fd);
fds[i].fd = DFL;
}
// 读取成功
else
{
read_buffer[ret] = '\0';
// 这里需要对读取的信息进行处理,暂时用打印替代
cout << "收到了: " << read_buffer;
}
}
}
}
}
int main(int argc, char *argv[])
{
// 判断调用者是否传入了端口号
if (argc != 2)
{
usage(argv[0]);
exit(-1);
}
// 创建套接字
int listen_sock = tcpSock::Socket();
// 将用户传入的端口绑定到套接字上
tcpSock::Bind(listen_sock, atoi(argv[1]));
// 使监听套接字处于监听状态
tcpSock::Listen(listen_sock);
// 默认将listen套接字设置进fd数组
fds[0].fd = listen_sock;
fds[0].events |= POLLIN;
fds[0].revents = 0;
fds_count = 1;
// 初始化fds数组
for (int i = 1; i < FDS_SIZE; ++i)
{
fds[i].fd = DFL;
fds[i].events = 0;
fds[i].revents = 0;
}
// 设置超时时间为2秒
int timeout = 2000;
// 不断地检测事件的发生
while (true)
{
// 只关心读事件
int n = poll(fds, FDS_SIZE, timeout);
switch (n)
{
case 0:
cout << "没有事件发生,但超时了..." << endl;
break;
case -1:
cerr << errno << ":" << strerror(errno) << endl;
break;
default:
HandlerEvent(listen_sock);
break;
}
}
return 0;
}
对比select,poll有以下的优点
但是poll还是没有解决以下问题
epoll有三个主要接口:epoll_creat,epoll_ctl,epoll_wait
#include
int epoll_create(int size);
epoll_create用于创建一个epoll对象,返回其描述符,失败返回-1并设置errno。关于其唯一参数size:自从Linux2.6版本之后,可以忽略该参数,但是要将其设置为大于0的值
#include
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
对epoll描述符的控制接口,可以实现对epoll控制描述符的添加,删除,修改
以下是struct epoll_event的具体成员
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 */
};
events可以是下面几个宏的集合:
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
收集在监控事件中发生的事件
select与poll对于事件的监听采用轮询检测的方式,而epoll不再使用这种低效且浪费资源的方式,转而使用回调函数。利用内核的事件通知机制,当监听的文件描述符发生状态变化时,通过回调函数将其加入到就绪链表中,然后用户可以通过就绪链表获取已经就绪的文件描述符
epoll在内核中主要涉及以下数据结构:
epoll主要用三个结构来管理和存储fd:eventpoll,epitem,eppoll_entry。eventpoll是一个全局对象,对应一个epoll实例。其红黑树和双向链表分别用来存储所有注册到epoll的fd和所有就绪的fd。每个注册到epoll中的fd都有一个对应的epitem对象,它含有一个红黑树节点和一个双向链表节点,分别用来插入到eventpoll中的两个数据结构中,epitem包含了一个eppoll_entry的链表(其pwqlist结构就是用来链接eppoll_entry的)。eppoll_entry包含了等待队列头和等待队列节点(wait_queue_t),用来将fd挂载到设备驱动程序提供的等待队列上,并注册回调函数。
关于红黑树与就绪链表:
eppoll_entry主要用于ep_insert和ep_remove两个函数中,eppoll_entry在函数中的作用是:在等待队列中添加和删除文件描述符
当设备发送IO事件时,设备驱动会遍历其等待队列头对应的链表,并调用每个节点上注册的回调函数
综上,__poll_wait和ep_poll_callback都有将epitem加入到就绪队列中的功能,不同的是:__poll_wait是在设备驱动中被调用,而ep_poll_callback是在epoll_wait中被调用。__poll_wait是在设备事件发生时被动添加epitem,而ep_poll_callback是在用户请求时主动检查并添加epitem
struct epitem {
struct rb_node rbn; /* 红黑树节点 */
struct list_head rdllink; /* 双向链表节点 */
struct epitem *next; /* 指向下一个epitem */
struct epoll_filefd ffd; /* 文件描述符和文件指针 */
struct eventpoll *ep; /* 所属的eventpoll指针 */
struct epoll_event event; /* 事件类型和数据 */
};
struct epoll_item {
struct rb_node rbn;
struct list_head rdllink;
int nwait;
struct list_head pwqlist;
struct epitem *epi;
};
epitem和epoll_item是两个不同的结构
epoll相对于select和poll的优势:
ET和LT是epoll的两种工作模式,默认选择LT,而select和poll只有LT模式。
关于最后一点:EPOLLOUT表示内核的发送缓冲区有数据可写。LT模式下,如果发送缓冲区不满,就会一直触发写事件。所以每次向内核的发送缓冲区写完数据后,需要检查是否要关闭或者开启EPOLLOUT事件(事件的开关根据用户需要发送的数据是否发送完来判断),以免浪费cpu资源。但是在ET模式下,只有发送缓冲区从满到不满才会触发写事件,表示可以进行数据的写入。所以ET不用频繁的进行EPOLLOUT的开关。但是在ET模式下,如果需要下一次的写事件触发来驱动任务,就需要重新注册EPOLLOUT。这是因为重新注册后EPOLLOUT一定会触发一次(相当于手动触发),ET模式下的发送一般都是直接发送,如果数据量太大,没有发送完,那么这时再设置EPOLLOUT,使写事件触发,再次发送数据
事件的触发体现在epoll_wait是否返回该事件上,LT模式下,如果(内核)接收缓冲区的数据没有及时处理完,epoll_wait依旧会返回该事件,以表示读事件的就绪。但在ET模式下,数据没有及时处理完,epoll_wait不会返回该事件,无论是否有新的数据到来。LT模式下,只要发送缓冲区不为满(可写),就会一直触发写事件。ET模式下,只有发送缓冲区从满到不满时,才会触发写事件
总结和补充:
不过选择了ET模式,就要设置fd为非阻塞。因为ET模式需要不断的读取或者发送数据,如果缓冲区满或空了,不能使程序进入阻塞,所以要设置非阻塞
void Util::set_nonblock(int sockfd)
{
int fd_flag = fcntl(sockfd, F_GETFL);
if (fd_flag < 0)
{
cerr << errno << ": " << strerror(errno) << endl;
exit(-FTL_FAIL);
}
int ret = fcntl(sockfd, F_SETFL,fd_flag | O_NONBLOCK);
if (ret < 0)
{
cerr << errno << ": " << strerror(errno) << endl;
exit(-FTL_FAIL);
}
}
#include "Socket.hpp"
#include
#define MAXEVENTS 1024
#define BUF_SIZE 1024
#define CRT_FAL -1
#define CTL_FAL -2
#define WAIT_FAL -3
#define ACP_FAL -4
class epoll_server
{
public:
epoll_server(uint16_t port, int listen_sockfd = -1, int epoll_fd = -1)
: _listen_sockfd(listen_sockfd), _epoll_fd(epoll_fd), _port(port)
{
}
~epoll_server()
{
if (-1 != _listen_sockfd)
close(_listen_sockfd);
if (-1 != _epoll_fd)
close(_epoll_fd);
}
// 监听套接字的初始化
void init_server();
// 使用epoll进行IO
void run_server();
private:
// IO事件的处理
void handler_event(struct epoll_event* revs, int n);
private:
// 监听套接字fd与epoll实例fd
int _listen_sockfd;
int _epoll_fd;
// epoll_server绑定的端口号
uint16_t _port;
};
void epoll_server::init_server()
{
// 创建sock,绑定端口并使之处于监听状态
_listen_sockfd = tcpSock::Socket();
tcpSock::Bind(_listen_sockfd, _port);
tcpSock::Listen(_listen_sockfd);
cout << "init_server done" << endl;
}
void epoll_server::run_server()
{
_epoll_fd = epoll_create(128);
if (-1 == _epoll_fd)
{
cerr << errno << ": " << strerror(errno) << endl;
exit(CRT_FAL);
}
struct epoll_event ev = {0};
ev.events = EPOLLIN;
ev.data.fd = _listen_sockfd;
// 注册文件到epoll实例
int ret = epoll_ctl(_epoll_fd, EPOLL_CTL_ADD, _listen_sockfd, &ev);
if (-1 == ret)
{
cerr << errno << ": " << strerror(errno) << endl;
exit(CTL_FAL);
}
struct epoll_event revs[MAXEVENTS] = {0};
int timeout = 2000; // 设置超时时间2秒
while (true)
{
int n = epoll_wait(_epoll_fd, revs, MAXEVENTS, timeout);
switch (n)
{
case -1:
cerr << errno << ": " << strerror(errno) << endl;
exit(WAIT_FAL);
break;
case 0:
cout << "超时事件内没有事件发生..." << endl;
break;
default:
handler_event(revs, n);
break;
}
}
}
void epoll_server::handler_event(struct epoll_event* revs, int n)
{
for (int i = 0; i < n; ++i)
{
// 发生了读事件
if (revs[i].events & EPOLLIN)
{
// 监听到一个新连接
if (revs[i].data.fd == _listen_sockfd)
{
string peer_ip;
uint16_t peer_port;
int server_sock = tcpSock::Accept(_listen_sockfd, &peer_ip, &peer_port);
if (-1 == server_sock)
{
cerr << errno << ": " << strerror(errno) << endl;
exit(ACP_FAL);
}
// 向epoll实例中注册这个服务套接字
struct epoll_event ev= {0};
ev.events = EPOLLIN;
ev.data.fd = server_sock;
int ret = epoll_ctl(_epoll_fd, EPOLL_CTL_ADD, server_sock, &ev);
if (-1 == ret)
{
cerr << errno << ": " << strerror(errno) << endl;
exit(CTL_FAL);
}
cout << "与客户端[" << peer_ip << "]:" << peer_port << "连接成功" << endl;
}
// 监听到普通IO事件
else
{
char read_buff[BUF_SIZE] = {0};
int ret = recv(revs[i].data.fd, read_buff, BUF_SIZE, 0);
if (ret < 0)
{
cerr << "recv fali" << endl;
close(revs[i].data.fd);
}
else if (ret == 0)
{
cout << "peer close..." << endl;
close(revs[i].data.fd);
}
else
{
cout << "普通IO:" << read_buff;
}
}
}
// 发生了写事件,暂时不处理
else{}
}
}
// Socket.hpp
#pragma once
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
using namespace std;
class tcpSock
{
static const int _backlog = 20;
public:
// 创建套接字文件,并设置套接字选项,使服务器可以立即重启
// 最后返回套接字fd
static int Socket()
{
int listen_sock = socket(AF_INET, SOCK_STREAM, 0);
if (listen_sock < 0)
{
cerr << errno << ": " << strerror(errno) << endl;
exit(-1);
}
int opt = 1;
int ret = setsockopt(listen_sock, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));
if (ret < 0)
{
cerr << errno << ": " << strerror(errno) << endl;
exit(-1);
}
return listen_sock;
}
// 填充服务器IP与端口信息,将其绑定到listen套接字上
static void Bind(int listen_sock, u_int16_t local_port)
{
// 服务器信息的填充
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_addr.s_addr = INADDR_ANY; // 只要绑定本机IP就行
local.sin_port = htons(local_port);
int ret = bind(listen_sock, (struct sockaddr*)&local, sizeof(local));
if (ret < 0)
{
cerr << errno << ": " << strerror(errno) << endl;
exit(-1);
}
}
// 使指定套接字处于监听状态
static void Listen(int listen_sock)
{
int ret = listen(listen_sock, _backlog);
if (ret < 0)
{
cerr << errno << ": " << strerror(errno) << endl;
exit(-1);
}
}
static int Accept(int listen_sock, string* peer_ip, uint16_t* peer_port)
{
struct sockaddr_in peer;
socklen_t peer_len = sizeof(peer);
int server_sock = accept(listen_sock, (struct sockaddr*)&peer, &peer_len);
if (server_sock < 0)
{
cerr << errno << ": " << strerror(errno) << endl;
exit(-1);
}
*peer_ip = inet_ntoa(peer.sin_addr);
*peer_port = ntohs(peer.sin_port);
return server_sock;
}
};
// main.cc
#include "epoll_server.hpp"
void usage(char *process_name)
{
cout << "usage: " << process_name << " port"
<< endl;
}
int main(int argc, char* argv[])
{
// 判断调用者是否传入了端口号
if (argc != 2)
{
usage(argv[0]);
exit(-1);
}
epoll_server eserver(atoi(argv[1]));
eserver.init_server();
eserver.run_server();
return 0;
}