1. Socket 基础:TCP/IP 协议栈回顾、Socket 是什么、Socket 类型与工作流程
各位同学,咱们今天正式开课。聊网络编程,绕不开 Socket。但很多人学了好几年,还是搞不清 Socket 到底是个什么东西——是协议?是接口?还是文件?
我个人习惯,讲任何技术之前,先把它的「地基」挖出来看看。Socket 的地基,就是 TCP/IP 协议栈。所以咱们先从协议栈说起。
1.1 TCP/IP 协议栈回顾
TCP/IP 协议栈,说白了就是一套「网络通信的分工协作规范」。它把通信过程拆成四层,每一层只管自己的事,层与层之间通过标准接口对接。
这四层分别是:
| 层级 | 名称 | 核心职责 | 常见协议/设备 |
|---|---|---|---|
| 4 | 应用层 | 为用户提供网络服务 | HTTP、FTP、SMTP、DNS |
| 3 | 传输层 | 端到端通信、流量控制、可靠性 | TCP、UDP |
| 2 | 网络层 | 路由选择、寻址、分组转发 | IP、ICMP、ARP |
| 1 | 网络接口层 | 物理传输、帧封装 | 以太网、Wi-Fi、PPP |
你想想看,当你在浏览器里输入一个网址,数据是怎么从你的电脑飞到服务器上的?
应用层先把 HTTP 请求打包好,交给传输层。传输层(TCP)给数据加上端口号,切成合适大小的段,再交给网络层。网络层(IP)给每个段加上源 IP 和目标 IP,决定走哪条路。最后网络接口层把数据变成比特流,从网线或 Wi-Fi 里发出去。
每一层只关心自己的「信封」,不关心里面的内容。这就是分层设计的好处——各司其职,互不干扰。
关键认知:Socket 位于应用层与传输层之间。它不是协议,而是操作系统提供给应用层的一个「编程接口」,让开发者不用直接操作 TCP 或 UDP 的细节。
1.2 Socket 是什么
Socket 这个词,直译是「插座」。这个比喻其实挺形象的——你把一个插头插到插座上,电流就通了。网络通信也一样,两个进程各自持有一个 Socket,建立连接后,数据就能双向流动。
从操作系统的角度看,Socket 就是一个文件描述符。对,你没听错,在 Linux 里,一切皆文件。Socket 也不例外。你可以像读写文件一样,用 read() 和 write() 来收发数据。
我记得刚入行时,有个同事死活不理解为什么 Socket 能当文件用。我给他打了个比方:你把水管接在水龙头上,打开阀门水就流出来。Socket 就是那个水龙头接口,操作系统帮你把水管另一端接到了远程机器上。你只管拧阀门(调用 read/write),水自然就来了。
Socket 的核心作用就三个:
- 标识端点:通过 IP 地址 + 端口号,唯一确定网络上的一个进程。
- 提供通信通道:数据从一端写入,从另一端读出。
- 抽象底层细节:你不用管数据是怎么经过路由器、怎么处理丢包重传的,Socket 全帮你干了。
小提示:在 Windows 上,Socket 也是类似的文件句柄。只不过 Windows 的 API 叫 WSAStartup,需要先初始化 Winsock 库。这个坑我踩过——跨平台代码里忘了调用 WSAStartup,结果在 Windows 上直接崩溃。
1.3 Socket 类型:流式与数据报
Socket 分两种主流类型,对应传输层的两个协议:TCP 和 UDP。
| 特性 | 流式 Socket (SOCK_STREAM) | 数据报 Socket (SOCK_DGRAM) |
|---|---|---|
| 底层协议 | TCP | UDP |
| 连接性 | 面向连接(必须先建立连接) | 无连接(直接发数据) |
| 可靠性 | 可靠,保证数据不丢失、不重复、按序到达 | 不可靠,可能丢包、乱序 |
| 数据边界 | 无边界(流式传输,需要自己处理粘包) | 有边界(每次 send 对应一次 recv) |
| 典型应用 | HTTP、FTP、SSH、数据库连接 | DNS、视频直播、在线游戏 |
流式 Socket(SOCK_STREAM)
这是咱们这门课的主角。流式 Socket 基于 TCP,提供可靠的、双向的、基于字节流的通信。
「流」是什么意思?就是数据像水流一样,没有明确的边界。你发 100 个字节,对端可能一次收到 50 个,下次再收 50 个;也可能一次收到 200 个(如果两次发送的数据粘在一起)。这就是后面要重点讲的「粘包拆包」问题。
数据报 Socket(SOCK_DGRAM)
数据报 Socket 基于 UDP,每次发送都是一个独立的消息,有明确的边界。你发 100 个字节,对端 recv 一次就能收到完整的 100 个字节。但问题是——可能收不到,或者收到乱序的。
我在项目中遇到过用 UDP 做实时日志传输的场景。当时图它快,结果线上环境偶尔丢日志,排查了半天才发现是 UDP 丢包了。后来加了应用层的确认重传机制才解决。所以,选 UDP 之前,一定要想清楚:你能不能接受丢数据?
避坑指南:我曾经在面试时被问到「UDP 是不是一定比 TCP 快?」答案是:不一定。TCP 有拥塞控制,在网络状况好的时候,TCP 的吞吐量可能比 UDP 还高。而且 UDP 如果丢包严重,应用层重传的开销可能比 TCP 还大。别被「UDP 快」这个说法忽悠了。
1.4 Socket 工作流程
Socket 的工作流程,分客户端和服务端两个视角。咱们先看一张整体流程图:
这张图把流程画得很清楚了。咱们拆开细说。
服务端流程
- socket():创建一个套接字,指定协议族(AF_INET)、类型(SOCK_STREAM)和协议(0)。
- bind():把 Socket 绑定到一个具体的 IP 地址和端口上。不绑定的话,操作系统会随机分配一个端口。
- listen():告诉内核,这个 Socket 开始监听连接请求。参数是 backlog,表示最大等待队列长度。
- accept():阻塞等待客户端连接。有连接进来时,返回一个新的 Socket 用于通信。
- recv() / send():在新 Socket 上收发数据。
- close():关闭连接,释放资源。
客户端流程
- socket():同样创建套接字。
- connect():向服务端发起连接请求。这里会触发 TCP 三次握手。
- send() / recv():连接建立后,直接收发数据。
- close():关闭连接。
注意:客户端通常不需要 bind(),因为客户端不需要固定的端口。操作系统会自动分配一个临时端口。但如果你需要客户端使用特定端口(比如防火墙只允许某些端口出站),也可以手动 bind()。
1.5 一个最小化的代码示例
光说不练假把式。咱们写一个最简单的 TCP 回显服务器,客户端发什么,服务端就原样返回什么。
服务端代码(server.py):
import socket
# 1. 创建 Socket
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 2. 绑定地址和端口
server_socket.bind(('0.0.0.0', 8888))
# 3. 开始监听
server_socket.listen(5)
print('服务端已启动,等待连接...')
# 4. 接受连接
client_socket, client_addr = server_socket.accept()
print(f'客户端 {client_addr} 已连接')
# 5. 收发数据
data = client_socket.recv(1024)
print(f'收到: {data.decode()}')
client_socket.send(data) # 原样返回
# 6. 关闭
client_socket.close()
server_socket.close()
客户端代码(client.py):
import socket
# 1. 创建 Socket
client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 2. 连接服务端
client_socket.connect(('127.0.0.1', 8888))
# 3. 发送数据
client_socket.send(b'Hello, Socket!')
# 4. 接收响应
data = client_socket.recv(1024)
print(f'收到响应: {data.decode()}')
# 5. 关闭
client_socket.close()
这段代码虽然简单,但包含了 Socket 通信的全部核心步骤。你先启动 server.py,再启动 client.py,就能看到效果。
个人经验:我刚开始写 Socket 程序时,经常忘记调用 bind(),或者 bind() 的端口被占用了也不检查。后来我养成了一个习惯——每次 bind() 之后都检查返回值,并且用 SO_REUSEADDR 选项来避免「Address already in use」错误。这个选项在开发调试时特别有用。
1.6 本章小结
咱们这一章把 Socket 的底子打好了。TCP/IP 协议栈的四层结构、Socket 的本质(文件描述符 + 编程接口)、两种 Socket 类型的区别、以及完整的通信流程,都过了一遍。