Linux系统编程之进程间通信

之前文件介绍了Linux下进程的创建,执行以及回收,此章节就进程间通信进行详细介绍。Linux提供了多种进程间通信机制,本文会分别介绍匿名管道pipe,有名管道fifo,内存共享映射机制以及Socket套接字等。通过其中任意一种机制,都能够方便两个进程间进行通信操作,极大方便开发者的开发效率。

进程间通信

在Linux中,每个进程各自有不同的用户地址空间,任何一个进程的全局变量在另一个进程中都看不 到,所以进程之间要交换数据必须通过内核,在内核中开辟一块缓冲区,进程1把数据从用 户空间拷到内核缓冲区,进程2再从内核缓冲区把数据读走,内核提供的这种机制称为进程 间通信(IPC,InterProcess Communication)。

Linux多进程间通信有多种方式,最简单的就是父子间的管道通信(父子进程共享文件描述符),然后是升级版的有名管道(指定管道描述符),其次是高效的内存共享映射和基于网络的Socket套接字。

pipe管道

管道pipe提供了父子间进行通信的简单方式,使用起来也是相反方便。管道作用于有血缘关系的进程之间,通过fork来传递。

原型
#include <unistd.h>
int pipe(int filedes[2]);
参数解释

filedes[2]: 整型数组2个大小,一个为读端,一个为写端。pipe的单条管道只能实现单工通信,如果要实现双向通信,需要创建两条pipe管道。

相关说明

先调用pipe函数时在内核中开辟一块缓冲区(称为管道)用于通信,它有一个读端一个写端,然后通过filedes参数传出给用户程序两个文件描述符,filedes[0]指向管道的读端,filedes[1]指向管道的写端。之后通过fork得到子进程,共享之前创建的文件描述符实现父子血缘进程之间通信。

编程流程:

  1. 父进程调用pipe开辟管道,得到两个文件描述符指向管道的两端。
  2. 父进程调用fork创建子进程,那么子进程也有两个文件描述符指向同一管道。
  3. 父进程关闭管道读端,子进程关闭管道写端。父进程可以往管道里写,子进程可以从管道里读,管道是用环形队列实现的,数据从写端流入从读端流出,这样就实现了进程间通信。

使用限制:

  1. 只支持单向数据流;
  2. 只能用于具有亲缘关系的进程之间;
  3. 没有名字;
  4. 管道的缓冲区是有限的(管道制存在于内存中,在管道创建时,为缓冲区分配一个页面大小);
  5. 管道所传送的是无格式字节流,这就要求管道的读出方和写入方必须事先约定好数据的格式,比如多少字节算作一个消息(或命令、或记录)等等;

4种特殊情况(假设都是阻塞I/O操作,没有设置O_NONBLOCK标志):

  1. 所有指向管道写端的文件描述符都关闭了(管道写端的引用计数等于0),而仍然有进程从管道的读端读数据,那么管道中剩余的数据都被读取后,再次read会返回0,就像读到文件末尾一样。
  2. 存在指向管道写端的文件描述符没关闭(管道写端的引用计数大于0),而持有管道写端的进程也没有向管道中写数据,这时有进程从管道读端读数据,那么管道中剩余的数据都被读取后,再次read会阻塞,直到管道中有数据可读了才读取数据并返回。
  3. 所有指向管道读端的文件描述符都关闭了(管道读端的引用计数等于0),这时有进程向管道的写端write,那么该进程会收到信号SIGPIPE,通常会导致进程异常终止。
  4. 存在指向管道读端的文件描述符没关闭(管道读端的引用计数大于0),而持有管道读端的进程也没有从管道中读数据,这时有进程向管道写端写数据,那么在管道被写满时再次write会阻塞,直到管道中有空位置了才写入数据并返回。
示例代码

阻塞式通信

#include <unistd.h>
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include <wait.h>

#define SIZE 64

int main()
{
    int pipeFd[2];
    char str[] = "This is test pipe content!\n";
    char red[SIZE];

    // 创建管道
    int ret = pipe(pipeFd);
    if(ret < 0)
    {
        perror("pipe error!");
        exit(1);
    }

    pid_t pid = fork();
    if(pid >0){
        // 父进程,父写子读,关闭父读接口
        close(pipeFd[0]);
        sleep(3);
        write(pipeFd[1],str,strlen(str));
        // 阻塞等待子进程退出
        wait(NULL);
    }
    else if(pid == 0)
    {
        // 子进程,父写子读,关闭子写接口
        close(pipeFd[1]);
        int len = read(pipeFd[0],red,sizeof(red));
        write(STDOUT_FILENO,red,len);
    }
    return 0;
}

非阻塞式通信

#include <unistd.h>
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include <wait.h>
#include <fcntl.h>
#include <errno.h>

#define SIZE 64

int main()
{

    int pipeFd[2];
    char str[] = "This is test pipe content!\n";
    char msg[] = "Try again!\n";
    char red[SIZE];

    // 创建管道
    int ret = pipe(pipeFd);
    if(ret < 0)
    {
        perror("pipe error!");
        exit(1);
    }

    pid_t pid = fork();
    if(pid >0){
        // 父进程,父写子读,关闭父读接口
        close(pipeFd[0]);
        sleep(5);
        write(pipeFd[1],str,strlen(str));
        close(pipeFd[1]);
        // 阻塞等待子进程退出
        wait(NULL);
    }
    else if(pid == 0)
    {
        // 子进程,父写子读,关闭子写接口
        close(pipeFd[1]);
        int flag, len;
        // 设置管道为非阻塞状态
        flag = fcntl(pipeFd[0],F_GETFL);
        flag |= O_NONBLOCK;
        fcntl(pipeFd[0],F_SETFL, flag);

try_again: 

        len = read(pipeFd[0],red,sizeof(red));
        if(len == -1){
            if(errno == EAGAIN){
                write(STDOUT_FILENO,msg,sizeof(msg));
                sleep(1);
                goto try_again;
            }
            else
            {
                perror("read error!");
                exit(1);
            }
        }
        write(STDOUT_FILENO,red,len);
        close(pipeFd[0]);
    }
    return 0;
}

fifo有名管道

管道应用的一个重大限制是它没有名字,因此,只能用于具有亲缘关系的进程间通信,在有名管道(named pipe或FIFO)提出后,该限制得到了克服。FIFO不同于管道之处在于它提供一个路径名与之关联,以FIFO的文件形式存在于文件系统中。这样,即使与FIFO的创建进程不存在亲缘关系的进程,只要可以访问该路径,就能够彼此通过FIFO相互通信(能够访问该路径的进程以及FIFO的创建进程之间),因此,通过FIFO不相关的进程也能交换数据。值得注意的是,FIFO严格遵循先进先出(first in first out),对管道及FIFO的读总是从开始处返回数据,对它们的写则把数据添加到末尾。它们不支持诸如lseek()等文件定位操作。

函数原型
#include <sys/types.h> 
#include <sys/stat.h>

int mkfifo(const char *pathname, mode_t mode);
参数解释

pathname: 事先规定的节点名称,多个进程间知晓
mode: 节点工作方式,可读可写等

注意:

  1. 当只写打开FIFO管道时,如果没有FIFO没有读端打开,则open写打开会阻塞。
  2. FIFO内核实现时可以支持双向通信。(pipe单向通信,因为父子进程共享同一个file
    结构体)
  3. FIFO可以一个读端,多个写端;也可以一个写端,多个读端。
  4. FIFO读端和写端需要协同工作,同时打开读写,阻塞式通信。
  5. FIFO内部数据结构采用严格的先进先出规则。
  6. 其由内核在内核中分配空间,再在磁盘中生成相应的节点,此节点内无任何数据(大小为0),只作为节点凭证。
相关说明

FIFO的打开规则:

  1. 如果当前打开操作是为读而打开FIFO时,若已经有相应进程为写而打开该FIFO,则当前打开操作将成功返回;否则,可能阻塞直到有相应进程为写而打开该FIFO(当前打开操作设置了阻塞标志);或者,成功返回(当前打开操作没有设置阻塞标志)。

  2. 如果当前打开操作是为写而打开FIFO时,如果已经有相应进程为读而打开该FIFO,则当前打开操作将成功返回;否则,可能阻塞直到有相应进程为读而打开该FIFO(当前打开操作设置了阻塞标志);或者,返回ENXIO错误(当前打开操作没有设置阻塞标志)。

从FIFO中读取数据:

约定:如果一个进程为了从FIFO中读取数据而阻塞打开FIFO,那么称该进程内的读操作为设置了阻塞标志的读操作。

  1. 如果有进程写打开FIFO,且当前FIFO内没有数据,则对于设置了阻塞标志的读操作来说,将一直阻塞。对于没有设置阻塞标志读操作来说则返回-1,当前errno值为EAGAIN,提醒以后再试。
  2. 对于设置了阻塞标志的读操作说,造成阻塞的原因有两种:

    • 当前FIFO内有数据,但有其它进程在读这些数据;

    • 另外就是FIFO内没有数据。解阻塞的原因则是FIFO中有新的数据写入,不论信写入数据量的大小,也不论读操作请求多少数据量。

  3. 读打开的阻塞标志只对本进程第一个读操作施加作用,如果本进程内有多个读操作序列,则在第一个读操作被唤醒并完成读操作后,其它将要执行的读操作将不再阻塞,即使在执行读操作时,FIFO中没有数据也一样(此时,读操作返回0)。

  4. 如果没有进程写打开FIFO,则设置了阻塞标志的读操作会阻塞。

  5. 如果FIFO中有数据,则设置了阻塞标志的读操作不会因为FIFO中的字节数小于请求读的字节数而阻塞,此时,读操作会返回FIFO中现有的数据量。

向FIFO中写入数据:

约定:如果一个进程为了向FIFO中写入数据而阻塞打开FIFO,那么称该进程内的写操作为设置了阻塞标志的写操作。

  • 对于设置了阻塞标志的写操作:

    1. 当要写入的数据量不大于PIPE_BUF时,linux将保证写入的原子性。如果此时管道空闲缓冲区不足以容纳要写入的字节数,则进入睡眠,直到当缓冲区中能够容纳要写入的字节数时,才开始进行一次性写操作。

    2. 当要写入的数据量大于PIPE_BUF时,linux将不再保证写入的原子性。FIFO缓冲区一有空闲区域,写进程就会试图向管道写入数据,写操作在写完所有请求写的数据后返回。

  • 对于没有设置阻塞标志的写操作:

    1. 当要写入的数据量大于PIPE_BUF时,linux将不再保证写入的原子性。在写满所有FIFO空闲缓冲区后,写操作返回。

    2. 当要写入的数据量不大于PIPE_BUF时,linux将保证写入的原子性。如果当前FIFO空闲缓冲区能够容纳请求写入的字节数,写完后成功返回;如果当前FIFO空闲缓冲区不能够容纳请求写入的字节数,则返回EAGAIN错误,提醒以后再写。

参考链接

代码示例

头文件

#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/stat.h>

#define FIFO_NAME  "fifo"

char str[] = "Test fifo file...\n";

写端

#include "fifo.h"

int main(void)
{
    // fifo文件存在检测
    if(access(FIFO_NAME, F_OK)){
        // 不存在文件,直接创建
        int ret = mkfifo(FIFO_NAME,0644);
        if (ret != 0)
        {
            /* code */
            perror("mkfifo error!");
            exit(1);
        }
    }

    // 存在fifo文件,打开
    int fd = open(FIFO_NAME,O_RDWR);
    if (fd < 0)
    {
        /* code */
        perror("open fifo error!");
        exit(1);
    }
    write(fd, str, strlen(str));
    printf("write fifo ok! \n");
    close(fd);
    return 0;
}

读端

#include "fifo.h"

int main(void)
{
    char buf[64];

    // fifo文件存在检测
    if(access(FIFO_NAME, F_OK)){
        perror("fifo file is  not exist!");
        exit(1);
    }

    // 存在fifo文件,打开
    int fd = open(FIFO_NAME,O_RDONLY);
    if (fd < 0)
    {
        /* code */
        perror("open fifo error!");
        exit(1);
    }
    int len = read(fd, buf, sizeof(buf));
    write(STDOUT_FILENO,buf,len);
    close(fd);
    return 0;
}

内存共享映射

介于所有的程序都是从磁盘读取到内存,然后由CPU在内存中进行读取修改等。因此,内存共享映射可以提供一个地址即可实现通信功能。mmap可以把磁盘文件的一部分直接映射到内存,这样文件中的位置直接就有对应的内存地址,对文件的读写可以直接用指针来做而不需read/write函数。

函数原型
#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset); 
int munmap(void *addr, size_t length);
参数解释

addr: 如果addr参数为NULL,内核会自己在进程地址空间中选择合适的地址建立映射。如果 addr不是NULL,则给内核一个提示,应该从什么地址开始映射,内核会选择addr之上的某个 合适的地址开始映射。建立映射后,真正的映射首地址通过返回值可以得到。

length: 需要映射的那一部分文件的长度。

offset: 从文件的什么位置开始映射,必须是页大小的整数倍(在32位体系统结构上通常是4K)。

filedes: 代表映射文件的描述符。

prot: 参数有四种取值

1. PROT_EXEC表示映射的这一段可执行,例如映射共享库
2. PROT_READ表示映射的这一段可读
3. PROT_WRITE表示映射的这一段可写
4. PROT_NONE表示映射的这一段不可访问

flag: 参数有很多种取值,这里只讲两种,其它取值可查看mmap(2)

1. MAP_SHARED多个进程对同一个文件的映射是共享的,一个进程对映射的内存做了修 改,另一个进程也会看到这种变化。
2. MAP_PRIVATE多个进程对同一个文件的映射不是共享的,一个进程对映射的内存做了修 改,另一个进程并不会看到这种变化,也不会真的写到文件中去。

如果mmap成功则返回映射首地址,如果出错则返回常数MAP_FAILED,errno被设置。当进程终止时,该进程 的映射内存会自动解除,也可以调用munmap解除映射。munmap成功返回0,出错返回-1。

EACCES:访问出错
EAGAIN:文件已被锁定,或者太多的内存已被锁定
EBADF:fd不是有效的文件描述词
EINVAL:一个或者多个参数无效
ENFILE:已达到系统对打开文件的限制
ENOMEM:内存不足,或者进程已超出最大内存映射数量
SIGSEGV:试着向只读区写入
具体查看 Man Page
示例代码
#include <sys/mman.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/stat.h>

#define MMAP_FILE "hello"

int main(void)
{
    char buf[] = "HELLOWORLD";
    // mmap文件存在检测
    if(access(MMAP_FILE, F_OK)){
        perror("mmap file is  not exist!");
        exit(1);
    }
    // 存在mmap文件,打开
    int fd = open(MMAP_FILE,O_RDWR);
    if (fd < 0)
    {
        /* code */
        perror("open mmap file error!");
        exit(1);
    }
    // 获取文件大小
    int length = lseek(fd,0,SEEK_END);

    int *addr = mmap(NULL, length, PROT_WRITE, MAP_SHARED, fd, 0);
    // 修改内存数据,查看是否同步到文件
    memcpy(addr,buf,length >= sizeof(buf)?sizeof(buf):length);
    // 关闭文件
    close(fd);
    // 解除内存映射
    munmap(addr,length);
    return 0;
}

测试代码中,可以事先向hello文件中写入一部分数据,之后调用该程序进行内存映射修改文件数据,之后打开文件查看内容。如果原数据小于写入buf数据,原文件替换成新数据,数据大小增加,如果写入数据buf小于源文件数据,那么只替换原数据中前面需要替换的字节,文件大小不变。

在内存映射文件时候,可以使用lseek实现对文件读写指针的修改,控制读取写入数据的位置。后续实现多进程或多线程数据拷贝中可以使用到。

Socket

Linux下的Socket套接字需要很大的篇章来介绍,这里险先立个flag,后续会着重介绍。

总结

Linux下进程间通信通用的七种方式:

第一类:传统的unix通信机制:

  1. 管道( pipe ):管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用。进程的亲缘关系通常是指父子进程关系。
  2. 有名管道 (named pipe) : 有名管道也是半双工的通信方式,但是它允许无亲缘关系进程间的通信。
  3. 信号 ( sinal ) : 信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。

第二类:System V IPC:

  1. 信号量( semophore ) : 信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。
  2. 消息队列( message queue ) : 消息队列是由消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。
  3. 共享内存( shared memory ) :共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号两,配合使用,来实现进程间的同步和通信。

第三类:BSD 套接字:

  1. 套接字( socket ) : 套解字也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同及其间的进程通信。

参考链接

邢文鹏Linux教学资料

坚持原创技术分享,您的支持将鼓励我继续创作!