构建后端应用程序需要一个通信协议、一个绑定的端口和一个处理请求和产生响应的进程。当特别使用TCP作为传输协议时,会为希望连接到后端的客户端创建一个有状态的连接。
在此之前,后端应用程序必须积极地接受连接,否则连接将保留在操作系统的后台缓冲区中,这将最终填满。从连接中读取TCP流也是后端应用程序的工作,这将从操作系统缓冲区移动原始流字节到后端应用程序中。
在本文中,我探讨了五种线程和连接管理的架构模式。在此之前,让我们先澄清一些定义,以便我们在同一页面上。
监听器——当后端应用程序在特定的IP和端口上监听时,它会创建一个套接字。套接字不是连接,而是一个连接的位置。就像一个墙上的插座,你可以插入连接。许多连接可以连接到套接字。监听器是套接字所在的进程。
接收器——有了一个套接字,后端就可以调用操作系统函数accept,将这个套接字传递给它,以接受在这个套接字上可用的任何连接。accept函数返回表示连接的file descriptor。连接必须由应用程序积极接受,以便为客户端提供服务。否则,连接将保留在操作系统的accept队列中未使用。接收器是调用accept函数的线程或进程。
读取器——你也可以称之为工作者,这是工作发生的地方。当我们有一个连接时,后端应用程序还负责读取发送到该连接的数据,否则操作系统为该连接分配的缓冲区将填满,客户端将无法再发送任何数据。我将在连接上读取TCP流的过程称为读取器。读取器将文件描述符(连接)传递给它,以读取流并对其进行操作。
TCP流与请求——TCP是一种流式协议。让我们将这意味着什么用于我们的HTTP web服务器进行翻译。当你从前端通过Axios发送GET请求时,Axios将创建一个TCP连接(如果不存在)并构建HTTP请求,包括方法、协议版本、标头、URL参数等。HTTP请求是完全定义的,它有一个开始和一个结束。但是,猜猜看?TCP流只是原始数据的字节,因此读取器负责读取所有流并“查找”请求,哦,这是请求的开头,让我继续读取,好的,我看到了标头、URL,是的,那就是请求的结束。这些字节的集合现在被转换为一个逻辑请求(有时被称为消息,因为我们喜欢让人困惑),该请求被传递到应用程序层7进行处理。因此,这并不像我们想象的那么容易。对于使用TCP、HTTP/2、gRPC、SSH等任何第7层协议,都是如此。这种解析可能会对任何线程造成负担。
说完这些,让我们来看看管理线程和连接的五种不同架构。请记住,这些是我在8年的职业生涯中亲自观察到的模式。这是否意味着不存在其他东西,当然不是。所以接受它时要心存怀疑。
单线程架构
单线程架构是一种简单、优雅、易于理解的架构。在这种架构中,后端应用程序由一个单线程充当监听器,它绑定在一个IP:Port上,接受套接字上的连接,并从它接受的所有连接中读取TCP流。虽然这个单线程很容易被淹没,但一些后端工程师认为这种架构很有吸引力,因为它可以保持他们的应用程序简单。他们选择通过启动多个实例来横向扩展他们的后端应用程序,而不是在后端使用多个线程。例如,NodeJS使用这种模型,因此您使用NodeJS构建的任何应用程序都将使用该模型。
多线程单Acceptor架构
多线程可以用来构建性能强大的后端应用程序,利用所有的CPU核心。作为工程师,你所付出的代价是复杂性(没有免费的午餐),但有时这是值得的。
在这种架构中,你仍然有一个单一的监听器线程,通常也接受连接。不同的是,每个已接受的连接会被传递给另一个线程。一旦线程拥有一个连接,它将使用当前流行的任何IO范例(例如,io_uring在2022年似乎很流行)在连接文件描述符上调用读取。Memcached使用这种架构。
我曾经认为,对于每个新接受的连接,你可以只是新建一个线程并将其交给它,但这证明是一个坏主意。很快你就会用尽内存,即使你没有用尽内存,不同线程之间的上下文切换也会显著地影响性能,尤其是在有限数量的核心上。
在Memcached中,更平衡的方法是指定一个最大线程数,线程将共享多个连接。一个好的经验法则是将最大线程数设定为你拥有的CPU核心数,假设你没有大量其他程序在运行,那就是一个好的开始。这确保线程粘到一个核心上,保持上下文切换最少,重用那美丽的L2/L1缓存。
这种方法的局限性是我们有一个单线程接受所有连接,所以如果有大量连接请求,该线程可能会落后于接受连接,这将导致客户端获得高延迟时间。这里需要注意的是,SYN/SYN-ACK/ACK三向TCP握手已经发生并由操作系统执行,这只是后端应用程序将连接从操作系统的接受队列移动到自己的缓冲区。如果后端应用程序无法快速接受连接并清空接受队列,就无法再连接更多的客户端到主机(三向握手将失败或超时)。
另一个局限性是缺乏负载平衡。有些线程可能会处理一个贪婪的客户端连接,其中有很多请求,而其他线程的连接几乎没有发送任何请求。这将创建热点,其中一个线程被压倒,而其他线程则没有,从而导致落在繁忙线程上的连接出现进一步的延迟。
多线程多接收器架构
与先前的架构类似,我们将使用单个侦听线程创建套接字。但是我们将套接字放置在共享内存中,以便其他线程可以访问它。侦听线程创建多个工作线程并传递共享套接字对象。每个线程将在套接字对象上调用accept以接受连接。由accept调用生成的连接成为线程的责任,在这种情况下,线程既是接收器又是读取器。这个模型将连接管理的责任分散到本地线程中。NGINX在1.9.1版本之前默认使用此特定架构。
这种架构的限制是所有线程都竞争在单个共享套接字对象上接受连接,这需要在accept队列上使用互斥量。这导致线程在接受连接时被序列化,可能会导致阻塞和高延迟。虽然肯定比让单个线程接受所有连接更快,但并不是它本来可以达到的速度,继续阅读以了解更多技巧。此架构中负载平衡的问题仍然存在。
基于消息的多线程负载均衡架构
我对这种架构非常感兴趣,我在审查试图为数据中心替换TCP的Homa论文中学到了这种架构。它类似于memcached的架构,但是除了将连接(原始文件描述符)移交给线程外,侦听线程还接受,读取和解析逻辑消息(请求)并将干净的请求交付给线程进行处理。RAMCloud使用了这种架构,对于负载均衡请求非常有优势,侦听线程知道工作线程正在做什么,并可以均匀地分配负载。
这种架构的问题是侦听线程成为瓶颈,因为它不仅需要负责所有连接,还必须对它们都调用read。
多线程 Socket Sharding(SO_REUSEPORT)
我们在绑定套接字时遇到的一个限制是,只有一个进程可以侦听给定的端口。默认情况下,不允许两个进程在同一端口/IP上侦听,操作系统不会允许这样做。最近,如果套接字侦听器指定套接字选项(SO_REUSEPORT),操作系统放松了这个约束条件,这允许多个进程侦听相同的端口,而两个进程将同时获得一个不同的套接字句柄,同时都指向相同的地址/端口。我的猜测是,操作系统会为每个绑定的套接字创建一个accept队列,并将目标到端口/地址的连接分发到多个accept队列中。在这种情况下,accept调用不会被串行化或阻塞,因为每个调用都有自己的队列。NGINX 1.9.1开始支持套接字共享,Envoy和HAProxy也支持。
就像软件工程中的一切一样,我很确定这个方法也有它自己的限制,只是我还没有找到它。我会继续锁定并在找到后更新这篇文章。
所有这些的酷之处在于,你可以混合和匹配架构。例如,我还没有见过这种情况,但将套接字共享与基于消息的负载平衡混合使用听起来像是一种强大的架构。
每日清单
喜欢这里的内容吗?我每天早上都会为2,000名软件开发人员撰写新的内容。
如果你喜欢我的文章,点赞,关注,转发!
网友评论