TCP 字典翻译系统
一、整体设计目标
Daemon,TcpServer,TcpClient三个文件配合工作,实现一个 TCP 字典翻译服务:
- 服务端(
TcpServer)启动后进入守护进程模式,在后台运行 - 客户端(
TcpClient)连接服务端,发送英文单词 - 服务端在线程池中查字典,把中文翻译返回给客户端
- 客户端支持断线重连
TCP 的核心特点(与 UDP 对比):
- 面向连接:通信前必须"三次握手"建立连接
- 字节流:数据没有边界,可能粘包
- 可靠传输:确认机制、超时重传、序号排序
- 需要 accept:服务端用 listen socket 接受连接,每个连接产生新的 sockfd
二、Daemon.hpp 详细解析
2.1 为什么需要守护进程?
普通进程运行时绑定在终端上:
- 终端关闭 → 进程收到
SIGHUP→ 进程退出 - 守护进程(Daemon)脱离终端,在后台独立运行
- 服务器必须用守护进程,否则 SSH 断开服务就停了
2.2 文件头部
cpp
#pragma once
#include <iostream>
#include <unistd.h> // fork(), setsid(), chdir(), dup2(), close()
#include <cstdlib> // exit()
#include <string>
#include <signal.h> // signal()
#include <fcntl.h> // open()
#include <sys/stat.h> // 文件权限相关
#include <sys/types.h>
const std::string nullfile = "/dev/null";
/dev/null 是什么?
- Linux 的"黑洞设备文件"
- 写入的数据全部丢弃,读取返回 EOF
- 守护进程不需要终端输入输出,所以把 stdin/stdout/stderr 都指向它
2.3 Daemon() 函数 - 逐行解析
cpp
void Daemon(const std::string &cwd = "")
{
// 第1步:忽略异常信号
signal(SIGCHLD, SIG_IGN);
signal(SIGPIPE, SIG_IGN);
signal(SIGSTOP, SIG_IGN);
// 第2步:创建子进程,父进程退出
if(fork() > 0)
exit(0);
setsid();
// 第3步:更改工作目录
if(cwd.empty()) chdir(cwd.c_str());
// 第4步:重定向标准输入输出到 /dev/null
int fd = open(nullfile.c_str(), O_RDWR);
if(fd < 0)
exit(1);
dup2(fd, STDIN_FILENO);
dup2(fd, STDOUT_FILENO);
dup2(fd, STDERR_FILENO);
close(fd);
}
第1步:忽略异常信号
cpp
signal(SIGCHLD, SIG_IGN);
signal(SIGPIPE, SIG_IGN);
signal(SIGSTOP, SIG_IGN);
signal() 函数:
- 接口:
void (*signal(int signum, void (*handler)(int)))(int); - 参数1:要处理的信号编号
- 参数2:处理方式(
SIG_IGN= 忽略,SIG_DFL= 默认,或自定义函数指针)
三个信号分别为什么要忽略?
| 信号 | 触发场景 | 不忽略的后果 | 忽略后的效果 |
|---|---|---|---|
SIGCHLD |
子进程退出时发给父进程 | 父进程需要 waitpid 回收,否则产生僵尸进程 |
内核自动回收子进程,不产生僵尸 |
SIGPIPE |
向已关闭的 socket 写数据时 | 进程被默认终止 | 写操作返回 -1,进程继续运行 |
SIGSTOP |
Ctrl+Z 挂起进程 | 进程暂停 | 忽略挂起信号,防止意外暂停 |
SIGPIPE 对 TCP 服务器特别重要:
- 客户端突然断开连接,服务端还在
write() - 如果不忽略
SIGPIPE,服务端进程会被直接杀死 - 忽略后,
write()返回 -1,errno = EPIPE,可以正常处理错误
第2步:fork() + setsid() - 脱离终端
cpp
if(fork() > 0)
exit(0); // 父进程退出
setsid(); // 子进程创建新会话
为什么要 fork?
调用 Daemon() 前:
终端 (tty) ──绑定──> 进程 A (会话组长)
│
└── 进程 A 可以重新打开终端
fork() 后:
终端 (tty) ──绑定──> 进程 A (父) ──exit(0) 退出
进程 B (子,不是会话组长) ──> setsid() ──> 进程 B (新会话组长,无终端)
setsid() 的作用:
- 接口:
pid_t setsid(void); - 创建一个新的会话(Session)和进程组
- 调用进程成为新会话的组长和新进程组的组长
- 脱离控制终端:新会话没有控制终端,终端关闭不会影响它
为什么不能直接在父进程调 setsid()?
setsid()的调用进程不能是进程组长- 父进程通常是进程组长(因为它创建了子进程)
- 所以必须 fork 出子进程,子进程不是组长,才能调
setsid()
第3步:更改工作目录
cpp
if(cwd.empty()) chdir(cwd.c_str());
chdir() 的作用:
- 接口:
int chdir(const char *path); - 更改进程的当前工作目录
为什么要改工作目录?
- 如果工作目录是某个挂载点(如 U 盘),该目录被卸载时进程会出问题
- 通常改成
/(根目录),确保不会被卸载
第4步:重定向标准 IO
cpp
int fd = open(nullfile.c_str(), O_RDWR);
if(fd < 0)
exit(1);
dup2(fd, STDIN_FILENO);
dup2(fd, STDOUT_FILENO);
dup2(fd, STDERR_FILENO);
close(fd);
open("/dev/null", O_RDWR):
- 以读写方式打开
/dev/null - 返回一个新的文件描述符
fd(例如 3)
dup2() 函数:
- 接口:
int dup2(int oldfd, int newfd); - 作用:把
oldfd复制到newfd - 如果
newfd之前打开着,先关闭它 - 之后
newfd和oldfd指向同一个文件
重定向过程图解:
重定向前:
fd 0 (stdin) ──> 终端输入
fd 1 (stdout) ──> 终端输出
fd 2 (stderr) ──> 终端错误
fd 3 (新打开) ──> /dev/null
dup2(fd, STDIN_FILENO) 后:fd 0 ──> /dev/null
dup2(fd, STDOUT_FILENO) 后:fd 1 ──> /dev/null
dup2(fd, STDERR_FILENO) 后:fd 2 ──> /dev/null
close(fd) 后:fd 3 关闭(0/1/2 已经指向 /dev/null)
为什么守护进程要重定向标准 IO?
- 守护进程没有终端,如果代码中有
cout/printf/cerr - 写入 fd 1/2 会失败(没有终端),可能导致进程崩溃
- 重定向到
/dev/null后,写入操作静默成功(数据丢弃),不会出错
三、TcpServer.hpp 详细解析
3.1 文件头部 - 头文件与依赖
cpp
#pragma once
#include "Log.hpp" // 日志模块
#include <cstdlib> // exit()
#include <cstring> // strerror()
#include <iostream>
#include <string>
#include <sys/socket.h> // socket(), bind(), listen(), accept(), setsockopt()
#include <sys/types.h>
#include <arpa/inet.h> // inet_aton(), inet_ntop(), htons(), ntohs()
#include <netinet/in.h> // sockaddr_in
#include <unistd.h> // close()
#include <sys/wait.h> // waitpid()(多进程版本用)
#include <signal.h> // signal()
#include <pthread.h> // pthread_create()(多线程版本用)
#include "ThreadPool.hpp" // 线程池
#include "Task.hpp" // 任务类
#include <signal.h> // 重复包含(无影响)
#include "Daemon.hpp" // 守护进程
与 UDP 的对比:
| 头文件 | UDP | TCP | 原因 |
|---|---|---|---|
<sys/wait.h> |
✘ | ✔ | TCP 版本有多进程方案 |
<pthread.h> |
✘ | ✔ | TCP 版本有多线程方案 |
"ThreadPool.hpp" |
✘ | ✔ | TCP 版本使用线程池 |
"Task.hpp" |
✘ | ✔ | TCP 版本将业务逻辑封装为 Task |
"Daemon.hpp" |
✘ | ✔ | TCP 版本使用守护进程 |
<sys/socket.h> |
✔ | ✔ | 都需要 |
3.2 全局常量与枚举
cpp
const int DEFAULT_FD = -1; // 默认 fd 值(表示未初始化)
const std::string DEFAULT_IP = "0.0.0.0"; // 默认绑定所有网卡
const int DEFAULT_BACKLOG = 10; // 监听队列最大长度
extern Log logger; // 引用全局日志对象
DEFAULT_BACKLOG = 10 是什么?
listen()的参数,表示"未完成连接队列 + 已完成连接队列"的总长度- 如果队列满了,新连接会被拒绝
- 不建议太大:每个排队连接都占内核资源
cpp
enum
{
UsageError = 1, // 命令行参数错误
SocketError, // socket() 失败 → 2
BindError, // bind() 失败 → 3
ListenError // listen() 失败 → 4
};
与 UDP 的对比:
- UDP 只有两个错误码(
SOCKET_ERROR,BIND_ERROR) - TCP 多了
ListenError,因为 TCP 需要调用listen() - TCP 多了
UsageError,用于test.cpp中参数检查
3.3 前向声明与 ThreadData 结构体
cpp
class TcpServer; // 前向声明
为什么需要前向声明?
ThreadData中要用TcpServer *tcp_server指针- 但
TcpServer类在ThreadData之后定义 - 前向声明告诉编译器"这个类存在,只是还没定义"
- 只能用指针/引用,不能用它的大小或成员
cpp
class ThreadData
{
public:
ThreadData(int fd, const std::string &ip, const uint16_t &port, TcpServer *tcp_server)
: sockfd(fd)
, client_ip(ip)
, client_port(port)
, tcp_server(tcp_server)
{}
public:
int sockfd;
std::string client_ip;
uint16_t client_port;
TcpServer *tcp_server;
};
接口设计:
- 四个参数:文件描述符、客户端 IP、客户端端口、TcpServer 指针
tcp_server指针用于在多线程版本中回调TcpServer::Service()方法
3.4 TcpServer类成员变量
cpp
private:
int _listensockfd; // 监听 socket(专门用于 accept)
uint16_t _port; // 监听端口
std::string _ip; // 监听 IP
与 UDP 的关键区别:
| 对比项 | UDP | TCP |
|---|---|---|
| Socket 数量 | 1 个(收发都用同一个) | 多个(1 个 listen + N 个 connection) |
| 成员变量名 | _sockfd |
_listensockfd(强调"监听"用途) |
| 在线用户表 | _online_client |
✘(TCP 每个连接独立处理,不需要全局管理) |
为什么 TCP 不需要 _online_client?
- UDP 是无连接的,服务端需要记住所有客户端地址才能广播
- TCP 每个连接有独立的
sockfd,通过accept就能拿到客户端地址 - 当前版本是"一对一翻译"服务,不需要群聊
3.5 构造函数
cpp
TcpServer(const uint16_t &port, const std::string &ip = DEFAULT_IP)
: _port(port)
, _ip(ip)
, _listensockfd(DEFAULT_FD) // 初始化为 -1
{}
接口设计:
- 必须传
port,ip有默认值0.0.0.0 _listensockfd初始化为 -1(表示"还没创建")
3.6 Init() 函数 - 初始化 Socket
cpp
void Init()
{
// 第1步:创建 TCP Socket
_listensockfd = socket(AF_INET, SOCK_STREAM, 0);
if(_listensockfd < 0)
{
logger(FATAL, "socket failed, errno: %d, errstr: %s", errno, strerror(errno));
exit(SocketError);
}
logger(INFO, "SOCKET SUCCESS");
// 第2步:设置地址重用
int on = 1;
setsockopt(_listensockfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
// 第3步:构造地址结构体
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
inet_aton(_ip.c_str(), &local.sin_addr);
local.sin_port = htons(_port);
// 第4步:绑定
if(bind(_listensockfd, (struct sockaddr *)&local, sizeof(local)) < 0)
{
logger(FATAL, "bind failed, errno: %d, errstr: %s", errno, strerror(errno));
exit(BindError);
}
logger(INFO, "BIND SUCCESS");
// 第5步:监听(TCP 独有)
if(listen(_listensockfd, DEFAULT_BACKLOG) < 0)
{
logger(FATAL, "listen failed, errno: %d, errstr: %s", errno, strerror(errno));
exit(ListenError);
}
logger(INFO, "LISTEN SUCCESS");
}
第1步:socket() - 注意 SOCK_STREAM
cpp
_listensockfd = socket(AF_INET, SOCK_STREAM, 0);
与 UDP 的唯一区别:
- UDP:
SOCK_DGRAM(数据报) - TCP:
SOCK_STREAM(字节流)
| 参数 | UDP 值 | TCP 值 | 含义 |
|---|---|---|---|
type |
SOCK_DGRAM |
SOCK_STREAM |
数据报 vs 字节流 |
第2步:setsockopt() - 设置地址重用(TCP 新增)
cpp
int on = 1;
setsockopt(_listensockfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
setsockopt() 接口:
cpp
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
| 参数 | 值 | 含义 |
|---|---|---|
sockfd |
_listensockfd |
要设置的 Socket |
level |
SOL_SOCKET |
选项所在的协议层(通用 socket 层) |
optname |
SO_REUSEADDR |
选项名:允许地址重用 |
optval |
&on |
指向选项值的指针(1 = 开启) |
optlen |
sizeof(on) |
选项值的长度 |
为什么需要 SO_REUSEADDR?
- TCP 连接关闭后,端口会进入
TIME_WAIT状态(持续 1~2 分钟) - 在此期间,
bind()同一端口会失败(EADDRINUSE) SO_REUSEADDR允许绑定处于TIME_WAIT状态的端口- 服务器重启时不用等待,可以立即绑定
UDP 不需要这个选项的原因:
- UDP 没有连接的概念,也就没有
TIME_WAIT状态 - 关闭后端口立即可用
第3步:构造地址 - inet_aton vs inet_addr
cpp
local.sin_family = AF_INET;
inet_aton(_ip.c_str(), &local.sin_addr); // 注意这里用 inet_aton
local.sin_port = htons(_port);
inet_aton() 与 inet_addr() 的对比:
| 特性 | inet_addr() |
inet_aton() |
|---|---|---|
| 返回值 | in_addr_t(整数) |
int(1=成功,0=失败) |
| 结果输出 | 返回值 | 写入第二个参数(in_addr 结构) |
| 错误处理 | 返回 INADDR_NONE(-1),与广播地址 255.255.255.255 冲突 |
返回 0,无歧义 |
| 线程安全 | 安全 | 安全 |
inet_aton 更推荐使用,因为错误处理更清晰。
第4步:bind() - 与 UDP 相同
cpp
bind(_listensockfd, (struct sockaddr *)&local, sizeof(local));
与 UDP 完全一致。
第5步:listen() - TCP 独有
cpp
listen(_listensockfd, DEFAULT_BACKLOG);
listen() 接口:
cpp
int listen(int sockfd, int backlog);
| 参数 | 含义 |
|---|---|
sockfd |
要监听的 Socket(_listensockfd) |
backlog |
连接队列的最大长度(这里是 10) |
listen() 做了什么?
listen() 之前:
_listensockfd 是一个"普通"的 TCP Socket
可以 connect,但不能被 connect
listen() 之后:
_listensockfd 变成"被动"Socket
内核维护两个队列:
┌────────────────────────────────────────┐
│ 未完成连接队列(SYN 队列) │
│ 客户端发了 SYN,服务器正在三次握手 │
│ 状态:SYN_RCVD │
└────────────────────────────────────────┘
┌────────────────────────────────────────┐
│ 已完成连接队列(Accept 队列) │
│ 三次握手完成,等待 accept() 取走 │
│ 状态:ESTABLISHED │
└────────────────────────────────────────┘
backlog = 两个队列总长度上限
为什么 UDP 不需要 listen()?
- UDP 是无连接的,不需要维护连接队列
- UDP 的 Socket 直接就能收发数据
3.7 Run() 函数 - 主事件循环
cpp
void Run()
{
Daemon(); // 第1步:变成守护进程
ThreadPool<Task>::GetInstance()->Start(); // 第2步:启动线程池
logger(INFO,"TcpServer is running...");
for(;;) // 第3步:无限循环 accept
{
struct sockaddr_in client;
socklen_t client_len = sizeof(client);
int sockfd = accept(_listensockfd, (struct sockaddr *)&client, &client_len);
if(sockfd < 0)
{
logger(WARNING, "accept failed, ...");
continue;
}
uint16_t client_port = ntohs(client.sin_port);
char client_ip[16];
inet_ntop(AF_INET, &(client.sin_addr), client_ip, sizeof(client_ip));
logger(INFO, "ACCEPT SUCCESS, sockfd: %d, client_ip: %s, client_port: %d",
sockfd, client_ip, client_port);
// 第4步:把任务推入线程池
ThreadPool<Task>::GetInstance()->Push(Task(sockfd, client_ip, client_port));
}
}
第1步:Daemon()
cpp
Daemon();
调用位置的意义:
- 在
Run()的第一行调用,此时 Socket 已创建并绑定 Daemon()会 fork 子进程,子进程继承父进程的 Socket 和所有状态- 之后父进程退出,子进程作为守护进程继续运行
为什么不在 Init() 之前调?
- 如果
Daemon()在Init()之前,fork 后父子进程都需要初始化 - 在
Init()之后调,只有守护子进程进入Run()循环
第2步:启动线程池
cpp
ThreadPool<Task>::GetInstance()->Start();
GetInstance() :单例模式获取线程池实例
Start():创建 5 个工作线程,等待任务
第3步:accept() - 接受新连接
cpp
int sockfd = accept(_listensockfd, (struct sockaddr *)&client, &client_len);
accept() 接口:
cpp
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
| 参数 | 含义 |
|---|---|
sockfd |
监听 Socket(_listensockfd) |
addr |
输出参数,保存客户端地址 |
addrlen |
输入输出参数,传入缓冲区大小,返回实际长度 |
返回值:
- 成功:返回新的 Socket 文件描述符(
sockfd) - 失败:返回 -1,设置
errno
accept() 到底做了什么?
监听 Socket (_listensockfd):
├── 被动监听,不参与数据收发
├── accept() 从已完成队列中取出一个连接
└── 取出后,返回一个新的 sockfd
连接 Socket (accept 返回的 sockfd):
├── 与特定客户端绑定
├── 用于实际的 read() / write()
└── 每个客户端连接都有一个独立的 sockfd
与 UDP 的核心区别:
| 对比项 | UDP | TCP |
|---|---|---|
| 收数据 | recvfrom() |
accept() + read() |
| 发数据 | sendto() |
write() / send() |
| Socket 数量 | 1 个 | 1 个 listen + N 个 connection |
| 客户端地址 | 每次 recvfrom 获取 | accept 时一次性获取 |
解析客户端地址
cpp
uint16_t client_port = ntohs(client.sin_port); // 网络→主机序
char client_ip[16];
inet_ntop(AF_INET, &(client.sin_addr), client_ip, sizeof(client_ip));
inet_ntop() 与 inet_ntoa() 的对比:
| 特性 | inet_ntoa() |
inet_ntop() |
|---|---|---|
| 返回值 | char*(静态缓冲区) |
const char*(写入用户提供的缓冲区) |
| 线程安全 | ✘ 不安全 | ✔ 安全 |
| 参数 | in_addr 结构 |
地址族 + in_addr + 缓冲区 + 缓冲区大小 |
| 适用场景 | 单线程 | 多线程 |
为什么这里用 inet_ntop 而非 inet_ntoa?
- TCP 服务器是多线程的(线程池),如果用
inet_ntoa,多个线程同时调用会互相覆盖静态缓冲区 inet_ntop把结果写入用户提供的client_ip数组,线程安全
第4步:推送任务到线程池
cpp
ThreadPool<Task>::GetInstance()->Push(Task(sockfd, client_ip, client_port));
Task(sockfd, client_ip, client_port):
- 构造一个 Task 对象,封装了连接信息和业务逻辑
- Task 内部会
read()客户端消息 → 查字典 →write()返回翻译
Push() 的流程:
- 加锁
- 任务入队列
- 唤醒一个等待的工作线程(
pthread_cond_signal) - 解锁
Run() 中的注释代码 - 四个版本演进
cpp
// v1: 单线程处理(服务端一次只能服务一个客户端)
Service(sockfd, client_ip, client_port);
close(sockfd);
// v2: 多进程处理
pid_t id = fork();
if(id == 0) {
close(_listensockfd); // 子进程不需要监听 Socket
if(fork() > 0) exit(0); // 子进程立刻退出,孙子进程成为孤儿(被 init 收养)
Service(sockfd, ...); // 孙子进程处理连接
close(sockfd);
exit(0);
}
waitpid(id, nullptr, 0); // 父进程等待子进程(立即返回,因为子进程已退出)
// v3: 多线程处理
ThreadData *td = new ThreadData(sockfd, client_ip, client_port, this);
pthread_t tid;
pthread_create(&tid, nullptr, Routine, (void *)td);
// v4: 线程池(当前版本)
ThreadPool<Task>::GetInstance()->Push(Task(sockfd, client_ip, client_port));
四个版本对比:
| 版本 | 方式 | 优点 | 缺点 |
|---|---|---|---|
| v1 | 单线程 | 简单 | 无法并发,一个客户端占用时其他客户端等待 |
| v2 | 多进程 | 隔离性好,子进程崩溃不影响父进程 | fork 开销大,进程间通信复杂 |
| v3 | 多线程 | 轻量,共享内存方便 | 频繁创建/销毁线程有开销 |
| v4 | 线程池 | 线程复用,高效 | 实现稍复杂 |
v2 多进程的"两次 fork"技巧:
父进程 fork → 子进程1 (fork后立即 exit) → 孙子进程 (孤儿,被 init 收养)
│
└── waitpid(子进程1) 立即返回
- 子进程1 立刻退出,父进程的
waitpid立即返回,可以继续accept - 孙子进程变成孤儿进程,被 init(PID=1)收养
- 孙子进程处理完连接后自动退出,由 init 回收
3.8 析构函数
cpp
~TcpServer()
{}
为什么析构函数是空的?
- 当前版本用
for(;;)死循环,Run()永远不会返回 TcpServer对象永远不会被析构- 而且守护进程模式下,资源由 OS 统一回收
与 UDP 析构函数的对比:
- UDP 在析构时
close(_sockfd) - TCP 没有关闭
_listensockfd,因为永远不会执行到这里 - 如果要改进,应该加
close(_listensockfd)
四、TcpClient.cpp 详细解析
4.1 文件头部
cpp
#include <iostream>
#include <unistd.h> // write(), read(), close(), sleep()
#include <sys/socket.h> // socket(), connect()
#include <sys/types.h>
#include <arpa/inet.h> // inet_pton(), htons()
#include <netinet/in.h> // sockaddr_in
#include <string>
#include <cstring> // strerror()
与服务端头文件的对比:
- 客户端没有
<signal.h>、<pthread.h>、"Daemon.hpp"等 - 客户端是单线程同步模型,不需要多线程
- 客户端不需要守护进程
4.2 Usage() 函数
cpp
void Usage(const std::string &proc)
{
std::cout<<"\n\rUsage: "<<proc<<" server_ip server_port"<<std::endl;
}
与 UDP 客户端 Usage 的对比:
- UDP:
void Usage(std::string proc)------ 传值 - TCP:
void Usage(const std::string &proc)------ 传 const 引用 - TCP 版本更规范,避免拷贝
4.3 main() 函数 - 逐段解析
第1段:检查参数与解析
cpp
int main(int argc, char *argv[])
{
if(argc != 3)
{
Usage(argv[0]);
exit(1);
}
std::string server_ip(argv[1]);
uint16_t server_port = std::stoi(argv[2]);
参数说明:
argv[0]:程序名argv[1]:服务端 IPargv[2]:服务端端口
与 UDP 客户端完全一致
第2段:构造服务端地址 - inet_pton
cpp
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
inet_pton(AF_INET, server_ip.c_str(), &server.sin_addr);
server.sin_port = htons(server_port);
inet_pton() 与 inet_addr() / inet_aton() 的对比:
| 函数 | 输入 | 输出 | 支持协议 | 线程安全 |
|---|---|---|---|---|
inet_addr() |
C 字符串 | 返回 in_addr_t |
仅 IPv4 | ✔ |
inet_aton() |
C 字符串 | 写入 in_addr |
仅 IPv4 | ✔ |
inet_pton() |
地址族 + C 字符串 | 写入 in_addr/in6_addr |
IPv4 + IPv6 | ✔ |
inet_pton 接口:
cpp
int inet_pton(int af, const char *src, void *dst);
| 参数 | 值 | 含义 |
|---|---|---|
af |
AF_INET |
IPv4 |
src |
server_ip.c_str() |
IP 字符串 |
dst |
&server.sin_addr |
输出:写入 in_addr 结构 |
返回值:1 = 成功,0 = 格式无效,-1 = 地址族不支持
为什么客户端用 inet_pton 而服务端用 inet_aton?
- 两种方式都可以正常工作
inet_pton是更现代的接口,支持 IPv6,推荐使用- 代码中混用是因为学习目的,展示不同的 API
第3段:断线重连机制
cpp
while(1) // 外层循环:支持重连
{
int cnt = 10; // 重连次数计数器
int isreconnect = false; // 是否需要重连标志
do
{
sockfd = socket(AF_INET, SOCK_STREAM, 0); // 每次重连都创建新 Socket
if(sockfd < 0)
{
std::cout << "socket failed, ..." << std::endl;
return 1;
}
int n = connect(sockfd, (struct sockaddr *)&server, sizeof(server));
if(n < 0)
{
isreconnect = true;
cnt--;
std::cout << "connect failed, ..., reconnect cnt: " << cnt << std::endl;
sleep(1); // 等 1 秒再重试
}
else break; // 连接成功,跳出 do-while
} while(cnt && isreconnect);
if(cnt == 0) // 重连 10 次都失败
{
std::cout << "connect failed, user offline" << std::endl;
break; // 退出外层 while
}
connect() 接口:
cpp
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
| 参数 | 含义 |
|---|---|
sockfd |
客户端 Socket |
addr |
目标服务端地址 |
addrlen |
地址长度 |
返回值:0 = 成功,-1 = 失败
connect() 做了什么?
- 向服务端发送
SYN(第一次握手) - 等待服务端回复
SYN+ACK(第二次握手) - 发送
ACK(第三次握手) - 连接建立后返回 0
TCP 三次握手过程:
客户端 服务端
│ │
│──── SYN (seq=x) ──────────────────►│ 第一次握手
│ │
│◄─── SYN+ACK (seq=y, ack=x+1) ──────│ 第二次握手
│ │
│──── ACK (ack=y+1) ────────────────►│ 第三次握手
│ │
│ 连接建立 (ESTABLISHED) │
断线重连的设计:
┌─────────────────────────────────────────────┐
│ 外层 while(1) 循环 │
│ ┌─────────────────────────────────────┐ │
│ │ do-while 重连循环 │ │
│ │ 1. 创建 socket │ │
│ │ 2. connect() │ │
│ │ 3. 失败 → cnt--, sleep(1), 重试 │ │
│ │ 4. 成功 → break │ │
│ │ 5. cnt==0 → 放弃 │ │
│ └─────────────────────────────────────┘ │
│ ┌─────────────────────────────────────┐ │
│ │ 内层 while(1) 通信循环 │ │
│ │ 1. getline 读输入 │ │
│ │ 2. write 发送 │ │
│ │ 3. read 接收回复 │ │
│ │ 4. write/read 失败 → break 回到外层 │ │
│ └─────────────────────────────────────┘ │
│ close(sockfd) │
└─────────────────────────────────────────────┘
为什么每次重连要创建新 Socket?
- TCP 连接失败后,Socket 状态可能不可恢复
- 旧 Socket 的资源可能还没完全释放
- 创建新 Socket 最干净、最可靠
第4段:通信循环
cpp
while(1)
{
std::string message;
std::cout<<"请输入消息: ";
std::getline(std::cin, message); // 读一行输入
int n = write(sockfd, message.c_str(), message.size()); // 发送
if(n < 0)
{
cnt--;
isreconnect = true;
std::cout << "write failed, ..." << std::endl;
break; // 跳出通信循环,回到重连循环
}
char inbuffer[4096];
n = read(sockfd, inbuffer, sizeof(inbuffer)); // 接收回复
if(n > 0)
{
inbuffer[n] = '\0';
std::cout<<"服务器回复: "<<inbuffer<<std::endl;
}
else break; // n<=0,服务端关闭或出错
}
close(sockfd);
write() 接口:
cpp
ssize_t write(int fd, const void *buf, size_t count);
| 参数 | 含义 |
|---|---|
fd |
Socket 文件描述符(connect 成功后的 sockfd) |
buf |
要发送的数据 |
count |
数据长度 |
read() 接口:
cpp
ssize_t read(int fd, void *buf, size_t count);
| 参数 | 含义 |
|---|---|
fd |
Socket 文件描述符 |
buf |
接收缓冲区 |
count |
缓冲区大小 |
返回值含义(TCP 特有):
| 返回值 | 含义 | 原因 |
|---|---|---|
> 0 |
收到数据,返回字节数 | 正常 |
== 0 |
对方关闭连接 | TCP 的 FIN 到达 |
< 0 |
出错 | 网络异常等 |
read 返回 0 的特殊含义:
- TCP 是面向连接的
- 对端调用
close()会发送FIN包 - 本端
read收到FIN后返回 0 - 这是 TCP 判断"对方下线"的标准方式(UDP 没有)
为什么用 write/read 而非 send/recv?
- 在 Linux 中,Socket 是文件描述符
write(fd, ...)和send(fd, ..., 0)等价(flags=0 时)read(fd, ...)和recv(fd, ..., 0)等价- 用
write/read更直观地体现了"一切皆文件"的 Linux 哲学
inbuffer[n] = '\0':
- 与 UDP 客户端的
buffer[s] = 0相同 - 在收到的数据末尾加终止符,保证
cout输出正确
通信失败后的处理:
write失败 →break回到外层循环 → 尝试重连read返回<= 0→break回到外层循环 → 尝试重连close(sockfd)→ 关闭旧连接,外层循环会创建新 Socket
五、辅助模块简述
5.1 Task.hpp - 任务封装
cpp
class Task
{
public:
Task(int sockfd, const std::string &client_ip, const uint16_t &client_port)
: _sockfd(sockfd), _client_ip(client_ip), _client_port(client_port)
{}
void run() // 业务逻辑
{
char buffer[4096];
while(1)
{
ssize_t n = read(_sockfd, buffer, sizeof(buffer));
if(n > 0)
{
buffer[n] = 0;
std::string echo_string = init.translate(buffer); // 查字典
write(_sockfd, echo_string.c_str(), echo_string.size());
}
else if(n == 0) // 客户端关闭
{
break;
}
else // 出错
{
break;
}
}
close(_sockfd); // 关闭连接 Socket
}
void operator()() // 仿函数,线程池调用
{
run();
}
};
设计要点:
operator()让 Task 变成"可调用对象",线程池中task()就会调用run()run()中循环 read/write,直到客户端断开- 客户端断开后
close(_sockfd)释放连接资源
5.2 init.hpp - 字典初始化
cpp
class Init
{
public:
Init() // 构造时加载字典文件
{
std::ifstream in("./dict.txt");
while(std::getline(in, line))
{
Split(line, &part1, &part2); // "apple:苹果" → "apple" + "苹果"
_dict[part1] = part2;
}
}
std::string translate(const std::string &key)
{
if(_dict.find(key) != _dict.end())
return _dict[key];
return "key not found";
}
};
全局对象 Init init; 在 Task.hpp 中定义,程序启动时自动加载字典。
5.3 ThreadPool.hpp - 线程池
cpp
template<class T>
class ThreadPool
{
// 单例模式
static ThreadPool<T> *GetInstance();
// 启动工作线程
void Start();
// 推入任务
void Push(const T &in);
// 工作线程函数
static void* HanlderTask(void *arg)
{
while(1)
{
tp->Lock();
while(tp->IsEmpty())
tp->Wait(); // 无任务时等待
T task = tp->Pop(); // 取出任务
tp->Unlock();
task(); // 执行任务(调用 Task::operator())
}
}
};
六、完整数据流图
┌──────────────────────────────────────────────────────────────┐
│ 服务端 (test.cpp) │
│ │
│ main() │
│ └── TcpServer │
│ ├── Init() │
│ │ ├── socket(AF_INET, SOCK_STREAM) → _listenfd │
│ │ ├── setsockopt(SO_REUSEADDR) │
│ │ ├── bind(_listenfd, 0.0.0.0:8080) │
│ │ └── listen(_listenfd, 10) │
│ │ │
│ └── Run() │
│ ├── Daemon() ────── 守护进程 │
│ ├── ThreadPool.Start() ────── 5个工作线程 │
│ └── for(;;) │
│ ├── accept() → sockfd (新连接) │
│ ├── inet_ntop() → client_ip │
│ └── Push(Task(sockfd, ip, port)) │
│ │ │
│ ▼ │
│ ┌─────────────────────────┐ │
│ │ 线程池工作线程 │ │
│ │ Task::run() │ │
│ │ ├── read(sockfd) │ │
│ │ ├── init.translate() │ │
│ │ ├── write(sockfd) │ │
│ │ └── close(sockfd) │ │
│ └─────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
▲ │
│ │ TCP 字节流
│ ▼
┌──────────────────────────────────────────────────────────────┐
│ 客户端 (TcpClient.cpp) │
│ │
│ main() │
│ └── while(1) ────── 外层循环(支持重连) │
│ ├── do-while 重连 │
│ │ ├── socket(AF_INET, SOCK_STREAM) │
│ │ ├── connect(sockfd, server_addr) │
│ │ └── 失败 → sleep(1) → 重试(最多10次) │
│ │ │
│ └── while(1) ────── 内层循环(通信) │
│ ├── getline(cin, message) │
│ ├── write(sockfd, message) │
│ ├── read(sockfd, inbuffer) │
│ ├── cout << "服务器回复: " << inbuffer │
│ └── 失败 → break → close(sockfd) → 回到外层 │
└──────────────────────────────────────────────────────────────┘
七、关键 API 对比表
7.1 TCP vs UDP API 对比
| 操作 | UDP | TCP | 说明 |
|---|---|---|---|
| 创建 Socket | socket(AF_INET, SOCK_DGRAM, 0) |
socket(AF_INET, SOCK_STREAM, 0) |
type 不同 |
| 绑定地址 | bind() |
bind() |
相同 |
| 监听 | ✘ | listen() |
TCP 独有 |
| 接受连接 | ✘ | accept() |
TCP 独有 |
| 发起连接 | ✘ | connect() |
TCP 客户端独有 |
| 发送数据 | sendto() |
write() / send() |
UDP 需指定目标地址 |
| 接收数据 | recvfrom() |
read() / recv() |
UDP 需获取发送方地址 |
| 关闭 | close() |
close() |
相同 |
7.2 TCP 服务端 API 调用链
socket() → setsockopt() → bind() → listen() → accept() → read()/write() → close()
7.3 TCP 客户端 API 调用链
socket() → connect() → write()/read() → close()
7.4 IP 地址转换函数对比
| 函数 | 方向 | 线程安全 | 支持 IPv6 | 使用位置 |
|---|---|---|---|---|
inet_addr() |
字符串→整数 | ✔ | ✘ | UDP 服务端 |
inet_aton() |
字符串→in_addr | ✔ | ✘ | TCP 服务端 |
inet_pton() |
字符串→in_addr | ✔ | ✔ | TCP 客户端 |
inet_ntoa() |
整数→字符串 | ✘ | ✘ | UDP 服务端 |
inet_ntop() |
in_addr→字符串 | ✔ | ✔ | TCP 服务端 |
八、问题总结
8.1 为什么 TCP 比 UDP 多了 listen + accept?
UDP(无连接):
客户端 ──sendto──> 服务端(一个 Socket 收所有)
TCP(面向连接):
客户端 ──connect──> 服务端 listen Socket
│
accept() 取出连接
│
返回新的 sockfd(专门服务这个客户端)
- UDP 不需要维护连接状态,一个 Socket 收发所有数据
- TCP 需要为每个客户端维护独立的连接状态(序号、窗口等),所以需要独立的 sockfd
8.2 为什么客户端不需要 bind?
cpp
// TCP客户端需要绑定,但无需显式绑定,因为内核会在 connect() 时自动完成绑定随机端口
connect()会触发三次握手- 内核在握手过程中自动为客户端 Socket 分配临时端口
- 与 UDP 的
sendto自动 bind 机制类似
8.3 read 返回 0 vs read 返回 -1
| 返回值 | 含义 | 原因 | 处理方式 |
|---|---|---|---|
> 0 |
正常收到数据 | 对端发来数据 | 处理数据 |
== 0 |
对端关闭连接 | 收到 FIN 包 | break + close |
< 0 |
出错 | 网络异常、EPIPE 等 | break + close |
UDP 的 recvfrom 不会返回 0(除非收到 0 字节数据报),因为 UDP 没有连接关闭的概念。
8.4 守护进程为什么要重定向标准 IO?
普通进程:
cout → fd 1 → 终端
如果终端关闭 → fd 1 失效 → cout 抛异常 → 进程崩溃
守护进程:
cout → fd 1 → /dev/null
写入的数据被丢弃,操作成功
进程不受终端关闭影响
8.5 为什么 setsockopt(SO_REUSEADDR) 很重要?
没有 SO_REUSEADDR:
服务端运行 → Ctrl+C 停止 → 端口进入 TIME_WAIT(1~2分钟)
立即重启 → bind() 失败:EADDRINUSE → 必须等几分钟
有 SO_REUSEADDR:
服务端运行 → Ctrl+C 停止 → 端口进入 TIME_WAIT
立即重启 → bind() 成功 → 可以立即运行