欢迎光临
我们一直在努力

【网络编程 Day2】TCP 通信核心:三次握手、核心 API、C/S 架构与粘包问题全梳理

今天是网络编程学习的第二天,从 UDP 的无连接不可靠,正式进入 TCP 的面向连接可靠传输。本文从 TCP 协议原理、核心函数接口、C/S 架构设计,到实战中踩的粘包、退出、多线程等坑,系统整理一天的学习成果

文章目录

  • 一、TCP 协议基础
    • 1.1 什么是 TCP
    • 1.2 三次握手(建立连接)
    • 1.3 四次挥手(断开连接)
  • 二、TCP 核心函数接口详解
    • 2.1 socket() — 创建套接字
    • 2.2 connect() — 客户端主动发起连接
    • 2.3 bind() — 绑定本地地址和端口
    • 2.4 listen() — 开启监听
    • 2.5 accept() — 接受连接
    • 2.6 send() — 发送数据
    • 2.7 recv() — 接收数据
  • 三、TCP C/S 架构与完整流程
    • 3.1 为什么必须有客户端和服务端之分
    • 3.2 服务端完整流程
    • 3.3 客户端完整流程
  • 四、TCP 全双工与多线程聊天
    • 4.1 什么是全双工
    • 4.2 为什么需要多线程
    • 4.3 聊天程序的退出机制(重点踩坑)
  • 五、TCP 粘包问题(文件传输必踩)
    • 5.1 什么是粘包
    • 5.2 粘包的解决方案
    • 5.3 文件传输的另一个坑:recv 返回 0 不处理
  • 六、TCP vs UDP 核心对比
  • 七、实战踩坑总结
  • 八、学习总结

一、TCP 协议基础

1.1 什么是 TCP

TCP(Transmission Control Protocol,传输控制协议)是传输层的核心协议,和 UDP 并列,但特性完全不同:

  • 面向连接:通信前必须通过三次握手建立连接,通信结束通过四次挥手断开
  • 可靠传输:有确认机制、超时重传、排序、流量控制、拥塞控制,保证数据不丢、不乱、不重复
  • 面向字节流:数据被看作连续的字节流,没有消息边界,会出现粘包
  • 全双工:同一个连接建立后,双方可以同时发送和接收数据,两条数据流独立

1.2 三次握手(建立连接)

TCP 连接的建立必须一方主动、一方被动,通过三次握手完成:

客户端 服务端
|── SYN ─────────────→| ① 客户端:我要连你
|←── SYN+ACK ─────────| ② 服务端:可以,我也准备好了
|── ACK ─────────────→| ③ 客户端:好的,连上了
| |
|==== 连接建立,可以收发数据 ====|

  • connect() 函数触发三次握手,connect 成功返回代表握手完成
  • accept() 只是从内核已完成握手的连接队列中取出一个连接,不参与握手过程
  • 必须一方 listen 被动等待,一方 connect 主动发起,两边都 connect 或都 listen 是连不上的

1.3 四次挥手(断开连接)

TCP 是全双工的,两个方向要分别关闭,所以是四次挥手:

客户端 服务端
|── FIN ─────────────→| ① 我发完了,要断开
|←── ACK ─────────────| ② 好的,知道了
|←── FIN ─────────────| ③ 我也发完了,断开
|── ACK ─────────────→| ④ 好的,彻底断开

  • 一方调用 close() 会触发 FIN 发送
  • 对方 recv() 会返回 0,代表对端关闭了连接(这不是错误)
  • recv() 返回 -1 才是真正的出错

二、TCP 核心函数接口详解

2.1 socket () — 创建套接字

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

  • AF_INET:IPv4 协议族
  • SOCK_STREAM:流式套接字,对应 TCP(UDP 用SOCK_DGRAM)
  • 0:默认协议
  • 返回值:成功返回文件描述符,失败返回 – 1

2.2 connect () — 客户端主动发起连接

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

  • 功能:向服务端发送连接请求,触发三次握手
  • 参数:
    • sockfd:套接字文件描述符
    • addr:服务端的地址(IP + 端口)
    • addrlen:地址结构体长度
  • 返回值:成功返回 0,失败返回 – 1
  • 谁调用:只有客户端调用,服务端不需要

2.3 bind () — 绑定本地地址和端口

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

  • 功能:将 IP + 端口与套接字绑定,在内核建立端口到套接字的映射
  • 谁必须调用:服务端必须 bind,固定端口让客户端知道连哪里;客户端一般不 bind,内核自动分配临时端口
  • 服务端推荐绑定 htonl(INADDR_ANY),监听所有网卡,兼容性最好

2.4 listen () — 开启监听

int listen(int sockfd, int backlog);

  • 功能:将套接字标记为被动监听模式,准备接收连接请求
  • 参数:
    • sockfd:套接字文件描述符
    • backlog:尚未处理的连接请求的最大排队个数
  • 返回值:成功返回 0,失败返回 – 1
  • 谁调用:只有服务端调用,客户端不需要

2.5 accept () — 接受连接

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

  • 功能:从监听队列中取出第一个已完成握手的连接请求,返回一个新的文件描述符
  • 参数:
    • sockfd:监听套接字(socket 返回的那个)
    • addr:输出参数,存放连接客户端的地址信息
    • addrlen:输入输出参数,传入地址长度,返回实际地址长度
  • 返回值:成功返回新的通信文件描述符,失败返回 – 1
  • 关键点:服务端有两个 fd—— 监听 fd(只用来接客)和通信 fd(真正收发数据),不能用监听 fd 收发数据

2.6 send () — 发送数据

ssize_t send(int sockfd, const void *buf, size_t len, int flags);

  • 功能:向已连接的套接字发送数据
  • 参数:
    • sockfd:已连接的套接字(客户端用 socket 的 fd,服务端用 accept 返回的 fd)
    • buf:发送数据首地址
    • len:发送数据长度
    • flags:属性,默认为 0
  • 返回值:成功返回实际发送字节数,失败返回 – 1

2.7 recv () — 接收数据

ssize_t recv(int sockfd, void *buf, size_t len, int flags);

  • 功能:从已连接的套接字接收数据
  • 参数:
    • sockfd:已连接的套接字
    • buf:存放数据的缓冲区
    • len:最多接收字节数
    • flags:属性,默认为 0
  • 返回值:
    • 成功:返回实际接收字节数
    • 失败:返回 – 1
    • 对方关闭连接:返回 0(重要!这是正常断开,不是错误)

三、TCP C/S 架构与完整流程

3.1 为什么必须有客户端和服务端之分

TCP 连接的建立天然不对称:必须一方listen+accept被动等待,一方connect主动发起。这不是代码设计选择,是 TCP 协议本身的要求。

表格

角色连接前连接后
服务端 socket→bind→listen→accept,被动等待 用 accept 返回的 fd,send/recv 收发
客户端 socket→connect,主动连接 用 socket 的 fd,send/recv 收发

连接建立之前角色不对称,连接建立之后两边完全平等,都是全双工收发。"谁是客户端谁是服务端" 只在连接建立那一刻有意义 —— 主动 connect 的是客户端,被动 accept 的是服务端。

3.2 服务端完整流程

socket() → bind() → listen() → accept()(阻塞等连接)
→ 得到通信fd → 循环 send/recv 收发数据
→ 通信结束 close(通信fd) → close(监听fd)

3.3 客户端完整流程

socket() → connect()(触发三次握手)
→ 连接建立 → 循环 send/recv 收发数据
→ 通信结束 close(fd)


四、TCP 全双工与多线程聊天

4.1 什么是全双工

TCP 连接有两条独立的数据流(A→B 和 B→A),可以同时收发,互不干扰。同一个 fd 既能调用 send 也能调用 recv,这就是全双工。

4.2 为什么需要多线程

虽然 TCP 内核层面支持同时收发,但阻塞式的 recv 会卡住当前线程。单线程里 recv 阻塞了,就没法去读键盘调用 send 了。所以聊天程序必须开两个线程:

  • 发送线程:读键盘输入 → send 发给对方
  • 接收线程:recv 收消息 → 打印到屏幕

4.3 聊天程序的退出机制(重点踩坑)

TCP 聊天的退出是学习中踩坑最多的地方,核心是搞清楚close和recv返回0的关系:

fd 是进程级别的,不是线程级别的。 一个线程调用close(fd)后,整个进程里这个 fd 就无效了,另一个线程再用它 recv 会返回 – 1(EBADF),不是返回 0。

正确的退出方案(不用 shutdown,纯应用层消息通知):

  • 发送线程输入.quit:先 send 一条.quit消息通知对方,再close(fd),break 退出
  • 接收线程收到.quit消息:判断后close(fd),退出
  • 接收线程recv返回0:对方关闭了连接,close(fd),退出
  • 主函数pthread_join等待两个子线程结束后,收尾 close
  • 注意:发送线程必须先发.quit 消息再 close,如果先 close 再 send,消息发不出去。


    五、TCP 粘包问题(文件传输必踩)

    5.1 什么是粘包

    TCP 是面向字节流的,没有消息边界。连续 send 多次,数据可能被内核合并发送;recv 一次可能收到多条数据的拼接。

    比如文件传输时,先发文件名"src.jpg"(7 字节),再发文件内容:

    send端:[文件名7字节][文件内容…]
    recv端第一次recv可能收到:src.jpg\\xff\\xd8\\xff\\xe0…
    └文件名┘└──文件内容──┘

    文件名和文件内容粘在一起了,直接当文件名 open 就会出错。

    UDP 是面向数据报的,一次 sendto 对应一次 recvfrom,有明确边界,不会粘包。

    5.2 粘包的解决方案

    方法 1:固定长度(最简单,适合文件名) 约定文件名固定占 32 字节,不够补 0。接收端循环读满 32 字节再当文件名。

    // 发送端
    char filename[32] = {0};
    strcpy(filename, "src.jpg");
    send(sockfd, filename, sizeof(filename), 0); // 发满32字节
    // 接收端:循环读满32字节
    int total = 0;
    while(total < 32) {
    sret = recv(confd, filename + total, 32 – total, 0);
    if(sret <= 0) break;
    total += sret;
    }

    方法 2:先发长度再发内容 先发 4 字节整数表示数据长度,接收端先读长度,再按长度读完整内容。工业界最常用。

    方法 3:特殊分隔符 约定每条消息以\\n或#结尾,接收端按字节读直到遇到分隔符。

    5.3 文件传输的另一个坑:recv 返回 0 不处理

    发送端读完文件后 close (sockfd),接收端 recv 返回 0 代表传输完毕。如果只判断recv==-1不判断==0,接收端会死循环,close(fd)执行不到,文件内容可能不完整。

    while(1) {
    sret = recv(confd, tmpbuff, sizeof(tmpbuff), 0);
    if(sret == 0) { printf("接收完毕\\n"); break; } // 必须加
    if(sret == -1) { perror("recv"); break; }
    write(fd, tmpbuff, sret);
    }
    close(fd); // 现在能执行到了


    六、TCP vs UDP 核心对比

    对比项TCPUDP
    连接 面向连接,三次握手建立,四次挥手断开 无连接,直接收发
    可靠性 可靠,确认 + 重传 + 排序 + 流量控制 + 拥塞控制 不可靠,丢了就丢了
    数据形式 面向字节流,会粘包 面向数据报,有边界不粘包
    收发接口 connect/accept 后用 send/recv 直接用 sendto/recvfrom
    架构 C/S,必须一方 listen 一方 connect 两边对称,不需要中间服务器
    对方退出感知 recv 返回 0,天然感知 完全感知不到,必须应用层发消息通知
    资源占用 较大,维护连接状态 较小,无连接状态
    适用场景 文件传输、网页、聊天等需要可靠数据的场景 直播、游戏、DNS 等对实时性要求高、可容忍丢包的场景

    七、实战踩坑总结

    坑点现象原因解决
    strcmp 判断写反 输入任何消息都退出 if(strcmp(a,b)) 含义是 "不相等就为真" 写成 if(strcmp(a,b)==0)
    gets 危险函数 编译报警告,输入过长崩溃 gets 不检查缓冲区长度 改用 fgets
    发送线程直接 close 对方感知不到退出,程序卡死 close 后 fd 全进程无效,recv 返回 – 1 而非 0 先发.quit 消息再 close,或用 shutdown
    recv 不判断返回 0 文件传输死循环,文件不完整 对方 close 后 recv 返回 0,只判断了 – 1 加 if(sret==0) break;
    TCP 粘包 文件名乱码,open 失败 文件名和文件内容粘在一起 固定长度 / 先发长度 / 分隔符
    服务端用监听 fd 收发 收发失败 listen fd 只用于监听,不能通信 用 accept 返回的新 fd 收发
    bind 写死具体 IP 换网卡后 bind 失败 IP 变了 用 htonl (INADDR_ANY)
    同目录运行文件传输 源文件被清空 O_TRUNC 覆盖了同名源文件 分目录运行,或接收端改输出文件名
    open 缺 O_WRONLY write 返回 – 1,文件为空 只写 O_CREAT|O_TRUNC,默认只读模式 加上 O_WRONLY

    八、学习总结

  • TCP 的核心是面向连接 + 可靠传输,三次握手建连、四次挥手断连,这是和 UDP 最本质的区别。
  • TCP 必须是 C/S 架构,一方 listen 被动等待,一方 connect 主动发起,连接建立后两边全双工平等。
  • 服务端有两个 fd:监听 fd 和通信 fd,accept 返回的新 fd 才是用来收发的。
  • recv 返回 0 是对方正常关闭,不是错误,必须单独判断。
  • TCP 粘包是字节流特性决定的,文件传输、聊天都必须自己定义消息边界。
  • 多线程聊天的退出逻辑要仔细设计:fd 是进程级别的,close 后全进程无效,要靠应用层消息或 shutdown 来通知对方。
  • 编译多线程代码必须加-lpthread。
  • TCP 比 UDP 复杂,但可靠性带来的价值是巨大的,文件传输、网页浏览、即时通讯这些场景都依赖 TCP。理解了连接管理、可靠传输、字节流这三个核心点,TCP 编程就入门了。

    赞(0)
    未经允许不得转载:171主机测评 » 【网络编程 Day2】TCP 通信核心:三次握手、核心 API、C/S 架构与粘包问题全梳理
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址