1.认识进程间通信
我们通过之前的知识知道,进程具有独立性。两个进程之间时不能进行数据的直接传递的
但我们之前学校的fork()函数不是能传递子进程的pid给父进程吗?——这个严格来说不算通信
我们往往需要多个进程协同来完成一些任务
那么OS提供的样式有:
基于文件的,让不同进程看到同一份资源的通信方式叫做管道 管道只能被设计成为单向通信
在Linux中,管道确实可以被视为一种机制,同时也是一种特殊的文件类型。这种双重性来自于Linux操作系统的设计和其对所有资源采取的抽象化处理方式。
这种双重性使得管道既具有机制的灵活性,又具有文件的可操作性。它可以在不同的进程之间建立连接,实现数据的传递和共享,同时也可以通过标准的文件操作接口进行访问和控制。
为了支持管道通信,OS提供了一个接口:pipe()
匿名管道(Anonymous Pipe)Linux中提供的一种进程间通信(IPC)机制。匿名管道没有名字,它们仅存在于创建它们的进程及其子进程之间,并且一旦这些进程终止,管道也将随之消失。
匿名管道的主要特点如下:
在C语言中,可以使用pipe()
函数来创建一个匿名管道。这个函数接受一个包含两个文件描述符的数组作为参数,并返回两个文件描述符:一个用于读操作,另一个用于写操作。然后,可以使用fork()
创建一个子进程,并在父进程和子进程之间使用这些文件描述符进行通信。
pipe
函数用于创建管道,这是一种特殊的文件,用于连接一个程序的标准输出和另一个程序的标准输入,从而实现这两个程序之间的通信。在C语言中函数原型为:int pipe(int pipefd[2]);
参数:
pipe
函数接受一个整型数组作为参数(这是个输出型参数),即int pipefd[2]
。这个数组用于存储管道的两个文件描述符:pipefd[0]
表示管道的读端,而pipefd[1]
表示管道的写端。
作用:
调用pipe
函数后,系统会创建一个匿名管道,并将这个管道的两个端点(一个用于读,一个用于写)的文件描述符分别赋值给pipefd[0]
和pipefd[1]
。这样,一个进程就可以通过pipefd[1]
向管道写入数据,而另一个进程则可以通过pipefd[0]
从管道中读取数据。这种机制使得两个进程之间可以通过管道进行通信。
返回值:
如果pipe
函数成功创建了管道,则返回0。如果创建失败,则返回-1,并将错误原因存储在全局变量errno
中。可能的错误原因包括:
EMFILE
:进程已达到其文件描述符的最大数量。ENFILE
:系统已达到其文件描述符的最大数量。EFAULT
:传递给pipe
函数的数组地址不合法。#include<stdio.h>
#include<string.h>
#include<stdlib.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
void writer(int wfd)//写端的操作
{
const char *str = "hi father, I am child";
char buffer[128];
int cnt = 0;
pid_t pid = getpid();
while(1)
{
//先调用snprintf向buffer数组里写,然后在把buffer数组写到fd为wfd的文件里(这里就是管道的写端)
snprintf(buffer, sizeof(buffer), "message: %s, pid: %d, count: %d\n", str, pid, cnt);
write(wfd, buffer, strlen(buffer));
cnt++;
sleep(1);
}
}
void reader(int rfd)//读端的操作
{
char buffer[1024];
while(1)
{
ssize_t n = read(rfd, buffer, sizeof(buffer)-1);
(void)n;
//没有使用这个 n 变量。如果编译器被配置为警告未使用的变量,那么它就会为 n 发出一个警告
printf("father gets a message: %s", buffer);
}
}
int main()
{
//创建管道
int pipefd[2];
int n = pipe(pipefd);
// pipefd[0]-->read pipefd[1]-->write 0是写端,1是读端
// 0-->嘴巴 读书 1-->钢笔 写字
if(n < 0) return 1;
//创建子进程
pid_t id = fork();
if(id == 0)
{
//child: w 我们让子进程来写
close(pipefd[0]);//那么就要关闭读端
writer(pipefd[1]);
exit(0);
}
// father: r我们让父进程来读
close(pipefd[1]);//那么就要关闭写端
reader(pipefd[0]);
wait(NULL);
return 0;
}
管道内部没有数据而且子进程不关闭自己的写端文件fd, 读端(父)就要阻塞等待,直到pipe有数据
管道中没有数据时,读端继续读取的默认行为是阻塞当前正在读取的进程。在这种情况下,进程会进入等待状态,其进程控制块(PCB)会被放置在管道文件的等待队列中。只要管道中没有新的数据到来,读端进程就会一直阻塞等待
管道内部被写满而且读端(父进程)不关闭自己的fd,写端(子进程)写满之后,就要阻塞等待
管道具有固定的缓冲区大小,当缓冲区中的数据量达到上限时,写端进程就会被阻塞,直到有读端进程从管道中读取数据并释放缓冲区空间
#include<stdio.h>
#include<string.h>
#include<stdlib.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
void writer(int wfd)//写端的操作
{
const char *str = "hi father, I am child";
char buffer[128];
int cnt = 0;
pid_t pid = getpid();
while(1)
{
char ch='A';
write(wfd, &ch, 1);
cnt++;
printf("cnt=%d\n",cnt);
}
}
void reader(int rfd)//读端的操作
{
char buffer[1024];
while(1)
{
sleep(10);
ssize_t n = read(rfd, buffer, sizeof(buffer)-1);
(void)n;
//没有使用这个 n 变量。如果编译器被配置为警告未使用的变量,那么它就会为 n 发出一个警告
printf("father gets a message: %s", buffer);
}
}
int main()
{
//创建管道
int pipefd[2];
int n = pipe(pipefd);
// pipefd[0]-->read pipefd[1]-->write 0是写端,1是读端
// 0-->嘴巴 读书 1-->钢笔 写字
if(n < 0) return 1;
//创建子进程
pid_t id = fork();
if(id == 0)
{
//child: w 我们让子进程来写
close(pipefd[0]);//那么就要关闭读端
writer(pipefd[1]);
exit(0);
}
// father: r我们让父进程来读
close(pipefd[1]);//那么就要关闭写端
reader(pipefd[0]);
wait(NULL);
return 0;
}
不再向管道写入数据并且关闭了写端(子进程)文件描述符时,读端(父进程)可以继续从管道中读取剩余的数据,直到管道中的数据全部被读取完毕。最后就会读到返回值为0,表示读结束,类似读到了文件的结尾
读端关闭其文件描述符并且不再读取数据时,如果写端继续向管道写入数据,操作系统会发送一个SIGPIPE
信号给写端进程。默认情况下,这个信号会终止写端进程。SIGPIPE
信号是一个用于处理管道写端在写操作时无读端接收的情况的信号。
SIGPIPE
信号(信号编号为13)的发送是为了通知写端进程,其写操作因为管道的另一端没有读端而不再有意义。这是一种保护机制,防止写端进程在没有读端的情况下无限期地等待或继续写入数据到一个不再被读取的管道中。
#include<stdio.h>
#include<string.h>
#include<stdlib.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
void writer(int wfd)//写端的操作
{
int cnt = 0;
while(1)
{
sleep(1);
char ch='A';
write(wfd, &ch, 1);
cnt++;
printf("cnt=%d\n",cnt);
}//子进程一直写
}
void reader(int rfd)//读端的操作
{
int cnt=8;
char buffer[1024];
while(1)
{
sleep(1);
ssize_t n = read(rfd, buffer, sizeof(buffer)-1);
if(n>0)
{
printf("father get a message: %s, n : %ld\n", buffer, n);
}
else if(n==0)
{
printf("reading has done: %s %ld\n", buffer,n);
break;
}
else
{
break;
}
cnt--;
if(cnt==0)
{
break;
}
}
close(rfd);//8秒后,父进程不再读,直接关闭
printf("end");
}
int main()
{
//创建管道
int pipefd[2];
int n = pipe(pipefd);
// pipefd[0]-->read pipefd[1]-->write 0是写端,1是读端
// 0-->嘴巴 读书 1-->钢笔 写字
if(n < 0) return 1;
//创建子进程
pid_t id = fork();
if(id == 0)
{
//child: w 我们让子进程来写
close(pipefd[0]);//那么就要关闭读端
writer(pipefd[1]);
exit(0);
}
// father: r我们让父进程来读
close(pipefd[1]);//那么就要关闭写端
reader(pipefd[0]);
int status=0;
pid_t rid=waitpid(id,&status,0);
printf("exit code: %d, exit signal: %d\n",WEXITSTATUS(status),status&0x7f);
return 0;
}
|
其实就是匿名管道:在命令行中,当我们使用|
来连接两个命令时,实际上是在这两个命令之间创建了一个匿名管道。这使得前一个命令的输出能够直接传输给后一个命令,实现了两个命令之间的数据共享和传输
我们设想一个这样的情况:
log.txt
),内核会为该进程创建一个struct file
结构体,其中包含指向inode
结构体、函数指针数组和缓冲区的指针。这个struct file
结构体会指向已加载的inode
结构体和缓冲区,用于表示文件在内核中的信息和缓存文件数据。struct file
结构体,其中也包含指向相同的inode
结构体和缓冲区的指针。这意味着多个进程可以共享相同的inode
结构体和缓冲区,而不会为每个进程创建一份完全一样的inode
结构体和缓冲区。inode
结构体和缓冲区是在内核中维护的,因此多个进程可以共享相同的inode
结构体和缓冲区,而不需要为每个进程复制一份。这种共享机制可以节省内存空间,并确保多个进程对同一文件的操作是一致的。此时这两个进程就看到了同一块资源(log.txt 文件)
log.txt
)时,它们实际上是在操作同一块资源。这是因为文件系统中的路径和文件名是唯一的,所以无论哪个进程打开同一个路径下的文件,都会访问到同一个文件。
命名管道是一种特殊的文件类型,它允许进程之间通过文件系统进行通信。通过路径+文件名来确定(唯一的路径+文件名来找到并访问这个管道),多个进程可以通过打开同一个命名管道来实现数据交换。
命名管道(Named Pipe)是一种特殊的文件,用于进程间通信。它是一种半双工通信方式,允许一个或多个进程之间通过读写同一个文件来进行通信。
创建命名管道:
命名管道是通过调用mkfifo
系统调用来创建的。命名管道在文件系统中以文件的形式存在,但实际上它是一个FIFO(First In First Out)的通信通道。创建命名管道的语法为:
mkfifo <管道名称>
打开和关闭命名管道:
命名管道可以像普通文件一样被打开和关闭。进程可以通过open
系统调用打开一个命名管道文件,并通过close
系统调用关闭它。在打开命名管道时,进程需要指定相应的读写权限。
读写数据: 进程可以通过打开的文件描述符对命名管道进行读写操作。一个进程往管道中写入数据,另一个进程从管道中读取数据。命名管道是阻塞的,如果写入进程写入数据时,没有进程读取数据,写入进程会被阻塞直到有进程读取数据。
进程间通信: 命名管道通常用于实现进程间通信,特别是在父子进程或者**不相关进程之间**。一个进程可以向命名管道写入数据,另一个进程可以从命名管道读取数据,实现了进程间的数据交换。
mkfifo
函数是一个UNIX系统中用于创建命名管道(named pipe)的函数。它的作用是在文件系统中创建一个特殊类型的文件,这个文件可以被多个进程用来进行进程间通信。
在C语言中,可以使用mkfifo
函数来创建一个命名管道,其原型如下:
int mkfifo(const char *pathname, mode_t mode);
pathname
参数是指定要创建的命名管道的路径和文件名。mode
参数是指定创建的管道的权限模式,通常以八进制表示(例如0666
)。使用mkfifo
函数创建命名管道后,其他进程可以通过打开这个路径+文件名来访问这个管道,从而实现进程间的通信。一旦创建了命名管道,它就可以在文件系统中像普通文件一样被打开、读取和写入。
#ifndef __COMM_HPP__
#define __COMM_HPP__
#include <iostream>
#include <string>
#include <cerrno>
#include <cstring>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>
using namespace std;
#define Mode 0666
#define Path "./fifo"
class Fifo
{
public:
Fifo(const string &path) : _path(path)
{
umask(0);
int n = mkfifo(_path.c_str(), Mode);
if (n == 0)
{
cout << "mkfifo success" << endl;
}
else
{
cerr << "mkfifo failed, errno: " << errno << ", errstring: " << strerror(errno) << endl;
}
}
~Fifo()
{
int n = unlink(_path.c_str());
if (n == 0)
{
cout << "remove fifo file " << _path << " success" << endl;
}
else
{
cerr << "remove failed, errno: " << errno << ", errstring: " << strerror(errno) << endl;
}
}
private:
string _path; // 文件路径+文件名
};
#endif//条件编译结束
整体上使用一个条件编译:
在C++头文件中,通常会使用条件编译指令来防止头文件被多次包含,以避免重复定义的问题。条件编译指令的一般结构如下:
#ifndef __HEADER_NAME__
#define __HEADER_NAME__
// 头文件内容
#endif
#ifndef __HEADER_NAME__
:这是条件编译指令的开始标记,用于检查是否已经定义了名为__HEADER_NAME__
的宏。如果之前没有定义这个宏,那么下面的代码将被执行。#define __HEADER_NAME__
:在条件编译指令的开始处,定义名为__HEADER_NAME__
的宏,表示这个头文件已经被包含过了。// 头文件内容
:在这个部分可以放置头文件的内容,包括类的定义、函数的声明等。#endif
:这是条件编译指令的结束标记,表示条件编译的范围结束。#ifndef __COMM_HPP__
是条件编译指令的开始标记,而#endif
是条件编译指令的结束标记。
cerr
:
cerr
是C++标准库中的标准错误流,它用于输出错误信息到标准错误设备(通常是显示器)。cout
(标准输出流)类似,cerr
也是一个对象,可以使用插入运算符<<
来将数据插入到cerr
中进行输出。cout
不同的是,cerr
通常用于输出错误消息,而不是普通的程序输出。它是线程安全的,可以在多线程环境中使用。errno
:
errno
是一个全局变量,通常定义在<cerrno>
头文件中,用于存储函数调用发生错误时的错误码。errno
中,以便程序能够检测和处理错误。strerror
:
strerror
是一个C标准库函数,通常定义在<cstring>
或<string.h>
头文件中,用于将错误码转换为对应的错误消息字符串。strerror
接受一个错误码作为参数,并返回一个指向描述该错误的字符串的指针。strerror(errno)
,可以获取与当前errno
值对应的错误消息字符串,以便程序输出或记录错误信息。#include "Comm.hpp"
int main()
{
// 打开管道,进行写入,最后关闭
int wfd = open(Path, O_WRONLY | O_CREAT); // 以只写方式打开
if (wfd < 0)
{
cerr << "open failed, errno: " << errno << ", errstring: " << strerror(errno) << endl;
return 1;
}
string buffer; // 开始写入
while (true)
{
cout << "please write your message:" << endl;
getline(cin, buffer);
ssize_t n = write(wfd, buffer.c_str(), buffer.size());
if (n < 0)
{
cerr << "write failed, errno: " << errno << ", errstring: " << strerror(errno) << endl;
break;
}
}
close(wfd);
return 0;
}
#include "Comm.hpp"
#include <unistd.h>
int main()
{
Fifo fifo(Path);
// 打开管道,进行读取,最后关闭
int rfd = open(Path, O_RDONLY); // 以只读方式打开
if (rfd < 0)
{
cerr << "open failed, errno: " << errno << ", errstring: " << strerror(errno) << endl;
return 1;
}
char buffer[1024];//开始读取
while (true)
{
ssize_t n = read(rfd, buffer, sizeof(buffer) - 1);
if (n > 0)
{
buffer[n] = '\0';
cout << "client say : " << buffer << endl;
}
else if (n == 0)
{
cout << "client quit, me too!!" << endl;
break;
}
else
{
cerr << "read failed, errno: " << errno << ", errstring: " << strerror(errno) << endl;
break;
}
}
close(rfd);
return 0;
}
这里我自己有个疑问:本来读端是一直堵塞在read函数的,我们一输入abcde,第一次read就能读取完,然后输出。下一次循环就应该接着读,读到末尾,返回0了吧? 但为什么这里是接着阻塞呢?
read
函数会立即返回 0。read
函数会阻塞等待直到有数据可读取或者发生错误,它不会因为读取到文件末尾而返回 0。相反,只有当管道被关闭或者读取操作被中断时,read
函数才会返回 0。文件描述符的阻塞模式和非阻塞模式指的是在进行I/O操作时的行为方式。
实现进程间通信的前提就是如何让不同的进程看到同一份资源
System V共享内存(Shared Memory)是一种Linux中用于进程间通信(IPC)的机制。它允许多个进程访问同一块物理内存区域,从而实现数据的快速共享和交换。
shmget()
系统调用来创建共享内存。这个函数会分配一块指定大小的内存区域,并返回一个标识符,用于后续对这块共享内存的操作。shmat()
系统调用来将共享内存关联到进程的地址空间。这个函数会将共享内存的地址告诉进程,使得进程可以通过这个地址来访问共享内存。shmdt()
系统调用来取消关联。这个函数会断开进程与共享内存之间的映射关系。shmctl()
系统调用来释放它。这个函数会回收这块内存区域,并释放相关的资源。ftok()
函数 Linux中用于生成一个唯一的键值(key)的系统调用,这个键值通常用于在进程间通信(IPC)中标识共享内存段、消息队列或信号量集。ftok()
函数基于一个已经存在的文件路径和一个非零的标识符(通常是一个小的正整数)来生成这个键值。
#include <sys/ipc.h>
#include <sys/types.h>
key_t ftok(const char *pathname, int proj_id);
参数:
pathname
:指向一个已经存在的文件路径的指针。这个文件通常被用作生成键值的“种子”或“基础”。proj_id
:一个非零的标识符,通常是一个小的正整数。这个值将与文件路径一起被用于生成键值。返回值:如果成功,ftok()
函数返回一个唯一的键值(key_t
类型),该键值可以在后续的 IPC 调用(如 shmget()
, msgget()
, semget()
等)中用作参数。如果失败,则返回 (key_t) -1
并设置 errno
以指示错误。
shmget()
:创建或获取共享内存shmget()
系统调用用于创建一个新的共享内存对象,或者如果它已存在,则返回该对象的标识符。
函数原型:
int shmget(key_t key, size_t size, int shmflg);
参数:
key
:一个键,用于唯一标识共享内存对象。通常使用ftok()
函数生成。
key
便是那个唯一性标识符。那么为什么这个key要由我们用户来传入呢?size
:共享内存的大小(以字节为单位)。
shmflg
:权限标志和选项。通常设置为IPC_CREAT
(如果对象不存在则创建,存在的话直接获取)和权限(如0666
)。
若设置为IPC_CREAT|IPC_EXCL
(如果对象不存在则创建,存在的话出错返回)
返回值:成功时返回共享内存对象的标识符;失败时返回-1并设置errno
。
shmat()
:将共享内存关联到进程的地址空间shmat()
系统调用用于将共享内存对象关联到调用进程的地址空间。
函数原型:
void *shmat(int shmid, const void *shmaddr, int shmflg);
参数:
shmid
:shmget()
返回的共享内存对象标识符。shmaddr
:希望将共享内存附加到的进程的地址。如果设置为NULL,则系统选择地址。shmflg
:通常设置为0或SHM_RND
(使附加地址向下舍入到最接近的SHMLBA边界)。返回值:成功时返回共享内存附加到进程的地址;失败时返回(void *)-1并设置errno
。
shmdt()
:取消共享内存的关联shmdt()
系统调用用于取消之前通过shmat()
附加到进程的共享内存的关联。
函数原型:
int shmdt(const void *shmaddr);
参数:
shmaddr
:shmat()
返回的共享内存附加到进程的地址。返回值:成功时返回0;失败时返回-1并设置errno
。
shmctl()
:控制共享内存shmctl()
系统调用用于获取或设置共享内存的属性,或者删除共享内存对象。
函数原型:
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
参数:
shmid
:共享内存对象标识符。cmd
:要执行的操作。例如,IPC_RMID
用于删除共享内存对象,IPC_STAT
用于获取其状态。buf
:指向shmid_ds
结构的指针,用于传递或接收共享内存的状态信息。返回值:成功时返回0;失败时返回-1并设置errno
。