前置知识:Linux网络收包链路、sk_buff、协议栈封装解封装、Socket内核对象

目录

  1. TCP与UDP核心本质对比(OS角度)
  2. Socket内核对象 + 收发缓冲区深度详解
  3. TCP、UDP适用场景与选型决策
  4. UDP编程:特性、API全解、坑点、使用注意事项
  5. TCP编程:字节流本质、粘包半包、长短连接工程实现
  6. 完整可编译代码:UDP服务端/客户端(带详细注释)
  7. 完整可编译代码:TCP服务端/客户端(带详细注释)
  8. send/recv 内核缓冲区行为细节与返回值全解
  9. TCP长短连接:工程实现、心跳机制、异常处理
  10. 高频踩坑清单(含错误码排查)
  11. 调试与观测工具
  12. 面试核心总结

1. TCP与UDP核心本质对比(OS角度)

对比维度 UDP(用户数据报协议) TCP(传输控制协议)
连接模型 无连接。无需握手、无需挥手,发送方随时可以发包,接收方随时可以收包。 面向连接。必须通过三次握手建立连接后才能传输数据,断开必须四次挥手。
报文模型 数据报(Datagram)。内核严格保留报文边界,一个报文是一个独立单元。 字节流(Stream)。内核不保留任何报文边界,所有数据被视为连续的字节序列。
可靠性保障 无任何可靠性机制。不保证报文到达、不保证顺序、不重复、出错不重传。 全链路可靠。超时重传、ACK确认、序号排序、去重、滑动窗口流控、拥塞控制。
传输层头部大小 固定8字节 最小20字节,带选项可达60字节
头部核心字段 源端口、目的端口、报文长度、校验和 源端口、目的端口、序号seq、确认号ack、数据偏移、标志位、窗口大小、校验和、紧急指针
内核分用匹配规则 仅通过目的端口匹配socket。同一个端口可以收到来自任意源地址的UDP报文。 通过**四元组(源IP+源端口+目的IP+目的端口)**精确匹配socket。
缓冲区行为 一次recvfrom读取一整个完整报文;缓冲区小于报文时,多余字节直接丢弃。 字节流模式,recv读取当前缓冲区中可用的任意字节数,可多可少。
流量控制 无。发送方可以全速发包,不管接收方是否处理得过来。 有。滑动窗口机制,接收方通告窗口大小,发送方严格按窗口发送。
拥塞控制 无。会直接打满链路带宽。 有。慢启动、拥塞避免、快速重传、快速恢复。
广播/组播 支持 不支持
内核处理开销 极低,协议逻辑简单 高,维护复杂状态机、重传队列、乱序队列
延迟 极低,无握手等待 相对高,有握手开销、重传等待

📌 协议栈分用完整链路回顾

  1. 链路层解析以太网头,读取h_proto字段,0x0800向上交付IP层;
  2. IP层解析IP头,读取protocol字段,6交付TCP,17交付UDP;
  3. 传输层根据端口/四元组,将payload写入对应socket的接收缓冲区;
  4. 唤醒socket等待队列上阻塞的进程。

2. Socket内核对象 + 收发缓冲区深度详解

Socket本质上是内核中的一个结构体对象,用户态进程通过系统调用拿到的int fd只是一个文件描述符编号,指向内核中的这个对象。

每个独立的socket内核对象,都维护两套完全独立的环形缓冲区:

2.1 接收缓冲区(Receive Buffer)

  • 数据来源:网卡DMA收包 → 软中断协议栈逐层解析 → 剥离协议头 → 将纯payload拷贝进该socket的接收缓冲区。
  • 数据去向:用户调用recv()/read()/recvfrom()系统调用,陷入内核,执行copy_to_user(),将内核缓冲区的数据拷贝到用户进程的虚拟地址空间。
  • 阻塞行为:若缓冲区为空,阻塞模式下的进程会被挂到socket内部的等待队列上,状态变为TASK_INTERRUPTIBLE,让出CPU;当新数据到达填充缓冲区后,内核唤醒等待队列中的进程。
  • 满溢处理
    • TCP:接收缓冲区满时,内核会通过TCP头部窗口字段通告对端窗口为0,对端停止发送,不会丢包。
    • UDP:接收缓冲区满时,新来的UDP报文直接被内核静默丢弃,应用层无任何感知,也不会给发送方返回任何错误。

2.2 发送缓冲区(Send Buffer)

  • 数据来源:用户调用send()/write()/sendto(),执行copy_from_user(),将用户数据拷贝进内核发送缓冲区。
  • 数据去向:内核协议栈从发送缓冲区取数据,封装TCP/UDP头、IP头、以太网头,交由网卡DMA发送。
  • 阻塞行为:若发送缓冲区已满,阻塞模式下send会阻塞挂起,直到内核把数据发出去、腾出空闲空间。
  • ⚠️ 最容易误解的点

    send()函数成功返回,绝不等于对方已经收到数据,甚至不等于数据已经被送到网卡上。它仅仅代表:数据已经成功从用户内存拷贝到了内核发送缓冲区。后续何时发送、是否重传,完全由内核协议栈异步控制。

2.3 缓冲区大小配置

缓冲区大小不是固定的,可以通过setsockopt系统调用调整:

// 设置接收缓冲区大小
int rcv_buf = 128 * 1024;  // 128KB
setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &rcv_buf, sizeof(rcv_buf));

// 设置发送缓冲区大小
int snd_buf = 128 * 1024;
setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &snd_buf, sizeof(snd_buf));

注意:内核不会完全按照你设置的值生效,通常会将设置值翻倍,且不能超过系统最大值。
查看系统默认与最大值:

# TCP接收缓冲区配置:最小值、默认值、最大值
sysctl net.ipv4.tcp_rmem
# TCP发送缓冲区配置
sysctl net.ipv4.tcp_wmem
# 全局socket最大接收缓冲区
sysctl net.core.rmem_max
# 全局socket最大发送缓冲区
sysctl net.core.wmem_max

2.4 如何观测缓冲区占用

# 查看所有TCP连接的收发缓冲区队列长度
ss -itn
# Recv-Q:接收缓冲区中未被应用读取的字节数
# Send-Q:发送缓冲区中已拷贝但未被确认的字节数

3. TCP、UDP适用场景与选型决策

3.1 UDP 适用场景

UDP的核心哲学是:允许丢包,但绝不能等。它把可靠性的控制权完全交给应用层。

典型场景:

  1. 实时音视频通话、直播:丢几帧只会画面花一下,重传延迟会造成严重卡顿,完全不可接受。
  2. 实时网络游戏帧同步:位置信息时效性极强,重传过来的旧位置毫无意义。
  3. DNS查询:单次请求-应答模式,请求本身很短,超时应用层自己重试即可,无需建立连接的开销。
  4. 局域网广播/组播:设备发现、同屏多播等场景。
  5. 隧道协议:如VXLAN、GRE,在内核之上再封装一层。

❌ 绝对不适合UDP的场景:

  • 文件传输、支付订单、数据库通信、日志落盘——任何不允许数据丢失、错乱的业务。

3.2 TCP 适用场景

TCP的核心哲学是:宁可慢一点,也要保证数据准确、有序、不丢。所有可靠性内核帮你做好。

典型场景:

  1. HTTP/HTTPS网页、RESTful API:绝大多数Web业务。
  2. 数据库连接(MySQL、Redis):命令与结果绝不允许出错。
  3. 文件传输(FTP、SFTP):文件必须完整无损。
  4. 消息队列、日志传输:保证消息不丢。
  5. 绝大多数后端服务间RPC调用

❌ 不适合TCP的场景:

  • 极致低延迟、可容忍少量丢包的实时场景;
  • 广播、组播场景。

3.3 选型决策口诀

  • 能接受丢包、追求最低延迟 → UDP + 应用层自定义可靠机制;
  • 数据绝不能丢、必须有序 → 直接选TCP,不要自己造轮子。

4. UDP编程:特性、API全解、坑点、使用注意事项

4.1 UDP核心特性再强调

  1. 无连接:没有建立连接的过程,调用sendto指定地址就可以直接发包。
  2. 报文边界保留:发送方发一个100字节的包,接收方一定读到完整的100字节,不会读一半,也不会和下一个包粘在一起。
  3. 支持一对多:一个服务端socket可以和无数个客户端通信,只靠recvfrom拿到的地址区分。

4.2 UDP也可以调用 connect()

很多人以为只有TCP能connect,其实UDP也可以调用connect(),但它不会发送任何网络数据包,没有握手过程

它的作用只有一个:在内核socket对象上记录一个固定的对端IP+端口

  • connect之后,可以直接用send()/recv(),不用每次都传地址参数;
  • connect之后,内核会过滤掉源地址不匹配的报文,只有指定对端发来的数据才会放进接收缓冲区;
  • connect之后,如果端口不可达,ICMP端口不可达错误会返回给应用进程,未connect的UDP通常收不到这个错误。

UDP connect可以重复调用,用来修改目标地址,不需要close。

4.3 核心API参数详解

socket() 创建UDP套接字
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
  • AF_INET:IPv4协议族;AF_INET6对应IPv6。
  • SOCK_DGRAM:数据报类型,固定对应UDP。
  • 第三个参数protocol:填0即可,内核自动选择UDP。
  • 返回值:成功返回非负文件描述符;失败返回-1,errno记录错误码。
bind() 绑定地址端口
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  • 服务端必须bind,固定监听端口;
  • 客户端通常不需要bind,内核会自动分配一个随机源端口。
  • INADDR_ANY表示监听本机所有网卡IP。
recvfrom() 接收数据
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
                 struct sockaddr *src_addr, socklen_t *addrlen);
  • buf:用户提供的接收缓冲区指针;
  • len:缓冲区最大长度;
  • flags:常用标志位:
    • 0:默认阻塞模式;
    • MSG_PEEK:偷窥数据,读完不把数据从缓冲区移除,下次还能读到;
    • MSG_DONTWAIT:本次调用非阻塞,没数据立刻返回错误。
  • src_addr:输出参数,返回发送方的IP和端口;
  • addrlen:传入传出参数,传入结构体大小,返回实际地址长度。
sendto() 发送数据
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
               const struct sockaddr *dest_addr, socklen_t addrlen);
  • dest_addr:目标地址,每次发送都可以不同;
  • 返回值:成功返回发送的字节数;UDP要么全发成功要么失败,不会出现部分发送。

4.4 UDP重要注意事项与坑点

  1. 接收缓冲区不足,数据静默截断丢弃

    例:一个UDP报文1500字节,你传入的buf只有1000字节。recvfrom会返回1000,剩下500字节直接被内核丢弃,永远找不回来。
    ✅ 工程规范:接收缓冲区大小必须大于等于业务最大报文长度。

  2. IP分片风险

    UDP报文如果加上IP头后超过链路MTU(以太网默认1500字节),IP层会分片。只要其中任意一片丢失,整个UDP报文全部丢弃。
    ✅ 工程建议:以太网环境下,UDP单包payload控制在1472字节以内(1500 - 20IP头 - 8UDP头),避免分片。

  3. 没有流量控制,发太快必然丢包

    UDP没有滑动窗口,接收方处理不过来就直接丢包。高速发送场景必须应用层做流控。

  4. sendto成功不代表对方收到

    只是放进了内核发送队列。甚至网卡没插上,sendto也可能成功返回。

  5. UDP不会返回0

    TCP recv返回0代表对端关闭连接;UDP没有连接概念,永远不会返回0,除非你传了len=0。

  6. 不存在TIME_WAIT

    关闭socket直接释放资源,没有连接状态机。


5. TCP编程:字节流本质、粘包半包、长短连接工程实现

5.1 TCP字节流与粘包问题

什么是粘包、半包
  • 粘包:发送方发了两个独立数据包,接收方一次recv全部读出来,合在了一起,分不清边界。
  • 半包:发送方发了一个完整数据包,接收方一次recv只读到了一部分。

这两个现象本质上是同一个原因:TCP是字节流,没有报文边界。内核只负责搬运字节,不关心业务报文怎么划分。

粘包产生的三个常见原因
  1. 发送方:Nagle算法合并小数据包,攒一波一起发,减少网络小包数量;
  2. 传输链路:网络中报文被合并;
  3. 接收方:接收不及时,多个报文堆积在socket缓冲区,一次recv读到多个。

粘包不是bug,是TCP为了提升传输效率的正常行为。

半包产生的原因
  1. 接收缓冲区剩余空间不足,只能放下一部分数据;
  2. 用户传入的recv缓冲区小于一个完整业务报文。
标准解决方案:应用层自定义协议

业界最通用、最稳妥的方案:固定长度包头 + 变长包体

┌─────────────────┬──────────────────┐
│ 4字节 包体长度N  │   N字节 业务数据  │
└─────────────────┴──────────────────┘

读取逻辑:

  1. 循环读取,先凑齐4字节的长度字段;
  2. 解析出包体长度N;
  3. 继续循环读取,凑齐N字节的包体;
  4. 处理完一个完整报文后,重复上述步骤。

UDP永远不需要处理粘包,内核保证报文边界。

5.2 TCP核心API详解

socket()
int fd = socket(AF_INET, SOCK_STREAM, 0);

SOCK_STREAM:字节流类型,对应TCP。

bind()

服务端绑定端口,和UDP用法一致。

listen() 转为监听状态
int listen(int sockfd, int backlog);
  • 将socket从主动套接字变为被动监听套接字;
  • backlog:全连接队列(已完成三次握手、等待accept取出的连接)的最大长度;
  • 内核还有一个半连接队列(SYN_RECV状态),长度由net.ipv4.tcp_max_syn_backlog控制;
  • 队列满了之后,新来的SYN会被直接丢弃。
accept() 取出新连接
int accept(int listenfd, struct sockaddr *addr, socklen_t *addrlen);
  • 阻塞等待,从全连接队列中取出一个已经完成三次握手的连接;
  • 返回值是全新的文件描述符conn_fd,专门代表这条TCP连接,后续收发全部用这个fd;
  • 原来的listen_fd继续用来监听新连接,绝对不能用listen_fd来收发数据。
connect() 发起连接
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  • 客户端调用,触发内核发送SYN报文,开始三次握手;
  • 阻塞模式下,会一直等到握手成功或超时失败才返回;
  • 失败常见原因:端口没开、网络不通、超时。
send() / recv()
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
  • flags常用:
    • 0:默认阻塞;
    • MSG_WAITALL:recv会等待,直到读满len字节才返回(信号或出错除外);
    • MSG_DONTWAIT:本次非阻塞。

5.3 TCP长连接 vs 短连接

短连接

生命周期:每次业务请求前建立连接 → 收发一次数据 → 立刻关闭连接。

  • 优点:实现简单,服务端不用管理连接状态,用完即释放;
  • 缺点:每次请求都有三次握手+四次挥手开销,延迟高;高频场景产生大量TIME_WAIT状态连接,消耗内核资源;
  • 适用场景:低频访问、HTTP/1.0默认模式、一次性脚本。
长连接

生命周期:建立一次连接 → 在连接上进行N次数据收发 → 空闲超时/程序退出才关闭。

  • 优点:省去握手挥手开销,延迟低,吞吐高;
  • 缺点:服务端要维护大量连接状态,占用文件描述符;需要处理连接异常断开、空闲释放;
  • 适用场景:数据库连接、RPC、游戏服务器、HTTP/1.1、WebSocket。
长连接必备机制:心跳检测

TCP本身没有内置心跳机制。如果对端机器突然断电、网线被拔掉,本端内核不会立刻感知到连接已断,recv会一直阻塞。

工程上两种心跳方案:

  1. 应用层心跳:双方约定,空闲N秒后,一端发一个心跳包,另一端回复心跳应答;连续M次没收到应答则判定连接断开,主动关闭。

    优点:可控、灵活、和业务结合紧密;工业界标准做法。

  2. TCP Keepalive:操作系统内核层面的探测机制,通过setsockopt开启。

    缺点:默认间隔时间太长(通常2小时),只能检测连接死活,不能携带业务信息。一般作为兜底。

5.4 TCP状态机简述

核心状态:

  • LISTEN:服务端监听状态;
  • SYN_SENT:客户端已发SYN,等待对端ACK;
  • SYN_RECV:服务端收到SYN,已回复SYN+ACK;
  • ESTABLISHED:连接建立,正常数据传输状态;
  • FIN_WAIT_1FIN_WAIT_2TIME_WAIT:主动关闭方状态;
  • CLOSE_WAITLAST_ACK:被动关闭方状态。

TIME_WAIT:主动关闭方最后停留的状态,持续2MSL(通常1-2分钟)。作用是保证最后一个ACK能到达对端,防止旧连接的迟到报文影响新连接。
服务端重启报“Address already in use”,基本都是TIME_WAIT占用端口导致,开启SO_REUSEADDR即可解决。


6. 完整可编译代码:UDP服务端/客户端(带详细注释)

udp_server.cc

#include <iostream>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>
#include <cerrno>

#define LISTEN_PORT 8888
#define BUFFER_SIZE 1472  // 以太网MTU下UDP最大安全payload

int main()
{
    // 1. 创建UDP套接字:AF_INET表示IPv4,SOCK_DGRAM表示数据报(UDP)
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0) {
        std::cerr << "create socket failed, errno=" << errno 
                  << " msg:" << strerror(errno) << std::endl;
        return -1;
    }

    // 2. 构造服务端地址结构体
    sockaddr_in serv_addr{};
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 监听所有网卡
    serv_addr.sin_port = htons(LISTEN_PORT);       // 端口号必须转网络字节序

    // 3. 绑定端口和地址
    if (bind(sockfd, reinterpret_cast<sockaddr*>(&serv_addr), sizeof(serv_addr)) < 0) {
        std::cerr << "bind failed, errno=" << errno 
                  << " msg:" << strerror(errno) << std::endl;
        close(sockfd);
        return -1;
    }

    std::cout << "UDP server started, listening on port " << LISTEN_PORT << std::endl;

    char recv_buf[BUFFER_SIZE];
    sockaddr_in client_addr{};
    socklen_t cli_addr_len = sizeof(client_addr);

    while (true) {
        // 4. 阻塞接收数据,同时获取客户端地址信息
        ssize_t recv_len = recvfrom(sockfd, recv_buf, BUFFER_SIZE, 0,
                                    reinterpret_cast<sockaddr*>(&client_addr),
                                    &cli_addr_len);
        if (recv_len < 0) {
            std::cerr << "recvfrom error, errno=" << errno << std::endl;
            continue;
        }

        // 手动补字符串结束符,方便打印
        recv_buf[recv_len] = '\0';

        // 打印客户端信息:IP转字符串,端口转主机字节序
        std::cout << "[" << inet_ntoa(client_addr.sin_addr) 
                  << ":" << ntohs(client_addr.sin_port) << "] "
                  << "recv " << recv_len << " bytes: " << recv_buf << std::endl;

        // 5. 回射:把收到的数据原样发回给客户端
        ssize_t send_len = sendto(sockfd, recv_buf, recv_len, 0,
                                  reinterpret_cast<sockaddr*>(&client_addr),
                                  cli_addr_len);
        if (send_len < 0) {
            std::cerr << "sendto error, errno=" << errno << std::endl;
        }
    }

    close(sockfd);
    return 0;
}

udp_client.cc

#include <iostream>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>
#include <cerrno>

#define SERVER_IP "127.0.0.1"
#define SERVER_PORT 8888
#define BUFFER_SIZE 1472

int main()
{
    // 1. 创建UDP socket
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0) {
        perror("socket");
        return -1;
    }

    // 2. 构造服务端地址
    sockaddr_in serv_addr{};
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_port = htons(SERVER_PORT);
    // 点分十进制IP转网络字节序二进制
    if (inet_pton(AF_INET, SERVER_IP, &serv_addr.sin_addr) <= 0) {
        std::cerr << "invalid server ip" << std::endl;
        close(sockfd);
        return -1;
    }

    const char* send_msg = "Hello, UDP Server!";
    // 3. 直接发送数据,无需连接
    ssize_t sent = sendto(sockfd, send_msg, strlen(send_msg), 0,
                          reinterpret_cast<sockaddr*>(&serv_addr),
                          sizeof(serv_addr));
    if (sent < 0) {
        perror("sendto");
        close(sockfd);
        return -1;
    }
    std::cout << "sent " << sent << " bytes to server" << std::endl;

    // 4. 接收服务端回射
    char recv_buf[BUFFER_SIZE];
    sockaddr_in from_addr{};
    socklen_t from_len = sizeof(from_addr);
    ssize_t recv_len = recvfrom(sockfd, recv_buf, BUFFER_SIZE, 0,
                                reinterpret_cast<sockaddr*>(&from_addr),
                                &from_len);
    if (recv_len > 0) {
        recv_buf[recv_len] = '\0';
        std::cout << "recv from server: " << recv_buf << std::endl;
    } else {
        perror("recvfrom");
    }

    close(sockfd);
    return 0;
}

编译运行:

g++ udp_server.cc -o udp_server -std=c++17 -Wall
g++ udp_client.cc -o udp_client -std=c++17 -Wall
./udp_server
./udp_client

7. 完整可编译代码:TCP服务端/客户端(带详细注释)

tcp_server.cc

#include <iostream>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>
#include <cerrno>

#define LISTEN_PORT 9999
#define BUFFER_SIZE 1024
#define BACKLOG 8

int main()
{
    // 1. 创建TCP监听套接字
    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd < 0) {
        perror("socket");
        return -1;
    }

    // 2. 设置地址复用,解决TIME_WAIT导致重启失败
    int reuse = 1;
    if (setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)) < 0) {
        perror("setsockopt SO_REUSEADDR");
    }

    // 3. 绑定地址端口
    sockaddr_in serv_addr{};
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    serv_addr.sin_port = htons(LISTEN_PORT);

    if (bind(listen_fd, reinterpret_cast<sockaddr*>(&serv_addr), sizeof(serv_addr)) < 0) {
        perror("bind");
        close(listen_fd);
        return -1;
    }

    // 4. 开启监听,backlog为全连接队列长度
    if (listen(listen_fd, BACKLOG) < 0) {
        perror("listen");
        close(listen_fd);
        return -1;
    }
    std::cout << "TCP server listening on port " << LISTEN_PORT << std::endl;

    while (true) {
        sockaddr_in client_addr{};
        socklen_t cli_len = sizeof(client_addr);

        // 5. 阻塞等待新连接,从全连接队列取出
        int conn_fd = accept(listen_fd, reinterpret_cast<sockaddr*>(&client_addr), &cli_len);
        if (conn_fd < 0) {
            perror("accept");
            continue;
        }
        std::cout << "new client connected: " 
                  << inet_ntoa(client_addr.sin_addr) << ":"
                  << ntohs(client_addr.sin_port) 
                  << ", fd=" << conn_fd << std::endl;

        char buf[BUFFER_SIZE];
        // 6. 循环读取客户端数据,回射
        while (true) {
            ssize_t n = recv(conn_fd, buf, BUFFER_SIZE, 0);
            if (n < 0) {
                // 出错
                if (errno == EINTR) continue; // 被信号打断,重试
                perror("recv");
                break;
            }
            if (n == 0) {
                // 对端正常关闭连接(收到FIN)
                std::cout << "client closed connection, fd=" << conn_fd << std::endl;
                break;
            }

            buf[n] = '\0';
            std::cout << "recv " << n << " bytes: " << buf << std::endl;

            // 原样发回
            ssize_t sent = send(conn_fd, buf, n, 0);
            if (sent < 0) {
                perror("send");
                break;
            }
        }

        // 7. 关闭连接套接字
        close(conn_fd);
        std::cout << "connection fd=" << conn_fd << " released" << std::endl;
    }

    close(listen_fd);
    return 0;
}

tcp_client.cc

#include <iostream>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>
#include <cerrno>

#define SERVER_IP "127.0.0.1"
#define SERVER_PORT 9999
#define BUFFER_SIZE 1024

int main()
{
    // 1. 创建TCP socket
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0) {
        perror("socket");
        return -1;
    }

    // 2. 构造服务端地址
    sockaddr_in serv_addr{};
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_port = htons(SERVER_PORT);
    if (inet_pton(AF_INET, SERVER_IP, &serv_addr.sin_addr) <= 0) {
        std::cerr << "invalid server ip" << std::endl;
        close(sockfd);
        return -1;
    }

    // 3. 发起连接,触发三次握手
    if (connect(sockfd, reinterpret_cast<sockaddr*>(&serv_addr), sizeof(serv_addr)) < 0) {
        perror("connect");
        close(sockfd);
        return -1;
    }
    std::cout << "connected to server" << std::endl;

    // 4. 发送数据
    const char* msg = "Hello, TCP Server!";
    ssize_t sent = send(sockfd, msg, strlen(msg), 0);
    if (sent < 0) {
        perror("send");
        close(sockfd);
        return -1;
    }
    std::cout << "sent " << sent << " bytes" << std::endl;

    // 5. 接收回射
    char buf[BUFFER_SIZE];
    ssize_t n = recv(sockfd, buf, BUFFER_SIZE, 0);
    if (n > 0) {
        buf[n] = '\0';
        std::cout << "recv: " << buf << std::endl;
    } else if (n == 0) {
        std::cout << "server closed connection" << std::endl;
    } else {
        perror("recv");
    }

    // 6. 关闭连接,触发四次挥手
    close(sockfd);
    std::cout << "connection closed" << std::endl;
    return 0;
}

编译运行:

g++ tcp_server.cc -o tcp_server -std=c++17 -Wall
g++ tcp_client.cc -o tcp_client -std=c++17 -Wall
./tcp_server
./tcp_client

8. send/recv 内核缓冲区行为细节与返回值全解

8.1 recv 返回值完整解读

返回值 含义 处理方式
> 0 成功读取到n字节数据 正常业务处理
== 0 对端发送了FIN,连接正常关闭 关闭本端socket,退出循环
< 0 发生错误 检查errno

常见errno:

  • EINTR:被信号中断,不是真错误,循环重试即可;
  • EAGAIN / EWOULDBLOCK:非阻塞模式下,没有数据可读,正常情况,稍后再试;
  • ECONNRESET:连接被重置,对端异常关闭或发了RST,直接关闭连接。

8.2 send 返回值完整解读

TCP的send返回值可能小于你传入的长度,这是非常重要的细节。

  • 发送缓冲区还有足够空间:全部拷贝进去,返回len;
  • 发送缓冲区空间不足:能装多少装多少,返回实际拷贝的字节数;
  • 阻塞模式下,如果缓冲区完全满了,send会阻塞等待,直到腾出空间。

⚠️ 工程代码绝对不能只调用一次send就完事。必须封装一个循环发送函数,保证所有数据全部写入内核缓冲区才算成功。

示例:可靠发送封装

ssize_t send_all(int fd, const char* buf, size_t len)
{
    size_t total_sent = 0;
    while (total_sent < len) {
        ssize_t n = send(fd, buf + total_sent, len - total_sent, 0);
        if (n < 0) {
            if (errno == EINTR) continue;
            return -1; // 真正错误
        }
        total_sent += n;
    }
    return total_sent;
}

UDP的sendto不存在部分发送,要么整个报文发出去,要么失败返回-1。

8.3 阻塞与非阻塞模式

  • 阻塞模式(默认):缓冲区没数据/没空间时,函数不返回,进程挂起睡眠,不占CPU。
  • 非阻塞模式:立刻返回,没数据返回-1,errno=EAGAIN。配合IO多路复用(select/poll/epoll)使用。

设置非阻塞方式:

int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);

9. TCP长短连接:工程实现、心跳机制、异常处理

9.1 短连接适用工程场景

  • 客户端数量极大,但每个客户端很久才请求一次;
  • 定时任务、脚本、一次性数据上报;
  • 对延迟不敏感,追求实现简单。

9.2 长连接工程实现要点

  1. 连接管理:服务端维护一个连接表,记录每个连接的fd、IP、最后活跃时间;
  2. 心跳机制
    • 约定心跳包格式,例如长度为0的特殊包;
    • 服务端超时检测:N秒没收到数据,主动踢掉连接;
    • 客户端心跳发送:空闲M秒发一次心跳;
  3. 断线重连:客户端检测到连接断开后,延时重试连接;
  4. 半包粘包处理:必须有完整的应用层协议解析器。

9.3 常见连接异常处理

  • 对端正常close:recv返回0,优雅关闭;
  • 对端崩溃/断网:长时间收不到数据,靠心跳超时检测;
  • 本端写一个已关闭的连接:内核会触发SIGPIPE信号,默认会杀死进程。工程上一般忽略SIGPIPE,通过send返回值判断错误。
    // 忽略SIGPIPE,防止进程被杀死
    signal(SIGPIPE, SIG_IGN);
    

10. 高频踩坑清单

TCP常见坑

  1. ❌ 用listen_fd收发数据:listen_fd只负责接连接,通信必须用accept返回的conn_fd。
  2. ❌ 不处理粘包半包:直接把一次recv的数据当完整业务报文。
  3. ❌ send只调用一次,不处理部分返回。
  4. ❌ 长连接不做心跳,异常断开永远感知不到。
  5. ❌ 不设置SO_REUSEADDR,服务端重启失败。
  6. ❌ 不忽略SIGPIPE,写关闭的socket导致进程意外退出。
  7. ❌ 忘记转网络字节序,端口号直接填数字。

UDP常见坑

  1. ❌ 接收缓冲区太小,数据被静默截断。
  2. ❌ 误以为sendto成功就是对方收到。
  3. ❌ 发超大包导致IP分片,丢包率飙升。
  4. ❌ 用UDP传文件、传关键数据,不做可靠性保障。

11. 调试与观测工具

# 查看所有TCP连接状态、缓冲区
ss -titn

# 查看UDP socket
ss -uin

# 实时抓包,分析报文
tcpdump -i lo port 9999 -nn -X

# 查看系统socket缓冲区参数
sysctl -a | grep -E "rmem|wmem|somaxconn"

# 查看TCP连接状态统计
netstat -n | awk '/^tcp/ {++S[$NF]} END {for(a in S) print a, S[a]}'

# 跟踪系统调用
strace ./tcp_server

12. 面试核心总结

  1. 本质区别:UDP是无连接数据报,TCP是面向连接字节流。
  2. 粘包:TCP字节流特性导致,应用层自定义长度字段解决;UDP不存在粘包。
  3. send成功:只代表数据进了内核发送缓冲区,不代表对端收到。
  4. recv返回0:TCP对端关闭连接;UDP永远不会返回0。
  5. 长连接:必须心跳保活;短连接:频繁创建销毁,有TIME_WAIT问题。
  6. UDP connect:不发包,只是内核记录对端地址,过滤报文。
  7. 缓冲区满:TCP流控不丢包;UDP直接丢包。
  8. listen backlog:全连接队列长度,不是最大连接数。
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐