进程相关概念总结
文章目录
- 1. 冯诺依曼体系结构
- 2. 操作系统(Operator System)
- 系统调用和库函数概念
- 3. 进程
- 3.1 查看进程
- 3.2 系统调用获取进程标识符(getpid() getppid())
- 3.3 通过系统调用创建进程-fork初识
- 3.4 进程状态
- 3.4.1 僵尸进程
- 3.4.2 孤儿进程
- 3.5 进程优先级
- 3.6 其他概念
- 4. 命令行参数
- 5. 环境变量
- 6. 程序地址空间
- 7. Linux2.6内核进程调度队列
1. 冯诺依曼体系结构
需要注意的是:
不考虑缓存情况,这里的CPU能且只能对内存进行读写,不能访问外设(输入或输出设备)。外设(输入或输出设备)要输入或者输出数据,也只能写入内存或者从内存中读取。即所有设备都只能直接和内存打交道。
2. 操作系统(Operator System)
操作系统是进行软硬件资源管理的软件,操作系统 = 操作系统的内核 (狭义认识)+ 操作系统的外壳周边程序(广义认识)。操作系统的存在可以对软硬件资源进行管理,同时为用户程序提供良好(稳定的、安全的、高效的)的运行环境。
操作系统在对软硬件资源进行管理的时候都是先描述在组织。 描述起来,用struct结构体;组织起来,用链表或其他高效的数据结构。
系统调用和库函数概念
在开发角度,操作系统对外会表现为一个整体,但是会暴露自己的部分接口,供上层开发使用,这部分由操作系统提供的接口,叫做系统调用。
系统调用在使用上,功能比较基础,对用户的要求相对也比较高,所以,有心的开发者可以对部分系统调用进行适度封装,从而形成库,有了库,就很有利于更上层用户或者开发者进行二次开发。
3. 进程
基本概念:
程序的一个执行实例,正在执行的程序等。内核观点:担当分配系统资源(CPU时间,内存)的实体。
操作系统中同时存在很多进程,OS想把这么多进程管理起来就要先描述在组织。
描述: 进程信息被放在一个叫做进程控制块的结构中(称之为PCB – process control block),可以理解为进程属性的集合,,Linux操作系统下的PCB是: task_struct。task_struct是Linux内核的一种数据结构,它会被装载到RAM(内存)里并且包含着进程的信息。
组织: 所有运行在系统里的进程都以task_struct链表的形式存在内核里。可以在内核源代码里找到它。
通过对进程的先描述在组织,操作系统对进程的管理就变成了对链表的增删查改。
因此,进程 = 内核数据结构task_struct + 程序的代码和数据。
task_struct的内容分类:
3.1 查看进程
进程的信息可以通过/proc 系统文件夹查看。
大多数进程信息同样可以使用top和ps这些用户级指令来获取。
//test.c
#include <stdio.h>
#include <unistd.h>int main()
{while(1){printf("Hello Linux!\n");sleep(1);}return 0;
}
3.2 系统调用获取进程标识符(getpid() getppid())
在操作系统中,每个进程都需要一个编号来区分、管理和调度,这个编号就称为进程标识符(Process Identifier),简称PID,是task_struct的其中一个字段。操作系统内核为每个新创建的进程都会分配的一个唯一整数。
而每个进程通常都是由另一个进程创建,这个创建进程的PID就是被创建进程的父进程ID(Parent PID), 简称PPID。
一个进程想知道自己的pid和ppid,可以通过系统调用getpid()和getppid()来获取。
//所需的头文件
#include <sys/types.h>
#include <unistd.h>pid_t getpid(void);
pid_t getppid(void);
3.3 通过系统调用创建进程-fork初识
在 Linux 中,使用系统调用 fork() 来创建一个新进程。
上面可以看出,fork()之后的代码被执行了两次。
这是因为,fork()会把当前进程(父进程)复制出一个几乎一模一样的进程(子进程)。复制内容包括:程序代码段、打开的文件描述符、当前信号掩码、寄存器上下文…。唯一不同的是,子进程拥有独立的数据段和用户栈——写入时才真正做拷贝(写时拷贝)。
既然创建出来的子进程和父进程几乎一模一样,那为什么还要创建子进程呢?因为我们想要子进程和父进程各自执行不同逻辑。因此,fork()之后通常要用if分流。在子进程分支中可以执行子任务(比如处理请求、执行 exec() 加载新程序等);在父进程分支中可以做父任务(比如继续监听、收集子进程退出状态、管理多个子进程等)。
fork()函数有两个返回值,给父进程返回子进程的pid(>0),告诉父进程子进程创建成功了,子进程号是多少;给子进程返回0,告诉子进程你是新创建的进程,你的返回值表示自己是子进程。如果返回值<0,则说明fork()失败,没有产生子进程。
当父进程执行到fork()时,操作系统会做两件事情:在内核中分配并初始化task_struct、页表等,映射与父进程相同的虚拟地址空间(目前页表都指向同一份物理页,且标记为只读以便写时拷贝);让父进程和子进程各自继续执行,但他们从fork()的返回那里分叉成两条执行流。
ps:写时拷贝
目的:避免每次 fork() 就把父进程所有内存都拷贝一遍,浪费时间和空间。
做法:初始时父子共享同一物理页,内核把这些页标记为“只读”。只有当某一进程尝试写入时,才会触发缺页异常(page fault),内核才真正拷贝该页,并把新拷贝的页变为可写。
3.4 进程状态
在操作系统中,进程状态就是操作系统用来描述一个进程在其生命周期中、当前所处活动阶段的标签。每个进程在内核的进程控制块(PCB)里都有一个状态字段,根据它,调度器知道该进程是该运行、该睡眠、还是该退出,进而决定如何分配 CPU、何时唤醒或回收。
一个进程可以有几个状态呢?可以看下在kernel源代码里的定义:
/*
* The task state array is a strange "bitmap" of
* reasons to sleep. Thus "running" is zero, and
* you can test for combinations of others with
* simple bit tests.
*/
static const char * const task_state_array[] = {
"R (running)", /* 0 */
"S (sleeping)", /* 1 */
"D (disk sleep)", /* 2 */
"T (stopped)", /* 4 */
"t (tracing stop)", /* 8 */
"X (dead)", /* 16 */
"Z (zombie)", /* 32 */
};
查看进程的状态可以使用 ps aux / ps axj 命令。
其中R运行状态(running): 并不意味着进程一定在运行中,它表明进程要么是在运行中要么在运行队列里。
S睡眠状态(sleeping): 意味着进程在等待事件完成(这里的睡眠有时候也叫做可中断睡眠(interruptible sleep))。
D磁盘休眠状态(Disk sleep)有时候也叫不可中断睡眠状态(uninterruptible sleep),在这个状态的进程通常会等待IO的结束。
T停止状态(stopped): 可以通过发送 SIGSTOP 信号给进程来停止(T)进程。这个被暂停的进程可以通过发送 SIGCONT 信号让进程继续运行。
t跟踪停止状态(tracing stop):仅当调试器(ptrace)单步、断点时会用到,类似暂停。
Z僵尸状态(zombie): 进程已执行 exit() 并终止,但其 PCB 尚未被父进程 wait() 回收,占据一个僵尸条目。
X死亡状态(dead):这个状态只是一个返回状态,你不会在任务列表里看到这个状态。
3.4.1 僵尸进程
僵死状态(Zombies)是一个比较特殊的状态,当子进程退出并且父进程(使用wait()系统调用)没有回收该子进程的退出状态时就会产生僵死(尸)进程。僵死进程会以终止状态保持在进程表中,并且会一直在等待父进程读取退出状态代码。所以,只要子进程退出,父进程还在运行,但父进程没有读取子进程状态,子进程进入Z状态。
通过一段代码进行验证:
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>int main()
{pid_t id = fork();if (id < 0){perror("fork");return 1;}else if (id > 0){int cnt = 5; while (cnt--){printf("I am a parent process! pid:%d ppid:%d\n", getpid(), getppid());sleep(1);}}else{int cnt = 2; while (cnt--){printf("I am a child process! pid:%d ppid:%d\n", getpid(), getppid());sleep(1);}}return 0;
}
可以看出,只要父进程不读取子进程的退出状态,子进程一直处于僵尸状态,而进程=内核数据结构task_struct+进程的代码和数据,僵尸进程虽然释放了大部分资源,但它的 task_struct(PCB)仍驻留在内核中没被释放,这就会导致内内存泄漏的问题。
3.4.2 孤儿进程
父进程如果提前退出,子进程后退出,子进程就称之为孤儿进程。
在Linux 中,这类进程会被 init(PID 1)自动收养,成为它们的子进程。
通过一段代码进行验证:
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>int main()
{pid_t id = fork();if (id < 0){perror("fork");return 1;}else if (id > 0){int cnt = 2; while (cnt--){printf("I am a parent process! pid:%d ppid:%d\n", getpid(), getppid());sleep(1);}}else{int cnt = 5; while (cnt--){printf("I am a child process! pid:%d ppid:%d\n", getpid(), getppid());sleep(1);}}return 0;
}
从上面可以看出父进程退出后,孤儿进程本身仍正常执行,收养过程对上层应用透明。
3.5 进程优先级
什么是优先级?
优先级决定了多个可运行进程争夺 CPU 时,内核调度器 先给谁执行 的权重和顺序。优先级高的进程更容易、更快地被分配到 CPU 上运行。Linux系统中优先级数字越小,优先级越高。
使用ps -l命令,可以显示每个进程的更多底层信息。
其中我们比较关注的几个重要信息如下:
UID:进程所有者的用户 ID。
PID:进程标识符(Process ID)。
PPID:父进程标识符(Parent PID)。
PRI:调度优先级(Priority)。数值越小,优先级越高(实时进程 PRI 通常比普通进程小得多)。
NI:nice 值,用于影响动态优先级。范围 −20(最高优先)到 +19(最低优先)。
什么是PRI和NI?
PRI也还是比较好理解的,即进程的优先级,或者通俗点说就是程序被CPU执行的先后顺序,此值越小进程的优先级别越高。数值越小 → 优先级越高 → 越容易被调度到 CPU 上运行。
而NI就是nice值了,其表示进程可被执行的优先级的修正数值PRI值越小越快被执行,那么加入nice值后,将会使得PRI变为:PRI(new)=PRI(old)+nice。
这样,当nice值为负值的时候,那么该程序将会优先级值将变小,即其优先级会变高,则其越快被执行。所以在Linux下,调整进程优先级,就是调整进程nice值。nice其取值范围是-20至19,一共40个级别。需要注意的是,进程的nice值不是进程的优先级,它们不是一个概念,但是进程nice值会影响到进程的优先级变化。
使用top命令更改已存在进程的nice。
命令行输入 top 进入 top
进入top后按 r –>输入进程PID–>输入nice值。
3.6 其他概念
竞争性: 系统进程数目众多,而CPU资源只有少量,甚至1个,所以进程之间是具有竞争属性的。为了高效完成任务,更合理竞争相关资源,便具有了优先级。
独立性: 多进程运行,需要独享各种资源,多进程运行期间互不干扰。
并行: 多个进程在多个CPU下分别,同时进行运行,这称之为并行。
并发: 多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,称之为并发。
4. 命令行参数
命令行参数(Command‐Line Arguments)是用户在终端输入可执行程序名后、选项(选项一般以 - 或 – 开头)和参数(如文件名、数字、字符串等)按空格分隔传递给程序的附加信息。命令行参数的本质是我们交给程序不同的选项,来定制不同的程序功能。 例如,命令中就会带很多的选项:
# ls 命令有两个参数:-l 和 /etc
ls -l /etc
在 C/C++ 中由 main(int argc, char *argv[]) 接收——其中 argc 是参数个数(含程序名),argv[]是命令行参数表。
argv[0] 是程序自身路径,argv[1]…argv[argc-1] 依次对应各个参数;它们允许同一程序根据外部输入的不同行为而执行不同逻辑。
命令行参数表char *argv[]是一个字符指针数组,是父进程传递过来的,并且命令行参数表以NULL结尾。
5. 环境变量
环境变量(environment variables)一般是指在操作系统中用来指定操作系统运行环境的一些参数。环境变量通常具有某些特殊用途,还有在系统当中通常具有全局特性。
比如下面是一些常见的环境变量:
PATH 可执行文件搜索路径,多个目录以冒号分隔
HOME 登录用户的主目录(默认工作目录)
USER 当前用户名
PWD 当前工作目录(等同 getcwd() 返回值)
查看环境变量的内容可以使用命令:echo $变量名。
可以看出PATH里面存储的是一串以冒号 : 分隔的目录列表。用来告诉 Shell 在哪里寻找你在命令行中输入的可执行程序(运行前要先找到该文件)。它的值是,Shell 会按照从左到右的顺序,依次在这些目录里查找对应的可执行文件。只要在某个目录下找到了,就立刻运行,后面的目录就不会再搜索。
这就是为什么执行系统指令的时候不用带路径,执行我们的程序需要 ./可执行文件。如果想让我们的程序像系统指令那样运行,可以将我们的程序所在路径加入到PATH变量中。
导环境变量使用命令:export name=value。
当我们退出,重新登陆Linux系统后,之前设置的环境变量就失效了。
这是因为环境变量是默认保存在进程环境里的(内存),当在终端用 export name=value设置后,只对当前 Shell 进程及它启动的子进程生效。退出登录,Shell 进程结束了,连同它维护的环境变量都消失了;重新登录,它的环境是从启动脚本(如/.bashrc)里重新加载的(环境变量最开始是保存在配置文件里的),之前只是临时 export,并没有写入这些文件,所以自然看不到老的变量。
取消环境变量可以使用命令:unset name。
查看所有环境变量可以使用命令:env。
进程环境表(environ)在内存中表现为一个 char *env[](或全局 extern char **environ;)的数组,每一项形如 “KEY=VALUE” 的字符串。
获取环境变量的三种方式:
- 通过命令行第三个参数。
- 使用第三方变量environ。
libc中定义的全局变量environ指向环境变量表,environ没有包含在任何头文件中,所以在使用时 要用extern声明。
- 通过系统调用获取或设置环境变量(putenv() getenv())。
环境变量通常是具有全局属性的,可以被子进程继承下去。
导出(export)与普通变量的区别:
在 Shell 里,只有用 export name=value 设置的变量,才会出现在 env/printenv 输出中,才会被子进程继承。
不带 export 的赋值(如 X=1)只是当前 Shell 的本地变量,不会传给子进程。
6. 程序地址空间
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>int global_val = 0;int main()
{pid_t id = fork();if (id < 0){perror("fork");}else if (id == 0){int cnt = 5;while (cnt--){printf("child[%d] global_val:%d %p\n", getpid(), global_val, &global_val);sleep(1);if (cnt == 3) global_val = 100;}}else{int cnt = 5;while (cnt--){printf("parent[%d] global_val:%d %p\n", getpid(), global_val, &global_val);sleep(1);}}return 0;
}
代码输出:
上面父子进程输出同一个变量的值不同,但是地址是相同的,因此该地址绝对不是物理地址,在Linux中这种地址叫做虚拟地址。我们在用C/C++语言所看到的地址,全部都是虚拟地址!物理地址,用户一概看不到,由OS统一管理,因此OS必须负责将虚拟地址转化成物理地址 。
进程地址空间是操作系统为每个进程虚拟出的一块连续的地址范围,它包含了该进程运行所需的所有虚拟内存区域(代码段、数据段、堆、栈、映射区等)。
进程地址空间并不存在于物理内存中,而是内核中一个结构体对象mm_struct。而是由页表(Page Tables)负责把它映射到真正的物理内存页面上。每个进程的 task_struct 里保存一个指针 ,指向该进程的 mm_struct。
每个程序都有自己的一块地址空间,主要是为了给上层应用提供一个连续、私有、安全、可管理的内存视图,同时让操作系统能够高效分配、隔离和保护底层物理内存。
地址虚拟化以后,编程更简单。如果没有虚拟地址空间,程序里所有指针都得直接操作物理地址,每个程序要么自己承担内存分配/回收、要么直接跑到别的程序内存里,很容易越界或相互干扰。虚拟地址空间给程序一个看似从 0 到 N连续的大块内存,程序只管用指针、malloc、new、栈操作,底层的地址映射、页分配都由操作系统和 MMU 透明处理。
每个进程的虚拟地址空间互不重叠——同一个虚拟地址在不同进程里可以映射到不同的物理页。一旦进程试图读写不属于自己的那块虚拟页,就会触发页错误(page fault),由操作系统拦截,避免越界写或偷窥别的进程数据,保护了物理内存。
操作系统可按需加载(Demand Paging)、写时拷贝(Copy-On-Write)、共享只读段(如代码段、共享库),大大减少实际物理内存使用,内存管理也变得更高效。
进一步理解页表和写是拷贝:
页表(Page Table) 与 写时拷贝(Copy-On-Write, COW) 是现代操作系统和硬件协作,实现虚拟内存的两项核心机制。
-
页表:虚拟地址到物理地址的映射表–负责 “虚拟 → 物理” 地址转换,并通过权限位保护内存安全。
每个进程拥有独立、连贯的虚拟地址空间;物理内存却是离散的、共享的。
页表就是在 CPU 的内存管理单元(MMU)里查表,把虚拟地址转换成物理地址。 -
写时拷贝(COW):高效的延迟复制策略–利用页表的读写权限标记,延迟在写操作时才真正做物理内存复制,极大地提升了多进程复制(如 fork) 的效率。
fork() 需要把父进程整个地址空间复制给子进程;如果立刻逐页拷贝,会非常耗时且浪费内存。而写时拷贝策略:初始共享:fork() 后,父子两份页表都指向同一组物理页,并把所有可写页在页表里标记为只读。当父或子进程第一次尝试在某页上写入时,CPU 在该页发现“只读”访问,触发一次 page-fault。操作系统捕获到这个 fault,就为写入的一方分配一个新的物理页,把原页内容复制过去,并在该进程页表里把该页“恢复为可写、指向新页”。写者在新页上继续写,另一方仍然继续在原页上读写(或写时再拷贝),互不干扰。
而在绝大多数场景下(父进程 fork 后马上 exec),子进程并不会修改大多数页,于是无需真的拷贝,大幅提高了 fork 的性能。只有真正写入的页才会付出一次拷贝代价。
7. Linux2.6内核进程调度队列
Linux 2.6 的 O(1) 调度器旨在实现 常数时间复杂度 的进程调度,即无论系统中有多少进程,调度器选择下一个进程的时间始终为 O(1)。
其核心机制基于 优先级队列 和 位图优化。
1.Runqueue(运行队列)
每个 CPU 维护一个 runqueue,包含以下核心成员:
活动队列(active):存放 仍有时间片 的进程。
- 时间片还没有结束的所有进程都按照优先级放在该队列。
- nr_active: 总共有多少个运行状态的进程。
- queue[140]: 一个元素就是一个进程队列,相同优先级的进程按照FIFO规则进行排队调度,所以,数组下标就是优先级!
从该结构中,选择一个最合适的进程,过程是怎么的呢?
- 从0下表开始遍历queue[140]。
- 找到第一个非空队列,该队列必定为优先级最高的队列。
- 拿到选中队列的第一个进程,开始运行,调度完成!
- 遍历queue[140]时间复杂度是常数!但还是太低效了!
- bitmap[5]:一共140个优先级,一共140个进程队列,为了提高查找非空队列的效率,就可以用5*32个比特位表示队列是否为空。通过 bitmap[5] 快速定位第一个非零位(即最高优先级队列),从该优先级队列的头部取出第一个进程。这样,便可以大大提高查找效率!
过期队列(expired):存放 时间片耗尽 的进程。
- 过期队列和活动队列结构一模一样。
- 过期队列上放置的进程,都是时间片耗尽的进程。
- 当活动队列上的进程都被处理完毕之后,对过期队列的进程进行时间片重新计算。
指针交换( active 和 expired):当活动队列为空时,交换 active 和 expired 指针,过期队列成为新的活动队列。
- active指针永远指向活动队列。
- expired指针永远指向过期队列。
- 可是活动队列上的进程会越来越少,过期队列上的进程会越来越多,因为进程时间片到期时一直都存在的。没关系,在合适的时候,只要能够交换active指针和expired指针的内容,就相当于有具有了一批新的活动进程!
2.优先级与队列管理
优先级范围:
普通优先级:100~139(对应 nice 值 -20~19)。
实时优先级:0~99(实时进程专用,不依赖时间片,不关心)。
优先级数组(prio_array_t):
queue[140]:每个元素是一个链表,存放同一优先级的进程(按 FIFO 调度)。
bitmap[5]:5 个 unsigned long(32 位)共 160 位,覆盖 140 个优先级。每一位标记对应优先级的队列是否非空。
3.调度流程
每个进程被分配一个时间片,在 CPU 上运行直至耗尽。时间片耗尽后,进程从活动队列移动到过期队列,并重新计算时间片。
当活动队列(active)为空时:
swap(active, expired); // 交换指针
过期队列成为新的活动队列。原活动队列重置为空队列,准备接收新的过期进程。
至此,本片文章就结束了,若本篇内容对您有所帮助,请三连点赞,关注,收藏支持下。
创作不易,白嫖不好,各位的支持和认可,就是我创作的最大动力,我们下篇文章见!
如果本篇博客有任何错误,请批评指教,不胜感激 !!!