详解Linux常用的进程间通信(IPC)

进程间通信(InterProcess communication, IPC)


文章目录

        • 一、匿名管道
          • 1. 管道的概念
          • 2. 管道的局限性
          • 3. 创建管道
          • 4. 管道的用法
          • 5.实例
          • 6. 函数 popen 和 pclose
        • 二、命名管道 FIFO
          • 1. FIFO
          • 2. 创建命名管道
          • 3. 命名管道 FIFO 的用途
          • 4. 实例
        • 三、消息队列
          • 1. 本质
          • 2. 标识符和键
          • 3. 创建或打开一个消息队列
          • 4. 对队列执行多种操作
          • 5. 调用 msgsnd 将数据放到消息队列中
          • 6. 函数 msgrcv 从队列中取用消息
          • 7. 实例
          • 8. 消息队列的缺陷
        • 四、信号量
          • 1. 本质
          • 2. 实现方法
          • 3. 内核中信号量集合的结构
          • 4. 创建 / 获取一个信号量
          • 5. semctl 包含的多种信号量操作
          • 6. 函数 semop 自动执行信号量集合上的操作数组
          • 7. exit 时的信号调整
          • 8. 实例
        • 五、共享内存
          • 1. 本质(原理)
          • 2. 如何实现
          • 3. 内核中共享内存的结构
          • 4. shmget 函数创建/获得一个共享内存标识符
          • 5. shmctl 函数对共享内存执行多种操作
          • 6. shmat 函数将共享内存映射到调用进程的地址空间
          • 7. 函数 shmdt 使共享存储段与该进程分离
          • 8. 实例
          • 9. 关于删除共享内存的问题


我们说进程和进程之间相互独立,一般情况下,一个进程的终止不会影响另一个进程的状态,这保证了进程与进程之间安全的运行,但是这也使得两个进程交换信息变得很困难。
我们可以通过传送打开的文件,可以通过 fork 或 exec 来传送,也可以通过文件系统来传送

一、匿名管道

1. 管道的概念

管道是用管子、管子联接件和阀门等联接成的用于输送气体、液体或带固体颗粒的流体的装置。
我们这里的管道是内核中的一段内存,传送的是数据

2. 管道的局限性

1) 管道是半双工的,数据只能在一个方向上流动;
2) 管道的生命周期随进程,由进程创建,进程终止,管道占用的内存也随之归还给操作系统;
3) 管道只能给具有血缘关系的进程进行进程间通信,例如,父子进程,兄弟进程;
4) 一般而言,内核对管道操作进行同步与互斥,读完了就不读了,写满了就不写了;
5) 管道面向字节流,传输的数据时没有数据结构的字节流。

3. 创建管道

通过函数 pipe 创建

#include 

int pipe(fd[2]);
  • 参数 fd[2]
    输出型参数,接收两个打开的文件描述符
    fd[0] 为读打开
    fd[1] 为写打开
    如创建从父进程到子进程的管道,父进程从 fd[1] 写入,子进程从 fd[0]读出,fd[1]的输出作为fd[0]的输入
  • 返回值
    若成功,返回 0
    若出错,返回 -1
  • 管道的数据在内核(内存)中流动
4. 管道的用法
  • 单个进程中的管道几乎没有任何用处
    详解Linux常用的进程间通信(IPC)_第1张图片
  • 通常,进程先调用 pipe 创建管道,接着调用 fork,创建子进程,从而创建出从父进程到子进程的 IPC 管道,fork 之后做什么取决于我们想要的数据流的方向
    1) 对于一个父进程到子进程的管道,父进程关闭读端(fd[0]), 子进程关闭写端(fd[1]);
    2) 对于一个子进程到父进程的管道,子进程关闭读端(fd[0]), 父进程关闭写端(fd[1])。
    fork之后的半双工管道 详解Linux常用的进程间通信(IPC)_第2张图片
    从父进程到子进程的管道 详解Linux常用的进程间通信(IPC)_第3张图片
  • 当管道的一端被关闭后:
    1) 当读一个写端被关闭的管道时,在所有数据被读后,read 返回 0,表示文件结束。
    2) 如果写一个读端被关闭的管道,会产生 SIGPIPE 信号。
5.实例

创建一个从父进程到子进程的管道,并且父进程经过管道向子进程传送数据

相关代码以上传至github,可以用 git 下载查看:Github链接,戳我查看

// filename pipe_fork.c
// 经过管道从父进程向子进程传送数据

#include 
#include 
#include 
#include 

int main()
{
    int fd[2];
    int ret_pipe = pipe(fd);
    
    if(ret_pipe < 0)
    {
        perror("pipe");
        exit(1);
    }
    pid_t pid = fork();
    if(pid > 0)
    {
        // father
        close(fd[0]); // 父进程关闭读端
        const char*msg = "haha,write from father!\n";
        ssize_t len = strlen(msg);
        if(write(fd[1], msg, len) != len)
        {
            perror("father write error");
            exit(2);
        }
    }
    else if(pid == 0)
    {
        // child
        close(fd[1]); // 子进程关闭写端
        char buf[1024] = {0};
        ssize_t ret_read = read(fd[0], buf, sizeof(buf) - 1);
        if(ret_read > 0)
        {
            buf[ret_read] = '\0';
            // 写到标准输出
            if(write(STDOUT_FILENO, buf, ret_read) != ret_read) 
            {
                perror("child write error");
                exit(3);
            }
        }
    }

    return 0;
}

编译(编译环境:CentOS 7)执行:
详解Linux常用的进程间通信(IPC)_第4张图片

6. 函数 popen 和 pclose

了解管道之后,常见的操作就是,创建一个连接到另一个进程的管道,然后读其输出或向输入端发送数据,标准 I/O库提供了两个函数 popen 和 pclose。这两个函数的操作是:创建一个管道, fork 一个子进程,关闭未使用的管道端,执行一个 shell 命令,然后等待命令终止。
函数原型:

#include 

FILE *popen(const char *cmdstring, const char *type);
int pclose(FILE *fp);

  • 函数 popen 先执行 fork,然后调用 exec 执行 cmdstring,并且返回一个标准 I/O 文件。
    如果 type 是 “r”, 则文件连接到 cmdstring 的标准输出
    如果 type 是 “w”,则文件链接到 cmdstring 的标准输入
    详解Linux常用的进程间通信(IPC)_第5张图片
  • popen 函数返回值
    若成功,返回文件指针
    若出错,返回 NULL
  • pclose 函数关闭标准 I/O 流,等待命令终止,然后返回 shell 的终止状态
  • pclose 函数返回值
    若成功,返回 cmdstring 的终止状态;
    若出错,返回 -1

这两个函数不难,我们可以尝试自己实现:
代码上传到github了,这是链接:Github链接

二、命名管道 FIFO

1. FIFO

上面我们介绍了匿名管道,可以完成进程间通信,但是,匿名管道只能在两个具有血缘关系的进程间使用,这算是匿名管道的一个缺陷吧。
命名管道弥补了这个缺陷,命名管道 FIFO 可以让不相关的进程交换数据
FIFO 是一种文件类型,通过 stat 结构的 st_mode 成员的编码可以知道文件是否是 FIFO 类型。可以用 S_ISFIFO 宏对此进行测试。

2. 创建命名管道

1) FIFO 作为一种文件类型,可以通过命令 mkfifo 创建
创建命名管道test.fifo
2) FIFO 也可以通过mkfifo 函数创建

#include 
#include 

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

着重声明:mkfifo只创建管道,不打开
打开命名管道需要调用 open 函数

  • path 参数
    指明路径
  • mode 参数
    这里要注意一下,创建的管道文件的权限是 mode & ~umask
    创建屏蔽字可以通过 umask 函数调整
    例如 umask(0000),那么文件的权限就是 mode
  • 返回值
    若成功,返回 0
    若出错,返回 -1
  • 命名管道创建好了之后,它就是一个文件
    我们用 open 函数打开它
  • 当 open 一个 FIFO 时,非阻塞标志(O_NONBLOCK)会产生下列影响
    1) 没有指定 O_NONBLOCK ,只读 open 要阻塞到某个其他进程为写而打开这个 FIFO 为止只写 open 要阻塞到某个其他进程为读而打开它为止
    2) 如果指定了 O_NONBLOCK,则只读 open 立即返回。但是,如果没有进程为读而打开这个 FIFO,那么只写 open 将返回 -1,并将 errno 设置成 ENXIO。
  • 类似于管道
    若 write 一个尚无进程为读而打开的 FIFO ,则产生 SIGPIPE 信号;
    若某个 FIFO 的最后一个写进程关闭了该 FIFO,则将为该 FIFO 的读进程产生一个文件结束标志。
  • 一个给定的 FIFO 有多个写进程是常见的。
    如果不希望多个进程所写的数据交叉,则必须考虑原子写操作
3. 命名管道 FIFO 的用途

1) shell 命令使用 FIFO 将数据从一条管道传送到另一条时,无需创建临时文件
2)客户进程-服务器进程应用程序中,FIFO 用作汇聚点,在客户进程和服务器进程之间传递数据。

4. 实例

我们写一个简单的程序:
创建一个命名管道,writer.c 以 只写方式打开 FIFO, 并获取键盘输入数据写到管道,reader.c 以只读方式打开这个 FIFO,并把读到的数据打印到显示器上。

相关代码以上传至github,可以用 git 下载查看:Github链接,戳我查看

// writer.c
#include 
#include 
#include 
#include 
#include 
#include 
#include 

int main(void)
{
    umask(000);
    int fd = mkfifo("./myfifo", 0666); /* mode & ~umask */
    if(fd < 0)
    {
        if(errno == EEXIST)
        {
            // printf("myfifo has been exist, open it\n");
        }
        else
        {
            perror("mkfifo error");
            exit(-1);
        }

    }
    fd = open("./myfifo", O_WRONLY);
    if(fd < 0)
    {
        perror("open error");
        exit(-1);
    }
    while(1)
    {
        char buf[1024] = {0};
        printf("> ");
        fflush(stdout);
        ssize_t ret_read = read(STDIN_FILENO, buf, sizeof(buf) - 1);
        if(ret_read < 0)
        {
            perror("read error");
            exit(-1);
        }
        else if(ret_read == 0)
        {
            printf("write done");
            return 0;
        }
        else
        {
            buf[ret_read] = '\0';
            ssize_t ret_write = write(fd, buf, ret_read);
            if(ret_write != ret_read)
            {
                perror("write error");
            }
        }
    }

    return 0;
}

相关代码以上传至github,可以用 git 下载查看:Github链接,戳我查看

// reader.c
#include 
#include 
#include 
#include 
#include 
#include 
#include 

int main(void)
{
    umask(000);
    int fd = mkfifo("./myfifo", 0666);
    if(fd < 0)
    {
        if(errno == EEXIST)
        {
            //printf("./myfifo has been exist , now open it\n");
        }
        else
        {
            perror("mkfifo error");
            exit(-1);
        }
    }
    fd = open("./myfifo", O_RDONLY);
    if(fd < 0)
    {
        perror("open myfifo error");
        exit(-1);
    }

    while(1)
    {
        char buf[1024] = {0};
        ssize_t ret_read = read(fd, buf, sizeof(buf) - 1);
        if(ret_read < 0)
        {
            perror("read error");
            exit(-1);
        }
        else if(ret_read == 0)
        {
            printf("read done!\n");
            return 0;
        }
        else
        {
            buf[ret_read] = '\0';
            printf("rcv: %s", buf);
        }
    }

    return 0;
}

我们再写一个 makefile 把这两个文件管理起来:

all: reader writer

reader: reader.c
	gcc $^ -o $@
writer: writer.c
	gcc $^ -o $@
.PHONY: clean

clean: reader writer myfifo
	rm -f $^

我们编译(编译环境:CentOS 7)之:
详解Linux常用的进程间通信(IPC)_第6张图片
编译通过,生成两个可执行文件:reader 和 writer
为了看出效果,我们再启用一个窗口,运行之:
详解Linux常用的进程间通信(IPC)_第7张图片
果然啊,我们在另一窗口看到了我们刚刚发送的消息,完成了进程间通信。
我们再多发几条消息:
详解Linux常用的进程间通信(IPC)_第8张图片
全部没问题,测试通过!
详解Linux常用的进程间通信(IPC)_第9张图片
最后我们写端先退出,读端相当于读一个写端被关闭的管道,read 返回0,read done!

三、消息队列

在 bash 窗口可以通过命令
ipcs -q
查看当前操作系统中已经存在的消息队列
详解Linux常用的进程间通信(IPC)_第10张图片
可以通过命令
ipcrm -q msqid
删除消息队列
详解Linux常用的进程间通信(IPC)_第11张图片

1. 本质

消息队列是消息的链接表,存储在内核中,由消息队列标识符标识。
内核管理,我们只需要调用内核提供给我们的接口就行。

2. 标识符和键
  • 每个内核中的 IPC 结构(消息队列、共享内存或信号量)都用一个非负整数的标识符加以引用
  • 当一个 IPC 结构被创建,然后又被删除时,与这种结构相关的标识符连续加 1,直到达到一个整型数的最大正值,然后又回转到0。
  • 标识符是IPC对象的内部名。为使多个合作进程能够在同一 IPC 对象上汇聚,需要提供一个外部命名方案,为此,每个 IPC 对象都和与一个键相关联将这个键作为该对象的外部名
  • 无论何时创建一个 IPC 结构,都应指定一个键。
    这个键的数据类型是基本系统数据类型 key_t,通常在 中被定义为长整型
  • 可以调用函数 ftok 生成一个键
    由路径名和项目ID 产生一个键
    #include
    key_t ftok(const char * path, int id);
    返回值:若成功,返回键;若出错,返回 (key_t) - 1
    path 参数必须引用一个现有文件
3. 创建或打开一个消息队列
#include 

int msgget(key_t key, int msgflg);
  • key
    就是我们刚刚说的了,可以用 ftok 函数生成
  • XSI IPC 为每一个 IPC 结构关联一个 ipc_perm 结构
    该结构规定了权限和所有者,至少包含以下成员:
    struct ipc_perm {
    uid_t uid; /* owner's effective user id 所有者有效 */
    gid_t gid; /* owner's effective group id */
    uid_t cuid; /* creator's effective user id 创建者有效*/
    uid_t cgid; /*creator's effective group id*/
    mode_t mode; /* access modes */
    ...
    };
    调用进程必须是 IPC 结构的创建者或者超级用户
  • 每个消息队列都有一个 msqid_ds 结构与其关联
    struct msqid_ds {
    struct ipc_perm msg_perm; /* ipc_perm 结构 */
    msgqnum_t msg_qnum; /* 队列的消息条数 */
    msglen_t msg_qbytes; /* 最大消息占用字节数 */
    pid_t msg_lspid; /* 最后一条发送消息的进程 ID */
    pid_t msg_lrpid; /* 最后一条接收消息的进程 ID */
    time_t msg_stime; /* last-msgsnd() time */
    time_t msg_rtime; /* last-msgrcv() time */
    time_t msg_ctime; /* last-change time */
    ...
    };
    这个结构定义了队列的当前状态
  • 参数 msgflag 指定了 struct ipc_perm 的 mode
  • 返回值
    若成功,返回非负消息队列 ID ---- 相当于一个句柄
    若出错,返回 -1
4. 对队列执行多种操作

通过函数 msgctl

#include 

int msgctl(int msqid, int cmd, struct msqid_ds *buf);
  • msqid 是 msgget 的返回值
  • cmd 参数指定 msqid 指定队列要执行的命令
  • 函数返回值
    若成功,返回 0
    若出错,返回 -1
cmd 说明
IPC_STAT 取此消息队列的 msqid_ds 结构,并将它存放在 buf 指向的结构中
IPC_SET 将字段 msg_perm.uid、msg_perm.gid、msg_perm.mode 和 msg_qbytes 从 buf 指向的结构复制到与这个队列相关的 msqid_ds 结构中。
IPC_RMID 从系统中删除该消息队列以及仍在该队列中的所有数据。
5. 调用 msgsnd 将数据放到消息队列中
#include 

int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
  • 这个函数设计的非常巧妙啊
  • msgp 是一个数据块指针,指向一个结构体
    这个结构体长成下面这个样子
    struct mymsg {
    long mtype; /* Message type. 必须大于 0*/
    char mtext[1]; /* Message text. */
    }
    这个结构体的第二个字段可以根据自己需要,随意调整大小
    其实我们知道,结构体的地址和结构体第一个字段的地址相同,
    我们也可以说,这个 msgp 指向一个长整数这个长整数是这条消息的类型
  • 并且第三个参数 msgsz 指明的不是结构体的大小,而是结构体第二个字段的大小
  • 参数 msgflag 的值可以指定为 IPC_NOWAIT, 设置非阻塞
    如果消息队列已满,msgsnd 立即出错返回 EAGAIN
  • 如果没有设置 IPC_NOWAIT,进程会一直阻塞到
    1) 有空间可以容纳要发送的消息;
    2) 从系统中删除了此消息队列;返回 EIDRM 错误
    3) 捕捉到一个信号,并从信号处理程序返回。返回 EINTE 错误
  • 返回值
    若成功,返回 0
    若出错,返回 -1
  • 如果从系统中删除某个消息队列:
    没有维护引用计数,删了就删了
    使用这一消息队列的进程下次将出错返回
6. 函数 msgrcv 从队列中取用消息
#include 

ssize_t msgrcv(int msqid, void *msgp, 
               size_t msgsz, long msgtyp, int msgflg);  
  • 和 msgsnd 一样
    msgp 指向一个结构体,也可以说是指向一个长整型数了

  • msgsz 是实际消息数据的缓冲区大小

  • 如果返回的消息长度大于 msgsz,并且 msgflg 设置了 MSG_NOERROR
    消息将被截断

  • msgflag 可以被设置为 IPC_NOWAIT ,使操作不阻塞
    如果没有所指定类型的消息,msgrcv 直接返回 -1,errno 设置为 ENOMSG
    如果没有指定 IPC_NOWAIT,则进程会一直阻塞到:
    1) 有指定消息可用;
    2) 从系统中删除此消息队列;
    3) 捕捉到一个信号并从信号处理程序返回。

  • 返回值
    若成功,返回消息数据部分长度
    若出错,返回 -1

  • 这里的 msgtyp参数也设置的很巧妙

msgtyp的大小 说明
0 返回队列中的第一条消息
> 0 返回队列中消息类型为 msgtyp 的第一个消息
< 0 返回消息队列中消息类型小于等于 msgtyp 绝对值的消息,如果消息有若干个,就取类型值最小的一个

msgsnd 和 msgrcv 在执行成功之后,内核才会更新与该消息队列相关连的 msgid_ds 结构中的相关信息(调用者进程 ID,时间)

7. 实例

描述:


一、 这是一个以 system V 消息队列实现的聊天程序客户端

  1. 创建一个消息队列
  2. 从消息队列中获取一个数据,打印出来
  3. 从标准输入获取一个数据,将它写入消息队列
  4. 不玩了删除该消息队列

相关代码已上传至 github,可用 git 工具下载查看:Github链接,戳我查看

// msgquqeue_s.c

#include 
#include 
#include 

#include 
#include 
#include 
#include 
#include 
#include 
#include 

// 这个是消息队列的 key 
#define IPC_KEY 0x12345678

// 这两个宏,用于赋值我们传输的数据块的类型
#define TYPE_S 1
#define TYPE_C 2

struct msgbuf
{
    long mtype;       /* message type, must be > 0 */
    char mtext[1024]; /* message data */
};

int main()
{
    int msgid = -1;
    // 1. 创建消息队列
    // int msgget(key_t key, int msgflg);
    msgid = msgget(IPC_KEY, IPC_CREAT | 0666);
    if(msgid < 0)
    {
        perror("msgget error");
        exit(-1);
    }
    //  ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);// 默认阻塞的获取数据
    //  msgp 
    struct msgbuf buf;
    while(1)
    {
        // 接收数据
        memset(&buf, 0x00, sizeof(struct msgbuf));
        msgrcv(msgid, &buf, sizeof(buf.mtext), TYPE_C, 0);
        // printf("ret_msgrcv = %d\n", ret_msgrcv);
        printf("client say: [%s]\n", buf.mtext);
        // 发送数据
        memset(&buf, 0x00, sizeof(struct msgbuf));
        buf.mtype = TYPE_S;
        printf("[S]: ");
        fflush(stdout);
        scanf("%s", buf.mtext);
        msgsnd(msgid, &buf, strlen(buf.mtext), 0);
        // printf("ret_msgsnd = %d\n", ret_msgsnd);
    }
    // IPC_RMID 删除 IPC
    // int msgctl(int msqid, int cmd, struct msqid_ds *buf);
    msgctl(msgid, IPC_RMID, NULL);

    return 0;
}

二、 这是一个以 system V 消息队列实现的聊天程序服务器段

  1. 创建一个消息队列
  2. 从消息队列中获取一个数据,打印出来
  3. 从标准输入获取一个数据,将它写入消息队列
  4. 不玩了删除该消息队列

相关代码已上传至 github,可用 git 工具下载查看:Github链接,戳我查看

// msgqueue_c.c

#include 
#include 
#include 

#include 
#include 
#include 
#include 
#include 
#include 
#include 

// 这个是消息队列的 key 
#define IPC_KEY 0x12345678

// 这两个宏,用于赋值我们传输的数据块的类型
#define TYPE_S 1
#define TYPE_C 2

struct msgbuf
{
    long mtype;       /* message type, must be > 0 */
    char mtext[1024]; /* message data */
};

int main()
{
    int msgid = -1;
    // 1. 创建消息队列
    // int msgget(key_t key, int msgflg);
    msgid = msgget(IPC_KEY, IPC_CREAT | 0666);
    if(msgid < 0)
    {
        perror("msgget error");
        exit(-1);
    }
    //  ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);// 默认阻塞的获取数据
    //  msgp 
    struct msgbuf buf;
    while(1)
    {
        // 发送数据
        memset(&buf, 0x00, sizeof(struct msgbuf));
        printf("[C]: ");
        fflush(stdout);
        scanf("%s", buf.mtext);
        buf.mtype = TYPE_C;
        msgsnd(msgid, &buf, strlen(buf.mtext), 0);
        // printf("ret_msgsnd = %d\n", ret_msgsnd);
        msgrcv(msgid, &buf, sizeof(buf.mtext), TYPE_S, 0);
        // printf("ret_msgrcv = %d\n", ret_msgrcv);

        printf("sever say: [%s]\n", buf.mtext);
    }
    // IPC_RMID 删除 IPC
    // int msgctl(int msqid, int cmd, struct msqid_ds *buf);
    msgctl(msgid, IPC_RMID, NULL);

    return 0;
}
// makefile

all: msgqueue_s msgqueue_c

msgqueue_s: msgqueue_s.c
	gcc $^ -o $@
msgqueue_c: msgqueue_c.c
	gcc $^ -o $@

.PHONY: clean

clean: msgqueue_s msgqueue_c
	rm -f $^

我们编译(编译环境: CentOS 7)之:
详解Linux常用的进程间通信(IPC)_第12张图片
编译通过,我们再运行之:
详解Linux常用的进程间通信(IPC)_第13张图片
哈哈哈,一个简单的一人一句的聊天小程序就写好了。

8. 消息队列的缺陷
  • 每个消息的最大长度有上限(MSGMAX)
  • 每个消息队列总的字节数有上限(MSGMNB)
  • 系统上消息队列的总数也有上限(MSGMNI)

正是这三个缺陷,导致设计如此巧妙的一种进程间通信方式,很少有人用!!!

四、信号量

进程间通信方式之一,用于实现进程间同步与互斥。
多个进程同时操作一个临界资源的时候就需要通过同步与互斥机制来实现对临界资源的安全访问。

1. 本质

信号量与前面介绍的管道、FIFO以及消息队列不同,信号量是具有一个等待队列的计数器(代表现在还有没有资源可以使用)用于为多个进程提供对共享数据的访问

2. 实现方法

当信号量没有资源可用时,这时候需要阻塞等待
同步:只有信号量资源计数大于0的时候,会通知别人,打断等待,去操作临界资源,也就是说,别人释放了资源(+1)之后你才能获取资源(-1)然后进行操作
互斥:
信号量如果要实现互斥,那么它的计数只能是 0/1 (一元信号量)
一个进程获取临界资源后,在他没释放临界资源之前,别的进程无法获取该临界资源。

这里我们不使用一元信号量,而使用二元信号量(计数 > 1)
为了获取共享资源,进程需要执行下列操作:
(1) 测试控制该资源的信号量。
(2)若此信号量的值为正(大于0),则进程可以使用该资源。在这种情况下,进程会将信号量值减一,表示使用了一个资源单位
(3)若此信号量值为0,则进程进入休眠状态,直至信号量大于0,进程被唤醒后,继续步骤(1)。
当进程不再使用由一个信号量控制的共享资源时,该信号量值增1。如果有进程正在休眠等待此信号量,则唤醒他们。
信号量作为进程间通信方式,意味着大家都能访问到信号量,所以信号量实际上也是一个临界资源信号量的测试及减1操作应当是原子操作
信号量通常是在 内核中实现的!!!

3. 内核中信号量集合的结构

Single UNIX Specification 定义的 semid_ds 结构

struct semid_ds{
struct ipc_perm sem_perm; /* ipc_perm 结构 */
unsigned short sem_nsems; /* of semaphores in set */
time_t sem_otime; /* last-semop() time */
time_t sem_ctime; /* last-change() time */
...
};

具体实现的 semid_ds 结构至少包含下列成员:

struct {
unsigned short semval; /* semaphore value, always > 0 */
pid_t sempid; /* pid of last operation */
unsigned short semncnt; /* # processes awaiting semval > curval */
unsigned short semzcnt; /* # processes awaitong semval==0 */
pid_t msg_lrpid; /* 最后一条接收消息的进程 ID */
...
};

4. 创建 / 获取一个信号量

#include
int semget(ket_t key, int nsems, int flag);

  • 参数 key
    键,可以通过函数 ftok 创建,也可以自己指定
  • 参数 nsems
    是该集合中的信号量数
    如果是引用现有集合,则将 nsems 指定为 0
  • 返回值
    若成功,返回信号量 ID
    若出错,返回 -1
5. semctl 包含的多种信号量操作

#include
int semctl(int semid, int semnum, int cmd, .../* union semun arg */);

  • 第四个参数 arg
    可选,是否使用取决于所请求命令
    如果使用该参数,则其类型是 semun,是多个命令特定参数的联合(union):
    union semun {
    int val;
    struct semid_ds * buf;
    unsigned short * array;
    };
    这个选项参数是一个联合,而非指向联合的指针
  • 参数 semid
    指定的信号量集合
  • 参数 semnum
    取值范围:[0, nsems)
    cmd 参数需要时才会用到
  • cmd 参数指定下列 10 种命令中的一种
    这些命令是运行在 semid 指定的信号量集合上的
cmd 参数 说明
IPC_STAT 对此集合取 semid_ds 结构,存储在 arg.buf 指向的结构中
IPC_SET 按 arg.buf 指向的结构中的值,设置与此集合相关的 semid_ds 结构
IPC_RMID 从系统中删除该信号量集合
GETVAL 返回成员 semnum 的 semval 值
SETVAL 设置成员 semnum 的 semval 值,该值由 arg.val 指定
GETPID 返回成员 semnum 的 sempid 值
GETNCNT 返回成员 semnum 的 semncnt 值
GETZCNT 返回成员 semnum 的 semzcnt 值
GETALL 取该集合中所有的信号量值,这些值存储在 arg.array 指向的数组中
SETALL 将集合中所有的信号量设置成 arg.array 指向的数组中的值
  • 返回值
    对 GETALL 以外的所有 GET 命令,semctl 函数都返回相应值
    对其他命令:
    若成功,返回 0
    若出错,设置 errno 并返回 -1
6. 函数 semop 自动执行信号量集合上的操作数组

#include
int semop(int semid, struct sembuf semoparray[], size_t nops);
semop 函数具有原子性,要么执行数组中的所有操作,要么一个也不做!

  • 返回值
    若成功,返回 0
    若出错,返回 -1
  • 参数 semoparray
    是一个指针,指向一个由 sembuf 结构表示的信号量操作数组:
    struct sembuf {
    unsigned short sem_num;
    short sem_op;
    short sem_flg;
    };
  • 参数 nops 规定该数组中操作的数量
    对集合中每个成员的操作由相应的 sem_op 值规定
    sem_op 值可以是负值、0或正值
    信号量的 “undo” 标志,此标志对应于 sem_flg 成员的 SEM_UNDO 位
    (1)sem_op 为正值,表示的是进程释放的占用资源数,sem_op 值会加到信号量的值上,如果指定了 undo 标志,则也从该进程的此信号量调整值中减去 sem_op 。
    (2)sem_op 为负值,表示要获取由该信号量控制的资源
    如若该信号量的值大于等于 sem_op 的绝对值,则从信号量中减去 sem_op 的绝对值。
    如若信号量小于 sem_op 的绝对值:
    a. 若指定了 IPC_NOWAIT,则 semop 出错返回 EAGAIN ;
    b. 若未指定 IPC_NOWAIT,则该信号量的 semncnt 加 1,然后调用进程被挂起等待直至下列事件之一发生:
    i. 此信号量值变为大于等于 sem_op 的绝对值
    ii. 从系统中删除了此信号量
    iii. 进程捕捉到一个信号,并从信号处理程序返回
    (3)若 sem_op 为 0,这表示调用进程希望等待到该信号量变为 0.
    若信号量值当前为 0 , 则此函数立即返回。
    如果此信号量值非 0,则适用于下列条件:
    a. 若指定了 IPC_NAWAIT,则出错返回 EAGAIN
    b. 若未指定 IPC_NOWAIT,则该信号量的 semzcnt 值加 1,然后调用进程被挂起,直至下列的一个事件发生。
    i. 此信号量值变为 0。此信号量的 semzcnt 值减 1。
    ii. 从系统中删除了此信号量。
    iii. 进程捕捉到一个信号,并从信号处理程序返回。在这种情况下,此信号量的 semzcnt 值减 1,并且函数出错返回 EINTR。
7. exit 时的信号调整

一个进程终止时,如果它占用了经由信号量分配的资源,并且没有归还给(执行V 操作,加1),等待的其他进程将会一直阻塞,那么就会出现问题。
这也是信号量必须处理的问题,它是这样做的:

无论何时只要为信号量操作指定了 SEM_UNDO 标志,然后分配资源(sem_op 值小于 0),那么内核就会记住对于该特定信号量,分配给调用进程多少资源(sem_op 的绝对值)。
当该进程终止时,不论自愿或不自愿内核都将检验该进程是否还有尚未处理的信号量调整值,如果有,则按调整值对相应信号量进行处理。
如果用 SETVAL 或 semctl 设置一个信号量的值,则在所有进程中,该信号量的调整值都将设置为 0。

8. 实例

同步操作

信号量同步操作:访问资源的时序性
一个简单的生产消费模型
买方便面的人:消费者
卖方便面的人:生产者(每隔一秒生产一包方便面)
刚开始是没有方便面的,只有生产者生产出来方便面,消费者才能消费

相关代码已上传至 github,可用 git 工具下载查看源码:github链接,戳我查看

#include  
#include 
#include 
#include 
#include 

#define IPC_KEY 0x12345678

union semun
{
    int val;
    struct semid_ds * buf;
    unsigned short * array;
    struct seminfo * _buf;
};

void sem_P(int id)
{
    struct sembuf buf;
    buf.sem_num = 0;
    buf.sem_op = -1;
    buf.sem_flg = SEM_UNDO;

    semop(id, &buf, 1);
}

void sem_V(int id)
{
    struct sembuf buf;
    buf.sem_num = 0;
    buf.sem_op = 1;
    buf.sem_flg = SEM_UNDO;

    semop(id, &buf, 1);
}

int main(void)
{
    int pid = 0;

    // 1. 创建或打开一个信号量
    int semid = semget(IPC_KEY, 1, IPC_CREAT | 0666);
    if(semid < 0)
    {
        perror("semget error");
        exit(1);
    }

    // 2. 设置信号量初值,只能设置一次,不能重复设置
    union semun un_sem;
    un_sem.val = 0;
    semctl(semid, 0, SETVAL, un_sem);
    pid = fork();
    if(pid < 0)
    {
        perror("fork error");
        exit(2);
    }
    else if(pid == 0)
    {
        while(1)
        {
            sem_P(semid);
            printf("我买了一包方便面!\n");
        }
    }
    else
    {
        while(1)
        {
            sleep(1);
            printf("我生产了一包方便面!\n");
            sem_V(semid);
        }
            
    }

    return 0;
}

编译(编译环境:CentOS 7)运行:
详解Linux常用的进程间通信(IPC)_第14张图片

互斥操作

这是一个基于信号量的互斥操作
让一个进程打印A睡 1001000 ms 然后再打印一个A
让另一个进程打印B睡 100
1000 ms然后再打印一个B
检查结果是否为连续的

相关代码已上传至 github,可用 git 工具下载查看源码:github链接,戳我查看

#include  
#include 
#include 
#include 
#include 

#define IPC_KEY 0x12345678

union semun
{
    int val;
    struct semid_ds * buf;
    unsigned short * array;
    struct seminfo * _buf;
};

void sem_P(int id)
{
    struct sembuf buf;
    buf.sem_num = 0;
    buf.sem_op = -1;
    buf.sem_flg = SEM_UNDO;

    semop(id, &buf, 1);
}

void sem_V(int id)
{
    struct sembuf buf;
    buf.sem_num = 0;
    buf.sem_op = 1;
    buf.sem_flg = SEM_UNDO;

    semop(id, &buf, 1);
}

int main(void)
{
    int pid = 0;

    // 1. 创建或打开一个信号量
    int semid = semget(IPC_KEY, 1, IPC_CREAT | 0666);
    if(semid < 0)
    {
        perror("semget error");
        exit(1);
    }

    // 2. 设置信号量初值,只能设置一次,不能重复设置
    union semun un_sem;
    un_sem.val = 1;
    semctl(semid, 0, SETVAL, un_sem);
    pid = fork();
    if(pid < 0)
    {
        perror("fork error");
        exit(2);
    }
    else if(pid == 0)
    {
        while(1)
        {
            // 获取信号量
            sem_P(semid);
            putchar('A');
            fflush(stdout);
            usleep(100 * 1000);
            putchar('A');
            putchar('\n');
            fflush(stdout);
            // 释放信号量
            sem_V(semid);
        }
    }
    else
    {
        while(1)
        {
            // 获取信号量
            sem_P(semid);
            putchar('B');
            fflush(stdout);
            usleep(100 * 1000);
            putchar('B');
            putchar('\n');
            fflush(stdout);
            // 释放信号量
            sem_V(semid);
        }
            
    }

    return 0;
}

编译(编译环境:CentOS 7)运行之:
详解Linux常用的进程间通信(IPC)_第15张图片

五、共享内存

最快的进程间通信!!!

1. 本质(原理)

一块内存,允许两个或多个进程共享!
数据不需要在客户进程和服务器进程之间复制,所以是最快的IPC。

2. 如何实现

同步:在多个进程之间同步访问给定的存储区。
互斥:若服务器进程正在将数据放入共享存储区,则在它做完这一操作之前,客户进程不应当去取这些数据。

在物理内存上开辟一段内存用作共享内存,对每个进程来说,都有自己独立的虚拟地址空间,进程只能访问自己虚拟地址空间的内容,无法访问自己虚拟地址空间之外的内存。于是我们通过页表,把物理内存映射到进程的虚拟地址空间中,进程操作这段内存,相当于直接操作物理内存,使用共享内存数据传输就不需要从用户态到内核态的数据拷贝过程,所以共享内存是最快的进程通信方式。
详解Linux常用的进程间通信(IPC)_第16张图片

3. 内核中共享内存的结构
struct shmid_ds {
  struct ipc_perm shm_perm; /* ipc_perm 结构 */
  size_t shm_segsz; /* size of segment in bytes */
  pid_t shm_lpid; /* pid of last shmop() */
  pid_t shm_cpid; /* pid of creator */
  shmatt_t shm_nattch; /* number of current attaches */
  time_t shm_atime; /* last-attach time */
  time_t shm_dtime; /* last-detach time */
  time_t shm_ctime; /* last-change time */
  ...
 };

shmatt_t 类型定义为无符号整型。

4. shmget 函数创建/获得一个共享内存标识符
#include 
int shmget(key_t key, size_t size, int flag);
  • 返回值:
    若成功,返回共享内存 ID
    若出错,返回 -1
  • 参数 key
  • 参数 size
    共享存储段的长度,以字节为单位
    实现通常将其向上取为系统页长的整数倍
    如果指定的 size 值并非系统页长的整数倍,那么最后一页的余下部分是不可使用的
    如果是==引用一个现存的段,则将 size 指定为 0。
    当创建一个新段时,段内的内容初始化为0
5. shmctl 函数对共享内存执行多种操作
#include 
int shmctl(int shmid, int cmd, struct shmid_ds * buf);
  • 返回值
    若成功,返回 0
    若出错,返回 -1
  • 参数 shmid
    shmget 函数返回的操作句柄
  • 参数 cmd
    指定下列 5 种命令中的一种,使其在 shmid 指定的段上执行
cmd 说明
IPC_STAT 取此段的 shmid_ds 结构,并将它存储在由 buf 指向的结构中
IPC_SET 将 buf 指向的结构中的值设置到此共享存储段的 shmid_ds 结构
IPC_RMID 从系统中删除该共享存储段
IPC_LOCK 在内存中对共享存储段加锁
IPC_UNLOCK 解锁共享存储段
6. shmat 函数将共享内存映射到调用进程的地址空间
#include 
void * shmat(int shmid, const void * addr, int flag);
  • 返回值
    若成功,返回指向共享存储段的指针,该段连接的实际地址
    若出错,返回 -1
  • 参数 addr
    共享存储段连接到调用进程的哪个地址上与 addr 参数以及 flag 中是否指定 SHM_RND 位有关。
    SHM_RND 命令的意思是 “取整”
    如果 addr 为 0,则此段连接到由内核选择的第一个可用地址上
    如果 addr 非 0,并且没有指定 SHM_RND ,则此段连接到 addr 所指定的地址上
    如果 addr 非 0,并且指定了 SHM_RND,则此段连接到 (addr - (addr mod SHMLBA)) 所表示的地址上,该算式是将地址向下取最近 1 个 SHMLBA 的倍数。
    SHMLBA 的意思是 “低边界地址倍数”,它总是 2 的乘方。
  • 参数 flag
    如果指定了 SHM_RDONLY 位,则以只读方式连接此段
    否则以读写方式连接此段
  • 如果 shmat 执行成功,那么内将与该共享存储段相关的 shmid_ds 结构中的 shm_nattch 计数器加 1。
7. 函数 shmdt 使共享存储段与该进程分离
#include 
int shmdt(const void * addr);
  • 返回值
    若成功,返回 0
    若出错,返回 -1
  • 参数 addr
    调用 shmat 函数的返回值,共享内存在进程中的实际地址
  • 如果执行成功, shmdt 将使相关的 shmid_ds 结构中的 shm_nattch 计数器减1。
8. 实例

这是一个基于共享内存的聊天程序的服务器端
共享内存操作步骤

  1. 创建共享内存
  2. 映射共享内存到虚拟地址空间
  3. 数据拷贝(通信)
  4. 不玩了
    解除映射关系
    删除共享内存

相关代码已上传到 github ,可用 git 工具下载查看:Github 链接,戳我查看

// sharemem_s.c

#include 
#include 
#include 
#include 
#include 
#include 

#define IPC_KEY 0x12345678

int main(void)
{
    // 1. 创建共享内存
    int shmid = shmget(IPC_KEY, 32, IPC_CREAT | 0666);
    if(shmid < 0)
    {
        perror("shmget error");
        exit(1);
    }

    printf("success get sharememory\n");
    // 2. 将共享内存映射到虚拟地址空间
    void * shm_start = shmat(shmid, NULL, 0);
    if(shm_start == (void *)-1)
    {
        perror("shmat error");
        exit(2);
    }
    
    while(1)
    {
        // 直接通过这个首地址操作共享内存即可
        printf("please input: ");
        fflush(stdout);
        // 清空一下共享内存中的数据
        scanf("%s", (char *) shm_start);
    }

    // 4. 不玩了,解除映射
    shmdt(shm_start);
    
    // 删除共享内存
    shmctl(shmid, IPC_RMID, NULL);

    return 0;
}

这是一个基于共享内存聊天程序的客户端
负责读取数据并输出

相关代码已上传到 github ,可用 git 工具下载查看:Github链接,戳我查看

// sharemem_c.c

#include 
#include 
#include 
#include 
#include 
#include 

#define IPC_KEY 0x12345678

int main(void)
{
    // 1. 创建共享内存
    int shmid = shmget(IPC_KEY, 0, 0);
    if(shmid < 0)
    {
        perror("shmget error");
        exit(1);
    }

    printf("success get sharemem\n");
    // 2. 将共享内存映射到虚拟地址空间
    void * shm_start = shmat(shmid, NULL, 0);
    if(shm_start == (void *)-1)
    {
        perror("shmat error");
        exit(2);
    }
    while(1)
    {
        printf("%s\n", (char *) shm_start);
        memset(shm_start, 0x00, 32); // 清空共享内存
        sleep(1);
    }

    // 4. 不玩了,解除映射
    shmdt(shm_start);
    
    // 删除共享内存
    shmctl(shmid, IPC_RMID, NULL);

    return 0;
}

编译(编译环境:CentOS 7)之:
在这里插入图片描述
然后我们输入消息:
详解Linux常用的进程间通信(IPC)_第17张图片
这样我们就一个简单的聊天程序就跑通了

9. 关于删除共享内存的问题

在删除共享内存的时候,并不是直接删的
如果有进程依然与共享内存保持映射连接关系,那么共享内存将不会被立即删除,而是等最后一个映射断开后删除,在这期间,将拒绝其他进程映射!

你可能感兴趣的:(Linux)