Linux的奇妙冒险——进程间通信(管道、SystemV IPC)
Linux的奇妙冒险——进程间通信
- 一.进程间通信
- 1.目的
- 2.分类
- 二.管道
- 1.匿名管道
- 1.原理
- 2.接口
- 3.利用匿名管道完成一个简单的进程池任务
- 2.命名管道
- 三.systemV ipc
- 1.共享内存
- 1.函数接口
- 2.操作
- 3.利用命名管道进行简易的互斥
- 2.其他ipc
- 1.消息队列
- 2.信号量
- 3.linux系统中的ipc统一管理
一.进程间通信
1.目的
• 数据传输:⼀个进程需要将它的数据发送给另⼀个进程
• 资源共享:多个进程之间共享同样的资源。
• 通知事件:⼀个进程需要向另⼀个或⼀组进程发送消息,通知它(它们)发⽣了某种事件(如进程终⽌时要通知⽗进程)。
• 进程控制:有些进程希望完全控制另⼀个进程的执⾏(如Debug进程),此时控制进程希望能够拦截另⼀个进程的所有陷⼊和异常,并能够及时知道它的状态改变
2.分类
• 管道
• System V进程间通信
• POSIX进程间通信
• 套接字(网络部分)
建议场景:
- 简单数据流:用管道。
- 高性能大数据共享:用共享内存(配合信号量或互斥锁)。
- 进程同步:用信号量或文件锁(fcntl)。
- 结构化消息传递:用消息队列或 D-Bus。
- 客户端/服务器模型(尤其是无关进程):用 Unix 域套接字。
- 通知简单事件:用信号。
二.管道
我们把从⼀个进程连接到另⼀个进程的⼀个数据流称为⼀个“管道”
管道读写规则
• 当没有数据可读时
◦ O_NONBLOCK disable:read调⽤阻塞,即进程暂停执⾏,⼀直等到有数据来到为⽌。
◦ O_NONBLOCK enable:read调⽤返回-1,errno值为EAGAIN。
• 当管道满的时候
◦ O_NONBLOCK disable:write调⽤阻塞,直到有进程读⾛数据
◦ O_NONBLOCK enable:调⽤返回-1,errno值为EAGAIN
• 如果所有管道写端对应的⽂件描述符被关闭,则read返回0
• 如果所有管道读端对应的⽂件描述符被关闭,则write操作会产⽣信号SIGPIPE,进⽽可能导致
write进程退出
• 当要写⼊的数据量不⼤于PIPE_BUF时,linux将保证写⼊的原⼦性。
• 当要写⼊的数据量⼤于PIPE_BUF时,linux将不再保证写⼊的原⼦性。
1.匿名管道
1.原理
针对父子进程,由于子进程天然继承父进程的文件描述符和页表,所以父子进程可以同时看到同一份资源
只能⽤于具有共同祖先的进程(具有亲缘关系的进程)之间进⾏通信;通常,⼀个管道由⼀个进程创建,然后该进程调⽤fork,此后⽗、⼦进程之间就可应⽤该管道。
• 管道提供流式服务
• ⼀般⽽⾔,进程退出,管道释放,所以管道的⽣命周期随进程
• ⼀般⽽⾔,内核会对管道操作进⾏同步与互斥
• 管道是半双⼯的,数据只能向⼀个⽅向流动;需要双⽅通信时,需要建⽴起两个管道
2.接口
pipe
#include <unistd.h>/* On Alpha, IA-64, MIPS, SuperH, and SPARC/SPARC64; see NOTES */struct fd_pair {long fd[2];
};
struct fd_pair pipe();/* On all other architectures */
int pipe(int pipefd[2]);#define _GNU_SOURCE /* Seefeature_test_macros(7) */
#include <fcntl.h> /* Obtain O_*constant definitions */
#include <unistd.h>
建立匿名管道的步骤就是,建立管道->创建子进程->分别关闭自己不需要的文件描述符
int main()
{// 创建管道int pipefd[2] = {0};if (pipe(pipefd) < 0){std::cout << "pipe error" << std::endl;return -1;}// 创建子进程pid_t pid = fork();if (pid < 0){std::cout << "fork error" << std::endl;return -1;}else if (pid == 0) { // 子进程 // 关闭写端 ::close(pipefd[1]); dup2(pipefd[0], 0); ::close(pipefd[0]); // 关闭不再需要的管道读端 // 正确的实现方法:直接从pipefd[0]读取 int ret; while (1) { int fd = ::read(0, &ret, sizeof(ret)); // 从pipefd[0]读取 if (fd < 0) { std::cout << "read error" << std::endl; exit(1); } else if (fd == 0) { std::cout << "pipe closed" << std::endl; break; // 退出循环 } std::cout << "i am father cnt : " << ret << std::endl; sleep(1); } exit(0); // 正常退出子进程 }else{// 父进程// 关闭读端::close(pipefd[0]);int cnt = 10;while (cnt){::write(pipefd[1], &cnt, sizeof(cnt));cnt--;sleep(1);}::close(pipefd[1]);int wait = ::waitpid(pid, NULL, 0);if (wait < 0){std::cout << "waitpid error" << std::endl;return -1;}}return 0;
}
3.利用匿名管道完成一个简单的进程池任务
具体代码参考:https://gitee.com/yang-liqiang1876/linux-system-learning-code/tree/master/system/processpool
2.命名管道
命名管道不局限于父子进程,命名管道是操作系统设置的一种特殊文件
- 命名管道可以从命令⾏上创建,命令⾏⽅法是使⽤下⾯这个命令
$ mkfifo filename
- 命名管道也可以从程序⾥创建,相关函数有:
int mkfifo(const char *filename,mode_t mode);
filename:文件名
mode:文件权限
FIFO(命名管道)与pipe(匿名管道)之间唯⼀的区别在它们创建与打开的⽅式不同,⼀但这些
⼯作完成之后,它们具有相同的语义
三.systemV ipc
1.共享内存
共享内存区是最快的IPC形式。⼀旦这样的内存映射到共享它的进程的地址空间,这些进程间数据传递不再涉及到内核,换句话说是进程不再通过执⾏进⼊内核的系统调⽤来传递彼此的数据
性质与特点
无结构: 共享内存段只是一块原始的字节数组,没有任何内置的结构(如链表、队列)。数据的含义和格式完全由使用它的进程自己约定。
极快的速度: 因为数据不需要在内核和用户空间之间拷贝(大多数操作就是这样)。进程可以直接读写内存,就像操作普通内存一样。
需要同步: 这是最大的挑战。因为多个进程可以同时读写,必须使用额外的同步机制(通常是信号量)来保护共享内存,防止出现竞态条件和数据不一致。
1.函数接口
- shmget函数
功能:⽤来创建共享内存
1.原型 int shmget(key_t key, size_t size, int shmflg);
2.参数:
key:这个共享内存段名字(由用户和系统共同决定)
size:共享内存⼤⼩
shmflg:由九个权限标志构成,它们的⽤法和创建⽂件时使⽤的mode模式标志是⼀样的
取值为IPC_CREAT:共享内存不存在,创建并返回;共享内存已存在,获取并返回。
取值为IPC_CREAT | IPC_EXCL:共享内存不存在,创建并返回;共享内存已存在,出错返回。
3.返回值:成功返回⼀个⾮负整数,即该共享内存段的标识码;失败返回-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
2.操作
3.利用命名管道进行简易的互斥
进程1
int main()
{shm.GetShm();shm.Shmattch();fifo.OpenForWrite();// 获取数据struct Data *shmdata = (struct Data *)shm.Getaddr();std::string str = "";char ch = 'A';while (ch <= 'Z'){str += ch;ch++;strcpy(shmdata->status, "最新");strcpy(shmdata->lasttime, GetCurTime().c_str());strcpy(shmdata->image, str.c_str());fifo.notify();sleep(1);}shm.Shmdeattch();return 0;
}
进程2
int main()
{shm.CreatShm();shm.Shmattch();fifo.OpenForRead();//获取数据struct Data* shmdata = (struct Data*)shm.Getaddr();while (true){fifo.wait();printf("status: %s\n", shmdata->status);printf("lasttime: %s\n", shmdata->lasttime);printf("image: %s\n", shmdata->image);strcpy(shmdata->status, "过期");sleep(1);}shm.Shmdeattch();sleep(5);shm.Shmdelete();return 0;
}
2.其他ipc
1.消息队列
消息队列提供了一个在内核中维护的消息链表。进程可以通过消息队列异步地发送和接收具有特定格式和优先级的数据块(消息)。
有结构: 数据被封装成一个个具有明确类型(long mtype)的消息,接收方可以按类型读取,从而实现了一种简单的优先级机制(类型值小的先被读取)。
面向数据块: 通信单位是完整的消息,而不是原始的字节流。
内核持久性: 消息会一直保留在队列中,直到被进程成功读取。
同步/异步: 可以是阻塞的(如果队列空,则接收者睡眠;如果队列满,则发送者睡眠)或非阻塞的。
开销较大: 每次读写都涉及内核和用户空间之间的数据拷贝,因此速度不如共享内存。
2.信号量
System V 信号量是一个由内核维护的计数器,主要用于同步对共享资源(如共享内存)的访问,防止多个进程同时进入临界区。它本身不传输数据,而是用来协调进程的执行顺序。
计数器: 其值通常表示可用资源的数量。例如,初始值为1的信号量可以实现互斥锁(Mutex)。
原子操作: 对信号量的操作(wait 和 signal)都是原子的,不会被其他进程打断,这是它能正确实现同步的基础。
P/V 操作 (或 Wait/Signal):
P操作 (Wait, semop 减1): 如果信号量的值大于0,则将其减1,表示进程获取了一个资源。如果值为0,则进程通常会阻塞,直到有资源可用。
V操作 (Signal, semop 加1): 将信号量的值加1,表示进程释放了一个资源。如果有进程在等待此信号量,则唤醒它们。
复杂操作: 通过 semop() 函数,可以一次对信号量集中的多个信号量进行一组操作,这些操作要么全部成功,要么全部失败,保证了复杂性下的原子性。
3.linux系统中的ipc统一管理
在linux系统中设计的这套ipc方案,共享内存,消息队列,信号量的接口和使用都极为相似,所以系统将其如何统一管理就值得深究
man shmctl
man msgctl
man semctl
我们可以看见,这三种ipc都含有一个struct ipc_perm
struct_ipc_ids 是系统定义的全局变量,所有进程都可以看到
在早期linux系统中,我们通常利用,结构体地址与结构体第一个成员地址相同的特性,完成多态,对不同的数据结构进行统一管理