CGI到底是个啥?从原理到演进,一次讲透
CGI,全称 Common Gateway Interface,通用网关接口。注意这几个字——"通用"说明不绑定任何编程语言,"网关"说明是个中间人角色,"接口"说明它是一套规范协议,不是什么具体的软件。
说白了,CGI就是一份"合同",规定了Web服务器跟外部程序怎么通信。没有这份合同之前,Web服务器只能干一件事:你请求什么文件,我就把那个文件原样扔回去。全是静态HTML,写死的内容,谁访问都一样。
有了CGI之后就不一样了。Web服务器可以"外包"了——收到一个请求自己处理不了,就按照CGI协议把请求数据打包,交给外部脚本去跑,脚本跑完把结果送回来,服务器再转交给浏览器。页面内容就是动态生成的,不同用户不同时间看到的都不一样。
打个比方,Web服务器就像一个前台接待,平时就负责发发宣传册(静态文件)。来了个客户问"我上个月的订单啥情况",前台自己答不上来,按照CGI这个"转接规范",把客户问题记下来转给后边的业务部门(外部脚本),业务部门查完系统把答案给前台,前台再告诉客户。CGI就是那个"转接规范"。
CGI在Web服务器里干了啥
核心就三个词:处理、传递、扩展。
处理HTTP请求。 静态文件服务器干不了的事——查数据库、做计算、根据用户身份返回不同内容——这些活儿全得靠CGI调用的外部程序来完成。你在网页上点了个"查询"按钮,后端真正去数据库里捞数据的,就是那个被CGI调起来的脚本。
数据传递。 CGI最核心的角色就是"桥梁"。浏览器发过来的数据(表单参数、URL查询串、Cookie什么的),Web服务器自己不处理,通过CGI协议把这些数据"喂"给外部脚本;脚本跑完输出的内容(一般是HTML,也可以是JSON、图片二进制),再由Web服务器"端"回浏览器。整个过程中,Web服务器不关心脚本内部怎么实现的,脚本也不关心自己是在Apache下面跑还是Nginx下面跑,大家只认CGI这个协议。这就是"接口"的威力——解耦。
扩展服务器功能。 这点特别关键。你想给Web服务器加个新功能,不用去改它的源码重新编译,写个脚本就行。今天想用Perl处理请求,写个Perl脚本;明天想用Python,写个Python脚本;后天想用C写个编译型程序跑,也行。Web服务器一视同仁,只要你的程序符合CGI规范,它就调用。这种可插拔的架构设计,放到今天看也是相当优雅的。
一次CGI请求的完整流程
光说概念不够,咱们把一次完整的请求流程走一遍。假设用户在浏览器里提交了一个表单,表单的action指向一个CGI脚本,比如/cgi-bin/query.pl。
浏览器发请求。 用户点了提交,浏览器往服务器发一个HTTP请求,可能是GET也可能是POST,URL里带着脚本路径和参数。
Web服务器识别CGI请求。 服务器收到请求一看,这个路径在/cgi-bin/下面,按照配置规则,得交给CGI处理。服务器开始准备环境——把HTTP请求头里的信息变成环境变量(QUERY_STRING、REQUEST_METHOD、CONTENT_TYPE这些),如果是POST请求还得把请求体通过标准输入(stdin)传给脚本。
这里插一句,CGI的数据传递机制就两种:环境变量和标准输入。URL参数走环境变量QUERY_STRING,POST数据走stdin。脚本那边通过读环境变量和stdin就能拿到所有请求数据,简单粗暴。
CGI脚本执行。 Web服务器fork出一个新进程,在这个进程里执行对应的CGI脚本。脚本拿到数据后开始干活——可能查数据库,可能做一堆计算,可能调别的API。处理完了,脚本把结果输出到标准输出(stdout),一般第一行是Content-Type: text/html这样的HTTP头,空一行之后是HTML正文。
Web服务器返回响应。 服务器拿到脚本的stdout输出,封装成HTTP响应发回浏览器。用户就看到了动态生成的页面。
整个流程走下来,最要命的一步就是fork新进程那一步。每次请求都要fork,请求完了进程就销毁。这个后面细说。
实际搭一个CGI环境看看
光说不练假把式。我之前在一台CentOS 7上搭过CGI环境,拿Apache做的,过程很简单。
装Apache:
yum install httpd -y
systemctl start httpdApache默认就带了mod_cgi模块,CGI脚本的存放目录一般是/var/www/cgi-bin/。去/etc/httpd/conf/httpd.conf里能看到这么一段:
ScriptAlias /cgi-bin/ "/var/www/cgi-bin/"
<Directory "/var/www/cgi-bin">
AllowOverride None
Options None
Require all granted
</Directory>这表示/cgi-bin/路径下的请求都会被当作CGI脚本处理。
写个最简单的Perl脚本,存成/var/www/cgi-bin/hello.pl:
#!/usr/bin/perl
print "Content-Type: text/html\n\n";
print "<html><body><h1>Hello from CGI!</h1></body></html>";给执行权限:
chmod +x /var/www/cgi-bin/hello.pl浏览器访问http://你的IP/cgi-bin/hello.pl,就能看到页面了。
再写个能接收参数的Python版本,存成/var/www/cgi-bin/greet.py:
#!/usr/bin/python3
import os
query = os.environ.get('QUERY_STRING', '')
name = query.split('=')[1] if '=' in query else 'Stranger'
print("Content-Type: text/html\n\n")
print(f"<html><body><h1>Hello, {name}!</h1></body></html>")访问http://你的IP/cgi-bin/greet.py?name=张三,页面上就会显示"Hello, 张三!"。
你看,CGI的编程模型就是这么直白——读环境变量和stdin,往stdout写内容。没有任何框架,没有任何魔法。这跟现在动不动就Spring Boot、Django一大套框架的画风完全不一样,但正是这种朴素,让你能看清楚Web服务器跟后端程序之间到底是怎么交互的。
CGI的致命问题:性能
前面说了,CGI每次请求都要fork一个新进程。这个事有多严重,算一笔账就知道了。
假设一个CGI脚本用Python写的,Python解释器启动一次大概50-100ms(取决于机器性能和加载的模块)。并发100个请求,服务器就得同时fork出100个Python进程,每个进程启动都要50ms+,内存占用一个进程少说30MB,100个就是3GB。还只是一个简单的脚本,要是脚本里还import了一堆第三方库,启动时间更长,内存更大。
我之前维护过一个老系统,用的Perl写的CGI脚本跑在Apache上。平时访问量不大,几十QPS还能撑住。有一次搞活动,流量突然涨到几百QPS,服务器直接load飙到几十,SSH都登不上去。最后只能临时加机器硬扛。
问题就出在fork进程这个机制上。进程创建和销毁的系统开销太大了,而且每个进程都是独立的,不能共享数据库连接、不能共享内存里的缓存,什么资源都得从头初始化一遍。
这就好比一个餐厅,每来一个客人就临时雇一个厨师,厨师做完这道菜就解雇。来100个客人就雇100个厨师,厨房根本塞不下。正常做法难道不是雇几个常驻厨师,客人来了排队做菜吗?
而且还有个问题,进程fork出来的时间是不确定的。系统负载低的时候可能几毫秒就完事,负载一高,fork本身就可能卡住。再加上Python/Perl这些解释型语言的启动开销,一个请求光在"准备阶段"就花掉大几百毫秒,用户体验能好才怪。
FastCGI:CGI的进化形态
上面那个"常驻厨师"的思路,就是FastCGI。
FastCGI的核心改进就一句话:进程不销毁,常驻内存,处理完一个请求接着处理下一个。
FastCGI的工作模式是这样的:启动的时候预先fork出一组worker进程(数量可配),这些进程一直活着,通过Unix Socket或TCP连接跟Web服务器通信。请求来了,Web服务器把数据通过这个连接发给worker,worker处理完把结果发回来,连接不断,继续等下一个请求。
对比一下:
| 维度 | CGI | FastCGI |
|---|---|---|
| 进程生命周期 | 请求来时创建,请求完销毁 | 常驻,处理多个请求 |
| 进程间通信 | 环境变量 + stdin/stdout | Socket连接 |
| 数据库连接 | 每次重新建立 | 可复用 |
| 并发处理 | 每请求一进程 | 固定数量进程池 |
| 内存占用 | 随并发线性增长 | 相对固定 |
拿Nginx + PHP-FPM来说,PHP-FPM就是一个FastCGI进程管理器。它管理着一组PHP-CGI worker进程,Nginx收到PHP请求后通过FastCGI协议转发给PHP-FPM,PHP-FPM分配一个空闲的worker处理,处理完归还到进程池。这套架构到现在还是PHP世界的主流方案。
Nginx配置FastCGI的写法大概长这样:
location ~ \.php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME /var/www/html$fastcgi_script_name;
include fastcgi_params;
}fastcgi_pass就是告诉Nginx,PHP请求往127.0.0.1:9000这个地址转,PHP-FPM就监听在这个端口上。
FastCGI还有个好处是worker进程可以部署在不同的机器上。Web服务器跟FastCGI进程之间是Socket通信,那就意味着可以是TCP Socket,天然支持远程部署。CGI就不行,脚本必须在Web服务器本机,因为它是靠fork本地进程来执行的。这点在需要做水平扩展的时候差别就很大了。
不过FastCGI也不是银弹。worker进程数是有限的,请求量突然暴增超过worker处理能力,请求就会排队,响应时间变长。PHP-FPM的max_children参数调不好,要么资源浪费要么502报错,这个踩过坑的都懂。
Servlet:Java的方案
Java走的是另一条路。Servlet不fork进程,而是在Web服务器(或者Servlet容器,比如Tomcat)的进程内部,用线程来处理请求。线程比进程轻量得多,创建和切换的开销小很多。
Servlet的运行机制是这样的:Tomcat启动的时候加载Servlet类,实例化之后常驻内存。请求来了,Tomcat从线程池里拿一个线程,调用Servlet的service()方法(内部根据HTTP方法分发给doGet()或doPost()),处理完线程归还。Servlet实例始终在内存里,所有线程共享同一个实例(所以Servlet要注意线程安全)。
对比CGI,Servlet省掉了进程创建的开销,还能共享内存状态(数据库连接池、缓存),性能优势非常明显。这也是Java在企业级Web开发中长期占据主导地位的原因之一。
我之前做过一个项目从CGI迁移到Servlet的改造。原来那个系统是C写的CGI程序,每次请求fork进程查数据库,QPS上到200就扛不住了。改成Servlet之后,连接池复用数据库连接,线程处理并发,同样的硬件配置QPS直接翻了十倍不止。当然这个对比不完全公平,因为连接池的贡献很大,但进程模型跟线程模型的差距确实是数量级的。
WSGI:Python的方案
Python世界有自己的规范叫WSGI(Web Server Gateway Interface)。跟CGI的思想有点像,都是定义一个接口标准,但实现方式完全不同。
WSGI不是基于进程fork的,它是基于函数调用的。写一个Python函数,接收两个参数(environ字典和start_response回调),返回可迭代对象,这就是一个WSGI应用。Web服务器(或者中间件)直接在进程内调用这个函数,没有进程创建的额外开销。
def application(environ, start_response):
status = '200 OK'
headers = [('Content-Type', 'text/html')]
start_response(status, headers)
return [b'<h1>Hello from WSGI!</h1>']你看这个environ字典,跟CGI的环境变量是不是很像?其实WSGI设计的时候就参考了CGI的规范,environ里面那些REQUEST_METHOD、QUERY_STRING、CONTENT_TYPE的key跟CGI环境变量基本一一对应。可以说WSGI是CGI在Python世界里"脱胎换骨"的产物——保留了接口标准化的思想,抛弃了fork进程的包袱。
Gunicorn、uWSGI这些应用服务器都是基于WSGI规范实现的,它们用多worker(多进程或多线程)的方式处理并发,每个worker常驻,跟FastCGI的思路类似。Gunicorn默认用的是pre-fork模型,启动时先fork好worker进程,请求来了分配给空闲worker,跟FastCGI简直是一个模子刻出来的。
Node.js:更激进的方案
Node.js又走了一条不同的路——单线程事件循环。不用多进程也不用多线程,一个进程一个线程,靠异步I/O和事件驱动来处理并发。
这个模型跟CGI的差距就更大了。CGI是"一个请求一个进程",Node.js是"所有请求一个线程"。但本质上解决的问题是一样的:怎么高效地处理大量并发请求。Node.js的方案在I/O密集型场景下表现特别好,因为I/O操作是异步的,不会阻塞事件循环。但CPU密集型任务就会卡住整个进程,所以得靠cluster模块或者拆分成多个进程来兜底。
CGI现在还在哪出现
说了这么多替代方案,CGI是不是彻底没用了?也不完全是。
在一些嵌入式设备、路由器的管理界面里,CGI还在用。那些环境资源有限,跑不动什么应用服务器,一个轻量的CGI脚本反而最合适。我之前帮人调试过一个OpenWrt路由器,它的Web管理界面就是用Lua写的CGI脚本跑在uhttpd上的,简单高效,占用的内存小到可以忽略不计。
还有一些遗留系统,九十年代和2000年代初用C或Perl写的CGI程序,现在还在跑着。你没法轻易迁移,因为业务逻辑都写死在那些脚本里了,没人敢动。这种系统的运维就很头疼,性能差但又不能停,只能靠前面加缓存、加负载均衡来缓解。
另外CGI的环境变量传递模型其实影响很深远。你去看Nginx的fastcgi_params文件,里面定义的那些参数,跟CGI标准里的环境变量几乎一模一样。FastCGI协议本质上就是CGI协议的"网络版"——把环境变量和stdin/stdout搬到了Socket上传输。所以理解了CGI,FastCGI的配置参数你一看就懂,不用死记硬背。
从CGI到现代Web架构的演进脉络
把这条线串起来看就很清晰了:
CGI每次请求fork进程 → 性能太差 → FastCGI用常驻进程池解决 → 但进程还是重 → Servlet用线程替代进程 → 更轻量 → WSGI用函数调用替代协议通信 → 更Pythonic → Node.js用单线程事件循环 → 另一种思路
每一步演进都是在解决上一步的性能瓶颈或者易用性问题。理解了这个演进逻辑,不管以后出现什么新技术,你都能快速理解它"为什么这么设计"。
CGI这个东西,说它过时吧确实过时了,说它没用吧还真不是。它是Web动态内容处理这条线上的起点,从CGI到FastCGI到WSGI到Servlet,一脉相承。把这些串起来理解,不管后面技术怎么迭代,底层逻辑你都能抓得住。
觉得这篇有帮助的话,转发给需要的朋友,让更多人少走弯路。有问题欢迎留言讨论,咱们下期见。
公众号:耕云躬行录
个人博客:躬行笔记