前言

本文专注讲解 TCP 网络编程核心知识点,包含 Socket 是什么、TCP 通信流程、服务端 / 客户端代码示例、新手必懂的细节(端口、流协议、线程安全、循环发送等)。

本文特点:


一、什么是 Socket?(套接字)

Socket 就是程序用于网络通信的 “接口” 或 “工具”。

你可以把它理解为:两台设备之间通信的 “电话”。

  • 想要发送数据 → 必须创建 Socket
  • 想要接收数据 → 必须创建 Socket
  • Linux 下 Socket 本质就是一个 int 类型文件描述符
  • 基于 TCP 的 Socket 叫 流式 Socket
  • 基于 UDP 的 Socket 叫 数据报 Socket

本文只讲 TCP Socket


二、什么是 TCP?

TCP 是一种可靠的、面向连接的传输层协议。

特点:

  • 必须先建立连接,才能通信
  • 保证数据不丢失、不乱序、不重复
  • 适合传输敏感、重要的数据
  • 类似 “打电话”,必须接通才能说话

三、什么是端口?为什么必须用端口?

端口 = 程序在操作系统里的唯一编号(门牌号)。

一台电脑会同时运行很多程序:

  • 浏览器
  • 微信
  • SSH
  • 你的服务端程序

数据到达电脑后,系统必须通过端口号判断数据交给哪个程序。

端口范围:

  • 0~1023:系统保留端口
  • 1024~65535:自定义可用

我们常用 9999,只是一个空闲、好记、不冲突的端口。

规则:服务端与客户端必须使用同一个端口才能通信。


四、TCP 服务端标准流程(固定 6 步)

所有 TCP 服务端都遵循这个流程:

  1. 创建 socket
  2. 设置端口复用(避免重启报错)
  3. 绑定 IP + 端口
  4. 监听(listen)
  5. 等待客户端连接(accept)
  6. 收发数据(send /recv)

五、TCP 服务端代码示例(超详细注释 + 讲解)

#include <iostream>       // 输入输出头文件,用于打印日志
#include <unistd.h>       // Linux 系统调用头文件,用于 close() 关闭文件描述符
#include <arpa/inet.h>    // TCP/IP 协议族头文件,包含所有 socket 相关函数
#include <cstring>        // 字符串操作头文件,用于 memset() 清空缓冲区
using namespace std;

int main() {
    // ==========================================
    // 第一步:创建 TCP 套接字
    // ==========================================
    // socket() 函数参数说明:
    // AF_INET:     使用 IPv4 协议
    // SOCK_STREAM: 使用流式套接字(TCP)
    // 0:           自动选择对应协议
    // 返回值:成功返回文件描述符(int 数字),失败返回 -1
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_fd < 0) {
        cerr << "创建 socket 失败!" << endl;
        return -1;
    }

    // ==========================================
    // 第二步:设置端口复用(新手必加!)
    // ==========================================
    // 为什么要加这一步?
    // 如果程序异常退出,端口可能会被系统占用几十秒(TIME_WAIT状态)
    // 加上这行代码,可以让端口立即被复用,重启程序不会报错 "Address already in use"
    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    // ==========================================
    // 第三步:准备地址结构体并绑定
    // ==========================================
    // sockaddr_in 是专门用于 IPv4 的地址结构体
    sockaddr_in addr{};
    addr.sin_family = AF_INET;         // 固定:使用 IPv4 协议
    
    // htons() 函数:主机字节序转网络字节序
    // 为什么要转?因为不同电脑存储数字的方式可能不同(大端/小端)
    // 网络传输必须统一使用“网络字节序”(大端序)
    addr.sin_port = htons(9999);
    
    // INADDR_ANY: 监听本机所有网卡(不管用哪个 IP 都能连接)
    addr.sin_addr.s_addr = INADDR_ANY;

    // bind() 函数:把 socket 和 IP+端口 绑定在一起
    // 就像把“电话”和“房间号”绑定
    bind(server_fd, (sockaddr*)&addr, sizeof(addr));

    // ==========================================
    // 第四步:开始监听
    // ==========================================
    // listen() 函数:让 socket 进入监听状态,等待客户端连接
    // 第二个参数 1:表示允许排队等待的最大客户端数量(这里设为 1)
    listen(server_fd, 1);
    cout << "服务端已启动,监听端口 9999,等待客户端连接..." << endl;

    // ==========================================
    // 第五步:阻塞等待客户端连接
    // ==========================================
    // accept() 函数:会阻塞程序,直到有客户端连接上来
    // 就像“守在电话旁,等电话响”
    // 返回值:成功返回一个新的文件描述符,专门用于和这个客户端通信
    sockaddr_in client_addr{};
    socklen_t len = sizeof(client_addr);
    int client_fd = accept(server_fd, (sockaddr*)&client_addr, &len);
    cout << "客户端已连接!" << endl;

    // ==========================================
    // 第六步:接收与发送数据(回显服务器)
    // ==========================================
    char buf[1024];  // 缓冲区,用于存储接收到的数据
    while (true) {
        memset(buf, 0, sizeof(buf));  // 先清空缓冲区,防止上次的数据残留
        
        // recv() 函数:接收数据
        // 参数:客户端 fd、缓冲区、缓冲区大小、标志位(0 表示默认)
        // 返回值:成功返回接收到的字节数,返回 0 表示客户端断开,返回 -1 表示出错
        int n = recv(client_fd, buf, sizeof(buf)-1, 0);
        if (n <= 0) {
            cout << "客户端断开连接" << endl;
            break;
        }
        
        cout << "收到客户端消息:" << buf << endl;
        
        // send() 函数:发送数据
        // 这里我们做一个简单的“回显服务器”:把收到的消息原封不动发回去
        send(client_fd, buf, n, 0);
    }

    // ==========================================
    // 第七步:关闭连接,释放资源
    // ==========================================
    close(client_fd);  // 关闭客户端连接
    close(server_fd);  // 关闭服务端监听
    return 0;
}

六、TCP 客户端标准流程(固定 4 步)

  1. 创建 socket
  2. 连接服务端(connect)
  3. 收发数据
  4. 关闭 socket

七、TCP 客户端代码示例(超详细注释 + 讲解)

#include <iostream>
#include <unistd.h>
#include <arpa/inet.h>
#include <cstring>
using namespace std;

int main() {
    // ==========================================
    // 第一步:创建 socket
    // ==========================================
    int sock = socket(AF_INET, SOCK_STREAM, 0);
    if (sock < 0) {
        cerr << "创建 socket 失败!" << endl;
        return -1;
    }

    // ==========================================
    // 第二步:准备服务端地址并连接
    // ==========================================
    sockaddr_in addr{};
    addr.sin_family = AF_INET;
    addr.sin_port = htons(9999);  // 必须和服务端端口一致!
    
    // inet_pton() 函数:把字符串形式的 IP 地址转换成网络能识别的格式
    // "127.0.0.1" 是本机回环地址(表示连接自己这台电脑)
    inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);

    // connect() 函数:主动连接服务端
    // 就像“主动拨打电话”
    if (connect(sock, (sockaddr*)&addr, sizeof(addr)) < 0) {
        cerr << "连接服务端失败!请确认服务端已启动。" << endl;
        close(sock);
        return -1;
    }
    cout << "成功连接到服务端!" << endl;

    // ==========================================
    // 第三步:发送数据
    // ==========================================
    string msg = "Hello TCP Server!我是客户端。";
    // c_str() 函数:把 C++ string 转换成 C 风格的 char* 字符串
    send(sock, msg.c_str(), msg.size(), 0);
    cout << "已发送消息:" << msg << endl;

    // ==========================================
    // 第四步:接收服务端的回显
    // ==========================================
    char buf[1024] = {0};  // 初始化缓冲区全为 0
    recv(sock, buf, sizeof(buf)-1, 0);
    cout << "收到服务端回显:" << buf << endl;

    // ==========================================
    // 第五步:关闭连接
    // ==========================================
    close(sock);
    return 0;
}

八、TCP 是流协议 → 必须循环发送

为什么要循环发送?

TCP 是字节流协议,不保证一次 send 发完所有数据。

比如你要发 100 字节,可能只发出去 40 字节就返回了(网络拥塞、缓冲区满等原因)。

如果不循环发送,剩下的 60 字节就丢了!

循环发送函数代码(超详细注释)

// send_all 函数:保证数据一定完整发送
// 参数:
//   fd:  客户端 socket 文件描述符
//   data: 要发送的数据指针
//   len:  要发送的总字节数
// 返回值:成功返回 true,失败返回 false
bool send_all(int fd, const char* data, int len) {
    int total = 0;  // 记录已经发送了多少字节
    
    // 循环发送,直到全部发完
    while (total < len) {
        // send() 函数:尝试发送剩下的数据
        // data + total: 从上次发送结束的位置继续发
        // len - total:  还剩下多少字节没发
        int n = send(fd, data + total, len - total, 0);
        
        if (n <= 0) {
            // 发送失败(可能是客户端断开连接、网络出错)
            return false;
        }
        
        total += n;  // 累计已发送的字节数
    }
    
    return true;  // 全部发送完成
}

九、多线程发送必须加锁(线程安全)

为什么要加锁?

如果多个线程同时调用 send 发送数据:

  • 线程 A 刚发一半:[线程1] 温度25
  • 线程 B 突然插进来:[线程2] 湿度60
  • 客户端收到的就是乱码:[线程1] 温度25[线程2] 湿度60

加锁代码示例(超详细注释)

#include <mutex>  // 互斥锁头文件

// 定义一个全局互斥锁
mutex send_mtx;

// 线程安全的发送函数
void safe_send(int client_fd, string msg) {
    // std::lock_guard:自动加锁、自动解锁的“智能锁”
    // 构造时自动加锁,析构时(函数结束时)自动解锁
    // 不用手动写 lock() 和 unlock(),绝对不会忘记解锁!
    lock_guard<mutex> lock(send_mtx);
    
    // 现在只有拿到锁的线程才能执行下面的代码
    // 其他线程必须在外面等
    send_all(client_fd, msg.c_str(), msg.size());
}

十、为什么要把 Socket 封装成类?

直接写代码的缺点:

  • 重复代码多
  • 逻辑混乱
  • 不方便复用
  • 每次都要写十几行 socket、bind、listen

封装成类的好处:

  • 外部只需 3 行代码
  • 逻辑清晰
  • 可跨项目使用
  • 隐藏复杂细节

简单封装示例

​
class TcpServer {
public:
    TcpServer(int port) : port_(port), server_fd_(-1), client_fd_(-1) {}
    
    ~TcpServer() {
        if (client_fd_ >= 0) close(client_fd_);
        if (server_fd_ >= 0) close(server_fd_);
    }
    
    // 启动服务
    bool start() {
        server_fd_ = socket(AF_INET, SOCK_STREAM, 0);
        // ... 省略 bind、listen、accept 代码 ...
        return true;
    }
    
    // 发送消息
    bool sendMessage(string msg) {
        lock_guard<mutex> lock(mtx_);
        return send_all(client_fd_, msg.c_str(), msg.size());
    }
    
private:
    int port_;
    int server_fd_;
    int client_fd_;
    mutex mtx_;
};

​

使用时只需 3 行:

​
TcpServer server(9999);
server.start();
server.sendMessage("hello");

​

十一、全局指针的作用(跨线程访问对象)

问题场景:

  • TCP 对象在 main 函数里创建
  • 消费者线程是单独的函数
  • 普通情况下:线程函数无法直接访问 main 里的局部变量

解决方案:全局指针

​
// 1. 定义一个全局指针
TcpServer* g_tcp = nullptr;

int main() {
    // 2. 创建 TCP 对象
    TcpServer server(9999);
    server.start();
    
    // 3. 把对象地址赋值给全局指针
    g_tcp = &server;
    
    // ... 启动线程 ...
    return 0;
}

// 在线程函数中
void thread_func() {
    // 4. 通过全局指针调用发送函数
    if (g_tcp) {
        g_tcp->sendMessage("data");
    }
}

​

一句话总结:全局指针 = 跨线程共享对象。


十二、新手最常见 4 个问题

1. 端口被占用

现象: 启动服务端报错 Address already in use原因: 程序异常退出,端口未释放解决: 加上 setsockopt 设置端口复用

2. 客户端连不上

现象: 客户端报错 连接失败原因: IP 或 端口不匹配解决: 确保客户端与服务端端口一致,IP 地址正确

3. 数据发送不完整

现象: 客户端只收到一半数据原因: TCP 是流协议解决: 使用循环发送 send_all

4. 多线程发送数据乱码

现象: 收到的数据拼接混乱原因: 多线程并发发送冲突解决: 加互斥锁


十三、知识点总结(简洁清晰)

  • Socket 是网络通信工具
  • TCP 是可靠连接协议
  • 端口是程序的唯一标识
  • 服务端流程:socket → bind → listen → accept
  • 客户端流程:socket → connect → send/recv
  • TCP 是流协议,必须循环发送
  • 多线程发送必须加锁保证安全
  • 封装类让代码更简洁、可复用
  • 全局指针用于跨线程访问对象

更多推荐