Warp是一个用Haskell(一种纯函数式编程语言)编写的,高性能的HTTP服务器库。Yesod(一个Web应用程序框架)和mighty
(一个HTTP服务器)都是基于Warp实现的。根据我们的吞吐量基准测试,mighty
的性能与nginx
相当。本文将解释Warp的架构以及我们如何实现其性能。Warp可以在许多平台上运行,包括Linux,BSD变体,Mac OS和Windows。但是,为了简化我们的解释,在本文的其余部分中,我们只讨论Linux。
有些人认为函数式编程语言速度慢或不实用。然而,据我们所知,Haskell为网络编程提供了一种近乎理想的方法。这是因为Glasgow Haskell Compiler(GHC),Haskell的旗舰编译器,提供了轻量级且健壮的用户线程(有时称为绿色线程)。在本节中,我们将简要回顾一些众所周知的服务器端网络编程方法,并将它们与Haskell中的网络编程进行比较。我们证明了Haskell提供了其他方法中无法获得的可编程性和性能的组合:Haskell的便捷抽象允许程序员编写清晰简单的代码,而GHC的复杂编译器和多核运行时系统生成多核程序,其执行方式非常类似于最先进的手工制作的网络程序。
传统的服务器使用一种称为线程编程的技术。在这种架构中,每个连接由单个进程或原生线程(有时称为OS线程)处理。
此架构可以根据用于创建进程或原生线程的机制进一步细分。当使用线程池时,会预先创建多个进程或原生线程。Apache中的预派生模式就是一个例子。否则,每次收到连接时都会生成一个进程或原生线程。图11.1说明了这一点。
图11.1 - 原生线程
此架构的优点是它使开发人员能够编写清晰的代码。特别是,线程的使用允许代码遵循简单且熟悉的控制流,并使用简单的过程调用来获取输入或发送输出。此外,由于内核将进程或原生线程分配给可用的核心,因此我们可以平衡核心的利用率。其缺点是内核和进程或原生线程之间发生了大量上下文切换,导致性能下降。
在高性能服务器领域,最近的趋势是利用事件驱动编程。在此架构中,多个连接由单个进程处理(图11.2)。Lighttpd是使用此架构的Web服务器的一个示例。
图11.2 - 事件驱动架构
由于无需切换进程,因此发生的上下文切换更少,从而提高了性能。这是其主要优势。
另一方面,此架构大大复杂化了网络程序。特别是,此架构反转了控制流,以便事件循环控制程序的整体执行。因此,程序员必须将其程序重构为事件处理程序,每个事件处理程序仅执行非阻塞代码。此限制阻止程序员使用过程调用执行I/O;相反,必须使用更复杂异步方法。同样,传统的异常处理方法不再适用。
许多人想到了创建N个事件驱动进程以利用N个核心的想法(图11.3)。每个进程称为工作进程。必须在工作进程之间共享服务端口。使用预派生技术,可以实现端口共享。
在传统的进程编程中,在接受连接后会为新连接派生一个进程。相反,预派生技术在接受新连接之前派生进程。尽管名称共享,但此技术不应与Apache的预派生模式混淆。
图11.3 - 每个核心一个进程
nginx
是一个使用此架构的Web服务器。Node.js过去使用事件驱动架构,但最近也实现了预派生技术。此架构的优点是它利用了所有核心并提高了性能。但是,由于依赖于处理程序和回调函数,它并没有解决程序清晰度差的问题。
GHC的用户线程可用于帮助解决代码清晰度问题。特别是,我们可以在新的用户线程中处理每个HTTP连接。此线程以传统样式编程,使用逻辑阻塞I/O调用。这使程序保持清晰和简单,而GHC处理非阻塞I/O和多核工作调度。
在幕后,GHC将用户线程多路复用到少量原生线程上。GHC的运行时系统包含一个多核线程调度程序,它可以廉价地在用户线程之间切换,因为它无需涉及任何OS上下文切换。
GHC的用户线程是轻量级的;现代计算机可以流畅地运行100,000个用户线程。它们是健壮的;即使是异步异常也会被捕获(此功能由超时处理程序使用,如Warp的架构和文件描述符的定时器中所述)。此外,调度程序包括一个多核负载平衡算法,以帮助利用所有可用核心的容量。
当用户线程执行逻辑阻塞I/O操作(例如在套接字上接收或发送数据)时,实际上会尝试进行非阻塞调用。如果成功,则线程会立即继续,而无需涉及I/O管理器或线程调度程序。如果调用将被阻塞,则线程会改为向运行时系统的I/O管理器组件注册相关事件的兴趣,然后指示调度程序它正在等待。独立地,一个I/O管理器线程监视事件,并在事件发生时通知线程,导致它们重新安排执行。所有这些对用户线程都是透明的,Haskell程序员无需付出任何努力。
在Haskell中,大多数计算都是非破坏性的。这意味着几乎所有函数都是线程安全的。GHC使用数据分配作为切换用户线程上下文的安全点。由于函数式编程风格,新数据会经常创建,并且已知此类数据分配会定期发生以进行上下文切换。
尽管过去一些语言提供了用户线程,但它们现在并不常用,因为它们不是轻量级的或不健壮的。请注意,一些语言提供了库级协程,但它们不是抢占式线程。另请注意,Erlang和Go分别提供了轻量级进程和轻量级goroutine。
在撰写本文时,mighty
使用预派生技术派生进程以使用更多核心。(Warp没有此功能。)图11.4说明了在使用Haskell编写的具有预派生技术的Web服务器的上下文中这种安排,其中每个浏览器连接由单个用户线程处理,并且一个进程中单个原生线程在CPU核心上运行处理来自多个连接的工作。
图11.4 - 每个核心一个进程的用户线程
我们发现GHC运行时系统本身的I/O管理器组件存在性能瓶颈。为了解决此问题,我们开发了一个并行I/O管理器,它使用每个核心的事件注册表和事件监视器来大大提高多核扩展性。使用并行I/O管理器的Haskell程序作为单个进程执行,多个I/O管理器作为原生线程运行以使用多个核心(图11.5)。每个用户线程都在任何一个核心上执行。
图11.5 - 单个进程中的用户线程
GHC 7.8版(包括并行I/O管理器)将于2013年秋季发布。使用GHC 7.8版,Warp本身将能够在没有任何修改的情况下使用此架构,并且mighty
将不需要使用预派生技术。
Warp是Web应用程序接口(WAI)的HTTP引擎。它通过HTTP运行WAI应用程序。如上所述,Yesod和mighty
都是WAI应用程序的示例,如图11.6所示。
图11.6 - Web应用程序接口(WAI)
WAI应用程序的类型如下
type Application = Request -> ResourceT IO Response
在Haskell中,函数的参数类型由右箭头分隔,最右边的类型是返回值的类型。因此,我们可以将定义解释为:一个WAI Application
获取一个Request
并返回一个Response
,用于I/O可能且资源得到良好管理的上下文中。
接受新的HTTP连接后,将为该连接生成一个专用的用户线程。它首先从客户端接收HTTP请求并将其解析为Request
。然后,Warp将Request
传递给WAI应用程序并从中接收Response
。最后,Warp根据Response
值构建HTTP响应并将其发送回客户端。图11.7说明了这一点。
图11.7 - Warp的架构
用户线程根据需要重复此过程,并在对等方关闭连接或收到无效请求时终止自身。如果在一段时间后没有收到大量数据(即超时),线程也会终止。
在解释如何提高Warp的性能之前,我们想展示基准测试的结果。我们测量了mighty
2.8.4版(使用Warp 1.3.8.1版)和nginx
1.4.0版的吞吐量。我们的基准测试环境如下
我们过去测试过几种基准测试工具,我们最喜欢的是httperf
。由于它使用select()
并且只是一个单进程程序,因此当我们尝试测量多核机器上的HTTP服务器时,它会达到其性能限制。因此,我们切换到weighttp
,它基于libev
(epoll
系列)并且可以使用多个原生线程。我们从FreeBSD使用weighttp
如下
weighttp -n 100000 -c 1000 -t 10 -k http://<ip_address>:<port_number>/
这意味着建立了1000个HTTP连接,每个连接发送100个请求。同时,会生成10个原生线程来执行这些任务。
目标Web服务器是在Linux上编译的。对于所有请求,都会返回相同的index.html
文件。我们使用的是nginx
的index.html
,大小为151字节。
由于Linux/FreeBSD有很多控制参数,我们需要仔细配置这些参数。您可以在ApacheBench & HTTPerf中找到关于Linux参数调优的良好介绍。我们对mighty
和nginx
进行了如下仔细配置
以下是结果
图11.8 - Warp的性能
X轴表示工作进程的数量,Y轴表示吞吐量,以每秒请求数衡量。
+RTS -qa -A128m -N<x>
,其中<x>
是核心数,128m
是垃圾回收器使用的分配区域大小。在使用Haskell实现高性能服务器时,我们牢记了四个关键思想
虽然系统调用在大多数现代操作系统上通常开销很小,但当频繁调用时,它们会增加明显的计算负担。实际上,Warp在处理每个请求时都会执行多个系统调用,包括recv()
、send()
和sendfile()
(允许零拷贝文件的系统调用)。其他系统调用,如open()
、stat()
和close()
,在处理单个请求时通常可以省略,这要归功于文件描述符计时器中描述的缓存机制。
我们可以使用strace
命令查看实际使用了哪些系统调用。当我们使用strace
观察nginx
的行为时,我们注意到它使用了accept4()
,当时我们对此一无所知。
使用Haskell的标准网络库,会创建一个带有非阻塞标志的监听套接字。当从监听套接字接受新连接时,需要将相应的套接字也设置为非阻塞。网络库通过调用fcntl()
两次来实现这一点:一次获取当前标志,另一次在启用非阻塞标志的情况下设置标志。
在Linux上,即使监听套接字是非阻塞的,已连接套接字的非阻塞标志也始终未设置。系统调用accept4()
是Linux上accept()
的扩展版本。它可以在接受时设置非阻塞标志。因此,如果我们使用accept4()
,可以避免对fcntl()
进行两次不必要的调用。我们使用accept4()
的补丁已合并到网络库中。
GHC提供了一种性能分析机制,但它有一个限制:只有当程序在前台运行且不生成子进程时,才能进行正确的性能分析。如果我们想分析服务器的实时活动,需要特别注意。
mighty
具有这种机制。假设N是mighty
配置文件中的工作进程数。如果N大于或等于2,mighty
会创建N个子进程,父进程只负责传递信号。但是,如果N为1,mighty
不会创建任何子进程。相反,执行的进程本身会服务于HTTP。此外,如果调试模式打开,mighty
会停留在其终端中。
当我们对mighty
进行性能分析时,我们惊讶地发现,格式化日期字符串的标准函数消耗了大部分CPU时间。众所周知,HTTP服务器应该在标头字段(如Date
、Last-Modified
等)中返回GMT日期字符串。
Date: Mon, 01 Oct 2012 07:38:50 GMT
因此,我们实现了一个特殊的格式化程序来生成GMT日期字符串。使用criterion
基准测试库对我们专门的功能和标准的Haskell实现进行比较表明,我们的速度要快得多。但是,如果HTTP服务器每秒接受多个请求,服务器会一遍又一遍地重复相同的格式化操作。因此,我们还为日期字符串实现了缓存机制。
我们还在编写解析器和HTTP响应标头组合器中解释了专门化和避免重复计算。
不必要的锁对于编程来说是邪恶的。我们的代码有时会不知不觉地使用不必要的锁,因为在内部,运行时系统或库会使用锁。为了实现高性能服务器,我们需要识别这些锁,并在可能的情况下避免使用它们。值得指出的是,在并行I/O管理器下,锁会变得更加重要。我们将在连接计时器和内存分配中讨论如何识别和避免锁。
Haskell中用于字符串的标准数据结构是String
,它是一个Unicode字符的链表。由于列表编程是函数式编程的核心,因此String
在许多方面都很方便。但是对于高性能服务器来说,列表结构太慢,Unicode也太复杂,因为HTTP协议基于字节流。相反,我们使用ByteString
来表示字符串(或缓冲区)。ByteString
是一个带有元数据的字节数组。由于此元数据,可以实现无复制拼接。这在编写解析器中进行了详细说明。
其他合适的数据结构示例包括Builder
和双IORef
。它们分别在HTTP响应标头组合器和连接计时器中进行了说明。
除了多核环境中高效并发和I/O涉及的许多问题之外,Warp还需要确保每个核心都在高效地执行其任务。在这方面,最相关的组件是HTTP请求处理器。它的目的是获取来自传入套接字的字节流,解析出请求行和各个标头,并将请求正文留给应用程序处理。它必须获取此信息并生成一个数据结构,该数据结构将由应用程序(无论是Yesod应用程序、mighty
还是其他内容)用于形成其响应。
请求正文本身提出了一些有趣的挑战。Warp完全支持流水线和分块请求正文。因此,Warp必须在将任何分块请求正文传递给应用程序之前对其进行“解块”。使用流水线,可以在单个连接上传输多个请求。因此,Warp必须确保应用程序不会消耗过多的字节,因为这会从下一个请求中删除重要信息。它还必须确保丢弃请求正文中剩余的任何数据;否则,其余部分将被解析为下一个请求的开头,从而导致无效请求或误解请求。
例如,考虑以下来自客户端的理论请求
POST /some/path HTTP/1.1
Transfer-Encoding: chunked
Content-Type: application/x-www-form-urlencoded
0008
message=
000a
helloworld
0000
GET / HTTP/1.1
HTTP解析器必须提取/some/path
路径名和Content-Type
标头并将它们传递给应用程序。当应用程序开始读取请求正文时,它必须剥离块标头(例如0008
和000a
),并提供实际内容,即message=helloworld
。它还必须确保在块终止符(0000
)之后不再消耗任何字节,以免干扰下一个流水线请求。
Haskell以其强大的解析能力而闻名。它拥有传统的解析器生成器以及组合器库,例如Parsec和Attoparsec。Parsec和Attoparsec的文本模块以完全Unicode感知的方式工作。但是,HTTP标头保证为ASCII,因此Unicode感知是我们不需要产生的开销。
Attoparsec还提供了用于解析的二进制接口,这将使我们能够绕过Unicode开销。但是,尽管Attoparsec效率很高,但与手工编写的解析器相比,它仍然会产生开销。因此,对于Warp,我们没有使用任何解析器库。相反,我们手动执行所有解析。
这引发了另一个问题:我们如何表示实际的二进制数据?答案是ByteString
,它本质上是三部分数据:指向某块内存的指针、从该内存的开头到相关数据的偏移量以及我们数据的尺寸。
偏移量信息似乎是冗余的。我们可以改为坚持让我们的内存指针指向我们数据的开头。但是,通过包含偏移量,我们启用了数据共享。多个ByteString
都可以指向同一块内存并使用其中的不同部分(也称为拼接)。无需担心数据损坏,因为ByteString
(与大多数Haskell数据一样)是不可变的。当不再使用指向某块内存的最终指针时,内存缓冲区将被释放。
这种组合非常适合我们的用例。当客户端通过套接字发送请求时,Warp会以相对较大的块(目前为4096字节)读取数据。在大多数情况下,这足以包含整个请求行和所有请求标头。然后,Warp将使用其手工编写的解析器将此大块拆分为行。由于以下原因,这可以高效地完成
我们只需要扫描内存缓冲区以查找换行符。ByteString库提供了此类辅助函数,这些函数使用memchr
等较低级别的C函数实现。(由于多行标头,实际上比这复杂一点,但相同的基本方法仍然适用。)
无需分配额外的内存缓冲区来保存数据。我们只需从原始缓冲区中获取拼接部分。有关从较大数据块中拼接各个组件的演示,请参见图11.9。值得强调这一点:我们最终获得的情况实际上比惯用的C更有效。在C中,字符串以空字符结尾,因此拼接需要分配一个新的内存缓冲区,将数据从旧缓冲区复制到新缓冲区,并附加空字符。
图11.9 - 拼接ByteString
一旦缓冲区被拆分为行,我们就会执行类似的操作,将标头行转换为键/值对。对于请求行,我们会相当深入地解析请求的路径。假设我们有一个对以下内容的请求
GET /buenos/d%C3%ADas HTTP/1.1
在这种情况下,我们需要执行以下步骤
["buenos", "d%C3%ADas"]
。["buenos", "d\195\173as"]
。["buenos", "días"]
。在此过程中,我们获得了一些性能提升
我们执行的最后一步解析是去分块。在许多方面,去分块是一种更简单的解析形式。我们解析一个十六进制数字,然后读取指定数量的字节。这些字节逐字地(/无需任何缓冲区复制)传递给应用程序。
本文多次提到了将请求主体传递给应用程序的概念。它还暗示了应用程序将响应传递回服务器,以及服务器从套接字接收和发送数据的问题。另一个尚未讨论的相关要点是中间件,它们是位于服务器和应用程序之间修改请求或响应的组件。中间件的定义是
type Middleware = Application -> Application
其背后的直觉是,中间件将获取某个“内部”应用程序,预处理请求,将其传递给内部应用程序以获取响应,然后后处理响应。对于我们的目的,一个好的例子是 gzip 中间件,它会自动压缩响应主体。
创建此类中间件的先决条件是修改传入和传出数据流的方法。历史上,在 Haskell 世界中,一种标准方法是惰性 I/O。使用惰性 I/O,我们将值流表示为单个纯数据结构。当从该结构请求更多数据时,将执行 I/O 操作以从其源获取数据。惰性 I/O 提供了极高的可组合性。但是,对于高吞吐量服务器而言,它带来了一个主要障碍:惰性 I/O 中的资源最终化是不确定的。使用惰性 I/O,负载过高的服务器很容易很快耗尽文件描述符。
也可以使用更低级的抽象,基本上直接处理读写函数。但是,Haskell 的优势之一是其高级方法,使我们能够推断代码的行为。也不清楚这样的解决方案将如何处理创建 Web 应用程序时出现的一些常见问题。例如,通常需要缓冲解决方案,其中我们在一步中读取一定数量的数据(/例如,请求标头处理),并在代码库的另一部分(例如,Web 应用程序)中读取其余数据。
为了解决这个难题,WAI 协议(因此还有 Warp)构建在管道包之上。此包为数据流提供了一个抽象。它保留了惰性 I/O 的大部分可组合性,提供了一个缓冲解决方案,并保证了确定性的资源处理。异常也保留在其所属的位置,即处理 I/O 的代码部分,而不是隐藏在一个声称是纯数据结构的数据结构中。
Warp 将来自客户端的传入字节流表示为Source
,并将要发送到客户端的数据写入Sink
。Application
使用请求主体中的Source
提供请求主体,并以Source
的形式提供响应。中间件能够拦截请求和响应主体的Source
并对其应用转换。图 11.10 演示了中间件如何在 Warp 和应用程序之间配合工作。管道包的可组合性使这成为一项简单而高效的操作。
图 11.10 - 中间件
详细说明 gzip 中间件示例,管道允许我们创建一个以接近最佳方式运行的中间件。应用程序提供的原始Source
连接到gzip
Conduit
。随着初始Source
产生每个新的数据块,它会被馈送到zlib
库中,用压缩字节填充缓冲区。当该缓冲区被填满时,它会被发出,要么发送到另一个中间件,要么发送到 Warp。然后,Warp 获取此压缩缓冲区并将其通过套接字发送到客户端。此时,缓冲区可以被重用,也可以释放其内存。这样,我们就可以获得最佳的内存使用率,在网络故障的情况下不会产生任何额外的数据,并减少运行时系统垃圾回收的负担。
管道本身是一个很大的话题,因此不会在此深入探讨。现在,可以说管道在 Warp 中的使用是其高性能的一个促成因素。
我们还有一个最终的担忧:Slowloris 攻击。这是一种拒绝服务 (DoS) 攻击形式,其中每个客户端发送非常少量的信息。通过这样做,客户端能够在相同的硬件/带宽上维持更多数量的连接。由于 Web 服务器对每个打开的连接都有恒定的开销,而不管传输的字节数如何,这可能是一种有效的攻击。因此,Warp 必须检测到连接何时未通过网络发送足够的数据并将其杀死。
我们在下面更详细地讨论超时管理器,它是 Slowloris 防护的真正核心。在请求处理方面,我们的唯一要求是戏弄超时处理程序,让它知道已从客户端接收到更多数据。在 Warp 中,这一切都在管道级别完成。如前所述,传入数据表示为Source
。作为该Source
的一部分,每次接收到新的数据块时,都会戏弄超时处理程序。由于戏弄处理程序是一个非常廉价的操作(本质上只是一个内存写入),因此 Slowloris 防护不会以显著的方式阻碍单个连接处理程序的性能。
本节描述 Warp 的 HTTP 响应组合器。WAI Response
有三个构造函数
ResponseFile Status ResponseHeaders FilePath (Maybe FilePart)
ResponseBuilder Status ResponseHeaders Builder
ResponseSource Status ResponseHeaders (Source (ResourceT IO) (Flush Builder))
ResponseFile
用于发送静态文件,而 ResponseBuilder
和 ResponseSource
用于发送在内存中创建的动态内容。每个构造函数都包含Status
和ResponseHeaders
。ResponseHeaders
定义为键/值头对列表。
旧的组合器使用Builder
(一种类似绳子的数据结构)构建 HTTP 响应头。首先,它将Status
和ResponseHeaders
的每个元素转换为Builder
。每次转换都在 O(1) 时间内完成。然后,它通过重复将一个Builder
追加到另一个Builder
来连接它们。由于Builder
的特性,每个追加操作也都在 O(1) 时间内完成。最后,它通过将数据从Builder
复制到缓冲区中来打包 HTTP 响应头,这需要 O(N) 时间。
在许多情况下,Builder
的性能就足够了。但我们发现它对于高性能服务器来说不够快。为了消除Builder
的开销,我们通过直接使用memcpy()
(C 中一个经过高度调整的字节复制函数)实现了一个特殊的 HTTP 响应头组合器。
对于ResponseBuilder
和ResponseSource
,应用程序提供的Builder
值被打包成一个ByteString
列表。一个组合的标头被预先添加到列表中,并且send()
用于在一个固定缓冲区中发送该列表。
对于ResponseFile
,Warp 使用send()
和sendfile()
分别发送 HTTP 响应头和主体。图 11.7 说明了这种情况。同样,由于文件描述符计时器中描述的缓存机制,可以省略open()
、stat()
、close()
和其他系统调用。下一小节描述了在ResponseFile
情况下进行的另一种性能调整。
当我们测量 Warp 发送静态文件的性能时,我们总是以高并发(同时多个连接)的方式进行,并取得了良好的结果。但是,当我们将并发值设置为 1 时,我们发现 Warp 非常慢。
观察tcpdump
命令的结果,我们意识到这是因为最初 Warp 使用了writev()
用于标头和sendfile()
用于主体的组合。在这种情况下,HTTP 标头和主体在单独的 TCP 数据包中发送(图 11.11)。
图 11.11 - 旧版 Warp 的数据包序列
为了将它们发送到单个 TCP 数据包中(在可能的情况下),新的 Warp 从writev()
切换到send()
。它使用带有MSG_MORE
标志的send()
来存储标头,并使用sendfile()
来发送存储的标头和文件。根据我们的吞吐量基准测试,这使得吞吐量至少提高了 100 倍。
本节解释如何实现连接超时以及如何缓存文件描述符。
为了防止 Slowloris 攻击,如果客户端在一段时间内未发送大量数据,则应取消与客户端的通信。Haskell 提供了一个名为timeout
的标准函数,其类型如下
Int -> IO a -> IO (Maybe a)
第一个参数是超时的持续时间,以微秒为单位。第二个参数是一个处理输入/输出 (IO
) 的动作。此函数在IO
上下文中返回Maybe a
的值。Maybe
定义如下
data Maybe a = Nothing | Just a
Nothing
表示错误(未指定原因),而Just
封装了成功的值a
。因此,如果动作在指定时间内未完成,则timeout
返回Nothing
。否则,将返回一个成功的值,并用Just
包装。timeout
函数巧妙地展示了 Haskell 的可组合性有多么强大。
timeout
可用于许多目的,但其性能不足以实现高性能服务器。问题在于,对于创建的每个超时,此函数都会生成一个新的用户线程。虽然用户线程比系统线程更便宜,但它们仍然涉及开销,这些开销可能会累积起来。我们需要避免为每个连接的超时处理创建用户线程。因此,我们实现了一个超时系统,该系统仅使用一个用户线程(称为超时管理器)来处理所有连接的超时。其核心是以下两个想法
IORef
假设连接的状态描述为Active
和Inactive
。为了清理非活动连接,超时管理器会重复检查每个连接的状态。如果状态为Active
,则超时管理器将其更改为Inactive
。如果为Inactive
,则超时管理器会终止其关联的用户线程。
每个状态都由一个IORef
引用。IORef
是一个引用,其值可以被破坏性地更新。除了超时管理器之外,每个用户线程还会通过其自己的IORef
重复将其状态更改为Active
,因为其连接正在继续活动。
超时管理器使用这些状态的IORef
列表。为新连接生成的用户线程尝试将其新的IORef
(表示Active
状态)添加到列表的开头。因此,该列表是一个临界区,我们需要原子性来保持列表的一致性。
图 11.12 - 状态值列表。和分别表示和
Haskell 中保持一致性的标准方法是使用MVar
。但MVar
速度较慢,因为每个MVar
都受到自制锁的保护。作为替代,我们使用了另一个IORef
来引用列表,并使用atomicModifyIORef
来操作它。atomicModifyIORef
是一个用于原子地更新IORef
值的函数。它通过CAS(比较并交换)实现,比锁快得多。
以下是安全交换和合并算法的概述
do xs <- atomicModifyIORef ref (\ys -> ([], ys)) -- swap with an empty list, []
xs' <- manipulates_status xs
atomicModifyIORef ref (\ys -> (merge xs' ys, ()))
超时管理器原子地将列表与空列表交换。然后它通过切换线程状态或删除已终止用户线程的不必要状态来操作列表。在此过程中,可能会创建新的连接,并且其状态值会由相应的用户线程通过atomicModifyIORef
插入。然后,超时管理器原子地合并修剪后的列表和新列表。由于 Haskell 的惰性求值,合并函数的应用在 O(1) 时间内完成,而合并操作(在 O(N) 时间内完成)则被推迟到其值实际被使用时。
让我们考虑 Warp 使用sendfile()
发送整个文件的情况。不幸的是,我们需要调用stat()
来获取文件的大小,因为 Linux 上的sendfile()
要求调用方指定要发送的字节数(FreeBSD/MacOS 上的sendfile()
有一个表示文件结尾的魔法数字0)。
如果WAI应用程序知道文件大小,Warp 就可以避免使用stat()
。对于WAI应用程序来说,缓存文件信息(如大小和修改时间)很容易。如果缓存超时足够快(例如 10 秒),则缓存不一致的风险并不严重。因为我们可以安全地清除缓存,所以不必担心泄漏。
由于sendfile()
需要文件描述符,因此发送文件的朴素序列是open()
、根据需要重复调用sendfile()
,以及close()
。在本小节中,我们将考虑如何缓存文件描述符以避免不必要地调用open()
和close()
。缓存文件描述符应按如下方式工作:如果客户端请求发送文件,则通过open()
打开一个文件描述符。如果另一个客户端随后不久请求相同的文件,则重用先前打开的文件描述符。稍后,如果没有任何用户线程使用该文件描述符,则通过close()
关闭它。
这种情况的典型策略是引用计数。我们不确定是否可以实现一个健壮的引用计数器。如果用户线程因意外原因被终止会发生什么?如果我们未能递减其引用计数器,则文件描述符会泄漏。我们注意到,连接超时方案可以安全地用作文件描述符的缓存机制,因为它不使用引用计数器。但是,我们不能简单地出于几个原因重用超时管理器。
每个用户线程都有自己的状态——状态不共享。但我们希望通过共享缓存文件描述符以避免open()
和close()
。因此,我们需要在一个缓存文件描述符集合中搜索请求文件的描述符。由于此搜索应该很快,所以我们不应该使用列表。因为请求是并发接收的,所以可能会为同一个文件打开两个或多个文件描述符。因此,我们需要为单个文件名存储多个文件描述符。我们正在描述的数据结构称为多映射。
我们实现了一个多映射,其查找操作为 O(log N),修剪操作为 O(N),使用节点包含非空列表的红黑树。由于红黑树是二叉搜索树,因此查找操作为 O(log(N)),其中 N 是节点数。我们还可以将其转换为 O(N) 时间内的有序列表。在我们的实现中,修剪包含要关闭的文件描述符的节点也在此步骤中完成。我们采用了在 O(N) 时间内将有序列表转换为红黑树的算法。
我们有一些关于未来改进 Warp 的想法,但这里只解释两个。
在接收和发送数据包时,会分配缓冲区。这些缓冲区被分配为“固定”字节数组,以便它们可以传递给像recv()
和send()
这样的 C 过程。由于最好在每个系统调用中接收或发送尽可能多的数据,因此这些缓冲区的大小适中。不幸的是,GHC分配大型(在 64 位机器上大于 409 字节)固定字节数组的方法在运行时系统中获取全局锁。如果每个核心用户线程频繁分配此类缓冲区,则当扩展到 16 个以上核心时,此锁可能会成为瓶颈。
我们对大型固定数组分配对HTTP响应报头生成的性能影响进行了初步调查。为此,GHC提供了eventlog
,它可以记录每个事件的时间戳。我们在内存分配函数周围添加了记录用户事件的函数。然后我们用它编译mighty
并记录了事件日志。生成的事件日志如下所示
图 11.13 - 事件日志
砖红色条表示我们创建的事件。因此,两个条形包围的区域是内存分配消耗的时间。大约是HTTP会话的 1/10。我们正在讨论如何在没有锁的情况下实现内存分配。
惊群问题是一个“老生常谈”的问题。假设进程或原生线程被预先分叉以共享监听套接字。它们在套接字上调用accept()
。当创建连接时,旧的 Linux 和 FreeBSD 实现会唤醒所有进程或线程。其中只有一个可以接受它,其他的则再次休眠。由于这会导致许多上下文切换,因此我们面临着性能问题。这称为惊群效应。最近的 Linux 和 FreeBSD 实现只唤醒一个进程或原生线程,使这个问题成为过去。
最近的网络服务器倾向于使用epoll
系列。如果工作进程共享一个监听套接字,并且它们通过epoll
系列操作连接,则惊群效应再次出现。这是因为epoll
系列的约定是通知所有进程或原生线程。nginx
和mighty
都是这种新的惊群效应的受害者。
并行 I/O 管理器不受新的惊群效应问题的影响。在这种架构中,只有一个 I/O 管理器通过epoll
系列接受新连接。其他 I/O 管理器处理已建立的连接。
Warp 是一个通用的 Web 服务器库,为各种用例提供高效的HTTP通信。为了实现其高性能,已经在许多层面进行了优化,包括网络通信、线程管理和请求解析。
Haskell 已被证明是编写此类代码库的绝佳语言。像默认情况下不变性这样的特性使编写线程安全代码并避免额外的缓冲区复制变得更容易。多线程运行时极大地简化了编写事件驱动代码的过程。而GHC强大的优化意味着在许多情况下,我们可以编写高级代码并仍然获得高性能的好处。然而,凭借所有这些性能,我们的代码库仍然相对较小(在撰写本文时,代码行数/少于 1300 行SLOC)。如果您希望编写可维护、高效、并发的代码,那么 Haskell 应该是一个强有力的选择。