目录
一. IO多路转接的概念
二. 通过select实现IO多路转接
2.1 select接口
2.2 Select服务器的实现
2.3 select实现IO多路转接的优缺点
三. 通过poll实现IO多路转接
3.1 poll接口
3.2 Poll服务器的实现
3.3 poll实现IO多路转接的优缺点
四. 总结
一. IO多路转接的概念
在IO操作中,如果我们阻塞式的等待某个文件描述符资源就绪,那么在等待的过程中,就会浪费大量的时间,造成程序运行的效率低下。在实际的工程应用中,可能存在同时有多个文件描述符就绪的情况,这时如果阻塞等待其他未就绪的文件描述符,其它已经就绪的文件描述符也就暂时无法进行处理。
相比与单纯地阻塞式IO,IO多路转接能够实现这样的功能:当用户所关心的多个文件描述符的其中之一就绪时,就对这个就绪的进行处理。IO多路转接能够大大降低阻塞等待的时间,提高程序IO操作的效率。
二. 通过select实现IO多路转接
2.1 select接口
函数原型:int select(int nfds, struct fd_set* readfds, struct fd_set* writefds, struct fd_set* exceptfds, struct timval* timeout)
头文件:#include <sys/select.h>
函数参数:
- nfds:所关注的值最大的文件描述符值+1。
- readfds/writefds/exceptfds:输入输出型参数,设置关注的读/写/异常文件描述符。
- timeout:输入输出型参数,设置最长阻塞时间,获取剩余时间。
返回值:如果执行成功,返回就绪的文件描述符个数,等待超时返回0,等待失败返回-1。
在使用select函数时,有以下几点需要注意:
- readfds/writefds/exceptfds均为输入输出型参数,作为输入型参数时告知内核需要关系哪些文件描述符,作为输出型参数时由内核告知用户哪些文件描述符已经就绪。因此,每次调用select之前,都需要对readfds/writefds/exceptfd重新进行设置。
- readfds/writefds/exceptfds的底层是由位图实现的,但是,不可以通过简单的按位与1操作设置关心特定文件描述符,而是应当通过下面四个接口,来实现对某个fd_set对象的操作:
- FD_SET(int fd, fd_set* set):将指定fd添加到fd_set类型对象中去。
- FD_ISSET(int fd, fd_set* set):检查指定fd是否出现在fd_set对象中。
- FD_ZERO(fd_set* set):将set对象设置关注的文件描述符全部清空。
- FD_CLR(int fd, fd_set* set):清除fd_set对象中的指定文件描述符。
- timeout为最长的阻塞等待时间,如果设置为nullptr则表示为一直阻塞,struct timeval类型的定义,如果select成功执行,那么timeout的值变为了剩余多长时间没有用,比如:设置了5s的最长等待时间,但是2s就有文件描述符就绪,还剩下3s,那么当select运行结束后,timeout就被设置为3s。
struct timeval
{
long tv_sec; /* seconds */
long tv_usec; /* microseconds */
};
代码2.1展示了如何使用select接口,设置关注标准输入、标准输出和标准错误的读状态,设置最长阻塞时间1s,每次调用select前对fd_set对象和timeout重新设置,避免上一层调用select输出覆盖,在检查到select返回值>0时,还应使用FD_ISSET进一步检查所关注的文件描述符是否真正就绪,下面的代码真正所关系的文件描述符是标准输入0。
代码2.1:select接口的使用方法
#include <iostream>
#include <cstring>
#include <sys/select.h>
#include <unistd.h>
int main()
{
// 读取缓冲区
char buffer[1024] = { 0 };
while(true)
{
// 每次调用select之前,重新设置fd_set类型参数和Timeval阻塞时间
fd_set rfd;
FD_SET(0, &rfd);
FD_SET(1, &rfd);
FD_SET(2, &rfd); // 设置关心标准输入、标准输出和标准错误
// 设置最长阻塞时间为1s
struct timeval timeout;
timeout.tv_sec = 1; timeout.tv_usec = 0;
// 调用select进行IO多路转接
int n = select(3, &rfd, nullptr, nullptr, &timeout);
if(n > 0)
{
// 如果标准输入没有就绪,那么直接到下一轮循环中去
if(!FD_ISSET(0, &rfd))
{
continue;
}
ssize_t sz = read(0, buffer, 1023);
buffer[sz - 1] = '\0';
std::cout << "Show message# " << buffer << std::endl;
std::cout << "Remain time: " << timeout.tv_sec << " seconds," << timeout.tv_usec << " microseconds" << std::endl;
if(strcmp(buffer, "quit") == 0)
{
break;
}
}
else if(n == 0)
{
std::cout << "WARNING, Time out!" << std::endl;
}
}
return 0;
}
2.2 Select服务器的实现
本文实现一个基于TCP协议,可以从客户端读取数据的Select服务器。Select服务器的声明见代码2.2,其中包含基本的构造函数和析构函数,还有Handler函数在检测到有就绪的文件描述符后进行处理、Accepter函数用于接受对端连接请求、Reciever函数用于从指定文件描述符中读取数据。
代码2.2:SelectServer的声明(SelectServer.hpp头文件)
#pragma once
#include "Sock.hpp"
#include <vector>
#include <sys/select.h>
#include <unistd.h>
static const int FD_CAPACITY = 8 * sizeof(fd_set);
static const int NON_FD = -1;
class SelectServer
{
public:
SelectServer(uint16_t port, const std::string& ip = ""); // 构造函数
void start(); // Select服务器启动运行函数
~SelectServer(); // 析构函数
private:
void Handler(fd_set& rfd); // 处理就绪文件描述符函数
void Reciever(int pos); // 内容读取函数
void Accepter(); // 链接接收函数
void ShowFdArray(); // 文件描述符输出函数 -- 用于DEBUG
int _listenSock; // 监听套接字fd
uint16_t _port; // 服务器端口号
std::string _ip; // ip地址
std::vector<int> _fd_array; // 文件描述符序列
};
下面为Select服务器每个成员函数的实现需要注意的一些事项:
- 在Class SelectServer中,需要有一个_fd_array数组,其中记录需要关注的文件描述符,用于每次调用select之前设置fd_set对象,其中_fd_array可以是C语言数组或顺序表vector。在构造函数中要为_fd_array预先开辟好一块空间,并将每个位置的值设置为一个负数值NON_FD,表示这个位置没有存放关注的文件描述符fd。
- 在构造函数中,要执行基于TCP协议服务器的基本操作:获取listen套接字、绑定端口号、设置监听状态。
- start函数为服务器运行函数,由于服务器是一个常驻进程,因此start执行while死循环,在每轮循环中,都遍历_fd_array来设置fd_set对象,并且要检查select的返回值是否大于0,即:检查是否有就绪的文件描述符。如果有,就调用handler函数进行处理。
- Handler的功能是处理已经就绪的文件描述符,在Handler中要遍历_fd_array的每个fd,通过FD_ISSET检查是否就绪,如果就就绪,还要分为listen文件描述符和普通文件描述符两种情况来讨论。
- Accepter函数用于接收客户端的连接请求,Reciever用于读取客户端发送的数据。
代码2.3:日志打印函数的实现(log.hpp头文件)
#pragma once
#include <iostream>
#include <cstdio>
#include <cstdarg>
#include <ctime>
#define DEBUG 0
#define NORMAL 1
#define WARING 2
#define ERROR 3
#define FATAL 4
// 日志等级
static const char* g_levelMap[5] =
{
"DEBUG",
"NORMAL",
"WARING",
"ERROR",
"FATAL"
};
// 日志打印哈数,level为日志等级,后面为格式化可变参数
static void logMessage(int level, const char *format, ...)
{
// 1. 输出常规部分
time_t timeStamp = time(nullptr);
struct tm *localTime = localtime(&timeStamp);
printf("[%s] %d-%d-%d, %02d:%02d:%02d\n", g_levelMap[level], localTime->tm_year, localTime->tm_mon, \
localTime->tm_mday, localTime->tm_hour, localTime->tm_min, localTime->tm_sec);
// 2. 输出用户自定义部分
va_list args;
va_start(args, format);
vprintf(format, args);
va_end(args);
}
代码2.4:网络通信Socket相关函数的实现(Sock.hpp头文件)
#pragma once
#include "log.hpp"
#include <iostream>
#include <string>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
class Sock
{
public:
// 创建socket文件描述符
static int Socket()
{
int sock = socket(AF_INET, SOCK_STREAM, 0);
if(sock < 0)
{
logMessage(FATAL, "socket error, sock:%d\n", sock);
return -1;
}
logMessage(NORMAL, "socket success, sock:%d\n", sock);
return sock;
}
// 绑定端口号
static int Bind(int sock, const std::string &ip, uint16_t port)
{
struct sockaddr_in tmp;
memset(&tmp, 0, sizeof(tmp));
tmp.sin_family = AF_INET; // 网络协议族
tmp.sin_addr.s_addr = ip.empty() ? INADDR_ANY : inet_addr(ip.c_str()); // ip地址
tmp.sin_port = htons(port); // 端口号
socklen_t len = sizeof(tmp);
if(bind(sock, (struct sockaddr *)&tmp, len) < 0)
{
logMessage(FATAL, "bind error!\n");
return -1;
}
logMessage(NORMAL, "bind success!\n");
return 0;
}
// 设置监听状态
static int Listen(int sock, int backlog = 10)
{
if(listen(sock, backlog) < 0)
{
logMessage(FATAL, "listen error!\n");
return -1;
}
logMessage(NORMAL, "listen success!\n");
return 0;
}
// 接受连接
static int Accept(int sock, std::string& ip, uint16_t& port)
{
struct sockaddr_in peer;
memset(&peer, 0, sizeof(peer));
socklen_t len = sizeof(peer);
int fd = accept(sock, (struct sockaddr *)&peer, &len);
if(fd < 0) return -1;
ip = inet_ntoa(peer.sin_addr);
port = ntohs(peer.sin_port);
logMessage(NORMAL, "accept success, [%s-%d]\n", ip.c_str(), port);
return fd;
}
// 连接对方
static int Connect(int sock, const std::string &ip, uint16_t port)
{
struct sockaddr_in peer;
memset(&peer, 0, sizeof(peer));
peer.sin_family = AF_INET;
peer.sin_addr.s_addr = inet_addr(ip.c_str());
peer.sin_port = htons(port);
socklen_t len = sizeof(peer);
if(connect(sock, (const struct sockaddr *)&peer, len) < 0)
{
logMessage(FATAL, "conect error!\n");
return -1;
}
logMessage(NORMAL, "connect success!\n");
return 0;
}
};
代码2.5:SelectServer的实现(SelectServer.cc源文件)
#include "SelectServer.hpp"
SelectServer::SelectServer(uint16_t port, const std::string& ip)
: _listenSock(-1), _port(port), _ip(ip), _fd_array(FD_CAPACITY, NON_FD)
{
// 获取监听套接字
_listenSock = Sock::Socket();
if(_listenSock < 0){
exit(2);
}
// 绑定端口号
if(Sock::Bind(_listenSock, ip, _port) < 0){
exit(3);
}
// 设置监听状态
if(Sock::Listen(_listenSock) < 0){
exit(4);
}
}
// Select服务器启动运行函数
void SelectServer::start()
{
while(true)
{
fd_set rfd; // select所关注的读取fd
FD_ZERO(&rfd); // 将文件描述符清零
_fd_array[0] = _listenSock; // 默认设置_fd_array[0]为listenSock
ShowFdArray();
// 将_fd_array中的有效文件描述符记录到rfd中去
int maxFd = _listenSock; // 最大文件描述符
for(const auto fd : _fd_array)
{
if(fd != NON_FD)
{
FD_SET(fd, &rfd); // 添加文件描述符
if(fd > maxFd) maxFd = fd; // 更新最大放大
}
}
// 设置select,监视文件描述符就绪状态(暂时设置为阻塞)
int n = select(maxFd + 1, &rfd, nullptr, nullptr, nullptr);
switch(n)
{
case 0: // 没有文件描述符就绪
logMessage(DEBUG, "Time out, without any interest fd prepared!\n");
break;
case -1: // select发生错误
logMessage(ERROR, "Select error, errno:%d, errMsg:%s\n", errno, strerror(errno));
break;
default: // 有至少一个文件描述符就绪
Handler(rfd);
break;
}
}
}
// 析构函数
SelectServer::~SelectServer()
{
if(_listenSock >= 0)
close(_listenSock);
}
// 就绪文件描述符处理函数
void SelectServer::Handler(fd_set& rfd)
{
// 遍历_fd_array,检查有哪个fd就绪了,进行处理
for(int i = 0; i < FD_CAPACITY; ++i)
{
if(_fd_array[i] != NON_FD && FD_ISSET(_fd_array[i], &rfd))
{
// 分为listen套接字和普通套接字来处理
if(_fd_array[i] == _listenSock) Accepter();
else Reciever(i);
}
}
}
// 数据读取函数
void SelectServer::Reciever(int pos)
{
char buffer[1024];
ssize_t n = recv(_fd_array[pos], buffer, 1023, 0);
if(n > 0) // 读取成功
{
buffer[n] = '\0';
printf("Recieve message from Client:%s\n", buffer);
}
else if(n == 0) // 对端关闭
{
logMessage(DEBUG, "Client closed, fd:%d\n", _fd_array[pos]);
close(_fd_array[pos]);
_fd_array[pos] = NON_FD;
}
else // 读取失败
{
logMessage(ERROR, "Recv error, errno:%d, errMsg:%s\n", errno, strerror(errno));
}
}
// 链接接收函数
void SelectServer::Accepter()
{
std::string cli_ip;
uint16_t cli_port; // 客户端ip和端口号
int fd = Sock::Accept(_listenSock, cli_ip, cli_port);
// 连接获取失败 -- fd < 0
if(fd < 0)
{
logMessage(ERROR, "Aeecpt fail, errno:%d, errMsg:%s\n", errno, strerror(errno));
}
else // 连接获取成功
{
// 将获取到的新连接的fd添加到_fd_array中去
int index = 0;
for(; index < FD_CAPACITY; ++index)
{
if(_fd_array[index] == NON_FD)
{
_fd_array[index] = fd;
break;
}
if(index == FD_CAPACITY)
{
logMessage(DEBUG, "_fd_array is already full, insert new fd fail, fd:%d\n", fd);
}
else
{
logMessage(NORMAL, "Insert new fd success, fd:%d\n", fd);
}
}
}
}
// 打印输出_fd_array
void SelectServer::ShowFdArray()
{
std::cout << "_fd_array[]: " << std::flush;
for(const auto fd : _fd_array)
{
if(fd != NON_FD) std::cout << fd << " ";
}
std::cout << std::endl;
}
2.3 select实现IO多路转接的优缺点
缺点:
- 每一次调用select之前,都需要重新设置fd_set对象的值,因为调用select会覆盖掉原来的值。
- 用户向内核传递关注的文件描述符信息时,需要从用户态转换到内核态,存在较大开销。
- select返回时,由内核将关注的文件描述符的状态告知用户,需要从内核态转换到用户态,存在较大开销。
- fd_set对象底层是位图结构,位图中能够记录的文件描述符数量存在限制,不能同时关注太多的文件描述符,能够管理的资源受限。
优点:适用于存在大量fd,但是只要少量处于活跃状态的场景。
三. 通过poll实现IO多路转接
3.1 poll接口
函数原型:int poll(struct pollfd *fds, nfds_t nfds, int timeout)
头文件:#include <poll.h>
函数参数:
- fds:struct pollfd类型数组,更确切的应当写为struct pollfd fds[]
- nfds:所关注的文件描述符数量。
- timeout:最长阻塞等待时间,以秒为单位,如果传-1表示一直阻塞等待直到有fd就绪。
返回值:如果执行成功返回就绪的文件描述符个数,返回0表示等待超时,执行失败返回-1。
下面是struct pollfd的定义式,其中成员fd表示文件描述符,events表示请求事件,即用户告知内核需要关注哪些文件描述符,revents为响应时间,即内核告知用于哪些文件描述符已经就绪。
events是用户传给内核的信息,revents是内核传给用户的信息,他们互不干扰,因此即使这里的的fds依旧是输入输出型参数,也不需要每次调用poll之前重新设定struct poll对象的值,这是poll相对于select的一大优势。
struct pollfd
{
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};
events和revents的值及其对于的含义见表3.2,如果为0表示不关注某个fd或这个fd尚未就绪,如果非0,events用于用户告知OS内核需要关注fd的哪些操作(读/写/异常),revents用户OS内核告知用户fd的哪些状态已经就绪。
events/revents | 含义 |
---|---|
POLLIN | 数据(包括普通数据和高优先级数据)可读。 |
POLLNORMAL | 普通数据可读。 |
POLLPRI | 高优先级数据可读,如TCP带有紧急指针的报文。 |
POLLOUT | 数据(包括普通数据和高优先级数据)可写。 |
POLLOUTNORMAL | 普通数据可写。 |
POLLRDHUP | 对方关闭TCP,或对端关闭写操作。 |
POLLERR | 发生错误。 |
POLLNVAL | 文件描述符没有打开。 |
3.2 Poll服务器的实现
Poll服务器的实现与Select服务器的实现十分类似,代码3.1为Poll服务器类的声明,与Select不同的是,其中有一个struct pollfd* _fd_array成员变量,这个成员变量为struct pollfd类型数组,用于告知内核哪些fd需要关心,哪些fd已经就绪。其余包括构造函数、析构函数、服务器运行函数start、就绪文件描述符处理函数Handler、获取客户端连接函数Accepter、读取数据函数Reciever。
代码3.1:PollServer服务器声明(PollServer.hpp头文件)
#pragma once
#include "Sock.hpp"
#include <poll.h>
#include <unistd.h>
static const int FD_CAPACITY = 100;
static const int NON_FD = -1;
class PollServer
{
public:
PollServer(uint16_t port, const std::string& ip = "");
void Start(); // 服务器启动函数
~PollServer(); // 析构函数
private:
void Handler(); // 就绪文件描述符处理函数
void Reciever(int pos); // 接收信息函数
void Accepter(); // 接收连接请求
void ShowFdArray(); // _fd_array打印函数 -- 用于DEBUG
int _listenSock; // 监听套接字
uint16_t _port; // 服务器进程端口号
std::string _ip; // 服务器ip
struct pollfd* _fd_array; // 文件描述符序列
};
关于Poll服务器的实现,有以下几点需要注意:
- Poll是基于TCP协议的,在构造函数中,要获取listen套接字、绑定端口号、设置监听状态。
- 在start函数中,要死循环调用poll,检查是否有就绪的文件描述符,如果有就调用Handler函数来处理就绪文件描述符。在Handler函数中,遍历_fd_array检查就绪的文件描述符,在后续处理中分为listen文件描述符和普通文件描述符处理。
- Accepter用于接收连接,新获取的文件描述符要添加到_fd_array中去,Reciever用于读取数据,如果检测到对端关闭,要调用close关闭对应fd,并将其在_fd_array中清除。
代码3.2:PollServer的实现(PollServer.cc源文件)
#include "PollServer.hpp"
// 构造函数
PollServer::PollServer(uint16_t port, const std::string& ip)
: _listenSock(-1), _port(port), _ip(ip)
, _fd_array(new pollfd[FD_CAPACITY])
{
// 获取listen套接字
_listenSock = Sock::Socket();
if(_listenSock < 0) {
exit(2);
}
// 绑定端口号
if(Sock::Bind(_listenSock, _ip, _port) < 0) {
exit(3);
}
// 设置监听状态
if(Sock::Listen(_listenSock) < 0) {
exit(4);
}
// 初始化pollfd序列
for(int i = 0; i < FD_CAPACITY; ++i)
{
_fd_array[i].fd = -1;
_fd_array[i].events = _fd_array[i].revents = 0;
}
// 对listenSock设置读取关心状态
_fd_array[0].fd = _listenSock;
_fd_array[0].events = POLLIN;
}
// 服务器启动函数
void PollServer::Start()
{
while(true)
{
ShowFdArray();
int n = poll(_fd_array, FD_CAPACITY + 1, -1);
switch(n)
{
case 0: // 尚无就绪的文件描述符
logMessage(DEBUG, "No fd has prepared!\n");
break;
case -1: // poll失败
logMessage(ERROR, "Poll error, errno:%d, errMsg:%s\n", errno, strerror(errno));
break;
default: // 有文件描述符就绪
Handler();
break;
}
}
}
// 析构函数
PollServer::~PollServer()
{
if(_listenSock >= 0)
close(_listenSock);
delete[] _fd_array;
}
// 就绪文件描述符处理函数
void PollServer::Handler()
{
// 遍历查找,有哪一个fd处于就绪状态
for(int i = 0; i < FD_CAPACITY; ++i)
{
if(_fd_array[i].fd != NON_FD && _fd_array[i].revents == POLLIN)
{
// 分接收连接请求和读取信息两种情况讨论
if(_fd_array[i].fd == _listenSock) Accepter();
else Reciever(i);
}
}
}
// 接收信息函数
void PollServer::Reciever(int pos)
{
char buffer[1024];
ssize_t n = recv(_fd_array[pos].fd, buffer, 1023, 0);
// 信息读取成功
if(n > 0)
{
buffer[n] = '\0';
logMessage(NORMAL, "PollServer recieve message success!\n");
printf("Client Message# %s\n", buffer);
}
else if(n == 0) // 对端关闭
{
logMessage(DEBUG, "Client closed, fd:%d", _fd_array[pos].fd);
close( _fd_array[pos].fd);
_fd_array[pos].fd = NON_FD;
_fd_array[pos].events = _fd_array[pos].revents = 0;
}
else // 读取失败
{
logMessage(ERROR, "Get message from Client[%d] success, errno:%d, errMsg:%s\n",
_fd_array[pos].fd, errno, strerror(errno));
}
}
// 接收连接请求
void PollServer::Accepter()
{
std::string cli_ip;
uint16_t cli_port;
int fd = Sock::Accept(_listenSock, cli_ip, cli_port);
if(fd < 0) {
exit(5);
}
// 将新的fd添加到_fd_array中去
int index = 0;
for(; index < FD_CAPACITY; ++index)
{
// 检查_fd_array的空缺位置
if(_fd_array[index].fd == NON_FD)
{
_fd_array[index].fd = fd;
_fd_array[index].events = POLLIN;
break;
}
}
if(index == FD_CAPACITY) {
logMessage(DEBUG, "The fd_array has already full, insert new fd fail, fd:%d\n", fd);
}
else {
logMessage(NORMAL, "Insert new fd success, _fd_array[%d]:%d\n", index, fd);
}
}
// _fd_array打印函数 -- 用于DEBUG
void PollServer::ShowFdArray()
{
std::cout << "_fd_array[] " << std::flush;
for(int i = 0; i < FD_CAPACITY; ++i)
{
if(_fd_array[i].fd != NON_FD)
std::cout << _fd_array[i].fd << " ";
}
std::cout << std::endl;
}
3.3 poll实现IO多路转接的优缺点
优点:
- 使用struct pollfd替代select使用fd_set进行传参和返回信息,不需要再每次调用poll之前都对输入输出型参数重新进行设置。
- 相比于select,poll可以管理的文件描述符没有上限。
缺点:
- 与select相同,poll在返回后需要轮询检测_fd_array来确定哪个文件描述符就绪,消耗较大。
- 在向poll传参和poll返回时,需要进行 用户态 -> 内核态、内核态 -> 用户态的切换,频繁进行状态切换会消耗资源。
- 当管理的fd数目较多时,会降低程序的性能。
四. 总结
- 相比于阻塞式IO,多路转接能够在有其中一个文件描述符就绪的情况下就进行对应的处理,能大幅提高IO的效率。
- selet和poll是实现多路转接IO的两种方式。
- select使用fd_set类型来管理文件描述符,缺点是每次调用select都需要重新设置参数,可且管理的文件描述符数量受限,适用于连接多、但处于活跃状态的连接少的场景。
- poll相比于select不需要每次调用前都设置参数,且可以管理大量的文件描述符,但在处理就绪文件描述符时依然躲不掉遍历操作。