📘 第十章 构建Web内容的技术
📄 一句话概括

Servlet 把「跑代码的地方」做成常驻:一个进程接所有请求,每个请求只占一个线程。 和 CGI 的结构差别只有这一条,实测代价差 71 倍。

📝 本节和前面几节的关系

上一节 CGI 的问题被量出来了:496 毫秒里大半花在起进程。 这一节看 Servlet 怎么把它变成 6.9 毫秒。

一、它解决的问题很具体

Servlet 是 Java 写的服务端程序规范。 它不自己监听端口,而是跑在一个容器里(Tomcat、Jetty 这类),由容器负责收请求、分线程、 调用对应的 Servlet。 所以 Servlet 程序员只写「收到请求该干什么」,生命周期(什么时候初始化、什么时候销毁)交给容器管。 这一点和 CGI 完全不同:CGI 程序自己就是整个进程。 书里说 Servlet 因 Java 而普及,原因有两个:一是 Java 当年跨平台,写一次到处跑; 二是 Java 有成熟的线程和连接池,正好对上「常驻进程接并发请求」这个需求。

二、常驻与每请求一进程,差在哪

Demo 把两种结构各跑 20 个请求:常驻进程 139 毫秒(平均每个 6.9 毫秒), 每请求起进程 9912 毫秒(平均每个 495.6 毫秒),差 71 倍。 这 71 倍里,唯一被改掉的东西就是「进程还留着」。干的活完全一样——一句加法,参数一样,结果一样。 本机没有装 JVM,跑不了真正的 Servlet 容器,所以上面这组数字是拿 Python 的两种结构量的。 我不假装跑过 Servlet,但它和 CGI 的结构差别确实只有这一条,量的就是这个差别。

三、顺带解决的两件事

第一件:资源可以复用。进程常驻,数据库连接就不用每次重连,攒成一个连接池放着。 这一条在 CGI 时代是做不到的——进程一退,连接全断。 第二件:状态可以存下来。 8.5.2 那个会话表,在 Servlet 里就是 HttpSession:容器给每个会话分配一块内存, 靠 cookie 里的 ID 找回来。CGI 也能存,但只能存到进程外面(文件、数据库),慢得多。 这两件事让「Web 应用」真正成为可能——不再是「一次请求算一个结果」,而是「一个长期运行的系统, 用户在里面操作」。

四、代价也要说

常驻进程里的共享状态要自己管并发。两个请求同时改同一个对象,不加锁就会出问题;加了锁又要小心死锁。 CGI 里每个请求都是干净的独立进程,根本没有这个问题。 还有内存泄漏:CGI 的进程一退,什么泄漏都跟着没了;常驻进程泄漏一点,跑几天就堆成山。 所以「省下的开销」换来的是一堆要考虑的工程问题。 这也是后来各种框架都在做的事:把并发、连接池、会话这些「常驻带来的麻烦」封装掉, 让写业务的人不用天天想它们。

五、动手 Demo:两种结构,各跑 20 个请求

先用一个常驻的本机服务端接 20 个请求,量总耗时; 再把上一节那个 CGI 脚本连跑 20 次的总耗时拿来对比。两次干的活都是同一句加法。

# 常驻:一个进程,来了就处理
with H.server_multi(calc_responder, keepalive=True) as port:
    a = time.perf_counter()
    for _ in range(20):
        send(port, u'GET', u'/calc?a=3&b=4')
    keep = (time.perf_counter() - a) * 1000.0

实跑输出:

--------------------------------------------------------------------
常驻进程与每请求一进程:差多少
--------------------------------------------------------------------
  本机没有装 JVM,Servlet 容器跑不了,所以我不假装跑过 Servlet。
  但它和 CGI 的结构差别只有一条:CGI 每个请求起一个进程,
  Servlet 是**一个常驻进程接所有请求**,每个请求只占一个线程。
  下面就把这两种结构各跑一遍,量的还是同一件事。

  常驻进程,20 个请求        139 毫秒
  每请求起进程,20 个请求    9912 毫秒
  倍数                       71 倍
  平均每个请求               6.9 毫秒 对 495.6 毫秒

  差距全在「起进程」这一件事上。两个例子里真正干的活都是一句加法,
  可一边每个请求要新建一个进程,一边只要在已有进程里跑一个函数。

  所以 Servlet 的思路是:把「跑代码的地方」做成常驻的,请求来了就调度,
  处理完线程回收、进程不回收。它还顺带解决了两件事——
  一是可以复用数据库连接之类的资源,二是能保存会话状态(8.5.2 那个会话表)。

  代价也有一句要说清楚:常驻进程里的共享状态要自己管好并发,
  而 CGI 的每个请求都是干净的独立进程。省下的开销,换来的是一堆要考虑的锁。

常驻 139 毫秒,每请求起进程 9912 毫秒,差 71 倍。 平均每个请求 6.9 毫秒 对 495.6 毫秒。 这组对比里唯一变的东西是「进程留不留」。代码没变、参数没变、结果没变。 本机没有 JVM,所以没有真跑 Servlet 容器。 这里演示的是结构差别,不是 Servlet 的性能数据——这个区别要说清楚。 注意 6.9 毫秒这个数也不全是「算加法」:里面大部分是建连接、发请求、读响应的往返开销。 但比起 495.6 毫秒,它已经小到可以忽略起进程那一项了。

30 秒实验

  1. 把请求数从 20 改成 100,看两种结构的倍数会不会变化。
  2. 给常驻服务端每次处理加 20 毫秒睡眠,看差距会不会被摊薄。
  3. 想一想:如果每次请求要读一个 50 MB 的文件,常驻的缓存能帮上什么忙?

常见误区

  1. 「Servlet 是一种语言」——它是 Java 的服务端程序规范,跑在容器里。
  2. 「Servlet 快是因为 Java 快」——快在常驻,换任何语言用常驻结构都会快。
  3. 「常驻只有好处」——并发、泄漏、重启预热全都要自己面对。
自测三问(点开对答案) 1. Servlet 和 CGI 的结构差别是什么? —— CGI 每请求起一个进程;Servlet 由一个常驻进程接所有请求,每请求一个线程。 2. 常驻带来了哪两个额外能力? —— 资源可以复用(连接池),会话状态可以存在内存里。 3. 常驻的代价是什么? —— 共享状态要处理并发,内存泄漏会累积,重启要重新预热。

内容怎么造、功能怎么跑,都讲完了。最后一节换到「数据怎么传」——三种格式。

相关笔记