计算机网络篇7:传输层——TCP 网络编程常用接口与调用流程


📚 本文收录于「流浪」的系列专栏

🐧 Linux系统 ⚙️ C++
📊 数据结构与算法 🐍 Python
🔗 LangChain & LangGraph 🗄️ MySQL 数据库
🌿 Git 工具 🌐 计算机网络
🤖 LLM 💯 大厂面试、八股
📚 学习筑基专栏

🏠 博客主页:流浪 | 📝 原创首发于 CSDN


前言: 《计算机网络篇6》把 bind 的另一半规则补完了,到这里 UDP 这一路算是走通:接口怎么调、地址怎么绑、报文怎么一来一回,全都能落地。本篇掉头走 TCP 一路,把它代码里用到的常用接口按调用顺序串一遍:服务端 socket、bind、listen、accept,客户端 socket、connect,连接建好之后统一换 read/write 收发。定位和 UDP 接口篇一样是简单讲解------每个接口讲清楚签名、参数怎么填、返回值看什么,能看懂、能照着搭出代码框架就行;至于 listen 背后的状态机、accept 和三次握手的时序关系这些理论,后面会单独开理论篇着重输出,本篇只在路过的时候把结论先立起来。


一、TCP 与 UDP 在接口层的分岔:UDP 报文自带地址,TCP 先把连接建好再收发

1.1 UDP 的收发不带连接概念,TCP 的接口全部围绕连接展开

1. UDP 的收发接口里没有任何一个以连接为主语
cpp 复制代码
ssize_t n = sendto(sockfd, input.c_str(), input.size(), 0, (struct sockaddr *)&local, sizeof(local));
ssize_t s = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&local, &len);
  1. UDP 这边的接口已经齐了:socket 创建套接字、bind 占地址,收发靠 recvfrom 和 sendto,地址随每次调用现给现带,《计算机网络篇5》和《计算机网络篇6》把这些拆到了参数一级
  2. 它的特点是「无连接」:不握手、不建连接,数据报自己带着源地址和目的地址在路上跑,套接字不记得对面是谁
  3. 所以 UDP 的接口清单里,没有任何一个函数的动作对象是「连接」

UDP 一句话收束:socket 加 bind 把套接字立起来,recvfrom 和 sendto 带着地址收发,全程没有连接的概念。

2. TCP 多出来的是「连接」这条主线
  1. TCP 是面向连接的,收发数据之前必须先把连接建好,这是它和 UDP 在接口层最大的分岔
  2. 落到代码上就是多出一串动作:服务端要 listen 把套接字挂成监听状态,再靠 accept 把建好的连接取出来用;客户端要 connect 主动发起连接
  3. 而连接建好之后,read/write 这些普通文件级的读写接口就能直接用了,反而不需要 recvfrom/sendto 那种每次都带地址的专用接口------收发反而变简单了

TCP 的接口围绕连接展开:listen 等连接、accept 取连接、connect 发起连接,连上之后读写换通用接口。

1.2 服务端四步客户端两步,一环扣一环串成完整流程

1. 服务端:socket → bind → listen → accept → read/write
  1. 第一步 socket 创建套接字拿到 sockfd,这一步 UDP 和 TCP 完全一样,区别只在第二个参数传 SOCK_STREAM 而不是 SOCK_DGRAM,《计算机网络篇4》讲过
  2. 第二步 bind 把 IP 和端口绑上去,占住固定端口等客户端找上门,绑定的规则《计算机网络篇6》刚讲完:服务端显式绑固定端口,通常还绑 INADDR_ANY
  3. 第三步 listen 把套接字挂成监听状态,从这一刻起客户端就能连它了;
  4. 第四步 accept 把已经建好的连接从队列里取出来,拿到一个新的 sockfd;之后 read/write 就在这个新 sockfd 上收发数据

服务端四步走:socket 拿 fd、bind 占地址、listen 挂监听、accept 取连接,每一步都是下一步的前提。

2. 客户端:socket → connect → write/read
  1. 客户端只要两步:socket 创建套接字,connect 填上服务器的 IP 加端口号发起连接,connect 成功返回后连接就建好了,直接 write 发、read 收
  2. 对照服务端那四步,客户端省掉的三步各有各的道理:listen 和 accept 本来就是服务端「等着被人连」这一侧的专属动作,客户端是主动去连人的那一侧,没有连接可以监听、也没有连接可以取;bind 则不用自己显式做,connect 的时候系统自动帮它完成绑定
  3. connect 发起的同时就完成了自动绑定:系统自动挑一个随机端口、配上本机 IP 把这个套接字绑上,客户端从此有了完整的源地址,服务器回话就能找到它------《计算机网络篇6》讲过的隐式绑定规则,在 TCP 里就落在 connect 这一步生效
  4. 把两边的流程对着看:客户端 connect 发起的那次连接,正好对应服务端 listen 之后放进来、accept 取出来的那条连接,一头连一头收,两头对上号通信才能开始

客户端两步走:socket 加 connect,bind、listen、accept 一个都不用调------listen 和 accept 是服务端的活,bind 在 connect 时自动完成。


二、listen:把套接字置入可被连接的监听状态

2.1 listen 的签名:两个参数,返回值只报成功失败

1. listen 的函数签名
c 复制代码
#include <sys/socket.h>

// 将套接字置为监听状态:sockfd 是套接字描述符,backlog 是全连接队列的最大长度
// 返回值:成功返回 0,失败返回 -1
int listen(int sockfd, int backlog);
2. listen 只改状态不碰地址,backlog 限制的是全连接队列长度
  1. 第一个参数还是那个 sockfd,socket 创建出来、bind 绑完地址之后交给它;第二个参数 backlog 填全连接队列的长度上限,入门阶段给个几十就够用,队列本身怎么运转属于理论篇的内容
  2. 注意 listen 的参数表里没有任何地址参数------绑地址是 bind 的事、已经干完了,listen 只负责把这个套接字的状态从「能用」改成「等着被连」
  3. 返回值约定很朴素:成功返回 0,失败返回 -1,没什么可挖的

listen 两个参数:告诉内核哪个套接字开始监听、最多攒多少条连接,绑地址的活它不管。

2.2 服务器处于 listen 状态就已经可以被连接了

1. listen 之后还没走 accept,客户端就能连上
  1. 服务器跑起来,代码停在 listen 之后、还没走到 accept,另一边用 telnet 去连,netstat 一看连接已经是 ESTABLISHED
  2. 结论就是一句话:只要 TCP 服务器处于 listen 状态,那么它就已经可以被连接了
  3. 由此 accept 的角色也就定了:它不是「开始等连接」,而是把内核里已经建好的连接取出来交给应用层------连接的建立发生在内核,accept 只管取
2. 写代码时别把「客户端连上了」当成「服务端走到 accept 了」
  1. 这个结论的直接指导意义是:accept 没被调到、连接照样能建,所以客户端那边显示已连接,不代表服务端代码已经走到 accept,更不代表已经开始 read 了
  2. 全连接队列、三次握手和 accept 谁先谁后这些名词,后面理论篇会着重展开,本篇先把现象和结论立住,不往下挖

listen 一挂就能被连,连接的建立在内核里完成,accept 只负责把建好的连接取出来。

2.3 netstat -antp:查看 TCP 连接状态的组合

1. 把 netstat -anup 里的 u 换成 t,就是查 TCP 的版本
  1. 《计算机网络篇6》用过 netstat -anup 查 UDP 的绑定,把中间的 u 换成 t,就是查 TCP 连接的版本:netstat -antp
  2. 四个选项各管一摊:a 显示全部状态的连接,n 以数字形式显示地址和端口不做域名反解,t 只看 TCP,p 把进程 pid 带出来
  3. 合起来读就是「以数字形式,列出这台机器上全部 TCP 套接字,并带上是谁开的」
2. 用它验证 listen 挂没挂上
  1. 服务器跑起来后执行 netstat -antp,能看到一行 tcpserver 0.0.0.0:8080 LISTEN,这就是 2.2 结论的直接证据:套接字已经在监听、可以被连接了
  2. 这时候 telnet 上去再看一次,LISTEN 那行还在,表里多出两条 ESTABLISHED------多出来的这两条是什么,第五章专门说
  3. 实验动作连成一句:起服务器、netstat -antp 看一眼、telnet 连上、再看一眼,两相对照现象全在里面

netstat -antp 是 TCP 侧的照妖镜,LISTEN 挂没挂上、连接建没建成,一看便知。


三、accept:把建好的连接取出来,返回一个新 fd

3.1 accept 的签名:后两个参数和 recvfrom 是同款套路

1. accept 的函数签名
c 复制代码
#include <sys/socket.h>

// 取出一条已建立的连接:sockfd 是监听套接字,cliaddr 用来带出客户端地址(不关心可传 NULL),
// addrlen 是地址结构体大小的指针(传入大小、返回实际长度)
// 返回值:成功返回一个新套接字描述符(专门服务这条连接),失败返回 -1
int accept(int sockfd, struct sockaddr *cliaddr, socklen_t *addrlen);
2. cliaddr 和 addrlen 是 recvfrom 那对地址盒子的翻版
  1. 第二个参数 cliaddr 是输出型地址盒子,内核把「这次连进来的是谁」------客户端的 IP 加端口------填进去,不关心可以传 NULL,这个套路和《计算机网络篇5》recvfrom 的第五个参数一模一样
  2. 第三个参数 addrlen 是输入输出型:调用前初始化成结构体大小、返回后被改成实际长度,忘了初始化同样是最经典的用法错误
  3. 返回值是它和收发接口最大的不同:成功返回的不是字节数,而是一个新的套接字描述符,这个新 fd 是干什么的,下一节说

accept 的后两个参数照搬 recvfrom 的地址盒子套路,返回值换成了一个新 fd。

3.2 监听 fd 只管拉客,accept 返回的新 fd 才管干活

1. 一个端口两个 fd:listen 用旧的、收发用新的
  1. 一台 TCP 服务器从监听到收发,手里有两个 fd:监听 fd 是 socket 返回的那个,只负责挂监听、把连接放进来;accept 返回的新 fd 才负责跟客户端 read/write 收发数据
  2. 分工就像前台和客服:前台只管接待登记,客服一人对接一个客户干活------落到代码上认的就是「listen 传旧 fd、accept 收新 fd、read/write 用新 fd」
  3. 顺带排掉一个误区:服务的是同一个端口,不代表用的是同一个 fd,netstat 里同一个 8080 能看到一行 LISTEN 加若干行 ESTABLISHED,对应的就是一个监听 fd 带着一群服务 fd
2. UDP 不需要 accept,因为它的地址不锁在连接里
  1. UDP 没有连接可取:数据报到了就是到了,recvfrom 直接把数据和对方地址一起交出来,不需要「先取出一条连接」这个环节
  2. TCP 一定要 accept,是因为它把「对端是谁」锁在连接里:连接建立时双方的地址关系就定死了,accept 取出连接拿到新 fd,之后 read/write 都不用再带地址
  3. 一多一少正好对上第一章的分岔:UDP 的地址随每次收发现给,TCP 的地址在连接里一次性锁死

listen 用监听 fd、收发用 accept 返回的新 fd;UDP 没有连接,压根不需要 accept。


四、connect 与 read/write:连接建好之后就是普通读写

4.1 connect 的签名:客户端把服务器地址填进去发起连接

1. connect 的函数签名
c 复制代码
#include <sys/socket.h>

// 发起连接:sockfd 是客户端套接字,serv_addr 是服务器的地址(IP+端口,要强转成 sockaddr*),
// addrlen 是地址结构体的大小
// 返回值:成功返回 0,失败返回 -1
int connect(int sockfd, const struct sockaddr *serv_addr, socklen_t addrlen);
2. connect 的参数就是「拿哪个套接字、去连哪个地址」
  1. 第二个参数填服务器地址:地址族、服务器 IP、服务器端口,在 sockaddr_in 里填好、强转传进来,和 bind、sendto 的填法同款,《计算机网络篇4》有完整拆解
  2. 客户端调 connect 之前不用自己 bind------connect 发起的同时系统自动完成绑定:挑一个随机端口、配上本机 IP 把这个套接字绑上,这就是《计算机网络篇6》讲过的隐式绑定在 TCP 客户端上的落点
  3. connect 成功返回 0 就意味着连接建好,后面 read/write 随便用;失败返回 -1,最常见的原因是服务器没起来或者端口填错了

connect 三个参数一句话:拿哪个套接字、去连哪个地址、地址多长,连上之后这个套接字就「认人」了。

4.2 read/write 的签名:read 返回 0 是一条必须刻在脑子里的约定

1. read 和 write 的函数签名
c 复制代码
#include <unistd.h>

// 从套接字收数据:fd 是套接字描述符,buf 是接收缓冲区,count 是缓冲区大小
// 返回值:成功返回实际读到的字节数,返回 0 表示对端关闭了连接,出错返回 -1
ssize_t read(int fd, void *buf, size_t count);

// 往套接字发数据:fd 是套接字描述符,buf 是待发送数据,count 是数据长度
// 返回值:成功返回实际写入的字节数,出错返回 -1
ssize_t write(int fd, const void *buf, size_t count);
2. read 返回 0 不是「没读到」,是「对面关门了」
  1. read 的返回值有三种情况:大于 0 是这次实际读到的字节数;-1 是出错;返回 0 表示对端把连接关闭了,这是普通文件上很难碰到的语义,在套接字这里是常态
  2. 所以 TCP 服务器的循环里,read 返回 0 就该收摊:break 掉这条连接的收发循环,回去 accept 下一条,不处理它程序就会在 0 上空转
  3. write 的返回值就朴素:写进去多少字节返回多少,出错 -1

read 见到 0 就收摊换下一条连接,这是写 TCP 循环时最容易被漏掉的约定。

4.3 连接把地址锁死,TCP 的 read/write 就不需要地址参数

1. 地址在不在连接里,决定了收发接口要不要带地址
  1. read/write 的参数表里没有任何地址参数,只有 fd、buf、count------因为连接建立的时候「跟谁收发」已经定死了,fd 背后就是那条连接,内核知道对端是谁
  2. UDP 没有这层保证:套接字不记得对面是谁,每次收发都得把地址现场给,所以 recvfrom 要用第五个参数把「谁发的」带出来,sendto 要用第五个参数把「发给谁」递进去
  3. 这就是接口分岔的根子:TCP 建连接时锁死地址,换来一对干净的通用读写接口;UDP 不建连接,就得让地址随参数走
2. 字节流的 read 长度不保证等于对端 write 的长度
  1. TCP 是字节流:一次 write 写进去的内容,对端可能分好几次 read 才凑齐,read 返回多少就是当前缓冲区里有多少,流式读取本来就是这样
  2. UDP 是数据报:recvfrom 一次收到的就是对方 sendto 发的那一整条,边界是完整的
  3. 「read 到的字节数不保证等于对方 write 的字节数」这个现象先记住,流边界的处理规则是 TCP 编程的大主题,后面单独展开

地址锁进连接里,read/write 就干净了;流式读取的长度差异先记现象,细节后面细讲。


五、netstat 实测:一条连接显示成两条方向相反的记录

5.1 两条方向相反的 ESTABLISHED,原因就两条

1. telnet 一连,表里多出两条反着的行
  1. 服务器 listen 挂着,telnet 连上去,netstat -antp 里除了那行 LISTEN,多出两条 ESTABLISHED:一条是 127.0.0.1:58752 连 127.0.0.1:8080、进程名 telnet,另一条是 127.0.0.1:8080 连 127.0.0.1:58752、进程名 tcpserver
  2. 同一条连接被记成了两行、方向正好相反,要回答的就是:为什么是两条、还互相反着
2. 原因一:TCP 是全双工的
  1. TCP 的连接是全双工的:一条连接上两个方向同时都能收能发,内核为每个方向各自记一条收发记录,落到 netstat 的表里就是两条
  2. 这两行要成对看:一条站在客户端视角、一条站在服务器视角,合起来才是一条完整连接的两个半边
3. 原因二:客户端和服务端跑在同一台机器上
  1. 实验里客户端是 telnet、服务端是 tcpserver,两个进程在同一台机器上,各自的表项都落在同一张 netstat 输出里,所以两行同框
  2. 换两台机器做这个实验,本机上只会看到自己这一侧的记录,另一条在对方机器的表里------「两条同框」是同机实验特有的现象
  3. 完整结论一句话:一条连接呈现两条方向相反的记录,一半功劳是全双工的两个方向、一半功劳是客户端和服务端在同一台机器上;状态机层面的机制后面理论篇再挖

两条反向记录 = 全双工的两个方向 + 同机实验让两端同框,现象记住、机制理论篇见。


六、TCP 回声服务器与客户端骨架

6.1 服务端骨架:四步走完,在新 fd 上循环收发(框架名:tcp_echo_server)

1. 服务端的完整骨架代码
c 复制代码
// TCP 回声服务器骨架:接受连接,收到什么就原样发回给谁(框架名:tcp_echo_server)
int listenfd = socket(AF_INET, SOCK_STREAM, 0);           // 1. 创建套接字:TCP 用 SOCK_STREAM
struct sockaddr_in serv;
serv.sin_family = AF_INET;
serv.sin_addr.s_addr = htonl(INADDR_ANY);                 // 2. 绑定:不挑 IP,固定端口 8080
serv.sin_port = htons(8080);
bind(listenfd, (struct sockaddr*)&serv, sizeof(serv));

listen(listenfd, 64);                                      // 3. 挂监听:从这一刻起就能被连接
while (1) {
    struct sockaddr_in client;
    socklen_t len = sizeof(client);
    int connfd = accept(listenfd,                          // 4. 取连接:返回新 fd,专门服务这条连接
                        (struct sockaddr*)&client, &len);
    char buf[1024];
    ssize_t n;
    while ((n = read(connfd, buf, sizeof(buf))) > 0) {     // 5. 在新 fd 上读:读到 0 就是对面关门
        write(connfd, buf, n);                             // 6. 原样写回:不带地址,连接里都锁死了
    }
}
  1. 骨架的骨干就是第一章那四步:socket、bind、listen、accept 顺次走完,read/write 全部发生在 accept 返回的新 fd 上,监听 fd 从头到尾只喂给 listen 和 accept
  2. 内层 while 的条件 (n > 0) 顺手把第四章那条约定用上了:read 返回 0 循环自然退出,外层再回去 accept 下一条连接,两条循环一里一外就是单连接版 TCP 服务器的全部逻辑
  3. 错误处理、并发接待多个客户端这些进阶内容先不堆进来,骨架认的就是「四步走完、新 fd 收发」这条主线

服务端骨架一句话:listen 喂旧 fd、accept 拿新 fd,read/write 只认新 fd,读到 0 收摊换下一条。

6.2 客户端骨架:connect 完在同一个 fd 上读写(框架名:tcp_echo_client)

1. 客户端的完整骨架代码
c 复制代码
// TCP 回声客户端骨架:连上服务器,发一条收一条(框架名:tcp_echo_client)
int sockfd = socket(AF_INET, SOCK_STREAM, 0);              // 1. 创建套接字
struct sockaddr_in serv;
serv.sin_family = AF_INET;
serv.sin_addr.s_addr = inet_addr("127.0.0.1");             // 2. 填服务器地址:本地回环测试
serv.sin_port = htons(8080);
connect(sockfd, (struct sockaddr*)&serv, sizeof(serv));    // 3. 发起连接:绑定也在这步自动完成

char buf[1024];
while (fgets(buf, sizeof(buf), stdin) != NULL) {          // 4. 标准输入读一行发一行
    write(sockfd, buf, strlen(buf));                       //    发出去:不用带地址
    read(sockfd, buf, sizeof(buf));                        //    收回来:还是这个 fd
    printf("%s", buf);
}
  1. 客户端全程只有一个 fd:socket 拿到它、connect 拿它去连、write 拿它发、read 拿它收,没有任何地址盒子出没
  2. connect 那行的注释就是第一章落下的结论:绑定在 connect 时自动完成,客户端没有 bind、listen、accept 的戏份
  3. 服务器地址填 127.0.0.1 是《计算机网络篇6》讲的本地回环测试,换成服务器真实 IP 就是跨机版,代码一行不用改

客户端骨架一句话:一个 fd 从头用到尾,connect 连上就自动绑定,读写不带地址。

6.3 对照 UDP 骨架:多出的是连接环节,省掉的是地址参数

1. 和 udp_echo_server 逐环节对一遍
  1. 对比《计算机网络篇5》的 udp_echo_server:UDP 骨架三行核心,收发全靠「recvfrom 带出地址、sendto 填进地址」;TCP 骨架多出 listen 和 accept 两个环节,正好对上第一章的流程分岔------先建连接,才谈收发
  2. 收发接口互换了身份:UDP 每次 recvfrom/sendto 都要伺候那对地址盒子,TCP 的 read/write 参数表里一个地址参数都没有,对端信息在连接建立时就锁死了
  3. read 返回 0 这个收摊信号是 TCP 特有的,UDP 的 recvfrom 没有对应语义------「对端关门」这个概念,在无连接的 UDP 里压根不存在

和 UDP 骨架一对照就清楚了:多出的环节都在连接上,省掉的参数都在地址上。


七、文末面试题

7.1 推导题(本篇浅讲,答案带过)

1. listen 之后还没调 accept,客户端能不能连上服务器?

答(推导):能。listen 一挂,套接字就进入可被连接的状态,连接的建立在内核里完成,此时 netstat 里客户端一侧已经能看到 ESTABLISHED。accept 只是应用层把建好的连接取出来,取晚了连接就在队列里排队等着,不会因此连不上。

2. accept 返回的 fd 和 socket 返回的 fd 是什么关系?

答(推导):是同一个端口上的两个角色。socket 返回的监听 fd 只负责挂监听、放连接进来;accept 每取一条连接就返回一个新 fd,专门服务这一条连接的 read/write。一个监听 fd 可以派生出多个服务 fd,对应多个客户端同时连入。

3. 为什么 netstat 里一条 TCP 连接是两条方向相反的记录?

答(推导):因为 TCP 是全双工的,两个方向各自有一条记录;再加上实验里客户端和服务端在同一台机器上,两端的表项落进同一张输出,就成了方向相反的两行。

7.2 真题

1. read() 返回 0 代表什么?

答(推导 · 已对照解析,转述):代表对端正常关闭了连接(收到 FIN)。read 返回大于 0 是读到了数据,返回 -1 是出错,返回 0 是读到 EOF 的信号------对端不会再发数据了。正确做法是见到 0 就把这条连接的读循环收掉,继续读写只会空转或出错。

【真题·转述自 Read和Write在网络中的返回值详解】


这一篇把 TCP 代码里要用的常用接口按调用顺序串了一遍:服务端 socket、bind、listen、accept 四步,客户端 socket、connect 两步,连接建好换 read/write 收发、读到 0 就是对面关门;listen 挂上就能被连、一条连接两条方向相反的记录这两个结论先立住,机制后面理论篇着重拆。评论区聊聊:你第一次写 TCP 服务器,有没有把 accept 返回的新 fd 和监听 fd 搞混过?

相关推荐
骑着蜗牛撵大象3272 小时前
从三次握手到 epoll:C++ 写出可落地的 TCP Socket 服务端
网络·c++·tcp/ip·高并发·tcp·epoll
比兔代理2 小时前
静态 IP 的粘性会话原理,以及连接稳定性解析
网络·网络协议
刘胡子大叔3 小时前
curl resolve 和 Host 头的两种免 DNS 验证 HTTPS
网络协议·http·https
j7~3 小时前
【Linux网络加餐】《HTTP 会话保持:Cookie 与 Session 原理及实验验证》---详解
linux·网络·网络协议·http·cookie·session
SHENGWEI胜为3 小时前
服务器 IP 地址冲突如何排查?从 DHCP 租约到 ARP 缓存定位故障
服务器·tcp/ip·缓存
mister_guo17 小时前
HTTP 和 RPC:从一次干饭说清它们的区别
网络协议·http·rpc
K成长日志18 小时前
BLE Host层L2CAP -- 配置参数选项
物联网·网络协议·嵌入式·蓝牙·iot·ble·bluetooth
幻智星科技20 小时前
纯FPGA网络通信实现微秒级确定性延迟
网络协议·fpga开发·硬件架构
码林鼠21 小时前
flutter网络http请求入门教程-dio
网络协议·flutter·http