运维知识
悠悠
2026年8月3日

一次 OOM 事故后,我把 Linux 进程与线程的区别彻底搞明白了

进程到底是什么?运维视角看进程

很多人对进程的理解停留在"一个运行中的程序",这句话没错,但太抽象了。

从运维的角度看,进程是 Linux 内核管理的最小资源分配单位。

这句话怎么理解?

我给你拆开说。

每个进程在内核里都有一个 task_struct 结构体,这个结构体你可以理解为进程的"身份证",里面记录了:

  • 进程 ID(PID)
  • 父进程 ID(PPID)
  • 进程状态(运行、睡眠、僵尸、停止)
  • 虚拟内存地址空间
  • 打开的文件描述符
  • 信号处理方式
  • 进程所属的用户和组
  • CPU 亲和性
  • 等等等等

这玩意在内核源码 include/linux/sched.h 里,几千行,运维不需要背,但你得知道它存在。

为什么要知道这个?

因为我们日常排查问题,很多工具的输出其实就是在解析这个结构体。

比如 cat /proc/PID/status,看到的信息全是从 task_struct 里取出来的。

再比如 ls -l /proc/PID/fd,看到的是这个进程打开的所有文件描述符,这也是 task_struct 里的一个链表。

所以进程在 Linux 内核里,不是一段代码,不是一个二进制文件,而是一个被内核精心管理的"资源包"。

每个进程都有自己的:

  • 独立的虚拟地址空间:进程 A 看到的内存地址 0x7f000000,和进程 B 看到的 0x7f000000,物理上可能是完全不同的内存页。这就是为什么一个进程崩溃不会影响另一个进程。
  • 独立的文件描述符表:进程 A 打开的 socket 句柄,和进程 B 的 socket 句柄互不干扰。
  • 独立的信号处理表:你给进程 A 发 SIGTERM,进程 B 不会有任何反应。
  • 独立的线程局部存储(TLS):虽然这个严格来说是线程级别的,但进程作为容器也参与管理。

举个生产中的实际例子。

我之前遇到过一个故障,某个 Java 应用启动后,运维同事去查连接数,发现 netstat -anp 出来一大堆 ESTABLISHED 的连接,但 lsof -p PID | wc -l 显示这个进程只打开了 200 多个 fd。

当时百思不得其解。

后来查 /proc/PID/fdinfo/ 目录才发现,很多连接是子进程继承的。父进程 fork 出子进程,子进程 fd 表是父进程的一份拷贝,复用了父进程已经建立的连接。

这就是进程"独立资源"的一个反面案例:fork 出来的子进程并不完全独立,它会继承父进程的大量资源。

理解这一点,对排查"为什么进程关掉了连接还在"、"为什么子进程继承了奇怪的句柄"这类问题特别有帮助。

线程是什么?别被"轻量级进程"忽悠了

线程这个概念,我以前一直觉得它和进程是平级的东西,是"进程内部的执行流"。

但这种理解太模糊。

从 Linux 内核的角度看,线程的实现方式在历史上是变过的。

早期的 Linux,线程被称为"轻量级进程"(Light Weight Process,LWP),内核并没有真正的线程概念。所谓线程,其实就是和进程共享某些资源的 task_struct。

后来 Linux 引入了 NPTL(Native POSIX Thread Library),线程的实现才真正成熟。但即便到现在,Linux 内核里线程和进程在数据结构层面差别不大,都是 task_struct。

那线程和进程在 Linux 里的本质区别是什么?

是否共享地址空间和其他资源。

进程之间:地址空间隔离、文件描述符隔离、信号处理隔离。

线程之间:同一进程内的所有线程,共享地址空间、文件描述符、信号处理、当前工作目录、用户和组 ID。但每个线程有自己的:

  • 线程 ID(TID)
  • 线程局部存储(TLS)
  • 栈空间(每个线程有独立的栈)
  • 寄存器上下文(包括 PC、SP 等)
  • 线程特有的信号掩码

这个区别决定了线程之间通信比进程之间通信效率高得多,因为它们天然共享内存。

但同时,这也带来了线程安全问题。

我在生产中遇到过这种案例:

某个 C++ 服务起了 16 个工作线程,没做任何锁保护,多个线程同时操作一个 std::unordered_map。跑得好好的,突然某天流量一上来,进程直接段错误,core dump 出来的栈完全随机,有时候在 insert,有时候在 erase。

这种就是典型的多线程数据竞争。unordered_map 在多线程下同时写,链表指针被搞坏了。

进程之间反而没这个问题,因为它们地址空间是隔离的。

进程与线程的核心区别,一次讲透

讲到这里,我把运维视角下最关心的几个区别给你列清楚。

资源分配的基本单位 vs CPU 调度的基本单位

这个是最经典的表述。

进程是资源分配的基本单位,因为内核在创建进程时要分配独立的地址空间、文件描述符表、信号处理表等。这些是"资源"。

线程是 CPU 调度的基本单位,因为内核调度器调度的是 task_struct,而同一个进程内的多个 task_struct 共享资源。内核并不区分"这是进程还是线程",它只调度 task_struct。

这个区别有什么用?

排查 CPU 飙高问题的时候,你就理解了为什么 top -H 能看到进程内每个线程的 CPU 占用。因为在线程模型下,每个线程都是一个独立的调度实体。

我经常用 top -H -p PID 来定位"进程 CPU 高,但不知道是哪个线程高"的场景。看到占用最高的线程 ID 后,转成十六进制,去 /proc/PID/stack 或者 jstack 输出里找对应的栈。

这就是利用了"线程是 CPU 调度基本单位"这个特性。

切换开销不同

进程切换的开销远大于线程切换。

为什么?

因为进程切换要切换页表(CR3 寄存器),刷新 TLB;要切换文件描述符表、信号处理表、namespace 等等。这些操作很重。

线程切换在同一进程内,页表不用换,TLB 不用刷,很多上下文可以直接复用。开销小一个数量级。

这个区别在生产中的影响是:

如果你的服务是 IO 密集型的,大量时间花在等待网络和磁盘上,那么用多线程模型是合适的,线程切换开销小。

如果你的服务是 CPU 密集型的,且需要严格隔离(比如不同业务线),用多进程模型更合适,进程隔离性好,一个进程崩了不影响别人。

Nginx 的设计就很有意思:master 进程管理 worker 进程,worker 进程之间相互独立。任何一个 worker 崩了,master 立刻拉起新的。

通信方式不同

进程之间通信,需要借助内核提供的机制:管道、消息队列、共享内存、信号、socket 等等。

线程之间通信,直接读写共享变量就行,因为它们天然共享地址空间。

但这并不意味着多线程通信就简单。

我在生产中见过太多因为多线程通信没处理好导致的诡异 bug:

  • 死锁:两个线程互相等对方释放锁,谁都动不了
  • 活锁:线程们互相谦让,一直做无用功
  • 资源竞争:上面提到的 unordered_map 崩溃
  • 内存可见性:一个线程改了变量,另一个线程看不到(CPU 缓存一致性)
  • 伪共享:两个线程的变量在同一个 cache line 里,互相影响性能

这些问题在单进程单线程模型下根本不会存在。

创建和销毁成本不同

创建一个新进程,Linux 要做很多事:

  1. 复制父进程的 task_struct
  2. 复制或写时复制页表
  3. 分配新的 PID
  4. 初始化文件描述符表
  5. 初始化信号处理表
  6. 加入调度队列

fork 一次,即便用上了 COW(写时复制),开销也不小。

我曾经在线上压测过,单纯 fork 空进程,每秒能创建大约 5 万个。

但如果用 pthread_create 创建线程,每秒可以创建几十万个。

差距是一个数量级。

这就是为什么高并发的服务(比如 Redis、Nginx worker)宁愿用单线程事件循环,或者有限的工作线程,也不愿意动不动就 fork 子进程。

不过这里要提一句,现代 Linux 的 vfork、posix_spawn、以及各种优化,让进程创建的开销已经小了很多。但相比线程,差距还是明显的。

进程和线程在生产中的实际应用场景

讲完区别,我给你讲讲生产中我们到底什么时候用进程,什么时候用线程。

用多进程的场景

  • Web 服务器(Apache 经典 prefork 模式、Nginx worker 模式):每个连接或每批连接一个进程,进程间完全隔离,安全性高
  • 数据库服务器(MySQL、PostgreSQL):为每个客户端连接维护一个独立进程,保证连接之间的隔离性
  • 浏览器多标签页:Chrome 早期每个标签页一个进程,一个标签页崩了不影响其他
  • 容器化部署的微服务:每个服务一个或多个进程,Kubernetes 管理进程生命周期
  • 需要强隔离的批处理任务:避免单个任务崩溃影响整个调度

用多线程的场景

  • 多线程下载器(迅雷、IDM):把文件分块,多个线程同时下载
  • Java 中的线程池:处理并发请求
  • 图像处理、视频编解码:把任务拆分成多块并行处理
  • 高并发的网络服务(Netty、Node.js 等):用少量线程处理大量连接
  • 科学计算:把大规模计算任务拆分到多线程并行执行

我们运维日常接触最多的,其实是多进程模型的 Web 服务和数据库。

比如我管的线上 Nginx,每个 worker 进程都是独立的。一个 worker 挂了,master 立刻拉起来,业务无感知。

但服务的业务代码(Java、Go、Python)里,多线程是常态。

所以运维理解线程,更多是为了:

  1. 理解 top -H 输出中线程的 CPU 占用
  2. 理解 jstack、pstack、strace 这些工具的输出
  3. 排查线程死锁、线程池打满等问题
  4. 理解为什么有些服务线程数不能开太多(线程栈默认 8M,开 1000 个线程就是 8G 内存)

进程间通信的几种方式,运维要知道

虽然我们运维主要用工具排查问题,但了解 IPC 机制对理解系统行为很有帮助。

管道(Pipe)

最古老的 IPC 方式。匿名管道只能用于有亲缘关系的进程(比如父子进程),命名管道(FIFO)可以用于任意进程。

运维中很常见,比如:

ps aux | grep nginx

这就是匿名管道,ps 的 stdout 通过管道传给 grep 的 stdin。

生产中,管道还常用于:日志收集(应用写日志到 FIFO,Filebeat 从 FIFO 读)、进程间传递简单数据。

消息队列(Message Queue)

内核维护一个消息链表,进程可以往里写消息、读消息。

经典的 sysv 消息队列、POSIX 消息队列,以及现在更常见的 RabbitMQ、Kafka、RocketMQ 这些应用层消息队列。

运维的痛点:消息队列积压是常见故障。比如 Kafka consumer 挂了,消息在 topic 里堆着,越堆越多,最后磁盘满了。

共享内存(Shared Memory)

多个进程映射同一块物理内存,这是最快的 IPC 方式,因为数据不需要在内核和用户空间之间拷贝。

但也最危险,因为没有同步机制。

经典案例:Oracle 数据库的 SGA 就大量使用共享内存,多个进程共享数据缓冲区。

我们运维有时候要配置 kernel.shmmaxkernel.shmall 这些参数,就是给共享内存限额。

信号(Signal)

异步通知机制,进程可以给另一个进程发信号。

最常用的:

  • SIGTERM(15):优雅终止,进程可以捕获做清理
  • SIGKILL(9):强制终止,进程无法捕获
  • SIGHUP(1):传统上用于让守护进程重新加载配置(Nginx、Apache 都用这个)
  • SIGUSR1/SIGUSR2:用户自定义信号,Nginx 用 SIGUSR1 重新打开日志

运维经常和信号打交道:

kill -HUP nginx_pid    # 让 Nginx 重新加载配置
kill -USR1 mysql_pid   # 让 MySQL 刷新日志

线程同步机制,生产中必须知道

线程同步和进程间通信是两回事,但目的类似:协调多个执行流对共享资源的访问。

互斥锁(Mutex)

最常用的同步原语,保证同一时间只有一个线程访问临界区。

但互斥锁用不好就是性能杀手。

我之前优化过一个服务,Java 写的,里面有个全局的 ConcurrentHashMap,锁竞争非常严重。线程数从 32 降到 8,性能反而提升了 30%。因为线程一多,锁竞争的开销就上来了,CPU 大部分时间花在等待锁上。

读写锁(Read-Write Lock)

读多写少场景的优化。多个线程可以同时读,但写的时候独占。

Java 的 ReentrantReadWriteLock、Linux 的 pthread_rwlock 都是这种。

条件变量(Condition Variable)

让线程等待某个条件成立。

经典的生产者-消费者模型就是用条件变量实现的。生产者往队列里放,消费者从队列里取,队列空的时候消费者阻塞,队列满的时候生产者阻塞。

我见过一个线上 bug,某个服务用条件变量实现任务队列,忘了处理"虚假唤醒"(spurious wakeup),导致消费者线程在队列空的时候被唤醒,去取任务,结果取到空指针,整个进程崩溃。

信号量(Semaphore)

控制同时访问某资源的线程数。

经典用途:数据库连接池。连接池大小是 20,就用初始值 20 的信号量,每取一个连接信号量减 1,每还一个加 1。信号量为 0 时,申请连接的线程阻塞。

进程线程相关工具,运维必备

最后讲讲工具,都是我日常排查问题真正用到的。

ps 命令

ps -ef                 # 查看所有进程
ps -eLf                # 查看所有进程及其线程(LWP 和 NLWP 列)
ps -T -p PID           # 查看指定进程的所有线程

ps -eLf 里的 LWP 列就是线程 ID,NLWP 是线程数量。看到一个 Java 进程 NLWP 是 200+,就知道里面线程不少。

tophtop

top -H                 # 显示线程
top -H -p PID          # 只看某个进程的线程

top -H 是定位 CPU 飙高线程的神器。配合 printf '%x\n' TID 把线程 ID 转十六进制,去 jstack 输出里搜,一找一个准。

pstree

显示进程树,能看到父子关系、init 进程、所有守护进程的层级。

生产中排查僵尸进程(defunct)特别有用。pstree -p 看到一堆 <defunct> 节点,说明有进程没被父进程回收,要找父进程。

/proc/PID/ 目录

Linux 的 procfs 是排查问题的宝库:

  • /proc/PID/status:进程状态、内存使用、线程数
  • /proc/PID/fd/:进程打开的文件描述符
  • /proc/PID/maps:进程的内存映射
  • /proc/PID/stack:内核栈(需要内核配置 CONFIG_STACKTRACE)
  • /proc/PID/task/:进程内所有线程的子目录

我经常用 /proc/PID/status 里的 VmRSS、VmSize、Threads 字段。

strace

跟踪进程的系统调用。

strace -p PID                  # 跟踪运行中的进程
strace -e trace=network -p PID # 只跟踪网络相关调用

排查"进程卡住了不动"的问题特别有效。看到进程一直在 futex 调用上循环,多半是多线程锁竞争。看到一直在 read 但没数据,多半是 IO 阻塞。

pstack

打印进程的线程栈。

pstack PID 等价于 gdb -p PID -batch -ex "thread apply all bt",能看到每个线程当前在干嘛。

Java 应用用 jstack 更详细,会显示线程状态(RUNNABLE、BLOCKED、WAITING、TIMED_WAITING)、锁信息、栈帧。

lsof

lsof -p PID              # 进程打开的所有文件
lsof -i :80              # 占用 80 端口的进程
lsof -u username         # 某个用户打开的文件

排查"端口被谁占用"、"文件句柄泄漏"特别好用。

pidstat

sysstat 包里的工具,比 top 更强大。

pidstat -p PID 1         # 每秒刷新指定进程的 CPU、内存、IO 统计
pidstat -t -p PID 1      # 包含线程

能看到上下文切换次数(cswch/s)、自愿上下文切换(nvcswch/s)、缺页中断(majflt/sminflt/s)等。

上下文切换异常高是性能问题的常见征兆。

一些容易踩的坑,血泪经验

讲讲我在生产中踩过的几个真实坑。

坑一:以为线程越多性能越好

某 Java 服务,CPU 32 核,运维同事觉得线程数开得越多越好,设成了 2000。

结果上线后性能反而下降,CPU 使用率高但 QPS 上不去。

原因:线程数远超 CPU 核数,大量时间花在上下文切换和锁竞争上。CPU 真正干活的时间很少,都在"切换-等待-切换"中浪费了。

经验:CPU 密集型服务,线程数 = CPU 核数 + 1 就够了。IO 密集型可以适当多一点,但也要测试。

坑二:忘了线程栈大小

某 Python 服务,开 500 个线程,内存用了快 4G。

原因:Python 线程栈默认 8M(虽然实际可能小一些),500 个线程光栈空间就 4G 了。再加上栈里的对象,内存爆炸。

经验:线程数大的时候,要调小线程栈(ulimit -s 或者编程语言里设置)。或者用协程替代线程。

坑三:父子进程的资源继承

某服务用 Python 多进程处理任务,父进程打开了一个网络连接监听某个端口,子进程继承了父进程的 fd。

后来父进程关闭了连接,但子进程还在用这个 fd 写数据,导致数据丢失。

经验:fork 之后,父子进程共享 fd 引用计数,但逻辑上是独立的。某个进程关闭 fd,不影响其他进程。某个进程写 fd,其他进程也能看到。这是常见的"为什么子进程写的数据丢了"的根因。

坑四:进程退出但端口还被占用

某服务异常崩溃,运维重启时发现"Address already in use"。

原因:进程虽然退出了,但子进程还活着,子进程继承了端口。或者 TIME_WAIT 状态的连接没释放。

经验:重启服务前,先用 pstree -p PID 看完整进程树,确保所有子进程都退了。或者加 SO_REUSEADDR 选项。

坑五:以为 ps 看到的进程数就是真实进程数

某机器 ps -e | wc -l 显示 200 多个进程,但实际跑的进程远不止这么多。

原因:内核线程(kthread)也在 ps 输出里。它们是内核自己用的,不占普通用户资源,但数量可能很多(特别是 IO 调度、网卡驱动、文件系统相关的线程)。

经验:看 ps -eLf 的时候,可以用 ps -eL | awk '$2==$5' 这种方式过滤,把内核线程排除掉(PPID=2 的基本都是内核线程)。

写在最后

进程和线程这些东西,看着是基础理论,但真正能讲清楚、能在生产中灵活运用,是需要大量实战经验的。

我以前觉得"懂个 top、ps、free 就能干运维了",直到遇到几次 OOM、几次 CPU 飙高、几次僵尸进程堆积,才发现自己对 Linux 内核的理解远远不够。

运维不是只会敲命令,运维需要对系统有"穿透式"的理解:从应用层到内核层,从进程到线程到内存到 IO,每一个环节出问题都可能引发故障。

希望这篇文章能帮你建立起对进程和线程的清晰认知。下次再遇到类似的故障,你能快速定位到是进程级的问题还是线程级的问题,而不是像我当年一样乱敲命令。


如果你觉得这篇文章对你有帮助,欢迎转发给身边做运维的兄弟。

公众号:耕云躬行录
个人博客:躬行笔记

关注我,持续分享生产一线的运维实战经验,拒绝八股文,只讲真东西。

文章目录

博主介绍

热爱技术的云计算运维工程师,Python全栈工程师,分享开发经验与生活感悟。
欢迎关注我的微信公众号@运维躬行录,领取海量学习资料

微信二维码