Skip to content

第2章:Socket 编程入门


元数据卡

维度
难度
前置第1章(分层模型)、基本 Python 语法
关键词Socket、TCP、UDP、bind/listen/accept、select/epoll、非阻塞
代码语言Python (主) / Java (差异窗口)

你的进度

"驿道有了(分层信标塔体系),但你需要学会怎么开口念传送咒。Socket 是法师与驿道对话的法术接口——你通过它把法术信函传出去,别的法师通过它发给你。这是你在驿道世界的第一个施法工具。"

上一章你学会了法术信函走出信标塔时的多层封装流程——法术卷轴套上传送咒的信封,再套驿道坐标信封,最后塞进信标塔的魔力传输车厢。你理解了驿道怎么修:每一层只负责一段路,封好的法术信函一路向下冲出信标塔。

但你现在站在信标塔门口,手里握着封装好的魔力脉冲串,一个现实问题迎头砸来:另一端的法师是谁?他在哪座塔里?我怎么把法术信函递到他手里?

你不能像法术同步一样说 result = receive("192.168.1.20", 8080)——驿道不是法术同步调用,驿道上没有返回值,只有法术信函的你来我往

你需要一个入口。一个能从你的法阵空间伸出去、穿透护城法阵、连接另一端法师塔管道。

这根管道就叫 Socket(法术信标接口)

本章分层

  • 必读:Socket 是什么(城门比喻)、TCP 客户端/服务端 API 顺序(socket/bind/listen/accept/connect)、实现 echo server/client、TCP vs UDP API 差异
  • 选读:非阻塞 I/O 的概念、select/epoll 初级认知(属于后续「高并发网络编程」章节完整讲解)
  • 进阶:多线程 TCP 服务器、消息边界处理

本章不会要求你掌握

  • epoll 的高性能调优参数
  • Reactor/Proactor 设计模式
  • Unix Domain Socket

你的任务

理解 Socket 是什么、TCP 客户端和服务端各自该调什么 API、UDP 和 TCP 的区别在哪,以及——最关键的——当你写出第一个 while True: client_data = sock.recv(1024) 时,你的程序到底在等什么。

等你学完本章,你会意识到:Socket 编程的核心不是 API,而是。而"等"的代价比你想象的大得多。

遭遇战:无 Socket 通信

回到上一章结尾——你写了一个 ProtocolStack 类,手动把法术信函打入以太网魔法帧。然后呢?你怎么把这个魔法帧送到信标塔的魔力发送阵上?

python
# 假装我们能直接写信标塔发信阵
with open('/dev/eth0', 'wb') as netcard:
    netcard.write(eth_frame)   # ← 错误!不是这么干的

实际上你不能直接写 /dev/eth0。信标塔是一个魔力直接存取(DMA)法术设备,由驿道守护法阵的法术设备驱动管理。用户态法师想碰信标塔,唯一的通道就是 socket 法术调用

关键认知:Socket 是法师态和法阵核心态之间的通信管道。它不是协议,不是驿站编号,不是驿道坐标——它是驿道提供给法师程序的网络法术读写抽象

一个 socket 的内部结构大约长这样(概念性):

用户进程
   ↓ 系统调用(socket / bind / connect / send / recv)
┌─────────────────────────────────────┐
│         Socket 文件描述符 (fd)        │  ← 像文件一样 read/write
│     ┌───────── 发送缓冲区 ────────┐  │
│     │  等待内核协议栈发送的数据      │  │
│     └────────────────────────────┘  │
│     ┌───────── 接收缓冲区 ────────┐  │
│     │ 已到达但没被进程读走的数据    │  │
│     └────────────────────────────┘  │
│        协议族: AF_INET              │
│        类型: SOCK_STREAM (TCP)      │
│        本地: 192.168.1.10:54321     │
│        远端: 93.184.216.34:80       │
└─────────────────────────────────────┘

  内核协议栈 (TCP/IP 栈)

  网卡驱动 → 硬件

每个 socket 绑定一个文件描述符(fd)。所以你熟悉的 read(fd, buf, n)write(fd, buf, n) 对 socket 同样有效——这是 Unix "一切皆文件"哲学的延伸。

获得技能:Socket 即城门

记忆锚点:Socket = 你的法师塔传信口。创建它 = 定好走法术驿道还是魔力信鸽;bind = 给你的传信口贴上驿道门牌号;listen = 打开传信口上的小观测窗,等别人发法术信函;accept = 打开门,拉信函进来;connect = 走到别人法师塔的传信口前敲门。


常见陷阱:TCP Socket API 实战

TCP Socket 完整生命周期

我们用 Python 写一个真实的 TCP 回显服务器(echo server),从创建到关闭走完整流程:

python
import socket
import sys

def echo_server(host='0.0.0.0', port=8888):
    """TCP 回显服务器:收到什么就返回什么"""
    # 1. 创建 Socket(城门)
    #    AF_INET  = IPv4
    #    SOCK_STREAM = TCP(可靠的字节流)
    server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

    # 2. 允许地址复用(开发调试常用)
    server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

    # 3. bind:贴上门牌号
    #    ("0.0.0.0", 8888) = 绑定所有网卡的 8888 端口
    server_sock.bind((host, port))
    print(f"[服务器] 绑定 {host}:{port}")

    # 4. listen:打开城门上的小窗口
    #    参数 = 等待队列的最大长度(未完成三次握手的连接数)
    server_sock.listen(5)
    print(f"[服务器] 正在监听,等待连接...")

    try:
        while True:
            # 5. accept:阻塞!直到有客户端敲门
            #    返回一个新 socket + 客户端地址
            client_sock, client_addr = server_sock.accept()
            print(f"[服务器] 接受连接: {client_addr}")

            with client_sock:
                while True:
                    # 6. recv:读取客户端数据
                    #    阻塞!直到收到数据或连接关闭
                    data = client_sock.recv(1024)
                    if not data:
                        # recv 返回空 = 对方已优雅关闭
                        print(f"[服务器] {client_addr} 断开连接")
                        break
                    print(f"[服务器] 收到 {len(data)} 字节: {data[:50]!r}")

                    # 7. send:发送数据给客户端
                    client_sock.send(data)  # echo 回去
                    print(f"[服务器] 回显 {len(data)} 字节")

    except KeyboardInterrupt:
        print("\n[服务器] 正在关闭...")
    finally:
        # 8. close:关闭城门
        server_sock.close()
        print("[服务器] 已关闭")

if __name__ == '__main__':
    echo_server()

对应的客户端:

python
import socket

def echo_client(message="Hello, Network!", host='127.0.0.1', port=8888):
    """连接到回显服务器"""
    # 1. 创建同类型 socket
    client_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

    try:
        # 2. connect:走到对方城门前敲门
        #    这里发生了 TCP 三次握手(内核帮你做了)
        print(f"[客户端] 正在连接 {host}:{port}...")
        client_sock.connect((host, port))
        print(f"[客户端] 连接成功")

        # 3. send:发数据
        client_sock.send(message.encode('utf-8'))
        print(f"[客户端] 发送 {len(message)} 字节: {message!r}")

        # 4. recv:等回应
        reply = client_sock.recv(1024)
        print(f"[客户端] 收到回应: {reply.decode('utf-8')!r}")

    finally:
        # 5. close
        client_sock.close()
        print("[客户端] 断开连接")

if __name__ == '__main__':
    echo_client("飞船呼叫地面站,收到请回答!")

运行

bash
# 终端 1:启动服务器
python echo_server.py

# 终端 2:启动客户端
python echo_client.py

预期输出(客户端):

[客户端] 正在连接 127.0.0.1:8888...
[客户端] 连接成功
[客户端] 发送 20 字节: '飞船呼叫地面站,收到请回答!'
[客户端] 收到回应: '飞船呼叫地面站,收到请回答!'

预期输出(服务端):

[服务器] 绑定 0.0.0.0:8888
[服务器] 正在监听,等待连接...
[服务器] 接受连接: ('127.0.0.1', 54321)
[服务器] 收到 20 字节: b'\xe9\xa3\x9e\xe8\x88\xb9\xe5\x91\xbc...'
[服务器] 回显 20 字节
[服务器] ('127.0.0.1', 54321) 断开连接

TCP API 流程图(记这张图)

[服务端]                              [客户端]
─────────                            ─────────
socket(AF_INET, SOCK_STREAM)         socket(AF_INET, SOCK_STREAM)
    │                                     │
bind(("0.0.0.0", port))                   │
    │                                     │
listen(backlog)                            │
    │                                     │
accept()  ←──────── 三次握手 ──────→  connect()
    │                                     │
    ├─ recv() ←─────────────── send() ──┤
    │                                     │
    ├─ send() ───────────────→ recv() ──┤
    │                                     │
    │     ... 更多数据交换 ...              │
    │                                     │
close()                                close()

注意:服务器端的 accept() 返回的 client_sock一个新 socket。原来的 server_sock 继续监听,新来的连接走新 socket。这是 TCP 服务器的标准模式——一个听门,多个接客

TCP 的"可靠"到底可靠在哪?

你写完了上面的例子,发现:

python
data = client_sock.recv(1024)
print(f"收到 {len(data)} 字节")  # 可能不到 1024 字节!

TCP 是字节流,不是消息流。

send("hello") 可能被内核拆成两个 TCP 段发送,recv(1024) 可能只收到 "hel",第二次 recv 才收到 "lo"。你必须自己处理消息边界——这就是为什么 HTTP 有 Content-Length 头。

python
# 正确做法:循环 recv 直到收够数据
def recv_exact(sock: socket.socket, n: int) -> bytes:
    """接收恰好 n 字节"""
    buf = []
    total = 0
    while total < n:
        chunk = sock.recv(n - total)
        if not chunk:
            raise ConnectionError("连接意外关闭")
        buf.append(chunk)
        total += len(chunk)
    return b''.join(buf)

关键区分:TCP 保证的是字节不会丢失、不会重复、按序到达。但它不保证你的消息边界。


UDP Socket:极简版

UDP 比 TCP 简单得多——没有连接,没有握手,没有重传,发出去就不管了。

python
import socket

def udp_echo_server(host='0.0.0.0', port=9999):
    """UDP 回显服务器"""
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sock.bind((host, port))
    print(f"[UDP 服务器] 监听 {host}:{port}")

    while True:
        # recvfrom 返回 (数据, (客户端地址))
        data, addr = sock.recvfrom(1024)
        print(f"[UDP 服务器] 收到来自 {addr}: {data!r}")
        sock.sendto(data, addr)  # 直接发回去,不需要连接

def udp_client(message="Hello UDP!", host='127.0.0.1', port=9999):
    """UDP 客户端"""
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sock.sendto(message.encode(), (host, port))
    data, addr = sock.recvfrom(1024)
    print(f"[UDP 客户端] 收到回应: {data!r} 来自 {addr}")
    sock.close()

UDP vs TCP 关键差异

特性TCPUDP
连接状态有(需三次握手)无(发就发)
可靠性可靠:重传、ACK、序号不可靠:发了不管
顺序保证按序到达可能乱序
消息边界字节流保留消息边界
适用场景网页、文件传输、邮件视频流、DNS、游戏状态同步
recv 调用方式recv(buf_size)recvfrom(buf_size)
send 调用方式send(data)sendto(data, addr)

差异窗口:Java Socket 编程

Java 的 Socket API 风格不同——更显式地要求 try-catch 和资源管理:

java
// TCP 服务器
import java.net.*;
import java.io.*;

class EchoServer {
    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket(8888);
        System.out.println("服务器启动,端口: 8888");

        while (true) {
            Socket clientSocket = serverSocket.accept();
            System.out.println("接受连接: " + clientSocket.getInetAddress());
            
            // 每个连接在单独的线程里处理
            new Thread(() -> {
                try (
                    BufferedReader in = new BufferedReader(
                        new InputStreamReader(clientSocket.getInputStream()));
                    PrintWriter out = new PrintWriter(
                        clientSocket.getOutputStream(), true)
                ) {
                    String inputLine;
                    while ((inputLine = in.readLine()) != null) {
                        out.println(inputLine);  // echo
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }).start();
        }
    }
}

差异关键:Java 用 BufferedReader.readLine() 帮你处理了消息边界(按行分隔),代价是你必须保证数据以 \n 结尾。Python 则把字节流直接扔给你,让你自己决定边界。


再战:Blocking 陷阱——你的程序在浪费 CPU 吗?

看这段代码:

python
while True:
    data = sock.recv(1024)  # 阻塞!

sock.recv(1024) 被调用时,如果没有数据到达,你的进程会被内核挂起,不再占用 CPU。这是好的——比忙等待好一万倍。

但问题是:如果你同时等两个 socket 呢?

python
# 错误的做法:逐一阻塞
data1 = sock1.recv(1024)    # 等 sock1 —— 如果 sock1 一直没数据
data2 = sock2.recv(1024)    # 永远等不到 sock2!

你需要在两个 socket 之间切换。解决方案有三种,从简单到高效:

方案复杂度适用连接数
多线程/多进程~100
select/poll~1000
epoll (Linux) / kqueue (macOS)~100000+

select——同时等一堆城门

python
import select

def echo_server_select(host='0.0.0.0', port=8888):
    """用 select 处理多连接的 TCP 服务器"""
    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    server.bind((host, port))
    server.listen(5)
    server.setblocking(False)  # 非阻塞模式

    inputs = [server]  # 要监视的可读 socket 列表
    outputs = []       # 要监视的可写 socket 列表

    print(f"[select 服务器] 监听 {host}:{port}")

    while inputs:
        # select(可读列表, 可写列表, 异常列表, 超时)
        readable, writable, exceptional = select.select(inputs, outputs, inputs, 1.0)

        for s in readable:
            if s is server:
                # 新连接
                client, addr = s.accept()
                client.setblocking(False)
                inputs.append(client)
                print(f"[select] 新连接: {addr}")
            else:
                data = s.recv(1024)
                if data:
                    outputs.append(s)  # 标记可写
                else:
                    inputs.remove(s)
                    s.close()

        for s in writable:
            s.send(b"echo: " + b"got it")  # 简单回应
            outputs.remove(s)

这个模式的关键:你不是在等数据,你是在问内核"哪个城门口有信了",然后只去有信的门

epoll——城门口的路灯系统

当连接数上万时,select 性能急剧下降(每次调用都要把整个 fd 集合从用户态拷到内核态)。Linux 上的 epoll 解决了这个问题:

python
import selectors  # Python 3.4+ 封装了 select/epoll/kqueue
import socket

def echo_server_epoll(host='0.0.0.0', port=8888):
    """用 epoll(通过 selectors 模块)处理多连接"""
    sel = selectors.DefaultSelector()  # 自动选最佳 IO 多路复用
    
    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    server.bind((host, port))
    server.listen(5)
    server.setblocking(False)
    
    # 注册服务器 socket,关注"可读"事件
    sel.register(server, selectors.EVENT_READ, data=None)
    print(f"[epoll 服务器] 监听 {host}:{port}")

    while True:
        events = sel.select(timeout=None)  # 阻塞,等待事件
        
        for key, mask in events:
            if key.data is None:
                # 新连接
                client, addr = server.accept()
                client.setblocking(False)
                # 注册客户端 socket
                sel.register(client, selectors.EVENT_READ, data=addr)
                print(f"[epoll] 新连接: {addr}")
            else:
                # 有客户端数据可读
                data = key.fileobj.recv(1024)
                if data:
                    key.fileobj.send(data)  # echo
                else:
                    sel.unregister(key.fileobj)
                    key.fileobj.close()
                    print(f"[epoll] 断开: {key.data}")

核心直觉

  • select = 每天早上去每个门口敲一遍:"有信吗?"
  • epoll = 在每个门口装一个信铃,有人投信时铃响,你去响铃的门拿信
  • 连接越多,epoll 的优势越大

通关挑战

挑战 1:写一个文件传输服务器(TCP)

客户端发送文件名,服务器读取文件内容并返回。必须处理以下情况:

  • 文件不存在 → 返回错误消息
  • 文件太大 → 分块传输,客户端拼接
  • 连接中断 → 服务器不崩溃
python
# 提示骨架
def file_server(host='0.0.0.0', port=8888):
    server = socket.socket(...)
    # ... 你的实现 ...
    # 使用 struct 打包文件大小:struct.pack('!Q', file_size)
    # 然后循环发送大文件块

挑战 2:UDP 聊天室

写一个 UDP 服务器,接收任意客户端的消息并广播给所有其他客户端。

bash
# 运行示例
$ python chat_server.py 8888
[广播] 用户 A: 有人吗?
[广播] 用户 B: 有!
[广播] 用户 C: 来啦!

挑战 3:用 select 写一个超时接收

recv 默认无限阻塞。改造客户端,使得如果 5 秒内没收到服务器回复,就打印"服务器无响应"并重试一次。


验收标准

阅读本章后,你能回答以下问题:

  • [ ] 什么是 socket?它和文件描述符有什么关系?
  • [ ] TCP 客户端/服务端的 API 调用顺序分别是什么?
  • [ ] TCP 和 UDP 在 API 层面的三个关键差异?
  • [ ] recv(1024) 返回的字节数总是 1024 吗?为什么?
  • [ ] 阻塞 I/O 对多连接有什么问题?select 和 epoll 怎么解决的?

常见卡点

卡点澄清
"bind 失败说 Address already in use"SO_REUSEADDR 选项,或者等 2MSL 时间(约 2分钟)
"connect 超时"对方没在 listen,或者防火墙挡了包
"recv 返回 0 字节"对方已经关闭连接(FIN 包已收到),你应该也 close
"客户端 send 了数据但服务器没收到"检查是否忘记 flush(Java BufferedWriter)或没有循环 recv(TCP 粘包)
"UDP 收不到数据"UDP 不保证送达;检查防火墙是否允许 UDP 端口
"select 在高并发下慢"select 有 1024 个 fd 上限(C 级宏定义),Linux 内核维护一个位图。换 epoll
"端口 0 是什么意思?"bind(("0.0.0.0", 0)) —— 内核自动分配一个空闲端口,常用于客户端临时端口

现在不需要理解

  • TCP 拥塞控制(Cubic / BBR) → 调优篇
  • Reactor vs Proactor 模式 → 高并发服务器设计篇
  • Unix Domain Socket vs INET Socket → 本地 IPC 篇
  • SSL/TLS 握手如何在 TCP 之上加密 → 安全篇
  • WebSocket 协议 → 应用层协议设计篇
  • SO_LINGER 和 TCP 半关闭状态 → 生产环境坑点篇

** 本章结构**

第一部分 必读:阻塞 TCP Socket 入门(主线)

  • 理解 Socket 是什么、TCP 客户端/服务端 API 调用顺序
  • 实现 echo server/client,走通完整的 TCP 生命周期
  • 区分 TCP vs UDP 的 API 差异
  • 认识 TCP 字节流和消息边界的陷阱

第二部分 进阶了解:I/O 多路复用(继续阅读,但不是主线必考)

  • select / epoll 的使用模式
  • 阻塞 I/O 在多连接场景的问题

本章主线只要求你用 阻塞 TCP socket 实现 echo server/client。select 和 epoll 属于后续「高并发网络编程」章节的系统讲解,这里只做初步认知。


旅人笔记

我写第一个 TCP 回显服务器时,花了 40 分钟"debug",因为我把 sendsendto 搞混了,程序在 TCP socket 上调 sendto,它在抛异常之前只沉默了足足 3 分钟。三年后我在生产环境看到一模一样的问题,一瞬间想起那天下午——Socket 的沉默比报错更可怕。

后来我调试过一个千兆网络上的 Python 服务器,单线程 recv/send 性能只有 200 Mbps。换成 epoll + 非阻塞后跑到 800+ Mbps。那多出的 600 Mbps 不是什么魔法——只是一个不再空等的循环换成了一个只处理有事儿的驱动。巧合的是,这与我们第一印象相反:看起来更"复杂"的多路复用,实际跑起来却更"简单"——因为你不再让 CPU 干等着。

记住:网络编程的本质不是 API——是在哪里等、等什么、怎么不等


下一站预告

你已经能在两台机器之间建立双向管道来回传数据了。但 HTTP 请求呢?URL 怎么解析?一个 curl http://example.com 背后发生了什么?下一章我们打开浏览器,追踪一次完整的 HTTP 事务,看看 Socket 之上的人间百态。

Built with VitePress | Software Systems Atlas