积累沉淀

待山花烂漫,化茧成蝶

CPP网络编程

前言: 服务器网络编程是构建高性能、可扩展、可靠网络服务(如 Web 服务器、数据库、游戏后端、微服务等)的核心技术。其核心围绕 网络通信模型、I/O 模型、并发处理、协议实现与性能优化 展开。以下从底层到高层,系统详解服务器网络编程的关键技术。


一、基础:网络通信模型

1. OSI 与 TCP/IP 模型

  • 服务器编程主要工作在 传输层(TCP/UDP)应用层(HTTP、gRPC 等)
  • TCP:面向连接、可靠、有序、流量控制 → 适用于大多数业务场景(如 Web、数据库)。
    TCP协议基础: 传输控制协议(TCP,Transmission Control Protocol)是一种面向连接的、可靠的、基于字节流的传输层通信协议.交互流程如下图:
    tcp-interaction
  • 注意:超时才能解决物理连接中断了默认的超时,可能长达两个小时;弥补:心跳包的机制,强制询问双方是否在线,如果终止,或者异常连续超过若干次(三次)。
  • UDP:无连接、不可靠、低延迟 → 适用于实时音视频、DNS、游戏等。

2. 套接字(Socket)

  • 是网络编程的抽象接口,由 IP 地址 + 端口号 唯一标识一个通信端点。

socket-interaction

  • 基本操作流程(以 TCP服务端 为例):
    1
    socket() → bind() → listen() → accept() → read()/write() → close()
  • 监听套接字(Listening Socket):用于接收新连接。
  • 已连接套接字(Connected 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
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
135
#include <cstdlib>
#include <cstring>
#include <cstdio>
#include <csignal>
#include <cerrno>

#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

// echo_server.cpp 为echo 的服务端,接收客服端的连接,并把客服端的消息返回过去

constexpr int PORT = 8080;
constexpr int BUF_SIZE = 1024;

// 忽略 SIGPIPE 信号,防止写入已关闭连接时进程终止
// 防止在客户端断开后继续写入时进程收到 SIGPIPE 信号而终止(可选,但提高健壮性)。
void ignore_sigpipe() {
struct sigaction sa;
sa.sa_handler = SIG_IGN;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sigaction(SIGPIPE, &sa, nullptr);
}

// 循环发送全部数据(处理部分发送)
ssize_t writen(int fd, const void *buf, size_t n) {
size_t nleft = n;
ssize_t nwritten;
const char *ptr = static_cast<const char*>(buf);

while (nleft > 0) {
nwritten = write(fd, ptr, nleft);
if (nwritten < 0) {
if (nwritten == -1 && errno == EINTR)
continue; // 被信号中断,重试
return -1;
} else if (nwritten == 0) {
break; // EOF
}
nleft -= nwritten;
ptr += nwritten;
}
return (n - nleft);
}


int main()
{
// 忽略 SIGPIPE,防止 write 到关闭的 socket 时进程终止
ignore_sigpipe();

//1. 建立socket
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
std::perror("socket");
std::exit(1);
}
//2. 设置 SO_REUSEADDR 选项,允许端口重用
int opt = 1;
if (setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) < 0) {
std::perror("setsockopt");
close(sockfd);
std::exit(1);
}
//3. 绑定地址和端口
struct sockaddr_in addr_in;
std::memset(&addr_in, 0, sizeof(addr_in));
addr_in.sin_family = AF_INET;
addr_in.sin_addr.s_addr = INADDR_ANY;
addr_in.sin_port = htons(PORT);
if (bind(sockfd, reinterpret_cast<const sockaddr *>(&addr_in), sizeof(addr_in)) < 0) {
std::perror("bind");
close(sockfd);
std::exit(1);
}
//4. 监听
if (listen(sockfd, 15) < 0) {
std::perror("listen");
close(sockfd);
std::exit(1);
}

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

//5. 循环接收客服端链接
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
char buf[BUF_SIZE];

while (true) {
int client_fd;
do {
client_fd = accept(sockfd, reinterpret_cast<sockaddr *>(&client_addr), &client_len);
} while (client_fd < 0 && errno == EINTR);

if (client_fd < 0) {
std::perror("accept");
continue;
}

std::printf("New client from %s:%d\n", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));

//6. 与客户端交互:接收数据并原样发送回去
ssize_t bytes_read;
while (true) {
// 处理 read 被信号中断的情况
do {
bytes_read = read(client_fd, buf, sizeof(buf));
} while (bytes_read < 0 && errno == EINTR);
if (bytes_read < 0) {
perror("read");
break;
}
if (bytes_read == 0) {
// 客户端关闭连接
break;
}
// 使用 writen 确保全部数据被写回
if (writen(client_fd, buf, bytes_read) != bytes_read) {
std::fprintf(stderr, "write error");
break;
}
}

close(client_fd);
std::printf("Client disconnected." );
}

// 服务器一般不会执行到这里,但为了完整性,关闭监听 socket
close(sockfd);

return 0;
}
  • 基本操作流程(以 TCP客户端 为例):
    1
    socket() → connect() → read()/write() → close()
  • 客服端代码:
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
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <cerrno>

constexpr int PORT = 8080;
constexpr int BUF_SIZE = 1024;

// 循环发送全部数据(处理部分发送)
ssize_t writen(int fd, const void *buf, size_t n) {
size_t nleft = n;
ssize_t nwritten;
const char *ptr = static_cast<const char*>(buf);

while (nleft > 0) {
nwritten = write(fd, ptr, nleft);
if (nwritten < 0) {
if (nwritten == -1 && errno == EINTR)
continue; // 被信号中断,重试
return -1;
} else if (nwritten == 0) {
break; // EOF
}
nleft -= nwritten;
ptr += nwritten;
}
return (n - nleft);
}

// 循环接收指定字节数(处理部分接收)
ssize_t readn(int fd, void *buf, size_t n) {
size_t nleft = n;
ssize_t nread;
char *ptr = static_cast<char*>(buf);

while (nleft > 0) {
nread = read(fd, ptr, nleft);
if (nread < 0) {
if (nread == -1 && errno == EINTR)
continue; // 被信号中断,重试
return -1;
} else if (nread == 0) {
break; // 连接关闭 EOF
}
nleft -= nread;
ptr += nread;
}
return (n - nleft); // 返回实际读取的字节数
}

int main(int argc, char *argv[]) {
// 指定服务器 IP(默认为 127.0.0.1)
const char *server_ip = "127.0.0.1";
if (argc > 1) {
server_ip = argv[1];
}

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

// 2. 设置服务器地址
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(PORT);
if (inet_pton(AF_INET, server_ip, &server_addr.sin_addr) <= 0) {
perror("inet_pton");
close(sockfd);
exit(EXIT_FAILURE);
}

// 3. 连接服务器
if (connect(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
perror("connect");
close(sockfd);
exit(EXIT_FAILURE);
}

printf("Connected to echo server %s:%d\n", server_ip, PORT);
printf("Enter text (type 'quit' to exit):\n");

char send_buf[BUF_SIZE];
char recv_buf[BUF_SIZE];

// 4. 循环读取用户输入
while (fgets(send_buf, sizeof(send_buf), stdin) != NULL) {
// 如果用户输入 "quit\n",退出循环
if (strcmp(send_buf, "quit\n") == 0 || strcmp(send_buf, "quit\r\n") == 0) {
printf("Exiting...\n");
break;
}

size_t len = strlen(send_buf);
// 发送数据
if (writen(sockfd, send_buf, len) != (ssize_t)len) {
perror("writen");
break;
}

// 接收服务器回显(接收与发送相同字节数)
ssize_t n = readn(sockfd, recv_buf, len);
if (n < 0) {
perror("readn");
break;
} else if (n == 0) {
printf("Server closed connection\n");
break;
}

// 打印接收到的数据(确保以 null 结尾)
recv_buf[n] = '\0';
printf("Echo: %s", recv_buf);
}

// 5. 关闭 socket
close(sockfd);
return 0;
}
  • 基于TCP服务端/客户端的函数调用关系

TCP-server-client-call-relation

3. TCP 内部原理

  • 三次握手
    一个小故事:TCP 三次握手好比在一个夜高风黑的夜晚,你一个人在小区里散步,不远处看见小区里的一位漂亮妹子迎面而来,但是因为路灯有点暗等原因不能100%确认,所以要通过招手的方式来确定对方是否认识自己。
    你首先向妹子招手(syn),妹子看到你向自己招手后,向你点了点头挤出了一个微笑(ack)。你看到妹子微笑后确认了妹子成功辨认出了自己(进入established状态)。
    但是妹子有点不好意思,向四周看了一看,有没有可能你是在看别人呢,她也需要确认一下。妹子也向你招了招手(syn),你看到妹子向自己招手后知道对方是在寻求自己的确认,于是也点了点头挤出了微笑(ack),妹子看到对方的微笑后确认了你就是在向自己打招呼(进入established状态)。
    TCP-handshake
  • 四次挥手
    TCP-handshake-close

4. UDP 内部原理

  • 基本操作流程(以 UDP服务端 为例):
    1
    socket() → bind() → recvfrom() → sendto() → close()
  • 服务端代码:
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 <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

constexpr int PORT = 8080;
constexpr int BUFFER_SIZE = 1024;

int main() {
// 1. 创建 UDP socket
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) {
std::cerr << "socket creation failed" << std::endl;
return 1;
}

// 2. 绑定地址和端口
sockaddr_in server_addr{};
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_port = htons(PORT);

if (bind(sockfd, (sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
std::cerr << "bind failed" << std::endl;
close(sockfd);
return 1;
}

std::cout << "UDP Echo Server running on port " << PORT << std::endl;

// 3. 循环接收并回显数据
char buffer[BUFFER_SIZE];
sockaddr_in client_addr{};
socklen_t client_len = sizeof(client_addr);

while (true) {
ssize_t recv_len = recvfrom(sockfd, buffer, BUFFER_SIZE - 1, 0,
(sockaddr*)&client_addr, &client_len);
if (recv_len < 0) {
std::cerr << "recvfrom error" << std::endl;
continue;
}

buffer[recv_len] = '\0'; // 确保字符串结束
std::cout << "Received from " << inet_ntoa(client_addr.sin_addr)
<< ":" << ntohs(client_addr.sin_port)
<< " -> " << buffer << std::endl;

// 将数据原样发回客户端
ssize_t sent_len = sendto(sockfd, buffer, recv_len, 0,
(sockaddr*)&client_addr, client_len);
if (sent_len < 0) {
std::cerr << "sendto error" << std::endl;
}
}

close(sockfd);
return 0;
}
  • 基本操作流程(以 UDP客户端 为例):
    1
    socket() → sendto() → recvfrom() → close()
  • 客服端代码:
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
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

constexpr int PORT = 8080;
constexpr int BUFFER_SIZE = 1024;
constexpr const char* SERVER_IP = "127.0.0.1";

int main() {
// 1. 创建 UDP socket
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) {
std::cerr << "socket creation failed" << std::endl;
return 1;
}

// 2. 设置服务器地址
sockaddr_in server_addr{};
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(PORT);
if (inet_pton(AF_INET, SERVER_IP, &server_addr.sin_addr) <= 0) {
std::cerr << "Invalid address" << std::endl;
close(sockfd);
return 1;
}

// 3. 发送消息给服务器
const char* message = "Hello, UDP Echo Server!";
socklen_t server_len = sizeof(server_addr);

ssize_t sent_len = sendto(sockfd, message, strlen(message), 0,
(sockaddr*)&server_addr, server_len);
if (sent_len < 0) {
std::cerr << "sendto failed" << std::endl;
close(sockfd);
return 1;
}
std::cout << "Sent: " << message << std::endl;

// 4. 接收服务器回显
char buffer[BUFFER_SIZE];
ssize_t recv_len = recvfrom(sockfd, buffer, BUFFER_SIZE - 1, 0,
(sockaddr*)&server_addr, &server_len);
if (recv_len < 0) {
std::cerr << "recvfrom failed" << std::endl;
close(sockfd);
return 1;
}

buffer[recv_len] = '\0';
std::cout << "Received: " << buffer << std::endl;

close(sockfd);
return 0;
}

二 网络通信中的阻塞和非阻塞IO的区别

阻塞 I/O(Blocking I/O)非阻塞 I/O(Non-blocking I/O) 的核心区别在于:当发起 I/O 请求时,数据尚未准备好(内核缓冲区为空),操作系统是否将当前线程挂起(休眠)。

  • 阻塞 I/O:如果数据没到,线程放弃 CPU 进入休眠,直到数据到达内核缓冲区,线程被唤醒,将数据拷贝到用户空间,函数才返回。
  • 非阻塞 I/O:如果数据没到,线程不会被挂起,函数立即返回一个错误码(如 EAGAINEWOULDBLOCK),线程可以继续做其他事,后续轮询检查数据是否到达。

1. 系统调用层面的“时间线”对比

假设执行 recv(fd, buffer, size, 0) 读取 Socket 数据:

阶段 阻塞 I/O(如默认的 socket 非阻塞 I/O(设置 O_NONBLOCK
① 调用 recv 线程陷入内核态,检查接收缓冲区 线程陷入内核态,检查接收缓冲区
② 缓冲区无数据 线程挂起(休眠),从运行队列移出,让出 CPU 立即返回 -1errno = EAGAIN,线程继续运行
③ 数据到达内核 网卡中断唤醒线程,放入运行队列 线程下次轮询(再次调用 recv)时发现数据就绪
④ 数据拷贝 线程从内核缓冲区拷贝数据到用户缓冲区,此过程依然占用 CPU 线程负责拷贝数据到用户缓冲区(此过程依然会短暂阻塞,但极快)
⑤ 返回 返回读取的字节数 返回读取的字节数

2. 阻塞 I/O 和非阻塞 I/O 的“致命误区”

很多初学者以为“非阻塞 I/O 就是完全不会卡住”,这是错的。

  • 非阻塞,指的是等待数据阶段(阶段②)不卡。它只保证你在数据没准备好时不被挂起。
  • 数据拷贝阶段(阶段④):无论是阻塞还是非阻塞,将数据从内核拷贝到用户空间的过程都是必须由线程执行的,且这一步是同步的、会占用 CPU 时间片

真正的“完全不卡”需要异步 I/O(AIO),如 Windows IOCP 或 Linux io_uring,它们由内核负责全程拷贝,拷贝完成后再通知应用。


3. 编程模型与代码示例

阻塞 I/O(传统多线程模型)

1
2
3
4
5
6
7
8
9
// 伪代码
int fd = socket(...);
connect(fd, ...);
char buf[1024];
while (true) {
// 如果没数据,这里会永久阻塞,线程被挂起
int n = recv(fd, buf, sizeof(buf), 0);
if (n > 0) handle(buf);
}
  • 优点:代码逻辑直观,像读文件一样顺序执行。
  • 缺点:每个连接必须独占一个线程。如果 1 万个连接,就要 1 万个线程,导致巨大的上下文切换开销和内存栈占用(每个线程默认 8MB),这就是著名的 C10K 问题

非阻塞 I/O(配合 epoll / Reactor 模式)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 1. 设置 fd 为非阻塞
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);

// 2. 加入 epoll 监听 EPOLLIN 事件
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev);

// 3. 事件循环
while (true) {
int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (int i = 0; i < n; i++) {
if (events[i].events & EPOLLIN) {
// 此时数据一定就绪(或有部分就绪),recv 会立即返回数据而不会阻塞
int len = recv(fd, buf, sizeof(buf), 0);
if (len > 0) handle(buf);
else if (len == -1 && errno == EAGAIN) {
// 理论上 epoll 不会触发虚假唤醒,但为了安全依旧判断
}
}
}
}
  • 优点:一个线程可以管理成千上万个 fd(epoll 监听),大大节省了线程资源。
  • 缺点:需要维护复杂的状态机。例如,读取一个 HTTP 请求可能需要分多次 recv,你得手动拼接数据包。

4. 阻塞/非阻塞 与 同步/异步 的关系(四象限图)

这是网络编程中最容易混淆的维度:

阻塞 (Blocking) 非阻塞 (Non-blocking)
同步 (Synchronous) 传统阻塞 I/O
(线程休眠等待)
非阻塞 I/O + 轮询/epoll
(主动检查就绪,但拷贝是自己做的)
异步 (Asynchronous) (理论上存在,实践中极少用) 真正的 AIO (IOCP/io_uring)
(内核代劳拷贝,完成后回调/唤醒协程)
  • 阻塞/非阻塞 = 调用者在等待数据就绪时是否被挂起。
  • 同步/异步 = 数据从内核到用户的拷贝过程是由用户线程亲自做的(同步),还是由内核代理做的(异步)。

结论阻塞 I/O 一定同步非阻塞 I/O 可以是同步(轮询)也可以是异步(io_uring)


5. 实际工程选型建议

场景 推荐模式 原因
连接数极少(< 100),内部工具脚本 阻塞 I/O + 多线程 开发最快,心智负担最低。
高并发服务端(如网关、游戏服务器),连接数 > 1000 非阻塞 I/O + epoll/Reactor 节省线程资源,配合 C++20 协程可写回同步风格(co_await 封装 epoll)。
极致性能(数据库、消息队列),追求零拷贝 非阻塞 + io_uring/Proactor 彻底解放 CPU,让内核负责搬运,应用只处理业务。
移动端/客户端 UI 编程 非阻塞 I/O 绝不允许主线程卡死导致界面失去响应。

6. 针对你之前 QUIC 场景的关联

你之前问的 QUIC + C++20 协程,在底层如果使用 epoll,那就是非阻塞同步 I/O(Reactor);如果使用 io_uring,那就是非阻塞异步 I/O(Proactor)。

  • 无论是哪种,你都不会使用阻塞 I/O,因为 QUIC 面向高并发,阻塞一个线程就损失大量性能。
  • 而在 C++20 协程加持下,你写的代码是 co_await socket.async_read(),看起来像阻塞,实质底层是 epoll_wait 挂起协程(释放线程),数据就绪后再恢复。这是一种“用户态阻塞,内核态非阻塞”的优雅封装。

在高并发场景下处理 Socket 连接,核心原则是:绝不能用一个线程(或进程)去服务一个连接,必须采用事件驱动 + 非阻塞 I/O 的架构。

以下是现代 C/C++ 服务端处理并发连接的标准实践,从架构选型、线程模型到落地细节,逐一拆解。

三 同步IO和异步IO的区别?

同步 I/O 和异步 I/O 的核心区别在于:数据从内核缓冲区拷贝到用户缓冲区的操作,是由谁(调用者线程还是操作系统)来完成,以及调用者是否必须等待这个拷贝过程结束。

  • 同步 I/O(Synchronous I/O):发起 I/O 请求后,调用者线程亲自负责数据拷贝,并且必须等待整个 I/O 操作(从内核到用户空间的拷贝)彻底完成后,函数才返回
  • 异步 I/O(Asynchronous I/O):发起 I/O 请求后,调用者立即返回,不阻塞。操作系统内核独立完成全部 I/O 操作(包括将数据拷贝到调用者指定的缓冲区),操作完成后通过回调、信号或 future 通知调用者。

1. I/O 操作的两个阶段(关键拆解)

理解网络/磁盘 I/O,必须把一次完整的读操作拆解为两个阶段:

  • 阶段 1:等待数据准备。对于网络 Socket,即等待数据包从网卡到达内核缓冲区(recv 等待 EPOLLIN);对于磁盘,即等待数据从磁盘加载到内核页缓存。
  • 阶段 2:将数据从内核空间拷贝到用户空间。即将数据从内核缓冲区搬到应用程序的 char buf[] 中。
类型 阶段 1(等待数据就绪) 阶段 2(拷贝数据到用户态) 调用者状态
同步阻塞 I/O 阻塞等待 调用者线程亲自拷贝,且在此过程中阻塞 全程阻塞
同步非阻塞 I/O(轮询) 轮询(不阻塞,但反复检查) 调用者线程亲自拷贝(调用 recv),拷贝时短暂阻塞 阶段1不阻塞,阶段2阻塞
同步 I/O 多路复用(epoll) Reactor 通知就绪,调用 recv 调用者线程亲自拷贝 epoll_wait 阻塞,拷贝时阻塞
异步 I/O(AIO / io_uring) 操作系统后台等待 操作系统内核负责拷贝,完成后再通知应用 全程不阻塞

一句话总结只要“将数据从内核拷贝到用户缓冲区”这一步必须由应用程序线程亲自调用 read/recv 来完成,那么该 I/O 就是“同步”的。


2. 阻塞/非阻塞 vs 同步/异步(四象限终极澄清)

绝大多数开发者的混淆源于将“阻塞/非阻塞”与“同步/异步”混为一谈。

  • 阻塞/非阻塞:描述的是 “当前线程是否因为等待数据就绪而被挂起”(阶段1是否阻塞)。
  • 同步/异步:描述的是 “数据拷贝工作是由谁完成,以及调用者是否等待拷贝完毕”(阶段2的责任方)。

由此产生经典的组合对应关系:

阻塞等待数据 非阻塞轮询数据
线程亲自拷贝数据(同步) 阻塞 I/O(阶段1+2都阻塞) 非阻塞 I/O + epoll(阶段1轮询,阶段2阻塞拷贝)
OS 代理拷贝数据(异步) (极少见,基本无意义) 真正的异步 I/O(如 Windows IOCP / Linux io_uring)

重要结论

  • 同步 I/O ≠ 阻塞 I/O:非阻塞 I/O 配上 epoll/select,虽然阶段1不阻塞,但因为阶段2是线程调用 recv 拷贝数据,所以它依然属于同步 I/O。这就是为什么 epoll 被称为同步 I/O 多路复用
  • 异步 I/O = 真正不阻塞:只要用户线程发起了请求,接下来直到数据出现在用户缓冲区之前,线程完全不需要参与任何系统调用,这是唯一的异步形态。

3. 实战对比:同步 I/O(epoll) vs 异步 I/O(io_uring)

结合你之前关心的 QUIC + C++20 协程,这两种模型的实际代码如下:

同步 I/O(epoll + 非阻塞 Socket)

1
2
3
4
// 1. 设置非阻塞,注册 epoll
// 2. 事件循环中检测到 EPOLLIN
int n = recv(fd, buffer, size, 0); // <-- 这一刻,线程亲自将数据从内核拷贝到 buffer
// 此调用会短暂占用 CPU,且数据量越大,拷贝耗时越久。

异步 I/O(io_uring)

1
2
3
4
5
// 1. 提交异步读请求:io_uring_prep_recv(sqe, fd, buffer, size, 0);
// 2. 提交后线程立即返回,去做其他事
// 3. 操作系统内核在后台等待数据、接收数据,并将数据直接存入 buffer
// 4. 内核完成所有工作后,放入完成队列(CQE),通知用户
// 用户线程此时无需调用 recv,直接处理 buffer 中的数据即可。

4. 现实中的适用场景

优先选择同步 I/O(epoll/Reactor)

  • 短连接、小包高频(如 HTTP API、RPC 调用)。
  • 业务逻辑中有复杂计算,阻塞拷贝开销远小于业务处理。
  • 需要更简单的错误处理(同步调用栈清晰,errno 直接可查)。
  • 代表组件:Nginx、Redis、Netty(底层基于 epoll)。

优先选择异步 I/O(io_uring/IOCP)

  • 大文件传输、视频流、数据库磁盘读写(拷贝数据量极大,若用同步 I/O 会长时间占用 CPU 进行内存搬运,拖垮事件循环)。
  • 追求零拷贝与极致吞吐量,希望 CPU 专注业务逻辑而非搬运数据。
  • IO 密集型场景,CPU 等待时间占比超过 70%。
  • 代表组件:ScyllaDB(高性能 NoSQL)、现代存储引擎。

5. 针对你 C++20 协程开发的关键启示

你之前的构想“C++20 协程 + QUIC 避免回调”,在两种模式下的实现感受是相同的(都是 co_await async_read),但底层的性能特征完全不同

  • 如果在 epoll(同步 I/O) 上跑协程:co_await 挂起协程等待可读,恢复协程后调用 recv 拷贝数据。拷贝动作依然占用当前线程,如果 QUIC 数据包极大,会拖慢其他协程的调度。
  • 如果在 io_uring(异步 I/O) 上跑协程:co_await 提交读请求给内核,内核完成后恢复协程。拷贝动作由内核异步完成,完全不占用业务线程

最终选型建议:追求极致的低延迟和稳定的吞吐,io_uring(Proactor/异步) 是未来方向;追求成熟稳定、社区生态完善,epoll(Reactor/同步) 依然是线上生产的主力,配合 C++20 协程已经足够优雅。


四 Socket通信中如何处理并发连接?

在高并发场景下处理 Socket 连接,核心原则是:绝不能用一个线程(或进程)去服务一个连接,必须采用事件驱动 + 非阻塞 I/O 的架构。

以下是现代 C/C++ 服务端处理并发连接的标准实践,从架构选型、线程模型到落地细节,逐一拆解。

1. 架构演进:从 C10K 到 C1000M

处理并发连接,本质上是在**资源(线程/内存)性能(吞吐/延迟)**之间做权衡。

模型 实现方式 并发上限 适用场景
多进程/多线程(阻塞 I/O) acceptfork/pthread_create ~几百(受线程栈 8MB 限制) 内部管理工具,并发极低
进程池/线程池(阻塞 I/O) 预先创建固定线程,accept 抢锁 ~几千 传统 Java BIO,已被淘汰
I/O 多路复用(非阻塞 I/O + epoll) 单线程/少量线程管理上万 fd 10万 ~ 100万 当前业界主流(Nginx/Redis/Netty)
异步 I/O(io_uring / IOCP) 内核代理完成拷贝,应用只收完成通知 百万级 + 零拷贝 未来方向(高性能数据库/消息队列)

2. 工业级标准实现:Reactor 模式(epoll + 非阻塞 I/O)

这是当前 C++ 后端最成熟、最普遍的方案,几乎所有知名的网络库(libeventmuduoBoost.Asio)都基于此。

线程模型选型(关键决策)

  • 单 Reactor 单线程(如 Redis):适合业务处理极快(纯内存操作),不存在耗时计算。若业务逻辑涉及磁盘 I/O 或 RPC,会阻塞整个事件循环。
  • 主从 Reactor 多线程(如 Nginx、Netty 的 EventLoopGroup):
    • 主 Reactor(1 个):专门负责 accept 新连接,极轻量,只做新连接的准入。
    • 从 Reactor(N 个,通常 = CPU 核数):每个线程拥有独立的 epoll 实例,负责已连接 Socket 的读写事件分发和业务处理。
    • 连接分发:主 Reactor accept 后,通过轮询(Round-Robin)或哈希将新 Socket 分配给某个从 Reactor 线程,保证该连接的生命周期始终由同一个线程管理(避免锁竞争)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 伪代码:主从 Reactor 事件循环结构
void main_reactor() {
while (true) {
epoll_wait(listen_fd);
int client_fd = accept(listen_fd);
// 将 client_fd 分发给某个子 Reactor 线程
sub_reactors[next_index]->add_fd(client_fd);
next_index = (next_index + 1) % cpu_cores;
}
}

void sub_reactor() {
while (true) {
epoll_wait(epfd); // 管理该线程独有的 client_fds
for (active_fd : events) {
if (可读) {
non_blocking_read(); // 读取数据
process_business(); // 处理业务
non_blocking_write(); // 发送响应
}
}
}
}

3. 必须掌握的底层优化细节(避坑指南)

  • 必须设置 Socket 为非阻塞(O_NONBLOCKepoll 的触发机制依赖于此。如果在 EPOLLIN 事件到达后,你用阻塞的 recv 去读取大包,依然会卡住线程。
  • 边缘触发(ET, Edge-Triggered) vs 水平触发(LT, Level-Triggered)
    • 追求极致性能时选 ET(如 Nginx)。ET 只在状态变化(从空变非空)时通知一次,要求你必须循环读取直到返回 EAGAIN,这能显著减少 epoll_wait 的系统调用次数。
    • 为了开发便捷选 LT(如 Redis)。LT 会重复通知直到数据处理完毕,逻辑简单,不容易漏读,但系统调用频率稍高。
  • 惊群效应(Thundering Herd)
    • 传统多进程 accept 会导致所有进程被唤醒抢同一个新连接。解决方式:
      1. Linux 内核 3.9+ 使用 SO_REUSEPORT 选项,允许多个 Socket 绑定同一端口,内核自动负载均衡,彻底解决惊群。
      2. 或主线程独占 accept,其他线程只处理读写(主从 Reactor 天然规避)。
  • 处理“粘包”与“半包”:TCP 是流式协议,必须自行维护接收缓冲区(Buffer)。应用层需要定义协议(如固定头 + body 长度),解析出完整包后再交给业务逻辑,绝不能假设单次 recv 返回的就是一个完整消息。

4. 如果业务逻辑耗时较长怎么办?(异步化)

事件循环线程(Reactor)绝对不能阻塞在 CPU 密集型计算或磁盘 I/O 上。

  • 方案一:将耗时任务提交给独立的线程池。Reactor 线程读完数据后,将任务打包放入队列,由后台工作线程(Worker Threads)计算,计算完成后再通过 eventfdpipe 唤醒 Reactor 线程发送结果。
  • 方案二:使用 Proactor / io_uring。将磁盘读写等重 I/O 完全丢给内核,Reactor 只负责接收“完成事件”,处理纯业务逻辑。

5. 现代 C++ 的终极解法:协程 + Reactor

这也是你之前关心的方向。C++20 协程本质上是对 Reactor(epoll)的优雅封装。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 传统状态机写法(晦涩)
void on_readable() { state = READ; recv(); if(complete) state = PROCESS; }

// C++20 协程写法(线性代码,隐藏了 epoll 的调度细节)
Task<> handle_client(Socket& sock) {
char buf[1024];
while (true) {
// co_await 挂起当前协程,线程去处理其他 fd
// 底层 epoll 监听到数据就绪后,恢复此协程
int n = co_await sock.async_read(buf, sizeof(buf));
if (n <= 0) break;
co_await sock.async_write(response);
}
}

这一层封装极大降低了开发者的心智负担,让你用同步编程的逻辑享受到非阻塞 I/O 的高并发红利。


总结:高并发 Socket 处理的三层架构清单

  1. 监听层SO_REUSEPORT + 主 Reactor(或内核自动均衡),避免 accept 竞争。
  2. 读写与解析层:多从 Reactor(线程数 = CPU 核数),非阻塞 I/O + ET 触发,每连接配独立 Buffer 解析协议。
  3. 业务处理层:若业务纯内存且极快,直接在 Reactor 线程处理;若耗时,通过无锁队列交给线程池,或利用 io_uring 异步完成。

五 IO多路复用的实现机制?

I/O 多路复用(I/O Multiplexing) 的本质是:单个线程通过一次系统调用(如 select/poll/epoll),同时监听多个文件描述符(Socket/文件),内核返回哪些描述符已就绪(可读/可写/异常),从而避免为每个连接创建线程。

现代 Linux 下,epoll 是性能最优、最通用的实现。下面我将从 通用原理具体实现机制 两个维度,层层剥开其内部工作原理。


1. 三大实现机制对比(select / poll / epoll)

机制 数据结构 最大监视数 内核态扫描方式 数据拷贝方式 触发模式
select 固定大小的位图(fd_set 1024(硬编码限制) 轮询(O(n)) 扫描全部 fd 每次调用将整个 fd 集合在用户态与内核态之间全量拷贝 仅水平触发(LT)
poll 动态链表(pollfd 数组) 无上限(受系统内存限制) 轮询(O(n)) 扫描全部 fd 每次调用将整个 fd 数组在用户态与内核态之间全量拷贝 仅水平触发(LT)
epoll 内核红黑树 + 就绪链表(epitem 无上限(受系统内存限制) 事件驱动(回调),仅返回就绪 fd,O(1) 复杂度 零拷贝,使用 epoll_wait 通过共享内存(mmap)返回事件,无需全量拷贝 支持 LT(水平触发)ET(边缘触发)

2. 深入底层:epoll 的“杀手锏”实现机制

理解 epoll 为什么快,只需看它的三个核心 API 和内核数据结构。

2.1 三个核心 API

  • epoll_create():在内核中创建一颗红黑树(用于存储所有被监听的 fd) 和一个双向就绪链表(用于存储已就绪的 fd),返回一个 epfd 句柄。
  • epoll_ctl(epfd, ADD/MOD/DEL, fd, event):将目标 fd 添加到红黑树中(或修改/删除),并注册一个回调函数(callback) 与网卡中断关联。
  • epoll_wait(epfd, events, maxevents, timeout):检查就绪链表是否为空。若为空且未超时,当前线程进入睡眠;若不为空,则将就绪事件通过共享内存复制到用户态的 events 数组并返回。

2.2 为什么 epoll 不需要全量拷贝?(关键零拷贝)

select/poll 每次调用时,用户态需要把全部 fd 数组整体拷贝进内核,内核处理完再整体拷出来。这是 O(n) 的内存复制开销。

epoll 的做法epoll_wait 只返回就绪事件列表(数量通常远小于全量 fd),且通过内核和用户共享同一块内存(mmap 映射),避免了内核态到用户态的数据拷贝开销。

2.3 事件就绪通知机制(回调 vs 轮询)

  • select/poll:内核在系统调用期间,暴力遍历(O(n)) 所有被监视的 fd,挨个检查它们的 sk_wait_data 队列状态。
  • epoll:当网卡收到数据包触发软中断时,中断处理程序会调用该 Socket 的回调函数,将就绪的 epitem 节点直接挂接到内核的就绪链表中。epoll_wait 只用检查该链表是否为空即可,复杂度 O(1)。

3. 水平触发(LT)与边缘触发(ET)的本质区别

这是 epoll 独有的设置,也是面试必问点。

  • LT(Level Triggered,水平触发):默认模式。只要 fd 缓冲区中还有数据未读完,epoll_wait会反复通知应用程序。这与 select/poll 行为一致,不容易漏读数据,编码简单安全。
  • ET(Edge Triggered,边缘触发):高速模式。仅当 fd 的缓冲区从“空”变为“非空”(或“不满”变为“满”)的状态变化瞬间通知一次。要求应用程序必须循环读取,直到 recv 返回 EAGAIN 为止。否则剩余数据将永远沉睡,导致漏读。

ET 的优势:减少了 epoll_wait 系统调用的触发次数,极大降低了上下文切换成本。Nginx、Memcached 等高性能组件默认使用 ET 模式。


4. 完整的 epoll 事件循环伪代码(经典 Reactor 模式)

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
// 1. 创建 epoll 实例
int epfd = epoll_create(1);

// 2. 将监听 Socket 加入红黑树
struct epoll_event ev;
ev.events = EPOLLIN; // 可读事件(常用 LT)
ev.data.fd = listen_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);

struct epoll_event ready_events[MAX_EVENTS];

while (true) {
// 3. 阻塞等待就绪事件(仅返回就绪的 fd,无需遍历全量)
int n = epoll_wait(epfd, ready_events, MAX_EVENTS, -1);

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

if (fd == listen_fd) {
// 4. 接收新连接(非阻塞)
int client_fd = accept(listen_fd, ...);
set_nonblocking(client_fd);

// 5. 将 client_fd 加入 epoll 监听
ev.events = EPOLLIN | EPOLLET; // ET 模式需要非阻塞读
ev.data.fd = client_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev);
} else {
// 6. 处理客户端数据
if (ready_events[i].events & EPOLLIN) {
// 若使用 ET,需 while(recv() > 0) 直到 EAGAIN
recv_and_process(fd);
}
}
}
}

5. select/poll 的“致命缺陷”(为什么被 epoll 取代)

  • 内存拷贝开销大:每次调用 select/poll 都要在内核和用户空间之间复制整个 fd 集合。如果有 10 万个连接,即使只有 1 个活跃,也得复制 10 万个 fd 的数据结构。
  • 时间复杂度高:内核需要通过遍历整个数组来检查就绪状态,O(n)。当 n 达到十万级时,select/poll 的系统调用延迟会急剧上升(超过 1 毫秒),而 epoll_wait 仍能保持在微秒级。

6. 补充:其他操作系统的 I/O 多路复用

操作系统 接口 特点
Linux epoll 基于回调 + 红黑树,最成熟、最推荐
FreeBSD/macOS kqueue 功能类似 epoll,支持多种事件类型(文件、信号、进程)
Windows IOCP(Proactor) 真正的异步 I/O,而非 I/O 多路复用(接口思路不同)
跨平台封装 libevent / Boost.Asio 底层自动选择 epoll/kqueue/IOCP,提供统一 API

总结:到底该怎么选?

  1. 并发连接数很少(小于 1000)select/poll 足够简单,无需引入 epoll 的复杂性。
  2. 高并发服务端(连接数 > 5000)必须用 epoll(Linux 平台)。配合非阻塞 I/O + ET 模式,搭配线程池或 C++20 协程,可以支撑百万连接。
  3. 涉及磁盘文件 I/O:注意 epoll 不支持普通磁盘文件的监听(会返回 EPERM)。大文件读写应配合 io_uring 或线程池。

核心认知:I/O 多路复用是“同步 I/O”(因为数据拷贝仍是线程亲自做),但它解决了“等待数据就绪”阶段的阻塞问题,是高性能网络编程的基石。


六、现代服务器开发框架/库

表格

语言 框架/库 模型
C/C++ libevent, libuv, Boost.Asio Reactor
Java Netty, Undertow Reactor + 多线程
Go net/http, gnet Goroutine(协程)
Python asyncio, Tornado Event-loop + 协程
Rust Tokio, async-std Async/Await + Reactor

七、性能调优要点

  1. 避免阻塞操作:数据库查询、文件 I/O 应异步化或交由线程池。
  2. 合理设置 backloglisten(sockfd, backlog) 控制等待队列长度。
  3. TCP 参数调优:- SO_REUSEADDR:快速重启绑定端口
    • TCP_NODELAY:禁用 Nagle 算法(降低延迟)
    • SO_RCVBUF / SO_SNDBUF:调整缓冲区大小
  4. 使用 ET 模式 + 非阻塞 socket:最大化 epoll 效率。
  5. 负载均衡:单机性能有限,需结合 LVS/Nginx 做集群。

八、安全考虑

  • 防止 DDoS:连接数限制、速率限制
  • 防止 缓冲区溢出:严格校验输入长度
  • 使用 TLS/SSL:加密通信(OpenSSL、BoringSSL)
  • 避免 Slowloris 攻击:设置读写超时

总结

服务器网络编程的核心在于:

“用最少的资源,高效、可靠地处理海量并发连接。”

掌握以下四点即可构建高性能服务器:

  1. 理解 I/O 模型(尤其是 epoll/kqueue)
  2. 选择合适的并发模型(Reactor / 协程)
  3. 处理 TCP 流特性(粘包、心跳、超时)
  4. 避免性能陷阱(阻塞、内存拷贝、锁竞争)

详解IPv4 和 IPv6

IP 地址(Internet Protocol Address)是用于在网络中唯一标识设备的逻辑地址。它使得设备之间能够相互通信。目前广泛使用的 IP 地址主要有两种版本:IPv4IPv6。下面分别详细说明它们的组成、格式、特点及区别。


一、IPv4 地址

1. 基本组成

  • IPv4(Internet Protocol version 4)使用 32 位(4 字节) 的地址空间。
  • 总共可表示 255*255*255*255 =4,294,967,296232 个地址(约 43 亿个)。

2. 表示方式

  • 采用 点分十进制(Dotted Decimal Notation) 表示法:- 将 32 位地址分为 4 个 8 位(1 字节)的段,每段转换为十进制数,用点号 . 分隔。
    • 例如:192.168.1.1

3. 结构划分

IPv4 地址由两部分组成:

  • 网络号(Network ID):标识所属网络。
  • 主机号(Host ID):标识该网络中的具体设备。

根据地址的前几位,IPv4 地址被划分为 A、B、C、D、E 五类:
表格

类别 范围(首字节) 网络位数 主机位数 用途
A 1 – 126 8 位 24 位 大型网络
B 128 – 191 16 位 16 位 中型网络
C 192 – 223 24 位 8 位 小型网络
D 224 – 239 组播(Multicast)
E 240 – 255 保留(实验用)

注意:127.x.x.x 是回环地址(如 127.0.0.1),不用于实际网络通信。

  • 以0开头的地址,都是不可以用的
  • 以0结尾的地址,表示的是网段,而不是具体的地址
  • 224开头到239开头的地址,是组播地址,不可用于点对点的传输

4. 特殊地址

  • 0.0.0.0:表示默认路由或“任意地址”。
  • 255.255.255.255:受限广播地址。
  • 127.0.0.1:本地回环地址。
  • 169.254.x.x:APIPA(自动私有 IP 寻址),当 DHCP 失败时自动分配。
  • 私有地址(不可在公网路由):- A 类:10.0.0.0 – 10.255.255.255
    • B 类:172.16.0.0 – 172.31.255.255
    • C 类:192.168.0.0 – 192.168.255.255

5. 子网掩码(Subnet Mask)

  • 用于区分网络号和主机号。
  • 例如:255.255.255.0 表示前 24 位是网络号,后 8 位是主机号(即 /24)。

二、IPv6 地址

1. 基本组成

  • IPv6(Internet Protocol version 6)使用 128 位(16 字节) 地址空间。
  • 总共可表示 2128≈3.4×10382128≈3.4×1038 个地址,几乎无限。

2. 表示方式

  • 采用 冒号十六进制(Colon-Hexadecimal) 表示法:- 将 128 位地址分为 8 段,每段 16 位(4 个十六进制数字),用冒号 : 分隔。
    • 例如:2001:0db8:85a3:0000:0000:8a2e:0370:7334

压缩规则(简化写法):

  • 前导零可省略2001:db8:85a3:0:0:8a2e:370:7334
  • 连续全零段可用 :: 代替(仅一次):- 上例可进一步压缩为:2001:db8:85a3::8a2e:370:7334

3. 地址类型

IPv6 不再使用“类别”概念,而是按功能分为三类:
表格

类型 说明
单播(Unicast) 标识单个接口,数据包发往一个目标。
组播(Multicast) 标识一组接口,数据包发往多个目标(取代广播)。
任播(Anycast) 标识一组接口,但数据包只送达“最近”的一个(按路由距离)。

IPv6 没有广播地址,广播功能由组播实现。

4. 常见 IPv6 地址前缀

  • ::1/128:本地回环地址(相当于 IPv4 的 127.0.0.1)
  • ::/128:未指定地址(类似 0.0.0.0)
  • fe80::/10:链路本地地址(Link-Local),仅在同一物理/逻辑链路内有效
  • fc00::/7:唯一本地地址(ULA,Unique Local Address),相当于 IPv4 的私有地址
  • 2000::/3:全球单播地址(Global Unicast),可在互联网上路由

5. 地址结构(以全球单播为例)

典型的全球单播 IPv6 地址结构如下:
text编辑

1
2
3
| 3 bits |   45 bits   |   16 bits   |      64 bits       |
+--------+-------------+-------------+--------------------+
| 001 | Global Routing Prefix | Subnet ID | Interface ID (EUI-64 或随机) |
  • Global Routing Prefix:由 ISP 分配的全球路由前缀。
  • Subnet ID:组织内部子网划分。
  • Interface ID:设备接口标识,常由 MAC 地址通过 EUI-64 生成,或使用隐私扩展(随机生成)。

三、IPv4 与 IPv6 主要区别总结

表格

特性 IPv4 IPv6
地址长度 32 位 128 位
地址数量 ~43 亿 ~3.4×10³⁸
表示法 点分十进制 冒号十六进制
配置方式 手动 / DHCP 自动配置(SLAAC)、DHCPv6
广播 支持 不支持(用组播替代)
安全性 依赖附加协议(如 IPsec) 内置 IPsec 支持
报头结构 较复杂,含校验和等 更简洁,无校验和,提高路由效率
移动性 有限支持 原生支持移动 IPv6
NAT 需求 广泛使用(因地址不足) 基本不需要(地址充足)

四、过渡技术(IPv4 → IPv6)

由于 IPv4 仍在广泛使用,IPv6 的部署通常采用以下过渡机制:

  • 双栈(Dual Stack):设备同时运行 IPv4 和 IPv6。
  • 隧道(Tunneling):如 6to4、GRE,将 IPv6 包封装在 IPv4 中传输。
  • 协议转换(Translation):如 NAT64,实现 IPv6 与 IPv4 互访。
Buy me a coffee please.