C/C++ Linux网络编程5 - 网络IO模型与select解决客户端并发连接问题

上篇文章:C/C++ Linux网络编程4 - 解决TCP服务器并发的方式-CSDN博客

代码仓库:橘子真甜 (yzc-YZC) - Gitee.com

上篇文章中,采用了多进程/多线程方式来解决服务器处理并发连接的问题。但是这并不是一个很好的处理方案,因为进程和线程的创建和切换对资源的消耗比较大。

而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);
    }

}

tcpserver.cc

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;
    }

}

tcpServer.cc

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的很多缺点。

相关推荐
霖002 小时前
ZYNQ——ultra scale+ IP 核详解与配置
服务器·开发语言·网络·笔记·网络协议·tcp/ip
e***74952 小时前
Nginx 常用安全头
运维·nginx·安全
oushaojun22 小时前
Linux内核KGDB进阶:源码级调试实战演练(转)
linux·运维·kgdb
flypwn2 小时前
justCTF 2025JSpositive_player知识
开发语言·javascript·原型模式
oliveira-time2 小时前
原型模式中的深浅拷贝
java·开发语言·原型模式
2501_941111462 小时前
C++中的原型模式
开发语言·c++·算法
船长㉿2 小时前
vim常用命令
linux·编辑器·vim
大聪明-PLUS2 小时前
Linux 系统中的 CPU。文章 2:平均负载
linux·嵌入式·arm·smarc
亿坊电商2 小时前
PHP框架的资源管理机制如何优雅适配后台任务?
开发语言·php