我们知道进程之间是独立的,但是太独立并不合适,我们有时候还是要进行进程间的通行,实现多进程的并发运行。

进程间通信的目的

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

管道

匿名管道

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

匿名管道是用于父子进程的进程通信的,通过调用pipe然后通过返回型参数pipefd给当前进程创建一个读fd和一个写fd,pipefd是返回型参数,0下标是读fd,1是写fd。

管道的缓冲区大小默认是64KB,是比较小的。

管道是半双工,只能一方读,一方写。当写入方关闭了文件描述符,且管道为空时,如果继续读那么会直接返回0。如果读方关闭了文件描述符,写端继续写,写端就会触发信号中断SIGPIPE,如果没有捕获就会终止写端。

匿名管道的正确创建

调用pipe后,父进程fork创建子进程,因为子进程会直接继承父进程的fd,这样父进程和子进程都会持有管道的读fd和写fd。但是管道只能一端读一端写,因此是需要根据实际需求让一方关闭读一方关闭写实现单向通信。如果不关闭,那么如果对端写关闭了,但是因为读端的读fd没有正常关闭,导致读端会持续阻塞(上面说了,只有写端关闭且管道为空才会返回0),这是比较致命的问题,直接导致进程的持续阻塞。

简单实现一个父子进程通信:

#include<iostream>
#include<sys/types.h>
#include<sys/fcntl.h>
#include<unistd.h>
#include<cstring>
#include<sys/wait.h>
using namespace std;

int main(){
    int fd[2];
    if(pipe(fd)==-1){
        return -1;
    }
    pid_t id = fork();
    if(id==0){
        close(fd[1]);
        char arr[1024];
        while (true){        
            int len = read(fd[0],arr,sizeof(arr)-1);
            if(len==0){
                cout<<"父进程关闭读端,子进程退出"<<endl;
                exit(0);
            }
            else{
                arr[len]=0;
                cout<<"获取数据:"<<arr<<endl;
            }
        }
        exit(0);
    }
    else{
        close(fd[0]);
        char arr[1024];
        int len = snprintf(arr,sizeof(arr)-1,"hello fork ,hello dgj,hello btye!");
        arr[len]=0;
        write(fd[1],arr,strlen(arr));
        sleep(1);
        close(fd[1]);
        int status;
        waitpid(id,&status,0);
        if(WIFEXITED(status)){
            int ret= WEXITSTATUS(status);
            cout<<"子进程退出:"<<ret<<endl;
        }
        else cout<<"子进程异常退出"<<endl;
    }
    cout<<"父进程正常退出"<<endl;

    return 0;
}

匿名管道的缺点

匿名管道的文件大小不是很大,对于高并发的场景,匿名管道可能经常阻塞导致性能下降

匿名管道是半双工,如果要实现全双工还需要实现两个管道,比较麻烦

匿名管道只能用于有亲缘关系的通信,具有局限性

匿名管道可以支持多进程写入,如果写入的内容小于等于匿名管道的剩余空间大小,那么就是原子操作的写入,保证安全性。但是如果大于剩余空间大小将不再保证原子性。

匿名管道的使用场景

匿名管道可以用于父子进程的简单通信,例如实现shell的管道功能,例如 ls|head -1 那么shell先创建一个管道,然后创建第一个子进程这里是ls,然后将读端关闭,然后用dup2来重定向标准输出到管道的写端fd,然后调用exec执行对应的操作,然后创建第二个子进程,让第二个子进程关闭写端,然后调用dup2来重定向标准读到管道的读fd,然后调用exec执行相关的操作。

第二个是实现一个简单的进程池,通过管道进行进程间通信。但是这个实现很局限,首先是怎么发送任务,发送任务肯定需要防止黏包,因此需要序列化反序列化等操作。然后就是如果进程数多,父进程需要管理大量的子进程fd,另外父进程不好知道子进程的空闲状态。总之用匿名管道实现进程池只能实习那简单的。

命名管道

命名管道可以通过指令创建:

mkfifo filename

也可以在代码里面调用函数:

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

只有具有相关权限进程才能访问对应的命名管道

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

命名管道问题:如果多个进程同时读写那么就会出现问题,如果用文件锁,那么也是性能的缺失,因此命名管道用的很少

共享内存

在栈区和堆区之间隔了很大一块地址,这块地址是进程之间共享的内存。

共享内存原理

我们之前接触过页表,页表可以记录虚拟地址到物理地址的映射,那么我们可以让各个进程的页表将共享内存的虚拟地址映射到同一个物理地址上,这样就实现了内存共享。

System V共享内存

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

key是赋予的键值对,size是要开辟的大小。shmflg和使用文件的mode模式是一样的

通过shmget让操作系统在物理地址上开辟一段内存,用键值key来进行获取

key_t key=1;
size_t size=1024;
int ret =shmget(key,size,IPC_CREAT|IPC_EXCL|0666);
if(ret==-1)return -1;

之后进程可以通过shmat函数来建立虚拟地址到物理地址的映射

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

shmaddr是手动给虚拟地址的起始地址,shmflg是权限位,如下

int shmdt(const void *shmaddr);

删除对应虚拟内存的共享内存

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

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

cmd:将要采取的动作(有三个可取值)

buf:指向⼀个保存着共享内存的模式状态和访问权限的数据结构

#include<sys/shm.h>
#include<iostream>
#include<fcntl.h>
#include<unistd.h>
#include<sys/ipc.h>
#include<sys/wait.h>
using namespace std;

int main(){
    key_t key=2;
    size_t size=1024;
    int shmid =shmget(key,size,IPC_CREAT|IPC_EXCL|0666);
    if(shmid==-1)return -1;
    pid_t id = fork();
    if(id==0){
        void* ret = shmat(shmid,nullptr,0);
        snprintf(static_cast<char*>(ret),size,"你好字节");
        sleep(2);
        char buff[1024];
        size_t len =sscanf(static_cast<const char*>(ret),"%s",buff);
        buff[len-1]=0;
        cout<<"子进程接收到:"<<buff<<endl;
        shmdt(ret);
        exit(0);
    }
    else{
        void *ret = shmat(shmid,nullptr,0);
        if(ret==(void*)-1){
            shmctl(shmid,IPC_RMID,nullptr);
            return 1;
        }
        sleep(1);
        char buff[1024];
        size_t len =sscanf(static_cast<const char*>(ret),"%s",buff);
        buff[len]=0;
        cout<<"父进程接收到:"<<buff<<endl;
        snprintf(static_cast<char*>(ret),size,"你好DGJ");
        waitpid(id,nullptr,0);
    }
    shmctl(shmid,IPC_RMID,nullptr);
    return 0;
}

对于不是血缘关系的进程如何找到这个共享内存呢?只要是同一个key进行的shmget,就能找到返回同一个shmid

System V缺点

缺点很大,如果在shmget后某个操作失败导致程序崩溃,那么就没有进程来回收创建出来的物理内存了,导致内存泄露。而且操作不顺手,接口比较原始

POSIX 共享内存

首先我们得复习磁盘和内存的页机制,磁盘和内存的内存交互是按页的。

对于磁盘,创建一个文件,会按页的整数倍进行分配,之后扩容也是按页的整数倍来扩容,尽管每个页可能不是连续的。这样就方便对文件数据进行管理,防止多个文件的数据在同一个页还要区分。

对于内存,mmap就会使用这个性质,将文件的页内存加载到物理内存上的,因此地址必定是页的整数倍,长度也必定是页的整数倍

#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);

addr是手动设置起始地址,建议直接设置为nullptr让系统自己来

length是要设置的长度,如果不是页的整数倍,系统会分配成整数倍的页内存。但是你不能访问多开辟的那块内存,会出现错误。

prot 映射区域的权限 PROT_READ可读 PROT_WRITE可写 PROT_ECEC可执行 PROT_NONE无

flags 映射标志位 MAP_SHARED共享映射(修改会同步到文件/其他进程,实现共享内存的核心)                                 MAP_PRIVATE(修改仅对当前进程可见,写时复制)                                                                       MAP_ANONYMOUS/MAO_ANON(无需文件,仅用于亲缘进程共享)

fd 要映射的文件描述符:- 非匿名映射:传打开的文件 fd- 匿名映射传 -1

offset 映射文件的偏移量,必须是页大小的整数倍;匿名映射设为 0。因为文件是多个页组成的,可能我并不需要第一个页的数据,所以可以传offset来跳过不需要的内存。

返回值是页整数倍的地址

匿名映射(亲缘关系映射)

主要用于父子进程之间的共享:

constexpr size_t len =1024;
void * addr =mmap(nullptr,len,PROT_READ|PROT_WRITE,MAP_ANON,-1,0);

原理是在创建子进程之前,先调用mmap,系统会自动创建一个匿名的物理地址,并返回一个addr的虚拟地址,创建子进程后,父子进程就可以用addr来获取内存了:

#include<iostream>
#include<fcntl.h>
#include<unistd.h>
#include<sys/wait.h>
#include<sys/mman.h>
#include<cstring>
#include<cstdlib>
using namespace std;

int main(){
    constexpr size_t len =1024;
    void * shm_ptr =mmap(nullptr,len,PROT_READ|PROT_WRITE,MAP_SHARED|MAP_ANON,-1,0);
    pid_t pid = fork();
    if (pid == 0) {  // 子进程
        // 写入数据到共享内存
        const char* msg = "你好,父进程!";
        strncpy(static_cast<char*>(shm_ptr), msg, len - 1);
        static_cast<char*>(shm_ptr)[len - 1] = '\0';

        sleep(2);  // 等待父进程写入新数据

        // 读取父进程写入的数据
        char buff[1024] = {0};
        strncpy(buff, static_cast<const char*>(shm_ptr), sizeof(buff) - 1);
        cout << "子进程读取:" << buff << endl;

        // 解除映射
        munmap(shm_ptr, len);
        exit(0);
    } else if (pid > 0) {  // 父进程
        sleep(1);  // 等待子进程先写入

        // 读取子进程写入的数据
        char buff[1024] = {0};
        strncpy(buff, static_cast<const char*>(shm_ptr), sizeof(buff) - 1);
        cout << "父进程读取:" << buff << endl;

        // 写入新数据到共享内存
        const char* msg = "你好,子进程!";
        strncpy(static_cast<char*>(shm_ptr), msg, len - 1);
        static_cast<char*>(shm_ptr)[len - 1] = '\0';

        // 等待子进程退出
        waitpid(pid, nullptr, 0);

        // 解除映射
        munmap(shm_ptr, len);
    } else {
        perror("fork failed");
        munmap(shm_ptr, len);
        return -1;
    }
    return 0;
}

非亲缘关系共享内存原理

非亲缘关系映射主要是依靠文件来实习内存的共享,打开一个文件,mmap通过fd将其文件数据映射到一块物理内存上面并返回虚拟地址,然后多个进程就可以使用共享内存区域的这个虚拟地址实现内存共享。

ftruncate

我们的文件大小可能小于共享内存的要求大小,因此我们要用ftruncate来扩容文件,扩容后的内存默认置为0,这样后面映射为内存后就有反斜杠零来作为终止符了。

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

int truncate(const char *path, off_t length);
int ftruncate(int fd, off_t length);

尽管你的length可能不是页大小,也会扩容到页的整数倍

下面是两种隐射方式,我们分别来看

私有隐射(非亲缘关系映射)

私有隐射是考虑到进程不需要将数据写回文件的情况,此时调用mmap就只会创建一个可读的共享内存区域,只要是设置为私有映射的进程,就会共同访问这块可读区域。如果某个进程要读,就会触发写实拷贝来开辟新的物理内存给这个进程。但最终都不会 刷新到文件里面。通过这个方式就裁剪了需要将物理内存写回到文件的操作。

下面的代码并不安全,只是做个例子

#include<iostream>
#include<sys/mman.h>
#include<unistd.h>
#include<sys/stat.h>
#include<sys/types.h>
#include<fcntl.h>
using namespace std;

int main(){
    int fd = open("a.txt",O_RDWR);
    constexpr size_t len = 1024;
    ftruncate(fd,len);
    void * addr =mmap(nullptr,len,PROT_READ|PROT_WRITE,MAP_PRIVATE,fd,0);
    string ret(static_cast<char*>(addr));
    cout<<"读取到的是"<<ret<<endl;
    ret+="123423452";
    std::copy(ret.begin(),ret.end(),static_cast<char*>(addr));
    cout<<"修改为:"<<ret<<endl;
    ret = string(static_cast<char*>(addr),len);
    cout<<"修改后:"<<ret<<endl;
    return 0;
}

如果文件内容是1234。先ftruncate扩容就会变成一下情况

读取到的是1234
修改为:1234123423452
修改后:1234123423452

然后再读取,创建string,就会读取到0停止,然后再修改拷贝回去。但是文件的结果还是,因此的确是写实拷贝

文件映射(非亲缘关系映射)

文件映射也是通过mmap将文件数据映射到物理内存上,但是此时是多个进程共用一个内存进行读写操作,并不会写时拷贝。当某个页被修改后,会被操作系统认定为脏页,不会立即写入磁盘,而是当修改到了一定此时或者到了一定时间才会写回到文件里面。

代码和私有映射很像,修改一个MAP_SHARED就行

#include<iostream>
#include<sys/mman.h>
#include<unistd.h>
#include<sys/stat.h>
#include<sys/types.h>
#include<fcntl.h>
using namespace std;

int main(){
    int fd = open("a.txt",O_RDWR);
    constexpr size_t len = 1024;
    ftruncate(fd,len);
    void * addr =mmap(nullptr,len,PROT_READ|PROT_WRITE,MAP_SHARED,fd,0);
    string ret(static_cast<char*>(addr));
    cout<<"读取到的是"<<ret<<endl;
    ret+="123423452";
    std::copy(ret.begin(),ret.end(),static_cast<char*>(addr));
    cout<<"修改为:"<<ret<<endl;
    ret = string(static_cast<char*>(addr),len);
    cout<<"修改后:"<<ret<<endl;
    return 0;
}

运行后,文件就会被修改

msync

文件映射的刷新机制不是立即的,因此需要有刷新的函数保证必要时候刷新到文件里面,例如进程退出,需要刷新到磁盘。

#include <sys/mman.h>
int msync(void *addr, size_t length, int flags);

addr  这个地址不能随便传,只能是页对齐的。你传的地址必须是页的起始地址,否则就会出现错误。

length 这个系统会自动对齐为页的整数倍。上层可以不管,你想刷新多少长度就写多少

flags 同步机制 MS_SYNC 阻塞同步到文件中,只有完全同步才会结束阻塞

                        MS_ASYNC 异步同步,交给系统来处理。

                        MS_INVALIDATE 同步数据到磁盘 + 让其他进程的映射缓存失效,其他进程必须同步数据才能使用。

mmap读取大文件

mmap不仅可以用于进程间通信,对于大文件的读取也是很方便的,对于read/write或者需要先拷贝到缓冲区再拷贝到内存,而且如果是C/C++的,就更加一层拷贝了,mmap是按需分配,只有你修改相关页的内存才会修改,这对于大文件的处理是极具优势的。而对于IO函数,如果要保证少次数的调用,那么就要开辟很大一块内存来存储,但是依然没有mmap好,因为你如果一次性读完,那么会有有些页不用的情况,如果是按一页一页读,那么又陷入了多调用IO函数的情况。因此mmap对应大文件操作很方便。

另一种情况,如果对文件的修改是高频次的,每次都会进行write,这会导致大量的系统调用,而mmap可以对修改的页进行修改计数,只有当计数到达一个次数或者到达某个时间才会写回,即将多次修改变成几次的修改,减少了写回的次数。

两种情况mmap的读写方式的系统调用都只有mmap,后续内存到磁盘的换回都是内核的操作,这样就极大减少了系统调用带来的开销,因此mmap是很有优势的。

消息队列

System V消息队列

这个我就不多说了,和System V 共享内存一样优缺点,需要手动释放开辟的队列资源,否则会资源泄露。并且操作比较麻烦。因此现在使用的是POSIX的和rabbit这种大型消息队列项目的消息队列通信

这是System V和POSIX的对比

但目前对于大项目来说rabbit这种消息队列可以跨主机的更加适合现在业务场景

网络通信

还有一种就是网络的通信同步策略了,这里不多说

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐