02.Socket编程
前置知识:具备基础的网络知识
2.1 我们要学什么:从黑箱到代码
人们通常将计算机网络简化为由方框和线条构成的示意图,却忽略了其背后真正的编码实现。然而,网络编程并非易事。假设现在有一个只提供两个方法的API:一个用于发送数据,一个用于接收数据。那么,除此之外,你还需要了解些什么呢?
TCP字节流和协议
人们往往相当人地认为,计算机网络及时各个节点之间互相交换“消息”。但实际上,最常见的TCP协议本身并不产生消息。它产生的是一个连续的、没有任何内部分界的字节流。如何解读这个字节流,正是应用协议的任务。它定义了一套规则,用于理解字节流的含义,其中就包括了如何将其拆分为一条条独立的消息。
将字节流拆分为消息,其难度远超你的想象,尤其是在事件循环(event loop)的环境下。这和解析普通的文件格式完全是两码事。
数据序列化
我们希望通过网络发送的“消息”,通常是字符串、结构体、列表等高级对象。然而,计算机网络的世界里只有0和1。因此,我们必须在这些对象和字节之间建立一种映射关系。这个过程就被称为序列化(将对象转换为字节)和反序列化(将字节恢复为对象)。
尽管你可以借住JSON或Protobuf这类库不费吹灰之力地完成这项工作,但通过亲手在比特与字节层面上进行操作实现自己的序列化方案,是迈向底层编程的一个绝佳起点。
并发编程
一旦有了清溪的协议规范,构建客户端应用程序通常是比较直接的。但服务器端的实现则要复杂得多,因为他需要同时处理大量的连接。如何应对海量的并发连接(即便其中大部分是空闲的),一直以来都是一个总所周知的难题(即经典的
现代软件工程给出的答案是采用事件循环(event loops) 机制的 事件驱动并发(event-based concurrency) 模型这项技术正是Nginx,Redis,Golang等运行时等众多现代高可扩展性软件背后的核心技术。对于你来说,事件驱动并发可能是一种全新的编程范式,而且它确实相当复杂,因此,在实践中学习是掌握它的不二法门。
2.2 程序员视角下的网络
协议层
抽象思路:网络协议是分层的。底层协议可以包含上层协议作为数据载荷(payload)来承载,而上层协议则在底层协议的基础上增添新的功能。
现实世界:以太网协议承载着IP协议,IP协议承载着UDP或TCP协议,而UDP或TCP协议则承载着各种应用协议。
或者,我们也可以按照功能划分层次。
承载独立小消息的层(IP)
当你下载一个大文件时,硬件不可能在转发数据之前将整个文件都存下来,它只能处理一个个更小的单元(IP数据包)。这就是为什么最底层是基于数据包的。而将这些零散的数据包组装成应用程序所需要的数据,则由更高层(通常是TCP)来提供。
实现多路复用的层(端口号)
在一台电脑上,多个程序可以同时共享网络。那么,计算机是如何知道哪个数据包属于哪个应用程序呢?这个过程被称为
[源IP, 源端口, 目标IP, 目标端口]
提供可靠有序字节流的层(TCP)
通常我们想要的并非短小的消息。像文件传输协议这样的场景需要传输任意大小的数据。更糟糕的是,网络是不可靠的,IP数据包可能会丢失或乱序。TCP协议在IP包的上层构建了一个有序且可靠的字节流层,它会自动处理重传和重排序等问题。
TCP/IP 模型
按照功能划分的网络协议层次:
主题 | 功能 |
---|---|
高层 TCP | 可靠且有序的字节序列 |
↕️ TCP/UDP中的端口 | 多路分解标识符(实现应用进程寻址与数据流分离) |
低层 IP | 小而离散的消息 |
这三层代表了网络中的三种核心需求,它们与TCP/IP的概念完美对应。当然也存在其他模型,比如TCP/IP模型自身:
应用层->传输层(TCP/UDP)->IP层->链路层(低于IP层)
TCP/IP模型反应了协议头的结构,它将TCP和UDP放在同一层级,但实际上TCP提供了更高级的功能,而UDP则更像是IP+端口的简单组合。
此外还有一个OSI模型,OSI模型自身的复杂度,超过了它试图描述的对象(TCP/IP)的复杂度,所以直接忽略它就好。
到底哪些与我们息息相关
一个常见的应用程序并不会直接与IP层打交道,因为多路复用是一项普遍的需求。对于IP层,我们需要关心的是源地址和目标地址。
以太网位于IP之下,它同样是基于包的,但使用一种不同类型的地址(MAC地址)。MAC地址主要由那些不关心IP的硬件(如某些网络交换机)使用。我们不需要关心这一层,在某些虚拟专用网络(VPN)中,这一层甚至可能不存在。
我们真正需要打交道的是IP层之上的协议。应用程序要么直接在TCP或UDP之上构建自己的协议,要么间接地使用某个著名协议的现成实现。我们将采取前一种方式,就像真正的Redis那样。
TCP和UPD都被IP协议所承载。IP也可以承载其它协议(如SCTP),但在2025年的今天,真正举足轻重的只有TCP和UDP。可以说,万物皆构建与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 primitives
尽管我们在Linux的环境下编码,但这些概念是跨平台且通用的。
什么是Socket?
Socket(套接字)是一个句柄(handle),用于纸袋一个网络连接或其他相关实体。网络编程被API称为Socket API,它在不同操作系统上大同小异。Socket这个名字和墙上的插座(socket)没有任何关系。
句柄是一个
socke()
方法会分配并返回一个socket句柄(fd),后续的操作将使用这个句柄来真正地创建连接。
当你使用完一个句柄后,必须将其关闭,以便操作系统释放相关联的资源。这是所有不同类型句柄的唯一共同点。
监听Socket与连接Socket
监听(listening)是指告知操作系统,某个应用程序已经准备好在指定的端口上接收TCP连接。随后操作系统就会返回一个socket句柄,应用程序用它指代该端口。通过这个监听socket,应用程序可以获取(即接受,accept)新建立的TCP连接,而这个新连接本身也由另一个socket句柄表示。因此,我们有两种类型的句柄:监听socket和连接socket。
创建一个监听socket至少需要三次API调用:
- 通过
socket()
获取一个socket句柄。 - 通过
bind()
为其设置监听的IP和端口。 - 通过
listen()
创建监听的socket。
然后,使用accept()
API来等待传入的TCP连接。伪代码如下:
fd = socket()
bind(fd, address)
listen(fd)
while True:
conn_fd = accept(fd)
do_something_with(conn_fd)
close(conn_fd)
从客户端发起连接
在客户端中,创建一个socket连接需要两次API调用:
- 通过
socket()
获取一个socket句柄 - 通过
connect()
创建连接。
伪代码如下:
fd = socket()
connect(fd, address)
do_something_with(fd)
close(fd)
socket()
创建的是一个无类型的socket,它的具体类型(监听或者是连接)是在调用listen()
或connect()
之后才最终确定的。在socket()
和listen()
之间的bind()
调用仅仅是设置了一个参数。setsockopt()
API可以用来设置其它后续会用到的socket参数。
读和写
尽管TCP和UDP提供不同类型的服务,但它们共享相同的SocketAPI,包括send()
和recv()
方法。对于给予消息的socket(UDP),每一次send/recv
调用都对应一个数据包。而对于基于字节流的socket(TCP),每一次send/recv
分别代表向字节流中追加数据或从字节流中消费数据。
在Linux中,send/recv
仅仅是更通用的read/write
系统调用的一个变体,后者可用于socket、磁盘文件、管道等。不同类型的句柄共享同一套读写API只是一个巧合。你基本不可能写出一段即适用于TCP又适用于UPD的代码,因为它们的语义是完完全全不兼容的。
小结:Socket primitives清单
- Listening TCP Socket:
bind() & listen()
accept()
close()
- Using a TCP Socket:
read()
write()
close()
- Create a TCP connection:
connect()
在下一章,我们将带你真正开始动手写代码。