一、TCP Socket通信实现

记忆口诀:服创绑监听,接收发关尽;客创连收发,关闭要记心。

服务器端流程

  1. 创建socket:调用socket()创建流式套接字(TCP)
  2. 绑定地址:通过bind()将socket与IP地址和端口绑定
  3. 监听连接:使用listen()开启监听,设置最大连接队列
  4. 接受连接:调用accept()阻塞等待客户端连接,返回新socket
  5. 数据收发:使用send()recv()进行数据传输
  6. 关闭socket:通信结束后关闭连接

客户端流程

  1. 创建socket:同服务器端
  2. 连接服务器:通过connect()向服务器发起连接请求
  3. 数据收发:同服务器端
  4. 关闭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
#include <iostream>
#include <cstring>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>

int main() {
// 1. 创建socket
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd == -1) {
std::cerr << "Failed to create socket" << std::endl;
return -1;
}

// 设置地址复用
int opt = 1;
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

// 2. 绑定地址
sockaddr_in server_addr{};
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY; // 监听所有可用接口
server_addr.sin_port = htons(8080); // 端口号(网络字节序)

if (bind(server_fd, (sockaddr*)&server_addr, sizeof(server_addr)) == -1) {
std::cerr << "Failed to bind" << std::endl;
close(server_fd);
return -1;
}

// 3. 监听连接
if (listen(server_fd, 3) == -1) {
std::cerr << "Failed to listen" << std::endl;
close(server_fd);
return -1;
}

std::cout << "Server listening on port 8080..." << std::endl;

// 4. 接受连接
sockaddr_in client_addr{};
socklen_t client_addr_len = sizeof(client_addr);
int client_fd = accept(server_fd, (sockaddr*)&client_addr, &client_addr_len);
if (client_fd == -1) {
std::cerr << "Failed to accept connection" << std::endl;
close(server_fd);
return -1;
}

std::cout << "Client connected: " << inet_ntoa(client_addr.sin_addr) << std::endl;

// 5. 数据收发
char buffer[1024] = {0};
int valread = recv(client_fd, buffer, 1024, 0);
if (valread > 0) {
std::cout << "Received: " << buffer << std::endl;
send(client_fd, "Hello from server!", strlen("Hello from server!"), 0);
}

// 6. 关闭连接(优雅关闭)
shutdown(client_fd, SHUT_RDWR);
close(client_fd);
close(server_fd);
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
#include <iostream>
#include <cstring>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>

int main() {
// 1. 创建socket
int client_fd = socket(AF_INET, SOCK_STREAM, 0);
if (client_fd == -1) {
std::cerr << "Failed to create socket" << std::endl;
return -1;
}

// 2. 连接服务器
sockaddr_in server_addr{};
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8080);

// 将IPv4地址从文本转换为二进制形式
if (inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr) <= 0) {
std::cerr << "Invalid address/ Address not supported" << std::endl;
close(client_fd);
return -1;
}

if (connect(client_fd, (sockaddr*)&server_addr, sizeof(server_addr)) == -1) {
std::cerr << "Connection failed" << std::endl;
close(client_fd);
return -1;
}

// 3. 数据收发
const char* message = "Hello from client!";
send(client_fd, message, strlen(message), 0);

char buffer[1024] = {0};
int valread = recv(client_fd, buffer, 1024, 0);
if (valread > 0) {
std::cout << "Received from server: " << buffer << std::endl;
}

// 4. 关闭连接
shutdown(client_fd, SHUT_RDWR);
close(client_fd);
return 0;
}

关键函数解析

  • socket(): 创建套接字,参数为协议族、套接字类型和协议
  • bind(): 绑定地址和端口
  • listen(): 监听连接请求,设置最大等待队列长度
  • accept(): 接受客户端连接,返回新的通信socket
  • connect(): 客户端连接服务器
  • send()/recv(): 数据传输函数
  • shutdown(): 优雅关闭连接,可指定关闭方向
  • close(): 关闭套接字,释放资源

跨平台实现

  • Windows平台: 使用WinSock API,如WSASocket()WSAStartup()
  • 跨平台库: 使用Boost.Asio、Poco等库封装底层差异

二、Socket阻塞与非阻塞模式

记忆口诀:阻塞等操作,线程被挂起;非阻立即返,错误EAGAIN;多路复用配,效率更优异。

阻塞模式

  • 默认行为: Socket I/O操作会阻塞调用线程,直到操作完成或发生错误
  • 例子: read()会一直等待直到接收到数据;write()会等待直到数据被写入缓冲区
  • 优点: 编程简单,易于理解
  • 缺点: 线程阻塞影响效率,可能导致资源浪费

非阻塞模式

  • 修改行为: Socket I/O操作不阻塞调用线程,无法立即完成时返回错误码
  • 错误码: 通常为EAGAINEWOULDBLOCK
  • 应用场景: 通常与I/O多路复用结合使用(select、poll、epoll)

模式切换方法

1. 使用fcntl函数:

1
2
3
#include <fcntl.h>
int flags = fcntl(sockfd, F_GETFL, 0);
fcntl(sockfd, F_SETFL, flags | O_NONBLOCK); // 设置非阻塞模式

2. 使用ioctl函数:

1
2
3
#include <sys/ioctl.h>
int nonblocking = 1;
ioctl(sockfd, FIONBIO, &nonblocking); // 设置非阻塞模式

三、多客户端连接处理方案

记忆口诀:多线程易实现,资源消耗大;多路复用优,并发能力强;异步效率高,实现最复杂。

1. 多进程/多线程模型

  • 多进程: 每个客户端连接fork一个子进程(Unix/Linux)
  • 多线程: 每个客户端连接创建一个新线程(跨平台)
  • 优点: 编程简单,隔离性好
  • 缺点: 资源消耗大,线程/进程上下文切换开销高

多线程服务器示例:

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
#include <thread>
#include <vector>
#include <atomic>
#include <iostream>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>

std::atomic<bool> server_running(true);

void handle_client(int client_fd) {
char buffer[1024];
while (server_running) {
int bytes_received = recv(client_fd, buffer, 1024, 0);
if (bytes_received <= 0) break;
// 处理数据并回显
send(client_fd, buffer, bytes_received, 0);
}
close(client_fd);
}

int main() {
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
// 绑定和监听(代码省略,同前)
std::vector<std::thread> threads;

while (server_running) {
int client_fd = accept(server_fd, nullptr, nullptr);
threads.emplace_back(handle_client, client_fd);
threads.back().detach(); // 分离线程,自动回收资源
}
close(server_fd);
return 0;
}

2. I/O多路复用模型

  • select/poll: 单线程轮询多个socket(select有FD数量限制)
  • epoll(Linux): 事件驱动,高效处理大量连接(LT/ET模式)
  • 优点: 资源利用率高,适合高并发
  • 缺点: 编程复杂度高

select多路复用示例:

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
#include <sys/select.h>
#include <vector>
#include <iostream>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>

int main() {
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
// 绑定和监听(代码省略,同前)

fd_set readfds;
std::vector<int> client_fds;
int max_fd = server_fd;

while (true) {
FD_ZERO(&readfds);
FD_SET(server_fd, &readfds);
for (int fd : client_fds) FD_SET(fd, &readfds);

select(max_fd + 1, &readfds, nullptr, nullptr, nullptr);

if (FD_ISSET(server_fd, &readfds)) {
int client_fd = accept(server_fd, nullptr, nullptr);
client_fds.push_back(client_fd);
max_fd = std::max(max_fd, client_fd);
}

for (auto it = client_fds.begin(); it != client_fds.end();) {
if (FD_ISSET(*it, &readfds)) {
char buffer[1024];
int bytes = recv(*it, buffer, 1024, 0);
if (bytes <= 0) {
close(*it);
it = client_fds.erase(it);
} else {
send(*it, buffer, bytes, 0);
++it;
}
} else {
++it;
}
}
}
}

epoll多路复用示例(Linux):

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
#include <sys/epoll.h>
#include <vector>
#include <iostream>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>

#define MAX_EVENTS 10

int main() {
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
// 绑定和监听(代码省略,同前)

int epoll_fd = epoll_create1(0);
epoll_event ev, events[MAX_EVENTS];
ev.events = EPOLLIN;
ev.data.fd = server_fd;
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, server_fd, &ev);

while (true) {
int nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
for (int i = 0; i < nfds; ++i) {
if (events[i].data.fd == server_fd) {
int client_fd = accept(server_fd, nullptr, nullptr);
ev.events = EPOLLIN | EPOLLET; // 边缘触发模式
ev.data.fd = client_fd;
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &ev);
} else {
char buffer[1024];
int bytes = recv(events[i].data.fd, buffer, 1024, 0);
if (bytes <= 0) {
close(events[i].data.fd);
} else {
send(events[i].data.fd, buffer, bytes, 0);
}
}
}
}
}

3. 异步I/O模型

  • Windows IOCP / Linux aio: 内核直接通知I/O完成
  • 优点: 线程利用率最大化
  • 缺点: 平台依赖,调试困难

4. 方案对比

方案 优点 缺点 适用场景
多线程/进程 编程简单,隔离性好 资源消耗大,扩展性差 连接数少,计算密集型
select/poll 跨平台支持 FD数量受限(select),轮询效率低 中小规模并发
epoll(Linux) 事件驱动,无FD限制 Linux专属,编程复杂度高 大规模高并发(如Web服务器)
异步I/O(IOCP) 线程利用率最大化 平台依赖,调试困难 特定平台高性能需求

5. epoll关键特性

  • 水平触发(LT): 默认模式,只要socket有数据可读就触发事件
  • 边缘触发(ET): 仅在数据到来时触发一次,要求一次性读完所有数据
  • 高效机制: 使用红黑树管理FD,事件链表通知就绪FD,O(1)时间复杂度

6. 多线程+epoll混合模型(主从Reactor)

  • 主Reactor线程: 接受连接并分发给Worker线程
  • Worker线程池: 每个线程维护一个epoll实例处理I/O

四、粘包和拆包问题

记忆口诀:定长最直接,分隔符易识别,消息头含长度,自定义最灵活。

粘包拆包问题的解决方法

1. 固定长度法

  • 发送端将每个包都封装成固定长度(如100字节)
  • 不足部分通过补0或空字符填充到指定长度
  • 接收端按固定长度读取数据

2. 分隔符法

  • 发送端在每个包的末尾使用固定分隔符(如\r\n
  • 接收端通过查找分隔符来确定包的边界
  • 示例: FTP协议采用此方式

3. 消息头+消息体法

  • 将消息分为头部和消息体两部分
  • 头部中保存整个消息的长度信息
  • 接收端先读取头部,再根据长度读取完整消息体
  • 优点: 高效可靠,广泛应用于自定义协议

4. 自定义协议法

  • 根据业务需求设计完整的协议格式
  • 通常包含魔数、版本号、消息类型、长度、校验等字段
  • 优点: 灵活适应特定场景,安全性更高

五、网络编程优化技巧

记忆口诀:地址复用快重启,超时设置防阻塞;线程池减开销,零拷贝提性能;事件驱动模式优,协程简化异步程。

1. 错误处理与优化

  • 地址复用: 设置SO_REUSEADDR标志允许快速重启服务器
  • 超时设置: 通过setsockopt()设置SO_RCVTIMEOSO_SNDTIMEO避免永久阻塞
  • 优雅关闭: 使用shutdown()而非直接close(),避免数据丢失

2. 关键优化策略

  • 使用线程池减少线程创建开销
  • 采用边缘触发模式提高epoll效率
  • 分离I/O操作与业务逻辑(Reactor模式)
  • 使用零拷贝技术(如splice())减少数据拷贝

3. 现代实践

  • 使用Boost.Asio、libevent等成熟库封装底层差异
  • 结合协程(如C++20的coroutine)简化异步编程模型
  • 考虑io_uring(Linux 5.1+)进一步提升I/O性能

六、常见面试问题

1. TCP和UDP的主要区别

  • TCP: 面向连接、可靠传输、有序、重量级
  • UDP: 无连接、不可靠、无序、轻量级
  • 适用场景: TCP用于文件传输、网页浏览等;UDP用于视频通话、游戏等

2. 为什么服务器需要两个socket

  • 监听socket: 用于接受连接请求,保持监听状态
  • 通信socket: 用于与客户端实际通信,可创建多个

3. select的1024个FD限制问题

  • 历史原因: 由fd_set的实现(位图)决定
  • 解决方法: 可通过修改内核参数调整,但效率仍低于epoll

4. epoll的ET模式为什么要求非阻塞socket

  • 原因: ET模式下若数据未读完,不会再次触发事件
  • 风险: 使用阻塞socket可能导致线程永久阻塞

5. 多线程服务器中的竞态条件处理

  • 互斥锁: 使用std::mutex保护共享资源
  • 无锁结构: 采用原子操作实现无锁数据结构