本章记录笔者在多进程编程中的实验心得与感受。
1、多进程的相关概念:
1>进程是程序一次执行的过程,有一定的生命周期,分为:创建态,就绪态,执行态,挂起态和死亡态。
2>进程是计算机资源分配的基本单位,系统会给每个进程分配0--4G的虚拟内存,其中0--3G是用户空 间,3--4G是内核空间
3>其中多个进程中0--3G的用户空间是相互独立的,但是,3--4G的内核空间是相互共享的 用户空间细分为:栈区、堆区、静态区
4>进程的调度机制:时间片轮询上下文切换机制

>>>>请注意!!!如果你足够细心,你会发现这和我们在计算机组成原理里面讲过的并发行为很相似,单核多次轮询是多进程编程的精髓所在,区别于多线程编程,那个就是我们常讲的并行了。
4> 并发和并行的区别:
并发:针对于单核CPU系统在处理多个任务时,使用相关的调度机制,实现多个任务进行细化时间 片轮询时,在宏观上感觉是多个任务同时执行的操作,同一时刻,只有一个任务在被CPU处理
并行:是针对于多核CPU而言,处理多个任务时,同一时间,每个CPU处理的任务之间是并行的, 实现的是真正意义上多个任务同时执行
2、进程的内存管理

1> 物理内存:内存条上(硬件上)真正存在的存储空间
2> 虚拟内存:程序运行后,通过内存映射单元,将物理内存映射出4G的虚拟内存,共进程使用
进程和程序的区别:
进程:进程是动态的,程序一次执行的过程,有生命周期,会被内核分配1-3G用户空间,进程在内存实时上存着。
程序:程序是静态的,没有生命周期,是存在磁盘存储设备上的二进制文件
eg:hello.cpp ->g++ hello.cpp -> a.out
进程的种类
进程一共有三种:交互进程、批处理进程、守护进程
1> 交互进程:它是由shell控制,可以直接和用户进行交互的,例如文本编辑器
2> 批处理进程:内部维护了一个队列,被放入该队列中的进程,会被统一处理。例如 g++编译器的一 步到位的编译
3> 守护进程:脱离了终端而存在,随着系统的启动而运行,随着系统的退出而停止。例如:操作系统 的服务进程
进程PID的概念
1> PID(Process ID):进程号,进程号是一个大于等于0的整数值,是进程的唯一标识,不可能重 复。
2> PPID(Parent Process ID):父进程号,系统中允许的每个进程,都是拷贝父进程资源得到的
3> 在linux系统中的 /proc目录下的数字命名的目录其实都是一个进程
特殊的进程
1> 0号进程(idel):他是由linux操作系统启动后运行的第一个进程,也叫空闲进程,当没有其他进 程运行时,会运行该进程。他也是1号进程和2号进程的父进程
2> 1号进程(init):由0号进程创建,处理硬件初始化和收养孤儿进程的操作
3> 2号进程(kthreadd):也称调度进程,由0号进程创建,主要完成任务调度问题 、
4> 孤儿进程:当前进程还正在运行,其父进程已经退出了。 说明:每个进程退出后,其分配的系统资源应该由其父进程进行回收,否则会造成资源的浪费
5> 僵尸进程:当前进程已经退出了,但是其父进程没有为其回收资源
好了,下面笔者记录一下关于孤儿进程和僵尸进程的处理办法:
首先是孤儿进程,因为linux特有的孤儿进程处理机制,即1号进程收养。
系统处理:
孤儿进程会被 init 进程 (PID=1) 或 systemd 自动收养
孤儿进程继续正常运行,不受影响
当孤儿进程结束时,init 进程会负责回收其资源
影响:几乎无负面影响,系统会自动处理。
僵尸进程:子进程先退出,但父进程没有回收其退出状态,导致子进程的进程描述符仍驻留在内核中,僵尸进程已经死亡,不占用 CPU、不占用内存,只占用一个进程表项(内核中的一个小结构体)。所以这里我们不能用常用的kill -9<进程名>来处理这个问题,此时如果实使用 ps aux | a.out可以看到未被回收的子进程有Z的标识。
注意:单个僵尸进程影响很小,但如果大量积累(如成千上万个),会耗尽系统的 进程号资源,导致无法创建新进程。
人工处理办法:
孤儿进程不用处理,我们现在介绍僵尸进程的预防
子进程退出后父进程使用:
bash
wait(NULL); // 阻塞等待任意子进程
// 或
waitpid(pid, NULL, 0); // 等待特定子进程
// 或
waitpid(pid, NULL, WNOHANG); // 非阻塞等待
或者忽略信号,让内核进程1统一回收:
bash
// 在父进程中设置
signal(SIGCHLD, SIG_IGN);
// 之后创建子进程时,子进程退出后会自动被内核回收
// 注意:这样就无法获取子进程的退出状态了
处理方面:
cpp
# 找到僵尸进程的父进程
ps -eo pid,ppid,stat,cmd | grep 'Z'
# 杀死父进程(假设父进程 PID 是 1234)
kill -9 1234
# 或者发送 SIGCHLD 信号提醒父进程回收
kill -CHLD 1234
杀死僵尸进程的父进程会让进程1来接管一切然后内核自动处理。
进程操作指令
bash
//ps指令:能够查看当前运行的进程相关属性
ps -ef
//:能够显示进程之间的关系
UID:用户ID号
PID:进程号
PPID:父进程号
C:用处不大
STIME:开始运行的时间
TTY:如果是问号表示这个进程不依赖于终端而存在
CDM:名称
ps -ajx
//:能够显示当前进程的状态
PGID:进程组ID
SID:会话组ID
STAT:进程的状态
ps -aux
//:可以查看当前进程对CPU和内存的占用率
%CPU:CPU占用率
%MEM :内存占用率
top //动态查看进程的相关属性
kill指令:发送信号的指令
使用方式:kill -信号号 进程号
可以通过指令:kill -l查看能够发送的信号有哪些
pidof:查看进程的进程号
使用方式:pidof 进程名
进程的状态切换
bash
1、如果有停止的进程,可以在终端输入指令:jobs -l查看停止进程的作业号
2、通过使用指令:bg 作业号 实现将停止的进程进入后台运行状态,如果只有一个停止的进程,输入bg不
加作业号也可以
3、对后台运行的进程,输入 fg 作业号 实现将后台运行的进程切换到前台运行
4、直接将可执行程序后台运行: ./可执行程序 &

多进程的实现
1、创建父子进程
cpp
pid_t pid=fork();
//此时pid>0为父进程,pid=0为子进程
注意:父进程结束要wait(NULL)等待子进程的exit(0);
2、父子进程号获取
bash
#include <sys/types.h>
#include <unistd.h>
pid_t getpid(void);
功能:获取当前进程的进程号
参数:无
返回值:当前进程的进程号
pid_t getppid(void);
功能:获取当前进程的父进程pid号
参数:无
返回值:当前进程的父进程pid
使用方法:
cpp
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int main() {
printf("当前进程 PID: %d\n", getpid());
printf("父进程 PPID: %d\n", getppid());
return 0;
}
进程退出:exit/_exit
cpp
#include <stdlib.h>
void exit(int status);
功能:退出当前进程,并刷新当前进程打开的标准IO的缓冲区
参数:进程退出时的状态,会将改制 与 0377进行位与运算后,返回给回收资源的进程
返回值:无
#include <unistd.h>
void _exit(int status);
功能:退出当前进程,不刷新当前进程打开的标准IO的缓冲区
参数:进程退出时的状态,会将改制 与 0377进行位与运算后,返回给回收资源的进程
返回值:无
进程回收:wait(NULL)最常见。
进程间通信
1> 由于多个进程的用户空间是相互独立的,其栈区、堆区、静态区的数据都是彼此私有的,所以不可 能通过用户空间中的区域完成多个进程之间数据的通信
2> 可以使用外部文件来完成多个进程之间数据的传递,一个进程向文件中写入数据,另一个进程从文 件中读取数据。该方式要必须保证写进程先执行,然后再执行读进程,要保证进程执行的同步性
3> 我们可以利用内核空间来完成对数据的通信工作,本质上,在内核空间创建一个特殊的区域,一个 进程向该区域中存放数据,另一个进程可以从该区域中读取数据
管道通信
1> 管道的原理:管道是一种特殊的文件,该文件不用于存储数据,只用于进程间通信。管道分为有名管道和无名管道。
2> 在内核空间创建出一个管道通信,一个进程可以将数据写入管道,经由管道缓冲到另一个进程中读取
无名管道
无名管道:顾名思义就是没有 名字的管道,会在内存中创建出该管道,不存在于文件系统,随着进程结束而消失。无名管道仅适用于亲缘进程间通信,不适用于非亲缘进程间通信 。 无名管道的API。
cpp
int fd[2];
pipe(fd); // 创建管道,fd[0]读端,fd[1]写端
pid_t pid = fork();
if (pid > 0) {
// 父进程 - 写
close(fd[0]); // 关闭读端
const char *msg = "Hello from parent";
write(fd[1], msg, strlen(msg));
close(fd[1]);
}
else if (pid == 0) {
// 子进程 - 读
close(fd[1]); // 关闭写端
char buf[100] = {0};
read(fd[0], buf, sizeof(buf));
printf("子进程收到: %s\n", buf);
close(fd[0]);
}
有名管道
有名管道:有名字的管道文件,会在文件系统中创建一个真实存在的管道文件 , 既可以完成亲缘进程间通信,也可以完成非亲缘进程间通信
cpp
进程1:写端
mkfifo("/tmp/my_fifo", 0664); // 创建有名管道
int fd = open("/tmp/my_fifo", O_WRONLY);
const char *msg = "Hello from writer";
write(fd, msg, strlen(msg));
close(fd);
进程2:读端
int fd = open("/tmp/my_fifo", O_RDONLY);
char buf[100] = {0};
read(fd, buf, sizeof(buf));
printf("收到: %s\n", buf);
close(fd);
return 0;
注意,管道通信是半双工,一次只能一端到另一端。
信号
信号 = 软件中断 ,是进程间通信的异步通知机制。
异步性:信号随时可能到达,打断进程正常执行
简单性:只传递信号编号,不携带复杂数据
全局性:同一信号对所有进程含义相同
cpp
kill -l # 列出所有信号
man 7 signal # 查看信号手册
信号的处理方式
cpp
#include <signal.h>
//忽略信号
signal(SIGINT, SIG_IGN); // 忽略 Ctrl+C
//自定义信号
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
// 信号处理函数
void sig_handler(int sig) {
printf("\n收到信号: %d\n", sig);
// 注意:处理函数中只能调用异步信号安全函数
}
int main() {
// 注册信号处理函数
signal(SIGINT, sig_handler);
while (1) {
printf("运行中... (按 Ctrl+C 测试)\n");
sleep(2);
}
return 0;
}
cpp
//举例子
#include<myhead.h>
//定义信号处理函数
void handler(int signo)
{
if(signo == SIGUSR1)
{
printf("逆子,何至于此!!!\n");
raise(SIGKILL); //向自己发送一个自杀信号 kill(getpid(), SIGKILL)
}
}
int main(int argc, const char *argv[])
{
//将子进程发送的信号绑定到指定功能中
if(signal(SIGUSR1, handler) == SIG_ERR)
{
perror("signal error");
return -1;
}
//创建父子进程
pid_t pid = fork();
if(pid > 0)
{
//父进程
while(1)
{
printf("我真的还想再活五百年\n");
sleep(1);
}
}else if(pid == 0)
{
//子进程
sleep(5);
printf("红尘已经看破,叫上父亲一起死吧\n");
kill(getppid(), SIGUSR1); //向自己的父进程发送了一个自定义的信号
exit(EXIT_SUCCESS); //退出进程
}
return 0;
}
system V提供的进程间通信概述
这一章,我们将要介绍消息队列和共享内存这两个概念,其中共享内存和信号量搭配使用。
1> 对于内核提供的三种通信方式,对于管道而言,只能实现单向的数据通信,对于信号通信而言,只 能完成多进程之间消息的通知,不能起到数据传输的效果。为了解决上述问题,引入的系统 V进程间通 信
2> system V提供的进程间通信方式分别是:消息队列、共享内存、信号量(信号灯集)
3> 有关system V进程间通信对象相关的指令
cpp
ipcs
可以查看所有的信息(消息队列、共享内存、信号量)
ipcs -q:可以查看消息队列的信息
ipcs -m:可以查看共享内存的信息
ipcs -s:可以查看信号量的信息
ipcrm -q/m/s ID :可以删除指定ID的IPC对象
4> 上述的三种通信方式,也是借助内核空间完成的相关通信,原理是在内核空间创建出相关的对象容 器,在进行进程间通信时,可以将信息放入对象中,另一个进程就可以从该容器中取数据了。
5> 与内核提供的管道、信号通信不同:system V的ipc对象实现了数据传递的容器与程序相分离,也 就是说,即使程序以己经结束,但是放入到容器中的数据依然存在,除非将容器手动删除。
消息队列

使用消息队列要通过声明结构体传输:此时结构体需要有一个类型变量和一个消息存储容器,而后续我们的空间大小是总大小减去类型变量大小的空间。
注意:
1、对于消息而言,由两部分组成:消息的类型和消息正文,消息结构体由用户自定义
2、对于消息队列而言,任意一个进程都可以向消息队列中发送消息,也可以从消息队列中取消息
3、多个进程,使用相同的key值打开的是同一个消息队列
4、对消息队列中的消息读取操作是一次性的,被读取后,消息队列中不存在该消息了
5、消息队列的大小:16K7
使用过程:
cpp
1、声明结构体
2、key_t一个独有钥匙key
3、msgget(key,IPC_CREAT|0664)申请一个队列空间返回msgid;
4、用结构体声明一个消息
5、如果是接收那就bzero,如果发送那就fgets(buf,MSGSZ,stdin);buf.mtext[strlen(buf.mtext)-1]= '\0';
6、如果发送那就msgsnd(msqid, &buf, MSGSZ, 0);如果接收那就 msgrcv(msqid, &buf, MSGSZ, 1, 0);(参数4: 0:表示每次都取消息队列中的第一个消息,无论类型。0:读取队列中第一个类型为msgtyp的消息。<0:读取队列中的一个消息,消息为绝对值小于msgtyp的第一个消息)
7、删除消息队列:msgctl(msqid, IPC_RMID, NULL)
共享内存

5> 注意:
//通过地址访问共享内存中的数据
1、共享内存是多个进程共享同一个内存空间,使用时可能会产生竞态,为了解决这个问题,共享 内存一般会跟信号量一起使用,完成进程的同步功能
2、共享内存VS消息队列:消息队列能够保证数据的不丢失性,而共享内存能够保证数据的时效性
3、对共享内存的读取操作不是一次性的,当读取后,数据依然存放在共享内存中
4、使用共享内存,跟正常使用指针是一样的,使用时,无需再进行用户空间与内核空间的切换 了,所以说,共享内存是所有进程间通信方式中效率最高的一种通信方式。
使用方法:
cpp
1、通过key_t创建一个key值
2、通过shmid= shmget(key, PAGE_SIZE, IPC_CREAT|0664)创建共享内存段,返回shmid。
3、类指针操作,将共享内存段映射到用户空间char *addr = (char *)shmat(shmid, NULL, 0);
4、同消息队列的发送接收操作
5、shmdt取消映射:shmdt(addr)
6、shmctl(shmid, IPC_RMID, NULL) == -1删除共享内存段
信号量

注意:
1、信号量集是完成多个进程间同步问题的,一般不进行信息的通信
2、信号量集的使用,本质上是对多个value值进行管控,每个信号量控制一个进程,在进程执行 前,申请一个信号量的资源,执行后,释放另一个信号量的资源
3、如果当前进程申请的信号量值为0,则当前进程在申请处阻塞,直到其他进程将该信号量中的 资源增加到大于0
使用方法:
cpp
#include<myhead.h>
union semun {
int val; // 设置信号量的值
struct semid_ds *buf; //关于信号量集属性的操作
unsigned short *array; //对于信号量集中所有信号量的操作
struct seminfo *__buf; /* Buffer for IPC_INFO(Linux-specific)
*/
};
//定义一个关于对信号量初始化函数
int init_sem(int semid, int semno)
{
int val = -1;
printf("请输入第%d个信号量的初始值:", semno+1); //让用户输入信号量的初始值
scanf("%d", &val);
getchar(); //吸收回车,以免影响其他程序
//调用semctl完成设置
union semun us;
us.val = val;
if(semctl(semid, semno, SETVAL, us) == -1)
{
perror("semctl error");
return -1;
}
return 0;
}
//创建信号量集并初始化:semcount表示本次创建的信号量集中信号灯的个数
int create_sem(int semcount){
//1、创建key值
key_t key = ftok("/", 'k');
if(key == -1)
{
perror("ftok error");
return -1;
}
//2、通过key值创建信号量集
int semid = -1;
if((semid = semget(key, semcount, IPC_CREAT|IPC_EXCL|0664)) == -1)
{
if(errno == EEXIST) //表示信号量集已经存在,直接打开即可
{
semid = semget(key, semcount, IPC_CREAT|0664); //将信号量集直接打开
return semid;
}
perror("semget error");
return -1;
}
//3、循环将信号量集中的所有信号量进行初始化
//该操作,只有在第一次创建信号量集时需要进行操作,后面再打开该信号量集时,就无需进行初始化
操作了
for(int i=0; i<semcount; i++)
{
init_sem(semid, i); //调用自定义函数将每个信号量进行初始化
}
//将信号量集的id返回
return semid;
}
//申请资源操作,semno表示要被申请资源的信号量编号
int P(int semid, int semno)
{
//定义一个结构体变量
struct sembuf buf;
buf.sem_num = semno; //要操作的信号编号
buf.sem_op = -1; //-1表示要申请该信号量的资源
buf.sem_flg = 0; //表示阻塞形式进行申请
//调用semop函数完成资源的申请
if(semop(semid, &buf, 1) == -1)
{
perror("P error");
return -1;
}
return 0;
}
//释放资源操作,semno表示要被释放资源的信号量编号
int V(int semid, int semno)
{
//定义一个结构体变量
struct sembuf buf;
buf.sem_num = semno;
buf.sem_op = 1;
buf.sem_flg = 0;
//要操作的信号编号
//1表示要释放该信号量的资源
//表示阻塞形式进行释放
//调用semop函数完成资源的释放
if(semop(semid, &buf, 1) == -1)
{
perror("V error");
return -1;
}
return 0;
}
//删除信号量集
int delete_sem(int semid)
{
//调用semctl函数完成对该信号量集的删除
if(semctl(semid, 0, IPC_RMID) == -1)
{
perror("delete error");
return -1;
}
return 0;
}
上述是第一步:手动封装信号量函数简化后续调用,相当于预处理操作。
cpp
2、int semid = create_sem(2); 调用自定义函数,完成对信号量集的创建
3、共享内存板块
4、while内操作开始和结束调用: P(semid, 0); 和 V(semid, 1);
5、调用自定义函数:删除信号量集 delete_sem(semid);
若有遗漏将会补充!