先修要求: 具备基础的计算机网络知识。
2.1 学什么:从“黑盒”到代码
计算机网络常被简化成“盒子+连线”的示意,但真正的编码细节往往被忽略。然而,网络编程并不简单。假设 API 只给了两个方法:发送数据与接收数据——除此之外你还需要懂什么?
TCP 字节流与协议
人们常把计算机网络想象成对等方在交换“消息”。但最常见的 TCP 协议并不产生离散的消息;它只提供连续的无边界字节流。如何理解这串字节,取决于应用层协议——一套把字节流“解释”成消息的规则,包括如何切分消息。
把字节流切分成消息比想象中更棘手,尤其在事件循环里。这跟解析某些文件格式并不一样。
数据序列化
你想通过网络发送的“消息”可能是字符串、结构体、列表等高层对象。但计算机网络只认识 0 和 1。因此需要在对象与字节之间建立映射:这就是序列化(对象→字节)与反序列化(字节→对象)。
虽然可以直接用 JSON 或 Protobuf 之类的库,但亲手用位与字节来实现一版,是迈向底层编程的好起点。
并发编程
有了协议规范,实现客户端相对容易;服务器端则更复杂,因为它要同时处理多条连接。即便大多数连接闲置,如何处理大量并发连接(历史上的 C10K 问题)也一直是难点。尽管在现代硬件上 1 万并发并不算大,要吃满硬件性能仍需要高效的软件。
现代的解决方案是基于事件的并发与事件循环,这驱动了 NGINX、Redis、Go 运行时等可扩展系统。事件驱动并发可能是一种对你而言全新的编程范式,而且确实复杂——最好的学习方式就是动手实践。
2.2 程序员视角下的网络
协议的分层
- 抽象概念: 网络协议被划分为多层。低一层把高一层当作“载荷”,高一层在此基础上提供新的功能。
- 现实例子: 以太网(Ethernet)里装的是 IP,IP 里装的是 UDP 或 TCP,UDP/TCP 里装的是应用层协议。
也可以按功能来划分这些层:
小而离散的消息层(IP)
下载大文件时,硬件不可能整块缓存后再转发,只能处理更小的单位(IP 分组/数据包)。因此最底层是基于分组的。把分组重新组装回应用数据的能力由更高一层(通常是 TCP)提供。
复用层(端口号)
一台电脑上的多个应用可以共享同一张网络。计算机如何知道某个分组属于哪个应用?这叫解复用。位于 IP 之上的 UDP/TCP 增加了一个 16 位端口号来区分不同应用。每个应用在收发数据前都必须占用一个未使用的本地端口。计算机用下面这个四元组识别一条“流”(flow):
(src_ip, src_port, dst_ip, dst_port)
可靠且有序的字节层(TCP)
小消息通常不是我们想要的;例如文件传输需要任意大的数据量。更糟的是,网络本身不可靠:IP 分组可能丢失、乱序。TCP 在 IP 分组之上提供可靠且有序的字节流,自动处理重传与乱序。
TCP/IP 功能模型
按功能分层可概括如下:
| 层次 | 协议/概念 | 功能 |
|---|---|---|
| 更高 | TCP | 可靠且有序的字节流 |
| ↕ | TCP/UDP 的端口 | 复用到各个应用程序 |
| 更低 | IP | 小而离散的消息(分组) |
这 3 层对应网络的 3 种核心需求,并能很好地映射到 TCP/IP 体系。还有一种更常见的表述方式:
应用层 -> 传输层(TCP/UDP) -> IP 层 -> 链路层(在 IP 之下)
这个 TCP/IP 模型更多反映协议头部的结构,因此把 TCP 与 UDP 放在同一层级。但从功能看,TCP 的能力更高,而 UDP 基本可视为“IP + 端口”。
另外还有 OSI 模型,但它比它要刻画的现实(TCP/IP)更复杂,在实践中可忽略。
与我们真正相关的是什么
- 典型应用不会直接与 IP 层交互,因为复用是普适需求;我们只关心 IP 层提供的源/目的地址。
- 以太网在 IP 之下,同样是分组化,但使用的是另一种地址(MAC)。MAC 地址由不关心 IP 的硬件使用(如部分交换机)。对我们来说这一层并不重要——在 VPN 中它甚至可能不存在。
- IP 之上的层才是我们关心的。 应用要么直接用 TCP/UDP 自定义协议,要么使用某个知名协议的实现。我们会选择前者,就像“真实的 Redis”。
- TCP 与 UDP 都封装在 IP 里。IP 也能封装其他协议(如 SCTP),但截至 2025 年,只有 TCP 与 UDP 是主角;万物皆构建在它们之上。
结论: IP、端口、TCP/UDP 是我们要打交道的核心概念。
请求-响应协议
Redis、HTTP/1.1 以及多数 RPC 协议都是请求-响应式:每个请求消息都与一个响应消息配对。如果消息既不可靠又无序,响应就很难与对应的请求匹配。因此大多数请求-响应协议都基于 TCP(DNS 是个例外)。
分组 vs. 字节流
TCP 提供的是字节流,而典型应用期望的是消息;很少有应用不解释字节流就直接使用。因此我们要么在 TCP 之上再加一层“消息层”,要么在 UDP 之上补齐“可靠+有序”。前者容易得多,所以多数应用选择 TCP:要么在 TCP 上使用知名协议,要么自定义应用协议。
需要注意,TCP 与 UDP 不仅功能不同,语义也不兼容。在设计任何联网应用时,首先要决定用 TCP 还是用 UDP。
2.3 套接字(Socket)原语
尽管我们在 Linux 上编码,这些概念本质上是与平台无关的。
什么是 socket?
Socket 是一个用于引用“某个连接或其他对象”的句柄(handle) 。网络编程的 API 被称为 socket API,在不同操作系统上都大同小异。“socket”这个名字与墙上的电源插座并无关系。
句柄 是跨越 API 边界引用对象所用的不透明整数,就像 Twitter handle 用来指向某个用户一样。在 Linux 上,句柄称为 文件描述符(file descriptor, fd) ,其作用域仅限于进程本地。“文件描述符”只是个名字:它既不一定与“文件”有关,也不“描述”什么。
socket() 调用会分配并返回一个 socket fd(句柄) ,之后用它来实际创建连接。
当你用完一个句柄时,必须关闭它,以释放操作系统侧分配的资源。这是各种句柄之间唯一的共同点。
监听套接字 与 连接套接字
监听(listening) 指的是告诉操作系统:某个应用已准备好在给定端口上接受 TCP 连接。操作系统会返回一个套接字句柄,供应用引用该端口。应用随后可以从这个监听套接字上获取(accept)传入的 TCP 连接——每个被接受的连接也用一个套接字句柄来表示。于是我们有两类句柄:监听套接字与连接套接字。
创建一个监听套接字至少需要 3 个 API 调用:
- 通过
socket()获取套接字句柄 - 用
bind()设定监听的 IP:port - 用
listen()让其进入监听状态
然后使用 accept() 等待传入的 TCP 连接。伪代码:
fd = socket();
bind(fd, address);
listen(fd);
while (true) {
conn_fd = accept(fd);
do_something_with(conn_fd);
close(conn_fd);
}
客户端发起连接
连接套接字在客户端侧通过 2 个 API 调用创建:
- 通过
socket()获取套接字句柄 - 通过
connect()建立连接
伪代码:
fd = socket();
connect(fd, address);
do_something_with(fd);
close(fd);
socket() 创建的是无类型的套接字;其具体类型(监听或连接)在调用 listen() 或 connect() 之后才确定。socket() 与 listen() 之间的 bind() 只是设置参数。setsockopt() 可设置其他将被后续使用的套接字参数。
读与写
尽管 TCP 与 UDP 提供的服务不同,它们共享同一套 socket API,包括 send() 与 recv() 方法。
- 对于基于消息的套接字(UDP),一次
send/recv就对应一个数据包。 - 对于基于字节流的套接字(TCP),
send/recv是向字节流追加/从字节流消费数据。
在 Linux 中,send/recv 只是更通用的 read/write 系统调用的变体(也用于套接字、磁盘文件、管道等)。不同类型的句柄共用 read/write API 只是历史巧合;由于 TCP 与 UDP 语义不兼容,几乎不可能写出一段对两者都同样适用的代码。
小结:socket 原语清单
监听型 TCP 套接字:
bind()&listen()accept()close()
使用 TCP 套接字:
read()write()close()
创建 TCP 连接:
connect()
下一章将用真实代码带你上手。