Python Socket编程入门到实战:TCP/UDP通信、粘包处理与多线程聊天室
2026/9/15 21:05:00 网站建设 项目流程

1. 写给打算入坑网络编程的你:socket到底是个什么鬼东西

每次一提到socket,网上一堆教程上来就是“套接字”、“文件描述符”、“三次握手四次挥手”,直接把刚想动手的新手劝退了。但你要真把网络通信搞明白,socket是绕不过去的一道坎。

我在几年的开发里有个很深的体会:socket说白了就是操作系统给应用层提供的一根“网线插口”。你不需要懂路由器怎么转发、TCP怎么实现可靠传输,只要会往这个插口里塞数据、从插口里取数据,两台设备就能通信。这个概念用大白话讲就是:

你家要和邻居家说话,不需要自己造一根网线、不需要自己实现信号编码,物业(操作系统)已经帮你在墙上装好了电话线接口(socket),你只需要拿起电话拨号、说话、挂断就行。

这篇博文的内容,适合这样的读者:刚学完Python基础语法,想写点真正能跑起来的东西;用Python做爬虫但只懂requests库,想知道底层发生了什么;工作中需要写点网络监控、数据上报、设备联调的小工具,但没系统学过socket编程。我会从原理一路讲到能直接抄的实战代码,最后把新手最容易踩的坑一个个拆开给你看。整篇读下来大概需要25分钟,读完你能独立写出一个可靠的双机通信程序,以后遇到“两台电脑UDP通信”“TCP长连接请求”这类需求,不会再一头雾水。

先说清楚,我们用Python学socket有天然优势:Python的socket模块是对C socket API的一层清爽封装,用法和C几乎一一对应,但省掉了指针、手动管理内存这些破事。你在Python里学会了socket,将来去看C++、Java的socket代码,基本上能无缝迁移理解。反过来如果你一上来就啃C的socket,光是一个struct sockaddr_in的地址结构就能让你怀疑人生。所以用Python入门socket,是性价比最高的路线。

2. 从一次“打电话”说起:TCP通信的完整生命周期

要理解socket通信,最经典的类比就是打电话。我们以TCP为例,把一次完整通信拆开看,这是后面所有代码的基础。你在写任何socket程序之前,脑子里得有这张通话流程图。

2.1 服务端的三个动作:bind、listen、accept

服务端程序的角色相当于“总机接线员”,它要做三件固定的事情:

第一步是bind(绑定)。电话总机得先占一根电话线,绑定的意思就是告诉操作系统:“我要在这个IP地址的这个端口上等电话,这个入口归我管了。”IP地址是要自己的哪块网卡,端口是要在这个网卡上开哪个门。如果你不bind,操作系统会随手给你分配一个随机端口,那客户端就不知道往哪儿打。所以bind是服务端的第一步,必须显式做。

第二步是listen(监听)。绑定好了号码,得把电话铃声打开,告诉操作系统:“这个入口我准备好了,有电话进来你叫我。”listen的参数是backlog,表示最大排队等待人数——如果当下有5个人同时在打电话,第6个人打进来,他会在门口排队,这个值决定了门口最多能排几个人。

第三步是accept(接受)。这是最容易被新手误解的一步。很多人以为accept就是“接起电话”,实际上不是。accept是从等待队列里取一个已经建立好的连接,然后返回一个新的socket专门和这个客户端聊。原来的监听socket继续等着下一个来电。

这里有个核心概念很多教程讲不明白:一个服务端程序至少有两个socket。一个是监听socket,负责守门;一个是通信socket,负责聊天。accept()的返回值才是真正用来收发数据的那个。

等这个通信过程结束,不需要这个连接了,就调用close()挂断电话。如果后面还有人来,监听socket还活着,可以继续accept。

2.2 客户端的两个动作:connect、send/recv

客户端的角色是“打电话的人”,逻辑比服务端简单很多。它只需要知道服务端的IP和端口,然后调用connect()去拨号。这个拨号过程在TCP层面就是传说中的三次握手——SYN、SYN+ACK、ACK。但这些你完全不需要手动处理,操作系统内核全包了。

连接建立成功之后,双方的地位就是对等的了,都可以用send()发数据、用recv()收数据。不需要区分谁是“主”谁是“从”,谁有数据谁就发。

这里我要特别强调一个新手最容易产生幻觉的地方TCP不是“你发一次我收一次”的消息协议。它是流式协议——你的数据发出去之后,就变成了一串连续的字节流。sendrecv之间没有任何边界概念。你发了两条“你好”和“世界”,对方可能一次就把“你好世界”全收走了,也可能第一次只收到半个字。这个特性会在后面引出网络编程最著名的问题——粘包。

2.3 理解socket是阻塞的还是非阻塞的

默认情况下,Python的socket是阻塞模式。什么叫阻塞?你调用recv()去读数据,如果对方没发数据,这个调用就会一直卡在那里,等到天荒地老,直到有数据进来或者对端断开。类似你打电话,说完话对方不吭声,你拿着听筒一直等着。

阻塞模式的好处是编程简单,符合直觉;坏处是如果对方一直不发数据,你的程序就卡死了。解决思路有两种:一是用多线程,主线程accept,每个连接丢给一个子线程去处理,子线程里recv卡住也不影响别人;二是把socket设置成非阻塞或超时,socket.settimeout()设一个秒数,超过这个时间recv会抛socket.timeout异常。

小白阶段建议先按阻塞模式写通,再用多线程,最后再去折腾非阻塞和异步IO(asyncio),一步一个脚印。

3. Python socket模块逐层拆解:不需要背API,理解这套组合拳就够了

网上关于socket API的表格很多,但光背参数没有意义。我自己总结了一套“组合拳”记忆法:只要记住每端各要做什么动作,代码就是动作的翻译。

3.1 核心API映射表

动作服务端调用客户端调用生活类比
创建插座socket.socket(family, type)socket.socket(family, type)拿一个新电话机
绑定地址bind((ip, port))一般不写给电话机插上电话线
开启监听listen(backlog)不写打开铃声开关
等待来电accept()不写接起排队中的电话
发起连接不写connect((ip, port))拨号
发数据send(data)send(data)说话
收数据recv(bufsize)recv(bufsize)听对方说
挂断close()close()挂电话

family参数用来指定地址族,写AF_INET就是IPv4,还有AF_INET6对应IPv6。type用来指定socket类型,写SOCK_STREAM是TCP流式,写SOCK_DGRAM是UDP数据报。

判断一个需求该用TCP还是UDP,有一个简单粗暴的标准:如果你丢一条数据心里会发慌,那就用TCP;如果丢一条数据无所谓、丢了就再发一次,就用UDP。具体来说,文件传输、网页请求、指令下发这类要求不能丢的,必须用TCP;视频直播、语音通话、游戏位置同步这类丢一两帧不影响的,用UDP省去重传的延迟,反而体验更好。

3.2 send和sendall的区别,别用错了

这是Python socket里一个特别容易踩的坑。send()的行为是“尽力发送”——它返回的是实际发送出去的字节数,这个数字可能小于你要发的内容长度。比如你要发1024字节,网络拥堵,可能一次只发出去500字节,剩下的需要你手动再调一次send。而sendall()帮你在内部循环调send,直到全部发完或者出错。

实操中我几乎永远用sendall(),只有在极少数需要精细控制发送节奏的场景才用send()新手如果用send,经常遇到“数据好像丢了”的问题,其实就是没把数据发完。注意一个细节:sendall()发送成功时返回None,发送失败直接抛异常,没有中间状态。

3.3 recv的返回值,是判断连接断没断的关键

recv(bufsize)的返回值规则,可以说是整个socket编程里最需要刻进DNA的知识点:

  • 返回的字节串长度大于0:正常收到数据。
  • 返回的字节串长度为0:对端已经关闭了连接(或者对端调用了close)。这在TCP里意味着你收到了EOF(文件结束符)。
  • 抛异常:连接出错或者超时。

很多教程讲到这里就停了,但我要强调一个实操教训:必须在收数据循环里对“返回0”做处理,否则程序会陷入死循环。比如你写了一个死循环while True: data = recv(1024),如果对端断开后你收到一次空字节串,没有break,下一次循环recv还是返回空字节串,就这么永无止境地空转下去,CPU占满,连接却早就死了。

判断对端断开还有一个更彻底的方式:调用send()往一个已经断开的连接上发数据,系统会触发ConnectionResetErrorBrokenPipeError,但这个方法不总可靠,最可靠的还是看recv返回0。

3.4 设置超时的三种方式

阻塞的socket在recv时如果对方一直不说话,程序就彻底卡住了。解决办法:

import socket # 方式一:创建socket后设置 client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) client.settimeout(5) # 5秒内没有数据就抛socket.timeout # 方式二:只对本次connect设置超时 client.connect(("192.168.1.10", 8888)) # 先不设超时,connect可能挂很久

实操技巧:settimeout要在connect之前还是之后设置?建议在connect之前就设置,因为connect一个不通的IP地址(比如对方关机、网络断开),默认情况下会挂好几十秒才报错,用户体验极差。设置了超时之后,连接不上会快速失败,方便你重试或换下一个地址。

4. 手写一个TCP服务端和客户端:从单机回显到可靠通信

前面讲了半天原理,这一节直接上手写代码。我会带着你写一个完整可跑的TCP通信程序,服务端接收客户端发来的消息,然后把消息原样返回给客户端(回显服务)。

4.1 最简单的服务端:能跑通就行

先写一个单机版服务端,监听本机的8888端口:

import socket # 1. 创建TCP socket server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # 2. 允许端口复用,避免程序重启时报"Address already in use" server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # 3. 绑定地址和端口 server.bind(("127.0.0.1", 8888)) # 4. 开始监听,最多排队5个连接 server.listen(5) print("服务端已启动,监听 127.0.0.1:8888 ...") # 5. 接受客户端连接 conn, addr = server.accept() print("收到来自 {} 的连接".format(addr)) # 6. 接收数据并回显 while True: data = conn.recv(1024) if not data: # 重要:对端关闭连接时会返回空字节串 print("客户端已断开") break print("收到消息: {}".format(data.decode("utf-8"))) conn.sendall(data) # 回显给客户端 # 7. 清理 conn.close() server.close()

这段代码里有一个必须强调的细节:bindlisten中间的setsockopt。新手写服务端时经常遇到一个问题——程序用Ctrl+C停掉之后马上重新启动,结果报错Address already in use(端口被占用)。这是因为TIME_WAIT状态占着端口,加一行SO_REUSEADDR就能解决。这一行的重要性怎么强调都不过分,我做网络联调时几乎每一段服务端代码都会先加它。

4.2 服务端接住多个客户端:多线程才是正经姿势

上面的服务端只能处理一个客户端——第二个客户端来的时候,第一个没断开,accept就一直阻塞在那里,第二个连不上。真实场景里服务端必须能同时处理大量客户端,标准方案就是每来一个连接,就开一个线程处理

import socket import threading def handle_client(conn, addr): """处理单个客户端的收发""" try: while True: data = conn.recv(1024) if not data: break print("[{}] 收到: {}".format(addr, data.decode("utf-8"))) conn.sendall(data) except ConnectionResetError: print("[{}] 连接被重置".format(addr)) finally: conn.close() print("[{}] 连接已关闭".format(addr)) server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind(("0.0.0.0", 8888)) server.listen(5) print("服务端启动,等待连接...") while True: conn, addr = server.accept() print("[{}] 新连接".format(addr)) t = threading.Thread(target=handle_client, args=(conn, addr)) t.start()

注意这里bind的IP我写的是0.0.0.0,不是127.0.0.1。这是一个很容易被忽略的区别:

  • 127.0.0.1:只允许本机自己访问,外部设备连不上。用于本机调试。
  • 0.0.0.0:监听本机所有网卡,外部设备可以通过你的局域网IP访问。用于真机联调。
  • 具体某个IP如192.168.1.100:只监听指定网卡上的连接。

如果你想让另一台电脑连上你的服务端,bind的地址必须写成0.0.0.0,否则对方永远连不上。这是“两台电脑UDP/TCP通信”这类需求里最常见的坑之一。

4.3 客户端的完整写法:带上异常处理和超时

import socket client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) client.settimeout(5) try: client.connect(("127.0.0.1", 8888)) except (socket.timeout, ConnectionRefusedError) as e: print("连接失败: {}".format(e)) exit(1) try: while True: msg = input("请输入要发送的内容(输入exit退出): ") if msg == "exit": break client.sendall(msg.encode("utf-8")) data = client.recv(1024) print("服务端回显: {}".format(data.decode("utf-8"))) finally: client.close()

ConnectionRefusedError是什么场景?就是服务端程序没启动,或者IP端口写错了,客户端connect过去,系统直接回了一个RST包,客户端这边就抛这个异常。遇到这个错,第一反应是检查服务端有没有在运行、端口对不对,而不是怀疑代码写错了。

4.4 两台电脑怎么联调:常见的三个坑

很多新手在自己的电脑上测试没问题,换成两台电脑就联不通了,百分之九十九是下面三个原因:

  1. 服务端bind的是127.0.0.1。改成0.0.0.0,前面已经说过。
  2. 防火墙拦截了端口。Windows和Linux的防火墙默认都会拦掉外部入站的陌生端口。最粗暴的测试办法:暂时关闭防火墙(自己电脑的临时测试可以,生产环境别这么干),或者放行指定的TCP/UDP端口。如果关了防火墙就能通,那基本就是防火墙的锅。
  3. IP地址填错了。客户端填的必须是服务端电脑的局域网IP(比如192.168.x.x),不是它的主机名,也不是127.0.0.1。可以在服务端电脑上执行ipconfig(Windows)或ip addr(Linux)查看。两端最好能互相ping通,这是网络连通性的最低保障。

另外还有一个看似不起眼的问题:服务端要最先启动,而且要在connect之前启动完毕。很多人习惯先跑客户端,报ConnectionRefusedError后一脸懵,其实只是服务端还没就绪。联调前养成一个好习惯:先启动服务端,看到“等待连接”的提示后再启动客户端。

5. 粘包问题:新手迟早会撞上的第一堵墙

如果你写完上面的程序,开始尝试一次发送多条消息,用不了多久就会撞上一个诡异现象:服务端收到的消息粘在一起了。比如客户端连续发了三次sendall(b"hello")sendall(b"world")sendall(b"!"),服务端第一次recv可能直接返回了b"helloworld!",三次发送一次就收完了。这就是著名的粘包问题。

5.1 为什么TCP会粘包:不是bug,是特性

TCP是面向字节流的协议,它是流,不是消息。TCP本身根本不知道“消息”的边界在哪里,它只负责把字节按顺序可靠地交付给接收方。而socket的接收方,也就是应用层,通过recv从内核缓冲区里抓数据,抓多少取决于你指定的bufsize和缓冲区里当前有多少数据。

发送方如果连续两次sendall,第一次的数据还和第二次的数据待在同一个内核缓冲区里,接收方一次recv(1024)就把两段都取走了。这就是粘包现象的来源。

如果用的是UDP,就不存在粘包问题,因为UDP是数据报协议,每条sendto的数据自带边界,接收方的recvfrom一次只取一条完整的消息。但前面说了,UDP不保证可靠,丢包自己兜着。

5.2 解决粘包的标准姿势:先发长度,再发内容

彻底解决粘包问题的方案,业界总结下来就一句话:应用层自定义协议,用“包头+包体”的方式标记消息边界。

最经典的做法是:在发送真实数据之前,先发送一个固定长度的包头,里面记录这条消息的字节数。接收方先读到这个长度,再按长度去读数据体,就能精确地切分出每一条消息。

import struct def send_message(sock, data: bytes): """先发4字节的消息长度(大端序),再发消息本身""" header = struct.pack(">I", len(data)) sock.sendall(header + data) def recv_exact(sock, n: int) -> bytes: """可靠地接收恰好n个字节""" chunks = [] remain = n while remain > 0: chunk = sock.recv(remain) if not chunk: raise ConnectionError("对端连接关闭,无法收满 {} 字节".format(n)) chunks.append(chunk) remain -= len(chunk) return b"".join(chunks) def recv_message(sock) -> bytes: """先读取4字节包头,再按包头里的长度读取完整消息""" header = recv_exact(sock, 4) (length,) = struct.unpack(">I", header) return recv_exact(sock, length)

struct.pack(">I", ...)这里有两个知识点:>I表示“大端序的无符号整数,占4字节”。为什么用4字节?因为I对应的长度范围最大到2^32-1,约4GB,一般的小消息绰绰有余。如果你传的是文本,还要在收完body后再做一次.decode("utf-8")

这套“先长度后内容”的方案是网络编程里最底层的公共约定,理解了它,你以后看很多现成协议(比如HTTP的Content-Length、消息队列入库时的长度字段)都会有一种“啊,原来如此”的通透感。

5.3 另一个容易混的坑:recv(1024)里的1024到底代表什么

recv(1024)表示最多读取1024字节。如果内核缓冲区里只有100字节,那这一行会返回100字节,不会傻傻地凑到1024。所以看到recv返回值不等于1024,是再正常不过的。

在“先长度后内容”的协议里,recv_exact必须用循环去收,因为recv(4)可能只返回2个字节,剩下的2个字节要再收一次才能凑够。很多新手在这里翻车——只调了一次recv(4),就理所当然认为拿到了完整的4字节包头,结果后面全乱套。记住:在流式协议里,任何一次recv都可能“没读完”,可靠收满n个字节必须用循环。

6. UDP通信:不要连接,直接扔数据包

有些场景不需要TCP那么重的机制,比如心跳上报、传感器数据推送、游戏位置同步。这时候UDP反而比TCP合适。UDP的socket代码比TCP更简洁,因为它压根不需要listenaccept,也谈不上“连接”。

6.1 UDP服务端和客户端的最小实现

UDP服务端,一个socket走天下:

import socket udp_server = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) udp_server.bind(("0.0.0.0", 9000)) print("UDP服务端已启动,监听 0.0.0.0:9000") while True: data, addr = udp_server.recvfrom(1024) print("来自 {} 的消息: {}".format(addr, data.decode("utf-8"))) # UDP可以回发数据 udp_server.sendto(data, addr)

UDP客户端:

import socket udp_client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) server_addr = ("192.168.1.100", 9000) for i in range(5): msg = "第{}条UDP消息".format(i + 1).encode("utf-8") udp_client.sendto(msg, server_addr) data, _ = udp_client.recvfrom(1024) print("收到回复: {}".format(data.decode("utf-8"))) udp_client.close()

UDP和TCP的对应关系一目了然:TCP用send/recv,UDP用sendto/recvfrom;TCP要先connect,UDP直接sendto带上对端地址就能发;TCP服务端要accept拿到conn,UDP服务端一个socket就能和所有人通信,每次从recvfrom里拿到对方的addr,再sendto回去。

6.2 UDP会丢包,程序里要自己兜底

必须反复强调:UDP不保证送达、不保证顺序、不保证不重复。在局域网里丢包率通常很低,但在公网上丢包是常态。用UDP做关键数据传输时,务必要自己加应用层机制:确认机制(收到消息后回ACK)、重传机制(超时没收到ACK就重发)、序号机制(客户端给消息编号,服务端检测是否有漏号)。

我见过很多新手用UDP传文件,传了几秒钟就发现内容缺失,跑到网上发帖问“UDP传文件丢数据怎么解决”。答案很简单:UDP不是一个为“全量可靠传输”设计的协议,如果你发现丢包不可接受,请换TCP或者在此基础上实现可靠协议(比如参考QUIC的思路)。工具用错了不代表你有问题,换工具就好。

7. 实战升级:写一个带协议的消息聊天室

TCP你已经会了,粘包你也了解了,长度字段协议你也知道了。这一节我们把它们组合起来,写一个结构相对完整的局域网聊天室。这是检验你有没有真正掌握socket编程的好题目——这个项目包括服务端多线程处理、自定义协议、客户端交互三块核心内容。

7.1 协议设计

我们定义一下消息格式,用JSON做消息体(可读性好,调试方便),外面套上固定长度的包头:

  • 4字节大端整数:表示body的长度
  • body(变长):JSON字符串,包含name(昵称)和text(消息内容)两个字段

用JSON而不是自定义二进制格式的考虑是:开发调试时一眼能看懂消息内容,不需要额外写解析代码。将来数据量大了、对性能有要求了,再换protobuf这类序列化方案。

7.2 服务端代码:多线程广播

import socket import threading import struct import json clients = [] # 保存所有活跃连接的conn def send_message(sock, data: bytes): sock.sendall(struct.pack(">I", len(data)) + data) def recv_exact(sock, n: int) -> bytes: chunks = [] remain = n while remain > 0: chunk = sock.recv(remain) if not chunk: raise ConnectionError("连接已断开") chunks.append(chunk) remain -= len(chunk) return b"".join(chunks) def recv_message(sock) -> dict: header = recv_exact(sock, 4) (length,) = struct.unpack(">I", header) body = recv_exact(sock, length) return json.loads(body.decode("utf-8")) def broadcast(sender_conn, message: dict): data = json.dumps(message, ensure_ascii=False).encode("utf-8") for conn in list(clients): if conn is sender_conn: continue try: send_message(conn, data) except (ConnectionError, BrokenPipeError): # 连接出错的客户端,从列表里移除 if conn in clients: clients.remove(conn) def handle_client(conn, addr): name = None try: # 客户端连接后的第一条消息是昵称 first = recv_message(conn) name = first.get("name", "匿名") broadcast(conn, {"type": "system", "name": "", "text": "{} 加入了聊天室".format(name)}) while True: msg = recv_message(conn) text = msg.get("text", "") if text == "quit": break broadcast(conn, {"type": "chat", "name": name, "text": text}) except (ConnectionError, ConnectionResetError): pass finally: if conn in clients: clients.remove(conn) conn.close() if name: broadcast(None, {"type": "system", "name": "", "text": "{} 离开了聊天室".format(name)}) server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind(("0.0.0.0", 8888)) server.listen(10) print("聊天室服务端已启动,端口 8888") while True: conn, addr = server.accept() clients.append(conn) threading.Thread(target=handle_client, args=(conn, addr), daemon=True).start()

这段代码里有一个非常关键的技巧:把“连接断开”和“正常业务”统一用异常和返回值去控制流程。recv_exact在对端断开时抛出ConnectionError,上层handle_client捕获后把该做的清理都做掉。这样不管是对端主动close、网络中断、还是异常崩溃,服务端都不会因为某个客户端的故障而整体挂掉。

broadcast里的list(clients)为什么要拷贝一份?因为循环过程里可能有新的客户端加入或离开,如果直接遍历列表又同时删除元素,会抛RuntimeError: dictionary changed size during iteration。这是多线程共享可变数据结构时很典型的坑。

7.3 客户端代码:两线程一收一发

import socket import threading import struct import json def send_message(sock, data: bytes): sock.sendall(struct.pack(">I", len(data)) + data) def recv_exact(sock, n: int) -> bytes: chunks = [] remain = n while remain > 0: chunk = sock.recv(remain) if not chunk: raise ConnectionError("连接已断开") chunks.append(chunk) remain -= len(chunk) return b"".join(chunks) def recv_message(sock) -> dict: header = recv_exact(sock, 4) (length,) = struct.unpack(">I", header) body = recv_exact(sock, length) return json.loads(body.decode("utf-8")) def receiver_thread(sock): """子线程负责接收服务端的所有消息""" while True: try: msg = recv_message(sock) except (ConnectionError, ConnectionResetError, OSError): print("与服务端的连接已断开") break if msg.get("type") == "system": print("\n[系统] {}".format(msg["text"])) else: print("\n[{}] {}".format(msg["name"], msg["text"])) print("请输入消息(输入quit退出): ", end="", flush=True) def main(): sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(5) try: sock.connect(("127.0.0.1", 8888)) except (socket.timeout, ConnectionRefusedError) as e: print("连接失败: {}".format(e)) return my_name = input("请输入你的昵称: ") send_message(sock, json.dumps({"name": my_name, "text": ""}).encode("utf-8")) threading.Thread(target=receiver_thread, args=(sock,), daemon=True).start() try: while True: text = input("请输入消息(输入quit退出): ") send_message(sock, json.dumps({"name": my_name, "text": text}).encode("utf-8")) if text == "quit": break except (KeyboardInterrupt, ConnectionError, BrokenPipeError): pass finally: sock.close() if __name__ == "__main__": main()

客户端的难点在于:input()是阻塞的,recv消息也会阻塞,两者没法同时跑。解决办法就是用子线程专门干接收消息这件事,主线程专心处理输入。这个“一发一收两个线程”的结构,是几乎所有长连接客户端程序的通用骨架。将来你要写WebSocket客户端、IM客户端、MQTT客户端,都可以参考这个架构。

7.4 这个聊天室还缺什么

如果你正打算把它扩展成自己的项目,我列几个可以继续完善的方向,每个都是真实项目里绕不开的点:

心跳机制。现在服务端怎么判断一个客户端到底是“下线了”还是“只是不说话”?靠recv返回0吗?不行,客户端拔网线、断电,服务端可能永远等不到任何数据,也不会收到RST包。标准做法是客户端每隔几秒发一个心跳包,服务端超过N秒没收到任何数据就判定掉线,踢掉连接。这在长连接里是保命级的设计。

消息序号和ACK。现在我们用的TCP已经保证了有序不重复,所以“发过去对方就一定收到了”这件事是成立的。但如果将来换到UDP实现类似聊天室,就必须引入序号和ACK机制,不然消息丢了都不知道。

身份认证。现在任何人都能直接连上来报个昵称。真实系统里第一步是登录鉴权,这个可以在协议的最前面加一个握手消息(token验证)。

这些问题不是复杂度在吓唬人,而是提醒你:写一个demo和写一个“生产可用”的程序之间,隔着一整个知识体系。但Socket的核心你已经掌握了,剩下的都是在这个骨架上添砖加瓦。

8. 新手一定会踩的坑:从报错信息反推原因

网络编程的报错信息往往很吓人,但对英文报错的解读,恰好是排查问题的捷径。我把新手最常遇到的几个错误整理成一个速查表,以后看到类似报错直接对照。

8.1 报错速查表

报错信息(片段)实际原因排查方向
ConnectionRefusedError: [Errno 111] Connection refused目标端口没有程序监听,或IP不对启动服务端、检查端口、检查IP
TimeoutError: [Errno 110] Connection timed out网络不可达,或防火墙丢弃了包ping测试、关防火墙测试、检查IP
OSError: [Errno 98] Address already in use端口被占用,或TIME_WAIT未结束换端口、启用SO_REUSEADDR、杀旧进程
ConnectionResetError: [Errno 104] Connection reset by peer对端(强制)关闭了连接代码里对recv空值和异常做处理
BrokenPipeError: [Errno 32] Broken pipe往已关闭的连接上发数据发送前确认连接状态、捕获异常
socket.timeout: timed out设置了超时但超过时限没有数据检查对方是否发送、调超时时间

8.2 绑定地址报错“only one usage of each socket address”详解

这是热搜词里出现频率极高的一条报错,值得单独拿出来讲。完整的报错通常是这样的:

OSError: [Errno 98] Address already in use或者 Windows 下的OSError: [WinError 10048] Only one usage of each socket address (protocol/network address/port) is normally permitted

翻译成人话就是:你想bind的这个IP+端口组合,已经被另一个socket占用了。一个端口同一时间只能被一个socket独占监听,这是操作系统的规则。

出现这个错误通常有几个场景:

  1. 你上次运行的服务端没关干净。程序被Ctrl+C中止后,除非代码里写了finally去close,否则内核里的socket会在短时间内处于TIME_WAIT状态,端口被占着不放。解决:加上SO_REUSEADDR,或者等一两分钟,或者直接杀掉旧的python进程。

  2. 两个服务端程序用了同一个端口。比如你开了两个聊天室脚本都监听8888。解决:改成不同端口。

  3. 服务端没有设置SO_REUSEADDR且上次异常退出。前面代码里的server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)就是专门治这个的,我几乎每段服务端代码都会带它。

在Windows上,还有自己的SO_EXCLUSIVEADDRUSE行为差异,但对我们日常学习来说,加上SO_REUSEADDR基本能解决99%的端口复用问题。

8.3 排查链路的完整示范

假如你现在遇到“客户端连不上服务端”的问题,不要慌,按下面这个顺序走一遍:

第1步:服务端有没有启动?启动日志里有没有“监听”字样? 第2步:服务端监听的是127.0.0.1还是0.0.0.0?外部设备能不能访问? 第3步:客户端填的IP是服务端的实际局域网IP吗?(ipconfig/ip addr 查询) 第4步:两端能互相ping通吗?ping不通先解决网络问题。 第5步:服务端电脑的防火墙有没有放行该端口?(临时关掉防火墙测试最快) 第6步:用网络调试助手(小工具)替代客户端去连一次,看能不能通。

这套链路我称之为“联调六步法”,能覆盖九成以上的连接失败问题。不要一上来就怀疑代码,先排除环境因素,效率会高很多。

9. 收尾:关于Python版本、编码和一行好用的调试技巧

最后分享几个我在实际使用中的零碎经验,它们不构成完整章节,但都是能救命的细节。

Python版本问题。Python 2的socket写法(比如s.sendall("hello"))和Python 3有很大差别——Python 3里socket.sendall要求传bytes,字符串必须.encode("utf-8")。我见过很多从旧教程抄代码的人,卡在一行TypeError: a bytes-like object is required, not 'str'上。如果你现在还在用Python 2学习,立刻换成Python 3,网上大量新资料和库都只支持3.x了。装新版本Python时,记得勾选“Add Python to PATH”,否则命令行里找不到python命令,这个坑每天都有大量新人在踩。

关于编码的规范。网络通信只在字节层面工作,"你好".encode("utf-8")b'\xe4\xbd\xa0\xe5\xa5\xbd'是同一件事。发消息格式的约定要双方一致:如果服务端按UTF-8解码,客户端就必须按UTF-8编码。如果一方用了GBK,另一方用UTF-8,你们看到的就会是一堆乱码。协议里写清楚用UTF-8,最好也在代码注释里标明。

一个小技巧:利用网络调试助手做联调。网上搜“网络调试助手”,是一个免费的Windows小工具,可以快速模拟TCP服务端或客户端。当你自己的代码连不上时,先用这个工具连一下,如果工具能通而你的代码不能通,那就是代码问题;如果工具也不通,那就是网络或服务端问题。这个二分法排查思路,能帮你快速定位问题边界。

我经历过无数次socket联调失败之后最大的体会是:网络编程的大部分坑,都来自人对“流”和“包”的认知偏差。你以为发一条就收一条,其实它是一条流淌的河;你以为连接断了receive会报错,其实它只是默默返回一个空串。把这一层想明白了,后面的路就顺了。希望这篇拆解能帮你少走几段弯路,去写出自己的第一个真正能用的网络程序。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询