Skip to content

Latest commit

 

History

History
228 lines (135 loc) · 20.6 KB

File metadata and controls

228 lines (135 loc) · 20.6 KB
title 进程与线程详解:区别、状态、通信、上下文切换与虚拟线程
description 进程与线程高频面试题总结,从操作系统视角梳理进程和线程的概念、资源模型、状态转换、PCB/TCB、fork/exec/wait、线程模型、上下文切换以及 Java 线程和虚拟线程的关系。
category 计算机基础
tag
操作系统
进程线程
Java 并发
head
meta
name content
keywords
进程,线程,进程和线程的区别,进程状态,线程状态,PCB,TCB,fork,exec,wait,clone,pthread,上下文切换,线程模型,Java虚拟线程,操作系统面试题

进程和线程是操作系统里最基础、也最容易被混着背的两个概念。

面试里问它们的区别,很多回答会停在“进程是资源分配的基本单位,线程是 CPU 调度的基本单位”。这句话可以作为入口,但不够用。

继续往下追,就会遇到一串更具体的问题:为什么进程之间默认隔离?线程到底共享了什么?fork() 后父子进程有哪些东西相同、哪些东西已经分开?为什么多线程程序里随便 fork() 会出问题?Java 虚拟线程又算不算操作系统线程?

这篇文章就顺着这些问题展开。先把程序、进程、线程的边界讲清楚,再看 Linux 里的 forkexecwaitclone,最后回到上下文切换、线程模型和 Java 虚拟线程。读的时候可以抓住一条主线:进程更像资源和隔离边界,线程更像一条可以被调度的执行路径。

程序、进程和线程分别是什么?

程序、进程和线程的关系

程序是存放在磁盘上的一组指令和数据,比如一个可执行文件、一个 JAR 包。它还没有真正运行,只是静态文件。

当操作系统把程序加载到内存,为它建立虚拟地址空间、文件描述符表等进程级资源,并为初始线程建立栈、寄存器上下文等执行现场后,程序的一次运行就变成了进程。同一个程序可以启动多次,对应多个进程;比如同时打开两个终端窗口,通常就是两个不同的进程实例。

线程是进程里的执行流。一个进程至少有一个线程,进程中的多个线程共享这份进程资源,但每个线程也有自己的执行现场。现代操作系统真正拿去调度的通常是线程:哪个线程处于可运行状态,调度器就可能把 CPU 时间片分给它。

可以用一句话先记住大方向:进程侧重资源边界,线程侧重执行和调度。

判断一个概念更偏进程还是更偏线程,也可以先问:它描述的是资源边界,还是一条执行路径?地址空间、打开文件表、权限信息更偏进程;栈、寄存器、程序计数器更偏线程。

用微信工厂类比进程和线程的区别

不过这句话只是学习时的抓手,不能当成所有系统的实现细节。比如 Linux 内核内部用 task_struct 描述调度实体,进程和线程更像是共享资源程度不同的任务;Windows 文档则明确把线程说成操作系统分配处理器时间的基本单位。不同系统名字不完全一样,但抽象层面的关系大致相通。

进程拥有哪些资源?

进程不是只有正在执行的代码。一个进程通常包含这些内容:

  • 虚拟地址空间:进程看到的是一段连续的虚拟内存,里面有代码段、数据段、堆、栈、内存映射区域等。
  • 打开的文件和句柄:比如文件描述符、Socket、管道、设备句柄。
  • 安全和身份信息:比如用户 ID、权限、凭据、安全上下文。
  • 调度相关信息:优先级、CPU 时间统计、亲和性、状态等。
  • 信号、环境变量、工作目录等运行上下文

进程之间默认是隔离的。一个进程不能随便读写另一个进程的虚拟地址空间,这也是操作系统能把不同程序保护起来的基础。两个进程想交换数据,需要借助管道、消息队列、共享内存、Socket、文件、信号等 IPC 方式。

隔离带来安全,也带来成本。两个进程各自有地址空间和资源表,切换、通信、创建和销毁都比线程更重。

进程有哪些常见状态?

教材里常见的五状态模型够应付大多数面试题:

  • 创建状态(New):进程正在创建,还没有进入就绪队列。
  • 就绪状态(Ready):运行条件基本具备,只差 CPU。
  • 运行状态(Running):正在 CPU 上执行。
  • 阻塞状态(Blocked/Waiting):正在等某个事件,比如 I/O 完成、锁释放、定时器到期。
  • 终止状态(Terminated/Exit):进程结束,操作系统回收相关资源。

进程状态图转换图

状态转换的关键不在名词,而在触发原因。就绪态拿到 CPU 会变成运行态;运行中的时间片用完,可能回到就绪态;运行中发起阻塞 I/O,会进入阻塞态;阻塞等待的事件完成后,先回到就绪态,等待下一次被调度。

有些教材还会加入挂起状态。挂起强调进程暂时不在内存中,或者被用户/系统暂停;阻塞强调它在等待事件。二者不是一回事:一个进程可以阻塞但仍在内存里,也可以被换出到外存后处于阻塞挂起。

PCB 是什么?

PCB(Process Control Block,进程控制块)是操作系统管理进程的数据结构。进程运行时的许多信息不会散落在空气里,而是由内核放在类似 PCB 的结构里维护。

PCB 通常记录:

  • 进程标识信息:PID、父进程 ID、用户 ID 等。
  • 进程状态和调度信息:就绪、运行、阻塞、优先级、时间统计。
  • CPU 上下文:程序计数器、栈指针、通用寄存器等,方便切换回来继续执行。
  • 内存管理信息:页表、虚拟地址空间、内存映射。
  • 资源信息:打开文件、信号处理、工作目录、I/O 状态等。

发生上下文切换时,操作系统会把当前执行实体的寄存器等现场保存起来,再恢复下一个执行实体的现场。PCB/TCB 这类结构就是“下次从哪儿继续跑”的依据。

Linux 的实现有一点特别:它把进程和线程都看成 task,task_struct 里并不直接塞进所有资源,而是通过指针指向内存描述符、文件表、信号处理等资源结构。多个线程属于同一进程时,它们会指向同一批资源结构;不同进程则指向不同资源。这也是 Linux 上理解 clone() 很有用的原因。

Linux 里 fork、exec、wait 分别做什么?

fork、exec、wait 的调用链路

在 Unix/Linux 编程里,进程创建常绕不开 fork()exec()wait() 这三个动作。

fork() 用来创建子进程。调用成功后,父子进程从同一个位置继续往下执行,只是返回值不同:父进程拿到子进程 PID,子进程拿到 0。父子进程拥有独立的虚拟地址空间,刚创建时内容看起来一样;现代系统一般配合写时复制,只有当某一方写内存时,内核才复制对应页面。

还要注意文件描述符。fork() 后,父子进程的文件描述符表是各自的副本,但对应 fd 会指向同一个 open file description,所以文件偏移量、打开状态标志等会共享。工程里常配合 FD_CLOEXECO_CLOEXEC,避免 exec() 后把不该继承的 fd 泄漏给新程序。

exec() 系列函数用于在当前进程里装入另一个程序。它不会新建进程,而是把当前进程的代码、数据、堆、栈等用户态内容替换成新程序。命令行里常见的模型就是:Shell 先 fork() 出子进程,子进程再 exec() 成目标程序。

wait()/waitpid() 用来等待子进程状态变化,并回收子进程退出后残留在内核里的状态信息。子进程已经退出但父进程还没有 wait,就会留下僵尸进程。僵尸进程不再执行代码,但仍占着 PID 和退出状态记录。

Shell 启动外部命令时,常见链路就是:Shell 调 fork() 创建子进程,子进程调 exec() 变成目标程序,父进程用 wait()waitpid() 等待并回收退出状态。

这里有个容易忽略的细节:多线程进程调用 fork() 后,子进程里只保留调用 fork() 的那个线程。父进程其他线程的锁状态、条件变量状态、malloc 状态、stdio 状态可能被复制过去,但对应线程已经不存在。更严格地说,多线程程序 fork() 之后、exec() 之前,子进程只应调用 async-signal-safe 的函数;在这段窗口里做复杂逻辑很容易踩坑。

线程共享什么,又私有什么?

线程共享资源和私有执行现场

从操作系统角度看,同一进程内的线程共享进程的大部分资源,例如:

  • 代码段、数据段、堆等进程地址空间里的内存区域;
  • 打开的文件描述符、Socket、工作目录;
  • 进程 ID、地址空间、信号处理配置中的一部分;
  • 全局变量和堆对象。

如果换到 Java/JVM 语境,Java 线程还会共享同一个 JVM 进程里的堆、方法区/元空间等运行时数据区域。方法区/元空间不是通用操作系统概念,放在 JVM 这一层理解更合适。

在 Linux 用户态,同一进程内的多个线程调用 getpid() 通常看到的是同一个线程组 ID,也就是平时说的进程 ID;但每个线程在内核里仍有自己的 task/TID,可以用 gettid() 区分。

每个线程也有自己的私有内容:

  • 栈:保存函数调用、局部变量、返回地址等。
  • 寄存器和程序计数器:记录线程当前执行到哪里。
  • 线程 ID、调度优先级、线程本地存储(TLS)。
  • 线程状态和少量内核用于恢复执行的上下文信息。

共享让线程间通信很方便,一个线程往堆里的对象写入数据,另一个线程马上就可能看到。但共享也带来数据竞争:多个线程同时读写同一份可变数据,如果没有锁、原子变量、条件变量等同步手段,结果就可能不符合预期。

这也是线程和进程在工程上的重要差别:进程崩溃通常不会直接破坏另一个进程;同一进程内某个线程越界写内存、触发非法访问,往往会把整个进程带走。

TCB 是什么?

TCB(Thread Control Block,线程控制块)可以理解为线程级别的控制信息。它通常记录线程 ID、线程状态、寄存器现场、栈信息、优先级、线程本地存储等内容。

在一些教材或系统实现里,PCB 和 TCB 是分开的:PCB 负责进程级资源,TCB 负责线程级执行现场。Linux 的 task_struct 则把调度实体统一为 task,再按资源结构是否共享来区分进程和线程。概念学习时不必纠结名字,关键是看清哪些信息属于资源边界,哪些信息属于执行现场。

有了进程为什么还需要线程?

主要是为了在同一个应用内用更低成本做并发。

如果一个服务端要同时处理网络读写、业务计算、日志刷盘,用多个进程当然也能做,但进程之间共享状态麻烦,通信要走 IPC,资源占用也更高。改成多个线程后,它们能直接共享堆内存和打开的连接,只要同步写对,协作成本低很多。

线程也能提高资源利用率。单核 CPU 上,一个线程阻塞在磁盘或网络 I/O 时,其他线程可以继续运行;多核 CPU 上,多个线程有机会在不同核心上同时执行。CPU 密集型任务、I/O 密集型任务对线程数的需求不同,不能简单理解为线程越多越快。

线程不是免费资源。Linux NPTL 下,如果进程启动时的 RLIMIT_STACK 软限制不是 unlimited,它会决定新线程的默认栈大小;常见 ulimit -s 为 8192 KB,因此常见默认线程栈是 8 MB。如果 RLIMIT_STACKunlimited,则使用架构相关默认值,例如多数架构为 2 MB。也可以通过 pthread_attr_setstacksize() 指定线程栈大小,但不能低于 PTHREAD_STACK_MIN,Linux man-pages 给出的值是 16384 字节。除此之外,线程还受 PID 数量、threads-max、内存等限制。线上系统里盲目创建大量平台线程,常见后果是内存压力、调度开销和上下文切换增多。

用户线程、内核线程和线程模型怎么区分?

按“谁负责调度”来看,线程可以分为用户级线程和内核级线程。

用户级线程由用户态运行时或线程库管理,内核通常看不到这些线程。它的好处是创建、切换不一定需要系统调用;问题是如果所有用户线程只对应一个内核调度实体,那么其中一个线程发起阻塞系统调用,可能拖住整个进程,也很难利用多核。

内核级线程由操作系统内核创建和调度。某个线程阻塞,内核还能调度同进程的其他线程;多个线程也能在多核上并行执行。代价是创建、销毁、阻塞、唤醒、切换都要内核参与。

常见线程模型有三类:

常见的三种线程模型

模型 含义 优点 主要问题
多对一 多个用户线程映射到一个内核线程 用户态切换快,实现成本低 一个阻塞可能影响整体,不能充分利用多核
一对一 一个用户线程映射到一个内核线程 能利用多核,阻塞影响较小 线程数量受系统资源限制,创建和切换成本更高
多对多 多个用户线程映射到多个内核线程 在灵活性和并行能力之间折中 运行时和调度实现更复杂

Linux 的 POSIX 线程和 Windows 系统线程基本属于一对一模型。Linux 的 pthread_create() 底层会使用 clone(),并由 CLONE_VMCLONE_FILESCLONE_FSCLONE_THREAD 等标志决定共享哪些资源。Linux 上进程和线程并不是两套完全割裂的创建机制,而是 clone() 参数不同带来的资源共享差异。

线程上下文切换和进程上下文切换有什么不同?

线程上下文切换和进程上下文切换成本对比

上下文切换指 CPU 从一个执行实体切到另一个执行实体。操作系统需要保存当前实体的寄存器、程序计数器、栈指针等现场,再恢复下一个实体的现场。

线程切换和进程切换都会有开销,但进程切换通常更重。原因是进程有独立地址空间,切换时可能涉及页表切换、TLB 失效、缓存局部性下降等成本;同一进程内的线程共享地址空间,切换时通常不需要换整套内存映射。

可以把它简化成两句话:同一进程内的线程切换,主要换线程自己的栈、寄存器、程序计数器等执行现场;跨进程切换除了换执行现场,还可能切换地址空间,并带来 TLB 和缓存局部性的影响。

不过,线程切换也不能只看成“保存几个寄存器”。跨核迁移、锁竞争、缓存行来回失效、线程数量远大于 CPU 核数时,线程调度照样会消耗很多 CPU。性能分析里如果看到大量时间花在调度、锁等待、系统调用和上下文切换上,继续加线程往往只会让情况更差。

纤程、协程和虚拟线程算线程吗?

纤程(Fiber)和协程通常运行在用户态,由应用或运行时调度。操作系统真正调度的是承载它们的内核线程,而不是每一个纤程或协程。因此,这类轻量执行单元切换时通常不需要陷入内核,成本可以更低。

但它们不是“免费线程”。如果运行时没有把阻塞 I/O 改造成可挂起、可恢复的形式,一个用户态任务阻塞住承载线程,同一承载线程上的其他任务也会受影响。另外,不同语言、运行时、CPU 架构和调用栈深度都会影响切换成本,不能把某个基准测试里的纳秒数字当成通用结论。

Java 21 引入的虚拟线程就是一个典型例子。它仍然是 java.lang.Thread,但不会长期独占一个操作系统线程。虚拟线程运行时会挂载到平台线程(platform thread)上,平台线程再对应底层的系统内核线程;当虚拟线程执行 JDK 支持的可挂起阻塞 I/O 时,JDK 可以先把它卸载下来,让这个平台线程去运行别的虚拟线程。

所以,虚拟线程适合大量“等 I/O”的任务,比如高并发请求、数据库访问、远程调用等。它提升的是并发承载能力和吞吐扩展性,不是让一段 CPU 计算代码跑得更快。CPU 密集型长任务仍然要看 CPU 核数、计算量和调度开销,不能无限量丢给虚拟线程。

虚拟线程、平台线程和系统内核线程的关系:

虚拟线程、平台线程和系统内核线程的关系

还要注意 pinning。以 Java 21 为例,虚拟线程在 synchronized 块/方法、native 方法或 foreign function 中执行阻塞操作时,可能无法从承载它的平台线程上卸载,结果就是平台线程也被一起占住,不能去运行其他虚拟线程。少量、短时间的 pinning 不会让程序出错,但频繁、长时间的 pinning 会影响扩展性。后续 JDK 对 synchronized 相关的 pinning 做过改进,实际判断时要以当前使用的 JDK 版本为准;native/foreign 调用这类边界仍然需要额外留意。

进程和线程的区别怎么总结?

面试里可以从资源、调度、通信、开销、可靠性 5 个角度答。

维度 进程 线程
基本定位 资源分配和隔离的基本单位 CPU 调度和执行的基本单位
地址空间 默认独立 同一进程内共享
私有内容 PID、地址空间、资源表等 栈、寄存器、程序计数器、TLS 等
通信方式 需要 IPC,如管道、Socket、共享内存 可直接读写共享内存,但要同步
创建/切换成本 通常更高 通常更低
故障影响 隔离性更好,一个进程崩溃通常不影响其他进程 一个线程崩溃可能导致整个进程退出

比较完整的回答可以这样组织:

进程是程序运行时的资源容器,拥有独立虚拟地址空间和文件、权限等资源;线程是进程内的执行流,多个线程共享进程资源,但各自保存栈、寄存器、程序计数器等执行现场。进程间隔离更强,通信和切换成本更高;线程间协作更方便,创建和切换通常更轻,但共享内存带来线程安全问题,一个线程出错也可能影响整个进程。

常见误区

误区一:进程并行,线程并发。

并发和并行描述的是执行关系,不是进程/线程的固定属性。单核上多个进程或线程都只能并发;多核上多个进程或线程都可能并行。

误区二:线程越多,性能越好。

线程适合掩盖 I/O 等待,也能利用多核;但线程过多会带来栈内存、调度、锁竞争和缓存失效。CPU 密集型任务通常更接近“核心数附近”的线程配置,I/O 密集型任务才可能需要更多并发执行单元。

误区三:进程之间完全不能共享内存。

默认隔离不等于不能共享。共享内存就是专门让多个进程映射同一块物理内存的 IPC 方式,只是程序员需要自己处理同步和生命周期。

误区四:Java 虚拟线程就是操作系统线程。

平台线程通常是 OS 线程的薄封装;虚拟线程由 Java 运行时调度,会挂载到平台线程上执行。它们都表现为 Thread,但资源模型和调度方式不同。