嵌入式软件八股文(1)
第一章 进程线程
1.1 进程线程的基本概念
1.1.1 什么是进程,线程,彼此有什么区别⭐⭐⭐⭐⭐
进程与线程的概念:在操作系统中,进程(Process)是计算机中已运行程序的实体,是操作系统进行“资源分配”的基本单位。每个进程都拥有独立的内存空间(包括代码段、数据段、堆栈等)和系统资源(如文件描述符)。线程(Thread)则是操作系统能够进行“运算调度”的最小单位,它被包含在进程之中,是进程中的实际运作单位。一个进程可以并发多个线程,这些线程共享该进程的所有资源。

从图里就能很清楚地看到:外面那个蓝色的大框就是“进程(工厂)”,它独占了代码、数据和文件资源;而里面三个绿色的框就是“线程(工人)”,他们共享外面的大环境,但每个人手里都有自己专属的“寄存器和栈(工具箱)”来干活。
判断结论:进程与线程的核心区别在于“资源拥有权”与“调度开销”。进程是资源的“所有者”,互相之间极其隔离,安全性高但切换开销巨大;线程是执行的“打工人”,轻装上阵,共享老板(进程)的资源,切换极快且通信方便,但“一损俱损”(一个线程崩溃可能导致整个进程崩溃)。
参考回答:
“关于进程和线程,我通常喜欢用‘工厂和工人’的模型来理解。
进程就像是一座工厂,操作系统分配给这个工厂独立的地皮(也就是内存空间)、各种设备和原材料(系统资源)。工厂本身是不干活的,它只是提供一个运行环境。线程就是这座工厂里的流水线工人,他们才是真正去干活、被 CPU 调度执行的最小单位。一个工厂里至少有一个工人(主线程),当然也可以招很多工人(多线程)一起并行干活。
它们俩的区别,在实际开发中主要体现在四个方面:
第一是资源分配。进程之间是完全隔离的,每座工厂都有自己的大门,互不干扰;但同一个进程里的线程,是共享这座工厂里的所有车间和原材料的(比如代码段、数据段、堆区),只有每个人自己干活用的工具箱(也就是栈空间和寄存器)是私有的。
第二是开销大小。如果要创建一个进程,或者在两个进程间切换,操作系统得重新分配一大堆资源、切换页表,这个开销非常大(建新工厂很费劲);但线程非常轻量级,因为它们共享绝大部分资源,所以线程的创建和上下文切换速度要快得多。
第三是通信方式。进程之间因为互相隔离,想要通信就得靠‘跨厂物流’,也就是操作系统提供的 IPC 机制,比如管道、消息队列或者共享内存,比较麻烦;而线程之间因为都在同一个内存空间里,直接读写全局变量就能通信,非常方便,但也因此必须得自己加锁(Mutex)来处理并发同步的问题。
第四是健壮性。如果一个进程崩了,因为操作系统有保护机制,通常不会波及到其他进程;但如果在多线程程序里,只要有一个线程因为野指针或者段错误崩了,整个工厂都会直接停工,也就是整个进程都会跟着崩溃。”
1.1.2多进程、多线程的优缺点⭐⭐⭐⭐
多进程与多线程优缺点的概念:在并发编程模型中,多进程(Multi-processing)依赖操作系统创建多个独立的内存空间,侧重于系统资源的重兵隔离;多线程(Multi-threading)则在同一个进程空间内开辟多条执行路径,侧重于轻量级的调度和资源共享。
判断结论:多进程拼的是“绝对的稳定和安全”,而多线程拼的是“极致的轻量和高效”。两者的优缺点是互为镜像的:多进程的缺点(开销大、通信难)正是多线程的优点;而多线程的缺点(极度脆弱、并发同步难)也正是多进程的优点。

参考回答:
多进程最大的优点就是极其稳定。因为各个进程的内存是物理隔离的,就像几个独立运作的工厂,一个工厂发生了火灾崩掉了,绝对不会‘火烧连营’影响其他工厂。但它的缺点也很明显,就是太笨重了。不仅操作系统创建和切换进程的开销巨大,而且它们之间想要通个信非常麻烦,得靠管道或者共享内存这些专门的 IPC 机制。
多线程则正好反过来。它的优点是极其轻量、通信极快。因为大家都在同一个工厂里干活,共享代码和数据,所以创建和切换的速度飞快,传数据也只需要直接读写同一个全局变量就行。但它最大的缺点是极其脆弱。只要有一个工人操作失误,比如搞出了个段错误或者野指针,整个进程会立刻被操作系统整体封杀,所有线程都得跟着陪葬。而且共享资源如果不加锁,很容易引发数据被乱写乱盖的并发问题。
所以在工程应用里,如果是那种对稳定性要求极高、或者计算极其密集的任务,比如 Chrome 浏览器的多标签页,一般用多进程;如果是遇到高并发、频繁被外部事件唤醒的 I/O 密集型任务,比如 Web 服务器处理几万个用户的 HTTP 请求,为了节省系统资源,我们一般会毫不犹豫地选择多线程。”
1.1.3什么时候用进程,什么时候用线程⭐⭐⭐
进程与线程选型的概念判断结论:在实际工程架构中,选择多进程还是多线程,核心取决于两个维度的考量:“任务的物理瓶颈在哪里(CPU密集型 vs I/O密集型)” 以及 “系统对容错率的要求有多高”。

参考回答:
“我在实际做架构选型的时候,通常会问自己两个问题:这活儿是费脑子(耗CPU)还是费时间(耗I/O)?这个模块崩了能不能连累整个系统?
第一种情况:果断用多进程。
如果任务是CPU密集型的,比如做视频渲染、大规模的科学计算、或者图像处理。这种活儿需要一直霸占着 CPU 疯狂算,那我会选择多进程,尤其是为了吃满多核 CPU 的算力(像 Python 因为有 GIL 锁,多线程其实是伪并行的,干这种活必须得多进程)。
另外,如果这个模块对稳定性要求极高,绝对不能因为一行代码越界就把整个系统带崩,比如 Nginx 的 Master-Worker 架构,或者 Chrome 浏览器的多标签页,那哪怕开销大一点,我也一定会用多进程把它们物理隔离起来。
第二种情况:果断用多线程。
如果任务是I/O密集型的,比如绝大多数的 Web 服务器、爬虫、或者频繁读写文件的程序。这种活儿绝大部分时间都在‘死等’网络响应或者硬盘读写,CPU 其实闲得发慌。这时候我就会开多线程,让 CPU 在等 I/O 的间隙快速切到别的线程去干活,把时间管理做到极致。
此外,如果并发的任务之间需要频繁地共享大量内存数据,用多线程可以直接读写同一块内存空间,效率极高,完全不用像多进程那样费劲地搞共享内存或者管道通信,只要注意加好锁就行了。”
1.1.4多进程、多线程同步(通讯)的方法⭐⭐⭐⭐⭐
多进程与多线程同步及通信的概念:因为进程和线程在操作系统底层的物理结构完全不同,所以它们之间互相打交道的核心诉求也截然不同。
判断结论:多线程本来就共享同一个内存空间,所以它的重点在于“同步”(防打架);多进程之间是物理隔离的,所以它的重点在于“通信”(送快递)。

进程间通信(IPC)的原理与必要性. Source: JavaGuide / 进程间通信(IPC)详解:管道、消息队列、共享内存、Socket 与Binder ...

多线程利用信号量进行同步的流程. Source: CSDN博客 / 多线程的同步与互斥(互斥锁、条件变量、读写锁、自旋锁、信号量 ...
参考回答: “聊到通信和同步,我一般会把多线程和多进程分开来看。
第一块,多线程的同步机制(重点是防打架)。 因为同一个进程里的线程都是‘一家人’,天生就共享全局变量和堆区内存,所以它们之间传数据根本不需要操作系统插手。但正因为这样,如果两个人同时改一个变量就容易出 Bug,所以多线程的核心是‘同步锁’:
-
互斥锁(Mutex):最简单粗暴。就像上公共厕所,进去就反锁,别人只能在外面死等。这保证了同一时刻只有一个线程能操作临界资源。
-
条件变量(Condition Variable):一般配合互斥锁用。这就像是等外卖,没必要一直开门看(死循环占用 CPU),而是直接让线程休眠,等条件满足了(外卖到了),别的线程发个信号把它唤醒。
-
信号量(Semaphore):像是个多坑位的停车场。可以设定坑位数量,进去一辆车坑位减一,出来加一。坑位为 0 时后面的线程就得排队阻塞。
-
读写锁(Read-Write Lock):针对‘读多写少’的场景优化。只要没人写,大家可以同时一起读;但只要有人在写,其他所有人(无论是读还是写)都得老老实实等着。
第二块,多进程的通信方式(IPC,重点是送快递)。 因为多进程之间有高墙(内存隔离),它们想说话必须得靠操作系统内核这个‘中间人’来传递:
-
管道(Pipe):最古老的方式,数据像水流一样只能单向传输。常见的有匿名管道(只能给父子进程用)和命名管道(没有血缘关系的进程也能用)。
-
消息队列(Message Queue):就像操作系统的快递驿站。A进程把数据打包成消息扔进队列,B进程有空了自己按类型去取,不用死等。
-
共享内存(Shared Memory):这是最快的 IPC 方式。操作系统直接在物理内存里划出一块‘公共白板’,映射到两个进程的地址空间里,大家都能直接读写,省去了数据来回拷贝的巨大开销。不过它往往必须配合信号量一起用,防止大家同时在白板上乱画。
-
套接字(Socket):这个最全能,它不仅能让同一台电脑里的不同进程通信,还能让网络上不同机器里的进程跨网通信,我们平时写的各种网络服务端/客户端全靠它。”
1.1.5进程的空间模型⭐⭐⭐⭐
进程空间模型(虚拟内存布局)的概念:在现代操作系统(如 Linux)中,每个进程都会被分配一个独立的、连续的“虚拟地址空间”(在 32 位系统中通常是 4GB)。为了让进程有条不紊地运行,操作系统把这个空间像切蛋糕一样,划分成了几个职责分明、严格隔离的区域,这就是进程的空间模型。

进程的经典虚拟内存分层结构

判断结论:进程的空间模型体现了操作系统对内存管理的“绝对掌控”与“权限隔离”。自底向上(从低地址到高地址),它被严格划分为代码段、数据段、BSS 段、堆区、内存映射区、栈区,最后是雷池一步不可逾越的内核空间。其中,堆和栈是动态相对生长的,这极大提高了内存利用率。
参考回答:
“聊到进程的空间模型,我脑子里一般会浮现出一个从下往上盖的大楼(以 32 位 Linux 为例,总共 4GB 大小)。这栋楼分工非常明确,大概可以切成这几个核心的楼层:
首先是最底层(低地址部分),主要用来存‘静态的死数据’:
-
代码段(Text Segment):这是给 CPU 看的机器指令,比如我们写的
if-else、函数。为了防止程序自己把代码给改崩了,这个区域通常是只读的。 -
数据段(Data Segment):专门用来存那些已经初始化了的全局变量和静态变量。
-
BSS 段(BSS Segment):专门用来存没有初始化的全局变量和静态变量。操作系统为了省事,会在程序运行前把这块区域统一清零。
接着往上走,就是程序的‘动态活动区’了,这也是面试最爱考的地方: 4. 堆区(Heap):我们平时用 malloc 或者 new 动态申请的内存都在这儿。它是由程序员自己管理的,由于不断地申请和释放,堆区是由低地址向高地址往上生长的。 5. 内存映射区(Memory Mapping Segment):夹在堆和栈中间。像我们用到的一些动态链接库(比如 .so 文件),或者用 mmap 映射的大文件,都会塞在这个区域。 6. 栈区(Stack):主要存函数的局部变量、函数参数、返回地址等等。栈区是操作系统自动管理的,非常快。它的特点是由高地址向低地址往下生长。这种‘堆往上长、栈往下长’的相向而行设计,能最大化地利用中间的空闲内存。
最后,在这个 3GB 的用户空间之上,是最顶层的 1GB——内核空间(Kernel Space)。这是操作系统的‘私人领地’,存着操作系统的代码和数据结构,用户态的程序绝对不允许直接访问这里,想进去必须得老老实实通过‘系统调用(System Call)。”
1.1.6进程线程的状态转换图 什么时候阻塞,什么时候就绪⭐⭐⭐
进程状态转换的概念:在操作系统中,进程(或线程)的生命周期不是一通到底的,而是要在多个状态之间来回横跳。最经典的就是“五态模型”(新建、就绪、运行、阻塞、终止)。面试中最常被追问的核心,就是运行(Running)、就绪(Ready)和阻塞(Blocked)这三个状态之间的转换时机。

带挂起功能的复杂进程状态转换图. Source: GitHub / linux_kernel_wiki/文章/Linux操作系统进程的状态和转换(五态模型).md ...
“就绪(Ready)”意味着万事俱备,只欠 CPU(我在排队,轮到我我就能跑);而“阻塞(Blocked/Waiting)”意味着即使现在把 CPU 白送给你,你也跑不动,因为你在等某个外部事件发生(我在等快递,拿到快递我才会去排队)。
参考回答:
“聊状态转换,我喜欢用‘去银行办业务’来举例子。柜台职员就是 CPU。
我们重点说什么时候阻塞(Blocked),和什么时候就绪(Ready)。
第一,什么时候会进入‘阻塞(Blocked)’状态?
当你正在柜台办业务(运行态)时,突然发现少带了身份证,你必须得打电话让家里人送过来(等待 I/O 读取),或者你想办的业务需要经理签字,但经理现在在忙(等待互斥锁/信号量)。
这时候,柜员不可能一直干等着你,他会把你请到旁边的等候区。这就是阻塞。
总结起来,进入阻塞态通常是因为:程序主动发起 I/O 请求(读写磁盘、网络通讯)、申请的锁被别人占了、或者代码里直接调了 sleep() 函数。这时候即使 CPU 空闲,你也跑不了。
第二,什么时候会进入‘就绪(Ready)’状态?
进入就绪态(也就是拿到号码牌坐在大厅里排队)有三种经典情况:
-
从新建态过来: 刚刚进银行大门,拿到了号码牌,万事俱备,就在大厅等叫号。
-
从阻塞态过来: 刚才不是在等家里人送身份证吗?现在身份证送到了(I/O 操作完成,或者拿到了锁,或者
sleep时间到了)。此时你不能直接冲到柜台接着办,你得重新拿个号,回到大厅的就绪队列里接着排队。 -
从运行态被‘踢’过来: 你正在柜台办业务,但是操作系统规定每个人每次最多只能办 5 分钟(时间片用完)。时间一到,无论办没办完,柜员都会强行让你停下,让你重新去后面排队。这也是由‘运行’退回到‘就绪’。”
1.1.7父进程、子进程的关系以及区别⭐⭐⭐⭐
父子进程的关系与区别的概念:在 Linux/Unix 操作系统中,所有的进程都像是一棵庞大的家族树。除了最顶层的祖先进程(通常是 init 或 systemd),每一个新进程都必须由一个已经存在的进程“生”出来。这个负责生孩子的原进程就是父进程,而通过 fork() 系统调用新创建出来的进程就是子进程。

fork() 创建子进程的执行流分叉. Source: Made with Google AI

父子进程生命周期终结时的特殊状态. Source: Made with Google AI
子进程在刚出生的那一刻,几乎是父进程的完美克隆体(克隆了代码、数据、打开的文件描述符等)。但从分叉的那一秒起,它们就变成了两个拥有独立内存空间、互不干涉的独立实体。它们最核心的区别在于身份标识(PID)不同、fork() 的返回值不同,以及由于生命周期结束时间不同而可能引发的“孤儿(Orphan)”与“僵尸(Zombie)”状态。
参考回答:
“聊到父子进程,我们可以把它当成一种‘克隆机制’。在系统里,你要想跑一个新程序,通常得先让当前进程(父进程)调一下 fork(),硬生生把自己克隆一份出来,这个新出来的就是子进程。
它们俩的关系和区别,面试里我一般从三个维度来答:
第一,是怎么区分谁是谁的?(身份与返回值)
虽然他俩长得一样,但身份证号绝对不同。子进程有自己全新的 PID,同时它的 PPID(父进程 ID)会指向生它的那个进程。
最有意思的是 fork() 这个函数的返回值:它会‘调用一次,返回两次’。在父进程的代码里,它返回的是新生孩子的 PID;而在子进程的代码里,它返回的是 0。我们平时写代码,就是靠这个返回值来写 if-else,让父子进程去干不同的活儿。
第二,家产是怎么分的?(写时复制 Copy-on-Write)
刚克隆完的时候,子进程和父进程是共享同一块物理内存的,这时候极其高效。但是,只要有任何一方想去修改里面的数据,操作系统就会立刻跳出来,偷偷把那块内存复制一份给修改方。这叫‘写时复制(COW)’。所以从逻辑上讲,它们的内存空间是完全独立、互不影响的。
第三,谁管谁送终?(生命周期绑定)
这就牵扯到著名的僵尸进程和孤儿进程了。
正常情况下,子进程干完活退出了,它会留下一个‘尸体’(退出状态码),等着父进程调用 wait() 来替它收尸。
-
如果子进程先死了,但父进程太忙一直没去收尸,那这个子进程就会变成僵尸进程(Zombie),白白占着系统的进程表条目。
-
如果父进程先死了,子进程还在跑,那这个子进程就会变成无依无靠的孤儿进程(Orphan)。不过不用担心,操作系统里的‘居委会大妈’(init 进程或 systemd)会自动收养它,等它跑完替它收尸。”
1.1.8什么是进程上下文、中断上下文⭐⭐
进程上下文与中断上下文的概念:在操作系统中,“上下文(Context)”本质上就是 CPU 运行一段代码时所依赖的“环境快照”(包括寄存器里的值、程序计数器、堆栈指针以及内存映射信息等)。进程上下文是 CPU 正在执行某个具体进程任务时的环境状态;中断上下文则是 CPU 响应外部硬件的突发请求,强行打断当前任务去执行“中断处理程序(ISR)”时的环境状态。
判断结论:在面试中,这两者最核心、最致命的区别只有一个——“能不能休眠(阻塞)”。处于进程上下文的代码可以休眠,因为系统知道它是谁,资源就绪后能准确唤醒它;处于中断上下文的代码绝对不能休眠,它是处理紧急事件的“无主状态”,一旦休眠,操作系统将无法进行调度唤醒,直接导致内核崩溃(Kernel Panic)。
参考回答:
“关于这俩概念,我平时喜欢用‘上班打工’的场景来对比理解。
进程上下文就像是你坐在工位上做自己的专属项目。无论是你自己敲代码(运行在用户态),还是你遇到权限不够、跑去找老板申请资源(通过系统调用陷入内核态),这本质上都是在为你这个具体的项目服务,这就叫处于进程上下文。在这个状态下,如果你申请的资源暂时没到位(比如等硬盘读写),你可以选择‘去睡一觉(阻塞挂起)’,因为系统认识你,知道这个项目是你的,等资源到了系统自然会叫醒你接着干。
中断上下文就像是办公室突然响起了火警,或者接到了极其紧急的客服电话(硬件触发了中断)。这时候不管你手头正在写什么代码,CPU 都必须立刻强行打断你,放下一切去处理这个突发事件。处理这种紧急事件的环境,就是中断上下文。
在面试里,这两者在开发规则上有一条不可逾越的红线:中断上下文里绝对不能发生休眠、阻塞,也不能去获取互斥锁。
为什么呢?因为中断是硬件随机触发的,它不属于任何一个具体的进程,它只是碰巧打断了当前正在跑的那个‘倒霉蛋’。如果在中断处理一半的时候你让它去‘睡一觉’或者死等资源,操作系统的调度器会彻底懵圈——因为它根本不知道该把这个休眠行为记在谁的头上,等资源好了也就不知道该去唤醒谁。所以在 Linux 内核开发里,中断上下文的原则永远只有四个字:快进快出,绝不逗留。”
1.1.9一个进程可以创建多少线程,和什么有关⭐⭐
进程最大线程数的概念:一个进程绝对不可能无限制地创建线程。在操作系统中,能创建多少个线程,并没有一个固定的“标准答案”,它是一个受制于虚拟内存寻址空间、系统内核参数限制以及物理硬件资源共同博弈的结果。
判断结论:在 32 位系统中,创建线程的“天花板”通常是进程的虚拟内存大小(内存被撑爆);而在 64 位系统中,虚拟内存大到几乎用不完,此时的“天花板”则变成了操作系统的内核参数限制和物理内存(RAM)的大小。
参考回答:
“面试官您好,关于一个进程能创建多少线程,我通常会分 32 位和 64 位两种情况来算这笔账:
第一重限制,是虚拟内存空间和栈大小(这也是 32 位系统的主要瓶颈)。
咱们知道,同一个进程里的所有线程虽然共享堆区和代码段,但每个线程都必须拥有自己独立的栈空间(Stack)。
在 Linux 系统里,默认的线程栈大小一般是 8MB(可以通过 ulimit -s 查看)。
如果是 32 位系统,给用户态程序的虚拟内存总共只有 3GB(也就是 3072MB)。如果除了代码和堆区,剩下的全拿来建线程,那做个简单的除法: 个。也就是说,在 32 位系统默认配置下,你大概建到三四百个线程的时候,哪怕物理内存还空着很多,程序也会因为‘虚拟地址空间被榨干’而报 OOM(Out of Memory)错误。如果想多建点,就得在调
pthread_create 之前手动把栈大小改小,比如改成 1MB。
第二重限制,是操作系统的内核参数(这是 64 位系统的主要瓶颈)。
到了 64 位系统,用户态虚拟内存动辄上百 TB,刚才那种‘地皮不够用’的情况不存在了。但你依然不能无限建线程,因为操作系统对‘人口数量’是有强制规定的:
-
pid_max限制: 哪怕是线程,在 Linux 内核里也是一个轻量级进程(LWP),都需要分配一个唯一的 PID/TID。系统有个全局参数/proc/sys/kernel/pid_max,默认通常是 32768。超过这个数,系统就没号发给你了。 -
threads-max限制: 内核还会硬性规定整个系统最多能允许存在多少个线程。 -
max_map_count限制: 每个线程的栈都需要在内存里做映射,如果进程拥有的内存映射区域数量超过了这个值,也建不出新线程。
第三重限制,就是物理硬件(CPU和物理内存)。
每个线程在内核里都要维护一个 task_struct 结构体,这都是实打实要吃物理内存(RAM)的。另外,如果真的建了几万个活跃线程,CPU 光是做线程之间的上下文切换(Context Switch)就把算力耗尽了,系统直接假死。
所以总结下来,能建多少个线程,等于是在‘虚拟空间容量 / 栈大小’、‘内核限制数’和‘物理内存余量’这三个值里,取那个最小的短板。”
1.2 并发,同步,异步,互斥,阻塞,非阻塞的理解
1.2.1什么是线程同步和互斥⭐⭐⭐⭐⭐
Write the text & embed the widget/follow-ups.线程同步与互斥的概念:在多线程编程中,由于多个线程共享同一个进程的内存空间(比如全局变量、堆区),当它们并发运行时,如果不加干预,一定会把数据搞得一团糟。为了管理这种混乱,操作系统引入了两个核心机制:互斥(Mutual Exclusion)和同步(Synchronization)。
很多初学者会把这两个词混为一谈,但它们的核心目的截然不同:互斥是为了“防打架(竞争)”,保证同一时刻只有一个人能碰共享资源;而同步是为了“打配合(协作)”,保证多个线程按照特定的逻辑顺序依次执行。严格来说,互斥只是同步的一种特殊情况。
参考回答:
“关于线程的同步和互斥,我通常用‘公共卫生间’和‘工厂流水线’这两个例子来区分它们。
先说互斥(Mutual Exclusion)。它的核心是解决‘竞争’问题,防打架。
假设进程里有一个全局变量 count。线程 A 和线程 B 都想去执行 count++。如果不加控制,两人同时去读、同时去加,最后的结果大概率是错的。
互斥机制就像是给这个变量建了一个只有单个坑位的公共卫生间(临界区)。线程 A 进去了,必须把门反锁上(加互斥锁 Mutex),这时候线程 B 就算再急,也只能在门外死等(阻塞)。等线程 A 完事出来了(解锁),线程 B 才能进去。互斥不关心你们谁先来谁后到,它的底线是:同一时刻,这个资源只能被一个人独占。
再说同步(Synchronization)。它的核心是解决‘协作’问题,打配合。
有些场景下,光有互斥是不够的。比如一条工厂流水线,线程 A 负责生产零件,线程 B 负责把零件打包。如果只用互斥锁,线程 B 抢到了锁,进去一看,零件还没造出来呢,只能退出来;过了一会儿又进去抢,还是没有,这就白白浪费了大量 CPU 算力。
同步机制(比如条件变量或信号量)就是用来安排顺序的。它会让线程 B 一开始就直接‘睡一觉’(挂起等待),等线程 A 把零件造好了,主动发个信号把线程 B 叫醒:“我完事了,该你干活了!”
所以,同步机制确保了线程之间能够按照我们期望的先后顺序,有条不紊地交替执行。”
1.2.2线程同步与阻塞的关系?同步一定阻塞吗?阻塞一定同步吗?⭐⭐⭐⭐
同步与阻塞的关系判断结论:同步不一定阻塞,阻塞也不一定同步。 这两个概念在多线程编程中经常结伴出现(比如用互斥锁不仅实现了同步,还伴随着阻塞),但它们在本质上是两个完全独立的概念维度。
简单来说:同步是“目的”(为了协调多个线程的执行顺序或保护共享资源),而阻塞是“手段/状态”(线程主动或被动地交出 CPU 控制权,进入休眠等待区)。
参考回答:
“在面试中,我一般会明确指出它俩是一对‘交叉但不包含’的关系。
第一,同步一定阻塞吗?答案是:不一定。
同步只是为了让大家按规矩办事,但并不意味着等规矩的时候必须去‘睡觉’(阻塞)。最典型的例子就是自旋锁(Spinlock)和底层的原子操作(比如 CAS:比较并交换)。
假设线程 A 拿到了自旋锁,线程 B 也想拿。此时线程 B 不会交出 CPU 进入阻塞态休眠,而是会在原地写个 while 死循环,疯狂地探测锁有没有被释放。这种做法虽然浪费了 CPU 的算力,但省去了线程上下文切换的巨大开销。这就是非常典型的‘实现了同步,但完全没有发生阻塞’。
第二,阻塞一定同步吗?答案也是:不一定。
阻塞只是线程运行状态的一种改变(从 Running 切到了 Blocked),这完全可以是线程自己的私事,跟别人没有任何关系。
比如,我的线程代码里直接写了一句 sleep(10),或者我发起了一个读取本地硬盘大文件的请求(I/O 阻塞)。这时候操作系统会把我的线程剥夺 CPU 并挂起,这确实是百分之百的阻塞,但这仅仅是因为我自己需要等时间或者等硬盘,我没有在和任何其他线程争抢资源,也没有在和别人打配合。所以这种情况下,‘发生了阻塞,但跟同步毫无关系’。
总结下来,我们平时用的互斥锁(Mutex)属于‘阻塞式同步’,自旋锁属于‘非阻塞式同步’,而普通的 I/O 等待则是‘与同步无关的纯阻塞’。”
1.2.3并发,同步,异步,互斥,阻塞,非阻塞的理解⭐⭐⭐⭐⭐
这六个词是操作系统和并发编程中最核心、但也最容易被初学者搞混的概念。要彻底理解它们,最好的办法是把它们拆分到三个完全不同的维度来看。
很多时候我们觉得乱,是因为我们把描述“系统架构”的词,和描述“通信方式”的词混为一谈了。

CPU 状态:经典同步阻塞 I/O 流程. Source: Less is more / 阻塞非阻塞与同步异步区别| Less is more
参考回答:
“面试官您好,这六个概念看似一团乱麻,但我通常会把它们归类到三个不同的维度来理解:
维度一:宏观执行架构(并发)
并发(Concurrency):它的核心是‘管理多个任务的能力’。只要系统能让人‘感觉’多个任务在同时推进,那就是并发。在单核 CPU 上,它通过极快的时间片轮转(你做一点,我做一点)交替执行来实现。这就好比一个厨师同时切菜、炖汤、炒饭,虽然他只有两只手(一个 CPU),但他能在不同任务间快速切换。 *(注:如果真有多个厨师同时颠勺,那是并行 Parallelism,并行是并发的一个子集。)*
维度二:资源访问与协作规则(互斥与同步)
这两个词关注的是‘多个线程怎么共享资源’。 2. 互斥(Mutual Exclusion):核心是‘防打架’。一条独木桥,同一时刻只能走一个人。在代码里就是加锁(Mutex),我拿到锁在改数据的时候,你必须在外面等着,绝对不能两个线程同时改。 3. 同步(Synchronization):核心是‘打配合’。不仅不能打架,还得按照约定的先后顺序来。比如 A 线程负责下载图片,B 线程负责渲染图片。B 必须等 A 明确发出‘下载完成’的信号后才能开始干活,这种按顺序的协调就是同步。
维度三:函数调用与 CPU 状态(同步/异步 vs 阻塞/非阻塞)
这两个是最容易被组成四象限矩阵来考的。它们关注的是‘你去找别人办事时的态度,以及你等待时的状态’。
(注意:这里的‘同步’和刚才线程协作里的‘同步’字面一样,但语境不同,这里特指 I/O 或函数通信模型)
-
同步(Synchronous) vs 异步(Asynchronous) —— 关注点是‘结果怎么拿到’(消息通信机制)。
<ul> -
同步:你去窗口买饭。点完菜后,你必须死盯着窗口,直到饭做好了端给你,你才算拿到结果。(调用者主动等待并获取结果)
-
异步:你去点外卖。点完菜后你留了个电话,然后就不管了。饭做好了,外卖员会主动打电话通知你(通过 Callback 回调函数或事件触发)。(被调用者准备好后主动通知)
-
阻塞(Blocking) vs 非阻塞(Non-blocking) —— 关注点是‘等结果时你的人(CPU)在干嘛’(线程状态)。
<ul> -
阻塞:等饭的过程中,你啥事都不干,就坐在椅子上睡觉。在操作系统里,就是线程交出 CPU 控制权,进入休眠状态,不再消耗 CPU 算力。
-
非阻塞:等饭的过程中,你不睡觉。你可能每隔一秒钟就跑去窗口问一句“好了没?”(轮询 Polling),或者你转身去隔壁买杯奶茶。在系统里,就是线程不交出 CPU,即使调用的数据还没准备好,函数也会立刻返回一个错误码,线程继续往下跑。
最经典的坑:这四个词的组合。
比如我们常说的同步非阻塞(Sync-NonBlocking):你(调用者)必须自己去拿饭(同步),但你不睡觉(非阻塞),于是你就像个神经病一样每隔一秒钟就去问一次老板“好了没”,这会疯狂消耗 CPU 资源。
再比如异步非阻塞(Async-NonBlocking)(如 Node.js 或 Nginx 的模型):你点了外卖(异步,等外卖员打电话),然后你跑去打游戏了,完全没闲着睡觉(非阻塞)。这是效率最高、最完美的并发处理模型。”
1.3 孤儿进程、僵尸进程、守护进程的概念
1.3.1基本概念⭐⭐⭐⭐⭐
-
孤儿进程(Orphan Process):“爹妈先走,被国家收养”。当一个子进程还在跑,但它的父进程突然提前挂了或退出了,这个子进程就会变成孤儿进程。不用害怕,它并不会危害系统,因为操作系统里的“居委会大妈”(通常是 PID 为 1 的
init进程或systemd进程)会自动接管它,当它退出时负责替它收尸。 -
僵尸进程(Zombie Process):“人已经没了,但户口没销,占着坑不拉屎”。当一个子进程退出了,它的代码和内存空间都被回收了,但它在内核进程表里的
task_struct(退出状态码等信息)还留着,等待父进程来调用wait()读取。如果父进程死活不来收尸,这个子进程就会变成僵尸进程。如果积攒太多,会导致系统的 PID 耗尽,再也创建不了新进程。 -
守护进程(Daemon Process):“常年驻守后台的无敌打工人”。它是一种在后台默默运行、独立于所有终端(Terminal)的长期服务进程(比如 Nginx、Mysql、系统的各种
syslogd服务)。它通常通过精心设计的“两次fork()”让自己彻底摆脱终端控制,避免被用户关闭终端时误杀。
参考回答
“在面试里,这三个概念我通常会结合它们的生命周期和危害来回答:
第一,什么是孤儿进程?
字面意思,就是父母进程先于子进程结束了。比如父进程闪退了,留下子进程还在干活。在 Linux 里,孤儿进程并不可怕,因为系统内核有一个机制:当一个进程发现自己被抛弃后,它的父进程 ID(PPID)会被自动重置为 1(即 init 或 systemd 进程)。这就好比孤儿被国家收养了,等这个子进程将来跑完退出时,init 进程会负责调用 wait() 帮它把尸体收了,不会留下任何隐患。
第二,什么是僵尸进程?
僵尸进程就比较麻烦了。它是指子进程已经死了,但父进程还没来得及(或者根本不理睬去)收尸。
子进程在退出的一瞬间,内核必须给它的父进程留一个交待(比如我是正常退出的还是报错退出的,返回值是什么)。这个交待保存在内核的进程描述符里。如果父进程一直不调用 wait() 或 waitpid() 去读取这个信息,内核就永远不敢把这个坑位注销。
如果程序写得有问题,不断产生大量僵尸进程,系统的 PID 资源很快就会被耗尽(因为 PID 总数是有限的),导致整个系统无法创建新进程。要消灭僵尸进程,最直接的办法就是杀死它的父进程(让父进程变成孤儿,被 init 接管并顺手收尸),或者在代码里正确编写 sigaction 捕获 SIGCHLD 信号并调用 wait。
第三,什么是守护进程?
守护进程和上面两个‘异常状态’不同,它是我们主动设计出来的‘后台常驻劳模’。
比如我们在服务器上跑一个 Web 服务,如果用户把远程 SSH 终端关了,或者按了 Ctrl+C,普通的进程就会收到 SIGHUP 信号直接跟着被杀死。而守护进程通过一系列标准的骚操作(脱离控制终端、改变工作目录、重定向输入输出、甚至通过两次 fork() 彻底摆脱父进程),让自己变成一个独立于终端的后台进程。它默默无闻地在后台运行,直到系统关机才会结束。”
1.3.2如何创建守护进程:⭐⭐
创建守护进程(Daemon)的概念:守护进程是 Linux 操作系统中一种在后台长期运行、独立于控制终端且不受用户登录登出影响的特殊进程。要用 C/C++ 编写一个标准的守护进程,不能直接写个死循环丢到后台,必须严格遵循一套标准的系统调用组合拳。
判断结论:编写守护进程的核心工程逻辑可以总结为“摆脱控制终端、切断父子连累、重定向输入输出”。其中最精妙、也最常被面试官拷问的灵魂操作就是“两次 fork()”,其根本目的在于彻底斩断对终端的控制,防止进程未来因为意外再次重新获得控制终端。
参考回答:
“在面试里被问到怎么写一个守护进程,我通常会把整个标准流程拆解为五个关键步骤,并且重点解释为什么要这么做:
第一步:创建子进程,父进程退出 (fork())
-
做法: 程序一启动,先调用一次
fork(),然后父进程直接exit()退出。 -
为什么要这么做? 这样能让程序立刻脱离前台。如果这个程序是在终端里被手动执行的,父进程退出后,终端会以为命令已经执行完了,从而把命令行提示符还给用户,让子进程在后台默默运行。
第二步:在子进程中创建新会话 (setsid())
-
做法: 子进程调用
setsid()系统调用。 -
为什么要这么做? 这是最核心的一步!默认情况下,子进程继承了父进程的会话(Session)、进程组和控制终端。如果用户将来关掉了 SSH 远程终端,终端会发送
SIGHUP信号杀死所有相关进程。调用setsid()后,子进程会成为新会话的“老大”(Session Leader),彻底摆脱原先的控制终端,以后不管终端怎么关,它都不会被误杀。
第三步:再次创建子进程,并让第一代子进程退出(第二次 fork())
-
做法: 刚当上会话老大的子进程,再
fork()一次,然后自己退出,留下孙子进程继续干活。 -
为什么要这么做? 很多同学最容易卡在这里。在 Linux 系统里,只有会话老大(Session Leader)才有权力重新打开或申请控制终端。为了彻底杜绝以后程序因为某些骚操作不小心又关联到一个控制终端上,我们通过第二次
fork()让孙子进程降级成“普通进程”(不再是会话老大),这样它就绝对、永远不可能再拥有控制终端了。
第四步:改变当前工作目录 (chdir())
-
做法: 调用
chdir("/")把当前工作目录切换到根目录/。 -
为什么要这么做? 因为守护进程要跑很久。如果它启动时所在的目录是在某个挂载的 U 盘或者特定文件夹下,万一系统后面想要卸载(umount)这个设备,就会因为被这个守护进程的工作目录占用而卸载失败。切到根目录可以避免这种尴尬。
第五步:重设文件权限掩码 (umask()) 并重定向文件描述符
-
做法:
-
调用
umask(0),给文件创建权限松绑,防止继承来的掩码导致后续创建的文件权限不符合预期。 -
把标准输入(0)、标准输出(1)、标准错误(2)全部重定向到
/dev/null(或者日志文件)。
-
-
为什么要这么做? 守护进程是在后台运行的,它没有终端,如果它不小心往
printf里写了东西,系统找不到显示器就会报错。所以必须把这些输入输出流‘关掉或者扔进黑洞’。”
1.3.3正确处理僵尸进程的方法⭐⭐⭐⭐
1.子进程退出时向父进程发送SIGCHILD信号,父进程处理SIGCHILD信号。在信号处理函数中调用wait进行处理僵尸进程。
2.把父进程杀掉。父进程死后,僵尸进程成为"孤儿进程",过继给进程init,init始终会负责清理僵尸进程。它产生的所有僵尸进程也跟着消失。
fork两次,父进程fork一个子进程,然后继续工作,子进程fork一 个孙进程后退出,那么孙进程被init接管,孙进程结束后,init会回收。不过子进程的回收 还要自己做。
两次fork的原理是将子进程成为孤儿进程,从而其的父进程变为init进程,通过init进程可以处理僵尸进程。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)