Linux —— 进程间通信

一、进程间通信介绍

1. 进程间通信的目的

进程间通信就是进程之间的信息交流,交流的目的主要的:

数据传输:一个进程需要将它的数据发送给另一个进程

资源共享:多个进程之间共享同样的资源。

通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止 时要通知父进程)。

进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另 一个进程的所有陷入和异常,并能够及时知道它的状态改变

2. 进程间通信的手段

我们都知道,进程之间具有独立性,每个进程是相对独立的,因此要实现进程间的通信,我们就要想办法让不同的进程看到同一份数据,并且可以对这个数据进行访问和读写,这样就是实现了通信

进程通信的发展:管道 ——>   System V进程间通信  ——>   POSIX进程间通信

二、管道

1.匿名管道

1.1 理解

我们要让两个进程看到同一份数据,可以采用匿名管道的方式,匿名管道实际上就是一个OS提供的一个内存文件,让父进程建立对这个文件的读写端(以读和以写的方式分别打开这个文件),然后再创建子进程,此时子进程同样可以看到该文件,关闭一方读端,关闭另一方写端,就形成了一个用于单向传递信息的文件,这种OS提供的临时文件被称为管道文件

1.2 使用方式

函数声明:int pipe(int fd[2]);

函数头文件:#include<unistd.h>

父进程可以通过调用pipe函数去创建管道文件,pipe函数内部做的工作实际就是建立一个读写端的管道文件,并且以输出型参数的形式返回读端和写端的fd,fd就是文件的文件描述符,在使用系统调用接口去对文件进行读写时,就是根据返回的这个fd去操作的,其中,下标0位置放的是读端的fd,下标1位置放的是写端的fd

以这种方式建立的管道文件,这个文件时操作系统帮我们建立的,我们并不知道该管道文件的名字,因此被称为匿名管道

1.3 管道通信的特点

1. 单向通信

2. 管道的本质是文件,因为fd的生命周期随进程结束,管道的生命周期也是随进程的

3. 管道通信是根据父进程创建子进程的方式,使得父子进程看到同一份数据,这种方式就注定了管道通信通常都是用来进行具有“血缘”关系的进程之间的通信

4. 由于是用pipe函数打开的管道,并不清楚管道文件的名字,因此这种方式被叫做匿名管道

5. 在管道通信中,写入端的写入次数和读取次数,不是严格匹配的,两者没有强相关

6. 具有一定的协调能力,让读写端具有一定的步骤进行,自带同步机制,体现在以下四种情况:

1.4 简单样例

通过一个简单的样例测试匿名管道的使用,我们希望建立一个父进程控制多个子进程,让子进程根据父进程的指令去执行不同任务的小demo

1.4.1 构建结构

先通过创建匿名管道,然后创建子进程的方式,去创建单向管道通信的结构,然后多次循环的去创建,但是这里有一个非常容易忽略的bug,就是通过这种循环创建的方式创建的结构,实际在第二次创建的子进程除了和匿名管道有读写端的链接,它实际还打开着对上一个管道的写段,这是通过父进程继承下来的

首先先建立基本的控制结构,至少能实现到上面的链接程度

然后再优化

这样基本的结构就创建出来了,然后就是完善其他功能

1.4.2 对该结构进行先描述,后组织

我们需要管理我们创建的结构,需要对该结构进行先描述后组织,就是创建一个类去将每一个写端和子进程关联起来,并且为了方便测试,我们还可以加入其他的信息查看。

创建后了基本的结构,并且可以通过先描述后组织的方式将其管理起来以后,接下来就是让父进程进行任务的派发,我们将任务单独用一个task.hpp文件封装起来,简单构建一些打印任务用于模拟即可,随着后续的深入学习可以进行补充

1.4.3 创建任务列表

创建好简单的用于测试的任务列表后,接下来就是由父进程派发任务和子进程接受任务

1.4.4 父进程派发任务

1.4.5 子进程接受任务

子进程这里会向管道读取信息,若是父进程不发,则子进程处于待机状态,因此可以实现控制多个子进程,在父进程派发任务的部分设置退出条件,到这里基本的通信就已经实现了,最后就是需要对资源进行回收处理

1.4.6 资源的回收处理

这里要注意,如果在前面构建结构的部分,没有处理掉那个隐患,就会出现卡顿,本质原因是因为在关掉第一个子进程后,由于第二个子进程还链接着第一个匿名管道的写端,所以第一个子进程并没有自动退出,而是继续等待着读取,所以会卡顿

1.4.7 源代码

ctrlProcess.cc 文件

#include<iostream>
#include<string>
#include<cassert>
#include<unistd.h>
#include<vector>
#include"task.hpp"
#include<sys/wait.h>
#include<sys/types.h>
using namespace std;

const int gnum = 5;//表示创建的管道数
Task t;//任务列表 

class EndPoint
{
private:
    static int number;
public:
    pid_t _child_id;//子进程的pid
    int _write_fd;//父进程对应子进程的写端
    std::string _processname;
public:
    EndPoint(pid_t 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;
    }

    const std::string& name()const//进程名字
    {
        return _processname;
    }
    
    ~EndPoint()
    {}
};

int EndPoint::number = 0;

void WaitCommand()//子进程等待读取命令
{
    while(true)
    {
        int command = 0;
        int n = read(0,&command,sizeof command);
        if(n == sizeof(int))
        {
            t.Execute(command);
        }
        else if(n == 0)
        {
            std::cout << "父进程让我退出,我就退出了:" << getpid() << std::endl;
            break;
        }
        else
        {
            break;
        }
    }

}

void Creat_Processes(vector<EndPoint>& end_points)//创建好父进程控制子进程的结构并返回
{
    vector<int> w_fd;
    for(int i =0;i<gnum;i++)
    {
        int pipefd[2] = {0};//0是读端,1是写端
        int n = pipe(pipefd);
        assert(n==0);
        (void)n;

        pid_t id = fork();//创建子进程
        assert(id != -1);

        //子进程
        if(id == 0)
        {
            if(!w_fd.empty())//关闭掉不该链接的写端
                for(int i=0;i<w_fd.size();i++) close(w_fd[i]);
            close(pipefd[1]);
            //让子进程在标准输入中读取,输入重定向
            dup2(pipefd[0],0);
            WaitCommand();//等待指令
            exit(0);
        }

        //父进程
        close(pipefd[0]);
        w_fd.push_back(pipefd[1]);//记录下当前写端的fd,当下次创建子进程时,需要关闭不该链接的
        end_points.push_back(EndPoint(id,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)
{
    int num = 0;
    int cnt = 0;
    while(true)
    {
        //1. 选择任务
        int command = ShowBoard();
        if(command == 3) break;
        if(command<0 || command>3) continue;

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

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

void waitProcess(const vector<EndPoint>& end_points)
{
    
    for(const auto& ep:end_points) 
    {
        //1. 我们需要让子进程全部退出,只需要让父进程写端全部关闭即可
        close(ep._write_fd);
        //2. 父进程要回收子进程的僵尸状态
        waitpid(ep._child_id,nullptr,0);
    }

    
}


int main()
{
    vector<EndPoint> end_points;
    //1.先构建控制结构
    Creat_Processes(end_points);

    //2.发出指令
    ctrlProcess(end_points);

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

task.hpp 文件

#pragma once

#include<iostream>
#include<functional>
#include<vector>

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

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

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

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

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

makefile 文件

ctrlProcess:ctrlProcess.cc
	g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
	rm -rf ctrlProcess

1.4.8 测试结果

                

2.命名管道

2.1 理解

管道本质就是一个文件,匿名管道文件由操作系统维护,没有名字,因此叫匿名管道,但是匿名管道要创建读写端,需要有血缘关系的进程才可以实现通信,这是因为匿名管道没有具体的文件名,也就因此没有具体的路径,只能通过父子进程的继承关系去让两个进程看到同一份文件(匿名管道文件),而命名管道,顾名思义就是有具体名字(路径)的管道文件,因为有名字,也就说明可以通过路径找到该文件,也就可以让不同的并且没有任何关系的进程去都找到该文件,对其进行读或者写操作,因此,不同的进程可以根据命名管道的路径去找到相同的管道文件,这是进程间通信的前提。

由于是文件,就一定有对应的struct file,并且有对应的缓冲区,而由于命名管道文件的作用只是用来进程间通信,而不记录数据,因此,实际管道文件的信息都是在缓冲区内读写,并不会刷新到磁盘,命名管道是内存级文件。

2.2 使用方式

基于上面的理解,在实际中要创建一个命名管道文件,可以直接在Linux下使用命令去创建一个命名管道,但更多是调用函数的方式去创造并使用

命令行方式

mkfifo filename(默认当前路径下,也可以指定路径下创建)

函数调用方式

int mkfifo(const char *filename,mode_t mode);

filename:命名文件的所在路径+文件名称

mode:文件的权限

返回值说明:创建成功返回0,创建失败则返回-1,并设置相应的错误码

2.3 命名管道和匿名管道的区别

匿名管道由pipe函数直接创建并打开(此时读写端已经自动打开了),而命名管道创建和打开是分开的,由mkfifo函数创建,并用open打开,其他的特点都和匿名管道是一样的。

2.4 简单样例

这次我们用命名管道其构建一个简单的客户端和服务端的一个简单通信模拟,实际就是一个进程输入消息,然后通过管道文件,由客户端读到,并打印出来

2.4.1 服务端

2.4.2 客户端

2.4.3 公共部分(公共头文件)

2.4.4 makefile部分

2.4.5 源代码

server.cc

#include<iostream>
#include<cerrno>
#include<cstring>
#include<sys/types.h>
#include<sys/stat.h>
#include<unistd.h>
#include<fcntl.h>
#include"comm.hpp"

int main()
{
    //1.创造管道文件
    umask(0);
    int n = mkfifo(fifoname.c_str(),mode);
    if(n != 0)
    {
        std::cout << errno << " : " << strerror(errno) << std::endl;
        return 1;
    }

    //2.打开文件
    std::cout << "等待客户端打开"  << std::endl;
    int rfd = open(fifoname.c_str(),O_RDONLY);
    if(rfd == -1)
    {
        std::cout << errno << " : " << strerror(errno) << std::endl;
        return 2;
    }
    std::cout << "客户端已打开"  << std::endl;

    //3.开始通信

    char buff[NUM];
    while(true)
    {
        buff[0] = 0;
        ssize_t n = read(rfd,buff,sizeof(buff)-1);
        if(n>0) 
        {
            buff[n] = 0;
            std::cout << "client : " << buff << std::endl;
        }    
        else if(n == 0)
        {
            std::cout << "client quit" << std::endl;
            break;
        }
        else
        {
            std::cout << errno << " : " << strerror(errno) << std::endl;
        }
    }
    std::cout << "关闭通信"  << std::endl;
    //关闭不要的文件
    close(rfd);
    //删除管道文件
    unlink(fifoname.c_str());
    return 0;
}

client.cc

#include<iostream>
#include<cerrno>
#include<cstring>
#include<sys/types.h>
#include<sys/stat.h>
#include<unistd.h>
#include<fcntl.h>
#include"comm.hpp"
#include<cassert>



int main()
{
    //不需要再建立管道文件,只需要打开即可
    int wfd = open(fifoname.c_str(),O_WRONLY,mode);
    if(wfd == -1)
    {
        std::cout << errno << " : " << strerror(errno) << std::endl;
        return 1;
    }

    //发消息
    char buffer[NUM];
    while(true)
    {
        buffer[0] = 0;
        std::cout<< "请输入你要发送的消息:" ;
        char* msg = fgets(buffer,sizeof(buffer)-1,stdin);
        assert(msg);
        (void)msg;
        
        buffer[strlen(buffer)-1] = 0;//把最后位置的‘/n’换成空
        if(strcasecmp(buffer,"quit") == 0) break;

        ssize_t n = write(wfd,buffer,strlen(buffer));
        assert(n>=0);
        (void)n;
    }


    //关闭文件
    close(wfd);
    return 0;
}

comm.hpp

#pragma once

#include<iostream>
#include<string>

const std::string fifoname = "./fifo";
uint32_t mode = 0666;

#define NUM 1024

makefile

.PHONY:all
all:server client

server:server.cc
	g++ -o $@ $^ -std=c++11

client:client.cc
	g++ -o $@ $^ -std=c++11

.PHONY:clean
clean:
	rm -f server client

三、system V —— 共享内存

1.理解

共享内存是操作系统单独设计的一套通信方式标准,也叫IPC通信机制。关于对共享内存的理解,我们要知道,要实现进程间通信,首先就是要让两个独立的进程看到同一份资源,而共享内存的原理,就是在物理内存中,分配一个用来实现通信的内存块,然后让两个或者多个不同进程的虚拟地址空间看到(关联到)该内存块,这样就实现了看到同一份数据,基于此,我们可以推测出,要通过共享内存的方式去实现进程间通信,至少要有以下几步:创建内存块、关联进程、实现通信、取消关联、释放内存块。

2.相关接口介绍

我们要实现上面的步骤,需要先学习几个相关的接口

2.1 创建内存块

2.1.1 shmget

函数声明

int shmget(key_t key, size_t size, int shmflg);

头文件

#include<sys/ipc.h>

#include<sys/shm.h>

参数说明

该函数是用于创建共享内存块的函数

第一个参数key是一个根据ftok函数生成的项目随机值,该函数会根据这个随机值生成一个固定的共享内存的标识符。

第二个参数是共享内存块的大小字节

第三个参数是参数选项,还有共享内存块的权限设置直接添加即可,其他选项常见的有:IPC_CREAT 和 IPC_EXCL

单独使用IPC_CREAT :创建一个共享内存块,若该共享内存块已经存在,则直接返回对应标识符

IPC_CREAT | IPC_EXCL : 一起使用则是,创建一个共享内存块,若共享内存块已存在,则出错返回,即只要这个选项下没有出错,则创建的共享内存一定是最新创建出来的

返回值:如果创建成功,则返回该共享内存的标识符,若是失败则返回-1,错误码被设置

2.1.2 ftok

函数声明

key_t ftok(const char *pathname, int proj_id);

头文件

#include <sys/types.h>
#include <sys/ipc.h>

参数说明

第一个参数是项目名称,第二个是项目id,都是自定义的值,该函数会根据这两个参数,通过算法生成一个重复率极低的随机值key值,作为shmget的第一个参数,这是因为一个key值对应生成一个shm标识符,这个标识符标识了不同的共享内存,因此尽可能不能出现重复的key,后续找到共享内存和操作共享内存,也都是用标识符去找的

2.2 关联和去关联

2.2.1 shmat (关联)

函数声明

void *shmat(int shmid, const void *shmaddr, int shmflg);

头文件

#include <sys/types.h>
#include <sys/shm.h>

函数说明

该函数根据你传过去的shmid(共享内存标识符)找到共享内存后,建立进程虚拟地址空间和该共享内存块的关联,并且向用户返回虚拟地址,类似于malloc一样,其中每个共享内存也都是相关数据结构+内存空间(先描述,后组织)管理起来的。

参数说明

第一个参数是由shmget生成的shmid,也就是共享内存的标识符。

第二个参数是可以指定使用虚拟地址空间的哪一部分,这个在用户使用时不需要给,让操作系统自己为我们分配即可,默认填nullptr,这个参数的设计是因为有些情况下有这个需求,例如动态库的加载。

第三个参数是以什么样的权限去访问这个内存空间,默认填0就是以读写的方式去访问内存空间

返回值是关联后的虚拟地址空间

2.2.2 shmdt (去关联)

函数声明

int shmdt(const void *shmaddr);

头文件

#include <sys/types.h>
#include <sys/shm.h>

参数说明

参数是shmat返回的虚拟地址,使用该地址即可取消掉关联,成功返回0,失败返回-1,有点类似于free的用法

2.3 释放共享内存

共享内存创建后,当进程退出,共享内存并不会被释放,而是依然存在

2.3.1 指令释放

ipcs -m : 查看共享内存的相关信息

ipcrm -m + shmid : 删除指定shmid的共享内存

2.3.2 函数调用 shmctl

函数声明

int shmctl(int shmid, int cmd, struct shmid_ds *buf);

头文件

#include <sys/types.h>
#include <sys/ipc.h>

函数说明

该函数是用来对共享内存块实现各种操作的函数,根据不同的参数指令去实现完成各种操作,其中释放共享内存块也就是用到这个函数

参数说明

第一个参数是shmid,也就是共享内存的标识符

第二个参数是指令cmd,这里只介绍释放内存块用到的指令IPC_RMID

第三个参数是共享内存块的结构体指针,是用于接受共享内存的结构体信息的,一些信息可以打印出来查看,这里只是想释放内存的话,直接传空指针即可

3.代码设计

介绍完相关的接口,我们现在利用这些接口去简单封装一个让两个进程那个使用共享内存通信的窗口

3.1 基本框架设计

我们可以通过一个类,去封装实现整个实现共享内存的过程,利用构造函数去完成创建共享内存块和关联共享内存块,析构函数去完成去关联和释放内存块的操作,并且主要server端和client端的差异,简单设计一下参数,然后再逐步根据接口实现各个部分的功能即可

class Init
{
public:
    Init(int flag)
    :_flag(flag)
    {
        key_t key = GetKey();
        //分情况创建和获取共享内存
        if(flag == SERVER)
        {
            _shmid = CreateShm(key,shmsize);
        }
        else
        {
            _shmid = GetShm(key,shmsize);
        }
        //关联
        _start = AttachShm(_shmid);        
    }

    //提供一个接口获取关联后的虚拟地址
    char* GetStart()
    {
        return _start;
    }

    ~Init()
    {
        //去关联
        DetachShm(_start);
        //释放共享内存
        if(_flag == SERVER) DelShm(_shmid);
    }

private:
    int _flag;
    int _shmid;
    char* _start;
};

3.2 创建共享内存块

创建共享内存块的时候,我们让server端最好不要创建到重复shmid的共享内存块,因此我在使用shmget的第三个参数选项中,我们选择参数 IPC_CREAT | IPC_EXCL | 0666,而client端只需要获取,所以只需要 IPC_CREAT 即可,还有就是用ftok函数去获取key值


key_t GetKey()//获取key
{
    key_t k = ftok(PATHNAME,PROJID);
    if(k == -1)
    {
        cerr << errno << ":" << strerror(errno) <<endl;
        exit(1);
    }
    return k;
}

static int CreateShmHelper(key_t key,size_t size,int falg)
{
    int n = shmget(key,size,falg);
    if(n == -1)
    {
        cerr << errno << ":" << strerror(errno) << endl;
        exit(2);
    }
    return n;
}

int CreateShm(key_t key,size_t size)//创建共享内存
{
    umask(0);
    return CreateShmHelper(key,size,IPC_CREAT | IPC_EXCL | 0666);
}

int GetShm(key_t key,size_t size)//获取共享内存shmid
{
    return CreateShmHelper(key,size,IPC_CREAT);
}

3.3 关联和去关联

关联和去关联只需要根据接口的参数设计,去完成函数功能的设计即可

char* AttachShm(int shmid)
{
    char* start = (char*)shmat(shmid,nullptr,0);
    return start;
}

void DetachShm(char* start)
{
    int n = shmdt(start);
    assert(n != -1);
    (void)n;
}

3.4 释放共享内存

同样,利用虚拟地址去释放共享内存块,用函数将这几个功能简单包装一下,代码显得更加优雅

void DelShm(int shmid)
{
    int n = shmctl(shmid,IPC_RMID,nullptr);
    assert(n != -1);
    (void)n;
}

3.5 实现一个简单的通信测试

我们让server端发送a到z,让client端打印出来,简单测试一下通信代码

sever端代码:

#include"comm.hpp"

int main()
{
    Init it(SERVER);
    char* start = it.GetStart();
    //开始通信
    int n = 0;
    while(n < 30)
    {
        cout << "发送端发来消息:" << start << endl;
        sleep(1);
        n++;
    }

    return 0;
}

client端代码:

#include"comm.hpp"

int main()
{
    Init it(CLIENT);
    char* start = it.GetStart();
    //开始通信
    char c = 'A';
    while(c <= 'Z')
    {
        start[c-'A'] = c;
        c++;
        start[c-'A'] = 0;
        sleep(1);
    }

    return 0;
}

测试结果:

4.细节补充

1.共享内存的大小是以4kb(4096bit)为单位给的,但是用户申请了多少,系统就允许你使用多少

2.共享内存是直接被进程所看到的,因此是通信速度最快的,但是也因此没有同步互斥,即可以让多个进程同时访问到资源

3.管道通信的方式是通过系统接口调用,内部是封装了对应的保护机制,而共享内存是直接通信

四、简单认识信号量

目前所学的知识,要完全了解信号量(信号灯)的概念是比较困难的,这里只是简单的、不太准确的了解一下部分概念,为今后的学习作铺垫

首先是互斥,互斥指定是,在任何一个时刻,都只允许一个执行流对共享资源进行访问,而这种共享资源被叫做临界资源,凡是访问临界资源的代码,我们称为临界区,要想实现互斥,就得对临界区进行加锁,实际就是在写代码的时候,对实际访问临界资源的那部分代码(临界区),前后都预设一些基本的条件(加锁),而所谓的信号量,我们可以把它简单的认为就是对当前可以被申请的资源数量count计数器,我们就是利用这个计数器(信号量)实现的加锁。

举个例子,在多个进程同时需要去访问一个资源的时候,假如此时该资源可以分为n个子资源,那么此时信号量可以认为它就是个等于n的计数器,当一个进程成功申请到该资源后,n--,而在该进程使用完后,释放资源后,n++,每个进程中去访问资源的部分前后加上这种条件判断,就是利用信号量实现加锁,而当信号量为0时,申请失败的进程则是进入阻塞挂起状态去等待资源,当n=1时,也就是说明,该资源只允许一个执行流对其进行访问,也就是临界资源。

所以,信号量其实就是用来对资源访问进行保护作用的一个设计,这里先简单了解一下即可

总结

本篇整理了关于进程间通信的内容,以及详解的介绍了两种进程间通信的方式,以及相关的接口和代码演练

相关推荐

最近更新

  1. docker php8.1+nginx base 镜像 dockerfile 配置

    2024-07-12 18:06:04       67 阅读
  2. Could not load dynamic library ‘cudart64_100.dll‘

    2024-07-12 18:06:04       71 阅读
  3. 在Django里面运行非项目文件

    2024-07-12 18:06:04       58 阅读
  4. Python语言-面向对象

    2024-07-12 18:06:04       69 阅读

热门阅读

  1. 大整数加法C++

    2024-07-12 18:06:04       19 阅读
  2. 【云原生】AWS云平台,ECR推送Helm chart包

    2024-07-12 18:06:04       19 阅读
  3. docker(一)

    2024-07-12 18:06:04       20 阅读
  4. TCP和HTTP之间的关系

    2024-07-12 18:06:04       17 阅读
  5. sql盲注

    sql盲注

    2024-07-12 18:06:04      22 阅读
  6. 数据库之锁

    2024-07-12 18:06:04       23 阅读
  7. kotlin distinctBy 使用

    2024-07-12 18:06:04       23 阅读
  8. 嵌入式Qt开发C++编程基础部分万字总结

    2024-07-12 18:06:04       19 阅读
  9. Oracle PL/SQL 循环批量执行存储过程

    2024-07-12 18:06:04       22 阅读