Z
Published on
· Last modified on
· Public

用 Python 理解服务器模型(下)

在 “用 Python 理解服务器模型(上)” 中, select 我是先以例子讲解的,缺少理论部分,在这篇文章中,我补充一下理论部分。

4.2 Event Driven 事件驱动

在 4.1 中我简单的把 select 作为避免 busy wait 的一个解决方案。而实际上非阻塞和IO多路复用的组合,有另一个更好的名字 “事件驱动” 。

第1,2章中的阻塞模型实现非常直接,每行代码都是按照它的顺序进行,我们不需要关心什么时候可以 accept 什么时候可以 recv ,只要调用函数等待返回结果就可以了。我们甚至根本不知道有“事件”的存在,因为在这种模型里“事件”是由操作系统维护的。操作系只负责到“进程”和“线程”这个层次,所以当事件需要等待时将“进程”或“线程”挂起,在事件发生时再将“进程”或“线程”唤醒。

阻塞模式非常适合客户端比较少的情况下使用。代码实现简单直接且不容易出 Bug 。如果我们需要实现一个高并发 Web Server 的时候这种方式的处理效率就捉襟见肘了。

非阻塞的作用是将操作系统对事件的处理权要回,在遇到阻塞事件时不再阻塞阻塞进程,而是告诉进程这个事件是否发生(返回数据,有事件。返回 EAGAIN ,无事件)。

再配合 IO多路复用 似乎就清楚很多了,非阻塞将事件的控制权要回,IO多路复用器用于接收事件通知。这两个功能足以让我们实现一个强大的“事件驱动”的服务器模型。

本还有一个更好的方式,使用 signal ,但因为早期 signal 的设计不佳,可扩展性太差。在现在看来几乎无法用 signal 实现一个稳定的IO事件通知,且 signal 在多线程模式下,会遇到各种各样的问题。所以 signal 除非必要不建议使用。

4.3 Epoll 的例子

import os
import fcntl
import socket
import select

response = 'HTTP/1.1 200 OK\r\nConnection: Close\r\nContent-Length: 1\r\n\r\nA'

server = socket.socket()
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(('0.0.0.0', 8080))
server.listen(32)

flags = fcntl.fcntl(server.fileno(), fcntl.F_GETFL)
fcntl.fcntl(server.fileno(), fcntl.F_SETFL, flags | os.O_NONBLOCK)

clients = {}

epoll = select.epoll()
epoll.register(server, select.EPOLLIN)

while True:
        events = epoll.poll()

        for fileno, event in events:
                if fileno == server.fileno():
                        client, clientaddr = server.accept()
                        epoll.register(client, select.EPOLLIN)
                        clients[client.fileno()] = client
                elif event == select.EPOLLIN:
                        client = clients[fileno]
                        request = client.recv(4096)
                        client.send(response)
                        del clients[fileno]
                        epoll.unregister(client)
                        client.close()

epoll 的例子与 select 的例子相差不多,但在某些情况下他们的性能可能会有非常大的差别。

select 的实现比较早,那时并没有考虑到会有成千上万个连接的情况。所以 select 返回的时候并不返回发生事件的 fd ,它只会告诉你有 N 个 fd 有事件,但具体是哪个 fd ,哪个事件,需要自己去找出来。我们不得不用循环的方式挨个检查我们传给 selectfd 。这样就会导致当连接数量比较多且通信比较少的时候(比如长连接心跳),每次都要循环检查一遍所有的连接,真是白白浪费 CPU

epollselect 不同的是它会返回具体的 fd 。我们只要处理有事件的 fd 就可以了。性能和连接数的多少没有关系。

4.4 Edge Trigger/Level Trigger 边缘触发/水平触发

同样是电子通信领域的名词,文字描述有点儿困难,我用两个图来表示。

在图中

  • x 轴表示时间。
  • high 表示 fd 不可读或不可写。
  • low 表示 fd 可读或可写。
  • 0 表示 IO 多路复用器不返回事件。
  • 1 表示 IO 多路复用器返回事件。

Edge Trigger 边缘触发

high           00000000      00000000
               0      0      0      0
               0      0      0      0
low     00000001      00000001      00000000

Level Trigger 水平触发

high           11111111      11111111
               1      1      1      1
               1      1      1      1
low     00000001      10000001      10000000

一图胜千言,简单来说就是当边缘触发时,只有 fd 变成可读或可写的那一瞬间才会返回事件。当水平触发时,只要 fd 可读或可写,一直都会返回事件。

我们来举个例子,当一个 fd 收到 1000 字节的数据,这时无论 边缘触发 还是 水平触发 都会返回可读事件。如果我们调用 recv 读了 500 字节并返回 IO多路复用器 。在 边缘触发 的IO多路复用器中,因为可读的状态并没有回到 low 位,我们以后永远都不会再收 fd 的读事件了,除非我们把所有 buffer 中的数据全部读完。在条件触发的IO多路复用器中,我们会立即再次收到可读事件,直到我们把剩下的 500 字节 recv 完。

通常来讲,我们倾向于使用 Level Trigger 处理读事件,以防代码有 Bug 导致接收不到可读事件。

4.5 Trap #1 Writable Event 第一个坑,写事件

我在第一章埋了一个坑,有可能你已经猜到了,就是那个 'maybe blocking' 的 send

如果你忘记了,可以回去再看一遍。在 Level Trigger 的条件下,我们必须先尝试直接调用 send 而不是把它加到 多路复用器 的写事件中。原因很明显,在 fd 的写缓存没被写满的情况下,fd可写的,如果把 fd 加到写事件,会被立即触发。当处理的 fd 数量很多的时候,这样做在白白浪费 CPU

我们来看一下多路复用器的处理流程。

Level Trigger 的读事件的处理顺序

  1. fd 加到读事件中。
  2. 读事件触发,循环调用 recv

    • 返回 EAGAIN,不需要任何处理,等待下次读事件。
    • 完成。

Level Trigger 的写事件的处理顺序

  1. 循环调用 send

    • 返回 EAGAIN ,将 fd 加到写事件中。
    • 完成:

      • 如果 fd 在写事件中,把 fd 从写事件中删除。
      • 完成
  2. 写事件触发,回到 1 。

处理写事件的过程略复杂,所以我们倾向于使用 Edge Trigger 处理写事件,这样就不用反复检查 fd 是否在 IO多路复用器 的写事件中。

Edge Trigger 的写事件的处理顺序

  1. 循环调用 send

    • 返回 EAGAIN ,将 fd 加到写事件中。
    • 完成
  2. 写事件触发,回到 1 。

只是有一个问题,现在 UNIX上 上无论 BSDkqueue 还是 Linuxepoll 暂时都不能给读写事件设置不同的触发条件。

所以现在普遍使用 Level Trigger 处理所有的事件,毕竟代码增加一些复杂度比可能会出现收不到事件的 Bug 要轻得多。

4.6 Trap #2 CPU bound 第二个坑,计算密集

这是 “事件驱动” 的“知名”缺陷之一,在收到事件之后,应该尽快处理完事件。否则一个事件处理时间太长,会卡住其它所有的事件。我们必须尽可能快的回到 IO多路复用器 的调用上。

这个问题并不是无解的,这里有几个方案可以参考。

  • 使用 Prefork + “事件驱动”。
  • 使用 Thread 或 ThreadPool 计算。
  • 使用 Queue 将计算交给其它进程。

几种方式各有优劣,读者不妨自己思考一下。我自己更倾向于用使用 Queue 。

4.7 Trap #3 Complexity 第三个坑,复杂度

在极端情况下,使用阻塞的方式实现 Server 一个循环就够了,比如 echo 服务。而在非阻塞下,这几乎是不可能的,尤其是在 C 这种不便实现 协程 的语言中。在 Request/Reply 这种的协议可以由 libeventmuduo 这样的库去处理,但如果是自己内部复杂的流协议,可能需要自己实现事件循环了。

5.0 How To Choose 如何选择

5.1 Small Client, Short Connection 少量客户端,短时间连接

典型应用:流量较少的静态 HTTP 服务

推荐模型:CGI ,Thread ,Prefork ,ThreadPool

5.2 Small Client, Long Connection 少量客户端,长时间连接

典型应用:数据库服务器

推荐模型:CGI , Thread

5.3 Large Client, Short Connection 大量客户端,短时间连接

典型应用:流量大的静态 HTTP 服务

推荐模型:Prefork ,ThreadPool ,Select

5.4 Large Client, Long Connection 大量客户端,长时间连接

典型应用:大流量 HTTP 服务, XMPP 服务,WebSocket 服务

推荐模型:Epoll

6 后记

没有后记

Z
Published on

注册页面的那个拿烟花的女孩照片好棒,哪里找的?

Z
Published on
zaza24kb

注册页面的那个拿烟花的女孩照片好棒,哪里找的?

https://unsplash.com/ 具体链接忘记了,你自己找一下吧。他们很多图片都很漂亮,而且可以免费使用。

Z
Published on
zhicheng
zaza24kb

注册页面的那个拿烟花的女孩照片好棒,哪里找的?

https://unsplash.com/ 具体链接忘记了,你自己找一下吧。他们很多图片都很漂亮,而且可以免费使用。

可否加个微信

Sign in or Sign up Leave Comment