进程间通信代码

博客围绕 C++ 展开,介绍了匿名管道 pipe、ctrlProcess、ctrlProcess2.0、命名管道 namepipe 以及共享内存 shm 等内容,涉及服务器端相关的算法和技术。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

匿名管道pipe

//pipe.cpp
#include <iostream>
#include <string>
#include <cerrno>
#include <cassert>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>
int main()
{
    // 让不同的进程看到同一份资源!!!!
    // 任何一种进程间通信中,一定要先保证不同的进程之间看到同一份资源
    int pipefd[2] = { 0 };//将元素初始化为0
    //1. 创建管道
    int n = pipe(pipefd);//pipefd是输出型参数
    if (n < 0)
    {
        std::cout << "pipe error, " << errno << ": " << strerror(errno) << std::endl;//errno是一个全局变量
        return 1;
    }
    std::cout << "pipefd[0]: " << pipefd[0] << std::endl; // 0下标是读端, 0->嘴巴->读书,一般情况下会给我们的管道nfd分别是3和4。
    std::cout << "pipefd[1]: " << pipefd[1] << std::endl; // 1下标是写端, 1->笔->写东西的

    //2. 创建子进程
    pid_t id = fork();
    assert(id != -1); //正常应该用判断,我这里就断言:意料之外用if,因为fork有可能失败。意料之中用assert

    if (id == 0)// 子进程
    {
        //3. 关闭不需要的fd,让父进程进行读取,让子进程进行写入
        close(pipefd[0]);

        //4. 开始通信 -- 结合某种场景
        // const std::string namestr = "hello, 我是子进程";
        // int cnt = 1;
        // char buffer[1024];
        int cnt = 0;
        while (true)
        {
            char x = 'X';
            write(pipefd[1], &x, 1);
            std::cout << "Cnt: " << cnt++ << std::endl;
            sleep(1);
            if (cnt > 5) break;
            // break;

            // snprintf(buffer, sizeof buffer, "%s, 计数器: %d, 我的PID: %d", namestr.c_str(), cnt++, getpid());//将不同变量信息按照字符串格式化
            // write(pipefd[1], buffer, strlen(buffer));
        }
        close(pipefd[1]);
        exit(0);
    }

    //父进程
    //3. 关闭不需要的fd,让父进程进行读取,让子进程进行写入
    close(pipefd[1]);

    //4. 开始通信 -- 结合某种场景
    char buffer[1024];
    int cnt = 0;
    while (true)
    {
        // sleep(10);
        // sleep(1);
        int n = read(pipefd[0], buffer, sizeof(buffer) - 1);//最多读取1023个字符,最后一个一定要留下来放值\0。read是按照字节流进行读取的,不会你是字符还是别的。
        if (n > 0)
        {
            buffer[n] = '\0';
            std::cout << "我是父进程, child give me message: " << buffer << std::endl;
        }
        else if (n == 0)
        {
            std::cout << "我是父进程, 读到了文件结尾" << std::endl;
            break;
        }
        else
        {
            std::cout << "我是父进程, 读异常了" << std::endl;
            break;
        }
        sleep(1);
        //if(cnt++ > 5) break;
    }
    close(pipefd[0]);

    // int status = 0;
     //waitpid(id, &status, 0);
     //std::cout << "sig: " << (status & 0x7F) << std::endl;

    sleep(100);

    return 0;
}

ctrlProcess

//task.cpp
#pragma once
#include <iostream>
#include <vector>
#include <unistd.h>
// typedef std::function<void ()> func_t;
typedef void (*fun_t)(); //函数指针
void PrintLog()
{
    std::cout << "pid: " << getpid() << ", 打印日志任务,正在被执行..." << std::endl;
}
void InsertMySQL()
{
    std::cout << "执行数据库任务,正在被执行..." << std::endl;
}
void NetRequest()
{
    std::cout << "执行网络请求任务,正在被执行..." << std::endl;
}
//约定,每一个command都必须是4字节
#define COMMAND_LOG 0
#define COMMAND_MYSQL 1
#define COMMAND_REQEUST 2
class Task
{
public:
    Task()
    {
        funcs.push_back(PrintLog);
        funcs.push_back(InsertMySQL);
        funcs.push_back(NetRequest);
    }
    void Execute(int command)
    {
        if (command >= 0 && command < funcs.size()) funcs[command]();
    }
    ~Task()
    {}
public:
    std::vector<fun_t> funcs;
};
//ctrlProcess.cpp
//实现父进程通过向子进程写入特定的消息唤醒子进程,甚至让子进程定向执行某种任务。
#include <iostream>
#include <string>
#include <vector>
#include <cassert>
#include <unistd.h>
#include "Task.hpp"
using namespace std;
const int gnum = 5;//总共创建5个子进程
Task t;
class EndPoint//管理,父进程需要知道自己有哪些子进程、哪些管道、子进程和管道的对用关系。
{
public:
    pid_t _child_id;//子进程的pid
    int _write_fd;//父进程需要向哪一个管道里写

public:
    EndPoint(int id, int fd) : _child_id(id), _write_fd(fd)
    {
    }
    ~EndPoint()
    {
    }
};
// 子进程要执行的方法
void WaitCommand()
{
    while (true)
    {
        int command = 0;
        int n = read(0, &command, sizeof(int));//子进程现在只要从0号文件描述符里面读取数据,管道读取的文件描述符重定向到了0。
        if (n == sizeof(int))
        {
            t.Execute(command);
        }
        else if (n == 0)
        {
            break;
        }
        else
        {
            break;
        }
    }
}
void createProcesses(vector<EndPoint>* end_points)
{
    for (int i = 0; i < gnum; i++)
    {
        // 1.1 创建管道
        int pipefd[2] = { 0 };
        int n = pipe(pipefd);
        assert(n == 0);
        (void)n;
        // 1.2 创建进程
        pid_t id = fork();
        assert(id != -1);
        // 一定是子进程
        if (id == 0)
        {
            // 1.3 关闭不要的fd
            close(pipefd[1]);
            // 我们期望,所有的子进程读取"指令"的时候,都从标准输入读取,也就是不在从pipefd[0]描述符进行读取而是从直接从0号描述符读取更好理解,
            // 1.3.1 输入重定向
            dup2(pipefd[0], 0);
            // 1.3.2 子进程开始等待获取命令
            WaitCommand();
            close(pipefd[0]);
            exit(0);
        }
        // 一定是父进程
        //  1.3 关闭不要的fd
        close(pipefd[0]);
        // 1.4 将新的子进程和他的管道写端,构建对象
        end_points->push_back(EndPoint(id, pipefd[1]));
    }
}
int main()
{
    // 1. 先进行构建控制结构, 父进程写入,子进程读取 , bug?
    vector<EndPoint> end_points;//父进程通过该结构进行管理子进程和对应的管道
    createProcesses(&end_points);
    // 2. 我们的得到了什么?end_points
    int num = 0;
    while (true)
    {
        //1. 选择任务
        int command = COMMAND_LOG;

        //2. 选择进程
        int index = rand() % end_points.size();

        //3. 下发任务
        write(end_points[index]._write_fd, &command, sizeof(command));

        sleep(1);
    }
    return 0;
}

ctrlProcess2.0

//task.h
#pragma once
#pragma once

#include <iostream>
#include <vector>
#include <unistd.h>
#include <unordered_map>

// typedef std::function<void ()> func_t;

typedef void (*fun_t)(); //函数指针

void PrintLog()
{
    std::cout << "pid: " << getpid() << ", 打印日志任务,正在被执行..." << std::endl;
}

void InsertMySQL()
{
    std::cout << "执行数据库任务,正在被执行..." << std::endl;
}

void NetRequest()
{
    std::cout << "执行网络请求任务,正在被执行..." << std::endl;
}

// void ExitProcess()
// {
//     exit(0);
// }

//约定,每一个command都必须是4字节
#define COMMAND_LOG 0
#define COMMAND_MYSQL 1
#define COMMAND_REQEUST 2

class Task
{
public:
    Task()
    {
        funcs.push_back(PrintLog);
        funcs.push_back(InsertMySQL);
        funcs.push_back(NetRequest);
    }
    void Execute(int command)
    {
        if (command >= 0 && command < funcs.size()) funcs[command]();
    }
    ~Task()
    {}
public:
    std::vector<fun_t> funcs;
    // std::unordered_map<std::string, fun_t> funcs;
};
//ctrlProcess.cpp
//通过父进程对不同的管道发送消息来控制子进 程的行为
#include <iostream>
#include <string>
#include <vector>
#include <cassert>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
#include "task.h"
using namespace std;
const int gnum = 3;
Task t;
class EndPoint
{
private:
    static int number;
public:
    pid_t _child_id;
    int _write_fd;
    std::string processname;
public:
    EndPoint(int id, int fd) : _child_id(id), _write_fd(fd)
    {
        //process-0[pid:fd]
        char namebuffer[64];
        snprintf(namebuffer, sizeof(namebuffer), "process-%d[%d:%d]", number++, _child_id, _write_fd);
        processname = namebuffer;
    }
    std::string name() const
    {
        return processname;
    }
    ~EndPoint()
    {
    }
};

int EndPoint::number = 0;

// 子进程要执行的方法
void WaitCommand()
{
    while (true)
    {
        int command = 0;
        int n = read(0, &command, sizeof(int));
        if (n == sizeof(int))
        {
            t.Execute(command);
        }
        else if (n == 0)
        {
            std::cout << "父进程让我退出,我就退出了: " << getpid() << std::endl;
            break;
        }
        else
        {
            break;
        }
    }
}

void createProcesses(vector<EndPoint>* end_points)
{
    vector<int> fds;//存储父进程所有的写端描述符,父进程创建了几个管道就会有几个写端的文件描述符。这时候父进程在创建子进程的时候,子进程继承父进程的写端文件描述符,这时候子进程可以借助这个容器将之前进程的写端文件描述符关闭。
    for (int i = 0; i < gnum; i++)
    {
        // 1.1 创建管道
        int pipefd[2] = { 0 };
        int n = pipe(pipefd);
        assert(n == 0);
        (void)n;

        // 1.2 创建进程
        pid_t id = fork();
        assert(id != -1);
        // 一定是子进程
        if (id == 0)
        {
            for (auto& fd : fds) close(fd);//将之前进程的写端文件描述符关闭

            // std::cout << getpid() << " 子进程关闭父进程对应的写端:";
            // for(auto &fd : fds)
            // {
            //     std::cout << fd << " ";
            //     close(fd);
            // }
            // std::cout << std::endl;

            // 1.3 关闭不要的fd
            close(pipefd[1]);
            // 我们期望,所有的子进程读取"指令"的时候,都从标准输入读取
            // 1.3.1 输入重定向,可以不做
            dup2(pipefd[0], 0);
            // 1.3.2 子进程开始等待获取命令
            WaitCommand();
            close(pipefd[0]);
            exit(0);
        }

        // 一定是父进程
        //  1.3 关闭不要的fd
        close(pipefd[0]);

        // 1.4 将新的子进程和他的管道写端,构建对象
        end_points->push_back(EndPoint(id, pipefd[1]));

        fds.push_back(pipefd[1]);
    }
}


int ShowBoard()
{
    std::cout << "##########################################" << std::endl;
    std::cout << "|   0. 执行日志任务   1. 执行数据库任务    |" << std::endl;
    std::cout << "|   2. 执行请求任务   3. 退出             |" << std::endl;
    std::cout << "##########################################" << std::endl;
    std::cout << "请选择# ";
    int command = 0;
    std::cin >> command;
    return command;
}

void ctrlProcess(const vector<EndPoint>& end_points)
{
    // 2.1 我们可以写成自动化的,也可以搞成交互式的
    int num = 0;
    int cnt = 0;
    while (true)
    {
        //1. 选择任务
        int command = ShowBoard();
        if (command == 3) break;
        if (command < 0 || command > 2) continue;

        //2. 选择进程
        int index = cnt++;
        cnt %= end_points.size();
        std::string name = end_points[index].name();
        std::cout << "选择了进程: " << name << " | 处理任务: " << command << std::endl;

        //3. 下发任务
        write(end_points[index]._write_fd, &command, sizeof(command));

        sleep(1);
    } 
}

void waitProcess(const vector<EndPoint>& end_points)
{
    
    for (int end = 0; end < end_points.size(); end++)
    {
        std::cout << "父进程让子进程退出:" << end_points[end]._child_id << std::endl;
        close(end_points[end]._write_fd);

        waitpid(end_points[end]._child_id, nullptr, 0);
        std::cout << "父进程回收了子进程:" << end_points[end]._child_id << std::endl;
    }//上面的写法是有问题的,因为父进程第一次创建管道,加入写端是4,第二次创建管道写端是6,此时第二个子进程对应的是第二个管道,但是由于他继承于父进程,所以他也有第一个子进程的写端也就是文件描述符4。
    //当父进程关闭第一个子进程的写端4的时候,其实第二个子进程也继承了这个文件描述符4的写端,所以就导致写端没有关完,就直接在waitpid这里阻塞住了。
    //解决方法一:到这关闭写端,也就是先关闭进程2管道的写端,再关闭进程1管道的写端
    //解决方法二:创建子进程的时候,将之前子进程的写端进行关闭。
    sleep(10);
    // 1. 我们需要让子进程全部退出 --- 只需要让父进程关闭所有的write fd就可以了!
    // for(const auto &ep : end_points) 
    // for(int end = end_points.size() - 1; end >= 0; end--) 
    // 2. 父进程要回收子进程的僵尸状态
    // for(const auto &ep : end_points) waitpid(ep._child_id, nullptr, 0);
    // std::cout << "父进程回收了所有的子进程" << std::endl;
    // sleep(10);
}


// #define COMMAND_LOG 0
// #define COMMAND_MYSQL 1
// #define COMMAND_REQEUST 2
int main()
{
    vector<EndPoint> end_points;
    // 1. 先进行构建控制结构, 父进程写入,子进程读取 , bug?
    createProcesses(&end_points);

    // 2. 我们的得到了什么?end_points
    ctrlProcess(end_points);

    // 3. 处理所有的退出问题
    waitProcess(end_points);
    return 0;
}

命名管道namepipe

//common.h
#pragma once
#pragma once
#include <iostream>
#include <string>
#define NUM 1024
const std::string fifoname = "./fifo";
uint32_t mode = 0666;
//server.cpp
//实现两个没有血缘关系的进程之间的通信,所以需要两个cpp文件分别形成两个可执行
#include <iostream>
#include <cerrno>
#include <cstring>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include "common.h"


int main()
{
    // 1. 创建管道文件,只需要一次创建
    umask(0); //这个设置并不影响系统的默认配置,只会影响当前进程
    int n = mkfifo(fifoname.c_str(), mode);//这是系统调用,当然也存在一个命令叫做mkfifo。fifoname.c_str()是文件路径。
    if (n != 0) 
    {
        std::cout << errno << " : " << strerror(errno) << std::endl;
        return 1;
    }
    std::cout << "create fifo file success" << std::endl;
    // 2. 让服务端直接开启管道文件
    int rfd = open(fifoname.c_str(), O_RDONLY);//打开命名管道文件,返回对应的文件描述符。如果只运行server,不运行clien,函数会卡在这一行,知道clien运行,该函数打开管道才能执行。
    if (rfd < 0)
    {
        std::cout << errno << " : " << strerror(errno) << std::endl;
        return 2;
    }
    std::cout << "open fifo success, begin ipc" << std::endl;

    // 3. 正常通信
    char buffer[NUM];
    while (true)
    {
        buffer[0] = 0;
        ssize_t n = read(rfd, buffer, sizeof(buffer) - 1);
        if (n > 0)//读取成功
        {
            buffer[n] = 0;//和buffer[n]='/0'是一个意思
            //std::cout << "client# " << buffer << std::endl;
            printf("%c", buffer[0]);
            fflush(stdout);
        }
        else if (n == 0)//当对端把写关闭了,我们读端就会读到头返回0
        {
            std::cout << "client quit, me too" << std::endl;
            break;
        }
        else
        {
            std::cout << errno << " : " << strerror(errno) << std::endl;
            break;
        }
    }

    // 关闭不要的fd
    close(rfd);

    unlink(fifoname.c_str());//删除一个文件,也就是文件的引用计数减一,这样可以不用每次手动rm fifo了。

    return 0;
}
//client.cpp
#include <iostream>
#include <cstdio>
#include <cerrno>
#include <cstring>
#include <cassert>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
// #include <ncurses.h>
#include "common.h"
int main()
{
    //1. 不需创建管道文件,我只需要打开对应的文件即可!
    int wfd = open(fifoname.c_str(), O_WRONLY); 
    if (wfd < 0)
    {
        std::cerr << errno << ":" << strerror(errno) << std::endl;
        return 1;
    }

    // 可以进行常规通信了
    char buffer[NUM];
    while (true)
    {
        // std::cout << "请输入你的消息# ";
        // char *msg = fgets(buffer, sizeof(buffer), stdin);//sizeof(buffer)不用减 一,应为fgets在获取的时候会自动再末尾留一个空格来放'\0'
        // assert(msg);
        // (void)msg;//防止一个变量被定义了但是没有被使用
        // int c = getch();
        // std::cout << c << std::endl;
        // if(c == -1) continue;

        system("stty raw");
        int c = getchar();
        system("stty -raw");
        //上面代码实现clien端实时写入,服务器端实时显示。
        //std::cout << c << std::endl;
        //sleep(1);

        //buffer[strlen(buffer) - 1] = 0;//因为客户端输入的时候会带上一个回车,这样服务显示的时候就会有空行,所以我们这里将\n设置成为\0。实例如下
        // abcde\n\0
        // 012345
        //if(strcasecmp(buffer, "quit") == 0) break;//strcasecmp是忽略大小写的比较
        //ssize_t n = write(wfd, buffer, strlen(buffer));
        ssize_t n = write(wfd, (char*)&c, sizeof(char));
        assert(n >= 0);
        (void)n;
    }

    close(wfd);

    return 0;
}

共享内存shm

//common.h
#pragma once
#ifndef __COMM_HPP__
#define __COMM_HPP__
#include <iostream>
#include <cerrno>
#include <cstdio>
#include <cstring>
#include <cassert>
#include <string>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/types.h>
#include <sys/stat.h>
using namespace std;
//  IPC_CREAT and IPC_EXCL
// 单独使用IPC_CREAT: 创建一个共享内存,如果共享内存不存在,就创建之,如果已经存在,获取已经存在的共享内存并返回
// IPC_EXCL不能单独使用,一般都要配合IPC_CREAT
// IPC_CREAT | IPC_EXCL: 创建一个共享内存,如果共享内存不存在,就创建之, 如果已经存在,则立马出错返回 -- 如果创建成功,对应的shm,一定是最新的!
#define PATHNAME "."
#define PROJID 0x6666
const int gsize = 4096; //暂时
//共享内存的代销是以PAGE页(4kb)为单位的
key_t getKey()
{
    key_t k = ftok(PATHNAME, PROJID);//根据路径字符串和项目id通过一定的算法结合形成冲突概率低的key值。该key值会在shmget函数中做为参数传入,创建共享内存并用key来对该共享内存的数据结构起到一定的标识作用,此时clien就可以通过key值找到这份共享内存,这样不同的进程就可以key值找到同一份共享内存。
    if (k == -1)
    {
        cerr << "error: " << errno << " : " << strerror(errno) << endl;
        exit(1);
    }
    return k;
}
string toHex(int x)//将一个整数x转换成为16进制字符串
{
    char buffer[64];
    snprintf(buffer, sizeof buffer, "0x%x", x);
    return buffer;
}
static int createShmHelper(key_t k, int size, int flag)
{
    int shmid = shmget(k, gsize, flag);//gsize是想要的内存大小,字节为段位返回共享内存标志符。创建和获取共享内存用的都是这个函数,只是第三个参数会有差别。
    if (shmid == -1)
    {
        cerr << "error: " << errno << " : " << strerror(errno) << endl;
        exit(2);
    }
    return shmid;
}
int createShm(key_t k, int size)//创建共享内存
{
    umask(0);
    return createShmHelper(k, size, IPC_CREAT | IPC_EXCL | 0666);//创建内存的时候还要给权限参数0666,通过命令ipcs -m可以查看共享内存的权限perm项
}

int getShm(key_t k, int size)//通过key值来获取通过key值创建的共享内存
{
    return createShmHelper(k, size, IPC_CREAT);
}

char* attachShm(int shmid)//我创建的共享内存并不一定我就能使用,有可能我创建的共享内存是给别人使用的,所以想要用共享内存还要进行关联。
{
    char* start = (char*)shmat(shmid, nullptr, 0);//返回值start就是共享内存虚拟地址的起始地址,是void*类型
    return start;
}
void detachShm(char* start)//和共享内存去关联
{
    int n =  shmdt(start);
    assert(n != -1);
    (void)n;
}
void delShm(int shmid)//
{
    int n = shmctl(shmid, IPC_RMID, nullptr);//该函数可以对共享内存进行很多操作,这便是IPC_RMID对共享内存执行删除操作
    assert(n != -1);
    (void)n;
}
#define SERVER 1
#define CLIENT 0
class Init
{
public:
    Init(int t) :type(t)
    {
        key_t k = getKey();
        if (type == SERVER) shmid = createShm(k, gsize);
        else shmid = getShm(k, gsize);
        start = attachShm(shmid);
    }
    char* getStart() { return start; }
    ~Init()
    {
        detachShm(start);
        if (type == SERVER) delShm(shmid);
    }
private:
    char* start;
    int type; //server or client
    int shmid;
};
#endif
//server.cpp
//并非以文件的方式让不同的进程看到同一份资源,而是通过让我们的进程看到同一个内存块的方式实现的 
#include "common.h"
#include <unistd.h>

int main()
{
    Init init(SERVER);
    char* start = init.getStart();
    int n = 0;
    // 我们在通信的时候,没有使用任何write和read的接口?一旦共享内存映射到进程的地址空间,该共享内存就直接被所有的进程 直接看到了!不会牵涉到从用户到内核以及内核到用户的拷贝
    // 因为共享内存的这种特性,可以让进程通信的时候,减少拷贝次数,所以共享内存是所有进程间通信,速度最快的
    // 共享内存没有任何的保护机制(同步互斥)现象就是:如果clien没有写入数据,server还是会一直从共享内存中进行读取,如果是管道的话clien读取会阻塞 -- 为什么?管道通过系统接口通信,共享内存直接通信。
    while (n <= 30)
    {
        cout << "client -> server# " << start << endl;//这里将共享内存的内容看成一个字符串
        sleep(1);
        n++;
    }
    // //1. 创建key
    // key_t k = getKey();
    // cout << "server key: " << toHex(k) << endl;

    // //2. 创建共享内存
    // int shmid = createShm(k, gsize);
    // cout << "server shmid: " << shmid << endl;

    // sleep(3);

    // //3. 将自己和共享内存关联起来
    // char* start = attachShm(shmid);

    // sleep(20);

    // // 通信代码在这里!

    // // 4.  将自己和共享内存去关联
    // detachShm(start);

    // sleep(3);

    // struct shmid_ds ds;
    // int n = shmctl(shmid, IPC_STAT, &ds);//该函数也可以获取共享内存的属性,通过IPC_STA参数并且传一个共享内存对应数据结构的指针就可以了
    // if(n != -1)
    // {
    //     cout << "perm: " << toHex(ds.shm_perm.__key) << endl;
    //     cout << "creater pid: " << ds.shm_cpid  << " : " << getpid() << endl;
    // }

    // ?. 删除共享内存
    //delShm(shmid);
    return 0;
}
//client.cpp
#include "common.h"
#include <unistd.h>
int main()
{
    Init init(CLIENT);
    char* start = init.getStart();
    char c = 'A';
    while (c <= 'Z')
    {
        start[c - 'A'] = c;//client向共享内存不断的写入内容
        c++;
        start[c - 'A'] = '\0';
        sleep(1);
    }
    // key_t k = getKey();
    // cout << "client key: " << toHex(k) << endl;

    // int shmid = getShm(k, gsize);
    // cout << "client shmid: " << shmid << endl;

    // //3. 将自己和共享内存关联起来
    // char* start = attachShm(shmid);

    // sleep(15);

    // // 4.  将自己和共享内存去关联
    // detachShm(start);
    return 0;
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值