第15章 进程间通信
进程间通信主要有:
- 管道(pipe)
- 有名管道(FIFO)
- XSI之消息队列
- XSI之信号量
- XSI之共享存储
- POSIX信号量
- Socket(16章)
- UNIX域SOCKET(17章)
1. 管道
管道有2种局限性:
- 历史上,它们是半双工的
- 只能在具有公共祖先的2个进程之间使用。
通常,一个管道由一个进程创建,在进程调用fork之后,这个管道就能在父进程和子进程之间使用了。
FIFO(有名管道)没有第2种局限性;
UNIX域socket没有这2种局限性。
每当在管道中键入一个命令序列,让 shell 执行时,shell 都会为每一条命令单独创建一个进程,然后用管道将前一条命令进程的标准输出与后一条命令的标准输入相连。
通过pipe函数创建管道:
#include <unistd.h>
int pipe(int fd[2]);
经由参数fd返回2个文件描述符: fd[0]为读而打开;fd[1]为写而打开。
POSIX.1允许实现支持全双工的管道。对于这样的实现,fd[0]和fd[1]以读/写方式打开。
通常,进程会先调用pipe,接着调用fork,从而创建从父进程到子进程的IPC通道。对于从父进程到子进程的管道,父进程关闭管道的读端(fd[0]),子进程关闭写端(fd[1])。 对于一个从子进程到父进程的管道,父进程关闭fd[1],子进程关闭fd[0].
当管道的一端被关闭后,下列两条规则起作用:
- 当读(read)一个写端已被关闭的管道时,在所有数据都被读取后,read返回0,表示文件结束。从技术上讲,如果管道的写端没有被关闭(即还有进程),则不会产生文件的结束。可以复制一个管道的描述符,使得多个进程可以对它进行写操作。但是通常,一个管道只有一个读进程和一个写进程。
- 如果写(write)一个读端已经被关闭的管道,则产生信号SIGPIPE. 如果忽略该信号,或者捕捉该信号并从处理程序返回,则write返回-1,error设置为EPIPE.
在写管道(或 FIFO)时,常量 PIPE_BUF 规定了内核的管道缓冲区大小。如果对管道调用write,而且要求写的字节数小于等于 PIPE_BUF,则此操作不会与其他进程对同一管道(或FIFO)的 write 操作交叉进行。但是,若有多个进程同时写一个管道(或 FIFO),而且我们要求写的字节数超过PIPE_BUF,那么我们所写的数据可能会与其他进程所写的数据相互交叉。用pathconf或fpathconf函数(见图2-12)可以确定PIPE_BUF的值。
函数popen和pclose
标准I/O库提供了两个函数popen和pclose,其功能是:
创建一个管道, fork一个子进程,关闭未使用的管道端,exec执行一个shell命令,然后等待命令终止。
目的: 创建一个连接到另一个进程的管道,然后读其输出或向其输入端发送数据。
#include <stdio.h>
FILE *popen(const char *cmdstring, const char *type); // 出错返回NULL
int pclose(FILE *fp); // 成功,返回cmdstring的终止状态; 出错返回-1
- 如果type是“r”,则文件指针连接到cmdstring的标准输出;
- 如果type是“w”,则文件指针连接到cmdstring的标准输入。
函数popen先执行 fork ,然后调用 exec 执行 cmdstring, 并且返回一个标准I/O文件指针。
popen = fork + exec + return File*
所以使用popen减少了需要编写的代码量。
pclose函数关闭标准I/O流,等待命令终止,然后返回shell的终止状态。
示例程序:
#include <sys/wait.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#define PAGER "${PAGER:-more}"
#define MAXLINE 100
int main(int argc, char *argv[])
{
char line[MAXLINE];
FILE *fpin, *fpout;
if (argc != 2) {
printf("Usage: a.out <pathname>\n");
exit(-1);
}
fpin = fopen(argv[1], "r"); // fpin is for reading a file
fpout = popen(PAGER, "w"); // fpout is for writing to 'more'
while(fgets(line, MAXLINE, fpin) != NULL) { // read the file
fputs(line, fpout); // write to 'more'
}
pclose(fpout);
exit(0);
}
注意,popen决不应由设置用户ID或设置组ID程序调用。当它执行命令时,popen等同于: execl("/bin/sh", "sh", "-c", command, NULL); 它在从调用者继承的环境中执行shell,并由shell解释执行command。一个恶意用户可以操控这种环境,使得shell能以设置ID文件模式所授予的提升了的权限以及非预期的方式执行命令。
popen特别适用于执行简单的过滤器程序,它变换运行命令的输入或输出。
协同进程
当一个过滤程序既产生某个过滤程序的输入,又读取该过滤程序的输出时,它就变成了协同进程。
标准I/O缓冲机制:
fgets 引起标准I/O库分配一个缓冲区,并选择缓冲的类型。如果标准输入是一个管道,所以标准I/O库默认的缓冲类型是全缓冲,即填满标准I/O缓冲区后才进行实际的I/O操作。
标准输出也是如此。
本节后略。
2. FIFO
FIFO也被称为命名管道 或 有名管道。
未命名的管道只能在两个相关的进程之间使用,而且这两个相关的进程还要有一个共同的创建了它们的祖先进程。但是,通过FIFO,不相关的进程也能交换数据。
创建 FIFO 类似于创建文件。FIFO的路径名存在于文件系统中。
#include <sys/stat.h>
int mkfifo(const char *path, mode_t mode);
int mkfifoat(int fd, const char *path, mode_t mode);
mkfifoat函数和mkfifo函数相似,但是mkfifoat函数可以被用来在fd文件描述符表示的目录相关的位置创建一个FIFO。
当open一个FIFO时,
-
如果没有指定 NON_BLOCK (非阻塞I/O), (注:默认就是阻塞I/O)
-
只读 open会阻塞到某个其他进程为写而打开这个FIFO为止;
-
只写open会阻塞到某个其他进程为读而打开它为止;
-
-
如果指定了 NON_BLOCK,
-
只读open会立即返回;
-
如果没有进程为读而打开一个FIFO,那么只写open会返回-1,并将errno设为ENXIO.
-
一个给定的 FIFO 有多个写进程是常见的。这就意味着,如果不希望多个进程所写的数据交叉,则必须考虑原子写操作。和管道一样,常量PIPE_BUF说明了可被原子地写到FIFO的最大数据量。
FIFO有以下两种用途:
- shell命令使用FIFO将数据从一条管道传送到另一条时,无需创建中间临时文件。
- 客户进程-服务器进程应用程序中,FIFO 用作汇聚点,在客户进程和服务器进程二者之间传递数据。
用FIFO复制输出流:
mkfifo fifo1
prog3 < fifo1 &
prog1 < infile | tee fifo1 | prog2
利用FIFO进行客户进程和服务器进程的通信:
- 各客户进程可以向一个 well-known 的 FIFO进行写操作,而服务器进程进行读操作
- 每个客户进程都在其请求中包含它的进程ID
- 然后服务器进程为每个客户进程创建一个FIFO,所使用的路径名以客户进程的ID为基础。该FIFO为服务器写response给客户进程。
XSI IPC
XSI IPC函数是紧密地基于System V的IPC函数的。
有3种称作XSI IPC的IPC:消息队列、信号量以及共享存储。它们之间有很多相似之处。
无论何时创建IPC结构(通过调用msgget、semget或shmget创建),都应指定一个键。这个键的数据类型是基本系统数据类型key_t,通常在头文件<sys/types.h>中被定义为长整型。这个键由内核变换成标识符。
有多种方法可以使客户进程和服务器进程在同一IPC结构上汇聚。(略,见15.6.1)
在Linux中,可以运行ipcs –l显示IPC相关的限制。
优缺点:
XSI IPC 的一个基本问题是:IPC 结构是在系统范围内起作用的,没有引用计数。
-
消息队列:如果进程创建了一个消息队列,并且在该队列中放入了几则消息,然后终止,那么该消息队列及其内容不会被删除。它们会一直留在系统中直至发生下列动作为止:
- 由某个进程调用 msgrcv 或 msgctl 读消息;
- 删除消息队列;
- 某个进程执行 ipcrm 命令删除消息队列;
- 或正在自举的系统删除消息队列。
-
管道:当最后一个引用管道的进程终止时,管道就被完全地删除了。
-
FIFO:在最后一个引用FIFO的进程终止时,FIFO的名字会被保留在系统中,直至被显式地删除,但是留在FIFO中的数据已被删除。
XSI IPC的另一个问题是:这些IPC结构在文件系统中没有名字。我们不能用第3章和第4章中所述的函数来访问它们或修改它们的属性。为了支持这些IPC对象,内核中增加了十几个全新的系统调用(msgget、semop、shmat等)。我们不能用 ls 命令查看IPC对象,不能用rm命令删除它们,也不能用chmod命令修改它们的访问权限。于是,又增加了两个新命令ipcs(1)和ipcrm(1).
因为这些形式的 IPC 不使用文件描述符,所以不能对它们使用多路转接 I/O 函数(select和poll)。这使得它很难一次使用一个以上这样的IPC结构,或者在文件或设备I/O中使用这样的IPC结构。例如,如果没有某种形式的忙等循环(busy-wait loop),就不能使一个服务器进程等待将要放在两个消息队列中任意一个中的消息。
3. XSI 消息队列
msgget: 创建一个新队列或打开一个现有队列。
msgsnd: 将新消息添加到队列尾端。每个消息包含一个正的长整型类型的字段、一个非负的长度以及实际数据字节数(对应于长度),所有这些都在将消息添加到队列时,传送给 msgsnd。
msgrcv: 从队列中取消息。我们并不一定要以先进先出次序取消息,也可以按消息的类型字段取消息。和msgsnd一样,ptr参数指向一个长整型数(其中存储的是返回的消息类型),其后跟随的是存储实际消息数据的缓冲区。nbytes 指定数据缓冲区的长度。若返回的消息长度大于 nbytes,而且在flag中设置了MSG_NOERROR位,则该消息会被截断。
msgctl函数对队列执行多种操作。它和另外两个与信号量及共享存储有关的函数(semctl和shmctl)都是XSI IPC的类似于ioctl的函数(亦即垃圾桶函数)。
调用的第一个函数通常是msgget,其功能是打开一个现有队列或创建一个新队列。
#include <sys/msg.h>
int msgget(key_t key, int flag); // 成功,返回消息队列ID;出错,返回-1
int msgsnd(int msqid, const void *ptr, size_t nbytes, int flag);
ssize_t msgrcv(int msgid, void *ptr, size_t nbytes, long type, int flag);
int msgctl(int msgid, int cmd, struct msgid_ds *buf);
注意,对删除消息队列的处理不是很完善。因为每个消息队列没有维护引用计数器(打开文件有这种计数器),所以在队列被删除以后,仍在使用这一队列的进程在下次对队列进行操作时会出错。
考虑到使用消息队列时遇到的问题(见15.6.4节),我们得出的结论是,在新的应用程序中不应当再使用消息队列。
4. XSI 信号量
信号量是一个计数器,用于为多个进程提供对共享数据对象的访问。
为了获得共享资源,进程需要执行下列操作:
- 测试控制该资源的信号量。
- 若此信号量的值为正,则进程可以使用该资源。然后,进程会将信号量值减1,表示它使用了一个资源单位。
- 若此信号量的值为0,则进程进入休眠状态,直至信号量值大于 0。进程被唤醒后,它返回至步骤1.
为了正确地实现信号量,信号量值的测试及减1操作应当是原子操作。为此,信号量通常是在内核中实现的。
常用的信号量形式被称为二元信号量(binary semaphore)。它控制单个资源,其初始值为1。但是,一般而言,信号量的初值可以是任意一个正值,该值表明有多少个共享资源单位可供共享应用。
遗憾的是,XSI信号量与此相比要复杂得多。以下3种特性造成了这种不必要的复杂性。
- 信号量并非是单个非负值,而必需定义为含有一个或多个信号量值的集合。当创建信号量时,要指定集合中信号量值的数量。
- 信号量的创建(semget)是独立于它的初始化(semctl)的。这是一个致命的缺点,因为不能原子地创建一个信号量集合,并且对该集合中的各个信号量值赋初值。
- 即使没有进程正在使用各种形式的XSI IPC,它们仍然是存在的。有的程序在终止时并没有释放已经分配给它的信号量,所以我们不得不为这种程序担心。
信号量函数:
- semget: 来获得一个信号量。
- semctl函数包含了多种信号量操作。
- semop函数自动执行信号量集合上的操作数组。semop函数具有原子性:它或者执行数组中的所有操作,或者一个也不做。
#include <sys/sem.h>
int semget(key_t key, int nsems, int flag); // 成功,返回信号量ID;出错,返回-1
int semctl(int semid, int semnum, int cmd, ... /* union semun arg */);
int semop(int semid, struct sembuf semoparray[], size_t nops);
信号量的常用使用方法:
- 先创建一个包含一个成员的信号量集合,然后将该信号量值初始化为 1。
- 为了分配资源,以 sem_op 为-1调用 semop;为了释放资源,以sem_op为+1调用semop。
- 对每个操作都指定SEM_UNDO,以处理在未释放资源条件下进程终止的情况。
共享存储、信号量和记录锁的比较:
在Linux上,记录锁比信号量快,但是共享存储中的互斥量的性能比信号量和记录锁的都要优越。如果我们能单一资源加锁,并且不需要XSI信号量的所有花哨功能,那么记录锁将比信号量要好。
原因是记录锁使用起来更简单、速度更快,当进程终止时系统会管理遗留下来的锁。
在共享存储中使用互斥量是一个更快的选择,但是我们依然喜欢使用记录锁,除非要特别考虑性能。这样做有两个原因。
- 首先,在多个进程间共享的内存中使用互斥量来恢复一个终止的进程更难。
- 其次,进程共享的互斥量属性还没有得到普遍支持。在本书讨论的4个平台中,只有Linux 3.2.0和Solaris 10当前支持进程共享的互斥量属性。
5. XSI 共享存储
前面已经看到了共享存储的一种形式,就是多个进程将同一个文件映射到它们的地址空间。
XSI 共享存储和内存映射的文件的不同之处在于,前者没有相关的文件。XSI 共享存储段是内存的匿名段。
共享存储操作函数:
- shmget:获得一个共享存储
- shmctl: 对共享存储段执行多种操作
- shmat: 一旦创建了一个共享存储段,进程就可调用shmat将其连接到它的地址空间中。
-
shmdt: 对共享存储段的操作结束时,则调用shmdt与该段分离.
注意,这并不从系统中删除其标识符以及相关的数据结构。该标识符仍然存在,直至某个进程带 IPC_RMID 命令地调用 shmctl 将其删除为止。
#include <sys/shm.h>
// 成功,返回共享存储ID;出错,返回-1
int shmget(key_t key, size_t size, int flag);
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
// 成功,返回指向共享存储段的指针;出错,返回-1
void *shmat(int shmid, const void *addr, int flag);
int shmdt(const void *addr);
shmdt函数的addr参数是以前调用shmat函数时的返回值。若成功,则shmdt将相关shmid_ds结构中的shm_nattach计数器减1.
图15-32 基于Intel的Linux系统的存储区布局
高地址 |
命令行参数和环境变量 |
栈 |
共享存储 |
堆 |
未初始化的数据(BSS段) |
已初始化的数据 |
正文 |
低地址 |
6. POSIX信号量
POSIX信号量接口意在解决XSI信号量接口的几个缺陷:
- 性能更高
- 使用更简单:没有信号量集,在熟悉的文件系统操作后一些接口被模式化了。尽管没有要求一定要在文件系统中实现,但是一些系统的确是这么实现的。
- 删除时表现更完美:当一个XSI信号量被删除时,使用这个信号量标识符的操作会失败,并将errno设置成EIDRM。使用POSIX信号量时,操作能继续正常工作直到该信号量的最后一次引用被释放。
POSIX信号量有两种形式:命名的和未命名的。
它们的差异在于创建和销毁的形式上,但其他工作一样。
- 未命名信号量只存在于内存中,并要求能使用信号量的进程必须可以访问内存。这意味着它们只能应用在同一进程中的线程,或者不同进程中已经映射相同内存内容到它们的地址空间中的线程。
- 命名信号量可以通过名字访问,因此可以被任何已知它们名字的进程中的线程使用(即可以被不同进程或同一个进程的不同线程访问)。
换句话说,POSIX信号量既支持多进程访问,也支持同一进程的多线程访问。
POSIX信号量操作函数:
- sem_open函数: 创建一个新的命名信号量或者使用一个现有信号量。
- sem_close函数: 释放任何信号量相关的资源。如果进程没有调用sem_close而退出,则内核将自动关闭任何打开的信号量。
- sem_unlink函数: 销毁一个命名信号量。sem_unlink函数删除信号量的名字。如果没有打开的信号量引用,则该信号量会被销毁,否则,销毁将延迟到最后一个打开的引用关闭。
- sem_wait函数和sem_trywait函数:实现信号量减1的操作。使用sem_wait函数时,如果信号量计数是0就会发生阻塞。直到成功使信号量减1或者被信号中断时才返回。可以使用sem_trywait函数来避免阻塞。调用sem_trywait时,如果信号量是0,则不会阻塞,而是会返回-1,并且将errno置为EAGAIN.
- sem_timedwait函数:阻塞一段确定的时间。
- sem_post函数:使信号量值增1.
- sem_init函数:创建一个未命名的信号量。当我们想在单个进程中使用POSIX信号量时,使用未命名信号量更容易。这仅仅改变创建和销毁信号量的方式。pshared参数表明是否在多个进程中使用信号量。如果是,将其设置成一个非0值。value参数指定了信号量的初始值。需要声明一个sem_t类型的变量并把它的地址传递给sem_init来实现初始化,而不是像sem_open函数那样返回一个指向信号量的指针。
- sem_destroy函数:丢弃未命名的信号量。
- sem_getvalue函数:检索信号量的值
#include <semaphore.h>
#include <time.h>
// 成功,返回指向信号量的指针;出错,返回 SEM_FAILED
// 返回值是一个指针,将来可以将其传递给其他信号量函数用于一些操作。
sem_t *sem_open(const char *name, int oflag, /* mode_t mode, unsigned int value */);
int sem_close(sem_t *sem);
int sem_unlink(const char *name);
int sem_trywait(sem_t *sem);
int sem_wait(sem_t *sem);
int sem_timedwait(sem_t *restrict sem, const struct timespec *restrict tsptr);
int sem_post(sem_t *sem);
int sem_init(sem_t *sem, int pshared, unsigned int value);
int sem_destroy(sem_t *sem);
int sem_getvalue(sem_t *restrict sem, int *restrict valp);
客户进程-服务器进程属性
略
小结
信号量实际上是同步原语,而不是IPC; 信号量常用于共享资源(如共享存储段)的同步访问。
(完)