系列:《0.8MB 跑通 Qwen:从零实现 ARM 零依赖纯 C 推理引擎》(30 天 × 90 篇) | 适配模型:Qwen3-VL-8B-Instruct(千问3_VL_8B_Instruct)· Qwen3-VL-2B-Instruct · Qwen3-30B-A3B | 测试设备:RK3588(4×Cortex-A76 + 4×Cortex-A55,aarch64)
系列总纲:《0.8MB 跑通 Qwen》30 天 90 篇 · 总纲(阿里云社区)
上一篇:19-3《视频帧与媒体模块——默认不启用的 H.264》| 下一篇:20-2《路由与自研最小 JSON》
真机实测通过:本文实验已在 RK3588 板端实测完成(2026-09;方法学与原始记录见仓库 docs 与《实验脚本》目录)
一句话导读:推理引擎的极简 HTTP 服务:socket、bind、select 轮询与有界 worker 队列怎么撑起零外部库的自研服务器,板端实测 /health 往返,讲清自研与上框架在单机板端场景的成本取舍。
关键词:手搓 Qwen 推理引擎、千问大模型推理、Qwen3-VL、零依赖纯 C、HTTP、socket、select 轮询
导语:数据面收官后,引擎要自己当服务器:它在 vllm_http.c 一个文件里用 socket、bind、select 轮询与有界 worker 队列撑起零外部库的 HTTP 服务,并在 RK3588 上以 /health 往返实测,讲清自研与上框架在单机板端的成本取舍。
多模态与数据面收官,Day 20 进入服务化:引擎的 HTTP 服务在 src/serve/vllm_http.c,一个文件里装下了 socket 监听、自研 JSON 解析器、请求/响应解析、线程池、SSE 流式——零外部库,只依赖系统 socket API。这篇先回答"极简服务器怎么撑起来的",并用板端实测的 /health 往返时间把"自研 vs 上框架"的成本讲清楚。
1. 知识点:一个最小 HTTP 服务要几块砖
HTTP 服务最内核只有四件事:
socket() → bind() → listen() → accept() 循环
│
每来一个连接 → 读请求头/体 → 路由 → 写响应
- listen:告诉内核"我在这端口等人连接",参数是积压队列长度;
- accept:从内核拿一个已完成握手的连接;
- select:一次等待多个 fd 就绪(本引擎用它在 accept 上等连接,附带超时做优雅停机轮询,
vllm_http.c854–868 行); - SO_RCVTIMEO:给每个已接受连接设接收超时(
vnet_set_recv_timeout,511 行)——慢连接/半开连接会被超时踢掉,这是"不引库也能防呆"的典型写法。
引擎的取舍(文件头注释 4–9 行):accept 循环单线程串行 + 连接丢给有界队列 + 8 个 worker。HTTP 层并行度不归推理管——推理本身是单 SSE 流串行(Day 14 讲过"单机守则"),HTTP worker 只是把"慢客户端"与"推理线程"隔开。为什么不上 libevent/nginx? 板端场景的并发模型是"单模型 + 少量并发请求",select + 有界队列 + SO_RCVTIMEO 已经覆盖;少一个第三方依赖,就少一分供应链审计成本(这是 Day 23 起国密/签名体系能自洽的前提)。
2. 对应代码:vllm_http.c 的砖块顺序
监听与主循环(vhttp_serve_ex,804–868 行):
listener.fd = socket(AF_INET, SOCK_STREAM, 0); // 812
setsockopt(listener.fd, SOL_SOCKET, SO_REUSEADDR, …);// 816
bind(listener.fd, (struct sockaddr *)&addr, sizeof(addr)); // 823
listen(listener.fd, 32); // 824
for (;;) {
fd_set rfds; FD_SET(listener.fd, &rfds);
select(listener.fd + 1, &rfds, NULL, NULL, &tv); // 860:等连接或超时
int cf = accept(listener.fd, (struct sockaddr *)&ca, &calen); // 868
queue_push(&ctx->q, conn); // 连接进有界队列,worker 取走
}
配套的三块:
- 请求解析:
read_request(554 行)+find_header(519 行)——按\r\n\r\n切头、按Content-Length收体,不读 body 以外的字节; - 响应写回:
send_response(636 行),socket 无缓冲,vhttp_stream_write每写一次就到线缆(683 行注释); - worker:
worker_main(791 行)从队列取连接、回调路由、关闭(serve_conn765 行)。
"极简"的代价要在文档里说清(技术文档 §2.2):默认 --threads 8 的 worker 数只影响连接并发,推理仍串行;想要吞吐只能靠批处理而不是把 HTTP worker 堆到推理上。
3. 改动后果:板端实测 /health 往返
实测口径:RK3588 板 serve(
model.vqf已加载)+ x86 主机客户端,局域网 192.168.1.92:8801,2026-09-07。客户端记录的是urlopen完整往返(含 TCP 握手),不是服务端处理时间。
GET /health ×6(模型已加载、无排队)
42.6 ms / 29.3 ms / 16.8 ms / 27.8 ms / 7.2 ms / 22.5 ms
响应体: {"status":"ok","model":"qwen3-vl-8b","busy":false,"queued":0,...}
三个观察:
- 7–43 ms 的抖动主要是网络与调度:同一板子同一路径,/health 服务端只做一次 JSON 拼装,远小于网络 RTT 的抖动——这告诉我们:单看一次 HTTP 往返测不出服务端快慢,要测服务端就该看服务端日志或压测多次取中位(Day 28 方法论预告);
busy/queued/active字段让 /health 能当调度器探针用:queued 不为 0 说明请求在排队,这是比"进程活着"更有用的健康定义;- 模型 id 是配置标签:响应里
"model":"qwen3-vl-8b"是服务启动时的模型 id 串(非架构声明),多模型共存时客户端靠它区分后端——兼容协议里模型字段的语义在 Day 21 细讲。
4. 学员调试任务
- A 档(板端/本地动手):
- 起 serve 后对
/health连打 10 次,记录每次往返时间并取中位(对比本节的 7–43ms 量级); - 用
strace -e trace=network或/proc/<pid>/fd看监听 socket 与已建立连接的状态,确认 select/accept 在干什么(netstat -tlnp | grep 8801也行); - 读
vllm_http.c812–868 行:把listen(..., 32)改成1再起服务,观察并发连接的表现差异(可在本机起两个 python 客户端同时连)。
- 起 serve 后对
- B 档(纯读源码):回答:① 为什么
select的超时对"优雅停机"是必要的(提示:g_vhttp_stop标志 28–34 行 +vhttp_stop在vhttp_serve_ex主循环哪里被检查)?②SO_RCVTIMEO设多少秒合理、设成 0 会发生什么?③ 若把queue_push换成无界队列,worker 忙时连接会怎样堆积,内存上会出什么问题?
预期输出:自己板子的 /health 往返直方图 + 监听 socket 的 netstat 截图,并能说出 select 在这里"等的是什么"。
收尾
- 本篇源码点名:vllm_http.c(socket/bind/listen 812–824、select 主循环 854–868、read_request 554、worker 队列 729–799)
- 开源仓库:Kestrel-LLM (Gitee)(AGPL-3.0-or-later 或商业许可,二选一)
- 下篇预告:连接进来了,URL 和 JSON 体怎么变成一次"对话"?20-2 讲路由表与自研最小 JSON 解析器,并用"改错字段名"实验演示兼容性断裂。