开篇介绍:
hello 大家,那么在前面几篇博客中,我们披荆斩棘,学习UDPsocket编程基础,同时实现了几个简单的小功能,那么在本篇博客中,我们就来学习一下另一个网络通信协议,也是一个更常用,更安全,更实用的协议------TCP。
在网络编程领域,TCP协议因其可靠、面向连接的特性,成为绝大多数网络应用(如网页浏览、文件传输、即时通讯)的底层支撑。而Socket作为操作系统提供的网络通信接口,是我们实现TCP通信的核心工具。
一、前置知识:TCP协议核心特性(为什么TCP需要这些编程步骤?)
在开始编程之前,我们必须先搞懂TCP协议的核心特性------这直接决定了TCP Socket编程的流程和函数设计。很多初学者困惑"为什么TCP要写这么多代码,而UDP却很简单",答案就藏在TCP的特性里。
1.1 TCP与UDP的核心区别(初学者必懂)
我们先通过一个表格清晰对比TCP和UDP的核心差异:
| 对比维度 | TCP协议 | UDP协议 |
|---|---|---|
| 连接特性 | 面向连接(必须先建立连接才能通信) | 无连接(无需建立连接,直接发数据) |
| 可靠性 | 可靠传输(保证数据有序、不丢失、不重复) | 不可靠传输(可能丢包、乱序、重复) |
| 通信方式 | 双向字节流(类似管道,可连续读写) | datagram(数据报,每次发送独立数据包) |
| 收发函数 | read/write、recv/send(无需指定目标地址) | recvfrom/sendto(必须指定目标地址) |
| 适用场景 | 网页、文件传输、登录验证等需可靠通信的场景 | 直播、语音、广播等对实时性要求高的场景 |
通俗类比:TCP就像打电话------必须先拨号(建立连接),双方确认能听到对方(三次握手),才能聊天(通信),而且说话是连续的(字节流),对方能按顺序听到(有序传输);UDP就像寄明信片------不用确认对方是否能收到,写好地址(目标IP+端口)直接寄出(发数据),明信片可能丢失、损坏,也可能乱序到达。
1.2 TCP的"连接"到底是什么?
TCP的"连接"本质是:客户端和服务端通过三次握手,在双方内核中建立一个"连接表项",表项中记录了双方的IP、端口、序列号、窗口大小等关键信息。这个表项就像一个"专属通信通道",后续双方的通信都通过这个通道进行,无需再重复指定对方地址。
正因为TCP需要"连接",所以它的编程流程比UDP多了几个关键步骤:
-
服务端:需要"监听"客户端的连接请求(listen函数),并"接收"连接(accept函数);
-
客户端:需要主动"发起"连接(connect函数);
-
双方:连接建立后,才能用read/write等函数收发数据。
1.3 Socket的本质:网络通信的"文件句柄"
在Linux系统中,"一切皆文件"------键盘、显示器、硬盘是文件,网络通信的接口(Socket)也被抽象成文件。Socket函数的返回值是一个"文件描述符"(整数),我们可以像操作普通文件一样,用read(读文件)、write(写文件)函数来收发网络数据。
这个特性是TCP能用read/write收发数据的核心原因!比如:
-
用write(sockfd, data, len)向Socket写数据,本质是把数据发送到网络中;
-
用read(sockfd, buff, len)从Socket读数据,本质是从网络中接收数据。
记住这个核心:Socket = 网络文件描述符,操作Socket = 操作文件。这能帮你理解后续所有函数的设计逻辑。
二、TCP Socket编程核心函数解析(逐个突破)
TCP Socket编程的核心函数有6个:socket(创建套接字)、bind(绑定地址)、listen(监听连接)、accept(接收连接)、connect(发起连接)、read/write(收发数据)。我们按"服务端流程"和"客户端流程"分别解析
2.1 基础准备:必备头文件
在编写TCP Socket程序前,需要包含以下核心头文件:
cpp
#pragma once
#include <iostream>
#include <sys/socket.h> // 核心Socket函数(socket、bind、listen等)
#include <netinet/in.h> // 网络地址结构体(sockaddr_in)
#include <arpa/inet.h> // IP地址转换函数(inet_pton、inet_ntoa)
#include <unistd.h> // 系统调用(read、write、close、fork)
#include <strings.h> // 内存操作函数(bzero)
#include <cerrno> // 错误码处理(errno)
#include <pthread.h> // 多线程相关(pthread_create、pthread_detach)
#include <signal.h> // 信号处理(sigaction)
#include <atomic> // 原子变量(避免多线程竞争)
#include <functional> // 函数对象(回调函数)
2.2 服务端核心函数:socket → bind → listen → accept
服务端的核心职责是:"站在固定地址上等待客户端连接,连接成功后为每个客户端提供服务"。对应的函数调用顺序是:创建套接字(socket)→ 绑定地址(bind)→ 监听连接(listen)→ 接收连接(accept)→ 收发数据(read/write)。
2.2.1 socket函数:创建网络通信"文件"
功能:创建一个Socket(网络文件描述符),作为后续网络通信的"句柄"。
函数原型
cpp
int socket(int domain, int type, int protocol);
参数解析
| 参数 | 含义 | TCP编程常用值 |
|---|---|---|
| domain | 协议族(指定网络通信的地址类型) | AF_INET(IPv4协议,最常用) |
| type | 套接字类型(指定通信方式) | SOCK_STREAM(流式套接字,对应TCP) |
| protocol | 具体协议(一般设为0,由系统自动匹配) | 0(系统自动选择TCP协议) |
返回值规则
-
成功:返回一个非负整数(即Socket文件描述符,类似文件的"身份证号");
-
失败:返回-1,并设置全局变量errno(可通过strerror(errno)查看具体错误信息)。
实战示例
cpp
// 服务端创建TCP Socket
_listensocketfd = socket(AF_INET, SOCK_STREAM, 0);
if (_listensocketfd < 0) {
LOG(LogLevel::FATAL) << "socket failed";
exit(SOCKET_ERR); // 创建失败直接退出
}
关键说明:TCP必须用SOCK_STREAM类型,这是TCP"流式通信"的核心标识;而UDP用SOCK_DGRAM(数据报套接字),这是两者最直观的区别。
2.2.2 bind函数:给服务端"定一个固定地址"
功能:将创建的Socket与"服务端IP+端口"绑定,让客户端能通过这个地址找到服务端。
通俗类比:bind就像给商店挂"招牌+地址"------只有挂了招牌(绑定IP+端口),顾客(客户端)才能找到这家店(服务端)。
函数原型
cpp
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数解析
| 参数 | 含义 | 实战用法 |
|---|---|---|
| sockfd | socket函数创建的Socket文件描述符 | 服务端的监听Socket(如_listensocketfd) |
| addr | 指向网络地址结构体的指针(存储服务端IP+端口) | 用struct sockaddr_in填充后强制转换(见下文示例) |
| addrlen | addr指向的结构体的实际长度 | sizeof(struct sockaddr_in) |
关键补充:struct sockaddr_in结构体(存储网络地址)
struct sockaddr是通用地址结构体,而struct sockaddr_in是专门用于IPv4的地址结构体(更易用)。两者可以强制转换,实战中我们常用struct sockaddr_in填充地址信息。
cpp
struct sockaddr_in {
sa_family_t sin_family; // 协议族(必须是AF_INET)
in_port_t sin_port; // 端口号(需转换为网络字节序)
struct in_addr sin_addr; // IP地址(需转换为网络字节序)
};
// IP地址结构体(sin_addr的类型)
struct in_addr {
in_addr_t s_addr; // IPv4地址(32位整数)
};
字节序转换:必须掌握的细节
计算机存储数据有两种字节序:
-
主机字节序(小端序):大多数PC机、服务器的存储方式;
-
网络字节序(大端序):网络通信的标准字节序。
因此,端口号和IP地址在绑定前必须转换为网络字节序,否则会出现"地址错误"问题。常用转换函数:
-
端口转换:htons(host to network short,主机字节序→网络字节序,适用于16位端口);
-
IP转换:htonl(host to network long,主机字节序→网络字节序,适用于32位IP);
-
字符串IP转网络IP:inet_pton(将"192.168.1.100"这类字符串IP转为网络字节序整数)。
返回值规则
-
成功:返回0;
-
失败:返回-1,设置errno(常见错误:端口被占用、IP地址无效)。
实战示例
cpp
// 1. 填充服务端地址信息
struct sockaddr_in sever;
bzero(&sever, sizeof(sever)); // 清空结构体(避免垃圾值)
sever.sin_family = AF_INET; // IPv4协议族
// 绑定所有网卡的IP(INADDR_ANY = 0.0.0.0),表示接收任意客户端的连接
sever.sin_addr.s_addr = htonl(INADDR_ANY);
// 绑定端口(_port是用户传入的端口号,需转为网络字节序)
sever.sin_port = htons(_port);
// 2. 端口复用(关键优化!避免服务端重启时端口被占用)
int opt = 1;
setsockopt(_listensocketfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// 3. 调用bind绑定地址
int ret_bind = bind(_listensocketfd, (struct sockaddr*)&sever, sizeof(sever));
if (ret_bind < 0) {
LOG(LogLevel::FATAL) << "bind failed";
exit(BIND_ERR);
}
关键说明:
-
INADDR_ANY(0.0.0.0):表示绑定主机上所有网卡的IP,比如服务器有两个网卡(IP1=192.168.1.100,IP2=10.0.0.100),客户端可以通过任意一个IP连接服务端;
-
SO_REUSEADDR选项:服务端重启时,若之前的端口还处于"TIME_WAIT"状态(TCP连接关闭后的延迟),设置该选项可以强制复用端口,避免"address already in use"错误。
2.2.3 listen函数:让服务端"开始等待客户端连接"
功能:将"主动态Socket"(默认创建的Socket是主动态,用于发起连接)转为"被动态Socket"(用于监听客户端连接),并维护一个"连接请求队列",暂存客户端的连接请求。
通俗类比:listen就像让商店的"门童"正式上岗------门童(listen)站在门口(绑定的端口),开始观察有没有顾客(客户端)来,同时在门口划一块区域(连接请求队列),顾客来了先排队,等服务员(accept)带进去。
函数原型
cpp
int listen(int sockfd, int backlog);
参数解析
| 参数 | 含义 | 实战用法 |
|---|---|---|
| sockfd | 已绑定地址的Socket文件描述符 | 服务端的监听Socket(必须已调用bind) |
| backlog | "全连接队列"的最大长度(已完成三次握手、等待accept处理的客户端数上限) | 8/16/32(现代Linux中是建议值,内核会根据系统配置调整,默认上限128) |
关键补充:TCP的"两个连接队列"(listen的底层核心)
服务端调用listen后,内核会为该Socket维护两个队列,这是理解TCP连接建立过程的关键:
-
半连接队列(SYN队列):存放"刚发送SYN包、还没完成三次握手"的客户端(比如顾客刚到门口,还没登记);
-
全连接队列(ACCEPT队列):存放"已完成三次握手、等待服务端调用accept提取"的客户端(比如顾客完成登记,在排队区等入座)。
backlog参数限制的是"全连接队列"的长度:如果队列满了,新的客户端连接会被内核拒绝(客户端会报错ECONNREFUSED)。半连接队列的长度由系统内核参数(如tcp_max_syn_backlog)控制,无需我们在代码中设置。
返回值规则
-
成功:返回0(服务端进入监听状态,开始等待客户端连接);
-
失败:返回-1,设置errno(常见错误:未调用bind就调用listen、Socket已被关闭)。
实战示例
cpp
int ret_listen = listen(_listensocketfd, 8); // 全连接队列最大长度为8
if (ret_listen < 0) {
LOG(LogLevel::FATAL) << "listen failed" << strerror(errno);
exit(LISTEN_ERR);
} else {
LOG(LogLevel::DEBUG) << "listen success";
}
关键禁忌:
-
未调用bind直接listen:失败(errno=EINVAL)------就像门童不知道站哪个门口;
-
TCP客户端调用listen:失败------客户端Socket是主动态,用于发起连接,无需监听;
-
UDP服务端调用listen:无用------UDP是无连接协议,没有"连接请求"的概念。
2.2.4 accept函数:"接收"客户端连接,创建专属通信通道
功能:从listen维护的"全连接队列"中,提取一个已完成三次握手的客户端连接请求,同时创建一个新的Socket文件描述符(通信Socket)------这个新Socket是服务端与该客户端后续收发数据的"专属通道"。
通俗类比:accept就像门童(listen)喊一名服务员(新的通信Socket),专门接待排队的顾客(客户端),服务员和顾客一对一服务,门童继续回去看排队的人。
函数原型
cpp
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
参数解析(重点关注输出型参数)
| 参数 | 含义 | 实战用法 |
|---|---|---|
| sockfd | listen后的监听Socket文件描述符(门童的工作岗位) | 服务端的_listensocketfd |
| addr | 输出型参数:存储连接成功的客户端的IP+端口 | 传入空的struct sockaddr_in指针(需提前初始化);若不需要客户端地址,传NULL |
| addrlen | 输入输出型参数:输入addr结构体长度,输出实际填充的长度 | 传入&client_len(client_len=sizeof(struct sockaddr_in));若addr传NULL,该参数也传NULL |
返回值规则(核心!)
| 返回值情况 | 含义 |
|---|---|
| 成功(返回非负整数) | 返回新的通信Socket文件描述符(服务员),后续用这个Socket与该客户端单独通信 |
| 失败(返回-1) | 设置errno(常见错误:监听Socket被关闭、被信号中断) |
关键特性:accept是阻塞函数
默认情况下,若全连接队列为空,accept会一直阻塞(门童没看到顾客就站着等),直到有新的客户端完成三次握手进入队列。这是服务端"等待连接"的核心逻辑。
实战示例
cpp
// 循环接收客户端连接(服务端核心循环之一)
while (_isrunning) {
struct sockaddr_in client;
bzero(&client, sizeof(client)); // 清空客户端地址结构体
socklen_t len = sizeof(client);
// 接收连接:若队列空,会阻塞在这里
int ret_accept = accept(_listensocketfd, (struct sockaddr*)&client, &len);
if (ret_accept < 0) {
LOG(LogLevel::ERROR) << "accept failed!!!";
continue; // 一次接收失败不影响下次,用continue跳过
}
// 解析客户端IP和端口(网络字节序→主机字节序)
char *client_ip = inet_ntoa(client.sin_addr); // 网络IP→字符串IP
uint16_t client_port = ntohs(client.sin_port); // 网络端口→主机端口
LOG(LogLevel::INFO) << "client connected: " << client_ip << ":" << client_port;
// 后续:用ret_accept(通信Socket)与该客户端通信
}
核心注意点:监听Socket(_listensocketfd)和通信Socket(ret_accept)不可混用:
-
监听Socket:只负责"等待客户端连接",全程不参与数据收发;
-
通信Socket:只负责与"对应的客户端"收发数据,一个客户端对应一个通信Socket。
2.3 客户端核心函数:socket → connect
客户端的核心职责是:"找到服务端的地址,主动发起连接,连接成功后与服务端收发数据"。对应的函数调用顺序是:创建套接字(socket)→ 发起连接(connect)→ 收发数据(read/write)。
注意:客户端不需要调用bind函数!首次发送数据时,操作系统会自动为客户端Socket绑定"本机出口IP+随机未被占用的端口"------这是因为客户端不需要固定端口(服务端只需根据连接表项回包即可),显式bind反而容易引发端口冲突(比如同一台机器启动多个客户端,都绑定同一个端口会失败)。
2.3.1 connect函数:主动"拨号"连接服务端
功能:客户端发起TCP三次握手,与服务端建立可靠连接(核心用途);若用于UDP,仅绑定服务端地址(无实际连接)。
通俗类比:connect就像给服务端"打电话"------拨号(传入服务端地址)后,等待对方接听(三次握手完成),接通后才能聊天(通信)。
函数原型
cpp
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数解析(与sendto函数高度相似)
| 参数 | 含义 | 实战用法 |
|---|---|---|
| sockfd | 客户端创建的Socket文件描述符 | 客户端的sockfd(由socket函数创建) |
| addr | 指向服务端地址结构体的指针(存储服务端IP+端口) | 用struct sockaddr_in填充服务端地址后强制转换 |
| addrlen | addr指向的结构体的实际长度 | sizeof(struct sockaddr_in) |
返回值规则
| 返回值情况 | TCP场景含义 | UDP场景含义 |
|---|---|---|
| 成功(返回0) | 三次握手完成,客户端与服务端已建立连接 | 成功绑定服务端地址,无实际连接 |
| 失败(返回-1) | 设置errno(常见错误:服务端未启动、网络不通、服务端地址错误) | 设置errno(常见错误:服务端地址错误) |
TCP connect的核心作用(必懂)
很多初学者觉得"connect只是发起连接",但它的作用远不止于此:
-
建立双向可靠通信通道:connect成功后,双方确认"彼此可达",并协商好序列号、窗口大小等关键参数,为后续"有序、不丢、不重发"传输打下基础;
-
简化后续通信:连接建立后,客户端无需再用sendto(指定服务端地址),直接用send/read收发数据------内核会自动把数据发给连接绑定的服务端;
-
保证通信唯一性:TCP连接是"客户端IP+端口 ↔ 服务端IP+端口"的唯一配对,服务端能精准识别消息来源,不会出现数据串流;
-
提前发现错误:connect会直接返回成功/失败,比如服务端未启动(errno=ECONNREFUSED)、网络不通(errno=ETIMEDOUT),客户端能立刻知道,不用等到发数据时才发现问题。
实战示例
cpp
// 1. 解析命令行参数(服务端IP和端口)
if (argc != 3) {
std::cerr << "Usage: " << argv[0] << " server_ip server_port" << std::endl;
exit(USAGE_ERR);
}
std::string server_ip = argv[1];
int port = std::stoi(argv[2]);
// 2. 创建客户端TCP Socket
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
std::cerr << "socket error" << std::endl;
exit(SOCKET_ERR);
}
// 3. 填充服务端地址信息
struct sockaddr_in server;
bzero(&server, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(port); // 服务端端口(转为网络字节序)
// 字符串IP转为网络字节序(inet_pton:presentation → numeric)
int ret_pton = inet_pton(AF_INET, server_ip.c_str(), &server.sin_addr);
if (ret_pton <= 0) {
LOG(LogLevel::ERROR) << "invalid server IP: " << server_ip << strerror(errno);
close(sockfd);
exit(INET_PTON_ERR);
}
// 4. 发起连接(connect)
int ret_connect = connect(sockfd, (struct sockaddr*)&server, sizeof(server));
if (ret_connect < 0) {
LOG(LogLevel::ERROR) << strerror(errno);
close(sockfd);
exit(CONNECT_ERR);
}
关键说明:客户端的socket创建与服务端完全一致(都是AF_INET+SOCK_STREAM+0),唯一区别是客户端调用connect,服务端调用bind/listen/accept。
2.4 数据收发函数:read/write 与 recv/send
TCP连接建立后,双方通过"字节流"方式收发数据,常用函数有两组:read/write(通用文件操作函数)和recv/send(专门的网络收发函数)。这两组函数在TCP场景下用法几乎一致,核心区别是recv/send多了一个"flags"参数(一般设为0,与read/write效果相同)。
2.4.1 read函数:从Socket读取数据(接收数据)
cpp
ssize_t read(int fd, void *buf, size_t count);
参数与返回值
-
fd:Socket文件描述符(通信Socket,不是监听Socket);
-
buf:存储读取数据的缓冲区;
-
count:缓冲区最大长度(避免溢出);
-
返回值:
-
大于0:实际读取到的字节数;
-
等于0:对端关闭连接(读到文件结尾,类似管道关闭);
-
小于0:读取失败,设置errno(常见错误:Socket被关闭、被信号中断)。
-
2.4.2 write函数:向Socket写入数据(发送数据)
cpp
ssize_t write(int fd, const void *buf, size_t count);
参数与返回值
-
fd:Socket文件描述符(通信Socket);
-
buf:要发送的数据缓冲区;
-
count:要发送的数据长度;
-
返回值:
-
大于0:实际发送的字节数(可能小于count,因为网络拥堵);
-
小于0:发送失败,设置errno(常见错误:对端关闭连接、Socket被关闭)。
-
2.4.3 实战示例(你的代码中收发逻辑)
服务端接收客户端消息并回复(ServerFunc函数):
cpp
void ServerFunc(int ret_accept) {
while (_isrunning) {
char buff[1024] = {0};
// 读取客户端消息(从通信Socket ret_accept读取)
int ret_read = read(ret_accept, buff, sizeof(buff)-1);
if (ret_read > 0) {
buff[ret_read] = '\0'; // 手动添加字符串结束符
LOG(LogLevel::INFO) << "recv from client: " << buff;
} else if (ret_read == 0) {
std::cout << "client disconnected" << std::endl;
break; // 客户端关闭连接,退出循环
} else {
LOG(LogLevel::ERROR) << "read failed!";
break;
}
// 回调函数处理消息(这里是简单的拼接"hello, ")
std::string result = _func(buff);
// 回复客户端消息(向通信Socket ret_accept写入)
int ret_write = write(ret_accept, result.c_str(), result.size());
if (ret_write < 0) {
LOG(LogLevel::ERROR) << "write data failed!";
close(ret_accept);
break;
}
}
close(ret_accept); // 退出循环后,关闭通信Socket
}
客户端发送消息(Send函数):
cpp
void Send() {
std::string input;
while(true) {
{
LockGuard slock(gmutex); // 互斥锁:避免输出混行
std::cout << "please enter# ";
std::cout.flush();
}
getline(std::cin, input);
// 发送消息给服务端(向客户端Socket sockfd写入)
int ret_write = write(sockfd, input.c_str(), input.size());
if (ret_write < 0) {
LOG(LogLevel::ERROR) << "write data failed!" << strerror(errno);
close(sockfd);
break;
}
// 输入QUIT,退出客户端
if (input == "QUIT") {
close(sockfd);
break;
}
}
}
客户端接收服务端回复(Recv函数):
cpp
void Recv() {
char buff[1024];
while(true) {
// 读取服务端回复(从客户端Socket sockfd读取)
int ret_read = read(sockfd, buff, sizeof(buff)-1);
if (ret_read > 0) {
buff[ret_read] = '\0';
// 加互斥锁,避免与Send函数的输出混行
LockGuard slock(gmutex);
std::cout << "\r\033[K[server reply]: " << buff << std::endl;
std::cout << "please enter# ";
std::cout.flush();
} else if (ret_read == 0) {
std::cout << "server disconnected" << std::endl;
break;
} else {
LOG(LogLevel::ERROR) << "read failed!";
break;
}
}
}
关键注意点:
-
缓冲区溢出:read时要预留1个字节存字符串结束符('\0'),避免buff数组越界;
-
对端关闭连接:read返回0时,说明对方已关闭连接(比如客户端输入QUIT后关闭Socket),此时要及时关闭自己的Socket,避免资源泄漏;
-
输出混行:客户端用两个线程(Send和Recv)同时操作控制台,需要用互斥锁(gmutex)保护输出逻辑,否则会出现"please enter#"和服务端回复混在一起的情况。
三、完整实战:TCP服务端与客户端代码全解析
前面我们解析了单个函数的用法,现在从"类封装""多线程处理""错误处理"等角度,拆解服务端和客户端的完整实现逻辑。实现一个"支持多客户端连接"的TCP服务端(用多线程处理每个客户端)和"支持同时收发消息"的客户端(用两个线程分别处理收发)。
3.1 服务端实现:TcpServer类封装(高内聚、低耦合)
服务端代码用TcpServer类封装了所有逻辑,这是工业级代码的常用写法(避免全局变量混乱,便于维护和扩展)。
3.1.1 类成员设计(私有成员+公有方法)
TcpServer类采用"私有成员存储核心状态,公有方法提供对外接口"的设计模式,符合面向对象的"封装性"原则。我们先梳理完整的类成员结构:
cpp
class TcpServer {
private:
int _listensocketfd; // 监听Socket文件描述符(门童的岗位标识)
int _port; // 服务端绑定的端口号
std::atomic<bool> _isrunning; // 服务端运行状态(原子变量,避免多线程竞争)
std::function<std::string(std::string)> _func; // 消息处理回调函数(业务逻辑解耦)
pthread_mutex_t _conn_mutex; // 连接管理互斥锁(可选,用于保护客户端连接列表)
std::vector<int> _client_fds; // 客户端通信Socket列表(可选,用于管理所有连接)
public:
TcpServer(int port, std::function<std::string(std::string)> func); // 构造函数
~TcpServer(); // 析构函数(资源清理)
void InitServer(); // 初始化服务端(socket→bind→listen)
void StartServer(); // 启动服务端(循环accept客户端连接)
void StopServer(); // 停止服务端(设置_isrunning=false,关闭所有Socket)
static void* ClientHandleThread(void* arg); // 客户端处理线程函数(静态成员,适配pthread)
void ServerFunc(int client_fd); // 核心业务逻辑(与单个客户端收发数据)
};
各私有成员的核心作用解析:
-
_listensocketfd:唯一的监听Socket,全程只用于接收客户端连接请求,不参与数据收发。生命周期与服务端一致,服务端启动时创建,停止时关闭。
-
_port:服务端绑定的端口号(如8080),由用户通过构造函数传入,确保客户端能精准连接。
-
_isrunning:原子布尔变量(std::atomic<bool>),用于控制服务端核心循环(accept循环和客户端处理循环)的启停。使用原子变量是为了避免多线程环境下的"数据竞争"------比如主线程调用StopServer()修改_isrunning为false时,客户端处理线程能立即感知并退出循环。
-
_func:函数对象(回调函数),用于解耦"网络通信逻辑"和"业务处理逻辑"。比如服务端收到客户端消息后,不直接在ServerFunc中处理,而是调用_func回调函数,用户可根据需求自定义_func(如拼接字符串、解析协议、查询数据库等),这是工业级代码的常用设计。
-
_conn_mutex与_client_fds:可选的连接管理机制。_client_fds存储所有已连接客户端的通信Socket,_conn_mutex用于保护该列表(避免多线程同时增删元素导致崩溃)。比如服务端需要广播消息时,就可以遍历_client_fds向所有客户端发送数据。
公有方法的职责划分(高内聚):
-
构造函数:接收端口号和回调函数,初始化私有成员(如_isrunning设为true,_port赋值)。
-
InitServer():封装socket、bind、listen三步操作,集中处理服务端初始化逻辑,便于后续维护和异常处理。
-
StartServer():核心循环函数,持续调用accept接收客户端连接,每接收一个连接就创建一个新线程处理该客户端的通信(ClientHandleThread)。
-
StopServer():优雅停止服务端------设置_isrunning为false,关闭监听Socket,遍历_client_fds关闭所有客户端通信Socket,释放互斥锁等资源。
-
ClientHandleThread():静态线程函数(因为pthread_create要求线程函数为全局或静态),接收客户端通信Socket作为参数,调用ServerFunc处理具体的收发逻辑。
-
ServerFunc():与单个客户端的通信核心,循环read客户端消息,调用回调函数处理,再用write回复结果,直到客户端断开连接。
3.1.2 核心方法实现:从初始化到启动
我们按"构造→初始化→启动"的流程,逐行解析核心方法的实现逻辑,重点关注资源管理和异常处理。
- 构造函数与析构函数(资源的创建与释放)
cpp
// 构造函数
TcpServer::TcpServer(int port, std::function<std::string(std::string)> func)
: _port(port), _func(func), _isrunning(true), _listensocketfd(-1) {
// 初始化互斥锁(若使用连接管理)
pthread_mutex_init(&_conn_mutex, NULL);
}
// 析构函数(RAII原则:资源获取即初始化,释放即析构)
TcpServer::~TcpServer() {
StopServer(); // 调用StopServer优雅清理资源
pthread_mutex_destroy(&_conn_mutex); // 销毁互斥锁
}
关键设计亮点:
-
初始化列表赋值:将_port、_func等成员在初始化列表中赋值,比在函数体内赋值更高效(避免二次拷贝)。
-
_listensocketfd初始化为-1:用于标记"未创建Socket"状态,避免后续关闭未创建的资源(close(-1)会报错)。
-
RAII原则应用:析构函数自动调用StopServer(),确保无论服务端如何退出(正常退出或异常崩溃),都能释放Socket、互斥锁等资源,避免资源泄漏。
- InitServer():服务端初始化三步曲
该方法封装了socket、bind、listen三个核心函数的调用,集中处理初始化过程中的异常,代码结构清晰:
cpp
void TcpServer::InitServer() {
// 第一步:创建监听Socket
_listensocketfd = socket(AF_INET, SOCK_STREAM, 0);
if (_listensocketfd < 0) {
LOG(LogLevel::FATAL) << "socket create failed: " << strerror(errno);
exit(SOCKET_ERR);
}
// 第二步:设置端口复用(关键优化)
int opt = 1;
if (setsockopt(_listensocketfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) < 0) {
LOG(LogLevel::WARN) << "setsockopt SO_REUSEADDR failed: " << strerror(errno);
}
// 第三步:绑定地址(IP+端口)
struct sockaddr_in server_addr;
bzero(&server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 绑定所有网卡IP
server_addr.sin_port = htons(_port); // 端口转为网络字节序
if (bind(_listensocketfd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
LOG(LogLevel::FATAL) << "bind failed: " << strerror(errno);
close(_listensocketfd); // 绑定失败,关闭已创建的Socket
exit(BIND_ERR);
}
// 第四步:开始监听连接
if (listen(_listensocketfd, 16) < 0) { // 全连接队列长度设为16
LOG(LogLevel::FATAL) << "listen failed: " << strerror(errno);
close(_listensocketfd); // 监听失败,清理资源
exit(LISTEN_ERR);
}
LOG(LogLevel::INFO) << "server init success, listen on port: " << _port;
}
关键注意点(异常处理逻辑):
-
每一步失败后都要"清理已创建的资源":比如socket创建成功但bind失败时,必须关闭_listensocketfd,否则会导致Socket资源泄漏(进程退出前不会自动释放)。
-
setsockopt的错误处理:即使端口复用设置失败,服务端仍可继续启动(仅打印警告),因为这是优化项而非必需项。
- StartServer():服务端核心循环(接收连接+创建线程)
该方法是服务端的"主循环",负责持续接收客户端连接,并为每个客户端创建独立线程处理通信(避免单个客户端阻塞其他客户端):
cpp
void TcpServer::StartServer() {
LOG(LogLevel::INFO) << "server start success, waiting for client connect...";
while (_isrunning) { // 原子变量控制循环启停
struct sockaddr_in client_addr;
bzero(&client_addr, sizeof(client_addr));
socklen_t client_len = sizeof(client_addr);
// 接收客户端连接(阻塞函数,直到有新连接进入全连接队列)
int client_fd = accept(_listensocketfd, (struct sockaddr*)&client_addr, &client_len);
if (client_fd < 0) {
// 处理accept失败场景:若服务端未停止,打印错误后继续循环
if (_isrunning) {
LOG(LogLevel::ERROR) << "accept failed: " << strerror(errno);
}
continue;
}
// 解析客户端地址信息(网络字节序→主机字节序)
char client_ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &client_addr.sin_addr, client_ip, sizeof(client_ip));
uint16_t client_port = ntohs(client_addr.sin_port);
LOG(LogLevel::INFO) << "new client connected: " << client_ip << ":" << client_port;
// (可选)将客户端通信Socket加入连接列表(需加互斥锁)
pthread_mutex_lock(&_conn_mutex);
_client_fds.push_back(client_fd);
pthread_mutex_unlock(&_conn_mutex);
// 为该客户端创建独立线程处理通信
pthread_t tid;
// 传入client_fd作为线程参数(注意:需用指针或结构体,避免值拷贝问题)
if (pthread_create(&tid, NULL, ClientHandleThread, (void*)&client_fd) != 0) {
LOG(LogLevel::ERROR) << "create thread for client failed: " << strerror(errno);
close(client_fd); // 线程创建失败,关闭该客户端连接
// 从连接列表中移除(需加互斥锁)
pthread_mutex_lock(&_conn_mutex);
_client_fds.erase(std::find(_client_fds.begin(), _client_fds.end(), client_fd));
pthread_mutex_unlock(&_conn_mutex);
} else {
// 分离线程(线程结束后自动释放资源,无需主线程pthread_join)
pthread_detach(tid);
}
}
}
核心设计逻辑(多线程处理多客户端):
-
线程分离(pthread_detach):服务端不需要等待客户端线程结束(客户端断开连接后线程自然退出),分离线程后,线程结束时内核会自动回收其资源,避免线程资源泄漏。
-
参数传递注意事项:pthread_create的第四个参数是void*类型,若直接传入client_fd(局部变量),可能因主线程循环过快导致客户端线程拿到错误的fd值。更安全的做法是用动态内存分配(如new int(client_fd))传递参数,在客户端线程结束时释放内存。
-
连接列表管理:加入和移除client_fd时必须加互斥锁(_conn_mutex),因为主线程(StartServer)和客户端线程可能同时操作_list_client_fds(比如客户端断开连接时从列表中移除),避免"并发修改容器"导致的崩溃。
- ClientHandleThread与ServerFunc:客户端通信核心
ClientHandleThread是静态线程函数,负责将客户端fd传递给ServerFunc,并处理线程内部的资源清理;ServerFunc是实际的通信逻辑实现,循环收发数据并调用回调函数处理业务:
cpp
// 静态线程函数(必须为静态,因为pthread_create不支持非静态成员函数)
void* TcpServer::ClientHandleThread(void* arg) {
// 接收客户端通信Socket(假设传入的是new int(client_fd))
int client_fd = *(int*)arg;
delete (int*)arg; // 释放动态分配的内存
// 获取TcpServer实例指针(若需要访问类成员,可通过全局变量或参数传递)
// 此处假设通过全局变量g_server获取(实际开发中可优化为参数传递)
TcpServer* server = &g_server;
// 调用ServerFunc处理与该客户端的通信
server->ServerFunc(client_fd);
// 客户端断开连接后,从连接列表中移除(需加互斥锁)
pthread_mutex_lock(&server->_conn_mutex);
auto it = std::find(server->_client_fds.begin(), server->_client_fds.end(), client_fd);
if (it != server->_client_fds.end()) {
server->_client_fds.erase(it);
}
pthread_mutex_unlock(&server->_conn_mutex);
LOG(LogLevel::INFO) << "client disconnected, close fd: " << client_fd;
pthread_exit(NULL); // 线程退出
}
// 核心通信逻辑:与单个客户端收发数据
void TcpServer::ServerFunc(int client_fd) {
char buff[1024] = {0}; // 接收缓冲区(1024字节,可根据需求调整)
while (_isrunning) {
// 读取客户端发送的消息(阻塞函数,直到有数据或客户端断开)
ssize_t ret_read = read(client_fd, buff, sizeof(buff) - 1); // 预留1字节存'\0'
if (ret_read > 0) {
buff[ret_read] = '\0'; // 手动添加字符串结束符(避免乱码)
LOG(LogLevel::INFO) << "recv from client (fd: " << client_fd << "): " << buff;
// 调用回调函数处理消息(业务逻辑解耦)
std::string result = _func(buff);
// 回复客户端处理结果
ssize_t ret_write = write(client_fd, result.c_str(), result.size());
if (ret_write < 0) {
LOG(LogLevel::ERROR) << "write to client (fd: " << client_fd << ") failed: " << strerror(errno);
break; // 写入失败,退出循环(关闭连接)
}
bzero(buff, sizeof(buff)); // 清空缓冲区,准备下一次读取
} else if (ret_read == 0) {
// ret_read == 0:客户端正常关闭连接(如调用close(sockfd))
LOG(LogLevel::INFO) << "client (fd: " << client_fd << ")主动断开连接";
break;
} else {
// ret_read < 0:读取失败(如Socket被关闭、信号中断)
LOG(LogLevel::ERROR) << "read from client (fd: " << client_fd << ") failed: " << strerror(errno);
break;
}
}
close(client_fd); // 退出循环后,关闭客户端通信Socket
}
关键细节解析:
-
静态线程函数的局限与解决:静态成员函数无法直接访问类的非静态成员(如_isrunning、_func),因此需要通过"传递TcpServer实例指针"或"全局变量"的方式获取类实例。实际开发中更推荐传递实例指针(避免全局变量导致的耦合)。
-
缓冲区处理:read时预留1字节存'\0',因为客户端发送的是字符串消息,手动添加结束符可避免打印时出现乱码(若接收的是二进制数据,无需此操作)。
-
异常退出处理:无论因何种原因(读取失败、写入失败、客户端断开)退出循环,都必须调用close(client_fd)关闭通信Socket,否则会导致文件描述符泄漏(进程的文件描述符有上限,泄漏过多会导致无法创建新的Socket)。
- StopServer():优雅停止服务端
该方法用于优雅停止服务端,避免强制退出导致的资源泄漏(如Socket未关闭、线程未回收):
cpp
void TcpServer::StopServer() {
_isrunning = false; // 设置原子变量为false,终止所有循环(accept循环和客户端通信循环)
// 关闭监听Socket(会唤醒阻塞在accept的主线程,使其退出循环)
if (_listensocketfd >= 0) {
close(_listensocketfd);
LOG(LogLevel::INFO) << "close listen socket: " << _listensocketfd;
}
// 关闭所有客户端通信Socket(强制断开所有连接)
pthread_mutex_lock(&_conn_mutex);
for (int fd : _client_fds) {
close(fd);
LOG(LogLevel::INFO) << "close client socket: " << fd;
}
_client_fds.clear(); // 清空连接列表
pthread_mutex_unlock(&_conn_mutex);
LOG(LogLevel::INFO) << "server stop success";
}
优雅停止的核心逻辑:
-
关闭监听Socket:主线程阻塞在accept时,关闭_listensocketfd会导致accept立即返回-1,此时主线程检查_isrunning为false,会退出accept循环。
-
关闭客户端Socket:客户端线程阻塞在read时,关闭client_fd会导致read返回-1,客户端线程检查_isrunning为false,会退出通信循环并释放资源。
服务端多进程单线程的实现思路:
在 TCP 服务端开发的入门阶段,"如何同时服务多个客户端" 是绕不开的核心问题。单进程单线程方案只能串行处理客户端请求 ------ 一个客户端占着服务端不放,其他客户端只能排队等待,体验极差。而多进程单线程方案,通过创建独立进程为每个客户端提供服务,能实现基础并发;
一、为什么要选 "多进程单线程"?先搞懂核心优势
在讲实现思路前,先明确:为什么不直接用单进程,非要搞多进程?
- 单进程单线程的痛点 :服务端主线程调用
accept拿到客户端连接后,会进入数据收发逻辑(比如read/write),这个过程中主线程被 "占满",无法再调用accept接收新客户端 ------ 哪怕有 100 个客户端想连接,也只能等当前客户端断开后才能处理下一个。 - 多进程的核心优势 :
- 并发能力 :父进程专职 "接待" 客户端(调用
accept),每来一个客户端就创建一个子进程,子进程专职服务该客户端,父进程能立刻回到 "接待位" 继续接收新连接; - 隔离性好:进程是独立的执行单元,一个子进程崩溃(比如处理客户端数据时出错),不会影响父进程和其他子进程,服务端整体稳定性更高;
- 实现简单 :基于 Linux 原生的
fork系统调用,不用复杂的线程库,是入门并发编程的最佳起点。
- 并发能力 :父进程专职 "接待" 客户端(调用
二、常规父子进程方案:看似可行,实则踩满坑
新手最容易想到的思路是 "父进程 accept + 子进程服务",我们先拆解这个思路,看看问题出在哪。
2.1 常规思路的流程(伪代码)
# 伪代码逻辑
while(1):
fd = accept() # 父进程接收客户端连接,拿到通信fd
pid = fork() # 创建子进程
if pid == 0:
# 子进程:服务客户端
ServerFunc(fd) # 处理客户端的收发数据
exit(0) # 服务结束后退出
else:
# 父进程:回收子进程,避免僵尸
waitpid(pid, NULL, 0) # 等待子进程退出
2.2 核心矛盾:"阻塞 accept" 与 "僵尸进程" 二选一
这个看似合理的逻辑,藏着一个无解的矛盾:
- 矛盾 1:如果父进程调用 waitpid------ 阻塞 accept
waitpid是阻塞函数 :父进程调用后会暂停执行,直到对应的子进程退出。这意味着:父进程在回收第一个子进程的过程中,完全没法执行accept接收新客户端 ------ 相当于又回到了 "单进程串行处理" 的状态,多进程的并发优势完全丧失。 - 矛盾 2:如果父进程不调用 waitpid------ 产生僵尸进程 子进程退出后,内核不会立刻释放它的所有资源(比如 PID、进程控制块 PCB),而是会保留这些信息,等待父进程调用
wait/waitpid"回收"。如果父进程一直不回收,这些退出的子进程就会变成 "僵尸进程"------ 占用系统 PID 资源(Linux 系统 PID 数量有限),长期运行会导致系统无法创建新进程,最终服务端崩溃。
这里必须先搞懂:僵尸进程不是 "还在运行的进程",而是 "退出后没被回收的进程残骸" ------ 它不占 CPU、内存,但占 PID,是系统的 "资源垃圾"。
2.3 新手的疑问:有没有两全其美的办法?
既要父进程不阻塞、能持续 accept 新客户端,又要避免僵尸进程 ------ 这就是多进程单线程方案要解决的核心问题。接下来我们讲两种解法:一种是 "绕弯路但易懂" 的父 - 子 - 孙三代进程方案,另一种是 "简洁高效" 的信号方案。
三、解法 1:父 - 子 - 孙三代进程 ------ 绕开阻塞,杜绝僵尸
这个方案的核心思路是 "用两次 fork 做中转",让父进程的waitpid几乎不阻塞,同时让服务客户端的进程变成 "孤儿进程",由系统自动回收,彻底杜绝僵尸进程。
我们用一个通俗的比喻理解角色分工:
- 父进程:大堂经理,只负责 "接待客户"(调用 accept),不做具体服务;
- 子进程:临时工,唯一任务是 "招正式客服"(创建孙子进程),招完立刻下班;
- 孙子进程:正式客服,专职服务对应的客户端,服务完自动离职。
3.1 三代进程的完整执行逻辑
-
父进程(大堂经理):接待客户 + 招临时工父进程循环调用
accept,拿到和客户端通信的 fd(相当于 "服务单");然后调用fork创建子进程(临时工)------ 此时子进程会继承父进程的所有资源,包括这个客户端 fd(不用额外传递)。 -
子进程(临时工):招正式客服 + 立刻下班子进程创建后,不做任何服务工作,立刻再调用
fork创建孙子进程(正式客服);创建完成后,子进程直接调用exit(0)退出 ------ 这个过程快到可以忽略不计。 -
父进程:快速回收临时工父进程调用
waitpid等待子进程退出,由于子进程 "招完人就下班",waitpid几乎瞬间完成,父进程立刻回到循环开头,继续accept新客户端,完全不阻塞。 -
孙子进程(正式客服):服务客户 + 系统自动回收孙子进程继承了客户端 fd,调用
ServerFunc处理和客户端的通信;由于它的父进程(子进程)已经退出,孙子进程会变成 "孤儿进程"------Linux 系统规定:所有孤儿进程会被 1 号进程(init/systemd,系统管家)接管,当孙子进程服务完客户端退出后,1 号进程会自动调用waitpid回收它的资源,绝不会变成僵尸进程。
3.2 核心逻辑拆解
我们代码注释,再把这个逻辑讲透:
int pid=fork(); // 父进程创建子进程(临时工)
if(pid<0) {
LOG(LogLevel::FATAL)<<"fork failed!!"; // 创建失败直接报错
} else if(pid==0) {
// 子进程(临时工)的逻辑
int ppid=fork(); // 子进程创建孙子进程(正式客服)
if(ppid>0) {
// 子进程:创建完孙子进程,立刻退出(下班)
exit(0);
} else if(ppid==0) {
// 孙子进程:继承客户端fd,专职服务客户端
ServerFunc(ret_accept);
}
}
// 父进程:等待子进程退出(回收临时工),几乎瞬间完成
waitpid(pid,nullptr,0);
这里有两个关键细节必须理解:
- 为什么孙子进程能拿到客户端 fd? Linux 中
fork创建子进程时,会复制父进程的 "文件描述符表"------ 父进程的accept返回的 fd,子进程有一份,孙子进程又从子进程那里继承一份,所以不用手动传递 fd,孙子进程能直接用。 - 为什么孤儿进程不会成僵尸? 1 号进程是系统的 "进程管家",会循环调用
waitpid回收所有孤儿进程的退出资源,相当于替我们完成了 "回收工作",不用父进程操心。
3.3 三代进程方案的优缺点
优点:
- 彻底解决矛盾 :父进程的
waitpid几乎不阻塞,能持续 accept 新客户端;孙子进程由系统回收,无僵尸进程; - 无需接触信号:不用理解复杂的信号机制,纯进程操作,新手易上手;
- 隔离性拉满:每个孙子进程独立服务一个客户端,一个客户端出问题(比如发送非法数据导致进程崩溃),不影响其他进程和父进程。
缺点:
- 系统开销大 :
fork是重量级系统调用 ------ 每次创建进程,内核要复制父进程的地址空间、文件描述符表等资源,两次fork的开销比单次fork大很多; - 调试难度高:多了一层子进程中转,排查问题时(比如某个客户端服务失败),需要区分父 / 子 / 孙进程的日志,新手容易晕;
- 资源利用率低:每个客户端对应一个独立进程,进程的内存占用远高于线程,系统能支持的并发数有限(比如几百个进程就可能占满内存)。
四、解法 2:信号机制 ------ 更简洁的最优解
三代进程方案虽然能解决问题,但终究是 "绕弯路"。其实 Linux 早就给我们准备了更高效的解法:利用SIGCHLD信号自动回收子进程,既不用两次fork,也不用父进程阻塞。
4.1 信号方案的核心原理
先记住一个关键知识点:子进程退出时,会向父进程发送一个SIGCHLD信号。
默认情况下,父进程对SIGCHLD信号的处理动作是 "忽略",但这并不影响僵尸进程的产生;而如果我们主动把SIGCHLD信号的处理动作设置为 "忽略"(注意:是显式设置),Linux 内核会做一个特殊处理 ------自动回收所有发送SIGCHLD信号的子进程资源,不用父进程调用waitpid。
4.2 信号方案的完整流程
-
父进程初始化:设置忽略 SIGCHLD 信号 服务端启动时,先执行一行代码,告诉系统:"子进程退出时的 SIGCHLD 信号,我直接忽略,你帮我回收资源"。
// 显式设置忽略SIGCHLD信号,内核自动回收子进程 signal(SIGCHLD, SIG_IGN); -
父进程循环 accept + 创建子进程 父进程不用调用
waitpid,创建子进程后直接回到accept循环,完全不阻塞。while(1) { int fd = accept(); // 接收客户端连接 pid_t pid = fork(); // 创建子进程 if(pid == 0) { // 子进程:服务客户端,退出后内核自动回收 ServerFunc(fd); close(fd); // 子进程关闭fd,避免资源泄漏 exit(0); } else if(pid > 0) { // 父进程:不用waitpid,直接继续accept close(fd); // 父进程用不到这个fd,提前关闭 continue; } else { // fork失败,容错处理 LOG(LogLevel::ERROR) << "fork failed: " << strerror(errno); close(fd); } }
4.3 信号方案的核心优势
对比三代进程方案,信号方案的优势一目了然:
- 开销极小 :只需一次
fork,省去了 "子进程中转" 的步骤,系统调用开销减半; - 逻辑极简:代码量少,没有复杂的进程嵌套,调试时只需关注父进程和子进程;
- 效率更高:父进程完全不用暂停,能以最快速度接收新客户端,并发能力更强。
这里补充一个新手易忽略的细节:父进程创建子进程后,要立刻close(fd)------ 因为父子进程都持有同一个 fd,父进程用不到的话提前关闭,避免子进程退出后,父进程的 fd 还占用系统资源。
五、多进程单线程方案的核心总结
不管是 "父 - 子 - 孙三代进程" 还是 "信号机制",多进程单线程服务端的核心逻辑可以总结为 3 句话:
- 并发的核心是 fork :通过
fork创建独立进程,让每个进程服务一个客户端,实现基础并发; - 核心矛盾是 "阻塞" 与 "僵尸":解决这个矛盾的关键是 "让父进程不阻塞的同时,确保子进程资源被回收";
- 信号方案是最优解:三代进程是 "绕弯路" 的入门解法,信号机制是 Linux 原生的高效解法,掌握后者能应对绝大多数场景。
同时也要清楚多进程方案的局限性:进程创建和切换的开销大,适合并发量不高的场景(比如几十到几百个客户端);如果要支持上万级并发,需要学习多线程或 IO 复用(epoll)方案 ------ 但多进程是并发编程的基础,理解透了再学其他方案会事半功倍。
六、避坑指南
最后给几个实用建议,避免踩坑:
- 一定要关闭无用的 fd :父子进程继承的 fd,不用的要及时
close,否则会导致文件描述符泄漏,服务端运行一段时间后无法接收新连接; - 容错处理不能少 :
fork可能失败(比如系统资源不足),失败后要关闭客户端 fd,避免资源泄漏; - 日志要标记进程 ID :在日志中打印
getpid()(当前进程 ID),方便排查 "哪个进程服务哪个客户端" 的问题;
多进程单线程是 TCP 服务端并发的 "入门第一课",虽然它不是最高效的方案,但能帮我们理解 Linux 进程的核心特性。掌握了这个思路,后续学习多线程、epoll 等高级并发方案时,会更容易理解 "为什么需要这些方案""它们解决了多进程的什么痛点"------ 这才是学习的核心。
cpp
//version 2------多进程单线程调用
//那么毋庸置疑,创建父子进程就行
//父进程去嗷嗷accept,父进程每accept一个,就创建出一个子进程去服务accept的客户端
//因为是父子进程,所以父进程能看到的socket套接字文件描述符,子进程也能看得到
//所以不用再传给子进程
//但是这样子也还是有个问题,就是你父进程得等待子进程退出吧,不然就会造成僵尸进程
//可问题要是我们就是直接在父进程调用waitpid函数的话,那么waitpid函数可是阻塞等待的
//也就是说在子进程退出之前,父进程是会阻塞等待,也就不会回到循环开头去accept
//那么这和单进程单线程有什么区别!!!
//所以啊,我们不应该让父进程阻塞等待,可是父进程又必须去等待以防僵尸进程
//那么怎么办呢???其实聪明的你很快就能想到
//用信号,子进程退出是会发信号给父进程的,而我们之前也有讲过
//可以将此信号的处理动作设置为忽略,那么父进程就不会阻塞等待
//会自动的当子进程退出了,再去等待回收了
//这确实是一个方法,但是今天我就是不想用怎么办???
//诶,还真有新方法,那就是让孙子进程去服务客户端
//具体是这样的:父进程创建子进程,然后父进程调用waitpid函数阻塞等待子进程退出
//而子进程要干的就是再创建一个子进程,即孙子进程,创建完之后,子进程直接退出!!!
//孙子进程去服务客户端,而由于子进程在创建完孙子进程后直接退出,所以父进程的阻塞等待相当于是没有销毁时间
//如此一来,就能达到上面我们所说的目的!!!
//那么孙子进程怎么办???它其实就是成为了孤儿进程
//而我们知道,孤儿进程是交给1号进程处理的,所以不用我们管!!!
//那么这就是一个极佳的方法
//当然这样子其实也挺麻烦的,毕竟创建子进程终究是系统调用会耗时
//所以最好的方法肯定还是使用信号!!!
// int pid=fork();
// if(pid<0)
// {
// LOG(LogLevel::FATAL)<<"fock failed!!";
// }
// else if(pid==0)
// {
// //子进程
// //创建孙子进程
// int ppid=fork();
// if(ppid>0)
// {
// //子进程
// //直接退出
// exit(0);
// }
// else if(ppid==0)
// {
// //孙子进程
// //进行服务端函数的处理
// ServerFunc(ret_accept);
// }
// }
// //父进程
// //等待子进程的退出
// //其实秒等待完
// waitpid(pid,nullptr,0);
服务端单进程多线程的实现思路:
在 TCP 服务端开发中,多进程方案虽然能实现并发,但进程创建和切换的开销太大 ------ 系统要为每个进程复制内存、分配 PID,高并发下很容易扛不住。而单进程多线程方案完美解决了这个问题:线程是轻量级的执行单元,共享进程的内存空间,创建和切换成本极低,是入门阶段实现 "同时服务多个客户端" 的最优选择。
今天就用最通俗的语言,拆解单进程多线程服务端的完整实现思路,解决新手最头疼的几个核心问题,全程无超纲内容,看完就能懂。
一、核心目标(先明确我们要做什么)
我们要实现的效果很简单:
- 一个主进程(主线程):专职做 "前台接待",循环调用
accept接收客户端的连接请求,拿到和客户端通信的 "管道"(fd,文件描述符); - 每来一个客户端:主线程就创建一个 "专属客服" 线程,让这个线程专门服务该客户端(收发数据);
- 主线程不阻塞:创建完线程后立刻回到 "接待台",继续接收下一个客户端的连接;
- 无资源泄漏:线程服务完客户端后,系统能自动回收资源,不产生 "僵尸线程"。
二、实现过程中要解决的 3 个核心问题(新手必踩的坑)
用线程原生接口(pthread 库)实现这个逻辑,会遇到 3 个看似 "无解" 的问题,我们逐个拆解,先讲问题,再给通俗的解决思路。
问题 1:线程函数的 "签名限制"------ 类成员函数没法直接用
pthread 库的pthread_create(创建线程)要求:线程执行函数必须是「返回void*、只有一个void*形参」的函数。
但我们的服务逻辑(比如处理客户端消息的ServerFunc)通常写在TcpServer类里,而类的非静态成员函数有个隐藏的this指针(相当于多了一个参数),比如:
// 类的非静态成员函数,隐藏this指针,不符合线程函数要求
void TcpServer::ServerFunc(int fd) { ... }
这个隐藏的this指针让函数签名和pthread_create的要求不匹配,直接用会报错。
解决思路:把线程函数设为 static 静态函数
static静态成员函数的特点是:不属于某个对象,没有隐藏的 this 指针 ,完全符合pthread_create的函数签名要求。比如:
// static函数,无隐藏this指针,符合线程函数签名
static void* TcpServer::routine(void* arg) { ... }
这一步先解决 "函数能传给线程" 的问题。
问题 2:static 函数没法调用类的非静态成员(比如 ServerFunc)
static 函数虽然符合签名要求,但它是 "类级别的函数",不能直接访问类的非静态成员(比如ServerFunc、对象的属性)------ 而我们的核心服务逻辑ServerFunc恰恰是非静态的(需要对象才能调用)。
更关键的是:线程不仅需要调用ServerFunc,还需要知道「服务哪个客户端」(即accept返回的通信 fd)。但线程函数只能传一个void*参数,怎么同时传 "服务端对象的 this 指针" 和 "客户端 fd"?
解决思路:封装一个 "数据载体",给线程打包传参
既然线程函数只能收一个参数,那我们就把需要的东西都 "打包" 成一个类(比如ServerData),相当于给线程递一个 "快递包裹",里面既有:
_self:服务端对象的this指针(用来找到ServerFunc函数);_ret_accept:客户端通信 fd(用来知道服务哪个客户端)。
这个 "包裹" 的设计非常简单:
// 线程参数的"快递包裹":装this指针和客户端fd
class ServerData {
public:
// 构造函数:把需要的东西装进去
ServerData(int fd, TcpServer* server) : _ret_accept(fd), _self(server) {}
int _ret_accept; // 和客户端通信的fd(accept的返回值)
TcpServer* _self; // 服务端对象的this指针
};
注意:这个对象必须在堆上创建 (用new),不能用栈上的临时变量 ------ 栈变量会随着主线程的循环被销毁,线程拿到的就是无效数据(相当于快递包裹还没送到就被拆了)。
问题 3:线程创建后,怎么避免主线程阻塞,又不产生僵尸线程?
如果用pthread_join等待线程结束,主线程会被阻塞(和多进程用waitpid的问题一模一样),没法继续accept新客户端;但如果不join,线程退出后资源没被回收,会变成 "僵尸线程"(内存泄漏)。
解决思路:用pthread_detach分离线程
这是最巧妙的一步:让线程自己 "分家"------ 线程创建后,立刻调用pthread_detach(pthread_self())(pthread_self()获取当前线程 ID),告诉系统:"我运行完后,你自动回收我的资源,不用主线程等我"。
这样既不用主线程join(不阻塞),又不会产生僵尸线程(系统自动回收),完美解决矛盾。
三、完整实现思路(一步一步走,清晰不绕)
把上面的解决思路串起来,就是单进程多线程服务端的完整实现流程,像搭积木一样简单:
步骤 1:主线程(单进程)循环 "接待" 客户端
主线程的核心工作只有一个:死循环调用accept,接收客户端的连接请求,拿到和客户端通信的 fd(记为ret_accept)。这一步是 "前台接待" 的核心,不做任何服务逻辑,保证主线程不被阻塞。
步骤 2:给新客户端打包 "服务参数"
创建ServerData对象(堆上),把「服务端对象的 this 指针」和「客户端通信 fd」装进去 ------ 相当于给 "专属客服" 线程准备好工作所需的全部信息。
// 堆上创建,避免临时变量销毁
ServerData* data = new ServerData(ret_accept, this);
步骤 3:创建 "专属客服" 线程
调用pthread_create创建线程,把打包好的ServerData对象转成void*(线程函数要求的参数类型),传给 static 线程函数:
pthread_t tid;
// 创建线程,把data传给static的routine函数
int ret = pthread_create(&tid, nullptr, routine, static_cast<void*>(data));
// 容错:创建失败就释放资源,不影响主线程
if (ret != 0) {
delete data; // 释放包裹
close(ret_accept); // 关闭客户端管道
continue; // 继续接待下一个客户端
}
步骤 4:线程 "上岗" 前先 "分家"(detach)
线程函数routine被调用后,第一步就是调用pthread_detach分离自己,避免主线程阻塞,也避免僵尸线程:
static void* routine(void* arg) {
// 第一步:分离线程,让系统自动回收资源
pthread_detach(pthread_self());
// 后续步骤...
}
步骤 5:线程解析 "包裹",开始服务客户端
把传入的void*参数转回ServerData类型,拿到 this 指针和客户端 fd,调用ServerFunc处理和客户端的通信(收发数据):
static void* routine(void* arg) {
pthread_detach(pthread_self());
// 拆包裹:把void*转回ServerData
ServerData* data = static_cast<ServerData*>(arg);
// 调用服务逻辑:通过this指针调用ServerFunc,传入客户端fd
data->_self->ServerFunc(data->_ret_accept);
// 服务结束:释放包裹(堆内存),避免泄漏
delete data;
return nullptr;
}
步骤 6:线程服务结束,资源自动回收
当客户端断开连接,ServerFunc执行完毕,线程会走到delete data释放内存,然后返回nullptr退出 ------ 因为之前已经 detach 了,系统会自动回收线程的所有资源,主线程全程没感知,还在继续accept新客户端。
cpp
//version 3------单进程多线程调用
//那么创建多进程终究负担比较大,不如创建多线程
//所以,我们可以使用多线程去使用,让一个线程去服务一个客户端
//那么问题来了,我们是可以使用我们已经封装好了的线程文件
//但是你说,诶,我就是不想,我就是想用线程原生接口怎么办!!!
//那简单,就干呗!!!!!!
//但是使用线程原生接口就会有个麻烦,那就是线程的执行函数要求只能有一个形参
//而你写在类里面的函数默认就会有个this指针的形参,那么这个要怎么办呢???
//其实很简单,我们把该函数设置为static就行
//可是如此一来就又有问题了,static函数是不能直接访问到类里面的函数的
//那么服务端类里面的处理函数要怎么在线程执行函数里面被调用呢???
//所以啊,我们得给线程的执行函数里面传入this指针才行哦,但是这些还不够
//因为线程是服务于客户端的,而我们也知道,服务端的处理函数是需要
//传入通信套接字文件描述符的,不然就不能正确的服务于对应的客户端
//所以仅仅给线程的执行函数传入this是不够的,还得传入accept函数的返回值才行
//可是线程的执行函数只能有一个void*形参啊!!!!
//这可怎么办,其实很简单,那就就是封装一个类
//这个类里面要有this,有accept函数的返回值
//每次在执行完accept函数之后,就创建一个该类,然后把this和函数的返回值传该类变量
//然后把这个类变量转换为void*之后再传给线程执行函数,
//最后在线程执行函数里通过该类变量去运行服务端处理函数
//这就是一个极佳的方法
//那么还有一个问题,就是创建了线程之后,得join吧,不然就可能出现僵尸线程
//可是你join,不就又是阻塞等待了吗,那么不就是和上面的进程等待一样的问题吗!!!
//那么这可怎么办???
//很简单,可不要忘记了线程的detach分离函数!!!
//用了它之后,线程就不必被join了!!!
//所以,实现单进程多线程的大致思路就是如上,其实还是很清晰的!!!
//先创建类变量
//要使用new出来的,不然直接创建的话,就是属于栈上的临时变量,可能会被销毁
ServerData* self = new ServerData(ret_accept,this);//传入accept函数返回值以及this指针
pthread_t t;
int ret_pthread_create = pthread_create(&t,nullptr,routine,static_cast<void*>(self));
if (ret_pthread_create != 0)
{
LOG(LogLevel::ERROR) << "pthread_create failed: " << strerror(ret_pthread_create);
delete self; // 释放堆内存
close(ret_accept); // 关闭通信fd
continue;//一次创建失败不影响下次的创建,所以使用continue
}
//就是这么的简单 //但是使用线程原生接口就会有个麻烦,那就是线程的执行函数要求只能有一个形参
//而你写在类里面的函数默认就会有个this指针的形参,那么这个要怎么办呢???
//其实很简单,我们把该函数设置为static就行
//可是如此一来就又有问题了,static函数是不能直接访问到类里面的函数的
//那么服务端类里面的处理函数要怎么在线程执行函数里面被调用呢???
//所以啊,我们得给线程的执行函数里面传入this指针才行哦,但是这些还不够
//因为线程是服务于客户端的,而我们也知道,服务端的处理函数是需要
//传入通信套接字文件描述符的,不然就不能正确的服务于对应的客户端
//所以仅仅给线程的执行函数传入this是不够的,还得传入accept函数的返回值才行
//可是线程的执行函数只能有一个void*形参啊!!!!
//这可怎么办,其实很简单,那就就是封装一个类
//这个类里面要有this,有accept函数的返回值
//每次在执行完accept函数之后,就创建一个该类,然后把this和函数的返回值传该类变量
//然后把这个类变量转换为void*之后再传给线程执行函数,
//最后在线程执行函数里通过该类变量去运行服务端处理函数
class ServerData
{
public:
//构造函数
ServerData(int ret_accept,TcpServer* self)
:_ret_accept(ret_accept)
,_self(self)
{}
//析构函数
~ServerData()=default;
public:
int _ret_accept;//accept函数的返回值
TcpServer* _self;//用来接收this指针
};
//线程执行函数
//要为静态函数,然后在函数内部去把传来的void*进行转换
//然后执行线程的detach函数,然后执行服务端的处理函数
static void* routine(void* self)
{
//先将线程进行分离
pthread_detach(pthread_self());//pthread_self()获取自身线程pid
//将参数转换为原本的
ServerData* server=static_cast<ServerData*>(self);
//运行服务端的处理函数
server->_self->ServerFunc(server->_ret_accept);
delete server;//要记得删除创建的内存哦,因为出了服务端的处理函数之后就代表说客户端不干了,所以得把我们申请的资源释放
return nullptr;
}
四、核心思路总结(3 句话记住关键)
- static 函数适配线程签名:用 static 线程函数解决 "类成员函数有 this 指针" 的问题,符合 pthread 库的要求;
- 打包参数传 this+fd:用 ServerData 类把 "服务端对象(this)" 和 "客户端管道(fd)" 打包,解决 static 函数无法访问类成员、线程函数只能传一个参数的问题;
- detach 避免阻塞和僵尸线程:线程自分离,既不阻塞主线程的 accept,又能让系统自动回收线程资源。
3.2 客户端实现:双线程收发(支持同时发送和接收消息)
客户端代码采用"主线程解析参数+发起连接,两个子线程分别处理发送(Send)和接收(Recv)"的设计,解决了"单线程下收发相互阻塞"的问题(比如单线程中调用read会阻塞,无法同时输入消息发送)
3.2.1 客户端全局变量与核心函数声明
cpp
// 全局变量(简化设计,实际开发中可封装为类)
int sockfd; // 客户端通信Socket(连接建立后用于收发数据)
pthread_mutex_t gmutex; // 控制台输出互斥锁(避免Send和Recv线程输出混行)
enum ErrorCode {
USAGE_ERR = 1,
SOCKET_ERR,
INET_PTON_ERR,
CONNECT_ERR
};
// 日志宏定义(简化版,与服务端一致)
#define LOG(level) std::cout << "[" << #level << "] "
// 互斥锁封装(RAII原则,自动加锁解锁)
class LockGuard {
public:
LockGuard(pthread_mutex_t& mutex) : _mutex(mutex) {
pthread_mutex_lock(&_mutex);
}
~LockGuard() {
pthread_mutex_unlock(&_mutex);
}
private:
pthread_mutex_t& _mutex;
};
// 发送消息线程函数
void* Send(void* arg);
// 接收消息线程函数
void* Recv(void* arg);
关键设计说明:
-
全局变量sockfd:客户端只有一个通信Socket(连接服务端后创建),Send和Recv线程共享该fd(TCP连接是双向的,同一个fd可读写)。
-
LockGuard类:封装互斥锁的加锁和解锁操作,采用RAII原则------创建LockGuard对象时自动加锁,对象销毁时自动解锁,避免因忘记解锁导致的死锁。
3.2.2 客户端初始化与连接流程(main函数)
cpp
int main(int argc, char* argv[]) {
// 1. 解析命令行参数(服务端IP和端口)
if (argc != 3) {
std::cerr << "Usage: " << argv[0] << " server_ip server_port" << std::endl;
exit(USAGE_ERR);
}
std::string server_ip = argv[1];
int server_port = std::stoi(argv[2]);
// 2. 初始化互斥锁(用于控制台输出同步)
pthread_mutex_init(&gmutex, NULL);
// 3. 创建客户端TCP Socket
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
LOG(ERROR) << "socket create failed: " << strerror(errno);
exit(SOCKET_ERR);
}
// 4. 填充服务端地址信息
struct sockaddr_in server_addr;
bzero(&server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(server_port); // 服务端端口转为网络字节序
// 字符串IP转为网络字节序(inet_pton支持IPv4和IPv6,比inet_addr更安全)
if (inet_pton(AF_INET, server_ip.c_str(), &server_addr.sin_addr) <= 0) {
LOG(ERROR) << "invalid server IP: " << server_ip << ", error: " << strerror(errno);
close(sockfd);
pthread_mutex_destroy(&gmutex);
exit(INET_PTON_ERR);
}
// 5. 发起连接(三次握手)
if (connect(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
LOG(ERROR) << "connect to server failed: " << strerror(errno);
close(sockfd);
pthread_mutex_destroy(&gmutex);
exit(CONNECT_ERR);
}
LOG(INFO) << "connect to server (" << server_ip << ":" << server_port << ") success";
// 6. 创建发送和接收线程
pthread_t send_tid, recv_tid;
if (pthread_create(&send_tid, NULL, Send, NULL) != 0) {
LOG(ERROR) << "create send thread failed: " << strerror(errno);
close(sockfd);
pthread_mutex_destroy(&gmutex);
exit(1);
}
if (pthread_create(&recv_tid, NULL, Recv, NULL) != 0) {
LOG(ERROR) << "create recv thread failed: " << strerror(errno);
close(sockfd);
pthread_mutex_destroy(&gmutex);
exit(1);
}
// 7. 等待线程结束(主线程阻塞,避免进程退出)
pthread_join(send_tid, NULL);
pthread_join(recv_tid, NULL);
// 8. 清理资源
close(sockfd);
pthread_mutex_destroy(&gmutex);
LOG(INFO) << "client exit success";
return 0;
}
客户端核心流程解析:
-
命令行参数解析:要求用户输入"服务端IP+端口"(如./client 127.0.0.1 8080),若参数错误则提示用法并退出。
-
inet_pton函数:比传统的inet_addr函数更安全,支持IPv6,且能检测无效IP(如"256.0.0.1")。
-
双线程创建:Send线程负责从控制台读取用户输入并发送给服务端,Recv线程负责接收服务端回复并打印,两个线程并行执行,解决了单线程收发阻塞问题。
-
pthread_join:主线程等待Send和Recv线程结束后再退出,确保线程资源被正常回收。
3.2.3 Send线程:读取控制台输入并发送消息
cpp
void* Send(void* arg) {
std::string input;
while (true) {
// 加互斥锁输出提示信息(避免与Recv线程的输出混行)
{
LockGuard slock(gmutex);
std::cout << "please enter# ";
std::cout.flush(); // 强制刷新缓冲区,确保提示信息立即显示
}
// 读取用户输入(getline支持空格,比scanf更友好)
getline(std::cin, input);
if (input.empty()) {
continue; // 输入为空,跳过
}
// 发送消息到服务端
ssize_t ret_write = write(sockfd, input.c_str(), input.size());
if (ret_write < 0) {
LOG(ERROR) << "send message failed: " << strerror(errno);
break; // 发送失败,退出线程
}
// 输入QUIT,主动退出客户端
if (input == "QUIT") {
LOG(INFO) << "client will exit, send QUIT to server";
break;
}
}
// 线程退出前,关闭Socket(会唤醒Recv线程的read阻塞)
close(sockfd);
pthread_exit(NULL);
}
关键细节:
-
控制台输出同步:Send线程的"please enter# "和Recv线程的"server reply: "若同时输出,会导致显示混乱(如"please enter#server reply: hello")。因此用LockGuard加互斥锁,确保每次只有一个线程操作控制台。
-
getline读取输入:支持输入包含空格的消息(如"hello world"),比scanf("%s", buf)更灵活(scanf遇到空格会停止读取)。
-
主动退出逻辑:输入"QUIT"后,发送消息给服务端,然后关闭sockfd并退出线程,Recv线程会因read返回0或-1而退出。
3.2.4 Recv线程:接收服务端回复并打印
cpp
void* Recv(void* arg) {
char buff[1024]; // 接收缓冲区
while (true) {
// 读取服务端回复(阻塞函数,直到有数据或连接断开)
ssize_t ret_read = read(sockfd, buff, sizeof(buff) - 1);
if (ret_read > 0) {
buff[ret_read] = '\0'; // 手动添加字符串结束符
// 加互斥锁输出,避免与Send线程混行
LockGuard slock(gmutex);
// \r\033[K:清除当前行剩余内容,确保输出整洁
std::cout << "\r\033[K[server reply]: " << buff << std::endl;
std::cout << "please enter# ";
std::cout.flush();
} else if (ret_read == 0) {
// 服务端正常关闭连接
LOG(INFO) << "server disconnected";
break;
} else {
// 读取失败(如Socket被关闭、网络异常)
LOG(ERROR) << "recv message failed: " << strerror(errno);
break;
}
}
// 线程退出前,关闭Socket(双重保险)
close(sockfd);
pthread_exit(NULL);
}
核心优化点:
-
输出格式优化:\r\033K 是ANSI转义序列,\\r将光标移到当前行开头,\\033\[K清除光标后的所有内容。比如用户正在输入时,Recv线程收到消息,用该序列可避免"一半输入+一半回复"的混乱显示(如"please enter# hello\[server reply: world")。
-
双重关闭Socket:Send线程和Recv线程都可能关闭sockfd,但close(sockfd)多次调用不会报错(第二次及以后调用会返回-1,但errno设为EBADF,不影响程序运行),因此无需担心重复关闭问题。
四、TCP Socket编程常见问题与解决方法(实战避坑指南)
4.1 端口被占用(address already in use)
**问题现象**:服务端启动时bind失败,errno=EADDRINUSE,提示"address already in use"。
**原因**:
-
该端口已被其他进程占用(用netstat -anp | grep 端口号 查看占用进程);
-
服务端异常退出后,端口处于TIME_WAIT状态(TCP连接关闭后的延迟,默认2MSL,约1-4分钟),内核不允许新的进程绑定该端口。
**解决方案**:
-
设置SO_REUSEADDR选项(服务端必备优化):如代码中所示,在bind前调用setsockopt设置该选项,允许端口复用,即使端口处于TIME_WAIT状态也能绑定。
-
更换未被占用的端口(如将8080改为8081);
-
等待TIME_WAIT超时(不推荐,效率低);
-
临时修改内核参数(仅测试用):echo 1 > /proc/sys/net/ipv4/tcp_tw_reuse,允许内核复用TIME_WAIT状态的端口(需root权限)。
4.2 数据粘包问题(TCP字节流特性导致)
**问题现象**:客户端连续发送两个消息(如"hello"和"world"),服务端一次read可能读取到"helloworld"(粘包),或"hel"(半包)。
**原因**:TCP是面向字节流的协议,内核会将小数据包合并成大数据包发送(Nagle算法),或因网络拥堵导致数据包拆分,因此服务端无法按"客户端发送次数"来读取数据。
**解决方案**(核心:自定义应用层协议):
-
**固定长度协议**:约定每个消息的长度(如1024字节),服务端每次读取固定长度的数据,不足则继续读取,超过则截断。缺点:浪费带宽(短消息需填充空白)。
-
**长度+数据协议**(最常用):消息分为两部分,前4字节表示消息长度(大端序),后部分为消息内容。服务端先读取4字节获取长度,再读取对应长度的内容。示例:
cpp
// 客户端发送消息(长度+数据)
void SendMsg(int fd, const std::string& msg) {
int len = msg.size();
len = htonl(len); // 转为网络字节序
write(fd, &len, 4); // 先发送长度
write(fd, msg.c_str(), msg.size()); // 再发送数据
}
// 服务端接收消息(长度+数据)
std::string RecvMsg(int fd) {
int len = 0;
read(fd, &len, 4); // 先读取长度
len = ntohl(len); // 转为主机字节序
char buff[len + 1];
read(fd, buff, len); // 再读取对应长度的数据
 buff[len] = '\0';
 return buff;
}
- **分隔符协议**:用特殊字符(如'\n'、'\r\n')作为消息结束标识,服务端逐字节读取,直到遇到分隔符。缺点:若消息内容中包含分隔符,会导致误判(需转义)。
4.3 输出混行(多线程操作控制台导致)
**问题现象**:客户端Send线程输出"please enter# ",Recv线程同时输出"server reply: hello",导致显示混乱(如"please enter#server reply: hello")。
**原因**:控制台输出是共享资源,多线程同时操作会导致"指令交错"。
**解决方案**:
-
用互斥锁保护所有控制台输出操作(如代码中的LockGuard类),确保每次只有一个线程输出。
-
使用ANSI转义序列优化输出格式(如Recv线程中的\r\033[K),清除当前行剩余内容,避免残留字符干扰。
4.4 资源泄漏(Socket、线程、互斥锁未释放)
**问题现象**:程序长时间运行后,出现"无法创建Socket""无法创建线程"等错误,或CPU、内存占用持续升高。
**原因**:
-
Socket未关闭:客户端/服务端断开连接后,未调用close(fd)释放文件描述符;
-
线程未回收:创建线程后未调用pthread_detach或pthread_join,导致线程资源泄漏;
-
互斥锁未销毁:程序退出前未调用pthread_mutex_destroy销毁互斥锁。
**解决方案**:
-
遵循RAII原则:用类封装资源(如LockGuard封装互斥锁,TcpServer析构函数关闭Socket),确保资源自动释放。
-
线程处理:服务端客户端线程用pthread_detach分离,客户端主线程用pthread_join等待线程结束。
-
异常处理:每一步操作失败后,都要清理已创建的资源(如socket创建成功但bind失败,需关闭socket)。
4.5 信号中断(导致accept/read阻塞函数返回失败)
**问题现象**:服务端运行时,若收到信号(如Ctrl+C),会导致阻塞在accept或read的函数立即返回-1,errno=EINTR。
**原因**:Linux中,默认情况下信号会中断阻塞的系统调用(如accept、read、write等)。
**解决方案**:
- 信号处理函数中重启系统调用:用sigaction设置信号处理函数,并指定SA_RESTART标志,使被中断的系统调用自动重启。示例:
cpp
void SigHandler(int sig) {
// 处理信号(如Ctrl+C时停止服务端)
if (sig == SIGINT) {
g_server.StopServer();
}
}
// 初始化信号处理
void InitSignal() {
struct sigaction sa;
sa.sa_handler = SigHandler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART; // 自动重启被中断的系统调用
sigaction(SIGINT, &sa, NULL); // 处理Ctrl+C信号
sigaction(SIGPIPE, &sa, NULL); // 处理SIGPIPE信号(避免客户端断开后服务端崩溃)
}
- 在代码中判断errno=EINTR时,重新调用阻塞函数:如accept失败后,若errno=EINTR且服务端未停止,重新调用accept。
五、总结:TCP Socket编程核心脉络
通过本文的解析,我们从TCP协议原理出发,逐步拆解了Linux环境下TCP Socket编程的完整流程,核心脉络可总结为:
-
**协议基础**:TCP面向连接、可靠传输的特性,决定了编程流程需包含"连接建立(三次握手)→ 数据收发 → 连接关闭(四次挥手)"三个阶段;
-
**核心函数**:服务端遵循socket→bind→listen→accept→read/write→close,客户端遵循socket→connect→read/write→close;
-
**实战关键**:多线程处理多客户端(服务端)、双线程收发(客户端)、自定义协议解决粘包、RAII原则避免资源泄漏;
-
**避坑指南**:重点解决端口占用、数据粘包、输出混行、资源泄漏、信号中断等高频问题。
完整代码:
Comman.hpp:
cpp
#pragma once
#include <iostream>
#include <cstdio>
#include <cstring>
#include <cstdlib>
#include <string>
//那么为了方便我们看退出码更直观
//所以我们直接设置一个枚举体用于枚举各种错误的退出码
enum ExitCode
{
OK = 0,
USAGE_ERR,
SOCKET_ERR,
INET_PTON_ERR,
BIND_ERR,
LISTEN_ERR,
CONNECT_ERR,
ACCEPT_ERR
};
//那么还有就是我们的服务端类是不允许被拷贝或者赋值重载的
//那么我们一般是......=delete,但是这样子一个两个类还好
//多个类的话就会很麻烦
//所以,我们不妨创建一个类,然后这个类要禁止拷贝构造或者赋值重载函数
//那么其他的类再去继承这个类,即这个类成为其他类的父类
//如此一来,其他的类想要进行拷贝构造或者赋值重载函数的话
//就必须先进行父类的拷贝构造或者赋值重载函数,而父类是允许的
//那么该子类也就无法被拷贝或者赋值重载的
//所以这么一来就能减少我们的操作
class NoCopy
{
public:
NoCopy()
{}
~NoCopy()
{}
NoCopy(const NoCopy &) = delete;
const NoCopy &operator = (const NoCopy&) = delete;
};
TcpServer.hpp:
cpp
#pragma once
#include <iostream>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <sys/types.h>
#include <strings.h>
#include <functional>
#include <cerrno>
#include <unistd.h>
#include <sys/wait.h>
#include <pthread.h>
#include <signal.h>
#include <atomic>
#include "Log.hpp"
#include "InetAddr.hpp"//可以利用该头文件做优化,但这里向突出最原始的,所以没有使用
#include "Comman.hpp"
//ok,那么伴随着我们把udp的核心接口以及一些功能实现完,接下来我们就来了解TCP这一更好用,更常用的网络通信协议
//那么对于TCP,依旧是要依靠着socket套接字,这个是毋庸置疑的,且客户端要有一个套接字,服务端也得有一个套接字
//这都是我们之前学习UDP知道的了,那么对于TCP的socket创建有什么不同呢???
//其实是差不多的,只不过传入给socket函数的第二个参数变了,要传入SOCK_STREAM
//即_socketfd=socket(AF_INET,SOCK_STREAM,0);,其他的都是一样的
//那么对于TCP协议啊,它的收发消息就是和UDP不一样的了,
//最首先也是最主要的区别就是,TCP是要连接的,即你客户端得先和我们服务端链接了,那么我服务端才能收到你这个客户端的信息
//而UDP是不必链接的,只要知道服务端ip和端口号即可,这就是二者最核心的一个区别
//除此之外,TCP收发消息的函数也不是recvfrom和sendto函数,而是最原始的write和read这两个函数,我们之前在文件那部分有讲过
//为什么呢???因为我们其实也知道,socket函数的返回值,其实就是一个文件描述符!!!!!!!
//而write和read函数不就是用文件的读写吗,所以,write和read这两个函数自然可以用到TCP的收发上
//那么其实也可以使用send和recv函数,但是使用方法和write和read这两个函数差不多
//所以可以自行了解
//至于原理,我们后面会说,在这里,我们先会用就完事了
//OK,那么上面说到链接,即客户端要和服务端进行链接才行,那么,要怎么链接呢???
//其实有两种方式,第一种就是我们直接在命令行中输入 telent 服务端ip 服务端端口号 即可
//那么此时你这个主机的shell进程就会和服务端建立链接,那么此时你也不用什么write函数
//shell进程会自动处理好一切,你直接啪啪啪输入信息即可
//而对于我们自己写的客户端程序呢????
//那么就需要使用connect函数了!!!函数如其名,就是链接的意思
//可以把你这个客户端进程链接到你所指定的服务端:
//connect 是 Linux/Unix 网络编程中客户端专用的系统调用,核心目的是让客户端套接字(socket)与服务端的地址(IP + 端口)建立关联:
//对 TCP 客户端:发起 TCP 三次握手,与服务端建立可靠的连接(这是 connect 最核心的用途);
//对 UDP 客户端:UDP 是无连接协议,connect 不建立实际连接,
//仅 "绑定" 服务端地址(后续发送 / 接收消息无需重复指定服务端地址,且只接收该服务端的消息)
//那么对于udp,我们一般是不用connect函数的,这里做个了解即可
//函数原型(Linux 下)
//#include <sys/socket.h>
//int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
//参数详细解析
//参数 含义 & 注意事项
//第一个参数:sockfd 客户端通过 socket() 函数创建的套接字描述符(整数),是客户端通信的 "句柄";
// 必须是已创建、未关闭的有效 fd。
//第二个参数:addr 指向 struct sockaddr 类型的指针(实际开发中常用 struct sockaddr_in 填充后强制转换),
// 存储服务端的 IP 地址和端口信息;核心是告诉客户端 "要连接哪个服务端"。
//addrlen addr 指向的结构体的实际长度,通常传入 sizeof(struct sockaddr_in) 即可。
//返回值规则
//成功:返回 0;
//TCP 场景:表示三次握手完成,客户端与服务端已建立连接;
//UDP 场景:表示成功绑定服务端地址,无实际连接建立。
//失败:返回 -1,同时设置全局变量 errno(关键!用于排查错误)。
//那么大家仔细看一看就会发现,我的天啊,这个和我们之前udp使用的sendto函数的参数怎么差不多啊!!!
//一样是要传入客户端的socket,一样是要传入存储着指定服务端的struct sockaddr 类型的指针
//也一样是要传入addr 指向的结构体的实际长度(通常传入 sizeof(struct sockaddr_in) 即可)
//那么也就少了要发送的字符串等等(其实这些就是在write函数中使用)
//所以啊,会了UDP,那么使用TCP也会好理解很多
//那么在客户端进行了和服务端connect函数之后,就代表该客户端和指定服务端建立起了链接!!!
//那么问题来了,链接有什么用呢???
// ===================== TCP下connect建立连接的核心作用 =====================
// 1. 建立「双向可靠的通信通道」(连接的本质价值)
// connect成功后,客户端和服务端通过三次握手完成了"双向可达性确认":
// - 客户端确认:服务端存在且能接收自己的消息;
// - 服务端确认:客户端存在且能接收自己的回包;
// - 双方协商好初始的序列号、窗口大小等关键参数,为后续"有序、不丢、不重发"传输数据打下基础。
// 👉 通俗类比:就像你和朋友打通了电话(三次握手),电话线路被专属占用,双方都确认能听到对方说话,
// 接下来才能顺畅聊天;如果没打通(connect失败),直接说话(发数据)对方根本收不到。
//
// 2. 简化后续通信接口,无需重复指定服务端地址
// 连接建立前,TCP客户端若想发数据,必须用sendto(指定服务端IP+端口);
// 连接建立后,客户端可以直接用send/recv收发数据------因为连接已经"绑定"了服务端的IP+端口,
// 内核会自动把数据发给该服务端,不用每次发数据都重复指定"发给谁"。
// 👉 代码层面的对比:
// // 无连接(connect 前):必须用 sendto,指定服务端地址
// sendto(sockfd, data, len, 0, (struct sockaddr*)&server_addr, sizeof(server_addr));
// // 有连接(connect 后):直接用 send,无需指定地址
// send(sockfd, data, len, 0);
//
// 3. 保证通信的「唯一性和可识别性」
// TCP连接是"客户端IP+端口 ↔ 服务端IP+端口"的唯一配对:
// - 服务端能通过这个连接,精准识别"这条消息来自哪个客户端"(不用像UDP那样每次解析recvfrom的输出参数);
// - 服务端回包时,能精准把数据发给对应的客户端,不会发错对象;
// - 即使多个客户端连接同一个服务端,每个连接都是独立的,数据不会串流。
//
// 4. 提前发现错误,避免无效的通信尝试
// connect调用会直接返回"成功/失败":
// - 若服务端未启动(errno=ECONNREFUSED)、网络不通(errno=ETIMEDOUT),客户端能立刻知道"连接失败",
// 不用等到发数据时才发现问题;
// - 若connect成功,说明服务端是可达的,后续发数据的成功率会大幅提升。
// ==========================================================================
//OK,那么在客户端和服务端建立了链接之后,那么客户端要怎么发送信息给服务端呢???
//很显然,在上面我们就已经说过了,可以使用write函数去对socket文件标识符进行写入数据
//那么服务端再使用read函数去对和服务端建立链接的客户端对应的那一个socket文件标识符进行读取数据即可
//上面的write/read==recv/send函数
//那么说到这里,其实聪明的你已经有问题了,阿西,客户端要和服务端建立链接了之后才能双方收发消息
//然后你说write/read==recv/send函数通过socket文件描述符进行我也能理解
//那么你客户端就只有一个socket文件描述符,然后通过这个文件描述符从服务端发收消息
//OK,这也是可以的
//可是问题是,你服务端只有一个socket文件描述符的话,那么你一个能扛得住多个客户端都给服务端发送消息吗???
//想想看,服务端要是只有一个socket文件描述符的话,那么就代表服务端只能通过这个socket文件描述符去
//接收客户端的消息和发送信息给客户端,那么就这一个socket文件描述符,多个客户端发送信息时,
//它能弄明白是哪个客户端发送信息,怎么处理吗,肯定是不行的
//若服务端只有一个socket文件描述符,意味着所有客户端的消息都要通过这个fd接收,
//服务端既无法区分"这条消息来自哪个客户端",也无法精准地将回包发送给对应的客户端
//单个fd根本无法完成多客户端的消息识别和处理。
//所以,我们也就能知道了,服务端是一定要有多个socket文件描述符的
//每一个socket文件描述符都对应着一个和服务端建立好链接的客户端
//只有这样子,多个客户端和一个服务端才能正确的发收消息!!!
//可是,难道真的要我们自己去判断有没有客户端连接过来,然后有的话,我们就创建一个socket文件描述符对应它吗??
//omg,这简直是难为人,所以,系统给我们提供能实现这个功能的函数接口了!!!
//首先就是实现服务端一直监听有没有客户端去和我们这个服务端建立链接的函数,那么它就像是饭店门口的门童
//一直观察有没有客人进来饭店吃饭,那么这个函数就是listen:
// listen 函数的核心作用
// listen 是 Linux/Unix 网络编程中服务端专属的系统调用,核心目的有两个:
// 将服务端通过 socket() 创建的 "主动态套接字" 转为被动态套接字
// (主动态默认用于客户端发起连接,被动态才支持监听客户端连接);
// 让服务端开始监听绑定的端口(bind 绑定的 IP + 端口),
// 并维护一个 "连接请求队列",暂存客户端的连接请求,等待 accept 函数提取处理。
// 👉 通俗类比:listen 就是 "让门童正式上岗"------ 门童(listen)站到饭店门口(绑定的端口),
// 开始观察有没有客人(客户端)来吃饭,同时在门口划了一块区域(连接请求队列),
// 客人来了先在这块区域排队,等门童(accept)带进去入座。
//只要服务端启动了listen,就会一直处于"等待客户端连接"的状态,无需我们手动轮询判断。
// ===================== listen 函数核心解析(Linux 下) =====================
// 一、函数原型
// #include <sys/socket.h>
// int listen(int sockfd, int backlog);
//
// 二、参数详细解析
// | 参数 | 含义 & 关键说明 |
// |----------|--------------------------------------------------------------------------------|
// | sockfd | 服务端通过 socket() 创建、并已调用 bind() 绑定 IP+端口的套接字描述符;<br>⚠️ 必须是 "已绑定未连接" 的套接字,且仅支持 TCP(UDP 无需 listen)。 |
// | backlog | 核心参数!指定 "全连接队列" 的最大长度
// (即已完成三次握手、等待 accept 处理的客户端连接数上限);
// <br>1. 现代 Linux 中,该值只是 "建议值",内核会根据系统配置调整(比如默认上限 128);
// <br>2. 若超过该值,新的客户端连接会被内核拒绝(客户端报 ECONNREFUSED);
// <br>👉 类比:门童门口的排队区最多站 backlog 个已登记的客人,多了就不让进了。 |
//
// 补充:必懂的 "两个连接队列"(listen 的底层核心)
// 服务端调用 listen 后,内核会为该套接字维护两个队列:
// 1. 半连接队列(SYN 队列):存放 "刚发了 SYN 包、还没完成三次握手" 的客户端(客人刚到门口,还没登记);
// 2. 全连接队列(ACCEPT 队列):存放 "已完成三次握手、等待服务端调用 accept 提取" 的客户端(客人完成登记,在排队区等入座);
// listen 的 backlog 参数主要限制全连接队列的长度,半连接队列的长度由系统内核参数(如 tcp_max_syn_backlog)控制。
//
// 三、返回值规则
// - 成功:返回 0 → 表示服务端已成功进入监听状态,门童正式上岗;
// - 失败:返回 -1,同时设置全局变量 errno → 需结合 errno 排查问题。
//
// 四、listen 的使用规则(和其他函数的配合)
// listen 不能单独用,必须遵循服务端的固定调用顺序:
// socket() 创建套接字 → bind() 绑定 IP+端口 → listen() 转为监听状态 → accept() 提取连接
//
// ⚠️ 关键禁忌:
// 1. 未调用 bind 直接 listen:会失败(errno=EINVAL),因为套接字没有绑定端口,门童不知道站哪个门口;
// 2. TCP 客户端不能调用 listen:客户端套接字是 "主动态",用于发起连接,无需监听;
// 3. UDP 服务端无需调用 listen:UDP 是无连接协议,没有 "连接请求",自然不需要监听。
// ==========================================================================
//所以看完上面我们也就知道了,服务端依旧是先要我们手动创建一个socket套接字然后进行bind
//目的是让服务端"出名",因为套接字没有绑定端口,门童不知道站哪个门口,
//然后才能去将这个socket套接字传入listen函数,将这个套接字转换为监听套接字,
//此时服务端就能嗷嗷监听有没有和其进行链接的客户端了
//OK,那么在服务端监听到了有和其进行链接的客户端了
//那么接下来要怎么操作呢???
//其实很简单,不就是让服务端接收链接!!!!!同时创建出和这个客户端对应的socket套接字文件描述符
//这样子,后续这个客户端和服务端才能正常进行消息的接发!!!
//那么对于这个功能,系统给我们提供了什么函数接口呢???
//就是accept函数,它的返回值就是创建出的和这个客户端对应的socket套接字文件描述符
//其实它的作用就是相当于监听的门童发现有客户端建立链接了,然后叫饭店内的服务员去接待这个客户端
// ===================== accept 函数核心解析(Linux 下) =====================
// 一、核心作用(对应"门童叫服务员接待客户端")
// accept 是 Linux/Unix 网络编程中服务端 TCP 专属的系统调用:
// 1. 从 listen 维护的 "全连接队列" 中,提取一个已完成三次握手的客户端连接请求;
// 2. 创建一个全新的套接字文件描述符(通信 fd)------ 这个 fd 唯一对应该客户端,是服务端与该客户端后续收发消息的 "专属通道";
// 👉 类比:listen 是门童站在门口看排队的客人(全连接队列),accept 是门童喊一名服务员(新的通信 fd),
// 专门接待这位客人,服务员和客人一对一服务,门童继续回去看排队的人。
//
// 二、函数原型
// #include <sys/socket.h>
// int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
//
// 三、参数详细解析(新手重点看输出型参数)
// | 参数 | 含义 & 关键说明 |
// |----------|--------------------------------------------------------------------------------|
// | sockfd | 服务端调用 listen 后的监听 fd(即门童的 "工作岗位");
// <br>⚠️ 必须是已调用 listen 的监听 fd,不能用普通文件 fd。
// 因为accept函数肯定是得用于和服务端有发起链接的客户端,而它们就在监听套接字的队列中
// | addr | 输出型参数(传入空的 struct sockaddr_in 指针):
// <br>调用成功后,内核会自动填充该客户端的 IP 地址和端口信息(知道 "是哪个客人来吃饭");
// <br>若不需要获取客户端地址,可传 NULL。
// | addrlen | 输入输出型参数:<br>1. 输入:传入 addr 结构体的长度(如 sizeof(struct sockaddr_in)),
// 告诉内核缓冲区大小;
// <br>2. 输出:内核返回实际填充的客户端地址长度(可忽略,通常传指针即可);
// <br>若 addr 传 NULL,该参数也传 NULL。
//那么聪明的你其实很容易就能想到,accept函数的参数和recvfrom函数的参数很类似
//都有输出参数,都有socket套接字,至于recvfrom函数剩余的函数,其实就是后续调用的read/recv函数所用到的!!!
//
// 补充:参数使用示例(获取客户端地址)
// struct sockaddr_in client_addr;
// socklen_t client_len = sizeof(client_addr);
// // 调用accept,获取客户端地址和专属通信fd
// int conn_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &client_len);
// // 解析客户端IP和端口
// char *client_ip = inet_ntoa(client_addr.sin_addr);
// uint16_t client_port = ntohs(client_addr.sin_addr);
//
// 四、返回值规则(核心!对应"创建客户端对应 fd")
// | 返回值情况 | 含义 |
// |------------|----------------------------------------------------------------------|
// | 成功 | 返回 非负整数 ------ 这就是与该客户端绑定的专属通信 fd(服务员),
// 后续用这个 fd 和客户端收发消息;
// | 失败 | 返回 -1,同时设置全局变量 errno,需结合 errno 排查问题;
// <br>常见场景:监听 fd 被关闭、被信号中断等。
//
// 五、底层工作原理(结合 listen 队列)
// 1. 服务端调用 listen 后,内核维护 "全连接队列"(已完成三次握手的客户端);
// 2. accept 默认是阻塞函数:如果全连接队列为空,accept 会一直阻塞,直到有新的客户端完成三次握手进入队列;
// 3. 当队列中有连接时,accept 会:
// - 从队列中取出第一个客户端连接;
// - 创建新的通信 fd,与该客户端绑定;
// - 填充 addr 参数(客户端地址),返回通信 fd;
// 4. 服务端拿到通信 fd 后,就可以用 read/write/recv/send 与该客户端单独通信,监听 fd 则继续等待其他客户端。
//
// 六、使用规则
// 1. 调用顺序(与 listen 强绑定)
// 必须遵循服务端固定流程,缺一不可:
// socket() 创建监听 fd → bind() 绑定端口 → listen() 启动监听 → accept() 接收连接(创建通信 fd)
// 2. 关键禁忌
// - 用普通 fd(非监听 fd)调用 accept:失败(errno=EINVAL);
// - 未调用 listen 直接 accept:失败(errno=EINVAL);
// - UDP 服务端无需调用 accept:UDP 无连接,没有 "接收连接" 的概念;
// - 通信 fd 与监听 fd 不可混用:监听 fd 只负责等连接,通信 fd 只负责与单个客户端通信。
// 3. 阻塞 / 非阻塞特性
// - 默认:accept 是阻塞的(门童没看到客人就站着等);
// - 若将监听 fd 设置为非阻塞,accept 会非阻塞:队列无连接时直接返回 -1,errno=EAGAIN/EWOULDBLOCK。
// ==========================================================================
using func_t = std::function<std::string(std::string)>;
const int defaultsocketfd=-1;//默认socketfd
using namespace LogModule;
//那么还有就是我们的服务端类是不允许被拷贝或者赋值重载的
//那么我们一般是......=delete,但是这样子一个两个类还好
//多个类的话就会很麻烦
//所以,我们不妨创建一个类,然后这个类要禁止拷贝构造或者赋值重载函数
//那么其他的类再去继承这个类,即这个类成为其他类的父类
//如此一来,其他的类想要进行拷贝构造或者赋值重载函数的话
//就必须先进行父类的拷贝构造或者赋值重载函数,而父类是允许的
//那么该子类也就无法被拷贝或者赋值重载的
//所以这么一来就能减少我们的操作
class TcpServer:public NoCopy
{
public:
//禁用拷贝构造和赋值运算符(避免多个对象共享同一个socketfd)
TcpServer(const TcpServer&) = delete;
TcpServer& operator=(const TcpServer&) = delete;
//构造函数
//是服务端,那么就是接收信息,那么也是一个进程,那么是不是应该我们得给它分配端口号呢???
//再想想,服务端是不是要出名,独一的,所以它应该就要有单独的指定的端口号
//这样子那些客户端才能定位到这个服务端,这一点需要注意
TcpServer(int port,func_t func)
:_port(port)
,_func(func)
,_listensocketfd(defaultsocketfd)
,_isrunning(false)//刚开始本服务端肯定没有运行
{
Use_Monitor_Log();//显示器上打印日志
}
//析构函数,关闭打开的socketfd
~TcpServer()
{
_isrunning = false;
if (_listensocketfd != defaultsocketfd)
{
close(_listensocketfd);
LOG(LogLevel::INFO) << "TCP server socket closed";
}
}
//创建服务端socket套接字并进行bind以及listen的函数
void InitTcpServer()
{
// 当客户端异常断开连接(比如强制关闭),服务端继续调用 write 向该客户端写数据时,
// 内核会发送 SIGPIPE 信号给服务端进程。
// 默认情况下,SIGPIPE 会直接终止进程 ------ 这意味着一个客户端的异常会导致整个服务端崩溃。
// 解决方向:
// 在服务端初始化时忽略 SIGPIPE 信号:
struct sigaction sa{};
sa.sa_handler = SIG_IGN;
sigemptyset(&sa.sa_mask);
sigaction(SIGPIPE, &sa, nullptr); // 忽略SIGPIPE信号,write失败时会返回-1,errno设为EPIPE
_listensocketfd=socket(AF_INET,SOCK_STREAM,0);
if(_listensocketfd<0)
{
LOG(LogLevel::FATAL)<<"socket failed";
exit(SOCKET_ERR);
}
//创建struct sockaddr_in结构体,将本进程的ip啊(其实就是0.0.0.0)端口号啊传入
struct sockaddr_in sever;
bzero(&sever,sizeof(sever));
sever.sin_family=AF_INET;
sever.sin_addr.s_addr=htonl(INADDR_ANY);
sever.sin_port=htons(_port);
//进行bind绑定!!!
//服务端是需要bind绑定的,因为服务端必须要有一个确定的端口号,原因上面也说了
//是服务端,那么就是接收信息,那么也是一个进程,那么是不是应该我们得给它分配端口号呢???
//再想想,服务端是不是要出名,独一的,所以它应该就要有单独的指定的端口号
//这样子那些客户端才能定位到这个服务端,这一点需要注意
//不想服务端是不需要有什么单独的我们已知的端口号,因为它只是负责发送信息!!!
//所以它是不用显式bind的,在发送信息的时候,OS就会自动给它客户端进程随机端口号!!!
//端口复用
int opt = 1;
setsockopt(_listensocketfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
int ret_bind=bind(_listensocketfd,(struct sockaddr*)&sever,sizeof(sever));
if(ret_bind<0)
{
LOG(LogLevel::FATAL)<<"bind failed";
exit(BIND_ERR);
}
LOG(LogLevel::INFO)<<"bind success!!!";
//然后进行监听函数listen的调用
int ret_listen=listen(_listensocketfd,8);
if(ret_listen<0)
{
LOG(LogLevel::FATAL)<<"listen failed"<<strerror(errno);
exit(LISTEN_ERR);
}
else
{
LOG(LogLevel::DEBUG)<<"listen success";
}
//至此该套接字就会进行监听是否有客户端向本服务端发起链接
}
//服务端要做的事情的函数
//我们把该函数包装成一个函数,因为我们服务端要进行多进程或者多线程的使用
//那么把服务端要做的事情的函数放在外面,看起来会更清晰,也更解耦
//那么得传入服务端中对应的客户端的对应的socket套接字文件描述符
//即accept函数的返回值
void ServerFunc(int ret_accept)
{
//下面我来详细的说明一下这个循环的原理,其实也就是服务端如何去处理多个客户端发消息
//我们知道,服务端的处理,本质就是一个死循环,每次进行完一次循环,就会回到循环开头进行新的一次循环
//那么服务端要干的其实就是接收客户端发来的信息,然后进行回调函数的处理
//那么这也就是每次循环要做的事情
//那么想想看,要是有一个客户端给服务端发送信息了,然后同时又有一个其他的客户端给服务端发送信息呢???
//那么其实服务端就是先给一个客户端执行一次循环(即接收信息,执行回调函数)
//那么服务端得执行完了这一次循环之后,才能去对另一个客户端进行接收信息,执行回调函数,即执行新的一次循环
//所以啊,本质上,服务端就是一次循环处理一次客户端发送的信息!!!
//这个是非常关键的,在服务端没执行完上一次的循环之前,它是无法再去处理其他客户端的信息的
//因为只有执行完一次循环之后,服务端才会回到循环开头的代码去执行accept、read等等操作
//那么当然,因为cpu处理速度极快,所以每次循环所消耗的时间几乎相当于没有!!!
//但是也难免会出现耗时的情况(回调函数的执行),所以我们会引入多线程处理
//从而大大的减少耗时
//所以在这里我想强调的核心本质就是:
//以 "单次循环处理单条客户端消息" 为最小单位,按顺序逐一处理所有客户端的消息,
//且必须完成上一轮循环才能处理下一轮。
while(_isrunning)//只要还在于运行,就一直运行下去,其实是个废话
{
//接收信息
char buff[1024]={0};//创建接收数据的字符串
//不要忘记了,我们的TCP可是建立好链接了
//那么直接向服务端中对应的客户端的对应的socket套接字文件描述符进行read/recv函数即可
int ret_read=read(ret_accept,buff,sizeof(buff)-1);
// a. ret_read>0: 读取成功
// b. ret_read<0: 读取失败
// c. ret_read==0: 对端把链接关闭了,读到了文件的结尾 --- pipe
if(ret_read>0)
{
buff[ret_read]='\0';
}
else if(ret_read==0)//代表读取结束,也是代表客户端断开链接
{
std::cout<<"断开链接";
break;//终止死循环
}
else
{
//代表读取失败
LOG(LogLevel::ERROR)<<"read failed!";
break;//终止死循环
}
//那么我们在这里就要进行函数的回调使用
std::string result=_func(buff);
//然后将结果写给客户端
//那么也就是使用write函数了(因为已经链接成功了)
int ret_write = write(ret_accept, result.c_str(), result.size());
if (ret_write < 0)
{
LOG(LogLevel::ERROR)<<"write data failed!";
close(ret_accept);//关闭socket套接字文件描述符
break;
}
//回调函数执行完成后,服务端立刻回到循环开头,再次调用 read 进入阻塞状态,
//等待下一个客户端(可能是之前的客户端,也可能是新客户端)的消息。
}
close(ret_accept);//如果退出循环了,就代表客户端不干了,所以也得close对应的socket套接字文件描述符
}
//但是使用线程原生接口就会有个麻烦,那就是线程的执行函数要求只能有一个形参
//而你写在类里面的函数默认就会有个this指针的形参,那么这个要怎么办呢???
//其实很简单,我们把该函数设置为static就行
//可是如此一来就又有问题了,static函数是不能直接访问到类里面的函数的
//那么服务端类里面的处理函数要怎么在线程执行函数里面被调用呢???
//所以啊,我们得给线程的执行函数里面传入this指针才行哦,但是这些还不够
//因为线程是服务于客户端的,而我们也知道,服务端的处理函数是需要
//传入通信套接字文件描述符的,不然就不能正确的服务于对应的客户端
//所以仅仅给线程的执行函数传入this是不够的,还得传入accept函数的返回值才行
//可是线程的执行函数只能有一个void*形参啊!!!!
//这可怎么办,其实很简单,那就就是封装一个类
//这个类里面要有this,有accept函数的返回值
//每次在执行完accept函数之后,就创建一个该类,然后把this和函数的返回值传该类变量
//然后把这个类变量转换为void*之后再传给线程执行函数,
//最后在线程执行函数里通过该类变量去运行服务端处理函数
class ServerData
{
public:
//构造函数
ServerData(int ret_accept,TcpServer* self)
:_ret_accept(ret_accept)
,_self(self)
{}
//析构函数
~ServerData()=default;
public:
int _ret_accept;//accept函数的返回值
TcpServer* _self;//用来接收this指针
};
//线程执行函数
//要为静态函数,然后在函数内部去把传来的void*进行转换
//然后执行线程的detach函数,然后执行服务端的处理函数
static void* routine(void* self)
{
//先将线程进行分离
pthread_detach(pthread_self());//pthread_self()获取自身线程pid
//将参数转换为原本的
ServerData* server=static_cast<ServerData*>(self);
//运行服务端的处理函数
server->_self->ServerFunc(server->_ret_accept);
delete server;//要记得删除创建的内存哦,因为出了服务端的处理函数之后就代表说客户端不干了,所以得把我们申请的资源释放
return nullptr;
}
//运行服务端网络通信的函数
//那么有客户端给本服务端发送消息的话,因为服务端本质是死循环的去等待客户端发送来的信息
//所以不管来几个客户端,服务端都能照收不误
void StartTcpServer()
{
//代表开始运行本服务端,所以要把_isrunning进行修改
_isrunning=true;
//然后就要调用accept函数去接受客户端发来的链接
//但是想想看,难道说accept函数就只调用一次吗???
//那不就只能accept一个客户端吗,这肯定是不行的
//所以我们也要用死循环去不断accept
//每次监听到有新的客户端进行链接
//就进行accept,不难就阻塞在accept函数那里
//下面我来详细的说明一下这个循环的原理,其实也就是服务端如何去处理多个客户端发消息
//我们知道,服务端的处理,本质就是一个死循环,每次进行完一次循环,就会回到循环开头进行新的一次循环
//那么服务端要干的其实就是接收客户端发来的信息,然后进行回调函数的处理
//那么这也就是每次循环要做的事情
//那么想想看,要是有一个客户端给服务端发送信息了,然后同时又有一个其他的客户端给服务端发送信息呢???
//那么其实服务端就是先给一个客户端执行一次循环(即接收信息,执行回调函数)
//那么服务端得执行完了这一次循环之后,才能去对另一个客户端进行接收信息,执行回调函数,即执行新的一次循环
//所以啊,本质上,服务端就是一次循环处理一次客户端发送的信息!!!
//这个是非常关键的,在服务端没执行完上一次的循环之前,它是无法再去处理其他客户端的信息的
//因为只有执行完一次循环之后,服务端才会回到循环开头的代码去执行accept、read等等操作
//那么当然,因为cpu处理速度极快,所以每次循环所消耗的时间几乎相当于没有!!!
//但是也难免会出现耗时的情况(回调函数的执行),所以我们会引入多线程处理
//从而大大的减少耗时
//所以在这里我想强调的核心本质就是:
//以 "单次循环处理单条客户端消息" 为最小单位,按顺序逐一处理所有客户端的消息,
//且必须完成上一轮循环才能处理下一轮。
while(_isrunning)
{
struct sockaddr_in client;//每次都重置出新的来,不然会被覆盖
bzero(&client,sizeof(client));
socklen_t len=sizeof(client);
int ret_accept=accept(_listensocketfd,(struct sockaddr*)&client,&len);
if(ret_accept<0)
{
LOG(LogLevel::ERROR)<<"accept failed!!!";
continue;//一次接受失败不影响下次的接收,所以使用continue
}
//然后就去执行服务端要做的事情的函数
//我们把该函数包装成一个函数,因为我们服务端要进行多进程或者多线程的使用
//至于为什么要多进程或者多线程的使用??
//其实在udp那里就有讲到过,就是因为要是有多个客户端都链接本服务端
//那么你只有单进程单线程的话,服务端是无法处理那么多个的客户端的
//必然会导致效率的低下,还有就是accept一个,那么就会有一个套接字文件描述符对应的去处理
//即对应的一个服务端处理函数去服务着一个客户端,就像一个服务员服务一个客人一样
//那你只是单进程单线程的话,你服务端accept了一个之后,就代表这个进程要去服务这个客户端了
//那么还有其他进程去服务其他的客户端吗?????
//很显然是没有的,所以这肯定就不行,那么我们要怎么办呢???
//自然是使用多进程或者多线程或者线程进行处理
//那么线程池一般是用于短服务
//多进程和多线程是用于长服务
//那么把服务端要做的事情的函数放在外面,看起来会更清晰,也更解耦
//version 1------单进程单线程调用
//ServerFunc(ret_accept);
//version 2------多进程单线程调用
//那么毋庸置疑,创建父子进程就行
//父进程去嗷嗷accept,父进程每accept一个,就创建出一个子进程去服务accept的客户端
//因为是父子进程,所以父进程能看到的socket套接字文件描述符,子进程也能看得到
//所以不用再传给子进程
//但是这样子也还是有个问题,就是你父进程得等待子进程退出吧,不然就会造成僵尸进程
//可问题要是我们就是直接在父进程调用waitpid函数的话,那么waitpid函数可是阻塞等待的
//也就是说在子进程退出之前,父进程是会阻塞等待,也就不会回到循环开头去accept
//那么这和单进程单线程有什么区别!!!
//所以啊,我们不应该让父进程阻塞等待,可是父进程又必须去等待以防僵尸进程
//那么怎么办呢???其实聪明的你很快就能想到
//用信号,子进程退出是会发信号给父进程的,而我们之前也有讲过
//可以将此信号的处理动作设置为忽略,那么父进程就不会阻塞等待
//会自动的当子进程退出了,再去等待回收了
//这确实是一个方法,但是今天我就是不想用怎么办???
//诶,还真有新方法,那就是让孙子进程去服务客户端
//具体是这样的:父进程创建子进程,然后父进程调用waitpid函数阻塞等待子进程退出
//而子进程要干的就是再创建一个子进程,即孙子进程,创建完之后,子进程直接退出!!!
//孙子进程去服务客户端,而由于子进程在创建完孙子进程后直接退出,所以父进程的阻塞等待相当于是没有销毁时间
//如此一来,就能达到上面我们所说的目的!!!
//那么孙子进程怎么办???它其实就是成为了孤儿进程
//而我们知道,孤儿进程是交给1号进程处理的,所以不用我们管!!!
//那么这就是一个极佳的方法
//当然这样子其实也挺麻烦的,毕竟创建子进程终究是系统调用会耗时
//所以最好的方法肯定还是使用信号!!!
// int pid=fork();
// if(pid<0)
// {
// LOG(LogLevel::FATAL)<<"fock failed!!";
// }
// else if(pid==0)
// {
// //子进程
// //创建孙子进程
// int ppid=fork();
// if(ppid>0)
// {
// //子进程
// //直接退出
// exit(0);
// }
// else if(ppid==0)
// {
// //孙子进程
// //进行服务端函数的处理
// ServerFunc(ret_accept);
// }
// }
// //父进程
// //等待子进程的退出
// //其实秒等待完
// waitpid(pid,nullptr,0);
//version 3------单进程多线程调用
//那么创建多进程终究负担比较大,不如创建多线程
//所以,我们可以使用多线程去使用,让一个线程去服务一个客户端
//那么问题来了,我们是可以使用我们已经封装好了的线程文件
//但是你说,诶,我就是不想,我就是想用线程原生接口怎么办!!!
//那简单,就干呗!!!!!!
//但是使用线程原生接口就会有个麻烦,那就是线程的执行函数要求只能有一个形参
//而你写在类里面的函数默认就会有个this指针的形参,那么这个要怎么办呢???
//其实很简单,我们把该函数设置为static就行
//可是如此一来就又有问题了,static函数是不能直接访问到类里面的函数的
//那么服务端类里面的处理函数要怎么在线程执行函数里面被调用呢???
//所以啊,我们得给线程的执行函数里面传入this指针才行哦,但是这些还不够
//因为线程是服务于客户端的,而我们也知道,服务端的处理函数是需要
//传入通信套接字文件描述符的,不然就不能正确的服务于对应的客户端
//所以仅仅给线程的执行函数传入this是不够的,还得传入accept函数的返回值才行
//可是线程的执行函数只能有一个void*形参啊!!!!
//这可怎么办,其实很简单,那就就是封装一个类
//这个类里面要有this,有accept函数的返回值
//每次在执行完accept函数之后,就创建一个该类,然后把this和函数的返回值传该类变量
//然后把这个类变量转换为void*之后再传给线程执行函数,
//最后在线程执行函数里通过该类变量去运行服务端处理函数
//这就是一个极佳的方法
//那么还有一个问题,就是创建了线程之后,得join吧,不然就可能出现僵尸线程
//可是你join,不就又是阻塞等待了吗,那么不就是和上面的进程等待一样的问题吗!!!
//那么这可怎么办???
//很简单,可不要忘记了线程的detach分离函数!!!
//用了它之后,线程就不必被join了!!!
//所以,实现单进程多线程的大致思路就是如上,其实还是很清晰的!!!
//先创建类变量
//要使用new出来的,不然直接创建的话,就是属于栈上的临时变量,可能会被销毁
ServerData* self = new ServerData(ret_accept,this);//传入accept函数返回值以及this指针
pthread_t t;
int ret_pthread_create = pthread_create(&t,nullptr,routine,static_cast<void*>(self));
if (ret_pthread_create != 0)
{
LOG(LogLevel::ERROR) << "pthread_create failed: " << strerror(ret_pthread_create);
delete self; // 释放堆内存
close(ret_accept); // 关闭通信fd
continue;//一次创建失败不影响下次的创建,所以使用continue
}
//就是这么的简单
// version 4------线程池版本,线程池一般比较适合处理短服务
// 将新链接和客户端构建一个新的任务,push线程池中
// ThreadPool<task_t>::GetInstance()->Enqueue([this, sockfd, &addr](){
// this->Service(sockfd, addr);
// })
//回调函数执行完成后,服务端立刻回到循环开头,再次调用accept函数进入阻塞状态,
//等待下一个客户端(可能是之前的客户端,也可能是新客户端)的消息。
}
}
private:
int _listensocketfd;//监听的socket套接字
int _port;//服务端进程端口号
func_t _func;//服务端处理任务的函数,回调函数
//int _ip;//本进程所在的主机ip地址,那么想想看,服务端要用指定本主机的哪个ip地址吗??
//不用,直接传入INADDR_ANY就行,因为是服务端,所以是肯定要接收一大堆客户端的访问,所以就要使用0.0.0.0
//这一点需要注意
std::atomic<bool> _isrunning;//判断本服务端是否还运行,其实本质上是一直死循环待机,毕竟想拼多多啊之类的服务器,什么时候关过机呢!!
};
//至此一个简单的sever服务端就封装好了
TcpServer.cc:
cpp
#include "TcpServer.hpp"
#include <iostream>
#include <functional>
// 仅仅是用来进行测试的
std::string defaulthandler(const std::string &message)
{
std::string hello = "hello, ";
hello += message;
return hello;
}
// 命令行参数,因为我们会要求用户在调用服务端的时候就得传入服务端的端口号,
// 那么ip可以不用,因为我们使用0.0.0.0
// 所以就要求用户这么调用服务端进程:
int main(int argc, char *argv[]) // 二级指针哦
{
if (argc != 2)
{
std::cerr << "Usage: " << argv[0] << " port" << std::endl;
return 1;
}
// std::string ip = argv[1];
uint16_t port = std::stoi(argv[1]);
// 那么想想看,对于服务端这个进程,它是不是要处理的很多,每次有客户端发送消息给服务端
// 服务端都得把收到的消息再给其他客户端发送过去一遍,那么要是又多个客户端同时发送呢
// 那么服务端不得忙成傻子,所以,应该让服务端多线程去执行这个任务
// 那么其实就是使用线程池去减轻压力
// 我们直接使用单例模式线程池就行,嘎嘎好使,统一,而且获取到的指针就已经是启动线程池了的
// 不用我们再去手动启动!!!
std::unique_ptr<TcpServer> usvr=std::make_unique<TcpServer>(port,defaulthandler);
usvr->InitTcpServer();
usvr->StartTcpServer();
return 0;
}
// 其实还是比较简单的
TcpClient.cc:
cpp
#include <iostream>
#include <string>
#include <cstring>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <cstdlib>
#include <cerrno>
#include <unistd.h>
#include <signal.h>
#include "Mutex.hpp"
#include "InetAddr.hpp"
#include "Log.hpp"
#include "Comman.hpp"
#include "Thread.hpp"
//ok,那么伴随着我们把udp的核心接口以及一些功能实现完,接下来我们就来了解TCP这一更好用,更常用的网络通信协议
//那么对于TCP,依旧是要依靠着socket套接字,这个是毋庸置疑的,且客户端要有一个套接字,服务端也得有一个套接字
//这都是我们之前学习UDP知道的了,那么对于TCP的socket创建有什么不同呢???
//其实是差不多的,只不过传入给socket函数的第二个参数变了,要传入SOCK_STREAM
//即_socketfd=socket(AF_INET,SOCK_STREAM,0);,其他的都是一样的
//那么对于TCP协议啊,它的收发消息就是和UDP不一样的了,
//最首先也是最主要的区别就是,TCP是要连接的,即你客户端得先和我们服务端链接了,那么我服务端才能收到你这个客户端的信息
//而UDP是不必链接的,只要知道服务端ip和端口号即可,这就是二者最核心的一个区别
//除此之外,TCP收发消息的函数也不是recvfrom和sendto函数,而是最原始的write和read这两个函数,我们之前在文件那部分有讲过
//为什么呢???因为我们其实也知道,socket函数的返回值,其实就是一个文件描述符!!!!!!!
//而write和read函数不就是用文件的读写吗,所以,write和read这两个函数自然可以用到TCP的收发上
//那么其实也可以使用send和recv函数,但是使用方法和write和read这两个函数差不多
//所以可以自行了解
//至于原理,我们后面会说,在这里,我们先会用就完事了
//OK,那么上面说到链接,即客户端要和服务端进行链接才行,那么,要怎么链接呢???
//其实有两种方式,第一种就是我们直接在命令行中输入 telent 服务端ip 服务端端口号 即可
//那么此时你这个主机的shell进程就会和服务端建立链接,那么此时你也不用什么write函数
//shell进程会自动处理好一切,你直接啪啪啪输入信息即可
//而对于我们自己写的客户端程序呢????
//那么就需要使用connect函数了!!!函数如其名,就是链接的意思
//可以把你这个客户端进程链接到你所指定的服务端:
//connect 是 Linux/Unix 网络编程中客户端专用的系统调用,核心目的是让客户端套接字(socket)与服务端的地址(IP + 端口)建立关联:
//对 TCP 客户端:发起 TCP 三次握手,与服务端建立可靠的连接(这是 connect 最核心的用途);
//对 UDP 客户端:UDP 是无连接协议,connect 不建立实际连接,
//仅 "绑定" 服务端地址(后续发送 / 接收消息无需重复指定服务端地址,且只接收该服务端的消息)
//那么对于udp,我们一般是不用connect函数的,这里做个了解即可
//函数原型(Linux 下)
//#include <sys/socket.h>
//int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
//参数详细解析
//参数 含义 & 注意事项
//第一个参数:sockfd 客户端通过 socket() 函数创建的套接字描述符(整数),是客户端通信的 "句柄";
// 必须是已创建、未关闭的有效 fd。
//第二个参数:addr 指向 struct sockaddr 类型的指针(实际开发中常用 struct sockaddr_in 填充后强制转换),
// 存储服务端的 IP 地址和端口信息;核心是告诉客户端 "要连接哪个服务端"。
//addrlen addr 指向的结构体的实际长度,通常传入 sizeof(struct sockaddr_in) 即可。
//返回值规则
//成功:返回 0;
//TCP 场景:表示三次握手完成,客户端与服务端已建立连接;
//UDP 场景:表示成功绑定服务端地址,无实际连接建立。
//失败:返回 -1,同时设置全局变量 errno(关键!用于排查错误)。
//那么大家仔细看一看就会发现,我的天啊,这个和我们之前udp使用的sendto函数的参数怎么差不多啊!!!
//一样是要传入客户端的socket,一样是要传入存储着指定服务端的struct sockaddr 类型的指针
//也一样是要传入addr 指向的结构体的实际长度(通常传入 sizeof(struct sockaddr_in) 即可)
//那么也就少了要发送的字符串等等(其实这些就是在write函数中使用)
//所以啊,会了UDP,那么使用TCP也会好理解很多
//那么在客户端进行了和服务端connect函数之后,就代表该客户端和指定服务端建立起了链接!!!
//那么问题来了,链接有什么用呢???
// ===================== TCP下connect建立连接的核心作用 =====================
// 1. 建立「双向可靠的通信通道」(连接的本质价值)
// connect成功后,客户端和服务端通过三次握手完成了"双向可达性确认":
// - 客户端确认:服务端存在且能接收自己的消息;
// - 服务端确认:客户端存在且能接收自己的回包;
// - 双方协商好初始的序列号、窗口大小等关键参数,为后续"有序、不丢、不重发"传输数据打下基础。
// 👉 通俗类比:就像你和朋友打通了电话(三次握手),电话线路被专属占用,双方都确认能听到对方说话,
// 接下来才能顺畅聊天;如果没打通(connect失败),直接说话(发数据)对方根本收不到。
//
// 2. 简化后续通信接口,无需重复指定服务端地址
// 连接建立前,TCP客户端若想发数据,必须用sendto(指定服务端IP+端口);
// 连接建立后,客户端可以直接用send/recv收发数据------因为连接已经"绑定"了服务端的IP+端口,
// 内核会自动把数据发给该服务端,不用每次发数据都重复指定"发给谁"。
// 👉 代码层面的对比:
// // 无连接(connect 前):必须用 sendto,指定服务端地址
// sendto(sockfd, data, len, 0, (struct sockaddr*)&server_addr, sizeof(server_addr));
// // 有连接(connect 后):直接用 send,无需指定地址
// send(sockfd, data, len, 0);
//
// 3. 保证通信的「唯一性和可识别性」
// TCP连接是"客户端IP+端口 ↔ 服务端IP+端口"的唯一配对:
// - 服务端能通过这个连接,精准识别"这条消息来自哪个客户端"(不用像UDP那样每次解析recvfrom的输出参数);
// - 服务端回包时,能精准把数据发给对应的客户端,不会发错对象;
// - 即使多个客户端连接同一个服务端,每个连接都是独立的,数据不会串流。
//
// 4. 提前发现错误,避免无效的通信尝试
// connect调用会直接返回"成功/失败":
// - 若服务端未启动(errno=ECONNREFUSED)、网络不通(errno=ETIMEDOUT),客户端能立刻知道"连接失败",
// 不用等到发数据时才发现问题;
// - 若connect成功,说明服务端是可达的,后续发数据的成功率会大幅提升。
// ==========================================================================
//OK,那么在客户端和服务端建立了链接之后,那么客户端要怎么发送信息给服务端呢???
//很显然,在上面我们就已经说过了,可以使用write函数去对socket文件标识符进行写入数据
//那么服务端再使用read函数去对和服务端建立链接的客户端对应的那一个socket文件标识符进行读取数据即可
//上面的write/read==recv/send函数
//客户端,其实就是向服务端发送信息的进程
//那么前面也说了,要客户端也要创建一个socket哦
//然后客户端必须要知道要发送信息的服务端的IP地址和端口号哦
//那么这就需要我们外界在调用该客户端进程的时候就一起传入
//所以,就又需要命令行参数了!!!
// ./udpclient server_ip server_port
using namespace ThreadModule;
using namespace LogModule;
using namespace MutexModule;
int sockfd;
Mutex gmutex;//设置全局锁变量,用于解决输出混行的情况(please enter#和服务端处理结果出现同一行)
//客户端向服务端发送信息的线程函数
void Send()
{
std::string input;//获取用户传来的消息
while(true)
{
//避免please enter#和服务端处理结果出现同一行
{
LockGuard slock(gmutex);
std::cout<<"please enter# ";
std::cout.flush(); // ③ 强制刷新缓冲区(避免内容留在缓冲区导致混行)
}
getline(std::cin,input);
//将信息发送给所绑定的服务端
//因为客户端已经和服务端绑定了,所以客户端就可以直接通过自己的socket套接字文件描述符去向服务端写信息
int ret_write = write(sockfd, input.c_str(), input.size());
if (ret_write < 0)
{
LOG(LogLevel::ERROR)<<"write data failed!"<<strerror(errno);
close(sockfd);//关闭socket套接字文件描述符
break;
}
else if (ret_write != input.size())
{
LOG(LogLevel::WARNING)<<"only send " << ret_write << " bytes / total " << input.size() << " bytes";
}
//设置退出标志
if(input=="QUIT")
{
close(sockfd);//关闭socket套接字文件描述符
break;
}
//send函数
//send(sockfd,input.c_str(),input.size(),0);
}
}
//客户端接收服务端发送的信息的线程函数
void Recv()
{
char buff[1024];
while(true)//死循环读取信息
{
int ret_read=read(sockfd,buff,sizeof(buff)-1);
// a. ret_read>0: 读取成功
// b. ret_read<0: 读取失败
// c. ret_read==0: 对端把链接关闭了,读到了文件的结尾 --- pipe
if(ret_read>0)
{
buff[ret_read]='\0';
}
else if(ret_read==0)//代表读取结束,也是代表客户端断开链接
{
std::cout<<"断开链接";
break;//终止死循环
}
else
{
//代表读取失败
LOG(LogLevel::ERROR)<<"read failed!";
break;//终止死循环
}
//将读取到的信息打印出来
// 原Recv线程的输出逻辑
{
LockGuard slock(gmutex);
// 1. \r回到行首 → \033[K清空当前行 → 输出回复 → 换行
std::cout << "\r\033[K[server reply]: " << buff << std::endl;
// 2. 重新打印输入提示,确保用户在新的干净行输入
std::cout << "please enter# ";
std::cout.flush();
}
}
}
int main(int argc, char *argv[])
{
if (argc != 3)
{
std::cerr << "Usage: " << argv[0] << " server_ip server_port" << std::endl;
exit(USAGE_ERR);
}
// 当客户端异常断开连接(比如强制关闭),服务端继续调用 write 向该客户端写数据时,
// 内核会发送 SIGPIPE 信号给服务端进程。
// 默认情况下,SIGPIPE 会直接终止进程 ------ 这意味着一个客户端的异常会导致整个服务端崩溃。
// 解决方向:
// 在服务端初始化时忽略 SIGPIPE 信号:
struct sigaction sa{};
sa.sa_handler = SIG_IGN;
sigemptyset(&sa.sa_mask);
sigaction(SIGPIPE, &sa, nullptr); // 忽略SIGPIPE信号,write失败时会返回-1,errno设为EPIPE
Use_Monitor_Log();//将日志打印到显示器上
std::string server_ip = argv[1];
int port = std::stoi(argv[2]);//将端口号进行转换,从字符串转换为整型
// 1. 创建TCPsocket
sockfd = socket(AF_INET, SOCK_STREAM, 0);//记得第二个参数传入SOCK_STREAM
if(sockfd < 0)
{
std::cerr << "socket error" << std::endl;
exit(SOCKET_ERR);
}
// 2. 本地的ip和端口是什么?要不要和上面的"文件"关联呢?
// 问题:client要不要bind?需要bind.
// client要不要显式的bind?不要!!首次发送消息,
// OS会自动给client进行bind,OS知道IP,端口号采用随机端口号的方式
// 为什么?一个端口号,只能被一个进程bind,为了避免client端口冲突
// client端的端口号是几,不重要,只要是唯一的就行!
//客户端(无论 UDP/TCP)不需要显式调用 bind,首次发送数据时操作系统(OS)会自动完成 bind:
//OS 会自动为客户端套接字绑定「本机出口 IP + 随机未被占用的端口号」;
//显式 bind 反而容易引发端口冲突,违背客户端 "灵活接入" 的设计需求。
//服务端 vs 客户端:端口需求的本质差异
//角色 端口需求 绑定方式 原因
//服务端(Server) 必须用固定端口(如 80、8080) 显式 bind 客户端需要 "找到" 服务端,必须知道服务端的固定 IP + 固定端口(比如访问网站要连 80 端口)
//客户端(Client) 只需要唯一端口(具体数值无关) OS 自动 bind 服务端只需 "回包" 给客户端的临时 IP + 端口,客户端端口不用固定,唯一即可
//OS 会做 3 件事:
//选一个未被占用的随机端口(比如 45678);
//选客户端的出口 IP(比如本机连路由器的 IP 192.168.1.200);
//自动将这个 "192.168.1.200:45678" 绑定到客户端套接字上。
//服务端通过recvfrom拿到客户端的 "192.168.1.200:45678",就能精准回包,
//客户端端口是 45678 还是 56789 完全不重要。
//如果客户端显式 bind 一个固定端口(比如 9090):
//同一台机器启动多个客户端进程时,第二个进程 bind 9090 会失败(端口被第一个进程占用);
//因为要是我们的客户端代码显式bind的话,即显式将客户端进程绑定到我们指定的端口号的话
//那么如果同一个主机下启动多个客户端进程呢???是不是本质上就是运行同一段代码
//那么这些客户端进程是不是会都去申请我们指定的端口号呢???
//可是第一个客户端进程已经绑定走了,那么后面的又怎么可能申请成功呢???
//所以这就导致无法同一个主机下启动多个客户端进程
//所以啊,我们不应该显式将客户端进程绑定到我们指定的端口号
//应该让OS自己随机挑选空余的端口号,这样子才不会发生上面所说的问题
//这一点就是最重要的原因,需要知道!!!
//客户端完全不需要固定端口,这种做法只会增加 "端口冲突" 的风险,没有任何收益。
//创建struct sockarr_in结构体存储服务端ip和端口号
struct sockaddr_in server;
bzero(&server,sizeof(server));
server.sin_family=AF_INET;
int server_port=htons(port);
server.sin_port=server_port;
//将主机ip转换为网络字节序,并存放进struct sockaddr_in server
int ret_pton = inet_pton(AF_INET, server_ip.c_str(), &server.sin_addr);
if (ret_pton <= 0)
{
LOG(LogLevel::ERROR) << "invalid server IP: " << server_ip << strerror(errno);
close(sockfd);
exit(INET_PTON_ERR);
}
//进行connect,将客户端和指定服务端进行联系
int ret_connect=connect(sockfd,(struct sockaddr*)&server,sizeof(server));
if(ret_connect<0)
{
LOG(LogLevel::ERROR)<<strerror(errno);
close(sockfd);//关闭socket套接字文件描述符
exit(CONNECT_ERR);
}
//那么和udp同理,我们可以让客户端中,一个线程负责发消息,一个线程负责收信息
//创建线程
Thread recver(Recv);
Thread sender(Send);
//运行线程
recver.Create_Start_Thread();
sender.Create_Start_Thread();
//等待线程
recver.JoinThread();
sender.JoinThread();
return 0;
}
结语:于字节流中筑路,在实践里成长
当你读到这里时,相信你已经跟着文字走完了 TCP Socket 编程的完整旅程 ------ 从 TCP 协议 "面向连接、可靠传输" 的底层特性,到 socket、bind、listen、accept 这些核心函数的逐行拆解;从单进程服务端的串行处理,到多进程、多线程并发模型的优化;从客户端双线程收发的细节把控,到端口占用、数据粘包、僵尸进程这些实战坑点的逐一填平。这一路的内容不算轻松,或许你曾在 "字节序转换" 的细节里困惑,在 "多进程阻塞与僵尸进程的矛盾" 中卡壳,在 "线程参数打包与 detach 处理" 时反复调试,但请相信:每一个让你停下脚步的问题,都是你敲开网络编程大门的钥匙。
TCP Socket 编程从来都不是 "背代码" 的学问,而是 "理解原理 + 打磨细节" 的修行。我们从 "为什么 TCP 需要三次握手" 出发,才明白 listen 的两个队列、accept 的阻塞特性不是凭空设计;我们吃透 "Linux 一切皆文件" 的本质,才懂了为什么 read/write 能直接操作 socket;我们踩过 "忘记 close (fd) 导致资源泄漏" 的坑,才学会用 RAII 原则封装资源;我们纠结过 "多进程 waitpid 阻塞" 的矛盾,才探索出三代进程中转、信号自动回收的不同解法...... 这些看似零散的知识点,最终编织成一张网 ------ 一端连着 TCP 协议的底层逻辑,一端系着实际业务的代码实现。这张网,就是你面对任何网络编程问题时,最坚实的底气。
我常常想起自己第一次写出能正常通信的 TCP 服务端和客户端时的场景:敲下编译命令,启动服务端,再打开客户端输入 "hello",屏幕上跳出服务端的回复,那一刻的喜悦,远胜过任何理论的堆砌。网络编程的魅力,正在于它的 "即时反馈"------ 你写的代码不是孤立的字符,而是能跨越进程、甚至跨越网络的 "对话";你解决的 bug 不是抽象的错误,而是让两个程序真正 "听懂" 彼此的关键。就像文中实现的多线程服务端,当你看到多个客户端同时连接、各自收发消息而互不干扰时,你会真切感受到:那些关于进程、线程、文件描述符的枯燥概念,最终都化作了让系统 "活" 起来的动力。
当然,我们今天讲的一切,都只是 TCP Socket 编程的 "入门基石"。文中的多进程模型虽能解决并发,但面对上万级连接时仍会因进程开销过高而力不从心;多线程模型虽轻量,却也面临线程安全、上下文切换的瓶颈;我们用 "长度 + 数据" 解决了粘包,但真实场景中的 HTTP、RPC 协议,有着更复杂的封装逻辑;我们处理了 SIGCHLD、SIGINT 信号,但 TCP 的拥塞控制、超时重传、四次挥手的 TIME_WAIT 状态优化,还有更广阔的探索空间。甚至,当你掌握了这些基础后,还可以去触碰 IO 复用(select/poll/epoll)、异步 IO、协程这些更高效的并发模型,去了解 muduo、libevent 这些成熟的网络库是如何封装底层细节的,去尝试实现一个简单的 HTTP 服务器、一个即时通讯的聊天室 ------ 你会发现,今天埋下的这颗 "TCP Socket" 的种子,能开出无数种可能性。
我想特别提醒你:不要害怕 "重复造轮子"。把文中的服务端改成支持文件传输,把客户端升级为能发送图片,给你的代码加上更完善的日志系统和错误处理 ------ 这些看似 "额外" 的工作,恰恰是你消化知识的最佳方式。网络编程的 bug 往往藏在细节里:可能是你忘记将端口转为网络字节序,导致客户端连不上服务端;可能是你没处理 SIGPIPE 信号,导致客户端断开后服务端崩溃;可能是你在多线程中忽略了互斥锁,导致控制台输出乱成一团...... 这些问题,只有在亲手敲代码、跑程序、抓包调试的过程中,才能真正被你掌握。就像工匠打磨器物,每一次调试都是一次抛光,最终让你的代码从 "能跑" 变成 "健壮"。
TCP 协议的设计里,藏着一种极致的智慧:它用序号保证有序,用确认应答保证可靠,用滑动窗口优化效率 ------ 这何尝不是我们学习编程的隐喻?我们的学习也需要 "序号",从原理到实践,从简单到复杂,一步步推进;我们的成长也需要 "确认应答",解决一个问题,吃透一个知识点,都是对自己的正向反馈;我们的提升也需要 "滑动窗口",在掌握基础的同时,不断拓展认知的边界,接纳更复杂的知识体系。
网络编程的世界很大,从局域网内的进程通信,到广域网中的分布式系统,从底层的 TCP/IP 协议栈,到上层的应用层协议设计,还有无数的内容等待你去探索。但请记住:所有复杂的系统,都始于最简单的基础。今天你写下的第一行 socket () 调用,你理解的第一个 "连接表项",你解决的第一个 "僵尸进程" 问题,都是未来构建大型网络应用的基石。就像 TCP 协议通过一个个字节的可靠传输,最终完成文件、消息、网页的传递;你也会通过一个个知识点的积累,最终搭建起属于自己的技术体系。
最后,愿你始终保持对 "底层逻辑" 的好奇,对 "代码细节" 的敬畏,对 "动手实践" 的执着。当你某天用今天学到的知识,实现了一个能被他人使用的网络应用时,你会明白:那些在屏幕上流动的字节,不仅是 0 和 1 的组合,更是你用代码为世界搭建的桥梁 ------ 而这,正是编程最动人的意义。
去敲代码吧,去调试吧,去让你的字节流跨越网络,抵达它想去的地方。TCP 的旅程没有终点,你的成长亦然。