Jche
qwqqqqqq

csapp-8

2022-02-16

第8章

进程

异常是允许操作系统提供进程概念的基本构造块

在前面的章节也有提到过进程,这是一个抽象的概念,在运行程序是我们会得到一个假象就好像我们的程序是系统中运行的唯一的程序一样。这些假象都是通过进程的概念提供给我们的。

进程的定义就是一个执行中程序的实例。系统中每个程序都运行在某个进程的上下文中,而上下文是有程序正确运行所需的状态组成的。

每次用户向shell输入可执行文件的名字运行程序时shell就会创建一个新的进程,然后在上下文中运行这个可执行目标文件。

进程提供给应用程序的关键抽象由两个部分组成。

1.一个独立的逻辑控制流,构造了程序独占使用处理器的假象

2.一个私有的地址空间,构造了程序独占使用内存系统的假象

逻辑控制流

当一个系统运行三个进程时,处理器的一个物理控制流被分成了三个逻辑流,每个进程一个,三个逻辑流可以交错也可以分开。

执行的关键点在于进程是轮流使用处理器的,每个进程执行它的流的一部分,然后被抢占(暂时挂起)然后轮到其他进程,看起来就像都在独占的使用处理器,实际上处理器会有周期性的停顿,但并不改变程序内存位置或寄存器的内容。

并发流

前面也提到过并发,在这里有详细的讲。

一个逻辑流的执行时间上与另一个流重叠,称为并发流,这两个流被称为并发的运行。互相并发。

多个流并发的执行的一般现象称为并发一个进程和其他进程轮流运行的概念称为多任务。一个进程执行它的控制流的一部分的每一段时间叫做时间片。因此多任务也叫时间分片。

如果两个流并发地运行在不同的处理器核或者计算机上,那么称他们为并行流

私有地址空间

在一台n位地址的机器上,地址空间是2的n次方个可能地址的集合。进程为每个程序提供自己的私有的地址空间。一般而言,和这个空间中某个地址相关联的内存字节是不能被其他进程读或者写的。

每个这样的空间都有相同的通用结构

地址空间底部是保留给用户程序的,通常包括代码、数据、和堆栈段。代码段总是从地址0x400000开始。地址空间顶部保留给内核。地址空间的这个部分包含内核在代表进程执行指令时使用的代码,数据和栈。

用户模式和内核模式

处理器通常是用某个控制寄存器的一个模式位来提供这种功能的,该寄存器描述了进程当前享有的特权。当设置了模式位时,进程就运行在内核模式中。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。

而没有设置模式位时,进程就运行在用户模式中,无法执行特权指令。(比如停止处理器等)

运行程序时一开始一般是在用户模式中的,当中断,故障或者陷入系统调用这样的异常时,处理器将模式从用户模式变为内核模式,当返回应用程序代码时,处理器就把模式改回用户模式。

linux中可以使用/proc文件系统,它允许用户模式进程访问内核数据结构的内容。

上下文切换

操作系统内核使用上下文切换的异常控制流来实现多任务。即实现上面提到的挂起程序和抢占等。

内核为每个进程维持一个上下文,它由一些对象的值组成,这些对象包括寄存器和栈,内核数据结构等等。在程序执行时,内核可以决定抢占进程,并重新开始先前被抢占的进程,被称为调度。这时候即进行了上下文切换。

1)保存当前进程的上下文 2)恢复先前被抢占的进程的上下文 3)将控制传递给这个新恢复的进程

系统错误处理

当unix系统级函数遇到错误时,它们通常会返回到-1,并设置全局整数变量errno来表示什么出错了

但这样表示会让程序很难看,所以我们可以定义错误报告函数,并包装

于是调用就可以缩减成一行

1
pid = Fork();

进程控制

获取进程ID

每个进程都有一个唯一的正数(非零)ID(PID)。gitpid函数返回调用进程的PID。gitppid函数返回它的父进程的PID(创建调用进程的进程)

1
2
3
4
5
#include <sys/types.h>
#include <unistd.h>

pid_t getpid(void);
pid_t getppid(void);

他们分别返回一个类型为pid_t的整数值,在linux系统上它在types.h中被定义为int。

创建和终止进程

进程的三种状态

1.运行(在cpu执行或等待执行且最终被内核调度)

2.停止(被挂起且不会被调度)

3.终止(停止了)

当父进程调用fork函数创建一个新的运行的子进程

1
2
3
4
5
#include <sys/types.h>
#include <unistd.h>

pid_t fork(void);
//返回,子进程返回0,父进程返回子进程的PID,出错则为-1

新创建的子进程和父进程几乎相同,虚拟地址空间相同(独立副本),且共享文件,它们的不同在于有不同的PID。

fork函数调用一次却返回两次。一次在调用父进程中,一次在新创建子进程中。这里给出一个示例程序:

在这里,父进程和子进程是并发执行的,下面是该进程的进程图。

回收子进程

当一个进程由于某种原因终止时,进程会被保存在已终止的状态中,直到被父进程回收。当父进程终止时,内核会安排init进程来回收。init进程的PID为1,是系统启动时内核创建的,不会终止。

进程可以通过waitpid函数来等待子进程终止或者停止。

1
2
3
4
5
#include <sys/types.h>
#include <sys/wait.h>

pid_t waitpid(pid_t pid, int *statusp, int options);
//成功返回子进程PID,WHOHANG则为0,其他错误则为-1。

waitpid挂起调用进程的执行,直到它的等待集合中的一个子进程终止。如果等待集合中有进程刚调用就终止了那waitpid就立即返回,在这两种情况中,waitpid返回导致waitpid返回返回的已终止子进程的PID。

1.判定等待集合的成员(pid>0,等待集合就是单独的子进程,pid=-1,等待集合就是由父进程和所有子进程组成的)

2.修改默认行为(通过将options设置为常量的各种组合来修改默认行为)(电子书p552,纸质p517)

3.检查已回收的子进程的退出状态

4.错误条件(如果调用进程没有子进程则waitpid返回-1,信号中断也返回-1)

5.wait(waitpid的简单版本)

1
2
3
4
5
6
#include <sys/types.h>
#include <sys/wait.h>

pid_t wait(int *statusp);
//等于调用waitpid(-1,&status,0)
//成功返回子进程PID,出错-1

让进程休眠

sleep函数将进程挂起一段指定的时间。

1
2
3
4
#include <unistd.h>

unsigned int sleep(unsigned int secs);
//返回还要休眠的秒数

请求时间到了slee返回0,若信号中断过早返回则会返回不为0.

pause函数让调用函数休眠,直到该进程收到一个信号。

1
2
#include <unistd.h>
int pause(void);

加载并运行程序

execve函数在当前进程的上下文加载并运行一个新程序。

1
2
3
4
5
#include <unistd.h>

int execve(const char *filename, const char *argv[]),
const char * envp[]);
//成功不返回,错误返回-1

execve函数加载并运行可执行目标文件filename

简单shell示例

电子书p560,纸质p524

信号

在上面的简单shell中是有缺陷的,在于它不回收它的后台子进程。修改这个缺陷就要求使用信号。

信号是一种更高层的软件形式的异常,称为Linux信号,它允许进程和内核中断其他进程。Linux系统支持30多种不同类型的信号,每种信号类型都对应于某种系统事件。(p563/p527)

信号术语

传送信号到目的进程由两个不同步骤组成

1.发送信号

内核通过更新目的进程上下文中的某个状态,发送一个信号给目的进程。

2.接收信号

当目的进程被内核强迫以某种方式对信号的发送做出反应时就接收了信号。捕获信号通过信号处理程序。

一个发出而没有被接收的信号叫做待处理信号,任何时刻一种类型至多有一个待处理信号。如果已有一个待处理k类型信号,再有其他k类型的信号都会被简单的丢弃。

一个待处理信号只能被接收一次

发送信号

1.进程组

发送信号机制基于进程组这个概念。每个进程都只属于一个进程组,进程组是由一个正整数进程组ID来标识的。getpgrp函数返回当前进程的进程组ID

1
2
#include <unistd.h>
pid_t getpgrp(void);//返回进程组ID

默认条件下,一个子进程和父进程同属一个进程组,可以通过setpgid来改变自己或者其他进程的进程组

1
2
#include <unistd.h>
int setpgid(pid_t pid, pid_t pgid) //成功返回0,错误返回-1

setpgid函数将进程pid的进程组改为pgid。如果pid为0,那么就使用当前进程的PID。如果pgid是0,那么就用pid指定的进程的PID作为进程组的ID。

2.用/bin/kill 程序发送信号

/bin/kill程序可以向另外的进程发送任意的信号。

3.从键盘发送信号

Unix shell使用作业(job)这个抽象概念来表示为对一条命令行求值而创建的进程。在任何时刻,至多只有一个前台作业和0个或多个后台作业。

默认情况下,输入ctrl+c是终止前台作业,输入ctrl+z是挂起前台作业。

4.用kill函数发送信号

进程通过调用kill函数发送信号给其他进程

1
2
3
4
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
//成功则返回0,错误返回-1

5.用alarm函数发送信号

进程可以通过调用alarm函数向自己发送信号。

接收信号

当内核把进程p从内核模式切换到用户模式时,他会检查进程p的未被阻塞的待处理信号的集合。如果集合为空,内核将控制传递到p的逻辑控制流下一条指令。如果集合为非空,那么内核选择某个信号k,并强制p回收信号k。

每个信号类型都有一个预定义的默认行为,例如:

1.进程终止 2.进程终止并转储内存 3.进程(挂起)直到被SIGCONT信号重启 4.进程忽略该信号

进程可以通过signal函数修改和信号相关联的默认行为。唯一的例外是SIGSTOP和SIGKILL,它们的默认行为是不能修改的。

阻塞和解除阻塞信号

Linux提供阻塞信号的隐式和显式的机制:

隐式:内核默认阻塞任何当前处理程序正在处理信号类型的待处理信号(前面有讲过)

显式:应用程序可以使用sigprocmask函数和它的辅助函数,明确地阻塞和解除阻塞选定的信号。

编写信号处理程序

信号处理是Linux系统编程最棘手的一个问题,有很多原因。例如处理程序和主程序的并发运行,共享同样的全局变量,因此可能与主程序和其他处理程序互相干扰。如何以及何时接收信号的规则比较奇怪。不同系统有不同的信号处理语义等等。

1.安全的信号处理

G0:信号处理要尽可能简单。例如处理程序简单地设置全局标志并立即返回,所有与接收信道相关的处理都由主程序执行,周期性检查重置标志。

G1:在处理程序中只调用异步信号安全的函数。他们能被信号处理程序安全地调用。因为它是可重入的(例如只访问局部变量或者不能被信号处理程序中断)。

信号处理程序中产生输出唯一安全的方法是使用write函数。或者可以开发一些安全的函数(SIO包(安全的I/O包))可以用来在信号处理程序中打印简单的消息。

G2:保存和恢复errno。在处理程序中调用errno可能会干扰主程序中其他依赖该函数的部分,可以将errno保存在一个局部变量中,在处理程序返回前恢复它。(只有需要返回时需要)

G3:阻塞所有信号,保护对共享全局数据结构的访问。当主程序和处理程序共享全局数据结构且访问该数据结构时应该暂时阻塞所有信号。

G4:用volatile声明全局变量。处理程序更新全局变量,main周期性得读变量。对于一个优化编译器来说,main中的变量值看起来从来没有变化过,因此使用缓存在寄存器中的副本来满足对变量的每次引用是很安全的。如果这样main函数可能永远不能看到处理程序更新过的值。

而用volatile类型限定符来定义一个变量,告诉编译器不要缓存这个变量,它强迫编译器每次引用该变量时要从内存中读取。一般来说和其他共享数据结构一样该暂时阻塞信号以保安全引用。

G5:用sig_atomic_t声明标志。在常见的处理程序设计中,处理程序会写全局标志来记录收到了信号。主程序周期性地读这个标志,响应信号,再清除该标志。sig_atomic_t是c提供的一种整形数据类型,对它的读和写是不会中断的,所以可以安全的读写,不需要阻塞信号。但只能用于单个的读写,例如flag++则要多条指令。

2.正确的信号处理

不能够简单的假设信号是排队的,信号在处理中最多接收一个相同类型的信号,其他相同类型的信号再来会被简单的丢弃,(前面提到过)并且需要正确的回收所有的僵死程序(已终止但未被回收)。在编写程序中正确的信号处理也需要注意,不然就会导致信号的丢失。

3.可移植的信号处理

Unix信号处理另一个缺陷在于不同的系统有不同的信号处理语义。例如:

1.signal函数语义各有不同

2.系统调用可以被中断

为了解决这些问题,posix标准定义了sigaction函数,允许用户在设置信号处理时明确指定他们想要的信号处理语义。

1
2
3
4
#include <signal.h>
int sigaction(int signum, struct sigaction *act,
struct sigaction *oldact);
//成功则为0,出错则为-1

sigaction函数运用不广泛,因为它要求用户设置一个复杂结构的条目。更简洁的是定义一个包装函数Signal

Signal包装函数设置了一个信号处理程序,其信号语义如下:

1.只有这个处理程序当前正在处理的那种类型的信号被阻塞

2.和所有信号实现一样,信号不会排队等待

3.只要可能,被中断的系统调用会自动重启

4.一旦设置了信号处理程序,它就会一直保持,知道Signal带着handler参数被调用

同步流以避免并发错误

经典的unix结构,父进程在一个全局作业列表中记录着它的当前子进程,每个作业一个条目。addjob和deletejob函数分别向这个作业列表添加和从中删除作业。

当父进程创建一个新的子进程时,它就把子进程添加到作业列表中。当父进程在SIGCHLD处理程序中回收一个终止的僵死子进程时,它就从作业列表中删除这个子进程。

但对于父进程的main程序和信号处理流的某些交错,可能会在addjob之前调用deletejob,这导致作业列表中出现一个不正确的条目,对应于一个不再勋在而且永远也不会被删除的作业,另一方面也有一些交错时间按照正确的顺序发生。

我们可以通过在调用fork之前,阻塞SIGCHLD信号,然后调用addjob之后取消阻塞这些信号,这样保证了在子程序被添加到作业列表之后回收该子进程。

显示地等待信号

有时候主程序需要显式地等待某个信号处理程序运行。例如当创建一个前台作业时,在接受下一条用户命令之前,它必须等待作业终止,被SIGCHLD处理程序回收。

一个基本思路是父进程设置SIGINT和SIGCHLD的处理程序,然后进入无限循环,阻塞SIGCHLD信号,避免上面讨论的父进程和子进程之间调用的顺序竞争。创建子进程后把pid重置为0,取消阻塞,然后以循环的方式等待pid变为非零。子进程终止后,处理程序回收,把非零PID复制给全局pid变量,会终止循环,父进程继续其他的工作,然后进行下一次迭代。

当代码正确执行时,循环在浪费处理器资源,我们可以通过在循环中插入sleep,pause,但这样收到一个或多个SIGINT信号时pause会被中断,且会引起竞争:在while测试后和pause之前收到SIGCHLD信号,pause会永远睡眠,sleep又会使代码执行时间过长,合适的解决方法是使用sigsuspend

1
2
3
#include <signal.h>
int sigsuspend(const sigset_t *mask);
//返回-1

sigsuspend函数暂时用mask替换当前的阻塞集合,然后挂起该进程,直到收到一个信号,其行为要么是运行一个处理程序,要么是终止该进程。如果它的行为是终止,那么该进程不从sigsuspend返回就直接终止。如果是运行一个处理程序,那么sigsuspend从处理程序返回,恢复调用时原有的阻塞集合。

非本地跳转

c语言提供了一种用户级的异常控制流形式,称为非本地跳转,它将控制直接从一个函数转移到另一个当前正在执行的函数,而不需要经过正常的调用返回序列,非本地跳转是通过setjmp和longjmp函数来提供的

1
2
3
#include <setjmp.h>
int setjmp(jmp_buf env);
int sigsetjmp(sigjmp_buf env, int savesigs);

非本地跳转的一个重要应用是允许从一个深层嵌套的函数调用中立即返回,通常是由检测到某个错误情况引起的。可以使用非本地跳转直接返回到一个普通的本地化的错误处理程序,而不是费力地解开调用栈。

非本地跳转的另一个重要应用是使一个信号处理程序分支到一个特殊的代码位置,而不是返回到被信号到达中断了的指令位置。

操作进程的工具

linux系统提供了监控和操作进程的有用工具。

STRACE:打印一个正在运行的程序和它的子进程调用的每个系统调用的轨迹。

PS:列出当前系统中的进程。

TOP:打印出关于当前进程资源使用的信息

PMAP:系那是进程的内存映射。

/proc:虚拟文件系统,以ASCII文本格式输出大量内核数据结构的内容,用户可以读取内容。

Author: John Doe

Link: http://example.com/2022/02/16/csapp-8/

Copyright: All articles in this blog are licensed under CC BY-NC-SA 3.0 unless stating additionally.

< PreviousPost
演练和使用自己的动态链接库C++
NextPost >
angr-理解符号执行
CATALOG
  1. 1. 第8章
    1. 1.1. 进程
      1. 1.1.1. 逻辑控制流
      2. 1.1.2. 并发流
      3. 1.1.3. 私有地址空间
      4. 1.1.4. 用户模式和内核模式
      5. 1.1.5. 上下文切换
      6. 1.1.6. 系统错误处理
    2. 1.2. 进程控制
      1. 1.2.1. 获取进程ID
      2. 1.2.2. 创建和终止进程
      3. 1.2.3. 回收子进程
      4. 1.2.4. 让进程休眠
      5. 1.2.5. 加载并运行程序
      6. 1.2.6. 简单shell示例
    3. 1.3. 信号
      1. 1.3.1. 信号术语
      2. 1.3.2. 发送信号
        1. 1.3.2.1. 1.进程组
        2. 1.3.2.2. 2.用/bin/kill 程序发送信号
        3. 1.3.2.3. 3.从键盘发送信号
        4. 1.3.2.4. 4.用kill函数发送信号
        5. 1.3.2.5. 5.用alarm函数发送信号
      3. 1.3.3. 接收信号
      4. 1.3.4. 阻塞和解除阻塞信号
      5. 1.3.5. 编写信号处理程序
        1. 1.3.5.1. 1.安全的信号处理
        2. 1.3.5.2. 2.正确的信号处理
        3. 1.3.5.3. 3.可移植的信号处理
      6. 1.3.6. 同步流以避免并发错误
      7. 1.3.7. 显示地等待信号
      8. 1.3.8. 非本地跳转
      9. 1.3.9. 操作进程的工具