You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

103 lines
14 KiB
Markdown

2 years ago
# 08 | 事件驱动C10M是如何实现的
你好,我是陶辉。
上一讲介绍了广播与组播这种一对多通讯方式,从这一讲开始,我们回到主流的一对一通讯方式。
早些年我们谈到高并发总是会提到C10K这是指服务器同时处理1万个TCP连接。随着服务器性能的提升近来我们更希望单台服务器的并发能力可以达到C10M也就是同时可以处理1千万个TCP连接。从C10K到C10M实现技术并没有本质变化都是用事件驱动和异步开发实现的。[\[第5讲\]](https://time.geekbang.org/column/article/233629) 介绍过的协程,也是依赖这二者实现高并发的。
做过异步开发的同学都知道处理基于TCP的应用层协议时一个请求的处理代码必须被拆分到多个回调函数中由异步框架在相应的事件生成时调用它们。这就是事件驱动方式它通过减少上下文切换次数实现了C10M级别的高并发。
不过做应用开发的同学往往不清楚什么叫做“事件”不了解处理HTTP请求的回调函数与事件间的关系。这样在高并发下当多个HTTP请求争抢执行时涉及资源分配、释放等重要工作的回调函数就可能在错误的时间被调用进而引发一系列问题。比如不同的回调函数对应不同的事件如果某个函数执行时间过长就会影响其他请求可能导致大量请求出现超时而处理失败。
这一讲我们就来介绍一下事件是怎样产生的它是如何驱动请求执行的多路复用技术是怎样协助实现异步开发的理解了这些你也就明白了这种事件驱动的解决方案知道了怎么样实现C10M。
## 事件是怎么产生的?
要了解“事件驱动”的运作机制,首先就要搞清楚到底什么是事件。这就需要你对网络原理有深入的理解了。
简单来说从网络中接收到一个报文就可能产生一个事件。如上一讲介绍过的UDP请求就是最简单的例子一个UDP请求通常仅由一个网络报文组成所以当收到一个UDP报文就意味着收到一个请求它会生成一个事件进而触发回调函数执行。
不过常见的HTTP等协议都是基于TCP实现的。由于TCP是一种面向字节流的协议HTTP请求的大小并不受限制当一个HTTP请求的大小超过TCP报文的最大长度时请求会被拆分到多个报文中运输在接收端的缓冲区中重组、排序。因此并不是每个到达的TCP报文都能生成事件的。
如果不理解事件和TCP报文的关系就没法准确地掌握处理HTTP请求的函数何时被调用。当然作为应用开发工程师我们无须在意实现细节只要了解TCP连接建立、关闭以及消息的发送和接收这四个场景中报文与事件间的关系就可以了。
事件并没有你想象中那么复杂它只有两种类型读事件与写事件其中读事件表示有到达的消息需要处理而写事件表示可以发送消息TCP连接的写缓冲区中有可用空间。我们先从三次握手建立连接说起这一过程会产生一读、一写两个事件。
由于TCP允许双向传输所以**建立连接时,会依次在连接的两个方向上建立通道。**主动发起连接的一方叫做客户端,被动监听端口等待连接的一方叫做服务器。
客户端首先发送SYN报文给服务器而服务器收到后回复ACK和SYN这里我们只需要知道产生事件的过程即可下一讲会详细介绍这两个报文的含义**当它们到达客户端时,双向连接中由客户端到服务器的通道就建立好了,此时客户端就已经可以发送请求了,因此客户端会产生写事件。**接着,**客户端发送ACK报文到达服务器后服务器上会产生读事件**因为进程原本在监听80等端口此时有新连接建立成功应当调用accept函数读取这个连接所以这是一个读事件。
![](https://static001.geekbang.org/resource/image/73/98/73b9d890c7087531b51180ada6e65f98.png)
在建立好的TCP连接上收发消息时读事件对应着接收到对方的消息这很好理解。写事件则稍微复杂些我们举个例子加以说明。假设要发送一个2MB的请求**当调用write函数发送时会先把内存中的数据拷贝到写缓冲区中后再发送到网卡上。**
为何要多此一举呢这是因为在对方没有明确表示收到前TCP会通过定时器重发写缓冲区中的数据保证消息能够到达对方。写缓冲区是有大小限制的我在\[第10讲\]中会详细介绍。这里假设写缓冲区只有1MB所以调用write发送2MB数据时write函数的返回值只有1MB表示写缓冲区已用尽。当收到对方发来的ACK报文后缓冲区中的数据才能释放就会产生写事件通知进程发送剩余的那1MB数据。
![](https://static001.geekbang.org/resource/image/c5/7a/c524965bee6407bd716c7dc33bdd437a.png)
如同建立连接需要双向建立一样关闭连接也需要双方各自关闭每个方向的通道。主动关闭的一方发送FIN报文到达被动方后内核自动回复ACK报文这表示从主动方到被动方的通道已经关闭。**但被动方到主动方的通道也需要关闭所以此时被动方会产生读事件提醒被动方调用close函数关闭连接。**
![](https://static001.geekbang.org/resource/image/b7/96/b73164fd504cc2574066f526ebee7596.png)
这样我们就清楚了TCP报文如何产生事件也明白回调函数何时执行了。然而同步代码拆分成多个异步函数成本并不低咱们手里拿着事件驱动这个锤子可不能看到什么都像是钉子。
什么样的代码值得基于事件来做拆分呢还得回到高性能这个最终目标上来。我们知道做性能优化一定要找出性能瓶颈针对瓶颈做优化性价比才最高。对于服务器来说对最慢的操作做异步化改造才能值回开发效率的损失。而服务里对资源的操作速度由快到慢依次是CPU、内存、磁盘和网络。CPU和内存的执行速度都是纳秒级的无须考虑事件驱动而磁盘和网络都可以采用事件驱动的异步方式处理。
相对而言网络不只速度慢而且波动很大既受制于连接对端的性能也受制于网络传输路径。把操作网络的同步API改为事件驱动的异步API收益最大。而磁盘特别是机械硬盘访问速度虽然不快但它最慢时也不过几十毫秒是可控的。而且目前磁盘异步IO技术参见[\[第4讲\]](https://time.geekbang.org/column/article/232676)还不成熟它绕过了PageCache性能损失很大。所以当下的事件驱动主要就是指网络事件。
## 该怎样处理网络事件?
有了网络事件的概念后,我们再来看用户态代码如何处理事件。
网络事件是由内核产生的进程该怎样获取到它们呢如epoll这样的多路复用技术可以帮我们做到。多路复用是通讯领域的词汇有些抽象但原理确很简单。
比如一条高速的光纤上允许多个用户用较低的网速同时通讯这就是多路复用。同样道理一个进程虽然任一时刻只能处理一个请求但处理每个请求产生的事件时若耗时控制在1毫秒以内这样1秒钟就可以处理数千个请求从更长的时间维度上看多个请求复用了一个进程也叫做多路复用或者叫做时分多路复用。我们熟知的epoll就是内核提供给用户态的多路复用接口进程可以通过它从内核中获取事件。
epoll是如何获取网络事件的呢最简单的方法就是在获取事件时把所有并发连接传给内核再由内核返回产生了事件的连接再处理这些连接对应的请求即可。epoll前的select等多路复用函数就是这么干的。
然而C10M意味着有一千万个连接若每个socket是4字节那么1千万连接就是40M字节。这样每收集一次事件就需要从用户态复制40M字节到内核态。而且高性能Server必须及时地处理网络事件所以每隔几十毫秒就要收集一次事件性能消耗巨大。
epoll为了降低性能消耗把获取事件拆分成两步。
* 第一步把需要监控的socket传给内核epoll\_ctl函数它仅在连接建立等有限的时机调用
* 第二步收集事件epoll\_wait函数便不用传递socket了这样就把socket的重复传递改为了一次传递降低了性能损耗。
由于网卡的处理能力有限千兆网卡下每秒只能接收100MB左右的数据如果每个请求约10KB那么每秒大概有1万个请求到达、10万个事件需要处理。这样即使每隔100毫秒收集一次事件调用epoll\_wait每次也不过只有1万个事件100000 Event/s \* 0.1s = 10000 Event/s需要处理只要保证处理一个事件的平均时间小于10微秒多核处理器可以做到100毫秒内就可以处理完这些事件100ms = 10us \* 10000。 因此哪怕有1千万并发连接也能保证1万RPS的处理能力这就是epoll能在C10M下实现高吞吐量的原因。
进程获取到产生事件的socket后又该如何处理它呢这里的核心约束是处理任何一个事件的耗时都应该是微秒级或者毫秒级否则就会延误其他事件的处理不只降低了用户的体验而且会形成恶性循环。
我们知道为了应对网络的不确定性每个参与网络通讯的进程都会为请求设置超时时间。一旦某个socket上的事件迟迟不被处理当客户端的超时定时器触发时客户端往往会关闭连接并重发请求这会让服务器雪上加霜。
怎样保证处理一个事件的时间不会太长呢? 我们把处理事件的代码分为三类来看。
第一类是计算任务虽然内存、CPU的速度很快然而循环执行也可能耗时达到秒级。所以如果一定要引入需要密集计算才能完成的请求为了不阻碍其他事件的处理要么把这样的请求放在独立的线程中完成要么把请求的处理过程拆分成多段确保每段能够快速执行完同时每段执行完都要均等地处理其他事件这样通过放慢该请求的处理时间就保障了其他请求的及时处理。
第二类会读写磁盘由于磁盘的写入操作使用了PageCache的延迟写特性当write函数返回时只是复制到了内存中所以写入操作很快。磁盘的读取操作就比较慢了这时通常要把大文件的读取拆分成许多份每份仅有几十KB降低单次操作的耗时。
第三类是通过网络访问上游服务。与处理客户端请求相似我们必须使用非阻塞socket用事件驱动方式处理请求。需要注意的是许多网络服务提供的SDK都是基于阻塞socket实现的使用前必须先做完非阻塞改造。比如Memcached的官方SDK是用阻塞socket实现的Nginx如果直接使用该SDK访问它性能就会一落千丈。正确的访问方式是使用第三方提供的ngx\_http\_memcached\_module模块它用非阻塞socket重新封装了SDK。
总之网络报文到达后内核就产生了读、写事件而epoll函数使得进程可以高效地收集到这些事件。接下来要确保在进程中处理每个事件的时间足够短才能及时地处理所有请求这个过程中既要避免阻塞socket的使用也要把耗时过长的操作拆成多份执行。最终通过快速、及时、均等地执行所有事件异步Server实现了高并发。
## 小结
最后我们对这一讲做个小结。异步服务改为从事件层面处理请求在epoll这样的多路复用机制协助下最终实现了C10M级别的高并发服务。
事件有很多种网络消息的传输既慢又不可控所以用网络事件驱动请求的性价比最高。这样就需要你了解TCP报文是如何产生事件的。
TCP连接建立时会在客户端产生写事件在服务器端产生读事件。连接关闭时则会在被动关闭端产生读事件。在连接上收发消息时也会产生事件其中发送消息前的写事件与内核分配的缓冲区有关。
清楚了事件与TCP报文的关系后可以用多路复用技术获取事件其中epoll是佼佼者它取消了收集事件时重复传递的大量socket参数给C10M的实现提供了基础。
你需要注意的是处理epoll收集到的事件时必须保证处理一个事件的平均时间在毫秒级以内。传统的阻塞socket是做不到的所以必须用非阻塞socket替换阻塞socket。如果事件的回调函数耗时过长也得拆分为多个耗时短的函数用多次事件比如定时器事件的触发来替代。
虽然我们有了上述的事件驱动方案但实现C10M还需要更谨慎地使用不过数百GB的服务器内存。关于如何降低内存的消耗可以关注[\[第2讲\]](https://time.geekbang.org/column/article/230221) 提到的内存池,\[第11讲\] 还会介绍如何减少连接缓冲区的空间占用。
这一讲我们介绍了事件驱动的总体方案但C10M需要高效的用心几乎所有服务器资源所以我们还得通过Linux更精细地控制TCP的行为接下来的3讲我们将深入Linux讨论如何优化TCP的性能。
## 思考题
最后留给你一个思考题需要CPU做密集计算的请求该如何拆分到事件驱动框架中呢欢迎你在留言区留言与大家一起探讨。
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。