4. 单进程单线程服务器模型:最简单的echo服务器实现、阻塞I/O的痛点、为什么无法处理并发?

好,咱们进入正题。上一章我们把端口复用的概念讲清楚了,但很多同学会问:“为什么需要端口复用?一个进程一个端口不是挺好吗?”

嗯,这个问题问得好。要回答它,我们得先看看最原始的服务器模型——单进程单线程。说白了,就是一个程序从头跑到尾,一次只干一件事。

我刚开始学网络编程时,写的第一个服务器就是这种模型。当时觉得挺酷的,一个程序就能跟客户端聊天。但后来线上出过一次事故,我才意识到这玩意儿有多脆弱。

4.1 最简单的echo服务器实现

先看代码。一个echo服务器,客户端发什么,它就原样返回什么。这是最基础的网络编程入门示例。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>

#define PORT 8080
#define BUFFER_SIZE 1024

int main() {
    int server_fd, client_fd;
    struct sockaddr_in address;
    int opt = 1;
    int addrlen = sizeof(address);
    char buffer[BUFFER_SIZE] = {0};

    // 1. 创建socket
    server_fd = socket(AF_INET, SOCK_STREAM, 0);

    // 2. 绑定端口
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(PORT);
    bind(server_fd, (struct sockaddr *)&address, sizeof(address));

    // 3. 开始监听
    listen(server_fd, 3);

    printf("Echo server listening on port %d\n", PORT);

    while (1) {
        // 4. 接受连接 —— 这里会阻塞
        client_fd = accept(server_fd, (struct sockaddr *)&address,
                           (socklen_t*)&addrlen);

        // 5. 读取数据 —— 这里也会阻塞
        read(client_fd, buffer, BUFFER_SIZE);
        printf("Received: %s\n", buffer);

        // 6. 原样返回
        send(client_fd, buffer, strlen(buffer), 0);
        printf("Echo message sent\n");

        // 7. 关闭连接
        close(client_fd);
        memset(buffer, 0, BUFFER_SIZE);
    }

    close(server_fd);
    return 0;
}

这段代码逻辑很直白:创建socket、绑定、监听,然后进入死循环。每次循环里,accept() 等客户端连上来,read() 等客户端发数据,处理完就关闭连接。

个人习惯:我写demo时喜欢加上 SO_REUSEADDR,这样程序崩溃重启时不会报“Address already in use”。虽然本章讲的是单进程模型,但这个选项我建议你养成习惯加上。

4.2 阻塞I/O的痛点

上面这个程序,看起来没问题对吧?但实际跑起来,你会发现一个严重的问题。

阻塞点一:accept() 阻塞

当没有客户端连接时,accept() 会一直卡在那里。程序什么都干不了,就干等着。

阻塞点二:read() 阻塞

就算有客户端连上了,如果它半天不发数据,read() 也会一直等。这时候如果有第二个客户端想连上来,对不起,得排队。

我举个例子你就明白了。假设有两个客户端:

时间点 客户端A 客户端B 服务器状态
T1 连接成功 — accept() 返回,进入 read()
T2 正在输入数据 尝试连接 read() 阻塞,无法 accept()
T3 还在输入 连接请求被内核接受,但服务器没处理 read() 仍然阻塞
T4 发送数据 等待中 read() 返回,处理A,然后才能 accept() B

看到了吗?客户端B明明已经连上来了,但因为服务器卡在 read() 上,B只能干等。这就是阻塞I/O的典型问题——一个慢客户端拖死所有客户端。

我曾经踩过的坑:有一次我写了个文件上传服务,用的就是这种单进程模型。结果有个客户端上传了一个大文件,网络还特别慢,其他所有客户端都超时了。线上报警响成一片,我查了半天才发现是阻塞I/O惹的祸。从那以后,我再也不敢在生产环境用这种模型了。

4.3 为什么无法处理并发?

好,现在我们来深挖一下根本原因。为什么单进程单线程模型处理不了并发?

说白了,就一句话:CPU的执行流是单线的,而I/O操作会卡住这条线。

你想想看,一个CPU核心在同一时刻只能执行一个指令序列。当程序执行到 read() 系统调用时,会发生什么?

  1. 程序从用户态切换到内核态
  2. 内核检查socket接收缓冲区是否有数据
  3. 如果没有数据,内核把当前进程标记为“睡眠”状态
  4. CPU去执行其他进程(如果有的话)
  5. 当数据到达时,内核把进程唤醒,切换回用户态继续执行

问题就出在第3步和第4步。进程一旦“睡眠”,它就不会再执行任何代码了。而我们的服务器只有一个进程,所以整个服务就停了。

更形象一点,你可以把服务器想象成一个收银员:

  • 单进程单线程 = 只有一个收银员,一次只能服务一个顾客。如果这个顾客掏钱很慢,后面的人只能排队等着。
  • 多进程/多线程 = 多个收银员,可以同时服务多个顾客。但每个收银员还是会被慢顾客卡住。
  • 非阻塞I/O + 事件驱动 = 一个收银员,但眼观六路耳听八方。谁准备好了就服务谁,不等任何人。

这个比喻虽然简单,但很能说明问题。我们后面要讲的端口复用,本质上就是第三种思路的体现。

核心结论:单进程单线程模型无法处理并发的根本原因,不是CPU不够快,而是I/O操作会阻塞执行流。一个慢I/O操作会让整个服务停滞,无法响应其他客户端。

那有没有办法解决呢?当然有。最简单的办法就是多开几个进程或线程,每个处理一个连接。但这样会带来新的问题:资源开销大、上下文切换频繁、共享数据需要加锁……

另一种思路,就是保持单进程,但让I/O不再阻塞。这就是我们后面要讲的 I/O多路复用(select、poll、epoll)和 非阻塞I/O。而端口复用,正是这些高级机制能够高效工作的基础。

嗯,先不急着往后讲。我们先把这张图看清楚,理解单进程模型的局限性,后面学高级模型时才能明白它们到底解决了什么问题。

单进程单线程服务器模型:阻塞I/O的痛点 服务器进程(单线程) while(1) 循环 阻塞点1: accept() 等待客户端连接 阻塞点2: read() 等待客户端数据 处理请求 (echo返回) close() 回到循环开始 问题:任何一个阻塞点都会卡住整个进程,导致无法同时处理多个客户端连接

这张图把整个流程画得很清楚了。两个红色阻塞点就像两个关卡,任何一个卡住,后面的代码都执行不了。而我们的服务器只有一个线程,所以一次只能处理一个客户端。

你可能会想:“那我多开几个进程不就行了?”

没错,多进程确实能解决问题。但每个进程都有自己的地址空间,创建开销大,进程间通信也麻烦。而且如果并发量上万,光是创建进程就能把系统资源吃光。

另一种思路是使用多线程。线程比进程轻量,但共享内存需要加锁,一不小心就出死锁或者数据竞争。我见过不少线上事故,都是多线程同步没做好导致的。

所以,真正高效的方案,是在单线程内解决阻塞问题。怎么解决?让I/O不再阻塞。这就是我们下一章要讲的内容——非阻塞I/O和I/O多路复用。

一个小建议:虽然单进程单线程模型在实际生产中几乎不用,但我建议你亲手写一遍这个echo服务器。把它跑起来,用telnet或nc连上去试试。感受一下阻塞是什么感觉。只有亲手踩过坑,后面学高级模型时才会有更深的体会。