计算机网络编程---手写TCP服务器(三)

计算机网络编程---手写TCP服务器(三) 目录一、开篇二、断线自动重连 —— 致敬王者荣耀2.1 场景2.2 do-while 重连逻辑2.3 那个要命的 else break三、英汉翻译 —— 服务器终于有点用了3.1 词典文件3.3 全局 Init 对象四、守护进程 —— 关掉终端它还活着4.1 背景4.2 守护进程的原理4.3 Daemon() 函数实现4.4 逐行拆解4.5 日志怎么处理五、补充知识三次握手、四次挥手、全双工5.1 三次握手 —— 建立连接5.2 四次挥手 —— 释放连接5.3 全双工5.4 连接的实质六、源码Init.hppTask.hppTcpServer.hppMain.ccTcpClient.cc带断线重连七、整个系列总结一、开篇前两篇我们用线程池实现了一个能并发处理多个客户端的 TCP 服务器。但说实话它离能用的产品还有距离客户端太脆弱服务器一挂客户端直接退出不会尝试重连服务端太无聊只会原样返回你发的内容echo# hello部署不靠谱终端一关进程就没了这篇就解决这三个问题顺便补充 TCP 协议层面的基础知识三次握手、四次挥手、全双工通信。二、断线自动重连 —— 致敬王者荣耀2.1 场景你打王者荣耀突然手机断网了。屏幕上显示重新连接...。几秒后网络恢复你自动回到了游戏对局中人物、血条、野怪快速更新同步。这就是断线自动重连。我们也要给我们的客户端加上这个功能。2.2 do-while 重连逻辑核心思路connect 失败后不要直接退出而是重试几次。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 connectedn 0为真进入重连分支 →cnt--→ 继续循环。明明已经连上了却因为没有及时 break最终重试 5 次耗尽了 cnt客户端显示 user offline 退出。三、英汉翻译 —— 服务器终于有点用了之前的服务器只会做 echo——你发 hello它回 tcpserver echo# hello。现在让它干点正事查词典。3.1 词典文件在当前目录下创建一个dict.txtapple: 苹果 banana: 香蕉 cat: 猫 dog: 狗 Monday: 星期一 book: 书 ...格式英文单词: 中文释义用冒号分隔。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_mapstd::string, std::string dict; };Part1 和 Part2 是输出型参数传指针函数内部修改外部变量。这是 C 风格的做法。unordered_map是哈希表查找时间复杂度 O(1)存 200 个单词绰绰有余。3.3 全局 Init 对象词典只需要加载一次所以定义成全局变量所有线程共享同一个init对象。因为Init只在构造时写入哈希表之后只有读取操作没有写操作线程安全。四、守护进程 —— 关掉终端它还活着4.1 背景现在的服务器启动方式./tcpserver 8080问题这个进程是前台进程依赖当前的终端会话session。你把 xshell 关了进程就没了。要想进程永远活着就得让它自成 session不受任何用户登录退出的影响。这就是守护进程。4.2 守护进程的原理用户张三的 session 守护进程自己的 session ├─ bash └─ 我们的服务器独立 ├─ process A 不受张三或任何用户影响 └─ process B守护进程 自成进程组 自成 session 的进程4.3 Daemon() 函数实现#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()要求调用者不能是进程组组长。但服务器进程一启动就是组长自己一个组。fork 前 服务器进程 (组长) PID1000 → 不能调 setsid fork 后 父进程 (组长) PID1000 → exit(0) ← 父进程死了 子进程 (组员) PID1001 → 可以调 setsid 了子进程变成孤儿 → 被 init(PID1) 收养。守护进程的本质就是孤儿进程。第三步setsid()创建一个全新的 session。从此这个进程跟任何用户的登录/退出无关了。第四步chdir(/)工作目录改到根目录。如果工作目录在/home/张三/下哪天张三把家目录卸载了服务器就炸了。放根目录最安全。第五步/dev/null守护进程不需要键盘输入没人跟它交互也不需要向屏幕输出没人看。所以把 0/1/2 全部重定向到/dev/null——数据丢进去就消失。4.5 日志怎么处理守护进程把 stdout 和 stderr 都扔了日志得写到文件里。好在 Log 类支持// Main.cc lg.Enable(Classfile); // 日志按等级分文件日志会出现在/log/log.txt.Info、/log/log.txt.Warning等文件中。五、补充知识三次握手、四次挥手、全双工5.1 三次握手 —— 建立连接用谈恋爱类比你做我女朋友吧 → SYN第一次握手 她什么时候 → SYNACK第二次握手 你就现在 → ACK第三次握手→ 成了映射到代码客户端调用connect()内核发出 SYN → 第一次握手服务端内核收到 SYN回复 SYNACK → 第二次握手客户端内核收到 SYNACK回复 ACK → 第三次握手服务端的accept()在三次握手完成后返回 sockfd三次握手完成后连接建立成功双方可以开始通信。为什么是三次不是两次为了防止已失效的连接请求被服务端误认为是新请求。三次握手让双方都确认自己发的和收的没问题。握手让双方都确认自己发的和收的没问题。5.2 四次挥手 —— 释放连接用分手类比她我们分手吧 → FIN① 服务端关写端 你好呀 → ACK② 客户端确认 你此时还能发消息但她不会再回了... 你我也要分手 → FIN③ 客户端关写端 她行 → ACK④ 服务端确认 彻底分干净为什么是四次不是两次因为 TCP 是全双工——每个方向有独立的管道。服务器调用close()关闭了服务器的写端不再发但读端还开着还能收客户端收到 FIN 后回复 ACK客户端的写端仍然开着还能继续发消息客户端也调用close()关闭客户端的写端服务器回复 ACK双方读写端全关连接彻底释放这就是为什么分手的例子中她说分手之后你还能发消息再骂两句——因为你的写端还开着。5.3 全双工// 同一时间可以同时做 read(sockfd, buf, sizeof(buf)); // 从接收缓冲区读 write(sockfd, buf, strlen(buf)); // 往发送缓冲区写 // 这两个操作互不干扰每个 TCP socket 在内核中有两个独立的缓冲区发送缓冲区和接收缓冲区。读写操作分别操作各自的缓冲区互不影响。5.4 连接的实质在操作系统层面连接就是一个 struct。操作系统的管理哲学先描述再组织。用 struct 描述一个连接的属性然后把所有连接的 struct 串成一个链表。对连接的管理就转化成了对链表的增删查改。三次握手 新增一个连接到链表。四次挥手 从链表中删除一个连接。六、源码makefileall:tcpserver tcpclient tcpserver:Main.cc g -o $ $^ -stdc11 -lpthread tcpclient:TcpClient.cc g -o $ $^ -stdc11 .PHONY:clean clean: rm -f tcpserver tcpclientInit.hpp#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_mapstd::string, std::string dict; };Task.hpp#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#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(/); ThreadPoolTask::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); ThreadPoolTask::GetInstance()-Push(t); } } ~TcpServer() { if (listensock_ 0) close(listensock_); } private: int listensock_; uint16_t port_; std::string ip_; };Main.cc#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_ptrTcpServer server(new TcpServer(port)); server-InitServer(); server-StartServer(); return 0; }TcpClient.cc带断线重连#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