进程、线程、协程:后端服务到底是怎么处理并发请求的?

在上一篇 《理解并发:一个 HTTP 请求到后端到底发生了什么?》 我们从一个 HTTP 请求出发,理解了并发最基本的概念:

HTTP 请求
    ↓
Web Server
    ↓
后端服务
    ↓
CPU / IO
    ↓
MySQL / Redis / RPC
    ↓
HTTP 响应

但这里留下了一个非常重要的问题:

后端服务到底由谁来执行这些请求?

  • 如果同时来了 1000 个 HTTP 请求,服务器是不是创建 1000 个进程?

  • 如果不是,是不是创建 1000 个线程?

  • 如果也不是,那么一个线程又是怎么处理大量请求的?

要回答这些问题,就必须理解后端程序最核心的三个执行概念:

进程
线程
协程

1. 先建立一个整体认识

可以先把三者理解成三个不同层次的概念:

进程
  │
  ├── 线程
  │     │
  │     ├── 协程
  │     ├── 协程
  │     └── 协程
  │
  └── 线程

但这里有一个需要特别注意的地方:

协程并不是操作系统进程或线程的同级概念。

进程和线程主要属于操作系统调度与资源管理层面;协程通常是由运行时、语言、框架或用户态库提供的更轻量级执行机制。

因此:

进程
    → 操作系统资源隔离与管理单位

线程
    → 操作系统调度的执行单位

协程
    → 用户态的轻量级执行/挂起/恢复机制

具体实现会因语言和运行时不同而不同。

例如 Go 的 goroutine、Python 的 coroutine、Swoole 的 coroutine,并不能简单认为底层实现完全一样。


2. 什么是进程?

进程(Process)可以简单理解为:

一个正在运行的程序实例,以及它所拥有的一组运行资源。

例如启动一个 PHP-FPM Worker:

PHP-FPM
   │
   ├── Worker Process 1
   ├── Worker Process 2
   ├── Worker Process 3
   └── Worker Process 4

每个进程拥有相对独立的:

虚拟地址空间
文件描述符
进程状态
资源上下文

一个进程内部可以包含多个线程。

例如:

Process A
 ├── Thread 1
 ├── Thread 2
 └── Thread 3

Process B
 ├── Thread 1
 └── Thread 2

进程之间通常具有较强的资源隔离性。

因此,如果一个进程崩溃:

Process A ×

一般不会直接导致:

Process B
Process C

同时崩溃。

这也是多进程模型非常重要的优势之一。


3. 什么是线程?

线程(Thread)是操作系统进行 CPU 调度的重要执行单位。

一个进程可以包含多个线程:

Process
   │
   ├── Thread A
   ├── Thread B
   └── Thread C

同一个进程中的线程通常共享:

代码
堆
全局数据
打开的文件等进程资源

但每个线程拥有自己的:

栈
寄存器状态
程序执行位置

因此线程比进程轻量,创建和切换成本通常也低于进程,但线程之间共享资源也意味着:

线程安全问题更加重要。

例如两个线程同时修改共享数据:

Thread A ──┐
           ├── Shared Data
Thread B ──┘

如果没有正确同步,就可能产生:

竞态条件
数据竞争
死锁

因此常见的线程同步机制包括:

Mutex
Semaphore
Condition Variable
Read/Write Lock
Atomic Operation

4. 进程、线程和 HTTP 请求是什么关系?

现在重新回到 Web 服务。

假设服务器收到:

100 个 HTTP 请求

最简单的处理方式之一是:

Request 1 → Process 1
Request 2 → Process 2
Request 3 → Process 3
...

但如果真的为每个请求创建一个进程:

1000 Requests
    ↓
1000 Processes

通常代价太高。

所以实际系统会复用已经存在的执行资源。

例如多进程模型:

               100 HTTP Requests
                       │
                       ▼
                 Process Pool
                 ┌─────┬─────┬─────┐
                 │ P1  │ P2  │ P3  │
                 └─────┴─────┴─────┘

或者线程池:

               100 HTTP Requests
                       │
                       ▼
                  Thread Pool
             ┌────┬────┬────┬────┐
             │ T1 │ T2 │ T3 │ T4 │
             └────┴────┴────┴────┘

又或者协程模型:

               100 HTTP Requests
                       │
                       ▼
                    Threads
                       │
              ┌────────┴────────┐
              ▼                 ▼
          Coroutine          Coroutine
          Coroutine          Coroutine
          Coroutine          Coroutine

所以:

HTTP 请求和进程、线程、协程之间不是一一对应关系。

真正的对应关系取决于服务运行时采用的并发模型。


5. PHP-FPM:典型的多进程模型

PHP-FPM 是理解 PHP 并发模型非常好的例子。

典型架构:

Client
   │
   ▼
Nginx
   │
   ▼
PHP-FPM
   │
   ├── Worker 1
   ├── Worker 2
   ├── Worker 3
   └── Worker 4

PHP-FPM 会维护多个 PHP worker 进程。

当 HTTP 请求进入后:

Request
   ↓
Nginx
   ↓
PHP-FPM
   ↓
某个 Worker
   ↓
执行 PHP 代码
   ↓
返回结果

一个 worker 在处理一个请求时,通常会持续执行这个请求直到完成。

例如:

Worker 1 → Request A
Worker 2 → Request B
Worker 3 → Request C
Worker 4 → Request D

如果 4 个 worker 都在处理请求:

Request E
    ↓
等待

直到有 worker 空闲。

因此,PHP-FPM 的一个非常直观的并发模型是:

并发能力
≈
可用 Worker 数量

当然,真实系统还受到 CPU、IO、数据库连接、内存、Web Server 等因素影响,不能简单用 worker 数量定义系统最终吞吐能力。


6. 为什么传统线程模型也可以处理并发?

很多语言的 Web 服务会使用线程池。

例如:

HTTP Requests
       │
       ▼
   Thread Pool
       │
 ┌─────┼─────┐
 ▼     ▼     ▼
 T1    T2    T3

假设线程池有:

100 Threads

同时有:

1000 Requests

那么并不是 1000 个请求同时占用 1000 个线程。

可能是:

100 Requests
   ↓
100 Threads 执行

900 Requests
   ↓
Queue / 等待

一个线程处理:

Request A
    ↓
MySQL
    ↓
等待

如果采用传统阻塞式 IO,那么线程可能会在等待期间被阻塞。

这意味着:

Thread
   ↓
执行
   ↓
MySQL
   ↓
阻塞等待

线程依然被占用。

如果大量请求都在等待 IO:

100 Threads
    ↓
100 Threads 都在等待

新的请求就只能继续排队。

因此线程池大小是非常重要的资源参数。


7. 协程到底解决了什么问题?

协程的核心价值可以先用一句话概括:

让程序能够以更低的成本创建大量轻量级执行上下文,并在合适的时机主动挂起和恢复。

假设一个请求:

Request A
   ↓
查询 MySQL
   ↓
等待

如果是传统阻塞线程模型:

Thread A
   ↓
MySQL
   ↓
阻塞

而在支持协程和异步 IO 的运行时中,可以实现:

Coroutine A
    ↓
MySQL
    ↓
挂起
    ↓
Event Loop
    ↓
处理 Coroutine B
    ↓
处理 Coroutine C
    ↓
处理 Coroutine D
    ↓
MySQL 返回
    ↓
恢复 Coroutine A

于是:

一个线程
    ↓
Coroutine A
Coroutine B
Coroutine C
Coroutine D
...

就可以同时管理大量 IO 密集型任务。

这里最关键的不是“协程比线程快”这么简单。

真正重要的是:

协程让大量 IO 等待可以被更高效地组织和调度。


8. Swoole / Hyperf 的并发模型

对于 PHP 开发者来说,这也是最值得理解的一部分。

传统 PHP-FPM:

Nginx
   ↓
PHP-FPM
   ↓
多个 Worker Process
   ↓
执行 PHP 请求

而 Swoole/Hyperf 的运行方式不同。

可以粗略理解为:

                HTTP Server
                     │
                     ▼
               Event Loop
                     │
          ┌──────────┼──────────┐
          ▼          ▼          ▼
      Coroutine  Coroutine  Coroutine
          │          │          │
          ▼          ▼          ▼
        MySQL      Redis       HTTP
          │          │          │
          └──────────┴──────────┘
                     │
                     ▼
               Event Loop

当协程执行到支持协程化的 IO 操作时:

Coroutine A
    ↓
IO
    ↓
挂起

运行时可以让其他协程继续执行:

Coroutine A → 等待 IO

Coroutine B → 执行业务

Coroutine C → 查询 Redis

Coroutine D → 处理 HTTP 请求

IO 完成之后:

Coroutine A
    ↓
恢复执行

因此,在 IO 密集型 Web 服务中,协程模型可以显著减少大量线程阻塞等待所带来的资源浪费。

但必须强调:

协程并不会凭空增加 CPU 计算能力。

如果代码一直执行 CPU 密集型任务:

while (true) {
    // 大量 CPU 计算
}

那么即使使用协程,也不会因为“用了协程”就自动获得更高性能。

因为:

CPU 计算
    ↓
持续占用执行线程
    ↓
其他协程也无法凭空获得更多 CPU

所以协程最适合解决的是:

大量 IO 等待

而不是:

无限 CPU 计算

9. Node.js 为什么一个线程也能处理很多请求?

Node.js 是另一个非常典型的例子。

它的核心模型可以简化成:

HTTP Requests
      │
      ▼
Event Loop
      │
 ┌────┼────┐
 ▼    ▼    ▼
 A    B    C

Node.js 使用事件循环处理大量网络 IO。

例如:

const data = await db.query(...);

在异步 IO 模型下,等待数据库期间并不需要让 JavaScript 主线程一直“卡住”。

可以理解为:

Task A
  ↓
DB Query
  ↓
等待
  ↓
Event Loop
  ↓
Task B
  ↓
Task C

当数据库返回:

DB Response
     ↓
Event Loop
     ↓
继续执行 Task A

这就是事件驱动并发模型。

不过“Node.js 是单线程”也是一个容易误解的说法。

更准确地说:

Node.js 的 JavaScript 执行通常以单个主线程上的 Event Loop 为核心,但 Node.js 进程本身并不等于只有一个操作系统线程。

Node.js 运行时还可能涉及其他线程,例如处理某些异步任务的线程池。

因此不能把:

Node.js = 一个线程

当成严格的运行时实现描述。


10. 三种模型应该怎么选择?

现在可以把三种模型放在一起:

模型基本执行单位并发特点典型场景
多进程Process隔离性强PHP-FPM
多线程Thread共享内存、并发执行Java/.NET 等
协程Coroutine用户态轻量调度Swoole/Hyperf、部分异步运行时

更准确地说,现代 Web 服务往往不是简单的“三选一”。

实际架构通常是组合:

多进程
   ↓
多线程
   ↓
Event Loop
   ↓
协程
   ↓
异步 IO

例如一个服务可能是:

多个进程
    │
    ├── Event Loop
    │      ├── Coroutine A
    │      ├── Coroutine B
    │      └── Coroutine C
    │
    └── Event Loop
           ├── Coroutine D
           ├── Coroutine E
           └── Coroutine F

因此讨论“哪个模型最好”通常没有意义。

真正应该问的是:

当前业务的并发模式是什么?CPU 密集还是 IO 密集?请求量是多少?下游资源是什么?运行时提供什么能力?

例如:

CPU 密集型

更应该关注:

CPU 核心数
进程/线程并行度
任务拆分
计算效率

IO 密集型

更应该关注:

异步 IO
IO 多路复用
协程
连接池
超时
下游服务容量

总结

理解进程、线程和协程,可以建立这样一个模型:

进程
    ↓
提供资源隔离
    ↓
线程
    ↓
承担操作系统级执行
    ↓
协程
    ↓
在用户态组织大量轻量级任务
    ↓
IO 多路复用 / Event Loop
    ↓
高效处理大量 IO 等待

它们解决的问题并不完全相同。

进程重点解决资源隔离和独立运行问题。

线程是操作系统重要的执行和调度单位,同一进程中的线程可以共享大量资源。

协程提供更加轻量的执行上下文和挂起/恢复机制,特别适合组织大量 IO 密集型任务。

对于 Web 后端,可以简单形成这样的认知:

PHP-FPM
    → 多进程

Java / .NET
    → 线程池 + 异步 IO 等模型

Node.js
    → Event Loop + 非阻塞 IO

Swoole / Hyperf
    → Event Loop + 协程 + 非阻塞 IO

但这些只是便于理解的概括,并不是完整的运行时实现描述。

最重要的是不要形成下面这些错误认识:

❌ 一个 HTTP 请求 = 一个线程

❌ 一个 HTTP 请求 = 一个进程

❌ 协程 = 更快的 CPU

❌ Node.js = 整个程序只有一个线程

❌ 协程可以解决所有高并发问题

更准确的理解是:

后端并发模型,本质上是在决定“如何承载、调度和执行大量同时存在的任务”。