【Linux】进程间通信

目录

一、进程间通信介绍:

1、为什么要进行进程间通信:

2、如何进行进程间通信:

二、管道通信:

1、匿名管道:

2、命名管道:

3、管道总结:

管道的4种情况:

管道的5种特征:

三、System V 标准进程间通信:

1、共享内存:

共享内存相关接口:

创建共享内存:

查看共享内存: 

删除共享内存:

共享内存通信:

2、消息队列:

3、信号量:

1、什么是信号量:

2、临界资源与临界区:

3、理解原子性:

4、信号量接口:


一、进程间通信介绍:

1、为什么要进行进程间通信:

        因为进程之间可能会存在特定的协同工作的场景,而协同就必须要进行进程间通信。

进程间通信的目的:

  • 数据传输:一个进程需要将它的数据发送给另一个进程。
  • 资源共享:多个进程之间共享同一份资源。
  • 通知事件:一个进程需要向另一个或一组进程发送消息,通知它发生了某种事件(例如子进程终止时要通知父进程以进行资源回收)。
  • 进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。

 

2、如何进行进程间通信:

由于一个进程是不能直接访问到另一个进程的资源的,因为进程具有独立性。所以进程之间要通信,就不能使用属于进程的资源,而应该使用一份公共资源(文件、队列、内存块等)。

所以进程间通信的前提是:

        先让不同的进程看到同一份(OS)资源(“一段内存”)(这段资源(内存)由操作系统提供,这样也就不违背进程间具有独立性的原则)

且一定是某一个进程先需要通信,让OS创建一个共享资源 -> OS就必须提供很多系统调用,OS创建的共享资源的不同,进程间通信就会有不同的种类。

二、管道通信:

1、匿名管道:

只能用来进行具有血缘关系的进程之间进行通信,常用于父子进程之间通信。

下面是一张概览图:

通过这个图我们来解答一些问题:

1、父子进程是如何指向同一份文件的?

答:指向文件的指针是浅拷贝的,子进程是通过继承,浅拷贝出来的指针,指向的也就是父进程指针指向的文件内容和属性。

(这就是为什么父子进程会向同一个显示器终端打印数据:因为父子进程都是向同一个内核级文件缓冲区写入数据,OS刷新时也是向同一个显示器刷新数据)

(并且这也就解释了为什么一个进程会默认打开0,1,2,因为所有进程都是bash的子进程,因为bash打开了,所有的子进程默认也就打开了,以及子进程主动close012不影响父进程继续使用,因为进程的struct file中也有一种内存级的引用计数)

2、struct file会被创建两次【读和写】,但是为什么文件的内容和属性就只需要加载一次即可?

答:虽然进程间具有独立性,但是文件可以是同一份,OS不需要做浪费空间资源的事,可以直接将指向之前打开的内容和属性。

3、父子进程既然要关闭不需要的fd,为什么曾经要打开呢?可以不关闭吗?

答:

1、因为这种只需要让子进程继承下去,然后关闭两端不需要的fd的方式简单,详细点就是,读写一个文件要以读和写方式打开,很麻烦,子进程也要设置相应的读和写方式,也是比较麻烦的,那不如之间以“读写”方式打开然后继承给子进程更简单。

2、可以不关闭,可以正常通信,但是建议关了,因为一个进程能打开的文件描述符是有上限的,也就是能使用的系统资源是有上限的,占着不用就纯粹是浪费了(文件描述符泄漏),以及可能会出现误写的情况,因为管道是单向通信的,但是另一端也是有写入权力的。

好了,说了这么多,我们来认识一个创建匿名管道的函数:

#include <unistd.h>
int pipe(int pipefd[2]);

pipe( ) 用于创建一个匿名管道,并返回两个文件描述符:pipefd[ 1 ] 是读端,pipefd[ 2 ] 是写端。返回值为0表示打开失败。

如下使用:创建一个匿名管道用于父子进程间通信,父进程写消息,子进程读消息:

#include <iostream>
#include <cerrno>
#include <cstring>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>

const int size = 1024;


// 子进程进行读
void SonRead(int wfd)
{
    char inbuffer[size];
    while (true)
    {
        ssize_t n = read(wfd, inbuffer, sizeof(inbuffer) - 1);

        if (n > 0)
        {
            inbuffer[n] = 0; // =='0'
            std::cout << "son get message: " << inbuffer << std::endl;
        }
    }
}

void FatherWrite(int wfd)
{

    std::string message = "i am father!";
    while (true)
    {

        write(wfd, message.c_str(), message.size()); // 写入管道的时候没有写入\0因为没有必要

        sleep(3); // 让父进程写的慢一点,保证读端比写端块
    }
}

// 父进程进行读取

int main()
{
    // 创建管道
    int pipefd[2];
    int n = pipe(pipefd);

    if (n != 0)
    {
        std::cerr << "errno: " << errno << ": " << "errstring : " << strerror(errno) << std::endl;
        return 1;
    }

    // 创建子进程:
    pid_t id = fork();
    if (id == 0)
    {
        std::cout << "子进程关闭不需要的fd, 准备通信: 收消息: " << std::endl;
        sleep(1);

        // 子进程 --- read
        close(pipefd[1]);

        SonRead(pipefd[0]);

        close(pipefd[0]); // 不再通信时关闭,也可以不关
        exit(0);
    }

    // 父进程 --- read

    std::cout << "父进程关闭不需要的fd, 准备通信: 写消息" << std::endl;
    sleep(1);
    // 关闭不需要的fd:
    close(pipefd[0]);

    FatherWrite(pipefd[1]);

    close(pipefd[1]); 

    pid_t rid = waitpid(id, nullptr, 0);
    if (rid > 0)
    {
        std::cout << "wait child process done!" << std::endl;
    }

    return 0;
}

效果演示:

问题解答:在上面代码中有一句注释写着:保证读端比写端快,那为什么呢?

答:因为管道是面向字节流的,字符串之间没由规矩分隔符,如果读取速度慢于写入速度,可能读端还没有将整个字符串读完,写端又写入了数据,会导致数据混乱。

例如:将读端sleep2秒,写端正常写:

2、命名管道:

为了解决匿名管道只能用来进行具有血缘关系的进程之间进行通信的缺陷,更新了命名管道。

那么问题来了,既然父子进程是通过继承的方式让两者看到同一份文件,那么不同进程间如何能看到同一份文件从而进行通信呢?

答:让他两个使用同一个文件路径就能看到同一个文件

        同时要求这个文件是一个特殊文件,因为他不能刷新在磁盘,因为一旦刷新会拖慢通信的速率,特殊在这个文件被打开之后在内存当中一旦写入不会把数据刷新在磁盘,而是只要让两者在内存当中进行文件级别的通信就可以了。这种特殊的文件通过路径进行标识两个资源的唯一性叫做命名管道,命名就是这个管道有名字,因为他有路径,所有必有文件名,管道是因为他依旧是一个内存级的基于文件进行通信的通信方案。

下面认识创建命名管道的函数:

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

int mkfifo(const char *pathname, mode_t mode);
// pathname 为文件名    mode 为权限

如下使用:创建一个命名管道,实现两端单向通信:server为读消息端,client为写消息端

头文件:mkfifo.h

#pragma once    

#include <iostream>                                                                                                                                                               
#include<stdio.h>    
#include<sys/stat.h>    
#include<sys/types.h>    
#include<fcntl.h>        
#include<unistd.h>  
#include<string.h>    


// 当前路径下放置管道
#define my_fifo "./fifo"  

写消息端:client.cc

#include "mkfifo.h" 
//此时两个进程具有了能看到同一分公共资源的能力 就能获取该头文件包含的管道

int main()
{
    // 不需要再创建一个fifo,只需要获取server创建的即可
    int fd = open(my_fifo, O_WRONLY); // 不需要O_CREAT,因为文件本来就存在
    if (fd < 0)
    {
        perror("open");
        return 1;
    }

    // 写端逻辑
    while (true)
    {
        std::cout << "please enter: ";
        fflush(stdout);
        char buffer[64] = {0};
        
        // 获取输入
        ssize_t s = read(0, buffer, sizeof(buffer) - 1); 
        if (s > 0)
        {
            buffer[s - 1] = 0; // 去掉键盘输入的\n

            // 写入数据
            write(fd, buffer, strlen(buffer)); 
        }
    }

    close(fd);
    return 0;
}

读消息端:server.cc

#include "mkfifo.h"

int main()
{
    // 建立管道
    if (mkfifo(my_fifo, 0666) < 0)
    {
        perror("mkfifo");
        return 1;
    }

    // 打开文件
    int fd = open(my_fifo, O_RDONLY);
    if (fd < 0)
    {
        perror("open");
        return 2;
    }

    std::cout << "server ready: " << std::endl;

    // 读端逻辑:
    while (true)
    {
        char buffer[128] = {0};
        ssize_t s = read(fd, buffer, sizeof(buffer) - 1);
        if (s > 0)
        {
            buffer[s] = 0; // =='0'
            std::cout << "client write: " << buffer << std::endl;
        }
        else if (s == 0)
        {
            std::cout << "client quit ..." << std::endl;
            break;
        }
        else
        {
            perror("read error!");
            break;
        }
    }

    close(fd);
    return 0;
}

效果演示:

 

3、管道总结:

管道的4种情况:

1、如果管道内部是空的,没有数据 并且 write fd(写端的进程描述符)并没有关闭,读取条件不具备,读进程会被阻塞 ---> 自动等待读取条件具备再写入数据

2、管道被写满 && read fd 不读且没有关闭, 管道被写满,写进程会被阻塞(管道被写满--写条件不具备) ---> wait ---> 写条件具备 <--- 读取数据

3、管道一直在读 && 写端关闭了wfd,读端read返回值会读到0,表示读到了文件结尾。

4、读端rfd直接关闭,写端wfd一直在进行写入,(写端会被操作系统使用13号信号关掉,相当于进程出现了异常)

管道的5种特征:

1、匿名管道:只用来进行具有血缘关系的进程之间进行通信,且只支持单向通信!常用于父子进程之间通信(爷孙进程也可)。

2、管道内部,自带进程之间同步机制,原子性写入,如上代码,子进程五秒发送,父进程五秒才读取并进行输出(多执行流执行代码的时候具有明显的顺序性!)。

3、文件的声明周期是随进程的,管道也是文件,所以管道文件的声明周期也是随进程的。

4、管道文件在通信的时候,是面向字节流的。(write的次数和读取的次数不是一一匹配的)

5、管道的通信模式是一种半双工模式(我在给你发消息的同时你不能给我发,得等我说完你才能给我说 )(与之相对的是全双工模式,就是我在给你发消息的同时,你也可以给我发消息)。

三、System V 标准进程间通信:

上面讲的管道是基于文件的通信方式,而system v标准的进程间通信方式是被设计在OS层面的,因为OS不相信任何用户,所以给用户提供功能的时候,采用系统调用的方式。所以System V进程间通信就一定会存在专门用来通信的接口(system call)

1、共享内存:

那么如何使用共享内存的方式进行进程间通信呢?"请看VCR:"

首先通过系统调用,在内存中创建一份内存空间,然后再通过系统调用,让进程"挂接"到这份刚开辟的内存空间上(就是把物理空间上新开辟的内存通过页表映射到进程的地址空间中,地址空间上就可以拿到映射这部分内存的起始地址)

这样就可以让不同的进程看到同一份资源了,而这种方式就叫做共享内存。

首先我们要来理解共享内存的一些特性:

1、共享内存 = 内存空间(数据) + 共享内存的属性

2、共享内存中一定有标识共享内存唯一性的字段

3、共享内存不随着进程的结束而自动释放,会一直存在,直到系统重启,或者手动释放(指令or其它系统调用行为)内存的生命周期随内核,文件的生命周期随进程

4、共享内存不提供任何保护机制(就是不提供任何同步或者互斥机制)

5、共享内存是所有进程间通信中速度最块的。

至此再来了解一些函数接口:

上面第二点我们说到,共享内存中一定有标识共享内存唯一性的字段(key),这个key用来让不同的进程看到同一份共享内存资源,且存在于描述共享内存的内核数据结构中。由用户自己设定需要用到ftok接口生成:

#include <sys/types.h>
#include <sys/ipc.h>
key_t ftok(const char *pathname, int proj_id); // 算法生成key
// pathname:自定义路径名
// proj_id:自定义项目id

共享内存相关接口:

shmget:申请共享内存

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

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

// key:创建共享内存时的算法和数据结构中唯一标识符,由用户自己设定需用到接口ftok

// size:共享内存的大小,建议是4KB的整数倍

// shmflg:有两个选项:IPC_CREAT(0),创建一个共享内存,如果已经存在则返回共享内存;IPC_EXCL(单独使用没有意义)

// IPC_CREAT|IPC_EXCL(如果调用成功,一定会得到一个全新的共享内存):如果不存在共享内存,就创建;反之,返回出错

// 返回值:shmdi,描述共享内存的标识符
​

为什么size设置建议是4kb(4096)的整数倍呢?   因为内核会向上进行倍数向上取整,但这就可能引发越界、抛异常导致的程序运行不正确的问题,不符合自身预期。

shmctl:控制共享内存

#include <sys/ipc.h>
#include <sys/shm.h>
int shmctl(int shmid, int cmd, struct shmid_ds *buf);

// shmid:共享内存id

// cmd:控制方式,有三个可取值,放在下面说明

// buf:描述共享内存的数据结构

返回值为成功返回0,失败返回-1,常用命令IPC_RMID,以删除共享内存,shmid_ds直接设置为NULL即可。

shmat、shmdt:关联、去关联共享内存

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

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

// shmid:  共享内存id
// shmaddr:挂接地址(自己不知道地址,所以默认为NULL)
// shmflg: 挂接方式,默认为0
// 返回值:  挂接成功返回共享内存起始地址(虚拟地址),失败返回-1类似C语言malloc
​
    
int shmdt(const void *shmaddr); // 去关联(取消当前进程和共享内存的关系)

// shmaddr:去关联内存地址,即shmat返回值
// 返回值:  调用成功返回0,失败返回-1

 

创建共享内存:

Shm.hpp:

#pragma once

#include <iostream>
#include <sys/ipc.h> // 共享内存
#include <sys/shm.h> // 共享内存
#include <string>
#include <cerrno>
#include <cstdio>
#include <unistd.h>
#include <cstring>

const std::string pathname = "./";
const int proj_id = 0x66;
const int ShmSize = 4096;

// 十六进制返回
std::string ToHex(key_t _key)
{
    char buffer[128];
    snprintf(buffer, sizeof(buffer), "0x%x", _key);
    return buffer;
}

server.cc:

#include "Shm.hpp"

int main()
{
    key_t Key = ftok(pathname.c_str(), proj_id);
        if (Key < 0)
        {
            perror("ftok");
        }

        int shimd = shmget(Key,ShmSize,IPC_CREAT | IPC_EXCL); //创建共享内存
        if(shimd < 0)
        {
            perror("shmget error!");
            exit(1);
        }

    std::cout << "Shared memory creation successful!"<< std::endl;
    std::cout << "Key: " << ToHex(Key) << "\n" << "shmid: " << shimd << std::endl;


    return 0;
}

 

查看共享内存: 

我们可以使用ipcs -m来查看ipc资源

删除共享内存:

1、命令行删除

命令行删除的方式就是利用 ipcrm -m +shmid

 

2、shmctl函数删除

上面我们说到shmctl函数可以控制共享内存,那么删除就是它常用的一种。

server.cc:

#include "Shm.hpp"

int main()
{
    key_t Key = ftok(pathname.c_str(), proj_id);
        if (Key < 0)
        {
            perror("ftok");
        }

        int shmid = shmget(Key,ShmSize,IPC_CREAT | IPC_EXCL); //创建共享内存
        if(shmid < 0)
        {
            perror("shmget error!");
            exit(1);
        }

    std::cout << "Key: " << ToHex(Key) << "\n" << "shmid: " << shmid << std::endl;

    sleep(10); // 共享内存在休眠的这10秒钟是存在的

    shmctl(shmid,IPC_RMID,NULL); // 删除共享内存

    sleep(10); // 删除了,这10秒钟就不存在了

    return 0;
}

通过上图我们可以看到共享内存20被删除了

共享内存通信:

下面写一个简单的共享内存方式进行进程间通信,由client端向共享内存中写入数据,server端读取数据并向显示器上打印数据:

Shm.hpp头文件:

#pragma once

#include <iostream>
#include <sys/ipc.h> // 共享内存
#include <sys/shm.h> // 共享内存
#include <string>
#include <cerrno>
#include <cstdio>
#include <unistd.h>
#include <cstring>

const std::string pathname = "./";
const int proj_id = 0x66;
const int ShmSize = 4096;

// 十六进制返回
std::string ToHex(key_t _key)
{
    char buffer[128];
    snprintf(buffer, sizeof(buffer), "0x%x", _key);
    return buffer;
}

server.h端:

#include "Shm.hpp"

int main()
{
    key_t Key = ftok(pathname.c_str(), proj_id);
    if (Key < 0)
    {
        perror("ftok");
        exit(1);
    }

    int shmid = shmget(Key, ShmSize, IPC_CREAT | IPC_EXCL | 0666); // 创建共享内存
    if (shmid < 0)
    {
        perror("shmget error!");
        exit(1);
    }

    std::cout << "Key: " << ToHex(Key) << "\n"
              << "Shmid: " << shmid << std::endl;

    // 关联挂接
    char *shmaddr = (char *)shmat(shmid, NULL, 0 ); 
    std::cout << "server shmat success" << std::endl;

    while(true)
    {
        sleep(2);
        std::cout << shmaddr << std::endl;
    }

    // 去关联
    shmdt(shmaddr);
    std::cout << "server shmdt success" << std::endl;

    shmctl(shmid, IPC_RMID, NULL); // 删除共享内存

    return 0;
}

client.cc端:

#include "Shm.hpp"

int main()
{
    key_t Key = ftok(pathname.c_str(), proj_id);
    if (Key < 0)
    {
        perror("ftok");
        exit(1);
    }

    // client端只需获取共享内存即可
    int shmid = shmget(Key, ShmSize, IPC_CREAT | 0666); // 创建共享内存
    if (shmid < 0)
    {
        perror("shmget error!");
        exit(1);
    }

    std::cout << "Key: " << ToHex(Key) << "\n"
              << "Shmid: " << shmid << std::endl;

    // 关联挂接
    char *shmaddr = (char *)shmat(shmid, NULL, 0 ); 
    std::cout << "server shmat success" << std::endl;

    char buffer[1024];
    
    // 通信
    while(true)
    {
        fgets(buffer,sizeof(buffer),stdin);
        size_t len = strlen(buffer);
        buffer[len - 1] = '\0';   // 将键盘端获取的\n改为\0结束
        
        strcpy(shmaddr,buffer); // 复制写入shmaddr中
    }

    // 去关联
    shmdt(shmaddr);
    std::cout << "server shmdt success" << std::endl;

    // client端是不需要删除共享内存的,因为共享内存是由server端建立的

    return 0;
}

效果演示:client端往共享内存中写入 "I am process A" ,server端读取

当我们运行server端之后,他就会直接开始读共享内存,并不会等client端,所以会一直向下刷屏,因为前面讲过:共享内存是通信中速度最快的,并且共享内存不提供任何保护机制(就是不提供任何同步或者互斥机制)如果想让其同步,可以复用管道的同步机制。所以共享内存在多进程通信的时候是不太安全的,需要程序员自行保证数据的安全。

2、消息队列:

消息队列这种进程间通信的方式目前运用的已经很少,也很少会遇到,所以这里只讲特性哈。

  • 消息队列提供了一个从一个进程向另外一个进程发送一块数据的方法
  • 每个数据块都被认为是有一个类型,接收者进程接收的数据块可以有不同的类型值
  • IPC资源也一样要手动删除(或者重启),IPC资源的生命周期也是随内核

msgget:创建消息队列

#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
int msgget(key_t key, int msgflg);

msgctl:控制消息队列

#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
int msgctl(int msqid, int cmd, struct msqid_ds *buf);

3、信号量:

1、什么是信号量:

信号量是一种用于多线程或多进程间同步的机制,是一个非负整数,用于控制对共享资源的访问。可以理解为就是一个计数器(count)

当一个线程或进程想要访问某个共享资源时,它必须先检查信号量的值。如果信号量的值大于0,则线程或进程可以访问该资源,并将信号量的值减1。如果信号量的值为0,则线程或进程必须等待,直到信号量的值变为大于0为止。

代码解释就是:

信号量主要用于两个目的:同步与互斥

同步:

        控制线程或进程的执行顺序,确保它们按照预定的顺序执行。(通常通过设置信号量的初始值为1来实现)

互斥:

        确保多个线程或进程不会同时访问同一资源,从而防止数据损坏或不一致。(通过多个信号量来实现,每个信号量代表不同的执行阶段或条件。)

2、临界资源与临界区:

临界资源:指一次仅允许一个进程使用的共享资源(各进程采取互斥的方式实现共享的资源)。比如在进程间通信的时候,管道,共享内存,消息队列等都是临界资源,再比如日常生活中的笔,一次只能由一个人使用。

临界区:每个进程中访问临界资源的那段代码,就是临界区。

举个例子,如果多个进程中都是使用的printf向显示器上打印消息,那么这个显示器就是临界资源,printf就是临界区。

3、理解原子性:

这里引入一个问题:信号量是用来保护临界资源的,但是信号量本身也是临界资源,那么信号量又如何保护自己呢?

进入信号量,要对信号量值--,退出就进行++,既然信号量本身也是临界资源,就要求我们对信号量-或者++必须是安全的,所以信号量内部对临界资源的--和++是原子的。这就叫做信号量的PV操作。P操作就是对计数器讲行--,V操作就是对计数器进行++。PV操作的特点就是他们是原子性的。

那么什么是原子性呢?

原子性:一个操作或一系列操作要么全部执行成功,要么全部不执行,中间不能被中断。这意味着操作在执行过程中不会受到其他并发操作的干扰

例如在多线程场景中:如果多个线程同时修改同一数据,而这些修改不是原子性的,就可能导致数据不一致的问题。通过使用原子操作或加锁机制,可以确保数据的一致性和线程安全。

这里插一个实例:x--操作不为原子性导致的全局数据出现错乱:

一个全局变量 x 为100,父子进程能同时看到这个 x,并都对 x 进行 -- 操作

首先我们要知道,我们定义的变量存放在内存中,我们要对x进行 -- ,就要把它读进cpu中,在cpu中进行 -- 操作,再将它写进内存中。

父进程把 x 读到CPU中,变成99,当他正准备写回的时候,父进程被切走了,此时这个99在父进程的上下文保存着,所以最终这个99被挂起了,然后子进程读取 x,同样遵守这个规则,可是子进程在进行 x-- 的时候没人干涉,直到把 x 减成了5,x 成了 5 之后,还没向内存中进行写入就被切走了,于是这个 5 在子进程的上下文中保存了起来,恢复到父进程的时候,父进程继续执行它还没执行完的代码,也就是继续向内存中进行写入,可是父讲程中 x 是99,直接将子讲程好不容易计算的 5 改成了 99 。此时导致了全局数据出现错乱的问题。

4、信号量接口:

有些类似于共享内存的接口

semget:创建信号量

#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
int semget(key_t key, int nsems, int semflg);

semctl:控制信号量

#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
int semctl(int semid, int semnum, int cmd, ...);

相关推荐

最近更新

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

    2024-07-17 20:34:03       67 阅读
  2. Could not load dynamic library ‘cudart64_100.dll‘

    2024-07-17 20:34:03       72 阅读
  3. 在Django里面运行非项目文件

    2024-07-17 20:34:03       58 阅读
  4. Python语言-面向对象

    2024-07-17 20:34:03       69 阅读

热门阅读

  1. Three.js常见的贴图类型及其用途

    2024-07-17 20:34:03       20 阅读
  2. MySQL 事务

    2024-07-17 20:34:03       21 阅读
  3. 力扣刷题之2956.找到两个数组中的公共元素

    2024-07-17 20:34:03       20 阅读
  4. 前端面试题日常练-day94 【Less】

    2024-07-17 20:34:03       22 阅读
  5. Linux第一章课后作业

    2024-07-17 20:34:03       25 阅读
  6. 免费服务器和付费服务器哪个更好?

    2024-07-17 20:34:03       22 阅读
  7. 云服务器,nginx访问失败,安全组,0.0.0.0/0

    2024-07-17 20:34:03       21 阅读