当前位置: 首页 > news >正文

第十一部分:进程通信

目录

1、进程间通信介绍

2、管道

2.1、匿名管道

2.1.1、原理

2.1.2、接口

2.1.3、应用

2.2、命名管道

2.2.1、原理

2.2.2、接口

2.2.3、应用

3、共享内存

3.1、原理

3.2、接口

3.3、应用

4、消息队列

4.1、原理

4.2、接口

5、信号量

5.1、原理

5.2、接口

6、System V 内核数据结构


1、进程间通信介绍

两个或多个进程实现数据层面的交互就叫做进程间通信。因为进程独立性的的存在,导致进程通信的成本是比较高的。进程间通信的本质是让不同的进程看到同一份“资源”,“资源”是指特定形式的内存空间。这个“资源”一般是由操作系统提供的,进程访问这个空间,进行通信,本质就是在访问操作系统,也就是在调用系统调用接口。一般操作系统会有一个独立的通信模块,这个模块是隶属于文件系统的,这个模块在系统中被称为IPC通信模块

进程间通信的目的:

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

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

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

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

进程间的通信是有标准的,有两套标准,分别为System V IPC(用于本机内部通信)还有就是posix IPC(用于网络通信和本机内部通信)。

System V IPC延申出来的通信方式有三种,分别是System V消息队列System V共享内存System V 信号量

posix IPC延申出来的通信方式有六种,分别是消息队列共享内存信号量互斥量条件变量读写锁

还有一种基于文件的通信方式,叫做管道。管道通信不属于System V也不属于posix。

下面主要讲解管道(包括匿名管道和命名管道)System V共享内存,以及消息队列和信号量的简单介绍;关于posix主要到网络再讲。

2、管道

管道是Unix中最古老的进程间通信的形式。 我们把从一个进程连接到另一个进程的一个数据流称为一个“管道”。

我们之前是在命令行中使用过管道的,例如:

who | wc -l

其中|就是管道,这个管道是匿名管道。(其中who命令是用来显示当前登录到系统的用户信息,wc命令是用于统计文本文件内容的,-l 选项表示统计行数)。

提到管道通信,有些人可能会想到我们可以使用普通的文件进行进程间通信,但是使用普通文件进行通信效率比较低,因为普通文件的内容和属性还需要刷新到磁盘上,但文件的属性和内容是否刷新到磁盘对于进程间通信是没有帮助的。除了上述说的一些问题,可能还会存在一些其他的问题。

2.1、匿名管道

2.1.1、原理

匿名管道并不在磁盘中真正的存在,该文件没有文件系统层次上的inode,有file_operators和缓冲区,但是不会向磁盘写入或读取,仅仅在内存中存在,因此管道文件是一种内存级文件。管道也是文件,只是不是我们理解的磁盘文件。匿名管道的是利用了进程之间的“血缘关系”来实现的。如下图:

其中file_operators是指操作方法的集合。

当进程创建子进程的时候,父子进程所能看到的文件是一样的,因此父子进程也就看到了同一份资源。

但是上图有问题的是当父进程是以读方式打开的时候,子进程继承父进程的文件也是采用读的方式打开的;当父进程以写的方式打开,那么子进程也是以写的方式进行打开的;因此这样还是没办法进行通信的。

有人可能会想到采用读写的方式打开一个文件,但是这样仍然会存在问题,因为读写会互相干扰。因此采用只读和只写的方式把文件打开两次。

为了避免一些问题的出现,子进程和父进程要分别关掉一个文件,从而实现单向的通信(比如父进程关掉以读方式打开的文件,那么子进程就要关掉以写方式打开的文件),这样读和写是互不干扰的。因此管道通信是单向通信的,这就是管道通信的原理,简单来讲的话,管道通信的原理如下图:

1、父进程创建管道。

2、父进程创建出子进程。

3、假设此时父进程写,子进程读。父进程关闭fd[0],子进程关闭fd[1]。

如果是子进程写父进程读,则和上图相反。 

因此就建立了一个单向的通信信道,如果想要进行双向通信,可以创建多个匿名管道来实现

注意:管道通信需要进程与进程之间是有“血缘”关系的,管道通信常用于父子关系之间的通信。理论上一台电脑的所有进程都是有“血缘关系”的,但是没有在fork之前创建匿名管道,是无法使用匿名管道进行通信的。

2.1.2、接口

使用系统调用pipe来创建匿名管道。如下:

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

 其中,pipefd是一个数组,是输出型参数,该数组有两个元素pipefd[0]和pipefd[1],其中pipefd[0]是读下标,pipefd[1]是写下标。返回值:成功返回0,失败返回-1。

匿名管道的特点

1、具有血缘关系的进程才能用匿名管道通信。通常,一个管道先由一个进程创建,然后该进程调用fork,此后父、子进程之间就可应用该管道。

2、匿名管道只能单向通信。需要双方通信时,需要建立起两个管道。例如:

3、父子进程是会进程协同的,也就是同步与互斥,目的是为了保护匿名管道的数据安全(关于同步与互斥到线程和网络再说)。主要有如下四种情况:

· 读写端正常,管道如果为空,读端就要阻塞。

例如:

#include<iostream>
#include<cstdlib>
#include<string>
#include<cstring>
using namespace std;#include<unistd.h>
#include<sys/wait.h>
#include<sys/types.h>
#include<sys/stat.h>#define N 2
#define NUM 1024//每隔一秒发送一个消息
void Writer(int wfd)
{string s = "helle i am child";pid_t self = getpid();int number = 0;char buffer[NUM]; // 缓冲区while(true){sleep(1);buffer[0] = 0;//把这个数组当成字符串了。//因为是C语言的函数,因此默认是会在末尾加\0的。snprintf(buffer, sizeof(buffer), "%s-%d-%d", s.c_str(), self, number++);write(wfd, buffer, strlen(buffer));if(number == 5) break;}
}//读取消息
void Reader(int rfd)
{char buffer[NUM];while(true){buffer[0] = 0;ssize_t n = read(rfd, buffer, sizeof(buffer));if(n > 0){buffer[n] = 0;cout << "father get a message[" << getpid() << "]# "<< buffer << endl;}cout << "n: " << n << endl;if(n == 0) break;}
}int main()
{int pipefd[N] = {0};int n = pipe(pipefd);if(n == -1) return 1;pid_t id = fork();if(id < 0) return 2;if(id == 0){//child:写close(pipefd[0]);Writer(pipefd[1]);close(pipefd[1]);exit(0);}//parent:读close(pipefd[1]);Reader(pipefd[0]);close(pipefd[0]);cout << "father close read fd: " << pipefd[0] << endl; sleep(5);//等待int status = 0;pid_t rid = waitpid(id, &status, 0);if(rid < 0) return 3;cout << "wait child sucess: " << rid << " exit code: " << ((status >> 8) & 0xff) << " exit signal: " << (status & 0x7f) << endl;return 0;
}

从运行结果便可以看出,刚开始管道为空,读端被阻塞住,直到管道中有了内容,读端才能读取内容。 

· 读写端正常,管道如果被写满,写端就要阻塞。(匿名管道也是有固定的大小的,在Linux内核版本2.6.11之前匿名管道的大小是4KB,Linux内核版本2.6.11之后,管道的大小是64KB。在我的这  台机器上是64KB的大小。)

例如:

#include<iostream>
#include<cstdlib>
#include<string>
#include<cstring>
using namespace std;#include<unistd.h>
#include<sys/wait.h>
#include<sys/types.h>
#include<sys/stat.h>#define N 2
#define NUM 1024void Writer(int wfd)
{string s = "helle i am child";pid_t self = getpid();int number = 0;char buffer[NUM]; // 缓冲区while(true){buffer[0] = 0;//把这个数组当成字符串了。//因为是C语言的函数,因此默认是会在末尾加\0的。snprintf(buffer, sizeof(buffer), "%s-%d-%d", s.c_str(), self, number++);write(wfd, buffer, strlen(buffer));}
}//读取消息
void Reader(int rfd)
{char buffer[NUM];int cnt = 0;while(true){sleep(1);buffer[0] = 0;ssize_t n = read(rfd, buffer, sizeof(buffer));if(n > 0){buffer[n] = 0;cout << "father get a message[" << getpid() << "]# "<< buffer << endl;}cout << "n: " << n << endl;if(n == 0) break;}
}int main()
{int pipefd[N] = {0};int n = pipe(pipefd);if(n == -1) return 1;pid_t id = fork();if(id < 0) return 2;if(id == 0){//child:写close(pipefd[0]);Writer(pipefd[1]);close(pipefd[1]);exit(0);}//parent:读close(pipefd[1]);Reader(pipefd[0]);close(pipefd[0]);cout << "father close read fd: " << pipefd[0] << endl; sleep(5);//等待int status = 0;pid_t rid = waitpid(id, &status, 0);if(rid < 0) return 3;cout << "wait child sucess: " << rid << " exit code: " << ((status >> 8) & 0xff) << " exit signal: " << (status & 0x7f) << endl;return 0;
}

 从运行结果便可以看出,当管道写满时,写端就会被阻塞住。

· 读端正常读,写端关闭,读端啥也读不到,读端不会被阻塞。

例如:

#include<iostream>
#include<cstdlib>
#include<string>
#include<cstring>
using namespace std;#include<unistd.h>
#include<sys/wait.h>
#include<sys/types.h>
#include<sys/stat.h>#define N 2
#define NUM 1024//每隔一秒发送一个消息
void Writer(int wfd)
{string s = "helle i am child";pid_t self = getpid();int number = 0;char buffer[NUM]; // 缓冲区while(true){sleep(1);buffer[0] = 0;//把这个数组当成字符串了。//因为是C语言的函数,因此默认是会在末尾加\0的。snprintf(buffer, sizeof(buffer), "%s-%d-%d", s.c_str(), self, number++);write(wfd, buffer, strlen(buffer));if(number == 5) break;}
}//读取消息
void Reader(int rfd)
{char buffer[NUM];while(true){buffer[0] = 0;ssize_t n = read(rfd, buffer, sizeof(buffer));if(n > 0){buffer[n] = 0;cout << "father get a message[" << getpid() << "]# "<< buffer << endl;}cout << "n: " << n << endl;}
}int main()
{int pipefd[N] = {0};int n = pipe(pipefd);if(n == -1) return 1;pid_t id = fork();if(id < 0) return 2;if(id == 0){//child:写close(pipefd[0]);Writer(pipefd[1]);close(pipefd[1]);exit(0);}//parent:读close(pipefd[1]);Reader(pipefd[0]);close(pipefd[0]);cout << "father close read fd: " << pipefd[0] << endl; sleep(5);//等待int status = 0;pid_t rid = waitpid(id, &status, 0);if(rid < 0) return 3;cout << "wait child sucess: " << rid << " exit code: " << ((status >> 8) & 0xff) << " exit signal: " << (status & 0x7f) << endl;return 0;
}

最终的运行结果为:

· 写端正常,读端关闭,操作系统通过信号杀掉正在写入的进程,使用13号信号杀掉写入的进程。

例如:

#include<iostream>
#include<cstdlib>
#include<string>
#include<cstring>
using namespace std;#include<unistd.h>
#include<sys/wait.h>
#include<sys/types.h>
#include<sys/stat.h>#define N 2
#define NUM 1024//每隔一秒发送一个消息
void Writer(int wfd)
{string s = "helle i am child";pid_t self = getpid();int number = 0;char buffer[NUM]; // 缓冲区while(true){sleep(1);buffer[0] = 0;//把这个数组当成字符串了。//因为是C语言的函数,因此默认是会在末尾加\0的。snprintf(buffer, sizeof(buffer), "%s-%d-%d", s.c_str(), self, number++);write(wfd, buffer, strlen(buffer));}
}//读取消息
void Reader(int rfd)
{char buffer[NUM];int cnt = 0;while(true){buffer[0] = 0;ssize_t n = read(rfd, buffer, sizeof(buffer));if(n > 0){buffer[n] = 0;cout << "father get a message[" << getpid() << "]# "<< buffer << endl;}cout << "n: " << n << endl;if(n == 0) break;cnt++;if(cnt == 5) break;}
}int main()
{int pipefd[N] = {0};int n = pipe(pipefd);if(n == -1) return 1;pid_t id = fork();if(id < 0) return 2;if(id == 0){//child:写close(pipefd[0]);Writer(pipefd[1]);close(pipefd[1]);exit(0);}//parent:读close(pipefd[1]);Reader(pipefd[0]);close(pipefd[0]);cout << "father close read fd: " << pipefd[0] << endl; sleep(5);//等待int status = 0;pid_t rid = waitpid(id, &status, 0);if(rid < 0) return 3;cout << "wait child sucess: " << rid << " exit code: " << ((status >> 8) & 0xff) << " exit signal: " << (status & 0x7f) << endl;return 0;
}

运行结果为:

4、管道文件是面向字节流的,简单来说就是一次能读多少就读多少(后面的文章再说)。

5、管道是基于文件的,而文件的生命周期是随进程的。进程退出了,管道也会被关闭。

注:管道内的数据被读取后管道就为空了(前提是可以读取完,否则接着没读完的内容读)。

匿名管道中有一个PIPE_BUF,大小为4KB,当写的单位小于等于4KB时,该操作是原子性的,也就是说在此写操作未完成之前,其他进程不能进行写入,这是为了避免数据的写入混乱。

2.1.3、应用

实现一个进程池,匿名管道一旦建立好,就可以像对待文件那样进行操作。

ProcessPool.cc

#include "Task.hpp"#include <string>
#include <vector>
#include <cstdlib>
#include <cassert>
#include <ctime>#include <unistd.h>
#include <sys/wait.h>
#include <sys/stat.h>const int processnum = 10; // 创建的子进程的数量std::vector<task_t> tasks; // 任务列表class channel
{
public:channel(int cmdfd, int slaverid, const std::string& processname):_cmdfd(cmdfd), _slaverid(slaverid), _processname(processname){}
public:int _cmdfd; // 向子进程发送任务的文件描述符pid_t _slaverid; // 子进程的pidstd::string _processname; // 子进程的名字 -- 方便打印日志
};void slaver() // 读取父进程的任务指令,并进行执行。
{while(true){int cmdcode = 0;int n = read(0, &cmdcode, sizeof(int)); // read返回值返回的是读了多少个字节if(n == sizeof(int)) // 当读到4个字节时{std::cout << "slaver say@ get a command: " << getpid() << " : cmdcode : " << cmdcode << std::endl;if(cmdcode >= 0 && cmdcode < tasks.size()) tasks[cmdcode]();}if(n == 0) break; // 什么也没有读到}
}void InitProcessPool(std::vector<channel>* channels)
{std::vector<int> oldfds; // 因为从第二个创建的子进程开始,每一个子进程都有前面子进程的写端。写这个的目的是记录这些写端,后面进行关闭。for(int i = 0; i < processnum; i++){int pipefd[2];int n = pipe(pipefd); // 创建管道assert(!n);(void)n;pid_t id = fork();if(id == 0) // 子进程{std::cout << "child: " << getpid() << " close history fd: ";for(auto fd : oldfds){std::cout << fd << " ";close(fd);}std::cout << '\n';close(pipefd[1]);dup2(pipefd[0], 0);close(pipefd[0]); // 其实不关闭也行slaver();std::cout << "process : " << getpid() << " quit " << std::endl;exit(0);}// 父进程close(pipefd[0]);std::string name = "process-" + std::to_string(i);channels -> push_back(channel(pipefd[1], id, name));oldfds.push_back(pipefd[1]);sleep(1); // 每隔一秒创建一个子进程}
}void Debug(std::vector<channel>& channels) // 为了便于调试写的,作用是把channels中的内容给打印出来。
{for(auto& c : channels){std::cout << c._cmdfd << " " << c._slaverid << " " << c._processname << std::endl;}
}void Menu() // 菜单
{std::cout << "#################################" << std::endl;std::cout << "#    1.刷新日志     2.刷新野怪   #" << std::endl;std::cout << "#    3.更新软件     4.更新血量   #" << std::endl;std::cout << "#    0.退出                     #" << std::endl;std::cout << "#################################" << std::endl;}void ctrlSlaver(const std::vector<channel>& channels)
{int which = 0; // 要执行任务的进程while(true){int select = 0;Menu(); std::cout << "Please enter@ ";std::cin >> select; // 输入要执行的任务if(select <= 0 || select >= 5) break;int cmdcode = select - 1; // 要执行任务的子进程std::cout << "father say: " << "cmdcode: " << cmdcode << " send to " << channels[which]._slaverid << " process name: " << channels[which]._processname << std::endl;write(channels[which]._cmdfd, &cmdcode, sizeof(cmdcode));which++;which %= channels.size();sleep(1); // 每执行一次任务,等待1秒,执行下一个任务。}
}void QuitProcess(const std::vector<channel>& channels)
{for(const auto& c : channels){close(c._cmdfd); // 父进程的写端关闭,导致子进程啥也读不到,进程退出waitpid(c._slaverid, nullptr, 0); // 回收子进程资源}
}int main()
{LoadTask(&tasks); // 加载任务// srand(time(nullptr) ^ getpid() ^ 1023); // 这里的^只是为了让这个时间变的更加离散,我们也可以使用随机数选择执行任务的进程。std::vector<channel> channels;InitProcessPool(&channels); // 创建进程,建立通信管道Debug(channels);ctrlSlaver(channels); // 控制进程执行任务QuitProcess(channels); // 进程退出,资源回收sleep(5);return 0;
}

Task.hpp

#pragma once#include <iostream>
#include <vector>typedef void (*task_t)();void task1()
{std::cout << "lol 刷新日志" << std::endl;
}void task2()
{std::cout << "更新野区" <<std::endl;
}void task3()
{std::cout << "lol检测软件是否更新,如果需要就提示用户" << std::endl;
}void task4()
{std::cout << "lol 释放技能,更新用户的血量和蓝量" << std::endl;
}void LoadTask(std::vector<task_t>* tasks) // 加载任务
{tasks->push_back(task1);tasks->push_back(task2);tasks->push_back(task3);tasks->push_back(task4);
}

2.2、命名管道

2.2.1、原理

匿名管道的应用的一个限制就是具有“亲缘关系”的进程间通信。 如果我们想在不相关的进程之间交换数据,可以使用命名管道来做这项工作。

命名管道是一种特殊类型的文件,该文件是有文件系统层次上的inode的,也有file_operators和缓冲区。命名管道是通过使用路径+文件名的方式,让不同的进程看到同一份文件资源,进而实现进程间通信的。

命名管道可以从命令行上创建,使用mkfifo命令创建,例如:创建一个test命名管道

mkfifo test

结果为:

创建的命名管道看起来像是磁盘文件,但实际上这个文件的数据并不会刷新到磁盘上,也是内存级文件。命令管道的名字就是一个符号。

例如:

echo "hello world" > test

我们便会看到进程阻塞了,如下:

 在另一个命令行界面中,输入:

cat < test

便会看到 

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

匿名管道由pipe函数创建并打开。 命名管道由mkfifo函数创建,打开用open 。命名管道和匿名管道之间唯一的区别在它们创建与打开的方式不同,一但这些工作完成之后,它们具有相同的语义。

命名管道的特点和匿名管道是一样的。命名管道被创建好后,就可以像操作文件那样操作命名管道。

注:当在只读模式下打开命名管道时,它会阻塞直到有进程以写入的方式打开管道;当以只写的方式打开管道时,它会阻塞直到有进程以读的方式打开。

2.2.2、接口

使用系统调用mkfifo创建命名管道,例如:

#include <sys/types.h>
#include <sys/stat.h>int mkfifo(const char *pathname, mode_t mode);

其中第一个参数是命名管道的路径,第二个参数是指文件的权限。成功返回0,失败返回-1。

另外再介绍一个接口,如下:

#include <unistd.h>int unlink(const char *pathname);

这个函数是用来删除一个文件的,其中参数是要被删除的文件的路径。成功返回0,失败返回-1。 

2.2.3、应用

实现client和sever通信,一旦命名管道创建完毕,就可以像操作文件那样,操作命名管道。

comm.hpp

#pragma once
#include <iostream>
#include <cerrno>
#include <cstring>
#include <cstdlib>
#include <string>#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>#define FIFO_FILE "./myfifo" // 文件路径
#define MODE 0664 // 权限enum { // 枚举是可以这样写的,学一下FIFO_CREATE_ERR = 1,FIFO_DELETE_ERR,FIFO_OPEN_ERR
};class Init
{
public:Init(){// 创建信道int n = mkfifo(FIFO_FILE, MODE);if(n == -1){// printf("%d: %s\n", errno, strerror(errno));perror("mkfifo");exit(FIFO_CREATE_ERR);}}~Init(){// 删除信道int m = unlink(FIFO_FILE); // 这个unlink不光可以删管道文件,还可以删其他的文件。if(m == -1){perror("unlink");exit(FIFO_DELETE_ERR);}}
};

server.cc

#include "comm.hpp"
#include "log.hpp"using namespace std;
// 服务端
int main()
{Init init;Log log;log.Enable(Onefile);// log.Enable(Classfile);// 打开信道int fd = open(FIFO_FILE, O_RDONLY); // 等待写入方打开管道,读端才能打开文件,向后执行,open阻塞了。if(fd < 0){log(Fatal, "error string: %s, error: %d", strerror(errno), errno);exit(FIFO_OPEN_ERR);}log(Info, "server open file done, error string: %s, error: %d", strerror(errno), errno);// 读信道while(true){char buffer[1024] = {0};int x = read(fd, buffer, sizeof(buffer));if(x > 0){buffer[x] = 0;cout << "client say# " << buffer << endl;}else if(x == 0){log(Debug, "client quit, me too, error string: %s, error: %d", strerror(errno), errno);break;}else break;}close(fd);return 0;
}

client.cc

#include "comm.hpp"
#include <iostream>
using namespace std;//客户端
int main()
{int fd = open(FIFO_FILE, O_WRONLY);if(fd < 0){perror("open");exit(FIFO_OPEN_ERR);}cout << "client open file done" << endl;// 写string line;while(true){cout << "Please Enter@ ";// cin >> line; // 使用这个会有格式问题getline(cin, line);write(fd, line.c_str(), line.size());}close(fd);return 0;
}

log.hpp

#pragma once#include <iostream>
#include <stdarg.h> // 使用可变参数列表需要用到这个头文件
#include <ctime>
#include <cstdlib>#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>#define Info 0 // 常规消息
#define Debug 1 // 调试信息
#define Warning 2 // 报警消息
#define Error 3 // 错误信息
#define Fatal 4 // 致命的错误#define SIZE 1024#define Screen 1 // 显示屏
#define Onefile 2 // 单个文件
#define Classfile 3 // 分类打印#define LogFile "log.txt" // 日志的文件名class Log
{
public:Log(){printMethod = Screen; // 默认为显示屏幕打印path = "./log/"; // 日志打印的路径}void Enable(int method) // 更换日志的打印方式{printMethod = method;}std::string levelToString(int level) // 返回日志等级的字符串{switch (level){case Info:return "Info";case Debug:return "Debug";case Warning:return "Warning";case Error:return "Error";case Fatal:return "Fatal";default:return "None";}}void printLog(int level, const std::string &logtxt) // 日志打印{switch (printMethod){case Screen:std::cout << logtxt << std::endl;break;case Onefile:printOneFile(LogFile, logtxt);break;case Classfile:printClassFile(level, logtxt);break;default:break;}}void printOneFile(const std::string &logname, const std::string &logtxt) // 打印到一个文件中{std::string _logname = path + logname;int fd = open(_logname.c_str(), O_WRONLY | O_CREAT | O_APPEND, 0666);if (fd < 0){return;}write(fd, logtxt.c_str(), logtxt.size());close(fd);}void printClassFile(int level, const std::string &logtxt) // 分类打印到对应的文件{std::string filename = LogFile;filename += '.';filename += levelToString(level);printOneFile(filename, logtxt);}~Log(){}void operator()(int level, const char *format, ...){char leftbuffer[1024]; // 一条日志左边的格式信息,包括日志等级和时间。time_t t = time(nullptr); // 返回值是时间戳struct tm *ctime = localtime(&t); // 该函数可以将时间戳转换成一个struct tm 结构。// 下面的\是续行符,加不加都行。snprintf(leftbuffer, sizeof(leftbuffer), "[%s][%d-%d-%d %d:%d:%d]", levelToString(level).c_str(),\ctime->tm_year + 1900, ctime->tm_mon + 1, ctime->tm_mday,\ctime->tm_hour, ctime->tm_min, ctime->tm_sec);va_list s;va_start(s, format);char rightbuffer[SIZE]; // 一条日志右边日志内容vsnprintf(rightbuffer, sizeof(rightbuffer), format, s);va_end(s);char logtxt[SIZE * 2]; // 合成一条日志信息snprintf(logtxt, sizeof(logtxt), "%s %s\n", leftbuffer, rightbuffer);// printf("%s", logtxt);printLog(level, logtxt); // 打印}private:int printMethod; // 日志打印的方式std::string path; // 日志打印路径
};

可变参数列表

可变参数列表的使用需要包含stdarg.h头文件。

例如:实现一个函数可以用来求n个数的和

// sum函数的第一个参数是要对多少个数求和,后面的...是具体要求和的数字
int sum(int n, ...) // 可变参数列表至少要有一个具体的参数(比如sum中的int n)。
{va_list s;      // va_list本质上是char*类型,也是宏。va_start(s, n); // 该宏的作用类似于s = &n + 1(指针向后移动n自身的大小,从而指向可变参数列表),本质上是想要s指向可变参数列表。int sum = 0;while (n){sum += va_arg(s, int); // va_arg宏相当于根据int类型依次解析...。n--;}va_end(s); // 也就是把s变量置空为NULL。return sum;
}

日志:日志主要用来记录系统或应用程序的运行信息,包括错误、异常、关键事件、状态变化等。通过分析日志,可以发现潜在问题、验证程序是否按预期工作。日志包括如下内容

1、日志时间,录每条日志的具体时间,便于追踪事件发生的顺序。

2、日志等级,用于区分日志的重要程度。以下是一些常见的日志等级的划分:

Info:常规信息

Warning:报警信息(不影响代码向后执行,但未来可能会引发一些问题)

Error:比较严重,可能需要立即处理。

Fatal:致命的问题。

Debug:调试的信息(一般不需要,调试的时候需要)。

3、日志内容,简洁明了地描述发生了什么。

4、其他信息,根据具体需要添加信息。

3、共享内存

3.1、原理

共享内存是最快的IPC形式。一旦这样的内存映射到共享区后,这些进程间数据传递不再涉及到内核,换句话说是进程不再通过执行进入内核的系统调用来传递彼此的数据。

如果要释放共享内存,也就是先断开物理内存和地址空间的映射,然后再释放对应的物理内存。

上面的操作都是由操作系统来做的,操作系统要管理所有的共享内存(系统中可能会有很多共享内存),是通过先描述再组织的方式进行管理的。 

我们可以使用下面的命令查看系统中的共享内存资源:

ipcs -m

 注:共享内存的生命周期是随内核的,用户一旦成功创建共享内存,如果后来不主动关闭,共享内存会一直存在。(除非内核重启,或用户释放)。

结果如下:因为没创建共享内存,所以这里一个共享内存也没有。

其中,perms指的是权限。bytes指的是共享内存的大小,单位为字节。 nattch是指有多少个进程和当前的共享内存是关联的。

可以使用下面的命令删除共享内存:

ipcrm -m shmid

3.2、接口

使用shmget函数来创建共享内存:

#include <sys/ipc.h>
#include <sys/shm.h>int shmget(key_t key, size_t size, int shmflg);

key:这个共享内存段名字,key是一个数字,这个数字是几并不重要,关键在于它在内核中必须具有唯一性,能够让不同的进程进行唯一性的标识。第一个进程可以通过key创建共享内存,之后的进程只要拿着同一个key,就可以和第一个进程看到同一个共享内存了。对于一个已经创建好的共享内存,key就在共享内存的描述对象中。关于这个key我们一般用ftok函数生成。key_t本质是int类型。

size:共享内存大小,单位是字节。

shmflg:由权限(与创建文件时的mode类似)和一些其他的选项构成(例如下面的两个选项)。

IPC_CREAT:如果申请的共享内存不存在,则创建;如果存在,则获取并返回。

IPC_CREAT | IPC_EXCL:如果申请的共享内存不存在,就创建;如果存在,则出错返回。(这两个选项一使用,可以确保我们申请的共享内存是新的)。注:IPC_EXCL:是不能单独使用的。

返回值:成功返回一个非负整数,即该共享内存段的标识码;失败返回-1

使用ftok函数生成一个key:

#include <sys/types.h>
#include <sys/ipc.h>key_t ftok(const char *pathname, int proj_id);

其中,pathname是一个路径,proj_id是一个整数。返回值是该函数利用pathname和proj_id这两个参数根据某种算法生成一个 key_t 类型的整数。

注:这个函数仅仅只是一个算法。并不会跑到操作系统中去查一下哪一个key没被占用,然后申请一个。

使用shmat函数将共享内存段连接到进程地址空间:

#include <sys/types.h>
#include <sys/shm.h>void *shmat(int shmid, const void *shmaddr, int shmflg);

shmid:是shmget函数的返回值。注:key和shmid是不同的,key是用来在操作系统内标定唯一性的,shmid只在当前进程内表示资源的唯一性。

shmaddr:为NULL,内核自动选择一个地址。

shmflg:一般填0。

返回值:成功返回一个指针,指向共享内存的起始地址;失败返回-1。 

使用shmdt函数将共享内存段与当前进程脱离:

#include <sys/types.h>
#include <sys/shm.h>
int shmdt(const void *shmaddr);

shmaddr:由shmat所返回的指针。

返回值:成功返回0;失败返回-1。

注意:将共享内存段与当前进程脱离不等于删除共享内存段。

使用shmctl函数控制共享内存:

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

shmid:由shmget返回的共享内存标识码。

cmd:将要采取的动作,可以为IPC_STAT:用于获取当前共享内存的属性值(用该动作时,我们需要使用shmid_ds类型创建一个变量传进第三个形参中)。IPC_RMID:删除共享内存(用该动作时,第三个参数一般填NULL)。

buf:是如下的数据结构。

返回值:成功返回0;失败返回-1。 

3.3、应用

comm.hpp:

#ifndef __COMM_HPP__
#define __COMM_HPP__#include <iostream>
#include <string>#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/types.h>#include <cstring>
#include <cstdlib>#include "log.hpp"using namespace std;const string pathname = "/home/wang";
const int proj_id = 0x6666;// 共享内存的大小一般建议为4096的整数倍。
// 如果设置的是4097,实际上操作系统给的空间大小是4096*2。但是多出来的4095个字节的空间是不允许用的。
const int size = 4096;Log log;key_t GetKey() // 获取key
{key_t k = ftok(pathname.c_str(), proj_id);if(k < 0){log(Fatal, "ftok error: %s", strerror(errno));exit(1);}log(Info, "ftok success, key is : 0x%x", k);return k;
}int GetShareMemHelper(int flag) 
{key_t k = GetKey();int shmid = shmget(k, size, flag); // 权限的设定也是在这个位置if(shmid < 0){log(Fatal, "create share memory error: %s", strerror(errno));exit(2);}log(Info, "create share memory success, shmid: %d", shmid); return shmid;
}int CreateShm() // 创建共享内存
{return GetShareMemHelper(IPC_CREAT|IPC_EXCL|0666);
}int GetShm() // 获取共享内存
{return GetShareMemHelper(IPC_CREAT);
}#endif

processa.cc:

#include "comm.hpp"// 读
int main()
{// 一旦有了共享内存,挂接到自己的进程地址空间中,直接当成内存空间来使用即可。不需要使用系统调用。int shmid = CreateShm(); // 创建char* shmaddr = (char*)shmat(shmid, nullptr, 0); // 关联// ipc codestruct shmid_ds shmds; // 创建shmid_ds结构体,内核数据结构的相关信息会拷贝到该结构体中while(true){cout << "client say@ " << shmaddr << endl;// 直接访问共享内存sleep(1);shmctl(shmid, IPC_STAT, &shmds);cout << "shm size: " << shmds.shm_segsz << endl; // 共享内存的大小cout << "shm nattch: " << shmds.shm_nattch << endl; // 共享内存的连接数printf("shm key: 0x%x\n", shmds.shm_perm.__key); // 共享内存的keycout << "shm mode: " << shmds.shm_perm.mode << endl;}shmdt(shmaddr); // 解关联shmctl(shmid, IPC_RMID, nullptr); // 销毁return 0;
}

processb.cc:

#include "comm.hpp"// 写
int main()
{int shmid = GetShm(); // 获取shmidchar* shmaddr = (char*)shmat(shmid, nullptr, 0); // 关联// ipc code// 一旦有人把数据写到共享内存中,我们立马就能看到了。不需要使用系统调用。while(true){cout << "Please Enter@ ";fgets(shmaddr, size, stdin);}shmdt(shmaddr); // 解关联return 0;
}

log.hpp:

#pragma once#include <iostream>
#include <stdarg.h> // 使用可变参数列表需要用到这个头文件
#include <ctime>
#include <cstdlib>#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>#define Info 0
#define Debug 1
#define Warning 2
#define Error 3
#define Fatal 4#define SIZE 1024#define Screen 1
#define Onefile 2
#define Classfile 3#define LogFile "log.txt"class Log
{
public:Log(){printMethod = Screen; // 默认为屏幕打印path = "./log/";}void Enable(int method) // 更换日志的打印方式{printMethod = method;}std::string levelToString(int level) // 返回日志等级的字符串{switch (level){case Info:return "Info";case Debug:return "Debug";case Warning:return "Warning";case Error:return "Error";case Fatal:return "Fatal";default:return "None";}}void printLog(int level, const std::string &logtxt) // 日志打印{switch (printMethod){case Screen:std::cout << logtxt << std::endl;break;case Onefile:printOneFile(LogFile, logtxt);break;case Classfile:printClassFile(level, logtxt);break;default:break;}}void printOneFile(const std::string &logname, const std::string &logtxt) // 打印到一个文件中{std::string _logname = path + logname;int fd = open(_logname.c_str(), O_WRONLY | O_CREAT | O_APPEND, 0666);if (fd < 0){return;}write(fd, logtxt.c_str(), logtxt.size());close(fd);}void printClassFile(int level, const std::string &logtxt) // 分类打印到对应的文件{std::string filename = LogFile;filename += '.';filename += levelToString(level);printOneFile(filename, logtxt);}~Log(){}void operator()(int level, const char *format, ...){char leftbuffer[1024]; // 一条日志左边的格式信息,包括日志等级和时间。time_t t = time(nullptr); // 返回值是时间戳struct tm *ctime = localtime(&t); // 该函数可以将时间戳转换成一个struct tm 结构。// 下面的\是续行符,加不加都行。snprintf(leftbuffer, sizeof(leftbuffer), "[%s][%d-%d-%d %d:%d:%d]", levelToString(level).c_str(),\ctime->tm_year + 1900, ctime->tm_mon + 1, ctime->tm_mday,\ctime->tm_hour, ctime->tm_min, ctime->tm_sec);va_list s;va_start(s, format);char rightbuffer[SIZE]; // 一条日志右边日志内容vsnprintf(rightbuffer, sizeof(rightbuffer), format, s);va_end(s);char logtxt[SIZE * 2]; // 合成一条日志信息snprintf(logtxt, sizeof(logtxt), "%s %s\n", leftbuffer, rightbuffer);// printf("%s", logtxt);printLog(level, logtxt); // 打印}private:int printMethod; // 日志打印的方式std::string path; // 日志打印路径
};

共享内存的特点:

1、共享内存没有同步与互斥保护机制。(我们可以通过管道实现共享内存同步互斥之类的保护机制)。

例如:

comm.hpp:

#ifndef __COMM_HPP__
#define __COMM_HPP__#include <iostream>
#include <string>#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/types.h>
#include <sys/stat.h>#include <cstring>
#include <cstdlib>#include "log.hpp"using namespace std;const string pathname = "/home/wang";
const int proj_id = 0x6666;// 共享内存的大小一般建议为4096的整数倍。
// 如果设置的是4097,实际上操作系统给的空间大小是4096*2。但是多出来的4095个字节的空间是不允许用的。
const int size = 4096;Log log;key_t GetKey()
{key_t k = ftok(pathname.c_str(), proj_id);if(k < 0){log(Fatal, "ftok error: %s", strerror(errno));exit(1);}log(Info, "ftok success, key is : 0x%x", k);return k;
}int GetShareMemHelper(int flag)
{key_t k = GetKey();int shmid = shmget(k, size, flag); // 权限的设定也是在这个位置if(shmid < 0){log(Fatal, "create share memory error: %s", strerror(errno));exit(2);}log(Info, "create share memory success, shmid: %d", shmid); return shmid;
}int CreateShm()
{return GetShareMemHelper(IPC_CREAT|IPC_EXCL|0666);
}int GetShm()
{return GetShareMemHelper(IPC_CREAT);
}#define FIFO_FILE "./myfifo" // 文件路径
#define MODE 0664 // 权限enum { // 记住这个用法FIFO_CREATE_ERR = 1,FIFO_DELETE_ERR,FIFO_OPEN_ERR
};class Init
{
public:Init(){// 创建信道int n = mkfifo(FIFO_FILE, MODE);if(n == -1){// printf("%d: %s\n", errno, strerror(errno));perror("mkfifo");exit(FIFO_CREATE_ERR);}}~Init(){// 删除信道int m = unlink(FIFO_FILE); // 这个unlink不光可以删管道文件,还可以删其他的文件。if(m == -1){perror("unlink");exit(FIFO_DELETE_ERR);}}
};#endif

log.hpp:

#pragma once#include <iostream>
#include <stdarg.h> // 使用可变参数列表需要用到这个头文件
#include <ctime>
#include <cstdlib>#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>#define Info 0
#define Debug 1
#define Warning 2
#define Error 3
#define Fatal 4#define SIZE 1024#define Screen 1
#define Onefile 2
#define Classfile 3#define LogFile "log.txt"class Log
{
public:Log(){printMethod = Screen; // 默认为屏幕打印path = "./log/";}void Enable(int method) // 更换日志的打印方式{printMethod = method;}std::string levelToString(int level) // 返回日志等级的字符串{switch (level){case Info:return "Info";case Debug:return "Debug";case Warning:return "Warning";case Error:return "Error";case Fatal:return "Fatal";default:return "None";}}void printLog(int level, const std::string &logtxt) // 日志打印{switch (printMethod){case Screen:std::cout << logtxt << std::endl;break;case Onefile:printOneFile(LogFile, logtxt);break;case Classfile:printClassFile(level, logtxt);break;default:break;}}void printOneFile(const std::string &logname, const std::string &logtxt) // 打印到一个文件中{std::string _logname = path + logname;int fd = open(_logname.c_str(), O_WRONLY | O_CREAT | O_APPEND, 0666);if (fd < 0){return;}write(fd, logtxt.c_str(), logtxt.size());close(fd);}void printClassFile(int level, const std::string &logtxt) // 分类打印到对应的文件{std::string filename = LogFile;filename += '.';filename += levelToString(level);printOneFile(filename, logtxt);}~Log(){}void operator()(int level, const char *format, ...){char leftbuffer[1024]; // 一条日志左边的格式信息,包括日志等级和时间。time_t t = time(nullptr); // 返回值是时间戳struct tm *ctime = localtime(&t); // 该函数可以将时间戳转换成一个struct tm 结构。// 下面的\是续行符,加不加都行。snprintf(leftbuffer, sizeof(leftbuffer), "[%s][%d-%d-%d %d:%d:%d]", levelToString(level).c_str(),\ctime->tm_year + 1900, ctime->tm_mon + 1, ctime->tm_mday,\ctime->tm_hour, ctime->tm_min, ctime->tm_sec);va_list s;va_start(s, format);char rightbuffer[SIZE]; // 一条日志右边日志内容vsnprintf(rightbuffer, sizeof(rightbuffer), format, s);va_end(s);char logtxt[SIZE * 2]; // 合成一条日志信息snprintf(logtxt, sizeof(logtxt), "%s %s\n", leftbuffer, rightbuffer);// printf("%s", logtxt);printLog(level, logtxt); // 打印}private:int printMethod; // 日志打印的方式std::string path; // 日志打印路径
};

processa.cc:

#include "comm.hpp"// 读
int main()
{   Init init;// 一旦有了共享内存,挂接到自己的进程地址空间中,直接当成内存空间来使用即可。不需要使用系统调用。int shmid = CreateShm(); // 创建char* shmaddr = (char*)shmat(shmid, nullptr, 0); // 关联// ipc codeint fd = open(FIFO_FILE, O_RDONLY); // 打开命名管道if(fd < 0){log(Fatal, "error string: %s, error code: %d", strerror(errno), errno);exit(FIFO_OPEN_ERR);}struct shmid_ds shmds;while(true){char c;ssize_t s = read(fd, &c, 1); // 接收通知if(s == 0) break;else if(s < 0) break;cout << "client say@ " << shmaddr << endl;// 直接访问共享内存sleep(1);shmctl(shmid, IPC_STAT, &shmds);cout << "shm size: " << shmds.shm_segsz << endl;cout << "shm nattch: " << shmds.shm_nattch << endl;printf("shm key: 0x%x\n", shmds.shm_perm.__key);cout << "shm mode: " << shmds.shm_perm.mode << endl;}shmdt(shmaddr); // 解关联shmctl(shmid, IPC_RMID, nullptr); // 销毁close(fd);return 0;
}

processb.cc:

#include "comm.hpp"// 写
int main()
{int shmid = GetShm(); // 获取shmidchar* shmaddr = (char*)shmat(shmid, nullptr, 0); // 关联int fd = open(FIFO_FILE, O_WRONLY); // 打开命名管道if(fd < 0){log(Fatal, "error string: %s, error code: %d", strerror(errno), errno);exit(FIFO_OPEN_ERR);}// ipc code// 一旦有人把数据写到共享内存中,我们立马就能看到了。不需要使用系统调用。while(true){cout << "Please Enter@ ";fgets(shmaddr, size, stdin);write(fd, "c", 1); // 通知对方}shmdt(shmaddr); // 解关联close(fd);return 0;
}

2、共享内存是所有的进程通信中,速度最快的,原因是拷贝的比较少。

3、共享内存中的数据,由用户自己维护。

4、消息队列

4.1、原理

消息队列提供了一个从一个进程向另外一个进程发送一块数据的方法

每个数据块都被认为是有一个类型,接收者进程接收的数据块可以有不同的类型值。

消息队列也是需要被管理的,是通过先描述,再组织的方式进行管理的。

注:除了管道是单向通信的,其它都是可以双向通信的,只不过为了避免一些可能存在的问题,一般都采用单向通信。

我们可以使用如下命令来查看消息队列:

ipcs -q

因为并没有创建消息队列,所以这里为一个消息队列也没有。 

可以使用如下命令删除消息队列:

ipcrm -q msqid

4.2、接口

使用msgget函数创建一个消息队列:

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

key:来自ftok函数;

msgflg:与shmget函数的shmflg的用法是一样的。

返回值:返回一个消息队列的标识符。

使用msgctl函数控制消息队列:

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

 其中buf是下面的结构:

使用msgsnd函数发送数据块:

#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);

msgp:指向要发送数据块的起始地址。

msgsz:数据块的大小。

msgflg:一般写0即可。

使用msgrcv函数接收数据块:

#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp,int msgflg);

 msgtype:指要读的类型。

 msgflg:一般写0即可。

5、信号量

在操作系统中,所有的IPC资源,都是被整合进操作系统的IPC模块中的。

以共享内存为例,当我们的A进程正在写入,写了一部分就被B进程给拿走了,导致双方发和收的数据不完整,就导致了数据不一致的问题。简单来说就是A与B看到同一份共享资源,如果不加以保护,就会导致数据不一致的问题。

互斥:任何时刻,只允许一个执行流访问共享资源。

临界资源:共享的,在某一时刻只能由一个执行流访问的资源。比如:管道就是临界资源。

临界区:访问临界资源的那部分代码。比如:100行代码,仅有5到10行是在访问临界资源,这5到10行代码就是临界区。

解释一种现象:多进程向显示器打印,显示器上的消息是错乱的,有时还会和命令行混合在一起。原因就是显示器本质上是文件,在多进程的情形中,显示器就是共享资源,多进程向显示器打印没有同步与互斥的保护机制,因此就导致了数据不一致的问题。

5.1、原理

信号量本质上就是一把计数器,用来描述共享资源中资源数量的多少。

1、申请计数器成功,就表示我具有访问资源的权限了。

2、申请了计数器资源,申请成功时并没有访问资源,这是对资源的一种预定机制。

3、计数器可有效的保证进入共享资源的执行流的数量。

4、所以每一个执行流,想访问共享资源中的某一部分的时候,不是直接访问,而是先申请计数器资源。

如果一个计数器的值只能为0或者1,那么该计数器又叫二元信号量,本质就是一个。资源为1的本质就是临界资源,当成一个整体使用与释放。

信号量计数器也是一种共享资源。该信号量计数器并不是安全的,到线程部分再说。

申请信号量,本质是对计数器进行--,称为P操作。释放资源,释放信号量,本质是对计数器进行++,称为V操作。我们把信号量的申请和释放称为PV操作,该操作是原子的,简单来说原子的是指要么不做,要做就要做完,是两态的,没有“正在做”这种概念。

信号量为什么是进程间通信的一种呢?通信不仅仅是通信数据,互相协同也是。要协同,信号量首先要被所有的通信进程看到。因此也是进程间通信的一种。

5.2、接口

使用semget函数申请信号量:

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

nsems:是指要申请几个信号量。

semflg:与shmget函数的shmflg的用法是一样的。

注:多个信号量和信号量是几是不同的概念。

使用semctl函数控制信号量:

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

semnum:是指信号的编号。

cmd:是指进行什么样的操作。

后面的可变参数列表具体填什么取决与cmd要执行什么样的操作。

使用semop函数操作信号量:

#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>int semop(int semid, struct sembuf *sops, unsigned nsops);

sops:指向如下的结构

sem_num:指要对哪一个信号量进行操作,编号从0开始。只有一个信号量,那这个参数就填0。

sem_op:一般为1(表示实现一个V操作)或者-1(表示实现一个P操作)。

6、System V 内核数据结构

如下图所示,当每创建一次共享内存时,在内核中创建对应的数据结构,然后使用数组管理起来;当每一次创建消息队列,也是在内核中创建对应的数据结构,然后再由数组进行管理起来;创建信号量也是如此。

其中这个数组的下标经过一定转化就是共享内存或者是消息队列和信号量的标识符(注意:这个标识符不是key)。

当需要访问对应的共享资源时,就可以通过上面的数组找到对应的struct ipc_perm,然后可以通过强制类型转换将该地址转换为对应的共享内存或者是消息队列和信号量的结构体即可。

相关文章:

  • 通过ca证书的方式设置允许远程访问Docker服务
  • Docker慢慢学
  • FreeCAD:开源世界的三维建模利器
  • 如何通过akshare库,获取股票数据,并生成TabPFN这个模型 可以识别、处理的格式(并进行了训练、推理)
  • 告别无效号码,精准营销从空号过滤开始
  • HarmonyOS NEXT应用开发-Notification Kit(用户通知服务)更多系统能力
  • 近端策略优化(PPO,Proximal Policy Optimization)
  • 第二章 进程管理
  • 在java中不同数据类型的运算与内存占用分析
  • 2025年文件加密软件推荐,最新款文档加密系统排名
  • dvwa9——Weak Session IDs
  • 将音频数据累积到缓冲区,达到阈值时触发处理
  • H5项目实现图片压缩上传——2025-06-04
  • RAID相关例题
  • Go语言学习-->go的跨平台编译
  • Educational Codeforces Round 179 (Rated for Div. 2)
  • JVM 内存溢出 详解
  • 协议融合驱动效能跃升:Modbus转Ethernet IP的挤出吹塑机应用
  • PostgreSQL-基于PgSQL17和11版本导出所有的超表建表语句
  • LeetCode[513]找树左下角的值
  • 怎么查看域名网站的容量到期/今日头条新闻大事件
  • 合肥网站设计建设/长春百度推广电话
  • 专业营销网站建设公司/整站seo排名
  • 网站购物车功能/国内免费顶级域名注册
  • 手机做网站软件/微信怎么推广自己的产品
  • 具有品牌的网站建设/百度seo排名优化软件化