[操作系统] 进程间通信:进程池的实现

引言

在学习操作系统时,进程间通信(IPC)和多进程管理是核心内容之一。进程池是一种常见的模式,通过预先创建一组工作进程来处理任务,避免频繁创建和销毁进程带来的开销。本文将详细剖析一个用 C++ 实现的进程池代码,逐步讲解其设计与实现,理清类之间的关系,并分析潜在问题和改进方向。

什么是进程池?

想象你开了一家餐馆,每次顾客点餐时都临时雇一个厨师显然效率低下。更好的方法是保持一组固定的厨师团队,随时待命处理订单。进程池的思路类似:它预先创建一组子进程(工作者),由父进程(管理者)分配任务给它们执行。这种方式减少了进程创建和销毁的开销,提高了任务处理的效率。

在这个实现中,进程池具有以下特点:

  • 使用**管道(pipe)**实现父子进程间的通信
  • 通过**轮询(round-robin)**方式分配任务,实现负载均衡
  • 支持动态注册和执行任务

代码主要由以下几个部分组成:

  1. Channel类:管理单个子进程的通信通道
  2. ChannelManager类:管理所有通道并实现任务分发的负载均衡
  3. ProcessPool类:进程池的核心实现
  4. TaskManager类:管理任务的注册和执行
  5. 主函数main():演示进程池的使用

下面,我们从基础开始,逐步解析实现。

第一步:基础概念和工具

在深入代码之前,先了解几个关键概念和工具:

  1. 进程 :在类Unix系统中,fork()用于创建子进程。父进程负责管理,子进程执行任务。
  2. 管道(Pipe) :管道是一种简单的IPC机制,提供单向通信通道。创建管道时返回两个文件描述符:pipefd[0](读端)和pipefd[1](写端)。
  3. 任务管理:需要一个机制来定义、注册和执行任务。
  4. 负载均衡:任务应均匀分配给子进程,避免某个进程过载。

这些工具和概念是进程池实现的基础,接下来我们逐一分析每个类的作用及其实现。

第二步:Channel类 - 父子进程间的通信通道

Channel类封装了父进程与单个子进程之间的通信通道。它基于管道的写端(父进程使用)和子进程的ID。

关键成员

  • _wfd:管道的写端文件描述符
  • _subid:子进程的PID(进程ID)
  • _name:通道的名称(用于调试,例如"channel-3-1234")

核心方法

构造函数
cpp 复制代码
Channel(int fd, pid_t id) : _wfd(fd), _subid(id) {
    _name = "channel-" + std::to_string(_wfd) + "-" + std::to_string(_subid);
}

作用:初始化一个通道,记录写端描述符和子进程ID。

设计依据:每个通道需要唯一标识其管道和子进程,便于管理和调试。

Send(int code):向子进程发送任务码
cpp 复制代码
void Send(int code) {
    int n = write(_wfd, &code, sizeof(code));
    (void)n; // 避免未使用变量警告
}

作用:父进程通过管道将任务码发送给子进程。

设计依据:任务码是一个整数,子进程根据它决定执行什么任务。

注意:这里忽略了write的返回值n,实际应用中应检查是否成功写入。

Close():关闭管道写端
cpp 复制代码
void Close() {
    close(_wfd);
}

作用:关闭管道,通知子进程退出(子进程读到EOF)。

设计依据:管道关闭是父进程控制子进程生命周期的关键信号。

Wait():等待子进程结束
cpp 复制代码
void Wait() {
    pid_t rid = waitpid(_subid, nullptr, 0);
    (void)rid;
}

作用:回收子进程,防止其变成僵尸进程。

设计依据:父进程必须调用waitpid来清理已结束的子进程。

与其他类的关系

  • ChannelChannelManager的基本单元,提供对单个子进程的控制
  • 它通过_wfd与子进程通信,子进程则通过对应的读端接收任务

第三步:ChannelManager类 - 管理所有通道

ChannelManager类负责管理所有Channel对象,并实现任务的负载均衡分发。

关键成员

  • _channels:一个std::vector<Channel>,存储所有通道
  • _next:下一个要使用的通道索引,用于轮询

核心方法

Insert(int wfd, pid_t subid):添加新通道
cpp 复制代码
void Insert(int wfd, pid_t subid) {
    _channels.emplace_back(wfd, subid);
}

作用:在进程池启动时,为每个子进程创建一个通道。

设计依据:将通道集中管理,便于后续操作。

Select():轮询选择下一个通道
cpp 复制代码
Channel &Select() {
    auto &c = _channels[_next];
    _next++;
    _next %= _channels.size(); // 循环使用
    return c;
}

作用:实现简单的轮询(round-robin)负载均衡。

设计依据:轮询确保任务均匀分配,避免某个子进程过载。

StopSubProcess():关闭所有通道
cpp 复制代码
void StopSubProcess() {
    for (auto &channel : _channels) {
        channel.Close();
        std::cout << "关闭: " << channel.Name() << std::endl;
    }
}

作用:关闭所有管道,通知子进程退出。

设计依据:统一关闭是停止进程池的第一步。

WaitSubProcess():回收所有子进程
cpp 复制代码
void WaitSubProcess() {
    for (auto &channel : _channels) {
        channel.Wait();
        std::cout << "回收: " << channel.Name() << std::endl;
    }
}

作用:等待并回收所有子进程。

设计依据:防止僵尸进程,确保资源清理。

与其他类的关系

  • ChannelManager管理多个Channel对象,是ProcessPool的核心组件
  • 它通过Select()ProcessPool::Run()协作,实现任务分发

第四步:TaskManager类 - 任务管理

TaskManager类负责任务的注册、生成和执行。

关键成员

  • _tasks:一个std::vector<task_t>,存储任务函数指针
  • task_t:定义为void (*)(),表示无参数、无返回值的函数

核心方法

Register(task_t t):注册新任务
cpp 复制代码
void Register(task_t t) {
    _tasks.push_back(t);
}

作用 :将任务函数(如PrintLog)添加到任务列表。

设计依据:支持动态扩展任务类型。

Code():生成随机任务码
cpp 复制代码
int Code() {
    return rand() % _tasks.size();
}

作用:返回一个随机任务索引。

设计依据:随机选择模拟任务多样性,实际应用中可能使用任务队列。

Execute(int code):执行指定任务
cpp 复制代码
void Execute(int code) {
    if (code >= 0 && code < _tasks.size()) {
        _tasks[code]();
    }
}

作用:根据任务码调用对应的函数。

设计依据:子进程根据收到的任务码执行具体任务。

示例任务

代码提供了三个示例任务:

  1. PrintLog():打印日志
  2. Download():模拟下载
  3. Upload():模拟上传

与其他类的关系

  • TaskManagerProcessPool使用,负责任务的生成(Code())和执行(Execute()
  • 子进程通过ProcessPool::Work()调用Execute()

第五步:ProcessPool类 - 进程池核心

ProcessPool类整合了上述组件,实现进程池的完整功能。

关键成员

  • _cmChannelManager对象,管理通信通道
  • _process_num:子进程数量(默认5)
  • _tmTaskManager对象,管理任务

核心方法

构造函数
cpp 复制代码
ProcessPool(int num) : _process_num(num) {
    _tm.Register(PrintLog);
    _tm.Register(Download);
    _tm.Register(Upload);
}

作用:初始化进程池并注册默认任务。

设计依据:预设任务便于测试和演示。

Work(int rfd):子进程工作循环
cpp 复制代码
void Work(int rfd) {
    while (true) {
        int code = 0;
        ssize_t n = read(rfd, &code, sizeof(code));
        if (n > 0) {
            std::cout << "子进程[" << getpid() << "]收到一个任务码: " << code << std::endl;
            _tm.Execute(code);
        } else if (n == 0) { // 管道关闭
            std::cout << "子进程退出" << std::endl;
            break;
        } else { // 读取错误
            std::cout << "读取错误" << std::endl;
            break;
        }
    }
}

作用:子进程持续读取任务码并执行,直到管道关闭。

设计依据:阻塞式read确保子进程在无任务时等待。

Start():启动进程池
cpp 复制代码
bool Start() {
    for (int i = 0; i < _process_num; i++) {
        int pipefd[2] = {0};
        if (pipe(pipefd) < 0) return false;
        pid_t subid = fork();
        if (subid == 0) { // 子进程
            close(pipefd[1]);
            Work(pipefd[0]);
            close(pipefd[0]);
            exit(0);
        } else { // 父进程
            close(pipefd[0]);
            _cm.Insert(pipefd[1], subid);
        }
    }
    return true;
}

作用:创建管道和子进程,初始化通道。

设计依据:父进程保留写端,子进程保留读端,确保单向通信。

Run():运行任务
cpp 复制代码
void Run() {
    int taskcode = _tm.Code();
    auto &c = _cm.Select();
    std::cout << "选择了一个子进程: " << c.Name() << std::endl;
    c.Send(taskcode);
    std::cout << "发送了一个任务码: " << taskcode << std::endl;
}

作用:生成任务码,选择子进程并发送。

设计依据:结合任务管理和负载均衡。

Stop():停止进程池
cpp 复制代码
void Stop() {
    _cm.StopSubProcess();
    _cm.WaitSubProcess();
}

作用:关闭管道并回收子进程。

设计依据:确保优雅退出和资源清理。

类之间的关系

  • ProcessPool是主控类,依赖_cm(通道管理)和_tm(任务管理)
  • _cm调用Channel的方法(如SendClose
  • _tm在父进程生成任务码,在子进程执行任务

第六步:main()函数 - 运行演示

cpp 复制代码
int main() {
    ProcessPool pp(gdefaultnum); // 默认5个进程
    pp.Start();
    int cnt = 10;
    while (cnt--) {
        pp.Run(); // 分发10个任务
        sleep(1);
    }
    pp.Stop();
    return 0;
}

作用:创建进程池,运行10个任务,每秒一个,然后停止。

设计依据:简单演示完整流程。

进程池退出问题分析与改进方案(之后补充)

问题现象分析

在原代码实现中,进程池退出时出现以下问题:

  1. 文件描述符泄漏:父进程未正确关闭所有管道写端,导致部分文件描述符残留
  2. 僵尸进程:父进程未正确处理异常退出的子进程

问题根源 在于文件描述符的继承关系。当父进程调用fork()创建子进程时,子进程会继承父进程打开的所有文件描述符。如果父子进程未正确关闭不需要的端口,会导致以下问题:

  • 父进程保留大量无用的管道写端
  • 子进程可能持有其他管道的读端(未显式关闭)

也就是说后打开的子进程在继承父进程后在文件描述符表上,会有描述符指向它的兄弟进程。管道也是文件,读写端对应的文件描述符会进行引用计数,如果引用计数没有为0,该端口就不会关闭。所以兄弟进程之间保留了继承至父进程的兄弟进程端口就会导致兄弟进程的端口最后无法正常关闭,因为即使按照顺序关闭,最后一个进程还是会将先打开的兄弟进程端口全部继承下来,引用计数不会为0。

++(开启了十个子进程,最后只关闭了五个)++

关键问题定位

观察原代码的Start()方法:

cpp 复制代码
bool Start() {
    for (int i = 0; i < _process_num; i++) {
        int pipefd[2] = {0};
        if (pipe(pipefd) < 0) return false;
        pid_t subid = fork();
        if (subid == 0) { // 子进程
            close(pipefd[1]); // 关闭写端 ✅
            Work(pipefd[0]);
            close(pipefd[0]); // ✅
            exit(0);
        } else { // 父进程
            close(pipefd[0]); // 关闭读端 ✅
            _cm.Insert(pipefd[1], subid);
        }
    }
    return true;
}

表面看似乎没有问题,但实际存在两个隐患:

隐患1:父进程累积管道写端

每次循环都会创建新的管道,但父进程将所有写端存入ChannelManager。当需要关闭时:

cpp 复制代码
void StopSubProcess() {
    for (auto &channel : _channels) {
        channel.Close(); // 逐个关闭写端
    }
}

这看似合理,但如果父进程在运行期间异常退出(未调用Stop),这些写端将不会被关闭,导致子进程永远阻塞在read()。

隐患2:子进程继承其他管道

虽然子进程关闭了自己的写端,但可能继承其他管道的读端。例如:

  1. 父进程先创建管道A(保留写端A1)
  2. 创建管道B(保留写端B1)
  3. 子进程B会继承父进程所有的文件描述符,包括写端A1

这种情况虽然不影响当前设计,但不符合最佳实践,可能引发意外问题。


改进方案:反向关闭与统一管理

方案1:严格反向关闭(Close-on-fork)

在fork之后立即关闭不需要的文件描述符:

cpp 复制代码
// 修改后的Start()片段
bool Start() 
{
    for (int i = 0; i < _process_num; i++) 
    {
        int pipefd[2] = {0};
        if (pipe(pipefd) return false;
        
        pid_t subid = fork();
        if (subid == 0) 
        { 
            // 子进程
            // 关闭所有其他管道的写端
            for (auto &ch : _cm.Channels()) 
            {
                close(ch.Fd());
            }
            close(pipefd[1]); // 关闭当前管道的写端 ✅
            
            Work(pipefd[0]);  // 只使用读端
            close(pipefd[0]);
            exit(0);
        } 
        else 
        { 
            // 父进程
            close(pipefd[0]); // 关闭读端 ✅
            
            // 关闭之前所有子进程的写端 ❌(错误做法)
            // 正确做法是保留所有写端到ChannelManager
            _cm.Insert(pipefd[1], subid);
        }
    }
    return true;
}

这里的关键改进:

  • 子进程主动关闭父进程中已存在的其他管道写端
  • 不能通过直接关闭之前子进程的写端
  • ChannelManager中存放着之前加进去的打开的子进程的写端,所以可以直接通过ChannelManager关闭其中存放的所有子进程写端,因为此时还没将当前子进程加进去。
方案2:父进程统一管理写端

修改ChannelManager的实现,确保:

  1. 所有写端文件描述符被集中管理
  2. 退出时自动关闭所有残留描述符

ChannelManager类中添加析构函数:

cpp 复制代码
class ChannelManager {
public:
    ~ChannelManager() {
        for (auto &ch : _channels) {
            close(ch.Fd()); // 确保关闭所有写端
        }
    }
    // ...其他成员不变
};

完整改进代码

Step 1:增强Channel类
cpp 复制代码
class Channel {
public:
    int Fd() const { return _wfd; } // 新增访问器
    
    // 原实现保持不变
};
Step 2:改进ChannelManager
cpp 复制代码
class ChannelManager {
public:
    const std::vector<Channel>& Channels() const { return _channels; }
    
    ~ChannelManager() {
        for (auto &ch : _channels) {
            close(ch.Fd()); // 析构时自动关闭
        }
    }
    // ...其他成员不变
};
Step 3:增强子进程关闭逻辑
cpp 复制代码
bool Start() {
    for (int i = 0; i < _process_num; i++) {
        int pipefd[2] = {0};
        if (pipe(pipefd) return false;
        
        // 设置管道写端为close-on-exec
        fcntl(pipefd[1], F_SETFD, FD_CLOEXEC);
        
        pid_t subid = fork();
        if (subid == 0) { // 子进程
            // 关闭所有继承的写端
            for (auto &ch : _cm.Channels()) {
                close(ch.Fd());
            }
            close(pipefd[1]); // 关闭当前写端
            
            Work(pipefd[0]);
            close(pipefd[0]);
            exit(0);
        } else { // 父进程
            close(pipefd[0]);
            _cm.Insert(pipefd[1], subid);
        }
    }
    return true;
}

改进效果验证

通过以下命令检查文件描述符泄漏:

bash 复制代码
strace -e trace=close,write,read ./process_pool 2>&1 | grep 'close('

应该观察到:

  1. 父进程正确关闭所有管道的读端
  2. 子进程关闭所有无关的写端
  3. 进程退出时没有未关闭的文件描述符

总结

这个进程池实现展示了如何利用管道、进程管理和任务分发构建一个高效的多进程系统。Channel处理通信,ChannelManager实现负载均衡,TaskManager管理任务,ProcessPool将它们整合。类之间的关系清晰:ProcessPool协调全局,依赖ChannelManagerTaskManager,而Channel是底层的通信单元。

通过这个实现,你可以深入理解操作系统中的IPC和进程管理。建议尝试改进,例如添加信号处理或任务队列,进一步优化其健壮性和实用性!

附录:源代码

cpp 复制代码
#ifndef __PROCESS_POOL_HPP__
#define __PROCESS_POOL_HPP__

#include <iostream>
#include <cstdlib>
#include <vector>
#include <unistd.h>
#include <sys/wait.h>
#include "Task.hpp"

// Channel类:表示父子进程间的通信通道
// 每个Channel对象包含一个写文件描述符和对应的子进程ID
class Channel
{
public:
    // 构造函数:初始化写文件描述符和子进程ID
    Channel(int fd, pid_t id) : _wfd(fd), _subid(id)
    {
        _name = "channel-" + std::to_string(_wfd) + "-" + std::to_string(_subid);
    }
    ~Channel()
    {
    }
    // 向子进程发送任务码
    void Send(int code)
    {
        int n = write(_wfd, &code, sizeof(code));
        (void)n; // 防止编译器警告未使用的变量
    }
    // 关闭通信管道
    void Close()
    {
        close(_wfd);
    }
    // 等待子进程结束
    void Wait()
    {
        pid_t rid = waitpid(_subid, nullptr, 0);
        (void)rid;
    }
    // 获取文件描述符
    int Fd() { return _wfd; }
    // 获取子进程ID
    pid_t SubId() { return _subid; }
    // 获取通道名称
    std::string Name() { return _name; }

private:
    int _wfd;          // 写端文件描述符
    pid_t _subid;      // 子进程ID
    std::string _name; // 通道名称,用于调试
};

// ChannelManager类:管理所有的通信通道
// 实现负载均衡的任务分发
class ChannelManager
{
public:
    ChannelManager() : _next(0)
    {
    }
    // 添加新的通信通道
    void Insert(int wfd, pid_t subid)
    {
        _channels.emplace_back(wfd, subid);
    }
    // 轮询选择下一个可用的通道
    Channel &Select()
    {
        auto &c = _channels[_next];
        _next++;
        _next %= _channels.size(); // 循环选择
        return c;
    }
    // 打印所有通道信息(调试用)
    void PrintChannel()
    {
        for (auto &channel : _channels)
        {
            std::cout << channel.Name() << std::endl;
        }
    }
    // 停止所有子进程:关闭所有通信管道
    void StopSubProcess()
    {
        for (auto &channel : _channels)
        {
            channel.Close();
            std::cout << "关闭: " << channel.Name() << std::endl;
        }
    }
    // 等待所有子进程结束
    void WaitSubProcess()
    {
        for (auto &channel : _channels)
        {
            channel.Wait();
            std::cout << "回收: " << channel.Name() << std::endl;
        }
    }
    ~ChannelManager() {}

private:
    std::vector<Channel> _channels; // 存储所有通信通道
    int _next;                      // 下一个要使用的通道索引
                                    // 用于负载均衡 轮番使用进程池中的进程
};

// 默认进程池大小
const int gdefaultnum = 5;

// ProcessPool类:进程池的主要实现
class ProcessPool
{
public:
    // 构造函数:初始化进程池,注册默认任务
    ProcessPool(int num) : _process_num(num)
    {
        _tm.Register(PrintLog);
        _tm.Register(Download);
        _tm.Register(Upload);
    }

    // 子进程的工作循环
    // rfd -> pipefd[0]:读端文件的描述符
    void Work(int rfd)
    {
        while (true)
        {
            int code = 0;
            // 如果父进程没有发送任务码,阻塞等待,不会触发管道关闭
            ssize_t n = read(rfd, &code, sizeof(code));
            if (n > 0)
            {
                if (n != sizeof(code))
                {
                    continue;
                }
                std::cout << "子进程[" << getpid() << "]收到一个任务码: " << code << std::endl;
                _tm.Execute(code);
            }
            else if (n == 0) // 管道被关闭
            {
                std::cout << "子进程退出" << std::endl;
                break;
            }
            else
            {
                std::cout << "读取错误" << std::endl;
                break;
            }
        }
    }

    // 启动进程池:创建子进程和通信管道
    bool Start()
    {
        for (int i = 0; i < _process_num; i++)
        {
            // 1. 创建管道
            int pipefd[2] = {0};
            int n = pipe(pipefd);
            if (n < 0)
                return false;

            // 2. 创建子进程
            pid_t subid = fork();
            if (subid < 0)
                return false;
            else if (subid == 0)
            {
                // 子进程:关闭写端,保留读端
                close(pipefd[1]);
                Work(pipefd[0]); // 开始工作循环
                close(pipefd[0]);
                exit(0);
            }
            else
            {
                // 父进程:关闭读端,保留写端
                close(pipefd[0]);
                _cm.Insert(pipefd[1], subid);
            }
        }
        return true;
    }

    // 打印调试信息
    void Debug()
    {
        _cm.PrintChannel();
    }

    // 运行任务:选择任务和子进程,发送任务
    void Run()
    {
        // 1. 获取一个任务码
        int taskcode = _tm.Code();

        // 2. 选择一个子进程执行任务(负载均衡)
        auto &c = _cm.Select();
        std::cout << "选择了一个子进程: " << c.Name() << std::endl;
        // 3. 发送任务
        c.Send(taskcode);
        std::cout << "发送了一个任务码: " << taskcode << std::endl;
    }

    // 停止进程池:关闭所有通信管道,回收子进程
    void Stop()
    {
        _cm.StopSubProcess(); // 关闭所有通信管道

        // 现在子进程为 Z,回收子进程
        _cm.WaitSubProcess(); // 等待所有子进程结束
    }

    ~ProcessPool()
    {
    }

private:
    ChannelManager _cm; // 通道管理器
    int _process_num;   // 进程池大小
    TaskManager _tm;    // 任务管理器
};

#endif
cpp 复制代码
#pragma once

#include <iostream>
#include <vector>
#include <ctime>

// 定义函数指针类型,用于存储任务处理函数
typedef void (*task_t)();

 示例任务函数 /
// 打印日志任务:模拟日志记录功能
void PrintLog()
{
    std::cout << "我是一个打印日志的任务" << std::endl;
}

// 下载任务:模拟文件下载功能
void Download()
{
    std::cout << "我是一个下载的任务" << std::endl;
}

// 上传任务:模拟文件上传功能
void Upload()
{
    std::cout << "我是一个上传的任务" << std::endl;
}
//

// TaskManager类:管理和执行任务的类
class TaskManager
{
public:
    // 构造函数:初始化随机数生成器
    TaskManager()
    {
        srand(time(nullptr));
    }

    // 注册新任务到任务列表
    // @param t: 任务函数指针
    void Register(task_t t)
    {
        _tasks.push_back(t);
    }

    // 随机生成任务码
    // @return: 返回一个随机的任务索引
    int Code()
    {
        return rand() % _tasks.size();
    }

    // 执行指定编号的任务
    // @param code: 任务编号
    void Execute(int code)
    {
        if(code >= 0 && code < _tasks.size())
        {
            _tasks[code]();
        }
    }

    ~TaskManager()
    {}
private:
    std::vector<task_t> _tasks;  // 存储所有注册的任务函数
};
cpp 复制代码
#include "ProcessPool.hpp"

int main()
{

    // 子进程读取-> r
    // 父进程写入-> w
    
    // 创建进程池对象,使用默认数量(gdefaultnum=5)的工作进程
    ProcessPool pp(gdefaultnum);

    // 启动进程池:创建子进程和通信管道
    pp.Start();

    // 模拟派发10个任务,每个任务间隔1秒
    int cnt = 10;
    while (cnt--)
    {
        pp.Run();    // 运行一个随机选择的任务
        sleep(1);    // 等待1秒
    }

    // 停止进程池:关闭所有通信管道,回收子进程
    pp.Stop();
    return 0;
}

    // 潜在的bug说明:
    // 1. 当子进程异常退出时,父进程可能无法及时感知
    // 2. 父进程可能继续向已经不存在的子进程发送任务
    // 3. 需要添加信号处理机制来处理子进程异常退出的情况
    // 4. 可以考虑添加心跳检测机制确保子进程存活
相关推荐
xiao--xin8 分钟前
使用ProcessBuilder执行FFmpeg命令,进程一直处于阻塞状态,一直没有返回执行结果
java·笔记·ffmpeg·个人开发·缓冲区·进程阻塞
chxii9 分钟前
5.go切片和map
开发语言·golang
技术干货贩卖机3 小时前
MATLAB绘图配色包说明
开发语言·matlab
时光不负追梦人3 小时前
谈谈对spring IOC的理解,原理和实现
java·后端·spring
胡耀超3 小时前
7.模型选择与评估:构建科学的参数调优与性能评估体系——Python数据挖掘代码实践
开发语言·人工智能·python·机器学习·数据挖掘
沐墨专攻技术3 小时前
深入理解指针(4)(C语言版)
c语言·开发语言
程序猿ZhangSir3 小时前
Redis 和 MySQL双写一致性的更新策略有哪些?常见面试题深度解答。
java·数据库·spring boot·redis·mysql·缓存·mybatis
flying jiang3 小时前
HttpServletRequest
java·网络·websocket·http
ElseWhereR3 小时前
机器人能否回到原点 - 简单
c++·算法·leetcode
南屿欣风3 小时前
Go 语言中使用 Swagger 生成 API 文档及常见问题解决
开发语言·后端·golang