计算机网络学习站

kp-028 · 05-应用层与Web

Socket 编程:构建 TCP/UDP 客户端与服务器

核心 约 30 分钟 Socket网络编程echo server客户端服务器
我的进度:

注:本文基于模型知识整理,建议结合权威教材与 RFC 原文核对细节。

一句话定义

套接字(socket)是应用进程访问传输层服务的门:一个文件描述符,附加上(协议族、本地地址:端口、远端地址:端口)的连接状态,TCP 与 UDP 的收发都通过它完成。

为什么重要

所有网络库与中间件(requests、gRPC、Nginx、数据库驱动)的底层都是 socket。理解它之后,端口占用、连接被拒、程序「卡住」、粘包这些日常问题才看得见成因;亲手写一个 echo server,是把 kp-018 的握手与关闭、kp-021 的字节流语义落到代码里的最短路径,也是一切网络服务的最小模型。

前置知识

建议先读 kp-018《TCP 连接管理》,掌握三次握手、四次挥手与端口语义;UDP 侧的无连接收发对照 kp-017《UDP》;代码中会直接遇到字节流无消息边界导致的拆包问题,对应 kp-021《TCP 工程实践》。

核心概念

  • 套接字抽象:对应用是一个 fd(file descriptor,文件描述符),对内核是(协议族 AF_INET、本地 IP:端口、远端 IP:端口)构成的状态;SOCK_STREAM 对应 TCP,SOCK_DGRAM 对应 UDP。
  • TCP 服务端五步:socket(创建)→ bind(绑定本地 IP:端口)→ listen(进入监听,backlog 限定等待队列长度,对应半连接/全连接队列)→ accept(从已完成三次握手的全连接队列取出一条连接,返回新的连接 fd)→ read/write 循环 → close。关键认知:三次握手由内核在网络层自动完成,accept 只是取货,不参与握手本身。
  • 客户端三步:socket → connect(向服务器发起并阻塞到握手完成)→ read/write;不显式 bind 时由内核自动分配临时端口。
  • UDP 版:无 connect(也可以 connect 锁定默认对端,但不改变无连接语义),直接 sendto/recvfrom 一问一答,服务器无需为每个客户端维持连接状态。

图示

TCP 服务端与客户端的系统调用时序:

服务端                                      客户端
socket()                                    socket()
bind(0.0.0.0:9000)   绑定端口
listen(5)            监听 + backlog 队列
accept() ◀── 取一条已完成三次握手的连接 ◀── connect() 触发三次握手
   │
read() ◀──────────────── 数据 ─────────────── write()
write() ──────────────── 回显 ───────────▶   read()
   │(对端关闭后 read 返回空,循环退出)
close()                                     close()

原理与机制

完整可运行的 Python TCP echo(服务端加客户端约 20 行,含注释):

# echo_server.py
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)    # TCP 套接字
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)  # 允许重用 TIME_WAIT 端口
s.bind(("", 9000))          # 绑定所有网卡的 9000 端口
s.listen(5)                 # backlog:等待 accept 的已完成连接队列长度
print("listening on :9000")
conn, addr = s.accept()     # 阻塞,直到取出一条握手完成的连接
with conn:
    while True:
        data = conn.recv(1024)   # 阻塞读,最多 1024 字节;对端关闭返回 b""
        if not data:
            break
        conn.sendall(data)       # 回显(sendall 保证全部写出)
s.close()

# echo_client.py
import socket
c = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
c.connect(("127.0.0.1", 9000))   # 触发三次握手,阻塞到完成
c.sendall(b"hello")
print(c.recv(1024))              # 输出 b'hello'
c.close()

UDP 对照:u = socket.socket(socket.AF_INET, socket.SOCK_DGRAM);服务端 data, addr = u.recvfrom(1024) 返回数据与对端地址,回信用 u.sendto(data, addr)——没有连接,天然一问一答。

常见错误与排查。 EADDRINUSE(地址已被占用):服务重启时旧连接处于 TIME_WAIT 仍占用端口,服务端在 bind 前设置 SO_REUSEADDR 即可解决。程序「卡住」:默认套接字是阻塞模式,read 在对端不发数据时会一直挂在系统调用上——这不是死循环,是等待;需要对读设超时,或改用非阻塞加 IO 多路复用。粘包:TCP 是字节流、没有消息边界,recv(1024) 一次可能读到半条消息,也可能读到一条半消息拼在一起(衔接 kp-021 的拆包),必须按定长、分隔符或长度前缀在应用层拆分。

实例分析

开两个终端:先运行 echo_server.py,再用 nc 127.0.0.1 9000(或运行 client)连上,敲任意字符都会原样返回;Ctrl+C 断开客户端,服务端 recv 返回空字节串、循环退出——亲手走完连接、传输、关闭的全生命周期,并可配合 kp-034 的 tcpdump 观察 9000 端口上的握手与挥手报文。生产级服务还必须处理并发:为每个连接开线程,或用 select/poll/epoll 单线程管理大量连接(IO 多路复用),那是下一步的扩展方向。

常见误区

  • 以为 accept 参与三次握手:握手在 connect 到达时由内核完成,accept 只从就绪队列取连接;backlog 满时新连接可能被丢弃。
  • 把 recv 的返回值当「一条完整消息」:它只是「当前已到达的字节」,长度由参数上限与网络到达情况决定。
  • 用 send 而非 sendall:send 可能只写出一部分数据,要用 sendall 或循环写直到写完。
  • 混淆监听 fd 与连接 fd:accept 返回的新 fd 才用于与该客户端通信,监听 fd 只负责受理连接。
  • 认为 UDP socket 一定不能 connect:可以 connect 锁定默认对端从而直接用 send/recv,但无连接、不保证可靠的语义不变。

自测题

  1. TCP 服务端的五个系统调用顺序及各自作用是什么?

答案要点: socket 创建套接字;bind 绑定本地地址与端口;listen 进入监听并设定 backlog 队列长度;accept 从已完成三次握手的全连接队列取出一条连接、返回新 fd;之后 read/write 收发数据,close 关闭。

  1. accept 与三次握手是什么关系?

答案要点: 握手由内核在客户端 connect 到达后自动完成,完成的连接进入全连接队列;accept 只是从队列取出一条连接返回新 fd,不参与握手过程本身。

  1. 服务重启时报 EADDRINUSE,原因与解法是什么?

答案要点: 旧连接处于 TIME_WAIT 状态仍占用该端口;服务端在 bind 前设置 SO_REUSEADDR,允许重用处于 TIME_WAIT 的地址与端口。

  1. echo 程序「卡在 recv 不动」是什么现象?

答案要点: 默认阻塞模式下,对端不发数据时 recv 挂起等待,属正常阻塞行为而非故障;应对读设置超时,或改用非阻塞 socket 配合 select/epoll 等IO多路复用。

  1. 发一条 100 字节消息,为什么对端 recv 可能只读到 37 字节?

答案要点: TCP 是无消息边界的字节流,recv 返回的是当前已到达的字节数;一条消息可能分多个报文段到达,应用层必须按定长、分隔符或长度前缀做拆包(粘包问题)。

延伸阅读

  • Stevens《UNIX 网络编程 卷1:套接字联网 API》
  • Python 官方标准库文档:socket 模块