并发思考:现代网络应用如何处理多重连接
Reuven 探讨了不同类型的多进程处理,并考察了每种方法的优点和缺点。
当我刚开始做咨询,我的客户都是刚起步的小型组织,他们总是会问我他们需要哪种高性能服务器。我的客户都深信他们会变得非常受欢迎和重要,而且他们的网站会有很多访客——重要的是他们的网站能够承受这种负载。
我会提醒他们,每天有 86,400 秒。这意味着如果每秒钟有一个新的人访问他们的网站,服务器每天需要处理 86,400 个请求——对于大多数现代计算机来说,这是一个微不足道的数字,特别是如果您只是提供静态文件。
然后我会问,他们真的期望每天有超过 86,000 名访客吗?客户几乎总是会有些不好意思地回答:“不,绝对不会。”
现在,我知道我的客户不需要担心他们服务器的大小或速度;我真的从他们的最佳利益出发,并且我试图以一种有点戏剧化的方式说服他们,他们不需要花钱购买新服务器。但是,当我提出这些数字时,我确实对真相做了一些自由的改动——例如
-
一天中的 86,400 名访客均匀分布在整天和午餐高峰时段(许多人在午餐时购物和休闲阅读)之间是有区别的。
-
包含 CSS、JavaScript 和图像的网页(在现代,所有网页都是如此)每次页面加载都需要多个 HTTP 请求。即使您有 10,000 名访客,您也可能有超过 100,000 个 HTTP 请求发送到您的服务器。
-
当一个简单的网站变成一个 Web 应用程序时,您需要开始担心后端数据库和第三方服务的速度,以及计算某些东西所需的时间。
那么,在这种情况下您该怎么办呢?如果您每秒只能处理一个请求,那么如果多个人同时访问您的网站会发生什么?您可以让其中一个人等待直到另一个人完成,然后再服务下一个,但是如果您有 10 或 15 个同时请求,那么这种策略最终会适得其反。
在大多数现代系统中,解决方案是利用多进程处理:让计算机一次执行多项操作。如果一台计算机每秒可以执行两项操作,并且如果您的访客均匀分布在一天中,那么您可以处理 172,800 名访客。如果您一次可以执行三项操作,那么您突然可以处理 259,200 名访客——依此类推。
计算机如何一次执行多项操作?对于单 CPU,每个进程获得一个“时间片”,这意味着 CPU 工作时间的其中一小部分。如果您有十个进程,并且每个进程获得相等的时间片,那么每个进程每秒将运行一次,持续 0.1 秒。随着您增加进程数量,分配给每个进程的时间会减少。
现代计算机配备了多个 CPU(又名“核心”),这意味着它们实际上可以并行执行操作,而不是简单地为系统单处理器上的每个进程分配时间片。理论上,具有十个进程的双核系统将使每个进程每秒运行一次,持续 0.2 秒,分布在处理器之间。
扩展永远不是完全线性的,所以您无法真正以这种方式预测事物,但这对于思考这个问题来说是一个不错的方法。
如此处所述,进程是计算机一次执行多项操作的好方法。然而,许多应用程序还有其他处理并发的方法。进程的两个最流行的替代方案是线程和反应器模式,尤其是在 node.js 和 nginx HTTP 服务器中非常流行和广为人知。
因此,在本文中,我探讨了存在于不同类型的多进程处理,考察了每种方法的优点和缺点。即使您不打算切换,了解那里有什么也很有用。
进程进程背后的想法相当简单。一个正在运行的程序不仅包含执行代码,还包含数据和一些上下文。由于代码、数据和上下文都存在于内存中,因此操作系统可以非常快速地从一个进程切换到另一个进程。代码+数据+上下文的这种组合被称为“进程”,它是 Linux 系统工作方式的基础。
当您启动 Linux 机器时,它只有一个进程。然后该进程“fork”自身,这样就运行了两个相同的进程。第二个(“子”)进程读取新的代码、数据和上下文(“exec”),从而开始运行一个新进程。这在系统运行的整个过程中持续进行。当您在命令行末尾使用 & 执行新程序时,您正在 fork shell 进程,然后在它的位置 exec 您想要的程序。
Apache httpd 服务器在许多 Linux 系统上非常流行和标准,默认情况下在进程模型上工作。您可能会认为,当新请求进入时,Apache 将启动一个新进程来处理它。但是启动它需要一些时间,没有人希望等待它发生。因此,解决方案是“预先 fork”一批服务器。这样,当新请求到达时,Apache 可以将该请求交给一个进程。当 Apache 看到您的进程数量不足时,它会向池中添加一批进程,确保始终有足够的备用服务器。如果您达到限制,事情就会开始给用户带来问题,
在许多情况下,进程模型非常棒。Linux 非常擅长启动进程;这是一个相当低成本的操作,典型的系统每小时会执行数百甚至数千次。此外,内核开发人员多年来已经学会了如何智能地做事,这样 fork 的进程仅在需要时才使用(和写入)自己的内存;在那之前,它会继续使用来自其父进程的内存。
此外,进程非常稳定和安全。一个进程拥有的内存通常对其他进程不可见,更不用说可被其他进程写入。如果一个进程崩溃,它不应该使整个系统崩溃。
那么,有什么不喜欢的呢?进程很棒,不是吗?
是的,但是它们也需要相当多的开销。如果您的所有操作只是提供一些文件,或者进行少量处理,那么为此使用一个完整的进程可能显得过分。
此外,如果您正在执行许多使用相同内存的相关任务,那么每个进程都将数据分开的事实可能会使事情变得安全,但也更耗费内存。
线程来自 Windows 或 Java 背景的人经常嘲笑 UNIX 使用进程的传统。他们说进程对于大多数事情来说太重了,最好改用线程。线程类似于进程,只是它存在于进程内部。
正如计算机在不同进程之间分配时间,为每个进程分配时间片一样,进程在其不同的线程之间分配时间,为每个线程分配时间片。
由于线程存在于现有进程中,因此它们的启动时间要快得多。而且由于线程与进程中的其他线程共享内存,因此它们消耗的内存更少,效率更高。
线程共享内存这一事实可能会导致各种问题情况。您如何确保两个不同的线程不会同时修改相同的数据?您如何确保您的线程以适当的顺序执行——或者,您如何确保顺序实际上并不那么重要?线程处理存在各种各样的问题,而使用线程的人非常清楚这些问题。线程的好处是显而易见的,但是确保它们工作并且正确地工作可能会非常令人沮丧。实际上,从小使用进程长大的人发现线程充满了危险和复杂性,他们会尽一切可能避免它们。
一般来说,具有 Microsoft 技术背景的人会一直使用线程,仅在必要时才启动新进程。相比之下,在 Microsoft 世界中启动新进程需要很长时间。同时,具有 UNIX 背景的人认为启动新进程是最简单和最安全的事情,并且他们倾向于避开线程。
哪个是正确的答案?这完全取决于情况。如果您使用线程,您可能会从计算机中榨取更多性能,但同时,您可以确保代码的编写方式能够充分利用线程,并且不会陷入任何常见的陷阱。
如果您想在 Apache 服务器上使用线程而不是进程,该怎么办?多年前,Apache httpd 开发人员意识到,他们向用户强推单一模型是愚蠢的。对于某些人,尤其是对于使用 Windows 的人来说,线程是更可取的。对于许多人来说,传统的进程是更可取的。在某些情况下,在没有先进行一些基准测试的情况下,哪种方法更好并不明显。
解决方案是所谓的 MPM(多进程处理模块)。您可以选择传统的“pre-fork”MPM,通常在 UNIX 上使用。您可以使用“worker”MPM,它是进程和线程的组合。如果您在 Windows 上,则有一个特殊的“mpm_winnt”MPM,它使用单个进程和多个线程。
“worker”MPM 可能是其中最有趣的,因为它允许您控制最大进程数(使用 MaxClients
指令),以及每个进程的线程数(使用 ThreadsPerChild
指令)。然后,您可以尝试服务器的最佳配置,决定哪种进程和线程的混合将为您带来最佳性能。
在过去的几年中,许多网络应用程序重新发现了一种编写代码的方法,这种方法似乎与所有这些想法背道而驰。不要有多个进程或多个线程,只需有一个进程,没有任何线程。然后,该进程可以处理所有传入的网络流量。
乍一看,这听起来有点疯狂。为什么将所有内容都放在一个进程中?
但是,然后考虑一下,即使在高度优化的 Linux 系统上,从一个进程移动到另一个进程的“上下文切换”仍然存在一些开销。当您从线程切换到线程时,这种开销会在进程内部以较小的级别重复出现。
如果您在一个进程中处理所有传入的网络请求,则可以避免所有这些上下文切换。您可以通过拥有一个事件循环,然后通过将函数挂载到该事件循环来实现。如果您的事件循环包含函数 A、B 和 C,则系统会给 A 一个运行的机会,然后是 B,然后是 C,然后再是 A。通过事件循环提供的每个机会,您会发现 A、B 和 C 每次都会向前推进一点。
这实际上效果很好,并且已被证明比进程和线程具有更好的扩展性。那么问题是什么呢?
首先,代码需要以可以划分为函数并放入事件循环的方式编写。以这种方式思考和编写这种风格的代码与人们通常在过程式和面向对象的世界中所习惯的不同。在许多方面,这就像创建一个回调函数,因为您不太清楚它何时运行。
除其他事项外,在这种函数中处理 I/O 时,您需要非常小心。这是因为磁盘和网络非常慢,如果函数 B 正在从磁盘读取数据,那么它会空闲等待,而 A 和 C 都没有机会运行。因此,处理 I/O 需要特殊处理。
这是一个更大的问题的一部分,即现代操作系统使用“抢占式多任务处理”,告诉每个进程其时间片何时到期。反应器模式使用“协同式多任务处理”,因为一个 rogue 函数可能仅仅通过未能遵守规则来占用 CPU。
这种范例被称为“反应器模式”,它是 nginx HTTP 服务器、node.js、Twisted Python 和 Python 中新的 asyncio 库的基础。它已经证明了自己,但它确实要求开发人员以新的和不同的方式思考。
为了不被 nginx 比下去,Apache 现在有一个“event”MPM,它使用这种方法处理事情。因此,如果您是 Apache 的粉丝,并且想尝试反应器模式而不切换到 nginx,您可以这样做。如果您只是使用服务器并连接到外部应用程序,而不是编写将嵌入到 Apache 中的代码,则 MPM 将影响性能,但不会影响您编写应用程序的方式。
结论那么,这会让您处于什么境地呢?首先,这意味着您有很多选择。这也意味着,当您开始担心性能时——只有那时——您才能开始运行实验来比较不同的范例及其工作方式。
但这也意味着,在当今高度网络化的世界中,您可能需要立即考虑这些选项中的一个或多个。至少,您应该熟悉它们以及它们的工作方式,以及与它们相关的权衡。特别是,虽然反应器模式可能难以理解,但这种理解将使设计可扩展的架构变得更容易——尤其是当您真正需要它时。