Linux:进程间通信

✨✨✨学习的道路很枯燥,希望我们能并肩走下来!

文章目录

文章目录

前言

[一 进程间通信介绍](#一 进程间通信介绍)

[1.1 进程间通信目的](#1.1 进程间通信目的)

[1.2 进程间通信发展](#1.2 进程间通信发展)

[1.3 进程间通信分类](#1.3 进程间通信分类)

[二 管道](#二 管道)

[​编辑2.1 匿名管道](#编辑2.1 匿名管道)

[2.1.1 匿名管道原理介绍](#2.1.1 匿名管道原理介绍)

[2.1.2 管道读写规则](#2.1.2 管道读写规则)

[2.1.3 管道特点](#2.1.3 管道特点)

[2.1.3 匿名管道实战---创建进程池处理任务](#2.1.3 匿名管道实战—创建进程池处理任务)

[2.2 命名管道](#2.2 命名管道)

[2.2.1 创建⼀个命名管道](#2.2.1 创建⼀个命名管道)

[2.2.2 匿名管道与命名管道的区别](#2.2.2 匿名管道与命名管道的区别)

[2.2.3 命名管道内核理解](#2.2.3 命名管道内核理解)

[2.2.4 命名管道的打开规则](#2.2.4 命名管道的打开规则)

[​编辑2.2.5 用命名管道实现server&client通信](#编辑2.2.5 用命名管道实现server&client通信)

[三 systemV共享内存](#三 systemV共享内存)

[3.1 共享内存示意图](#3.1 共享内存示意图)

[3.2 共享内存数据结构](#3.2 共享内存数据结构)

[3.3 共享内存函数](#3.3 共享内存函数)

​编辑

[3.3.1 shmget函数](#3.3.1 shmget函数)

[3.3.2 shmat函数](#3.3.2 shmat函数)

​编辑

[3.3.3 shmdt函数](#3.3.3 shmdt函数)

[​编辑 3.3.4 shmctl函数](#编辑 3.3.4 shmctl函数)

[3.4 共享内存实现通信](#3.4 共享内存实现通信)

[3.5 共享内存特点](#3.5 共享内存特点)

[四 systemV消息队列](#四 systemV消息队列)

[4.1 消息队列函数](#4.1 消息队列函数)

[五 systemV信号量 ​编辑](#五 systemV信号量 编辑)

​编辑

[5.1 并发编程,概念铺垫](#5.1 并发编程,概念铺垫)

[5.2 信号量](#5.2 信号量)

[六 System V如何实现IPC](#六 System V如何实现IPC)

[6.1 应用角度,看IPC属性](#6.1 应用角度,看IPC属性)

[6.2 内核角度,看IPC结构](#6.2 内核角度,看IPC结构)

[​编辑 七 重谈共享内存](#编辑 七 重谈共享内存)


前言

本篇详细介绍了进一步介绍Linux的进程间通信,让使用者有更加深刻的认知,而不是仅仅停留在表面,更好的模拟,为了更好的使用. 文章可能出现错误,如有请在评论区指正,让我们一起交流,共同进步!


一 进程间通信介绍

1.1 进程间通信目的

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

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

• 通知事件:⼀个进程需要向另⼀个或⼀组进程发送消息,通知它(它们)发⽣了某种事件(如进程终⽌时要通知⽗进程)。

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

1.2 进程间通信发展

• 管道

• SystemV进程间通信

• POSIX进程间通信

1.3 进程间通信分类

管道:

• 匿名管道pipe

• 命名管道

SystemV IPC:

• SystemV消息队列

• SystemV共享内存

• SystemV信号量

POSIX IPC:

• 消息队列

• 共享内存

• 信号量

• 互斥量

• 条件变量

• 读写锁

二 管道

什么是管道

• 管道是Unix中最古⽼的进程间通信的形式。

• 我们把从⼀个进程连接到另⼀个进程的⼀个数据流称为⼀个"管道"

2.1 匿名管道

cpp 复制代码
 #include <unistd.h>
功能:创建⼀⽆名管道
原型
 int pipe(int fd[2]);
参数
fd:⽂件描述符数组,其中fd[0]表⽰读端, fd[1]表⽰写端
返回值:成功返回0,失败返回错误代码

实例代码:

cpp 复制代码
例⼦:从键盘读取数据,写⼊管道,读取管道,写到屏幕
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
int main(void)
{
	int fds[2];
	char buf[100];
	int len;
	if (pipe(fds) == -1)
		perror("make pipe"), exit(1);
	// read from stdin
	while (fgets(buf, 100, stdin)) {
		len = strlen(buf);
		// write into pipe
		if (write(fds[1], buf, len) != len) {
			perror("write to pipe");
			break;
		}
		memset(buf, 0x00, sizeof(buf));
		// read from pipe
		if ((len = read(fds[0], buf, 100)) == -1) {
			perror("read from pipe");
			break;
		}
		// write to stdout
		if (write(1, buf, len) != len) {
			perror("write to stdout");
			break;
		}
	}
}

2.1.1 匿名管道原理介绍

⽤fork来共享管道原理:

先fork在创建子进程,因为进程间通信首先要做到的是让不同进程看到同一份资源

站在⽂件描述符⻆度-深度理解管道 :

站在内核⻆度-管道本质

**匿名管道不是文件,是操作系统提供的内存级别的文件内核缓冲区,**看待管道,就如同看待⽂件⼀样!管道的使⽤和⽂件⼀致,迎合了"Linux⼀切皆⽂件思想"。

2.1.2 管道读写规则

• 当要写⼊的数据量不⼤于PIPE_BUF 时,linux将保证写⼊的原⼦性

• 当要写⼊的数据量⼤于PIPE_BUF时,linux将不再保证写⼊的原⼦性。

这里我们暂时不提起原子性的概念,说简单点就是,你今天向管道里写入的数据小于PIPE_BUF,数据就是安全的,不用担心写一半被别人读走

2.1.3 管道特点

2.1.3 匿名管道实战---创建进程池处理任务

lesson11/ProcessPoll · 祈诺/qinuo_Linux - 码云 - 开源中国

Channel.hpp

cpp 复制代码
#ifndef __CHANNEL_HPP__
#define __CHANNEL_HPP__

#include <iostream>
#include <string>
#include <unistd.h>
using namespace std;
class Channel
{
public:
    Channel(int wfd, pid_t who) : _wfd(wfd), _who(who)
    {
        // Channel-3-1234
        _name = "Channel-" + to_string(wfd) + "-" + to_string(who);
    }
    string Getname()
    {
        return _name;
    }
    void Send(int cmd)
    {
        write(_wfd, &cmd, sizeof(cmd));
    }
    void Close()
    {
        close(_wfd);
    }
    pid_t Id()
    {
        return _who;
    }
    int wFd()
    {
        return _wfd;
    }
    ~Channel()
    {
    }

private:
    int _wfd;
    string _name;
    pid_t _who;
};
#endif

ProcessPool.hpp

cpp 复制代码
#include <iostream>
#include <string>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <cstdlib>
#include <vector>
#include <functional>
#include "Task.hpp"
#include "Channel.hpp"
using namespace std;

using work_t = function<void()>;
enum
{
    OK = 0,
    UsageError,
    PipeError,
    ForkError
};

class ProcessPool
{
public:
    ProcessPool(int n, work_t w) : processnum(n), work(w)
    {}
    int InitProcessPool()
    {
        for (int i = 0; i < processnum; i++)
        {
            int pipes[2] = {0};
            int n = pipe(pipes);
            if (n < 0)
                return PipeError;
            pid_t id = fork();
            if (id < 0)
                return ForkError;
            if (id == 0)
            {
                for(auto& c: channels)
                    c.Close();
                close(pipes[1]);
                dup2(pipes[0], 0);
                work();
                exit(0);
            }

            close(pipes[0]);
            channels.emplace_back(pipes[1], id);
        }
        return OK;
    }

    void DispatchTask()
    {
        int who = 0;
        int num = 20;
        while (num--)
        {
            int task = tam.SelectTask();
            Channel &curr = channels[who++];
            who %= channels.size();
            cout << "######################" << endl;
            cout << "send " << task << " to " << curr.Getname() << ", 任务还剩: " << num << endl;
            cout << "######################" << endl;
            curr.Send(task);
            sleep(2);
        }
    }

    void CleanProcessPool()
    {
        for (auto &c : channels)
        {
            c.Close();
            pid_t rid = waitpid(c.Id(), nullptr, 0);
            if (rid > 0)
            {
                cout << " child " << rid << " wait ... success" << endl;
            }
        }
    }

    void DebugPrintf()
    {
        for (auto &ch : channels)
            cout << ch.Getname() << endl;
    }

private:
    int processnum;
    vector<Channel> channels;
    work_t work;
};

Task.hpp

cpp 复制代码
#pragma once
#include <iostream>
#include <unordered_map>
#include <functional>
#include <ctime>
using namespace std;
using task_t = function<void()>;
static int number = 0;

void DownLoad()
{
    cout << "我是下载任务,pid:" << getpid() << endl;
}

void Log()
{
    cout << "我是日志任务,pid:" << getpid() << endl;
}

void sql()
{
    cout << "我是数据库同步任务,pid:" << getpid() << endl;
}

class TaskManger
{
public:
    TaskManger()
    {
        srand(time(nullptr));
        InsertTask(DownLoad);
        InsertTask(Log);
        InsertTask(sql);
    }
    void InsertTask(task_t task)
    {
        tasks[number++] = task;
    }
    int SelectTask()
    {
        return rand() % number;
    }
    void Excute(int num)
    {
        if (tasks.find(num) == tasks.end())
            return;
        tasks[num]();
    }
    ~TaskManger()
    {
    }

private:
    unordered_map<int, task_t> tasks;
};
TaskManger tam;
void Worker()
{
    while (true)
    {
        int cmd = 0;
        int n = read(0, &cmd, sizeof(cmd));
        if (n == sizeof(cmd))
        {
            tam.Excute(cmd);
        }
        else if (n == 0)
        {
            cout << "pid:" << getpid() << " ... quit ..." << endl;
            break;
        }
        else
        {
        }
    }
}

Main.cc

cpp 复制代码
#include "ProcessPool.hpp"
#include "Task.hpp"

void Usage(string proc)
{
    cout << "Usage " << proc << " process - num" << endl;
}

int main(int argc, char *argv[])
{
    if (argc != 2)
    {
        Usage(argv[0]);
        return UsageError;
    }
    int num = stoi(argv[1]);
    ProcessPool *pp = new ProcessPool(num, Worker);
    // 1. 初始化进程池
    pp->InitProcessPool();
    // 2. 派发任务
    pp->DispatchTask();
    // 3. 退出进程池
    pp->CleanProcessPool();
    delete pp;
    return 0;
}

makefile

cpp 复制代码
BIN = processspoll
CC = g++
FLAGS = -c -Wall -std=c++11
LDFLAGS = -o
SRC = $(shell ls *.cc)
OBJ = $(SRC:.cc=.o)

$(BIN):$(OBJ)
	$(CC) $(LDFLAGS) $@ $^
%.o:%.cc
	$(CC) $(FLAGS) $<
.PHONY:clean
clean:
	rm -f $(BIN) $(OBJ)

PS:

2.2 命名管道

• 管道应⽤的⼀个限制就是只能在具有共同祖先(具有亲缘关系)的进程间通信。

• 如果我们想在不相关的进程之间交换数据,可以使⽤FIFO⽂件来做这项⼯作,它经常被称为命名管道。

命名管道是⼀种特殊类型的⽂件

2.2.1 创建⼀个命名管道

• 命名管道可以从命令⾏上创建,命令⾏⽅法是使⽤下⾯这个命令:

mkfifo filename

• 命名管道也可以从程序⾥创建,相关函数有:

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

创建命名管道:

cpp 复制代码
 int main(int argc, char *argv[])
 {
     mkfifo("p2", 0644);
     return 0;
 }

2.2.2 匿名管道与命名管道的区别

• 匿名管道由pipe函数创建并打开。

• 命名管道由mkfifo函数创建,打开⽤open

• FIFO(命名管道)与pipe(匿名管道)之间唯⼀的区别在它们创建与打开的⽅式不同,⼀但这些⼯作完成之后,它们具有相同的语义。

2.2.3 命名管道内核理解

命名管道是文件,加载到内存,使用文件内核缓冲区进行不同进程间的通信,与普通文件不同的是,不会向磁盘内进行刷新

普通文件要把内容刷新到磁盘里,并且,对缓冲区的数据没有进行管理

2.2.4 命名管道的打开规则

• 如果当前打开操作是为读⽽打开FIFO时

◦ O_NONBLOCK disable:阻塞直到有相应进程为写⽽打开该FIFO

◦ O_NONBLOCK enable:⽴刻返回成功

• 如果当前打开操作是为写⽽打开FIFO时

◦ O_NONBLOCK disable:阻塞直到有相应进程为读⽽打开该FIFO

◦ O_NONBLOCK enable:⽴刻返回失败,错误码为ENXIO

2.2.5 用命名管道实现server&client通信

lesson12 · 祈诺/qinuo_Linux - 码云 - 开源中国

Comm.hpp

cpp 复制代码
#pragma once
#include <iostream>
#include <string>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
using namespace std;

const string gpipeFile = "./fifo";
const mode_t gmode = 0600;
const int gdefultfd = -1;
const int gsize = 1024;
const int gForRead = O_RDONLY;
const int gForWrite = O_WRONLY;

int OpenPipe(int flag)
{
    int fd = open(gpipeFile.c_str(), flag);
    if (fd < 0)
    {
        cerr << "open error" << endl;
    }
    return fd;
}

void ClosePipeHelper(int fd)
{
    if (fd > 0)
        close(fd);
}

Cilent.cc

cpp 复制代码
#include "Cilent.hpp"

int main()
{
    Cilent cilent;
    cilent.OpenPipeForWrite();
    string message;
    while(true)
    {
        cout << "Please Enter# ";
        getline(std::cin, message);
        cilent.SendPipe(message);
    }
    cilent.ClosePipe();
    return 0;
}

Cilent.hpp

cpp 复制代码
#include"Comm.hpp"

class Cilent
{
public:
    Cilent() : _fd(gdefultfd)
    {}

    bool OpenPipeForWrite()
    {
        _fd = OpenPipe(gForWrite);
        if (_fd < 0)
        {
            cerr << "pipe error" << endl;
            return false;
        }
        return true;
    }

    int SendPipe(string& in)
    {
        return write(_fd, in.c_str(),in.size());
    }

    void ClosePipe()
    {
        ClosePipeHelper(_fd);
    }
    ~Cilent()
    {}

private:
    int _fd;
};

Server.cc

cpp 复制代码
#include "Server.hpp"
#include <iostream>

int main()
{
    Server server;
    server.OpenPipeForRead();

    std::string message;
    while (true)
    {
        if (server.RecvPipe(&message) > 0)
        {
            std::cout << "client Say# " << message << std::endl;
        }
        else
        {
            break;
        }

    }
    cout << "client quit, me too!" << std::endl;
    server.ClosePipe();
    return 0;
}

Server.hpp

cpp 复制代码
#pragma once
#include "Comm.hpp"

class Init
{
public:
    Init()
    {
        umask(0);
        int n = mkfifo(gpipeFile.c_str(), gmode);
        if (n < 0)
        {
            cerr << "mkfifo error" << endl;
            return;
        }
    }
    ~Init()
    {
        int n = unlink(gpipeFile.c_str());
        if (n < 0)
        {
            cerr << "unlink error" << endl;
            return;
        }
        cout << "unlink success" << endl;
    }
};

Init it;

class Server
{
public:
    Server() : _fd(gdefultfd)
    {}

    bool OpenPipeForRead()
    {
        _fd = OpenPipe(gForRead);
        if (_fd < 0)
        {
            cerr << "pipe error" << endl;
            return false;
        }
        return true;
    }

    int RecvPipe(std::string *out)
    {
        char buffer[gsize];
        ssize_t n = read(_fd, buffer, sizeof(buffer) - 1);
        if (n > 0)
        {
            buffer[n] = 0;
            *out = buffer;
        }
        return n;
    }

    void ClosePipe()
    {
        ClosePipeHelper(_fd);
    }
    ~Server()
    {}

private:
    int _fd;
};

三 systemV共享内存

共享内存区是最快的IPC形式。⼀旦这样的内存映射到共享它的进程的地址空间,这些进程间数据传递不再涉及到内核,换句话说是进程不再通过执⾏进⼊内核的系统调⽤来传递彼此的数据

3.1 共享内存示意图

3.2 共享内存数据结构

3.3 共享内存函数

ftok函数,创建唯一key

3.3.1 shmget函数

3.3.2 shmat函数

功能:将共享内存段连接到进程地址空间

原型

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

参数

shmid: 共享内存标识

shmaddr: 指定连接的地址

shmflg: 它的两个可能取值是 SHM_RND 和 SHM_RDONLY

返回值: 成功返回⼀个指针,指向共享内存第⼀个节;失败返回-1

说明:

无特殊要求,我们一般使用就shmat(shmid,nullptr,0);

3.3.3 shmdt函数

3.3.4 shmctl函数

3.4 共享内存实现通信

ShareMemory.hpp

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <cstdio>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <stdalign.h>
#include <unistd.h>
using namespace std;

const int gsize = 4096;
const int gmode = 0600;
const int gID = 0x6666;
const string gpath = "/home/ubuntu/qinuo_-linux";

class ShareMemory
{
private:
    void CreateShmHelper(int shmflg)
    {
        _key = ftok(gpath.c_str(), gID);
        if (_key < 0)
        {
            cerr << "ftok error" << endl;
            return;
        }
        _shmid = shmget(_key, gsize, shmflg);
        if (_shmid < 0)
        {
            cerr << "shmget error" << endl;
            return;
        }
        cout << "shmid:" << _shmid << endl;
    }
public:
    ShareMemory():_shmid(-1),_key(0),_addr(nullptr)
    {}
    ~ShareMemory()
    {}
    void CreateShm()
    {
        if(_shmid == -1)
            CreateShmHelper(IPC_CREAT | IPC_EXCL | gmode);
    }
    void GetShm()
    {
        CreateShmHelper(IPC_CREAT);
    }
    void AttachShm()
    {
        _addr = shmat(_shmid, nullptr, 0);
        if ((long long)_addr == -1)
        {
            cout << "attach error" << endl;
        }
    }
    void DetachShm()
    {
        if(_addr != nullptr)
            shmdt(_addr);
        cout << "shmidt success" << endl;
    }
    void DeleteShm()
    {
        shmctl(_shmid,IPC_RMID,nullptr);
    }
    void *GetAddr()
    {
        return _addr;
    }
private:
    int _shmid;
    key_t _key;
    void *_addr;
};

ShareMemory shm;

Cilent.cc------向共享内存写

cpp 复制代码
#include "ShareMemory.hpp"

int main()
{
    shm.CreateShm();
    shm.AttachShm();

    char* message = (char*)shm.GetAddr();

    char ch = 'A';
    while(ch <= 'Z')
    {
        message[ch - 'A'] = ch;
        ch++;
        sleep(1);
    }
    shm.DetachShm();
    shm.DeleteShm();
    return 0;
}

Server.cc------从共享内存读

cpp 复制代码
#include "ShareMemory.hpp"

int main()
{
    shm.GetShm();
    shm.AttachShm();

    char* message = (char*)shm.GetAddr();

    char ch = 'A';
    while(true)
    {
        printf("%s\n",message);
        sleep(1);
    }
    shm.DetachShm();
    shm.DeleteShm();
    return 0;
}
cpp 复制代码
 # ./server
shmget: File exists
 # ipcs -m
----- Shared Memory Segments --------
key        shmidowner     owner      perms      bytes    nattch    status
0x66026a25   688145        root      666        4096        0 

# ipcrm -m 688145 #删除shm ipc资源,注意,不是必须通过⼿动来删除,这⾥只为演⽰相关指令,删除IPC
资源是进程该做的事情
 

注意:共享内存没有进⾏同步与互斥!共享内存缺乏访问控制!会带来并发问题。

ps:

1 Prems是权限,共享内存也有权限

2 操作系统申请空间

4097 分配两页,只让你用4097 浪费了

建议使用4096的整数倍

3.5 共享内存特点

共享内存可以减少拷贝次数

四 systemV消息队列

4.1 消息队列函数

我们发现,消息队列函数基本和共享内存函数在使用几乎一模一样,侧面证明,无论 共享内存,消息队列,还是信号量,都遵从System V这个标准

但也有名字叫消息队列,肯定也有不一样的地方

发送和接受消息函数:

要求你在用户层要定义一个struct msgbuf的结构体

msgrcv

msgtyp 就是自己创建的结构体中的第一个类型

比如我是B进程,发送的消息第一个为1,要接收的消息第一个为2,

五 systemV信号量

信号量集的概念

semctl如果执行删除操作,semnum随便填,一般填0

5.1 并发编程,概念铺垫

• 多个执⾏流(进程),能看到的同⼀份公共资源:共享资源

• 被保护起来的资源叫做临界资源

• 保护的⽅式常⻅:互斥与同步

• 任何时刻,只允许⼀个执⾏流访问资源,叫做互斥

• 多个执⾏流,访问临界资源的时候,具有⼀定的顺序性,叫做同步

• 系统中某些资源⼀次只允许⼀个进程使⽤,称这样的资源为临界资源或互斥资源。

• 在进程中涉及到互斥资源的程序段叫临界区。你写的代码=访问临界资源的代码(临界区)+不访问 临界资源的代码(⾮临界区)

• 所谓的对共享资源进⾏保护,本质是对访问共享资源的代码进⾏保护

5.2 信号量

特性⽅⾯

• IPC资源必须删除,否则不会⾃动清除,除⾮重启,所以systemVIPC资源的⽣命周期随内核

理解⽅⾯

• 信号量是⼀个计数器

作⽤⽅⾯

• 保护临界区

本质⽅⾯

• 信号量本质是对资源的预订机制

操作⽅⾯

• 申请资源,计数器--,P操作

• 释放资源,计数器++,V操作

如果我们按照上面理解,信号量是一个int类型的计数器吗?

显然不是,如果是的话,多个进程是看不到这个计数器的,因为它不是公共资源 PS:count++ 是由好几步汇编指令组成的,在执行count++或--时,有可能数据被读走或修改,所以他不符合原子性,因此信号量要自己设计PV操作

六 System V如何实现IPC

6.1 应用角度,看IPC属性

共享内存:

消息队列:

信号量:

推测,在OS层面,IPC是同类资源

6.2 内核角度,看IPC结构

七 重谈共享内存

共享内存在底层本质也是一个文件,文件就有inode,有inode就有自己的缓冲区,即内存块

vm_area_struct进行映射,就能使用虚拟地址空间进行对内存块操作

普通文件能做到这一点吗?可以的

mmap是一种内存映射文件的方法,即将一个文件或者其它对象映射到进程的地址空间, 实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系。实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,即完成了对文件的操作而不必再调用read,write等系统调用函数。相反,内核空间对这段区域的修改也直接反映用户空间,从而可以实现不同进程间的文件共享


总结

✨✨✨各位读友,本篇分享到内容是否更好的让你理解进程间通信,如果对你有帮助给个👍赞鼓励一下吧!!
🎉🎉🎉世上没有绝望的处境,只有对处境绝望的人。
感谢每一位一起走到这的伙伴,我们可以一起交流进步!!!一起加油吧!!

相关推荐
sinat_384241091 小时前
使用 npm 安装 Electron 作为开发依赖
服务器
机器视觉知识推荐、就业指导1 小时前
C++设计模式:建造者模式(Builder) 房屋建造案例
c++
朝九晚五ฺ2 小时前
【Linux探索学习】第十四弹——进程优先级:深入理解操作系统中的进程优先级
linux·运维·学习
自由的dream2 小时前
Linux的桌面
linux
xiaozhiwise2 小时前
Makefile 之 自动化变量
linux
Kkooe3 小时前
GitLab|数据迁移
运维·服务器·git
久醉不在酒3 小时前
MySQL数据库运维及集群搭建
运维·数据库·mysql
Yang.993 小时前
基于Windows系统用C++做一个点名工具
c++·windows·sql·visual studio code·sqlite3
熬夜学编程的小王3 小时前
【初阶数据结构篇】双向链表的实现(赋源码)
数据结构·c++·链表·双向链表