多线程的作用
当处理一个任务,只雇一个人时,就太慢啦,则我么们可以多股几个人,将任务分为几份,让其每个人同时进行这些任务。则这里的人就是进程,多进程,就是多人同时工作。每个进程就相当于每个人,都是相互独立的,有着独立的用户空间,所以当一个用户出意外时,别的进程可以继续进行。
提高效率(尤其是CPU密集型任务) 在Python里,由于GIL(全局解释器锁)的存在,多线程没法真正利用多核CPU做并行计算,但多进程可以——每个进程有独立的解释器和内存空间,能实实在在地跑在不同的CPU核心上。 隔离性更强,更稳定 每个进程有自己独立的内存空间,一个进程崩溃了,不会直接影响其他进程(不像线程共享内存那样容易互相"拖累")。 能同时干多件事 比如一个进程负责读取数据,另一个进程负责计算,再一个进程负责写结果,多个环节可以并行推进,而不是排队等待。
进程之间如何通信:
应为每个进程的内存空间都是独立的,所以当这个进程需要另外一个进程的数据时,就需要进程传递
1.通过队列,就像一个传送带,一个进程往里放东西,另一个进程从里面取
2.当传递的数据过于大时,进程们就创立一个共享内存的空间,有需要直接从里面读,如果数据量大,反复拷贝浪费性能,可以直接开一块"公共内存区域",多个进程都能直接读写。
进程附加态
高优先级进程:<
低优先级进程:N
主要原理是:
cpu进行进程的调度时:主要时看进程的nice值,如果nice值低则其就可以插队就可以优先进行,同时能够分配更长时间的cpu 反之越低:就会导致其容易被插队,然后分配时间越少。/
进程和程序
程序:你可以将其理解成二进制文件。
进程:就是程序执行之后的产物,是动态的,有声明周期。
就比如你打开百度这个程序,在打开之前其是静止的,在打开之后,操作系统会创建进程,开辟虚拟空间给这个进程,接着会将程序的代码赋值虚拟空间中,然后将进程放进调度进程队列,等待cpu的调度。
交互进程:就是那些和用户进行输入输出的进程就叫做。
| 交互式进程的例子 | 特点 |
|---|---|
| 你在用的文本编辑器 | 你打字,它实时显示 |
| 终端/命令行窗口(Shell) | 你敲命令,它立刻执行并返回结果 |
| 游戏 | 你按键,画面立即响应 |
| 聊天软件 | 你发消息,等待/收到回复 |
进程的创建:本质都是通过系统调用之后,通过操作系统调用的。
两种创建情况:
1.在打开程序时:shell会接受指令,调用系统调用函数fork(),进行创建新进程
已有进程主动创建新进程(父子进程):
import os pid = os.fork() # 调用系统调用,让内核创建一个子进程 if pid == 0: print("我是子进程") else: print("我是父进程,创建了子进程,它的pid是", pid)不依赖于终端文件存在的意思是:
【依赖终端的普通进程】 你打开终端 → 运行程序 → 进程与该终端绑定 通过终端创造的进程,都依赖于终端。 你关闭终端 → 系统发SIGHUP → 进程被杀死(默认行为) 【不依赖终端的守护进程】 终端启动它 → 进程主动脱离终端(nohup/setsid/daemon化) 你关闭终端 → 进程完全不受影响,继续运行 (这也是为什么Web服务器、数据库这类程序, 可以在你退出登录之后依然7×24小时提供服务)
"进程不依赖于终端存在",指的是这个进程已经和最初启动它的那个终端窗口"解绑"了,终端窗口关闭时发出的挂断信号(SIGHUP)不会影响到它,所以即便终端消失,这个进程也能继续存活和运行——这正是所有后台服务/守护进程(如Nginx、MySQL、SSH服务等)能够长期稳定运行的基础。
进程组:就是为了统一管理一些进程,就比如使用ctrl+c之后能够将同一个进程组的全都关闭
会话组:就是进程组的集合,方便统一管理进程组,但是一般的会话组都是关联终端的,如果关联终端的会话组,则里面的进程就全是由shell进程创建的,当关闭终端时,传递信号,然后直接操作全部的会话组
但是也存在不与终端相关的会话,就是由守护进程创建的会话组,来实现脱离终端独立运行.
【常规会话 —— 有终端】 终端窗口 → 会话(关联该终端) ├── 进程组1 └── 进程组2 关闭终端 → SIGHUP → 会话里所有进程组被清理 【守护进程会话 —— 无终端】 setsid() → 新建会话(不关联任何终端) └── 进程组(比如Nginx主进程+子进程) 不受任何终端关闭的影响,一直独立运行
前台运行:就是执行时,这个进程得霸占着终端,然后会讲终端卡主,你输入的一切都是给他的,只能当他跑完,才可以敲下一个命令
后台任务:就是执行之后,就在后台跑,不会占用终端,你可以使用终端干其他事。
【前台运行】 $ python task.py (提示符消失,卡住) (等啊等...) 任务完成 $ ←提示符终于回来了 【后台运行】 $ python task.py & [1] 12345 ← 立刻返回,告诉你后台任务的编号和PID $ ←提示符马上就在这,你可以继续敲命令 $ 敲点别的命令... (后台任务自己默默跑着,跑完了会提示你)
一个程序已经在前台跑着,突然想让它转后台:先按
Ctrl+Z(挂起它),再敲bg(把它丢到后台继续跑)想把后台的任务重新拉回前台:敲
fg想看看有哪些任务在后台跑着:敲
jobs进程的状态
多进程的实现
fork:在执行fork后创造的子进程,会拷贝当前进程的数据,拷贝父进程内存中的内容,所以其两个进程会有相同的变量名,且在fork创建拷贝父进程之后,创建子进程之后,会返回值,给父进程的承接fork返回值的变量子进程的pid的值,给子进程返回0,如果失败则返回-1,通过这种方式就可以区分子进程和父进程了
注意:本质上父进程和子进程之间的调用同样遵循cpu的时间调度,没有固定的顺序。
第1步:fork() 被调用,内核开始工作 ↓ 第2步:内核复制父进程的整个地址空间(含栈、堆等), 生成一个新的子进程,此时 pid 变量所在内存 也被完整复制了一份(值还是未定义/旧值) ↓ 第3步:子进程创建完毕后,fork() 这个函数调用 要"返回"了 —— 而且是返回两次: - 在父进程的执行流里返回子进程PID - 在子进程的执行流里返回0 ↓ 第4步:赋值动作 pid = 返回值,在父子进程中 各自独立执行,写入各自那份 pid 内存
资源回收:
wait:父进程进行调用函数进行回收第一个退出的子进程,会进行堵塞,就是说,在父进程执行到这个函数和后,会卡到那,然后等第一个退出的程序之后,将其回收之后,才可以继续向下执行。
waitpid:可以指定回收指定的进程,也可以随机,同时需要注意可以进行堵塞,也可以不进行,但是当不进行时,当指定的回收进程还没有退出时,则父进程就直接执行下面的指令了,不会在这等着,当父进程退出之后,,则子进程就变成僵尸了,所以我们在使用不堵塞时,我们需要将主程序定到那个位置,让其等待一个程序直至其结束,实现回收。
#include <stdio.h> #include <unistd.h> #include <sys/wait.h> int main() { pid_t pid = fork(); if (pid == 0) { sleep(3); printf("子进程退出\n"); return 0; } int status; pid_t result; // 父进程主动留在这个循环里,不停地问"子进程结束了没" while ((result = waitpid(pid, &status, WNOHANG)) == 0) { printf("父进程还在忙别的事,顺便看一眼子进程...\n"); sleep(1); // 避免死循环空转,消耗过多CPU // 这里可以插入父进程真正要做的其他工作 } printf("子进程已回收,PID=%d\n", result); return 0; }当程序还没有退出时会返回0.
但是需要注意就算子进程变成孤儿进程,变成僵尸进程之后,还是会有init进程进行接盘,进行调用wait进行 回收的。
通信IPC
一般的通信就是利用内核的缓存区,用户通过写将用户缓存区的数据拷贝在内核缓存区,在读时,进程通过函数调用,讲内核缓存区的数据拷贝到用户态中,就是进程的通信的过程。大多依赖内核。
所以根据在内核空间中容器或方式的不同分为三种:
内核提供:主要时通过管道文件作为容器。
无名管道:存在于内存中,不存在于文件系统中。同时当进程中的读端和写端全都关闭就消失。
无名管道堵塞是将相应的进程堵塞,就是进程直接卡在那,也不会进行缓存区的刷新之类的。
管道:管道本质上就是一个不存储数据的文件,其数据在写入和读取之后就消失,不能长久的存储数据。不和普通的文件一样。是一个数据实时传输的通道传完就没了。
信号不同于管道主要是由内核实现的,同时不是进行数据的传输,而是进行进程与进程之间信号的传递,当一个进程拿到相应的信号之后,可以去执行信号相应的绑定的操作
注意信号本身不具备任何的操作,但是内核会给信号附上默认的操作,但是当你给信号绑定了相应的handler之后他就会执行你设置的操作而不会去执行默认的操作。
system v提供:主要提供三种IPC对象,消息队列,共享内存,信号量
---共性就是都需要使用key进行创建
其中system v提供的三种ipc对象,主要是解决了内核提供的三种,一是system v是全双工,同时实现进程和容器进行分离,就是说当没有进程连接容器时,容器同样还会存在
共享内存:
虚拟地址空间:就是内核给进程花的大饼,就是说他骗进程说给他多大的空间,其实是给他的虚拟空间,但是进程还会傻傻的觉得这个空间是存在的,是独立的,因此就实现了,进程与进程之间的隔离以独立,同时虚拟空间主要是存储编号,就比如进程的数据的编号,但是真正的书数据是存储在物理内存的,应为每个进程都会有一个页表,可以将虚拟空间对应的标号翻译成真实存在的物理内存地址,cpu在需要地址时,就可找到数据 所以共享内存的原理:就是使用页表将进程中的两个不同的虚拟地址,翻译成同一个物理地址来实现内存的共享。 具体创建的过程就是:现在物理内存中请求一点空间,然后在将虚拟地址映射到多个进程中去 共享内存之所以快:是应为其是进程直接操作的是真实的物理地址,不用进行多次的拷贝,直接就可以在内存中读取。
问题:当一个进程将共享地址删除了,另一个正在读取的进程会怎样
# 会发生什么 结论:**不会立刻出问题**。System V 共享内存的删除机制是有"保护"的,不会因为一个进程删除了,另一个正在使用的进程就突然崩溃或读到乱七八糟的东西。 ## 关键概念:`IPC_RMID` 只是"打上删除标记",不是"立刻销毁" ```c shmctl(shmid, IPC_RMID, NULL); ``` 这行代码做的事情,准确说是:**把这块共享内存标记为"待删除"**,而不是马上把物理内存回收掉。 内核内部会维护一个**引用计数**(`shm_nattch`),记录当前有多少个进程 attach 了这块内存。`IPC_RMID` 之后: - 如果这时候引用计数**不为 0**(说明还有进程正连着它),内核**不会真的释放物理内存**,只是打个标记 - 只有等到**最后一个**进程也 `shmdt()` 解除挂载,引用计数归 0 时,内核才会真正把物理内存回收掉 ## 用例子说明 ```c // 进程A和进程B都已经 shmat() 连接上了这块共享内存,此时 shm_nattch = 2 // 进程A执行: shmctl(shmid, IPC_RMID, NULL); // 只是打标记,不会真删 // 进程B此时: // 继续读写 addr 指向的内存,完全正常,数据还在,不受影响 memcpy(buffer, addr, size); // 正常读取,没问题 // 直到进程B也执行: shmdt(addr); // 此时 shm_nattch 变成 0,内核才真正释放这块物理内存 ``` ## 需要注意的几个细节 1. **已经 attach 的进程完全不受影响**,可以继续正常读写,就跟没删除过一样 2. **但是** `IPC_RMID` 之后,任何**新的**进程再想 `shmat()` 这个 `shmid`,会失败——因为它已经被标记删除,不允许新的挂载了 3. 用 `ipcs` 命令能看到这种"僵尸"状态的共享内存,它的状态会显示为 `dest`(destination/待销毁),表示已经标记删除,但还在等最后的引用者释放 ## 为什么这么设计 这其实是个很合理的保护机制:一个进程删除共享内存的意图,通常是"我不再需要新的进程加入进来用它了",但不应该粗暴地打断正在使用它的进程。这种"标记删除 + 引用计数归零才真正释放"的做法,类似 Linux 文件系统里"文件被删除但还有进程打开着,数据依然可读"的原理——本质上是同一套思路:**只要还有人在用,就不能真的把资源收走**。
信号量集:主要是用于解决进程间的异步问题,就是说信号量集可以使进程同步操作,通过一个信号量集控制一个一个进程,当该信号量集被一个进程申请后,则就可以执行这个进程,同时其余的进程就得堵塞等待。
信号量集:主要是用于控制,不是用于信号将的通信