Socket(套接字)

1. 定义

Socket(套接字)是操作系统提供的一套网络通信编程接口,是两台机器上的两个程序之间通信的端点 (endpoint)。你想让程序收发网络数据,不用自己去操心 TCP/IP 协议的一堆细节,只要通过 socket 这组 API(socket / bind / listen / accept / connect / send / recv / close)就能完成。

一句话概括:Socket 是应用程序和网络协议栈之间的一扇门——程序把数据丢进门,操作系统负责按 TCP/IP 送出去;对方从门里把数据取出来。

类比:Socket 像电话插座 + 电话机。IP 地址是电话号码(找到哪台机器),端口是分机号(找到机器里的哪个程序),建立连接就是拨通电话,之后双方就能对着话筒收发声音(数据)。挂电话就是 close

“Socket” 原意就是”插座/插口”——把两个程序”插”在一起通信。

2. Socket 在哪一层(它不是协议,是接口)

很多人误以为 socket 是一种协议,其实不是。它是夹在应用程序和传输层协议之间的一层抽象

┌─────────────────────┐
│  你的程序(应用层)    │  ← Nginx / 浏览器 / 你写的 server.py
├─────────────────────┤
│   Socket API         │  ← 操作系统提供的"门":socket()/bind()/send()...
├─────────────────────┤
│  传输层 TCP / UDP     │  ← 真正的协议,负责可靠传输/分包
├─────────────────────┤
│  网络层 IP            │  ← 负责寻址、路由到目标机器
├─────────────────────┤
│  链路层 / 网卡         │  ← 电信号/网线/WiFi
└─────────────────────┘

关键认知:socket 是”接口”不是”协议”。 同一套 socket API,既能创建走 TCP 的 socket,也能创建走 UDP 的 socket——只是创建时参数不同(见第 8 节)。它屏蔽了底层协议差异,让你用统一的方式编程。

3. 一个 Socket 是怎么被唯一确定的

一条 TCP 连接由四元组 (4-tuple) 唯一标识:

(源 IP, 源端口, 目标 IP, 目标端口)
要素作用
源 IP + 源端口标识连接的本机这一端
目标 IP + 目标端口标识连接的对方那一端

由此推出一个常被问的问题:一个服务器的 80 端口,怎么同时和成千上万个客户端通信? 因为区分连接靠的是整个四元组,不是只看服务器端口。服务器端口都是 80,但每个客户端的 (IP, 端口) 不同,所以四元组各不相同,操作系统能把它们区分成一条条独立连接。

补充概念:服务器上负责”监听”的那个 socket(监听 socket)和每次 accept 出来负责”和某个具体客户端通信”的 socket(连接 socket)是不同的 socket。一个监听 socket 能孵化出无数个连接 socket。

4. TCP Socket 的生命周期(服务端 vs 客户端)

这是 socket 编程的骨架,也解释了 server.md 第 5 节那个”监听循环”底下每一步到底调了什么:

     服务端 (Server)                        客户端 (Client)
  ┌────────────────────┐
  │ socket()  创建套接字 │                 ┌────────────────────┐
  │ bind()    绑定IP:端口│                 │ socket()  创建套接字 │
  │ listen()  开始监听   │                 └─────────┬──────────┘
  └─────────┬──────────┘                            │
            │ 阻塞等待                     connect() │ 发起连接
  accept() ◀├───────────── 三次握手 ───────────────┤
            │ 返回一个"连接socket"                    │
            ▼                                        ▼
  ┌────────────────────┐  ◀── 数据 ──▶   ┌────────────────────┐
  │ recv() / send()     │                │ send() / recv()     │
  │ 收发数据(可循环多次) │                │ 收发数据             │
  └─────────┬──────────┘                └─────────┬──────────┘
            │ close()      ◀── 四次挥手 ──▶        │ close()
            ▼                                       ▼
          关闭连接                                关闭连接
系统调用谁用干什么
socket()双方创建一个套接字,指定用 TCP 还是 UDP
bind()服务端把 socket 绑到某个 IP:端口(“我在这个门牌等”)
listen()服务端把 socket 转为监听状态,设置等待队列长度
accept()服务端阻塞等连接进来,来一个就返回一个新的”连接 socket”
connect()客户端主动连服务端的 IP:端口,触发三次握手
send()/recv()双方发送 / 接收数据
close()双方关闭连接,触发四次挥手

客户端一般不用 bind:它的源端口由操作系统随机分配一个空闲端口(临时端口),所以你写客户端时只管 connect 就行。

5. 上手代码:一个能跑的 Echo 服务器(Python)

Python 的 socket 模块几乎是对系统调用的直接封装,最适合看清 socket 本质。下面这个”回声服务器”把收到的内容原样发回:

服务端 echo_server.py

import socket
 
# 1. 创建 socket:AF_INET=IPv4,SOCK_STREAM=TCP
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
 
# 关键:允许端口复用,避免重启时报 "Address already in use"(见第 10 节)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
 
# 2. bind:绑定到本机 8888 端口。'' 表示监听所有网卡(0.0.0.0)
server.bind(('', 8888))
 
# 3. listen:开始监听,参数是等待队列(backlog)长度
server.listen(5)
print('服务器在 8888 端口监听中...')
 
while True:                          # 监听循环
    # 4. accept:阻塞,直到有客户端连进来
    conn, addr = server.accept()     # conn 是"连接 socket",addr 是客户端地址
    print(f'客户端接入: {addr}')
    with conn:
        while True:
            # 5. recv:收数据,参数是一次最多收多少字节
            data = conn.recv(1024)
            if not data:             # 收到空 = 对方关闭了连接
                break
            print(f'收到: {data.decode()}')
            conn.sendall(data)       # 6. 原样发回(echo)
    print(f'客户端断开: {addr}')

客户端 echo_client.py

import socket
 
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.connect(('127.0.0.1', 8888))   # 连接服务器,触发三次握手
client.sendall('你好 socket'.encode())  # 发数据
reply = client.recv(1024)               # 收回显
print(f'服务器回复: {reply.decode()}')
client.close()                          # 关闭,触发四次挥手
python echo_server.py     # 一个终端起服务端
python echo_client.py     # 另一个终端跑客户端
# 也可以用 nc 当客户端测试:
nc 127.0.0.1 8888

对照第 4 节的图,代码里的 socket/bind/listen/accept/recv/send/close 一一对应。你平时用的 Flask、Express 只是把这套东西藏在框架里了。

6. 建连接与断连接:三次握手 & 四次挥手

connect()close() 背后是 TCP 的握手/挥手,socket 帮你自动完成:

三次握手(建立连接,connect 时发生):

客户端 ──── SYN ────▶ 服务端      "我想连你,序号x"
客户端 ◀── SYN+ACK ── 服务端      "行,我序号y,确认你的x"
客户端 ──── ACK ────▶ 服务端      "确认你的y,开始吧"
             连接建立 ✓

为什么要三次?为了双方都确认”我能发、我能收、你能发、你能收”都正常,避免用一条实际已失效的旧连接。

四次挥手(关闭连接,close 时发生):

主动方 ──── FIN ────▶ 被动方      "我没数据要发了"
主动方 ◀─── ACK ───── 被动方      "知道了"
主动方 ◀─── FIN ───── 被动方      "我也发完了"
主动方 ──── ACK ────▶ 被动方      "好,拜拜"

为什么四次?因为关闭是双向的,一方说完不代表另一方也说完,两个方向要各关一次,所以中间的 ACK 和 FIN 不能像握手那样合并。

主动关闭的一方最后会进入 TIME_WAIT 状态等一会儿(约 2 倍报文最大存活时间)才真正释放,确保最后的 ACK 对方收到、且旧连接的残留报文消散。这解释了第 10 节”服务器刚重启端口还被占用”的坑。

7. 阻塞 / 非阻塞 / IO 多路复用(连接并发的关键)

第 5 节的 accept()recv() 默认是阻塞的——没连接/没数据就卡住等。这就带来 server.md 第 7 节讲的并发问题。socket 层面有几种应对:

模式做法特点
阻塞 (blocking)调用后卡住直到有结果简单,但一个线程同时只能盯一个 socket
非阻塞 (non-blocking)没数据立刻返回错误码,程序轮询不卡住,但空转轮询浪费 CPU
IO 多路复用select/poll/epoll 一次性盯一堆 socket,谁就绪通知谁一个线程高效管上万连接,高并发首选
# Python 用 selectors 封装了 epoll/kqueue,一个线程管多个连接
import selectors, socket
sel = selectors.DefaultSelector()
 
server = socket.socket()
server.bind(('', 8888)); server.listen(); server.setblocking(False)
sel.register(server, selectors.EVENT_READ)   # 把监听socket交给selector盯着
 
while True:
    events = sel.select()          # 阻塞直到"任意一个"注册的socket就绪
    for key, _ in events:
        sock = key.fileobj
        # 谁就绪就处理谁(新连接 or 有数据可读)...

演进逻辑:selectpollepoll(Linux) / kqueue(macOS/BSD)。epoll 是 Nginx、Redis、Node.js 能用单线程/少量线程扛百万连接的底层秘密——它避免了每次都遍历所有连接,只回调”有变化”的那些。详见 server.md 第 7 节并发模型。

8. TCP Socket vs UDP Socket

创建 socket 时的第二个参数决定走哪种协议:

TCP (SOCK_STREAM)UDP (SOCK_DGRAM)
连接面向连接(要握手)无连接(直接发)
可靠性可靠:保证到达、有序、不重复不保证:可能丢、可能乱序
数据形态字节流(无消息边界,会”粘包”)数据报(一发一收,有边界)
速度/开销较慢,开销大快,开销小
典型场景HTTP、数据库、文件传输——不能错的直播、游戏、DNS、语音——快比全更重要
关键 APIlisten/accept/connect没有连接,直接 sendto/recvfrom
# UDP 服务端:没有 listen/accept,收到就地回
udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
udp.bind(('', 9999))
while True:
    data, addr = udp.recvfrom(1024)   # 收数据 + 对方地址
    udp.sendto(data, addr)            # 按地址发回

一句话:TCP 像打电话(先拨通、保证听清、按顺序);UDP 像寄明信片(写上地址直接扔进邮筒,不保证送到、不保证顺序,但快)。

9. Socket 的几种类型

类型说明用途
流式 (SOCK_STREAM)基于 TCP,字节流绝大多数网络服务
数据报 (SOCK_DGRAM)基于 UDP,数据报实时性优先的场景
Unix Domain Socket同一台机器内进程间通信,走文件系统路径而非 IP:端口本机进程通信,比走网络快(如 Nginx 连本机 PHP-FPM、Docker 的 /var/run/docker.sock
原始 socket (RAW)绕过 TCP/UDP 直接收发 IP 包抓包、ping、自定义协议(需管理员权限)

Unix Domain Socket 值得记一笔:当两个程序在同一台机器上,用它比走 TCP 回环(127.0.0.1)更快,因为省掉了协议栈的封装/解封。你在 docker.md 里见过的 docker.sock 就是它。

10. 常见坑 / 误区

  • ❌ “socket 是一种协议” → 错,它是接口/抽象,底层协议是 TCP 或 UDP(见第 2 节)
  • ❌ “一个端口只能服务一个客户端” → 错,连接靠四元组区分,一个监听端口能同时维持海量连接(见第 3 节)
  • Address already in use(重启服务器最常见) → 上次的连接还在 TIME_WAIT,端口没完全释放。开发时加 SO_REUSEADDR(见第 5 节代码)即可复用
  • TCP “粘包/拆包” → TCP 是字节流没有消息边界,你 send 两次对方可能一次 recv 全收到、或分几次收到。要自己定协议界定消息边界(如加长度头、用分隔符),别以为一次 recv 就是一条完整消息
  • ❌ “recv 到多少就是对方发了多少” → 不一定,recv(1024) 只是”最多收 1024”,实际可能更少,要循环收直到够一条完整消息
  • ❌ “recv 返回空字符串是出错” → 不是,TCP 里 recv 返回空 (b'') 表示对方正常关闭了连接,这是判断断开的标志
  • ❌ “客户端也要 bind” → 一般不用,源端口由系统自动分配(见第 4 节)
  • ❌ “阻塞 socket 也能扛高并发” → 单线程阻塞模型一次只能处理一个连接,高并发要用多线程/IO 多路复用/异步(见第 7 节)
  • ❌ “close 了数据一定发完了” → 想确保发完再关,注意缓冲区和 SO_LINGER;粗暴 close 可能丢未发数据

11. 延伸阅读 / 关联概念

  • 服务器原理 — socket 是服务器”监听循环”的底层实现,务必对照着看;见 server.md
  • DNS 解析绑定connect 前要先把域名解析成 IP,socket 才知道连哪;见 dns-binding.md
  • ping / traceroute — ping 用的就是原始 socket(ICMP);见 ping.md
  • TCP / UDP — socket 底下的两种传输层协议,决定可靠性与形态;见 tcp-udp.md
  • HTTP / HTTPS — 最典型的 TCP 之上的应用层协议;见 http.md
  • epoll / kqueue / select — IO 多路复用的系统调用,高并发服务器的基石
  • WebSocket — 建立在 HTTP 之上的全双工长连接,名字带 socket 但属于应用层协议,别和本文的系统 socket 混淆
  • Unix Domain Socket — 本机进程间通信,比 TCP 回环更快