前言
这一篇讲 socket:TCP 与 UDP 的差别、怎么用套接字收发、TCP「字节流」带来的粘包拆包问题与长度前缀解法、半关闭、超时与端口占用,最后给一个能同时服务多个客户端的小服务器。
先解释两个词:套接字(socket) 是程序收发网络数据的接口,可以想成「插在网络上的一个电话听筒」,你从它读字节、也往它写字节;端口 是同一台机器上区分不同程序的编号,像一栋楼里的房间号——IP 找到那台机器,端口找到机器里的那个程序。
所有示例在本机 Python 3.14.10(Windows)上实跑过,全部只走 127.0.0.1 回环 ,不依赖外网;端口用 bind(('127.0.0.1', 0)) 让系统随机分配,避免撞车。
相关概念
OSI 七层模型 :理论参考模型(物理、数据链路、网络、传输、会话、表示、应用)。
TCP/IP :实际使用的协议族,通常简化成四层:网络接口层、网络层(IP)、传输层(TCP/UDP)、应用层(HTTP、DNS…)。
IP 地址 :标识网络中的一台主机。127.0.0.1 是本机回环,0.0.0.0 表示「本机所有网卡」。查看:Windows ipconfig,类 Unix ip addr(ifconfig 已属过时的 net-tools)。
端口 :
标识主机上的一个应用进程,范围 0 ~ 65535 。
0~1023 是公认端口(HTTP 80、HTTPS 443、FTP 21、SSH 22、SMTP 25、MySQL 3306、Redis 6379、MongoDB 27017),在类 Unix 上绑定它们需要 root 权限。
Windows 不强制,但常用端口照样会撞。
套接字二元组 :网络编程的最小定位单位是「IP + 端口」,Python 用 socket 模块表达。
TCP 与 UDP 的差别:
维度
TCP(SOCK_STREAM)
UDP(SOCK_DGRAM)
连接
面向连接,先三次握手
无连接,拿到地址就发
可靠性
可靠:确认重传、排序、去重、校验和
不保证送达、可能乱序或丢包
数据形态
字节流 ,没有消息边界(会粘包)
数据报 ,一条就是一条
速度
相对慢(握手、拥塞控制)
相对快
典型场景
HTTP、文件传输、数据库
DNS、直播、语音、广播/组网发现
TCP 的连接与释放过程(三次握手建立、四次挥手释放):
UDP 没有建立/释放过程:
TCP:起一个能收发的连接
服务端 bind → listen → accept,客户端 connect,之后双方用 send/recv 收发。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 import socketimport threadingdef serve (server ): conn, addr = server.accept() with conn: data = conn.recv(1024 ) conn.sendall(data.upper()) if __name__ == '__main__' : server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.bind(('127.0.0.1' , 0 )) host, port = server.getsockname() print ('绑到的地址:' , host, '| 端口是随机分配的:' , port > 0 ) server.listen() threading.Thread(target=serve, args=(server,), daemon=True ).start() with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as client: client.connect((host, port)) client.sendall(b'hello socket' ) print ('客户端收到:' , client.recv(1024 )) server.close()
输出:
1 2 绑到的地址: 127.0.0.1 | 端口是随机分配的: True 客户端收到: b'HELLO SOCKET'
要点:
bind(('127.0.0.1', 0)) 让内核挑一个空闲端口,再 getsockname() 读回来——写演示代码时这样就不会和别人抢端口。
accept() 返回新 的套接字,专门服务这个客户端;原来的监听套接字继续等下一个。
sendall() 保证整块数据都交出去(send() 可能只发一部分)。
套接字支持 with,退出自动 close()。
TCP 是字节流:粘包与拆包
TCP 不保留消息边界。下面客户端一次发 10 个字节,服务端用 recv(4) 收三次:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 import socketimport threadingdef serve (server ): conn, _ = server.accept() with conn: chunks = [] for _ in range (3 ): chunks.append(conn.recv(4 )) print ('服务端分三次收到:' , chunks) server.close() if __name__ == '__main__' : server = socket.socket() server.bind(('127.0.0.1' , 0 )) host, port = server.getsockname() server.listen() threading.Thread(target=serve, args=(server,), daemon=True ).start() with socket.socket() as client: client.connect((host, port)) client.sendall(b'helloworld' ) print ('客户端把 10 字节一次性发出' ) import time time.sleep(0.2 )
输出:
1 2 客户端把 10 字节一次性发出 服务端分三次收到: [b'hell', b'owor', b'ld']
recv(n) 的语义是「最多 拿 n 个字节,有多少拿多少」,所以:一条消息可能被拆成几次,几条消息也可能挤在一次 recv 里。解决办法是自己定义消息边界,最常用的是长度前缀 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 import socketimport structimport threadingdef send_msg (conn, msg ): raw = msg.encode('utf-8' ) conn.sendall(struct.pack('>I' , len (raw)) + raw) def recv_msg (conn ): head = conn.recv(4 ) if not head: return None (size,) = struct.unpack('>I' , head) buf = bytearray () while len (buf) < size: chunk = conn.recv(size - len (buf)) if not chunk: break buf.extend(chunk) return buf.decode('utf-8' ) def serve (server ): conn, _ = server.accept() with conn: msgs = [] for _ in range (3 ): msgs.append(recv_msg(conn)) print ('服务端按条收到:' , msgs) server.close() if __name__ == '__main__' : server = socket.socket() server.bind(('127.0.0.1' , 0 )) host, port = server.getsockname() server.listen() threading.Thread(target=serve, args=(server,), daemon=True ).start() with socket.socket() as client: client.connect((host, port)) for m in ['第一条消息' , 'second' , '第三条比较长' * 3 ]: send_msg(client, m) import time time.sleep(0.2 )
输出:
1 服务端按条收到: ['第一条消息', 'second', '第三条比较长第三条比较长第三条比较长']
struct.pack('>I', 长度) 先写 4 字节大端长度,接收方先读满 4 字节、再按长度读满正文,这样无论怎么粘怎么拆都能还原成一条条消息。其他常见做法:分隔符(每行一条,如 HTTP 头)、固定长度。
TCP 半关闭:告诉对方「我说完了」
连接是全双工的,可以只关一个方向。客户端发完数据后 shutdown(SHUT_WR),服务端下一次 recv 就会拿到 b'',但仍能回数据:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 import socketimport threadingdef serve (server ): conn, _ = server.accept() with conn: first = conn.recv(1024 ) second = conn.recv(1024 ) print ('服务端第一次收到:' , first) print ('第二次收到空字节,说明对方不再发了:' , second == b'' ) conn.sendall(b'still here' ) server.close() if __name__ == '__main__' : server = socket.socket() server.bind(('127.0.0.1' , 0 )) host, port = server.getsockname() server.listen() threading.Thread(target=serve, args=(server,), daemon=True ).start() with socket.socket() as client: client.connect((host, port)) client.sendall(b'bye' ) client.shutdown(socket.SHUT_WR) print ('客户端还能收:' , client.recv(1024 )) import time time.sleep(0.1 )
输出:
1 2 3 服务端第一次收到: b'bye' 第二次收到空字节,说明对方不再发了: True 客户端还能收: b'still here'
recv() 返回 b'' 的唯一含义 是「对方关闭了写方向」,不是「这次没读到数据」——没数据时它会阻塞。写循环退出条件时别搞混。
UDP:一个数据报一次收发
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 import socketimport threadingreplies = [] def serve (server ): for _ in range (2 ): data, addr = server.recvfrom(1024 ) server.sendto(data.upper(), addr) if __name__ == '__main__' : server = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) server.bind(('127.0.0.1' , 0 )) host, port = server.getsockname() threading.Thread(target=serve, args=(server,), daemon=True ).start() got = [] with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client: client.sendto(b'ping' , (host, port)) client.sendto(b'pong' , (host, port)) got.append(client.recvfrom(1024 )[0 ]) got.append(client.recvfrom(1024 )[0 ]) print ('两条数据报分开返回:' , got) print ('消息边界还在(没被拼成一条):' , got[0 ] == b'PING' and got[1 ] == b'PONG' ) server.close()
输出:
1 2 两条数据报分开返回: [b'PING', b'PONG'] 消息边界还在(没被拼成一条): True
和 TCP 的区别在这里看得最清楚:两条 sendto 就是两条 recvfrom,边界天然保留 ,不需要长度前缀。UDP 服务端不需要 listen/accept,recvfrom 顺带告诉你来源地址,回包直接 sendto 回去即可。
超时、端口占用与地址解析
超时
1 2 3 4 5 6 7 8 9 10 11 12 import sockets = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.bind(('127.0.0.1' , 0 )) s.settimeout(0.2 ) try : s.recvfrom(1024 ) except socket.timeout as e: print ('超时异常:' , type (e).__name__, '| 和 TimeoutError 是同一个类:' , socket.timeout is TimeoutError) finally : s.close()
输出:
1 超时异常: TimeoutError | 和 TimeoutError 是同一个类: True
settimeout 之后 recv / connect 不再无限阻塞;Python 3.10 起 socket.timeout 就是内置的 TimeoutError。也可以用 socket.create_connection((host, port), timeout=2) 一步完成「解析 + 连接 + 超时」。
同一个地址端口不能绑两次
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 import socketfirst = socket.socket() first.bind(('127.0.0.1' , 0 )) host, port = first.getsockname() try : second = socket.socket() second.bind((host, port)) print ('竟然绑上了' ) except OSError as e: print ('绑定失败:' , type (e).__name__, '| errno:' , e.winerror if hasattr (e, 'winerror' ) else e.errno) finally : second.close() first.close() with socket.socket() as third: third.bind((host, port)) print ('释放后重新绑定成功:' , third.getsockname()[1 ] == port)
输出:
1 2 绑定失败: OSError | errno: 10048 释放后重新绑定成功: True
Windows 上 errno 是 10048(WSAEADDRINUSE),类 Unix 是 98。想重启服务不等待 TIME_WAIT,在 bind 前加 setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)(注意 Windows 上这个选项的语义和 Unix 不同,别指望它能双绑)。
地址解析
1 2 3 4 5 6 7 import socketprint ('本机主机名:' , socket.gethostname() != '' )print ('localhost 解析到回环:' , socket.gethostbyname('localhost' ))infos = socket.getaddrinfo('localhost' , 80 , type =socket.SOCK_STREAM) print ('getaddrinfo 返回的家族集合:' , sorted ({i[0 ].name for i in infos}))print ('其中 sockaddr:' , infos[0 ][4 ])
输出:
1 2 3 4 本机主机名: True localhost 解析到回环: 127.0.0.1 getaddrinfo 返回的家族集合: ['AF_INET', 'AF_INET6'] 其中 sockaddr: ('::1', 80, 0, 0)
getaddrinfo() 是推荐写法:一次给出所有可用结果(IPv4/IPv6),把家族和 sockaddr 直接喂给 socket() / connect(),双栈代码就不用自己判断。gethostbyname() 只回一个 IPv4,属于老接口。
少写点样板:socketserver
socketserver 把「循环 accept + 每个连接交给 handler」这套骨架封好了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 import socketimport socketserverimport threadingclass Handler (socketserver.BaseRequestHandler): def handle (self ): data = self .request.recv(1024 ) self .request.sendall(b'server got: ' + data) if __name__ == '__main__' : srv = socketserver.TCPServer(('127.0.0.1' , 0 ), Handler) host, port = srv.server_address threading.Thread(target=srv.serve_forever, daemon=True ).start() with socket.create_connection((host, port), timeout=2 ) as c: c.sendall(b'hi' ) print ('socketserver 版回执:' , c.recv(1024 )) srv.shutdown() srv.server_close()
输出:
1 socketserver 版回执: b'server got: hi'
生产环境把 TCPServer 换成 ThreadingTCPServer 或 ForkingTCPServer(后者仅 Unix)就能并发。要理解原理,看下一节手写版本更直观。
整体示例:同时服务多个客户端
手写一个「accept 循环 + 每连接一个线程」的 echo 服务器,三个客户端并发请求:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 import socketimport threadingdef handle (conn ): with conn: while True : line = conn.recv(64 ) if not line: break conn.sendall(b'echo:' + line.strip() + b'\n' ) def serve (server, n ): for _ in range (n): conn, _ = server.accept() threading.Thread(target=handle, args=(conn,), daemon=True ).start() server.close() def client (host, port, tag, out, lock ): with socket.create_connection((host, port), timeout=2 ) as c: c.sendall(tag.encode()) reply = c.recv(1024 ).decode().strip() with lock: out.append(reply) if __name__ == '__main__' : server = socket.socket() server.bind(('127.0.0.1' , 0 )) host, port = server.getsockname() server.listen(8 ) threading.Thread(target=serve, args=(server, 3 ), daemon=True ).start() out = [] lock = threading.Lock() ts = [threading.Thread(target=client, args=(host, port, f'task{i} ' , out, lock)) for i in range (3 )] for t in ts: t.start() for t in ts: t.join() print ('三个客户端各自拿到回执:' , sorted (out))
输出:
1 三个客户端各自拿到回执: ['echo:task0', 'echo:task1', 'echo:task2']
结构上就是《线程》里的套路:主线程 accept,每个连接丢给一个 daemon 线程处理,共享列表用锁保护。真实服务还要考虑:listen(backlog) 的排队长度、连接超时回收、单条消息大小上限、以及用线程池而不是无限开线程。
再往上走一层
socket 是最底层的手动挡。日常业务里更常用的是:
发 HTTP 请求 :http.client / urllib.request(标准库,见《函数使用(七)》),或第三方的 requests / httpx。
高并发 :asyncio 的 open_connection / start_server(见《Python-协程》),单线程扛几千连接。
服务发现 / 组播 :socket.IP_ADD_MEMBERSHIP 组播、SO_BROADCAST 广播——注意别拿它去扫整段网段,那是扫描行为。
参考