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

目录

一、开篇

[二、断线自动重连 ------ 致敬王者荣耀](#二、断线自动重连 —— 致敬王者荣耀)

[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 连接的实质)

六、源码

Init.hpp

Task.hpp

TcpServer.hpp

Main.cc

TcpClient.cc(带断线重连)

七、整个系列总结


一、开篇

前两篇我们用线程池实现了一个能并发处理多个客户端的 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(第三次握手)→ 成了 

映射到代码:

  1. 客户端调用 connect(),内核发出 SYN → 第一次握手
  2. 服务端内核收到 SYN,回复 SYN+ACK → 第二次握手
  3. 客户端内核收到 SYN+ACK,回复 ACK → 第三次握手
  4. 服务端的 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 地基
第二篇 多进程→孙子进程→多线程→线程池 从串行到并发
第三篇 重连+翻译+守护进程 让服务器真正上线

核心知识点清单:

  1. listensock_ vs sockfd:一个监听,一个通信
  2. read 返回 0:不处理操作系统会杀进程
  3. setsockopt:开发阶段防止端口绑定失败
  4. 文件描述符引用计数:父子进程关同一个 fd 不会互相影响
  5. 孤儿进程:父进程先挂,子进程被 init 收养
  6. static 线程函数:去掉 this 指针匹配 pthread_create 签名
  7. 短服务:线程池服务的核心------干完活立刻回池子
  8. 单例双重检查锁:兼顾正确性和性能
  9. 断线重连:do-while + else break
  10. 守护进程:fork + setsid + chdir + /dev/null
相关推荐
草莓熊Lotso5 小时前
【Linux网络】从 0 到工业级:TCP 服务器多线程 / 线程池全实现 + 远程命令执行实战
linux·运维·服务器·网络·人工智能·网络协议·tcp/ip
盛世宏博北京5 小时前
物联网赋能档案保护——档案馆“八防”温湿度智能监控系统实施方案
运维·服务器·网络
手揽回忆怎么睡5 小时前
云服务器部署
运维·服务器
华纳云IDC服务商5 小时前
站群服务器能带多少个网站?内存和带宽该如何配置?
运维·服务器
ChampaignWolf5 小时前
ABAP的代理时刻:SAP-ABAP-1、ABAP MCP服务器和VS Code云扩展将于2026年第二季度上线
运维·服务器·人工智能
成空的梦想5 小时前
免费 vs 付费国密 SSL 怎么选?
服务器·网络·网络协议·http·https·ssl
minji...5 小时前
Linux 网络基础之传输层TCP(六)TCP报头格式,TCP可靠性,序号/确认序号,窗口大,标志位,初识三次握手四次挥手
linux·运维·服务器·网络·网络协议·tcp/ip·http
文青小兵5 小时前
云计算Linux——数据库MySQL主从复制和读写分离(十七)
linux·运维·服务器·数据库·mysql·云计算
文青小兵5 小时前
云计算Linux——负载均衡 (十四)
linux·运维·服务器·nginx·云计算·负载均衡