☰
InterviewGuide 操作系统面试精讲(第 21–40 题):并发经典问题、内存管理与进程控制的深度剖析
2026/10/12 3:44:37 网站建设 项目流程
  • 教程

【免费下载链接】InterviewGuide

🔥🔥「InterviewGuide」是阿秀从校园->职场多年计算机自学过程的记录以及学弟学妹们计算机校招&秋招经验总结文章的汇总,包括但不限于C/C++ 、Golang、JavaScript、Vue、操作系统、数据结构、计算机网络、MySQL、Redis等学习总结,坚持学习,持续成长!

项目地址:https://gitcode.com/gh_mirrors/in/InterviewGuide
点击查看免费下载

导读:本文基于 InterviewGuide 开源仓库中「操作系统 21–40」面试真题笔记(docs/notes/03-hunting_job/02-interview/02-02-os.md)展开,覆盖哲学家进餐、读者-写者、典型锁与 POSIX 线程锁等并发同步经典问题,内存作用、逻辑/物理地址、快表 TLB、malloc 底层实现、覆盖与交换技术等内存管理核心,以及守护/僵尸/孤儿进程、进程组与会话、后台运行、进程终止、中断与异常等进程控制高频考点。读完本文,你将能系统回答校招/社招面试中从「进程线程模型」延伸到「并发同步与内存管理」的进阶追问,并掌握可直接上手的 pthread 接口用法与 Linux 后台运维命令。

本文对应的题库共三篇,本文讲解第 21–40 题,前 20 题见 02-01-os.md,后 20 题(41–60,含内存布局、磁盘调度、堆栈分配等)见 02-03-os.md。若想了解阿秀推荐的 OS 学习路径,可参考 操作系统学习路线。


一、内存管理:从物理内存到虚拟地址

1. 内存的理解与作用

关于内存,面试官通常想听到的是分层认知:

  • 从硬件角度,内存(RAM)是 CPU 直接寻址的物理存储介质,访问速度远快于磁盘,但容量远小于磁盘;
  • 从操作系统角度,内存是进程运行的物质基础——程序必须被加载到内存中才能被 CPU 执行;
  • 从程序员角度,内存是一段可通过地址访问的线性空间,我们通过指针、数组、malloc 等机制操作它。

内存的核心作用可以概括为三点:存放正在运行的进程的指令与数据、为进程提供共享的数据交换区域、通过缓存机制弥补 CPU 与磁盘之间的速度鸿沟。后续的覆盖、交换、虚拟内存等所有技术,本质上都是为了在"内存有限"与"程序需求无限"这对矛盾之间寻找平衡。

注:内存相关的可视化示意可见 02-02-os.assets/202205212343183.png(对应原文档第 21 题配图),更多内存布局细节(用户空间 7 种内存段)在第 41 题中展开。

2. 逻辑地址与物理地址

编译时,程序中的变量并不会直接得到一个真实的物理地址,而是得到一个相对地址。例如变量x存放的相对地址是 100,这个 100 是相对于进程在内存中起始地址的偏移。

CPU 想要找到x在内存中的实际存放位置,只需要用进程的起始地址 + 100即可得到绝对地址。

  • 相对地址又称逻辑地址:程序视角看到的地址,是虚拟的、独立于物理内存的;
  • 绝对地址又称物理地址:内存单元的真实地址,由地址总线给出。

操作系统正是通过页表等机制,把每个进程的逻辑地址映射到不同的物理地址上,从而实现多个进程互不干扰地共享物理内存。

3. 快表(TLB):加速地址变换的关键

快表,又称联想寄存器(TLB,Translation Lookaside Buffer),是一种访问速度比内存快很多的高速缓冲存储器,用来存放当前访问的若干页表项,以加速地址变换的过程。与此对应,内存中的页表常称为慢表。

为什么需要 TLB?因为页表存放在内存中,每次逻辑地址到物理地址的转换都需要查内存中的页表,而一次访存已经很昂贵,如果每次都额外查一次页表,开销会成倍增加。TLB 把最近常用的页表项缓存到 CPU 附近的高速存储中,从而大幅降低地址变换成本。

有快表和没有快表的地址变换过程对比如下:

对比项地址变换过程访问一个逻辑地址的访存次数
基本地址变换机构①算页号、页内偏移量 ②检查页号合法性 ③查页表,找到页面存放的内存块号 ④根据内存块号与页内偏移量得到物理地址 ⑤访问目标内存单元两次访存
具有快表的地址变换机构①算页号、页内偏移量 ②检查页号合法性 ③查快表。若命中,即可知道页面存放的内存块号,可直接进行⑤;若未命中则进行④ ④查页表,找到页面存放的内存块号,并且将页表项复制到快表中 ⑤根据内存块号与页内偏移量得到物理地址 ⑥访问目标内存单元快表命中,只需一次访存;快表未命中,需要两次访存

可以明显看出:快表命中时把两次访存压缩为一次,这就是 TLB 提升系统性能的根本原因;而快表未命中时的开销与基本机构持平,还会额外付出将页表项复制进快表的成本。

4. 执行 malloc 时操作系统做了什么?

从操作系统层面看,malloc是通过两个系统调用来实现的:brk和mmap。

  • brk:将进程数据段(.data)的最高地址指针向高处移动,这一步可以扩大进程在运行时的堆大小;
  • mmap:在进程的虚拟地址空间中寻找一块空闲的虚拟内存,这一步可以获得一块可以操作的堆内存。

通常的分配策略是:分配的内存小于 128K 时,使用 brk 调用来获得虚拟内存;大于 128K 时就使用 mmap 来获得虚拟内存。

完整的过程分两步走:

  1. 进程先通过这两个系统调用获取或扩大进程的虚拟内存,获得相应的虚拟地址;
  2. 在访问这些虚拟地址的时候,通过缺页中断,让内核分配相应的物理内存,至此内存分配才算真正完成。

也就是说,malloc分配到的首先只是虚拟地址空间,物理内存是按需(首次访问时)通过缺页中断分配的。这与后面第 44 题(02-03-os.md)中"动态内存分配时更新页表 PTE、建立虚拟页与物理页映射"的描述互为印证。

5. 内存的覆盖技术与交换技术

覆盖(Overlay):由于程序运行时并非任何时候都要访问程序及数据的各个部分(尤其是大程序),因此可以把用户空间分为一个固定区和若干个覆盖区。将经常活跃的部分放在固定区,其余部分按照调用关系分段:首先将那些即将要访问的段放入覆盖区,其他段放在外存中,在需要调用前,系统将其调入覆盖区,替换覆盖区中原有的段。

覆盖技术的特点:

  • 打破了"必须将一个进程的全部信息装入内存后才能运行"的限制;
  • 但当同时运行程序的代码量大于主存时仍不能运行;
  • 内存中能够更新的地方只有覆盖区的段,不在覆盖区的段会常驻内存。

**交换(对换,Swap)**的设计思想:内存空间紧张时,系统将内存中某些进程暂时换出外存,把外存中某些已具备运行条件的进程换入内存(进程在内存与磁盘间动态调度)。

  • 换入:把准备好竞争 CPU 运行的程序从辅存移到内存;
  • 换出:把处于等待状态(或 CPU 调度原则下被剥夺运行权力)的程序从内存移到辅存,把内存空间腾出来。

什么时候会进行内存的交换?内存交换通常在许多进程运行且内存吃紧时进行,而系统负荷降低就暂停。例如:发现许多进程运行时经常发生缺页,就说明内存紧张,此时可以换出一些进程;如果缺页率明显下降,就可以暂停换出。

覆盖是同一进程内部的段替换技术,而交换是进程之间的整体换入换出,两者解决的问题层次不同。

6. 局部性原理

局部性原理是虚拟存储技术能够成立的基石,主要分为时间局部性和空间局部性两大类型:

  • 时间局部性:如果执行了程序中的某条指令,那么不久后这条指令很有可能再次执行;如果某个数据被访问过,不久之后该数据很可能再次被访问(因为程序中存在大量的循环);
  • 空间局部性:一旦程序访问了某个存储单元,在不久之后,其附近的存储单元也很有可能被访问(因为很多数据在内存中都是连续存放的,并且程序的指令也是顺序地在内存中存放的)。

正因局部性的存在,程序才不需要一次性把全部代码和数据装入内存,缓存、快表、预取、缺页换入等机制才能高效工作。


二、并发同步:经典问题与典型锁

1. 哲学家进餐问题

五个哲学家围着一张圆桌,每个哲学家面前放着食物。哲学家的生活有两种交替活动:吃饭以及思考。当一个哲学家吃饭时,需要先拿起自己左右两边的两根筷子,并且一次只能拿起一根筷子。

下面是一种错误的解法:如果所有哲学家同时拿起左手边的筷子,那么所有哲学家都在等待其它哲学家吃完并释放自己手中的筷子,导致死锁。

#define N 5 void philosopher(int i) { while(TRUE) { think(); take(i); // 拿起左边的筷子 take((i+1)%N); // 拿起右边的筷子 eat(); put(i); put((i+1)%N); } }

为了防止死锁的发生,可以设置两个条件:

  • 必须同时拿起左右两根筷子;
  • 只有在两个邻居都没有进餐的情况下才允许进餐。

基于信号量(semaphore)的正确解法如下:

#define N 5 #define LEFT (i + N - 1) % N // 左邻居 #define RIGHT (i + 1) % N // 右邻居 #define THINKING 0 #define HUNGRY 1 #define EATING 2 typedef int semaphore; int state[N]; // 跟踪每个哲学家的状态 semaphore mutex = 1; // 临界区的互斥,临界区是 state 数组,对其修改需要互斥 semaphore s[N]; // 每个哲学家一个信号量 void philosopher(int i) { while(TRUE) { think(i); take_two(i); eat(i); put_two(i); } } void take_two(int i) { down(&mutex); state[i] = HUNGRY; check(i); up(&mutex); down(&s[i]); // 只有收到通知之后才可以开始吃,否则会一直等下去 } void put_two(i) { down(&mutex); state[i] = THINKING; check(LEFT); // 尝试通知左右邻居,自己吃完了,你们可以开始吃了 check(RIGHT); up(&mutex); } void eat(int i) { down(&mutex); state[i] = EATING; up(&mutex); } // 检查两个邻居是否都没有用餐,如果是的话,就 up(&s[i]),使得 down(&s[i]) 能够得到通知并继续执行 void check(i) { if(state[i] == HUNGRY && state[LEFT] != EATING && state[RIGHT] !=EATING) { state[i] = EATING; up(&s[i]); } }

这段代码的巧妙之处在于:state 数组本身就是临界区,所有对它的修改都用mutex保护;每个哲学家各自的信号量s[i]用于阻塞自己,只有check判定邻居都不在吃饭时才up(&s[i])唤醒它,从而既避免了死锁,也保证了两个条件同时满足。

2. 读者-写者问题

允许多个进程同时对数据进行读操作,但是不允许读和写以及写和写操作同时发生。

使用一个整型变量count记录在对数据进行读操作的进程数量,一个互斥量count_mutex用于对count加锁,一个互斥量data_mutex用于对读写的数据加锁:

typedef int semaphore; semaphore count_mutex = 1; semaphore data_mutex = 1; int count = 0; void reader() { while(TRUE) { down(&count_mutex); count++; if(count == 1) down(&data_mutex); // 第一个读者需要对数据进行加锁,防止写进程访问 up(&count_mutex); read(); down(&count_mutex); count--; if(count == 0) up(&data_mutex); // 最后一个读者要对数据进行解锁,防止写进程无法访问 up(&count_mutex); } } void writer() { while(TRUE) { down(&data_mutex); write(); up(&data_mutex); } }

这里的关键设计是:只有第一个读者才真正获取数据锁,最后一个读者才释放数据锁。中间读者只是修改count计数,这样多个读者之间可以并行读,而写者一旦拿到data_mutex就独占数据,实现"读读共享、读写互斥、写写互斥"。这种实现是读者优先的——只要有读者在,写者就可能被持续阻塞。

3. 介绍一下几种典型的锁

读写锁

  • 多个读者可以同时进行读;
  • 写者必须互斥(只允许一个写者写,也不能读者写者同时进行);
  • 写者优先于读者(一旦有写者,则后续读者必须等待,唤醒时优先考虑写者)。

互斥锁

一次只能一个线程拥有互斥锁,其他线程只有等待。

互斥锁是在抢锁失败的情况下主动放弃 CPU 进入睡眠状态,直到锁的状态改变时再唤醒;而操作系统负责线程调度,为了实现锁的状态发生改变时唤醒阻塞的线程或进程,需要把锁交给操作系统管理,所以互斥锁在加锁操作时涉及上下文的切换。

互斥锁实际的效率还是可以让人接受的,加锁的时间大概 100ns 左右;而实际上互斥锁的一种可能的实现是先自旋一段时间,当自旋的时间超过阈值之后再将线程投入睡眠中,因此在并发运算中使用互斥锁(每次占用锁的时间很短)的效果可能不亚于使用自旋锁。

条件变量

互斥锁一个明显的缺点是它只有两种状态:锁定和非锁定。而条件变量通过允许线程阻塞和等待另一个线程发送信号的方法弥补了互斥锁的不足,它常和互斥锁一起使用,以免出现竞态条件。

当条件不满足时,线程往往解开相应的互斥锁并阻塞线程,然后等待条件发生变化。一旦其它的某个线程改变了条件变量,它将通知相应的条件变量唤醒一个或多个正被此条件变量阻塞的线程。

总的来说:互斥锁是线程间互斥的机制,条件变量则是同步机制。

自旋锁

如果线程无法取得锁,线程不会立刻放弃 CPU 时间片,而是一直循环尝试获取锁,直到获取为止。

如果别的线程长时期占有锁,那么自旋就是在浪费 CPU 做无用功;但是自旋锁一般应用于加锁时间很短的场景,这个时候效率比较高。

4. POSIX 线程锁(互斥锁、条件变量、自旋锁)

互斥锁(mutex)属于sleep-waiting 类型的锁。例如在一个双核的机器上有两个线程 A 和 B,它们分别运行在 core 0 和 core 1 上。假设线程 A 想要通过pthread_mutex_lock操作去得到一个临界区的锁,而此时这个锁正被线程 B 所持有,那么线程 A 就会被阻塞,此时会通过上下文切换将线程 A 置于等待队列中,此时 core 0 就可以运行其他的任务(如线程 C)。

条件变量(cond)常与互斥锁配合使用,用于实现"等待条件满足再继续"的同步语义,解决互斥锁只有锁定/非锁定两种状态的不足。

自旋锁(spin)属于busy-waiting 类型的锁。如果线程 A 是使用pthread_spin_lock操作去请求锁,如果自旋锁已经被线程 B 所持有,那么线程 A 就会一直在 core 0 上进行忙等待并不停地进行锁请求,检查该自旋锁是否已经被线程 B 释放,直到得到这个锁为止。因为自旋锁不会引起调用者睡眠,所以自旋锁的效率远高于互斥锁。

虽然它的效率比互斥锁高,但是它也有些不足之处:

  • 自旋锁一直占用 CPU,在未获得锁的情况下一直进行自旋,所以占用着 CPU;如果不能在很短的时间内获得锁,无疑会使 CPU 效率降低;
  • 用自旋锁时有可能造成死锁,当递归调用时有可能造成死锁;
  • 自旋锁只有在内核可抢占式或 SMP(对称多处理)的情况下才真正需要;在单 CPU 且不可抢占式的内核下,自旋锁的操作为空操作。自旋锁适用于锁使用者保持锁时间比较短的情况。

一句话总结两者的取舍:互斥锁会睡眠等待(省 CPU 但慢),自旋锁会忙等(快但耗 CPU),因此自旋锁适合临界区极短的场景。


三、进程、线程与作业控制

1. 怎么回收线程?有哪几种方法?

等待线程结束:int pthread_join(pthread_t tid, void** retval);

主线程调用,等待子线程退出并回收其资源,类似于进程中wait/waitpid回收僵尸进程,调用pthread_join的线程会被阻塞。

  • tid:创建线程时通过指针得到的 tid 值;
  • retval:指向返回值的指针。

结束线程:void pthread_exit(void *retval);

子线程执行,用来结束当前线程并通过retval传递返回值,该返回值可通过pthread_join获得。

  • retval:同上。

分离线程:int pthread_detach(pthread_t tid);

主线程、子线程均可调用。主线程中pthread_detach(tid),子线程中pthread_detach(pthread_self()),调用后和主线程分离,子线程结束时自己立即回收资源。

  • tid:同上。

从工程实践看,这三者对应三种典型的线程生命周期管理策略:join 同步回收(阻塞等待)、exit 主动结束并传递结果、detach 交给系统自动回收(适合不需要等待结果的短生命周期线程)。线程创建与属性相关的完整接口(pthread_create、pthread_attr_t结构体等)可参见 02-01-os.md。

2. 守护进程、僵尸进程和孤儿进程

守护进程(Daemon):指在后台运行的、没有控制终端与之相连的进程。它独立于控制终端,周期性地执行某种任务。Linux 的大多数服务器就是用守护进程的方式实现的,如 web 服务器进程 http 等。

创建守护进程的要点:

  1. 让程序在后台执行。方法是调用fork()产生一个子进程,然后使父进程退出;
  2. 调用setsid()创建一个新会话期。控制终端、登录会话和进程组通常是从父进程继承下来的,守护进程要摆脱它们、不受它们的影响,方法是调用setsid()使进程成为一个会话组长。setsid()调用成功后,进程成为新的会话组长和进程组长,并与原来的登录会话、进程组和控制终端脱离;
  3. 禁止进程重新打开控制终端。经过以上步骤,进程已经成为一个无终端的会话组长,但是它可以重新申请打开一个终端。为了避免这种情况发生,可以通过使进程不再是会话组长来实现——再一次通过fork()创建新的子进程,使调用 fork 的进程退出;
  4. 关闭不再需要的文件描述符。子进程从父进程继承打开的文件描述符,如不关闭,将会浪费系统资源,造成进程所在的文件系统无法卸下以及引起无法预料的错误。首先获得最高文件描述符值,然后用一个循环程序,关闭 0 到最高文件描述符值的所有文件描述符;
  5. 将当前目录更改为根目录;
  6. 将文件创建屏蔽字清零。子进程从父进程继承的文件创建屏蔽字可能会拒绝某些许可权,为防止这一点,使用umask(0)将屏蔽字清零;
  7. 处理 SIGCHLD 信号。对于服务器进程,在请求到来时往往生成子进程处理请求。如果子进程等待父进程捕获状态,则子进程将成为僵尸进程,从而占用系统资源;如果父进程等待子进程结束,将增加父进程的负担,影响服务器进程的并发性能。在 Linux 下可以简单地将 SIGCHLD 信号的操作设为SIG_IGN,这样子进程结束时不会产生僵尸进程。

孤儿进程(Orphan):如果父进程先退出,子进程还没退出,那么子进程的父进程将变为 init 进程(注意:任何一个进程都必须有父进程)。一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被 init 进程(进程号为 1)所收养,并由 init 进程对它们完成状态收集工作。

僵尸进程(Zombie):如果子进程先退出,父进程还没退出,那么子进程必须等到父进程捕获到了子进程的退出状态才真正结束,否则这个时候子进程就成为僵尸进程。

设置僵尸进程的目的是维护子进程的信息,以便父进程在以后某个时候获取。这些信息至少包括进程 ID、进程的终止状态,以及该进程使用的 CPU 时间,所以当终止子进程的父进程调用wait或waitpid时就可以得到这些信息。如果一个进程终止,而该进程有子进程处于僵尸状态,那么它的所有僵尸子进程的父进程 ID 将被重置为 1(init 进程)。继承这些子进程的 init 进程将清理它们(也就是说 init 进程将wait它们,从而去除它们的僵尸状态)。

3. 如何避免僵尸进程?

  • 通过signal(SIGCHLD, SIG_IGN)通知内核对子进程的结束不关心,由内核回收。如果不想让父进程挂起,可以在父进程中加入一条语句signal(SIGCHLD, SIG_IGN);,表示父进程忽略 SIGCHLD 信号——该信号是子进程退出的时候向父进程发送的;
  • 父进程调用wait/waitpid等函数等待子进程结束。如果尚无子进程退出,wait会导致父进程阻塞;waitpid可以通过传递WNOHANG使父进程不阻塞立即返回;
  • 如果父进程很忙,可以用signal注册信号处理函数,在信号处理函数中调用wait/waitpid等待子进程退出;
  • 通过两次调用 fork。父进程首先调用fork创建一个子进程然后waitpid等待子进程退出,子进程再fork一个孙进程后退出。这样子进程退出后会被父进程等待回收;而对于孙子进程,其父进程已经退出所以孙进程成为一个孤儿进程,孤儿进程由 init 进程接管,孙进程结束后 init 会等待回收。

第一种方法——忽略 SIGCHLD 信号——常用于并发服务器的性能技巧:因为并发服务器常常 fork 很多子进程,子进程终结之后需要服务器进程去wait清理资源。如果将此信号的处理方式设为忽略,可让内核把僵尸子进程转交给 init 进程去处理,省去了大量僵尸进程占用系统资源。

4. 父进程、子进程、进程组、作业和会话

父进程:已创建一个或多个子进程的进程。

子进程:由fork创建的新进程被称为子进程(child process)。该函数被调用一次,但返回两次。两次返回的区别是子进程的返回值是 0,而父进程的返回值则是新进程(子进程)的进程 id。

将子进程 id 返回给父进程的理由是:一个进程的子进程可以多于一个,没有一个函数使一个进程可以获得其所有子进程的进程 id。对子进程来说,之所以fork返回 0 给它,是因为它随时可以调用getpid()来获取自己的 pid,也可以调用getppid()来获取父进程的 id(进程 id 0 总是由交换进程使用,所以一个子进程的进程 id 不可能为 0)。

fork之后,操作系统会复制一个与父进程完全相同的子进程。虽说是父子关系,但在操作系统看来,它们更像兄弟关系:这 2 个进程共享代码空间,但是数据空间是互相独立的,子进程数据空间中的内容是父进程的完整拷贝,指令指针也完全相同,子进程拥有父进程当前运行到的位置(两进程的程序计数器 PC 值相同,也就是说,子进程是从 fork 返回处开始执行的)。

有一点不同:如果fork成功,子进程中fork的返回值是 0,父进程中fork的返回值是子进程的进程号;如果fork不成功,父进程会返回错误。

子进程从父进程继承的有:

  1. 进程的资格(真实(real)/有效(effective)/已保存(saved)用户号(UIDs)和组号(GIDs));
  2. 环境(environment);
  3. 堆栈;
  4. 内存;
  5. 进程组号。

子进程独有的是:

  1. 进程号;
  2. 不同的父进程号(即子进程的父进程号与父进程的父进程号不同,父进程号可由getppid函数得到);
  3. 资源使用(resource utilizations)设定为 0。

进程组:进程组就是多个进程的集合,其中肯定有一个组长,其进程 PID 等于进程组的 PGID。只要在某个进程组中一个进程存在,该进程组就存在,这与其组长进程是否终止无关。

作业:shell 分前后台来控制的不是进程而是作业(job)或者进程组(Process Group)。一个前台作业可以由多个进程组成,一个后台作业也可以由多个进程组成,shell 可以运行一个前台作业和任意多个后台作业,这称为作业控制。

为什么只能运行一个前台作业?当我们在前台新起了一个作业,shell 就被提到了后台,因此 shell 就没有办法再继续接受我们的指令并且解析运行了。但是如果前台进程退出了,shell 就会被提到前台来,就可以继续接受我们的命令并且解析运行。

作业与进程组的区别:如果作业中的某个进程创建了子进程,则该子进程是不属于该作业的。一旦作业运行结束,shell 就把自己提到前台(子进程还存在,但是子进程不属于作业);如果原来的前台进程还存在(这个子进程还没有终止),它将自动变为后台进程组。

会话(Session):一个或多个进程组的集合。一个会话可以有一个控制终端。在 xshell 或者 WinSCP 中打开一个窗口就是新建一个会话。

5. 进程终止的几种方式

  1. main 函数的自然返回,return;
  2. 调用exit函数,属于 C 的函数库;
  3. 调用_exit函数,属于系统调用;
  4. 调用abort函数,异常程序终止,同时发送 SIGABRT 信号给调用进程;
  5. 接受能导致进程终止的信号:ctrl+c(^C)、SIGINT(SIGINT 中断进程)。

exit 和 _exit 的区别:exit()是对_exit()的封装,两者都会终止进程并做相关收尾工作。最主要的区别是:_exit()函数关闭全部描述符和清理函数后不会刷新流,而exit()会在调用_exit()函数前刷新数据流(即先冲洗标准 I/O 缓冲区再终止进程)。

另外在 02-01-os.md 中还补充了return与exit()的区别:exit()是函数、有参数,执行完之后控制权交给系统;return若在调用函数中,执行完之后控制权交给调用进程,若在 main 函数中,控制权交给系统。

6. 终端退出与后台运行

终端退出,终端运行的进程会怎样?终端在退出时会发送SIGHUP给对应的 bash 进程,bash 进程收到这个信号后首先将它发给 session 下面的进程;如果程序没有对 SIGHUP 信号做特殊处理,那么进程就会随着终端关闭而退出。

如何让进程后台运行?有五种常用方法:

  1. 命令后面加上&即可。实际上,这样是将命令放入到一个作业队列中了;
  2. ctrl + z挂起进程,使用jobs查看序号,再用bg %序号后台运行进程;
  3. nohup+&,标准输出和标准错误缺省会被重定向到nohup.out文件中,忽略所有挂断(SIGHUP)信号;
  4. 运行指令前面加setsid,使其父进程变成 init 进程,不受 HUP 信号的影响;
  5. 将"命令 + &"放在()括号中,也可以是进程不受 HUP 信号的影响。

这五种方法的核心差异在于"如何阻断 SIGHUP 信号链":&只是放入作业队列仍可能随会话退出;nohup是显式忽略 SIGHUP;setsid与(cmd &)是让进程脱离原会话,从根源上不再接收该信号。结合第 35 题守护进程的创建要点(fork+setsid),可以看出setsid正是创建守护进程摆脱控制终端的关键一步。


四、中断与异常:CPU 与外设的两种"打扰"

中断

大家都知道,当我们在敲击键盘的同时就会产生中断,当硬盘读写完数据之后也会产生中断。所以我们需要知道,中断大多数是由硬件设备产生的,而它们从物理上说就是电信号。

之后,它们通过中断控制器发送给 CPU,接着 CPU 判断收到的中断来自于哪个硬件设备(这定义在内核中),最后,由 CPU 发送给内核,由内核处理中断。

软件中断与软中断:除了硬件中断,还存在部分由软件产生的中断,称之为软件中断(Software Interrupt),最常见的有引发系统调用的Int 0x80。

同时,软件中断又区别于软中断(SoftIRQ):软中断主要用于中断处理的下半程(Bottom Halves)——非关键逻辑的部分,用来提高中断处理的效率与实时性,最常用于 I/O 相关的中断处理。

处理中断下半程的方法除了软中断,还有tasklet,二者存在一定的区别与联系:

  • 二者都可以被注册用于中断处理下半程的延时任务;
  • 同一种类型的 tasklet 只能串行执行,而同类型的软中断可以多个 CPU 并发执行;不同类型的软中断和 tasklet 均可并发执行;
  • tasklet 底层是基于两种软中断来实现的,分别是HI_SOFTIRQ和TASKLET_SOFTIRQ。

异常

学习《计算机组成原理》时会知道两个概念:CPU 处理程序的时候一旦程序不在内存中,会产生缺页异常;当运行除法程序时,当除数为 0 时,又会产生除 0 异常。

异常是由 CPU 产生的,同时,它会发送给内核,要求内核处理这些异常。

相同点

  • 最后都是由 CPU 发送给内核,由内核去处理;
  • 处理程序的流程设计上是相似的。

不同点

  • 产生源不相同:异常是由 CPU 产生的,而中断主要是由硬件设备产生的;
  • 内核需要根据是异常还是中断调用不同的处理程序;
  • 中断不是时钟同步的,这意味着中断可能随时到来;异常由于是 CPU 产生的,所以它是时钟同步的;
  • 当处理中断时,处于中断上下文中;处理异常时,处于进程上下文中。

补充一点:在本系列前 20 题中(02-01-os.md)还区分过"外中断和异常":外中断是指由 CPU 执行指令以外的事件引起,如 I/O 完成中断;而异常是由 CPU 执行指令的内部事件引起,如非法操作码、地址越界、算术溢出等。本题则进一步上升到中断/异常的全链路处理流程与上下文的区别,前后两题可以结合起来记忆。


小结:第 21–40 题的复习路线

这 20 道题可以归纳为三条主线,也是面试官层层追问的常见路径:

  1. 内存主线:内存的作用 → 逻辑/物理地址 → 页表与快表(TLB)→ malloc 的 brk/mmap 实现 → 覆盖与交换 → 局部性原理。这条线直接衔接第 41 题"内存分布"与虚拟内存、抖动等后续考点;
  2. 并发主线:哲学家进餐 → 读者-写者 → 读写锁/互斥锁/条件变量/自旋锁 → POSIX 线程锁。配合前 20 题的进程线程模型(02-01-os.md),就能完整覆盖"进程-线程-并发同步"的高频追问;
  3. 进程控制主线:线程回收(join/exit/detach)→ 守护/僵尸/孤儿进程 → 避免僵尸进程 → 进程组/作业/会话 → 进程终止方式 → 终端退出与后台运行。这条线大量涉及 APUE 风格的实战知识点,也是校招面试中"动手能力"考核的高发区。

建议在复习完本文后,继续阅读 02-03-os.md 的第 41–60 题(内存分布、磁盘调度、交换空间、内存分配错误等),并在本地 Linux 环境中实际执行jobs/bg/nohup/setsid、fork/waitpid、pthread_*系列接口做验证,才能真正把这些面试知识点内化为自己的工程能力。

  • 教程

【免费下载链接】InterviewGuide

🔥🔥「InterviewGuide」是阿秀从校园->职场多年计算机自学过程的记录以及学弟学妹们计算机校招&秋招经验总结文章的汇总,包括但不限于C/C++ 、Golang、JavaScript、Vue、操作系统、数据结构、计算机网络、MySQL、Redis等学习总结,坚持学习,持续成长!

项目地址:https://gitcode.com/gh_mirrors/in/InterviewGuide
点击查看免费下载

相关推荐

上一篇:StatsD Ruby客户端使用指南:Rails应用集成
下一篇:基于 Rube MCP 自动化 Gagelist 操作:awesome-claude-skills 中的完整实战指南

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询