【译】Tcp-Socket是怎么工作的

在这篇文章中,我将从较高层次上解释 TCP/IP 堆栈在 Linux 上的工作原理。特别是,我将探讨套接字系统调用如何与内核数据结构交互,以及内核如何与实际网络交互。写这篇文章的部分动机是解释监听队列溢出(listen queue)机制是如何工作的,因为它与我在工作中一直在处理的问题有关。

已建立的连接如何工作

这个解释将自上而下,所以我们将从已经建立的连接(Established Connections)如何工作开始。稍后我将解释新建立的连接是如何工作的。

数据结构

对于内核持续跟踪的每个 TCP 文件描述符,都有一个套接字结构体用于保存一些 TCP 特定信息(例如序列号、当前窗口大小等),以及一个接收缓冲区(或“队列(queue)”)和一个写缓冲区(或“队列(queue)”)。我将交替使用术语缓冲区(buffer)和队列(queue)。如果您对更多细节感到好奇,可以在 Linux 内核的 net/sock.h 中查看套接字结构的实现。

接受

当新数据包进入网络接口 (NIC) 时,内核会通过被 NIC 中断或通过轮询 NIC 获取数据来得到通知。通常内核是中断驱动还是轮询模式取决于发生的网络流量。当 NIC 非常忙时,内核轮询(poll)效率更高;但如果 NIC 不忙,则可以通过使用中断来节省 CPU 周期和功率。Linux 将这种技术称为 NAPI,字面意思是“New API”。

当内核从 NIC 获取数据包时,它会解码数据包并根据源 IP、源端口、目标 IP 和目标端口找出数据包与哪个 TCP 连接相关联。这些信息用于查找与该连接关联的内存中的套接字结构体 。假设数据包是按顺序的,然后数据有效负载被复制到套接字的接收缓冲区中。此时,内核将唤醒任何执行阻塞 read(2) 的进程,或者正在使用 I/O 多路复用系统调用(如 select(2)epoll_wait(2) )等待套接字的进程。

当用户空间进程实际调用文件描述符上的 read(2) 时,它会导致内核从其接收缓冲区中删除数据,并将该数据复制到提供给 read(2) 系统调用的缓冲区中。

发送

发送数据(Send)的工作方式类似。当应用程序调用 write(2) 时,它将数据从用户提供的缓冲区 复制 到内核写队列中。随后内核会将写入队列中的数据复制到网卡中,并真正发送数据。如果网络繁忙、TCP 发送窗口已满、流量整形策略生效等情况下,实际向 NIC 传输数据可能会比用户实际调用 write(2) 时有所延迟。

结论

这种设计的一个结果是,如果应用程序读取速度太慢或写入速度太快,内核的接收和写入队列可能会填满。因此,内核为读写队列设置了最大大小。这确保了行为不佳的应用程序使用有限数量的内存。例如,内核可能将每个接收和写入队列的上限限制为 100 KB。那么每个 TCP 套接字可以使用的最大内核内存量大约为 200 KB(因为与队列的大小相比,其他 TCP 数据结构的大小可以忽略不计)。

Read Semantics读语义

  • 如果接收缓冲区为空并且用户调用 read(2),则系统调用将阻塞直到数据可用。
  • 如果接收缓冲区非空并且用户调用 read(2),系统调用将立即返回任何可用的数据。
  • 如果读取队列中准备就绪的数据量小于用户提供的缓冲区的大小,则可能会发生部分读取。调用者可以通过检查 read(2) 的返回值来检测这一点。
  • 如果接收缓冲区已满并且 TCP 连接的另一端尝试发送额外的数据,内核将拒绝确认数据包。这就是常规的 TCP 拥塞控制

Write Semantics 写语义

如果写队列未满,用户调用write(2),系统调用就会成功。如果写队列有足够的空间,所有数据将被复制。如果写入队列只有部分数据的空间,那么将发生部分写入,并且只有部分数据将被复制到缓冲区。调用者通过检查 write(2) 的返回值来检查这一点。

如果写队列已满并且用户调用 write(2),系统调用将阻塞。

新建立的连接如何工作

在上一节中,我们看到已建立的连接如何使用接收和写入队列来限制为每个连接分配的内核内存量。类似的技术用于限制为新连接保留的内核内存量。
从用户空间的角度来看,新建立的 TCP 连接是通过在侦听套接字上调用 accept(2) 创建的。监听套接字是使用 listen(2) 系统调用指定的套接字。

accept(2) 的原型采用一个套接字和两个存储有关套接字另一端信息的字段。accept(2) 返回的值是一个整数,表示新建立的连接的文件描述符:

1
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

listen(2) 的原型采用套接字文件描述符和积压参数:

1
int listen(int sockfd, int backlog);

backlog 是一个参数,它控制当用户没有足够快地调用 accept(2) 时内核将为新连接保留多少内存。

例如,假设您有一个阻塞的单线程 HTTP 服务器,每个 HTTP 请求大约需要 100 毫秒。在这种情况下,HTTP 服务器将花费 100 毫秒处理每个请求,然后才能再次调用 accept(2)。这意味着在高达 10 rps 时将不会排队。如果超过 10 rps 进来,内核有两个选择。

内核的第一个选择是根本不接受连接。例如,内核可以拒绝 ACK 传入的 SYN 数据包。更常见的情况是内核将完成 TCP 三向握手,然后使用RST终止连接。无论哪种方式,结果都是一样的:如果连接被拒绝,则不需要分配接收或写入缓冲区。这样做的理由是,如果用户空间进程接受连接的速度不够快,正确的做法是让新请求失败。反对这样做的理由是它非常激进,尤其是当新连接随着时间的推移“突发”时。

内核的第二个选择是接受连接并为其分配套接字结构(包括接收/写入缓冲区),然后将套接字对象排队以备后用。下次用户调用 accept(2) 时,不会阻塞系统调用,而是会立即获得已分配的套接字。

第二种行为的论点是,当处理速率或连接速率趋于爆发时,它会更宽容。例如,在我们刚刚描述的服务器中,假设一下子有 10 个新连接进入,然后在接下来的一秒内不再有连接进入。如果内核对新连接进行排队,那么所有请求都将在第二个过程中得到处理。如果内核一直在拒绝新连接,那么只有一个连接会成功,即使该进程能够跟上总请求率。

反对排队的论据有两个。首先是过度排队会导致分配大量内核内存。如果内核分配了数千个具有大接收缓冲区的套接字,那么内存使用量会快速增长,用户空间进程甚至可能无法处理所有这些请求。反对排队的另一个论据是,它使应用程序在连接的另一端(客户端)看来很慢。客户端会看到它可以建立新的 TCP 连接,但是当它尝试使用它们时,服务器的响应速度会很慢。争论点是,在这种情况下,最好让新连接失败,因为这会提供更明显的服务器不健康反馈。此外,如果服务器积极地使新连接失败,则客户端可以知道要退出;这是另一种形式的拥塞控制

监听队列溢出

您可能会怀疑,内核实际上结合了这两种方法。内核将对新连接进行排队,但只有一定数量的连接。内核排队的连接数量由 listen(2) 的 backlog 参数控制。通常这被设置为一个相对较小的值。在 Linux 上,socket.h 头文件将 SOMAXCONN 的值设置为 128,在内核 2.4.25 之前,这是允许的最大值。如今,最大值在 /proc/sys/net/core/somaxconn 中指定,但通常您会发现程序仍然使用 SOMAXCONN(或更小的硬编码值)。

当监听队列填满时,新的连接将被拒绝。这称为监听队列溢出。您可以通过阅读 /proc/net/netstat 并检查 ListenOverflows 的值来观察何时发生这种情况。这是整个内核的全局计数器。据我所知,您无法获得每个监听套接字的监听溢出统计信息。

在编写网络服务器时监视监听溢出很重要,因为从服务器的角度来看监听溢出不会触发任何用户可见的行为。服务器将全天愉快地接受 (2) 个连接,而不会返回任何连接正在被丢弃的指示。例如,假设您在 Python 应用程序前使用 Nginx 作为代理。如果 Python 应用程序太慢,则可能导致 Nginx 侦听套接字溢出。发生这种情况时,您不会在 Nginx 日志中看到任何相关指示——您将像往常一样继续看到 200 状态代码等。因此,如果您只是监视应用程序的 HTTP 状态代码,您将看不到 TCP 错误正在阻止将请求转发到应用程序。