一次 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 要做很多事:
- 复制父进程的 task_struct
- 复制或写时复制页表
- 分配新的 PID
- 初始化文件描述符表
- 初始化信号处理表
- 加入调度队列
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)里,多线程是常态。
所以运维理解线程,更多是为了:
- 理解
top -H输出中线程的 CPU 占用 - 理解 jstack、pstack、strace 这些工具的输出
- 排查线程死锁、线程池打满等问题
- 理解为什么有些服务线程数不能开太多(线程栈默认 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.shmmax、kernel.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+,就知道里面线程不少。
top 和 htop
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/s、minflt/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,每一个环节出问题都可能引发故障。
希望这篇文章能帮你建立起对进程和线程的清晰认知。下次再遇到类似的故障,你能快速定位到是进程级的问题还是线程级的问题,而不是像我当年一样乱敲命令。
如果你觉得这篇文章对你有帮助,欢迎转发给身边做运维的兄弟。
公众号:耕云躬行录
个人博客:躬行笔记
关注我,持续分享生产一线的运维实战经验,拒绝八股文,只讲真东西。