上篇文章中,采用了多进程/多线程方式来解决服务器处理并发连接的问题。但是这并不是一个很好的处理方案,因为进程和线程的创建和切换对资源的消耗比较大。
而IO多路复用可以通过一个线程就能并发处理所有关心fd的事件
目录
[一. 五种网络IO模型](#一. 五种网络IO模型)
[1.1 阻塞IO](#1.1 阻塞IO)
[1.2 非阻塞IO](#1.2 非阻塞IO)
[1.3 信号驱动IO](#1.3 信号驱动IO)
[1.4 IO多路复用⭐](#1.4 IO多路复用⭐)
[1.5 异步IO](#1.5 异步IO)
[二. select TCP服务器](#二. select TCP服务器)
[2.1 select 系统调用](#2.1 select 系统调用)
[2.2 select服务器](#2.2 select服务器)
[2.3 服务器代码](#2.3 服务器代码)
[三. 总结与wrk测试select服务器QPS](#三. 总结与wrk测试select服务器QPS)
[3.1 测试对比](#3.1 测试对比)
[3.2 总结](#3.2 总结)
一. 五种网络IO模型
1.1 阻塞IO
阻塞IO是非常常见的一种模型,我们之前的代码都是使用的阻塞式IO。当我们调recv/send/ recvfrom/sendfile的时候会一直判断内核数据是否就绪,直到数据就绪后才会读取数据执行后面的代码。

1.2 非阻塞IO
非阻塞IO在调用read/send等接口时候,不会像阻塞IO一样循环判断数据是否就绪,而是判断一次内核数据是否就绪,就绪就去读写数据,未就绪就去执行自己其他的代码。
这么来看非阻塞IO效率貌似非常高?对比阻塞IO来说,确实效率高一些。但是非阻塞IO并没有解决等待的问题,使用非阻塞IO仍会不断去判断内核数据是否就绪。
只不过非阻塞IO将部分等待时间用于做其他事情,实际并没有减少等待的时间。仅仅是让出cpu去执行其他代码,减少cpu空转等待。

1.3 信号驱动IO
信号驱动IO是利用了系统调用signal/sigaction来自定义信号,当内核有数据的时候,就通知用户进程从内核读取数据。 不过这种方法会频繁进入内核处理信号,并且多个信号同时触发会导致处理连接困难,并不实用。

1.4 IO多路复用⭐
无论是阻塞IO/非阻塞IO都是用户程序主动去判断内核有无数据读取,能否有一种方式,让所有文件fd有数据读写时候主动通知用户程序读取呢?
select/poll/epoll就是这种方式,能否监视所有关心的fd。fd数据就绪后通知用户程序来读取数据,这样不仅能并发处理不同连接,还能提高cpu的效率。

1.5 异步IO
之前的IO都是同步IO(数据就绪我去读写),而异步IO是用户向内核提交请求,然后立即返回。等待内核将请求处理完毕之后通知用户请求处理的情况,用户根据请求处理情况再执行后续的任务。
这样一来,用户并不需要主动去等待数据是否就绪,只需要根据内核提供的结果执行对应的操作即可。

二. select TCP服务器
IO = 等待 + 数据拷贝。select并不会完成数据拷贝,他只会帮我们处理等待这个事情。当数据就绪后,我们直接读写即可。
2.1 select 系统调用
cpp
//所需头文件
#include<sys/select.h>
//函数原型
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *execptfds, struct timeval *timeout)
//参数说明:
nfds:select要监视的多个文件描述符fd的最大值+1 -》 监视 3 4 5 6 。应该输入7
//后面四个参数都是输入输出型参数,前三个参数比较重要
*readfds : 读事件就绪
*writefds : 写事件就绪
*execptfds : 发生异常事件
1 我们使用select的时候一般只关心读事件/只关心写事件/只关心异常事件 --- 对任何一个fd,都是这种情况
2 fd_set : 是一个位图,用于表示文件描述符集合
输入:
readfds:输入时候表示用户告诉内核,你要帮我关心一个我给你集合中所有的读事件。即内核会帮助我们处理位图中的读事件
0000 0010 0100 1000 比特位的位置表示fd的数值,比特位的内容,表示是否关心
输出:
返回的时候,内核告诉用户,你所关心的fd有哪些已经就绪了
0000 0000 0000 1000 比特位的位置表示fd的数值,比特位的内容,表示这些fd上面的事件就绪了。比如左侧的位图表示3号fd读数据就绪了
可以让用户和内核之间互相沟通,互相知晓对方需要的关心fd内容(用户告诉内核你需要帮我关心的fd事件,内核告诉用户你关心的fd事件就绪了)
如果想要同时关心读写,可以添加readfds和writefds 如果想要先关心读,后关心写,可以先添加readfds,获取读fd就绪后,在添加writefds
*timeout : 表示select等待多个fd时候,等待的方式。
输入的秒数,表示你需要等待的时间,输出等待剩余的时间,比如输入5秒,3秒时刻返回了,返回值就是2
nullptr表示阻塞式等待,没有任何一个就绪就阻塞等待,直到有一个文件描述符完成
struct timeval timeout = {0,0}表示非阻塞等待:如果没有任何一个文件描述符就绪,就立刻返回
struct timeval timeout = {5,0}表示5秒内,阻塞式等待,此时有fd就绪就立马返回。5秒后,非阻塞返回一次。每隔5秒就会返回一次
返回值:
ret > 0 表示有几个fd就绪了,不可能超过监视fd的个数
ret = 0 没有fd就绪,表示超时返回了
ret < 0 select调用失败了,错误码被设置。比如监视没有被打开文件的文件描述符
系统也提供了部分接口帮助我们处理set
cpp
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的全部
2.2 select服务器
首先拿出我们之前的代码(上篇文章有)。
tcpServer.hpp
cpp
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <cstring>
#include <functional>
#include <thread>
namespace YZC
{
// 设置默认端口和最大backlog
const int defaultPort = 8080;
const int maxBacklog = 128;
// 设置回调函数
using func_t = std::function<void(int)>;
// typedef void (*func_t)(int);
class tcpServer
{
public:
tcpServer(func_t func, int port = defaultPort)
: _port(port), _callback(func) {}
void init()
{
// 1.创建socket
_listensock = socket(AF_INET, SOCK_STREAM, 0);
if (_listensock < 0)
{
std::cerr << "sockte err" << std::endl;
exit(-1);
}
std::cout << "socket success" << std::endl;
// 2 bind绑定fd和端口
struct sockaddr_in serveraddr;
memset(&serveraddr, 0, sizeof(serveraddr));
// 设置地址的信息(协议,ip,端口)
serveraddr.sin_family = AF_INET;
serveraddr.sin_addr.s_addr = htonl(INADDR_ANY); // 绑定任意网卡ip,通常我们访问某一个IP地址是这个服务器的公网网卡IP地址
serveraddr.sin_port = htons(_port); // 注意端口16位,2字节需要使用htons。不可
socklen_t len = sizeof(serveraddr);
if (bind(_listensock, (struct sockaddr *)&serveraddr, len) < 0)
{
std::cerr << "bind err" << std::endl;
exit(-1);
}
std::cout << "bind success" << std::endl;
// 3 设置sockfd为监听fd
if (listen(_listensock, maxBacklog) < 0)
{
std::cerr << "listen err" << std::endl;
exit(-1);
}
std::cout << "listen success" << std::endl;
}
void run()
{
while (true)
{
struct sockaddr_in clientaddr;
memset(&clientaddr, 0, sizeof(clientaddr));
socklen_t len = sizeof(clientaddr);
int sockfd = accept(_listensock, (struct sockaddr *)&clientaddr, &len);
if (sockfd < 0)
{
std::cerr << "accept err" << std::endl;
exit(-1);
}
std::cout << "accept success" << std::endl;
std::thread t1(_callback, sockfd);
t1.detach();
}
}
private:
int _listensock;
int _port;
func_t _callback;
};
void serviceIO(int sockfd)
{
// 这里仅做简单的数据收发
while (true)
{
char buffer[128] = {0};
int count = recv(sockfd, buffer, sizeof(buffer) - 1, 0);
if (count < 0)
{
std::cerr << "recv err" << std::endl;
exit(-1);
}
if (count == 0)
{
// 对方关闭
break;
}
printf("client --> server:%s\n", buffer);
count = send(sockfd, buffer, strlen(buffer), 0);
if (count < 0)
{
std::cerr << "send err" << std::endl;
exit(-1);
}
}
close(sockfd);
}
void serviceHTTP(int sockfd)
{
// 这里仅做简单的数据收发
while (true)
{
char buffer[128] = {0};
int count = recv(sockfd, buffer, sizeof(buffer) - 1, 0);
if (count < 0)
{
std::cerr << "recv err" << std::endl;
exit(-1);
}
if (count == 0)
{
// 对方关闭
break;
}
printf("client --> server:%s\n", buffer);
std::string outbuffer;
std::string body = "<h1>hello world</h1>";
outbuffer =
"HTTP/1.1 200 OK\r\n"
"Content-Type: text/html; charset=utf-8\r\n"
"Content-Length: " +
std::to_string(body.size()) + "\r\n"
"Server: Apache/2.4.41\r\n"
"Date: Mon, 18 Dec 2023 08:32:10 GMT\r\n"
"X-Frame-Options: DENY\r\n"
"X-Content-Type-Options: nosniff\r\n"
"Referrer-Policy: strict-origin-when-cross-origin\r\n"
"\r\n" // 空行分隔头部和正文
+ body;
// 无脑向客户端发送一个简单http响应
count = send(sockfd, outbuffer.c_str(), outbuffer.size(), 0);
if (count < 0)
{
std::cerr << "send err" << std::endl;
exit(-1);
}
}
close(sockfd);
}
}
cpp
#include "tcpServer.hpp"
#include <iostream>
#include <memory>
using namespace std;
// tcp 服务器,启动方式与udp server一样
//./tcpServer + local_port //我们将本主机的所有ip与端口绑定
static void Usage(string proc)
{
cout << "\nUsage:\n\t" << proc << " lock_port\n\n";
}
int main(int argc, char *argv[])
{
if (argc != 2)
{
Usage(argv[0]);
}
uint16_t serverport = atoi(argv[1]);
unique_ptr<YZC::tcpServer> tsvr(new YZC::tcpServer(YZC::serviceIO, serverport));
tsvr->init();
tsvr->run();
return 0;
}
只要更改tcpServer.hpp 中的run中的代码即可
首先要关系监听fd,这样有新连接到来之后就可以通知应用层accept新连接了。监听fd本质也是一个用于读取内核数据的fd。
并且这里只考虑读事件,其他事件暂不考虑
cpp
void run()
{
fd_set rfds;
FD_ZERO(&rfds);
// 设置rfds关心监听事件
FD_SET(_listensock, &rfds);
int maxfd = _listensock;
//通过select获取就绪事件,然后进行处理
while (true)
{
}
}
全部代码如下:
cpp
void run()
{
fd_set rfds;
FD_ZERO(&rfds);
// 设置rfds关心监听事件
FD_SET(_listensock, &rfds);
int maxfd = _listensock;
// 通过select获取就绪事件,然后进行处理
while (true)
{
fd_set retfds = rfds; // 防止select修改rfds
int n = select(maxfd + 1, &retfds, nullptr, nullptr, nullptr);
// 判断监听事件是否就绪
if (FD_ISSET(_listensock, &retfds))
{
// 说明监听事件就绪了!,可以建立新的连接
struct sockaddr_in clientaddr;
memset(&clientaddr, 0, sizeof(clientaddr));
socklen_t len = sizeof(clientaddr);
int sockfd = accept(_listensock, (struct sockaddr *)&clientaddr, &len);
// 将新的fd增加到rfd中,更新最大fd
FD_SET(sockfd, &rfds);
maxfd = sockfd;
}
// 遍历所有fd,处理对应数据
for (int i = _listensock + 1; i <= maxfd; i++)
{
// 这里直接判断读事件就绪
if (FD_ISSET(i, &retfds))
{
int n = _callback(i);
if(n == 0) //对方退出,去除事件关心
FD_CLR(i, &rfds); // 事件处理后,需要重新关心这个事件
}
}
}
}
这样就能实现IO多路复用并发处理连接了。

2.3 服务器代码
为了适配select,修改了回调函数的返回值。这里给出全部代码方便使用
tcpServer.hpp
cpp
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <sys/select.h>
#include <cstring>
#include <functional>
#include <thread>
namespace YZC
{
// 设置默认端口和最大backlog
const int defaultPort = 8080;
const int maxBacklog = 128;
// 设置回调函数
using func_t = std::function<int(int)>;
class tcpServer
{
public:
tcpServer(func_t func, int port = defaultPort)
: _port(port), _callback(func) {}
void init()
{
// 1.创建socket
_listensock = socket(AF_INET, SOCK_STREAM, 0);
if (_listensock < 0)
{
std::cerr << "sockte err" << std::endl;
exit(-1);
}
std::cout << "socket success" << std::endl;
// 2 bind绑定fd和端口
struct sockaddr_in serveraddr;
memset(&serveraddr, 0, sizeof(serveraddr));
// 设置地址的信息(协议,ip,端口)
serveraddr.sin_family = AF_INET;
serveraddr.sin_addr.s_addr = htonl(INADDR_ANY); // 绑定任意网卡ip,通常我们访问某一个IP地址是这个服务器的公网网卡IP地址
serveraddr.sin_port = htons(_port); // 注意端口16位,2字节需要使用htons。不可
socklen_t len = sizeof(serveraddr);
if (bind(_listensock, (struct sockaddr *)&serveraddr, len) < 0)
{
std::cerr << "bind err" << std::endl;
exit(-1);
}
std::cout << "bind success" << std::endl;
// 3 设置sockfd为监听fd
if (listen(_listensock, maxBacklog) < 0)
{
std::cerr << "listen err" << std::endl;
exit(-1);
}
std::cout << "listen success" << std::endl;
}
void run()
{
fd_set rfds;
FD_ZERO(&rfds);
// 设置rfds关心监听事件
FD_SET(_listensock, &rfds);
int maxfd = _listensock;
// 通过select获取就绪事件,然后进行处理
while (true)
{
fd_set retfds = rfds; // 防止select修改rfds
int n = select(maxfd + 1, &retfds, nullptr, nullptr, nullptr);
// 判断监听事件是否就绪
if (FD_ISSET(_listensock, &retfds))
{
// 说明监听事件就绪了!,可以建立新的连接
struct sockaddr_in clientaddr;
memset(&clientaddr, 0, sizeof(clientaddr));
socklen_t len = sizeof(clientaddr);
int sockfd = accept(_listensock, (struct sockaddr *)&clientaddr, &len);
// 将新的fd增加到rfd中,更新最大fd
FD_SET(sockfd, &rfds);
maxfd = sockfd;
}
// 遍历所有fd,处理对应数据
for (int i = _listensock + 1; i <= maxfd; i++)
{
// 这里直接判断读事件就绪
if (FD_ISSET(i, &retfds))
{
int n = _callback(i);
if(n == 0)
FD_CLR(i, &rfds); // 事件处理后,需要重新关心这个事件
}
}
}
}
private:
int _listensock;
int _port;
func_t _callback;
};
int serviceIO(int sockfd)
{
// 这里仅做简单的数据收发
char buffer[128] = {0};
int count = recv(sockfd, buffer, sizeof(buffer) - 1, 0);
if (count < 0)
{
std::cerr << "recv err" << std::endl;
exit(-1);
}
if (count == 0)
{
// 对方关闭
return 0;
close(sockfd);
}
printf("client --> server:%s\n", buffer);
send(sockfd, buffer, strlen(buffer), 0);
return count;
}
int serviceHTTP(int sockfd)
{
// 这里仅做简单的数据收发
char buffer[128] = {0};
int count = recv(sockfd, buffer, sizeof(buffer) - 1, 0);
if (count < 0)
{
std::cerr << "recv err" << std::endl;
exit(-1);
}
if (count == 0)
{
// 对方关闭
return 0;
close(sockfd);
}
printf("client --> server:%s\n", buffer);
std::string outbuffer;
std::string body = "<h1>hello world</h1>";
outbuffer =
"HTTP/1.1 200 OK\r\n"
"Content-Type: text/html; charset=utf-8\r\n"
"Content-Length: " +
std::to_string(body.size()) + "\r\n"
"Server: Apache/2.4.41\r\n"
"Date: Mon, 18 Dec 2023 08:32:10 GMT\r\n"
"X-Frame-Options: DENY\r\n"
"X-Content-Type-Options: nosniff\r\n"
"Referrer-Policy: strict-origin-when-cross-origin\r\n"
"\r\n" // 空行分隔头部和正文
+ body;
// 无脑向客户端发送一个简单http响应
send(sockfd, outbuffer.c_str(), outbuffer.size(), 0);
return count;
}
}
cpp
#include "tcpServer.hpp"
#include <iostream>
#include <memory>
using namespace std;
// tcp 服务器,启动方式与udp server一样
//./tcpServer + local_port //我们将本主机的所有ip与端口绑定
static void Usage(string proc)
{
cout << "\nUsage:\n\t" << proc << " lock_port\n\n";
}
int main(int argc, char *argv[])
{
if (argc != 2)
{
Usage(argv[0]);
}
uint16_t serverport = atoi(argv[1]);
unique_ptr<YZC::tcpServer> tsvr(new YZC::tcpServer(YZC::serviceIO, serverport));
tsvr->init();
tsvr->run();
return 0;
}
三. 总结与wrk测试select服务器QPS
3.1 测试对比
上篇文章中我们使用wrk测试了多进程服务器/多线程服务器的QPS,输入如下图

这里也测试一下select的QPS。对比一下它们的性能。直接在main函数中将回调函数替换为serviceHTTP即可
测试结果如下:
| 并发数 | 架构 | QPS | 总请求数 | 平均延迟 | 吞吐量 | 错误数 | 错误类型 | 测试状态 | 数据来源 |
|---|---|---|---|---|---|---|---|---|---|
| 1,000 | 多进程 | 7,281 | 73,625 | 100ms | 1.84MB/s | 482 | 482超时 | ✅ 正常 | 原表 |
| 1,000 | 多线程 | 8,650 | 87,421 | 126ms | 2.19MB/s | 73 | 73超时 | ✅ 最佳 | 原表 |
| 1,000 | select | 15,965 | 160,346 | 48ms | 4.03MB/s | 286 | 286超时 | 🎯 优异 | 你的测试 |
| 10,000 | 多进程 | 5,522 | 55,745 | 102ms | 1.40MB/s | 433 | 123读+310超时 | ✅ 正常 | 原表 |
| 10,000 | 多线程 | 7,375 | 74,453 | 194ms | 1.86MB/s | 353 | 107读+246超时 | ✅ 最佳 | 原表 |
| 25,000 | 多进程 | 1,042 | 35,604 | 420ms | 270KB/s | 10,972 | 77读+8932写+1963超时 | ▲ 高压稳定 | 原表 |
| 25,000 | 多线程 | 313 | 24,298 | 205ms | 81KB/s | 953 | 691读+262超时 | ▲ 性能衰减 | 原表 |
| 55,555 | 多进程 | 0 | 0 | 0us | 0B/s | 37,170 | 37170写错误 | ❌ 崩溃 | 原表 |
| 55,555 | 多线程 | N/A | N/A | N/A | N/A | N/A | 测试被终止 | ❌ 崩溃 | 原表 |
为何不继续测试:
因为系统限定了select的最大并发连接是1024。如果需要更大,需要我们自行修改内核数据。我这里就不去修改测试了
3.2 总结
通过测试可以看到,select的性能比多线程/多进程的性能高多了。但是select也有自己的缺点:
1 最大连接限定为 1024
2 每一次就绪后都要遍历所有的关心的fd,即便部分事件并没有测试。有冗余数据
3 需要不断地向内核拷贝进/出 fd_set这个数据,有着频繁的系统调用,这样会不断地在内核层与用户层切换,浪费性能
下篇文章将使用 poll 作为IO多路复用,poll是select的改进。修补了select的很多缺点。