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() 等客户端发数据,处理完就关闭连接。
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的典型问题——一个慢客户端拖死所有客户端。
4.3 为什么无法处理并发?
好,现在我们来深挖一下根本原因。为什么单进程单线程模型处理不了并发?
说白了,就一句话:CPU的执行流是单线的,而I/O操作会卡住这条线。
你想想看,一个CPU核心在同一时刻只能执行一个指令序列。当程序执行到 read() 系统调用时,会发生什么?
- 程序从用户态切换到内核态
- 内核检查socket接收缓冲区是否有数据
- 如果没有数据,内核把当前进程标记为“睡眠”状态
- CPU去执行其他进程(如果有的话)
- 当数据到达时,内核把进程唤醒,切换回用户态继续执行
问题就出在第3步和第4步。进程一旦“睡眠”,它就不会再执行任何代码了。而我们的服务器只有一个进程,所以整个服务就停了。
更形象一点,你可以把服务器想象成一个收银员:
- 单进程单线程 = 只有一个收银员,一次只能服务一个顾客。如果这个顾客掏钱很慢,后面的人只能排队等着。
- 多进程/多线程 = 多个收银员,可以同时服务多个顾客。但每个收银员还是会被慢顾客卡住。
- 非阻塞I/O + 事件驱动 = 一个收银员,但眼观六路耳听八方。谁准备好了就服务谁,不等任何人。
这个比喻虽然简单,但很能说明问题。我们后面要讲的端口复用,本质上就是第三种思路的体现。
那有没有办法解决呢?当然有。最简单的办法就是多开几个进程或线程,每个处理一个连接。但这样会带来新的问题:资源开销大、上下文切换频繁、共享数据需要加锁……
另一种思路,就是保持单进程,但让I/O不再阻塞。这就是我们后面要讲的 I/O多路复用(select、poll、epoll)和 非阻塞I/O。而端口复用,正是这些高级机制能够高效工作的基础。
嗯,先不急着往后讲。我们先把这张图看清楚,理解单进程模型的局限性,后面学高级模型时才能明白它们到底解决了什么问题。
这张图把整个流程画得很清楚了。两个红色阻塞点就像两个关卡,任何一个卡住,后面的代码都执行不了。而我们的服务器只有一个线程,所以一次只能处理一个客户端。
你可能会想:“那我多开几个进程不就行了?”
没错,多进程确实能解决问题。但每个进程都有自己的地址空间,创建开销大,进程间通信也麻烦。而且如果并发量上万,光是创建进程就能把系统资源吃光。
另一种思路是使用多线程。线程比进程轻量,但共享内存需要加锁,一不小心就出死锁或者数据竞争。我见过不少线上事故,都是多线程同步没做好导致的。
所以,真正高效的方案,是在单线程内解决阻塞问题。怎么解决?让I/O不再阻塞。这就是我们下一章要讲的内容——非阻塞I/O和I/O多路复用。