Skip to content

Socket 网络编程基础

前言

Netty 是一个 Java 网络通信框架,底层依赖操作系统提供的 Socket 系统调用。因此,在《图解 Netty 源码》主线任务的第一篇文章中,我们会首先介绍 Socket 网络编程的基础。

计算机网络有多种分层模型,这里我们选择 TCP/IP 协议分层模型来进行讲解。

TCP/IP 协议分层自上而下包括以下层级:

  • 应用层:负责用户交互并处理特定的应用需求,常见协议包括 HTTPFTPSMTP 等。
  • 传输层:负责端到端的数据传输,常用协议为 TCPUDP
  • 网络层:负责跨网络传输数据包,IP 协议是核心协议。
  • 链路层:负责数据帧的传输和物理链路管理,典型协议有以太网等。

如下图所示:

image-20241029221357740

由此可推测,开发者在应用层开发 APP 时,需要使用 操作系统提供的 TCP 或者 UDP 系统调用 来实现网络相关的编程。

那么,什么是这个系统调用?我们又该如何调用它呢?

没错,这就是 Socket

image-20241029221645821

原来 Socket 就在这里。

那么,什么是 Socket 呢?

Socket 是应用层与 TCP/IP 协议族通信的中间抽象层,一组接口。在设计模式中,Socket 实际上是一个门面模式的实现,它将复杂的 TCP/IP 协议族隐藏在 Socket 接口背后。对用户而言,只需调用一组简单的接口即可完成通信,Socket 会组织数据,使之符合指定的协议。

你会使用它们吗?

前人已经为我们简化了许多工作,使网络通信变得相对容易。当然,仍然有一些底层细节需要处理。过去听到“Socket 编程”,总觉得它是很高深的知识,但只要理解了 Socket 编程的工作原理,它的神秘面纱就会被揭开。

用一个生活场景来类比:当你打电话给朋友时,首先拨号,朋友接听电话,连接建立成功后你们就可以交谈了。通话结束后,挂断电话结束交流。这个生活场景就类似于 Socket 的工作原理。或许,TCP/IP 协议族的设计灵感就源自生活,也说不定呢。

什么是 Socket?

socket 一词的起源

在计算机组网领域中,socket 一词首次出现在 1970 年 2 月 12 日发布的文献 IETF RFC33 中,撰写者为 Stephen Carr、Steve Crocker 和 Vint Cerf。根据美国计算机历史博物馆的记载,Crocker 写道:“命名空间的元素都可称为套接字接口。一个套接字接口构成一个连接的一端,而一个连接可完全由一对套接字接口规定。”

计算机历史博物馆补充道:“这比 BSD 套接字接口的定义早了大约 12 年。”


前文提到,网络中的进程是通过 socket 进行通信的。那么,究竟什么是 socket 呢?

socket 起源于 Unix,而 Unix/Linux 的一个基本哲学是“一切皆文件”,即所有资源都可以通过“打开 (open) → 读写 (write/read) → 关闭 (close)”的模式进行操作。

可以理解为,socket 就是这种文件操作模式的一个实现:它是一种特殊的文件,允许我们使用各种 socket 函数对其进行操作(如读写 IO、打开和关闭)。接下来,我们将基于C语言进一步介绍这些函数的具体作用。

基本 TCP 套接字编程

先来看看《UNIX网络编程 卷1》中的 基本TCP客户/服务器程序的套接字函数

image-20241101232014889

创建套接字

c
// 创建 socket 文件描述符 (TCP/UDP, 客户端 + 服务器)
int socket(int domain, int type, int protocol);

为执行网络 I/O,进程的第一步是调用 socket 函数(本质上是打开网络文件),并指定所需的通信协议类型(如 IPv4TCPIPv6UDPUnix 域字节流协议等)。

参数说明:

domain参数:指定协议族,即你想要使用的协议(IPv4IPv6 等)
domain说明
AF_INETIPv4 协议
AF_INET6IPv6 协议
AF_LOCALUnix 域协议
AF_ROUTE路由套接字
AF_KEY密钥套接字

规定:本例中所用的套接字协议为 AF_INET(即 IPv4 协议)。

type参数:指定套接字的类型
type说明
SOCK_STREAM字节流套接字
SOCK_DGRAM数据报套接字
SOCK_SEQPACKET有序分组套接字
SOCK_RAW原始套接字

说明:若使用 TCP 通信,则套接字类型应设为 SOCK_STREAM;若为 UDP 通信,则使用 SOCK_DGRAM

protocol参数:指定协议类别,如 TCPUDP。一般情况下,该字段设置为 0 即可,表示默认协议。系统会根据前两个参数自动推导出适用的协议。
protocol说明
IPPROTO_TCPTCP 传输协议
IPPROTO_UDPUDP 传输协议
IPPROTO_SCTPSCTP 传输协议

返回值说明:

套接字创建成功则返回文件描述符;若创建失败,返回值为 -1,同时设置错误码。

绑定端口号

c
// 绑定端口号 (TCP/UDP, 服务器)
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

bind 函数用于将协议地址赋予一个套接字。

参数说明:

  • sockfd 参数:要绑定的文件描述符,即在创建套接字时获得的文件描述符。
  • addr 参数:指向特定协议的地址结构的指针,包含协议族、端口号和 IP 地址等信息(详见下一节关于 sockaddr 结构的介绍)。
  • addrlen 参数:该协议地址结构的长度。

返回值说明:

绑定成功则返回 0,若绑定失败返回 -1,并设置错误码。

监听套接字

c
// 开始监听socket (TCP, 服务器)
int listen(int sockfd, int backlog);

listen 函数通常由 TCP 服务器调用,用于向外宣告服务器愿意接受连接请求,并执行以下两项操作:

  1. 转换为被动套接字:当 socket 函数创建套接字时,它默认是主动套接字,即用于客户端发起连接的套接字。listen 函数将未连接的套接字转换为被动套接字,指示内核接受对该套接字的连接请求。简单来说,服务器调用 listen 就是告知客户端它可以接受连接。
  2. 设置最大连接数:第二个参数指定内核为该套接字排队的最大连接数。backlog 提供了一个提示,表明系统应为该进程排队的未完成连接请求数量。对于 TCP,默认值为 128
    • 当队列满时,系统会拒绝多余的连接请求。因此,backlog 的值应根据服务器期望的负载和处理能力来选择,其中处理能力指的是可以接受和启动服务的连接数量。

一旦调用了 listen,套接字便可以接受连接请求。连接请求可通过 accept 函数来处理并建立连接。

返回值:成功返回 0,失败则返回 -1

注意:该函数通常应在调用 socketbind 后使用,并且在调用 accept 之前调用。


为了理解 backlog 参数,我们需要认识到内核为任何给定的 监听套接字 维护两个队列:

  1. 未完成连接队列(incomplete connection queue):每个这样的 SYN 分节对应队列中的一项,这些分节已由某个客户端发送并到达服务器,但服务器正在等待完成相应的 TCP 三路握手 过程。这些套接字处于 SYN RCVD 状态。
  2. 已完成连接队列(completed connection queue):每个已完成 TCP 三路握手 过程的客户端对应队列中的一项,这些套接字处于 ESTABLISHED 状态。

backlog 参数主要影响 未完成连接队列 的大小,它定义了内核在接受客户端连接请求时所能排队的最大数量。如果队列已满,额外的连接请求将被拒绝。

image-20241101234824469

每当在 未完成连接队列 中创建一项时,来自 监听套接字 的参数会被复制到即将建立的连接中。连接的创建机制是完全自动的,无需服务器进程的插手。

接受请求

c
// 接收请求 (TCP, 服务器)
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

accept 函数由 TCP 服务器 调用,用于从已完成连接队列中返回下一个已完成连接。如果已完成连接队列为空,进程将进入阻塞状态。

如果 accept 成功,返回值是内核自动生成的一个全新描述符,表示与所返回客户端的 TCP 连接。通常,我们称其第一个参数为监听套接字描述符(由 socket 创建,并用作 bindlisten 的第一个参数),而将返回值称为已连接套接字描述符。

区分这两个套接字非常重要

  • 一个服务器通常仅创建一个监听套接字,该套接字在服务器的生命周期内一直存在。
  • 内核为每个由服务器进程接受的客户连接创建一个已连接套接字,这意味着其 TCP 三次握手过程已经完成。
  • 当服务器完成对某个特定客户的服务时,相应的已连接套接字 就会被关闭。

总的来说,accept 返回的文件描述符是一个新的套接字描述符,连接到调用 connect 的客户端。这个新的套接字描述符与原始套接字(sockfd)具有相同的套接字类型和地址族。传给 accept 的原始套接字仍然保持监听状态,以接受其他连接请求。

建立连接

c
// 建立连接 (TCP, 客户端)
#included <sys/socket.h>
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

connect 函数用于 TCP 客户端 请求 TCP 服务器 建立连接。

参数说明:

  • sockfd:由 socket 函数返回的套接字描述符。
  • 第二个参数:指向套接字地址结构的指针,表示目标服务器的地址。
  • 第三个参数:该结构的大小。

connect 中指定的地址是我们希望与之通信的服务器地址。如果 sockfd 没有绑定到一个地址,connect 将为调用者绑定一个默认地址。

返回值说明:

  • 成功时返回 0,出错时返回 -1

关闭连接

image-20241102000601634

  1. 客户端调用 close,表明没有数据需要发送,此时会向服务器发送 FIN 报文,并进入 FIN WAIT 1 状态。
  2. 服务器接收到 FIN 报文后,TCP 协议栈会为 FIN 包插入一个文件结束符 EOF 到接收缓冲区中,应用程序可以通过 read 调用来感知这个 FIN 包。这个 EOF 会被放在已排队的其他已接收数据之后,这意味着服务器需要处理这种异常情况,因为 EOF 表示在该连接上不会再有额外数据到达。此时,服务器进入 CLOSE WAIT 状态。
  3. 服务器处理完数据后,自然会读取到 EOF,于是也调用 close 关闭它的套接字,这会使得服务器发出一个 FIN 包,之后处于 LAST ACK 状态。
  4. 客户端接收到服务器的 FIN 包,并发送 ACK 确认包给服务器,此时客户端将进入 TIME WAIT 状态。
  5. 服务器收到 ACK 确认包后,就进入了最后的 CLOSE 状态。
  6. 客户端经过 2MSL 时间之后,也进入 CLOSE 状态。

深入 read&write

image-20241102001726163

read/write的语义:为什么会阻塞?

先从 write 说起:

c
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);

首先,write 成功返回,仅意味着 buf 中的数据被复制到了内核中的 TCP 发送缓冲区。至于数据何时被发送到网络、何时被对方主机接收、何时被对方进程读取,系统调用层面并不提供任何保证和通知。

write 在什么情况下会阻塞?

当内核的该 socket 的发送缓冲区已满时。对于每个 socket,都有其自己的发送(send buffer)和接收缓冲区(receive buffer)。从 Linux 2.6 开始,这两个缓冲区的大小由系统自动调整(autotuning),但通常在默认值和最大值之间浮动。

获取 socket 的发送/接收缓冲区的大小

c
sysctl net.core.wmem_default       # 126976
sysctl net.core.wmem_max           # 131071

已发送到网络的数据仍需要暂存在发送缓冲区中,只有收到对方的 ack 后,内核才会从缓冲区中清除相应数据,为后续发送的数据腾出空间。接收端将收到的数据暂存在接收缓冲区中,自动进行确认。

如果 socket 所在的进程未及时从接收缓冲区中取出数据,最终会导致缓冲区填满。由于 TCP 的滑动窗口和拥塞控制,接收端会阻止发送端向其发送数据。这些控制都发生在 TCP/IP 栈中,对应用程序透明。若应用程序继续发送数据,最终会导致 send buffer 填满,write 调用阻塞。

通常来说

由于接收端进程从 socket 读取数据的速度不及发送端写入 socket 的速度,最终会导致发送端的 write 调用阻塞。

read 调用的行为相对容易理解,即从 socket 的接收缓冲区中拷贝数据到应用程序的缓冲区。read 调用阻塞的原因通常是发送端数据未到达


blocking(默认)和 nonblock 模式下 read/write 行为的区别

socket 文件描述符设置为非阻塞模式(nonblock)是服务器编程中的常见做法。使用阻塞 I/O 并为每个客户端创建一个线程的方式开销巨大,且可扩展性差(带来大量的上下文切换开销)。更为通用的做法是采用 线程池 + 非阻塞 I/O + 多路复用select/poll,以及 Linux 上特有的 epoll)。

c
// 设置一个文件描述符为非阻塞模式
int set_nonblocking(int fd) {
    int flags;
    if ((flags = fcntl(fd, F_GETFL, 0)) == -1)
        flags = 0;
    return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

几个重要的结论

  1. read 调用read 总是在接收缓冲区有数据时立即返回,而不是等到填满整个缓冲区。只有当接收缓冲区为空时,阻塞模式会等待;在非阻塞模式下,read 会立即返回 -1,并设置 errno = EAGAINEWOULDBLOCK

  2. 阻塞模式的 write 调用: 阻塞模式的 write 只有在缓冲区足够容纳整个 buffer 时才返回。而非阻塞模式的 write 会返回已经写入的字节数,接下来的调用会返回 -1errno = EAGAINEWOULDBLOCK)。

    阻塞模式的 write 有一个特例:当 write 正在阻塞等待时,如果对方关闭了 socketwrite 会立即填满剩余缓冲区并返回写入的字节数。再次调用 write 时会失败,并返回 connection reset by peer 错误。


read/write 对连接异常的反馈行为

在应用程序层面,TCP 通信实际上是一个完全异步的过程,存在以下不确定性:

  1. 不确定对方是否收到数据。
  2. 不知道何时能收到对方数据。
  3. 不确定通信何时结束(可能由于主动退出、异常退出、机器故障或网络故障)。

对于前两点,可以通过 write() -> read() -> write() -> read() ->... 的序列操作,配合阻塞 read 或非阻塞 read + 轮询方式来正确处理。

对于第三点,内核通过 read/write 的结果将事件“通知”应用层。

假设 A 机器上的进程 a 正在和 B 机器上的进程 b 通信,某一时刻 a 阻塞在 socketread 调用上(或在非阻塞模式下轮询 socket)。当进程 b 终止时,无论应用程序是否显式关闭 socket,操作系统都会关闭所有文件描述符,并发送一个 FIN 包至对端。

同步通知: 进程 a 在已收到 FINsocket 上调用 read,若已读完接收缓冲区剩余字节,则返回 EOF:0

异步通知: 若进程 a 阻塞在 read 上(接收缓冲区为空时 read 会阻塞),则调用 read 会立即返回 EOF,并唤醒进程 a

异常情况: 如果进程 b 异常终止,操作系统会为其所有打开的 socket 发送 RST(因为 socket 所属进程已终止)。此时,进程 a 调用 write 会收到 SIGPIPE 信号,默认处理是终止进程,这就解释了进程为何可能会“毫无征兆”地终止。


但是,仅仅通过 read/write 检测异常情况并不完全可靠,因此还需一些额外手段:

  1. TCP 的 KEEPALIVE 功能

    c
    cat /proc/sys/net/ipv4/tcp_keepalive_time       # 7200
    cat /proc/sys/net/ipv4/tcp_keepalive_intvl      # 75
    cat /proc/sys/net/ipv4/tcp_keepalive_probes     # 9

    以上参数含义为:keepalive 每 2 小时(7200 秒)启动一次,发送第一个探测包,若在 75 秒内未收到应答则重发探测包,连续 9 次未应答则认为连接已断开。此时,read 应返回错误。

  2. 应用层心跳

    在严格的网络程序中,应用层心跳协议是必不可少的。虽然比 TCP 自带的 keepalive 要复杂一些,但更为可控。

简单的 TCP 网络程序

客户端

image-20241101234241863

服务器

image-20241102010143029

总结

Socket 是应用层与 TCP/IP 协议族之间的通信接口,它通过提供简单的 API 隐藏了底层的协议细节。开发者通过调用操作系统的 Socket 系统调用来进行网络编程,进程通过 Socket 与其他进程进行通信。Socket 本质上是一种特殊的文件,遵循文件操作的模式,允许进程进行读写操作。它在网络编程中充当着重要的角色,尤其是 TCP/IP 协议族的通信。

Socket 的创建过程首先通过 socket 函数,指定通信协议类型(如 IPv4 或 IPv6)和套接字类型(如 TCP 或 UDP)。接着,服务器通过 bind 函数将协议地址与套接字绑定,并使用 listen 函数将套接字转换为被动套接字,准备接受客户端连接。客户端通过 connect 函数发起连接请求,服务器通过 accept 函数接收并建立与客户端的连接。连接成功后,客户端和服务器可以进行数据传输,直到使用 close 关闭连接。

在 TCP 网络编程中,服务器通常有两个队列管理连接请求:未完成连接队列和已完成连接队列。listen 函数中的 backlog 参数控制未完成连接队列的大小。当队列满时,额外的连接请求将被拒绝。因此,合理设置 backlog 参数对于系统的连接管理非常重要。

总结而言,Socket 编程通过一组简单的系统调用抽象了网络通信的复杂性,使得开发者可以通过少量的 API 进行复杂的网络交互。了解 Socket 的底层工作原理是高效进行网络编程的基础。

参考

基于 MIT 许可发布