如何提高linux环境下服务器并发连接数

前言

最近学习在 linux 环境下跑高并发服务器去测试服务器 并发连接数量 偶遇众多问题,拼尽全力无法战胜。因此决定好好研究一番,下面通过一些我遇到的问题分享一下我是如何解决这些错误让服务器并发连接数量从一两万达到几十万的。只想修改配置提高并发量的可以直接下方或右侧目录跳到问题1,问题2。

对了,我的语雀文档更新了嵌入式系列相关的系统学习笔记,详细且图文并茂,希望能帮到你
alive的语雀文档 嵌入式方向系列笔记


服务器工作流程分析及环境准备

项目运行环境

项目技术栈

  • C++、epoll、线程池 、内存池 tcmalloc

工程运行环境

  • VirtualBox + ubuntu 22.04

模块关系图

服务器工作流程

服务器核心工作流程图

我这种 基于 epoll + 线程池 的并发服务器模式,通常也叫做 多线程 Reactor模型

不整这些高大上的名词,简单来说就是

  • epoll 负责 事件监听

  • 主线程 负责检测 I/O 事件 并分发给 线程池 处理。

  • 线程池 负责数据读取、业务逻辑处理,并返回结果给客户端

优点:

  • 将大型数据分离出主线程,避免 I/O 阻塞影响主线程,可以提升并发能力。
  • 充分利用多核 CPU,线程池可并行处理多个请求。

缺点:

  • 线程切换成本高,这里涉及到 CPU 调度的问题,通俗点来说就是在一个空间狭隘材料有限的厨房,每个厨师抢着同一瓶酱油往自己的锅里倒还没倒完又被其他厨师抢走了,得重新抢回来,耗费时间。

  • 线程池里的多个线程 可能会争抢同一个资源,通俗点来说就是饭堂多个学生去同一个窗口打菜,如果窗口不够多,大家都得排队,效率就低了。

注意:

  • 我这里只对服务器进行并发连接数量测试,根据上面的优缺点分析可知,单线程 Reactor 处理并发连接也是完全可以的。
  • 我使用这个处理方法仅仅是为了预留万一以后需要拓展处理其他业务,直接拿当前代码拿来用也是很不错的。

下面也拓展一下 epoll 常见的 6 种设计模式,每种模式各有优缺点,选择适合自己的即可

模型线程/进程特点适用场景
单线程 Reactor1 个线程适用于小型应用低并发服务器
多线程 Reactor线程池epoll监听,线程池处理高并发服务器
多进程 Reactor多进程epoll监听,子进程处理Web 服务器(Nginx)
主从 Reactor主线程 + Worker(线程/进程)主线程监听epoll_wait(),分发给 Worker 处理 I/O高吞吐服务器
协程 Reactor线程 + 协程低切换开销,高并发游戏服务器、微服务
Proactor 模式线程池 + 异步 I/O事件驱动,内核完成 I/OCDN、分布式存储

Proactor 模式 和 Reactor 的区别可以看下面文章

https://zhuanlan.zhihu.com/p/430986475

性能瓶颈识别思路介绍

在进行高并发服务器开发的时候,往往很多时候遇到的性能问题都不是自己的代码原因,因为现在网上都有很多公共的服务器代码可以参考学习,这个基本问题不大,当然前提是你要分的清楚自己的代码报错是由于业务逻辑的原因还是性能的原因。

分析性能瓶颈一般从下面这五个方向入手,利用排除法一个一个排除:

内存、操作系统、CPU、网卡、磁盘

1️⃣ 内存

遇到和内存有关的东西我们通常要考虑两个问题

  • 当前内存使用是否接近上限?
  • 是否有 swapping 或内存分配延迟?

在高并发的情况下内存导致的性能下降通常表现为

  • 高并发下,每个连接占用内存累积,服务器性能下降,响应时间变长

检查方法

free -m
#查看可用内存 (available)和 swap 使用情况

avaiabel 过低或者 swapused 增加 证明内存不足
使用命令 top/htop 列出占用内存最高的进程
高并发情况下优先考虑 TCP 建立时占用过多内存,尝试降低 TCP 的发送缓冲区和接收缓冲区

2️⃣CPU

CPU 我们通常需要确认
● CPU 是否过载?
● 是否由创建过多线程或 CPU 上下文切换开销导致?
性能瓶颈表现为
● CPU使用率接近 100%,任务处理速度变慢,甚至导致系统卡顿

检查方法

top  #top或 htop 查看 CPU 使用率和每个核心负载。

%CPU 持续过高,说明 CPU 占用过大。

可能原因: 线程池问题、代码有低效算法。

3️⃣操作系统(OS)

操作系统的问题就比较多了,通常可以考虑以下两点

  • 是不是 fd 数量不够了?
  • 是不是操作系统本身限制了你的连接数量?
  • 是不是端口用完了?

检查方法

ulimit -a #查看文件描述符打开数是多少
sysctl net.ipv4.ip_local_port_range #查看端口范围

端口或文件描述符设置的过小的话可以适当调大一点再去测试是不是这个问题

4️⃣网卡

网卡可以考虑以下两点

  • 是否带宽达到上限?
  • TCP 等连接的配置优化有没有做好

性能瓶颈可表现为

  • 数据传输延迟
  • 丢包,无法创建连接

检查方法

sudo iftop #监控实时带宽
sysctl -w net.ipv4.tcp_tw_reuse=1 #优化TCP参数

可能原因: 高并发情况下数据吞吐量超过网卡负载

5️⃣磁盘

关于磁盘我们通常要确认

  • 是否有大量的 数据写入操作?
  • 磁盘是否老旧,性能不行?

性能瓶颈表现为

  • 读写操作慢

检查方法

iostat -x 1 #查看磁盘 I/O,关注 await(等待时间)和 %util(利用率)

可能原因: 高并发下情况下,频繁读写数据导致磁盘压力过大。
解决方法: 使用内存缓存或更换硬盘

我遇到的问题

问题 1:Too many open files

描述

客户端连接到一定数量时,终端出现 Too many open files

根本原因:

在 Linux 系统中,每创建一个 socket(无论是服务端的监听 socket 还是客户端的连接 socket),操作系统都会为之分配一个 FD。随着连接数增加,FD 数量快速累积,最终超过进程或系统的限制,导致无法创建新的 FD,从而触发该错误。

验证:

ulimit -n #查看进程级别的文件描述符限制  默认为1024表示最多只能打开1021个,
                                       #前面三个为标准输入输出出错
sysctl fs.file-max #查看系统级别文件描述符限制

解决方案:

1️⃣设置进程级别文件描述符大小

#临时设置,只在当前终端生效,关闭后恢复原状
ulimit -n 1048576 #设置为最大值

-------------------------------------------
#永久设置
sudo nano /etc/security/limits.conf 

#添加
li  soft  nofile  1048576  #第一个为我的用户名,这里修改成你自己的
li  hard  nofile  1048576

#添加
echo "session required pam_limits.so" | sudo tee -a /etc/pam.d/common-session
echo "session required pam_limits.so" | sudo tee -a /etc/pam.d/common-session-noninteractive

#重启生效
sudo reboot

2️⃣修改系统级别限制

#进入以下文件
sudo vi /etc/sysctl.conf
#文件末尾添加
fs.file-max = 9223372036854775807
#生效
sudo sysctl -p

此时这个Too many open files问题解决。

问题 2:提升并发数量

描述:

系统并发到一定数量连接时时候突然无法继续连接,有时候可能会崩掉。

根本原因

Linux 默认的本地端口范围(net.ipv4.ip_local_port_range)设置为 32768 到 60999,共约 28,000 个端口,因此默认只能并发 2.8 万左右。

解决办法:

1️⃣修改 linux 默认分配的端口范围

sudo vim /etc/sysctl.conf  #修改配置文件
#文件末尾添加端口范围为1024~65535,1024以下是系统默认使用的
net.ipv4.ip_local_port_range = 1024 65535 

#生效
sudo sysctl -p

sudo modprobe ip_conntrack

  • 修改完理论上可用端口数为 65535 - 1024 + 1 = 64,512 个,实际连接数达到 64,500 左右,基本符合预期。

那么问题来了!!有没有办法继续提高这个限制呢

有的,兄弟有的

继续提升并发连接客户端


首先我们要知道 TCP 连接的五元组

  • 在 TCP 中,每个连接由五元组唯一标识:
    • 源 IP 地址(Source IP):发送方的 IP 地址。
    • 源端口(Source Port):发送方使用的端口号。
    • 目标 IP 地址(Destination IP)x接收方的 IP 地址。
    • 目标端口(Destination Port)接收方监听的端口号。
    • 协议(Protocol):通常是 TCP。

例如,当客户端(IP 为 192.168.11.15)连接到服务器(IP 为 192.168.11.10,端口 9999)时,假设客户端使用随机端口 50001,五元组为:

(192.168.11.15, 50001, 192.168.11.10, 9999, TCP)

五元组的每项必须唯一,以区分不同的 TCP 连接。在客户端发起连接的场景中,服务器的 IP 和端口(192.168.11.10:9999)通常固定,而客户端的源 IP 和源端口则可能变化

所以,当客户端使用单一源 IP(例如 192.168.11.10)连接到固定的服务器 IP 和端口(192.168.11.10:9999),五元组中只有源端口可以变化因此,连接数的上限取决于可用临时端口的数量:

  • 默认范围下:约 28,000 个连接。
  • 最大范围下:约 64,000 个连接。

解决方案

要突破这一限制,我们需要使五元组中的某项变化,从而创建更多的唯一连接。

1️⃣使用多个 IP 突破单 IP 只能并发 6 万的限制

原理:

五元组包含源 IP,通过使用多个源 IP,每个 IP 可以独立使用其临时端口范围。例如,若每个 IP 支持 60,000 个连接,10 个 IP 可支持 600,000 个连接。

解决方法:

#临时添加多个虚拟进程
sudo ip addr add 127.0.0.2/8 dev lo
sudo ip addr add 127.0.0.3/8 dev lo
sudo ip addr add 127.0.0.4/8 dev lo
sudo ip addr add 127.0.0.5/8 dev lo
......

-----------------------------------------
#或者使用下面这句代码,一行搞定 添加了 127.0.0.2 到 127.0.0.20,共 19 个额外 IP。
for i in {2..20}; do sudo ip addr add 127.0.0.$i/8 dev lo; done
#不是回环地址也可以这样子设置 这种形式创建的相当于192.168.11.15的子IP
for i in {2..10}; do sudo ip addr add 192.168.11.15$i/32 dev enp0s3; done
#添加192.168.16 到 192.168.11.30的IP地址
for i in {16..30}; do sudo ip addr add 192.168.11.$i/24 dev enp0s3; done

这里要说一下,我是在局域网进行测试,有些人可能会选择放在云平台去跑,但是测试并发连接不需要,实际测起来可能测的想吐,因为网络时延的问题。选择本地局域网即可,我这里直接使用的本地回环地址。

客户端循环遍历去连接这几个 IP 提升并发数量

#include <iostream>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>
#include <chrono>
#include <vector>
using namespace std;

class Client {
public:
Client(const string &serverIP, int serverPort, const vector<string> &clientIPs)
: serverIP_(serverIP), serverPort_(serverPort), clientIPs_(clientIPs), ipIndex_(0) {}

~Client() {
    for (int sock : sockets_) {
        close(sock);
    }
}

void run() {
    int count = 0;
    auto lastReportTime = chrono::steady_clock::now();

    while (true) {
        string localIP = clientIPs_[ipIndex_ % clientIPs_.size()];
        ipIndex_++;

        int sock = socket(AF_INET, SOCK_STREAM, 0);
        if (sock < 0) {
            perror("创建 sock 失败");
            continue;
        }

        struct sockaddr_in localAddr;
        memset(&localAddr, 0, sizeof(localAddr));
        localAddr.sin_family = AF_INET;
        localAddr.sin_port = 0;
        inet_pton(AF_INET, localIP.c_str(), &localAddr.sin_addr);
        if (bind(sock, (struct sockaddr*)&localAddr, sizeof(localAddr)) < 0) {
            perror("bind 错误");
            close(sock);
            continue;
        }

        struct sockaddr_in serverAddr;
        memset(&serverAddr, 0, sizeof(serverAddr));
        serverAddr.sin_family = AF_INET;
        serverAddr.sin_port = htons(serverPort_);
        inet_pton(AF_INET, serverIP_.c_str(), &serverAddr.sin_addr);
        if (connect(sock, (struct sockaddr*)&serverAddr, sizeof(serverAddr)) < 0) {
            perror("connect 错误");
            close(sock);
            continue;
        }

        sockets_.push_back(sock);
        count++;

        if (count % 999 == 0) {
            auto now = chrono::steady_clock::now();
            auto duration = chrono::duration_cast<chrono::milliseconds>(now - lastReportTime).count();
            lastReportTime = now;
            string message = "当前连接数量: " + to_string(count) + 
            ", clientIP: " + localIP + 
            ", clientFD: " + to_string(sock) + 
            ", timeuse: " + to_string(duration) + "ms";
            ssize_t n = write(sock, message.c_str(), message.size());
            if (n < 0) {
                perror("发送失败");
            } else {
                char buffer[1024] = {0};
                n = read(sock, buffer, sizeof(buffer) - 1);
                if (n > 0) {
                    buffer[n] = '\0';
                    cout << "服务器返回: " << buffer << endl;
                }
            }
        }
    }
}

private:
string serverIP_;
int serverPort_;
vector<string> clientIPs_;
size_t ipIndex_;
vector<int> sockets_;
};

int main() {
    // 配置 10 个虚拟 IP
    vector<string> clientIPs = {
    "127.0.0.1", "127.0.0.2", "127.0.0.3", "127.0.0.4" //可以继续填写剩余IP
    };
    Client client("127.0.0.1", 9999, clientIPs);
    client.run();
    return 0;
}

验证

  • 多个 IP 并发连接轻松突破单个 6 万的限制
2️⃣连接多个服务器端口

原理:

五元组其他四项固定,服务器 IP 是变化的,那么客户端可以连接到不同的目标端口,每个端口支持独立的 60,000 个连接。

解决方法:

服务器监听多个端口,将多个端口添加到同一个epoll即可,无需多个epoll,详细步骤就不列出来了因为我没写哈哈。

问题 3:连接耗时问题

描述:

当连接数量变多的时候从最开始每 1000 个连接用时 几十毫秒大大增加到上千甚至上万毫秒

根本原因:

  • 首先排除文件描述符接近上限的原因,在前面的设置中已经设置了非常大的文件描述符,这才几十万连接不会达到上限
  • 每个连接需要内存来存储套接字缓冲区、TCP 控制块等,随着连接数增加,内存使用量增大,系统可能开始使用交换空间(swap)或内存分配变慢。

因此我们从优化内存的思路去解决问题,于我的项目而言我需要设置 TCP 缓冲区大小

解决思路

1️⃣优化套接字缓冲区

int bufsize = 4096;//TCP建立连接的时候设置更少的缓冲区
setsockopt(sock, SOL_SOCKET, SO_SNDBUF, &bufsize, sizeof(bufsize));
setsockopt(sock, SOL_SOCKET, SO_RCVBUF, &bufsize, sizeof(bufsize));

2️⃣修改 linux 本地配置

sudo vim /etc/sysctl.conf 
#下面几个是系统默认的,可以修改为更大的值
net.ipv4.tcp_mem = 262144 524288 786432  #524288 1048576 1572864 修改为更大的值
net.ipv4.tcp_wmem = 2048 2048 4096
net.ipv4.tcp_rmem = 2048 2048 4096
net.nf_conntrack_max = 1048576

net.ipv4.tcp_mem = 262144 524288 786432

  • 控制全局 TCP 内存使用,单位是页面大小(通常 4KB),有三个值:
    • 262144低阈值,低于此值时不限制内存使用(约 1GB)。
    • 524288:压力阈值,达到此值时开始限制新连接内存(约 2GB)。
    • 786432:最大阈值,上限内存量,超出可能丢包(约 3GB)。

net.ipv4.tcp_wmem = 2048 2048 4096TCP 发送缓冲区大小(单位:字节)

net.ipv4.tcp_rmem = 2048 2048 4096TCP 接收缓冲区大小(单位:字节)

因此,只需要将tcp_mem设置为更大值即可降低连接耗时,如下,30 多万后每一千连接耗时比之前降低很多

问题 4: Bad file descriptor

描述

客户端主动断开连接时,终端出现 epoll_ctl 移除fd失败: Bad file descriptor

在这里插入图片描述

场景

多个线程同时处理同一个 clientFD,一个线程关闭 fd 后,另一个线程尝试操作已关闭的 fd。

根本原因:

我的代码逻辑是 while 循环检测分配事件,如果是客户端有数据的话就交给工作线程去处理,也就是调用线程池去接受处理客户端发送的数据。这就导致水平触发模式(EPOLLET)下,epoll_wait 可能多次返回同一 fd,导致多个线程使用同一个 clientfd 处理并发冲突。

解决方案

  • 使用 EPOLLONESHOT,确保 fd 在触发事件后禁用:
    • Epoll::addClientFD 中设置 EPOLLIN | EPOLLONESHOT
    • 添加 rearm 方法重新启用 fd。
    • readFromClient 处理完成后调用 rearm(如果客户端连接未关闭)。

epoll.cpp

在这里插入图片描述
server.cpp
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值