sunoo-229头像
关注

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

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

文章目录


一、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 聊天的退出是学习中踩坑最多的地方,核心是搞清楚closerecv返回0的关系:

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

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

  1. 发送线程输入.quit:先 send 一条.quit消息通知对方,再close(fd),break 退出
  2. 接收线程收到.quit消息:判断后close(fd),退出
  3. 接收线程recv返回0:对方关闭了连接,close(fd),退出
  4. 主函数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,只判断了 - 1if(sret==0) break;
TCP 粘包文件名乱码,open 失败文件名和文件内容粘在一起固定长度 / 先发长度 / 分隔符
服务端用监听 fd 收发收发失败listen fd 只用于监听,不能通信用 accept 返回的新 fd 收发
bind 写死具体 IP换网卡后 bind 失败IP 变了用 htonl (INADDR_ANY)
同目录运行文件传输源文件被清空O_TRUNC 覆盖了同名源文件分目录运行,或接收端改输出文件名
open 缺 O_WRONLYwrite 返回 - 1,文件为空只写 O_CREAT|O_TRUNC,默认只读模式加上 O_WRONLY

八、学习总结

  1. TCP 的核心是面向连接 + 可靠传输,三次握手建连、四次挥手断连,这是和 UDP 最本质的区别。
  2. TCP 必须是 C/S 架构,一方 listen 被动等待,一方 connect 主动发起,连接建立后两边全双工平等。
  3. 服务端有两个 fd:监听 fd 和通信 fd,accept 返回的新 fd 才是用来收发的。
  4. recv 返回 0 是对方正常关闭,不是错误,必须单独判断。
  5. TCP 粘包是字节流特性决定的,文件传输、聊天都必须自己定义消息边界。
  6. 多线程聊天的退出逻辑要仔细设计:fd 是进程级别的,close 后全进程无效,要靠应用层消息或 shutdown 来通知对方。
  7. 编译多线程代码必须加-lpthread

TCP 比 UDP 复杂,但可靠性带来的价值是巨大的,文件传输、网页浏览、即时通讯这些场景都依赖 TCP。理解了连接管理、可靠传输、字节流这三个核心点,TCP 编程就入门了。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/2401_89475491/article/details/164268654

文章来源转载

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--