进程
一、进程的抽象
1.1 进程的定义
进程是操作系统为运行中的程序提供的核心抽象,简单来说,进程就是运行中的程序。程序本身是静态的,存储在磁盘上的指令和数据,而操作系统通过执行这些指令,使程序具备生命周期,变成动态的进程。
1.2 关键问题:虚拟化CPU
操作系统通过虚拟化CPU,提供多个虚拟CPU的假象,让用户感觉可以同时运行多个程序。核心技术是时分共享(time sharing),即通过时间片轮换运行不同进程,实现并发。
- 优势:允许多个进程看似同时运行,简化用户操作。
- 代价:性能开销,因CPU共享,每个进程运行速度可能略慢。
1.3 进程的组成
进程的机器状态(machine state)定义了其运行时的核心组成部分:
- 内存(地址空间):包括指令、数据、堆、栈等。
- 寄存器:包括程序计数器(PC)、栈指针、帧指针等,用于指令执行和函数调用管理。
- I/O信息:如打开的文件描述符,用于与持久存储设备交互。
1.4 机制与策略
操作系统实现进程抽象需要:
- 机制(Mechanism):低级方法,如上下文切换,用于暂停一个进程并运行另一个。
- 策略(Policy):高级决策算法,如调度策略,决定哪个进程优先运行,依赖历史信息、工作负载和性能目标。
提示:时分共享和空分共享是资源共享的两种方式。时分共享在时间上分配资源(如CPU),空分共享在空间上分配资源(如磁盘块)。
设计原则:分离机制与策略(Lampson定律),便于模块化设计,策略可变而机制稳定。
二、进程API
2.1 核心API功能
现代操作系统提供的进程API包括:
- 创建(create):创建新进程,如通过命令行或点击图标启动程序。
- 销毁(destroy):终止进程,支持用户强制停止失控进程。
- 等待(wait):暂停父进程,直到子进程完成。
- 其他控制:暂停、恢复进程等操作。
- 状态查询(status):获取进程运行时间、状态等信息。
2.2 UNIX进程API
UNIX系统通过以下系统调用实现进程管理:
- fork():创建子进程,子进程是父进程的副本。
- exec():将当前进程替换为新程序,保留进程ID但加载新代码。
- wait():父进程等待子进程完成。
三、进程创建的细节
3.1 创建流程
操作系统将程序转变为进程的步骤:
- 加载代码和静态数据:
- 从磁盘(或SSD)读取可执行文件到内存,形成进程的地址空间。
- 早期系统采用尽早加载(eagerly),现代系统使用惰性加载(lazily),按需加载代码和数据。
- 分配运行时栈:
- 为局部变量、函数参数、返回地址分配内存。
- 初始化栈,填入
main()
函数参数(如argc
和argv
)。
- 分配堆:
- 为动态分配数据(如链表、树)预留内存,初始较小,调用
malloc()
时扩展。
- 为动态分配数据(如链表、树)预留内存,初始较小,调用
- 初始化I/O:
- 设置标准输入、输出、错误文件描述符(如UNIX中的0、1、2)。
- 启动程序:
- 跳转到
main()
函数,CPU控制权移交新进程,程序开始执行。
- 跳转到
3.2 进程状态
进程在运行中可能处于以下状态:
- 运行(running):正在执行指令。
- 就绪(ready):准备运行,但等待调度。
- 阻塞(blocked):等待事件(如I/O完成)。
- 其他状态:
- 初始(initial):进程创建时的过渡状态。
- 僵尸(zombie):进程已退出但未清理,保留返回码供父进程检查。
状态转换:
- 就绪 ↔ 运行:通过调度/取消调度。
- 运行 → 阻塞:发起I/O请求。
- 阻塞 → 就绪:I/O完成。
3.3 状态转换示例
- 纯CPU任务:两个进程交替运行,状态在运行和就绪间切换。
- 带I/O任务:进程发起I/O后阻塞,OS调度其他进程,I/O完成后回到就绪状态。
四、UNIX进程API详解
4.1 fork()系统调用
- 功能:创建子进程,子进程是父进程的副本,拥有独立地址空间、寄存器等。
- 返回值:
- 父进程:子进程的PID。
- 子进程:0。
- 特点:子进程从
fork()
调用返回处开始执行,非从main()
开始。 - 非确定性:父子进程的执行顺序由调度器决定,可能导致输出顺序不同。
示例代码(p1.c):
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(int argc, char *argv[]) {
printf("hello world (pid:%d)\n", (int) getpid());
int rc = fork();
if (rc < 0) { // fork失败
fprintf(stderr, "fork failed\n");
exit(1);
} else if (rc == 0) { // 子进程
printf("hello, I am child (pid:%d)\n", (int) getpid());
} else { // 父进程
printf("hello, I am parent of %d (pid:%d)\n", rc, (int) getpid());
}
return 0;
}
输出示例:
hello world (pid:29146)
hello, I am parent of 29147 (pid:29146)
hello, I am child (pid:29147)
或:
hello world (pid:29146)
hello, I am child (pid:29147)
hello, I am parent of 29147 (pid:29146)
4.2 wait()系统调用
- 功能:父进程等待子进程完成,同步执行顺序。
- 效果:确保子进程输出先于父进程,消除执行顺序的非确定性。
- 示例代码(p2.c):
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main(int argc, char *argv[]) {
printf("hello world (pid:%d)\n", (int) getpid());
int rc = fork();
if (rc < 0) {
fprintf(stderr, "fork failed\n");
exit(1);
} else if (rc == 0) {
printf("hello, I am child (pid:%d)\n", (int) getpid());
} else {
int wc = wait(NULL);
printf("hello, I am parent of %d (wc:%d) (pid:%d)\n", rc, wc, (int) getpid());
}
return 0;
}
输出:
hello world (pid:29266)
hello, I am child (pid:29267)
hello, I am parent of 29267 (wc:29267) (pid:29266)
4.3 exec()系统调用
- 功能:将当前进程替换为新程序,加载新代码和数据,保留进程ID。
- 变体:
execvp()
、execl()
等。 - 特点:成功调用不返回,失败则继续执行后续代码。
- 示例代码(p3.c):
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/wait.h>
int main(int argc, char *argv[]) {
printf("hello world (pid:%d)\n", (int) getpid());
int rc = fork();
if (rc < 0) {
fprintf(stderr, "fork failed\n");
exit(1);
} else if (rc == 0) {
printf("hello, I am child (pid:%d)\n", (int) getpid());
char *myargs[3];
myargs[0] = strdup("wc"); // 程序:wc
myargs[1] = strdup("p3.c"); // 参数:文件
myargs[2] = NULL; // 数组结束
execvp(myargs[0], myargs); // 执行wc
printf("this shouldn't print out");
} else {
int wc = wait(NULL);
printf("hello, I am parent of %d (wc:%d) (pid:%d)\n", rc, wc, (int) getpid());
}
return 0;
}
输出:
hello world (pid:29383)
hello, I am child (pid:29384)
29 107 1030 p3.c
hello, I am parent of 29384 (wc:29384) (pid:29383)
4.4 输出重定向
- 功能:将进程输出重定向到文件而非屏幕。
- 实现:在
fork()
后、exec()
前关闭标准输出(STDOUT_FILENO
),打开目标文件。 - 示例代码(p4.c):
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <fcntl.h>
#include <sys/wait.h>
int main(int argc, char *argv[]) {
int rc = fork();
if (rc < 0) {
fprintf(stderr, "fork failed\n");
exit(1);
} else if (rc == 0) {
close(STDOUT_FILENO);
open("./p4.output", O_CREAT|O_WRONLY|O_TRUNC, S_IRWXU);
char *myargs[3];
myargs[0] = strdup("wc");
myargs[1] = strdup("p4.c");
myargs[2] = NULL;
execvp(myargs[0], myargs);
} else {
int wc = wait(NULL);
}
return 0;
}
输出:
prompt> ./p4
prompt> cat p4.output
32 109 846 p4.c
4.5 为什么分离fork()和exec()
- 优势:
fork()
和exec()
分离为shell实现功能(如重定向、管道)提供了灵活性。 - 示例:shell在
fork()
后、exec()
前修改环境(如重定向输出到文件)。 - Lampson定律:做对事比抽象或简化更重要,
fork()
和exec()
的组合简单且强大。
五、数据结构
5.1 进程列表
操作系统维护进程列表(process list),跟踪所有进程的状态和信息。每个进程的信息存储在进程控制块(PCB)中。
5.2 xv6的进程结构
xv6内核的proc
结构示例:
struct context {
int eip; // 指令指针
int esp; // 栈指针
int ebx; // 通用寄存器
// ...
};
enum proc_state { UNUSED, EMBRYO, SLEEPING, RUNNABLE, RUNNING, ZOMBIE };
struct proc {
char *mem; // 进程内存起始地址
uint sz; // 内存大小
char *kstack; // 内核栈底
enum proc_state state; // 进程状态
int pid; // 进程ID
struct proc *parent; // 父进程
void *chan; // 阻塞等待的通道
int killed; // 是否被终止
struct file *ofile[NOFILE]; // 打开的文件
struct inode *cwd; // 当前目录
struct context context; // 寄存器上下文
struct trapframe *tf; // 中断帧
};
- 作用:保存进程的寄存器上下文、状态、文件描述符等,供上下文切换和状态管理使用。
六、其他API与工具
6.1 信号机制
- kill():向进程发送信号,如终止、暂停等。
- 信号子系统:处理外部事件,丰富进程交互。
6.2 实用工具
- ps:列出当前运行的进程。
- top:实时显示进程资源占用。
- MenuMeters:监控CPU利用率。
七、扩展与总结
7.1 进程管理的意义
进程抽象和API是操作系统多任务处理的核心。通过虚拟化CPU和提供灵活的API(如fork()
、exec()
、wait()
),操作系统实现了并发、资源共享和用户交互的简化。
7.2 UNIX设计的哲学
UNIX的进程API设计体现了模块化和灵活性:
- 分离机制与策略:便于扩展和优化。
- 简单而强大:
fork()
和exec()
的组合支持复杂功能(如管道、重定向)。 - 用户态shell:将复杂功能交给用户程序,保持内核简洁。