1. 为什么我建议每个后端新手都亲手写一遍TCP单机通信
刚入行那会儿,我对TCP的理解基本停留在"三次握手、四次挥手"这八个字上,面试能背,真让我写一个能跑通的通信程序,立马抓瞎。后来带过几个新人,发现大家卡的点出奇一致:知道socket是个接口,但不知道bind到底绑了什么,listen之后连接从哪来,accept返回的又是个什么东西。这些疑问,光看八股文是解决不了的,必须自己敲一遍代码,看着两个终端窗口一问一答,脑子里那根线才会真正接上。
这篇要聊的就是TCP socket单机通信实验——在一台机器上,用回环地址让客户端和服务端完成一次完整的收发。别小看这个"单机"二字,它恰恰是最适合打基础的场景:没有网线、没有路由器、没有防火墙干扰,所有变量都被压缩到最小,你能把全部注意力放在socket API的调用顺序和数据流动上。搞懂了单机,再去做跨机通信、做并发服务器,无非是在这个骨架上加东西。
这篇文章适合谁看?如果你是刚学网络编程的学生、转行做后端的新人,或者写了几年业务代码但从没碰过socket的老手,都值得跟着走一遍。我会把每一步的为什么讲透,把参数怎么算、坑在哪、报错怎么查都摊开说,代码可以直接抄作业。全程用Python演示,因为它的socket接口最贴近系统调用原貌,看得清底层逻辑;同时我也会点出C语言版本的关键差异,方便你对照理解。
先说清楚实验目标:写一个服务端,监听某个端口;写一个客户端,连上去发一句话;服务端收到后回一句话;客户端收到后打印出来,双方各自关闭连接。就这么简单,但麻雀虽小,五脏俱全,TCP通信的核心流程全在里面了。
2. 实验整体设计与核心思路拆解
2.1 单机通信到底"单"在哪,为什么先做它
所谓单机通信,就是客户端和服务端跑在同一台物理机器上,数据不经过网卡对外发送,而是走回环接口(loopback)。回环接口是一个虚拟的网络设备,操作系统内核直接在里面把数据转一圈就还给你,速度极快,也完全不依赖外部网络环境。
这里有个关键认知:即使不插网线、断掉WiFi,回环接口照样工作。你用的地址127.0.0.1(IPv4)或::1(IPv6)就是专门给回环用的。为什么先做单机?因为跨机通信会引入一堆额外变量——IP是否可达、端口是否被防火墙拦、NAT怎么转换——任何一个出问题都会让你怀疑人生。单机把这些全排除掉,如果单机都跑不通,那一定是代码逻辑问题,排查范围瞬间缩小到几十行代码里。
从学习曲线看,单机实验让你专注三件事:socket的创建与绑定、连接的建立与接受、数据的发送与接收。这三件事是TCP编程的原子操作,跨机、并发、多路复用全都是它们的组合与扩展。
2.2 服务端与客户端的角色分工
TCP是面向连接的协议,通信前必须先建立连接,这就决定了双方角色不对称。服务端是被动方:它先创建socket,绑定一个固定的地址和端口,然后进入监听状态,等着别人来连。客户端是主动方:它创建socket后,直接指定要连的服务端地址和端口,发起连接请求。
打个生活化的比方:服务端像一家开了固定门牌号的店,bind就是挂门牌,listen就是开门营业,accept就是站在门口等客人进门。客户端像顾客,知道店的门牌号(IP+端口),主动上门(connect)。客人进门后,双方才能对话(send/recv)。这个类比能帮你记住调用顺序:服务端必须先bind再listen,客户端直接connect就行,不需要bind(系统会自动分配一个临时端口)。
为什么客户端通常不bind?因为客户端用什么端口不重要,操作系统从临时端口范围里随便挑一个没被占用的就行。而服务端必须bind固定端口,否则客户端根本不知道去哪找它。
2.3 技术选型:为什么用Python演示,C版本差在哪
选Python是因为它的socket模块几乎是对BSD socket API的直译,函数名、参数顺序都和C一致,但省去了内存管理和头文件的繁琐。你写Python时脑子里建立的调用链,换成C、Java、Go基本通用。
C语言版本的核心差异在于:需要#include <sys/socket.h>等头文件,socket()返回的是文件描述符int,send/recv操作的是char*缓冲区加长度,还要处理errno。Python把这些封装成了对象方法,但底层是同一套系统调用。等你把Python版跑通,再去看C版,会发现只是语法糖的差别,逻辑一模一样。
提示:本实验所有代码仅用于本机回环通信的教学演示,请勿将其直接用于任何生产环境或对外服务场景。
3. 核心API逐个拆解与参数计算
3.1 socket创建:三个参数决定了什么
socket()函数有三个参数,Python里写作socket.socket(family, type, proto)。这三个参数决定了你创建的是什么样的通信端点。
family(地址族):常用的是AF_INET(IPv4)和AF_INET6(IPv6)。单机实验用AF_INET就够了,配127.0.0.1。如果你要用IPv6的回环,就选AF_INET6配::1。还有AF_UNIX用于本机进程间通信,走的是文件系统路径而非网络协议栈,性能更高,但那是另一个话题。
type(套接字类型):SOCK_STREAM对应TCP,提供面向连接、可靠、有序的字节流;SOCK_DGRAM对应UDP,无连接、不保证可靠。本实验要的是TCP,所以选SOCK_STREAM。这里要理解"字节流"的含义:TCP不保留消息边界,你发两次,对方可能一次收完,也可能分三次收完,这直接影响到后面recv怎么写。
proto(协议):通常填0,表示根据前两个参数自动选择。当family是AF_INET、type是SOCK_STREAM时,系统自动选TCP协议。一般不用手动指定。
创建socket的本质,是向操作系统申请一个通信端点,内核会返回一个文件描述符(Python里封装成socket对象)。这个描述符后续所有操作都要用到,用完必须关闭,否则会泄漏资源。
3.2 bind与端口选择:为什么1024以下不能随便用
bind((host, port))把socket和一个本地地址绑定。host填127.0.0.1表示只接受本机回环的连接;如果填0.0.0.0则表示监听本机所有网卡地址。单机实验填127.0.0.1更安全,外部根本连不进来。
端口选择有讲究。0到1023是知名端口,被系统服务占用,普通用户权限绑定会报Permission denied。1024到49151是注册端口,一般应用用这段。49152到65535是动态/私有端口,系统给客户端临时分配就用这段。实验里我习惯用8888或9999这种好记的,落在注册端口区间,不需要特殊权限。
如果端口被占用,bind会抛OSError: [Errno 98] Address already in use。这时候要么换端口,要么找出占用进程干掉它。还有个常见坑:服务端刚关闭,端口处于TIME_WAIT状态,短时间内无法立即重新bind。解决办法是设置SO_REUSEADDR选项,这个后面细说。
3.3 listen与accept:连接队列是怎么回事
listen(n)里的n叫backlog,表示已完成三次握手、等待被accept的连接队列的最大长度。注意,它不是"最大连接数",而是"还没被取走的连接数"。现代Linux内核里,backlog还受系统参数somaxconn限制,实际生效值取两者较小者。
为什么要有这个队列?因为客户端connect成功后,连接就进入内核的已完成队列,但服务端程序可能正忙着处理上一个连接,还没来得及accept。队列就是缓冲区,让内核先帮你把连接收着。如果队列满了,新的连接请求会被拒绝或忽略,客户端可能收到超时或重置。
accept()返回一个新的socket对象和客户端地址。这是新手最容易懵的点:为什么有两个socket?原来的监听socket(我们叫它listen_sock)只负责"接客",它本身不传输数据;accept返回的新socket(conn_sock)才是和这个特定客户端通信的通道。一个服务端可以accept出很多个conn_sock,每个对应一个客户端,这就是并发的基础。
3.4 send与recv:字节流没有边界
send(data)把数据放进内核发送缓冲区,返回实际发送的字节数。注意,send返回成功不代表对方收到了,只代表数据进了本地内核缓冲区。TCP会在后台负责重传、确认。
recv(bufsize)从内核接收缓冲区读数据,返回读到的字节。bufsize是本次最多读多少字节。关键点来了:recv返回0表示对方关闭了连接,这是判断连接结束的标准信号,不是读到空数据。如果对方还有数据没发完就关闭,你会先读到数据,最后读到0。
因为TCP是字节流,没有消息边界,所以"发一次收一次"的假设是不成立的。比如客户端send("hello"),服务端recv(1024)可能一次收到全部5字节,也可能先收到2字节再收到3字节。要可靠地传输"一条完整消息",你得自己设计协议:要么固定长度,要么用分隔符,要么在消息头里带上长度字段。单机实验数据量小,通常一次就收全了,但你必须知道这个隐患。
4. 完整实操过程与代码实现
4.1 服务端代码逐行实现
先上完整代码,再逐段拆。
import socket HOST = '127.0.0.1' PORT = 8888 def start_server(): # 1. 创建TCP socket server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # 2. 设置端口复用,避免TIME_WAIT导致无法立即重启 server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # 3. 绑定地址和端口 server_sock.bind((HOST, PORT)) # 4. 开始监听,backlog设为5 server_sock.listen(5) print(f"[服务端] 已启动,监听 {HOST}:{PORT}") # 5. 阻塞等待客户端连接 conn_sock, client_addr = server_sock.accept() print(f"[服务端] 收到来自 {client_addr} 的连接") # 6. 接收客户端数据 data = conn_sock.recv(1024) print(f"[服务端] 收到消息: {data.decode('utf-8')}") # 7. 回复客户端 reply = "服务端已收到你的消息" conn_sock.send(reply.encode('utf-8')) print(f"[服务端] 已回复") # 8. 关闭连接socket和监听socket conn_sock.close() server_sock.close() print("[服务端] 连接已关闭") if __name__ == '__main__': start_server()第1步创建socket,选AF_INET+SOCK_STREAM,就是TCP over IPv4。第2步的SO_REUSEADDR非常关键,它允许服务端在TIME_WAIT状态下重新绑定同一端口。没有它,你调试时反复重启服务端,很快就会撞上Address already in use。第3步bind,第4步listen(5),backlog给5够实验用了。
第5步accept()是阻塞调用,程序会停在这里,直到有客户端连进来。它返回的conn_sock是新的通信socket,client_addr是客户端的(IP, 端口)元组。第6步recv(1024)同样阻塞,等客户端发数据。第7步把回复编码成bytes发回去。第8步两个socket都要关,先关conn_sock再关server_sock。
4.2 客户端代码逐行实现
import socket HOST = '127.0.0.1' PORT = 8888 def start_client(): # 1. 创建TCP socket client_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # 2. 主动连接服务端 client_sock.connect((HOST, PORT)) print(f"[客户端] 已连接到 {HOST}:{PORT}") # 3. 发送消息 msg = "你好,我是客户端" client_sock.send(msg.encode('utf-8')) print(f"[客户端] 已发送: {msg}") # 4. 接收服务端回复 data = client_sock.recv(1024) print(f"[客户端] 收到回复: {data.decode('utf-8')}") # 5. 关闭连接 client_sock.close() print("[客户端] 连接已关闭") if __name__ == '__main__': start_client()客户端简单得多:创建socket后直接connect((HOST, PORT)),这一步会触发三次握手。连接成功后send发消息,recv等回复,最后close。注意客户端没有bind和listen,因为它是主动方,系统自动分配本地临时端口。
4.3 运行顺序与实测现象
运行顺序不能错:先启动服务端,再启动客户端。如果你先跑客户端,会立刻报ConnectionRefusedError: [Errno 111] Connection refused,因为服务端还没在监听那个端口,内核直接拒绝了连接请求。
开两个终端窗口。终端A跑服务端:
python server.py你会看到它打印"已启动,监听 127.0.0.1:8888",然后光标停住——它在accept()处阻塞。此时终端B跑客户端:
python client.py客户端瞬间打印连接成功、发送成功、收到回复。切回终端A,你会看到它依次打印收到连接、收到消息、已回复、连接已关闭。整个过程一气呵成。
实测下来,单机回环的往返延迟通常在几十微秒级别,肉眼完全感觉不到。你可以用time命令或者代码里加时间戳测一下,会发现大部分时间花在进程启动上,真正的通信几乎不耗时。
4.4 用netstat和ss验证连接状态
光看程序输出还不够,用系统工具看一眼连接状态,理解会更立体。服务端启动后、客户端连接前,在第三个终端跑:
ss -tlnp | grep 8888你会看到一行LISTEN状态的记录,本地地址是127.0.0.1:8888,这就是监听socket。客户端连上后,再跑:
ss -tnp | grep 8888能看到两条ESTABLISHED记录:一条是服务端的127.0.0.1:8888对客户端的127.0.0.1:某临时端口,另一条方向相反。这就是一条完整的TCP连接的两个端点。等程序关闭后,可能还会看到TIME_WAIT状态的记录,那是主动关闭方在等待2MSL,属于正常现象。
5. 常见问题与排查技巧实录
5.1 端口被占用怎么办
报错OSError: [Errno 98] Address already in use,八成是上一个服务端进程没退干净,或者处于TIME_WAIT。排查步骤:先用ss -tlnp | grep 8888看谁占着,找到PID后kill掉。如果确认进程已退但端口还在TIME_WAIT,那就靠SO_REUSEADDR解决——这也是我强烈建议在服务端代码里默认加上它的原因。加上之后,即使端口处于TIME_WAIT,也能立即重新bind。
注意:
SO_REUSEADDR要在bind之前设置,顺序反了不生效。
5.2 连接被拒绝的几种原因
ConnectionRefusedError最常见的原因是服务端没启动,或者启动的端口和客户端连的端口不一致。还有一种隐蔽情况:服务端bind的是127.0.0.1,客户端连的是本机的真实网卡IP,两者不匹配,也会被拒。单机实验统一用127.0.0.1就不会有这个问题。另外,如果服务端listen的backlog满了,新连接也可能被拒,但实验场景下基本遇不到。
5.3 recv返回空、程序卡死怎么查
recv返回b''(空字节)表示对端已关闭连接,这是正常信号,不是错误。如果你发现程序卡在recv不动,说明对端既没发数据也没关闭连接,双方在互相等。这种"死锁"在实验里通常是逻辑写错了,比如客户端发完就等回复,服务端却在等客户端再发一条。解决办法是理清通信协议:谁先发、谁后发、发几条、什么时候关,提前约定好。
还有一种卡死是send阻塞。如果发送的数据量超过内核发送缓冲区,且对方一直不recv,缓冲区满了send就会阻塞。实验数据量小,不会触发,但你要知道这个机制。
5.4 中文乱码的根源
send和recv操作的都是bytes,不是str。Python里必须encode('utf-8')再发,收到后decode('utf-8')。如果一端编码用utf-8,另一端解码用gbk,就会乱码。统一用utf-8是最省心的做法。如果忘了encode直接send字符串,Python 3会直接报TypeError,反而好排查;真正坑的是两端编码不一致,数据能传但显示是乱码。
5.5 常见问题速查表
| 报错/现象 | 可能原因 | 解决方向 |
|---|---|---|
| Address already in use | 端口被占或TIME_WAIT | 设置SO_REUSEADDR,或换端口 |
| Connection refused | 服务端未启动/端口不符 | 先启服务端,核对端口 |
| recv返回b'' | 对端已关闭连接 | 正常,据此退出循环 |
| 程序卡在recv | 双方都在等对方发 | 理清收发顺序 |
| 中文乱码 | 编解码不一致 | 两端统一utf-8 |
| Permission denied | 绑定了1024以下端口 | 换1024以上端口 |
6. 从单机到实用的进阶改造
6.1 让服务端支持多次收发
现在的服务端收一条就关,太脆弱。改成循环收发,直到客户端说再见:
while True: data = conn_sock.recv(1024) if not data: # 对端关闭 break print(f"收到: {data.decode('utf-8')}") conn_sock.send(f"已收到{len(data)}字节".encode('utf-8'))这里if not data就是判断recv返回空字节,是退出循环的标准写法。客户端也相应改成循环发送,最后close触发服务端退出。
6.2 处理粘包:给消息加个长度头
前面说过TCP没有消息边界。要传多条消息,最稳妥的办法是长度前缀:先发4字节表示消息长度,再发消息体。接收方先读4字节拿到长度N,再循环读满N字节。这样无论内核怎么切分数据,都能还原出完整消息。这是几乎所有二进制协议的基础做法,值得你亲手实现一遍。
6.3 用线程支持多个客户端
单机实验里accept一次只处理一个连接。要同时服务多个客户端,最简单的办法是每accept一个就开一个线程去处理:
import threading def handle(conn, addr): with conn: while True: data = conn.recv(1024) if not data: break conn.send(data) # 回声 while True: conn, addr = server_sock.accept() t = threading.Thread(target=handle, args=(conn, addr)) t.start()主线程只管accept,每个连接交给独立线程。这就是最朴素的并发服务器模型。再往上就是线程池、IO多路复用(select/epoll)、异步IO,但那些都是在这个骨架上的优化,核心的socket调用没变。
6.4 设置超时避免永久阻塞
accept和recv默认无限阻塞,调试时容易卡死。可以给socket设超时:
server_sock.settimeout(10.0) # 10秒超时后抛socket.timeout异常,捕获后可以决定重试还是退出。客户端connect也可以设超时,避免连一个不存在的地址时等太久。生产环境里超时设置是必须的,实验阶段加上它能让你的调试体验好很多。
我个人在实际操作中的体会是,TCP socket这东西,看十遍不如敲一遍。单机实验最大的价值不是教会你写一个能跑的demo,而是让你亲眼看到accept返回的新socket、亲手摸到recv返回的空字节、亲自撞上端口占用的报错。这些具体的、带痛感的经验,才是真正长在你身上的知识。等你把这些坑都踩过一遍,再去看那些讲epoll、讲Reactor模式的资料,会发现底层逻辑早就通了。最后分享一个小技巧:调试时在服务端和客户端各加一行打印本地socket的getsockname(),你会清楚看到系统给客户端分配的临时端口是多少,对理解"客户端不bind"这件事特别有帮助。