1.Linux进程间通信概述
1.1进程间通信(IPC)工具分类
一些复杂的应用程序可能会需要多个进程分工协作来满足所需的功能需求,这就必然涉及到数据在进程之间的共享或交换,称为IPC(Inter-process communication,进程间通信)。
UNIX提供了许多工具(facilities)用于实现进程间通信,这些工具可以分为三类:
通信(Communication):用于进程间交换数据。
同步(Synchronization):同步进程(或线程)之间的动作(actions)。
信号(Signals):既可以用于同步,也可以用于通信(很少使用)
1.2通信工具分类
数据传输工具(Data-transfer facilities)
为了实现通信,一个进程将数据写至IPC工具,另一个进程从IPC工具读取数据。整个过程,需要在用户内存(user memory)和内核内存(kernel memory)之间进程两次数据传输。
共享内存(Shared memory)
多个进程可以通过把数据放置在各进程可以共享的区域以实现信息的交换。 大多数现代UNIX系统都提供三种共享内存的方式:System V shared memory、POSIX shared memory以及memory mappings。
1.3数据传输工具分类
数据传输工具(data-transfer facilities)可以进一步分为:
字节流(Byte stream)
可以通过pipes、FIFOs以及stream sockets进行数据交换。每个读操作可以读取任意数量的字节。
消息(Message)
可以通过System V message queues、POSIX message
queues以及datagram sockets进行数据交换。每个读操作读取一条消息。
1.4数据传输与共享内存的区别
对于数据传输工具来说,一个进程的一次读操作将消耗(consumes)数据,即读完之后,数据对于其它进程的读操作不可用。而放置在共享内存中的数据对于所有的进程均可见。
对于数据传输工具来说,读进程和写进程之间的同步自动完成。而共享内存需要程序员实现同步操作。
1.5UNIX同步工具
信号量(Semaphores)
信号量是一个内核维护的整数,其值不允许小于0。一个进程可以减小或增加信号量的值。
一个进程减小信号量的值(例如1→0)以实现对共享资源的互斥访问;当完成对资源的操作后,增加信号量的值使得共享资源可以被其它进程使用。
Linux支持System V semaphores和POSIX semaphores。
文件锁(File locks)
主要用于同步多个进程对同一个文件的操作,也可以用于同步多个进程对共享资源的访问。
文件锁又分为read (shared) locks和write (exclusive) locks。任意数目的进程可以拥有对相同文件的读锁,而当一个进程对文件拥有写锁后,其它进程不允许再拥有读锁或写锁。
互斥量(Mutexes)和条件变量(condition variables)
这两个同步机制通常用于POSIX线程。
通信工具也可以用于同步。更一般地,任何数据传输工具都可以用于同步。
1.6类UNIX的IPC解决方案
1.7LINUX系统的IPC接口
Linux系统的IPC接口主要由System V IPC,POSIX IPC以及BSD Socket构成。
BSD Socket
socket不仅可以用于同一主机上的各个进程之间的通信,更主要的是可以用于不同主机间的网络通信。
System V IPC
System V IPC接口是由AT&T的贝尔实验室发展出来的,其通信机制主要包括管道、FIFO、消息队列,信号量、共享内存等。
POSIX IPC
由于UNIX的分支版本众多,为了提高应用程序的可移植性,IEEE制定了POSIX。POSIX IPC主要包括消息队列、信号量、共享内存等。
2.管道
当从一个进程连接数据流到另一个进程时,使用术语管道(pipe)。通常是把一个进程的输出通过管道连接到另一个进程的输入。
对于shell命令来说,命令的连接是通过管道字符来完成的:
cmd1 | cmd2
这实际上是把一个进程的输出直接传递给另一个进程的输入。具体地讲:
cmd1的标准输入来自终端键盘。
cmd1的标准输出传递给cmd2,作为它的标准输入。
cmd2的标准输出连接到终端屏幕。
2.1进程管道
两个程序之间传递数据的一种简单方法是使用popen和pclose。
#include <stdio.h>
FILE *popen(const char *command, const char *type);
int pclose(FILE *stream);
popen函数允许一个程序将另一个程序作为新进程来启动,并可以传递数据给它或者通过它接收数据。command字符串是要运行的程序名和相应的参数。type必须是"r"或"w"。
如果type是"r",被调程序的输出就可以被调用程序使用,调用程序利用popen函数返回的FILE *文件流指针,可以读取被调程序的输出;如果type是"w",调用程序就可以向被调程序发送数据,而被调程序可以在自己的标准输入上读取这些数据。
pclose函数只在popen启动的进程结束后才返回。如果调用pclose时它仍在运行,pclose将等待该进程的结束。
2.2pipe()系统调用
利用popen函数运行一个程序时,它首先启动shell,即系统中的sh命令,然后将command字符串作为一个参数传递给它。
pipe()是一个底层函数,通过pipe()在两个进程之间传递数据时不需要启动一个shell来解释请求的命令,此外,该函数还提供了对读写数据的更多控制。
pipe()的原型如下:
#include <unistd.h>
int pipe(int filedes[2]);
该函数在数组中填上两个新的文件描述符后返回0,若出错则返回-1并设置errno的值。
2.3pipe调用成功示意图
写到filedes[1]的所有数据都可以从filedes[0]读回来。数据基于先进先出(FIFO)的原则进行处理。例如,如果把字节1,2,3写到filedes[1],从filedes[0]读取到的数据也会是1,2,3。这与栈的处理方式不同,栈采用后进先出的原则,即LIFO。
管道的用途主要是在两个进程之间传递数据。通过在进程中创建一个管道,然后再调用fork创建新进程,即可利用管道在两个进程之间传递数据。
2.4管道关闭后的读操作
当没有数据可读时,read调用通常会阻塞,即它将暂停进程来等待直到有数据到达为止。
如果管道的另一端已被关闭,也就是说,没有进程打开这个管道并向它写数据了,这时进行read调用将返回0而不是阻塞。这就使读进程能够像检测文件结束一样,对管道进行检测并作出相应的动作。
如果通过fork使用管道,就会有两个不同的文件描述符可以用于向管道写数据,一个在父进程中,一个在子进程中。只有把父子进程中的针对管道的写文件描述符都关闭,管道才会被认为是关闭了,对管道的read调用才会失败。
2.5命名管道:FIFO
使用管道只能在相关的程序之间传递数据,即这些程序是由一个共同的祖先进程启动的。如何在不相关的进程之间交换数据呢?
可以用FIFO文件完成这项工作,它通常被称为命名管道(named pipe)。命名管道是一种特殊类型的文件,它在文件系统中以文件名的形式存在,但它的行为却和没有名字的管道类似。
可以在命名行上创建命名管道,也可以在程序中创建。命令行的命令是:
mkfifo filename
2.6使用open打开FIFO文件
打开FIFO的一个主要限制是,程序不能以O_RDWR模式打开FIFO文件进行读写操作。FIFO只是为了单向传递数据,如果确实需要在程序之间双向传递数据,最好使用一对FIFO或管道,一个方向使用一个。
打开FIFO文件时,O_RDONLY、O_WRONLY和O_NONBLOCK标准通常有四种组合方式。
O_RDONLY
open调用将阻塞,除非有一个进程以写方式打开同一个FIFO文件,否则open不会返回。
O_RDONLY | O_NONBLOCK
即使没有其它进程以写方式打开FIFO,这个open调用也将成功并立刻返回。
O_WRONLY
open调用将阻塞,直到有一个进程以读方式打开同一个FIFO为止。
O_WRONLY | O_NONBLOCK
这个函数调用总是立刻返回,但如果没有进程以读方式打开FIFO文件,open调用将返回一个错误-1,且FIFO不会被打开。
注意:O_NONBLOCK分别和O_RDONLY、O_WRONLY搭配时,效果是不一样的。如果没有进程以读方式打开FIFO,非阻塞写方式的open调用将失败,但非阻塞读方式的open调用总是成功。
2.7对FIFO(命名管道)进行读写操作
对一个空的、阻塞的FIFO的read调用将等待,直到有数据可以读时才继续执行。对一个空的、非阻塞的FIFO的read调用将立即返回0字节。
对一个阻塞的FIFO的write调用将等待,直到数据可以被写入时才继续执行。如果FIFO不能接收所有写入的数据,它将按照下面的规则执行:
如果请求写入的数据的长度小于等于PIPE_BUF字节,调用失败,数据不能写入。
如果请求写入的数据的长度大于PIPE_BUF字节,将写入部分数据,返回实际写入的字节数,返回值也可能是0。
注:PIPE_BUF定义在头文件limits.h中。系统规定,一个以阻塞方式打开的FIFO中,如果写入的数据长度小于等于PIPE_BUF,那么或者写入全部字节,或者一个字节都不写入。
3.信号量
3.1为什么需要信号量
假设要访问一个文件,如果多个进程试图在同一时间更新这个文件,文件中的数据就可能会遭到破坏。
不同的进程要求向文件中写入数据(例如,更新日志),这本身并没有错,问题只可能出现在对文件进行更新的那部分代码上。这部分真正执行文件更新的代码需要独占式地执行,它们被称为临界区域。
补充:要想编写通用的代码,以确保程序对某个特定的资源具有独占式的访问权是很困难的。但如果硬件支持独占式访问(一般是通过特定的CPU指令的形式),情况就会变得简单。例如,用一条指令以原子方式访问并增加寄存器的值,在这个读取/增加/写入操作执行的过程中不会有其它指令(甚至一个中断)发生。
3.2信号量概念
荷兰计算机科学家Edsger Dijkstra提出的信号量概念是在并发编程领域迈出的重要一步。
信号量是一个特殊的变量,它只取0和正整数值,程序对其的访问都是原子操作。
信号量只允许对它进行等待(wait)和发送信号(signal)这两种操作。因为在Linux编程中,“等待”和“发送信号”都已具有特殊的含义,所以采用如下的方式表示这两种操作:
P(信号量变量):用于等待。
V(信号量变量):用于发送信号。
PV操作的具体定义如下:
P(SV):如果SV的值大于零,就给它减去1;如果它的值等于零,就挂起该进程的执行。
V(SV):如果有其它进程因等待SV而被挂起,就让它恢复运行;如果没有进程因等待SV而被挂起,就给它加1。
3.3信号量工作原理
假设有两个进程proc1和proc2,这两个进程都需要在其执行过程中的某一个时刻对一个文件进行独占式的访问。
可以定义一个二进制信号量SV,该变量的初始值为1,两个进程都可以访问它。要想对代码中的临界区域进行访问,这两个进程都需要执行相同的处理步骤:
P(SV);
critical code section;
V(SV);
noncritical code section;
一旦其中一个进程执行了P(SV)操作,它将获得信号量,并可以进入临界区域。而第二个进程将被阻止进入临界区域,因为当它试图执行P(SV)操作时,它会被挂起以等待第一个进程离开临界区域并执行V(SV)操作释放信号量。
3.4System V 信号量
信号量函数的定义如下:
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
int semctl(int semid, int semnum, int cmd, ...);
int semget(key_t key, int nsems, int semflg);
int semop(int semid, struct sembuf *sops, unsigned nsops);
semget()
该函数的作用是创建一个新信号量或取得一个已有信号量的键。
参数key是一个整数值,不相关的进程可以通过它访问同一个信号量。程序对所有信号量的访问都是间接的,它先提供一个键,再由系统生成一个相应的信号量标识符。只有semget函数才能直接使用信号量键,所有其他的信号量函数都是使用semget函数返回的信号量标识符。
3.5 semop函数
变为可用;一个是+1,也就是V操作,它发送信号表示信号量现在已可用。
第三个成员sem_flg通常被设置为SEM_UNDO。它将使得操作系统跟踪当前进程对这个信号量的修改情况,如果这个进程在没有释放该信号量的情况下终止,操作系统将自动释放该进程持有的信号量。
semctl()
该函数用来控制信号量信息。cmd参数是将要采取的动作。如果还有第4个参数,它将会是一个union semun结构:
union semun {
int val; /* Value for SETVAL */
struct semid_ds *buf; /* Buffer for IPC_STAT, IPC_SET */
unsigned short *array; /* Array for GETALL, SETALL */
};
semctl函数中的cmd参数可以设置许多不同的值,这里介绍两个常用的值:
SETVAL
把信号量初始化为一个已知的值。这个值通过union semun中的val成员设置。其作用是在信号量第一次使用之前对它进行设置。
IPC_RMID
删除一个已经无需继续使用的信号量标识符。
使用这两个参数时,成功时返回0,失败时返回-1
4.共享内存
共享内存概念:
一个进程不能简单地将自己的内存空间地址传递给其他进程使用,这是因为Linux操作系统的内存保护机制或者说内存映射机制的限制。
在一个进程内,指向一块内存的指针实际上是虚拟地址,而不是真正的物理内存地址,这个地址仅在当前进程内使用才是有效的。
但是,如果通过某种方式能够实现多个进程访问一块物理内存,那么进程之间的数据交换就可以通过读写内存来进行,这将是一种效率很高的通信方式。
共享内存为在多个进程之间共享和传递数据提供了一种有效的方式。由于它并未提供同步机制,所以通常需要用其他的机制来同步对共享内存的访问。对共享内存的同步控制必须由程序员来负责。
4.1共享内存创建函数(shmget)
使用shmget函数来创建共享内存:
#include <sys/ipc.h>
#include <sys/shm.h>
int shmget(key_t key, size_t size, int shmflg);
与信号量一样,程序需要提供一个参数key,它有效地为共享内存命名。shmget函数返回一个共享内存标识符,该标识符用于后续的共享内存函数。
参数size以字节为单位指定需要共享的内存容量。参数shmflg包含9个比特的权限标志,它们的作用与创建文件时使用的mode标志一样。
4.2shmat函数//启动对共享内存的访问
第一次创建共享内存段时,它不能被任何进程访问。要想启用对该共享内存的访问,必须将其连接到一个进程的地址空间中,这项工作由shmat函数完成。
#include <sys/types.h>
#include <sys/shm.h>
void *shmat(int shmid, const void *shmaddr, int shmflg);
shmid是由shmget返回的共享内存标识符。
shmaddr指定共享内存连接到当前进程中的地址位置。
shmflg是一组位标志。 它通常是一个空指针,表示让系统来选择共享内存出现的地址。
若shmat调用成功,它返回一个指向共享内存第一个字节的指针。
4.3 shmdt函数
shmdt函数的作用是将共享内存从当前进程中分离。
#include <sys/types.h>
#include <sys/shm.h>
int shmdt(const void *shmaddr);
参数shmaddr是shmat返回的指针。
将共享内存分离并未删除它,只是使得该共享内存对当前进程不再可用
4.4 shmctl函数
该函数是共享内存的控制函数:
#include <sys/ipc.h>
#include <sys/shm.h>
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
shmid是shmget返回的共享内存标识符。
cmd是要采取的动作,有三个取值:
IPC_STAT:把shmid_ds结构中的数据设置为共享内存的当前关联值。
IPC_SET:如果进程有足够的权限,就把共享内存的当前关联值设置为shmid_ds结构中给出的值。
IPC_RMID:删除共享内存段。
buf是一个指针,它指向包含共享内存模式和访问权限的结构。
5.消息队列
5.1消息队列概念
消息队列就是一个消息的链表,可以把一条消息看做一个记录,具有特定的格式以及特定的优先级,消息的发送和接收都以条为单位。
消息队列提供了一种从一个进程向另一个进程发送一个数据块的方法。接收进程可以独立地接收含有不同类型值的数据块。
5.2msgget函数
msgget函数的作用是创建和访问一个消息队列:
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
int msgget(key_t key, int msgflg);
key用来命名某个特定的消息队列。msgflg由9个权限标志组成。若成功,msgget函数返回一个正整数,即队列标识符。
5.3msgsnd函数
msgsnd函数用来把消息添加到消息队列中:
int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
消息的结构受到两方面的约束:首先它的长度必须小于系统规定的上限;其次,它必须以一个长整型成员变量开始,接收函数将用这个成员变量来确定消息的类型。使用消息时,最好把消息结构定义为下面这样:
struct my_message {
long int message_type;
/* 需要传输的数据 */
};
参数msqid是msgget函数返回的消息队列标识符。msgp是一个指向准备发送的消息的指针。msgsz是消息的长度,这个长度不包括消息类型成员变量。
msgflg控制在当前消息队列满或队列达到系统范围限制时将要发生的事情。如果msgflg中设置了IPC_NOWAIT标志,函数将立刻返回,不发送消息并且返回值为-1。如果msgflg中的IPC_NOWAIT标志被清除,则发送进程将挂起以等待队列中腾出可用空间。
函数执行成功返回0,失败返回-1。若成功消息数据的一份拷贝将被放到消息队列中。
5.4msgrcv函数
msgrcv函数从一个消息队列中获取消息:
ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);
msqid是由msgget函数返回的消息队列标识符。msgp是一个指向准备接收的消息的指针。msgsz是消息的长度,不包括消息类型。msgtyp可以实现一种简单形式的接收优先级。如果msgtyp的值为0,就获取队列中的第一个可用消息;如果它的值大于零,就获取具有相同消息类型的第一个消息;如果它的值小于零,就获取消息类型等于或小于msgtyp的绝对值的第一个消息。msgflg用于控制当队列中没有相应类型的消息可以接收时,将发生的事情。
若msgrcv函数执行成功,返回放到接收缓冲区中的字节数,消息被复制到由msgp指向的用户分配的缓冲区中,然后删除消息队列中的对应消息。
5.5msgctl函数
msgctl函数的作用与共享内存的控制函数非常类似:
int msgctl(int msqid, int cmd, struct msqid_ds *buf);
cmd可以取3个值:
IPC_STAT:把msqid_ds结构中的数据设置为消息队列的当前关联值。
IPC_SET:如果进程有足够的权限,就把消息队列的当前关联值设置为msqid_ds结构中给出的值。
IPC_RMID:删除消息队列。
如果删除消息队列时,某个进程正在msgsnd或msgrcv函数中等待,这两个函数将失败。