找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

3673

积分

0

好友

485

主题
发表于 3 小时前 | 查看: 4| 回复: 0

前言

有人问我:Redis 为什么单线程能跑出 10 万 QPS?Nginx 为什么比 Apache 快那么多?

这些问题的答案,藏在服务器架构三十年的演进史里。

从最早的“来一个请求开一个进程”,到今天的协程,每一次架构升级都是被现实逼出来的。理解这个演进过程,你就真正理解了高性能服务器的本质。

想深入探究后端 & 架构的核心设计思想,不妨先从这条进化之路开始。

我们从头说起。

一、单线程模型:能跑,但只能同时接一个人

最原始的服务器长这样:

int server_fd = create_listen_socket(8080);
while (1) {
    int client_fd = accept(server_fd, NULL, NULL);
    handle_request(client_fd);  // 处理完才能接下一个
    close(client_fd);
}

逻辑清晰,代码简单,但有个致命缺陷:handle_request 是阻塞的。

一个客户端在传文件,后面 999 个连接只能排队等。并发数=1,这不是服务器,是单人窗口排号机。

二、多进程模型:来一个客户,fork 一个儿子

为了解决并发,最直观的思路是:来一个连接,开一个进程处理它

Apache 早期就是这么干的,叫 prefork 模型。

while (1) {
    int client_fd = accept(server_fd, NULL, NULL);
    if (fork() == 0) {
        // 子进程:处理这个连接
        close(server_fd);
        handle_request(client_fd);
        exit(0);
    }
    // 父进程:继续等下一个连接
    close(client_fd);
}

并发问题解决了,但新问题来了:

进程太重了。每个进程独立的地址空间、文件描述符表、页表……光是 fork 一次的开销就不小,1000 个并发就是 1000 个进程。内存和 CPU 上下文切换的代价,随并发数线性膨胀。

这就是著名的 C10K 问题的根源之一——想撑住一万个并发连接,靠进程根本扛不住。

多进程并发模型架构图:父进程accept+fork派生子进程,每个子进程处理对应客户端连接,展示内存开销随连接数线性膨胀

三、多线程模型:同一屋檐下,共享内存

既然进程太重,换成线程怎么样?

线程共享同一进程的地址空间,创建和切换比进程轻很多。于是有了“一连接一线程”的方案,后来又演进成线程池——预先创建好一批线程,来了任务往里塞。

// 线程池简化版
ThreadPool pool(100);  // 预创建 100 个线程
while (1) {
    int client_fd = accept(server_fd, NULL, NULL);
    pool.submit([client_fd]() {
        handle_request(client_fd);
    });
}

比多进程好很多,但本质问题没变:

每个线程在等待 I/O 时是阻塞的。 1000 个连接,哪怕 990 个都在等网络数据,也得占着 990 个线程傻等。线程数一多,内核调度开销就上来了,而且每个线程还有独立的栈(默认 8MB),内存压力依然存在。

多线程模型的瓶颈,本质上是用线程来承载“等待”这件事,太浪费了。

四、Reactor 模型:只干活,不等待

这是高性能服务器真正的革命。

核心思想只有一句话:不要让线程去等 I/O,让 I/O 就绪了再通知线程来处理。

这就是 Reactor 模式,也叫事件驱动模型。

整个模型由三个角色构成:

事件分发器(Dispatcher):用 epoll 监视所有 fd,哪个 fd 有事件就通知对应的 Handler。

Handler:处理具体的业务逻辑,读数据、写响应。

Acceptor:专门处理新连接的到来。

事件循环处理流程架构图:左侧输入就绪事件,中心epoll_wait分发,右侧Handler处理读写,底部一个线程管理所有连接

核心骨架代码就是这样:

// Reactor 核心循环(伪代码)
while (1) {
    int n = epoll_wait(epfd, events, 64, -1); // 等事件
    for (int i = 0; i < n; i++) {
        int fd = events[i].data.fd;
        if (fd == listen_fd)
            acceptor_handle(fd);  // 新连接
        else
            handler_dispatch(fd); // 读写事件
    }
}

Nginx 就是这个架构:一个 worker 进程跑一个事件循环,用 epoll 管理数以万计的连接,哪个连接有数据来才去处理它,其余时间全在 epoll_wait 里睡觉——不占 CPU,不浪费线程。

这正是 Nginx 吊打 Apache 的根本原因。

五、Multi-Reactor:多核时代的进化

单个 Reactor 再猛,也只跑在一个 CPU 核上,没有利用多核优势。

于是有了 Multi-Reactor(也叫主从 Reactor):一个 Main Reactor 专门接受新连接,多个 Sub-Reactor 各自跑一个事件循环处理连接的读写。

这就是 Nginx 多 worker 进程、Netty 多 EventLoop 的本质。

Main Reactor(主线程)
    └─ epoll 监听 listen_fd
    └─ 新连接来了 → 分发给某个 Sub Reactor

Sub Reactor 0(线程0)     Sub Reactor 1(线程1)
    ├─ conn1                   ├─ conn3
    ├─ conn2                   └─ conn4
    └─ ...                      ...

负载均衡、多核并行,连接数到百万级也能扛。

六、Reactor 的隐痛:回调地狱

Reactor 模型有个绕不开的痛点:业务逻辑被打碎成一堆回调函数

想象一个请求要做三件事:读请求 → 查数据库 → 写响应。

在多线程里,直来直去:

// 多线程:直线逻辑,清晰
read(fd, buf);
result = db_query(buf);
write(fd, result);

在 Reactor 里,就变成了这样:

// Reactor:逻辑被拆散成回调
on_readable(fd, [](fd) {
    read(fd, buf);
    db_query_async(buf, [](result) {
        write(fd, result);
        // 还有更多嵌套...
    });
});

嵌套层数一多,就是臭名昭著的“回调地狱”——代码难以维护,调试更是噩梦。

这个问题,协程来解决。

七、协程:鱼和熊掌都要

协程的终极目标是:用同步的写法,达到异步的性能。

原理只有一句话:遇到 I/O 等待时,不阻塞线程,而是“挂起”当前协程,把 CPU 让出去执行其他协程;I/O 就绪后再“恢复”回来继续跑。

这个挂起和恢复,发生在用户态,不需要内核介入,开销极低。

多协程在同一线程中的时间轴调度图:协程A、B、C交替运行,等待I/O时挂起让出CPU,展示CPU始终在工作无空转

用协程写服务器,业务代码能保持多线程的直线逻辑,但底层自动做协程切换:

// 协程风格:看起来是阻塞的,底层是非阻塞的
co_await read(fd, buf);        // 挂起,让出 CPU
result = co_await db_query(buf); // 挂起,让出 CPU
co_await write(fd, result);    // 挂起,让出 CPU
// 代码像同步,性能像异步

这就是为什么 Go 语言的 goroutine 能让程序员用同步的写法写出高并发的程序——底层正是协程调度的功劳。微信的 libco、腾讯的 fiber,也都是基于这个原理。

八、五代架构终极对比

架构 并发能力 内存开销 代码复杂度 代表项目
单线程 1 极低 最简单 脚本服务
多进程 百级 极高(8MB/进程) 简单 Apache prefork
多线程池 千级 高(8MB/线程) 中等 Apache worker
Reactor 万~百万级 极低 高(回调) Nginx, Redis
协程 万~百万级 极低(KB级/协程) 中等 Go, libco

九、高频面试题精析

Q:Redis 单线程为什么这么快?

数据库/中间件/技术栈 里的 Redis,其网络层就是单线程 Reactor——一个事件循环用 epoll 管理所有连接。它快的根本原因:① 操作全在内存,没有磁盘 I/O;② 命令处理时间极短(微秒级),不会长期占用 CPU;③ 不需要多线程,没有锁竞争开销。单线程 + epoll + 内存操作,三者叠加,就是 10 万 QPS 的底气。(Redis 6.0 引入了多线程处理网络 I/O,但命令执行仍是单线程。)

Q:Nginx 为什么比 Apache 快?

本质是架构差异:Apache 传统上用多进程/多线程模型(一连接一线程),线程数一多,切换和内存开销爆炸;Nginx 用 Multi-Reactor,每个 worker 进程跑一个事件循环,一个 worker 能管上万连接,几乎没有上下文切换,内存占用极低。

Q:协程和线程的本质区别?

线程切换由内核调度,需要陷入内核态,保存/恢复完整的 CPU 上下文,开销约数微秒;协程切换在用户态完成,只保存/恢复少量寄存器,开销在纳秒级。一个线程可以跑成千上万个协程,协程等 I/O 时让出 CPU 给别的协程,不浪费任何资源。

Q:什么场景用多线程,什么场景用协程?

CPU 密集型任务(加密、压缩、编解码)→ 多线程,充分利用多核并行。I/O 密集型任务(网络请求、数据库查询、文件读写)→ 协程,等待期间不浪费 CPU。绝大多数服务器业务是 I/O 密集型,协程优势明显。

结语

从单线程到协程,每一次架构升级背后都有一个核心矛盾在驱动:

  • 多进程解决了“一次只能处理一个连接”的问题,代价是内存爆炸
  • 多线程减轻了内存压力,但线程切换开销随并发线性增长
  • Reactor 让线程彻底从“等待”中解放出来,一个线程管万连接
  • 协程在 Reactor 高性能基础上,还消灭了回调地狱,让代码再次可读

这条路走下来,本质上就是一件事:不断减少“等待”对资源的浪费

理解了这一点,你就理解了所有高性能服务器的设计哲学。

也希望云栈社区能成为你深入技术路上的一个讨论角落。




上一篇:Linux内存管理:Buffer/Cache是内存泄漏?教你正确看free命令
下一篇:epoll 原理详解:从 select 到 epoll,Linux 高性能 I/O 模型演进之路
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-6-5 04:21 , Processed in 0.736921 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表