目录
一、进程间通信介绍
1. 进程间通信概念
进程间通信(Inter-Process Communication, IPC)是指在不同进程之间传递或交换信息的一种机制。在操作系统中,进程是资源分配和独立运行的基本单位,它们拥有各自独立的内存空间和系统资源。因此,进程间不能直接访问对方的内存空间,需要通过特定的通信机制来实现数据交换和同步操作
是什么
两个或多个进程实现数据层面的交互。因为进程独立性的存在,导致进程通信的成本比较高
为什么
发送基本数据、发送指令、多进程协同等需求
怎么办
- 进程间通信的本质:必须让不同的进程看到同一份“资源”,这里的资源就是指以特定形式存在的内存空间
- 资源由谁来提供?一般是操作系统。为什么不是我们两个进程中的一个呢?假设一个进程提供,这个资源就属于该进程所有,因为进程具有独立性!破坏进程独立性,这是操作系统不允许的,所以需要第三方提供空间,所以操作系统就是和事佬
- 我们进程访问空间,进行进程间通信,本质就是访问操作系统,而进程代表的是用户,用户不能直接访问操作系统内核数据,所以操作系统提供了系统调用接口,所以是从操作系统底层设计,从接口设计,一个独立的通信模块IPC —— 隶属文件系统。当进程通信变多,显而易见,操作系统需要将他们管理起来,先描述再组织。
2. 进程间通信目的
-
数据传输:一个进程需要将它的数据发送给另一个进程
-
资源共享:多个进程之间共享同样的资源。
-
通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
-
进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
3. 进程间通信的本质
进程间通信的本质:必须让不同的进程看到同一份“资源”,这里的资源就是指以特定形式存在的内存空间
资源由谁来提供?
一般是操作系统。为什么不是我们两个进程中的一个呢?假设一个进程提供,这个资源就属于该进程所有,因为进程具有独立性!破坏进程独立性,这是操作系统不允许的,所以需要第三方提供空间,所以操作系统就是和事佬
我们进程访问空间,进行进程间通信,本质就是访问操作系统,而进程代表的是用户,用户不能直接访问操作系统内核数据,所以操作系统提供了系统调用接口,所以是从操作系统底层设计,从接口设计,一个独立的通信模块IPC —— 隶属文件系统。当进程通信变多,显而易见,内存里的资源变多了,操作系统需要将他们管理起来,先描述再组织。
因此,进程间通信的本质就是,让不同的进程看到同一份资源(内存,文件内核缓冲等)。 由于这份资源可以由操作系统中的不同模块提供,因此出现了不同的进程间通信方式。
4. 进程间通信发展
- 管道
- System V 进程间通信
- POSIX 进程间通信
5. 进程间通信分类
管道(文件缓冲区)
- 匿名管道pipe
- 命名管道
System V IPC
- System V 消息队列
- System V 共享内存 (内存块)
- System V 信号量
POSIX IPC
- 消息队列
- 共享内存
- 信号量
- 互斥量
- 条件变量
- 读写锁
二、管道
管道(Pipes)是一种基本的进程间通信(IPC)机制,用于连接一个进程的输出到另一个进程的输入。管道允许数据以字节流的形式从一个进程传递到另一个进程。它是单向的,即数据只能从一个方向流动。管道分为匿名管道和命名管道(也称为FIFO,即First In First Out)。
原理:基于文件的一种通信方式
1. 匿名管道
匿名管道是最早出现的UNIX IPC机制之一,它只能用于具有亲缘关系的进程之间(通常是父子进程或兄弟进程)。当一个进程创建了一个管道后,它会得到两个文件描述符:一个用于写(通常称为管道的写端),另一个用于读(通常称为管道的读端)。进程可以将数据写入管道的写端,然后另一个进程可以从管道的读端读取数据。由于管道是基于文件描述符的,因此当所有指向管道的文件描述符都被关闭后,管道中的数据就会被丢弃,管道本身也会被销毁。
管道是一种简单但强大的IPC机制,它适用于需要数据流的场景,如父子进程之间的数据传递。然而,由于其单向性和有限的容量,管道可能不适合所有类型的IPC需求。在这种情况下,可以考虑使用其他IPC机制,如消息队列、共享内存或套接字等。
例如,统计我们当前使用云服务器上的登录用户个数
who | wc -l
who命令和wc命令都是两个程序,当它们运行起来后就变成了两个进程,who进程通过标准输出将数据打到“管道”当中,wc进程再通过标准输入从“管道”当中读取数据,至此便完成了数据的传输,进而完成数据的进一步加工处理。( who命令用于查看当前云服务器的登录用户(一行显示一个用户),wc -l用于统计当前的行数)
由管道(“|”)连接起来的各个进程是有亲缘关系的,它们之间互为兄弟进程,是匿名管道
1.1 匿名管道原理
重温进程结构体
进程创建时,创建 task_struct 结构体,结构体内有指针指向 files_struct 结构体,该结构体内有一个strcut file* 数组,即文件描述符表,指向被该进程打开的 struct file 文件(默认打开stdin、stdout、stderr,对应键盘和显示器文件)。如果进程又打开一个文件(操作系统创建的内存级文件),那么为就为该文件分配一个最小的没有被使用的fd,在fd下标的文件描述符数组的元素填写指向该文件的struct file的struct file*,并且 struct file 结构体还指向了 inode 属性集结构体(有大部分inode的属性)、file_opeartors函数方法集(对硬件的操作函数的函数指针结构体)、文件页缓冲区。对于文件页缓冲区来说,无论读写,都将磁盘数据加载到文件页缓冲区(相当于缓冲,提高效率),如果是写就会修改缓冲区数据,该数据为脏,所以就会被操作系统刷新到磁盘,
管道虽然用的是文件的方案,但操作系统一定不会把进程进行通信的数据刷新到磁盘当中,因为这样做有IO参与会降低效率,而且也没有必要。也就是说,这种文件是一批不会把数据写到磁盘当中的文件,换句话说,磁盘文件和内存文件不一定是一一对应的,有些文件只会在内存当中存在,而不会在磁盘当中存在。
进程打开文件后再 fork,会拷贝 files_struct 吗?
会拷贝files_struct ,但是不会拷贝struct file,对于 struct file 直接引用计数即可(图画错了,两个进程的3号fd应该指向相同的struct file)。
所以父子进程会看到同一个新建的文件 —— 内存级文件,这就实现了不同的进程,看到同一份资源!父进程可以向文件页缓冲区写入数据,子进程就可以在文件页缓冲区读取数据!这就实现了进程间通信。所以管道就是文件!是一种内存级别文件,不在磁盘
内存级文件:不在磁盘,操作系统在内存直接创建一个struct file。
这里父子进程看到的同一份文件资源是由操作系统来维护的,所以当父子进程对该文件进行写入操作时,该文件缓冲区当中的数据并不会进行写时拷贝
内存级文件(管道文件)有路径、文件名、inode吗?
由于匿名管道不直接对应于文件系统中的任何实体,因此它没有自己的inode号。并且根本就不需要名字,因为子进程看到父进程是靠继承拷贝来看到的
匿名管道的文件描述符仅用于在创建它的进程及其子进程或通过其他IPC机制(如消息队列、共享内存等)与之通信的进程之间进行数据传递。
- 命名管道:有明确的路径、文件名和inode号,可以通过文件系统的接口进行访问和操作。
- 匿名管道:没有明确的路径、文件名和inode号,仅存在于内存中,通过进程间的文件描述符进行通信。
当进程依靠管道文件通信时,一方关闭文件,另一方会受影响而出现错误吗?
如果父进程只是以读方式打开管道文件,那么子进程拷贝父进程进程的一系列结构体,导致子进程也只能以读方式读管道文件,父子都只能读管道文件,这就会引发矛盾。所以,父进程打开文件是有要求的,需要以读和写分别打开管道文件!
虽然读写在技术角度可以,但是读写不建议混起来,因为读写都是由各自的变量控制的,如果混在一起我们刚写的数据是读不出来的。所以,一般都是读打开一次、写打开一次,但是除了创建一个struct file,其他的都是共享(inode结构体、文件缓冲区)
所以操作系统规定,一个进程不能同时读写,因为如果同时读写,不能确定哪些数据是自己的哪些是其他进程写的,这就很繁琐,操作系统使用文件系统来实现就是为了简单一些,否则就会再设计一个复杂的系统来实现,所以操作系统就规定进程之间只能进行单向通信!
所以一个进程在读时,就关闭自己的写文件,另一个进程在写时,就关闭自己的读文件,从而实现单向通信。
设计者为了简单方便,所以依靠文件系统设计了单向通信——管道
匿名管道是单向的,即数据只能从一个方向流动。一个管道有一个读端和一个写端,数据从写端进入管道,从读端被读取
为什么非要搞管道,我父进程写一个全局数据,子进程继承,这不就是传递消息吗?
首先,进程的通信内容大部分为动态的消息,即消息是即时性的传递,而不是静态的数据,其次父进程的全局数据只是单方面的传递,子进程只能读数据,不能写数据,一旦写数据就会触发写时拷贝。
如果进程不是父子关系,那么还能通过管道通信吗?
不可以,必须是父子关系。管道原理就是父子进程共享管道文件
如果父进程创建多个子进程,那么这些子进程可以通过管道通信吗?
可以,因为全都继承的父进程,所以都指向同一个内存文件——管道文件(文件页缓冲区)
如果父进程创建的子进程,子进程又创建了子进程,那么爷孙进程可以通过管道通信吗?
可以,因为也都是拷贝的同一份files_struct,都指向同一个内存文件——管道文件
小结:匿名管道通信,进程之间需要又血缘关系,常见于父子关系。匿名管道通常只能用于具有亲缘关系的进程之间(如父子进程、兄弟进程)。这是因为非亲缘关系的进程无法直接访问对方的内存空间,也无法通过文件描述符/句柄继承来访问对方的管道
1.2 pipe系统调用
man 2 pipe
int pipe(int pipefd[2]);
该函数参数是一种输出型参数,pipe内部将struct file、文件缓冲区等结构创建好,以读写方式打开内存文件(匿名管道),然后返回两个文件描述符fd ,靠数组带回,数组只需两个元素,默认:
- pipefd[0] :读下标
- pipefd[1] :写下标
返回值:pipe函数调用成功时返回0,调用失败时返回-1
举例:
1.3 匿名管道的使用
在创建匿名管道实现父子进程间通信的过程中,需要pipe函数和fork函数搭配使用
1、父进程调用pipe函数创建管道
2、父进程创建子进程
3、父进程关闭写端,子进程关闭读端(当然,父进程也可以关闭读端)
匿名管道是单向的,即数据只能从一个方向流动。一个管道有一个读端和一个写端,数据从写端进入管道,从读端被读取
父子进程的通信靠操作系统的系统调用接口wite、read,因为操作系统不相信用户,不可能让用户自己指定一块内存区域,让用户随便访问,这是不允许的
//child->write, father->read
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
int fd[2] = { 0 };
if (pipe(fd) < 0){ //使用pipe创建匿名管道
perror("pipe");
return 1;
}
pid_t id = fork(); //使用fork创建子进程
if (id == 0){
//child
close(fd[0]); //子进程关闭读端
//子进程向管道写入数据
const char* msg = "hello father, I am child...";
int count = 10;
while (count--){
write(fd[1], msg, strlen(msg));
sleep(1);
}
close(fd[1]); //子进程写入完毕,关闭文件
exit(0);
}
//father
close(fd[1]); //父进程关闭写端
//父进程从管道读取数据
char buff[64];
while (1){
ssize_t s = read(fd[0], buff, sizeof(buff));
if (s > 0){
buff[s] = '\0';
printf("child send to father:%s\n", buff);
}
else if (s == 0){
printf("read file end\n");
break;
}
else{
printf("read error\n");
break;
}
}
close(fd[0]); //父进程读取完毕,关闭文件
waitpid(id, NULL, 0);
return 0;
}
管道内部自带同步与互斥机制
当多执行流共享时,可能会出现访问冲突问题。即一个进程正在访问数据时,另一个进程写入数据,这就可能覆盖原数据——临界资源竞争问题
临界资源是需要被保护的,若是我们不对管道这种临界资源进行任何保护机制,那么就可能出现同一时刻有多个进程对同一管道进行操作的情况,进而导致同时读写、交叉读写以及读取到的数据不一致等问题。
为了避免这些问题,内核会对管道操作进行同步与互斥:
- 同步: 两个或两个以上的进程在运行过程中协同步调,按预定的先后次序运行。比如,A任务的运行依赖于B任务产生的数据。
- 互斥: 一个公共资源同一时刻只能被一个进程使用,多个进程不能同时使用公共资源。
实际上,同步是一种更为复杂的互斥,而互斥是一种特殊的同步。对于管道的场景来说,互斥就是两个进程不可以同时对管道进行操作,它们会相互排斥,必须等一个进程操作完毕,另一个才能操作,而同步也是指这两个不能同时对管道进行操作,但这两个进程必须要按照某种次序来对管道进行操作。
也就是说,互斥具有唯一性和排它性,但互斥并不限制任务的运行顺序,而同步的任务之间则有明确的顺序关系。
1.4 管道通信的特征
- 亲缘性:具有血缘关系的进程才能通过管道通信
- 单向性:管道是单向的,数据只能从一个方向流动
- 通信进程之间会协同(互斥、同步),这是为了保护管道文件的数据安全。如果父进程读数据时,缓冲区直接拿来就读会导致乱码,因为缓冲区不是每时每刻都为空,子进程也不是时时刻刻写入缓冲区,所以进程间需要协同!一方没有写入,另一方就不读写
- 基于字节流:管道是面向字节流的,管道中的数据以字节流的形式传递,没有消息边界的概念(不管一个进程写了多少字符、写了几次,另一个进程读取时一次全部读完。就像是自来水,来自写端的自来水不分边界,不管写端放了多少自来水,读端都是一个读完,不管读端是拿盆还是拿桶来接)对于进程A写入管道当中的数据,进程B每次从管道读取的数据的多少是任意的,这种被称为流式服务
- 生命周期:匿名管道的生命周期随进程结束而结束;命名管道的生命周期则取决于文件系统,除非显式删除,否则会一直存在。管道是基于文件的,文件的生命周期是跟随进程的。父子进程退出时,管道文件自动被操作系统释放,例如默认打开的stdin、stdout、stderr都是操作系统关闭的
管道是有固定大小的 ,在不同内核里大小有差别
ulimit -a
使用 ulimit 指令查看对很多重要资源的限制,进程可打开最大文件数、管道大小等等
centos7.6版本默认给管道大小是64KB。当写入的字节小于PIPE_BUF时,写入的必须是原子(单次写入大小),这里的pipe size 就可以看 作 PIPE_BUF
- 当要写入的数据量不大于PIPE_BUF时,linux将保证写入的原子性。
- 当要写入的数据量大于PIPE_BUF时,linux将不再保证写入的原子性
1.5 管道读写的4种情况
1. 读写端正常,管道如果为空,读端就要阻塞,直到管道内出现数据
2. 读写端正常,管道如果被写满,写端就要阻塞,直到管道内数据被读端读取
3. 读端正常读,写端关闭,读端就会读到0,表明读到了文件(pipe)结尾,不会被阻塞
4. 写端正常写,读端关闭,操作系统通过13号信号SIGPIPE杀死还在向管道写入的进程(操作系统不会做低效、浪费内存等类似的工作,如果做了,那么就是操作系统的bug)
操作系统通过13号信号SIGPIPE杀死还在向管道写入的进程
其中前面两种情况就能够很好的说明,管道是自带同步与互斥机制的,读端进程和写端进程是有一个步调协调的过程的,不会说当管道没有数据了读端还在读取,而当管道已经满了写端还在写入。读端进程读取数据的条件是管道里面有数据,写端进程写入数据的条件是管道当中还有空间,若是条件不满足,则相应的进程就会被挂起,直到条件满足后才会被再次唤醒。
第三种情况也很好理解,读端进程已经将管道当中的所有数据都读取出来了,而且此后也不会有写端再进行写入了,那么此时读端进程也就可以执行该进程的其他逻辑了,而不会被挂起。
第四种情况也不难理解,既然管道当中的数据已经没有进程会读取了,那么写端进程的写入将没有意义,因此操作系统直接将写端进程杀掉。而此时子进程代码都还没跑完就被终止了,属于异常退出,那么子进程必然收到了某种信号。
1.6 实践操作:
shell管道
hpp文件:直接将.h和.cpp文件混在一起,之前 .h 和 .cpp 分开编译是因为为了打包成库,如果本身就奔着开源,那么大部分就是直接使用hpp后缀
进程池
由于每次fork一个子进程效率并不高,我们可以在空闲时让父进程fork出一些子进程,组成一个进程池,当有需要时指定进程池中任意一个进程分配任务,这样的效率就提高了
Task.hpp
#pragma once
#include <iostream>
#include <vector>
//重命名函数指针task_t
typedef void (*task_t)();
//各个任务
void task1()
{
std::cout << "LOL 刷新日志" << std::endl;
}
void task2()
{
std::cout << "LOL 刷新野怪" << std::endl;
}
void task3()
{
std::cout << "LOL 用户释放技能,更新血量" << std::endl;
}
void task4()
{
std::cout << "LOL 检测软件更新" << std::endl;
}
//向任务数组加入任务
void LoadTask(std::vector<task_t>* tasks)
{
tasks->push_back(task1);
tasks->push_back(task2);
tasks->push_back(task3);
tasks->push_back(task4);
}
processPool.cc
#include "Task.hpp"
#include <string>
#include <vector>
#include <cstdlib>
#include <assert.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/wait.h>
const int processnum = 5; //进程池中子进程数量
std::vector<task_t> tasks; //tasks任务数组,数据类型为函数指针
//放在父进程全局处,可以被子进程继承
//先描述,父进程与申请的子进程之间的管道描述组织
class channel
{
public:
channel(int cmdfd, pid_t slaverid, const std::string& processname)
:_cmdfd(cmdfd), _slaverid(slaverid), _processname(processname)
{}
public:
int _cmdfd; //父进程向管道写入端的fd
pid_t _slaverid; //创建的子进程pid
std::string _processname; //子进程名字,方便打日志
};
//输入:const &
//输出:*
//输入输出:&
//进程池初始化
void InitProcessPool(std::vector<channel>* channels)
{
for (int i = 0; i < processnum; i++)
{
int pipefd[2] = {0}; //临时空间,但会被存储在channels中
int n = pipe(pipefd); //pipe创建管道
assert(!n); //只有在debug模式下assert才有效
(void)n;
pid_t id = fork(); //创建子进程
if (id == 0)
{
//child
close(pipefd[1]);
//重定向:将标准输入重定向为管道的读端,即从管道读取
dup2(pipefd[0], 0);
//死循环slaver,子进程阻塞,等待管道信息
//一旦写入端被关闭,读端就会读到0,然后break出来,进程退出
slaver();
std::cout << "process: " << getpid() << " quit" << std::endl;
exit(0);
}
//father
close(pipefd[0]);
//因为每次都把pipefd[0]关闭了,所以每次子进程都是读的3,父进程每次写的是4,5,6...
//即父进程每次pipe申请的是(3, 4)(3, 5)(3, 6)...
//向channels添加数据
std::string name = "process-" + std::to_string(i);
channels->push_back(channel(pipefd[1], id, name));
sleep(1);
}
}
//子进程读取任务码并工作
void slaver()
{
int cnt = 4;
while (cnt--)
{
//read从管道读取任务码
int cmdcode = 0;
int n = read(0, &cmdcode, sizeof(int)); //因为dup2了管道文件和键盘文件,所以直接从管道读数据
if (n == sizeof(int))
{
//执行cmdcode对应的任务列表
std::cout << "pid: " << getpid() << ", slaver get a command, cmdcode: " << cmdcode << std::endl;
if (cmdcode >= 0 && cmdcode < tasks.size())
tasks[cmdcode]();
}
else if (n == 0) break;
}
}
//打印检验是否向channels写入数据
void Print(const std::vector<channel>& channels)
{
for (const auto& e : channels)
{
std::cout << e._cmdfd << " " << e._processname << " " << e._slaverid << std::endl;
}
}
//菜单打印
void Menu()
{
std::cout << "-----------------------------" << std::endl;
std::cout << "- 1. 刷新日志 2.刷新野怪 -" << std::endl;
std::cout << "- 3. 更新血量 4.检测更新 -" << std::endl;
std::cout << "- 0. 退出菜单 -" << std::endl;
std::cout << "-----------------------------" << std::endl;
}
//用户指定菜单控制任务码,随机调度
// void ctrlSlaver(const std::vector<channel>& channels)
// {
// srand(time(nullptr)^1024); //随机数打的更散
// while (true)
// {
// int select = 0;
// Menu();
// std::cout << "Please Enter # ";
// std::cin >> select;
// if (select <= 0 || select > 4) break;
// //选择任务
// int cmdcode = select - 1; //因为tasks数组从0开始
// //选择进程
// int processpos = rand()%channels.size();
// std::cout << "father say: cmdcode is " << cmdcode << " already send to "
// << channels[processpos]._slaverid << " process name: "
// << channels[processpos]._processname << std::endl;
// //发送任务
// write(channels[processpos]._cmdfd, &cmdcode, sizeof(cmdcode));
// }
// }
//用户指定菜单控制任务码,轮转调度(挨个来)
void ctrlSlaver(const std::vector<channel>& channels)
{
int which = 0;
while (true)
{
int select = 0; //用户输入码
Menu();
std::cout << "Please Enter # ";
std::cin >> select; //获取用户输入
//判断是否合法
if (select < 0 || select > 4)
{
std::cout << "输入错误,请重新输入 # " << std::endl;
continue;
}
if (select == 0) break;
//选择任务
int cmdcode = select - 1; //因为tasks数组从0开始,用户输入从1开始
//选择进程,which控制轮转下标
std::cout << "father say: cmdcode is " << cmdcode << " already send to "
<< channels[which]._slaverid << " process name: "
<< channels[which]._processname << std::endl;
//发送任务,将任务码写进管道文件
write(channels[which]._cmdfd, &cmdcode, sizeof(cmdcode));
//更新which
which++;
which %= channels.size();
}
}
//管道文件关闭、进程等待回收
void QuitProcess(const std::vector<channel>& channels)
{
// int last = channels.size() - 1;
// for (int i = last; i >= 0; i--)
// {
// close(channels[i]._cmdfd);
// waitpid(channels[i]._slaverid, nullptr, 0);
// }
for (const auto& e : channels)
{
close(e._cmdfd);
waitpid(e._slaverid, nullptr, 0);
}
// for (const auto& e : channels)
// waitpid(e._slaverid, nullptr, 0);
}
int main()
{
//向任务队列加入任务
LoadTask(&tasks);
//先描述,再组织(vector组织)
std::vector<channel> channels;
//1. 初始化
InitProcessPool(&channels);
//Debug
Print(channels);
//2.控制子进程,向管道write,下发任务
ctrlSlaver(channels);
//3. 清理首尾
QuitProcess(channels);
std::cout << "程序结束" << std::endl;
return 0;
}
为什么输入0之后,管道没有关闭,进程没有被回收?
输入0后,ctrlSlaver函数退出,程序开始在main函数内执行QuitProcess函数
又因为父进程在创建新的子进程时,后面每一个进程都继承了父进程的管道写端 fd(4、5、6、7...每多创建一个子进程,第一个子进程的管道就会被多个后面的子进程的写端指向),即使父进程的写端关闭,第一个子进程的管道还被后面的子进程的写端所指向,所以第一个子进程的读端不会读到0,没有读到0就不会break,第一个子进程也就不会exit,所以QuitProcess就会卡在第一次循环的waitpid处
解决方案1:倒着回收
解决方案2:确保每一个子进程都只有一个写端,不继承父进程的多个写端
2. 命名管道(FIFO)
命名管道克服了匿名管道只能用于亲缘关系进程间通信的限制,它允许无亲缘关系的进程间通信。命名管道在文件系统中有一个名字,任何进程都可以通过这个名字来访问管道。命名管道的创建、读写等操作与文件操作非常类似,但它实际上是一种特殊的文件类型,用于进程间通信。与匿名管道一样,命名管道也是单向的,但可以通过创建两个管道(一个用于读,一个用于写)来实现双向通信。
命名管道和匿名管道一样,都是内存文件,只不过命名管道在磁盘有一个简单的映像,但这个映像的大小永远为0,因为命名管道和匿名管道都不会将通信数据刷新到磁盘当中
匿名管道是通过子进程继承父进程实现的看到同一份资源,而命名管道是通过 路径+文件名 确定同一份资源,该文件只存一份数据,即一份inode、一份文件缓冲区、一份操作方法集
2.1 mkfifo
man mkfifo
命令行创建命名管道
mkfifo myfifo
可以看到,创建出来的文件的类型是p
,代表该文件是命名管道文件
举例:我们打开两个终端,一个终端持续向命名管道追加写入字符串,另一个终端cat命名管道,两个终端靠命名管道实现echo进程与cat进程的通信
echo 指令并不在左边终端打印,而是从命名管道myfifo传到右边终端的cat进程,并且在打印过程中命名管道大小不变(因为命名管道不会将通信数据刷新到磁盘当中)
进程间通信的前提,是先让不同的进程看到同一份资源。那么为什么两个进程不直接读同一个磁盘上的文件?
进行交流信息的进程只想用文件缓冲区来交流,只需要一个进程把数据放到缓冲区,另一个进程去拿就够了,如果是磁盘文件,它就需要刷盘,这是一个冗余的行为!
管道文件不需要刷到磁盘,是一个内存级文件,所以即使追加写到命名管道,它的属性inode也不会改变,因为不会刷到磁盘
不同的进程怎么知道打开的是同一个文件?
匿名管道是通过继承,而命名管道:路径+文件名
我们在基础IO学过,路径+文件名具有唯一性,因为路径确定了分区,同一目录下文件名不能重复,因为文件名需要和inode一一映射,再者找到文件后发现是p属性,进程就知道要找到文件就是它了,所以这种方式就是命名管道通信方式
命名管道与匿名管道几乎完全相同,不同的一点就是命名管道可以让毫不相干、没有血缘关系的进程进行通信
系统调用创建命名管道
程序中创建命名管道
man 3 mkfifo
int mkfifo(const char *pathname, mode_t mode);
mkfifo函数的第一个参数是pathname,表示要创建的命名管道文件。
- 若pathname以路径的方式给出,则将命名管道文件创建在pathname路径下。
- 若pathname以文件名的方式给出,则将命名管道文件默认创建在当前路径下。(注意当前路径的含义)
mkfifo函数的第二个参数是mode,表示创建命名管道文件的默认权限。
例如,将mode设置为0666,则命名管道文件创建出来的权限:
prw-rw-rw- 具体权限会受到umask掩码的影响(0002)
mkfifo函数的返回值。
- 命名管道创建成功,返回0
- 命名管道创建失败,返回-1
命名管道的打开规则
1、如果当前打开操作是为读而打开FIFO时。
- O_NONBLOCK disable:阻塞直到有相应进程为写而打开该FIFO。
- O_NONBLOCK enable:立刻返回成功。
2、如果当前打开操作是为写而打开FIFO时。
- O_NONBLOCK disable:阻塞直到有相应进程为读而打开该FIFO。
- O_NONBLOCK enable:立刻返回失败,错误码为ENXIO。
2.2 unlink
unlink——删除命名管道
man 3 unlink
2.3 实践通信
comm.hpp 封装命名管道的创建与销毁功能
#pragma once
#include "log.hpp"
#include <iostream>
#include <string>
#include <cerrno>
#include <cstring>
#include <cstdlib>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>
#define FIFO_FILE "./myfifo" //命名管道的文件名
#define MODE 0664 //管道默认创建权限
//enum错误类型,假定为进程退出码
enum
{
FIFO_CREAT_ERR = 1,
FIFO_DELETE_ERR,
FIFO_OPEN_ERR
};
//对创建和销毁命名管道做封装
class Init
{
public:
Init()
{
//创建命名管道,mkfifo
int n = mkfifo(FIFO_FILE, MODE);
if (n == -1)
{
perror("mkfifo");
exit(FIFO_CREAT_ERR);
}
}
~Init()
{
//销毁命名管道,unlink
int m = unlink(FIFO_FILE);
if (m == -1)
{
perror("unlink");
exit(FIFO_DELETE_ERR);
}
}
private:
Log log; //日志
};
server.cc 服务端创建命名管道,打开管道读取数据(此时客户端未进程启动,所以会在open处阻塞)
#include "comm.hpp"
#include "log.hpp"
using namespace std;
// 让服务端管理命名管道文件
int main()
{
Init init;
Log log;
// 先设置日志为单文件输出
log.Enable(Classfile);
// 打开管道
int fd = open(FIFO_FILE, O_RDONLY);
if (fd < 0)
{
log(Fatal, "error string: %s, error code: %d", strerror(errno), errno);
exit(FIFO_OPEN_ERR);
}
log(Warning, "error string: %s, error code: %d", strerror(errno), errno);
log(Error, "error string: %s, error code: %d", strerror(errno), errno);
log(Info, "error string: %s, error code: %d", strerror(errno), errno);
// 让服务端作为读端
while (true)
{
char buffer[1024] = {0};
int x = read(fd, buffer, sizeof(buffer));
if (x > 0)
{
buffer[x] = 0; // 读取后在末尾处加\0,构成C语言字符串
cout << "client say# " << buffer << endl;
}
else if (x == 0)
{
log(Debug, "client quit, me too! error string: %s, error code: %d", strerror(errno), errno);
break;
}
else
break;
}
// break出来表示写端读端都关闭了,所以关闭管道文件
close(fd);
return 0;
}
cilent.cc 打开管道文件,从键盘读取数据,再向文件内write数据
#include <iostream>
#include "comm.hpp"
using namespace std;
int main()
{
int fd = open(FIFO_FILE, O_WRONLY);
if (fd < 0)
{
perror("open");
exit(FIFO_OPEN_ERR);
}
cout << "client open file done" << endl;
string line;
while (true)
{
cout << "Please Enter@ ";
getline(cin, line);
write(fd, line.c_str(), line.size());
}
close(fd);
return 0;
}
在大多数现代操作系统和编程语言中,当一个进程被Ctrl+C(SIGINT信号)直接关闭时,程序会接收到一个中断信号,这通常会导致程序立即终止执行。对于C++等需要显式资源管理的语言来说,这意味着如果程序在没有适当处理该信号的情况下被终止,那么程序中的对象可能不会按预期调用它们的析构函数。
析构函数的调用通常发生在以下几种情况:
作用域结束:对于局部变量,当它们的作用域结束时(例如,函数返回或块结束),它们的析构函数会被自动调用。
delete操作符:对于通过
new
操作符动态分配的对象,当使用delete
操作符释放这些对象时,它们的析构函数会被调用。程序正常结束:当程序正常结束(例如,通过
return
语句从main
函数返回)时,全局对象和静态局部对象的析构函数会按照与它们被创建时相反的顺序被调用。然而,当程序接收到SIGINT信号(如Ctrl+C)并直接终止时,这些规则并不适用。程序会立即停止执行,而不会执行任何清理操作(如调用析构函数)。这可能导致资源泄漏,比如未关闭的文件描述符、未释放的内存等。
要处理这种情况,你可以:
使用信号处理:在C++中,你可以使用信号处理函数(如
signal
或sigaction
在UNIX/Linux系统中)来捕获SIGINT信号,并执行一些清理工作。但请注意,从信号处理函数中直接调用非异步信号安全的函数(包括大多数C++库函数)是不安全的。使用RAII(Resource Acquisition Is Initialization):尽量使用RAII技术来管理资源。RAII是一种在对象构造时获取资源并在对象析构时释放资源的编程技术。这样,即使程序因为接收到SIGINT信号而异常终止,如果对象的析构函数有机会被调用(这通常很难保证),那么资源也能被正确释放。然而,如前所述,当程序因为信号而终止时,析构函数可能不会被调用。
优雅地关闭程序:在程序的关键位置添加检查点,以检测用户是否请求了中断(例如,通过捕获SIGINT信号),并在检测到中断时执行必要的清理工作,然后正常退出程序。
综上所述,当程序被Ctrl+C直接关闭时,程序中的对象可能不会按预期调用它们的析构函数,这可能导致资源泄漏等问题。因此,在编写需要处理中断信号的程序时,应该采取适当的措施来确保资源被正确管理。
先关闭客户端(写端),服务端(读端)就会因为写端关闭而读到0,然后break,init对象的生命周期随程序结束,才会调用它的析构函数,unlink命名管道。
如果直接ctrl c掉服务端,那么进程直接退出,不会调用析构函数,这就会导致内存泄露
多文件编译Makefile
.PHONY:all
all:client server
client:client.cc
g++ -o $@ $^ -std=c++11
server:server.cc
g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
rm -f client server
3. 命名管道和匿名管道的区别
- 命名管道有明确的路径、文件名和inode号,可以通过文件系统的接口进行访问和操作。
- 匿名管道没有明确的路径、文件名和inode号,仅存在于内存中,通过进程间的文件描述符进行通信
- 匿名管道由pipe函数创建并打开。
- 命名管道由mkfifo函数创建,由open函数打开。
- FIFO(命名管道)与pipe(匿名管道)之间唯一的区别在于它们创建与打开的方式不同,一旦这些工作完成之后,它们具有相同的语义
三、封装日志类
编程中的日志是开发人员不可或缺的工具。它不仅可以帮助开发人员快速定位问题、优化系统性能、加强系统安全性、优化用户体验,还能促进团队协作和知识共享,以及提升个人的编程能力和技术水平
1. 日志等级
常见的日志等级从高到低(或详细程度从低到高)可以分为多个级别,但不同系统或框架可能有所差异
OFF:最高等级,表示关闭日志功能,不记录任何日志信息。
FATAL:表示非常严重的错误,通常会导致应用程序或系统崩溃,无法继续运行。这种级别的日志需要立即关注并处理。
ERROR:表示程序中的错误,这些错误虽然不会立即导致程序崩溃,但会影响程序的正常功能或导致部分功能失效。ERROR级别的日志需要尽快修复。
WARN(或WARNING):表示警告信息,通常指出程序中存在潜在的问题或风险,但不一定立即影响程序的运行。WARN级别的日志需要引起注意,并考虑是否需要采取措施来避免潜在的问题。
INFO:表示程序运行过程中的一般信息,如用户登录、系统启动等。INFO级别的日志主要用于记录程序的运行状态和流程,便于监控和了解程序的运行情况。
DEBUG:表示调试信息,通常用于记录程序在开发或调试过程中的详细信息,如变量的值、方法的调用过程等。DEBUG级别的日志有助于开发者定位和解决程序中的问题。
TRACE:表示比DEBUG更详细的跟踪信息,通常用于记录程序执行的每一步细节,包括方法的入参、出参、执行路径等。TRACE级别的日志主要用于问题排查和性能调优,但在生产环境中可能会产生大量的日志数据,因此需要谨慎使用。
ALL:最低等级,表示记录所有级别的日志信息。这个级别通常用于调试和开发过程中,以便获取最全面的日志数据。但在生产环境中,由于可能产生大量的日志数据,因此很少使用。
这里我们只设计五种等级
//日志等级
#define Info 0
#define Debug 1
#define Warning 2
#define Error 3
#define Fatal 4
2. 日志格式
日志等级 + 实时时间 + 自定义具体信息
自定义具体时间就需要用到可变参数列表的功能
3. 可变参数列表
可变参数列表(Variable Arguments List)是一种在函数定义时允许接受可变数量参数的机制。这种机制在不同的编程语言中有不同的实现方式,但核心思想相似,即允许函数在调用时接收不定数量的参数
在C语言中,可变参数列表通过三个点号(...)在函数声明中标识,并通过stdarg.h
(或C++中的cstdarg
)库中的宏来处理。这些宏包括va_list
、va_start
、va_arg
和 va_end
- va_list:定义一个指向可变参数列表的指针。
- va_start:初始化该指针,使其指向可变参数列表的起始位置。
- va_arg:从可变参数列表中取出下一个参数,并根据提供的类型返回该参数的值,同时指针后移。
- va_end:清理工作,通常在处理完所有可变参数后调用。
C++中,除了可以使用C语言风格的可变参数列表外,还引入了模板可变参数(Variadic Templates),允许函数模板接受任意数量和类型的参数。
va_list其实就是char*类型,可变参数列表是从后往前压栈,所以只要去第一个参数的地址,再根据这个地址和类型大小进行偏移,就可以取出后序的所有参数(va_list va_start va_arg都是宏) ,所以可变参数至少有一个具体的参数,去找起始地址。
那么这里我们的函数是写死的int类型,如果可变参数列表传递了其他类型,函数会出错,如何解决呢?格式控制字符串!我们的printf就是如此
4. 日志输出方向
- 向屏幕输出
- 向一个文件输出
- 向多个文件分类别输出
5. 封装类
成员变量:输出方式、输出路径
日志默认向屏幕输出
#pragma once
#include <iostream>
#include <time.h>
#include <stdarg.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
#define SIZE 1024
//日志等级
#define Info 0
#define Debug 1
#define Warning 2
#define Error 3
#define Fatal 4
//日志的输出方向
#define Screen 1 //显示器打印
#define Onefile 2 //向单文件打印
#define Classfile 3 //向多文件分类别打印
class Log
{
public:
Log()
: printMethod(Screen), path("./log/")
{}
//获取打印方式
void Enable(int method)
{
printMethod = method;
}
//获取日志等级,并返回相应的string字符串
std::string levelToString(int level)
{
switch(level)
{
case Info:
return "Info";
case Debug:
return "Debug";
case Warning:
return "Warning";
case Error:
return "Error";
case Fatal:
return "Fatal";
default:
return "None";
}
}
private:
int printMethod; //打印方式:屏幕、单文件、多文件
std::string path; //打印文件所在路径
};
6. operator()
我们使用operator(),即仿函数,这会使用户在使用日志类时方便使用(直接定义对象,使用仿函数传参即可)
time_t
time_t
是 C 语言中用于表示时间的一个数据类型,它通常是一个长整型(long int
)或者在某些系统上可能是其他类型,具体取决于系统和编译器的实现。time_t
类型的值表示从某个特定时间点(通常是“Epoch”时间,即1970年1月1日00:00:00 UTC)到当前时间的秒数(或毫秒数,取决于系统)。这个值被称为时间戳(timestamp)。localtime
localtime
是一个函数,其原型定义在<time.h>
头文件中。该函数将一个time_t
类型的时间戳转换为本地时间(即考虑时区的时间)的struct tm
表示。struct tm
是一个结构体,包含了年、月、日、小时、分钟、秒等详细信息。
snprintf
将格式控制字符串写入缓冲区str,最大size大小
- 使用 C语言的 time_t、localtime组件获取实时时间,并使用 snprintf 函数,格式化输入到 leftbuffer 数组中
- 将用户输入的自定义信息(可变参数列表)格式化输入到 rightbuffer
- 组合 leftbuffer 和 rightbuffer,格式化输入到一个 logtxt 数组中
- 最后根据输出方向,调用函数将 logtxt 数组传出
//使用可变参数,并将该函数运算符重载为仿函数,这就可以在类外直接使用对象(),调用该函数
void operator()(int level, const char* format, ...)
{
//获取实时时间
time_t t = time(nullptr);
struct tm* ctime = localtime(&t);
//向leftbuffer数组中,使用 snprintf 输入:日志等级 + 实时时间
char leftbuffer[SIZE];
snprintf(leftbuffer, sizeof(leftbuffer), "[%s][%d-%d-%d %d:%d:%d]", levelToString(level).c_str(),
ctime->tm_year + 1900, ctime->tm_mon + 1, ctime->tm_mday,
ctime->tm_hour, ctime->tm_min, ctime->tm_sec);
//年份+1900,因为时间戳是从1900年开始的,月份+1,因为这里月份范围是[0, 11]
//向rightbuffer数组中,使用 vsnprintf 输入可变参数信息
va_list s;
va_start(s, format);
char rightbuffer[SIZE];
vsnprintf(rightbuffer, sizeof(rightbuffer), format, s);
va_end(s);
//最终格式:默认部分(日志等级 + 实时时间) + 自定义部分
char logtxt[SIZE*2];
snprintf(logtxt, sizeof(logtxt), "%s %s\n", leftbuffer, rightbuffer);
//调用函数,将日志分方向输出
printlog(level, logtxt);
}
7. printlog
- 根据成员变量printMethod,选择输出函数
- 因为多文件输出需要分日志级别,所以对多文件输出函数要传入level
void printlog(int level, const std::string& logtxt)
{
switch (printMethod)
{
case Screen:
std::cout << logtxt << std::endl;
break;
case Onefile:
printOneFile(LogFile, logtxt); //单文件输出时,给出指定的文件名
break;
case Classfile:
printClassFile(level, logtxt); //因为要分类别向文件输出,所以要传入日志等级
break;
default:
break;
}
}
8. printOneFile
- 构建文件名,加上前置路径
- 向文件输出要打开文件,以只写、没有就创建、追加写,0666权限打开文件
- 文件写入
- 文件关闭
//日志向单文件输出
void printOneFile(const std::string& logname, const std::string& logtxt)
{
//加上前置的路径,可以将所有入职文件放在我们指定的log目录下
std::string _logname = path + logname;
//打开文件,只写、没有就创建、追加写,默认权限0666(umask掩码默认为0002)
int fd = open(_logname.c_str(), O_WRONLY|O_CREAT|O_APPEND, 0666);
if (fd < 0)
return;
//写入日志信息
write(fd, logtxt.c_str(), logtxt.size());
close(fd);
}
9. printClassFile
- 多文件输出函数核心代码和单文件输出重复,所以可以直接复用 printOneFile
- 我们处理的都是单条日志,不是多条累积的日类,所以没有循环
//日志向多文件分类别输出
void printClassFile(int level, const std::string& logtxt)
{
std::string filename = LogFile;
filename += "." + levelToString(level); //log.txt.Debug、log.txt.Warning
//复用单文件输出
printOneFile(filename, logtxt);
}
四、system V
管道通信本质是基于文件的,也就是说操作系统并没有为此做过多的设计工作,而system V IPC是操作系统特地设计的一种通信方式。但是不管怎么样,它们的本质都是一样的,都是在想尽办法让不同的进程看到同一份由操作系统提供的资源。
system V IPC提供的通信方式有以下三种:
- system V共享内存:允许两个或多个进程共享一段内存区域,是进程间通信中最快的方式,因为数据不需要在进程间复制。
- system V消息队列:允许一个或多个进程写入或读取消息,可以看作是一个消息链表,每个消息都有一个类型和一个优先级
- system V信号量:用于同步进程,控制多个进程对共享资源的访问。System V信号量分为二进制信号量和计数信号量。
其中,system V共享内存和system V消息队列是以传送数据为目的的,而system V信号量是为了保证进程间的同步与互斥而设计的,虽然system V信号量和通信好像没有直接关系,但属于通信范畴
1. 共享内存
1.1 共享内存原理
类似动态库加载,操作系统在物理内存创建一块区域,通过页表映射到需要通信的进程的虚拟地址空间的共享区中,并向应用层返回一个起始的虚拟地址,使得虚拟地址和物理地址之间建立起对应关系,从而使不同的进程看到同一份资源,这块物理内存就是共享内存
可以概括为:申请内存、挂接到进程地址空间、返回首地址。并且不能由进程自己malloc内存,因为进程是独立的,需要操作系统创建内存空间,创建信道,才可以实现不同进程通信。所以需求方进程要求执行方操作系统完成任务的过程就是系统调用
1.2 共享内存数据结构
当进程之间的共享内存多了之后,操作系统就需要描述并组织起来大量的共享内存!所以对共享内存的增删查改就变成了对组织的数据结构的增删查改,所以共享内存除了在内存当中真正开辟空间之外,系统一定还要为共享内存维护相关的内核数据结构
共享内存的数据结构:
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 */
};
当我们申请了一块共享内存后,为了让要实现通信的进程能够看到同一个共享内存,因此每一个共享内存被申请时都有一个key值,这个key值用于标识系统中共享内存的唯一性。
可以看到上面共享内存数据结构的第一个成员是shm_perm
,shm_perm
是一个ipc_perm
类型的结构体变量,每个共享内存的key值存储在shm_perm
这个结构体变量当中,其中ipc_perm
结构体的定义如下:
struct ipc_perm{
__kernel_key_t key;
__kernel_uid_t uid;
__kernel_gid_t gid;
__kernel_uid_t cuid;
__kernel_gid_t cgid;
__kernel_mode_t mode;
unsigned short seq;
};
1.3 共享内存的建立与释放
共享内存的建立大致包括以下两个过程:
- 在物理内存当中申请共享内存空间。
- 将申请到的共享内存挂接到地址空间,即建立映射关系。
共享内存的释放大致包括以下两个过程:
- 将共享内存与地址空间去关联,即取消映射关系。
- 释放共享内存空间,即将物理内存归还给系统。
1.4 共享内存的创建
创建共享内存我们需要用shmget函数,shmget函数的函数原型如下:
shmget
man 2 shmget
shmget函数的参数说明:
- 第一个参数key,表示待创建共享内存在系统当中的唯一标识。
- 第二个参数size,表示待创建共享内存的大小。
- 第三个参数shmflg,表示创建共享内存的方式。
shmflg:创建共享内存的操作只需要一次,剩下的进程只需要获取就好了,所以需要有一个参数来标识
- IPC_CREAT:如果申请的共享内存不存在,就创建;存在,就获取并返回。(这些都是宏,标记位,每个bit都不相同)
- IPC_CREAT | IPC_EXCL:如果申请的共享内存不存在,就创建;存在,就出错返回原有的共享内存。
解释:第二个搭配确保了,如果我们申请成功了一个共享内存,这个共享内存一定是一个新的!并且 IPC_EXCL不单独使用
shmget函数的返回值说明:
- shmget调用成功,返回一个有效的共享内存标识符(用户层标识符)。
- shmget调用失败,返回-1。
在进程内,shimid 用来标识资源的唯一性,shmid并不像fd那样是0,1,2的小的数组下标,shmid数值很大,但是他也是一个数组下标,在它内部还有一套算法。shmid搞特殊,因为Linux中一切皆文件,那么共享内存也可以被看作文件,那么我们也应该使用fd就可以访问,这是因为当初shmid 标准没有制定好。
注意: 我们把具有标定某种资源能力的东西叫做句柄,而这里shmget函数的返回值实际上就是共享内存的句柄,这个句柄可以在用户层标识共享内存,当共享内存被创建后,我们在后续使用共享内存的相关接口时,都是需要通过这个句柄对指定共享内存进行各种操作
我们怎么保证让不同的进程看到同一个共享内存?怎么知道这个共享内存是是否存在呢?
通过相同的参数调用ftok函数,获取相同的Key值
传入shmget函数的第一个参数key,需要我们使用ftok函数进行获取
key_t ftok(const char *pathname, int proj_id);
ftok函数的作用就是,将一个已存在的路径名pathname和一个整数标识符proj_id转换成一个key值,称为IPC键值,在使用shmget函数获取共享内存时,这个key值会被填充进维护共享内存的数据结构当中。需要注意的是,pathname所指定的文件必须存在且可存取。
ftok是一套算法,用路径名和项目id进行数值计算,获得冲突概率极低的数字 。
为什么要让用户自己指定key,而不是操作系统自动生成?
操作系统完全有能力创建很多的不冲突的key,但是操作系统创建的key只能由一个进程拿到,又因为进程的独立性,其他的想要和该进程通信的进程(没有血缘关系)拿不到该数据,又需要通信传递key,这就矛盾了,所以让用户(程序员)约定一个key,根据ftok使用通向的路径名和项目id,即使通信双方看不到彼此,也能获取相同的key,这样才可以看到同一个共享内存
注意:
- 使用ftok函数生成key值可能会产生冲突,此时可以对传入ftok函数的参数进行修改。
- 需要进行通信的各个进程,在使用ftok函数获取key值时,都需要采用同样的路径名和和整数标识符,进而生成同一种key值,然后才能找到同一个共享资源。
至此我们就可以使用ftok和shmget函数创建一块共享内存了,创建后我们可以将共享内存的key值和句柄进行打印,以便观察,代码如下:
Log log;
// 共享内存的大小一般建议是4096的整数倍
// 4097,实际上操作系统给你的是4096*2的大小
const int size = 4096;
const string pathname="/home/ljs";
const int proj_id = 0x666;
//进程调用的GetKey函数相同,代表着它们拿到的Key一定相同
key_t GetKey()
{
//获取key
key_t k = ftok(pathname.c_str(), proj_id);
if(k < 0)
{
log(Fatal, "ftok error: %s", strerror(errno));
exit(1);
}
log(Info, "ftok success, key is : 0x%x", k);
return k;
}
//创建共享内存
int GetShareMemHelper(int flag)
{
//调用GetKey获取key
key_t k = GetKey();
//创建
int shmid = shmget(k, size, flag);
if(shmid < 0)
{
log(Fatal, "create share memory error: %s", strerror(errno));
exit(2);
}
log(Info, "create share memory success, shmid: %d", shmid);
return shmid;
}
int CreateShm()
{
return GetShareMemHelper(IPC_CREAT | IPC_EXCL | 0666);
}
int GetShm()
{
return GetShareMemHelper(IPC_CREAT);
}
共享内存权限在flag参数处追加
在Linux中,查看系统内IPC的指令
ipcs
单独使用 ipcs
命令时,会默认列出消息队列、共享内存以及信号量相关的信息,若只想查看它们之间某一个的相关信息,可以选择携带以下选项:
- -q:列出消息队列相关信息。
- -m:列出共享内存相关信息。
- -s:列出信号量相关信息。
ipcs
命令输出的每列信息的含义如下:
key | 系统区别各个共享内存的唯一标识 |
shmid | 共享内存的用户层id(句柄) |
owner | 共享内存的拥有者 |
perms | 共享内存的权限 |
bytes | 共享内存的大小 |
nattch | 关联共享内存的进程数 |
status | 共享内存的状态 |
注意: key是在内核层面上保证共享内存唯一性的方式,而shmid是在用户层面上保证共享内存的唯一性,key和shmid之间的关系类似于fd和FILE*之间的的关系。
共享内存挂接
共享内存创建后,开始调用系统调用shmat进行挂接
void *shmat(int shmid, const void *shmaddr, int shmflg);
shmat函数的参数说明:
- 第一个参数shmid:表示待关联共享内存的用户级标识符。应用层都是以shmid为准
- 第二个参数shmaddr:指定共享内存映射到进程地址空间的某一地址,通常设置为NULL,表示让内核自己决定一个合适的地址位置。最终的挂接位置会被返回
- 第三个参数shmflg:表示关联共享内存时设置的某些属性。挂接时按什么权限,在创建共享内存时的权限就可以了,不用再修改,所以可以从传0
其中,作为shmat函数的第三个参数传入的常用的选项有以下三个:
选项 | 作用 |
---|---|
SHM_RDONLY | 关联共享内存后只进行读取操作 |
SHM_RND | 若shmaddr不为NULL,则关联地址自动向下调整为SHMLBA的整数倍。公式:shmaddr-(shmaddr%SHMLBA) |
0 | 默认为读写权限 |
shmat函数的返回值说明:
- shmat调用成功,返回共享内存映射到进程地址空间中的起始地址。
- shmat调用失败,返回(void*)-1。
//由processa来管理共享内存的创建和销毁
int main()
{
int shmid = CreateShm(); //创建共享内存
char *shmaddr = (char*)shmat(shmid, nullptr, 0); //挂接共享内存
//通信...
}
1.5 共享内存去关联
取消共享内存与进程地址空间之间的关联我们需要用shmdt函数,shmdt函数的函数原型如下:
int shmdt(const void *shmaddr);
去掉共享内存的关联,如果直接进程退出,那么进程会释放它的进程虚拟地址空间,和页表映射的物理内存,所以此时共享内存的引用计数--,ipcs -m 就可以查看到 nattck (挂接数)减1
shmdt函数的参数说明:
- 待去关联共享内存的起始地址,即调用shmat函数时得到的起始地址。
shmdt函数的返回值说明:
- shmdt调用成功,返回0。
- shmdt调用失败,返回-1。
只给它起始地址,shmdt它是怎么知道我们申请的共享内存有多大呢?因为需要知道具体大小才能在页表消除映射,并使共享内存引用计数--
这个问题就如同malloc申请内存、free只需要起始地址,这两个概念是完全相同的,因为操作系统有结构体维护申请的空间,例如malloc,如果你申请100字节,那么实际上操作系统会在进程地址空间的堆区申请120字节,多出来的就是维护申请空间信息的变量
举例:
//由processa来管理共享内存的创建和销毁
int main()
{
int shmid = CreateShm(); //创建共享内存
char *shmaddr = (char*)shmat(shmid, nullptr, 0); //挂接共享内存
//通信...
shmdt(shmaddr); //取消关联
}
注意: 将共享内存段与当前进程脱离不等于删除共享内存,只是取消了当前进程与该共享内存之间的联系。
1.6 共享内存的释放
通过上面创建共享内存的实验可以发现,当我们的进程运行完毕后,申请的共享内存依旧存在,并没有被操作系统释放。即进程退出,共享内存没有被释放!
这表明共享内存的生命周期是跟随内核的(管道是生命周期是随进程的),用户不主动关闭,共享内存会一直存在,除非内核重启或用户释放。如果我们忘记释放共享内存,那么这就算内存泄漏;如果我们没有忘记,那么这就不算内存泄漏
例如两个进程通信完毕后走了,操作系统把共享内存释放了,过了一段时间,这两个进程又回来继续通信,此时发现共享内存没有了,而里面可能还有它们上一次通信的内容,此时操作系统就算是惹祸了
此时我们若是要将创建的共享内存释放,有两个方法,一就是使用命令释放共享内存,二就是在进程通信完毕后调用释放共享内存的系统调用函数进行释放。
命令释放共享内存
在shell中使用命令释放共享内存
ipcrm
ipcrm -m 共享内存shmid
注意: 指定删除时使用的是共享内存的用户层id,即列表当中的shmid。用户层统一使用shmid,命令行输入也是用户层
系统调用释放共享内存
在程序中,释放共享内存我们需要用shmctl函数,控制共享内存,我们用它来删除共享内存。shmctl函数的函数原型如下:
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
shmctl函数的参数说明:
- 第一个参数shmid,表示所控制共享内存的用户级标识符。
- 第二个参数cmd,表示具体的控制动作。
- 第三个参数buf,用于获取或设置所控制共享内存的数据结构。
shmctl函数的返回值说明:
- shmctl调用成功,返回0。
- shmctl调用失败,返回-1
其中,作为shmctl函数的第二个参数传入的常用的选项有以下三个:
选项 | 作用 |
---|---|
IPC_STAT | 获取共享内存的当前关联值,此时参数buf作为输出型参数 |
IPC_SET | 在进程有足够权限的前提下,将共享内存的当前关联值设置为buf所指的数据结构中的值 |
IPC_RMID | 删除共享内存段 |
我们目前使用该函数时,不需要获取值,所以在第三个参数处传nullptr即可(我们是C/C++混编,所以使用了nullptr)
//由processa来管理共享内存的创建和销毁
int main()
{
int shmid = CreateShm(); //创建共享内存
char *shmaddr = (char*)shmat(shmid, nullptr, 0); //挂接共享内存
//通信...
shmdt(shmaddr); //取消关联
shmctl(shmid, IPC_RMID, nullptr); //删除共享内存
return 0;
}
1.7 实践通信
我们讲了这么多全都是预备工作,创建、挂接、去关联、释放共享内存。那么预备工作完成后,双方如何进行通信?
很简单,此时共享内存已经被映射到各进程的地址空间中,已经属于进程(进程:这就是我创建的空间,我随便用),使用方法与平时我们 malloc 的空间使用方法完全相同!直接访问、写入即可
为了让服务端和客户端在使用ftok函数获取key值时,能够得到同一种key值,那么服务端和客户端传入ftok函数的路径名和和整数标识符必须相同,这样才能生成同一种key值,进而找到同一个共享资源进行挂接。这里我们可以将这些需要共用的信息放入一个头文件当中,服务端和客户端共用这个头文件即可。
我们依旧使用日志类来管理程序。共享内存流程:创建、挂接、去关联、释放
comm.hpp
该文件用来定义+实现共享内存的创建和获取
#ifndef __COMM_HPP__
#define __COMM_HPP__
#include <iostream>
#include <string>
#include <cstdlib>
#include <cstring>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/types.h>
#include <sys/types.h>
#include <sys/stat.h>
#include "log.hpp"
using namespace std;
Log log;
// 共享内存的大小一般建议是4096的整数倍
// 4097,实际上操作系统给你的是4096*2的大小
const int size = 4096;
const string pathname="/home/ljs";
const int proj_id = 0x666;
//进程调用的GetKey函数相同,代表着它们拿到的Key一定相同
key_t GetKey()
{
//获取key
key_t k = ftok(pathname.c_str(), proj_id);
if(k < 0)
{
log(Fatal, "ftok error: %s", strerror(errno));
exit(1);
}
log(Info, "ftok success, key is : 0x%x", k);
return k;
}
//创建共享内存
int GetShareMemHelper(int flag)
{
//调用GetKey获取key
key_t k = GetKey();
//创建
int shmid = shmget(k, size, flag);
if(shmid < 0)
{
log(Fatal, "create share memory error: %s", strerror(errno));
exit(2);
}
log(Info, "create share memory success, shmid: %d", shmid);
return shmid;
}
int CreateShm()
{
return GetShareMemHelper(IPC_CREAT | IPC_EXCL | 0666);
}
int GetShm()
{
return GetShareMemHelper(IPC_CREAT);
}
#endif
服务端processa.cc
读取共享内存的信息
#include "comm.hpp"
extern Log log;
//由processa来管理共享内存的创建和销毁
int main()
{
int shmid = CreateShm(); //创建共享内存
char *shmaddr = (char*)shmat(shmid, nullptr, 0); //挂接共享内存
while(true)
{
cout << "client say@ " << shmaddr << endl; //直接访问共享内存
sleep(1);
}
//注意:只有写端先被ctrl c掉,读端才能读到0,然后break出来到这里,执行共享内存的删除
//如果读端先被ctrl c掉,那么进程会收到中断信号,程序立即终止,不会执行shmctl系统调用
shmdt(shmaddr); //取消关联
shmctl(shmid, IPC_RMID, nullptr); //删除共享内存
return 0;
}
客户端processb.cc
向共享内存写入
#include "comm.hpp"
//processb直接使用即可,共享内存的创建和销毁由processa全权负责
int main()
{
int shmid = GetShm();
char *shmaddr = (char*)shmat(shmid, nullptr, 0); //挂接
while(true)
{
cout << "Please Enter@ ";
fgets(shmaddr, 4096, stdin);
}
shmdt(shmaddr); //去掉共享内存关联
return 0;
}
根据结果来看,共享内存有几个特性:
- 共享内存没有同步互斥之类的保护机制,跟管道不同,共享内存的读端进程不会等待写端进程就绪,会一直读空字符串。所以我们可以将共享内存和管道结合,当共享内存创建后,两者再使用管道,那么读端就必须等待写端写入数据,否则阻塞,这就实现了同步!
- 当共享内存创建好后就不再需要调用系统接口进行通信了,共享内存是所有的进程间通信中,速度是最快的,因为拷贝少!不需要read、write等系统接口进行通信。一旦有了共享内存,挂接到自己的地址空间中,那么直接使用起始地址进程操作即可,就如同操作自己malloc的空间,而管道创建好后仍需要read、write等系统接口进行通信。
- 共享内存内部的数据由用户自己维护
我们来算算管道通信拷贝次数:
使用管道通信的方式,将一个文件从一个进程传输到另一个进程需要进行四次拷贝操作:
- 服务端将信息从输入文件复制到服务端的临时缓冲区中。
- 将服务端临时缓冲区的信息复制到管道中。
- 客户端将信息从管道复制到客户端的缓冲区中。
- 将客户端临时缓冲区的信息复制到输出文件中。
注意:服务端和客户端的退出顺序 ,将共享内存和命名管道结合
与命名管道的服务端和客户端相同,只有先关闭客户端(写端),读端才会读到0,然后break,再执行后序的shmctl,shmctl是真的能删除共享内存,不用手动的使用 ipcrm -m 删除共享内存
如果直接ctrl c掉读端,进程会直接退出,不会执行后面的代码
每一个共享内存都有自己的数据结构,里面就有相应的key值
共享内存+命名管道代码
comm.hpp
#ifndef __COMM_HPP__
#define __COMM_HPP__
#include <iostream>
#include <string>
#include <cstdlib>
#include <cstring>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/types.h>
#include <sys/types.h>
#include <sys/stat.h>
#include "log.hpp"
using namespace std;
#define FIFO_FILE "./myfifo"
#define MODE 0664
enum
{
FIFO_CREATE_ERR = 1,
FIFO_DELETE_ERR,
FIFO_OPEN_ERR
};
class Init
{
public:
Init()
{
// 创建管道
int n = mkfifo(FIFO_FILE, MODE);
if (n == -1)
{
perror("mkfifo");
exit(FIFO_CREATE_ERR);
}
}
~Init()
{
//销毁管道
int m = unlink(FIFO_FILE);
if (m == -1)
{
perror("unlink");
exit(FIFO_DELETE_ERR);
}
}
};
Log log;
// 共享内存的大小一般建议是4096的整数倍
// 4097,实际上操作系统给你的是4096*2的大小
const int size = 4096;
const string pathname="/home/ljs";
const int proj_id = 0x666;
//进程调用的GetKey函数相同,代表着它们拿到的Key一定相同
key_t GetKey()
{
//获取key
key_t k = ftok(pathname.c_str(), proj_id);
if(k < 0)
{
log(Fatal, "ftok error: %s", strerror(errno));
exit(1);
}
log(Info, "ftok success, key is : 0x%x", k);
return k;
}
//创建共享内存
int GetShareMemHelper(int flag)
{
//调用GetKey获取key
key_t k = GetKey();
//创建
int shmid = shmget(k, size, flag);
if(shmid < 0)
{
log(Fatal, "create share memory error: %s", strerror(errno));
exit(2);
}
log(Info, "create share memory success, shmid: %d", shmid);
return shmid;
}
int CreateShm()
{
return GetShareMemHelper(IPC_CREAT | IPC_EXCL | 0666);
}
int GetShm()
{
return GetShareMemHelper(IPC_CREAT);
}
#endif
processa.cc
#include "comm.hpp"
extern Log log;
//由processa来管理共享内存的创建和销毁
int main()
{
// Init init;
int shmid = CreateShm(); //创建共享内存
char *shmaddr = (char*)shmat(shmid, nullptr, 0); //挂接共享内存
// ipc code 在这里!!
// 一旦有人把数据写入到共享内存,其实我们立马能看到了!!
// 不需要经过系统调用,直接就能看到数据了!
int fd = open(FIFO_FILE, O_RDONLY); // 等待写入方打开之后,自己才会打开文件,向后执行, open 阻塞了!
if (fd < 0)
{
log(Fatal, "error string: %s, error code: %d", strerror(errno), errno);
exit(FIFO_OPEN_ERR);
}
// struct shmid_ds shmds;
while(true)
{
char c;
ssize_t s = read(fd, &c, 1);
if(s == 0) break;
else if(s < 0) break;
cout << "client say@ " << shmaddr << endl; //直接访问共享内存
sleep(1);
// shmctl(shmid, IPC_STAT, &shmds);
// cout << "shm size: " << shmds.shm_segsz << endl;
// cout << "shm nattch: " << shmds.shm_nattch << endl;
// printf("shm key: 0x%x\n", shmds.shm_perm.__key);
// cout << "shm mode: " << shmds.shm_perm.mode << endl;
}
//注意:只有写端先被ctrl c掉,读端才能读到0,然后break出来到这里,执行共享内存的删除
//如果读端先被ctrl c掉,那么进程会收到中断信号,程序立即终止,不会执行shmctl系统调用
shmdt(shmaddr); //取消关联
shmctl(shmid, IPC_RMID, nullptr); //删除共享内存
close(fd);
return 0;
}
processb.cc
#include "comm.hpp"
//processb直接使用即可,共享内存的创建和销毁由processa全权负责
int main()
{
int shmid = GetShm();
char *shmaddr = (char*)shmat(shmid, nullptr, 0); //挂接
int fd = open(FIFO_FILE, O_WRONLY); // 等待写入方打开之后,自己才会打开文件,向后执行, open 阻塞了!
if (fd < 0)
{
log(Fatal, "error string: %s, error code: %d", strerror(errno), errno);
exit(FIFO_OPEN_ERR);
}
// 一旦有了共享内存,挂接到自己的地址空间中,你直接把他当成你的内存空间来用即可!
// 不需要调用系统调用
// ipc code
while(true)
{
cout << "Please Enter@ ";
fgets(shmaddr, 4096, stdin);
write(fd, "c", 1); // 通知对方
}
shmdt(shmaddr); //去掉共享内存关联
close(fd);
return 0;
}
2. System V消息队列
2.1 消息队列基本原理
消息队列实际上就是在系统当中创建了一个队列,队列当中的每个成员都是一个数据块,这些数据块都由类型和信息两部分构成,两个互相通信的进程通过某种方式看到同一个消息队列,这两个进程向对方发数据时,都在消息队列的队尾添加数据块,这两个进程获取数据块时,都在消息队列的队头取数据块。
其中消息队列当中的某一个数据块是由谁发送给谁的,取决于数据块的类型。
总结一下:
- 消息队列提供了一个从一个进程向另一个进程发送数据块的方法。
- 每个数据块都被认为是有一个类型的,接收者进程接收的数据块可以有不同的类型值。
- 和共享内存一样,消息队列的资源也必须自行删除,否则不会自动清除,因为system V IPC资源的生命周期是随内核的。
2.2 消息队列数据结构
当然,系统当中也可能会存在大量的消息队列,系统一定也要为消息队列维护相关的内核数据结构。
消息队列的数据结构如下:
struct msqid_ds {
struct ipc_perm msg_perm;
struct msg *msg_first; /* first message on queue,unused */
struct msg *msg_last; /* last message in queue,unused */
__kernel_time_t msg_stime; /* last msgsnd time */
__kernel_time_t msg_rtime; /* last msgrcv time */
__kernel_time_t msg_ctime; /* last change time */
unsigned long msg_lcbytes; /* Reuse junk fields for 32 bit */
unsigned long msg_lqbytes; /* ditto */
unsigned short msg_cbytes; /* current number of bytes on queue */
unsigned short msg_qnum; /* number of messages in queue */
unsigned short msg_qbytes; /* max number of bytes on queue */
__kernel_ipc_pid_t msg_lspid; /* pid of last msgsnd */
__kernel_ipc_pid_t msg_lrpid; /* last receive pid */
};
可以看到消息队列数据结构的第一个成员是msg_perm
,它和shm_perm
是同一个类型的结构体变量,ipc_perm
结构体的定义如下:
struct ipc_perm{
__kernel_key_t key;
__kernel_uid_t uid;
__kernel_gid_t gid;
__kernel_uid_t cuid;
__kernel_gid_t cgid;
__kernel_mode_t mode;
unsigned short seq;
};
2.3 消息队列的创建
创建消息队列我们需要用msgget函数,msgget函数的函数原型如下:
int msgget(key_t key, int msgflg);
参数接口几乎与共享内存完全相同,这里还不用传size
说明一下:
- 创建消息队列也需要使用ftok函数生成一个key值,这个key值作为msgget函数的第一个参数。
- msgget函数的第二个参数,与创建共享内存时使用的shmget函数的第三个参数相同。
- 消息队列创建成功时,msgget函数返回的一个有效的消息队列标识符(用户层标识符)。
2.4 消息队列的释放
释放消息队列我们需要用msgctl函数,msgctl函数的函数原型如下:
int msgctl(int msqid, int cmd, struct msqid_ds *buf);
说明一下:
- msgctl函数的参数与释放共享内存时使用的shmctl函数的三个参数相同,只不过msgctl函数的第三个参数传入的是消息队列的相关数据结构。
向消息队列发送数据
向消息队列发送数据我们需要用msgsnd函数,msgsnd函数的函数原型如下
int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
msgsnd函数的参数说明:
- 第一个参数msqid,表示消息队列的用户级标识符。
- 第二个参数msgp,表示待发送的数据块。
- 第三个参数msgsz,表示所发送数据块的大小
- 第四个参数msgflg,表示发送数据块的方式,一般默认为0即可。
msgsnd函数的返回值说明:
- msgsnd调用成功,返回0。
- msgsnd调用失败,返回-1。
其中msgsnd函数的第二个参数必须为以下结构:
struct msgbuf{
long mtype; /* message type, must be > 0 */
char mtext[1]; /* message data */
};
注意: 该结构当中的第二个成员mtext即为待发送的信息,当我们定义该结构时,mtext的大小可以自己指定。
2.5 从消息队列获取数据
从消息队列获取数据我们需要用msgrcv函数,msgrcv函数的函数原型如下:
ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);
msgrcv函数的参数说明:
- 第一个参数msqid,表示消息队列的用户级标识符。
- 第二个参数msgp,表示获取到的数据块,是一个输出型参数。
- 第三个参数msgsz,表示要获取数据块的大小
- 第四个参数msgtyp,表示要接收数据块的类型。
msgrcv函数的返回值说明:
- msgsnd调用成功,返回实际获取到mtext数组中的字节数。
- msgsnd调用失败,返回-1。
消息队列不作为重点
3. System V信号量
3.1 信号量的基本概念
- 定义:信号量是一种用于保证两个或多个关键代码段不被并发调用的机制。线程在进入一个关键代码段之前,必须先获取一个信号量;一旦该关键代码段执行完成,线程必须释放信号量。
- 类型:信号量主要分为计数信号量(Counting Semaphore)和二元信号量(Binary Semaphore)或称为互斥量(Mutex)。计数信号量允许多个线程访问一定数量的资源,而二元信号量则只允许一个线程访问资源。
- 核心操作:信号量的核心操作包括P操作(也称为wait或down操作)和V操作(也称为signal或up操作)。P操作会使信号量的值减1,如果信号量的值已经为0,则调用线程将会被阻塞;V操作则使信号量的值加1,如果有线程因为信号量值为0而被阻塞,则这些线程会被唤醒。
共享内存中,如果进程A正在写入10个数字,只写了一部分,例如5个数字,此时进程B直接就拿走了这5个数字,但是这10个数字必须组合在一起才有意义,所以B拿了也没用,这就会导致数据不一致问题,即A发的数据和B收的数据不一致,这就是没有互斥的弊端。而管道就不会出现这种问题,因为管道数据有原子性
1. A、B看到的同一份资源——共享资源,如果不加保护,会导致数据不一致问题
2. 加锁 -- 互斥访问 -- 任何时刻只允许一个执行流访问共享资源 -- 互斥
3. 共享资源,任何时刻只允许一个执行流访问的资源——临界资源 --- 一般是内存空间
4. 如果有100行代码,其中只有5-10行在访问临界资源(访问IPC资源都是代码干的),那么我们访问临界资源的代码叫做临界区
为什么多进程在显示器上打印的数据又是是错乱的、还会和命令行混在一起?
因为显示器也是被多个进程共享的共享资源,没有互斥保护所以各打各的
3.2 信号量数据结构
在系统当中也为信号量维护了相关的内核数据结构。
信号量的数据结构如下:
struct semid_ds {
struct ipc_perm sem_perm; /* permissions .. see ipc.h */
__kernel_time_t sem_otime; /* last semop time */
__kernel_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 short sem_nsems; /* no. of semaphores in array */
};
信号量数据结构的第一个成员也是ipc_perm
类型的结构体变量,ipc_perm
结构体的定义如下:
struct ipc_perm{
__kernel_key_t key;
__kernel_uid_t uid;
__kernel_gid_t gid;
__kernel_uid_t cuid;
__kernel_gid_t cgid;
__kernel_mode_t mode;
unsigned short seq;
};
3.3 信号量原理
以停车场的运作为例,假设停车场只有三个车位,一开始三个车位都是空的。这时如果同时来了五辆车,看门人允许其中三辆直接进入,然后放下车拦,剩下的车则必须在入口等待。此后来的车也都不得不在入口处等待。当有一辆车离开停车场时,看门人得知后,会打开车拦,放入外面的一辆车进去。这个过程中,车位是公共资源,每辆车好比一个线程,看门人起的就是信号量的作用——计数信号量(Counting Semaphore)
如果此时只有一个车位,那么就表示只有一辆车能抢占位置,那么我们只需要一个值为1的计数器——二元信号量(Binary Semaphore)或称为互斥量(Mutex)
将临界资源整合,作为一个整体,此时就是互斥原理
信号量是描述临界资源中资源数量的多少,我们怕的是多个执行流(车)访问同一个资源(车位),所以引入计数器,当计数器为0时,再有执行流申请资源,就不会同意了
但是信号量计数器也是共享资源!它的目的是保护别人的安全,但是前提是它自己是安全的!
所以信号量的申请和释放是原子的!要么不做,要做就做完,是两态,没有正在做的情况,例如只有一条汇编指令,它就是原子的
信号量的核心操作包括P操作(也称为wait或down操作)和V操作(也称为signal或up操作)。P操作会使信号量的值减1,如果信号量的值已经为0,则调用线程将会被阻塞;V操作则使信号量的值加1
小结:
- 信号量本质是一把计数器,信号量的申请和释放(PV)操作是原子的
- 执行流申请资源,必须先申请信号量资源,得到信号量之后才能访问临界资源
- 信号量值为0或1两态的是二元信号量,对应互斥功能
- 申请信号量的本质就是对临界资源的预定机制,就是对计数器的--,即P操作
- 释放资源,释放信号量,本质是对计数器的++,即V操作
信号量凭什么是进程通信的一种?它又不能进行通信
- 通信不仅仅是通信数据,互相协同也算通信
- 要协同本质也是通信,信号量首先要被所有的通信进程看到,即不同的进程看到同一份资源
信号量不能用来通信资源,它是来帮助通信的
3.4 信号量的优缺点
- 优点:
- 灵活性强:信号量可以用于多种同步场景,如进程同步、资源管理和死锁预防。
- 可扩展性:信号量可以扩展为计数信号量,用于管理多个同类型资源的并发访问。
- 缺点:
- 编程复杂度:信号量的使用需要开发者仔细设计同步逻辑,避免出现死锁、优先级反转等问题。
五、mmap系统调用
mmap
(内存映射文件)函数是Unix和类Unix系统(包括Linux)中用于创建内存映射文件的一个系统调用。它允许程序将文件或其他对象的内容映射到进程的地址空间,从而实现文件内容的高效访问。通过mmap
,进程可以像访问内存一样访问文件内容,这可以显著提高处理大文件时的性能,因为操作系统可以更有效地管理内存和磁盘之间的数据传输。
mmap
(Memory Mapped File)是将磁盘上的文件加载到内存中,并将其映射到进程的地址空间中。这种机制允许进程通过访问内存的方式来直接操作文件内容,而无需通过传统的系统调用如read
和write
来进行数据的读写操作。这样做可以显著提高文件I/O操作的效率,尤其是在处理大文件或需要频繁访问文件内容的场景下。
1. 函数原型
在C语言中,mmap
函数的原型定义在<sys/mman.h>
头文件中,其基本形式如下:
#include <sys/mman.h>
#include <sys/types.h>
#include <fcntl.h>
#include <unistd.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
2. 参数说明
void *addr
:建议的映射地址。通常设为NULL
,让系统自动选择地址。size_t length
:要映射的字节数。int prot
:映射区域的保护方式,可以是PROT_READ
(可读)、PROT_WRITE
(可写)或PROT_EXEC
(可执行)的位或操作。int flags
:映射选项,常用的有MAP_SHARED
(共享映射)和MAP_PRIVATE
(私有映射)。int fd
:文件描述符,表示要映射的文件。off_t offset
:文件映射的偏移量,即从文件的哪个位置开始映射。
3. 返回值
成功时,mmap
返回指向映射区域的指针。失败时,返回MAP_FAILED
(其值通常被定义为(void *)-1
),并设置errno
以指示错误。
4. 示例
以下是一个简单的使用mmap
来读取文件的例子:
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main() {
int fd = open("example.txt", O_RDONLY);
if (fd == -1) {
perror("Error opening file");
return EXIT_FAILURE;
}
struct stat sb;
if (fstat(fd, &sb) == -1) {
perror("Error getting file size");
close(fd);
return EXIT_FAILURE;
}
size_t length = sb.st_size;
char *map = mmap(0, length, PROT_READ, MAP_PRIVATE, fd, 0);
if (map == MAP_FAILED) {
close(fd);
perror("Error mmapping the file");
return EXIT_FAILURE;
}
// 假设example.txt是一个文本文件
printf("Contents of file:\n%s", map);
if (munmap(map, length) == -1) {
close(fd);
perror("Error un-mmapping the file");
return EXIT_FAILURE;
}
close(fd);
return EXIT_SUCCESS;
}
5. 注意事项
- 使用完映射区域后,应使用
munmap
函数来取消映射。 - 映射的文件在进程终止时自动取消映射,但在进程生命周期内,如果不显式调用
munmap
或关闭文件描述符,映射会一直保持。 - 映射的写操作(当使用
MAP_SHARED
时)会直接影响磁盘上的文件内容,这可能不是所有应用场景都期望的。
六、IPC在内核中的数据结构设计
每一种IPC资源都可以描述自己类型的struct,但是他们封装的第一个字段都是 struct ipc_perm XXX_perm,并且每一种IPC资源结构体 (struct XXXid_ds) 的第一个字段 struct ipc_perm XXX_perm 的地址都被放置在 struct ipc_perm* array[] 数组中。
所以操作系统可以统一管理 IPC 资源:当我们想获取共享内存shmid_ds内除了第一个字段的数据时,我们可以从struct ipc_perm* array[] 数组中找到相应的 shm_perm 地址,强制类型转换位struct shmid_ds* 即可,因为shm_perm时struct shimid_ds结构体内的第一个字段,与结构体地址相同,所以直接强制类型转换即可。
但是多种IPC资源的 XXX_perm 都在数组内,操作系统如何强转为正确的 struct XXXid_ds*类型呢?
在 struct ipc_perm XXX_perm 中有标志位,OS可以识别指针指向的对象是哪种IPC资源。
看到这里,我们会发现根据指针指向的不同,OS就能获取不同IPC资源,这不就是C++中的多态吗!struct ipc_perm是基类,各个IPC资源继承基类,并再加以描述组成各自的struct XXXid_ds结构体,最终OS根据指针类型是哪一种IPC XX_perm ,从而决定访问哪一种IPC资源!所以操作系统用C语言实现了多态
是以,我们的面向对象语言都是在前人大量的工程实践中总结出来的,而不是突然就出现的!
shmget 返回值的含义
举例:如果一个进程拿着用户传递的Key,想要创建共享内存shmget,那么操作系统会在 struct ipc_perm* array[] 数组中遍历查找每一个数据 XXX_perm 内封装的 key ,查看key是否已经存在,如果已经存在就会返回该XXX_perm在数组内的下标值,即 XXXid (shmid、msqid),这就是shmget返回值的含义,所以shmid就是数组下标。
所以某个进程如果想使用 IPC 资源,直接传递 XXXid 即可,因为他直接对应数组下标。
但是我们如果实践可能会发现这个XXXid数有的可能很大,有的可能很小,这是为什么呢?
这是单独设计的数组,不隶属于任何进程,是OS的数组,它的下标跟文件描述符 fd 不同,无法跟进程强关联,并且它的下标是线性递增的,不会从头开始查空缺的下标。当下标增加至最大时,会回退到0