简介
进程是系统资源分配的最小单位,它曾经也是CPU调度的最小单位,但后面被线程所取代。
进程树
Linux系统通过父子进程关系串联起来,所有进程之前构成了一个多叉树
结构。
孤儿进程
孤儿进程是指父进程已经结束,子进程还在执行的进程。那么此时此刻,该进程就变成了孤儿进程。
当进程变成孤儿进程后,系统会认领该进程,并为他再分配一个父进程(就近原则,爸爸的爸爸,爸爸的爸爸的爸爸)。
》当孤儿进程被认领后,就很难再进行标准准入输出对其停止了。因为切断了跟终端的联系,所以编码过程中要尽量避免出现孤儿进程
进程通讯(inter -Process Communication,IPC)
多个进程之间的内存相互隔离,多个进程之间通讯方式有如下几种:
管道(pipe)
匿名管道
半双工通信,即数据只能在一个方向上流动。只能在具有父子关系的进程之间使用。
其原理为:内核在内存中创建一个缓冲区,写入端将数据写入缓冲区,读取端从缓冲区中读取数据
点击查看代码
#include <stdio.h> #include <unistd.h> #include <string.h> int main() { int pipefd[2]; pid_t pid; char buffer[100]; // 创建管道 if (pipe(pipefd) == -1) { perror("pipe"); return 1; } // 创建子进程 pid = fork(); if (pid == -1) { perror("fork"); return 1; } if (pid == 0) { // 子进程:关闭写端,从管道读取数据 close(pipefd[1]); read(pipefd[0], buffer, sizeof(buffer)); printf("Child process received: %sn", buffer); close(pipefd[0]); } else { // 父进程:关闭读端,向管道写入数据 close(pipefd[0]); const char *message = "Hello, child process!"; write(pipefd[1], message, strlen(message) + 1); close(pipefd[1]); } return 0; }
命名管道
可以在任意两个进程之间进行通信,不要求进程具有亲缘关系;遵循先进先出(FIFO)原则。
其原理为:在文件系统中创建一个特殊的文件,进程通过读写这个文件来进行通信,因此文件读取是从头开始读,所以先进先出。
点击查看代码
// 写进程 #include <stdio.h> #include <fcntl.h> #include <string.h> #include <unistd.h> #define FIFO_NAME "myfifo" int main() { int fd; const char *message = "Hello, named pipe!"; // 创建命名管道 mkfifo(FIFO_NAME, 0666); // 打开命名管道进行写操作 fd = open(FIFO_NAME, O_WRONLY); if (fd == -1) { perror("open"); return 1; } // 向命名管道写入数据 write(fd, message, strlen(message) + 1); close(fd); return 0; } // 读进程 #include <stdio.h> #include <fcntl.h> #include <unistd.h> #define FIFO_NAME "myfifo" int main() { int fd; char buffer[100]; // 打开命名管道进行读操作 fd = open(FIFO_NAME, O_RDONLY); if (fd == -1) { perror("open"); return 1; } // 从命名管道读取数据 read(fd, buffer, sizeof(buffer)); printf("Received: %sn", buffer); close(fd); // 删除命名管道 // 理论上,命名管道是可以重复使用的,其本质就是操作文件而已。只是不建议这么操作。 unlink(FIFO_NAME); return 0; }
共享内存(Shared Memory)
多个进程共享同一块内存地址,是最快的IPC方式。
其原理为:内核在物理内存中分配一块内存区域,多个进程将内存地址映射到自己的虚拟空间内,从而可以直接读写该区域。
点击查看代码
临时文件系统
linux的临时文件系统是一种基于内存的文件系统,它将数据存储在RAM中或者SWAP中,共享对象同样也是挂在在临时文件系统中。
我们可以写一段不释放的代码,来眼见为实。
点击查看代码
代码执行中:生成的临时文件
执行后,临时文件也不会消失,因为没有释放。
消息队列(message queue)
消息队列是消息的链表,存放在内核中,由消息队列标识符标识。进程可以向队列中添加消息,也可以从队列中读取消息。
其原理为:内核为每个消息队列维护一个消息链表,消息可以按照不同的类型进行分类,进程可以根据消息类型有选择地读取消息。
生产者
#include <fcntl.h> #include <sys/stat.h> #include <mqueue.h> #include <stdio.h> #include <time.h> #include <string.h> #include <unistd.h> #include <stdlib.h> int main() { struct mq_attr attr; attr.mq_flags=0; attr.mq_maxmsg=10; attr.mq_msgsize=100; attr.mq_flags=0; attr.mq_curmsgs; struct timespec time_info; //创建消息队列 char * mq_name="/p_c_mq"; mqd_t mqdes=mq_open(mq_name,O_RDWR|O_CREAT,0664,&attr); if(mqdes==(mqd_t)-1){ perror("mq_open"); exit(EXIT_FAILURE); } //从控制台接受数据,并发送给消费者 char write_buff[100]; while (1) { memset(write_buff,0,100); ssize_t read_count= read(STDIN_FILENO,write_buff,100); clock_gettime(0,&time_info); time_info.tv_sec+=5; if(read_count==-1){ perror("read"); continue; } else if(read_count==0) { printf("控制台停止发送消息"); char eof=EOF; if(mq_timedsend(mqdes,&eof,1,0,&time_info)==-1){ perror("mq_timedsend"); } break; } else{ if(mq_timedsend(mqdes,write_buff,strlen(write_buff),0,&time_info)==-1){ perror("mq_timedsend"); } printf("从命令行接受到数据,并发送给消息的队列。"); } } //关闭资源 close(mqdes); //由消费者关闭更加合适,否则消费者可能无法接收到最后一条消息。 //unlink(mq_unlink); return 0; }
消费者
临时文件
与内存共享类似,Linux底层也会生成一个临时文件来代表队列。
三者之间的演化关系
在操作系统的发展过程中,最早出现的是管道通信,用于父子进程之间的信息通信
。
不足:
- 单向且受限于亲缘关系限制
只能在父子进程之间传递,后面又演化出了命名管道,解决了亲缘关系的限制。 - 传输效率低
依赖内核缓冲区,且内核空间有限,无法传输大数据。 - 缺乏消息分类与异步支持
然后又演化出共享内存
,解决了管道在大数据传输的效率问题。成为现在操作系统中最高效的IPC方式,
不足:
- 需要开发者自行处理同步逻辑。
经常听到的Zero Copy也是这个原理。
与共享内存
同时推出的还有消息队列,它解决了管道/共享内存在功能上的局限性。
消息可以按照分组来选择性的接收,由内核来处理同步逻辑,并对外提供原子操作。
不足:
- 性能不如共享内存
因为消息队列与管道一样,需要从内核复制数据。
消息队列是对管道的功能增强,共享内存是对管道的性能增强。
机制 | 是否复制内核 | 存储位置 | 描述 |
---|---|---|---|
管道 | 是(两次复制) | 内核缓冲区 | 基于字节流,需内核复制数据,效率较低,但实现简单 |
共享内存 | 否 | 内核物理地址 | 直接访问,无数据复制,效率最高,但需同步机制 |
消息队列 | 是(两次复制) | 内核物理地址 | 消息按类型组织,支持异步通信,但需内核复制数据,适合中小数据量传输 |
进程模型
内核会为每一个进程创建并保存一个名为PCB(Process Control Block)的数据结构,来维护进程运行过程中的一些关键信息。
- PID
- 进程状态
- 进程切换时需要保存和回复的寄存器的值
- 内存管理
当前进程所属哪一块内存,如页表,段表等 - 当前工作目录
- 进程调度信息
比如优先级,进程调度指针 - I/O状态信息
最常见的就是文件描述符表(struct fdtable),I/O设备列表 - 同步和通讯信息
比如信号量,信号等。 - 权限管理
比如所属id与所属group
struct task_struct { /* 进程状态 */ volatile long state; /* 进程标识符 */ pid_t pid; /* 指向父进程的指针 */ struct task_struct __rcu *parent; /* 进程的用户和组信息 */ uid_t uid, euid, suid, fsuid; gid_t gid, egid, sgid, fsgid; /* 其他成员... */ };
内存模型
内存模型,基本都大同小异,不再赘述.
以C语言程序为例:
栈指针 SP,表示栈顶
帧指针 BP,表示栈底
命令指针 IP,命令指针,指向下一条要运行的命令
进程状态模型
对于进程状态,有一个抽象的定义。
- 初始状态(initial)
进程刚被创建的初始态,该阶段,操作系统会为进程分配资源 - 就绪态(ready)
进程已经准备就绪,当并未被CPU所调度。 - 运行态(Running)
正在CPU上执行代码 - 阻塞态(Blocked)
进程等待其他事件完成,比如网络I/O,磁盘I/O而无法继续时,就处于阻塞状态 - 终止态(Final)
进程执行完毕,准备释放其占用的资源,PCB信息依旧存在。该状态理论上非常短暂。 - 僵尸态(Zombie)
与终止态非常相似,唯一的区别就是因为未知原因PCB长期不释放。
而在Linux中,并未完全遵循上述抽象概念。
不过总体类似,状态主要有如下几种。
- D
不可中断的睡眠状态,比如执行IO操作时,不可中断。 - I
空闲的内核线程 - R
Runnig/Read 运行中或者可以运行的状态 - S
可中断的睡眠状态,比如等待唤醒 - T
由工作控制信号停止 - t
由调试器停止 - W
分页,从2.6内核版本后就不再有效 - X
死亡状态,理论上不会看到,因为相当于整个进程都被回收了,包括PCB,是现实不了的。你如何显示一个不存在的进程? - Z
僵尸进程,已经终止但PCB尚未被回收
抽象 | Linux实现 |
---|---|
初始态 | N/A |
就绪态 | R |
运行态 | R |
阻塞态 | D,S,T,t |
僵尸态 | Z |
进程状态控制
当一个进程状态变换
的时候,通常需要三步。
- 找到PCB
- 设置PCB状态信息
- 将PCB移到响应的队列
比如进程从阻塞态变成就绪态,状态变化后,CPU的调度队列也要变化。
思考一个问题,如果在第二步的时候,突然来了一个中断,导致第三步没有执行。破坏了原子性,从而使得程序出现异常,这时候应该怎么处理?
汇编每执行一行代码,CPU都会检查一次有没有中断。
这个时候,就要依靠特权指令
来实现原子性了
cpu提供两个特权指令
- 关中断指令
- 开中断指令
因此,在开/关中断指令中间的汇编代码,CPU不会再检查有没有中断,从而实现操作原子性。