【Linux】第七篇:进程间通信


在这里插入图片描述


1. 进程间通信(IPC:interprocess communication)

理解

在操作系统中,一个进程可以理解为一个计算机资源的一次运行活动,即一个执行程序的实例。每个进程拥有自己的PCB,每个进程彼此之间独立。

为了使得多个进程能够在同一时间协同工作,便要让进程之间通信来相互传递交换信息。
而让两个进程能够通信,前提便是让两个进程可以看到同一份资源。

目的

  • 数据传输 :进程间数据可以交互
  • 资源共享 :由于进程间的独立性,于是可以借助第三方的资源使得进程可以共同访问。通过向这个第三方资源写入或是读取数据,进而实现进程之间的通信。
  • 事件通知 :进程间可收发信息。比如A进程做完一部分工作,A通知B进行后续工作。
  • 进程控制 :一个进程可完全控制另一部分进程的执行,如debug进程,控制进程可拦截其中一些进程的异常,并获取状态。

分类

进程间通信的发展:管道->system IPC->POSIX IPC

分类如下

  • 管道
  • 共享内存
  • 信号量
  • 消息队列

2. 管道

匿名管道

恰如其名,管道就如同生活中的水管,一端输送而另一端接受。管道是最基本的进程间通信方式。

在shell指令中,我们之前也有用过管道指令 —— |

例如 ls -l | grep *.txt

ls 指令是一个进程,获得当前目录所有文件名打印到屏幕上,但是通过管道,我们再通过grep进程,对这些文件名先进行筛选,挑选出后缀为.txt的文件名再得以输出。

特点

  • 数据只能在一个方向上流动,具有固定的读端和写端。
  • 只能用于亲缘关系的进程之间的通信(父子进程以及兄弟进程)
  • 管道可以视为一种特殊的文件,它可以使用普通的IO函数如read、write等,但它并不属于任何文件系统,并且只存在于内存中。

站在文件描述符角度理解管道

  1. 父进程创建管道

  1. 父进程fork子进程

  1. 父进程关闭 fd[0] ,子进程关闭fd[1]

创建匿名管道函数 —— pipe

系统调用接口 —— pipe

  • 函数声明
#include <unistd>
int pipe(int pipedfd[2]);
  • 参数

    • pipefd:文件描述符数组,存放了管道两端的文件描述符。
    • pipefd[0]指向了管道的读端,
    • pipefd[1]指向了管道的写端。

写入管道的数据由内核进行缓冲,直到从管道端的读取端读取为止。

  • 返回值

    成功返回0,失败返回-1

  • 用法

    一个进程fork一个子进程,那么子进程将会复制父进程的内存空间信息,注意这里是复制而不是共享,这意味着父子进程仍然是独立的,但是在这一时刻,它们所有的信息又是相等的。因此子进程也知道该全局管道,并且也拥有两个文件描述符与管道挂钩,所以匿名管道只能在具有亲缘关系的进程间通信。

    还要注意,匿名管道被设计为半双工的,一方要写入则必须关闭读描述符,一方要读出则必须关闭写入描述符。因此我们说管道的消息只能单向传递。

  • 实例代码

#include <stdio.h>
#include <sys/types.h>
#include<sys/stat.h>
#include <unistd.h>
#include <fcntl.h>
#include <string.h>
#include <stdlib.h>
int main(int argc,char* argv[])
{
    int pipefd[2];
    pipe(pipefd);
  
    pid_t id=fork();

    if(id==0)
    {
        //child
        close(pipefd[0]);
        printf("child deliver\n");
        const char*  message="hello pipe";
        while(1)
        {
            write(pipefd[1],message,strlen(message));
            sleep(1);
        }
    }
    else
    {
        //father
        close(pipefd[1]);
        char buffer[64];
        while(1)
        {
            ssize_t size=read(pipefd[0],buffer,sizeof(buffer)-1); 
            if(size>0)
            {
                buffer[size]=0;
                printf("father gets :%s\n",buffer);
            }
            else if(size==0)
            {
                printf("end of file\n");
            }
        }
    }
  
    return 0;
}

管道的四个读写规则

  1. 当没有数据可读时——管道为空

    如当写入速度小于读取速度时,管道大部分时间处于空的状态,read调用产生阻塞,等待管道的数据填入。

    • O_NONBLOCK disable :read 调用阻塞,读取方进程R->S,直至管道进新数据。
    • O_NONBLOCK enable :read调用返回-1,errno值为EAGAIN。
  2. 当管道满时

    如当写入速度快于读取速度时,管道长时间处于“满”状态,再写入数据,需要先将管道读走一些数据。

    • O_NONBLOCK disable :write 调用阻塞,写入方进程R->S,直至管道读走数据。
    • O_NONBLOCK enable :write调用返回-1,errno值为EAGAIN。
  3. 如果将管道写端关闭(close(pipefd[1])),read会将管道文件读完,再也读不到任何字节后,返回0。

  4. 如果管道读端关闭(close(pipefd[0])),write操作便没有了意义,操作系统发送信号 SIGPIPE,写端进程会被kill退出。(不建议这么操作,因为会错失管道中的剩余内容)

    当我们关闭父进程的读端后,写端的子进程将会终止变为僵尸态。

    运行程序,通过脚本查看进程状态

    while : ; do ps axj | grep mypipe | grep -v grep ;echo "###########################" ; sleep 1;done;

    我们再通过wait调用获取status(低7位),查看子进程终止的返回码:

#include <stdio.h>
#include <sys/types.h>
#include<sys/stat.h>
#include <unistd.h>
#include <fcntl.h>
#include <string.h>
#include <stdlib.h>
#include <wait.h>
int main(int argc,char* argv[])
{
    int pipefd[2];

    pipe(pipefd);
    //printf("%d,%d\n",pipefd[0],pipefd[1]);
  
    pid_t id=fork();

    if(id==0)
    {
        //child
        close(pipefd[0]);
        printf("child deliver\n");
        const char*  message="hello pipe";
        while(1)
        {
            write(pipefd[1],message,strlen(message));
        }
    }
    else
    {
        close(pipefd[1]);
        char buffer[64];
        int cnt=0;
        while(1)
        {
  
            ssize_t size=read(pipefd[0],buffer,sizeof(buffer)-1); 
  
            if(size>0)
            {
                buffer[size]=0;
                printf("father gets :%s\n",buffer);
                sleep(1);
            }
            else if(size==0)
            {
                printf("end of file\n");
            }
            if(cnt++==5)//5秒后关闭管道读端
            {
                 close(pipefd[0]);
                 break;
            }

        }
        int status;
        waitpid(id,&status,0);
        printf("status:%d\n",status & 0x7F);
    }
  
    return 0;
}

匿名管道的特点

  1. 管道的生命周期是随进程的。

  2. 内核对于管道进行同步与互斥。

    • 同步:A进程接收数据的速度会与B进程写入数据的速度协同步调
    • 互斥:一条管道在同一时刻,只能被一个进程使用。
  3. 匿名管道只在亲缘关系的进程之间通信。

  4. 管道为半双工通信模式,只是单方向流通,若需双方通信则需建立两根管道。

    • 单工通信(Simplex Communication):单工模式的数据传输是单向的。通信双方中,一方固定为发送端,另一方固定为接收端。
    • 半双工通信(Half Duplex):半双工数据传输指数据可以在一个信号载体的两个方向上传输,但是不能同时传输。
    • 全双工通信(Full Duplex):全双工通信允许数据在两个方向上同时传输,它的能力相当于两个单工通信方式的结合。全双工可以同时(瞬时)进行信号的双向传输。
  5. 管道为流式服务:写入与读取数据的多少是任意的。与之相对的是数据报文,每次数据的分割有固定大小。

回到我们的系统指令 |

sleep 1000 | sleep 2000

我们查看此指令的进程:

可见管道两边的bash进程相同,所以他们是兄弟进程。可见 | 是匿名管道。

对于指令 ls -l | grep *.txt

bash 创建两个子进程,管道左侧指令负责写入,所以关闭pipefd[0],同时将标准输出重定向到pipefd[1],即将输出的内容输入至管道里;管道右侧指令负责读取,所以关闭pipefd[1],同时将标准输入重定向到pipefd[0],即从标准输入的读取转为从管道读取;

命名管道

匿名管道只能限制在亲缘关系的进程间通信,而命名管道则是帮助两个毫无相关的两个进程建立通信(路径+文件名可保证文件的唯一性,同时可以让多个独立进程进行访问)。

进程间通信的本质就是让多个进程看到共享资源,文件可以是一种共享的资源,而命名空间也是一种特殊的文件,但是他只活动在内存空间,并不往磁盘写入,所以可以提成通信的效率。

命令行创建命名管道

  • shell指令 mkfifo [OPTION]... NAME..

    p代表管道文件,我们使用shell脚本向管道写入字符,另一进程同时读取管道中的字符并输出至屏幕,注意此时的两个进程将是毫不相关的:

系统调用创建命名管道

  • 函数声明
#include <sys/types.h>
#include <sys/stat.h>

int mkfifo(const char *pathname, mode_t mode);
  • 返回值

    成功返回0,失败返回-1

  • 参数

    • pathname 创建的命名管道文件名(默认创建在当前路径亦可指定绝对路径)

    • mode 文件的权限

      实际上我们设定的 mode 值会被系统处理为 mode & (~umask),umask系统默认为0002,若我们设定mode为0666,那么实际上创建的管道文件为0664。

      避免umask捣乱,我们可以在创建管道文件前,提前将umask设为0。

      umask(0);//将文件默认掩码设为0
      
  • 函数功能描述

    fifo管道与pipe类似,区别在于fifo会进入文件系统。一旦创建了fifo文件,任何一个进程就能对其进行读写,就像对待一个常规文件一样。

命名管道应用实例

实现服务端于客户端的字节流通信。

  • 服务端(server.c)

任务:负责接受来自客户端的信息,首先运行服务端,创建并以读方式打开管道文件,读取管道内容输出至屏幕上。

#include "common.h"
int main()
{
    umask(0);
    if(mkfifo(MYFIFO,0666)<0)
    {
        perror("mkfifo");
        exit(1);
    }

    //creat fifo success
    int fd=open(MYFIFO,O_RDONLY);
    if(fd<0)
    {
        perror("open");
        exit(2);
    }

    //业务逻辑
    while(1)
    {
        char msg[128]={0};
        ssize_t s=read(fd,msg,sizeof(msg)-1);
        if(s>0)
        {
            msg[s]=0;
            printf("client message #:%s\n",msg);
        }
        else if(s==0)
        {
            printf("client quit\n");
            break;
        }
        else
        {
            perror("read error\n");
            break;
        }
    }
  
    close(fd);
    return 0;
}

  • 客户端(client.c)

任务:无需再创建管道文件,服务端已经创建。以写方式打开管道文件,将内容写至命名管道中。

#include "common.h"
int main()
{
    int fd=open(MYFIFO,O_WRONLY);
    if(fd<0)
    {
        perror("open");
        exit(2);
    }

    while(1)
    {
        printf("client:");
        fflush(stdout);
        char msg[128]={0};
        //客户端进程获取标准输入的内容,存放于msg数组中
        ssize_t s=read(0,msg,sizeof(msg)-1);//s记录了读取字符数
        msg[s-1]=0;//抛弃尾端的换行符
        if(s>0)
        {
            write(fd,msg,strlen(msg));
        }
    }
    close(fd);
    return 0;
}
  • 所需头文件 common.h
#pragma once
#include <stdio.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#define MYFIFO "./fifo"

  • 运行实例

先运行服务端(创建命名管道),随后运行客户端:

(左侧为客户端,右上为服务端,右下查看进程状态)

成功运行,查看进程状态亦可得知我们在完全没有亲缘关系的进程间实现了通信。

注意

  1. 如果提前关闭sever读端的管道,那么client写端的管道也没有了存在的意义,操作系统会强制退出client进程,这里的表现和匿名管道一样。
  2. 在进程通信时管道里的数据始终流于内存中,不写入磁盘,所以命名管道的文件大小将始终是0。

命名管道派发任务

既然我们做到了客户端向服务端的通信,那么就不能放过这个机会,我们可以让服务端解析客户端的字符串,使用exec系列函数来执行某些指令!

只需对服务端进行修改即可

这里我们让服务端添加三个字符串解析:

  • “showfile” -> ls -l
  • “run” -> sl
  • “path” -> pwd

改动后的server.c

#include<stdio.h>
#include "common.h"
int main()
{
    umask(0);
    if(mkfifo(MYFIFO,0666)<0)
    {
        perror("mkfifo");
        exit(1);
    }

    //creat fifo success
    int fd=open(MYFIFO,O_RDONLY );
    if(fd<0)
    {
        perror("open");
        exit(2);
    }

    //业务逻辑
    while(1)
    {
        char msg[128]={0};
        ssize_t s=read(fd,msg,sizeof(msg)-1);
        if(s>0)
        {
            msg[s]=0;
            //解析处理字符串
            if(strcmp(msg,"showfile")==0)
            {
                //子进程进程替换
                if(fork()==0)
                {
                    execl("/usr/bin/ls","ls","-l",NULL);   
                    exit(1);
                }
                waitpid(-1,NULL,0);
            }
            else if(strcmp(msg,"run")==0)
            {
                if(fork()==0)
                {
                    execl("/usr/bin/sl","sl",NULL);   
                    exit(1);
                }
                waitpid(-1,NULL,0);
            }
            else if(strcmp(msg,"path")==0)
            {
                if(fork()==0)
                {
                    execl("/usr/bin/pwd","pwd",NULL);   
                    exit(1);
                }
                waitpid(-1,NULL,0);
            }
            else 
            {
                printf("client message #:%s\n",msg);
            }
        }
        else if(s==0)
        {
            printf("client quit\n");
            break;
        }
        else
        {
            perror("read error\n");
            break;
        }
    }
  
    close(fd);
    return 0;
}

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

  • 匿名管道的系统调用pipe,一旦调用,就创建了管道并且直接open,所以只能亲缘进程通信。
  • 命名管道的系统调用mkfifo只负责创建管道文件,打开则需要进程自主打开,命名的意义则是为了让不相关的进程找到可共同访问的路径。

3. 共享内存

之前谈到过进程间通信的本质是为了让不同的进程有可共同访问的空间。而每个进程都有页表使其进程地址空间与物理内存空间产生映射,让不同进程的虚拟地址通过页表映射到物理内存的同一块区域,这块区域便称之为共享内存

共享内存是效率最高的IPC机制,因为他不涉及进程之间的任何数据传输。

使用共享内存的一般步骤是:
1. 创建共享内存空间并获取共享内存对象的ID
2. 将共享内存映射至本进程虚拟内存空间的某个区域(挂接)
3. 当不再使用时,解除映射关系(接触挂接)
4.当没有进程再需要这块共享内存时,删除。

Linux共享内存的API都定义在sys/shm.h头文件中,包括4个系统调用可对应上述4个步骤:shmget、shmat、shmdt、shmctl,接下来将依次讨论。

shmget 系统调用——创建共享内存

shmget 创建一段新的共享内存,或者获取一段已经存在的共享内存。

#include <sys/ipc.h>
#include <sys/shm.h>

int shmget(key_t key, size_t size, int shmflg);
  • 参数

    • key: 键值,用来标识全局唯一的共享内存。

      共享内存一定要有标识唯一性的ID,方便让不同的进程识别同一个共享内存资源。所以我们需要提前告知进程这个唯一标识符:key

      这里需要通过函数 ftok()来得到key值:

      #include <sys/types.h>
      #include <sys/ipc.h>
      
      key_t ftok(const char *pathname, int proj_id);
      
      • pathname:一个确实存在且可以访问的文件路径名

      • proj_id:自定义项目id

        成功,生成并返回key值,失败返回-1。这个key值是多少我们不用关心,只要知道它保证了用户层面的唯一性即可,而且shmget会将key值设置进共享内存的数据结构中。

      也就是说,如果两个进程调用ftok的数据是一样的,那就可以得到一样的key值,进而可以访问同一共享内存。(注意共享内存可以同时存在多份的,由双向链表管理)

    • size :预设共享内存的大小(单位字节),需设置为PAGE_SIZE(4096字节)的整数倍。如果用户申请的size不是4KB的整数倍,实际上操作系统在为共享内存划分的区间仍然为4KB的整数倍,而用户所能使用的确确实实还是实际size的大小。

    • shmflg :创建共享内存的标志(IPC选项), 其意义有点类似于open函数中的O_CREAT,O_RDONLY等,

      IPC选项作用
      单独使用IPC_CREAT,或shmflg为0如果key对应的共享内存空间不存在,则新建。若key对应内存已存在,则返回。(不会空手而归)
      IPC_CREAT I IPC_EXCL如果key对应的共享内存空间不存在,则新建。如果key对应的共享内存已存在则报错(只要最新的共享内存),单独使用IPC_EXCL是没用的
      SHM_HUGETLB使用“大页面”来分配共享内存
      SHM_NORESERVE不在交换分区中为这块共享内存保留空间
      mode共享内存的访问权限(八进制,如 0644)
  • 返回值

    成功,返回共享内存的身份标识符(全局唯一),失败返回-1;

    那key值和shmid的存在什么联系呢?

    key:在系统层面标识唯一性,不能用来管理shm

    shmid:OS返回的共享内存id,用于管理shm。

    所以当我们对shm进行挂接,删除等操作时,实际上使用的是shmid。

  • 示例代码

这里我们依旧使用C/S模型来测试

头文件

//common.h
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <string.h>
#include <sys/wait.h>
#define MYPATH_NAME "./"
#define PROJ_ID 0x123
#define SIZE 4097

server.c —— 创建共享内存

int main()
{
    key_t key=ftok(MYPATH_NAME,PROJ_ID);
    if(key<0)
    {
        perror("ftok");
        return 1;
    }

    int shmid=shmget(key,SIZE,IPC_CREAT|IPC_EXCL|0644);

    if(shmid==-1)
    {
        perror("shmget");
        return 2;
    }
    printf("key:%d,shmid%d\n",key,shmid);

    return 0;
}

创建完成后,我们可以使用ipcs -m 来查看当前的共享内存。

ipcs 指令

用于查看进程间通信设施的状态,显示的信息包括消息列表、共享内存和信号量的信息 。

  • -a 默认的输出信息
  • -m 打印出使用共享内存进行进程间通信的信息
  • -q 打印出使用消息队列进行进程间通信的信息
  • -s 打印出使用信号进行进程间通信的信息
标题含义
key系统区别各个共享内存的唯一标识
shmid共享内存的用户层id(句柄)
owner共享内存的拥有者
perms共享内存的权限
bytes共享内存的大小
nattch关联共享内存的进程数
status共享内存的状态

这里可以看到我们上述代码创建的共享内存,可以看到server进程结束,共享内存还没有小时,这里需要说明system的IPC资源生命周期是跟随内核的。
要关闭共享内存资源,可以使用指令,systemcall 或者os重启得以释放。

ipcrm 指令

用来删除一个或更多的消息队列、信号量集或者共享内存标识。

  • -m SharedMemoryid: 删除共享内存

  • -q Messageid: 删除消息队列标识 MessageID 和与其相关的消息队列和数据结构

  • -s Semaphoreid: 删除信号量标识 SemaphoreID 和与其相关的信号量集及数据结构

    标题含义
    key系统区别各个共享内存的唯一标识
    shmid共享内存的用户层id(句柄)
    owner共享内存的拥有者
    perms共享内存的权限
    bytes共享内存的大小
    nattch关联共享内存的进程数
    status共享内存的状态

shmctl 系统调用——控制共享内存

  • 函数声明
#include <sys/ipc.h>
#include <sys/shm.h>

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

    • shmid 共享内存ID
    • cmd 共享内存操作标志
      标志含义
      IPC_STAT获取属性信息,放置到 buf 中
      IPC_SET设置属性信息为 buf 指向的内容
      IPC_RMID(remove id)将共享内存标记为“即将被删除”状态
    • buf 共享内存的结构体
  • 示例代码

我们用这函数进行共享内存的删除工作,使用cmd:IPCRMID,别的cmd操作暂不考虑。此时buf置为NULL。

我们在上述shmget的server.c 代码后面添加如下代码,删除刚创建的共享内存:

// server.c

int main()
{
    ...

    sleep(10);

    shmctl(shmid,IPC_RMID,NULL);

    printf("key:%0x,shmid :%d delete\n",key,shmid);

    sleep(5);

    return 0;
}

复制渠道,输入脚本 while : ; do ipcs -m ; sleep 1 ;echo "###############" ; done 实时查看共享内存状态

shmat 系统调用——挂接共享内存(attach)

将当前进程挂接到指定的共享内存上

  • 函数声明
#include <sys/types.h>
#include <sys/shm.h>

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

    • shmid :共享内存ID
    • shmaddr :指定连接的地址,1.如果为 NULL,则系统会自动选择一个合适的虚拟内存空间地址去映射共享内存。2.如果不为 NULL,则系统会根据 shmaddr 来选择一个合适的内存区域。
    • shmflg :操作选项
      标志含义
      0读写方式挂接
      SHM_RDONLY以只读方式映射共享内存
      SHM_REMAP重新映射,此时 shmaddr 不能为 NULL
      SHM_RND自动选择比 shmaddr 小的最大页对齐地址

    说明:

    • 共享内存只能以只读或者可读写方式映射,无法以只写方式映射。

    • 当前进程必须要拥有访问共享内存的权限,也就是说在我们创建共享内存时,必须在shmflg给到相应mode。

    • shmat()第二个参数shmaddr一般都设为NULL,让系统自动找寻合适的地址。但当其确实不为空时,那么要求 SHM_RND 在 shmflg 必须被设置,这样的话系统将会选择比 shmaddr 小而又最大的页对齐地址(即为 SHMLBA 的整数倍)作为共享内存区域的起始地址。

      如果没有设置 SHM_RND,那么 shmaddr 必须是严格的页对齐地址。总之,映射时将shmaddr设置为NULL是更明智的做法,因为这样更简单,也更具移植性。

    • 解除映射之后,进程不能再允许访问 SHM。

  • 返回值

    成功,返回共享内存映射到虚拟进程空间的起始地址,有点类似于c语言的malloc函数。失败,返回-1;

  • 示例代码

在上述server.c的基础上,再添加进挂接步骤,代码部分与下面介绍的shmdt(去挂接)合并到一起,这里不再贴出。

shmdt 系统调用——共享内存去挂接(detach)

  • 函数声明
#include <sys/types.h>
#include <sys/shm.h>

int shmdt(const void *shmaddr);
  • 参数

    • shmaddr : 此共享内存映射到虚拟进程空间的起始地址,即shmat时的返回值。
  • 示例代码

//server.c
#include "common.h"
int main()
{
    //获取key
    key_t key=ftok(MYPATH_NAME,PROJ_ID);
    if(key<0)
    {
        perror("ftok");
        return 1;
    }

    //创建共享内存
    //这里mode设置为664(八进制),
    //注意若不设置mode,mode将默认为0,则进程没有挂接权限。
    int shmid=shmget(key,SIZE,IPC_CREAT|IPC_EXCL|0664);

    if(shmid==-1)
    {
        perror("shmget");
        return 2;
    }
    printf("key:%0x,shmid:%d\n",key,shmid);
  

    //挂接共享内存
    printf("attach begin\n");
    sleep(5);
    char* mem_address=shmat(shmid,NULL,0);
    if(mem_address==(void*)-1)
    {
        perror("shmat");
        return 1;
    }
    printf("attach success\n");
    sleep(1);


    //使用共享内存

    //去挂接共享内存
    printf("detach begin\n");
    sleep(1);
    shmdt(mem_address);
    sleep(1);
    printf("detach success\n");

    //释放共享内存
    shmctl(shmid,IPC_RMID,NULL);
    printf("key:%0x,shmid :%d delete\n",key,shmid);


    return 0;
}

共享内存进程通信实验

只要保证两个进程获取到一样的 key,就能保证进程可访问同一共享内存。

我们在这里设计两个进程:client.c(发送数据),server.c(接受数据)

client.c

#include "common.h"

int main()
{
    key_t key=ftok(MYPATH_NAME,PROJ_ID);
    if(key<0)
    {
        perror("ftok");
        return 1;
    }
    printf("%x\n",key);

    //通过key找到shmid
    int shmid=shmget(key,SIZE,IPC_CREAT);
    if(shmid<0)
    {
        perror("shmget");
        return 2;
    }

    //挂接
    char* mem_address=(char*)shmat(shmid,NULL,0);
    //传输
    int i=0;
    while(1)
    {
        mem_address[i]='A'+i;
        i++;
        mem_address[i]=0;
        sleep(1);
    }
    //去挂接
    shmdt(mem_address);
    return 0;
}

server.c

#include "common.h"
int main()
{
    key_t key=ftok(MYPATH_NAME,PROJ_ID);
    if(key<0)
    {
        perror("ftok");
        return 1;
    }

    //创建共享内存
    int shmid=shmget(key,SIZE,IPC_CREAT|IPC_EXCL|0664);
    if(shmid==-1)
    {
        perror("shmget");
        return 2;
    }
    printf("key:%0x,shmid:%d\n",key,shmid);
  
    //挂接共享内存
    sleep(5);
    char* mem_address=(char*)shmat(shmid,NULL,0);
    if(mem_address==(void*)-1)
    {
        perror("shmat");
        return 1;
    }
  
    //通信
    while(1)
    {
        printf("client message# %s\n",mem_address);
        sleep(1);
    }

    shmdt(mem_address);
    //释放共享内存
    shmctl(shmid,IPC_RMID,NULL);

    return 0;
}

实现了字符串的通信:
在这里插入图片描述

共享内存内核数据结构

shmid_ds

struct shmid_ds {
	struct	ipc_perm shm_perm;	/* operation perms */
	int	shm_segsz;		/* size of segment (bytes) */
	time_t	shm_atime;		/* last attach time */
	time_t	shm_dtime;		/* last detach time */
	time_t	shm_ctime;		/* last change time */
	unsigned short	shm_cpid;	/* pid of creator */
	unsigned short	shm_lpid;	/* pid of last operator */
	short	shm_nattch;		/* no. of current attaches */
	/* the following are private */
	unsigned short   shm_npages;  /* size of segment (pages) */
	unsigned long   *shm_pages;   /* array of ptrs to frames -> SHMMAX */ 
	struct shm_desc *attaches;    /* descriptors for attaches */
};

struct ipc_perm

struct ipc_perm
{
  key_t  key;
  ushort uid;   /* owner euid and egid */
  ushort gid;
  ushort cuid;  /* creator euid and egid */
  ushort cgid;
  ushort mode;  /* access modes see mode flags below */
  ushort seq;   /* sequence number */
};

在内核层面,多个进程区分共享内存唯一性——key值

在进程层面,区分共享内存使用的是shmid。

共享内存是所有进程间通信方式中最快的一种通信方式,因为该通信方式需要进行的拷贝次数最少。

但是共享内存也是有缺点的,我们知道管道是自带同步与互斥机制的,但是共享内存并没有提供任何的保护机制,包括同步与互斥。

4. 消息队列

也是进程间通信的一种,可以理解为队列的链表数据结构,每个单元为一个数据块,记录着消息类型和消息内容和一些属性信息。

其操作主要也是四个函数,msgget,msgctl,msgsnd和msgrcv分别对应着创建消息队列,控制消息队列,往消息队列添加信息,获取消息队列的信息。

msgget 系统调用——创建消息队列

  • 函数声明
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>

int msgget(key_t key, int msgflg);
  • 参数

    • key 和共享内存的shmget函数一致,需要ftok构建,key_t ftok(const char *pathname, int proj_id);
    • msgflag
      标志含义
      IPC_CREAT如果key对应的MSG不存在则创建该消息队列,已存在则返回
      IPC_EXCL与IPC_CREAT一起使用,如果该 key 对应的 MSG 已经存在,则报错。(只要最新)
      modeMSG的访问权限(八进制,如0644)
  • 返回值

    成功返回消息队列ID,失败返回-1。

msgctl 系统调用——消息队列的控制(删除)

  • 函数声明
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>

int msgctl(int msqid, int cmd, struct msqid_ds *buf);
  • 参数
    • msqid : msgget返回的消息队列id

    • cmd :

      标志含义
      IPC_RMID立即删除该MSG,并且唤醒所有阻塞在该 MSG 上的进程,同时忽略第三个参数

      cmd 也有其他标志 可通过指令 man msgctl自行查看,这里我们只做删除用。

    • buf 消息队列的相关数据结构,删除时设为NULL。

  • 返回值
    成功返回0,失败-1。

msgsnd,msgrcv 系统调用——消息队列发送,接受消息

  • 函数声明
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>

int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);

ssize_t msgrcv(int msqid, void *msgp,size_t msgsz, long msgtyp, int msgflg);
  • 参数

    • msqid : 发送数据的消息队列ID

    • msgp : 要发送数据的存储区域指针

    • msgsz : 发送的数据大小

    • msgflg : 操作标志位

      标志含义
      标志为0表示msgsnd操作以阻塞的方式进行
      IPC_NOWAIT非阻塞写入
      MSG_EXCEPT读取标识不等于 msgtyp 的第一个消息
      MSG_NOERROR消息尺寸比 msgsz 大时,截断消息而不报错

      注意:读写一定要在msgget中设置好权限。

  • 返回值
    成功,msgsnd返回0,msgrcv返回读取到的字节数。
    失败 -1。

  • 函数用法

    1. 发送消息时,消息必须要组成以下形式,结构体要自己定义,因为消息的长度是自己指定的:
    struct msgbuf
    {
        long mtype;//消息的标识
        char mtext[1];//消息的正文,长度自定义
    }
    

    发送出去消息必须以一个long型数据打头。
    2. 消息的标识可以是任意长整型数值,必须大于0不能是0L。
    3. 参数msgsz是消息正文的大小,不包含消息的标识。

示例代码

我们建立两个客户端 clientA.c 和 clientB.c 来发送消息,用服务端 server.c 来接受数据

头文件 common.h

#pragma once 
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/ipc.h>
#include <sys/msg.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <string.h>
#include <sys/wait.h>
#define MYPATH_NAME "./"
#define PROJ_ID 0x123
#define MSG_TYPE 0
#define MSG_TYPE_A 10
#define MSG_TYPE_B 11

服务端 server.c

#include "common.h"
struct msgbuffer
{
  
    long mtype;
    char mtext[128];
    int pid;
};

int main()
{
    key_t key=ftok(MYPATH_NAME,PROJ_ID);
    if(key==-1)
    {
        perror("ftok");
        return -1;
    }

    int msgid=msgget(key,IPC_CREAT|IPC_EXCL|0666);
    if(msgid==-1)
    {
        perror("msgget");
        return -1;
    }
    printf("key:%d,msgid:%d\n",key,msgid);
  
    struct msgbuffer msg;

    while(1)
    {
        //接受数据
        //IPC_NOWAIT 非阻塞
        int retval=msgrcv(msgid,&msg,sizeof(msg),MSG_TYPE,IPC_NOWAIT);
      
        //ENOMSG: 还没有消息
        //保证没有收到信息时的轮询状态
        //而不是没有收到信息就返回retval=-1退出
        if((retval==-1) && (errno!=ENOMSG))
        {
            perror("msgrcv");
            return -1;
        }
        else if(retval!=-1) 
        {
            //此时本进程的结构体msg已被消息队列填充
            printf("[PID:%d],msg type:%ld,msg:%s\n",msg.pid,msg.mtype,msg.mtext);
            //检测到“exit”时,退出读取
            if(strncmp(msg.mtext,"exit",4)==0)
            {
                break;
            }
        }
    }

    msgctl(msgid,IPC_RMID,NULL);
    return 0;
}  

客户端A clientA.c

#include "common.h"
struct msgbuffer
{
  
    long mtype;
    char mtext[128];
    int pid;
};

int main()
{
    key_t key=ftok(MYPATH_NAME,PROJ_ID);
    if(key==-1)
    {
        perror("ftok");
        return -1;
    }

    int msgid=msgget(key,IPC_CREAT|0666);
    if(msgid==-1)
    {
        perror("msgget");
        return -1;
    }
    printf("key:%d,msgid:%d\n",key,msgid);

    //开始传输
    struct msgbuffer msg;
    //设定clientA的类型
    msg.mtype=MSG_TYPE_A;
    msg.pid=getpid();

    while(1)
    {
        fgets(msg.mtext,sizeof(msg.mtext),stdin);
      
        msgsnd(msgid,&msg,sizeof(msg),0);
      
        if(strncmp(msg.mtext,"exit",4)==0)
        {
            break;
        }
    }
    msgctl(msgid,IPC_RMID,NULL);
    return 0;
}  

客户端B clientB.c

#include "common.h"
struct msgbuffer
{
  
    long mtype;
    char mtext[128];
    int pid;
};

int main()
{
    key_t key=ftok(MYPATH_NAME,PROJ_ID);
    if(key==-1)
    {
        perror("ftok");
        return -1;
    }

    int msgid=msgget(key,IPC_CREAT|0666);
    if(msgid==-1)
    {
        perror("msgget");
        return -1;
    }
    printf("key:%d,msgid:%d\n",key,msgid);

    //开始传输
    struct msgbuffer msg;
    //设定clientA的类型
    msg.mtype=MSG_TYPE_B;
    msg.pid=getpid();

    while(1)
    {
        fgets(msg.mtext,sizeof(msg.mtext),stdin);
      
        msgsnd(msgid,&msg,sizeof(msg),0);
      
        if(strncmp(msg.mtext,"exit",4)==0)
        {
            break;
        }
    }
    msgctl(msgid,IPC_RMID,NULL);
    return 0;
}  

结果:

在这里插入图片描述

消息队列内核数据结构

struct msqid_ds {
	struct ipc_perm msg_perm;
	struct msg *msg_first;      /* first message on queue,unused  */
	struct msg *msg_last;       /* last message in queue,unused */
	__kernel_time_t msg_stime;  /* last msgsnd time */
	__kernel_time_t msg_rtime;  /* last msgrcv time */
	__kernel_time_t msg_ctime;  /* last change time */
	unsigned long  msg_lcbytes; /* Reuse junk fields for 32 bit */
	unsigned long  msg_lqbytes; /* ditto */
	unsigned short msg_cbytes;  /* current number of bytes on queue */
	unsigned short msg_qnum;    /* number of messages in queue */
	unsigned short msg_qbytes;  /* max number of bytes on queue */
	__kernel_ipc_pid_t msg_lspid;   /* pid of last msgsnd */
	__kernel_ipc_pid_t msg_lrpid;   /* last receive pid */
};

msg_permshm_perm是同一个类型的数据结构这里不再贴出。

总结:

  1. 消息队列提供了一个进程间发送数据块的方法,效率比不上共享内存。
  2. 每个数据块都有一个消息类型(可以理解为客户端的PID,这样服务端便可以识别消息队列的数据块具体来自哪一个客户端)
  3. 和共享内存一样,消息队列的资源的声明周期跟随内核,是需要手动清除。

5. 信号量

当多个进程访问某个资源时,就需要考虑进程同步的问题,以确保任意时刻只有一个进程对资源的独占式访问。

通常,程序对共享资源的访问的代码只是很短的一段,但就是这一段引发了进程间的竞态条件,我们称这段代码为临界区。对进程同步,即是确保任意时刻只有一个进程能够进入临界区代码段。

信号量是一种数据操作锁,本身不具备数据交换功能,而是一种外部资源的标识,通过控制资源访问来实现进程间通信。

简单说来,信号量就是一个计数器,它只取自然数值并且只支持两种操作:等待(wait)和信号(signal),不过在Linux中“等待”和“信号”都已经具有特殊的含义,所以对信号量的两种操作称呼为P(荷兰语:passeren 传递),V(vrijgeven 释放)操作。

二元信号量的PV操作

当进程对信号量所管理的资源进行请求时,进行需要先读取信号量的值,大于0,资源可以请求。等于0,资源不可用,这时进程会进入睡眠状态直至资源可用。当进程不再使用资源时,信号量+1(V操作),反之当有进程使用资源时,信号量-1。由于信号量本身也是一种资源,也是需要多个进程共同访问,所以对信号量的值操作均为原子操作。

假设有信号量SV,则对它的操作含义如下:

  • P(SV),如果SV的值>0,则减1:如果SV的值为0,则挂起进程的执行。
  • V(SV),如果有其他进程因为等待SV而挂起,则唤醒,如果没有则将SV+1。

信号量的取值可以是任何自然数,最简单的时二元信息量,只取0和1。

使用二元信号量同步两个进程,以确保临界区的独占式访问:

当SV为1,那么临界区代码可以访问,如果A执行P(SV)操作将SV减1,B再执行P(SV)则会被挂起,直到A离开临界区执行V(SV)操作,将SV+1,临界区重新变得可用,如果B因为等待SV而处于挂起状态,则它会被唤醒,并进入临界区。同样A如果再执行P(SV)操作,则只能被操作系统挂起等待B离开临界区。


-end-

青山不改 绿水长流

  • 6
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 4
    评论
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值