Skip to content

02.Socket编程

说明

原文链接:https://build-your-own.org/redis/02_intro_sockets

原文作者:James Smith

译者:Cheng

前置知识:具备基础的网络知识

2.1 我们要学什么:从黑箱到代码

人们通常将计算机网络简化为由方框和线条构成的示意图,却忽略了其背后真正的编码实现。然而,网络编程并非易事。假设现在有一个只提供两个方法的API:一个用于发送数据,一个用于接收数据。那么,除此之外,你还需要了解些什么呢?

TCP字节流和协议

人们往往相当人地认为,计算机网络及时各个节点之间互相交换“消息”。但实际上,最常见的TCP协议本身并不产生消息。它产生的是一个连续的、没有任何内部分界的字节流。如何解读这个字节流,正是应用协议的任务。它定义了一套规则,用于理解字节流的含义,其中就包括了如何将其拆分为一条条独立的消息。

将字节流拆分为消息,其难度远超你的想象,尤其是在事件循环(event loop)的环境下。这和解析普通的文件格式完全是两码事。

数据序列化

我们希望通过网络发送的“消息”,通常是字符串、结构体、列表等高级对象。然而,计算机网络的世界里只有0和1。因此,我们必须在这些对象和字节之间建立一种映射关系。这个过程就被称为序列化(将对象转换为字节)和反序列化(将字节恢复为对象)。

尽管你可以借住JSON或Protobuf这类库不费吹灰之力地完成这项工作,但通过亲手在比特与字节层面上进行操作实现自己的序列化方案,是迈向底层编程的一个绝佳起点。

并发编程

一旦有了清溪的协议规范,构建客户端应用程序通常是比较直接的。但服务器端的实现则要复杂得多,因为他需要同时处理大量的连接。如何应对海量的并发连接(即便其中大部分是空闲的),一直以来都是一个总所周知的难题(即经典的

C10K问题*
)。尽管对于如今的硬件而言,一万的并发量早已不在话下,但要真正榨干硬件性能,我们依旧需要高效的软件设计。

现代软件工程给出的答案是采用事件循环(event loops) 机制的 事件驱动并发(event-based concurrency) 模型这项技术正是Nginx,Redis,Golang等运行时等众多现代高可扩展性软件背后的核心技术。对于你来说,事件驱动并发可能是一种全新的编程范式,而且它确实相当复杂,因此,在实践中学习是掌握它的不二法门。

2.2 程序员视角下的网络

协议层

抽象思路:网络协议是分层的。底层协议可以包含上层协议作为数据载荷(payload)来承载,而上层协议则在底层协议的基础上增添新的功能。

现实世界:以太网协议承载着IP协议,IP协议承载着UDP或TCP协议,而UDP或TCP协议则承载着各种应用协议。

或者,我们也可以按照功能划分层次。

承载独立小消息的层(IP)

当你下载一个大文件时,硬件不可能在转发数据之前将整个文件都存下来,它只能处理一个个更小的单元(IP数据包)。这就是为什么最底层是基于数据包的。而将这些零散的数据包组装成应用程序所需要的数据,则由更高层(通常是TCP)来提供。

实现多路复用的层(端口号)

在一台电脑上,多个程序可以同时共享网络。那么,计算机是如何知道哪个数据包属于哪个应用程序呢?这个过程被称为

demultiplexing*
。IP的上一层(UDP或TCP)为此增加了一个16位的端口号来区分不同的应用。每个应用在收发数据前,都必须申请一个未被占用的本地端口号。计算机正是通过一下四元组来识别一条信息流的。

[源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)没有任何关系。

句柄是一个

不透明的整型标识符*
,用于指代跨越API边界的事物。这个句柄的概念,类似于你在QQ上的QQ账号(在英文中也叫handle),它指向一个特定的QQ用户。在Linux系统中,句柄被称为文件描述符(file descriptor,fd),其作用域是进程级别的。文件描述符仅仅是一个历史遗留名称。它跟文件没有什么关系,本身也不描述任何东西。

socke()方法会分配并返回一个socket句柄(fd),后续的操作将使用这个句柄来真正地创建连接。

当你使用完一个句柄后,必须将其关闭,以便操作系统释放相关联的资源。这是所有不同类型句柄的唯一共同点。

监听Socket与连接Socket

监听(listening)是指告知操作系统,某个应用程序已经准备好在指定的端口上接收TCP连接。随后操作系统就会返回一个socket句柄,应用程序用它指代该端口。通过这个监听socket,应用程序可以获取(即接受,accept)新建立的TCP连接,而这个新连接本身也由另一个socket句柄表示。因此,我们有两种类型的句柄:监听socket和连接socket。

创建一个监听socket至少需要三次API调用:

  1. 通过socket()获取一个socket句柄。
  2. 通过bind()为其设置监听的IP和端口。
  3. 通过listen()创建监听的socket。

然后,使用accept()API来等待传入的TCP连接。伪代码如下:

python
fd = socket()
bind(fd, address)
listen(fd)
while True:
    conn_fd = accept(fd)
    do_something_with(conn_fd)
    close(conn_fd)

从客户端发起连接

在客户端中,创建一个socket连接需要两次API调用:

  1. 通过socket()获取一个socket句柄
  2. 通过connect()创建连接。

伪代码如下:

python
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()

在下一章,我们将带你真正开始动手写代码。