前面两篇文章,我分别写了 AST 解释器优化和 Bytecode VM。那时我关心的是:同一段 HHY 程序,能不能在保持语义一致的前提下跑得更快?
到了 Web Runtime,问题发生了变化。
脚本执行完,进程退出,很多生命周期问题会被操作系统替你收尾。服务却要一直活着:第一万个请求不能读到上一个请求留下的数据,一次 handler 异常不能带走整个进程,客户端读得慢也不能让服务无限积累输出。
所以我做 HHY Web 的起点,是让 Runtime 经得起重复调用。等这件事成立,路由、JSON API 和框架才有落脚点。
HHY v1.4.3 的发布验证记录里,有两组我比较看重的数据:同一加载上下文重复调用 100,000 次;16 个并发客户端完成 1,000,000 次本机 HTTP 请求,零失败,耗时 131.439 秒,约 7,608.1 请求/秒。 前者验证常驻调用,后者验证特定短请求负载。它们都不是所有 Web 场景的性能承诺。发布记录
先把解释器变成可以长期调用的 Runtime
我把 v1.4 的工作拆成四个能力阶段,最终统一在 v1.4.3 正式交付。这里的版本表示开发边界,不意味着用户需要依次安装四个版本。
| 阶段 | 先解决的问题 | 交付的能力 |
|---|---|---|
| v1.4.0 | 程序能否只加载一次、反复调用? | opaque C handles、常驻上下文、JSON ABI |
| v1.4.1 | 网络请求怎样进入 HHY? | HTTP/1.1、Router、请求对象与响应对象 |
| v1.4.2 | API 怎样长成一个应用? | Middleware、静态文件、上传、CORS、Cookie、开发重载 |
| v1.4.3 | 服务怎样承受持续运行? | Stream、SSE、Range、多进程 Worker、日志与指标 |
这个顺序对我很重要。如果一开始只盯着 GET /hello,很容易把每次请求做成一次新的脚本执行。能返回 JSON,却把进程启动、源码加载和执行环境初始化都放进了请求路径。
常驻模型把这部分固定工作移到启动阶段,后续请求重复调用 handler。公开 embedding API 用 HhyApplication 和 HhyContext 隐藏内部表示,宿主通过 JSON 边界传参和取结果,不直接依赖 HHY 的 Value 内存布局。
一个 application 必须比它创建的 contexts 活得更久;一个 context 也不能被多个线程随意同时调用。这里的生命周期约束,比 API 名字是否简洁更重要。
Web 服务同样复用已经准备好的应用状态。AST 和 Bytecode 仍然共享语言语义,Bytecode 是默认引擎;AST 则继续提供差分验证。顶层可变绑定在 embedding context 中被拒绝,避免把全局变量变成隐式的跨请求状态。单次 handler 异常返回 500,后续请求继续服务。Runtime 设计
Runtime 和框架,我把它们分成两层
HHY Language 内置的 import web 负责请求、路由、响应、Stream 和 Worker。独立仓库里的 HHY Web 0.1.0 则是一层普通 HHY 代码,要求语言版本至少为 v1.4.3。
框架补的是应用组织方式:默认 Request ID、可选 CORS 和 gzip、JSON 错误、Bearer 鉴权,以及更直接的路由函数。网络栈和 Runtime 不需要再实现一遍。
例如 Blueprint,在这里就是一个接收 application、返回 application 的函数:
fn api(application) {
return application
|> hhyweb.get("/api/books/:id", book)
|> hhyweb.post("/api/books", create_book)
}
mount 调用这个函数就够了。模块组合继续使用 HHY 原来的函数和 Flow,不需要引入另一套注册语法。框架真正的价值,是减少每个项目都要写的重复代码,同时保持执行过程可以顺着源码读下去。HHY Web 源码
一个 HTTP 请求,怎样走到 handler
可以先把一个普通请求拆开:
GET /api/books/42?lang=zh HTTP/1.1
Host: example.com
Accept: application/json
这一行里有三个不同维度:GET 是方法,/api/books/42 是路径,lang=zh 是查询字符串。它们最后分别影响路由选择和 handler 的输入。
底层收到的却不是“请求对象”,而是一段段 TCP 字节。一次 recv 可能读不完请求头,也可能连请求体的一部分一起读进来。解析器必须自己识别消息边界。
当前 C Server 先累积数据,寻找 \r\n\r\n,再拆分请求行和 Header。它检查方法 token、以 / 开始的请求目标,以及 HTTP/1.1 版本;随后把路径和原始 Query 分开。
请求体的长度由 Content-Length 决定。长度要能解析为合法数字,也要低于配置的 max_body;没有接收完整,就继续读取。超过请求体上限返回 413,读取超时走 408,畸形输入走 400。进入 HHY 业务代码之前,网络层先把这些边界处理好。
这里有一个容易误解的细节:当前服务支持 chunked 响应,但不接受带 Transfer-Encoding 的请求体。 请求和响应两个方向的能力并不对称。上传接口不能因为服务支持流式输出,就假设它也能接收 chunked 上传。
进入 Runtime 后,请求被组织成 WebRequest:路由参数放在 params,解码后的查询参数放在 query_params,另有 headers、cookies、文本 body 和二进制 bytes。例如匹配 /api/books/:id 后,42 才成为 request.params.id。
Router 需要同时看方法和路径。没有匹配路径是 404;路径存在而方法不匹配是 405。Middleware 可以提前返回响应,例如拒绝缺少凭据的请求;通过后再调用 handler。最后由 Runtime 组织响应,C Server 写回 Header 和 Body,并结束连接。HTTP Server 实现
GET 和 POST 的区别,不只是参数放在哪里
开发 API 时,我更愿意从“这次请求表达什么操作”选择方法。
| 方法 | 典型用途 | 安全 / 幂等语义 | HHY Web 中的入口 |
|---|---|---|---|
| GET | 读取资源、查询列表 | 安全、幂等 | hhyweb.get |
| POST | 创建资源、提交一次操作 | 不保证安全或幂等 | hhyweb.post |
| PUT | 按目标 URI 创建或替换资源状态 | 不安全、幂等 | hhyweb.put |
| PATCH | 局部修改资源 | 不安全,不保证幂等 | hhyweb.patch |
| DELETE | 删除目标资源 | 不安全、幂等 | hhyweb.delete |
这里的“安全”指方法语义上不请求修改资源,不代表接口有鉴权或数据经过加密。“幂等”指同一请求重复执行的预期效果相同,不要求每次状态码和返回内容完全相同。DELETE 第一次返回成功、第二次返回资源不存在,仍可以符合幂等语义。
这些语义需要业务代码兑现。把扣款逻辑注册到 GET,不会因为用了 get 就变安全;POST 如果要支持可靠重试,需要应用额外设计幂等键或去重机制。HTTP 方法语义、PATCH 语义
Query 也不专属于 GET,POST 的 URL 同样可以包含 Query。JSON、表单或二进制则是请求体格式,不能单靠方法名判断。GET 请求体没有通用定义的语义,实际 API 中应避免依赖它。
HHY Web 的 JSON helper 做得很直接:对 request.body 执行 parse_json,解析失败转成稳定的 400 JSON 错误。它并不自动替业务完成字段校验,也没有在这个 helper 里强制验证 Content-Type。需要严格媒体类型约束的接口,应在应用层补上检查。
仓库里的创建图书示例就是这个边界:
fn create_book(request) {
let parsed = hhyweb.request_json(request)
if not parsed.ok {
return parsed.error
}
return hhyweb.created({ id: "new-book", book: parsed.value })
}
它演示的是 JSON 解析和 201 响应,没有实现数据库持久化。真正接入业务时,下一步是校验字段、执行存储操作,再返回已创建的资源。
HEAD 和 OPTIONS 也值得单独理解:前者表达读取响应元数据而不传回响应体,后者用于询问通信选项,浏览器的 CORS 预检会用到它。HHY Runtime 提供 CORS 预检能力,但当前薄框架公开的是上表五种路由 helper,不能从 HTTP 标准反推每一种方法都有同名框架函数。
Stream 的价值,在于控制生产速度
普通 JSON 响应通常先完成序列化,再发送一段确定长度的 Body。导出大量数据或持续推送事件时,这样做会把等待时间和内存占用一起推高。
web.stream 把输出转换为 chunked 响应。它的关键约束是:上一块写出之后,才向 Stream 请求下一项。客户端读取速度会通过 socket 写入压力影响服务端的生产速度。
这就是这里的背压。它避免网络输出端无止境地预取数据,但如果应用已经提前创建一个巨大数组,再把数组转成 Stream,前面的内存开销仍然存在。
SSE 则在 Stream 之上增加事件格式,适合单向通知和逐步输出。静态文件还有大文件流式发送和单段 byte Range;缓存验证使用 ETag / Last-Modified,能够减少不必要的实体传输。
这些机制分别解决不同成本:Stream 控制输出积累,Range 缩小读取范围,缓存验证减少重复传输。它们不能合并成一句“支持流式,所以性能更高”。
百万次请求,究竟说明了什么
正式发布记录里的压测结果如下。这是已有发布证据的引用,不是这篇文章重新运行的测试。
| 项目 | 发布验证记录 |
|---|---|
| 请求总数 | 1,000,000 |
| 并发客户端 | 16 |
| 失败数 | 0 |
| 总耗时 | 131.439 s |
| 吞吐 | 7,608.1 requests/s |
| 网络范围 | loopback,本机回环 |
结合压测脚本,才能理解这组数字:Python 客户端使用线程池,每次新建 HTTP 连接,发起 GET /,读取响应并检查状态码及 JSON 内容,随后关闭连接。服务来自验收 fixture,配置了两个 Worker;测试关闭访问日志。因此它测的是包含连接、协议处理、handler、JSON 响应和客户端校验的短请求链路。压测脚本、服务 fixture
这比只测一个空函数更接近服务,但还没有覆盖数据库等待、公网延迟、TLS、复杂业务、大请求体和慢速 SSE 客户端。Python 压测端也可能成为约束之一。
发布记录没有同时给出机器型号、CPU 使用率、RSS 曲线和 p95 / p99 延迟,因此我不会拿它和 Go、Node.js 或其他框架做横向排名。也不能用 1 / RPS 得出单个请求的平均延迟:这是一组并发负载。
前一篇 Bytecode 文章里约 2.7 倍的收益,来自特定 CPU Flow 负载,同样不能直接套到 HTTP。Web 请求的总时间还包括读写 socket、解析、分配、路由和响应编码。常驻加载减少固定开销;Bytecode 缩短部分执行路径;实际瓶颈仍要逐层测量。
多 Worker 的收益和限制,都要写清楚
当前源码采用 prefork 模型:多个 Worker 进程共享监听 socket,父进程监控并补起退出的 Worker。每个 Worker 在接受连接后,同步完成读取、handler 执行和响应写入,再接受下一个连接。
源码里有 select,但它等待的是监听 socket。它不意味着已经实现了对所有客户端连接的非阻塞事件循环。
当前响应还显式使用 Connection: close。也就是说,这个版本的性能形状是多进程、单 Worker 同步处理、短连接。增加 Worker 可以提高同时处理能力,但不会自动变成海量连接调度器;一个读取很慢或持续时间很长的连接,仍会占住一个 Worker。
这也解释了为什么“有 SSE”和“能承受大量 SSE 在线连接”是两个阶段。背压控制了输出积累,却没有消除连接占用。真正要做大规模长连接,需要进一步设计非阻塞 I/O、连接调度、超时和取消协作,再用慢客户端混合负载验证。
从当前实现还能看到另一个成本:每次处理连接,接收缓冲会按 Header 与 Body 的配置上限申请容量。max_body 因此既是输入边界,也会影响分配规模,不能为了省事无限放大。后续是否改成渐进分配,要由分配热点和内存测量决定。
我会优先补齐短请求与长连接混合场景的尾延迟、Worker 占用和 RSS 变化,再判断优化路由查找、分配策略还是连接模型。只有明确哪部分占了时间,优化才有方向。
把服务真正放到浏览器前面
在线 Dashboard 是这条链路的一个直观入口:HHY handler 返回 HTML,浏览器再调用 /api/status 和 /api/hello,把 JSON 展示出来。
它适合确认页面、路由、Query 和 Request ID 的链路已经连通。不过示例源码里的 Runtime 版本、引擎名和 Worker 数量是显式填写的展示字段,不能把这些卡片当成实时进程探针。真实运行状态还需要结合健康接口、日志、指标和进程监控判断。Dashboard 源码
生产边界也保持明确:HHY 服务监听内部地址,由 Nginx、Caddy 或云负载均衡器终止 TLS / HTTP/2;v1.4 不包含 WebSocket。只有直连客户端全部来自可信代理时,才启用 trust_proxy。
开发过程中,我更在意失败之后的行为。错误的 JSON 是否返回 400?Body 超限是否返回 413?handler 抛错后,下一个请求能否成功?上传的临时文件在成功和失败路径上是否都被清理?这些都比首页显示“服务器正在运行”更能说明常驻服务是否成立。
从能执行一段代码,到能持续接住请求
写完 Web Runtime,我对 HHY 的要求又具体了一层。
原来,Flow 把文件、进程、HTTP 客户端和 Stream 连成一个任务。现在,同样的语言能力可以放进常驻 handler,对外提供一个持续运行的服务。框架保持轻,生命周期、错误隔离和资源边界由 Runtime 接住。
百万次短请求零失败,是这条路上的一份证据。同步 Worker、短连接和长流占用,也给下一步划出了清楚的问题范围。
一个 Runtime 能长期服务,不只因为成功请求足够快,也因为失败、等待和资源释放都有明确的去处。
这才是我从脚本走向 Web 时,真正想补齐的能力。