ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

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

一次 OOM 事故后,我把 Linux 进程与线程的区别彻底搞明白了 进程到底是什么运维视角看进程很多人对进程的理解停留在一个运行中的程序这句话没错但太抽象了。从运维的角度看进程是 Linux 内核管理的最小资源分配单位。这句话怎么理解我给你拆开说。每个进程在内核里都有一个task_struct结构体这个结构体你可以理解为进程的身份证里面记录了进程 IDPID父进程 IDPPID进程状态运行、睡眠、僵尸、停止虚拟内存地址空间打开的文件描述符信号处理方式进程所属的用户和组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 ProcessLWP内核并没有真正的线程概念。所谓线程其实就是和进程共享某些资源的 task_struct。后来 Linux 引入了 NPTLNative POSIX Thread Library线程的实现才真正成熟。但即便到现在Linux 内核里线程和进程在数据结构层面差别不大都是 task_struct。那线程和进程在 Linux 里的本质区别是什么是否共享地址空间和其他资源。进程之间地址空间隔离、文件描述符隔离、信号处理隔离。线程之间同一进程内的所有线程共享地址空间、文件描述符、信号处理、当前工作目录、用户和组 ID。但每个线程有自己的线程 IDTID线程局部存储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。生产中管道还常用于日志收集应用写日志到 FIFOFilebeat 从 FIFO 读、进程间传递简单数据。消息队列Message Queue内核维护一个消息链表进程可以往里写消息、读消息。经典的 sysv 消息队列、POSIX 消息队列以及现在更常见的 RabbitMQ、Kafka、RocketMQ 这些应用层消息队列。运维的痛点消息队列积压是常见故障。比如 Kafka consumer 挂了消息在 topic 里堆着越堆越多最后磁盘满了。共享内存Shared Memory多个进程映射同一块物理内存这是最快的 IPC 方式因为数据不需要在内核和用户空间之间拷贝。但也最危险因为没有同步机制。经典案例Oracle 数据库的 SGA 就大量使用共享内存多个进程共享数据缓冲区。我们运维有时候要配置kernel.shmmax、kernel.shmall这些参数就是给共享内存限额。信号Signal异步通知机制进程可以给另一个进程发信号。最常用的SIGTERM15优雅终止进程可以捕获做清理SIGKILL9强制终止进程无法捕获SIGHUP1传统上用于让守护进程重新加载配置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 列就是线程 IDNLWP 是线程数量。看到一个 Java 进程 NLWP 是 200就知道里面线程不少。top和htoptop -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 tracenetwork -p PID # 只跟踪网络相关调用排查进程卡住了不动的问题特别有效。看到进程一直在futex调用上循环多半是多线程锁竞争。看到一直在read但没数据多半是 IO 阻塞。pstack打印进程的线程栈。pstack PID等价于gdb -p PID -batch -ex thread apply all bt能看到每个线程当前在干嘛。Java 应用用 jstack 更详细会显示线程状态RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、锁信息、栈帧。lsoflsof -p PID # 进程打开的所有文件 lsof -i :80 # 占用 80 端口的进程 lsof -u username # 某个用户打开的文件排查端口被谁占用、文件句柄泄漏特别好用。pidstatsysstat包里的工具比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这种方式过滤把内核线程排除掉PPID2 的基本都是内核线程。写在最后进程和线程这些东西看着是基础理论但真正能讲清楚、能在生产中灵活运用是需要大量实战经验的。我以前觉得懂个 top、ps、free 就能干运维了直到遇到几次 OOM、几次 CPU 飙高、几次僵尸进程堆积才发现自己对 Linux 内核的理解远远不够。运维不是只会敲命令运维需要对系统有穿透式的理解从应用层到内核层从进程到线程到内存到 IO每一个环节出问题都可能引发故障。希望这篇文章能帮你建立起对进程和线程的清晰认知。下次再遇到类似的故障你能快速定位到是进程级的问题还是线程级的问题而不是像我当年一样乱敲命令。
返回列表