TCP Socket编程实战(三):线程池优化与TCP编程最佳实践

文章目录

    • [TCP Socket编程实战(三):线程池优化与TCP编程最佳实践](#TCP Socket编程实战(三):线程池优化与TCP编程最佳实践)
    • 一、V3多线程方案的性能问题
      • [1.1 线程创建的开销](#1.1 线程创建的开销)
      • [1.2 线程池的优势](#1.2 线程池的优势)
    • 二、V4线程池版本实现
      • [2.1 任务类型的定义](#2.1 任务类型的定义)
      • [2.2 Service函数的改造](#2.2 Service函数的改造)
      • [2.3 std::bind绑定成员函数](#2.3 std::bind绑定成员函数)
        • [2.3.1 std::bind的用法](#2.3.1 std::bind的用法)
        • [2.3.2 为什么这里不能用auto](#2.3.2 为什么这里不能用auto)
        • [2.3.3 参数的拷贝语义](#2.3.3 参数的拷贝语义)
      • [2.4 线程池的使用](#2.4 线程池的使用)
      • [2.5 线程池的内部实现(简述)](#2.5 线程池的内部实现(简述))
    • 三、SO_REUSEADDR深入理解
      • [3.1 TIME_WAIT状态](#3.1 TIME_WAIT状态)
      • [3.2 为什么需要TIME_WAIT](#3.2 为什么需要TIME_WAIT)
      • [3.3 TIME_WAIT导致的问题](#3.3 TIME_WAIT导致的问题)
      • [3.4 SO_REUSEADDR的作用](#3.4 SO_REUSEADDR的作用)
      • [3.5 SO_REUSEPORT的作用](#3.5 SO_REUSEPORT的作用)
      • [3.6 实战建议](#3.6 实战建议)
    • 四、read/write返回值处理最佳实践
      • [4.1 read的三种返回值](#4.1 read的三种返回值)
      • [4.2 处理read返回值的标准模式](#4.2 处理read返回值的标准模式)
      • [4.3 write的返回值](#4.3 write的返回值)
      • [4.4 处理write的标准模式](#4.4 处理write的标准模式)
    • 五、TCP粘包问题预告
      • [5.1 什么是粘包](#5.1 什么是粘包)
      • [5.2 为什么会粘包](#5.2 为什么会粘包)
      • [5.3 解决方案(简述)](#5.3 解决方案(简述))
    • 六、生产环境最佳实践
      • [6.1 错误处理](#6.1 错误处理)
      • [6.2 资源管理](#6.2 资源管理)
      • [6.3 性能优化](#6.3 性能优化)
      • [6.4 安全性](#6.4 安全性)
    • 七、本篇总结
      • [7.1 核心要点](#7.1 核心要点)
      • [7.2 容易混淆的点](#7.2 容易混淆的点)

TCP Socket编程实战(三):线程池优化与TCP编程最佳实践

💬 开篇:前两篇实现了单连接、多进程、多线程版本的TCP服务器。但V3多线程方案有个问题:每个连接都创建一个新线程,如果并发量很大(比如10000个连接),就要创建10000个线程,线程创建和销毁的开销会成为性能瓶颈。这一篇引入线程池(V4)解决这个问题,然后系统总结TCP编程的最佳实践:SO_REUSEADDR的深入理解、read/write的返回值处理、TCP粘包问题预告、生产环境的优化建议。掌握了这些,就能写出高性能、生产级别的TCP服务器。

👍 点赞、收藏与分享:这篇是TCP编程系列的收官之作,会把所有实战技巧和坑都讲清楚。如果对你有帮助,请点赞收藏!

🚀 循序渐进:从V3的性能问题分析开始,到线程池的设计与实现,到std::bind的高级用法,到TCP编程的各种最佳实践,一步步构建完整的知识体系。


一、V3多线程方案的性能问题

1.1 线程创建的开销

V3的ProcessConnection每次都创建新线程:

cpp 复制代码
void ProcessConnection(int sockfd, struct sockaddr_in &peer)
{
    pthread_t tid;
    ThreadData *td = new ThreadData(sockfd, peer);
    pthread_create(&tid, nullptr, threadExecute, (void*)td);
}

假设处理一个请求需要10ms,线程创建需要0.5ms。如果有10000个并发连接,线程创建的总开销就是5秒(0.5ms × 10000)。

更重要的是,10000个线程同时运行,会导致:

  • 上下文切换开销巨大:CPU不停地在线程间切换,真正执行业务代码的时间变少
  • 内存占用过高:每个线程默认栈大小8MB,10000个线程就是80GB内存
  • 线程调度混乱:操作系统难以有效调度这么多线程

1.2 线程池的优势

线程池的核心思想:预先创建固定数量的线程,复用它们处理所有任务

bash 复制代码
V3方案:
连接1 → 创建线程1 → 处理 → 销毁线程1
连接2 → 创建线程2 → 处理 → 销毁线程2
...

V4线程池方案:
预先创建5个工作线程,一直运行
连接1 → 工作线程1处理
连接2 → 工作线程2处理
连接3 → 工作线程3处理
连接4 → 工作线程4处理
连接5 → 工作线程5处理
连接6 → 工作线程1处理(线程1处理完了,复用)
...

优势:

  • 没有线程创建和销毁的开销
  • 线程数量可控,不会无限增长
  • 上下文切换开销固定
  • 内存占用可控

二、V4线程池版本实现

2.1 任务类型的定义

线程池要执行的"任务"是什么?在我们的场景中,任务就是"处理一个客户端连接"。

用C++11的std::function定义任务类型:

cpp 复制代码
using func_t = std::function<void()>;

std::function<void()>表示:无参数、无返回值的可调用对象。可以是:

  • 普通函数指针
  • lambda表达式
  • std::bind绑定的函数对象
  • 函数对象(重载了operator()的类)

2.2 Service函数的改造

V3的Service是静态成员函数,接收ThreadData参数:

cpp 复制代码
static void Service(ThreadData &td)
{
    // 处理连接
}

V4要把Service变成可以封装进func_t的形式。问题是:Service需要访问sockfdaddr,怎么传递这些参数?

答案是:把Service改成普通成员函数,用std::bind绑定参数

cpp 复制代码
void Service(int sockfd, InetAddr addr)
{
    char buffer[1024];
    
    while (true) {
        ssize_t n = read(sockfd, buffer, sizeof(buffer) - 1);
        if (n > 0) {
            buffer[n] = 0;
            std::cout << "client say# " << buffer << std::endl;
            
            std::string echo_string = "server echo# ";
            echo_string += buffer;
            write(sockfd, echo_string.c_str(), echo_string.size());
        }
        else if (n == 0) {
            lg.LogMessage(Info, "client[%s:%d] quit...\n", 
                          addr.Ip().c_str(), addr.Port());
            break;
        }
        else {
            lg.LogMessage(Error, "read socket error\n");
            break;
        }
    }
}

注意:

  • 去掉了static关键字(变回普通成员函数)
  • 参数从ThreadData&改成int sockfd, InetAddr addr

2.3 std::bind绑定成员函数

cpp 复制代码
void ProcessConnection(int sockfd, struct sockaddr_in &peer)
{
    using func_t = std::function<void()>;
    
    InetAddr addr(peer);
    func_t func = std::bind(&TcpServer::Service, this, sockfd, addr);
    
    ThreadPool<func_t>::GetInstance()->Push(func);
}
2.3.1 std::bind的用法
cpp 复制代码
func_t func = std::bind(&TcpServer::Service, this, sockfd, addr);

这行代码的含义:

  • &TcpServer::Service:成员函数指针
  • this:对象指针(成员函数需要一个对象才能调用)
  • sockfd, addr:函数参数

绑定后的func就是一个void()类型的函数对象,调用func()相当于调用this->Service(sockfd, addr)

2.3.2 为什么这里不能用auto

注意代码里写的是:

cpp 复制代码
using func_t = std::function<void()>;
func_t func = std::bind(...);

而不是:

cpp 复制代码
auto func = std::bind(...);

为什么?因为线程池的Push函数声明是:

cpp 复制代码
template<typename T>
class ThreadPool {
public:
    void Push(const T& task);
};

如果用autofunc的类型是std::_Bind<...>(编译器内部类型),不是std::function<void()>

虽然两者都是可调用对象,但线程池模板参数T被推导成了std::_Bind<...>,和其他地方推导出的std::function<void()>不一致,会导致类型不匹配。

所以必须显式指定类型为func_t(即std::function<void()>),让所有任务类型统一。

2.3.3 参数的拷贝语义
cpp 复制代码
func_t func = std::bind(&TcpServer::Service, this, sockfd, addr);

sockfd是int,按值传递,会拷贝。
addrInetAddr对象,按值传递,也会拷贝。

为什么要拷贝?因为ProcessConnection函数返回后,栈上的sockfdaddr就销毁了。如果用引用,线程池执行任务时访问的就是野指针。

所以必须拷贝一份,让任务持有自己的数据副本。

2.4 线程池的使用

cpp 复制代码
void Start()
{
    _isrunning = true;
    
    // 启动线程池(单例模式,全局唯一)
    ThreadPool<func_t>::GetInstance()->Start();
    
    while (_isrunning) {
        struct sockaddr_in peer;
        socklen_t len = sizeof(peer);
        
        int sockfd = accept(_listensock, CONV(&peer), &len);
        if (sockfd < 0) {
            lg.LogMessage(Warning, "accept socket error\n");
            continue;
        }
        
        lg.LogMessage(Debug, "accept success, sockfd: %d\n", sockfd);
        
        // 把任务提交给线程池
        ProcessConnection(sockfd, peer);
    }
}

流程:

  1. 启动线程池(创建固定数量的工作线程,开始从任务队列取任务)
  2. accept获取连接
  3. 用std::bind把Service绑定成任务
  4. Push任务到线程池的任务队列
  5. 工作线程从队列取出任务并执行

2.5 线程池的内部实现(简述)

之前文章中介绍过了,这里简要理解它的核心结构:

cpp 复制代码
template<typename T>
class ThreadPool {
public:
    static ThreadPool* GetInstance() {
        // 单例模式
        static ThreadPool instance;
        return &instance;
    }
    
    void Start() {
        // 创建固定数量的工作线程
        for (int i = 0; i < thread_num; i++) {
            pthread_create(&threads[i], nullptr, Worker, this);
        }
    }
    
    void Push(const T& task) {
        // 加锁,把任务放入队列
        LockGuard lock(&mutex);
        task_queue.push(task);
        pthread_cond_signal(&cond);  // 唤醒一个工作线程
    }
    
private:
    static void* Worker(void* arg) {
        ThreadPool* pool = (ThreadPool*)arg;
        while (true) {
            T task;
            {
                LockGuard lock(&pool->mutex);
                // 等待任务
                while (pool->task_queue.empty()) {
                    pthread_cond_wait(&pool->cond, &pool->mutex);
                }
                task = pool->task_queue.front();
                pool->task_queue.pop();
            }
            // 执行任务(不持有锁)
            task();
        }
    }
    
    std::queue<T> task_queue;
    pthread_mutex_t mutex;
    pthread_cond_t cond;
    std::vector<pthread_t> threads;
};

关键点:

  • 单例模式保证全局唯一
  • 任务队列+互斥锁+条件变量实现生产者消费者模型
  • 工作线程在没有任务时阻塞在条件变量上
  • Push时唤醒一个工作线程取任务

三、SO_REUSEADDR深入理解

3.1 TIME_WAIT状态

TCP连接关闭时,主动关闭的一方会进入TIME_WAIT状态,持续2MSL(Maximum Segment Lifetime,通常是2分钟)。

bash 复制代码
客户端主动关闭:
客户端发FIN → 服务器回ACK → 服务器发FIN → 客户端回ACK
客户端进入TIME_WAIT,等待2分钟

服务器主动关闭:
服务器发FIN → 客户端回ACK → 客户端发FIN → 服务器回ACK
服务器进入TIME_WAIT,等待2分钟

3.2 为什么需要TIME_WAIT

TIME_WAIT的作用:

  1. 确保最后的ACK能够到达:如果最后的ACK丢失,对端会重传FIN,TIME_WAIT状态可以重新发送ACK
  2. 防止旧连接的数据包干扰新连接:等待2MSL可以确保所有旧数据包都从网络中消失

3.3 TIME_WAIT导致的问题

如果服务器主动关闭连接(调用close),它会进入TIME_WAIT状态。在TIME_WAIT期间,这个IP:端口组合不能再被bind。

测试场景:

bash 复制代码
./tcp_server 8888
# 服务器运行一段时间后按Ctrl+C退出
# 立刻重启
./tcp_server 8888
bind error: Address already in use

原因:端口8888还处于TIME_WAIT状态,不能立刻重用。

3.4 SO_REUSEADDR的作用

设置SO_REUSEADDR后,可以立刻重用处于TIME_WAIT状态的端口。

cpp 复制代码
int opt = 1;
setsockopt(_listensock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

这样即使端口处于TIME_WAIT,也能立刻bind成功,不用等2分钟。

3.5 SO_REUSEPORT的作用

SO_REUSEADDR解决的是"同一个进程重启时端口重用"的问题。

SO_REUSEPORT解决的是"多个进程/线程同时bind同一个端口"的问题(Linux 3.9+支持)。

cpp 复制代码
int opt = 1;
setsockopt(sockfd, SOL_SOCKET, SO_REUSEPORT, &opt, sizeof(opt));

应用场景:

  • 多进程负载均衡:多个进程bind同一个端口,内核自动把新连接分配给不同进程
  • 热重启:新版本程序启动时,和老版本同时监听同一个端口,实现无缝切换

3.6 实战建议

开发环境:必须设置SO_REUSEADDR,否则每次测试都要等2分钟。

生产环境:也建议设置,方便快速重启服务器,不影响安全性。

SO_REUSEPORT:看具体需求,如果不需要多进程bind同一个端口,可以不设置。


四、read/write返回值处理最佳实践

4.1 read的三种返回值

cpp 复制代码
ssize_t n = read(sockfd, buffer, sizeof(buffer));

返回值 > 0:成功读取了n字节数据。

返回值 == 0对端关闭了连接(收到FIN包)

这是TCP最重要的特性之一。当客户端调用close(sockfd)时,会发送FIN包给服务器,服务器的read会返回0,表示"文件结束"(EOF)。

返回值 < 0 :发生错误,需要检查errno

errno 含义 处理方式
EINTR 被信号中断 重试
EAGAIN/EWOULDBLOCK 非阻塞模式下没有数据 稍后重试
ECONNRESET 连接被对端重置 关闭连接
其他 真正的错误 关闭连接并记录日志

4.2 处理read返回值的标准模式

cpp 复制代码
while (true) {
    ssize_t n = read(sockfd, buffer, sizeof(buffer) - 1);
    
    if (n > 0) {
        buffer[n] = 0;  // 加'\0'方便当字符串处理
        // 处理数据
    }
    else if (n == 0) {
        // 对端关闭连接,正常退出
        lg.LogMessage(Info, "client closed connection\n");
        break;
    }
    else {
        // 错误处理
        if (errno == EINTR) {
            continue;  // 信号中断,重试
        }
        else if (errno == EAGAIN || errno == EWOULDBLOCK) {
            // 非阻塞模式,稍后重试(我们的代码是阻塞模式,不会遇到)
            continue;
        }
        else {
            // 真正的错误
            lg.LogMessage(Error, "read error: %s\n", strerror(errno));
            break;
        }
    }
}

close(sockfd);  // 关闭连接

4.3 write的返回值

cpp 复制代码
ssize_t n = write(sockfd, data, len);

返回值 == len:成功写入所有数据。

返回值 > 0 但 < len:部分写入(TCP发送缓冲区满了)。需要继续写入剩余部分。

返回值 < 0 :发生错误,检查errno(和read类似)。

4.4 处理write的标准模式

cpp 复制代码
ssize_t WriteN(int sockfd, const char* data, size_t len)
{
    size_t left = len;      // 剩余待写入字节数
    const char* ptr = data;  // 当前写入位置
    
    while (left > 0) {
        ssize_t n = write(sockfd, ptr, left);
        
        if (n > 0) {
            left -= n;
            ptr += n;
        }
        else if (n == 0) {
            // write返回0?理论上不应该出现
            break;
        }
        else {
            if (errno == EINTR) {
                continue;  // 信号中断,重试
            }
            else if (errno == EAGAIN || errno == EWOULDBLOCK) {
                // 非阻塞模式,缓冲区满,稍后重试
                continue;
            }
            else {
                // 真正的错误
                return -1;
            }
        }
    }
    
    return len - left;  // 返回实际写入的字节数
}

我们的Echo Server代码简化了处理(假设write一次就能成功),但生产环境应该用上面的循环写入模式。


五、TCP粘包问题预告

5.1 什么是粘包

TCP是字节流协议,不保留消息边界。

发送端:

cpp 复制代码
write(sockfd, "Hello", 5);
write(sockfd, "World", 5);

接收端可能:

cpp 复制代码
read(sockfd, buffer, 10);  // 读到 "HelloWorld" (粘包)

也可能:

cpp 复制代码
read(sockfd, buffer, 10);  // 读到 "Hello"
read(sockfd, buffer, 10);  // 读到 "World"

还可能:

cpp 复制代码
read(sockfd, buffer, 10);  // 读到 "HelloWor"
read(sockfd, buffer, 10);  // 读到 "ld"

5.2 为什么会粘包

TCP层有发送缓冲区和接收缓冲区:

bash 复制代码
发送端:
应用层write("Hello") → 发送缓冲区
应用层write("World") → 发送缓冲区
TCP层:把缓冲区里的数据打包成TCP段发送,可能把"Hello"和"World"放在同一个TCP段里

接收端:
TCP层:收到TCP段,放入接收缓冲区
应用层read():从接收缓冲区读取,可能一次读到"HelloWorld"

5.3 解决方案(简述)

需要在应用层定义协议,明确消息边界:

方案1:固定长度

bash 复制代码
每个消息固定100字节,不足的补空格

方案2:分隔符

bash 复制代码
每个消息以'\n'结尾
"Hello\n"
"World\n"

方案3:长度前缀

bash 复制代码
前4字节表示消息长度,后面跟实际内容
[5]Hello[5]World

方案4:应用层协议(最常用)

bash 复制代码
HTTP协议:Content-Length头指定消息长度
Redis协议:RESP格式,每行以\r\n结尾

具体实现会在后续的HTTP服务器项目中详细讲解。


六、生产环境最佳实践

6.1 错误处理

日志要详细

cpp 复制代码
// 不好
if (n < 0) {
    printf("error\n");
}

// 好
if (n < 0) {
    lg.LogMessage(Error, "read error: errno=%d, errmsg=%s, client=%s:%d\n", 
                  errno, strerror(errno), ip, port);
}

区分错误类型

  • 临时性错误(EINTR、EAGAIN):重试
  • 永久性错误(ECONNRESET、EPIPE):关闭连接
  • 严重错误(ENOMEM):记录日志并通知运维

6.2 资源管理

及时关闭fd

cpp 复制代码
// 每个分支都要确保fd被关闭
if (error) {
    close(sockfd);
    return;
}
// 正常处理
close(sockfd);

用RAII管理资源

cpp 复制代码
class SockGuard {
public:
    SockGuard(int fd) : fd_(fd) {}
    ~SockGuard() { if (fd_ >= 0) close(fd_); }
private:
    int fd_;
};

void Service(int sockfd) {
    SockGuard guard(sockfd);  // 构造时持有,析构时自动关闭
    // 不管怎么返回,fd都会被关闭
}

6.3 性能优化

减少系统调用

cpp 复制代码
// 不好:每次write一个字符
for (char c : str) {
    write(sockfd, &c, 1);
}

// 好:一次write整个字符串
write(sockfd, str.c_str(), str.size());

使用writev批量发送

cpp 复制代码
struct iovec iov[3];
iov[0].iov_base = header;
iov[0].iov_len = header_len;
iov[1].iov_base = body;
iov[1].iov_len = body_len;
iov[2].iov_base = footer;
iov[2].iov_len = footer_len;

writev(sockfd, iov, 3);  // 一次系统调用发送三块数据

设置TCP_NODELAY禁用Nagle算法(小数据包场景):

cpp 复制代码
int flag = 1;
setsockopt(sockfd, IPPROTO_TCP, TCP_NODELAY, &flag, sizeof(flag));

6.4 安全性

限制缓冲区大小

cpp 复制代码
// 不好:用户可以发送任意大小的数据
char buffer[100000000];  // 400MB栈空间?会崩溃
read(sockfd, buffer, sizeof(buffer));

// 好:限制单次读取大小
char buffer[8192];  // 8KB
read(sockfd, buffer, sizeof(buffer));

超时保护

cpp 复制代码
struct timeval timeout;
timeout.tv_sec = 30;   // 30秒超时
timeout.tv_usec = 0;
setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));

限制并发数

cpp 复制代码
if (current_connections > MAX_CONNECTIONS) {
    close(sockfd);  // 拒绝新连接
    lg.LogMessage(Warning, "too many connections, reject\n");
}

七、本篇总结

7.1 核心要点

V4线程池版本

  • 预先创建固定数量的工作线程,复用处理所有任务
  • 用std::function<void()>定义任务类型
  • 用std::bind把成员函数绑定成任务
  • 任务参数按值传递,确保拷贝语义
  • 不能用auto推导,必须显式指定func_t类型

SO_REUSEADDR/SO_REUSEPORT

  • SO_REUSEADDR:允许重用TIME_WAIT状态的端口,开发和生产环境都建议设置
  • SO_REUSEPORT:允许多个进程bind同一个端口,用于负载均衡或热重启
  • TIME_WAIT持续2分钟,是TCP协议的正常行为

read/write返回值

  • read返回0表示对端关闭连接(EOF),是正常情况不是错误
  • read/write返回-1要检查errno,区分临时错误和永久错误
  • write可能部分写入,生产环境要循环写入确保全部发送

TCP粘包问题

  • TCP是字节流协议,不保留消息边界
  • 多次write可能被接收方一次read读取(粘包)
  • 一次write也可能被拆成多次read(拆包)
  • 解决方案:应用层协议定义消息边界(长度前缀、分隔符、固定长度)

生产环境最佳实践

  • 详细的错误日志(包含errno和上下文信息)
  • RAII管理资源,确保fd正确关闭
  • 减少系统调用,批量发送数据
  • 设置超时、限制缓冲区、控制并发数

7.2 容易混淆的点

  1. 为什么不能用auto推导bind的返回值:因为线程池需要统一的任务类型std::function<void()>,auto推导出的是编译器内部类型,会导致模板参数不匹配。

  2. std::bind为什么要拷贝参数:因为绑定时的栈变量在函数返回后就销毁了,任务执行时访问的是野指针。必须拷贝参数让任务持有自己的数据。

  3. read返回0是错误吗:不是,是对端正常关闭连接,应该优雅地关闭本端连接并退出循环。

  4. 为什么write可能返回小于len的值:TCP发送缓冲区满了,只能写入部分数据。需要循环写入剩余部分。

  5. TIME_WAIT是bug吗:不是,是TCP协议的正常机制,用于确保连接正确关闭。SO_REUSEADDR可以让我们重用TIME_WAIT状态的端口。

  6. TCP粘包能不能靠设置选项避免:不能,TCP层不保证消息边界,必须在应用层定义协议解决。


💬 总结:TCP Socket编程系列三篇到此结束!从单连接到多进程,从多线程到线程池,从API详解到最佳实践,构建了完整的TCP并发服务器知识体系。线程池通过复用工作线程大幅提升了性能,std::bind优雅地解决了参数绑定问题。SO_REUSEADDR、read返回值处理、粘包问题、生产环境优化,这些都是实战中必须掌握的技能。掌握了这些,你就能写出高性能、高可用、生产级别的TCP服务器。
👍 点赞、收藏与分享:如果这个系列帮你系统掌握了TCP编程,请点赞收藏!后续如果写HTTP服务器、协议设计等内容,会在TCP基础上继续深入。感谢阅读!

相关推荐
大大大反派2 小时前
CANN 生态中的自动化部署引擎:深入 `mindx-sdk` 项目构建端到端 AI 应用
运维·人工智能·自动化
June`2 小时前
高并发网络框架:Reactor模式深度解析
linux·服务器·c++
小镇敲码人2 小时前
剖析CANN框架中Samples仓库:从示例到实战的AI开发指南
c++·人工智能·python·华为·acl·cann
WHD3063 小时前
苏州勒索病毒加密 服务器数据解密恢复
运维·服务器
刘琦沛在进步3 小时前
【C / C++】引用和函数重载的介绍
c语言·开发语言·c++
蜡笔小炘3 小时前
LVS -- 持久链接(Persistent Connection)实现会话粘滞
运维·服务器
蜡笔小炘3 小时前
LVS -- 利用防火墙标签(FireWall Mark)解决轮询错误
服务器·数据库·lvs
生活很暖很治愈3 小时前
Linux——孤儿进程&进程调度&大O(1)调度
linux·服务器·ubuntu
JoySSLLian3 小时前
手把手教你安装免费SSL证书(附宝塔/Nginx/Apache配置教程)
网络·人工智能·网络协议·tcp/ip·nginx·apache·ssl