CGI - 通用网关不通用

CGI 起源

从 Web 服务器说起

在万维网初期,Web 服务器接受并解析客户端发来的 HTTP 请求,返回请求所需的静态资源如 HTML 和图片。

随着技术的发展,Web 服务器实现了对动态网页的支持 (早期):根据请求调用外部扩展应用程序进行处理,这些程序动态处理完成后将结果返回给 Web 服务器和客户端。如下图所示:

下文将把 外部扩展应用程序 这类程序称为 Web 应用程序

web_server.png

但是不同的 HTTP 服务器使用各自不同的方式与 Web 应用程序做交互,这导致即使对于同样的请求进行同样的处理,也要做不同的实现。正是在这样的背景下,CGI 应运而生。

何为 CGI

CGI(Common Gateway Interface)即通用网关接口最早于 1993 年由 NCSA(美国国家超级电脑应用中心)设计与开发,之后被其他开发者接纳从而成为一种标准。1997 年由 Ken Coar 领导制定了更详细的规范,并最终输出为 RFC 3875。

它规定了 HTTP 服务器与 Web 应用程序之间该如何交互、如何传递数据、传递哪些数据等,其本质是一种 HTTP 服务器与 Web 应用程序之间的交互协议。CGI 制定的接口规范主要涉及三部分:环境变量、标准输入、标准输出

其中环境变量作为 HTTP 服务器与 Web 应用程序之间传递数据的一种方式,例如客户端通过 GET 请求传递而来的参数将被 HTTP 服务器放置于 QUERY_STRING 变量,请求方法放置在 REQUEST_METHOD ,HTTP服务器 拉起「Web 应用程序」的进程之后,Web 应用程序从环境变量中读取到请求方法、请求参数等必要信息。 Web 应用程序处理完成后将结果通过标准输出返回给 HTTP 服务器和客户端( Web 应用程序的输出将被 HTTP 服务器重定向至「HTTP 服务器与客户端之间建立的 socket」)。

所以何为 CGI?

CGI 是一种 HTTP 服务器与 Web 应用程序之间交互的协议。

使用了这套协议与 HTTP 服务器进行交互的程序被称为 CGI 程序。

由于早期这类程序通常采用轻量的脚本编写,所以也经常被称为 CGI 脚本

通用网关不通用

CGI 通用网关接口这个名字不难理解,其中网关接口表示 CGI 作为 HTTP 服务器与真正的 Web 应用程序之间的「中介」,起到接收请求和协议转换的「网关」作用。再结合当时不同 HTTP 服务器与 Web 程序之间具有不同的交互规则这一历史背景,因此 CGI 协议的目的并是提供一个通用交互协议。

但在企鹅厂工作期间,发现公司内部大量使用 CGI 技术,在部分团队中甚至是唯一的网关技术,久而久之,导致不少同事对 CGI 的理解产生了偏差。不少人将 CGI 直接理解为字面含义,即:通用网关接口。

在这种理解下,任何面向 Web 客户端实现了 HTTP 请求接收和协议转换的程序都被当作 CGI。在公司内部的日常讨论中,经常听到将 Java Servlet、Nodejs、Python 编写的 Web 程序称做为 CGI。这显然是错误的,CGI 仅仅是众多网关技术中的一种,并且作为一项古老的技术甚至不是主流的技术选择。

其他网关技术

CGI 在处理每个客户端请求时都会新建一个进程来运行 CGI 程序,如果是 CGI 脚本每次还需要初始化脚本解释器。这种 fork-and-execute 的处理模式会消耗大量的服务器资源,在面对大量客户端请求时,CGI 的技术将存在严重的性能问题。

所以后续又发展出了 Servlet、FastCGI 等更为高效的网关技术。

Apache HTTP Server

CGI 最早是为 NCSA 的 HTTPd 服务器设计和开发的,但之后 NCSA 的开发工作所有停滞。

而基于 HTTPd 的 Apache 服务器则于 1995 年初开始启动开发工作。从 1996 年开始成为最受欢迎的 Web 服务器之一。截止 2020 年 4 月,最为繁忙的百万个站点中有 29.12% 使用着 Apache,依然高于占比 25.54% 的 Nginx。

与之前的 HTTP 服务器相比,Apache 提供了 mod_* 模块的加载技术,从而能够将原先需独立运行的 Web 应用程序加载成 Apache 服务器的一个模块。

例如 Apache 通过 mod_php 并能加载 php 编写的 Web 应用程序,在服务器启动时并初始化 PHP 解析器,无需每次请求都重新启动解析器。同时由于 Web 应用程序作为 Apache 的一个模块运行,所以也不需要通过 CGI 协议实现两者之间的交互,请求 Web 应用程序将能够更加方便的使用 Apache 的提供的各种接口,也能够更好的融入 Apache 的生命周期管理。

通过 mod_* 模块技术,可以说将原本分离的 Web 应用程序和 HTTP 服务器融合在一起,因此 Apache 也更像一个完整的 Web 应用服务器。

另一方面,模块化设计的好处是 Apache 可以为不同的业务需求或场景提供不同的请求处理模型。

如 Apache 在 Unix 系统环境下就提供了三种不同的处理模型:

1. prefork

与 CGI 协议下的处理模型相似,采用多进程的方式处理请求。但 Apache 的 prefork 模式将事先创建一些工作进程,并通过一种管理进程来维护这些工作进程。

每次请求不再实时新建进程,而是从已经创建的进程中分配一个空闲进程来处理请求。

apache_mpm_prefork.png

2. worker
worker 模型采用多进程 + 多线程的方式处理请求。相对于 prefork 多进程模型,worker 模型可以处理更多的请求,这是由于每个请求由线程处理将消耗更少的资源。

但同时其稳定性相比 prefork 模式将有所下降,因为一个进程包含多个线程,每个线程处理一个请求,如果一个进程中的某个线程失败可能会导致整个进程失败,从而影响其他请求的处理。

apache_mpm_worker.png

3. event
上述两种模型都存在 keep-alive 问题。即当请求为 keep-alive 类型的请求时(尤其是 HTTP/1.1 默认使用了 keep-alive),传统模式下的 apache 的处理进程和线程会一直维持状态以阻塞等待客户端的数据(除非超时被释放),针对 keep-alive 长连接请求,apache 存在进一步优化的空间。

为解决 keep-alive 请求问题,event 模型中的监听线程除了监听和接收请求之外,还需要承担起更多的处理任务,负责处理所有 keep-alive 的 socket 套接字(且这些 socket 套接字已经被各种 handler 、协议过滤器处理完毕,只剩下一件事:发送数据给客户端),这样工作线程就可以释放出来处理新的请求。

另一方面,监听线程将采用 epoll 的方式管理 socket(最初采用 select,现已支持 epoll),从而提高请求的处理能力。

关于五种 IO 模型(包括 IO 多路复用的 select\poll\epoll) 等知识后续会单独梳理出一篇博客。

apache_mpm_event.png

epoll 的优化主要在于:

  • 请求响应:如果底层 TCP send buffer 被写满,那么工作线程只能等待其空出空间,线程将被阻塞。而采用 epoll 将由 socket 主动告知 send buffer 是否空闲。如此工作线程并能将写入事件交给监听线程处理,自己则处理下一个请求。当 socket 主动告知 send buffer 有多余空间时,监听线程得知该事件后再通知工作线程发送响应数据给客户端。
  • keep-alive 长链接的处理:如果工作线程某个 keep-alive 的请求已经写回了一次数据。则可将其此连接交给监听线程管理,自己则继续处理下一个请求。当 socket 主动告知 keep-alive 连接是否有新的数据发送过来时,监听线程再通知工作线程进一步处理。这样可以避免 keep-alive 连接长时间不发送请求数据导致工作线程长时间等待的问题。

FastCGI

为了解决传统 CGI 效率差,难以扩展等缺陷,同时也是为了和同期出现的嵌入式模块 mod_* 技术相竞争,Open Market 设计开发了 CGI 的改进版 FastCGI。

FastCGI 的核心改进就是取缔传统 CGI 每个请求都新建进程的处理方式,减少因此产生的资源开销。FastCGI 工作模式如下:

  • 首先创建一个 master 进程(FastCGI 进程管理器)。master 进程 fork 多个 worker 进程,并等待请求
  • HTTP 服务器与 master 进程通过 socket 进行通信(传统 CGI 中 HTTP 服务器直接与 worker 进程通信),master 进程通过 FastCGI 协议将环境变量、请求参数、标准输入等传递给 worker 进程(即 CGI 程序)
  • worker 进程(CGI 程序)处理完成后将标准输出和错误信息返回给 HTTP 服务器
  • worker 进程继续等待下一个请求连接(传统 CGI 的 worker 在请求完成后销毁)
fastcgi_model.png

同时 FastCGI 还有缓存机制进一步提高 CGI 脚本的执行效率,即首次调用时会编译脚本并将编译结果保存到缓存中,而下次接受到新请求时会优先转向这个编译过的代码,而不用每次都调用解释器来重新解释脚本。当更改了脚本,加速器的临时缓存也会被清空以保证调用的是新版本的脚本。

Servlet

Servlet 狭义上只是一种 Java Web 程序中的一种组件,它提供了编写 Web 应用程序的一系列接口。

但这里提及 Servlet 更多的是指广义上所代表的 Web 程序技术。在上文介绍的 CGI 中,HTTP 服务器与 CGI 程序属于不同的程序,分别跑在不同的进程中,虽然 CGI 程序进程可被 HTTP 服务器管理,但毕竟不在同一个内存空间中,所以两者之间需要通过 CGI 协议进行交互。

而 Servlet 所采用的技术则是将 HTTP 服务器和 Web 应用程序(相当于 CGI)整合在一起。由于 Servlet 是一种 Java 组件,开发者编写的 Servlet 程序需要运行在相应的具有 JVM 环境的 Servlet 容器中。我们将这类容器称为 Web 容器

那么 Servlet 最终面向客户端的将是一个完整统一的 Web 服务器(HTTP 模块 + Servlet 容器模块),例如 Tomcat 就是这类服务器的典型。Servlet 处理模型如下图所示:

servlet_model.png

基于 Servlet 的 Web 应用服务器内置了 HTTP 模块,对于 HTTP 与 Servlet 程序之间的通信制定了一套自己的协议,并且对并发请求下的线程的管理、调度等均有完整的实现。

当然除此之外,基于 Servlet 的 Web 应用服务器也可以与其他 HTTP 服务器如 Apache 结合使用。将 HTTP 服务器单拎出来,可以更好的发挥其优秀的静态资源处理能力。

servlet_model_1.png

与 CGI 相比,Servlet 采用多线程处理请求可有效减少请求处理时所消耗的资源,采用线程池和有效的实例生命周期的管理,则进一步增加资源复用率,提高请求处理的性能。同时在编程层面也提供了强大又便利的接口,提供更为高效的开发能力。

WSGI

WSGI(Python Web Server Gateway Interface)即 Web 服务器网关接口。是专门为 Python 设计的网关协议,用来描述和规范 HTTP 服务器如何与 Web 应用程序交互。

在 CGI 和 FastCGI 协议之上编写异步 Web 程序存在诸多不便,因此 Python 社区设计开发了 WSGI 协议。实现 WSGI 协议的 WSGI 服务器从 HTTP 服务器那接收到请求后,将把环境变量(请求参数)以及一个 callback 回调函数传递给 Web 应用程序,Web 应用程序处理完成后通过调用 callback 回调函数将响应返回给 HTTP 服务器。

WSGI 协议包含两个部分,一个是 WSGI 服务器端(server),另一个则是应用程序端(application)。即 WSGI 对上文中的 Web 应用程序也有比较严格协议规定。固编写 Web 应用程序通常需要使用支持 WSGI 协议的应用框架,如 Django、Flask 等。

WSGI 对进程/线程没有明确的限制,所以实现时可以各自采用不同的处理模型。

Node.js

Node.js 不是一门语言,也不是应用层框架。

Node.js 是能够在服务器端运行 JS 的开源、跨平台 JS 运行时环境

nodejs_architecture.png

Node.js 需要包含:

  • JavaScript 引擎(V8),从而支持 JS 代码的运行
  • 也要提供能在服务器端进行各种操作的内建模块(C/C++ 编写),如文件处理、线程处理、HTTP 协议处理等
  • 还要实现将内建模块封装成 JS 核心模块,同时在内建模块和 JS 核心模块之间还应该有起到桥接作用的 Bindings
  • 同时还需要封装出 API 层,让业务层能够更加便利的调用底层模块或使用底层能力

由上述各部分一起构建出了一个能够在服务器端使用 JavaScript 来编写各种服务器端应用的运行时环境,我们称之为 Node.js。

Node.js 经常被用来实现 Web 服务器,但与上文涉及的诸多 Web 服务器相比,Node.js 有许多独具特色的设计。

由上文 Node.js 的架构图可以知晓,Node.js 内置了 HTTP 相关的 http 模块,即 Node.js 平台已经提供了对 HTTP 请求的解析能力。开发者可以在此基础能力之上很方便的开发出 Web 应用,结合上文的内容,甚至可以称其能够很方便的开发出 Web 服务器,实际上这也正是 Node.js 的设计初衷之一。

JavaScript 本身是一门单线程的语言,所以 Node.js 本身所采用的请求处理模型与上文提及的多进程/多线程都有所不同。

Node.js 采用单线程 + 事件驱动 + 非阻塞 IO的技术实现了对请求(主要针对 IO 密集型请求)的高并发处理。

  • 单线程
    这里的单线程指的是运行用户代码的主线程为单线程[1]

    [1] 在 V8 引擎等其他地方为多线程运行。

    Node.js 的单线程具有以下特点:

      1. 不需要关注多线程引发的资源竞争和同步问题,也没有线程上下文切换的消耗,程序的复杂性有所降低
      1. 事件驱动和非阻塞 I/O 将使得单线程足够高效的专心接收网络请求
      1. CPU 计算任务将阻塞单线程(可通过子进程模式优化),所以 Node.js 相对而言不适用哪些 CPU 密集型的请求
      1. 错误将引起整个应用退出,应用健壮性较差
  • 非阻塞 I/O
    虽然 Node.js 对外只提供了单线程的入口,但实际在 Node.js 内部处理 I/O 事件时,Node.js 拥有一套线程池,所以可以对多个 I/O 事件进行并发处理。
    在主线程中遇到 I/O 事件时,主线程将把 I/O 事件交给 I/O 线程池处理,I/O 线程池处理完成通过回调的方式通知主线程即可。而在此之前主线程将继续执行后面的逻辑,也将继续接收新请求。这种「异步 I/O」 的编程模型可以极大提供 I/O 请求的处理能力

    这里的「异步 I/O」是从开发者应用层的角度看待,而 Node.js 底层进行 I/O 处理的时候实际使用的是 unix 五种 I/O 模型中的非阻塞 I/O 而非异步 I/O。所以这里应用层的「异步 I/O」可以理解为是 Node.js 通过底层非阻塞 I/O + 线程池模拟而来。

  • 事件驱动模型
    上文中的非阻塞 I/O 部分应该会让人有点疑惑,即 I/O 线程是如何将 I/O 处理结果告知主线程的?答案并是事件驱动,而事件驱动实际上就是:主线程事件循环 + 事件队列
    所谓事件循环,是指主线程会不断的进行 while 循环,每次循环就是判断事件队列里是否存在待处理的事件,存在则进行处理(如执行回调函数)。而另一方面,I/O 线程处理完 I/O 后,并会生成一个事件(事件中就包含了回调函数),将该事件推入事件队列中。

事件驱动模型如下图所示:

nodejs_event.png

而每次事件循环中所做的处理如下:

nodejs_event_handler.png
  • timers: 处理通过 setTimeout\setInterval 等 API 设置的回调。
  • pending callbacks: 执行延迟到下一个循环迭代的 I/O 回调。
  • Idle, prepare: 仅系统内部使用
  • poll: 查询所有 I/O 事件,执行 I/O 回调。
  • check: 执行 setImmediate 的回调
  • close callback: 执行关闭相关的回调,例如 socket.on('close', ...)

参考资料

Common Gateway Interface Wikipedia
通用网关接口 Wikipedia
RFC 3875 - CGI 规范 v1.1
李勇.CGI在嵌入式WEB服务器中的应用和实现[J].微计算机信息,2008(30):110-111+184.
What is Common Gateway Interface (CGI)? StackOverFlow
万法归宗——CGI
Apache HTTP Server Wikipedia
mod_perl Wikipedia
What is mod_php? StackOverFlow
Apache HTTP 服务器官方文档
The C10K problem
httpd三种MPM的原理剖析
FastCGI Wikipedia
FastCGI_Specification
FastCGI 协议规范中文版
Java servlet Wikipedia
Web服务器网关接口
PEP 3333
Node.js Wikipedia
Node.js GitHub
Node.js 官方中文文档
NodeJS Architecture & Concurrency Model
深入理解Node.js 中的进程与线程

你可能感兴趣的:(CGI - 通用网关不通用)