网络编程

前言

  • 这一篇讲 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 socket
import threading

def 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)) # 端口 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 socket
import threading

def serve(server):
conn, _ = server.accept()
with conn:
chunks = []
for _ in range(3): # 同一个 recv(4) 调三次
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') # 一次发 10 个字节
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 socket
import struct
import threading

def send_msg(conn, msg):
raw = msg.encode('utf-8')
conn.sendall(struct.pack('>I', len(raw)) + raw) # 4 字节长度 + 正文

def recv_msg(conn):
head = conn.recv(4)
if not head:
return None
(size,) = struct.unpack('>I', head)
buf = bytearray()
while len(buf) < size: # 收满 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 socket
import threading

def serve(server):
conn, _ = server.accept()
with conn:
first = conn.recv(1024)
second = conn.recv(1024) # 客户端 shutdown 之后读到空
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 socket
import threading

replies = []

def serve(server):
for _ in range(2): # 一个数据报 = 一次 recvfrom
data, addr = server.recvfrom(1024)
server.sendto(data.upper(), addr)

if __name__ == '__main__':
server = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # SOCK_DGRAM = UDP
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)) # 不用 connect,直接 sendto
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 socket

    s = 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 socket

    first = 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 socket

    print('本机主机名:', 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 socket
import socketserver
import threading

class 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 socket
import threading

def 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 广播——注意别拿它去扫整段网段,那是扫描行为。

参考

本文结束 感谢您的阅读
正在加载今日诗词....