- 性能测试
- 测试
- 后端
- 开发者工具
【免费下载链接】FrameworkBenchmarks
Source for the TechEmpower Framework Benchmarks project
TechEmpower Framework Benchmarks(TFB)中的frameworks/D/juptune目录,用于在基准测试平台上度量 Juptune——一个围绕 io_uring 构建、尚在开发中的 D 语言异步 I/O 库——的 HTTP 服务能力。本文以该目录的 README 为主体骨架,结合其完整源码、Dockerfile 与基准配置,说明这一基准条目如何从零搭建 HTTP 服务器、如何注册测试端点,以及 TFB 工具链如何对http://localhost:8080/plaintext进行压测与校验,帮助读者理解一个“无路由框架”的底层 I/O 库是如何被纳入标准化的框架基准体系的。
Juptune 是什么,为什么出现在 TFB 里
README 对 Juptune 的定位一句话概括:
Benchmarks for the WIP Juptune library - an upcoming async I/O library based around io_uring.
即这是一个面向“在研(WIP)”库的基准测试条目:Juptune 是基于 Linux io_uring 接口实现的异步 I/O 库,测试目标不是某个 Web 框架,而是这个底层库本身。这一点在 benchmark_config.json 中得到了印证:
{ "framework": "juptune", "maintainers": ["BradleyChatha"], "tests": [ { "default": { "plaintext_url": "/plaintext", "json_url": "/json", "port": 8080, "approach": "Realistic", "classification": "Platform", "database": "None", "framework": "juptune", "language": "D", "flavor": "ldc2", ... "display_name": "Juptune" } } ] }从这份配置可以读出几个关键事实:
classification: "Platform":TFB 将其归类为“平台/底层库”,而不是应用层框架,这与 README 中“async I/O library”的定位一致;flavor: "ldc2":编译后端为 LDC(LLVM 驱动的 D 编译器),且是 2.x 语言版本的 D;database: "None"、webserver: "None"、platform: "None":不依赖外部数据库与反向代理(如 nginx),被测进程直接监听端口;approach: "Realistic":采用贴近真实使用方式的实现,这一点与源码中“手写路由”的取舍相呼应(见下文);- 端口为
8080,测试 URL 声明了plaintext_url: "/plaintext"与json_url": "/json"。
值得注意的是:README 的“Test Type Implementation Source Code”一节仅列出了 PLAINTEXT 一个实现,但配置与源码中实际同时实现了/json端点(见 json.d)。换言之,JSON 测试的实现已随代码进入仓库,只是 README 尚未同步更新。
构建体系:LDC + Meson + 静态链接的基准镜像
TFB 中每个语言/框架条目通常配套一个 Dockerfile,juptune.dockerfile 即本条目的构建入口。它的完整流程如下:
FROM debian:bookworm ARG LDC_VERSION=1.41.0 ARG JUPTUNE_REF=1b012d22d4e5e29e88fdf760be4cfa899ce3dfb2 # 安装系统依赖与 LDC RUN apt update \ && apt install -y curl xz-utils gnupg libsodium-dev meson unzip pkg-config clang cmake libssl-dev \ && curl -fsS https://dlang.org/install.sh | bash -s ldc-${LDC_VERSION}几个细节值得展开:
- 版本锚定:
LDC_VERSION=1.41.0与JUPTUNE_REF(一个具体的上游 commit)共同保证基准结果可复现——基准测试条目必须冻结依赖版本,否则不同时间的结果不可比。 - 系统依赖清单(
libsodium-dev meson clang cmake libssl-dev等)反映了 LDC 编译工具链与 Juptune 构建所需的外部库。
接着是安装 Juptune 库本身的阶段,其中包含多处“为兼容较老发行版做的降级与裁剪”:
# Bookworm 的 meson 偏旧,因此需要重命名 meson.options; # 部分依赖版本比 Juptune 的 targets 旧,但对本基准的代码而言降级是可接受的 RUN curl -fsSL https://github.com/Juptune/juptune/archive/${JUPTUNE_REF}.zip -o code.zip \ && unzip code.zip \ && cd juptune* \ && . ~/dlang/ldc-${LDC_VERSION}/activate \ && mv meson.options meson_options.txt \ && sed -i 's/1.0.20/1.0.18/' meson.build \ && sed -i 's/3.5.0/1.0.18/' meson.build \ && sed -iE "s|.+./examples|#|" meson.build \ && sed -iE 's|^subdir|#|' meson.build \ && sed -iEz 's|juptune_all_unittest_exe[^)]+)|#|' meson.build \ && meson setup build --buildtype release -Dlightweight-results=true -Ddefault_library=static \ && meson install -C build这里的注释信息量很大(来自 Dockerfile 原文):
mv meson.options meson_options.txt——Debian Bookworm 自带 meson 版本较旧,不识别新文件名,故改名以兼容;- 两处
sed将依赖版本要求从1.0.20/3.5.0降到1.0.18,与 Bookworm 中较旧的依赖兼容; - 若干
sed注释掉subdir、examples 目录与juptune_all_unittest_exe目标——按注释解释,这是“把一些可执行文件的构建 dummy 掉以加快整体构建”,作者也注明理想做法是上游增加相应构建选项; - 构建参数
--buildtype release -Dlightweight-results=true -Ddefault_library=static:发布优化 + 精简 Result 类型 +静态链接,保证最终二进制不依赖动态库环境。
最后编译基准应用本体:
WORKDIR /app COPY ./src/ . RUN . ~/dlang/ldc-${LDC_VERSION}/activate \ && meson setup build --buildtype release -Ddefault_library=static \ && meson compile -C build ENTRYPOINT [ "/app/build/juptune-tfb" ]应用侧的构建脚本是 src/meson.build,它声明了 4 个 D 源文件与对 Juptune 的依赖:
project('juptune-tfb', 'd') srcs = files( './main.d', './tests/common.d', './tests/json.d', './tests/plaintext.d', ) juptune_dep = dependency('juptune') dep = declare_dependency( include_directories: include_directories('.'), sources: srcs, dependencies: [juptune_dep], d_module_versions: [ 'Juptune_Result_NoLineInfo', 'Juptune_Result_NoContext' ] ) main_exe = executable('juptune-tfb', dependencies: [dep])两个实现细节:
- 产物名为
juptune-tfb,与 Dockerfile 的ENTRYPOINT [ "/app/build/juptune-tfb" ]对应——容器启动即运行基准服务器; d_module_versions启用了Juptune_Result_NoLineInfo与Juptune_Result_NoContext。从命名可推断,这两个编译开关用于裁减 JuptuneResult错误类型中携带的行号/上下文信息,以换取更小的错误对象与更低的运行开销——对一个以Result为返回通道的高频 I/O 库来说,这是典型的基准级调优手段。
服务器启动流程:EventLoop、Fiber 配置与无 GC 引导线程
main.d 是整个基准应用的入口,也是理解 Juptune 编程模型的核心样本。先看顶部的常量配置区:
enum SOCKET_BACKLOG_PER_THREAD = 1000; enum FIBER_CALL_STACK_BYTES = 1024 * 100; enum HTTP_READ_BUFFER_BYTES = 1024; enum HTTP_WRITE_BUFFER_BYTES = 1024; enum HTTP_CONFIG = Http1Config() .withReadTimeout(1000.msecs) .withWriteTimeout(1000.msecs); static assert( HTTP_READ_BUFFER_BYTES + HTTP_WRITE_BUFFER_BYTES < FIBER_CALL_STACK_BYTES / 4, "To be safe, please ensure the buffer bytes are only a quarter the size of a fiber call stack." );这组参数体现了基准作者对资源布局的显式控制:
| 参数 | 取值 | 作用 |
|---|---|---|
SOCKET_BACKLOG_PER_THREAD | 1000 | 每个线程的 listen backlog 上限,实际监听 backlog 为1000 * totalCPUs |
FIBER_CALL_STACK_BYTES | 102400(100KB) | 每个 Fiber 调用栈的块大小,通过FiberAllocatorConfig注入 EventLoop |
HTTP_READ_BUFFER_BYTES/HTTP_WRITE_BUFFER_BYTES | 各 1024 | 每连接 HTTP 读/写缓冲大小 |
HTTP_CONFIG | 读/写超时各 1000ms | 通过Http1Config().withReadTimeout(...).withWriteTimeout(...)配置 |
尤其值得一提的是那条static assert:编译期强制要求“读缓冲 + 写缓冲 < 单个 Fiber 栈的四分之一”,并以断言消息说明理由——“为了安全,缓冲字节数应仅为 Fiber 调用栈大小的四分之一”。这是因为每个连接的读/写缓冲会被声明为栈上数组并置于连接处理的 Fiber 栈内(见下文路由循环),若不约束缓冲与栈的比例,高并发下可能出现 Fiber 栈溢出。这是把“内存布局约束”下沉到编译期的一个典型例子。
main()的启动分两个阶段:
void main() { auto loop = EventLoop( EventLoopConfig() .withFiberAllocatorConfig( FiberAllocatorConfig() .withBlockStackSize(FIBER_CALL_STACK_BYTES) ) ); // open() 和 listen() 不能在 event loop 线程之外执行, // 所以目前只能用这种“别扭”的方式来搭建服务器。 loop.addNoGCThread(() @nogc nothrow { server.open().resultAssert; server.listen("0.0.0.0:8080", SOCKET_BACKLOG_PER_THREAD*totalCPUs).resultAssert; juptuneEventLoopCancelThread(); }); loop.join(); // 然后再设置真正的 loop 线程。 foreach(i; 0..totalCPUs) loop.addGCThread(&router); loop.join(); }从这段代码与源码注释可以还原出 Juptune 的并发模型与当前限制:
- 全局 server 套接字:
__gshared TcpSocket server;上的注释写道——“目前没有直接把数据传递给新线程的机制,所以不得不使用全局状态”。也就是说,在引导阶段创建、在后续路由线程中使用的TcpSocket通过__gshared全局变量跨线程传递; - 两阶段启动:由于
open()/listen()只能在 event loop 线程内执行,作者先addNoGCThread(...)起一个无 GC 线程完成server.open()与server.listen("0.0.0.0:8080", 1000*totalCPUs),随后调用juptuneEventLoopCancelThread()取消该线程;loop.join()等待其结束; - 每 CPU 一个路由线程:
foreach(i; 0..totalCPUs) loop.addGCThread(&router);——按物理 CPU 数拉起一组带 GC 的事件循环线程,每个线程跑同一份router函数。多核下多个监听/接受线程共享同一个监听套接字,是基准常见的水平扩展方式(源码层面的具体调度细节由 Juptune 库负责,此处仅描述调用关系)。
手写路由:accept 循环 + 每连接异步 Fiber
Juptune 当前不提供高层的“服务器/路由”特性。main.d 中的注释直接说明了这一基准实现的定位:
Juptune currently does not provide higher-level server features out of the box, so we have to hand-make a custom router.
This is realistic in the sense that building a custom router is a completely valid, supported pattern for people who want/need something very specialised.
This is unrealistic in the sense that once Juptune has a native router, the native router would almost certainly be used in a case like this (but since that's a TODO, this will have to do for now).
翻译过来:Juptune 没有开箱即用的路由层,必须手写;手写自定义路由本身是“现实的(Realistic)”,因为它是完全合法且被支持的专用场景做法;但不太现实之处在于,一旦 Juptune 提供原生路由(目前是 TODO),真实项目大概率会用原生路由。这与 benchmark_config.json 中"approach": "Realistic"的声明相互呼应。
router()的主体是一个 accept 循环,每个连接被包装进一个异步 Fiber:
while(!juptuneEventLoopIsThreadCanceled()) { TcpSocket client; auto result = server.accept(client); if(result.isError) { log("error accepting socket: ", result); continue; } result = async(function () nothrow { auto client = juptuneEventLoopGetContext!TcpSocket; scope(exit) if(client.isOpen) auto _ = client.close(); // ... 逐连接处理循环 ... }, client, &asyncMoveSetter!TcpSocket); if(result.isError) { log("error calling async(): ", result); continue; } }结构要点:
- 循环以
juptuneEventLoopIsThreadCanceled()为退出条件,与启动阶段juptuneEventLoopCancelThread()的用法构成线程生命周期的对称接口; - 每个 accept 到的
client通过async(...)提交给事件循环,回调通过juptuneEventLoopGetContext!TcpSocket取回自己的套接字上下文,scope(exit)保证连接处理结束时关闭套接字; - Fiber 内部是一个
do { ... } while(!readSummary.connectionClosed && !writeSummary.connectionClosed);循环,即支持同一 TCP 连接上的连续请求(HTTP keep-alive 语义),直到读/写摘要表明连接已关闭。
HTTP/1.1 请求解析流程
连接 Fiber 内先构造读/写器(缓冲为栈上数组,受上文static assert的约束):
ubyte[HTTP_READ_BUFFER_BYTES] readBuffer; ubyte[HTTP_WRITE_BUFFER_BYTES] writeBuffer; auto reader = Http1Reader(client, readBuffer, HTTP_CONFIG); auto writer = Http1Writer(client, writeBuffer, HTTP_CONFIG);随后按“请求行 → 头 → 体 → 收尾”的顺序解析:
- 请求行与路由:
reader.readRequestLine(requestLine)之后,用requestLine.access(...)无拷贝地取出方法与 URI,做两级 switch 路由:
switch(methodString) { case "GET": method = Method.get; break; default: setError(405, "Unexpected method"); break; } switch(uri.path) { case "/plaintext": route = Route.plaintext; break; case "/json": route = Route.json; break; default: setError(404, "Not found"); break; }路由表是一个Route枚举(FAILSAFE / plaintext / json)加一个Method枚举(FAILSAFE / get),未匹配则走FAILSAFE分支并记录 404/405 错误码。
- 请求头:先
checkEndOfHeaders探测是否到头,否则逐个readHeader。由于是自定义路由,作者选择“在路由过程中直接决定处理/忽略每个头”,而不是把所有头塞进 hashmap 再后处理——这是手写路由的性能优势所在,源码注释明确点出了这一点:
// Since we're using a custom router, we have the luxury of handling/ignoring // headers during routing rather than stuffing them all into a hashmap... header.access((scope name, scope value) { /* 按 route 分发 */ });- 请求体:
readBody循环 +chunk.access(...)逐块访问,同样按路由决定是否处理; - 收尾与分发:
reader.finishMessage(readSummary)汇总本次读操作的结果;若路由阶段设置了errorMsg(404/405),直接写错误响应后continue;否则final switch(route)分发到handlePlainText或handleJson。
错误路径全部以log(...)记录后返回,日志函数由 common.d 提供:
enum ENABLE_LOGGING = false; void log(Args...)(scope auto ref Args args) { static if(ENABLE_LOGGING) writeln(args).assumeWontThrow; }ENABLE_LOGGING = false意味着发布构建下log()是零开销的空操作(static if在编译期消除整个函数体),这是基准代码控制日志开销的常用手法。
PLAINTEXT 实现:README 列出的核心端点
README 的 “Test Type Implementation Source Code” 一节指向 src/tests/plaintext.d,其完整实现如下:
void handlePlainText( scope ref PlainTextHeaderInput input, scope ref Http1Writer writer, scope ref Http1MessageSummary summary, ) nothrow { import juptune.core.util : then; import juptune.core.util.conv : IntToCharBuffer, toBase10; immutable RESPONSE = "Hello, World!"; IntToCharBuffer contentLengthBuffer; const contentLength = toBase10(RESPONSE.length, contentLengthBuffer); auto result = writer.putResponseLine(Http1Version.http11, 200, "OK").then!( () => writer.putServerAndDate(), () => writer.putHeader("Content-Length", contentLength), () => writer.putHeader("Content-Type", "text/plain"), () => writer.finishHeaders(), () => writer.putBody("Hello, World!"), () => writer.finishBody(), () => writer.finishTrailers(), () => writer.finishMessage(summary) ); if(result.isError) { log("writing response [plaintext] failed: ", result); return; } }实现特征:
then!链式调用:Juptune 的Result类型通过then!(来自juptune.core.util)把“响应行 → Server/Date 头 → 自定义头 → 结束头 → 写体 → 结束体/尾节 → 完成消息”串成惰性执行链,任一步失败即中止并返回错误,避免每步都手写错误判断;- 零堆分配的数字转换:
Content-Length不是先itoa成动态字符串,而是toBase10(RESPONSE.length, contentLengthBuffer)写入IntToCharBuffer栈缓冲——对热路径而言,这类“不分配”的细节正是底层库基准要度量的对象; - 固定响应体:
"Hello, World!"(13 字节),与 TFB plaintext 用例的校验要求完全吻合(见下文“TFB 校验规则”一节)。
JSON 端点的对应实现
虽然 README 未列出,json.d 中的handleJson与 plaintext 同构:
ArrayNonShrink!char buffer; buffer.reserve(256); ubyte[8] jsonDepth; scope put = (scope const(char)[] slice) { buffer.put(slice); return Result.noError; }; scope builder = JsonBuilder!(typeof(put))(put, jsonDepth); auto result = builder.jsonSerialise(Message("Hello, World!"));要点:用 Juptune 自带的JsonBuilder+ 回调式写入器把Message("Hello, World!")(@Json string message;)序列化到预分配的ArrayNonShrink!char栈缓冲中(reserve(256)),随后同样以then!链写出Content-Type: application/json响应;序列化失败则回写 500。json_depth使用 8 字节的栈数组控制嵌套深度,避免了递归/堆分配。
共用工具:手写 GMT 日期头
common.d 的putServerAndDate负责写入 TFB 要求的两个响应头:
return writer.putHeader("Server", "Juptune (Linux)").then!( () => writer.putHeader("Date", dateBuffer[0..dateCursor]) );Date头是手工逐字段拼装到char[256] dateBuffer上的:星期缩写(final switch(currTime.dayOfWeek))、日、月份缩写(final switch(currTime.month))、年、时分秒,最后加" GMT"。两处final switch利用枚举穷尽性让编译器证明分支完备,配合全程栈缓冲,保持了与整体“无分配热路径”一致的风格。
测试 URL 与 TFB 工具链的校验逻辑
README 的 “Test URLs” 一节给出:
PLAINTEXT
http://localhost:8080/plaintext
容器内服务监听0.0.0.0:8080(见 main.d 的server.listen("0.0.0.0:8080", ...)),TFB 的测试驱动在宿主机访问该 URL 验证连通性与响应正确性。plaintext 用例的校验规则实现在 toolset/test_types/plaintext/plaintext.py 中,与本条目相关的检查包括:
- URL 长度约束:
plaintext_url至少 10 个字符,否则记为 fail。本条目的/plaintext恰好 10 个字符(README 中写作/plaintext,配置为"/plaintext"),满足下限; - 响应体校验:大小写不敏感地检查 body 中是否包含
b"hello, world!";handlePlainText返回的Hello, World!正好命中; - 额外字节告警:若响应体比
Hello, World!多字节,会提示 “Server is returning N more bytes than are required. This may negatively affect benchmark performance.”——本实现的响应体恰好 13 字节,不触发告警; - 状态码与头校验:
verify_status与verify_headers(..., should_be='plaintext')检查 200 状态和Content-Type: text/plain,与handlePlainText写入的200 OK/text/plain对应。
实际压测阶段,TFB 使用 wrk 脚本执行流水线压测:toolset/wrk/pipeline.sh 配合 toolset/wrk/pipeline.lua,按benchmark_config.json中声明的并发级别对http://localhost:8080/plaintext发起请求——这部分属于 TFB 通用工具链,各框架条目共享。
小结:一个底层库如何进入框架基准
把 README 的三个信息点(Juptune 是 io_uring 异步 I/O 库、PLAINTEXT 实现源码、测试 URL)放回仓库上下文后,可以归纳出该基准条目的设计逻辑:
- 冻结依赖:juptune.dockerfile 用
LDC_VERSION=1.41.0+ 固定上游 commit 保证可复现,并针对 Debian Bookworm 的旧工具链做版本降级与构建裁剪; - 显式资源布局:main.d 中的常量区 +
static assert把 Fiber 栈、HTTP 缓冲、backlog 的配比变成编译期可验证的约束; - 手写 HTTP 层:在没有原生路由的库里,用
Http1Reader/Http1Writer+Result.then!链手工实现路由与响应,作为“Realistic”方案提交基准; - 零开销日志与栈上缓冲:
ENABLE_LOGGING=false、IntToCharBuffer、ArrayNonShrink等细节保证热路径贴近库的真实能力。
对希望在 TFB 中新增 D 语言底层库条目的开发者,这个目录提供了完整的参考结构:README.md(说明与实现入口)、benchmark_config.json(用例与元数据声明)、<name>.dockerfile(构建与 ENTRYPOINT)、src/main.d+src/tests/*.d(服务器与各用例实现)。
- 性能测试
- 测试
- 后端
- 开发者工具
【免费下载链接】FrameworkBenchmarks
Source for the TechEmpower Framework Benchmarks project
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考