背景知识
1.核心结论:CPU与内存交互
CPU通过内存与外设进行交互,实现数据计算与处理。在进行计算时,数据需要先从外设加载到内存中进行处理,而计算结果再通过内存写回外设。这一过程中,内存充当着一个缓冲区的角色,确保数据的持久性和一致性。内存不仅是CPU计算数据的地方,也是CPU和外设之间交换数据的中介。
2.CPU与指令集
2.1 CPU的工作方式
- CPU并不主动去执行某些操作,它是通过接收和执行外部程序提供的指令来工作的。CPU的执行方式依赖于指令集架构(ISA, Instruction Set Architecture),这是一个定义了CPU能够理解和执行的指令集合。
- 不同的CPU采用不同的指令集,比如x86、ARM等。指令集不仅指定了CPU可以执行的操作,还定义了程序与硬件如何交互的方式。因此,CPU可以在同一套指令集的框架下执行各种复杂的任务。
2.2 指令集的作用
指令集充当了CPU与程序之间的桥梁。程序中的代码通过操作指令集来控制CPU,指令集定义了如何从内存读取数据、进行算术计算、控制程序的执行流程等操作。例如,程序会通过“ADD”指令告诉CPU进行加法运算,或者通过“JMP”指令改变程序的执行流程。
指令集的设计不仅影响程序执行的效率,也直接关系到硬件的资源利用率,因此,它也是架构设计中的一个重要决策点。
3.管理的本质:对数据的管理
3.1 感性认识
管理本质上是对数据的管理。可以把管理者和被管理者类比为操作系统与硬件之间的关系,操作系统作为管理者,硬件作为被管理者。操作系统通过其提供的接口与硬件进行交互,以实现对硬件资源的有效管理。
3.2 理性认识
当数据量变得庞大时,简单的存储方法无法有效管理这些数据。这就需要通过数据结构和算法来进行管理,操作系统也同样需要采用数据结构来组织和管理系统中的资源(如内存、硬盘、进程等)。
例如,操作系统使用链表、队列等数据结构来管理进程的调度、内存的分配和回收等任务。通过对数据结构的抽象和管理,操作系统可以有效地进行资源分配和调度。
示例:进程信息管理
struct Process {
int pid; // 进程ID
string state; // 进程状态
void* memory_space; // 进程的内存空间
// 其他进程相关信息
};
3.3 操作系统的作用
操作系统是一个管理软硬件资源的软件,负责为用户提供稳定、高效和安全的执行环境。
3.4 为什么要管理?
对下(硬件层面):通过合理的管理硬件资源,操作系统可以确保系统运行的稳定性和高效性。这包括内存管理、CPU调度、设备管理等。
对上(软件层面):操作系统通过提供各种接口(如系统调用、库函数)为用户程序提供方便的运行环境。程序员通过这些接口与操作系统交互,操作系统则负责具体的资源管理工作。
通过这些管理,操作系统能够让程序员更高效地使用硬件资源,并使系统的各项操作变得透明和稳定。
4.进程管理
4.1 进程的本质
- 程序与进程:程序是存储在硬盘上的一组指令,而进程是加载到内存中并正在执行的程序。当一个程序运行时,操作系统会为它分配内存和其他资源,从而将它转变为一个进程。进程是操作系统进行资源管理的基本单位。
4.2 冯诺依曼架构
冯诺依曼架构规定,程序执行的基本要求是将程序加载到内存中,然后CPU从内存中读取指令并执行。操作系统通过加载程序和管理进程的生命周期来确保程序能够顺利执行。
加载程序:操作系统会将程序从磁盘加载到内存中,并为其分配必要的内存空间。加载过程可能涉及将程序分段或分页到内存的不同位置。
管理进程:当有多个进程同时运行时,操作系统需要管理它们的执行。操作系统通过进程控制块(PCB,Process Control Block)来保存进程的所有状态信息,包括进程的寄存器状态、内存映射、进程的调度信息等。
4.3 PCB与任务结构
每个进程都有一个对应的进程控制块(PCB),操作系统使用它来管理进程的状态和资源。task_struct
是Linux内核中用于表示进程的结构体,它包含了关于进程的所有信息,如进程的状态、调度信息、内存管理信息等。
struct task_struct {
int pid; // 进程ID
enum state {TASK_RUNNING, TASK_SLEEPING, TASK_STOPPED} state; // 进程状态
void* memory_space; // 进程的内存空间
// 其他管理信息
};
进程管理的基本操作就是对这些task_struct
对象进行增删查改,操作系统通过调度算法决定哪个进程在什么时候运行。
进程 = 内核数据结构(
task_struct
) + 进程对应的磁盘代码。操作系统的管理工作主要是对这些内核数据结构的操作,通过调度算法和资源分配策略,保证系统高效且公平地运行多个进程。
进程内存布局
每个进程所分配的内存有很多部分组成,通常称之为”段(segment)“。如下所示:
- 文本段包含了进程运行的程序机器语言指令。文本段具有只读属性。因为多个进程可同时运行同一程序,所以又将文本段设为可共享。
- 初始化数据段包含显示初始化的全局变量和静态变量。当程序加载到内存时,从可执行文件中读取这些变量的值。
- 未初始化数据段包含了未进行显示初始化的全局变量和静态变量。程序启动之前,系统会将本段内所有内存初始化为0。可执行文件只需记录未初始化数据段的位置及所需大小,直到运行时再由程序加载器来分配这一空间。
- **栈(stack)**是一个动态增长和收缩的段。有栈帧(stack frames)组成。系统会为每个当前调用的函数分配一个栈帧。栈帧中存储了函数的局部变量(即自动变量)、实参和返回值。后续深入探讨
- **堆(heap)**是可在运行时(为变量)动态进行内存分配的一块区域。堆顶端称作program break。
程序变量在进程内存各段中的位置:
#include <stdio.h>
#include <stdlib.h>
char globBuf[65536]; /* 未初始化数据段 */
int primes[] = { 2, 3, 5, 7 }; /* 初始化数据段 */
static int
square(int x) /* 分配在 square() 函数的栈帧中 */
{
int result; /* 分配在 square() 函数的栈帧中 */
result = x * x;
return result; /* 返回值通过寄存器传递 */
}
static void
doCalc(int val) /* 分配在 doCalc() 函数的栈帧中 */
{
printf("The square of %d is %d\n", val, square(val));
if (val < 1000) {
int t; /* 分配在 doCalc() 函数的栈帧中 */
t = val * val * val;
printf("The cube of %d is %d\n", val, t);
}
}
int main(int argc, char *argv[])/* 分配在 main() 函数的栈帧中 */
{
static int key = 9973; /* 初始化数据段 */
static char mbuf[10240000]; /* 未初始化数据段 */
char *p; /* 分配在 main() 函数的栈帧中 */
p = malloc(1024); /* 指向堆段中的内存 */
doCalc(key);
exit(EXIT_SUCCESS);
}
C/C++地址空间分布图:
注意:这一布局存在于虚拟内存中。因为对虚拟内存的理解有助于后续对诸如fork()
系统调用、共享内存和映射文件之类主题的阐述,所以这里将探讨一些有关虚拟内存的详细内容。
虚拟内存的规划之一是将每个程序使用的内存切割成小型的、固定大小的“页”(page)单元。相应的RAM
划分成一系列与虚拟内存页形同的页帧。任一时刻,每个程序仅有部分页需要驻留在物理内存页帧中。若进程欲访问的页面目前并未驻留在屋里内存中,将会发生页面错误(page fault),内核即刻改期进程的执行,同时从磁盘中将也买你载入内存。
1.虚拟空间与地址空间
每个进程通常认为它拥有整个内存空间,这类似于云存储服务为每个用户提供独立的存储空间,或者银行为每个用户提供独立的账户空间。然而,操作系统并不允许进程直接访问物理内存。操作系统必须负责将虚拟地址转化为物理地址。
1.1 地址空间的基本概念
- 地址空间是由一系列唯一地址构成的,基本单位是字节。
- 在32位系统下,地址空间大小为: 2^32个地址 * 1字节 = 4GB 空间范围。
- 每个字节都有唯一的地址,以便操作系统可以管理和访问内存。
1.2 为什么存在地址空间
隔离与安全性:直接让进程访问物理内存可能导致非法访问,比如越界操作或者修改其他进程数据。操作系统通过虚拟地址与页表机制,能够有效拦截进程对不该访问内存的非法请求,保障系统安全。
进程独立性:每个进程应当拥有独立的内存空间。一个进程对数据的修改如果影响到其他进程,就失去了“对立性”。操作系统通过虚拟内存中“写时拷贝”(Copy-on-Write,COW)技术来保证进程的独立性。
- 写时拷贝:但父子进程共享数据并尝试修改时,操作系统会修改数据分配新的物理内存,并修改页表的映射关系,确保每个进程都拥有自己的独立的内存空间。
进行与数据、代码的解耦:地址空间的存在使得进程的代码、数据、堆栈等区域得以独立管理,从而保证了进程的独立性与隔离性。进程只需要关心它自己都拥有的地址空间,而不需要了解其他进程的内部数据结构。
统一视角与便携管理:地址空间为每个进程提供了一致的虚拟地址视图。这使得编译编译器可以用统一的方式生成代码,而不需要考虑物理内存的复杂性。进程的代码、数据、栈等各个区域都可以在虚拟地址空间中按照统一规则排列,有助于提高内存管理的效率和程序的可移植性。
针对写时拷贝,我们来看一下下面这个现象:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_val = 0;
int main(){
pid_t id = fork();
if(id < 0){
perror("fork");
return 0;
}
else if(id == 0){ //child,
g_val=100;
printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);
}else{ //paren
sleep(3);
printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);
}
sleep(1);
return 0;
}
输出结果:
chi1d[7539] : 100 0x601058
parent[7538] : 0 0x601058
为什么会看到不同的结果?
父进程和子进程访问同一变量 g_val
的虚拟地址(&g_val
),但它们的物理内存是独立的。因此:
- 子进程 在
fork()
后会将g_val
的值修改为 100,但由于写时复制,子进程会将其修改的内存页复制到新的物理内存页。子进程的虚拟地址指向新的物理内存页。 - 父进程 在子进程修改
g_val
后,g_val
在父进程中仍然保持为 0。父进程的虚拟地址没有发生变化,因为父进程的物理内存页没有被修改,所以它仍然指向原始的物理内存。
因此,尽管父进程和子进程的 g_val
在虚拟地址空间中看似相同,但由于它们的物理内存不同,父进程和子进程会显示不同的值。
总结:我们发现多进程在读取同一个地址的时候,出现了不同的结果,因为语言基本的地址(指针)不是对应的物理地址,而是虚拟地址(线性地址)[逻辑地址]。
环境变量
PATH:指定命令的搜索路径
HOME:指定用户的主工作目录(即用户登录到Linux系统中时,默认的目录)
SHELL:当前Shell,它的值通常是/bin/bash.
echo $[NAME]
NAME->环境变量名称:查看环境变量。export
bash就是一个系统进程,
$./mycmd
也会变成一个进程(fork),是bash的子进程
环境变量具有全局属性是会被子进程继承下去(为什么要?为了不同的应用场景->让bash帮我找指令路径,身份认证)。
本地变量->只会在当前进程(bash)内有效。
环境变量相关命令
echo
:显示某个环境变量值。export
:设置一个新的环境变量。env
:显示所有环境变量。unset
:清除环境变量。set
:显示本地定义的shell变量和环境变量。
获取环境变量的三种方式:
getenv()
√
#include <stdio.h>
#include <stdlib.h>
int main(){
printf("%s\n",getenv("PATH"));
return 0;
}
char *env[]
#include <stdio.h>
int main(int argc, char *argv[], char *env[]){
int i = 0;
for(; env[i]; i++){
printf("%s\n", env[i]);
}
return 0;
}
extern char **environ
;
#include <stdio.h>
int main(int argc, char *argv[]){
extern char **environ;
int i = 0;
for(; environ[i]; i++){
printf("%s\n", environ[i]);
}
return 0;
}
进程概述
进程(process)是一个可执行程序(program)的实例。可以用一个程序来创建多个进程,或者反过来说,许多进程运行的可以是同一程序。
从内核角度看,进程有用户内存空间(user-space memory)和一系列内核数据结构组成,其中用户内存空间包含了程序代码及代码所使用的变量,而内核数据结构则用于维护进程状态信息。
1.进程状态的概念
1.1 进程的常见状态
CPU要有运行程序第一步先将要运行的程序加载到对应的内存中,系统管理进程采用先描述再组织
的方式,就会有一个对应的进程控制块结构体包含进程的所有属性,并把这个进程控制块放到运行队列里。操作系统通过进程控制块(PCB)来维护进程的状态,进程控制块包含了关于进程的信息,如状态、优先级、程序计数器、寄存器内容、内存指针等。根据进程在不同队列中的位置,进程会处于不同的状态:
/*
* 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 */
};
R
运行状态(running):并不意味着进程一定在进行中,它表明进程要么实在运行中要么是在运行队列里。S
睡眠状态(sleeping):意味着进程在等待时间完成(这里的睡眠有时候也叫做可中断睡眠(interruptible sleep))D
磁盘休眠状态(Disk sleep)有时候也叫不可中断睡眠状态(uninterruptible sleep),在这个状态的进程通常会等待IO的结束。T
停止状态(stopped): 可以通过发送 SIGSTOP 信号给进程来停止(T)进程。这个被暂停的进程可以通过发送 SIGCONT 信号让进程继续运行。X
死亡状态(dead):这个状态只是一个返回状态,你不会在任务列表里看到这个状态。Z
僵尸状态(zombie):表示进程已终止,但其父进程尚未调用wait()
来收集其退出状态,因此它仍然存在于进程表中。
在进程管理中,进程的生命周期包括多个不同的状态,这些状态反映了进程在不同时刻的执行情况。常见的进程状态包括:运行、就绪、阻塞、挂起、停止等。
1.2 初识fork()
在Unix/Linux系统中,fork()
是用于创建新进程的关键系统调用。它会复制当前进程(父进程)的所有内容,包括程序代码、数据、堆栈等,形成一个子进程。父子进程拥有独立的进程控制块(PCB)和内存空间,但它们共享相同的代码段和数据段。
工作原理:
- 复制父进程:
fork()
会复制父进程的所有内容,包括程序代码、数据、堆栈等,形成一个新的子进程。- 子进程和父进程具有相同的代码段和数据段,但它们各自拥有独立的内存空间和进程控制块(PCB)。
- 返回值:
- 父进程:
fork()
在父进程中返回子进程的 PID(进程标识符)。 - 子进程:
fork()
在子进程中返回 0。
- 父进程:
- 独立性:尽管子进程和父进程共享相同的代码段和数据段,但它们在内存中是独立的。这意味着子进程对数据的修改不会影响父进程的数据,反之亦然。
父进程一般是shell进程(例如bash),通过调用fork()
来创建新的子进程执行具体任务。
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <stdlib.h>
int main() {
pid_t pid = fork();
if (pid < 0) {
// fork 失败
perror("fork 失败");
return 1;
} else if (pid == 0) {
// 子进程
printf("我是子进程,PID=%d,PPID=%d\n", getpid(), getppid());
} else {
// 父进程
printf("我是父进程,PID=%d,子进程PID=%d\n", getpid(), pid);
}
return 0;
}
pid_t pid = fork();
调用fork()
创建子进程;pid
的值决定当前进程是父进程还是子进程。if (pid < 0)
:fork()
调用失败;else if (pid == 0)
:当前进程是子进程;else
:当前进程是父进程。
2.阻塞与挂起
2.1 阻塞
- CPU 维护一个运行队列:CPU 维护一个运行队列(
runqueue
),用于管理处于运行状态的进程。 - 进程入队列:将进程的
task_struct
结构体对象放入运行队列中。 - 运行状态:进程的
task_struct
结构体对象在运行队列中时,称为运行状态(R
),但这并不意味着进程实际在运行。- 状态:进程状态是进程内部的一个属性,存储在
task_struct
结构体中。 - 状态类型:常见的进程状态包括:
RUNNING
(运行)、STOPPED
(停止)、ZOMBIE
(僵死)、DEAD
(已终止)
- 状态:进程状态是进程内部的一个属性,存储在
- 资源需求:进程不仅等待 CPU 资源,还可能需要访问外设资源。
- 状态转换:进程的不同状态本质上是进程在不同的队列中等待某种资源。
CPU 只运行运行队列中处于 R
状态的进程。当进程需要访问(等待)外设资源时,CPU 会将其从运行队列中移除,放入等待队列中,并将其状态修改为阻塞状态(BLOCKED
)。
ps.在银行办理业务时,需要填写一个表格这个表格可能要花费你十分钟时间填写,这个时候时候柜台人员会让你先去旁边的桌子上去填写并让你的下一个人上前来办理。
当 CPU 空闲时,操作系统会从等待队列中选择一个进程,将其状态调整为 R
状态,并将其 PCB
加入运行队列。之后,CPU 可以正常调用该进程。
2.2 挂起
系统里存在很多进程,此时如果大部分进程都要访问外设导致大量阻塞,此时会有大量的程序的PCB不会被调度也就会有同样大量的数据和代码不会被立即使用,而此时如果还有大量的程序需要被调用的话而内存空间不够的话就造成了内存空间的浪费。这个时候CPU出手了,它把这些占着茅坑不拉屎的数据和代码整个移动(暂时保存)到磁盘当中,而和这些数据代码链接的PCB保留在内存中,从而节省大量的内存空间,此时的进程就叫挂起状态。
ps.当银行大厅人满为患时,工作人员会让一些客户先去外面等候,等大厅有空位时再让他们进来办理业务。
将进程的相关数据加载或保存到磁盘,称为内存数据的换入换出。
阻塞不一定挂起,挂起一定阻塞
深入理解进程控制块(PCB)
进程控制块(PCB,Process Control Block) 是操作系统用来管理和控制进程的关键数据结构。每当一个进程在操作系统中创建时,操作系统会为该进程分配一个PCB,它包含了进程运行所需的所有状态信息。通过PCB,操作系统能够追踪进程的状态、控制进程的执行以及管理资源的分配。
1. PCB的作用
标识进程:每个进程都会被分配一个唯一的进程标识符(
PID
)。这个标识符帮助操作系统区分不同的进程,并跟踪其执行情况。保存进程状态:操作系统通过PCB记录进程的当前状态(如运行、就绪、阻塞等)。状态变化时,操作系统根据进程的状态切换其执行上下文。
管理进程资源:PCB中存储了进程使用的资源信息,如内存、文件描述符、I/O设备等。操作系统通过它来分配、回收和管理这些资源。
调度信息:操作系统根据PCB中的调度信息决定哪个进程应该执行。例如,调度优先级、CPU时间片、进程的排队顺序等。
2. PCB的组成部分
一个典型的PCB结构包含以下主要部分:
进程标识符(PID):唯一标识一个进程的ID。
进程状态(State):记录进程的当前状态,通常包括:
TASK_RUNNING
:进程正在运行。TASK_READY
:进程已准备好运行,等待分配CPU时间。TASK_BLOCKED
:进程由于某些原因被阻塞,无法继续执行(如等待I/O操作完成)。
程序计数器(Program Counter,PC):保存着进程将要执行的下一条指令的地址,操作系统通过它知道进程暂停执行的位置,以便恢复时能够从正确的地方继续执行。
CPU寄存器的内容:在进程上下文切换时,操作系统需要保存当前进程的CPU寄存器的值(如通用寄存器、堆栈指针等),以便下次恢复时能继续从中断点处执行。
内存管理信息:存储进程使用的内存分配信息,包括:
- 进程的代码段、数据段、堆和栈的地址。
- 页面表或段表,用于虚拟内存管理。
I/O状态信息:保存进程使用的I/O资源的状态,如打开的文件描述符、待处理的I/O请求等。
调度信息:操作系统需要根据进程的优先级、时间片、排队等信息进行调度,调度信息包括:
- 进程的优先级(如高优先级、低优先级)。
- 进程的时间片(每次允许该进程运行的时间长度)。
- 上次调度的时间或CPU占用信息。
父子进程关系:记录进程与其父进程、子进程的关系。在操作系统中,进程的创建通常是通过父进程创建子进程,因此父子进程之间的关系是非常重要的。
信号与同步信息:保存进程间的同步和通信信息。例如,进程正在等待的信号量或事件、信号的处理状态等。
3. PCB的生命周期
一个进程的生命周期从创建开始,到执行,最后终止。在此过程中,PCB会不断更新,以反映进程状态的变化。进程的生命周期大致可以分为以下几个阶段:
创建:操作系统创建一个新的进程时,会为其分配一个新的PCB,并初始化各项资源和状态信息。
就绪状态:当进程准备好运行时,它进入就绪队列,等待CPU分配时间。当进程被调度执行时,操作系统会将进程的PCB从就绪队列中取出。
运行状态:CPU开始执行进程。此时,进程的PCB中会保存当前的程序计数器(PC)和寄存器信息。当发生上下文切换时,操作系统会保存这些信息并恢复其他进程的状态。
阻塞状态:如果进程需要等待某个事件(例如I/O操作完成),它会进入阻塞状态,并暂时无法继续执行。在此过程中,进程的PCB会记录等待事件的具体信息。
终止:当进程完成任务或被强制终止时,操作系统会释放进程占用的资源,销毁该进程的PCB。
4. PCB与上下文切换
上下文切换是操作系统中一个非常重要的过程,它涉及将当前进程的状态保存到PCB中,并将下一个进程的状态从其PCB中恢复。这一过程使得操作系统能够高效地管理多个进程并实现多任务。
保存当前进程状态:操作系统首先保存当前运行进程的CPU寄存器内容、程序计数器、堆栈指针等到其PCB中。
恢复下一个进程状态:操作系统从下一个进程的PCB中恢复出其程序计数器、寄存器内容等,准备让该进程继续执行。
上下文切换是实现进程调度的基础,它允许操作系统同时管理多个进程,并确保每个进程能够在适当的时候得到CPU时间。
5. PCB与多核处理器
在多核处理器系统中,每个核心都有自己的调度队列和PCB。操作系统需要协调这些核心,确保它们之间的进程调度不会冲突,并最大化利用处理器资源。虽然每个核心都有自己的PCB,但它们共享系统的全局进程列表和调度信息。
Linux进程管理状态
在Linux系统中,进程状态的管理是操作系统调度和资源分配的重要部分。系统根据进程的行为、资源需求以及系统资源的可用情况将进程分配到不同的状态。常见的进程状态包括 R
(运行)、S
(睡眠)、D
(深度睡眠)、T
(停止)、Z
(僵尸)、X
(死亡)等。以下是更详细的介绍,特别是关于 D
、Z
状态,以及进程管理中的一些重要机制。
当进程存在大量访问外设的行为时,这时访问进程状态可能只能访问到D
死亡状态,这是因为系统很难捕捉到R
状态。
进程状态的具体操作
暂停进程(T
状态):
- 通过
kill -19 [进程号]
命令可以暂停一个进程,使其进入T
(停止)状态。此时,进程会被暂停执行,但它的资源会被保留,随时可以通过kill -18 [进程号]
恢复。 T
状态的进程不能继续执行,直到被恢复。可以使用SIGSTOP
信号来暂停进程,SIGCONT
信号来恢复。
暂停进程(S
\R
状态):
- 通过
kill -18 [进程号]
,可以让进程暂停并进入S
(可中断睡眠)或R
(运行)状态。这里的区别在于进程是否能被中断。
杀死进程(X
状态):
- 通过
kill -9 [进程号]
命令可以强制杀死进程。进程将进入X
(死亡)状态,之后会被内核回收。 kill -9
是一种强制终止进程的方式,通常用于无法正常终止的进程。注意,使用kill -9
时,进程会直接终止,不会正常清理资源。
注:在ps查看进程状态信息时状态符号后有
+
表示该进程为前台进程,可以通过shell命令杀死,如:Ctrl+C
。没有+
表示该进程为后台进程,此时只能通过发送信号(如kill -9 [进程号]
)的方式杀死该。
X
死亡状态下的进程会被系统立即或延迟回收,因此不可察觉。
深度睡眠(D
状态)
为什么会进入D
状态?
比如当进程A在内存中需要执行大量的IO程序,然后内存就呼叫磁盘来完成将进程A挂起,当在等待进程A的反馈时系统中又产生了大量的进程,当内存中产生大量进程的时候操作系统第一时间回采用挂起策略,而当压力更大的时候操作系统发现挂起也解决不了了内存空间紧张的问题时,(Linux)操作系统就会自主的杀掉一些进程(服务器压力测试时容易挂掉的原因),这是就很可能产生程序崩溃甚至系统崩溃。此时操作系统就设计出了一个新的状态->D
状态,在该状态下的进程就好比挂了一个免死金牌,无法被OS杀掉!只能通过断电或者进程自己醒过来解决。
深度睡眠状态的危害:
- 如果系统内有大量进程处于
D
状态,可能表示系统正在经历严重的I/O瓶颈。此时,如果系统资源紧张(例如内存不足),操作系统可能会启动进程挂起策略,将不活跃的进程转移到磁盘上。进一步的资源短缺可能会导致系统崩溃,尤其在压力较大的服务器环境中。 - 在某些情况下,进程在
D
状态下可能永远无法恢复,除非外部条件改变,如I/O操作完成或系统重新启动。 - 此状态只会出现在高IO的情况下才会产生,当服务器中产生了大量的D状态时,表明这个状态已经在崩溃的边缘了。
如何避免D
状态的过度产生?
- 避免进程频繁进行长时间的阻塞I/O操作。
- 监控I/O性能,优化磁盘访问,避免磁盘I/O成为系统的瓶颈。
僵尸状态(Z
状态)
一个进程完成任务之后不会立即回收而是需要父进程(通过
wait()
/waitpid()
)或者操作系统来获取它的退出结果后再判断是否转为X
死亡状态之后再供父进程或操作系统去回收,而在等待的这个时间段就被成为Z
僵尸状态。
方便查看状态的shell命令
while :; do ps axj | head -1 && ps axj | grep myprocess | grep -v grep && echo "************************************************************************"; sleep 1; done
僵尸进程的危害:
资源浪费:即使进程已经结束,它的进程控制块(PCB)仍然占用系统资源。如果产生大量僵尸进程,会导致系统资源(如内存、进程表条目等)浪费。
内存泄漏:僵尸进程占用内存并未被及时回收,可能导致系统内存资源紧张。
如何避免僵尸进程?
- 父进程需要通过
wait()
或waitpid()
系统调用来回收子进程的退出状态。 - 如果父进程不能及时回收退出状态,操作系统会将子进程的
init
进程作为作为父进程,并由init
进程回收僵尸进程。
孤儿进程
孤儿进程是指父进程如果提前退出,而子进程仍然存活的进程。孤儿进程不会留下僵尸状态,因为孤儿进程会自动将它们的父进程指派给init
进程(PID 1)领养,由init
进程回收,避免资源泄露。如果是前台进程创建的子进程变成了孤儿进程就会由前台进程变为后台进程。
为什么这么做?如果不领养,那么当子进程退出时候对应的僵尸进程将无人回收,就会造成内存泄漏。
进程优先级(了解)
1. 什么叫做优先级?
优先级是一种相对顺序,用于决定进程执行的先后次序。它描述了一个进程能被调度的紧迫程度,通常表示为一个数字,数值越小表示优先级越高,即越早被执行。
权限与优先级的区别:权限和优先级是两个不同的概念,前者涉及**“能不能”,后者涉及“先后顺序”**。
- 权限:决定进程能否访问某些资源,涉及到操作系统的安全和资源访问控制。
- 优先级:在系统资源(如CPU)有限的情况下,决定哪些进程能够更快地获得资源。
2. 为什么存在优先级?
存在优先级的主要原因是资源有限,需求多。在多进程操作系统中,CPU资源是稀缺的,多个进程需要在有限的CPU时间上进行竞争。如果所有进程都具有相同的调度优先级,操作系统将无法有效地分配资源,可能导致一些重要的任务无法及时完成。因此,操作系统引入了进程优先级机制来合理分配资源,使得高优先级的进程能够更快地执行,降低低优先级进程的等待时间。
3. Linux的进程优先级
Linux中的进程优先级并不仅仅是一个简单的数值,它实际上由两个关键参数来确定:PRI
和 NI
。
- UID:执行进程的用户标识符。
- PID:每个进程的唯一标识符。
- PPID:父进程代号
- PRI:进程的基本优先级值,值越小,优先级越高。
- NI:进程的nice值,也就是调节优先级的手段,控制进程在某一时刻的调度优先级。
Linux中优先级计算公式:最终优先级=旧优先级[80]+NI值
PRI:是系统设定的一个固定值,通常情况下,系统会为每个进程分配一个默认的优先级值(通常为80)。优先级越低,表示该进程的优先级越高。
NI (nice):是用户可调整的值,控制进程的相对优先级。
nice
值的取值范围是[-20, 19]
,其中-20
表示最高优先级,19
表示最低优先级。
因此,Linux中的进程优先级是PRI
与NI
相加的结果,PRI
值较小表示进程优先级较高,而NI
值调整了进程的优先级,nice
值越低,表示该进程会比其他进程更频繁地获得CPU时间。
上下文切换
在进程切换时,操作系统需要进行上下文切换,也就是保存当前进程的状态并恢复下一个进程的状态。上下文切换涉及到寄存器、程序计数器、堆栈等信息的保存与恢复,确保每个进程在恢复时能够从上次中断的地方继续执行。
- 每个进程在运行时都有一套自己的上下文数据,这些数据包括寄存器中的内容、程序计数器、堆栈指针等。
- 操作系统通过**进程控制块(PCB)**来管理进程的上下文。每个进程在操作系统中都有一个PCB,包含了与该进程相关的所有状态信息。
上下文切换是操作系统进行进程调度时不可避免的一部分,频繁的上下文切换可能带来一定的性能损耗。
其他概念
- 竞争性:在多进程操作系统中,进程之间在有限的资源(如CPU、内存、I/O等)上竞争,进程优先级是解决这种竞争的重要手段。
- 独立性:每个进程在运行时都拥有自己的独立资源,包括内存、文件描述符等。进程之间是相互独立的,不会直接影响对方的资源。
- 并行:在多核处理器上,不同的进程或线程可以同时在多个CPU核心上运行,从而实现真正的并行处理。
- 并发:在单核处理器上,多个进程通过时间分片的方式轮流使用CPU,进程看似“同时”运行,实际上是CPU在不同进程之间切换的结果,这种情况称为并发。
进程控制
1.进程创建
1.1 再识fork - 深入理解
先来两个问题:
如何理解
fork
返回之后,给父进程返回子进程id
而给子进程返回0
?答:因为对于父进程来说它可以有多个子进程,但子进程的父进程具有唯一性.
同一个
fork()
如何导致父子进程执行不同代码这个问题换个问法就是说:如何理解同一个
pid_t id = fork()
值,为什么会保存两个不同的值让if-else if
同时执行?回答这个问题我们要在回归一下在调用
fork()
之后,操作系统会创建一个新的进程(子进程),并把这个子进程加入到进程调度队列。此时,父进程和子进程都从fork()
返回的地方继续执行,但它们的返回值不同。- 父进程返回子进程的PID,表示它知道新创建了一个进程。
- 子进程返回
0
,表示它是由父进程创建的。
因此即使
fork()
的返回值是同一个pid_t id = fork()
,在父进程和子进程中,id
的值是不同的。父进程中id
存储的是子进程的PID,而子进程中id
是0
。因此,if-else if
语句可以根据id
的值来判断当前是父进程还是子进程,执行不同的代码。同时
fork()
在底层实际上做了下面的操作:- 内存分配:操作系统为子进程分配一个新的内存块(新的地址空间)。子进程的数据段、堆栈等会与父进程隔离。
- 拷贝父进程的部分数据结构:操作系统将父进程的一些信息(如文件描述符、程序计数器等)复制给子进程。此时,父子进程在用户空间的数据是隔离的,但共享代码段和某些资源。
- 创建子进程:子进程的PCB(进程控制块)被添加到系统进程表中,操作系统将子进程添加到进程调度队列。
- 返回值:父进程返回子进程的PID,子进程返回
0
。 - 调度器调度:当父子进程都准备好后,调度器将根据调度策略决定谁先执行。因此,
fork()
之后谁先执行完全由操作系统的调度器决定,这也是fork()
之后父子进程的执行顺序不确定的原因。
当函数准备
return
时,核心代码就已经执行完了,再此之前内核就已经对产生了两个执行流.由于返回的本质就是写入,此时父子进程谁先返回谁就先写入id,
并且由于进程具有独立性,就会产生写时拷贝.于是我们就能看到同一个id
,地址明明是一样的,但是其内容却不一样.
fork
常规用法
- 并行处理:父进程通常会在等待外部事件(如客户端请求)时通过
fork()
创建子进程来处理这些事件。例如,Web服务器在接收到客户端请求时,通过fork()
创建一个子进程来处理该请求,而父进程继续监听新的请求。 - 执行新程序:父进程可以通过
fork()
创建一个子进程,子进程可以通过exec()
族系统调用执行不同的程序。这种方式使得进程能够在不退出的情况下执行新的任务。
fork
调用失败的原因
虽然fork()
是一个非常强大且常用的系统调用,但在某些情况下它会失败。fork()
失败通常是由于系统资源限制或操作系统配置引起的:
- 系统进程数达到限制:操作系统对每个用户可以创建的进程数有上限。如果系统中的进程数量达到限制,
fork()
会返回-1
,表示无法创建新的进程。 - 内存资源不足:
fork()
调用会分配新的内存给子进程,如果系统没有足够的内存,fork()
会失败。通常,这种情况发生在系统资源紧张时,特别是在进程数量很多或系统内存不足时。 - 用户进程数限制:操作系统通常会限制每个用户可以创建的最大进程数。如果用户创建的子进程数量超过了这个限制,
fork()
也会失败。
2.进程终止
进程终止时操作系统中非常重要的一个概念。一个进程的生命周期最终会以其退出或终止作为结束。进程退出时会给操作系统留下退出码(Exit Status),用于标明进程执行的结果。
2.1 进程退出场景
进程的退出有多种方式,通常可以分为以下几种情况:
- 代码运行完毕,结果正确。->
return 0;
- 代码运行完毕,结果不正确。->
return !0;
(此时才能体现退出码的效果,表示发生错误) - 代码异常终止。->此时退出码无意义
2.2 进程常见退出方法
进程退出时,可以通过多种方式来实现。以下是常见的几种退出方式:
从
main
函数返回:在C语言中,程序执行完main
函数中的代码后,可以通过返回值来结束程序。通常,return 0;
表示程序正常退出,返回非零值表示程序异常退出。调用
exit()
函数:exit()
函数用于终止进程,并允许程序执行一些清理操作(如释放资源、关闭文件描述符、写入缓冲区等)。在调用exit()
时,程序将执行注册的退出处理函数(如atexit()
注册的清理函数)。调用
_exit()
函数:_exit()
函数直接终止进程,不做任何清理工作。_exit()
函数常用于子进程退出时,不需要做清理工作或者缓冲区刷新的情况。信号终止:使用控制台或其他信号机制可以发送终止信号给进程,例如
Ctrl + C
发送 SIGINT 信号来终止进程,或者使用kill
命令发送信号终止进程。
关于
main
函数返回值:一般情况下默认返回
return 0;
即可。但若需要进程退出码的时候,要返回特定的数据表明特定的错误即可。用非零退出码表示错误,可以用不同的非零值标定不同的错误。
但是由于用数值的方式对人类来说不友好因此一般来说,退出码都必须要有对应的退出码的文字描述。->
strerror()
2.3 退出码($?
)
进程退出后可以通过echo $?
查看进程退出码(Exit Status),$?
永远记录最近一个进程在命令行中执行完毕时对应的退出码(main->return ?;
)
- 退出码为
0
表示程序正常退出。 - 退出码为非
0
表示程序出现错误,通常使用不同的非零值来表示不同类型的错误。
2.4 exit()
与_exit()
函数
这两个函数都是用于终止进程,但它们有一些不同之处:
_exit()
:
直接终止进程,不执行缓冲区刷新操作。
参数
status
用于表示进程退出的状态,父进程可以通过wait()
获取该状态。虽然status
是int
类型,但只有低8位有效。示例:
_exit(-1); // 调用_exit()终止进程,退出码为-1,实际返回255(仅低八位有效)
exit
函数:
在终止进程之前,会先执行一些清理工作:
调用通过
atexit()
或on_exit()
注册的退出处理函数。关闭所有打开的流,写入缓冲区中的数据。
exit
最终也会调用_exit
,但在调_exit
之前,还做了其他工作:
- 执行用户通过atexit或on_exit定义的清理函数。
- 关闭所有打开的流,所有的缓存数据均被写入。
- 调用
_exit
exit
终止进程,主动刷新缓冲区,_exit
终止进程,不会刷新缓冲区。缓冲区是用用户级的缓冲区,不是操作系统中的。
exit()
和_exit()
的返回值都可以通过echo $?
接收。这两个函数终止的是进程而不是程序。
2.5 非零退出码的意义
当进程出现错误时,可以使用非零退出码来表明不同类型的错误。通过返回不同的退出码,操作系统或调用者可以根据不同的退出码做出相应的处理。
一般约定:
0
:程序正常退出。1
:程序出现一般错误(如文件找不到、内存不足等)。2
:命令行参数错误。其他
:根据具体情况定义不同的退出码,用于表示不同的错误类型。
通过调用 strerror()
可以获取与退出码相关的错误信息,提供人类可读的错误描述:
#include <stdlib.h>
#include <string.h>
int main() {
if (some_error_condition) {
fprintf(stderr, "Error: %s\n", strerror(errno));
return 1; // 返回1表示程序异常退出
}
return 0; // 正常退出
}
信号终止:
进程在运行时,也可能被外部信号终止,常见的信号终止方式有:
Ctrl + C
:发送SIGINT
信号终止进程。kill -9
:发送SIGKILL
信号强制终止进程。kill -15
:发送SIGTERM
信号请求终止进程。
这些信号会导致进程退出,并且通常不执行正常的退出处理函数(如 atexit()
)。例如,SIGKILL
信号会强制终止进程,不能被捕获或忽略。
3.进程等待
3.1 进程等待的必要性
引入:在之前进程概念我们认识到一个叫僵尸进程的状态:子进程退出而父进程不及时回收就会造成僵尸状态,进而构成内存泄漏问题。
- 父进程可以通过进程等待的方式,回收子进程资源,获取子进程退出信息。从而避免僵尸进程问题.
- 进程等待就是通过系统调用让父进程等待子进程的一种方式。
- 可以通过
wait
/waitpid
指定等待方式获取指定退出结果可以以阻塞或非阻塞的方式对子进程进行等待。
3.2 wait
方法(回收子进程资源)
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int* status)
- 返回值:成功返回被等待进程pid, 失败返回
-1
. - 参数:输出型参数,获取子进程退出状态,如不需要则可以设置成为
NULL
.
3.3 waitpid
方法(获取子进程退出信息)
waitpid()
更加灵活,允许父进程等待特定的子进程,并且提供阻塞与非阻塞两种等待方式:
#include <sys/types.h>
#include <sys/wait.h>
pid_t waitpid(pid_t pid,int* status, int options)
返回值:
- 当正常返回的时候
waitpid
等同于wait
,返回收集到的子进程的进程的PID. - 如果设置了选项
WNOHANG
,而调用中waitpid
发现没有已退出的子进程可收集,则返回0
。 - 如果调用中出错,则返回
-1
,这时errno
会被设置成相应的值以只是错误所在。
- 当正常返回的时候
参数:
pid
:指定等待的子进程。- pid = -1 ->等待任何子进程,与
wait
等效。 - pid > 0 ->等待其进程ID与
pid
相等的子进程。
- pid = -1 ->等待任何子进程,与
status
:用于返回子进程的退出状态。- WIFEXITED(status):若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出)
- WEXITSTATUS(status):若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)
options
:可以设置WNOHANG
,表示非阻塞方式。- WNOHANG:若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待。若正常结束,则返回该子进程的ID。
- 如果子进程已经退出,调用
wait
/waitpid
时,wait
/waitpid
会立即返回,并且释放资源,获得子进程退出信息。- 如果在任意时刻调用
wait
/waitpid
,子进程存在且正常运行,则进程可能阻塞。- 如果不存在孩子进程,则立即出错并返回。
我们看一下下面的例子:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <string.h>
#include <stdlib.h>
int main() {
pid_t id = fork();
if (id == 0) {
// 子进程
int cnt = 3;
while (cnt) {
printf("[子进程]: PID=%d, PPID=%d, cnt=%d\n", getpid(), getppid(), cnt--);
sleep(3);
}
exit(10); // 子进程正常退出,退出码为 10
} else if (id > 0) {
// 父进程
int status = 0;
while (1) {
pid_t ret = waitpid(id, &status, WNOHANG); // WNOHANG: 非阻塞 -> 子进程没有退出,父进程检查时立即返回
if (ret == 0) {
// 子进程还在运行
printf("子进程仍在运行...\n");
} else if (ret > 0) {
// 子进程已经退出
if (WIFEXITED(status)) {
printf("子进程正常退出,退出码: %d\n", WEXITSTATUS(status));
} else if (WIFSIGNALED(status)) {
printf("子进程被信号终止,信号编号: %d\n", WTERMSIG(status));
}
break;
} else {
// waitpid 调用失败
perror("waitpid 调用失败");
break;
}
sleep(1); // 父进程每秒检查一次子进程状态
}
} else {
// fork 失败
perror("fork 失败");
return 1;
}
return 0;
}
/* 执行结果:
子进程仍在运行...
[子进程]: PID=19652, PPID=19651, cnt=3
子进程仍在运行...
子进程仍在运行...
[子进程]: PID=19652, PPID=19651, cnt=2
子进程仍在运行...
子进程仍在运行...
子进程仍在运行...
[子进程]: PID=19652, PPID=19651, cnt=1
子进程仍在运行...
子进程仍在运行...
子进程仍在运行...
子进程正常退出,退出码: 10
*/
3.4 获取子进程状态信息status
status
参数是一个位图,操作系统会将子进程的退出状态填充到status
中。通过以下宏,可以解析子进程的退出信息:
WIFEXITED(status)
:判断子进程是否正常退出。WEXITSTATUS(status)
:获取子进程的退出码。WIFSIGNALED(status)
:判断子进程是否因信号终止。WTERMSIG(status)
:获取导致子进程终止的信号编号
理解:
wait
和waitpid
都有一个status
参数,该参数是一个输出型参数,由操作系统填充。- 如果传递NULL,表示不关心子进程的退出状态信息。
- 否则,操作系统会根据该参数将子进程的退出信息反馈给父进程。
status
不是简单的整形,而是一种位图的形式,具体一些如下图(一般只取status低16比特位)。
从退出子进程的
task_struct
中获取的:
- 进程退出会变成僵尸->会把自己的退出结果写入到自己的
task_struct
wait
/waitpid
是一个系统调用->OS(OS有资格也有能力读取子进程的task_struct
)
3.5 阻塞与非阻塞
- 阻塞:父进程会等待子进程退出后再继续执行。如果子进程尚未退出,父进程会被挂起,直到子进程终止。
- 非阻塞:父进程立即返回,不会挂起。如果没有已退出的子进程,
waitpid
会返回0
,并且父进程可以继续执行其他任务。
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <stdlib.h>
#include <errno.h>
int main() {
pid_t id = fork(); // 创建子进程
if (id == 0) {
// 子进程
int cnt = 3;
while (cnt--) {
printf("[子进程] PID: %d, 父进程ID: %d, 剩余计数: %d\n", getpid(), getppid(), cnt);
sleep(3); // 模拟子进程的工作
}
exit(10); // 子进程正常退出,并返回退出码 10
}
// 父进程
int status = 0;
pid_t ret;
// 非阻塞等待
printf("【父进程】开始进行非阻塞等待...\n");
while (1) {
ret = waitpid(id, &status, WNOHANG); // 非阻塞模式
if (ret == 0) {
// 子进程仍在运行
printf("【父进程】子进程仍在运行...\n");
} else if (ret > 0) {
// 子进程已退出
if (WIFEXITED(status)) {
int exitCode = WEXITSTATUS(status);
printf("【父进程】子进程正常退出,退出码为: %d\n", exitCode);
} else {
printf("【父进程】子进程异常终止\n");
}
break; // 子进程已退出,退出循环
} else {
// 错误处理
if (errno == ECHILD) {
printf("【父进程】没有子进程\n");
} else if (errno == EINTR) {
printf("【父进程】waitpid 被信号中断\n");
} else {
perror("【父进程】waitpid 调用失败");
}
break;
}
sleep(1); // 每隔 1 秒检查一次
}
// 阻塞等待
printf("\n【父进程】接下来进行阻塞等待...\n");
ret = waitpid(id, &status, 0); // 阻塞模式,直到子进程退出
if (ret > 0) {
if (WIFEXITED(status)) {
int exitCode = WEXITSTATUS(status);
printf("【父进程】阻塞等待确认子进程正常退出,退出码为: %d\n", exitCode);
} else {
printf("【父进程】阻塞等待确认子进程异常终止\n");
}
} else {
perror("【父进程】阻塞等待失败");
}
return 0;
}
4.进程替换
创建子进程无非两个目的:
- 让子进程执行父进程代码的一部分->执行父进程对应的磁盘代码中的一部分。
- 让子进程执行一个全新的程序->通过子进程加载磁盘指定的程序并执行新程序的代码和数据,即进程的程序替换。
4.1 替换函数
进程替换通常发生在一个进程(父进程)创建子进程后,子进程调用 exec
系列函数来加载并执行新的程序。调用 exec
系列函数后,原进程的代码和数据会被新的程序覆盖,进程ID保持不变,但程序的执行环境、代码段、数据段和堆栈都被替换成新程序的内容。
- 目标:将当前进程的代码和数据替换为新程序的代码和数据。
- 特点:进程ID不变,内存空间(代码、数据、堆栈)被替换,且替换后不会返回原程序的代码。
这些函数都属于exec
家族,用于在当前进程中替换当前程序映像(image)为另一个程序。这意味着调用exec
函数会停止当前程序的执行,并启动一个新的程序,新程序将在当前进程的上下文中运行,拥有相同的进程ID。以下是对每个函数的简要介绍:
execl
:(最简单的替换)int execl(const char *path, const char *arg, ...);
execl
函数加载并执行由path
指定的程序,参数列表由arg
和后面的可变参数列表组成。最后一个参数必须是NULL
来表示参数列表的结束。execlp
:(自定查找路径)int execlp(const char *file, const char *arg, ...);
类似于
execl
,但是execlp
会在环境变量PATH
指定的目录中查找file
,而不必指定完整路径。execle
:(自定义环境变量)int execle(const char *path, const char *arg,..., char * const envp[]);
此函数与
execl
类似,但是它允许你指定一个额外的环境变量列表envp[]
,这将覆盖默认的环境变量。execle
使用的较少execv
:(参数使用数组)int execv(const char *path, char *const argv[]);
execv
函数接受一个字符数组的指针argv
,其中argv[0]
是程序的名称,argv[n]
是参数,argv[n+1]
必须是NULL
。execvp
:(自动查找路径,使用数组)int execvp(const char *file, char *const argv[]);
类似于
execv
,但execvp
会在环境变量PATH
指定的目录中查找file
。execvpe
:(自定义环境变量,自动查找路径)int execvpe(const char *file, char *const argv[], char *const envp[]);
结合了
execvp
和execle
的功能,既在PATH
中查找file
,也允许指定额外的环境变量列表envp
。
命名理解:
l
(list)表示参数列表;v
(vector)参数用数组p
(path)有p自动搜索环境变量PATHe
(env)表示自己维护环境变量
所有
exec
函数在成功执行时都不会返回,如果失败则返回-1,并设置errno
以指示错误原因。常见的错误包括权限问题、找不到文件、内存不足等。
execl
系列函数第一参数是到要执行程序的地址去找对应的程序,第二个参数是是让要执行的方式(命令行中怎么执行就怎么传参)。带有...
代表是可变参数列表,注意带有可变参数列表的函数一定要以NULL
做参数传递。ps:函数执行失败(找不到对应文件/参数不正确)将不进行程序替换。
ps:
#include <unistd.h>
int main(){
char *const argv[] = {"ps", "-ef", NULL};
char *const envp[] = {"PATH=/bin:/usr/bin", "TERM=console", NULL};
execl("/bin/ps", "ps", "-ef", NULL);
// 带p的,可以使用环境变量PATH,无需写全路径
execlp("ps", "ps", "-ef", NULL);
// 带e的,需要自己组装环境变量
execle("ps", "ps", "-ef", NULL, envp);
execv("/bin/ps", argv);
// 带p的,可以使用环境变量PATH,无需写全路径
execvp("ps", argv);
// 带e的,需要自己组装环境变量
execve("/bin/ps", argv, envp);
exit(0);
}
4.2 程序替换原理
当你使用execl
、execv
、execle
、execlp
、execvp
等exec
系列函数时,这些函数会用一个新的程序替换当前进程的执行上下文。具体来说,这是通过以下步骤实现的:
停止当前进程的执行:当
exec
函数被调用时,当前正在执行的代码会暂停,以便操作系统可以进行替换操作。加载新程序的代码和数据:操作系统会找到新程序的二进制文件,将其代码段(包含机器指令)和数据段(包含全局变量和静态变量的初始值)加载到内存中。这个过程可能涉及从磁盘读取文件,并将它们映射到进程的地址空间中。
设置新程序的环境:
exec
函数允许传递新的环境变量和命令行参数给新程序。这些信息会被设置好,以便新程序可以访问它们。替换当前程序:最核心的一点是,新程序的代码和数据覆盖了原来进程中的代码和数据。这意味着原来的程序不再存在,而新程序现在处于准备好执行的状态。
开始执行新程序:操作系统跳转到新程序的入口点(通常是
main
函数),开始执行新程序。从新程序的角度看,它就像是在一个全新的进程环境中启动一样,但它实际上是在同一个进程ID下运行。不创建新进程:关键在于,上述所有操作都是在同一个进程的上下文中完成的,没有创建新的进程。因此,父进程看到的仍然是同一个进程ID,但是这个进程现在运行的是不同的代码。
总结起来,
execl
等exec
函数实现了“程序替换”,而不是“进程替换”。这个过程涉及将新程序的代码和数据加载到当前进程的地址空间中,然后开始执行这个新程序,而这一切都在同一个进程中发生,没有创建额外的进程开销。这种机制在资源受限的环境下特别有用,因为它避免了创建新进程所需的额外资源分配。
- 程序替换:通过
exec
系列函数。所谓程序替换的本质,就是将制定程序的代码和数据加载到指定的位置,然后覆盖自己的代码和数据。在此期间不会创建新的进程,进程ID保持不变,只是将磁盘中对应的代码和数据加载到物理内存中。- 进程替换:相对于程序替换,进程替换通常指的是完全创建一个新的进程(如通过
fork
创建子进程),而exec
函数实际上是对当前进程进行“程序替换”。
程序替换的示例:
#include <stdio.h>
#include <unistd.h>
int main(){
printf("process is running...\n");
//load->exe
// 要执行哪个程序 你要怎么执行
execl("/usr/bin/ls", "ls", "-la", NULL);
// 由于execl执行完毕的时候,代码已经全部被覆盖,该是执行新的代码,所以之后的代码就不会被执行了
printf("process running done...\n");
}
- 解析:在这个例子中,
execl
调用后,当前进程将会加载/usr/bin/ls
程序,并且ls -la
将作为命令行参数传递给新程序。当execl
执行成功时,后续的代码(如printf("...");
)将不会被执行,因为当前进程的上下文已被新程序替代。
子进程中的程序替换:
在下面的示例中,父进程通过fork
创建一个子进程,子进程调用 execl
替换程序。父进程等待子进程完成:
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdlib.h>
#include <assert.h>
int main(){
printf("process is running...\n");
pid_t id = fork();
assert(id != -1);
if(id == 0){
// 因为进程具有独立性,此处的替换不会影响到父进程
sleep(1);
execl("/usr/bin/ls", "ls", "-a", "-l", NULL);
exit(1); // 如果execl失败,子进程退出
}
int status = 0;
pid_t ret = waitpid(id, &status, 0);
if(ret>0){
printf("wait success: exit code:%d, sig:%d\n", (status>>8)&0xFF, status&0x7F);
}
return 0;
}
- 解析:在这个例子中,父进程调用
fork
创建了一个子进程。子进程通过execl
调用替换程序为ls
,并打印目录信息。父进程等待子进程完成后打印子进程的退出状态。
execl
调用通过替换进程的执行上下文来执行新程序,这一过程并不是触发“写时复制”机制,因为原进程的代码和数据被新程序的代码和数据完全替代,而非修改。
execl
在子进程中执行新程序,利用虚拟地址空间和页表机制确保与父进程的独立性,即使共享内存页,也只有在修改时才会触发“写时复制”机制来分离数据,从而实现代码和数据的替换而不影响父进程。
下面是根据现有只是实现的一个shell
小程序:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <string.h>
#include <stdlib.h>
#include <assert.h>
#define NUM 1024
#define OP_NUM 64
char lineCommand[NUM];
char *myargv[OP_NUM]; // 指针数组
int main(){
while(1){
// 输出提示符
printf("用户名@主机名: 当前路径#");
fflush(stdout);
// 获取用户输入
char *s = fgets(lineCommand,sizeof(lineCommand)-1, stdin);
assert(s != NULL);
(void)s;
// 清除最后一个'\n',如:abcd\n
lineCommand[strlen(lineCommand)-1] = 0;
// printf("test: %s\n", lineCommand);
// 字符串切割
myargv[0] = strtok(lineCommand, " ");
// 如果没有子串了,strtok->NULL, myargv[end]=NULL;
int i = 1;
while(myargv[i++] = strtok(NULL, " "));
// 测试是否成功, 条件编译
#ifdef DEBUG
for(int i = 0; myargv[i]; ++i){
printf("myargv[%d]: %s\n", i, myargv[i]);
}
#endif
// 执行命令
pid_t id = fork();
assert(id != 1);
if(id == 0){
execvp(myargv[0], myargv);
exit(1);
}
waitpid(id, NULL, 0);
}
}