1. 1. Linux Socket 编程与 epoll 入门:从 0 到最小可运行 Demo,再到海量连接框架
  2. 2. 一、先建立整体图景:Linux 下 TCP 服务器到底在做什么
  3. 3. 二、什么是 socket
  4. 4. 三、最基础的 TCP 服务端,每个函数是干什么的
    1. 4.1. 1. socket()
      1. 4.1.1. 作用
      2. 4.1.2. 原型
      3. 4.1.3. 常见参数
      4. 4.1.4. 示例
      5. 4.1.5. 注意
    2. 4.2. 2. bind()
    3. 4.3. 3. listen()
    4. 4.4. 4. accept()
      1. 4.4.1. 解释
    5. 4.5. 5. recv()
    6. 4.6. 6. send()
    7. 4.7. 7. close()
  5. 5. 四、最简单的 Linux TCP 服务端 Demo
    1. 5.1. 这个 demo 的缺点
  6. 6. 五、什么是 epoll
  7. 7. 六、为什么 epoll 比 select / poll 更适合海量连接
    1. 7.1. 1. select 的问题
    2. 7.2. 2. poll 的问题
    3. 7.3. 3. epoll 的优势
  8. 8. 七、epoll 的三个核心函数
    1. 8.1. 1. epoll_create1()
      1. 8.1.1. 作用
      2. 8.1.2. 原型
      3. 8.1.3. 示例
    2. 8.2. 2. epoll_ctl()
      1. 8.2.1. 作用
      2. 8.2.2. 原型
      3. 8.2.3. 常见操作
      4. 8.2.4. epoll_event 结构体
      5. 8.2.5. 常见事件
      6. 8.2.6. 示例:注册一个监听 fd
    3. 8.3. 3. epoll_wait()
      1. 8.3.1. 作用
      2. 8.3.2. 原型
      3. 8.3.3. 参数解释
      4. 8.3.4. 示例
  9. 9. 八、epoll 的工作流程
    1. 9.1. 第一步:创建 epoll 实例
    2. 9.2. 第二步:把你关心的 fd 注册进去
    3. 9.3. 第三步:循环等待事件
  10. 10. 九、epoll 最小可运行 Demo:回显服务器
    1. 10.1. 功能
    2. 10.2. 代码
    3. 10.3. 编译方式
  11. 11. 十、这个 epoll demo 里每一步到底在干什么
    1. 11.1. 1. epoll_create1()
    2. 11.2. 2. epoll_ctl(ADD)
    3. 11.3. 3. epoll_wait()
    4. 11.4. 4. 处理监听 fd
    5. 11.5. 5. 把新连接也加入 epoll
    6. 11.6. 6. 处理普通连接读事件
  12. 12. 十一、为什么高并发场景一定要非阻塞
  13. 13. 十二、非阻塞怎么设置
  14. 14. 十三、非阻塞模式下 recv() / send() 的特点
  15. 15. 十四、LT 和 ET 是什么
    1. 15.1. 1. LT:Level Trigger,水平触发
    2. 15.2. 2. ET:Edge Trigger,边沿触发
    3. 15.3. 面试时怎么答选 LT 还是 ET
  16. 16. 十五、真正“异步通信、处理海量连接”的框架该怎么设计
    1. 16.1. 核心设计思路
      1. 16.1.1. 1. 一个监听 socket
      2. 16.1.2. 2. 一个 epoll 实例
      3. 16.1.3. 3. 一个连接对象 Connection
      4. 16.1.4. 4. 读事件处理
      5. 16.1.5. 5. 写事件处理
      6. 16.1.6. 6. I/O 和业务解耦
  17. 17. 十六、海量连接服务器伪代码框架
  18. 18. 十七、这个框架里最关键的工程点
    1. 18.1. 1. 为什么要输入缓冲区 inBuffer
    2. 18.2. 2. 为什么要输出缓冲区 outBuffer
    3. 18.3. 3. 为什么 EPOLLOUT 不应该一直监听
    4. 18.4. 4. 为什么 I/O 线程不能做重业务
  19. 19. 十八、你可以怎么向面试官总结
  20. 20. 十九、学习建议:你下一步该怎么练
    1. 20.1. 第 1 步
    2. 20.2. 第 2 步
    3. 20.3. 第 3 步
    4. 20.4. 第 4 步
    5. 20.5. 第 5 步
    6. 20.6. 第 6 步
  21. 21. 二十、最后总结

Linux Socket 编程

Linux Socket 编程与 epoll 入门:从 0 到最小可运行 Demo,再到海量连接框架

这篇文章的目标很明确:

  1. 帮你搞懂 Linux 下最基础的 socket 编程流程。
  2. 帮你搞懂 epoll 到底是什么、为什么它适合高并发。
  3. 逐个介绍常见函数的作用和用法。
  4. 给出一个最小可运行的 socket demo。
  5. 给出一个最小可运行的 epoll demo。
  6. 最后给出一个适合“异步通信 / 海量连接”的服务器框架伪代码。

如果你以前总听别人说:

  • epoll 很重要
  • 非阻塞 + epoll 才能处理海量连接
  • Boost.Asio 底层在 Linux 上也离不开 epoll

但自己没真正写过,那这篇文章就是给你补这个坑的。


一、先建立整体图景:Linux 下 TCP 服务器到底在做什么

一个最基础的 TCP 服务端,流程其实很固定:

  1. 创建一个监听 socket
  2. 绑定 IP 和端口
  3. 开始监听
  4. 接收客户端连接
  5. 对每个连接做收发数据
  6. 关闭连接

对应到 Linux socket API,通常就是:

  • socket():创建套接字
  • bind():绑定地址和端口
  • listen():把 socket 变成监听状态
  • accept():接收客户端连接
  • recv() / read():读数据
  • send() / write():写数据
  • close():关闭 fd

所以 socket 编程的本质并不神秘:

就是把网络连接当成一个“可读可写的文件描述符 fd”去操作。


二、什么是 socket

可以先把 socket 理解成:

操作系统为网络通信提供的一个端点。

在 Linux 里,一切皆文件。socket 本质上也会对应一个文件描述符 fd

例如:

1
int listenfd = socket(AF_INET, SOCK_STREAM, 0);

这里:

  • AF_INET:表示 IPv4
  • SOCK_STREAM:表示 TCP(流式协议)
  • 返回值 listenfd:就是这个 socket 对应的文件描述符

后面你对这个 fdbindlistenacceptrecvsend,就完成了网络通信。


三、最基础的 TCP 服务端,每个函数是干什么的

1. socket()

作用

创建一个 socket,返回文件描述符。

原型

1
int socket(int domain, int type, int protocol);

常见参数

  • domain
    • AF_INET:IPv4
    • AF_INET6:IPv6
  • type
    • SOCK_STREAM:TCP
    • SOCK_DGRAM:UDP
  • protocol
    • 通常填 0,让系统根据前两个参数自动选择

示例

1
int listenfd = socket(AF_INET, SOCK_STREAM, 0);

注意

如果返回值 < 0,说明创建失败。


2. bind()

作用

把这个 socket 和某个 IP、端口绑定起来。

原型

1
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

为什么要 bind

因为服务端必须明确:

我到底监听哪一个 IP、哪一个端口?

示例

1
2
3
4
5
6
sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(8888);
addr.sin_addr.s_addr = htonl(INADDR_ANY);

bind(listenfd, (sockaddr*)&addr, sizeof(addr));

解释

  • sin_family = AF_INET:IPv4
  • sin_port = htons(8888):监听 8888 端口
  • INADDR_ANY:监听本机所有网卡 IP

为什么要 htons / htonl

因为网络字节序通常使用大端序,而主机字节序可能不同,所以需要转换。

  • htons:host to network short
  • htonl:host to network long

3. listen()

作用

把 socket 变成一个“监听 socket”。

原型

1
int listen(int sockfd, int backlog);

示例

1
listen(listenfd, 128);

解释

backlog 表示监听队列长度的大致上限。

它的直觉可以理解成:

当前一批新连接还没来得及 accept,它们先在队列里排队。


4. accept()

作用

从监听 socket 上接收一个新的客户端连接。

原型

1
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

示例

1
2
3
sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
int connfd = accept(listenfd, (sockaddr*)&client_addr, &client_len);

解释

  • listenfd:监听 socket
  • 返回的 connfd:和某个具体客户端通信的新连接 fd

这里要注意:

监听 socket 和连接 socket 不是同一个东西。

  • listenfd:只负责接连接
  • connfd:只负责和某个客户端收发数据

5. recv()

作用

从连接 socket 读取数据。

原型

1
ssize_t recv(int sockfd, void *buf, size_t len, int flags);

示例

1
2
char buf[1024];
ssize_t n = recv(connfd, buf, sizeof(buf), 0);

返回值含义

  • n > 0:读到了 n 字节
  • n == 0:对端关闭连接
  • n < 0:发生错误

6. send()

作用

向连接 socket 发送数据。

原型

1
ssize_t send(int sockfd, const void *buf, size_t len, int flags);

示例

1
2
const char* msg = "hello client\n";
send(connfd, msg, strlen(msg), 0);

7. close()

作用

关闭文件描述符。

示例

1
2
close(connfd);
close(listenfd);

四、最简单的 Linux TCP 服务端 Demo

这个版本不引入 epoll,只是帮你先熟悉 socket 基础流程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

int main() {
// 1. 创建监听 socket
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
if (listenfd < 0) {
perror("socket");
return 1;
}

// 2. 绑定地址
sockaddr_in server_addr;
std::memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8888);
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);

if (bind(listenfd, (sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
perror("bind");
close(listenfd);
return 1;
}

// 3. 开始监听
if (listen(listenfd, 128) < 0) {
perror("listen");
close(listenfd);
return 1;
}

std::cout << "server start at port 8888..." << std::endl;

// 4. 接收一个客户端连接
sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
int connfd = accept(listenfd, (sockaddr*)&client_addr, &client_len);
if (connfd < 0) {
perror("accept");
close(listenfd);
return 1;
}

std::cout << "client connected." << std::endl;

// 5. 读取客户端消息
char buf[1024] = {0};
ssize_t n = recv(connfd, buf, sizeof(buf) - 1, 0);
if (n > 0) {
std::cout << "recv: " << buf << std::endl;

// 6. 回复客户端
const char* reply = "hello client, message received.\n";
send(connfd, reply, std::strlen(reply), 0);
} else if (n == 0) {
std::cout << "client closed." << std::endl;
} else {
perror("recv");
}

// 7. 关闭连接
close(connfd);
close(listenfd);
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

int main() {
// 1. 创建 socket
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
perror("socket");
return 1;
}

// 2. 设置服务端地址
sockaddr_in server_addr;
std::memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8888);

// 把字符串 IP 转成网络地址
if (inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr) <= 0) {
perror("inet_pton");
close(sockfd);
return 1;
}

// 3. 连接服务端
if (connect(sockfd, (sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
perror("connect");
close(sockfd);
return 1;
}

std::cout << "connected to server." << std::endl;

// 4. 发送消息
const char* msg = "hello server, I am client.\n";
if (send(sockfd, msg, std::strlen(msg), 0) < 0) {
perror("send");
close(sockfd);
return 1;
}

std::cout << "send: " << msg;

// 5. 接收服务端回复
char buf[1024] = {0};
ssize_t n = recv(sockfd, buf, sizeof(buf) - 1, 0);
if (n > 0) {
std::cout << "recv from server: " << buf << std::endl;
} else if (n == 0) {
std::cout << "server closed." << std::endl;
} else {
perror("recv");
}

// 6. 关闭连接
close(sockfd);
return 0;
}

这个 demo 的缺点

这个 demo 只能处理:

  • 一个客户端
  • 一次收发
  • 同步阻塞

它的逻辑非常简单,但也说明一个问题:

如果连接很多,这种写法完全不够。

因为:

  • accept() 可能卡住
  • recv() 可能卡住
  • 一个客户端没发数据,线程就被挂住了
  • 根本不适合海量长连接

这就引出 epoll


五、什么是 epoll

你可以先用一句最通俗的话理解:

epoll 是 Linux 下用于同时管理大量 fd 就绪事件的一种机制。

它的目的就是解决:

我有很多连接,不能一个连接一个线程去等,那我怎么用少量线程同时盯着这些连接?

答案就是:

  • 把一批 fd 注册到 epoll
  • 告诉内核:我关心这些 fd 的可读/可写/异常事件
  • 然后通过 epoll_wait() 等待
  • 一旦有事件发生,内核把“已经就绪”的 fd 返回给我

重点是:

epoll 只告诉你哪些 fd 已经有事了,不需要你每次扫描全部连接。

这就是它适合高并发的关键。


六、为什么 epoll 比 select / poll 更适合海量连接

这块是面试高频。

1. select 的问题

  • fd 数量上限受限
  • 每次都要把整个 fd 集合传给内核
  • 返回后还要遍历所有 fd,看谁就绪

2. poll 的问题

  • 没有固定上限
  • 但本质上仍然要遍历整个 fd 数组

3. epoll 的优势

  • fd 集合注册一次即可
  • epoll_wait 返回的是已经就绪的事件
  • 不需要每次扫描所有连接

一句话记忆:

epoll 的关键优势是“只关注活跃连接,不遍历所有连接”。


七、epoll 的三个核心函数

1. epoll_create1()

作用

创建一个 epoll 实例。

原型

1
int epoll_create1(int flags);

示例

1
int epfd = epoll_create1(0);

返回值 epfd 就是 epoll 实例对应的 fd。


2. epoll_ctl()

作用

向 epoll 中添加、修改、删除要监听的 fd。

原型

1
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

常见操作

  • EPOLL_CTL_ADD:添加 fd
  • EPOLL_CTL_MOD:修改 fd 关注的事件
  • EPOLL_CTL_DEL:删除 fd

epoll_event 结构体

1
2
3
4
struct epoll_event {
uint32_t events; // 关心的事件
epoll_data_t data; // 用户数据,通常放 fd
};

常见事件

  • EPOLLIN:可读
  • EPOLLOUT:可写
  • EPOLLERR:错误
  • EPOLLHUP:挂断
  • EPOLLET:边沿触发(ET)

示例:注册一个监听 fd

1
2
3
4
5
epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = listenfd;

epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev);

3. epoll_wait()

作用

等待事件发生,并把就绪事件返回给你。

原型

1
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

参数解释

  • epfd:epoll 实例

  • events:输出数组,用于接收已就绪事件

  • maxevents:本次最多返回多少个事件

  • 1
    timeout
    • -1:一直等
    • 0:立即返回
    • >0:最多等多少毫秒

示例

1
2
epoll_event events[1024];
int n = epoll_wait(epfd, events, 1024, -1);

返回值:

  • n > 0:有 n 个事件就绪
  • n == 0:超时
  • n < 0:错误

八、epoll 的工作流程

完整流程可以这样理解:

第一步:创建 epoll 实例

1
int epfd = epoll_create1(0);

第二步:把你关心的 fd 注册进去

1
epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev);

第三步:循环等待事件

1
2
3
4
5
6
while (true) {
int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (int i = 0; i < n; ++i) {
// 处理每个就绪事件
}
}

这就是典型事件循环。


九、epoll 最小可运行 Demo:回显服务器

这个 demo 很重要,因为它是:

  • Linux socket
  • epoll
  • 非阻塞思想
  • 多连接处理

最小能连起来的版本。

功能

  • 监听 8888 端口
  • 支持多个客户端连接
  • 客户端发什么,服务端回什么

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <fcntl.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/epoll.h>

int set_nonblocking(int fd) {
int old_flags = fcntl(fd, F_GETFL);
if (old_flags < 0) return -1;
return fcntl(fd, F_SETFL, old_flags | O_NONBLOCK);
}

int main() {
// 1. 创建监听 socket
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
if (listenfd < 0) {
perror("socket");
return 1;
}

// 允许端口快速复用
int opt = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

// 2. 绑定地址
sockaddr_in server_addr;
std::memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8888);
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);

if (bind(listenfd, (sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
perror("bind");
close(listenfd);
return 1;
}

// 3. 监听
if (listen(listenfd, 128) < 0) {
perror("listen");
close(listenfd);
return 1;
}

// 4. 创建 epoll
int epfd = epoll_create1(0);
if (epfd < 0) {
perror("epoll_create1");
close(listenfd);
return 1;
}

// 5. 把监听 fd 注册到 epoll
epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = listenfd;

if (epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev) < 0) {
perror("epoll_ctl add listenfd");
close(epfd);
close(listenfd);
return 1;
}

std::cout << "epoll echo server start at 8888..." << std::endl;

epoll_event events[1024];

while (true) {
// 6. 等待事件
int n = epoll_wait(epfd, events, 1024, -1);
if (n < 0) {
perror("epoll_wait");
break;
}

for (int i = 0; i < n; ++i) {
int fd = events[i].data.fd;

// 7. 监听 fd 可读:表示有新连接
if (fd == listenfd) {
sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);

int connfd = accept(listenfd, (sockaddr*)&client_addr, &client_len);
if (connfd < 0) {
perror("accept");
continue;
}

std::cout << "new client, fd = " << connfd << std::endl;

// 这里为了 demo 简化,不强制设置非阻塞也能跑
// 真正高并发场景必须设成非阻塞
set_nonblocking(connfd);

epoll_event client_ev;
client_ev.events = EPOLLIN;
client_ev.data.fd = connfd;

if (epoll_ctl(epfd, EPOLL_CTL_ADD, connfd, &client_ev) < 0) {
perror("epoll_ctl add connfd");
close(connfd);
}
}
// 8. 普通连接可读
else if (events[i].events & EPOLLIN) {
char buf[1024] = {0};
ssize_t cnt = recv(fd, buf, sizeof(buf) - 1, 0);

if (cnt > 0) {
std::cout << "recv from fd " << fd << ": " << buf << std::endl;

// 回显
send(fd, buf, cnt, 0);
} else if (cnt == 0) {
std::cout << "client closed, fd = " << fd << std::endl;
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, nullptr);
close(fd);
} else {
perror("recv");
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, nullptr);
close(fd);
}
}
}
}

close(epfd);
close(listenfd);
return 0;
}

编译方式

1
g++ -std=c++11 -O2 epoll_echo_server.cpp -o server

运行:

1
./server

客户端测试可以用:

1
telnet 127.0.0.1 8888

或者:

1
nc 127.0.0.1 8888

十、这个 epoll demo 里每一步到底在干什么

1. epoll_create1()

创建 epoll 实例。

1
int epfd = epoll_create1(0);

你可以把它理解成:

创建了一个“事件管理器”。


2. epoll_ctl(ADD)

把监听 socket 注册进去。

1
2
3
ev.events = EPOLLIN;
ev.data.fd = listenfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev);

意思是:

我关心 listenfd 的读事件。
一旦它可读,就通知我。

为什么监听 socket 可读表示有新连接?

因为对监听 socket 来说,“可读”意味着:

当前 accept 队列里有连接可以取了。


3. epoll_wait()

等待事件发生。

1
int n = epoll_wait(epfd, events, 1024, -1);

意思是:

你先睡着,等有事件了再叫醒我。
醒来后告诉我,这次有哪些 fd 有事。


4. 处理监听 fd

如果返回的 fd 是 listenfd,说明来了新连接。

1
2
3
if (fd == listenfd) {
int connfd = accept(listenfd, ...);
}

这里拿到的 connfd 才是真正和客户端收发数据的 fd。


5. 把新连接也加入 epoll

1
2
3
client_ev.events = EPOLLIN;
client_ev.data.fd = connfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, connfd, &client_ev);

意思是:

以后这个客户端连接有数据可读时,也通知我。


6. 处理普通连接读事件

1
ssize_t cnt = recv(fd, buf, sizeof(buf) - 1, 0);

如果 cnt > 0

  • 说明读到了数据

如果 cnt == 0

  • 说明客户端关闭连接

如果 cnt < 0

  • 说明出错

十一、为什么高并发场景一定要非阻塞

刚才那个 demo 虽然已经用了 epoll,但要注意:

真正做高并发,必须让连接 socket 配合非阻塞模式。

因为在一个事件循环里,一个线程通常要负责很多连接。

如果某个连接上的一次 recv()send() 阻塞住了,那这个线程就不能继续处理别的连接事件了。

所以高并发事件驱动模型通常都是:

  • epoll
  • 非阻塞 socket
  • 收到事件后循环读/写到 EAGAIN

十二、非阻塞怎么设置

fcntl

1
2
3
4
5
int set_nonblocking(int fd) {
int old_flags = fcntl(fd, F_GETFL);
if (old_flags < 0) return -1;
return fcntl(fd, F_SETFL, old_flags | O_NONBLOCK);
}

这段代码做的事是:

  1. 先获取当前 fd 的标志位
  2. 加上 O_NONBLOCK
  3. 再设置回去

十三、非阻塞模式下 recv() / send() 的特点

如果没有数据可读,recv() 不会一直等,而会返回:

  • -1
  • 同时 errno == EAGAINEWOULDBLOCK

这表示:

当前没有更多数据了,不是致命错误。

同理,send() 也可能因为发送缓冲区满了而返回 EAGAIN

所以真正高并发代码里,你不能把 EAGAIN 当错误直接关连接。


十四、LT 和 ET 是什么

epoll 有两种常见触发模式。

1. LT:Level Trigger,水平触发

只要 fd 还处于可读/可写状态,epoll_wait 下次还会继续通知你。

优点:

  • 更稳
  • 更容易写
  • 不容易漏事件

缺点:

  • 可能会重复提醒

2. ET:Edge Trigger,边沿触发

只有状态发生变化时才通知一次。

比如:

  • 从不可读变成可读,通知一次
  • 如果你这次没把数据读完,后面可能不再提醒

优点:

  • 减少重复通知
  • 潜在性能更高

缺点:

  • 更难写
  • 通常必须配合非阻塞
  • 一次事件里要尽量读到 EAGAIN

面试时怎么答选 LT 还是 ET

比较稳妥的回答是:

如果先实现一个稳定版本,我会优先 LT,因为逻辑更简单、不容易漏读漏写;
如果对代码严谨性和吞吐要求更高,也可以使用 ET,但要确保每次事件都把内核缓冲区尽可能处理干净。


十五、真正“异步通信、处理海量连接”的框架该怎么设计

这里给你一个更工程化的思路。

注意,Linux 下常说的“异步通信”,很多时候工程里其实是:

非阻塞 socket + epoll + 事件驱动 + 连接状态机

也就是更接近 Reactor 模型。

核心设计思路

1. 一个监听 socket

负责接入新连接。

2. 一个 epoll 实例

统一监听:

  • 监听 fd
  • 所有连接 fd
  • 定时器 fd(如果有)

3. 一个连接对象 Connection

每个客户端连接都维护自己的上下文。

例如:

1
2
3
4
5
6
7
struct Connection {
int fd;
std::string inBuffer; // 输入缓冲区
std::string outBuffer; // 输出缓冲区
bool closed;
uint64_t lastActiveTime;
};

4. 读事件处理

  • 循环 recv
  • 读到数据就追加到 inBuffer
  • 尝试按协议解析完整消息
  • 如果解析出完整消息,就投递给业务层处理

5. 写事件处理

  • 如果 outBuffer 有数据,循环 send
  • 发出去多少就移除多少
  • 直到发完或遇到 EAGAIN

6. I/O 和业务解耦

  • I/O 线程只做网络收发和缓冲区管理
  • 重业务逻辑交给工作线程池
  • 业务线程把响应结果再投递回 I/O 线程发送

十六、海量连接服务器伪代码框架

下面这段是最值得你背的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
create listenfd
set listenfd non-blocking
bind(listenfd)
listen(listenfd)

create epoll fd
add listenfd to epoll with EPOLLIN

connections = map<fd, Connection>

while (server running) {
events = epoll_wait(epfd)

for each event in events {
fd = event.fd

// 1. 新连接到来
if (fd == listenfd) {
while (true) {
connfd = accept(listenfd)
if (connfd < 0) {
if errno is EAGAIN:
break
else:
log error
break
}

set connfd non-blocking
create Connection for connfd
add connfd to epoll with EPOLLIN
}
}

// 2. 连接可读
else if (event has EPOLLIN) {
conn = connections[fd]

while (true) {
n = recv(fd, tmpBuf)
if (n > 0) {
append tmpBuf to conn.inBuffer
} else if (n == 0) {
close connection
break
} else {
if errno is EAGAIN:
break
else:
close connection
break
}
}

// 尝试从 inBuffer 中解析完整业务包
while (canParseOnePacket(conn.inBuffer)) {
packet = parseOnePacket(conn.inBuffer)
dispatch packet to business thread pool
}
}

// 3. 连接可写
else if (event has EPOLLOUT) {
conn = connections[fd]

while (!conn.outBuffer.empty()) {
n = send(fd, conn.outBuffer.data(), conn.outBuffer.size())
if (n > 0) {
remove first n bytes from conn.outBuffer
} else {
if errno is EAGAIN:
break
else:
close connection
break
}
}

// 如果已经发完,就取消 EPOLLOUT 监听
if (conn.outBuffer.empty()) {
modify epoll event to EPOLLIN only
}
}

// 4. 错误 / 挂断
else if (event has EPOLLERR or EPOLLHUP) {
close connection
}
}

// 5. 处理业务线程返回的待发送消息
while (has business response) {
fd, msg = pop response
conn = connections[fd]
append msg to conn.outBuffer
modify epoll event to EPOLLIN | EPOLLOUT
}

// 6. 定时清理超时连接
cleanup timeout connections
}

十七、这个框架里最关键的工程点

1. 为什么要输入缓冲区 inBuffer

因为 TCP 是字节流。

一次 recv()

  • 可能只读到半个包
  • 可能读到一个半包
  • 也可能一次读到多个包

所以你不能假设“一次 recv 就是一个完整业务消息”。

必须把数据先放进输入缓冲区,再按协议拆包。


2. 为什么要输出缓冲区 outBuffer

因为一次 send() 也不保证把所有数据都写完。

尤其在非阻塞模式下:

  • 可能只发出去一部分
  • 可能暂时发不动,返回 EAGAIN

所以剩余没发完的数据必须留在输出缓冲区里,等下次可写事件继续发。


3. 为什么 EPOLLOUT 不应该一直监听

因为 socket 大多数时候通常都是可写的。

如果你一直监听 EPOLLOUT,会造成很多无意义唤醒。

正确做法通常是:

  • outBuffer 为空时:只监听 EPOLLIN
  • outBuffer 从空变非空时:加上 EPOLLOUT
  • outBuffer 发空后:再去掉 EPOLLOUT

4. 为什么 I/O 线程不能做重业务

因为事件循环线程的职责是:

快速处理网络事件,不要被某一个请求拖慢。

如果你在 I/O 线程里直接做复杂业务计算、数据库查询、磁盘 I/O,那么:

  • 一个连接的慢处理
  • 就可能拖住整个事件循环
  • 影响其他连接的响应

所以常见做法是:

  • I/O 线程只负责收包、发包、连接状态
  • 业务逻辑丢给线程池

十八、你可以怎么向面试官总结

如果面试官问你:

epoll 怎么用?
Linux 下怎么做海量 TCP 连接?

你可以这样答:

epoll 本质上是 Linux 下管理大量 fd 就绪事件的机制。
使用上通常分三步:先用 epoll_create1 创建 epoll 实例;再通过 epoll_ctl 把监听 socket 和连接 socket 注册进去,并指定关心的事件,比如 EPOLLINEPOLLOUT;然后在主循环里调用 epoll_wait 等待就绪事件。

真正做高并发时,我会把所有 socket 都设成非阻塞,收到读事件后循环 recvEAGAIN,收到写事件后循环 sendEAGAIN。每个连接维护自己的输入输出缓冲区,用来解决 TCP 粘包拆包和部分发送问题。

再往上会把网络 I/O 和业务线程池解耦,I/O 线程只负责事件循环和缓冲区管理,业务线程负责实际逻辑处理。这样就可以实现一个 Reactor 风格的高并发 TCP 服务器,这也是 Linux 下处理海量长连接的常见方式。


十九、学习建议:你下一步该怎么练

如果你是第一次接触,建议按下面顺序练:

第 1 步

先把最简单的阻塞版 socket 服务端写出来:

  • socket
  • bind
  • listen
  • accept
  • recv
  • send

第 2 步

写一个最简单的多客户端 epoll 回显服务器。

第 3 步

把连接 socket 改成非阻塞,并正确处理 EAGAIN

第 4 步

给每个连接加输入缓冲区和输出缓冲区。

第 5 步

实现一个简单协议,比如:

  • 固定长度头
  • 头里带 body 长度
  • 再按长度收 body

第 6 步

最后再考虑:

  • 业务线程池
  • 心跳超时
  • 定时器
  • 主从 Reactor
  • 连接对象生命周期管理

这样你再回头看 Boost.Asio,就会明白:

它不是“凭空会异步”,而是把 Linux 下这些底层机制帮你统一封装好了。


二十、最后总结

整篇内容可以浓缩成下面几句话:

  1. socket 编程的本质,就是把网络连接当成 fd 去做读写。
  2. 最基础的 TCP 服务端流程是:socket -> bind -> listen -> accept -> recv/send -> close。
  3. epoll 是 Linux 下管理大量 fd 就绪事件的机制,特别适合高并发长连接。
  4. epoll 的核心 API 只有三个:epoll_create1epoll_ctlepoll_wait
  5. 高并发场景一定要配合非阻塞 socket。
  6. 海量连接服务器的关键是:事件循环、连接对象、输入缓冲区、输出缓冲区、I/O 与业务解耦。
  7. Linux 下所谓“异步通信”在工程里通常就是 Reactor 风格:非阻塞 + epoll + 事件驱动。

如果你真正把这篇文章吃透,再去看 Asio、muduo、nginx、redis 的网络模型,就不会再觉得那么抽象了。