Last updated
Last updated
(1) HTTP server
(2) WSGIGateway
(3) WSGI app
下面分别从这三部分介绍下
我们都知道,为了 OS 的安全性等的考虑
进程是无法直接操作 I/O 设备的,其必须通过系统调用请求内核来协助完成 I/O 动作,而内核会为每个 I/O 设备维护一个 buffer。如下图所示:
整个请求过程
在整个请求过程中,数据输入至 buffer 需要时间,而从 buffer 复制数据至进程也需要时间。因此根据在这两段时间内等待方式的不同,I/O 动作可以分为以下五种模式:
(1) 阻塞 I/O (Blocking I/O)
(2) 非阻塞 I/O (Non-Blocking I/O)
(3) I/O 复用(I/O Multiplexing)
(4) 信号驱动的 I/O (Signal Driven I/O)
(5) 异步 I/O (Asynchrnous I/O)
前四种为同步 IO,第 5 为异步 IO
阻塞 IO(blocking IO)的特点:就是在 IO 执行的两个阶段(等待数据和拷贝数据两个阶段)都被 block 了
2.2.1.1 一个简单的开始
阻塞
这个例子是阻塞的
在 accept 阻塞的时候,无法调用 recv 接收客户端数据。
在 recv 阻塞的时候也无法调用 accept 接受新的请求。
所以它同时只能接受和处理一个请求,如果一个客户端发送数据的速度比较慢,会拖慢整个服务器响应的速度。
第一处阻塞
第二处阻塞
第三处可能的阻塞
2.2.1.2 用进程或线程处理多个客户端
因为 accept 和 recv 互相阻塞,服务器才不能同时处理多个请求,那么把它们拆出来不就好了?
这是早期大多数服务器使用的办法,具体实现有下面几种。
CGI 的方式
这里的 accept 依然会阻塞,但一旦新的连接建立,会创建一个新的进程去调用 recv 和 send (handler 函数在新的进程中执行)。
在主进程中立即再次调用 accept 准备接收下一个客户端连接。
CGI 虽然能处理多个客户端的请求,可是它实在太慢了。打个比方,1.1.1 的代码相当于一辆汽车在一条公路上行驶,虽然每次只能送一个人,但它可以开得飞快。
在 CGI 中变成了无数辆汽车,一旦汽车的数量超过道路的负载就堵车了。
Thread 线程
进程换成了线程。既然给汽车太多会堵车,那么给每个人发一辆自行车好了,这样一条路就可以让更多的人通过了。
显然这样做是治标不治本啊,自行车多了,不还是会堵车吗。
Prefork
如果道路只允许固定的汽车同时通过能够保证道路的畅通,而且其它汽车等待的时间比堵车的时间还要短。 那我干脆只允许每次只有固定数量的汽车通过好了,如果路上没有位置,就让他等待别的车行驶到终点。 如果路上还有空余的位置,就立即通行。这样的设计即能同时处理多个请求,也能保证不会让系统变得很慢。
这个设计简直太聪明了。现在有相当多的服务器是这样实现的。
因为 UNIX 子进程会继承父进程的所有 fd 。显然 server 变量也不例外,这样只要在子进程中调用 accept 和 recv 就好了(相当于多个 第 1 章 中的实例),即使阻塞也只会阻塞一个进程。这个方式能够同时处理请求的数量等于子进程的数量。
你可能会有一个问题,“如果多个子进程同时阻塞在 accept 上,那么新的连接来了应该唤醒谁?”
这个现象叫“惊群效应”,在早期 Linux 内核中,每个阻塞在 accept 的子进程都会被唤醒,但只有一个进程能成功建立连接,其它的进程会返回一个错误。新的 Linux 内核已经不存在这个问题,每次只会唤醒一个进程,其它进程继续等待新的连接。
这个时候想,如果把 Prefork 中的 “汽车” 换成 “自行车” 会怎样?
ThreadPool 线程池
ThreadPool 有两个版本,第一个版本在主线程中 accept , 子线程中 recv 和 send
第二个版本,子线程同时 accept 并 recv 和 send
这个就是 “Prefork” 的线程池版。现在也有相当多的服务器是用这个方法实现的。
很难说 Prefork 和 ThreadPool 哪个更好,如果业务相对独立 Prefork 提供了更好的隔离性,进程之间不能访问彼此的内存空间,而且即使有一个子进程甚至主进程崩溃都不会影响正常的子进程。但如果需要共享数据的话,Prefork 是相当的麻烦。
当需要共享大量数据的时候 ThreadPool 用起来会更顺手。但也同时需要注意多线程临界区的加锁等。实现不好可能会变成死锁或者效率太低。
在线程池的版本中,第一个 ThreadPool 中的例子在 queue 上会有读写锁(主线程写,子线程读)。不过可以用 Lock Free 算法实现一个精巧的 Ring Buffer 避免这个问题,后边服务器优化的文章再介绍。
第二个 ThreadPool 的例子和 prefork 版本几乎是一模一样的。依赖内核的 accept 的实现。
CGI, Thread, Prefork, ThreadPool 的目标只有一个,解决阻塞调用拖慢服务器的问题。费了这么大的功夫,为什么不直接把“阻塞”的函数变成“不阻塞”的呢?
非阻塞式 IO 中,用户进程其实是需要不断的主动询问 kernel 数据准备好了没有
这两行代码把 server 设置成非阻塞模式。
现在 server 上的 accept 调用就成了非阻塞的了,并且在 accept 成功返回的 client 也会继承 server 的非阻塞特性,所以对 client 的 recv 调用,也是非阻塞的。
在非阻塞模式下调用阻塞函数(accept 和 recv),如果并没有可用的数据, 原本应阻塞的函数会直接返回 EAGAIN 或 EWOULDBLOCK 错误,程序可以继续执行,不会被操作系统挂起。
EAGAIN 和 EWOULDBLOCK 的错误码通常是相同的。但从兼容上考虑,需要两个错误分别判断。
在 Python 中是以 异常 的形式返回这两个错误的,我用 Exception 就为了捕获这两个错误码,处理方式是什么都不做。
这是处理非阻塞的 accept 调用,如果 accept 成功返回,就把返回的 client 放到一个 clients 的数组中,准备下一步接收 client 的数据。如果没有新的连接,accept 抛出异常,我们直接 pass 到下一步。
现在进程不会被操作系统挂起,将立即执行下面的代码。
这段代码并不难理解,for 循环遍历所有已经建立连接的 client 并 尝试 调用 recv 。如果 recv 返回数据,继续调用 send 发送回复,然后把 client 从 clients 中删除并关闭。如果抛出异常就 pass 到下一个 client 。
因为 Python 不允许在遍历一个数组的时候修改数组的内容 ( clients.remove(c) ),所以我用遍历的是 clients.copy() 。
实际上,这个例子中的代码是有严重性能问题的,假定以下情况。
(1) 没有新的连接请求。
(2) 现在也没有任何连接,clients 数组是空的。
当满足这两情况下的时候,服务器负载为 0 。会发生什么?
(1) accept 会立即失败进入 for 循环。
(2) for 循环 0 次立即进入下一次循环。
服务器逻辑相当于下面的代码。
进入了一个无意义的循环消耗 CPU ,这个叫做 busy wait 。这种情况在服务器编程中是不允许出现的。
那么如何避免这样的情况发生?方法可能让你大跌眼镜。
用 blocking 的方式。我们躲避了半天的 blocking ,又回来了。
这是电子通信的术语。把它用在非阻塞编程上,也是恰到好处。
2.2.3.1 Select
可看最后一章,传送门
2.2.3.2 epoll
select 的实现比较早,那时并没有考虑到会有成千上万个连接的情况。
所以 select 返回的时候并不返回发生事件的 fd ,它只会告诉你有 N 个 fd 有事件,但具体是哪个 fd ,哪个事件,需要自己去找出来。
我们不得不用循环的方式挨个检查我们传给 select 的 fd 。这样就会导致当连接数量比较多且通信比较少的时候(比如长连接心跳),每次都要循环检查一遍所有的连接,真是白白浪费 CPU 。
epoll 与 select 不同的是它会返回具体的 fd 。我们只要处理有事件的 fd 就可以了。性能和连接数的多少没有关系。
典型应用:流量较少的静态 HTTP 服务
推荐模型:CGI ,Thread ,Prefork ,ThreadPool
典型应用:数据库服务器
推荐模型:CGI , Thread
典型应用:流量大的静态 HTTP 服务
推荐模型:Prefork ,ThreadPool ,Select
典型应用:大流量 HTTP 服务, XMPP 服务,WebSocket 服务
推荐模型:Epoll