目录
[二、断线自动重连 ------ 致敬王者荣耀](#二、断线自动重连 —— 致敬王者荣耀)
[2.1 场景](#2.1 场景)
[2.2 do-while 重连逻辑](#2.2 do-while 重连逻辑)
[2.3 那个要命的 else break](#2.3 那个要命的 else break)
[三、英汉翻译 ------ 服务器终于有点用了](#三、英汉翻译 —— 服务器终于有点用了)
[3.1 词典文件](#3.1 词典文件)
[3.3 全局 Init 对象](#3.3 全局 Init 对象)
[四、守护进程 ------ 关掉终端它还活着](#四、守护进程 —— 关掉终端它还活着)
[4.1 背景](#4.1 背景)
[4.2 守护进程的原理](#4.2 守护进程的原理)
[4.3 Daemon() 函数实现](#4.3 Daemon() 函数实现)
[4.4 逐行拆解](#4.4 逐行拆解)
[4.5 日志怎么处理?](#4.5 日志怎么处理?)
[5.1 三次握手 ------ 建立连接](#5.1 三次握手 —— 建立连接)
[5.2 四次挥手 ------ 释放连接](#5.2 四次挥手 —— 释放连接)
[5.3 全双工](#5.3 全双工)
[5.4 连接的实质](#5.4 连接的实质)
一、开篇
前两篇我们用线程池实现了一个能并发处理多个客户端的 TCP 服务器。但说实话,它离"能用的产品"还有距离:
- 客户端太脆弱:服务器一挂,客户端直接退出,不会尝试重连
- 服务端太无聊:只会原样返回你发的内容,"echo# hello"
- 部署不靠谱:终端一关进程就没了
这篇就解决这三个问题,顺便补充 TCP 协议层面的基础知识:三次握手、四次挥手、全双工通信。
二、断线自动重连 ------ 致敬王者荣耀
2.1 场景
你打王者荣耀,突然手机断网了。屏幕上显示"重新连接..."。几秒后网络恢复,你自动回到了游戏对局中,人物、血条、野怪快速更新同步。
这就是断线自动重连。我们也要给我们的客户端加上这个功能。
2.2 do-while 重连逻辑
核心思路:connect 失败后不要直接退出,而是重试几次。
cpp
bool isreconnect = false;
int cnt = 5; // 最多重试 5 次
do {
int n = connect(sockfd, (struct sockaddr*)&server, len);
if (n < 0) {
// 连接失败,准备重连
isreconnect = true;
cnt--;
sleep(2); // 歇两秒再试
std::cerr << "connect error..., reconnect: " << cnt << std::endl;
}
else {
break; // 连接成功,退出循环
}
} while (cnt && isreconnect);
if (cnt == 0) {
std::cerr << "user offline..." << std::endl;
return 2; // 5 次都失败,真离线了
}
2.3 那个要命的 else break
这段代码里有一个非常容易踩的坑。
如果不写 else break
连接成功后,isreconnect 仍然是 true。所以 while(cnt && isreconnect) 的条件继续成立,循环不退出,继续执行 connect。
但此时 socket 已经连上了!再次 connect 会返回错误("socket already connected"),n < 0 为真,进入重连分支 → cnt-- → 继续循环。
明明已经连上了,却因为没有及时 break,最终重试 5 次耗尽了 cnt,客户端显示 "user offline" 退出。
三、英汉翻译 ------ 服务器终于有点用了
之前的服务器只会做 echo------你发 "hello",它回 "tcpserver echo# hello"。
现在让它干点正事:查词典。
3.1 词典文件
在当前目录下创建一个 dict.txt:
apple: 苹果
banana: 香蕉
cat: 猫
dog: 狗
Monday: 星期一
book: 书
...
格式:英文单词: 中文释义,用冒号分隔。
cpp
class Init {
public:
Init() {
std::ifstream in(dictname); // "./dict.txt"
if (!in.is_open()) {
lg(Fatal, "open dict file error");
exit(1);
}
std::string line;
while (std::getline(in, line)) {
std::string part1, part2;
if (Split(line, &part1, &part2))
dict.insert({part1, part2});
}
in.close();
}
bool Split(const std::string& line, std::string* part1, std::string* part2) {
size_t pos = line.find(sep); // sep = ":"
if (pos == std::string::npos)
return false;
*part1 = line.substr(0, pos); // "apple"
*part2 = line.substr(pos + 1); // " 苹果"
return true;
}
std::string Translation(const std::string& key) {
auto iter = dict.find(key);
if (iter == dict.end())
return "unknow";
return iter->second;
}
private:
std::unordered_map<std::string, std::string> dict;
};
Part1 和 Part2 是输出型参数(传指针),函数内部修改外部变量。这是 C 风格的做法。
unordered_map 是哈希表,查找时间复杂度 O(1),存 200 个单词绰绰有余。
3.3 全局 Init 对象
词典只需要加载一次,所以定义成全局变量
所有线程共享同一个 init 对象。因为 Init 只在构造时写入哈希表,之后只有读取操作,没有写操作,线程安全。
四、守护进程 ------ 关掉终端它还活着
4.1 背景
现在的服务器启动方式:
cpp
./tcpserver 8080
问题:这个进程是前台进程,依赖当前的终端会话(session)。你把 xshell 关了,进程就没了。
要想进程永远活着,就得让它自成 session,不受任何用户登录退出的影响。 这就是守护进程。
4.2 守护进程的原理
cpp
用户张三的 session 守护进程自己的 session
├─ bash └─ 我们的服务器(独立)
├─ process A 不受张三或任何用户影响
└─ process B
守护进程 = 自成进程组 + 自成 session 的进程
4.3 Daemon() 函数实现
cpp
#include <iostream>
#include <string>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
const std::string nullfile = "/dev/null";
void Daemon(const std::string& cwd = "")
{
// 第一步:忽略无关信号
signal(SIGCHLD, SIG_IGN); // 子进程退出不管
signal(SIGPIPE, SIG_IGN); // 管道破裂不杀进程
signal(SIGSTOP, SIG_IGN); // 不能被暂停
// 第二步:fork,父进程退出
if (fork() > 0)
exit(0);
// 第三步:setsid() 创建新 session
setsid();
// 第四步:chdir 到根目录
if (!cwd.empty())
chdir(cwd.c_str());
// 第五步:打开 /dev/null,重定向 0/1/2
int fd = open(nullfile.c_str(), O_WRONLY);
if (fd > 0) {
dup2(fd, 0); // stdin → 黑洞
dup2(fd, 1); // stdout → 黑洞
dup2(fd, 2); // stderr → 黑洞
close(fd);
}
}
4.4 逐行拆解
第一步:忽略信号
SIGCHLD:子进程退出时父进程不用管,不会产生僵尸SIGPIPE:客户端断了服务器还 write,不会让服务器挂掉SIGSTOP:守护进程不能被暂停
第二步:为什么先 fork?
因为 setsid() 要求调用者不能是进程组组长。但服务器进程一启动就是组长(自己一个组)。
cpp
fork 前:
服务器进程 (组长) PID=1000 → 不能调 setsid
fork 后:
父进程 (组长) PID=1000 → exit(0) ← 父进程死了
子进程 (组员) PID=1001 → 可以调 setsid 了!
子进程变成孤儿 → 被 init(PID=1) 收养。守护进程的本质就是孤儿进程。
第三步:setsid()
创建一个全新的 session。从此这个进程跟任何用户的登录/退出无关了。
第四步:chdir("/")
工作目录改到根目录。如果工作目录在 /home/张三/ 下,哪天张三把家目录卸载了,服务器就炸了。放根目录最安全。
第五步:/dev/null
守护进程不需要键盘输入(没人跟它交互),也不需要向屏幕输出(没人看)。所以把 0/1/2 全部重定向到 /dev/null------数据丢进去就消失。
4.5 日志怎么处理?
守护进程把 stdout 和 stderr 都扔了,日志得写到文件里。
好在 Log 类支持:
cpp
// Main.cc
lg.Enable(Classfile); // 日志按等级分文件
日志会出现在 /log/log.txt.Info、/log/log.txt.Warning 等文件中。
五、补充知识:三次握手、四次挥手、全双工
5.1 三次握手 ------ 建立连接
用谈恋爱类比:
cpp
你:"做我女朋友吧" → SYN(第一次握手)
她:"什么时候?" → SYN+ACK(第二次握手)
你:"就现在!" → ACK(第三次握手)→ 成了
映射到代码:
- 客户端调用
connect(),内核发出 SYN → 第一次握手- 服务端内核收到 SYN,回复 SYN+ACK → 第二次握手
- 客户端内核收到 SYN+ACK,回复 ACK → 第三次握手
- 服务端的
accept()在三次握手完成后返回 sockfd
三次握手完成后,连接建立成功,双方可以开始通信。
为什么是三次不是两次? 为了防止"已失效的连接请求"被服务端误认为是新请求。三次握手让双方都确认自己发的和收的没问题。
握手让双方都确认自己发的和收的没问题。
5.2 四次挥手 ------ 释放连接
用分手类比:
cpp
她:"我们分手吧" → FIN(① 服务端关写端)
你:"好呀" → ACK(② 客户端确认)
你此时还能发消息,但她不会再回了...
你:"我也要分手!" → FIN(③ 客户端关写端)
她:"行" → ACK(④ 服务端确认)
彻底分干净
为什么是四次不是两次?
因为 TCP 是全双工------每个方向有独立的管道。
- 服务器调用
close():关闭了服务器的写端(不再发),但读端还开着(还能收) - 客户端收到 FIN 后回复 ACK:客户端的写端仍然开着,还能继续发消息
- 客户端也调用
close():关闭客户端的写端 - 服务器回复 ACK:双方读写端全关,连接彻底释放
这就是为什么分手的例子中,她说分手之后,你还能发消息再骂两句------因为你的写端还开着。
5.3 全双工
cpp
// 同一时间可以同时做
read(sockfd, buf, sizeof(buf)); // 从接收缓冲区读
write(sockfd, buf, strlen(buf)); // 往发送缓冲区写
// 这两个操作互不干扰
每个 TCP socket 在内核中有两个独立的缓冲区:发送缓冲区和接收缓冲区。读写操作分别操作各自的缓冲区,互不影响。
5.4 连接的实质
在操作系统层面,"连接"就是一个 struct。操作系统的管理哲学:先描述,再组织。
用 struct 描述一个连接的属性,然后把所有连接的 struct 串成一个链表。对连接的管理,就转化成了对链表的增删查改。
三次握手 = 新增一个连接到链表。
四次挥手 = 从链表中删除一个连接。
六、源码
makefile
cpp
all:tcpserver tcpclient
tcpserver:Main.cc
g++ -o $@ $^ -std=c++11 -lpthread
tcpclient:TcpClient.cc
g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
rm -f tcpserver tcpclient
Init.hpp
cpp
#include <iostream>
#include <fstream>
#include <string>
#include <unordered_map>
#include "Log.hpp"
const std::string dictname = "./dict.txt";
const std::string sep = ":";
class Init
{
public:
Init()
{
std::ifstream in(dictname);
if (!in.is_open()) {
lg(Fatal, "ifstream open %s error", dictname.c_str());
exit(1);
}
std::string line;
while (std::getline(in, line)) {
std::string part1, part2;
if (Split(line, &part1, &part2))
dict.insert({part1, part2});
}
in.close();
}
bool Split(const std::string& line, std::string* part1, std::string* part2)
{
size_t pos = line.find(sep);
if (pos == std::string::npos)
return false;
*part1 = line.substr(0, pos);
*part2 = line.substr(pos + 1);
return true;
}
std::string Translation(const std::string& key)
{
auto iter = dict.find(key);
if (iter == dict.end())
return "unknow";
return iter->second;
}
private:
std::unordered_map<std::string, std::string> dict;
};
Task.hpp
cpp
#pragma once
#include <iostream>
#include <string>
#include <cstring>
#include <unistd.h>
#include "Log.hpp"
#include "Init.hpp"
extern Log lg;
Init init;
class Task
{
public:
Task() {}
Task(int sockfd, const std::string& clientip, const uint16_t& clientport)
: sockfd_(sockfd), clientip_(clientip), clientport_(clientport)
{}
void operator()()
{
char inbuffer[4096];
ssize_t n = read(sockfd_, inbuffer, sizeof(inbuffer) - 1);
if (n > 0) {
inbuffer[n] = 0;
std::cout << "client say# " << inbuffer << std::endl;
std::string echo_string = init.Translation(inbuffer);
n = write(sockfd_, echo_string.c_str(), echo_string.size());
if (n < 0)
lg(Info, "write err");
}
else if (n == 0) {
lg(Info, "%s:%d quit, server close sockfd: %d",
clientip_.c_str(), clientport_, sockfd_);
}
else {
lg(Warning, "read error, sockfd: %d", sockfd_);
}
close(sockfd_);
}
~Task() {}
private:
int sockfd_;
std::string clientip_;
uint16_t clientport_;
};
TcpServer.hpp
cpp
#include <iostream>
#include <string>
#include <cstring>
#include <unistd.h>
#include <pthread.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "Log.hpp"
#include "ThreadPool.hpp"
#include "Task.hpp"
#include "Daemon.hpp"
const int defaultfd = -1;
const uint16_t defaultport = 8080;
const std::string defaultip = "0.0.0.0";
const int backlog = 10;
extern Log lg;
enum
{
UsageError = 1,
SocketError,
BindError,
ListenError
};
class TcpServer
{
public:
TcpServer(const uint16_t &port = defaultport, const std::string &ip = defaultip)
: listensock_(defaultfd), port_(port), ip_(ip)
{}
void InitServer()
{
listensock_ = socket(AF_INET, SOCK_STREAM, 0);
if (listensock_ < 0) { lg(Fatal, "create socket error"); exit(SocketError); }
lg(Info, "create socket success, listensock_: %d", listensock_);
int opt = 1;
setsockopt(listensock_, SOL_SOCKET, SO_REUSEADDR|SO_REUSEPORT, &opt, sizeof(opt));
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(port_);
inet_aton(ip_.c_str(), &(server.sin_addr));
socklen_t len = sizeof(server);
if (bind(listensock_, (struct sockaddr *)&server, len) < 0) { lg(Fatal, "bind error"); exit(BindError); }
lg(Info, "bind socket success, listensock_: %d", listensock_);
if (listen(listensock_, backlog) < 0) { lg(Fatal, "listen error"); exit(ListenError); }
lg(Info, "listen socket success, listensock_: %d", listensock_);
}
void StartServer()
{
Daemon("/");
ThreadPool<Task>::GetInstance()->Start();
lg(Info, "tcpserver is running...");
for (;;) {
struct sockaddr_in client;
socklen_t len = sizeof(client);
int sockfd = accept(listensock_, (struct sockaddr*)&client, &len);
if (sockfd < 0) { lg(Warning, "accept error"); continue; }
uint16_t clientport = ntohs(client.sin_port);
char clientip[32];
inet_ntop(AF_INET, &(client.sin_addr), clientip, sizeof(clientip));
lg(Info, "get a new link, sockfd: %d, client ip: %s, client port: %d",
sockfd, clientip, clientport);
Task t(sockfd, clientip, clientport);
ThreadPool<Task>::GetInstance()->Push(t);
}
}
~TcpServer()
{
if (listensock_ > 0)
close(listensock_);
}
private:
int listensock_;
uint16_t port_;
std::string ip_;
};
Main.cc
cpp
#include <iostream>
#include <memory>
#include "TcpServer.hpp"
void Usage(const std::string str)
{
std::cout << "\n\tUsage: " << str << " port[1024+]\n" << std::endl;
}
int main(int argc, char* argv[])
{
if (argc != 2) {
Usage(argv[0]);
exit(UsageError);
}
lg.Enable(Classfile); // 守护进程不能打印到屏幕,改写到文件
uint16_t port = std::stoi(argv[1]);
std::unique_ptr<TcpServer> server(new TcpServer(port));
server->InitServer();
server->StartServer();
return 0;
}
TcpClient.cc(带断线重连)
cpp
#include <iostream>
#include <string>
#include <unistd.h>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
void Usage(const std::string& str)
{
std::cout << "\n\tUsage: " << str << " serverip serverport\n" << std::endl;
}
int main(int argc, char* argv[])
{
if (argc != 3) {
Usage(argv[0]);
return 0;
}
std::string serverip = argv[1];
uint16_t serverport = std::stoi(argv[2]);
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(serverport);
inet_pton(AF_INET, serverip.c_str(), &(server.sin_addr));
socklen_t len = sizeof(server);
while (true) {
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
std::cerr << "socket create err" << std::endl;
return 1;
}
// 断线自动重连逻辑
bool isreconnect = false;
int cnt = 5;
do {
int n = connect(sockfd, (struct sockaddr*)&server, len);
if (n < 0) {
isreconnect = true;
cnt--;
sleep(2);
std::cerr << "connect error..., reconnect: " << cnt << std::endl;
}
else {
break; // 必须加!否则连上了还会继续重连
}
} while (cnt && isreconnect);
if (cnt == 0) {
std::cerr << "user offline..." << std::endl;
return 2;
}
std::string message;
char inbuffer[4096];
std::cout << "Please Enter# ";
std::getline(std::cin, message);
int n = write(sockfd, message.c_str(), message.size());
if (n < 0)
std::cerr << "write err" << std::endl;
n = read(sockfd, inbuffer, sizeof(inbuffer) - 1);
if (n > 0) {
inbuffer[n] = 0;
std::cout << inbuffer << std::endl;
}
close(sockfd);
}
return 0;
}
七、整个系列总结
三篇博客,我们从零走到一个生产级 TCP 服务器。回头看走过的路:
| 阶段 | 核心变化 | 一句话 |
|---|---|---|
| 第一篇 | socket→listen→accept→read/write | 搞懂 TCP API 地基 |
| 第二篇 | 多进程→孙子进程→多线程→线程池 | 从串行到并发 |
| 第三篇 | 重连+翻译+守护进程 | 让服务器真正上线 |
核心知识点清单:
- listensock_ vs sockfd:一个监听,一个通信
- read 返回 0:不处理操作系统会杀进程
- setsockopt:开发阶段防止端口绑定失败
- 文件描述符引用计数:父子进程关同一个 fd 不会互相影响
- 孤儿进程:父进程先挂,子进程被 init 收养
- static 线程函数:去掉 this 指针匹配 pthread_create 签名
- 短服务:线程池服务的核心------干完活立刻回池子
- 单例双重检查锁:兼顾正确性和性能
- 断线重连:do-while + else break
- 守护进程:fork + setsid + chdir + /dev/null