Linux基础 | 进程间通信

概述

很多时候我们在写程序的时候为了提高效率采用多进程的方式,但是进程之间的内存区域实际上是分隔开的。我们需要采用一种机制来让各个进程之间进行通讯。(比如想写一个程序计算1到1千亿加法,当然这只是举个例子,一个进程去写只用一个cpu太亏了,我们用4个cpu,4个进程,每个写4分之1的加法,最后每个进程把结果汇总到主进程并打印到屏幕上,这样就得让4个进程进行通信,把数据发过去,不然还写个锤锤)下面来介绍linux操作系统下的进程间通信的方式:

信号

信号本质

软中断信号(signal,又简称为信号)用来通知进程发生了异步事件。在软件层次上是对中断机制的一种模拟,在原理上,一个进程收到一个信号与处理器收到一个中断请求可以说是一样的。信号是进程间通信机制中唯一的异步通信机制,一个进程不必通过任何操作来等待信号的到达

# 所有的信号
# kill -l
 1) SIGHUP       2) SIGINT       3) SIGQUIT      4) SIGILL       5) SIGTRAP
 6) SIGABRT      7) SIGBUS       8) SIGFPE       9) SIGKILL     10) SIGUSR1
11) SIGSEGV     12) SIGUSR2     13) SIGPIPE     14) SIGALRM     15) SIGTERM
16) SIGSTKFLT   17) SIGCHLD     18) SIGCONT     19) SIGSTOP     20) SIGTSTP
21) SIGTTIN     22) SIGTTOU     23) SIGURG      24) SIGXCPU     25) SIGXFSZ
26) SIGVTALRM   27) SIGPROF     28) SIGWINCH    29) SIGIO       30) SIGPWR
31) SIGSYS      34) SIGRTMIN    35) SIGRTMIN+1  36) SIGRTMIN+2  37) SIGRTMIN+3
38) SIGRTMIN+4  39) SIGRTMIN+5  40) SIGRTMIN+6  41) SIGRTMIN+7  42) SIGRTMIN+8
43) SIGRTMIN+9  44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9  56) SIGRTMAX-8  57) SIGRTMAX-7
58) SIGRTMAX-6  59) SIGRTMAX-5  60) SIGRTMAX-4  61) SIGRTMAX-3  62) SIGRTMAX-2
63) SIGRTMAX-1  64) SIGRTMAX

这些信号都是什么作用呢?我们可以通过man 7 signal命令查看,里面会有一个列表


Signal     Value     Action   Comment
──────────────────────────────────────────────────────────────────────
SIGHUP        1       Term    Hangup detected on controlling terminal
                              or death of controlling process
SIGINT        2       Term    Interrupt from keyboard
SIGQUIT       3       Core    Quit from keyboard
SIGILL        4       Core    Illegal Instruction


SIGABRT       6       Core    Abort signal from abort(3)
SIGFPE        8       Core    Floating point exception
SIGKILL       9       Term    Kill signal
SIGSEGV      11       Core    Invalid memory reference
SIGPIPE      13       Term    Broken pipe: write to pipe with no
                              readers
SIGALRM      14       Term    Timer signal from alarm(2)
SIGTERM      15       Term    Termination signal
SIGUSR1   30,10,16    Term    User-defined signal 1
SIGUSR2   31,12,17    Term    User-defined signal 2
……

进程对信号的响应

进程可以通过三种方式来响应一个信号:

  1. 忽略信号,即对信号不做任何处理,其中,有两个信号不能忽略:SIGKILLSIGSTOP;它们用于在任何时候中断或结束某一进程。
  2. 捕捉信号。定义信号处理函数,当信号发生时,执行相应的处理函数;
  3. 执行缺省操作,Linux 对每种信号都规定了默认操作。上面列表中的Term,就是终止进程的意思。Core 的意思是 Core Dump,也即终止进程后,通过 Core Dump 将当前进程的运行状态保存在文件里面,方便程序员事后进行分析问题在哪里

信号处理流程

信号处理最常见的流程主要是两步,第一步是注册信号处理函数,第二步是发送信号和处理信号

信号注册

  1. 第一种方式就是使用signal(),其意思就是定义一个方法handlersgnum绑定起来,当进程遇到这个信号就执行这个方法。signal 不是系统调用,而是 glibc 封装的一个函数。但是linux更建议使用第二种sigaction()
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
  1. 第二种使用sigaction(),其实它还是将信号和一个动作进行关联,只不过这个动作由一个结构 struct sigaction 表示了。
int sigaction(int signum, const struct sigaction *act,struct sigaction *oldact);

struct sigaction {
  __sighandler_t sa_handler;
  unsigned long sa_flags;
  __sigrestore_t sa_restorer;
  sigset_t sa_mask;    /* mask last for extensibility */
};

signal函数glibc实现:


#  define signal __sysv_signal
__sighandler_t
__sysv_signal (int sig, __sighandler_t handler)
{
  struct sigaction act, oact;
......
  act.sa_handler = handler;
  __sigemptyset (&act.sa_mask);
  act.sa_flags = SA_ONESHOT | SA_NOMASK | SA_INTERRUPT;
  act.sa_flags &= ~SA_RESTART;
  if (__sigaction (sig, &act, &oact) < 0)
    return SIG_ERR;
  return oact.sa_handler;
}
weak_alias (__sysv_signal, sysv_signal)

sa_flags字段解读:

  1. SA_ONESHOT 是什么意思呢?意思就是,这里设置的信号处理函数,仅仅起作用一次。用完了一次后,就设置回默认行为。这其实并不是我们想看到的。毕竟我们一旦安装了一个信号处理函数,肯定希望它一直起作用,直到我显式地关闭它。

  2. SA_NOMASK。 我们通过 __sigemptyset,将 sa_mask 设置为空。这样的设置表示在这个信号处理函数执行过程中,如果再有其他信号,哪怕相同的信号到来的时候,这个信号处理函数会被中断。如果一个信号处理函数真的被其他信号中断,其实问题也不大,因为当处理完了其他的信号处理函数后,还会回来接着处理这个信号处理函数的,但是对于相同的信号就有点尴尬了,这就需要这个信号处理函数写得比较有技巧了。(sa_mask 设为空,则这个信号处理函数本身有可能被其他信号中断)。
    例如,对于这个信号的处理过程中,要操作某个数据结构,因为是相同的信号,很可能操作的是同一个实例,这样的话,同步、死锁这些都要想好。其实一般的思路应该是,当某一个信号的信号处理函数运行的时候,我们暂时屏蔽这个信号。后面我们还会仔细分析屏蔽这个动作,屏蔽并不意味着信号一定丢失,而是暂存,这样能够做到信号处理函数对于相同的信号,处理完一个再处理下一个,这样信号处理函数的逻辑要简单得多。(信号处理函数被中断问题不大,但是如果被同一个信号中断就有可能因为要操作同一个数据导致死锁,思路是这个信号处理函数运行时,屏蔽同一个信号

  3. SA_INTERRUPT,清除了 SA_RESTART。这是什么意思呢?我们知道,信号的到来时间是不可预期的,有可能程序正在调用某个漫长的系统调用的时候,这个时候一个信号来了,会中断这个系统调用,去执行信号处理函数,那执行完了以后呢?系统调用怎么办呢?这时候有两种处理方法:
    (1) 一种就是 SA_INTERRUPT,也即系统调用被中断了,就不再重试这个系统调用了,而是直接返回一个 -EINTR 常量,告诉调用方,这个系统调用被信号中断了,但是怎么处理你看着办。如果是这样的话,调用方可以根据自己的逻辑,重新调用或者直接返回,这会使得我们的代码非常复杂,在所有系统调用的返回值判断里面,都要特殊判断一下这个值。
    (2) 另外一种处理方法是 SA_RESTART。这个时候系统调用会被自动重新启动,不需要调用方自己写代码。当然也可能存在问题,例如从终端读入一个字符,这个时候用户在终端输入一个'a'字符,在处理'a'字符的时候被信号中断了,等信号处理完毕,再次读入一个字符的时候,如果用户不再输入,就停在那里了,需要用户再次输入同一个字符。

信号发送

信号事件的发生有两个来源:

  • 软件来源(一些非法运算等操作,人为的调用信号发送函数指定发送的信号
  • 硬件来源 (比如我们按下了键盘或者其它硬件故障)这里同样是硬件产生的,对于中断信号还是要加以区别。中断要注册中断处理函数,但是中断处理函数是在内核驱动里面的,信号也要注册信号处理函数,信号处理函数是在用户态进程里面的
  • 对于硬件触发的,无论是中断,还是信号,肯定是先到内核的,然后内核对于中断和信号处理方式不同。一个是完全在内核里面处理完毕,一个是将信号放在对应的进程 task_struct 里信号相关的数据结构里面,然后等待进程在用户态去处理。当然有些严重的信号,内核会把进程干掉。但是,这也能看出来,中断和信号的严重程度不一样,信号影响的往往是某一个进程,处理慢了,甚至错了,也不过这个进程被干掉,而中断影响的是整个系统。一旦中断处理中有了 bug,可能整个 Linux 都挂了

最常用发送信号的系统函数是kill, raise,alarmsetitimer 以及 sigqueue 函数,软件来源还包括一些非法运算等操作。

管道

首先看点概念,管道是半双工的,其两端,只能一端读一端写,非常符合日常生活里的管子,位置固定以后,从一边倒水只能从另外一端出水。管道有两种无名管道和命名管道,先来看无名管道。
管道

无名管道

首先举例我们平常在linux下使用命令时,比如 ls | grep main就是查找当前目录下有main文件,其中的|就是一个无名管道,ls进程的输出结果进入此管道的写端grep进程从读端读出ls的结果,grep读到结果后查找main。这就是一个典型场景。

在api方面,其使用函数如下:

#include<unistd.h>
int pipe(int fd[2]);  //可以看到pipe()函数将一个fd[2]数组作为参数来构造一个管道

fd数组会返回两个文件描述符,fd[0]表示读端,fd[1]表示写端。我们通常先把数据写入到fd[1]里,然后从fd[0]中读出。我们想做到概述里的加法的话就得让子进程去做数据处理,然后把答案写到fd[1],我们从父进程读fd[0],得到结果。下图可以看到,父进程先用pipe函数创建一个管道,接着fork让子进程继承,最后进行数据通信
通信
当然管道创建好了之后就根据需要去关闭当前进程不需要的一端,然后做业务处理。比如下面父进程写,子进程读的模型在这里插入图片描述

//
// Created by weizhenkun on 2020/7/22.
//
#include <sys/wait.h>
#include <zconf.h>
#include<bits/stdc++.h>
#include <sys/types.h>
using namespace  std;

int main()
{
    int pipefd[2];
    if( pipe(pipefd)<0)
    {
        cout<<"error"<<endl;
    }
    pid_t cpid;
    char line[128];
    cpid=vfork();
    if(cpid>0)
    {
        close(pipefd[1]);
        int n=read(pipefd[0],line,128);
        write(STDOUT_FILENO,line,n);
    }
    else if(cpid==0)
    {
        close(pipefd[0]);
        write(pipefd[1],"hello world\n",12);
    }
    exit(0);
}


上面这段程序就执行了让子进程写入数据,然后从父进程读出数据显示到屏幕上的过程。对于无名管道没有实体,纯粹是一端内核的缓冲区。当然这样的写法套路比较难用。还得自己fork再编写子进程,为了图方便,linux还有更简单的写法,如下

popen和pclose

linux还提供了popen()pclose函数来方便管道程序的编写。来看看这俩函数:

#include<stdio.h>
FILE *popen(const char *cmdstring,const char *type);
int pclose(FILE *fp);

popen()的做法是先执行fork再调用exec执行cmdstring,返回一个标准I/O文件指针,type有两种:
r是文件指针指向cmdstring的标准输出
w是文件指针指向cmdstring的标准输入

pclose比较简单,即使关闭I/O流,等待命令终止,然后返回shell的终止状态。

命名管道(FIFO)

由于无名管道只能用于有亲缘关系的进程之间通信,很麻烦,而命名管道可以解决这个问题,它可以在无亲缘进程之间进行通信。命名管道会创建一个管道文件来帮助实现通信,而无名管道并不是文件,只是一端缓冲区。它也有命令行的场景:

mkfifo hello //可以创建一个叫hello的命名管道
echo "www" > hello  //写入管道,然后此进程阻塞
cat < hello         //读出管道
www

其api为:

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

int mkfifo(const char *filename,mode_t mode); //mkfifo函数成功返回0,失败返回-1并且设置errno。

参数含义:

  1. 第一个参数是一个普通的路径名,也就是创建后FIFO的名字。
  2. 第二个参数与打开普通文件的open()函数中的mode参数相同。如果mkfifo的一个参数是一个已经存在路径名时,会返回EEXIST错误,所以一般典型的调用代码首先会检查是否返回该错误,如果确实返回该错误,那么只要调用打开FIFO的函数open就可以了。
  3. 第三个参数是FIFOopen打开规则,O_RDONLYO_WRONLYO_NONBLOCK标志共有四种合法的组合方式:
    flags=O_RDONLYopen将会调用阻塞,除非有另外一个进程以的方式打开同一个FIFO,否则一直等待。
    flags=O_WRONLYopen将会调用阻塞,除非有另外一个进程以的方式打开同一个FIFO,否则一直等待。
    flags=O_RDONLY|O_NONBLOCK:如果此时没有其他进程以的方式打开FIFO,此时open也会成功返回,此时FIFO被读打开,而不会返回错误。
    flags=O_WRONLY|O_NONBLOCK立即返回,如果此时没有其他进程以的方式打开,open会失败打开,此时FIFO没有被打开,返回-1

特别得来说:
fifo由于其在乎进程间关系,甚至可以用来做客户端和服务器进程通信的桥梁,如下图:
fifo
FIFO所有人已知,那么就可以用FIFO来处理通信,当然这样还是不够好,还有很多缺点。这里只是举个例子,不再多描述。

异同

pipefifo的相同点:

  1. 虽然都是管道,fifo可以很方便地在双向上打开读写,但其内核实现依然是单向的。严格遵循先进先出(first in first out),对pipefifo的读总是从开始处返回数据,对它们的写则把数据添加到末尾
  2. pipe, fifo都不支持诸如lseek()等文件定位操作。
  3. 对于pipe或者fifo,如果在读端或者写端打开了多个读写端(进程),之间的读写是不确定的,需要通过其他的同步机制实现多进程通讯的同步

编码不同点:

  1. 创建并打开一个管道只需调用pipe。创建并打开一个fifo则需在调用mkfifo后再调用open
  2. pipe在所有进程最终都关闭它之后自动消失。fifo的名字则只有通过调用unlink才文件系统删除。

fifo需要额外调用的好处是:fifo在文件系统中有一个名字,该名字允许某个进程创建个fifo,与它无亲缘关系的另一个进程来打开这个fifo。对于pipe来说,这是不可能的。

总结

对于管道而言,使用方便,有两种:
无名管道,命令行|或者int pipe(int fd[2]),并不是文件,只是一端缓冲区,fd[0]写,fd[1]读,只能用于父子进程通信。
命名管道,命令行mkfifo -fifoname,是个管道文件,文件类型是pmkfifo()创建之后需要open,可用于任意进程通信。

好处和缺点:
好处:编码简单,使用方便,很容易得知管道里的数据已经被另一个进程读取了。相比于下面要介绍得IPC方式,pipe存在引用计数机制,最后一个引用pipe的进程终止后,pipe也就删除了。而fifo的最后一个引用进程终止后,虽然fifo名字还在系统中,但是数据已经被删除了。
缺点:通信效率低,不适合进程间频繁地交换数据

XSI IPC

消息队列、信号量以及共享内存三个被称作XSI IPC,因为他们存在很多类似之处,这里先介绍类似点:

标识符和键

内核会为每个XSI IPC(消息队列、信号量、共享内存)创建一个标识符来引用。比如消息队列,想给它发送消息或者从消息队列读出消息,需要知道它的队列标识符。创建IPC结构需要使用(msggetsemgetshmget这种xxxget函数),使用时需要指定一个key_t key当做外部名,来代指其内核的标识符内部名。

权限结构

每个ICP结构关联一个ipc_perm结构,规定了权限和所有者

struct ipc_perm
{
	uid_t uid;   //拥有者的user id
	gid_t gid;   //拥有者的group id
	uid_t cuid;  //创建者的user id
	gid_t cgid;  //创建者的group id
	mode_t mode; //访问模式
	......       //等等
};

创建IPC时会对每个字段赋初始值,后续可以通过xxxctl函数比如msgctlsemctlshmctl来修改uidgidmode字段。当然调用者必须是IPC的创建者或者超级用户root

结构限制

3种形式的XSI IPC都有内置限制,在linux下可以通过ipcs -l来查看:

weizhenkun@weizhenkun-VirtualBox:~/prac$ ipcs -l

---------- 消息限制 -----------
系统最大队列数量 = 32000
最大消息尺寸 (字节) = 8192
默认的队列最大尺寸 (字节) = 16384

---------- 同享内存限制 ------------
最大段数 = 4096
最大段大小 (千字节) = 18014398509465599
最大总共享内存 (千字节) = 18014398509481980
最小段大小 (字节) = 1

--------- 信号量限制 -----------
最大数组数量 = 32000
每个数组的最大信号量数目 = 32000
系统最大信号量数 = 1024000000
每次信号量调用最大操作数 = 500
信号量最大值=32767

优点和缺点

  1. IPC结构在系统范围内起作用,没有引用计数。例如,如果进程终止,消息队列及其内容不会被删除。它们会一直留在系统中直至发生下列动作为止:
    (1)由某个进程调用msgrcvmsgctl读消息或删除消息队列;
    (2)某个进程执行ipcrm(1)命令删除消息队列;
    (3)正在自举的系统删除消息队列。
    将此与pipe相比,当最后一个引用pipe的进程终止时,pipe就被完全地删除了。对于FIFO而言,在最后个引用FIFO的进程终止时,虽然FIFO 的名字仍保留在系统中,直至被显式地删除,但是留在FIFO中的数据已被删除了。
  2. 这些IPC结构在文件系统中没有名字。我们不能用第3章和第4章中所述的函数来访问它们或修改它们的属性。为了支持这些IPC对象,内核中增加了十几个全新的系统调用(msggetsemopshmat 等)。我们不能用ls命令查看IPC对象,不能用rm命令删除它们,也不能用chmod命令修改它们的访问权限。于是,又增加了两个新命令ipcs()ipcrm(1)

消息队列

相比于管道这种一股脑地把数据全部发给对方就完事的操作,通信效率低,我们需要一个高点儿的。这就来了消息队列。其可以双向通信克服了管道只能承载无格式字节流的缺点消息队列是消息的链表,存放在内核中并由消息队列标识符表示。 生命周期随内核,消息队列会一直存在,需要我们显示的调用接口删除或使用命令删除

基本表述:

  1. 消息队列提供了一个从一个进程向另一个进程发送数据块的方法,每个数据块都可以被认为是有一个类型,接受者接受的数据块可以有不同的类型。
  2. 但是同管道类似,它有的不足就是:
    (1) 每个消息的最大长度是有上限的(MSGMAX)
    (2) 每个消息队列的总长度的字节数(MSGMNB)
    (3) 系统上消息队列的总数上限(MSGMNI)。可以使用cat/proc/sys/kernel/msgmax查看具体的数据。
  3. 内核为每个IPC对象维护了一个数据结构struct ipc_perm,用于标识消息队列,让进程知道当前操作的是哪个消息队列。每一个msqid_ds表示一个消息队列,并通过msqid_ds.msg_firstmsg_last维护一个先进先出的msg链表队列,当发送一个消息到该消息队列时,把发送的消息构造成一个msg的结构对象,并添加到msqid_ds.msg_firstmsg_last维护的链表队列。

相关api:
1、msgget

//功能:创建和访问一个消息队列 
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
int msgget(key_t key, int msgflag);

参数和返回值:

  1. key:某个消息队列的名字,用ftok()产生
  2. msgflag:有两个选项IPC_CREATIPC_EXCL,单独使用IPC_CREAT,如果消息队列不存在则创建之,如果存在则打开返回;单独使用IPC_EXCL是没有意义的;两个同时使用,如果消息队列不存在则创建之,如果存在则出错返回。
  3. 返回值:成功返回一个非负整数,即消息队列的标识码,失败返回-1

2、ftok

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

参数和返回值:

  • 调用成功返回一个key值,用于创建消息队列,如果失败,返回-1

3、msgctl

//功能:消息队列的控制函数 
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
int msgctl(int msqid, int cmd, struct msqid_ds *buf);
  1. msqid:由msgget函数返回的消息队列标识码
  2. cmd:有三个可选的值,在此我们使用IPC_RMID
    IPC_STAT 把msqid_ds结构中的数据设置为消息队列的当前关联值
    IPC_SET 在进程有足够权限的前提下,把消息队列的当前关联值设置为msqid_ds数据结构中给出的值
    IPC_RMID 删除消息队列
  3. 成功返回0,失败返回-1

4、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);

参数和返回值:

  1. msgid:由msgget函数返回的消息队列标识码
  2. msgp:指针指向准备发送的消息
  3. msgze:msgp指向的消息的长度(不包括消息类型的long int长整型)
  4. msgflg:默认为0
  5. 返回值:成功返回0,失败返回-1

5、msgrcv

//功能:是从一个消息队列接受消息 
ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg); 

参数和返回值:

  • 参数:与msgsnd相同
  • 返回值:成功返回实际放到接收缓冲区里去的字符个数,失败返回-1

消息结构一方面必须小于系统规定的上限,另一方面必须以一个long int长整型开始,接受者以此来确定消息的类型

struct msgbuf
{
     long mtye;
     char mtext[1];
};

优缺点
优点:相比于管道,消息队列可以频繁在进程间频繁交换数据
缺点:
1.通信不及时
2.消息也有大小限制
3. 消息队列通信过程中,存在用户态与内核态之间的数据拷贝开销,因为进程写入数据到内核中的消息队列时,会发生从用户态拷贝数据到内核态的过程,同理另一进程读取内核中的消息数据时,会发生从内核态拷贝数据到用户态的过程。

共享内存

对于共享内存而言,其原理很粗暴,进程需要通信的原因就是linux用户态的进程之间的虚拟地址空间各自隔绝,没法访问别的进程中数据的地址,这样保证了安全。按照这个思路,我们完全可以从内存中划分一段区域,让两个进程可以同时访问到这一段物理内存。这样就相当于一个进程之间的全局变量。只要小心翼翼维护好这一段内存的使用,共享内存就是最快的通信方式!原理如下:
图片来源于网络
图片来自于陈同学的学习笔记,公众号<陈同学在搬砖>。

相关api:
1、 shmget

//创建共享内存
int shmget(key_t key, size_t size, int shmflg);

参数和返回值:

  1. 第一个参数,共享内存段的命名,shmget函数成功时返回一个与key相关的共享内存标识符(非负整数),用于后续的共享内存函数。调用失败返回-1.

    注:其它的进程可以通过该函数的返回值访问同一共享内存,它代表进程可能要使用的某个资源,程序对所有共享内存的访问都是间接的,程序先通过调用shmget函数并提供一个键,再由系统生成一个相应的共享内存标识符(shmget函数的返回值),只有shmget函数才直接使用信号量键,所有其他的信号量函数使用由semget函数返回的信号量标识符。

  2. 第二个参数,size以字节为单位指定需要共享的内存容量。

  3. 第三个参数,shmflg是权限标志,它的作用与open函数的mode参数一样,如果要想在key标识的共享内存不存在时,创建它的话,可以与IPC_CREAT做或操作。共享内存的权限标志与文件的读写权限一样,举例来说,0644,它表示允许一个进程创建的共享内存被内存创建者所拥有的进程向共享内存读取和写入数据,同时其他用户创建的进程只能读取共享内存。

2、shmat

//启动对共享内存的访问
void *shmat(int shm_id, const void *shm_addr, int shmflg);
//第一次创建完共享内存时,它还不能被任何进程访问,shmat函数的作用就是用来启动对该共享内存的访问,并把共享内存连接到当前进程的地址空间。
//如果一个进程想要访问这一段共享内存,需要将这个内存加载到自己的虚拟地址空间的某个位置
//通过 shmat 函数,就是 attach 的意思。其中 addr 就是要指定 attach 到这个地方。
//但是这个地址的设定难度比较大,除非对于内存布局非常熟悉,否则可能会 attach 到一个非法地址。
//所以,通常的做法是将 addr 设为 NULL,让内核选一个合适的地址。返回值就是真正被 attach 的地方。

参数和返回值:

  1. 第一个参数,shm_id是由shmget函数返回的共享内存标识。
  2. 第二个参数,shm_addr指定共享内存连接到当前进程中的地址位置,通常为空,表示让系统来选择共享内存的地址。
  3. 第三个参数,shm_flg是一组标志位,通常为0。
  4. 调用成功时返回一个指向共享内存第一个字节的指针,如果调用失败返回-1.

3、shmdt

//该函数用于将共享内存从当前进程中分离。注意,将共享内存分离并不是删除它,只是使该共享内存对当前进程不再可用。
int shmdt(void *addr); 

参数和返回值:

  • 参数shmaddr是shmat函数返回的地址指针,调用成功时返回0,失败时返回-1。

4、shmctl

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

参数和返回值:

  • 第一个参数,shm_idshmget函数返回的共享内存标识符。
  • 第二个参数,command是要采取的操作,它可以取下面的三个值 :
    IPC_STAT:把shmid_ds结构中的数据设置为共享内存的当前关联值,即用共享内存的当前关联值覆盖shmid_ds的值。
    IPC_SET:如果进程有足够的权限,就把共享内存的当前关联值设置为shmid_ds结构中给出的值
    IPC_RMID:删除共享内存段
  • 第三个参数,buf是一个结构指针,它指向共享内存模式和访问权限的结构。
struct shmid_ds
{
	uid_t shm_perm.uid;
	uid_t shm_perm.gid;
	mode_t shm_perm.mode;;

如果两个进程 attach 同一个共享内存,大家都往里面写东西,很有可能就冲突了。例如两个进程都同时写一个地址,那先写的那个进程会发现内容被别人覆盖了。所以我们对其进行数据操作的时候要格外小心,需要用信号量(Semaphore)来维护进程的同步和互斥。因此,信号量和共享内存往往要配合使用。

信号量

信号量其实是一个计数器,用来表示资源使用情况的数据结构,主要用于实现进程间的互斥与同步,而不是用于存储进程间通信数据。它用来表示资源的使用状况。主要类型有数组、整数,其值有P,V操作改变:

  • 当它的值大于0时,表示当前可用资源的数量
  • 当它的值小于0时,其绝对值表示等待使用该资源的进程个数。

信号量的增加或减少应该都是原子操作。

整形信号量

整型信号量被定义为一个用于表示资源数目的整型量S,wait和signal操作可描述为:

wait(S)
{    
	while (S<=0);    
	S=S-1;
}
signal(S)
{    
	S=S+1;
}

wait操作中,只要信号量S<=0,就会不断地测试。因此,该机制并未遵循“让权等待”的准则,而是使进程处于“忙等”的状态。

记录型信号量

记录型信号量是不存在“忙等”现象的进程同步机制。除了需要一个用于代表资源数目的整型变量value外,再增加一个进程链表L,用于链接所有等待该资源的进程,记录型信号量是由于釆用了记录型的数据结构得名。

记录型信号量可描述为:

typedef struct
{   int value;    
	struct process *L;
}semaphore ;

//相应的wait(S)和signal(S)的操作如下:
void wait (semaphore S)
{ 	
	//相当于申请资源    
	S.value--;    
 	if(S.value<0) 
 	{        
 	add this process to S.L;        
 	block(S.L);    
 	}
}
//wait操作,S.value--,表示进程请求一个该类资源,当S.value<0时,表示该类资源已分配完毕,因此进程应调
//用block原语,进行自我阻塞,放弃处理机,并插入到该类资源的等待队列S.L中,可见该机制遵循了“让权等待”的
//准则。
void signal (semaphore S) 
{  
	//相当于释放资源    
	S.value++;    
	if(S.value<=0)
	{        
		remove a process P from S.L;        
		wakeup(P);    
	}
}
//signal操作,表示进程释放一个资源,使系统中可供分配的该类资源数增1,故S.value++。若加1后仍是
//S.value<=0,则表示在S.L中仍有等待该资源的进程被阻塞,故还应调用wakeup 原语,将S.L中的第一个等待进
//程唤醒。

这里直接上一个小程序:
首先是share.h

//
// Created by weizhenkun on 2020/8/12.
//
#ifndef TEST_SHARE_H
#define TEST_SHARE_H
#include <stdio.h>
#include <stdlib.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/types.h>
#include <sys/sem.h>
#include <string.h>
#include <unistd.h>
#define MAX_NUM 128

struct shm_data {
    int data[MAX_NUM];
    int datalength;
};
union semun {
    int val;
    struct semid_ds *buf;
    unsigned short int *array;
    struct seminfo *__buf;
};

int get_shmid(){
    int shmid;
    key_t key;
    if((key = ftok("sharememory/sharememorykey", 1024)) < 0){
        perror("ftok error");
        return -1;
    }
    shmid = shmget(key, sizeof(struct shm_data), IPC_CREAT|0777);
    return shmid;
}

int get_semaphoreid(){
    int semid;
    key_t key;
    if((key = ftok("sharememory/semaphorekey", 1024)) < 0){
        perror("ftok error");
        return -1;
    }
    semid = semget(key, 1, IPC_CREAT|0777);
    return semid;
}

int semaphore_init (int semid) {
    union semun argument;
    unsigned short values[1];
    values[0] = 1;
    argument.array = values;
    return semctl (semid, 0, SETALL, argument);
}

int semaphore_p (int semid) {
    struct sembuf operations[1];
    operations[0].sem_num = 0;
    operations[0].sem_op = -1;
    operations[0].sem_flg = SEM_UNDO;
    return semop (semid, operations, 1);
}

int semaphore_v (int semid) {
    struct sembuf operations[1];
    operations[0].sem_num = 0;
    operations[0].sem_op = 1;
    operations[0].sem_flg = SEM_UNDO;
    return semop (semid, operations, 1);
}
#endif //TEST_SHARE_H

然后是producer.cpp


#include "share.h"

int main() {
    void *shm = NULL;
    struct shm_data *shared = NULL;
    int shmid = get_shmid();
    int semid = get_semaphoreid();
    int i;

    shm = shmat(shmid, (void*)0, 0);
    if(shm == (void*)-1){
        exit(0);
    }
    shared = (struct shm_data*)shm;
    memset(shared, 0, sizeof(struct shm_data));
    semaphore_init(semid);
    while(1){
        semaphore_p(semid);
        if(shared->datalength > 0){
            semaphore_v(semid);
            sleep(1);
        } else {
            printf("how many integers to caculate : ");
            scanf("%d",&shared->datalength);
            if(shared->datalength > MAX_NUM){
                perror("too many integers.");
                shared->datalength = 0;
                semaphore_v(semid);
                exit(1);
            }
            for(i=0;i<shared->datalength;i++){
                printf("Input the %d integer : ", i);
                scanf("%d",&shared->data[i]);
            }
            semaphore_v(semid);
        }
    }
}

最后是consumer.cpp


#include "share.h"

int main() {
    void *shm = NULL;
    struct shm_data *shared = NULL;
    int shmid = get_shmid();
    int semid = get_semaphoreid();
    int i;

    shm = shmat(shmid, (void*)0, 0);
    if(shm == (void*)-1){
        exit(0);
    }
    shared = (struct shm_data*)shm;
    while(1){
        semaphore_p(semid);
        if(shared->datalength > 0){
            int sum = 0;
            for(i=0;i<shared->datalength-1;i++){
                printf("%d+",shared->data[i]);
                sum += shared->data[i];
            }
            printf("%d",shared->data[shared->datalength-1]);
            sum += shared->data[shared->datalength-1];
            printf("=%d\n",sum);
            memset(shared, 0, sizeof(struct shm_data));
            semaphore_v(semid);
        } else {
            semaphore_v(semid);
            printf("no tasks, waiting.\n");
            sleep(1);
        }
    }
}

程序的主要作用是,producer.cpp负责写入数据,每次输入一个num,再输入num个数字,写入共享内存,要求consumer.cpp从共享内存中拿数据进行累加,打印出来,具体解读:

  1. share.h中定义了共享内存和信号量的id初始化函数get_shmid()get_semaphoreid(),并对信号量进行了初始化semaphore_init()。还实现了信号量的pv操作:semaphore_p()semaphore_v()。对放入共享内存的数据进行了约定,用结构体shm_data来放入数据
  2. producer.cpp负责输入数据,将数据写入共享内存,所以其具体逻辑就是:
    (1) 初始化好共享内存和信号量
    (2) 调用shmat,把producer进程映射到一段物理内存上(由OS自己选择),将获取到的内存地址转换为shm_data类型变量shared便于使用。接着准备进行具体业务处理,准备写入shared数据,这里就要用到信号量,先初始化semaphore_init,准备循环下面(3)(4)
    (3) 进入先P操作,semaphore_p将信号量减1
    (3.1)进行业务处理,先看shared长度是否大于0,如果大于0,说明consumer还没读。调用V操作semaphore_v让信号量+1,并阻塞
    (3.2)小于0,输入要处理数据个数,过多的话会V操作,然后干掉producer。如果数据个数正常就输入,然后V操作让consumer去处理

consumer逻辑类似,不多介绍了。值得一提的是,共享内存一般所映射的物理地址是在堆栈区之间的地址区域。共享内存+信号量的通信方式很重要,很多开源软件中都使用到了这种方式。

本地Socket(UNIX域套接字)

UNIX域套接字和网际间Socket不同,虽然网络Socket也能做类似的操作,即:在同一台主机上为两个进程提供通信服务。但是UNIX域套接字比网络Socket效率更高不用执行类似于TCP协议处理,也不用添加什么网络报头信息,计算校验和,发送确认报文等等冗余操作。UNIX域套接字提供流和数据报两种接口。UNXI域套接字数据报服务是可靠的,不会丢失报文或出错

系统函数api:

#include<sys/socket.h>
int socketpair(int domain,int type,int protocol,int sockfd[2]); //成功返回0,失败返回-1

一堆相互连接的UNIX域套接字可以起到全双工管道的作用,两端对读写都开放。叫做fd管道,(fd-pipe
在这里插入图片描述

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值