文章目录
- 🌈 一、select 初步认识
- 🌈 二、select 函数原型
- 🌈 三、select 执行过程
- 🌈 四、socket 就绪条件
- ⭐ 读就绪
- ⭐ 写就绪
- 🌈 五、select 的优缺点
- ⭐ select 的优点
- ⭐ select 的缺点
- 🌈 六、select 使用示例
- ⭐ Socket 类
- ⭐ select_server 类
🌈 一、select 初步认识
- select 是系统提供的一个多路转接接口。可以让程序同时监视多个文件描述符上的事件是否就绪。
- select 的核心工作就是等,当被 select 监视的多个文件描述符中有事件就绪时,select 才会成功返回,并将对应描述符上的就绪事件告知调用者。
举个例子
- “我”(调用者)找来了一个快递通知员(select),它会帮我监视我所指定的快递员(文件描述符)。每当有快递到达(事件就绪)时,select 就会通知 “我” 快递到来(事件已就绪)。
- 但是,select 只会告诉 “我” 快递到来,但并不会告诉 “我” 这个快递是谁送来的(不知道就绪事件发生在哪个文件描述符上)。此时就需要 “我” 对每个快递员都询问一遍。
- select 只会通知调用者事件就绪了(快递到了),快递此时还在快递员手上拿着,接收快递这个动作依然要由 “我” 来完成。
- 当事件就绪时,如果 “我” 一直不做处理,select 就会一直进行通知,直到该事件被处理了为止。
🌈 二、select 函数原型
#include <sys/select.h>int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
1. 参数说明
nfds
:需要监视的所有的文件描述符中,最大的那个文件描述符的值 + 1。readfds
:输入输出型参数。调用时,用户告知内核需要监视哪些文件描述符的读事件是否就绪。返回时,内核告知用户哪些文件描述符的读事件就绪。writefds
:输入输出型参数。调用时,用户告知内核需要监视哪些文件描述符的写事件是否就绪。返回时,内核告知用户哪些文件描述符的写事件就绪。exceptfds
:输入输出型参数。调用时,用户告知内核需要监视哪些文件描述符的异常事件是否就绪。返回时,内核告知用户哪些文件描述符的异常事件就绪。timeout
:输入输出型参数。调用时,由用户设置 select 的等待时间。返回时,表示 timeout 的剩余时间。
2. 参数 timeout 的取值
NULL / nullptr
:调用 select 后,进行阻塞等待,没有 timeout。直到被监视的某个文件描述符上发生了事件为止。0
:调用 select 后,进行非阻塞等待。无论被监视的文件描述符上的事件是否就绪,select 都会立即返回。特定的时间值
:调用 select 后,在指定的事件内进行阻塞等待。如果被监视的文件描述符在指定时间内一直没有事件就绪,则 select 自动进行超时返回。
3. 返回值说明
- 函数调用成功:返回有事件就绪的文件描述符的个数。
- timeout 时间耗尽:返回 0。
- 函数调用失败:返回 -1,同时错误码会被设置。错误码可能的取值如下:
EBADF
:文件描述符无效 / 该文件已经关闭EINTR
:此调用被信号所中断EINVAL
:参数 nfds 为负数ENOMEM
:核心内存不足
4. 关于 fd_set 文件描述符集的结构
- select 函数的返回值只会告知调用者,被 select 所监视的文件描述符上是否有事件就绪,并不会告知是哪些文件描述符上的事件就绪。当事件就绪时,调用者要从
fd_set
这个输入输出参数中获取事件就绪的文件描述符。 fd_set
和sigset_t
的结构类似,本质上也是一个位图。它使用位图中对应的位来表示要监视的文件描述符。
- 在调用 select 函数前,需要用
fd_set
这个结构体定义出对应的文件描述符集,然后再将需要监视的文件描述符添加到这个文件描述符集中。 - 这个添加的过程本质上就是一个位操作,但这个位操作不需要用户自己来,系统提供了一组专门的接口,用来对
fd_set
类型的位图进行操作。
void FD_CLR(int fd, fd_set *set); // 用来清除描述词组 set 中相关 fd 的位
int FD_ISSET(int fd, fd_set *set); // 用来测试描述词组 set 中相关 fd 的位是否为真
void FD_SET(int fd, fd_set *set); // 用来设置描述词组 set 中相关 fd 的位
void FD_ZERO(fd_set *set); // 用来清除描述词组 set 的全部位
5. 关于 timeval 结构
- 传给 select 函数的最后一个
timeout
参数,它是一个用来指向timeval
结构的指针。 - timeval 结构是用来描述一段时间长度,该结构体中包含着
tv_sec
(秒)和tv_usec
(微秒)两个成员变量。
🌈 三、select 执行过程
- 想要理解 select 模型,关键就在于理解
fd_set
。 - 取 fd_set 的长度为 1 字节,fd_set 中的每一个 bit 都可以对应一个文件描述符。则 1 字节厂的 fd_set 最大可对应 8 个文件描述符。
- 执行 fd_set set; FD_ZERO(&set);则 set 用位表示是 0000 0000
- 若 fd = 5,执行 FD_SET(fd,&set);后 set 变为 0001 0000 (第 5 位置为 1)
- 若再加入 fd = 2,fd = 1,则 set 变为 0001 0011
- 执行 select(6,&set,0,0,0)阻塞等待
- 若 fd = 1,fd = 2 上都发生可读事件,则 select 返回,此时 set 变为 0000 0011。注意:没有事件发生的 fd = 5 被清空。
🌈 四、socket 就绪条件
⭐ 读就绪
- 在 socket 内核中,如果接收缓冲区中的字节数 >= 低水位标记
SO_RCVLOWAT
,此时就可以无阻塞的读取该文件描述符,并且返回值 > 0 - 在 socket TCP 通信中,如果对端关闭了连接,此时对该 socket 进行读操作,则返回 0。
- 监听的 socket 上有新的连接请求。
- socket 上有未处理的错误。
⭐ 写就绪
- 在 socket 内核中,发送缓冲区的可用字节数 >= 低水位标记
SO_SNDLWOAT
时,可以无阻塞的进行写入,且返回值 > 0 - 如果 socket 已经被关闭(close / shutdown),对这个被关闭的 socket 进行写操作,会触发 SINGPIPE 信号。
- socket 使用非阻塞 connect 连接成功或失败后。
- socket 上有未被读取的数据。
🌈 五、select 的优缺点
⭐ select 的优点
- 可同时等待多个文件描述符上的事件就绪,且只负责等待。实际的 IO 操作由 accept、read、write 等接口来实现,这些接口在进行 IO 操作时不会被阻塞。
- 由于 select 可以同时等待多个文件描述符,因此可以将这个 等 的时间进行重叠,提高 IO 效率。
⭐ select 的缺点
- 每次调用 select 时,都需要手动设置 fd_set 文件描述符集,不方便使用。
- 每次调用 select 时,都需要将 fd_set 从用户态拷贝到内核态,在 fd 很多时开销大。
- 每次调用 select 时,都需要在内核遍历传递进来的所有 fd,在 fd 很多事开销大。
- select 可监控的文件描述符数量太少(只有
sizeof(fd_set) * 8 == 1024
个)。
🌈 六、select 使用示例
- 利用 select 来实现一个服务器。
⭐ Socket 类
#pragma once#include <string>
#include <memory>
#include <cstring>
#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include <strings.h>
#include <functional>
#include <sys/wait.h>
#include <arpa/inet.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>#include "log.h"
#include "inet_addr.h"using std::bind;
using std::cerr;
using std::cin;
using std::cout;
using std::endl;
using std::function;
using std::make_shared;
using std::make_unique;
using std::shared_ptr;
using std::stoi;
using std::string;
using std::to_string;
using std::unique_ptr;// 封装: 模板方法模式
namespace socket_ns
{class Socket;const static int gbacklog = 8;using socket_sptr = shared_ptr<Socket>;enum{SOCKET_ERROR = 1,USAGE_ERROR = 2,BIND_ERROR = 3,LISTEN_ERROR = 4,CONNECT_ERROR = 5};class Socket{public:// 声明纯虚函数virtual void create_socket_or_die() = 0; // 创建套接字,如果不成功则直接结束virtual void bind_socket_or_die(uint16_t port) = 0; // 绑定套接字,如果不成功则直接结束virtual void listen_socket_or_die() = 0; // 监听套接字,如果不成功则直接结束virtual int accepter(Inet_addr *addr) = 0; // 获取连接virtual bool connetcor(Inet_addr &addr) = 0; // 连接服务器virtual int sockfd() = 0; // 用户获取套接字virtual int Recv(string *out) = 0; // 读取信息放到 out 中,并返回读取到的字符串的长度virtual int Send(const string &in) = 0; // 发送添加处理好并添加好报头的数据virtual void reuse_address() = 0; // 地址复用public:// 创建监听套接字void build_listen_socket(uint16_t port){create_socket_or_die(); // 创建套接字reuse_address(); // 对该套接字进行地址复用bind_socket_or_die(port); // 绑定套接字listen_socket_or_die(); // 监听套接字}// 创建客户端的套接字bool build_client_socket(Inet_addr &addr){create_socket_or_die(); // 创建套接字return connetcor(addr); // 向服务器发起连接}};class tcp_socket : public Socket{private:int _sockfd;public:tcp_socket(int fd = -1): _sockfd(fd){}/*纯虚函数不能实例化出对象,需要重写纯虚函数*/// 创建套接字,如果不成功则直接结束void create_socket_or_die() override{// 创建流式套接字_sockfd = ::socket(AF_INET, SOCK_STREAM, 0);if (_sockfd < 0){LOG(FATAL, "create socket error");exit(SOCKET_ERROR);}LOG(DEBUG, "create socket success, sockfd is %d", _sockfd);}// 绑定套接字,如果不成功则直接结束void bind_socket_or_die(uint16_t port) override{// 填充 sockaddr_in 结构struct sockaddr_in local; // 用于和本地套接字关联bzero(&local, sizeof(local)); // 清空 local 所占用的内存空间local.sin_family = AF_INET; // 地址协议家族: 本地通信 or 网络通信local.sin_port = htons(port); // 将端口号从主机序列转成网络序列 (大端)local.sin_addr.s_addr = INADDR_ANY; // 绑定服务器上任意 IP// 绑定 socket 文件信息和网络信息int n = ::bind(_sockfd, (struct sockaddr *)&local, sizeof(local));if (n < 0){LOG(FATAL, "socket bind error, %s %d", strerror(errno), errno);exit(BIND_ERROR);}LOG(DEBUG, "socket bind success, sockfd is %d", _sockfd);}// 监听套接字,如果不成功则直接结束void listen_socket_or_die() override{// 4.监听// TCP 是面向连接的,通信之前必须先建立连接,// 服务器是要被连接的,服务器要一直等待客户的连接int n = ::listen(_sockfd, gbacklog); // 对对应的套接字进行监听,以等待用户向服务器发起连接if (n < 0) // 监听失败{LOG(FATAL, "listen error");exit(LISTEN_ERROR);}LOG(DEBUG, "listen success, sockfd is %d", _sockfd);}// 服务器获取连接int accepter(Inet_addr *addr) override{struct sockaddr_in peer;socklen_t len = sizeof(peer);int sockfd = ::accept(_sockfd, (struct sockaddr *)&peer, &len);// 获取新连接失败if (sockfd < 0){LOG(WARNING, "accept error");return -1;}*addr = Inet_addr(peer);return sockfd;}// 客户端连接服务器bool connetcor(Inet_addr &addr) override{// 获取服务器的套接字信息struct sockaddr_in server;bzero(&server, sizeof(server));server.sin_family = AF_INET;server.sin_port = htons(addr.port());server.sin_addr.s_addr = inet_addr(addr.ip().c_str());// 3.客户端通过套接字向服务器发起连接int n = connect(_sockfd, (struct sockaddr *)&server, sizeof(server));// 发起连接失败if (n < 0){cerr << "connect error" << endl;return false;}// 发起连接成功return true;}// 获取文件描述符int sockfd() override{return _sockfd;}// 读取信息放到 out 中,并返回读取到的字符串的长度int Recv(string *out) override{// 从 sockfd 指定的文件中读取数据到 inbuffer 中char inbuffer[1024];ssize_t n = ::recv(_sockfd, inbuffer, sizeof(inbuffer) - 1, 0);if (n > 0){inbuffer[n] = 0;*out += inbuffer;}return n;}// 服务器将处理好后的字符串发送出去int Send(const string &in) override{int n = ::send(_sockfd, in.c_str(), in.size(), 0);return n;}// 地址复用void reuse_address() override{int opt = 1;::setsockopt(_sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));}};
}
⭐ select_server 类
- 在构造 select_server 对象时,需要指定 select 服务器的端口号。
- 在初始化 select 服务器时,需要调用 Socket 类中的成员被函数,依次进行套接字的创建、绑定和监听。
- 在析构函数中,可选择调用 close 函数将监听套接字关闭。
#pragma once#include <iostream>
#include <algorithm>
#include <sys/select.h>#include "socket.h"using std::cerr;
using std::make_unique;
using std::max;
using std::unique_ptr;using namespace socket_ns;class select_server
{const static int fd_max_num = sizeof(fd_set) * 8; // 文件描述符的最大数量 = fd_set 的大小const static int defauld_fd = -1;private:uint16_t _port;unique_ptr<Socket> _listen_socket;// select 想要正常工作,需要一个辅助数组,用来保存所有合法的 fdint fd_array[fd_max_num];public:select_server(uint16_t port): _port(port), _listen_socket(make_unique<tcp_socket>()){// 创建监听套接字_listen_socket->build_listen_socket(_port);}// 初始化服务器void init_server(){for (size_t i = 0; i < fd_max_num; i++)fd_array[i] = defauld_fd;fd_array[0] = _listen_socket->sockfd(); // 将 listen 套接字先添加进这个数组(默认用 0 号下标存储)}// 获取连接(处理 listen fd 的就绪事件)void accept_connection(){// 有新连接到来 - 连接事件就绪// 处理 listen 套接字Inet_addr addr;int sockfd = _listen_socket->accepter(&addr); // 由于 select 的原因,一定不会阻塞if (sockfd > 0){LOG(DEBUG, "get a new link success, client info: %s:%d", addr.ip().c_str(), addr.port());// 已经获得了一个新的 sockfd,但是接下来 【绝对不能 - 直接读取】// 这个新的 sockfd 不一定有数据可读,如果直接开始读,则直接进入阻塞 - 挂起 - 服务端挂掉// 只能通过 select 来判断底层 fd 是否有数据可读// 要想办法把新的 fd 添加给 select,由 select 统一监管// 这也就是为什么被 select 所监管的文件描述符越来越多的原因// 只要将新的 fd 添加到 fd_array 即可bool sockfd_add_to_fdarray_success = false;for (int pos = 1; pos < fd_max_num; pos++){// 找到一个空闲的位置, 将新的 sockfd 添加进去if (fd_array[pos] == defauld_fd){sockfd_add_to_fdarray_success = true;fd_array[pos] = sockfd;LOG(INFO, "add %d to fd_array success", sockfd);break;}}// 如果整个 fd_array 中都没位置放入新的 sockfdif (!sockfd_add_to_fdarray_success){LOG(WARNING, "server sockfd is full");close(sockfd);}}}// 处理 IO(处理普通 fd 的就绪事件)void handle_io(int i){// 处理普通 sockfd,正常读写char buffer[1024];// 这里的读取不会阻塞,因为 select 已经判断出 fd 的读事件就绪ssize_t n = ::recv(fd_array[i], buffer, sizeof(buffer) - 1, 0);if (n > 0){buffer[n] = '\0';cout << "client say: " << buffer << endl;// 服务器给客户端回复string content = "<html><body><h1>hello world</h1></body></html>";string echo_str = "HTTP/1.0 200 OK\r\n";echo_str += "Content-Type: text/html\r\n";echo_str += "Content-Length: " + to_string(content.size()) + "\r\n\r\n";echo_str += content;// 发送数据::send(fd_array[i], echo_str.c_str(), echo_str.size(), 0);}else if (0 == n){// 对端关闭连接LOG(INFO, "client quit, sockfd: %d", fd_array[i]);close(fd_array[i]);fd_array[i] = defauld_fd; // 让 select 不要再关心这个 fd 了}else{// 读取失败LOG(ERROR, "recv error, sockfd: %d", fd_array[i]);close(fd_array[i]);fd_array[i] = defauld_fd;}}// 处理已经就绪的事件 (一定会同时存在大量就绪的 fd,可能是普通 sockfd,也可能是 listensockfd)void handle_event(fd_set &rfdset){for (size_t i = 0; i < fd_max_num; i++){if (fd_array[i] == defauld_fd)continue;cout << "for debug" << endl;// 能走到这里说明该 fd 一定合法(但不一定就绪),需要判断该 fd 是否就绪if (FD_ISSET(fd_array[i], &rfdset)){cout << "legal" << endl;// 读事件就绪// 需要判断是 listensockfd 还是 sockfd 就绪if (fd_array[i] == _listen_socket->sockfd())accept_connection(); // 处理连接事件else handle_io(i); // 处理普通事件}}}// 展示所有合法的 fdvoid print_all_legal_fd(){cout << "legal fd list: ";for (size_t i = 0; i < fd_max_num; i++)if (fd_array[i] != defauld_fd)cout << fd_array[i] << " ";cout << endl;}// 运行服务器void loop(){while (true){// 1.初始化文件描述符集fd_set readset;FD_ZERO(&readset); // 清空文件描述符集int max_fd = defauld_fd;// 2.将合法的文件描述符添加到文件描述符集中for (size_t i = 0; i < fd_max_num; i++){if (fd_array[i] == defauld_fd)continue;FD_SET(fd_array[i], &readset);// 更新出最大的文件描述符值if (max_fd < fd_array[i])max_fd = fd_array[i];}struct timeval timeout = {3, 0}; // 每隔 3s timeout 一次// 让 select 监听,此处只关心读事件是否就绪int n = ::select(max_fd + 1, &readset, nullptr, nullptr, nullptr /*&timeout*/);switch (n){case 0:LOG(DEBUG, "timeout, %d.%d", timeout.tv_sec, timeout.tv_usec);break;case -1:LOG(ERROR, "select error");break;default:LOG(INFO, "haved event ready, n: %d", n); // 如果事件就绪,但时不处理,select 会一直通知我,直到事件被处理为止handle_event(readset);print_all_legal_fd();sleep(1);break;}}}~select_server(){if (_listen_socket)close(_listen_socket->sockfd());}
};
%d.%d", timeout.tv_sec, timeout.tv_usec);break;case -1:LOG(ERROR, "select error");break;default:LOG(INFO, "haved event ready, n: %d", n); // 如果事件就绪,但时不处理,select 会一直通知我,直到事件被处理为止handle_event(readset);print_all_legal_fd();sleep(1);break;}}}~select_server(){if (_listen_socket)close(_listen_socket->sockfd());}
};