Linux 多路转接select
1. select
select()
是一种较老的多路转接IO接口,它有一定的缺陷导致它不是实现多路转接IO的最优选择,但 poll()
和 epoll()
都是较新版的Linux系统提供的,一些小型嵌入式设备的存储很小,只能使用老版本的 Linux 系统(老内核的代码编译体积小),select()
在这些设备上可能是唯一的选择。
2 select的工作原理
调用 select()
会在内核中建立 readfds
、writefds
、exceptfds
三张位图(如果参数设置为空就不建立),分别对应监听读事件、写事件、异常事件。对位图进行添加、删除等操作都必须要使用系统提供的位图操作系统调用(见函数声明部分)。调用 select()
时,向位图输入的时要关心的文件描述符,当事件就绪时,位图的内容就变文件描述符是否有事件发生。
select()
会返回就绪的文件描述符的数量,用户自行设置判断条件,对相应的文件描述符进行读、写、或异常处理操作。
3. 函数声明
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout)
int nfds
: 表示文件描述符的范围,需要输入等待的最大值fd + 1
。(硬要解释就是遍历的是[ 0, fd + 1 )
的左闭右开区间,但是需要手动+1
就匪夷所思)
fd_set *readfds
:readfds
表示只关心读事件。
fd_set *writefds
:writefds
只关心写事件。
fd_set *exceptfds
:exceptfds
只关心异常事件。
struct timeval *timeout
: 表示执行流调用select()
后等待事件就绪的最大时间。如果等待就绪成功,其内部会存储等待剩余的时间;如果等待超时,执行流会跳出select()
。(详细见下文)
reval
: 返回值大于0,则返回值表示有reval
个文件描述符就绪;返回值等于0表示等待超时;返回值小于0代表select()
出错(如传入的文件描述符不存在)。
4. fd_set
fd_set 是文件描述符集,是一个数据类型,本质是一个位图结构,它的比特位的位置表示文件描述符编号,如第二个比特位表示二号文件描述符。 fd_set
能存储的上线取决于内核编译它时的取值,在云服务器上测试理论最多存储
4096
4096
4096 个文件描述符,这也是 select()
的缺点之一,它能存储的最大文件描述符是定长的,且对于现代服务器来说很小。
输入时,fd_set
比特位的内容表示是否关心该 fd 事件,1 表示关心,0 表示不关心;输出时,比特位的内容表示事件是否发生。
如图表示第 0、1、6、8 号文件描述符被存进去。
由于 fd_set
是位图结构,对位图做操作必须使用系统提供的专门的系统调用:
void FD_CLR(int fd, fd_set *set)
将某个文件描述符对应的位清零,表示它不在监听范围内。
int FD_ISSET(int fd, fd_set *set)
用于检查某个文件描述符是否在集合中被设置。
void FD_SET(int fd, fd_set *set)
将某个文件描述符对应的位设置为
1
,表示它在监听范围内。
void FD_ZERO(fd_set *set)
用来清除(初始化)整个
fd_set
。
fd_set简化源码:
/* Macros for manipulating `fd_set`. */
#define __FD_SETSIZE 1024 /* fd_set 能保存的最大文件描述符数 */
/* 一组文件描述符,每个比特位表示一个文件描述符的状态。 */
typedef struct {
unsigned long fds_bits[__FD_SETSIZE / (8 * sizeof(unsigned long))];
} fd_set;
/* FD_ZERO: 清空 fd_set 中的所有位。 */
#define FD_ZERO(fdsetp) \
(memset (fdsetp, 0, sizeof (fd_set)))
/* FD_SET: 将文件描述符 `fd` 在 fd_set 中置位。 */
#define FD_SET(fd, fdsetp) \
((fdsetp)->fds_bits[(fd) / (8 * sizeof (unsigned long))] |= \
(1UL << ((fd) % (8 * sizeof (unsigned long)))))
/* FD_CLR: 清除文件描述符 `fd` 在 fd_set 中的位。 */
#define FD_CLR(fd, fdsetp) \
((fdsetp)->fds_bits[(fd) / (8 * sizeof (unsigned long))] &= \
~(1UL << ((fd) % (8 * sizeof (unsigned long)))))
/* FD_ISSET: 检查文件描述符 `fd` 在 fd_set 中是否被置位。 */
#define FD_ISSET(fd, fdsetp) \
(((fdsetp)->fds_bits[(fd) / (8 * sizeof (unsigned long))] & \
(1UL << ((fd) % (8 * sizeof (unsigned long))))) != 0)
5. struct timeval
timeval
是一个时间戳类结构体,用于存储执行流进入 select()
后等待事件就绪的最大时间。它的源码长这样:
#ifndef _STRUCT_TIMEVAL
#define _STRUCT_TIMEVAL
/* 用于存储时间的结构体,包含秒和微秒。 */
struct timeval {
long tv_sec; /* 秒。 */
long tv_usec; /* 微秒。 */
};
#endif /* _STRUCT_TIMEVAL */
tv_sec
表示秒, tv_usec
表示微秒,设置为 { n, 0 }
,表示阻塞等待,在 n 秒内反复轮询直到有事件就绪就返回, n 秒后,没有事件就绪也会返回;设置为 { 0, 0 }
表示非阻塞等待,轮询一次就返回;也可以设置为 NULL
表示一直阻塞等待。
tv_sec
和 tv_usec
等待结束后并不会被销毁或重置,设置为 { n, 0 }
时,如果在 n 秒内有事件就绪,可以查看它的剩余时间。
6. select() 的使用
这里演示 select()
在服务器中的使用,用于管理 accept()
传来的文件描述符,因此其只关心读事件。如果不及时处理 select()
,也没有关系,因为事件就绪但是不处理,select()
就会一直通知,直到就绪被处理。注意select()
要正常工作,需要借助一个辅助数组,来保存所有合法的文件描述符。select()
每次使用都要重置。
//select_server.h
#pragma once
#include <iostream>
#include <sys/select.h>
#include "Socket.hpp"
#include "Log.hpp"
#include "InetAddr.hpp"
using namespace socket_ns;
class SelectServer
{
const static int gnum = sizeof(fd_set) * 8; //fd_set的理论最大容量
const static int gdefaultfd = -1; //初始化 fd_array 的值
public:
SelectServer(uint16_t port) : _port(port), _listensock(std::make_unique<TcpSocket>())
{
_listensock->BuildListenSocket(_port);
}
void InitServer()
{
for (int i = 0; i < gnum; i++)
{
fd_array[i] = gdefaultfd;
}
fd_array[0] = _listensock->Sockfd(); // 默认直接添加listensock到数组中
}
// 处理新连接的
void Accepter()
{
// 我们叫做连接事件就绪,等价于读事件就绪
InetAddr addr;
int sockfd = _listensock->Accepter(&addr);
if (sockfd > 0)
{
LOG(DEBUG, "get a new link, client info %s:%d\n", addr.Ip().c_str(), addr.Port());
bool flag = false;
for (int pos = 1; pos < gnum; pos++)
{
if (fd_array[pos] == gdefaultfd)
{
flag = true;
fd_array[pos] = sockfd;
LOG(INFO, "add %d to fd_array success!\n", sockfd);
break;
}
}
if (!flag)
{
LOG(WARNING, "Server Is Full!\n");
::close(sockfd);
}
}
}
// 处理普通的fd就绪的
void HandlerIO(int i)
{
char buffer[1024];
ssize_t n = ::recv(fd_array[i], buffer, sizeof(buffer) - 1, 0); // 这里读取不会阻塞
if (n > 0)
{
buffer[n] = 0;
std::cout << "client say# " << buffer << std::endl;
std::string content = "<html><body><h1>hello bite</h1></body></html>";
std::string echo_str = "HTTP/1.0 200 OK\r\n";
echo_str += "Content-Type: text/html\r\n";
echo_str += "Content-Length: " + std::to_string(content.size()) + "\r\n\r\n";
echo_str += content;
// echo_str += buffer;
::send(fd_array[i], echo_str.c_str(), echo_str.size(), 0); // 临时方案
}
else if (n == 0)
{
LOG(INFO, "client quit...\n");
// 关闭fd
::close(fd_array[i]);
// select 不要在关心这个fd了
fd_array[i] = gdefaultfd;
}
else
{
LOG(ERROR, "recv error\n");
// 关闭fd
::close(fd_array[i]);
// select 不要在关心这个fd了
fd_array[i] = gdefaultfd;
}
}
// 一定会存在大量的fd就绪,可能是普通sockfd,也可能是listensockfd
void HandlerEvent(fd_set &rfds)
{
// 事件派发
for (int i = 0; i < gnum; i++)
{
if (fd_array[i] == gdefaultfd)
continue;
// fd一定是合法的fd
// 合法的fd不一定就绪, 判断fd是否就绪
if (FD_ISSET(fd_array[i], &rfds))
{
// 读事件就绪
// 1. listensockfd 2. normal sockfd就绪
if (_listensock->Sockfd() == fd_array[i])
{
Accepter();
}
else
{
HandlerIO(i);
}
}
}
}
void Loop()
{
while (true)
{
// 1. 文件描述符进行初始化
fd_set rfds;
FD_ZERO(&rfds);
int max_fd = gdefaultfd;
// 2. 合法的fd 添加到rfds集合中
for (int i = 0; i < gnum; i++)
{
if (fd_array[i] == gdefaultfd)
continue;
FD_SET(fd_array[i], &rfds);
// 2.1 更新出最大的文件fd的值
if (max_fd < fd_array[i])
{
max_fd = fd_array[i];
}
}
struct timeval timeout = {30, 0};
// _listensock->Accepter();// 不能,listensock && accept 我们把他也看做IO类的函数。只关心新链接到来,等价于读事件就绪!
int n = ::select(max_fd + 1, &rfds, nullptr, nullptr, nullptr ,&timeout); // 只关心读事件
switch (n)
{
case 0:
LOG(DEBUG, "time out, %d.%d\n", timeout.tv_sec, timeout.tv_usec);
break;
case -1:
LOG(ERROR, "select error\n");
break;
default:
LOG(INFO, "haved event ready, n : %d\n", n); // 如果事件就绪,但是不处理,select会一直通知
HandlerEvent(rfds);
PrintDebug();
break;
}
}
}
void PrintDebug()
{
std::cout << "fd list: ";
for (int i = 0; i < gnum; i++)
{
if (fd_array[i] == gdefaultfd)
continue;
std::cout << fd_array[i] << " ";
}
std::cout << "\n";
}
private:
uint16_t _port;
std::unique_ptr<Socket> _listensock;
// 1. select要正常工作,需要借助一个辅助数组,来保存所有合法fd
int fd_array[gnum];
};
fd_array[gnum]
是一个用来存储所有合法 fd 的辅助数组,它里面所有的元素最初被初始化为-1
,第0
个元素默认存储listensockfd
,其他位置存储accept()
传递过来的合法 fd 。当连接关闭时,再close(fd_array[i])
关闭对应位置的 fd ,并将fd_array[i] = -1
重置为初始化状态。这里
select()
用来多路转接accept()
传递过来的文件描述符,listensock
和accept()
也可以看作 IO 类的函数,但这两个函数只关心新连接到来,也就是只关心读事件就绪,所以select()
中只有rfds
,其他被置为了NULL
。
7. select() 缺点
-
每次调用
select()
, 都需要手动设置 fd 集合,接口使用不方便。 -
每次调用
select()
, 都需要把 fd 集合从用户态拷贝到内核态, 这个开销在 fd 很多时会很大。 -
同时每次调用
select()
都需要在内核遍历传递进来的所有 fd, 这个开销在 fd 很多时也很大。 -
select()
支持的文件描述符数量是定长的,而且太小。