ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

TPC 模式详解、线程共享进程空间、与 PPC 的 close 为何不同

TPC 模式详解、线程共享进程空间、与 PPC 的 close 为何不同 TPC 模式详解、线程共享进程空间、与 PPC 的 close 为何不同原文里有一段话“TPC 和 PPC 相比主进程不用 close 连接了。原因是在于子线程是共享主进程的进程空间的连接的文件描述符并没有被复制因此只需要一次 close 即可。”这篇笔记拆三件事TPC 模式是什么和 PPC 一句话区别在哪。“主进程不用 close 了”—— 为什么把 PPC 必须双重 close 的根因和 TPC 只需一次 close 的根因都讲到内核层。“子线程共享主进程的进程空间”—— 这句话最容易被误解共享的到底是哪块是服务器的、“JVM 的还是堆外内存”答案是这三类说法都不对得重新分。关联文档[[prefork与PPC模式区别-Java中间件是否使用-Apache并发配置]]、[[fork之后为什么需要IPC-从文件描述符继承说起]]——TPC 和 PPC 的 close 差异根子就在 fork 复制 fd 表这件事上。一、TPC 模式是什么1. TPC Thread Per Connection每连接一线程主线程accept拿到一个连接后创建或从线程池取一个线程专门处理这个连接连接断开线程就回池或结束。客户端A 连进来 ── 主线程 accept ── 取/建 worker线程A 处理 ── 处理完回池/结束 客户端B 连进来 ── 主线程 accept ── 取/建 worker线程B 处理 ── 处理完回池/结束 全程只有一个进程内部开线程和 PPCProcess Per Connection每连接一进程对照唯一区别是用线程替代进程来承载单个连接维度PPC每连接一进程TPC每连接一线程承载单元进程fork线程pthread_create/ 线程池地址空间各自独立互不可见共享同一个进程地址空间创建成本fork复制页表/进程描述符重建线程只配栈轻约 1/10 量级单元间通信靠 IPC管道/共享内存/信号量直接读写共享内存天然可见并发上限受进程数/内存限制几千封顶受线程数限制但线程更轻、可上万close 连接主进程必须再 close 一次只需一次 close一句话TPC 是把 PPC 的进程换成线程因为线程共享进程空间省掉了 IPC、省掉了进程复制开销也顺带把双重 close的坑给消除了——下面重点讲这最后一点。二、“主进程不用 close 了” —— 为什么这是那段话里最值得拆的一句。要讲清楚得先回到 PPC 里为什么主进程必须 close。1. PPCfork 之后fd 被复制了所以必须双重 closefork()在内核里干的事简化fork() ├─ copy_files()子进程【复制一份新的】struct files_struct文件描述符表 │ ——注意是复制出新表不是共用一张表 └─ 表里每个 fd 项指向【同一个】struct file打开文件描述其引用计数 f_count关键点fork 出的子进程有自己独立的一张 fd 表但表里的每一项都指向内核里同一个struct file那个 accept 来的 socket。于是这个 socket 的引用计数从 1 变成2内核 struct file (socket, f_count2) ▲ ▲ │ │ 父进程 fd 表[fd4] ──┘ 子进程 fd 表[fd4] ──┘ 两张不同的表各占一个引用要真正释放这个连接让内核回收 socket、发 FIN 关闭 TCPf_count必须降到 0。也就是说子进程处理完close(fd)——f_count从 2 → 1socket 没真正关闭父进程也必须close(fd)——f_count从 1 → 0socket 才真正释放。这就是 [[fork之后为什么需要IPC-从文件描述符继承说起]] 里讲的fork 之后父进程必须关掉自己那份 accepted fd的根因——fd 被复制成了两份引用必须各关一次。父进程如果不关fd 就泄漏在父进程的表里连接永远关不掉最终撑爆 fd 上限。2. TPC线程共享 fd 表fd 只有一份引用一次 close 就够线程不是进程。创建线程Linux 下是clone(CLONE_VM | CLONE_FILES | ...)时内核不再复制files_struct——因为带了CLONE_FILES标志子线程直接共享父线程那张现成的 fd 表是同一张表、同一个指针没有任何复制clone(CLONE_FILES) └─ 不调 copy_files()task-files current-files ——新线程的 task-files 指向【同一张】struct files_struct于是 accept 到的那个 socket在整个进程里只有一条引用内核 struct file (socket, f_count1) ▲ │ 进程唯一的 fd 表[fd4] 主线程和 worker 线程看的是同一张表主线程把 fd其实是个小整数比如 4交给 worker 线程去处理——这连复制都谈不上就是传了个整数。worker 线程处理完close(fd)f_count直接从 1 → 0socket 当场释放。主线程从头到尾没多出来一份需要去关的东西自然不用 close。这就是原文说的连接的文件描述符并没有被复制因此只需要一次 close 即可。3. 一个容易混的点主线程不用 close ≠ 主线程不能碰准确说法是主线程没必要主动 close但也不能在 worker 还在用的时候抢先 close——因为表是共享的主线程要是close(fd4)worker 线程手里的 fd4 当场作废再去 read/write 就 EBADF 了。实际工程里的正确姿势是主线程只负责 accept 派发把 fd 的生命周期完全交给 worker 线程管理worker 处理完自己 close。这和 PPC 的姿势正好相反PPC 里父进程必须主动关掉自己的副本TPC 里主线程必须别去关。主进程/主线程对 accepted fd 的正确动作PPCfork 后必须主动 close自己那份副本否则泄漏TPC派发后不要 close交给 worker 线程worker 处理完 close三、“子线程共享主进程的进程空间” —— 到底共享哪块这句话被问得最多也最容易被带偏成共享服务器的内存、“共享 JVM”、“共享堆外内存”。这些说法都不准。重新分一下类。1. 准确表述共享的是进程级的资源最关键有两类线程同一进程内共享的是整个进程的虚拟地址空间 进程级的内核资源其中和这段话直接相关的是两个① 整个虚拟地址空间进程虚拟地址空间所有线程共享 ├─ 代码段 .text ← 共享同一份可执行代码 ├─ 数据段 .data/.bss ← 共享全局变量、静态变量 ├─ 堆 heap ← 共享malloc/new 出来的都在这所有线程都能访问 ├─ mmap 区 ← 共享内存映射文件、共享库、DirectByteBuffer 指向的堆外块 └─ 栈 stack ← 【不共享】每个线程有自己的栈默认 ~1MB② 内核级的文件描述符表struct files_struct这才是 close 那段话的真正主角每个进程【一张】files_struct所有线程共享这一张 └─ fd 表fd3 → stdin, fd4 → 那个 socket, ... ——这张表只有一份所以 fd 没有副本一次 close 即可2. 哪些是【不共享】的线程私有的别把共享进程空间理解成什么都共享线程私有说明线程栈每个线程独立栈局部变量、函数调用帧在这寄存器 / PC / 栈指针每个线程自己的执行上下文信号掩码 signal mask每线程可独立屏蔽信号但信号处理函数表是共享的errnoC 语境每线程一份避免互相覆盖线程局部存储 TLSThreadLocal这类机制存的就是这里3. 直接回答“服务器的JVM 的还是堆外内存”这三个都不是正确分类——“共享进程空间是操作系统进程级别的事实不依附于服务器”、JVM或堆外内存任何一个概念。逐个纠正❌ 不是服务器的服务器是个应用层概念一台机器、一个服务进程。共享发生在单个 OS 进程内部跟它是不是服务器、跑在哪台机器上无关。一个普通单进程程序里开两个线程照样共享进程空间。❌ 不特指JVM 的但 JVM 恰好是 TPC 的典型场景JVM 本身就是一个进程。如果你说的是 Java 程序那这个进程里所有的线程——Tomcat 请求线程、GC 线程、编译线程、Netty 的 NioEventLoop——都是这一个 JVM 进程的线程它们共享的确实是这个 JVM 进程的整个空间。但这只是恰好这个进程是个 JVM 进程不是共享 JVM 这个东西。而且 JVM 进程的地址空间里同时包含JVM 堆-Xmx管的那块由 GC 管理—— 共享Metaspace类元数据—— 共享Code CacheJIT 编译产物—— 共享线程栈每线程私有—— 不共享DirectByteBuffer / Netty 的 PooledByteBuf 指向的堆外内存通过 mmap/Unsafe 分配—— 这块也在进程地址空间里也是共享的所以线程共享进程空间在 JVM 语境下的精确含义是所有线程共享 JVM 进程的整个虚拟地址空间包括堆、Metaspace、Code Cache、堆外内存外加内核里那张 fd 表。❌ 不是堆外内存堆外内存off-heap / direct memory只是进程地址空间里的一块区域线程确实也共享它但它只是被共享的众多区域之一不是进程空间本身。把它单拎出来说共享的是堆外内存是典型的以偏概全。✅ 正确答案“子线程共享主进程的进程空间” 同一进程内的所有线程共享该进程的整个虚拟地址空间以及进程级的内核资源最关键的是文件描述符表struct files_struct。和这段话close 连接直接相关的是文件描述符表的共享——这是内核态的进程级结构既不在 JVM 堆里、也不在堆外内存里是内核为该进程维护的一张表。线程共享这张表所以 fd 不会被复制一次 close 就释放干净。4. 一张图理清 JVM 进程里共享 vs 私有JVM 进程OS 层一个进程 一个 task_struct 集合 一张 files_struct │ ├─【共享·进程级】 │ ├─ 虚拟地址空间 │ │ ├─ 代码段 / 数据段 ← 所有线程可见 │ │ ├─ JVM 堆-Xmx ← 所有线程可见GC 线程 业务线程都在这干活 │ │ ├─ Metaspace / Code Cache ← 所有线程可见 │ │ └─ 堆外内存mmap/Unsafe ← 所有线程可见Netty/Tomcat direct buffer 在这 │ ├─ 文件描述符表 files_struct ← 所有线程共享同一张 ★close 那段话的主角 │ ├─ 信号处理函数表、cwd、umask ← 所有线程共享 │ └─【私有·线程级】 ├─ 线程栈局部变量、调用帧 ← 每线程一份互不可见 ├─ 寄存器 / PC ← 每线程一份 ├─ 信号掩码 ← 每线程一份 └─ ThreadLocal / errno ← 每线程一份四、PPC vs TPCclose 行为对照表PPC每连接一进程TPC每连接一线程承载单元创建方式fork()clone(CLONE_FILES|CLONE_VM)/pthread_create内核是否复制 fd 表是copy_files复制新表否CLONE_FILES共享同一张socket 的f_countfork 后 2始终 1释放连接需要的 close 次数2 次父子各一1 次worker 线程 close 即可主进程/主线程该做的主动 close 自己那份副本不要 close交给 worker不这么做的后果fd 泄漏在父进程撑爆 fd 上限主线程抢先 close → worker 用 fd 报 EBADF根因一句话fd 表被复制成两份引用fd 表只有一份fd 没有副本五、Java 里的 TPCTomcat 就是Java 中间件基本都走线程模型见 [[prefork与PPC模式区别-Java中间件是否使用-Apache并发配置]] 的结论。其中最贴近 TPC 原始定义的就是早期 Tomcat 的 BIO ConnectorTomcat BIO Connector早期 ├─ Acceptor 线程阻塞 accept()拿到 Socket ├─ 交给线程池里一个 worker 线程处理thread-per-request └─ worker 处理完连接关闭、线程回池 → 一个请求/连接配一个线程正是 TPC 思路 → 主线程Acceptor从不 close socket由 worker 收尾 → 整个 Tomcat 是一个 JVM 进程所有 worker 线程共享进程空间与 fd 表现代 Tomcat 默认 NIO Connector 已经升级成Selector 多路复用 线程池事件驱动 少量线程扛大量连接但处理请求那一步仍然是 thread-per-request所以在连接/请求由线程承载、线程共享进程空间这个本质上没变仍是 TPC 家族只是不再一个连接独占一个线程到 keepalive 结束而已。对比 PPC 阵营的 Apache prefork / PHP-FPM多进程 每进程一连接就能看清两条技术路线分野的根源进程隔离但通信贵、fd 要双重 close线程共享但通信直接、fd 一次 close。Java 选了后者。六、一句话回答最初的三个问题TPC 是什么每连接一线程的并发模型主线程 accept 后派 worker 线程处理全程单进程。和 PPC 的唯一区别是把进程换成线程。为什么主进程不用 close 了PPC 里fork会复制一份文件描述符表socket 的内核引用计数变 2父子必须各 close 一次才释放TPC 里线程带CLONE_FILES共享同一张 fd 表socket 引用计数始终是 1worker 线程 close 一次就降到 0、连接释放主线程没有副本要关。共享的到底是哪块是操作系统进程级别的共享——所有线程共享该进程的整个虚拟地址空间堆、代码、数据、mmap、堆外内存都在内以及进程级内核资源最关键的是文件描述符表struct files_struct。它不是服务器的、不特指JVM 的、更不是堆外内存fd 表是内核态结构既不在 JVM 堆也不在堆外。在 Java 语境下这个进程恰好是 JVM 进程所以所有线程共享 JVM 堆、Metaspace、Code Cache、堆外内存以及那张 fd 表——但共享进程空间指的是进程这一层不等于其中任何一块。
返回列表