Linux系统编程(八)--进程间通信
这里写自定义目录标题
- 1. 进程间通信介绍
- 1.1 进程间通信目的
- 1.2 进程间通信的本质
- 1.3 进程间通信的分类
- 2. 管道
- 2.1 什么是管道
- 3. 匿名管道
- 3.1 匿名管道的原理
- 3.2 pipe函数
- 3.3 管道的特点
- 3.4 管道的四种特殊情况
- 3.5 管道的大小
- 4. 进程池的设计与实现:一个简单而高效的多进程任务处理框架
- 4.1 什么是进程池?
- 4.2 为什么需要进程池?
- 4.3 进程池的设计思路
- 4.4 进程池的实现
- 任务管理
- 进程管理
- 通信机制
- 负载均衡
- 主程序
- 5. 命名管道
- 5.1 命名管道的基本概念
- 5.2 用命名管道实现serve&client通信
- 5.3 命名管道和匿名管道的区别
- 6. system V进程间通信
- 6.1 system V共享内存
- 共享内存的基本原理
- 共享内存数据结构
- 共享内存的建立与释放
- 共享内存的创建
- 共享内存的关联
- 共享内存的去关联
- 共享内存与管道进行对比
- 加入管道
- 6.2 System V消息队列
- 消息队列的基本原理
- 消息队列数据结构
- 消息队列的创建
- 消息队列的释放
- 向消息队列发送数据
- 从消息队列获取数据
- 6.3 System V信号量
- 基本概念
- 主要操作
- 信号量数据结构
- 信号量相关函数
- 信号量集的创建
- 信号量集的删除
- 信号量集的操作
- 6.4 system V IPC联系
1. 进程间通信介绍
进程间通信的概念
进程间通信简称IPC(Interprocess communication),进程间通信就是在不同进程之间传播或交换信息。
1.1 进程间通信目的
数据传输:一个进程需要将它的数据发送给另一个进程。
资源共享:多个进程之间共享同样的资源。
通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
1.2 进程间通信的本质
进程间通信的本质就是,让不同的进程看到同一份资源。
由于各个运行进程之间具有独立性,这个独立性主要体现在数据层面,而代码逻辑层面可以私有也可以公有(例如父子进程),因此各个进程之间要实现通信是非常困难的。
各个进程之间若想实现通信,一定要借助第三方资源,这些进程就可以通过向这个第三方资源写入或是读取数据,进而实现进程之间的通信,这个第三方资源实际上就是操作系统提供的一段内存区域。
因此,进程间通信的本质就是,让不同的进程看到同一份资源(内存,文件内核缓冲等)。 由于这份资源可以由操作系统中的不同模块提供,因此出现了不同的进程间通信方式。
1.3 进程间通信的分类
管道
- 匿名管道
- 命名管道
System V IPC
- System V 消息队列
- System V 共享内存
- System V 信号量
POSIX IPC
- 消息队列
- 共享内存
- 信号量
- 互斥量
- 条件变量
- 读写锁
2. 管道
2.1 什么是管道
管道是Unix中最古老的进程间通信的形式,我们把从一个进程连接到另一个进程的数据流称为一个“管道”。
例如,统计我们当前使用云服务器上的登录用户个数。
其中,who命令和wc命令都是两个程序,当它们运行起来后就变成了两个进程,who进程通过标准输出将数据打到“管道”当中,wc进程再通过标准输入从“管道”当中读取数据,至此便完成了数据的传输,进而完成数据的进一步加工处理。
注明: who命令用于查看当前云服务器的登录用户(一行显示一个用户),wc -l用于统计当前的行数。
3. 匿名管道
3.1 匿名管道的原理
匿名管道用于进程间通信,且仅限于本地父子进程之间的通信。
进程间通信的本质就是,让不同的进程看到同一份资源,使用匿名管道实现父子进程间通信的原理就是,让两个父子进程先看到同一份被打开的文件资源,然后父子进程就可以对该文件进行写入或是读取操作,进而实现父子进程间通信。
这张图片大部分内容前面的博客都已经讲过了,就不标注了。
讲一下父子进程共享资源:
图中显示父子进程的 fd_array 指向同一个 struct file 结构,fork() 创建子进程时,会复制父进程的文件描述符表,但文件描述符表中的每个文件描述符指向的 struct file 是共享的。这是 Linux 操作系统中进程管理的一个重要特性。这意味着父子进程共享同一份文件资源。
因此,父子进程看到的是同一份资源(文件缓冲区和磁盘上的文件)。
注意:
- 这里父子进程看到的同一份文件资源是由操作系统来维护的,所以当父子进程对该文件进行写入操作时,该文件缓冲区当中的数据并不会进行写时拷贝。
- 管道虽然用的是文件的方案,但操作系统一定不会把进程进行通信的数据刷新到磁盘当中,因为这样做有IO参与会降低效率,而且也没有必要。也就是说,这种文件是一批不会把数据写到磁盘当中的文件,换句话说,磁盘文件和内存文件不一定是一一对应的,有些文件只会在内存当中存在,而不会在磁盘当中存在。
3.2 pipe函数
pipe() 函数是 Linux 系统编程中用于创建管道(Pipe)的系统调用,它允许两个进程之间进行简单的单向数据传输。管道是进程间通信(IPC)的一种基本方式,通常用于具有亲缘关系的进程(如父子进程)之间。
函数原型:
#include <unistd.h>
int pipe(int pipefd[2]);
参数
pipefd[2]:一个整型数组,用于存储管道的两个文件描述符。数组的第一个元素 pipefd[0] 是管道的读端,第二个元素 pipefd[1] 是管道的写端。
返回值
成功:返回 0。
失败:返回 -1,并设置 errno 以指示错误类型。
匿名管道使用步骤
在创建匿名管道实现父子进程间通信的过程中,需要pipe函数和fork函数搭配使用,具体步骤如下:
创建管道:使用 pipe() 函数创建一个管道,并获取两个文件描述符。
创建子进程:使用 fork() 创建子进程。管道通常用于父子进程之间的通信。
关闭不必要的文件描述符:
父进程通常关闭管道的读端(pipefd[0])。
子进程通常关闭管道的写端(pipefd[1])。
数据传输:
父进程通过写端(pipefd[1])向管道写入数据。
子进程通过读端(pipefd[0])从管道读取数据。
关闭文件描述符:通信完成后,关闭所有文件描述符。
图片示例:
如下面一段代码实现了父子进程通过管道(pipe)进行通信,子进程定期向管道写入消息,父进程从管道读取消息并输出。
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <cstdio>
#include <cstring>// 子进程写入管道的函数
void ChildWrite(int wfd)
{char buffer[1024];int cnt = 0;while (true){ // 子进程无限循环,持续向管道写入数据snprintf(buffer, sizeof(buffer), "I am child,pid: %d ,cnt:%d", getpid(), cnt++); // 构造要写入的信息,包含子进程ID和计数write(wfd, buffer, strlen(buffer)); // 向管道的写端写入数据sleep(1); // 暂停1秒}
}// 父进程从管道读取的函数
void FatherRead(int rfd)
{char buffer[1024];while (true){ // 父进程无限循环,持续从管道读取数据buffer[0] = 0; // 初始化缓冲区ssize_t n = read(rfd, buffer, sizeof(buffer) - 1); // 从管道的读端读取数据if (n > 0){ // 如果读取到数据buffer[n] = '\0'; // 确保字符串以空字符结尾std::cout << "child say: " << buffer << std::endl; // 输出读取到的信息}}
}int main()
{// 1. 创建管道int fds[2] = {0}; // fds[0]:管道的读端,fds[1]:管道的写端int n = pipe(fds);if (n < 0){ // 如果创建管道失败std::cerr << "pipe error" << std::endl;return 1;}std::cout << "fds[0]:" << fds[0] << std::endl; // 输出读端文件描述符std::cout << "fds[1]:" << fds[1] << std::endl; // 输出写端文件描述符// 2. 创建子进程pid_t id = fork();if (id == 0){ // 子进程// 3. 关闭子进程不需要的读端close(fds[0]);ChildWrite(fds[1]); // 调用子进程写入函数close(fds[1]); // 关闭写端exit(0); // 子进程结束}else{ // 父进程// 3. 关闭父进程不需要的写端close(fds[1]);FatherRead(fds[0]); // 调用父进程读取函数waitpid(id, nullptr, 0); // 父进程等待子进程结束close(fds[0]); // 关闭读端}return 0;
}
运行结果如下:
3.3 管道的特点
1、匿名管道:只用来进行具有血缘关系的进程之间进行通信,通常用于父子之间进行通信。因为子进程能看到父进程的数据。
2、管道内部自带同步与互斥机制。
我们将一次只允许一个进程使用的资源,称为临界资源。管道在同一时刻只允许一个进程对其进行写入或是读取操作,因此管道也就是一种临界资源。
临界资源是需要被保护的,若是我们不对管道这种临界资源进行任何保护机制,那么就可能出现同一时刻有多个进程对同一管道进行操作的情况,进而导致同时读写、交叉读写以及读取到的数据不一致等问题。
为了避免这些问题,内核会对管道操作进行同步与互斥:
同步: 两个或两个以上的进程在运行过程中协同步调,按预定的先后次序运行。比如,A任务的运行依赖于B任务产生的数据。
互斥: 一个公共资源同一时刻只能被一个进程使用,多个进程不能同时使用公共资源。
实际上,同步是一种更为复杂的互斥,而互斥是一种特殊的同步。对于管道的场景来说,互斥就是两个进程不可以同时对管道进行操作,它们会相互排斥,必须等一个进程操作完毕,另一个才能操作,而同步也是指这两个不能同时对管道进行操作,但这两个进程必须要按照某种次序来对管道进行操作。
3、 半双工通信
在数据通信中,数据在线路上的传送方式可以分为以下三种:
- 单工通信(Simplex Communication):单工模式的数据传输是单向的。通信双方中,一方固定为发送端,另一方固定为接收端。
- 半双工通信(Half Duplex):半双工数据传输指数据可以在一个信号载体的两个方向上传输,但是不能同时传输。
- 全双工通信(Full Duplex):全双工通信允许数据在两个方向上同时传输,它的能力相当于两个单工通信方式的结合。全双工可以同时(瞬时)进行信号的双向传输。
管道是半双工的,数据只能向一个方向流动,需要双方通信时,需要建立起两个管道。
4、管道文件在通信的时候,是面向字节流的。
字节流传输” 是指数据通过管道时,会被视为一个连续的字节序列,而不保留其原始的结构或格式。这可能让你觉得数据传输后的内容和格式发生了变化,但实际上,它只是要求你在读取时按照字节顺序处理数据。结合一个例子帮助理解。
示例代码:
#include <iostream>
#include <unistd.h>
#include <cstring>int main() {int fds[2];pipe(fds);// 写入数据const char *msg1 = "Hello";const char *msg2 = "World";write(fds[1], msg1, strlen(msg1)); // 写入 "Hello"write(fds[1], msg2, strlen(msg2)); // 写入 "World"close(fds[1]); // 关闭写端// 读取数据char buffer[1024];ssize_t n = read(fds[0], buffer, sizeof(buffer) - 1);if (n > 0) {buffer[n] = '\0';std::cout << "Read: " << buffer << std::endl;}close(fds[0]);return 0;
}
运行结果:
当我们从管道读取数据时,返回的是一个连续的字节流 “HelloWorld”。管道不会保留字符串之间的分隔或顺序,所有字节被连续读取。
5、管道的生命周期随进程。
管道本质上是通过文件进行通信的,也就是说管道依赖于文件系统,那么当所有打开该文件的进程都退出后,该文件也就会被释放掉,所以说管道的生命周期随进程。
3.4 管道的四种特殊情况
写端进程不写,读端进程一直读,那么此时会因为管道里面没有数据可读,对应的读端进程会被挂起,直到管道里面有数据后,读端进程才会被唤醒。
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>int main()
{int fd[2];pipe(fd);pid_t pid = fork();if (pid == 0){ // 子进程(写端)close(fd[0]); // 关闭读端sleep(2); // 模拟延迟写入write(fd[1], "Hello", 6);close(fd[1]);}else{ // 父进程(读端)close(fd[1]); // 关闭写端char buf[6];printf("Reading... (will block until data arrives)\n");read(fd[0], buf, 6); // 阻塞直到数据到达printf("Read: %s\n", buf);close(fd[0]);wait(NULL);}return 0;
}
读端进程不读,写端进程一直写,那么当管道被写满后,对应的写端进程会被挂起,直到管道当中的数据被读端进程读取后,写端进程才会被唤醒。
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/wait.h>int main() {int fd[2];pipe(fd);pid_t pid = fork();if (pid == 0) { // 子进程(读端)close(fd[1]); // 关闭写端sleep(5); // 模拟长时间不读取char buf[1024];read(fd[0], buf, 1024); // 延迟读取printf("Child finally read data\n");close(fd[0]);} else { // 父进程(写端)close(fd[0]); // 关闭读端const char* data = "Filling the pipe...";int total = 0;while (1) { // 持续写入直到管道满int bytes = write(fd[1], data, strlen(data)+1);if (bytes == -1) {perror("write failed (pipe full)"); // 实际不会触发,会阻塞break;}total += bytes;printf("Total written: %d bytes\n", total);}close(fd[1]);wait(NULL);}return 0;
}
写端进程将数据写完后将写端关闭,那么读端进程将管道当中的数据读完后,就会继续执行该进程之后的代码逻辑,而不会被挂起。
// 3. 写端关闭,读端正常读取
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>int main()
{int fd[2];pipe(fd);pid_t pid = fork();if (pid == 0){ // 子进程(读端)close(fd[1]); // 关闭写端char buf[12];read(fd[0], buf, 12); // 读取数据printf("Read: %s\n", buf);printf("Trying to read again...\n");ssize_t ret = read(fd[0], buf, 12); // 返回0(EOF)printf("read returned %zd (EOF), exiting\n", ret);close(fd[0]);}else{ // 父进程(写端)close(fd[0]); // 关闭读端write(fd[1], "Hello World", 12);close(fd[1]); // 关闭写端,发送EOFwait(NULL);}return 0;
}
读端返回值会读到0,表示读到文件结尾。
读端进程将读端关闭,而写端进程还在一直向管道写入数据,那么操作系统会将写端进程杀掉。
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>int main() {int fd[2];pipe(fd);pid_t pid = fork();if (pid == 0) { // 子进程(写端)close(fd[0]); // 关闭读端sleep(1); // 确保父进程关闭了读端printf("Writing to pipe...\n");if (write(fd[1], "Hello", 6) == -1) {perror("write failed"); // 会触发}close(fd[1]);} else { // 父进程(读端)close(fd[0]); // 关闭读端(关键操作)close(fd[1]); // 父进程不操作写端int status;wait(&status);if (WIFSIGNALED(status)) {printf("Child killed by signal %d (SIGPIPE)\n", WTERMSIG(status));}}return 0;
}
注:WTERMSIG 是一个宏,用于获取导致子进程终止的信号编号。
运行结果:
我们用这个宏捕捉到的退出信号是13(SIGPIPE),是由操作系统向子进程发送的是SIGPIPE信号将子进程终止的。
3.5 管道的大小
管道的容量是有限的,如果管道已满,那么写端将阻塞或失败,那么管道的最大容量是多少呢?
方法一:使用man手册
根据man手册,在2.6.11之前的Linux版本中,管道的最大容量与系统页面大小相同,从Linux 2.6.11往后,管道的最大容量是65536字节。通过 man 7 pipe
查询
然后我们可以使用uname -r命令,查看自己使用的Linux版本。
根据man手册,我使用的是Linux 2.6.11之后的版本,因此管道的最大容量是65536字节。
方法二:使用ulimit命令
其次,我们还可以使用ulimit -a命令,查看当前资源限制的设定。
根据显示,管道的最大容量是 512 × 8 = 4096
方法三:自行测试
这里发现,根据man手册得到的管道容量与使用ulimit命令得到的管道容量不同,那么此时我们可以自行进行测试。
前面说到,若是读端进程一直不读取管道当中的数据,写端进程一直向管道写入数据,当管道被写满后,写端进程就会被挂起。据此,我们可以写出以下代码来测试管道的最大容量。
int main()
{int fd[2];pipe(fd); // 创建管道// 设置写端为非阻塞模式fcntl(fd[1], F_SETFL, O_NONBLOCK);//确保当管道满时 write 立即返回错误而非阻塞。pid_t pid = fork();if (pid == 0){ // 子进程(写入端)close(fd[0]); // 关闭读端size_t total = 0;char buf[4096]; // 每次写入4KB数据块while (1){ssize_t n = write(fd[1], buf, sizeof(buf));//返回写入的字节数if (n == -1){if (errno == EAGAIN){ // 管道已满printf("管道缓冲区大小: %zu 字节\n", total);break;}else{ // 其他错误perror("写入错误");break;}}else{total += n; // 累计写入量}}close(fd[1]);return 0;}else{ // 父进程(不操作数据)close(fd[1]); // 关闭父进程的写端wait(NULL); // 等待子进程结束close(fd[0]);}return 0;
}
非阻塞写入:
当管道缓冲区满时,非阻塞模式的 write 会立即返回 -1 并设置 errno 为 EAGAIN,通过此机制可准确检测管道容量上限。
运行结果:
可以看到,在读端进程不进行读取的情况下,写端进程最多写65536字节的数据就被操作系统挂起了,也就是说,我当前Linux版本中管道的最大容量是65536字节。0~65536也就是总共有65537个字节,65537/1024=6464Kb。
4. 进程池的设计与实现:一个简单而高效的多进程任务处理框架
4.1 什么是进程池?
进程池是一种用于管理和调度多个子进程的机制,广泛应用于多任务处理场景。它通过创建一定数量的子进程,并将任务分配给这些子进程来执行,从而提高程序的并发处理能力和资源利用率。进程池的核心思想是将任务的创建和执行分离,避免频繁创建和销毁进程带来的开销。
4.2 为什么需要进程池?
在多任务处理中,频繁地创建和销毁进程会导致系统资源的浪费和性能下降。进程池通过预先创建一定数量的子进程,并将任务分配给这些子进程来执行,可以显著减少进程创建和销毁的开销,提高程序的效率和响应速度。
4.3 进程池的设计思路
设计一个高效的进程池需要考虑以下几个关键点:
- 任务管理:如何存储和分配任务。
- 进程管理:如何创建、管理和回收子进程。
- 通信机制:如何在父进程和子进程之间传递任务和结果。
- 负载均衡:如何合理分配任务,避免某些子进程过载而其他子进程空闲。
4.4 进程池的实现
以下是一个简单的进程池实现,包括任务管理、进程管理和通信机制。
任务管理
任务管理的核心是将任务存储在一个容器中,并提供接口供进程池调用。我们使用一个简单的任务管理器 TaskManger
来实现这一点。
Task.hpp
#pragma once
#include <iostream>
#include <vector>
#include <ctime>
typedef void (*task_t)(); // 定义任务函数指针类型// 定义三个任务函数
void PrintLog()
{std::cout << "我是一个打印日志的任务" << std::endl;
}void Download()
{std::cout << "我是一个下载的任务" << std::endl;
}void Upload()
{std::cout << "我是一个上传的任务" << std::endl;
}// 任务管理器类
class TaskManger
{
public:TaskManger() {}// 注册任务函数void Register(task_t t){_tasks.push_back(t);}// 随机生成一个任务码int Code(){return rand() % _tasks.size();}// 根据任务码执行对应的任务void Execute(int code){if (code >= 0 && code < _tasks.size()){_tasks[code]();}}~TaskManger() {}private:std::vector<task_t> _tasks; // 存储任务函数指针
};
功能说明:
Register(task_t t)
:注册一个任务函数。Code()
:随机生成一个任务码,用于选择任务。Execute(int code)
:根据任务码执行对应的任务。
进程管理
进程管理的核心是创建和管理子进程,并在任务完成后回收子进程。我们使用 Channel
和 ChannelManger
类来实现这一点。
ProcessPool.hpp
#ifndef __PROCESS_POOL_HPP__
#define __PROCESS_POOL_HPP__#include <iostream>
#include <vector>
#include <unistd.h>
#include <cstdio>
#include <string>
#include <sys/wait.h>
#include "Task.hpp"// 通道类,封装管道的写端和子进程的进程 ID
class Channel
{
public:Channel(int fd, pid_t id): _wfd(fd), _subid(id){_name = "channel-" + std::to_string(_wfd) + "-" + std::to_string(id);}~Channel() {}// 向子进程发送任务码void Send(int code){int n = write(_wfd, &code, sizeof(code));(void)n; // 绕过编译器对未使用变量的语法检查}// 关闭管道的写端void Close(){close(_wfd);}// 等待子进程退出void Wait(){pid_t rid = waitpid(_subid, nullptr, 0);(void)rid;}int Fd() { return _wfd; } // 获取管道的写端文件描述符pid_t Subid() { return _subid; } // 获取子进程的进程 IDstd::string Name() { return _name; } // 获取通道的名称private:int _wfd; // 管道的写端文件描述符pid_t _subid; // 子进程的进程 IDstd::string _name; // 通道的名称
};// 通道管理器类,管理多个通道
class ChannelManger
{
public:ChannelManger() : _next(0) {}// 插入一个通道void Insert(int wfd, pid_t subid){_channels.emplace_back(wfd, subid);}// 选择一个通道Channel &select(){auto &c = _channels[_next];_next++;_next %= _channels.size();return c;}// 打印所有通道的信息void PrintChnal(){for (auto &channel : _channels){std::cout << channel.Name() << std::endl;}}// 关闭通道的写端void StopSubProcess(){for (auto &channel : _channels){channel.Close();std::cout << "关闭:" << channel.Name() << std::endl;}}// 等待所有子进程退出void WaitSubProcess(){for (auto &channel : _channels){channel.Wait();std::cout << "回收:" << channel.Name() << std::endl;}}~ChannelManger() {}private:std::vector<Channel> _channels; // 存储所有通道int _next; // 当前选择的通道索引
};const int gdefaultnum = 5; // 默认创建的子进程数量// 进程池类
class ProcessPool
{
public:ProcessPool(int num): _process_num(num){_tm.Register(PrintLog); // 注册任务函数_tm.Register(Download);_tm.Register(Upload);}// 启动进程池bool Start(){for (int i = 0; i < _process_num; i++){int pipefd[2];if (pipe(pipefd) < 0) // 创建管道return false;pid_t subid = fork(); // 创建子进程if (subid < 0)return false;else if (subid == 0){// 子进程逻辑close(pipefd[1]); // 关闭写端Work(pipefd[0]); // 调用 Work 函数处理任务close(pipefd[0]); // 关闭读端exit(0); // 子进程退出}else{// 父进程逻辑close(pipefd[0]); // 关闭读端_cm.Insert(pipefd[1], subid); // 将写端和子进程 ID 存储到通道管理器}}return true;}// 派发任务void Run(){int taskcode = _tm.Code(); // 随机生成一个任务码auto &c = _cm.select(); // 选择一个子进程std::cout << "选择一个子进程:" << c.Name() << std::endl;c.Send(taskcode); // 向子进程发送任务码std::cout << "发送了一个任务码:" << taskcode << std::endl;}// 停止进程池void Stop(){_cm.StopSubProcess(); // 关闭所有子进程的写端_cm.WaitSubProcess(); // 等待所有子进程退出}~ProcessPool() {}private:ChannelManger _cm; // 通道管理器int _process_num; // 子进程数量TaskManger _tm; // 任务管理器// 子进程的工作函数void Work(int fd){while (true){int code = 0;ssize_t n = read(fd, &code, sizeof(code)); // 从管道读取任务码if (n > 0){if (n != sizeof(code)) // 如果读取的数据不完整,跳过{continue;}std::cout << "子进程[" << getpid() << "]读取到一个任务码:" << code << std::endl;_tm.Execute(code); // 执行对应的任务}else if (n == 0) // 如果读取到 EOF,表示父进程关闭了写端{std::cout << "子进程[" << getpid() << "]读取到 EOF,退出" << std::endl;exit(0); // 子进程退出}else // 如果读取失败{std::cout << "子进程[" << getpid() << "]读取错误" << std::endl;exit(1); // 子进程退出}}}
};#endif
功能说明:
ProcessPool
类:Start()
:启动进程池,创建指定数量的子进程。Run()
:选择一个子进程并发送任务码。Stop()
:关闭所有子进程的写端,并等待所有子进程退出。
Channel
类:Send(int code)
:向子进程发送任务码。Close()
:关闭管道的写端。Wait()
:等待子进程退出。
ChannelManger
类:Insert(int wfd, pid_t subid)
:插入一个通道。select()
:选择一个通道。StopSubProcess()
:关闭所有子进程的写端。WaitSubProcess()
:等待所有子进程退出。
通信机制
父进程和子进程之间的通信通过管道实现。父进程通过管道的写端发送任务码给子进程,子进程通过管道的读端接收任务码并执行对应的任务。
负载均衡
一个子进程如果接受多个任务,而其他子进程闲置,这样是不合理的。这里有多种方式处理,比如让子进程轮流接受任务,或者随机选择,或者对每一个子进程接收的任务数量做一个标记,每次选择最少的那个,为了便于理解,这里我们采取轮询。
负载均衡通过 ChannelManger::select()
方法实现,该方法通过轮询的方式选择一个子进程,确保任务均匀分配。
主程序
主程序负责初始化进程池,派发任务,并在任务完成后回收进程池。
main.cpp
#include "ProcessPool.hpp"
#include "Task.hpp"int main()
{srand(time(0)); // 初始化随机数种子ProcessPool pp(gdefaultnum); // 创建进程池pp.Start(); // 启动进程池int cnt = 10; // 派发 10 个任务while (cnt--){pp.Run(); // 派发任务sleep(1); // 模拟任务间隔}pp.Stop(); // 停止进程池return 0; // 程序退出
}
功能说明:
- 初始化随机数种子。
- 创建进程池并启动。
- 派发 10 个任务,每个任务间隔 1 秒。
- 停止进程池,关闭所有子进程的写端,并等待所有子进程退出。
- 程序正常退出。
通过以上设计和实现,我们构建了一个简单的进程池框架。该框架通过创建多个子进程,并通过管道通信机制将任务分配给子进程来执行,从而提高了程序的并发处理能力。同时,通过负载均衡机制,确保任务均匀分配给每个子进程,避免某些子进程过载而其他子进程空闲。
以上代码全部理解,可以更好的将我们前面的知识汇聚到一起。
上面的代码似乎有一部分有一些冗余,可能会有朋友尝试将StopSubProcess函数中的两个接口合并成为一个,即关闭一个子进程就回收一个子进程,似乎能够有效的处理僵尸进程的出现。代码如下:
// void StopSubProcess()// {// for (auto &channel : _channels)// {// channel.Close();// std::cout << "关闭:" << channel.Name() << std::endl;// }// }// void WaitSubProcess()// {// for (auto &channel : _channels)// {// channel.Wait();// std::cout << "回收:" << channel.Name() << std::endl;// }// }//将上面的代码合并成下面一个void ClosseWaitProcess(){for (auto &channel : _channels){channel.Close();std::cout << "关闭:" << channel.Name() << std::endl;channel.Wait();std::cout << "回收:" << channel.Name() << std::endl;}}
但运行我们的程序却发现:
这里发生了阻塞是什么原因呢?这里需要回顾一下知识点:
-
管道的写端引用计数
当创建一个管道时,操作系统会为管道的读端和写端分别维护一个引用计数。这些计数跟踪有多少个文件描述符仍然打开并指向管道的读端或写端。
写端引用计数:表示有多少个进程持有管道的写端文件描述符。
读端引用计数:表示有多少个进程持有管道的读端文件描述符。 -
read() 返回EOF的条件
read() 调用会阻塞,直到管道中有数据可读或管道的写端被所有进程关闭(即写端引用计数为0)。只有当写端引用计数为0时,read() 调用才会返回0,表示读到了文件末尾(EOF)。
子进程继承的文件描述符
当父进程创建子进程时(通过 fork()),子进程会继承父进程的所有文件描述符,包括管道的读端和写端。这意味着子进程不仅持有读端文件描述符,还持有写端文件描述符。
如果父进程关闭了自己的写端文件描述符,但子进程仍然持有写端文件描述符,那么写端的引用计数不会变为0。因此,子进程的 read() 调用不会返回EOF,因为它仍然认为写端是打开的。
所以我们就可以知道这里的阻塞原因了,我们后面创建的子进程是持有父进程上一个写端的,如果我们关闭子进程1管道的写端,但是子进程2的文件描述符表是继承了父进程的,所以这个时候有父进程的写端指向管道1,子进程的描述符表的写端也指向1,即使关闭了父进程的写端,此时管道1写端的引用计数依然不会为0,因此子进程不会返回EOF,这里就发生了阻塞。
这里给两个解决方案:
方案一:倒着关
如果我们倒着关就可以解决这个问题,先关闭父进程中的文件描述符5,这个写端指向的是管道2,因为我们这里没有子进程3,所以就没有其他的子进程的文件描述符中的写端指向它,如果我们关闭父进程文件描述符5,那么管道2的写端引用计数就归0,此时管道2的读端就会读取到EOF,子进程2就不会阻塞,然后退出,子进程2正常退出,那么操作系统会开始清理该进程所使用的资源。内核会遍历该进程的文件描述符表,依次关闭每个文件描述符。这包括所有打开的文件、管道、套接字等。所以子进程2的写端文件描述符也会被关掉,此时,管道1的引用计数就会减少一个。此时在关闭父进程对应管道1的写端文件描述符,那么也就能正常关闭了。
代码如下:
void CloseWaitProcess(){// for (auto &channel : _channels)// {// // channel.Close();// // std::cout << "关闭:" << channel.Name() << std::endl;// // channel.Wait();// // std::cout << "回收:" << channel.Name() << std::endl;// }for (int i = _channels.size() - 1; i >= 0; i--){_channels[i].Close();std::cout << "关闭:" << _channels[i].Name() << std::endl;_channels[i].Wait();std::cout << "回收:" << _channels[i].Name() << std::endl;}}
上述的方案并没有实现真正的一对一。
方案2:当我们创建第二个子进程的时候,我们就已经保存了前面所有打开的写端描述符,因此我们可以再第二次创建子进程之后,每次都先关闭写端文件描述符。
这一点可能有点难以理解,上面代码中我们有一个_channels,里面插入的全是写端,所以子进程继承下来的_channels当中自然也就是上一个父进程中的写端,我们将它全部关闭即可。
代码如下:
class ChannelManger
{
//....
void CloseAll(){for (auto &channel : _channels){channel.Close();}}
//....
void CloseWaitProcess(){for (auto &channel : _channels){channel.Close();std::cout << "关闭:" << channel.Name() << std::endl;channel.Wait();std::cout << "回收:" << channel.Name() << std::endl;}// for (int i = _channels.size() - 1; i >= 0; i--)// {// _channels[i].Close();// std::cout << "关闭:" << _channels[i].Name() << std::endl;// _channels[i].Wait();// std::cout << "回收:" << _channels[i].Name() << std::endl;// }}//....
}
bool Start(){ // 父写子读for (int i = 0; i < _process_num; i++){// 1.创建管道int pipefd[2] = {0};int n = pipe(pipefd);if (n < 0)return false;// 2. 创建子进程pid_t subid = fork();if (subid < 0)return false;else if (subid == 0){// 子进程// 让子进程关闭自己继承下来的上一个父进程的写端_cm.CloseAll();// 3. 关闭不需要的文件描述符close(pipefd[1]);Work(pipefd[0]);close(pipefd[0]);exit(0);}else{// 父进程// 3. 关闭不需要的文件描述符close(pipefd[0]);_cm.Insert(pipefd[1], subid);}}return true;}
5. 命名管道
5.1 命名管道的基本概念
匿名管道只能用于具有共同祖先的进程(具有亲缘关系的进程)之间的通信,通常,一个管道由一个进程创建,然后该进程调用fork,此后父子进程之间就可应用该管道。
如果要实现两个毫不相关进程之间的通信,可以使用命名管道来做到。命名管道就是一种特殊类型的文件,两个进程通过命名管道的文件名打开同一个管道文件,此时这两个进程也就看到了同一份资源,进而就可以进行通信了。
注意:
普通文件是很难做到通信的,即便做到通信也无法解决一些安全问题。
命名管道和匿名管道一样,都是内存文件,只不过命名管道在磁盘有一个简单的映像,但这个映像的大小永远为0,因为命名管道和匿名管道都不会将通信数据刷新到磁盘当中。
使用命令创建命名管道
我们可以使用mkfifo命令创建一个命名管道。
link@VM-8-17-ubuntu:~/linux-os/lesson18$ mkfifo fifo
p代表为管道文件。
使用这个命名管道文件,就能实现两个进程之间的通信了。我们在一个进程(进程A)中用shell脚本每秒向命名管道写入一个字符串,在另一个进程(进程B)当中用cat命令从命名管道当中进行读取。
程序中创建命名管道
在程序中创建命名管道使用mkfifo函数,mkfifo函数的函数原型如下:
int mkfifo(const char *pathname, mode_t mode);
mkfifo函数的第一个参数是pathname,表示要创建的命名管道文件。
- 若pathname以路径的方式给出,则将命名管道文件创建在pathname路径下。
- 若pathname以文件名的方式给出,则将命名管道文件默认创建在当前路径下。(注意当前路径的含义)
mkfifo函数的第二个参数是mode,表示创建命名管道文件的默认权限。
mkfifo函数的返回值。
- 命名管道创建成功,返回0。
- 命名管道创建失败,返回-1。
创建命名管道示例:
使用以下代码即可在当前路径下,创建出一个名为myfifo的命名管道。
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>#define FILE_NAME "myfifo"int main()
{umask(0); //将文件默认掩码设置为0if (mkfifo(FILE_NAME, 0666) < 0){ //使用mkfifo创建命名管道文件perror("mkfifo");return 1;}//create success...return 0;
}
5.2 用命名管道实现serve&client通信
代码的逻辑并不是很难,服务端先运行起来,创建一个管道,以读的方式打开该命名管道,之后服务端就可以从该命名管道当中读取客户端发来的通信信息了。而对于客户端来说,因为服务端运行起来后命名管道文件就已经被创建了,所以客户端只需以写的方式打开该命名管道文件,之后客户端就可以将通信信息写入到命名管道文件当中,进而实现和服务端的通信。
下面的代码都是封装好的,只有服务端才创建和删除管道,客户端只访问管道内容。
comm.hpp
#pragma once#include <iostream>
#include <cstdio>
#include <string>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>#define PATH "." // 定义 FIFO 文件的默认路径在当前目录下
#define FILENAME "fifo" // 定义 FIFO 文件的默认文件名// 宏定义,用于错误处理
#define ERR_EXIT(m) \do \{ \perror(m); \exit(EXIT_FAILURE); \} while (0)// 命名管道类
class NamedFifo
{
public:// 构造函数,初始化 FIFO 文件NamedFifo(const std::string &path, const std::string &name): _path(path), _name(name){_fifoname = _path + "/" + _name; // 构造完整的 FIFO 文件路径umask(0); // 设置文件权限掩码,允许所有权限// 创建 FIFO 文件int n = mkfifo(_fifoname.c_str(), 0666);if (n < 0){ERR_EXIT("mkfifo"); // 如果创建失败,输出错误并退出}else{std::cout << "mkfifo success" << std::endl; // 成功创建输出成功信息}}// 析构函数,删除 FIFO 文件~NamedFifo(){// 删除 FIFO 文件int n = unlink(_fifoname.c_str());if (n == 0){ERR_EXIT("unlink"); // 如果删除失败,输出错误并退出}else{std::cout << "remove fifo failed" << std::endl; // 删除失败输出失败信息}}private:std::string _path; // FIFO 文件的路径std::string _name; // FIFO 文件的文件名std::string _fifoname; // 完整的 FIFO 文件路径
};// 文件操作类
class FileOper
{
public:// 构造函数,初始化文件操作FileOper(const std::string &path, const std::string &name): _path(path), _name(name), _fd(-1){_fifoname = _path + "/" + _name; // 构造完整的 FIFO 文件路径}// 打开 FIFO 文件进行读取void OpenForRead(){// 打开 FIFO 文件,读方式打开_fd = open(_fifoname.c_str(), O_RDONLY);if (_fd < 0){ERR_EXIT("open"); // 如果打开失败,输出错误并退出}std::cout << "open fifo success" << std::endl; // 成功打开输出成功信息}// 打开 FIFO 文件进行写入void OpenForWrite(){// 打开 FIFO 文件,进行写入_fd = open(_fifoname.c_str(), O_WRONLY);if (_fd < 0){ERR_EXIT("open"); // 如果打开失败,输出错误并退出}std::cout << "open fifo success" << std::endl; // 成功打开输出成功信息}// 写入操作void Write(){// 写入操作std::string message;int cnt = 1;pid_t id = getpid();while (true){std::cout << "Please Enter# "; // 提示输入std::getline(std::cin, message); // 获取输入message += (", message number: " + std::to_string(cnt++) + ", [" + std::to_string(id) + "]");write(_fd, message.c_str(), message.size()); // 写入 FIFO}}// 读取操作void Read(){// 读取操作while (true){char buffer[1024];int number = read(_fd, buffer, sizeof(buffer) - 1); // 从 FIFO 读取数据if (number > 0){buffer[number] = 0; // 确保字符串以空字符结尾std::cout << "Client Say# " << buffer << std::endl; // 输出读取到的数据}else if (number == 0){std::cout << "client quit! me too!" << std::endl; // 读取到文件结束符,退出break;}else{std::cerr << "read error" << std::endl; // 读取失败,输出错误信息break;}}}// 关闭文件描述符void Close(){if (_fd > 0)close(_fd); // 关闭文件描述符}// 析构函数~FileOper(){}private:std::string _path; // FIFO 文件的路径std::string _name; // FIFO 文件的文件名std::string _fifoname; // 完整的 FIFO 文件路径int _fd; // 文件描述符
};
sereve.cc
#include "comm.hpp"int main()
{// 创建管道文件NamedFifo fifo(".", FILENAME);// 文件操作了FileOper readerfile(PATH, FILENAME);readerfile.OpenForRead();readerfile.Read();readerfile.Close();return 0;
}
client.cc
#include "comm.hpp"int main()
{FileOper writerfile(PATH, FILENAME);writerfile.OpenForWrite();writerfile.Write();writerfile.Close();return 0;
}
代码编写完毕后,先将服务端进程运行起来,之后我们就能在客户端看到这个已经被创建的命名管道文件。
接着再将客户端也运行起来,此时我们从客户端写入的信息被客户端写入到命名管道当中,服务端再从命名管道当中将信息读取出来打印在服务端的显示器上,该现象说明服务端是能够通过命名管道获取到客户端发来的信息的,换句话说,此时这两个进程之间是能够通信的。
当客户端和服务端运行起来时,我们还可以通过ps命令查看这两个进程的信息,可以发现这两个进程确实是两个毫不相关的进程,因为它们的PID和PPID都不相同。也就证明了,命名管道是可以实现两个毫不相关进程之间的通信的。
当客户端退出后,服务端将管道当中的数据读完后就再也读不到数据了,那么此时服务端也就会去执行它的其他代码了(在当前代码中是直接退出了)。
当服务端退出后,客户端写入管道的数据就不会被读取了,也就没有意义了,那么当客户端下一次再向管道写入数据时,就会收到操作系统发来的13号信号(SIGPIPE),此时客户端就被操作系统强制杀掉了。
5.3 命名管道和匿名管道的区别
匿名管道由pipe函数创建并打开。
命名管道由mkfifo函数创建,由open函数打开。
FIFO(命名管道)与pipe(匿名管道)之间唯一的区别在于它们创建与打开的方式不同,一旦这些工作完成之后,它们具有相同的语义。
注: ”|"是匿名管道
6. system V进程间通信
管道通信本质是基于文件的,也就是说操作系统并没有为此做过多的设计工作,而system V IPC是操作系统特地设计的一种通信方式。但是不管怎么样,它们的本质都是一样的,都是在想尽办法让不同的进程看到同一份由操作系统提供的资源。
system V IPC提供的通信方式有以下三种:
system V共享内存
system V消息队列
system V信号量
其中,system V共享内存和system V消息队列是以传送数据为目的的,而system V信号量是为了保证进程间的同步与互斥而设计的,虽然system V信号量和通信好像没有直接关系,但属于通信范畴。
说明一下:
system V共享内存和system V消息队列就类似于手机,用于沟通信息;system V信号量就类似于下棋比赛时用的棋钟,用于保证两个棋手之间的同步与互斥。
6.1 system V共享内存
共享内存的基本原理
共享内存让不同进程看到同一份资源的方式就是,在物理内存当中申请一块内存空间,然后将这块内存空间分别与各个进程各自的页表之间建立映射,再在虚拟地址空间当中开辟空间并将虚拟地址填充到各自页表的对应位置,使得虚拟地址和物理地址之间建立起对应关系,至此这些进程便看到了同一份物理内存,这块物理内存就叫做共享内存。
注意:
这里所说的开辟物理空间、建立映射等操作都是调用系统接口完成的,也就是说这些动作都由操作系统来完成。
共享内存数据结构
在系统当中可能会有大量的进程在进行通信,因此系统当中就可能存在大量的共享内存,那么操作系统必然要对其进行管理,所以共享内存除了在内存当中真正开辟空间之外,系统一定还要为共享内存维护相关的内核数据结构。
共享内存数据结构
共享内存的数据结构如下:
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;
};
共享内存的建立与释放
共享内存的建立大致包括以下两个过程:
在物理内存当中申请共享内存空间。
将申请到的共享内存挂接到地址空间,即建立映射关系。
共享内存的释放大致包括以下两个过程:
将共享内存与地址空间去关联,即取消映射关系。
释放共享内存空间,即将物理内存归还给系统。
共享内存的创建
创建共享内存我们需要用shmget函数,shmget函数的函数原型如下:
这些都是可以用man手册查的
int shmget(key_t key, size_t size, int shmflg);
shmget函数的参数说明:
- 第一个参数key,表示待创建共享内存在系统当中的唯一标识。
- 第二个参数size,表示待创建共享内存的大小。
- 第三个参数shmflg,表示创建共享内存的方式。
shmget函数的返回值说明:
- shmget调用成功,返回一个有效的共享内存标识符(用户层标识符)。
- shmget调用失败,返回-1。
注意: 我们把具有标定某种资源能力的东西叫做句柄,而这里shmget函数的返回值实际上就是共享内存的句柄,这个句柄可以在用户层标识共享内存,当共享内存被创建后,我们在后续使用共享内存的相关接口时,都是需要通过这个句柄对指定共享内存进行各种操作。
传入shmget函数的第一个参数key,需要我们使用ftok函数进行获取
ftok函数的函数原型如下:
key_t ftok(const char *pathname, int proj_id);
ftok函数的作用就是,将一个已存在的路径名pathname和一个整数标识符proj_id转换成一个key值,称为IPC键值,在使用shmget函数获取共享内存时,这个key值会被填充进维护共享内存的数据结构当中。需要注意的是,pathname所指定的文件必须存在且可存取。
注意:
使用ftok函数生成key值可能会产生冲突,此时可以对传入ftok函数的参数进行修改。
需要进行通信的各个进程,在使用ftok函数获取key值时,都需要采用同样的路径名和和整数标识符,进而生成同一种key值,然后才能找到同一个共享资源。
组合方式 | 作用 |
---|---|
IPC_CREAT | 如果内核中不存在键值与key相等的共享内存,则新建一个共享内存并返回该共享内存的句柄;如果存在这样的共享内存,则直接返回该共享内存的句柄 |
IPC_CREAT | IPC_EXCL 如果内核中不存在键值与key相等的共享内存,则新建一个共享内存并返回该共享内存的句柄;如果存在这样的共享内存,则出错返回 |
换句话说:
使用组合IPC_CREAT,一定会获得一个共享内存的句柄,但无法确认该共享内存是否是新建的共享内存。
使用组合IPC_CREAT | IPC_EXCL,只有shmget函数调用成功时才会获得共享内存的句柄,并且该共享内存一定是新建的共享内存。
#include <stdio.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <unistd.h>#define PATHNAME "." // 当前目录#define PROJ_ID 0x6666 // 整数标识符
#define SIZE 4096 // 共享内存的大小int main()
{key_t key = ftok(PATHNAME, PROJ_ID); // 获取key值if (key < 0){perror("ftok");return 1;}int shmid = shmget(key, SIZE, IPC_CREAT | IPC_EXCL); // 创建新的共享内存if (shmid < 0){perror("shmget");return 2;}printf("key: %x\n", key); // 打印key值printf("shm: %d\n", shmid); // 打印句柄return 0;
}
该代码编写完毕运行后,我们可以看到输出的key值和句柄值。
在Linux当中,我们可以使用ipcs
命令查看有关进程间通信设施的信息。
单独使用ipcs命令时,会默认列出消息队列、共享内存以及信号量相关的信息,若只想查看它们之间某一个的相关信息,可以选择携带以下选项:
-q:列出消息队列相关信息。
-m:列出共享内存相关信息。
-s:列出信号量相关信息。
标题 | 含义 |
---|---|
key | 系统区别各个共享内存的唯一标识 |
shmid | 共享内存的用户层id(句柄) |
owner | 共享内存的拥有者 |
perms | 共享内存的权限 |
bytes | 共享内存的大小 |
nattch | 关联共享内存的进程数 |
status | 共享内存的状态 |
注意: key是在内核层面上保证共享内存唯一性的方式,而shmid是在用户层面上保证共享内存的唯一性,key和shmid之间的关系类似于fd和FILE*之间的的关系。
共享内存的释放
通过上面创建共享内存的实验可以发现,当我们的进程运行完毕后,申请的共享内存依旧存在,并没有被操作系统释放。实际上,管道是生命周期是随进程的,而共享内存的生命周期是随内核的
,也就是说进程虽然已经退出,但是曾经创建的共享内存不会随着进程的退出而释放。
这说明,如果进程不主动删除创建的共享内存,那么共享内存就会一直存在,直到关机重启(system V IPC都是如此),同时也说明了IPC资源是由内核提供并维护的。
此时我们若是要将创建的共享内存释放,有两个方法,一就是使用命令释放共享内存,二就是在进程通信完毕后调用释放共享内存的函数进行释放。
使用命令释放共享内存资源
我们可以使用ipcrm -m shmid
命令释放指定id的共享内存资源。
共享内存的关联
将共享内存连接到进程地址空间我们需要用shmat函数,shmat函数的函数原型如下:
void *shmat(int shmid, const void *shmaddr, int shmflg);
hmat函数的参数说明:
- 第一个参数shmid,表示待关联共享内存的用户级标识符。
- 第二个参数shmaddr,指定共享内存映射到进程地址空间的某一地址,通常设置为NULL,表示让内核自己决定一个合适的地址位置。
- 第三个参数shmflg,表示关联共享内存时设置的某些属性。
shmat函数的返回值说明:
- shmat调用成功,返回共享内存映射到进程地址空间中的起始地址。
- shmat调用失败,返回(void*)-1。
其中,作为shmat函数的第三个参数传入的常用的选项有以下三个:
选项 | 作用 |
---|---|
SHM_RDONLY | 关联共享内存后只进行读取操作 |
SHM_RND | 若shmaddr不为NULL,则关联地址自动向下调整为SHMLBA的整数倍。公式:shmaddr-(shmaddr%SHMLBA) |
0 | 默认为读写权限 |
#include <stdio.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <unistd.h>#define PATHNAME "." //路径名#define PROJ_ID 0x6666 //整数标识符
#define SIZE 4096 //共享内存的大小int main()
{key_t key = ftok(PATHNAME, PROJ_ID); //获取key值if (key < 0){perror("ftok");return 1;}int shmid = shmget(key, SIZE, IPC_CREAT | IPC_EXCL | 0666); // 创建权限为0666的共享内存if (shmid < 0){perror("shmget");return 2;}printf("key: %x\n", key); //打印key值printf("shm: %d\n", shmid); //打印句柄printf("attach begin!\n");sleep(2);char* mem = (char*)shmat(shmid, NULL, 0); //关联共享内存if (mem == (void*)-1){perror("shmat");return 1;}printf("attach end!\n");sleep(2);shmctl(shmid, IPC_RMID, NULL); //释放共享内存return 0;
}
共享内存的去关联
取消共享内存与进程地址空间之间的关联我们需要用shmdt函数,shmdt函数的函数原型如下:
int shmdt(const void *shmaddr);
shmdt函数的参数说明:
- 待去关联共享内存的起始地址,即调用shmat函数时得到的起始地址。
shmdt函数的返回值说明:
- shmdt调用成功,返回0。
-shmdt调用失败,返回-1。
现在我们就能够取消共享内存与进程之间的关联了。
#include <stdio.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <unistd.h>#define PATHNAME "." //路径名#define PROJ_ID 0x6666 //整数标识符
#define SIZE 4096 //共享内存的大小int main()
{key_t key = ftok(PATHNAME, PROJ_ID); //获取key值if (key < 0){perror("ftok");return 1;}int shmid = shmget(key, SIZE, IPC_CREAT | IPC_EXCL | 0666); // 创建权限为0666的共享内存if (shmid < 0){perror("shmget");return 2;}printf("key: %x\n", key); //打印key值printf("shm: %d\n", shmid); //打印句柄printf("attach begin!\n");sleep(2);char* mem = (char*)shmat(shmid, NULL, 0); //关联共享内存if (mem == (void*)-1){perror("shmat");return 1;}printf("attach end!\n");sleep(2);shmdt(mem); // 共享内存去关联printf("detach end!\n");sleep(2);shmctl(shmid, IPC_RMID, NULL); //释放共享内存return 0;
}
注意: 将共享内存段与当前进程脱离不等于删除共享内存,只是取消了当前进程与该共享内存之间的联系。
用共享内存实现serve&client通信
通过以下代码可以使我们更好的理解共享内存。为了客户端更好的调用接口,在这里对接口都做好了封装,可以直接调用。
管理共享内存代码:
#pragma once// 引入必要的头文件
#include <iostream>
#include <cstdio>
#include <string>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <unistd.h>
#include <sys/stat.h>
#include "Comm.hpp" // 自定义通信相关头文件// 默认共享内存标识符和大小
const int gdefaultid = -1; // 默认无效共享内存ID
const int gsize = 4096; // 默认共享内存大小(4KB)
const std::string pathname = "."; // 生成System V IPC key的路径
const int projid = 0x66; // 生成System V IPC key的项目ID
const int gmode = 0666; // 共享内存权限(rw-rw-rw-)// 用户类型标识
#define CREATER "creater" // 创建者(负责创建和销毁共享内存)
#define USER "user" // 普通用户(仅连接现有共享内存)// 错误处理宏:打印错误信息并退出程序
#define ERR_EXIT(m) \do \{ \perror(m); \exit(EXIT_FAILURE); \} while (0)/*** @class Shm* @brief 封装System V共享内存操作的C++类* * 提供共享内存的创建、连接、销毁等功能,通过用户类型区分创建者/使用者*/
class Shm
{
private:/*** @brief 辅助函数:创建/获取共享内存* @param flg shmget的标志参数(如IPC_CREAT等)*/void CreateHelper(int flg){printf("生成System V IPC key: 0x%x\n", _key);_shmid = shmget(_key, _size, flg); // 调用shmget获取共享内存IDif (_shmid < 0){ERR_EXIT("shmget失败\n"); // 错误处理}printf("获取共享内存ID: %d\n", _shmid);}/*** @brief 创建全新的共享内存(需确保不存在)*/void Create(){// 使用排他创建模式(IPC_CREAT | IPC_EXCL)CreateHelper(IPC_CREAT | IPC_EXCL | gmode);}/*** @brief 将共享内存附加到当前进程地址空间*/void Attach(){_start_mem = shmat(_shmid, nullptr, 0); // 连接共享内存if ((long long)_start_mem < 0) // 检查返回值(Linux返回(void*)-1){ERR_EXIT("shmat失败\n");}printf("共享内存连接成功\n");}/*** @brief 获取已存在的共享内存*/void Get(){CreateHelper(IPC_CREAT); // 仅使用创建标志(不排他)}/*** @brief 销毁共享内存(仅由创建者调用)*/void Destory(){if (_shmid == gdefaultid) return; // 避免重复销毁// 设置IPC_RMID标志,标记共享内存为待删除int n = shmctl(_shmid, IPC_RMID, nullptr);if (n >= 0){printf("成功删除共享内存: %d\n", _shmid);}else{ERR_EXIT("shmctl失败\n");}}public:/*** @brief 构造函数* @param pathname 生成key的路径* @param projid 生成key的项目ID* @param usertype 用户类型(CREATER/USER)*/Shm(const std::string &pathname, int projid, const std::string &usertype): _shmid(gdefaultid), // 初始化共享内存ID为无效值_size(gsize), // 设置默认大小_start_mem(nullptr), // 初始化内存地址指针_usertype(usertype) // 记录用户类型{// 生成System V IPC key_key = ftok(pathname.c_str(), projid);if (_key < 0){ERR_EXIT("ftok失败\n");}// 根据用户类型选择操作if (_usertype == CREATER)Create(); // 创建者:创建新共享内存else if (_usertype == USER)Get(); // 使用者:获取现有共享内存Attach(); // 无论创建者还是使用者都需要连接内存}/*** @brief 获取共享内存的虚拟地址* @return 共享内存起始地址*/void *VirtualAddr(){printf("返回虚拟地址: %p\n", _start_mem);return _start_mem;}/*** @brief 获取共享内存大小* @return 共享内存字节数*/int Size(){return _size;}/*** @brief 析构函数* @note 仅创建者会触发共享内存销毁*/~Shm(){if (_usertype == CREATER){Destory(); // 创建者负责销毁共享内存}}private:int _shmid; // 共享内存标识符key_t _key; // System V IPC keyint _size; // 共享内存大小(字节)void *_start_mem; // 共享内存起始地址指针std::string _usertype; // 用户类型标识(CREATER/USER)
};
服务端代码如下:
#include "Shm.hpp"
#include "Fifo.hpp"int main()
{Shm shm(pathname, projid, CREATER);char *men = (char *)shm.VirtualAddr();while (true){printf("%s\n", men);sleep(1);}return 0;
}
客户端代码:
#include "Shm.hpp"
int main()
{Shm shm(pathname, projid, USER);char *men = (char *)shm.VirtualAddr();for (char c = 'A'; c <= 'Z'; c++){men[c - 'A'] = c;sleep(1);}return 0;
}
运行代码可以看到:
启动客户端之后,客户端不断向共享内存写入数据,服务端才可以读取并输出。
共享内存与管道进行对比
当共享内存创建好后就不再需要调用系统接口进行通信了,而管道创建好后仍需要read、write等系统接口进行通信。实际上,共享内存是所有进程间通信方式中最快的一种通信方式。
我们先来看看管道通信:
从这张图可以看出,使用管道通信的方式,将一个文件从一个进程传输到另一个进程需要进行四次拷贝操作:
- 服务端将信息从输入文件复制到服务端的临时缓冲区中。
- 将服务端临时缓冲区的信息复制到管道中。
- 客户端将信息从管道复制到客户端的缓冲区中。
- 将客户端临时缓冲区的信息复制到输出文件中。
我们再来看看共享内存通信:
从这张图可以看出,使用共享内存进行通信,将一个文件从一个进程传输到另一个进程只需要进行两次拷贝操作:
从输入文件到共享内存。
从共享内存到输出文件。
所以共享内存是所有进程间通信方式中最快的一种通信方式,因为该通信方式需要进行的拷贝次数最少。
但是共享内存也是有缺点的,我们知道管道是自带同步与互斥机制的,但是共享内存并没有提供任何的保护机制,包括同步与互斥。
加入管道
其实上面的代码我们可以将命名管道加入进去弥补这一缺点,因为命名管道是同步通信的。
以下代码是修改好的,将管道加入进去,附加全部代码:
Fifo.hpp
#pragma once#include <iostream>
#include <cstdio>
#include <string>
#include <iostream>
#include <string>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include "Comm.hpp"#define PATH "."
#define FILENAME "fifo"class NamedFifo
{
public:NamedFifo(const std::string &path, const std::string &name): _path(path), _name(name){_fifoname = _path + "/" + _name;umask(0);// 新建管道int n = mkfifo(_fifoname.c_str(), 0666);if (n < 0){ERR_EXIT("mkfifo");}else{std::cout << "mkfifo success" << std::endl;}}~NamedFifo(){// 删除管道文件int n = unlink(_fifoname.c_str());if (n == 0){// ERR_EXIT("unlink"); // bug在这里,先析构fifo,导致shm的析构没有被调用}else{std::cout << "remove fifo failed" << std::endl;}}private:std::string _path;std::string _name;std::string _fifoname;
};class FileOper
{
public:FileOper(const std::string &path, const std::string &name): _path(path), _name(name), _fd(-1){_fifoname = _path + "/" + _name;}void OpenForRead(){// 打开, write 方没有执行open的时候,read方,就要在open内部进行阻塞// 直到有人把管道文件打开了,open才会返回!_fd = open(_fifoname.c_str(), O_RDONLY);if (_fd < 0){ERR_EXIT("open");}std::cout << "open fifo success" << std::endl;}void OpenForWrite(){// write_fd = open(_fifoname.c_str(), O_WRONLY);if (_fd < 0){ERR_EXIT("open");}std::cout << "open fifo success" << std::endl;}void Wakeup(){// 写入操作char c = 'c';int n = write(_fd, &c, 1);printf("尝试唤醒: %d\n", n);}bool Wait(){char c;int number = read(_fd, &c, 1);if (number > 0){printf("醒来: %d\n", number);return true;}return false;}void Close(){if (_fd > 0)close(_fd);}~FileOper(){}private:std::string _path;std::string _name;std::string _fifoname;int _fd;
};
Shm.hpp
#pragma once// 引入必要的头文件
#include <iostream>
#include <cstdio>
#include <string>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <unistd.h>
#include "Comm.hpp" // 自定义通信相关头文件// 默认共享内存标识符和大小
const int gdefaultid = -1; // 默认无效共享内存ID
const int gsize = 4096; // 默认共享内存大小(4KB)
const std::string pathname = "."; // 生成System V IPC key的路径
const int projid = 0x66; // 生成System V IPC key的项目ID
const int gmode = 0666; // 共享内存权限(rw-rw-rw-)// 用户类型标识
#define CREATER "creater" // 创建者(负责创建和销毁共享内存)
#define USER "user" // 普通用户(仅连接现有共享内存)/*** @class Shm* @brief 封装System V共享内存操作的C++类** 提供共享内存的创建、连接、状态查询、销毁等完整生命周期管理功能* 通过用户类型区分创建者/使用者,实现自动化资源管理(RAII)*/
class Shm
{
private:/*** @brief 辅助函数:创建/获取共享内存* @param flg shmget的标志参数(如IPC_CREAT等)** 注意:共享内存的生命周期由内核管理,需显式删除*/void CreateHelper(int flg){printf("生成System V IPC key: 0x%x\n", _key);_shmid = shmget(_key, _size, flg); // 调用shmget获取共享内存IDif (_shmid < 0){ERR_EXIT("shmget失败"); // 错误处理}printf("获取共享内存ID: %d\n", _shmid);}/*** @brief 创建全新的共享内存(需确保不存在)*/void Create(){// 使用排他创建模式(IPC_CREAT | IPC_EXCL)CreateHelper(IPC_CREAT | IPC_EXCL | gmode);}/*** @brief 将共享内存附加到当前进程地址空间*/void Attach(){_start_mem = shmat(_shmid, nullptr, 0); // 连接共享内存if ((long long)_start_mem < 0) // 检查返回值(Linux返回(void*)-1){ERR_EXIT("shmat失败");}printf("共享内存连接成功\n");}/*** @brief 断开共享内存连接*/void Detach(){int n = shmdt(_start_mem); // 断开共享内存连接if (n == 0){printf("共享内存断开成功\n");}}/*** @brief 获取已存在的共享内存*/void Get(){CreateHelper(IPC_CREAT); // 仅使用创建标志(不排他)}/*** @brief 销毁共享内存资源** 1. 先断开当前进程的连接* 2. 如果是创建者则删除共享内存*/void Destroy(){Detach(); // 无论用户类型都需要断开连接if (_usertype == CREATER){// 设置IPC_RMID标志,标记共享内存为待删除int n = shmctl(_shmid, IPC_RMID, nullptr);if (n >= 0){printf("成功删除共享内存: %d\n", _shmid);}else{ERR_EXIT("shmctl失败");}}}public:/*** @brief 构造函数* @param pathname 生成key的路径* @param projid 生成key的项目ID* @param usertype 用户类型(CREATER/USER)** 构造流程:* 1. 生成IPC key* 2. 根据用户类型创建/获取共享内存* 3. 自动连接共享内存*/Shm(const std::string &pathname, int projid, const std::string &usertype): _shmid(gdefaultid), // 初始化共享内存ID为无效值_size(gsize), // 设置默认大小_start_mem(nullptr), // 初始化内存地址指针_usertype(usertype) // 记录用户类型{// 生成System V IPC key_key = ftok(pathname.c_str(), projid);if (_key < 0){ERR_EXIT("ftok失败");}// 根据用户类型选择操作if (_usertype == CREATER)Create(); // 创建者:创建新共享内存else if (_usertype == USER)Get(); // 使用者:获取现有共享内存Attach(); // 无论创建者还是使用者都需要连接内存}/*** @brief 获取共享内存的虚拟地址* @return 共享内存起始地址*/void *VirtualAddr(){printf("返回虚拟地址: %p\n", _start_mem);return _start_mem;}/*** @brief 获取共享内存大小* @return 共享内存字节数*/int Size(){return _size;}/*** @brief 打印共享内存属性** 显示信息包括:* - 共享内存段大小* - 关联的IPC key值*/void Attr(){struct shmid_ds ds;// 获取共享内存状态信息int n = shmctl(_shmid, IPC_STAT, &ds);printf("共享内存段大小: %ld 字节\n", ds.shm_segsz);printf("关联IPC key: 0x%x\n", ds.shm_perm.__key);}/*** @brief 析构函数** 自动执行资源清理:* - 创建者:删除共享内存* - 所有用户:断开内存连接*/~Shm(){std::cout << "清理资源,用户类型: " << _usertype << std::endl;Destroy(); // 统一调用销毁方法处理清理逻辑}private:int _shmid; // 共享内存标识符key_t _key; // System V IPC keyint _size; // 共享内存大小(字节)void *_start_mem; // 共享内存起始地址指针std::string _usertype; // 用户类型标识(CREATER/USER)
};
Comm.hpp
#pragma once
#include <cstdio>
#include <cstdlib>// 错误处理宏:打印错误信息并退出程序
#define ERR_EXIT(m) \do \{ \perror(m); \exit(EXIT_FAILURE); \} while (0)
client.cc
#include "Shm.hpp"
#include "Fifo.hpp"int main()
{FileOper writerfile(PATH, FILENAME);writerfile.OpenForWrite();Shm shm(pathname, projid, USER);char *mem = (char *)shm.VirtualAddr();// 我们读写共享内存,有没有使用系统调用??没有!!int index = 0;for (char c = 'A'; c <= 'F'; c++, index += 2){// 才是向共享内存写入sleep(1);mem[index] = c;mem[index + 1] = c;sleep(1);mem[index + 2] = 0;writerfile.Wakeup();}writerfile.Close();return 0;
}
server.cc
#include "Shm.hpp"
#include "Fifo.hpp"int main()
{Shm shm(pathname, projid, CREATER);// sleep(5);shm.Attr();NamedFifo fifo(PATH, FILENAME);// 文件操作了FileOper readerfile(PATH, FILENAME);readerfile.OpenForRead();char *mem = (char *)shm.VirtualAddr();// 我们读写共享内存,有没有使用系统调用??也没有!!while (true){if (readerfile.Wait()){printf("%s\n", mem);}elsebreak;}readerfile.Close();std::cout << "server end normal!" << std::endl; // server段的析构函数没有被成功调用!return 0;
}
Makefile
.PHONY:all
all:client server
client:client.ccg++ -o $@ $^ -std=c++11
server:server.ccg++ -o $@ $^ -std=c++11.PHONY:clean
clean:rm -f client server
6.2 System V消息队列
消息队列的基本原理
消息队列实际上就是在系统当中创建了一个队列,队列当中的每个成员都是一个数据块,这些数据块都由类型和信息两部分构成,两个互相通信的进程通过某种方式看到同一个消息队列,这两个进程向对方发数据时,都在消息队列的队尾添加数据块,这两个进程获取数据块时,都在消息队列的队头取数据块。
其中消息队列当中的某一个数据块是由谁发送给谁的,取决于数据块的类型。
ipcs -q # 查看消息队列属性信息
ipcrm -q msgid # 删除消息队列
[root@ubuntu:Msg]# ipcs -q------ Message Queues --------
key msqid owner perms used-bytes messages关键字(key):用于在创建消息队列时指定一个唯一的标识符。
这个关键字可以是任何整数值,但通常使用ftok()函数生成,以确保其唯一性。消息队列ID(msqid):每个消息队列都有一个唯一的标识符(ID),
用于区分系统中的其他消息队列。所有者(owner):显示创建消息队列的用户ID(UID)和组ID(GID),
表示该消息队列的拥有者。权限(perms):表示消息队列的访问权限,类似于文件系统的权限设置。
这些权限决定了哪些用户或组可以访问(读、写或控制)该消息队列。已用字节数(used-bytes):表示当前消息队列中已经占用的字节总数。
这有助于了解消息队列的使用情况。消息数量(messages):表示消息队列中当前存储的消息总数。
这是衡量消息队列负载的重要指标。
总结一下:
- 消息队列提供了一个从一个进程向另一个进程发送数据块的方法。
- 每个数据块都被认为是有一个类型的,接收者进程接收的数据块可以有不同的类型值。
- 和共享内存一样,消息队列的资源也必须自行删除,否则不会自动清除,因为system V IPC资源的生命周期是随内核的。
消息队列数据结构
当然,系统当中也可能会存在大量的消息队列,系统一定也要为消息队列维护相关的内核数据结构。
消息队列的数据结构如下:
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;
};
消息队列的创建
创建消息队列我们需要用msgget函数,msgget函数的函数原型如下:
int msgget(key_t key, int msgflg);
说明一下:
- 创建消息队列也需要使用ftok函数生成一个key值,这个key值作为msgget函数的第一个参数。
- msgget函数的第二个参数,与创建共享内存时使用的shmget函数的第三个参数相同。
- 消息队列创建成功时,msgget函数返回的一个有效的消息队列标识符(用户层标识符)。
消息队列的释放
释放消息队列我们需要用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的大小可以自己指定。
从消息队列获取数据
从消息队列获取数据我们需要用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。
6.3 System V信号量
System V信号量是一种用于进程间通信(IPC)和同步的机制,它主要用于控制对共享资源的访问,解决竞争条件和死锁等并发编程中的问题。以下是对System V信号量的详细讲解:
基本概念
-
信号量集:System V信号量以信号量集的形式存在,一个信号量集可以包含多个信号量,每个信号量都可以独立地进行操作。
-
信号量类型:
1.二元信号量:其值只能为0或1,类似于互斥锁,用于控制单个资源的访问。
2.计数信号量:其值在0和某个限制值之间,表示可用资源的数量。
相关概念:
- 多个执行流(进程)能看的一份资源:共享资源
- 被保护起来的资源:临界资源;临界资源的两个特性:同步和互斥;用互斥的方式保护共享资源:临界资源
- 互斥:任何时候只能有一个进程在访问共享资源
- 资源 — 要被程序员访问 — 资源被访问,朴素的认识就是通过代码访问,代码 = 访问共享资源的代码(临界区) + 不访问共享资源的代码(非临界区)
- 所谓对共享资源进行保护,本质是对访问共享资源的代码进行保护
对信号量的理论理解:
信号量(信号灯) — 保护临界资源(code)
信号量本质是一个计数器
使用生活中看电影来理解信号量:
普通用户去电影院看电影
1、先买票
2、让买票人(执行流)和座位(资源)一一对应 (程序员编码实现),不关心
看电影买票的本质:是对资源的 预定 机制!
最担心超过资源个数的买票!
int count = 25; // 总共25张票
if(count > 0) count--;
else wait;
// 购票
- 电影院:共享资源(临界资源)
- 买票:申请信号量
- 票数:信号量初始值
申请信号量的本质是对公共资源的一种预定机制!!
超级VIP去电影院看电影
使用信号量的通信过程:
1、申请信号量
2、访问共享内存
3、释放信号量
信号量是一个计数器,能不能使用全局变量 gcount 标记信号量?
不能!!!
1、因为全局变量不能让所有进程看到。因此有了信号量,和共享内存,消息队列一样,必须先让不同的进程看到同一块资源(计数器)。意味着信号量也是一个公共资源,作用是保护临界资源的安全,前提自己得是安全的!!!
2、gcount ++/–不是原子(要么执行要么不执行)的。
主要操作
System V信号量支持两种基本操作:P(也称为wait或down)操作和V(也称为signal或up)操作。
P操作:
用于获取(或等待)一个信号量。
如果信号量的值大于0,则将其减一,并允许进程继续执行。
如果信号量的值已经是0,则阻塞当前进程,直到信号量的值变为非0(即有其他进程释放了信号量),然后再将其减一。
V操作:
用于释放(或增加)一个信号量。
将信号量的值加一。
如果有其他进程因为等待该信号量而被阻塞,则唤醒其中一个被阻塞的进程。
信号量数据结构
在系统当中也为信号量维护了相关的内核数据结构。
信号量的数据结构如下:
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;
};
信号量相关函数
信号量集的创建
创建信号量集我们需要用semget函数,semget函数的函数原型如下:
int semget(key_t key, int nsems, int semflg);
说明一下:
- 创建信号量集也需要使用ftok函数生成一个key值,这个key值作为semget函数的第一个参数。
- semget函数的第二个参数nsems,表示创建信号量的个数。
- semget函数的第三个参数,与创建共享内存时使用的shmget函数的第三个参数相同。
- 信号量集创建成功时,semget函数返回的一个有效的信号量集标识符(用户层标识符)。
信号量集的删除
删除信号量集我们需要用semctl函数,semctl函数的函数原型如下:
int semctl(int semid, int semnum, int cmd, ...);
信号量集的操作
对信号量集进行操作我们需要用semop函数,semop函数的函数原型如下:
int semop(int semid, struct sembuf *sops, unsigned nsops);
6.4 system V IPC联系
通过对system V系列进程间通信的学习,可以发现共享内存、消息队列以及信号量,虽然它们内部的属性差别很大,但是维护它们的数据结构的第一个成员确实一样的,都是ipc_perm类型的成员变量。
这样设计的好处就是,在操作系统内可以定义一个struct ipc_perm类型的数组,此时每当我们申请一个IPC资源,就在该数组当中开辟一个这样的结构。
也就是说,在内核当中只需要将所有的IPC资源的ipc_perm成员组织成数组的样子,然后用切片的方式获取到该IPC资源的起始地址,然后就可以访问该IPC资源的每一个成员了。
本篇博客到此结束,欢迎各位评论区留言~