6.1 进程
操作系统是一个进程吗?
操作系统的核心部分(内核):不是进程,而是驻留在内存中的常驻代码,独立于用户进程。它通过调度器管理进程,但自身不属于任何进程。
操作系统的其他组件:
-
驱动程序:通常作为内核模块运行,直接与硬件交互,不属于进程。
-
守护进程/服务:在用户空间长期运行的程序(如
sshd),是进程,但属于操作系统管理的应用程序,而非操作系统本身。
类比理解:操作系统像交通警察,协调所有车辆(进程)的行驶。内核是交通警察的“指挥中心”,而进程是路上行驶的车辆。
【必问】进程是什么?
-
我们平时写的C语言代码,通过编译器编译,最终它会成为一个可执行程序,当这个可执行程序运行起来后(没有结束之前),它就成为了一个进程。
-
进程拥有自己独立的处理环境(如:当前需要用到哪些环境变量,程序运行的目录在哪,当前是哪个用户在运行此程序等)和系统资源(如:处理器 CPU 占用率、存储器、I/0设备、数据、程序)。
-
进程是资源分配的最小单位。
一个进程是怎么跑起来的?比如,在终端输入 top 命令,发生了什么?
-
shell 解析命令
-
查找可执行文件
-
通过 fork 创建子进程
-
通过 exec 加载并替换为新程序
-
操作系统内核管理进程地址空间和资源
-
最终 CPU 调度执行 top 程序的 main 函数
【必问】并行 vs 并发有什么区别?
并行(parallel):指在同一时刻,有多条指令在多个处理器上同时执行。
并发(concurrency):指在同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,使得在宏观上具有多个进程同时执行的效果,但在微观上并不是同时执行的,只是把时间分成若干段,使多个进程快速交替的执行。
【必问】进程的三态模型和五态模型是什么?进程的状态切换过程请大致描述下?
在三态模型中,进程状态分为三个基本状态,即运行态,就绪态,阻塞态。
在五态模型中,进程分为新建态、终止态,运行态,就绪态,阻塞态。
-
进程状态切换可以概括为一个完整的生命周期循环。进程首先被创建进入新建态,系统为其分配资源后转入就绪态,此时进程已准备好运行,只等待CPU调度。当获得CPU时间片后,进程切换到运行态开始执行指令。
-
在运行过程中,如果时间片用完或被更高优先级进程抢占,进程会从运行态回到就绪态等待下次调度;如果遇到I/O请求或等待资源等事件,进程则进入阻塞态,此时会主动释放CPU资源。阻塞态的进程必须等待特定事件完成后才能重新回到就绪态。
-
当进程完成所有任务或收到终止信号时,会进入终止态,系统回收其占用的所有资源。这个状态切换过程体现了操作系统对进程的精细管理,通过五态模型(新建、就绪、运行、阻塞、终止)实现了多进程的高效并发执行。
Linux子进程继承了父进程的哪些内容?自己独有哪些内容?
-
使用 fork()函数得到的子进程是父进程的一个复制品,它从父进程处继承了整个进程的地址空间:包括进程上下文(进程执行活动全过程的静态描述)、进程堆栈、打开的文件描述符、信号控制设定、进程优先级、进程组号等。
-
子进程所独有的只有它的进程号,计时器等(只有小量信息)。因此,使用fork() 函数的代价是很大的。
fork()函数调用时系统发生了什么?
-
简单来说,一个进程调用 fork() 函数后,系统先给新的进程分配资源,例如存储数据和代码的空间。
-
然后把原来的进程的所有值都复制到新的新进程中,只有少数值与原来的进程的值不同。
fork()的写时拷贝技术是什么?
写时拷贝是一种可以推迟甚至避免拷贝数据的技术。内核此时并不复制整个进程的地址空间,而是让父子进程共享同一个地址空间。只有在需要写入的时候才会复制地址空间,从而使各个进行拥有各自的地址空间。也就是说,资源的复制是在需要写入的时候才会进行,在此之前,只有以只读方式共享。
父子进程共享打开的文件吗?文件引用计数增加吗?
fork之后父子进程共享文件,fork产生的子进程与父进程相同的文件文件描述符指向相同的文件表,引用计数增加,共享文件文件偏移指针。
【必问】fork()函数之后如何区分父子进程?
fork()函数被调用一次,但返回两次。两次返回的区别是: 子进程的返回值是0,而父进程的返回值则是新子进程的进程ID。
父子进程哪个会先执行?
一般来说,在 fork() 之后是父进程先执行还是子进程先执行是不确定的。这取决于内核所使用的调度算法。父子进程各自的地址空间是独立的。
进程退出函数exit() vs _exit()有什么区别?
exit() 和 _exit() 函数功能和用法是一样的,无非是所包含的头文件不一样,还有的区别就是:_exit()属于标准库函数,exit()属于系统调用函数。
wait()函数是做什么的?wait() vs waitpid()有什么区别?
回收子进程的资源。
wait() 和 waitpid() 函数的功能一样,区别在于,wait() 函数会阻塞,waitpid()可以设置不阻塞,waitpid() 还可以指定等待哪个子进程结束。
当waitpid()设置了非阻塞WNOHANG时,若没有任何已经结束的子进程,则立即返回。
【必问】孤儿进程是什么?孤儿进程有危害吗?
父进程运行结束,但子进程还在运行(未运行结束)的子进程就称为孤儿进程。每当出现一个孤儿进程的时候,内核就把孤儿进程的父进程设置为init (图形界面里可能被upstart收养),而init进程会循环地 wait() 它的已经退出的子进程。
因此孤儿进程并不会有什么危害。
【必问】僵尸进程是什么?僵尸进程有危害吗?
进程终止,父进程尚未回收,子进程残留资源(PCB)存放于内核中,变成僵尸(Zombie)进程。
这样就会导致一个问题,如果进程不调用wait()或 waitpid() 的话,那么保留的那段信息就不会释放,其进程号就会一直被占用,但是系统所能使用的进程号是有限的,如果大量的产生僵尸进程,将因为没有可用的进程号而导致系统不能产生新的进程,此即为僵尸进程的危害,应当避免。
为什么僵尸进操作系统不回收?
-
操作系统认为父进程可能关心子进程的退出状态,因此保留这些信息,直到父进程主动来取。
-
如果操作系统在父进程还没有读取之前就擅自清除这些信息,父进程将无法得知子进程是如何终止的。
exec函数族替换进程时,是创建了一个新进程吗?
进程调用一种 exec 函数时,该进程完全由新程序替换,而新程序则从其 main 函数开始执行。因为调用 exec并不创建新进程,所以前后的进程 ID(当然还有父进程号、进程组号、当前工作目录.…)并未改变。exec 只是用另一个新程序替换了当前进程的正文、数据、堆和栈段(进程替换)。
进程A访问进程B的内存为什么会出错?
核心原因:进程地址空间隔离(Process Memory Isolation)
每个进程都有自己独立的虚拟内存空间
-
操作系统通过 虚拟内存机制(Virtual Memory),为每个进程提供了一个 独立的、连续的虚拟地址空间,这个空间映射到物理内存(或交换空间)上是 被操作系统内核严格隔离和控制的。
-
进程 A 的 0x12345678 地址 和 进程 B 的 0x12345678 地址,指向的是完全不同的物理内存(或者可能根本不映射到物理内存)。
-
操作系统 + 硬件(MMU,内存管理单元)会确保一个进程不能直接读写另一个进程的内存区域,否则就会触发 访问违例(Access Violation) 或 段错误(Segmentation Fault)。
【必问】说说进程调度算法?
-
非抢占式调度(Non-preemptive Scheduling):一旦 CPU 分配给某个进程,该进程将 一直运行,直到它主动放弃 CPU(如阻塞、结束)。调度器不会强行中断它。特点:实现简单,但可能导致响应慢、不公平。
-
先来先服务(FCFS, First-Come First-Served):按进程到达就绪队列的顺序进行调度,先到的先运行。
-
短作业优先(SJF, Shortest Job First):优先调度 运行时间最短的进程。
-
-
抢占式调度(Preemptive Scheduling):CPU 可以在执行期间被调度器强行剥夺,转交给其他进程。通常基于 时间片、优先级、紧迫程度 等因素触发。特点:响应及时,适合交互式系统。
-
时间片轮转(Round Robin, RR):每个进程分配一个固定长度的时间片(如 10ms),时间片用完就强制切换到下一个进程。
-
优先级调度(Priority Scheduling):每个进程有一个优先级,调度器总是选择 优先级最高的进程运行。
-
多级队列调度(Multilevel Queue Scheduling):将进程分成多个队列(如:系统进程、交互式进程、批处理进程),每个队列有不同的调度策略。队列之间可以有优先级关系。
-
多级反馈队列(Multilevel Feedback Queue, MLFQ)【最重要、最实用】:是 优先级调度 + 时间片轮转 + 动态调整 的组合体。进程可在不同优先级队列间移动(比如:用完时间片降级、I/O 密集型进程优先级提高)。
-
6.2 进程通信
【必问】进程通信一共有几种方法?
-
无名管道
-
命名管道
-
共享文件存储
-
共享内存
-
信号
-
信号量
-
本地套接字
-
消息队列
6.2.1 无名管道PIPE
【必问】无名管道有哪些特点?
-
一端写入,从另一端读出。数据是单向的、且先入先出。
-
管道不是普通的文件,不属于某个文件系统,其只存在于内存中。
-
管道没有名字,只能在具有公共祖先的进程(父进程与子进程,或者两个兄弟进程,具有亲缘关系)之间使用。
无名管道可以设置非阻塞吗?
可以,如果写端没有关闭,读端设置为非阻塞,如果没有数据,直接返回-1。
使用pipe进行通信时需要考虑同步互斥吗?
-
pipe 本身具有一定的“同步”特性(阻塞式读写):在单生产者-单消费者(一个写端,一个读端)且都使用阻塞 IO 的情况下,pipe 自身提供的阻塞行为,可以在一定程度上避免数据竞争和混乱,起到一定的“同步”作用,你 不一定需要显式加锁(如 mutex)。
-
但 pipe 本身并不提供“互斥”保护:多个线程同时写,数据可能会交错(pipe 不保证原子写入超过 PIPE_BUF 大小的数据);多个线程同时读,可能导致数据被多个消费者争抢,消费顺序不可控。
6.2.2 命名管道FIFO
【必问】命名管道 vs 无名管道有什么不同?
-
FIFO 在文件系统中作为一个特殊的文件而存在,但 FIFO 中的内容却存放在内存中。当使用 FIFO 的进程退出后,FIFO 文件将继续保存在文件系统中以便以后使用。
-
FIFO 有名字,不相关的进程可以通过打开命名管道进行通信。
FIFO有哪些需要注意的点?
-
FIFO严格遵循先进先出,对管道及FIFO的读总是从开始处返回数据,对它们的写则把数据添加到末尾。它们不支持诸如lseek()等文件定位操作。
-
一个为只读而打开一个管道的进程会阻塞直到另外一个进程为只写打开该管道。
-
一个为只写而打开一个管道的进程会阻塞直到另外一个进程为只读打开该管道。
6.2.3 共享文件存储映射MMAP
共享文件存储映射是什么?
存储映射I/O (Memory-mapped I/O) 使一个磁盘文件与存储空间中的一个缓冲区相映射。
于是当从缓冲区中取数据,就相当于读文件中的相应字节。与此类似,将数据存入缓冲区,则相应的字节就自动写入文件。这样,就可在不使用read和write函数的情况下,使用地址(指针)完成I/O操作。
写入映射区的内容一定会被复制回文件吗?
可以设置flags:
-
MAP_SHARED: 写入映射区的数据会复制回文件,且允许其他映射该文件的进程共享。
-
MAP_PRIVATE: 对映射区的写入操作会产生一个映射区的复制(copy-on-write),对此区域所做的修改不会写回原文件。
mmap有哪些值得注意的地方?
-
创建映射区的过程中,隐含着一次对映射文件的读操作。
-
当MAP_SHARED时,要求: 映射区的权限应<=文件打开的权限。而MAP_PRIVATE则无所谓。
-
映射区的释放与文件关闭无关。只要映射建立成功,文件可以立即关闭。
-
特别注意,当映射文件大小为0时,不能创建映射区。
-
文件偏移量必须为4K的整数倍。
匿名共享存储是什么?
Linux系统给我们提供了创建匿名映射区的方法,无需依赖一个文件即可创建映射区。同样需要借助标志位参数flags来指定。
注意:only父子进程、only Linux。
6.2.4 共享内存SHM
共享内存是什么?
共享内存是进程间通信中最简单的方式之一。共享内存允许两个或更多进程访问同一块内存,当一个进程改变了这块地址中的内容的时候,其它进程都会察觉到这个更改。
使用共享内存进行进程通信的流程是什么?
-
向内核申请一块内存 -> 指定大小
-
如果有两个进程,需要通信,可以使用这块共享内存来完成,先创建出这两个进程
-
进程A
-
进程B
-
-
进程A和进程B分别和共享内存进行关联
-
拿到共享内存的地址(首地址)
-
-
两个进程可以通过这个首地址对共享内存进行读/写操作
-
如果这个进程不再使用这块共享内存,需要和共享内存断开关联
-
进程退出,对共享内存是没有任何影响的
-
-
当不再使用共享内存的时候,需要将共享内存销毁
共享内存何时会被销毁?是不是可以对共享内存进行多次shmctl删除?
shmctl设置参数标记某个共享内存要被销毁。并非立刻销毁,在没有进程使用时销毁。
因为shmctl删除只是一个标记,最终只会删除一次,所以可以对共享内存进行多次shmctl删除。
共享内存被标记为删除时,还可以有进程去关联这块内存吗?
不能。共享内存被标记删除,并且还有进关联这块共享内存时,key被内核设置为0。当key==0,没有和共享内存进程关联的进程,就不允许进行关联了。这时只会和已经关联成功的进程服务。
【必问】共享文件存储映射 vs 共享内存有什么区别?
-
存储介质不同 共享内存直接使用物理内存作为载体,数据完全在RAM中交换;文件映射则是将磁盘文件映射到进程虚拟地址空间,每个进程都会在自己的虚拟地址空间中有一块独立的内存。
-
性能差异显著 共享内存的访问速度最快(内存级速度),适合高频通信;文件映射受限于磁盘I/O性能(即使有page cache缓冲),适合对实时性要求不高的场景。
-
数据持久性 文件映射天然支持数据持久化,进程崩溃后数据仍在磁盘;共享内存的数据在进程退出或系统重启后会丢失,必须自行实现持久化机制。
-
同步复杂度 共享内存需要额外引入信号量/互斥锁等同步手段;文件映射依赖文件系统的原子操作(如O_APPEND模式),某些场景下同步更简单。
6.2.5 信号
信号是什么?它的特点是什么?
信号是 Linux 进程间通信的最古老的方式。信号是软件中断,它是在软件层次上对中断机制的一种模拟,是一种异步通信的方式。信号可以导致一个正在运行的进程被另一个正在运行的异步进程中断,转而处理某一个突发事件。信号可以直接进行用户空间进程和内核空间进程的交互,内核进程可以利用它来通知用户空间进程发生了哪些系统事件。
信号的特点:
-
简单
-
不能携带大量信息
-
满足某个特设条件才发送
信号的四要素是什么?
1)编号 2)名称 3)事件 4)默认处理动作
特别强调:9)SIGKILL 和19)SIGSTOP信号,不允许忽略和捕捉,只能执行默认动作。甚至不能将其设置为阻塞。
信号的三状态是什么?
产生、未决(没有被处理)、递达(被处理)。
信号的捕捉是什么?sigaction是执行捕捉吗?
执行自定义信号处理函数(捕获):用用户定义的信号处理函数处理该信号。原本该执行的动作被你自定义的内容替换。
sigaction是注册捕捉信号,不是由他抓捕。
“阻塞信号集”和“未决信号集”是做什么的?
未决信号集,记录着信号产生了,但是还没有被当前信号响应。
被阻塞的信号不一定是未决信号,但未决信号一定是被阻塞的信号。如果一个信号虽然被阻塞了,但从来没有被发送过,那它就既不是未决状态也不是递送状态。
用户可以设置阻塞信号集。未决信号集可以读,不可以设置,是内核自动设置。
阻塞是禁止信号发送吗?阻塞期间发送多个信号,阻塞结束后会收到几个?
所谓阻塞并不是禁止传送信号,而是暂缓信号的传送。若将被阻塞的信号从信号阻塞集中删除,且对应的信号在被阻塞时发生了,进程将会收到相应的信号。但是就算阻塞期间发送多个信号,也只会收到一个信号。
sigaction函数中设置信号阻塞集,是否会和原来的信号阻塞集冲突?
在使用 sigaction 设置信号处理时,如果通过 sa_mask 指定了新的阻塞集,它不会直接覆盖进程原有的信号阻塞集,而是临时叠加到当前阻塞集中。具体来说,当信号处理函数被调用时,sa_mask 中指定的信号会被自动加入阻塞集,防止该信号在处理期间被再次触发(避免重入问题)。
9号和19号信号无法被阻塞,那么使用sigprocmask强行设置它为阻塞会发生什么?
如果尝试用 sigprocmask 阻塞 9 号(SIGKILL)或 19 号(SIGSTOP)信号,这些操作会被内核静默忽略,信号实际上不会被阻塞。也就是说,即使调用了 sigprocmask 并设置了阻塞标志,9 号和 19 号信号依然可以被立即传递给进程,导致进程被终止(SIGKILL)或暂停(SIGSTOP)。
内核实现信号捕捉过程是什么?
当内核要处理信号捕捉时,首先会在目标进程因系统调用、中断或异常返回用户态时检查是否有待处理的信号。如果有,内核会根据信号的处理方式决定后续动作。 这时,进入内核态。
进入内核之后,内核会暂停当前进程的执行,将用户态的寄存器上下文保存到内核栈,并切换到用户态执行对应的信号处理函数。为了保证处理函数的安全性,内核会在调用前构建一个类似中断帧的结构,其中包含返回地址(指向信号处理结束后的恢复代码)和必要的寄存器状态。
在信号处理函数返回时,内核会检查是否有更高优先级的信号需要处理,如果没有,则返回内核,恢复之前保存的上下文,继续执行原进程。如果信号处理期间再次收到同一信号,可能会被阻塞(取决于具体实现),避免递归调用导致栈溢出。
用户态:系统调用、中断->内核态:是否能捕获(没被阻塞)?暂停当前进程、上下文保存->用户态:信号处理函数->内核态:恢复之前保存的上下文->用户态:继续执行原进程
什么是“可重入函数”和“不可重入函数”?
不同任务调用这个函数时可能修改其他任务调用这个函数的教据,从而导致不可预料的后果。这样的函数是不安全的函数,也叫不可重入函数。
满足下列条件的函数多数是不可重入(不安全)的:
-
函数体内使用了静态的数据结构;
-
函数体内调用了malloc()或者 free() 函数(谨慎使用堆);
-
函数体内调用了标准 I/O 函数。
【必问】如何避免僵尸进程?
-
最简单的方法,父进程通过 wait() 和 waitpid() 等函数等待子进程结束,但是,这会导致父进程挂起。
-
如果父进程要处理的事情很多,不能够挂起,通过 sigaction() 函数人为处理信号 SIGCHLD, 只要有子进程退出自动调用指定好的回调函数,因为子进程结束后,父进程会收到该信号 SIGCHLD,可以在其回调函数里调用wait()或waitpid() 回收。
-
如果父进程不关心子进程什么时候结束,那么可以用sigaction通知内核,自己对子进程的结束不感兴趣,父进程忽略此信号,那么子进程结束后,内核会回收,并不再给父进程发送信号。
(追问)使用sigaction函数避免僵尸进程,为什么单个wait依然有可能造成僵尸进程?为什么循环回收子进程能优化这一点?
使用sigaction捕获SIGCHLD信号时,如果在信号处理函数中直接调用普通wait,可能存在信号丢失或竞争问题。例如当多个子进程同时终止时,若父进程未及时处理,部分子进程的状态可能未被及时回收。因为SIGCHLD信号默认是合并的,如果多个子进程在父进程未处理期间终止,只会触发一次信号,此时wait只能回收一个子进程,其他未被处理的就会变成僵尸。
而采用循环回收能持续检索所有已终止的子进程。WNOHANG选项使waitpid非阻塞,循环会逐一清理所有僵尸进程,直到没有更多子进程需要处理。这种机制确保了即使多个子进程并发退出,父进程也能在一次信号触发中批量回收所有僵尸进程,从根本上解决了单次wait的遗漏问题。
(追问)父进程在注册SIGCHLD之前子进程就已经死亡还是会变成僵尸进程,除了让子进程sleep还有什么更好的方法解决?
一个方法是在fork子进程前就完成SIGCHLD信号处理函数的注册。但是更好的方法是在fork子进程前阻塞SIGCHLD信号,待父进程注册完信号处理函数后再解除阻塞。这样,子进程终止时发送的SIGCHLD会被挂起,直到父进程准备好处理,此时通过非阻塞的waitpid循环能一次性回收所有僵尸子进程。这种方式既避免了信号丢失,又无需依赖特定系统特性,确保父进程总能正确处理子进程终止状态。
慢速系统调用是什么?
慢速系统调用是指那些在执行过程中可能会因为某些外部事件(比如信号)而被中断的系统调用,可能会使进程永远阻塞的。这类调用通常涉及到 I/O 操作,比如读写文件、网络通信等。
(追问)慢速系统调被中断后如何重启?
可修改 sa_flags参数来设置被信号中断后系统调用是否重启。SA_INTERRURT不重启。 SA_RESTART重启。
6.3 守护进程
会话 vs 进程组有什么区别?
进程组代表一个或多个进程的集合。每个进程都属于一个进程组。在waitpid函数和ki函数的参数中都曾使用到。操作系统设计的进程组的概念是为了简化对多个进程的管理。
会话是一个或多个进程组的集合。一个会话可以有一个控制终端,如果有一个控制终端,则它有一个前台进程组,其它进程组为后台进程组。
【必问】守护进程是什么,为什么需要他?
守护进程,也就是通常说的精灵进程,是 Linux 中的后台服务进程。守护进程是个特殊的孤儿进程,这种进程通常独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件。般采用以d结尾的名字。
守护进程为什么要脱离终端?
为了避免进程被任何终端所产生的信息所打断,其在执行过程中的信息也不在任何终端上显示。由于在 Linux 中,每一个系统与用户进行交流的界面称为终端,每一个从此终端开始运行的进程都会依附于这个终端,这个终端就称为这些进程的控制终端,当控制终端被关闭时,相应的进程都会自动关闭。
【必问】将一个进行变成守护进程的流程?
-
创建子进程,父进程退出。父进程直接退出,使子进程成为孤儿进程,被 init/systemd 接管。
-
创建新会话,脱离原终端控制(成为会话组长、进程组长,脱离控制终端)。
-
忽略 SIGHUP 信号(避免终端关闭时收到该信号导致退出)。
-
设置文件权限掩码(umask),一般设为 0,让守护进程有最大的文件创建权限控制权。
-
切换当前工作目录到根目录(或其它合适的目录,如 /var/run)。
-
重定向标准输入输出到 /dev/null,避免后续代码误用。
6.4 线程
6.4.1 线程的概念
【必问】线程是什么?
线程存在于进程当中(进程可以认为是线程的容器),是操作系统调度执行的最小单位。
线程是进程的一个实体,是 CPU 调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。线程自己,基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源。
【必问】线程的优点和缺点分别是什么?
优点:
-
提高程序并发性
-
开销小
-
数据通信、共享数据方便
缺点:
-
库函数,不稳定
-
gdb不支持调试、编写困难
-
对信号支持不好
【必问】线程 vs 进程有什么区别?
线程和进程的主要区别在于资源隔离和调度粒度。
进程是操作系统资源分配的基本单位,每个进程有独立的地址空间、文件描述符等系统资源,进程间通信需要借助管道、共享内存等机制,开销较大但稳定性高,一个进程崩溃通常不会影响其他进程。
而线程是进程内的执行单元,多个线程共享同一进程的资源,比如内存空间和文件句柄,线程间通信直接读写共享数据即可,效率更高,但一个线程崩溃可能导致整个进程挂掉。
另外,线程的创建和切换开销比进程小,适合需要频繁并发但数据共享多的场景,比如Web服务器处理请求;而进程更适合需要强隔离的任务,比如浏览器多标签页。
线程之间哪些资源是共享的?哪些是不共享的?
共享资源:
-
文件描述符表
-
每种信号的处理方式
-
当前工作目录
-
用户ID和组ID
-
内存地址空间(.text/.data/.bss/heap/共享库)
非共享资源:
-
线程id
-
处理器现场和栈指针(内核栈)
-
独立的栈空间(用户空间栈)
-
errno变量
-
信号屏蔽字
-
调度优先级
线程分离是什么?
一般情况下,线程终止后,其终止状态一直保留到其它线程调用pthread_join获取它的状态为止。但是线程也可以被置为detach状态,这样的线程一旦终止就立刻回收它占用的所有资源,而不保留终止状态。
线程分离的目的是将线程资源的回收工作交由系统自动来完成,也就是说当被分离的线程结束之后,系统会自动回收它的资源。
线程如何取消?取消是实时的吗?
pthread_cancel取消线程。pthread_cancel 后线程不会自动分离,需要手动处理或确保线程函数中有清理代码,除非这个子线程已经被分离。
线程的取消并不是实时的,而有一定的延时。需要等待线程到达某个取消点(检查点)。取消点通常是一些系统调用:creat,openpause,close,read,write……
主线程退出,其他线程会退出吗?
如果主线程直接调用return或exit()退出,整个进程就会终止,其他线程也会被强制结束,因为进程的资源会被回收。
但如果主线程只是调用pthread_exit()(POSIX线程)或类似API主动退出自身而不终止进程,其他线程会继续运行,直到它们自己结束或者进程被显式终止。
如何避免僵尸线程?
-
pthread_join
-
pthread_detach
-
pthread_create指定分离属性
-
被join线程可能在join函数返回前就释放完自己的所有内存资源,所以不应当返回被回收线程栈中的值。
多线程模型中,某个线程调用fork(),会发生什么?
当多线程程序中的某个线程调用fork()时,只有调用fork()的那个线程会在子进程中存活,其他线程全部消失,相当于子进程是调用线程的一个“单线程拷贝”。
所以,在多线程程序里调用fork()通常很危险,除非紧接着调用exec()替换掉整个进程映像。
请简述进程切换和线程切换的区别?
进程切换:
-
进程切换涉及 整个进程上下文的保存与恢复,包括内存空间、页表、文件描述符表等。资源开销大。
-
不同进程拥有独立的地址空间,切换时需要切换页表,导致 TLB 失效,性能开销大。
线程切换:
-
线程切换 一般只涉及线程私有数据(如寄存器、栈、程序计数器等)。资源开销小。
-
同一进程的多个线程共享同一个地址空间,无需切换页表,TLB 可继续有效,性能更高。
进程间通信和线程间通信的区别?
线程间通信:
-
共享同一进程的地址空间,可以直接读写共享变量,但要注意线程安全。
-
可以直接通过共享内存,但需同步机制避免竞争。
-
共享变量 + 互斥锁、条件变量、信号量在线程间通信更常见。
伪线程是什么?
这个你直接回答协程相关内容就行。
工作线程和CPU黏性大,导致占满了怎么办,怎么线程隔离?
CPU 黏性(CPU Affinity / CPU Pinning):指线程被操作系统或人为绑定到某个或某几个固定的 CPU 核心上运行。
-
可以在代码中为线程设置 CPU 亲和性(Affinity),实现线程隔离。使用
pthread_setaffinity_np():pthread_setaffinity_np(thread, sizeof(cpu_set_t), &cpuset); -
使用线程池 + 任务分片 + CPU 分区,实现逻辑隔离
-
按功能/模块划线程组,并绑定到不同 CPU 核(CPU Affinity)
【必问】说说线程调度算法?
-
先来先服务(FCFS, First-Come First-Served)
-
原理:按照线程到达就绪队列的顺序进行调度,先到的先执行。
-
类型:非抢占式。
-
优点:实现简单。
-
缺点:可能导致“护航效应”(短线程等待长线程执行完毕,平均等待时间长)。
-
-
短作业优先(SJF, Shortest Job First)
-
原理:优先调度预计运行时间最短的线程。
-
类型:可以是非抢占式或抢占式(称为 Shortest Remaining Time First, SRTF)。
-
优点:平均等待时间较短。
-
缺点:难以准确预估线程运行时间;可能导致长线程“饥饿”。
-
-
时间片轮转(Round Robin, RR)
-
原理:为每个线程分配一个固定长度的时间片(time quantum),线程运行完一个时间片后,就被强制挂起,调度下一个线程。
-
类型:抢占式。
-
优点:公平,响应快,适用于分时系统。这是现代通用操作系统中线程调度最常见的算法之一,尤其是在分时和多任务系统中。
-
缺点:时间片太短会导致频繁上下文切换;太长则退化为 FCFS。
-
-
优先级调度(Priority Scheduling)
-
原理:每个线程都有一个优先级,调度器优先执行优先级最高的线程。
-
类型:
-
静态优先级:优先级在创建时确定,之后不变。
-
动态优先级:优先级可以运行时调整(比如基于等待时间)。
-
-
抢占式 / 非抢占式 都可以。
-
优点:灵活,适用于实时系统。
-
缺点:低优先级线程可能产生“饥饿”(永远得不到执行)。
-
Linux 实际使用的是一种称为 “完全公平调度器(CFS, Completely Fair Scheduler)” 的算法,它基于虚拟运行时间(vruntime)来近似公平。
-
-
多级队列调度(Multilevel Queue Scheduling)
-
原理:将线程划分为多个队列(比如前台交互式线程、后台批处理线程),每个队列有不同的调度策略(比如 RR 用于交互式, FCFS 用于批处理)。
-
队列之间可以有优先级关系。
-
优点:适用于线程类型差异大的系统。
-
缺点:可能导致低优先级队列中的线程饥饿。
-
-
多级反馈队列(Multilevel Feedback Queue, MLFQ)
-
原理:是多级队列的增强版,线程可以在不同优先级队列之间动态移动。
-
例如:新线程进入高优先级队列,如果它用完了时间片还没执行完,就降到低优先级队列。
-
优先级高的队列时间片短,响应更快。
-
-
优点:兼顾响应性与吞吐量,防止饥饿,适应不同行为线程。
-
缺点:实现复杂。
-
-
完全公平调度(CFS, Completely Fair Scheduler)—— Linux 默认
-
原理:Linux 使用 CFS 作为其默认的进程/线程调度器(从 2.6.23 内核开始)。
-
核心思想:尽量让每个线程都公平地获得 CPU 时间,依据是线程的“虚拟运行时间”(vruntime)。
-
运行时间越少的线程 vruntime 越小,优先级越高。
-
-
数据结构:使用红黑树来高效选择 vruntime 最小的线程。
-
优点:公平、高效、支持动态优先级与多核调度。
-
特点:不是严格基于优先级,而是以“公平共享 CPU 时间”为核心。
-
说说线程调度的触发时机?
-
当前线程主动放弃 CPU:
-
如调用
sleep()、yield()、wait()、I/O 阻塞等。
-
-
时间片用完(时间片轮转调度)。
-
有更高优先级线程就绪(优先级调度)。
-
当前线程终止或阻塞。
-
多核间负载均衡触发迁移。
6.4.2 线程池
线程池是什么?线程池的设计思想?
在Linux网络通信中,线程池通过预先创建一组可复用的线程并维护任务队列,避免了传统多线程服务器频繁创建/销毁线程的高开销,同时限制了并发线程数量以防止资源耗尽,提升了系统稳定性和性能;相比纯IO多路转接(如epoll),线程池将I/O事件的处理任务分配到多个工作线程并行执行,解决了高并发下事件处理可能成为瓶颈的问题,尤其适合需要复杂计算或混合型I/O与业务逻辑的场景,从而实现了高效的任务调度与资源利用。
线程池的应用?可以用在哪里?
-
高并发网络服务器(如 Web 服务器、游戏服务器、RPC 框架)
-
异步任务处理 / 后台任务
-
数据库连接 / 文件操作等 I/O 密集型任务
让你设计一个线程池,其基本组成部分有哪些?
-
任务队列:线程池中的工作线程从这个队列中获取任务并执行。一般使用
std::queue<std::function<void()>>,并配合互斥锁保护。 -
工作线程集合:线程池的核心就是复用这些线程,避免频繁创建和销毁。使用
std::vector<std::thread>来管理一组线程。 -
同步机制(互斥锁 & 条件变量):
-
互斥锁(std::mutex):用于保护共享资源(如任务队列),防止多线程同时访问造成数据竞争。
-
条件变量(std::condition_variable):用于线程间通信,主要作用是:
-
当任务队列为空时,工作线程等待;
-
当有新任务加入时,通知一个或多个等待的线程醒来去执行任务。
-
-
-
线程池管理类(ThreadPool 类):对外提供接口,如提交任务(
submit/enqueue)、启动线程池、停止线程池等。 -
优雅退出机制(Shutdown / Stop 逻辑)。
你设计的线程池的一般执行流程是什么?
-
线程池初始化阶段
-
用户创建线程池对象,指定线程数量(比如 4 个线程)。
-
线程池构造函数中:
-
初始化一个任务队列(如
std::queue<std::function<void()>>); -
创建若干个工作线程(如
std::vector<std::thread>),每个线程运行一个统一的线程函数(worker loop); -
初始化必要的同步原语:互斥锁(std::mutex) 和 条件变量(std::condition_variable);
-
工作线程一开始处于等待任务的状态。
-
-
-
任务提交阶段(用户提交任务)
-
用户通过线程池提供的接口(如
submit()/enqueue())提交一个任务,通常是std::function<void()>,也可能是一个 lambda 表达式。 -
线程池将这个任务放入任务队列中,并对任务队列加锁(用
std::mutex保护,防止多线程竞争)。 -
任务入队后,线程池通过条件变量通知一个或多个等待中的工作线程:“有新任务可以执行了!”
-
-
任务执行阶段(工作线程运行)
-
每个工作线程都有一个循环(worker loop),不断执行以下逻辑:
-
先加锁,尝试从任务队列中取任务;
-
如果任务队列为空,则通过条件变量 wait() 等待,直到有新任务到来并被唤醒;
-
一旦被唤醒,再次检查队列(防止虚假唤醒),如果仍然为空且线程池未停止,则继续等待;
-
如果队列中有任务,则取出一个任务(如 std::function),解锁,然后执行该任务;
-
任务执行完毕后,回到循环开头,继续等待下一个任务。
-
-
-
线程池停止 / 优雅退出阶段
-
当线程池需要停止(比如析构函数被调用,或者用户显式调用
stop())时:-
设置一个标志位(如
std::atomic<bool> stop_ = true),表示不再接受新任务,线程池即将关闭; -
通过条件变量 唤醒所有正在等待的工作线程(防止它们一直阻塞);
-
工作线程被唤醒后,会检查停止标志以及任务队列是否为空:
-
如果线程池已停止 且 任务队列为空 → 线程自行退出;
-
如果还有任务未处理,线程可能继续处理完队列中的任务后再退出(视具体设计而定);
-
-
所有线程安全退出后,线程池销毁,释放资源。
-
-
任务队列为空时,线程应该做什么?线程池如何避免忙等待?
当线程池中的工作线程从任务队列中取任务时,发现队列为空,说明当前没有任务需要执行。
正确做法是:线程应该等待(阻塞),而不是空转(循环忙等)。为了避免忙等待,线程池通常采用 “条件变量(std::condition_variable)” 机制,让线程在任务队列为空时主动进入等待状态,不占用 CPU,直到有新任务到来时被唤醒。
如何提交一个任务到线程池?
-
用户调用提交接口,传入一个 可调用对象(Callable),比如:普通函数/Lambda 表达式/函数对象(仿函数)/
std::function<void()>。 -
线程池将任务放入任务队列,为了保证多线程安全,这个操作需要加锁(使用
std::mutex)。 -
任务入队后,线程池会通过 条件变量(std::condition_variable) 唤醒一个正在等待任务的工作线程,告诉它:“有活干了!”
-
工作线程被唤醒,从队列中取任务并执行。
线程如何从任务队列中获取任务?如何调度?
调度策略通常是:“先到先服务(FIFO),谁先抢到任务谁执行”—— 也就是任务队列 + 线程竞争获取。
具体来说:
-
所有工作线程共享一个任务队列,任务按照提交顺序入队(FIFO)。
-
当有任务到来时,它被放到队尾。
-
当线程获取任务时,是从队首取任务(先进先出)。
-
哪个线程先获取到锁,谁就能从队列中取出任务并执行 —— 本质是一种隐式的竞争调度。
其他可能的调度策略:优先级调度、轮询 / 分配固定任务、工作窃取。
如何保证线程安全地访问共享的任务队列?
使用 互斥锁(std::mutex) 保护共享资源:
-
用来保护共享数据(如任务队列),保证同一时间只有一个线程可以访问它。
-
当一个线程持有锁时,其他线程如果也想加锁,就会被阻塞,直到锁被释放。
如何优雅地关闭线程池?
-
设置一个标志位(如
std::atomic<bool> stop_或bool stop)-
用来表示线程池是否已经进入 “停止接收新任务” 的状态;
-
工作线程在每次执行任务循环时都会检查这个标志;
-
一般使用
std::atomic保证多线程间的可见性与原子性。
-
-
使用条件变量(std::condition_variable)唤醒等待的线程
-
当线程池准备关闭时,通过
condition.notify_all()唤醒所有正在等待任务的工作线程; -
唤醒后,线程会检查停止标志和任务队列状态,决定是否退出。
-
-
工作线程的循环逻辑中检查停止条件
-
工作线程一般运行在一个
while (true)循环中; -
每次循环会:
-
先加锁
-
检查任务队列是否为空,且线程池是否已停止
-
如果满足停止条件且任务队列为空,线程就退出循环,结束运行
-
否则,从队列中取出任务并执行
-
-
-
在析构函数(或显式 stop() 方法)中触发关闭流程
-
析构函数是线程池关闭的常见入口,你应该:
-
设置停止标志位(stop = true)
-
唤醒所有等待中的线程(notify_all)
-
等待所有线程执行完毕并 join(避免野线程)
-
-
线程池如何实现任务的优先级?
将原来的普通队列(如 std::queue)替换为 优先队列(如 std::priority_queue),并根据任务的优先级决定其出队顺序。
你需要定义一个任务类型,它不仅仅是 std::function<void()>,而是包含:
-
一个可调用对象(任务逻辑)
-
一个优先级值(如 int,越大优先级越高,或越小优先级越高,具体看排序规则)
如何实现一个支持返回值的线程池?
-
在提交任务时(如 submit 方法),为每个任务创建一个
std::promise<T>,并从中获取关联的std::future<T>,将其返回给调用者。 -
将任务和对应的
std::promise打包在一起,提交到线程池的任务队列中。 -
工作线程在执行任务时,正常执行用户逻辑,并通过
promise.set_value(result)将任务的返回值设置进 promise。 -
如果任务抛出异常,可以调用
promise.set_exception(std::current_exception()),将异常传递给调用者。 -
调用者之后可以通过
future.get()获取任务的返回值(或捕获异常),如果任务尚未完成,get() 会阻塞等待。
线程池如何动态扩容?
动态扩容让线程池能够 根据当前任务队列的负载情况,动态地增加或减少工作线程数量,从而:
-
在负载高时 自动扩容(增加线程),提升并发处理能力,避免任务长时间等待;
-
在负载低时 适当缩容(减少线程),节约系统资源(如内存、CPU 上下文切换开销);
-
实现 更高的资源利用率和系统弹性。
-
基于任务队列长度触发扩容
-
监控任务队列的长度(如 tasks.size())
-
当队列中的任务数超过某个阈值(如
queue_threshold),且当前线程数小于最大线程数时:-
创建一个新的工作线程,加入线程池;
-
新线程开始从任务队列中取任务执行。
-
-
-
基于等待时间(任务入队后迟迟未被处理)
-
记录任务入队的时间,或者让工作线程在发现队列过长时触发反馈机制;
-
如果任务在队列中等待时间超过某个阈值,可以考虑扩容。
-
-
设置最大线程数上限 & 动态创建线程
-
线程池维护两个变量:
-
current_threads_:当前工作线程数 -
max_threads_:允许的最大线程数(如 16、32 等)
-
-
当任务积压且
current_threads_ < max_threads_时,就创建新线程。
-
6.5 锁
6.5.1 互斥锁
【必问】同步 vs 互斥有什么区别?
互斥:是指散布在不同任务之间的若干程序片段,当某个任务运行其中一个程序片段时,其它任务就不能运行它们之中的任一程序片段,只能等到该任务运行完这个程序片段后才可以运行。最基本的场景就是:一个公共资源同一时刻只能被一个进程或线程使用,多个进程或线程不能同时使用公共资源。
同步:是指散布在不同任务之间的若干程序片段,它们的运行必须严格按照规定的某种先后次序来运行,这种先后次序依赖于要完成的特定的任务。最基本的场景就是:两个或两个以上的进程或线程在运行过程中协同步调,按预定的先后次序运行。比如 A任务的运行依赖于 B任务产生的数据。
显然,同步是一种更为复杂的互斥,而互斥是一种特殊的同步。
【必问】同步=阻塞?异步=非阻塞?
-
同步(Synchronous) vs 异步(Asynchronous)
这两个概念主要描述的是任务的执行顺序和调用方式。重点在顺序。
-
阻塞(Blocking) vs 非阻塞(Non-blocking)
这两个概念主要描述的是调用者在等待某个操作时的状态,特别是线程或进程的状态。重点在是否等待。
场景:你去饭馆吃饭
-
同步阻塞(最常见)
-
你到饭店,点完菜后,啥也不干,就坐在那儿等服务员上菜,期间不玩手机也不走动。
-
→ 你同步地等待结果(上菜),而且你是阻塞的(没干别的)。
-
-
同步非阻塞
-
你点完菜后,不坐着等,而是每分钟去问服务员一次:“菜好了吗?”,期间你可以走来走去。
-
→ 你同步地关心结果(必须亲自得到上菜的消息),但你没有一直傻等(非阻塞)。
-
-
异步非阻塞(推荐高效方式)
-
你点完菜后,服务员说:“好了我们叫你。”然后你就去玩手机或做其他事,等菜好了服务员通知你。
-
→ 你异步地等待结果(不用主动问,有通知),而且你没有阻塞(可以做别的事)。
-
-
异步阻塞(几乎无意义)
-
你点了菜后,告诉服务员做好了叫我,但我还是坐在旁边一动不动等,啥也不干。
-
→ 这种情况比较奇怪,逻辑上有点矛盾,一般不会这么设计。
-
互斥锁的操作流程是什么?
-
在访问共享资源后临界区域前,对互斥锁进行加锁。
-
在访问完成后释放互斥锁导上的锁。
-
对互斥锁进行加锁后,任何其他试图再次对互斥锁加锁的线程将会被阻塞,直到锁被释放。
死锁是什么?
死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。
死锁引起的原因有哪些?
-
竞争不可抢占资源引起死锁。
-
竞争可消耗资源引起死锁。
-
进程推进顺序不当引起死锁。
【必问】死锁的四个必要条件是什么?
-
互斥条件
-
某资源只能被一个进程使用,其他进程请求该资源时,只能等待,直到资源使用完毕后释放资源。
-
-
请求和保持条件
-
程序已经保持了至少一个资源,但是又提出了新要求,而这个资源被其他进程占用,自己占用资源却保持不放。
-
-
不可抢占条件
-
进程已获得的资源没有使用完,不能被抢占。
-
-
循环等待条件
-
必然存在一个循环链。
-
处理死锁的思路有哪些?
-
预防死锁:破坏死锁的四个必要条件中的一个或多个来预防死锁。
-
破坏请求和保持条件1:所有进程开始前,必须一次性地申请所需的所有资源,这样运行期间就不会再提出资源要求,破坏了请求条件,即使有一种资源不能满足需求,也不会给它分配正在空闲的资源,这样它就没有资源,就破坏了保持条件,从而预防死锁的发生。
-
破坏请求和保持条件2:允许一个进程只获得初期的资源就开始运行,然后再把运行完的资源释放出来。然后再请求新的资源。
-
破坏不可抢占条件:当一个已经保持了某种不可抢占资源的进程,提出新资源请求不能被满足时,它必须释放已经保持的所有资源,以后需要时再重新申请。
-
破坏循环等待条件:对系统中的所有资源类型进行线性排序,然后规定每个进程必须按序列号递增的顺序请求资源。假如进程请求到了一些序列号较高的资源,然后有请求一个序列较低的资源时,必须先释放相同和更高序号的资源后才能申请低序号的资源。多个同类资源必须一起请求。
-
-
避免死锁:和预防死锁的区别就是,在资源动态分配过程中,用某种方式防止系统进入不安全的状态。
-
检测死锁:运行时出现死锁,能及时发现死锁,把程序解脱出来。
-
解除死锁:发生死锁后,解脱进程,通常撤销进程,回收资源,再分配给正处于阻塞状态的进程。
6.5.2 读写锁
【必问】读写锁 vs 互斥锁有什么区别?
在对数据的读写操作中,更多的是读操作,写操作较少,例如对数据库数据的读写应用。为了满足当前能够允许多个读出,但只允许一个写入的需求,线程提供了读写锁来实现。
读写锁的特点是什么?
-
如果有其它线程读数据,则允许其它线程执行读操作,但不允许写操作。
-
如果有其它线程写数据,则其它线程都不允许读、写操作。
读写锁分为读锁和写锁,规则如下:
-
如果某线程申请了读锁,其它线程可以再申请读锁,但不能申请写锁。
-
如果某线程申请了写锁,其它线程不能申请读锁,也不能申请写锁。
6.5.3 自旋锁和屏障
自旋锁是什么?
当线程尝试获取自旋锁时,如果锁已被其他线程占用,该线程会持续循环检查锁的状态(即“自旋”),直到锁被释放。
特点:
-
不释放 CPU:线程在等待期间保持活跃状态,不会进入睡眠。
-
无上下文切换:避免了线程切换的开销,但会占用 CPU 资源。
【必问】互斥锁 vs 自旋锁有什么区别?
互斥锁是一种“阻塞型”锁,当一个线程尝试获取一把已经被其他线程占用的互斥锁时:
-
该线程会进入阻塞状态(挂起),让出 CPU 资源;
-
操作系统会将其放入等待队列中;
-
当锁被释放后,操作系统会从队列中唤醒一个等待线程(不一定立即,依赖调度);
-
被唤醒的线程再次尝试获取锁。
自旋锁是一种“非阻塞/忙等型”锁,当一个线程尝试获取一个已被占用的自旋锁时:
-
它不会挂起自己,而是通过循环(自旋)不断尝试获取锁;
-
期间线程持续处于运行状态,占用 CPU 资源;
-
直到锁被释放,它才能成功获取锁并进入临界区。
自旋锁的适用场景有哪些?
-
锁被持有的时间非常短(例如临界区代码执行时间极短)。
-
多核 CPU 环境下,锁的竞争者可能在其他核心上快速释放锁。
-
不适用场景:单核 CPU(自旋会阻塞锁持有者的运行)、锁持有时间较长(浪费 CPU)。
屏障是什么?
-
线程在代码中调用
屏障等待函数(例如pthread_barrier_wait)时,会进入等待状态。 -
当所有参与线程都调用了
屏障等待后,它们会同时被释放,继续执行后续代码。 -
屏障会自动重置,以便在后续阶段重复使用。
6.5.4 条件变量
条件变量是什么?它如何工作?
条件变量用来自动阻塞一个线程,直到某特殊情况发生为止。通常条件变量和互斥锁同时使用。条件变量的两个动作:
-
条件不满,阻塞线程。
-
当条件满足,通知阻塞的线程开始工作。
pthread_cond_wait是做什么工作的?
-
a)阻塞等待条件变量cond(参1)满足
-
b)释放已掌握的互斥锁(解锁互斥量)相当于pthread_mutex_unlock(&mutex);
-
a)b) 两步为一个原子操作。
-
c)当被唤醒,pthread_cond_wait函数返回时,解除阻塞并重新申请获取互斥锁pthread_mutex_lock(&mutex);
pthread_cond_signal唤醒至少一个线程,那么具体会唤醒多少个?和pthread_cond_broadcast的本质区别是什么?
在 pthread_cond_signal 中,它会唤醒至少一个等待在该条件变量上的线程,但具体唤醒多少个取决于线程调度器的实现——可能是一个,也可能是多个(虽然标准只保证至少一个)。而 pthread_cond_broadcast 则会唤醒所有等待在该条件变量上的线程。
两者的本质区别在于唤醒范围:signal 是“保守”的,适合资源有限时按需唤醒;broadcast 是“激进”的,通常用于状态变更后所有等待线程都需要重新检查条件(比如生产者-消费者模型中缓冲区从空变非空时,可能多个消费者都需要竞争处理)。另外,broadcast 的开销更大,因为它需要遍历所有等待线程。
【必问】生产者消费者模型是什么?它的流程是怎样的?
假定有两个线程,一个模拟生产者行为,一个模拟消费者行为。两个线程同时操作一个共享资源(一般称之为汇聚),生产向其中添加产品,消费者从中消费掉产品。
消费者
-
创建锁
pthread_mutex_t mutex; -
初始化
pthread_mutex_init(&mutex, NULL); -
加锁
pthread_mutex_lock(&mutex); -
待条件满足:
pthread_cond_wait(&cond, &mutex);-
阻塞等条件变量
-
解锁
unlock(mutex) -
等待:例如10s
-
加锁
lock(mutex);
-
-
访问共享数据
-
解锁、释放条件变量。释放锁
-
循环消费
生产者
-
生产数据
-
加锁
pthread_mutex_lock(&mutex) -
将数据放置到公共区域
-
解锁
pthread_mutex_unlock(&mutex); -
通知阻塞在条件变量上的线程
-
pthread_cond_signal() -
pthread_cond_broadcast()
-
-
循环生产后续数据
相比于互斥量,条件变量有什么优势?
相较于mutex而言,条件变量可以减少竞争。
如直接使用mutex,除了生产者、消费者之间要竞争互斥量以外,消费者之间也需要竞争互斥量,但如果汇聚(链表)中没有数据,消费者之间竞争互斥锁是无意义的。
条件变量使用过程中,信号丢失问题是怎么产生的?如何解决?
某个线程本应该被唤醒(因为条件已经满足),但由于通知(如 notify_one() 或 notify_all())发生在等待(如 cv.wait(...)) 之前,导致该线程错过了唤醒信号,从而一直阻塞,即使条件已经为真。
如果线程在调用 wait() 之前,没有检查条件、没有上锁、或者通知已经发出,就可能导致线程进入等待时,条件其实已经成立,但没人知道,也没人再通知它。
解决:
始终在调用 wait() 之前加锁,并先检查条件。
条件变量使用过程中,虚假唤醒是怎么产生的?如何解决?
虚假唤醒(Spurious Wakeup)是指:一个线程从 std::condition_variable::wait() 中返回(被“唤醒”),但实际上并没有任何其他线程调用过 notify_one() 或 notify_all() 来主动唤醒它。
解决:
把条件判断的逻辑放到while循环里。
-
如果线程被虚假唤醒,它会重新进入
while循环,再次检查条件; -
如果条件仍未满足(
data_ready == false),线程会 继续等待; -
只有当条件真正满足时,线程才会跳出循环,继续执行。
6.5.5 信号量
信号量是用来做什么的?
根据操作信号量值的结果判断是否对公共资源具有访问的权限,当信号量值大于0时,则可以访问,否则将阻塞。
PV 原语是对信号量的操作,一次P操作使信号量减1,一次V操作使信号量加1。信号量的初值,决定了占用信号量的线程的个数。
【必问】信号量 vs 信号有什么区别?
信号量和信号虽然名字像,但完全是两回事。
信号量是一种进程间同步机制,主要用于控制多个线程或进程对共享资源的访问,比如P操作减1、V操作加1,可以用来实现互斥锁或者条件等待。
而信号是进程间异步通信的一种方式,比如kill命令发的SIGKILL或者程序异常时的SIGSEGV,它更像是一种事件通知机制,进程可以注册信号处理函数来响应,但信号不保证执行的时机和顺序,更多是“发生了什么事”的通知,而不是“怎么协调资源”的工具。简单说,信号量管同步,信号管事件通知。
条件变量 vs 信号量哪个效率高一些?
性能差距不大。
-
在典型的“生产者-消费者”、线程等待某个状态(如任务就绪、数据到达)的场景下:
-
条件变量是更自然、更高效的选择,因为它直接与某个逻辑条件绑定,且能避免很多误用(比如丢失唤醒、忙等)。
-
信号量也能实现类似功能,但往往需要额外逻辑,且不够直观。
-
-
在需要“资源计数”或“N 个任务完成后继续”等场景:
-
信号量更直接,比如用信号量做线程池任务控制、限流、并发数限制等。
-
哲学家就餐问题是什么?如何解决?
哲学家就餐问题是一个经典的并发编程问题,由Dijkstra提出,用来描述多线程环境下资源竞争和死锁的问题。场景是五位哲学家围坐在圆桌旁,每人左右各有一根筷子,哲学家要么思考,要么吃饭,但吃饭必须同时拿到左右两根筷子。如果所有哲学家都同时拿起左边的筷子,就会导致所有人都在等右边的筷子,形成循环等待,也就是死锁。
解决这个问题有几种常见方法。第一种是破坏死锁的四个必要条件之一,比如规定哲学家必须按顺序拿筷子,比如先拿编号小的筷子再拿编号大的,这样就能避免循环等待。第二种是限制同时拿筷子的哲学家数量,比如最多允许四个哲学家同时尝试拿筷子,这样至少能保证有一个人能拿到两根筷子吃饭。第三种是使用信号量或者条件变量来协调,比如让哲学家在拿不到筷子时等待,而不是死循环尝试,这样可以通过资源分配策略避免死锁。
实际工程中,我们可能会用更高级的同步机制,比如C++里的std::mutex配合条件变量,或者直接用更上层的并发框架来避免手动处理这种问题,因为现实中的资源竞争往往比筷子复杂得多。
银行家算法是什么?请简述。
银行家算法是操作系统里用来避免死锁的一种资源分配算法,由Dijkstra提出,它的核心思想是在分配资源之前先进行安全性检查,确保系统始终处于安全状态,从而避免进入死锁。
简单来说,银行家算法把系统资源抽象成"银行家"手里的钱,进程就是来借钱的"客户"。每个进程在运行前会申请一定数量的资源,银行家不会直接满足所有请求,而是会先模拟分配后的系统状态,检查是否存在至少一个进程能够顺利完成并释放资源,最终让所有进程都能顺利执行。如果能找到这样的安全序列,就批准分配;否则就拒绝请求,让进程等待。
举个例子,假设系统有10个资源,当前已分配6个,剩余4个。如果一个进程申请2个资源,银行家会检查分配后是否还能让其他进程顺利执行完毕。如果能,就分配;如果不能,就暂时不分配,避免系统进入不安全状态。
不过银行家算法在实际工程中用得不多,因为它的计算开销比较大,需要维护资源分配表和安全序列,而且要求预先知道每个进程的最大需求,这在复杂的服务器环境里不太现实。但它作为理论模型,很好地展示了如何通过预防策略来避免死锁。
Linux系统编程面试核心
2008

被折叠的 条评论
为什么被折叠?



