进程 (process)
进程的抽象
进程是一种最基本的抽象。
进程的非正式定义非常简单:进程就 是运行中的程序。程序本身是没有生命周期的,它只是存在磁盘上面的一些指令(也可能 是一些静态数据)。是操作系统让这些字节运行起来,让程序发挥作用。
操作系统决定何时令 CPU 运行何地的指令,通过不断地切换内存中不同程序的指令,类抽象出同时执行多个进程的错觉。
可以很自然地联想到组原中的 IO 中断方式,他通过一种类似回调的方式,令 CPU 中断当前运行的程序,关中断,并将断点地址压栈,开中断,跳转至中断向量指向的内存空间,中断服务会保存当前的现场,例如寄存器状态等,服务结束后会进行现场的恢复。
这保存和恢复像极了操作系统对进程的上下文切换。
为了搞清楚我们要保存和恢复什么东西,必须搞清楚一个进程会使用的哪些东西,或者说,在操作系统的抽象中,是什么构成了一个进程。这里有一个名词来描述他 —— 机器状态 (machine state)。
机器状态包含主存和寄存器状态,主存状态就是进程用到的主存空间,同时进程也会用到寄存器(还有 PC 等特殊的寄存器)。
策略 (policy) 和机制 (mechanism),在实现操作系统时,策略和机制会被分为两个模块。可以这样理解,机制是策略的细节和组成部分,例如在程序的调度策略 (scheduling policy) 中,上下文切换操作被称为一种机制,而策略则是挑选哪个进程进行上下文切换。
描述进程
数据结构
xv6 的 proc 结构:
// the registers xv6 will save and restore$
// to stop and subsequently restart a process
struct context {
int eip;
int esp;
int ebx;
int ecx;
int edx;
int esi;
int edi;
int ebp;
};
// the different states a process can be in
enum proc_state { UNUSED, EMBRYO, SLEEPING, RUNNABLE, RUNNING, ZOMBIE };
// the information xv6 tracks about each process
// including its register context and state
struct proc {
char *mem; // Start of process memory
uint sz; // Size of process memory
char *kstack; // Bottom of kernel stack ocessfor this process
enum proc_state state; // Process state
int pid; // Process ID
struct proc *parent; // Parent process
void *chan; // If non-zero, sleeping on chan
int killed; // If non-zero, have been killed
struct file *ofile[NOFILE]; // Open files
struct inode *cwd; // Current directory
struct context context; // Switch here to run process
struct trapframe *tf; // Trap frame for the // current interrupt
};
进程状态
一个进程有很多状态,主要是:运行(running)、就绪(ready)和阻塞(blocked)。
OS 会提供一些线程操作的 API,它们最少会包括这些:创建(create)、销毁(destroy)、等待(wait)、状态(state)和其他的控制接口(miscellaneous control)。
进程创建
OS 会将代码和静态数据加载到主存空间,不过在此之前还需要分配进程的栈空间(运行时栈 run time stack)。
还有一些其他的初始化任务,例如 IO,在 UNIX 中,每个进程默认拥有三个文件句柄,分别用于输入、输出和错误。
最后通过跳转到进程的入口地址,令 CPU 开始执行接下来的机器指令。
system call 实例
fork
fork 是 linux 下用于创建进程的系统调用。(注意是进程而非线程)
这个接口有些奇怪,不过非常符合 fork 的原意 —— 分叉,当在一个进程调用它时,当前进程和子进程将接连从 fork 调用返回,只不过子进程的返回值是 0 ,而父进程返回子进程的进程 id(当 返回值小于 0 时代表出现错误)。
下面给出一个最简单的创建进程的封装:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int createThread(void(*callback)(void)) {
fflush(stdout); fflush(stdin); fflush(stderr);
int rc = fork();
if (rc < 0) {
exit(1);
} else if (rc == 0) {
callback(); exit(0);
}
return rc;
}
由于进程创建时缓冲区会被完整拷贝,为了避免子进程缓冲区带有不必要的东西,我们首先刷新了它。
然后我们调用了 fork,主进程从 fork 返回后没有进入下面的任何一个条件分支,而子进程将进入第二个条件分支,调用 callback 后销毁。
这里子进程的销毁很重要,否则子进程也会从 createThread 函数返回,执行主进程的逻辑,除非我们有必要这样做,否则还是直接销毁他比较好。
wait
wait 用于等待一个子进程结束,被等待子进程按照创建顺序在依次调用中被等待,子进程结束后,父进程将从 wait 调用处返回。
还有一个 waitpid,可以提供一个具体的 pid 来等待,具体的查 man。
exec
exec 用于执行别的程序,调用 exec 后,当前进程将从某个给定的可执行程序中加载代码和静态数据,覆写自己的代码段和静态数据,堆栈也会重新初始化,同时将参数传递给该进程,开始执行。
示例:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include "thread.h"
void thread() {
execl("/bin/ls", "ls", "-al", NULL);
}
int main(int argc, char* argv[]) {
createThread(thread);
return 0;
}
exec 系列系统调用:exec[v|l][p][e]
v,表示给定参数以 char* 分别给出;
l,表示参数列表以 char** 给出。
p,表示会从 path 变量中寻找程序和命令,不用给出完整路径。
e,表示使用新的环境变量。
示例:
char* args[] = {"ls", "-al", NULL};
char *env[] = {"AA=aa","BB=bb",NULL};
execv("/bin/ls", args);
execvp("ls", args);
execvpe("ls", args, env);
execl("/bin/ls", "ls", "-al", NULL);
execlp("ls", "ls", "-al", NULL);
execle("ls", "ls", "-al", NULL, env);
shell 的实现原理通常是:首先通过 fork 创建一个子进程,然后在子进程调用 exec 来覆写当前进程,最后通过 wait 等待子进程结束。
fork 和 exec 允许我们在创建另一个子进程程序前做一些有意思的工作,例如重定向输出或输入流到某个文件:
$ wc ./thread.c > out
pipe
pipe 是管道,结构类似队列,可以实现跨进程的通信(进程之间的数据共享受限)。
c 原型:
int pipe(int[2] fd);
返回 -1 代表出错,0 代表成功。
调用返回后,fd 指向的内存空间将被顺序写入两个文件句柄(实际上位于内存中),一个用于读,另一个用于写,且几乎可以使用任何标准 I/O 函数进行处理。
例如 work8.c:
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include "thread.h"
// 管道句柄
static int fd[2], readPipe, writePipe;
// 子进程 1
void child1() {
char buf[1024] = {0};
printf("child1 in:");
fflush(stdout);
while (scanf("%s", buf) != -1) {
// 从 stdin 读,写入 pipe
write(writePipe, buf, sizeof(buf));
memset(buf, 0, sizeof(buf));
// 让出 cpu,让子进程 2 输出
// (这种做法并不稳定,应该通过进程共享内存实现进程同步锁,这里不展开)
sleep(0);
printf("child1 in: ");
fflush(stdout);
}
}
// 子进程 2
void child2() {
char buf[1024];
while (read(readPipe, buf, sizeof(buf))) {
printf("child2 out: ");
printf(buf);
printf("\n");
fflush(stdout);
}
}
int main() {
// 创建管道
if (pipe(fd) != -1) {
readPipe = fd[0];
writePipe = fd[1];
} else {
printf("error");
exit(1);
}
// 创建进程
createThread(child1);
createThread(child2);
// 主进程守护进程 1
wait();
return 0;
}
进行读写时会阻塞,对读端,若无数据则等待,对写端,若已写入且未被读端取走,则等待。
dup 和 dup2
这里再介绍两个有意思的函数dup
和dup2
:
int dup (int oldfd)
int dup2 (int oldfd, int newfd)
shell 中允许使用一种特殊的语法,将即将运行的子进程的 stdout 定向到某个文件,
例如:
$ ls -alh > out.txt
然后我们看一下 out.txt:
ls 的输出内容被定向到 out.txt 中了。
$ cat out.txt
总用量 72K
drwxrwxr-x 2 devgaolihai devgaolihai 4.0K 12月 31 18:21 .
drwxrwxr-x 6 devgaolihai devgaolihai 4.0K 12月 31 17:58 ..
-rw-rw-r-- 1 devgaolihai devgaolihai 535 12月 28 18:38 05_fork.c
-rwxrwxr-x 1 devgaolihai devgaolihai 17K 12月 31 18:15 a.out
-rw-rw-r-- 1 devgaolihai devgaolihai 683 12月 31 18:16 dup.c
-rw-rw-r-- 1 devgaolihai devgaolihai 0 12月 31 18:21 out.txt
-rw-rw-r-- 1 devgaolihai devgaolihai 283 12月 31 15:48 thread.h
-rw-rw-r-- 1 devgaolihai devgaolihai 314 12月 29 22:19 threadTest.c
-rw-rw-r-- 1 devgaolihai devgaolihai 435 12月 30 21:55 work1.c
-rw-rw-r-- 1 devgaolihai devgaolihai 372 12月 31 17:35 work2.c
-rw-rw-r-- 1 devgaolihai devgaolihai 586 12月 31 17:11 work3.c
-rw-rw-r-- 1 devgaolihai devgaolihai 884 12月 31 17:29 work4.c
-rw-rw-r-- 1 devgaolihai devgaolihai 184 12月 31 17:35 work7.c
-rw-rw-r-- 1 devgaolihai devgaolihai 1.2K 12月 31 17:07 work8.c
-rwx------ 1 devgaolihai devgaolihai 9 12月 31 18:15 work8_test.txt
这个功能可以通过dup2
实现,下面给出例子:
dup2 会将第二个参数代表的文件映射为第一个参数代表的文件。
#include <fcntl.h>
#include <stdio.h>
#include "thread.h"
// 子进程
void child() {
printf("child pro");
}
int main() {
// 相当于将 STDOUT_FILENO 变成 target
// 以后对 STDOUT_FILENO 的操作全部变成对 target 的操作
int target = open("./dup_out.txt", O_CREAT | O_TRUNC | O_RDWR, 0664);
dup2(target, STDOUT_FILENO);
createThread(child);
return 0;
}
而dup
则是返回参数代表的文件的另一个文件句柄。