目录
为什么有进程间通信?进程间通信的目的是什么?
管道
匿名管道
父子进程共享管道
命名管道
共享内存
概念
原理
共享内存和内存映射(文件映射)的区别
使用
消息队列
概念
使用
信号量
概念
使用
IPCS 命令
System V 版本进程通信内核表示
为什么有进程间通信?进程间通信的目的是什么?
- 数据传输:一个进程需要将他的数据发送给另一个进程。
- 资源共享:多个进程之间共享相同的资源。
- 通知事件:一个进程需要向另一个或一组进程发送消息,通知这些进程发生了某种事件(如进程终止时要通知父进程)。
- 进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
管道
什么是管道?管道是Unix中最古老的进程间通信的形式。我们把从一个进程连接到另一个进程的一个数据流称为一个“管道”。
管道怎么实现的?内核环形缓冲区
优点:实现简单,管道通信不需要像消息队列、共享内存等进程通信方式那样对系统资源进行复杂管理,实现起来较为简单方便。
缺点:
- 通信方式效率低,不适合进程间频繁地交换数据。
- 数据是无格式的流且大小受限,Ubuntu 20.04下管道大小为 65536 B = 64 KB。
- 通信的方式是单向的,数据只能在一个方向上流动,如果要双向通信,需要创建两个管道。
匿名管道
特点:
- 自带同步机制。
- 匿名管道是只能用于存在父子关系的进程间通信。
- pipe 是面向字节流的。
- 没有名字标识,匿名管道是特殊文件只存在于内存,没有存在于文件系统中。
- 生命周期随着进程创建而建立,随着进程终止而消失。父子进程退出,管道自动释放,因为管道文件的生命周期是随进程的。
-
管道只能单向数据通信的,这就意味着管道是半双工的一种特殊情况。
匿名管道使用的四种情况:
- 管道内部没有数据 && 子进程(写端)不关闭自己的写端文件fd,读端(父进程)就要阻塞等待,直到pipe有数据。
- 管道内部被写满 && 父进程(读端)不关闭自己的读端文件fd,写端(子进程)写满之后就要阻塞等待。这个大小在Ubuntu 20.04为 65536 B = 64 KB。
- 写端不写了 && 关闭了pipe,读端会将pipe中的数据读完,最后就会读取到返回值0,表示读结束。
- 读端不读了 && 关闭了pipe,写端再写就无意义了,OS会直接终止写入的进程(子进程),通过信号13) SIGPIPE杀掉进程。
此外,pipe 内部定义了 PIPE_BUF = 4 KB,PIPE_BUF 的定义见 <limits.h>
- 当要写入的数据量不大于PIPE_BUF时,linux将保证写入的原子性;
- 当要写入的数据量大于PIPE_BUF时,linux将不保证写入的原子性。
#include <unistd.h>
功能:创建匿名管道
原型
int pipe(int fd[2]);
参数
fd:文件描述符数组,其中 fd[0] 表示读端, fd[1] 表示写端
返回值:成功返回 0,失败返回错误代码
父子进程共享管道
使用 pipe 创建匿名管道,然后父进程调用 close(fd[1]) 关闭写段,子进程调用 close(fd[0]) 关闭读端,就可以实现父子进程通信。
示例代码
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
void writer(int wfd)
{
const char *str = "hello father, I am child";
char buffer[128];
int cnt = 0;
pid_t pid = getpid();
while(1)
{
snprintf(buffer, sizeof(buffer), "message: %s, pid: %d, count: %d\n", str, pid, cnt);
write(wfd, buffer, strlen(buffer));
cnt++;
sleep(1);
}
}
void reader(int rfd)
{
char buffer[1024];
while(1)
{
ssize_t n = read(rfd, buffer, sizeof(buffer)-1);
(void)n;
printf("father get a message: %s", buffer);
}
}
int main()
{
// 1.
int pipefd[2];
int n = pipe(pipefd);
if(n < 0) return 1;
printf("pipefd[0]: %d, pipefd[1]: %d\n", pipefd[0]/*read*/, pipefd[1]/*write*/);
// 2.
pid_t id = fork();
if(id == 0)
{
//child: w
close(pipefd[0]);
writer(pipefd[1]);
exit(0);
}
// father: r
close(pipefd[1]);
reader(pipefd[0]);
wait(NULL);
return 0;
}
命名管道
在文件系统创建一个类型为 p 的设备文件,那么毫无关系的进程就可以通过这个设备文件进行通信。
调用接口
#include <sys/types.h>
#include <sys/stat.h>
int mkfifo(const char *pathname, mode_t mode);
#include <fcntl.h> /* Definition of AT_* constants */
#include <sys/stat.h>
int mkfifoat(int dirfd, const char *pathname, mode_t mode);
#include <unistd.h>
int unlink(const char *pathname);
共享内存
概念
共享内存区是最快的 IPC 形式。一旦这样的内存映射到共享它的进程的地址空间,这些进程间数据传递不再涉及到内核,换句话说是进程不再通过执行进入内核的系统调用来传递彼此的数据。
共享内存可以存在很多个,OS必须对这些共享内存进行管理 --- 先描述,再组织!
共享内存数据结构如下:
struct shmid_ds
{
struct ipc_perm shm_perm; /* operation perms */
int shm_segsz; /* size of segment (bytes) */
__kernel_time_t shm_atime; /* last attach time */
__kernel_time_t shm_dtime; /* last detach time */
__kernel_time_t shm_ctime; /* last change time */
__kernel_ipc_pid_t shm_cpid; /* pid of creator */
__kernel_ipc_pid_t shm_lpid; /* pid of last operator */
unsigned short shm_nattch; /* no. of current attaches */
unsigned short shm_unused; /* compatibility */
void *shm_unused2; /* ditto - used by DIPC */
void *shm_unused3; /* unused */
};
原理
-
拿出一块虚拟地址空间来,映射到相同的物理内存中。
-
解决消息队列通信中用户态与内核态之间数据拷贝过程带来的开销,它直接分配一个共享空间,每个进程都可以直接访问。
-
带来新的问题,多进程竞争同个共享资源会造成数据的错乱。解决方式:1. 信号量 2. 条件变量 + 锁。
共享内存和内存映射(文件映射)的区别
共享内存:利用共享内存完成进程间通信,两个进程通过虚拟内存到用户级页表,然后通过用户级页表最后访问同一块物理内存,实现进程间通信。
mmap:
- mmap 是在磁盘上建立一个文件,每个进程地址空间中开辟出一块空间进行映射。而 shm 共享内存,每个进程最终会映射到同一块物理内存。
- mmap 会把数据同步到文件中(会有缓存机制),shm不会把数据写入文件,这意味着 mmap 重启不会丢失共享数据。
- mmap 共享数据需要通过文件,io 效率要比 shm 的内存读写肯定要低很多。
- 内存空间比磁盘空间小很多,所以使用 mmap 可以共享的数据比 shm 大很多。
关于 mmap 以及 malloc / free 可查看以下文章:【C语言】一文详解 malloc / free 分配内存和释放内存相关问题-CSDN博客
使用
头文件
#include <sys/ipc.h>
#include <sys/shm.h>
shmget
功能:用来创建共享内存
原型
int shmget(key_t key, size_t size, int shmflg);
参数
key:这个共享内存段名字
size:共享内存大小
shmflg:由九个权限标志构成,它们的用法和创建文件时使用的mode模式标志是一样的
返回值:成功返回一个非负整数,即该共享内存段的标识码;失败返回-1
shmat
功能:将共享内存段连接到进程地址空间
原型
void *shmat(int shmid, const void *shmaddr, int shmflg);
参数
shmid: 共享内存标识
shmaddr:指定连接的地址
shmflg:它的两个可能取值是SHM_RND和SHM_RDONLY
返回值:成功返回一个指针,指向共享内存第一个节;失败返回-1说明
shmaddr为NULL,核心自动选择一个地址
shmaddr不为NULL且shmflg无SHM_RND标记,则以shmaddr为连接地址。
shmaddr不为NULL且shmflg设置了SHM_RND标记,则连接的地址会自动向下调整为SHMLBA的整数倍。公式:shmaddr - (shmaddr % SHMLBA)
shmflg=SHM_RDONLY,表示连接操作用来只读共享内存
shmdt
功能:将共享内存段与当前进程脱离
原型
int shmdt(const void *shmaddr);
参数
shmaddr: 由shmat所返回的指针
返回值:成功返回0;失败返回-1
注意:将共享内存段与当前进程脱离不等于删除共享内存段
shmctl
功能:用于控制共享内存
原型
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
参数
shmid:由shmget返回的共享内存标识码
cmd:将要采取的动作(有三个可取值),见下图
buf:指向一个保存着共享内存的模式状态和访问权限的数据结构
返回值:成功返回0;失败返回-1
消息队列
概念
实际上是保存在内核的消息链表,消息队列的消息体是可以用户自定义的数据类型。
优点
- 可以频繁通信了
- 可以独立于读写进程存在,避免了 FIFO 中同步管道打开和关闭可能产生的困难
- 避免了 FIFO 的同步阻塞问题,不需要进程自己提供同步方法
- 读进程可以根据消息消息类型有选择的接受消息,而不是像 FIFO 那样默认接受
缺点
- 通信不及时,每次数据的写入和读取都需要经过用户态与内核态之间的拷贝过程。
- 消息队列不适合比较大数据的传输,因为在内核中每个消息体都有一个最大长度的限制,同时所有队列所包含的全部消息体的总长度也是有上限。
消息队列生命周期随内核,如果没有释放消息队列或者没有关闭操作系统,消息队列会一直存在。
使用
头文件
#include <sys/types.h>
#include <sys/ipc.h>#include <sys/msg.h>
msgget
功能:获取消息队列标识符
原型
int msgget(key_t key, int msgflg);
msgctl
功能:用于控制消息队列
原型
int msgctl(int msqid, int cmd, struct msqid_ds *buf);
msgsnd
功能:用于发送数据给消息队列
原型
int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
msgrcv
功能:用于从消息队列接收数据
原型
ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);
信号量
概念
对于共享资源进行保护,是一个多执行流场景下,一个比较常见和重要的话题。
互斥:在访问一部分共享资源的时候,任何时刻只有一个进程访问。
同步:访问资源在安全的前提下,具有一定的顺序性。
临界资源:被保护起来的,任何时刻只允许一个进程进行访问的公共资源。
临界区:访问临界资源的代码。
信号量本质上是一个描述临界资源数量的计数器,对公共局部临界资源的预定机制,用来保护临界资源。
假设用 count 描述临界资源的数量。
可以将信号量申请看作预定资源,if (count > 0) count--; else wait;
将信号量释放看作释放资源,此临界资源可以被其他进程申请,count++;
但是,用 int 类型定义 count 不能实现信号量的效果。
原因:
1. 无法在进程间共享,对 count 的操作会触发写时拷贝,于是就要让进程提前看到一部分资源 --- 信号量这种计数器资源。
2. count++,count-- 的操作不是原子的。
所有的资源要访问临界资源,都必须先申请信号量,那么所有进程都得先看到同一个信号量,同时,信号量本身就是共享资源!信号量的申请和释放就必须是原子性的。信号量申请是P操作,信号量释放是V操作,信号量的申请释放称为PV操作。
如果信号量初始值是1呢?代表将临界资源看作整体,来实现互斥,二元信号量就是一把锁。
使用
头文件
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
功能:创建信号量集标识符
int semget(key_t key, int nsems, int semflg);
功能:op是operation的缩写,对指定信号量进行操作
int semop(int semid, struct sembuf *sops, size_t nsops);
功能:对指定信号量进行控制
int semctl(int semid, int semnum, int cmd, ...);
该函数参数数量为 3 或 4,取决于 cmd 参数,第 4 个参数是一个 union 类型变量,如下图:
IPCS 命令
ipcs [options]
options:
-m | 查看系统中的共享内存 |
-q | 查看系统中的消息队列 |
-s | 查看系统中的信号量 |
-a | 查看当前使用的共享内存、消息队列及信号量所有信息 |
-p | 得到与共享内存、消息队列相关进程之间的消息 |
-u | 查看各个资源的使用总结信息 |
-l | 查看各个资源的系统限制信息 |
ipcrm [options] <id>
options:
-m <shmid> | 删除 shmid 对应的共享内存 |
-q <msgid> | 删除 msgid 对应的消息队列 |
-s <semid> | 删除 semid 对应的信号量 |
-a | 删除所有进程间通信的资源 |
System V 版本进程通信内核表示
/* used by in-kernel data structures */
struct kern_ipc_perm
{
spinlock_t lock;
int deleted;
key_t key;
uid_t uid;
gid_t gid;
uid_t cuid;
gid_t cgid;
mode_t mode;
unsigned long seq;
void *security;
};
struct ipc_id_ary {
int size;
struct kern_ipc_perm *p[0];
};
struct shmid_kernel /* private to the kernel */
{
struct kern_ipc_perm shm_perm;
struct file * shm_file;
int id;
unsigned long shm_nattch;
unsigned long shm_segsz;
time_t shm_atim;
time_t shm_dtim;
time_t shm_ctim;
pid_t shm_cprid;
pid_t shm_lprid;
struct user_struct *mlock_user;
};
struct msg_queue {
struct kern_ipc_perm q_perm;
time_t q_stime; /* last msgsnd time */
time_t q_rtime; /* last msgrcv time */
time_t q_ctime; /* last change time */
unsigned long q_cbytes; /* current number of bytes on queue */
unsigned long q_qnum; /* number of messages in queue */
unsigned long q_qbytes; /* max number of bytes on queue */
pid_t q_lspid; /* pid of last msgsnd */
pid_t q_lrpid; /* last receive pid */
struct list_head q_messages;
struct list_head q_receivers;
struct list_head q_senders;
};
struct sem_array {
struct kern_ipc_perm sem_perm; /* permissions .. see ipc.h */
time_t sem_otime; /* last semop time */
time_t sem_ctime; /* last change time */
struct sem *sem_base; /* ptr to first semaphore in array */
struct sem_queue *sem_pending; /* pending operations to be processed */
struct sem_queue **sem_pending_last; /* last pending operation */
struct sem_undo *undo; /* undo requests on this array */
unsigned long sem_nsems; /* no. of semaphores in array */
};
共享内存、消息队列、信号量的部分源码如上,可以看到:
共享内存、消息队列、信号量 --- 存在共性,这是操作系统故意为之的,用于 system V 进程间通信的,因为操作系统注定要对 IPC 资源进行管理,如何管理?先描述,再组织。
内核中,所有描述管理 IPC 资源的结构体,第一个成员都一样 --- kern_ipc_perm,然后定义出了一个与文件描述符表毫无关系的 ipc_id_ary 结构体,里面包含申请到的 kern_ipc_perm 和 对应的个数,用于管理 IPC 资源,而这种设计方式与文件操作进行了隔离,有悖于 Linux 里的一切皆文件,所以这种技术后来逐渐被替代了。
这种设计方式也有优点,用C语言实现了高级语言里的多态!!
(shmid_kernel*)ipc_array[0]->shm_nattch;
(msg_queue*)ipc_array[1]->q_stime;
(sem_array*)ipc_array[2]->sem_pending;
那么我怎么知道 ipc_array 里 i 号下标的是什么资源呢?
#define IPC_SHM_TYPE 1 << 0
#define IPC_MSG_TYPE 1 << 1
#define IPC_SEM_TYPE 1 << 2
auto GetKernIpcPerm(kern_ipc_perm* p)
{
if (p->mode & IPC_SHM_TYPE)
return (shmid_kernel*) p;
else if (p->mode & IPC_MSG_TYPE)
return (msg_queue*) p;
else if (p->mode & IPC_SEM_TYPE)
return (sem_array*) p;
}