基于协程的高性能 LuaJIT 网络库,构建于 libuv 之上。
本项目基于 夏磊 (Xia Lei) 的 xialeistudio/lunet。详见他的精彩文章:Lunet:高性能协程网络库的设计与实现。
Lunet 的首页用例是微型 localhost MCP 服务器 - 运行在 LLM 客户端旁、回环地址上的 Model Context Protocol 工具:空闲时只占个位数 MB 内存,瞬间启动。长篇理念阐述请阅读:Lunet 的 Talos、Ethos 与無為。
| 路径 | 适合谁 | 需要什么 | 从这里开始 |
|---|---|---|---|
| 运行 Lua | 你写 Lua 应用;不想碰 C 工具链 | 对应 OS 的发布压缩包 | 发布页 |
| 打包一体机 | 你要交付单一自包含可执行文件 - 你的 main() + Lunet + 你的应用 |
发布版 SDK + 一个 C 编译器 | docs/EMBEDDING-CN.md |
| hack 核心 | 你开发 Lunet 本身,或想要最小化功能构建 | xmake + 系统开发库 | make init,然后 docs/XMAKE_INTEGRATION-CN.md |
最省事的办法:直接下载预构建发布包。 无需任何工具链 - 每个
发布 都会发布自包含压缩包
(lunet-linux-amd64.tar.gz、lunet-linux-arm64.tar.gz、
lunet-macos.tar.gz、lunet-windows-amd64.zip),内含 lunet-run 与全部
扩展模块的预构建二进制:
TAG=v0.14.1
curl -fsSLO "https://github.com/lua-lunet/lunet/releases/download/${TAG}/lunet-macos.tar.gz"
tar -xzf lunet-macos.tar.gz
./lunet-run path/to/app.lua(Windows 用户:下载并解压 lunet-windows-amd64.zip。)共享库引用由你
操作系统已有的运行时包满足 - 在 Debian/Ubuntu 上:libluajit-5.1-2 libuv1 zlib1g,再为你实际 require() 的每个扩展装一个运行时包(例如
SQLite 驱动装 libsqlite3-0,lunet.httpc 装 libcurl4)。完整的依赖
清单与平台说明见:docs/RELEASE-LAYOUT-CN.md。
压缩包内容一览:
| 文件 | 是什么 |
|---|---|
lunet-run |
独立可执行文件 - 直接运行 Lua 应用 |
lunet.so |
require("lunet") 使用的核心共享库 |
lunet/ |
预构建扩展模块:sqlite3 驱动为 sqlite3core.so + 其 sqlite3.lua 加载器(完整参考:ext/sqlite3/SQLITE-CN.md)、mysql / postgres 驱动 + 事务包装、httpc、Rust 扩展(lnt_shared、jsonic、paxe、sodium)的 .so / .dylib / .dll - 规范清单见 docs/RELEASE-LAYOUT-CN.md |
types/ |
用于编辑器补全的 LuaCATS 注解 |
*.md |
随附文档 |
你也可以从源码构建、深入底层细节 - 见 docs/XMAKE_INTEGRATION-CN.md。当目标环境 不允许使用预构建二进制时必须走这条路;除此之外请直接使用发布压缩包。
Lunet 拒绝把监听器绑定到 127.0.0.1、::1 或 Unix socket 以外的地址 - 默认没有远程安全漏洞。要对外暴露服务,请在前面放一个久经考验的 sidecar(nginx、OpenResty、Caddy、Envoy,由管理员选择),让协议拆分攻击死在代理层而不是你的 Lua 协程里。在唯一入口是加固云代理(清洗畸形流量、吸收 DDoS)的私有网络中,绑定所有网卡可以是安全的 - 但 Lunet 要求你用显式的 --dangerously-skip-loopback-restriction 标志郑重声明这一点。详见 docs/SECURITY_ARCHITECTURE-CN.md。
Lunet 采用模块化设计。只构建你需要的:
- 核心 (
lunet):TCP/UDP 套接字、文件系统、定时器、信号 - 数据库驱动(可选 xmake 目标):
lunet-sqlite3- SQLite3 驱动lunet-mysql- MySQL/MariaDB 驱动lunet-postgres- PostgreSQL 驱动- 出站 HTTPS 客户端(可选 xmake 目标):
lunet-httpc- 基于 libcurl 的 HTTPS 客户端(require("lunet.httpc");参考:docs/HTTPC-CN.md)- 共享字典(可选 Rust 扩展,Linux/macOS):
lunet.lnt_shared- lunet 风格的共享字典,通过 Rust FFI 实现(xmake build-lnt-shared)- JSON(可选 Rust 扩展,Linux/macOS):
lunet.jsonic- dkjson 风格的编解码;解码通过 Rust FFI 封装 jsonic(xmake build-jsonic)- 数据报加密(可选 Rust 扩展,全平台):
lunet.paxe- 通过 Rust FFI 的 AES-256-GCM 数据报加密,静态链接 libsodium(xmake build-paxe;参考:docs/PAXE-CN.md)- 加密 / JWT(Lua 模块,本身无二进制 - 其加密是经
lunet.sodium,由 FFI 调用独立 cdylibliblunet_crypto中的 libsodium 实现): lunet.security- 基于lunet.sodium+lunet.jsonic的 HMAC-SHA-256 签名/验证、base64url 与 JWT HS256(参考:docs/CRYPTO_SECURITY-CN.md)
只构建一个数据库驱动,而不是全部。没有未使用的依赖。不需要为从未使用的库打安全补丁。
你可能会想"我可以直接用 LuaJIT FFI 调用 sqlite3/libpq/libmysqlclient" - 确实可以。但这些调用是阻塞的。它们会在等待数据库时冻结整个事件循环。
Lunet 数据库驱动是协程安全的:
- 查询在 libuv 线程池上运行 (
uv_work_t) - 连接使用互斥锁保护,支持安全的并发访问
- 协程在等待时让出执行权,其他协程继续运行
如果在 lunet 应用中使用原生 FFI 数据库绑定,你将失去所有异步优势。
# 默认 SQLite 构建(同时构建 lunet-static SDK 静态库和 sdk-api-test)
xmake build-release
# 调试模式构建(启用追踪)
xmake build-debugLuaJIT、libuv 和 zlib 是必需依赖;每个驱动只增加自己的客户端库。发布档位默认会剥离 EasyMem。若需 EasyMem 诊断,请使用 debug/ASan 档位。
仓内最小入门示例见 examples/mcp_openalex_sse/ - 一个通过 lunet.httpc 调用 OpenAlex 学术 API 的 SSE 传输 MCP 服务器,无数据库、无文件系统状态。
lunet-mcp-sse 是一个更完整的 MCP(Model Context Protocol)服务器,带 Tavily 网络搜索。
examples/README-CN.md 是路标:完整的 RealWorld "Conduit" 实现位于
lua-lunet/realworld-lua,演示如何
用 Lunet 构建更典型的应用,同时保持更小的内存占用与更快的启动时间。
先构建运行器:
xmake build-release
LUNET_BIN=$(find build -path '*/release/lunet-run' -type f 2>/dev/null | head -1)如果你下载的是预构建的发布压缩包而不是自己构建,把 LUNET_BIN 指向压缩包里的
lunet-run 即可:旁边的 lunet/ 目录已经把下表中「需要」列的每个扩展都作为预构
建二进制一并携带 - 只有从源码构建时才需要 xmake build <target>。
| # | 示例 | 演示内容 | 需要 | 运行 |
|---|---|---|---|---|
| 01 | examples/01_http_json.lua |
极简 HTTP 服务器,返回 JSON | 核心(lunet、lunet.socket) |
"$LUNET_BIN" examples/01_http_json.lua |
| 02 | examples/02_http_routing.lua |
带路径 :params 的微型路由 |
核心(lunet、lunet.socket) |
"$LUNET_BIN" examples/02_http_routing.lua |
| 03 | examples/03_db_sqlite3.lua |
SQLite3 CRUD + (rw, ro) 读写拆分(读在循环线程内联执行) |
xmake build lunet-sqlite3 |
"$LUNET_BIN" examples/03_db_sqlite3.lua |
| 04 | examples/04_db_mysql.lua |
MySQL CRUD + 预处理语句(?) |
xmake build lunet-mysql + MySQL 服务器 |
"$LUNET_BIN" examples/04_db_mysql.lua |
| 05 | examples/05_db_postgres.lua |
Postgres CRUD + 预处理语句($1) |
xmake build lunet-postgres + Postgres 服务器 |
"$LUNET_BIN" examples/05_db_postgres.lua |
| 08 | examples/08_http.lua |
HTTP 服务器 + 路由器(lunet.http、lunet.http_router),带中间件与可选 CORS;以 lnt_shared 计数器作为刻意简化的 kv 存储 - GET 读取、POST 递增、PATCH 调整 |
xmake build-release + xmake build-lnt-shared |
"$LUNET_BIN" examples/08_http.lua |
| 09 | examples/09_jsonic_demo.lua |
经 Rust FFI 的 dkjson 风格 JSON 编解码 | xmake build-jsonic |
"$LUNET_BIN" examples/09_jsonic_demo.lua |
| 10 | examples/mcp_openalex_sse/ |
规范级微型 MCP 服务器:SSE 传输 + 经 lunet.httpc 的 OpenAlex API,无数据库、仅回环 |
xmake build lunet-httpc + .env 中的 OPEN_ALEX_API_KEY |
"$LUNET_BIN" examples/mcp_openalex_sse/main.lua |
所有网络操作必须在通过 lunet.spawn 创建的协程中调用。
local socket = require("lunet.socket")
-- 服务器
local listener = socket.listen("tcp", "127.0.0.1", 8080)
local client = socket.accept(listener)
-- 客户端
local conn = socket.connect("127.0.0.1", 8080)
-- I/O
local data = socket.read(conn)
socket.write(conn, "hello")
socket.close(conn)TCP 数据套接字(accept 与 connect 的返回结果)默认启用 TCP_NODELAY:本运行时的目标负载是
小型单次写入的请求/响应流量,Nagle 在这种场景下只会带来 delayed-ACK 尾延迟,没有任何收益。
对特定套接字重新启用 Nagle(多小帧写协议)用 socket.set_nodelay(handle, false);
socket.set_nodelay(handle, true) 再关回去。Unix 套接字忽略该设置。
socket.set_nodelay(conn, false) -- 对该套接字重新启用 Naglelocal udp = require("lunet.udp")
-- 绑定
local h = udp.bind("127.0.0.1", 20001)
-- I/O
udp.send(h, "127.0.0.1", 20002, "payload")
local data, host, port = udp.recv(h)
udp.close(h)数据库驱动是可选构建目标。只构建你需要的:
xmake build lunet-sqlite3 # SQLite3
xmake build lunet-mysql # MySQL/MariaDB
xmake build lunet-postgres # PostgreSQLlocal db = require("lunet.sqlite3")
-- 打开:返回两个连接 - rw(worker 池)+ ro(读在循环线程内联执行)。
-- 调用对按路径缓存;create="WAL" 一步完成建文件并设上持久的 WAL 日志
-- 模式。完整参考见 ext/sqlite3/SQLITE-CN.md
local rw, ro, err = db.open({ path = "myapp.db", create = "WAL" })
if not rw then error(err) end
-- 执行语句(INSERT/UPDATE/DELETE)- 返回结果元数据
local result = db.exec(rw, "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
print(result.affected_rows)
-- 通过只读句柄查询(SELECT)- 返回行表数组
local users = db.query(ro, "SELECT * FROM users WHERE active = 1")
for _, user in ipairs(users) do
print(user.id, user.name)
end
-- 参数化查询(防止 SQL 注入)
local results = db.query(ro, "SELECT * FROM users WHERE name = ?", "alice")
db.exec(rw, "INSERT INTO users (name) VALUES (?)", "bob")
-- 缓存对经 destroy(path) 关闭;open_raw() 打开未缓存的一对,
-- 其句柄经 db.close() 逐个关闭
db.destroy("myapp.db")local db = require("lunet.mysql")
-- 打开连接
local conn = db.open({
host = "127.0.0.1",
port = 3306,
user = "root",
password = "secret",
database = "myapp"
})
-- 与 SQLite3 相同的 API
local users = db.query(conn, "SELECT * FROM users")
db.exec(conn, "INSERT INTO users (name) VALUES (?)", "alice")
db.close(conn)local db = require("lunet.postgres")
-- 打开连接
local conn = db.open({
host = "127.0.0.1",
port = 5432,
user = "postgres",
password = "secret",
database = "myapp"
})
-- 与 SQLite3 相同的 API
local users = db.query(conn, "SELECT * FROM users")
db.exec(conn, "INSERT INTO users (name) VALUES ($1)", "alice") -- PostgreSQL 使用 $1, $2 等
db.close(conn)| 函数 | 描述 | 返回值 |
|---|---|---|
db.open(config) |
打开连接:sqlite3 返回按路径缓存的 (rw, ro) 对 - create = "WAL"|"NORMAL" 允许驱动创建新文件(不传则文件必须已存在);postgres/mysql 返回单个连接(可用 pool_size 池化,默认 1) |
连接句柄 |
db.close(conn) |
关闭连接 | - |
db.query(conn, sql, ...) |
执行 SELECT(可带参数) | 行表数组 |
db.exec(conn, sql, ...) |
执行 INSERT/UPDATE/DELETE(可带参数) | 结果表(affected_rows、last_insert_id) |
db.query_params(conn, sql, ...) |
与 db.query 行为一致 |
行表数组 |
db.exec_params(conn, sql, ...) |
与 db.exec 行为一致 |
结果表(affected_rows、last_insert_id) |
注意:三种驱动内部均使用原生预处理语句。参数通过驱动原生函数(sqlite3_bind_*、mysql_stmt_bind_param、PQexecParams)自动绑定,从根本上消除 SQL 注入风险。本项目有意不提供 db.escape:手工转义既无必要,也永远不如参数绑定安全。
使用你自己持有的连接句柄,像执行普通语句一样执行事务控制语句:
db.exec(conn, "BEGIN")
local ok, err = pcall(function()
db.exec(conn, "DELETE FROM nodes WHERE path = $1", dst)
db.exec(conn, "UPDATE nodes SET path = $1 WHERE path = $2", dst, src)
end)
db.exec(conn, ok and "COMMIT" or "ROLLBACK")需要注意两点:
- 带绑定参数的语句必须是单条命令。 这是 libpq / MySQL 扩展协议的行为,并非
Lunet 的限制。不带参数的语句可以包含多条以
;分隔的命令,但只会返回最后 一条命令的行数据与affected_rows。 - 事务属于连接句柄,而不属于协程。 事务中的所有语句必须使用同一个
conn。 如果应用层使用连接池,请在整个事务期间固定同一个句柄 - 每次调用都从池中租借 连接的做法会把BEGIN与COMMIT分散到不同会话上。在提交之前,不要让其他 协程使用该句柄。
lunet 风格的共享字典,由 Rust FFI 库支撑。纯可选扩展。
构建(需要 Rust 1.85+ / 2024 edition):
xmake build-lnt-shared # 等同于: cd ext/lnt_shared && cargo build --release使用示例:
local lnt = loadfile("ext/lnt_shared/lnt_shared.lua")()
local cache = lnt.store("my_cache", 1024 * 1024) -- 1 MiB
cache:set("key", "value")
cache:set("hits", 0)
cache:incr("hits", 1, 0) -- 原子自增(init=0 时自动创建)
cache:set("session", "tok", 30) -- 30 秒后过期
cache:add("lock", "worker-1") -- 仅在 key 不存在时设置
cache:replace("lock", "w2") -- 仅在 key 存在时更新
cache:flush_all()
local evicted = cache:flush_expired()
print(cache:capacity(), cache:free_space())
-- 句柄由 GC 自动管理(ffi.gc 终结器);如需立即释放可显式关闭。
-- 共享区域本身的生命周期不受单个句柄关闭影响。
cache:close()架构:每个命名字典使用一个 mmap(MAP_ANONYMOUS|MAP_SHARED) 匿名映射区域;
所有状态均在该区域内(无堆分配持久数据);FNV-1a 开放地址哈希表;
bump 分配器;单自旋锁。同一进程内以相同名称打开的多个句柄共享同一区域。
dkjson 兼容的 encode/decode/null API,外加定形渲染辅助函数(jstr
使用与 encode 相同的转义器处理字符串;jint 按 encode 的输出原样渲染
数字),供以静态前缀拼接 JSON 字节串的响应构造器使用。解码由 Rust FFI 封装
jsonic(MIT / Apache-2.0,无运行时依赖)
实现 - 完整署名见 ext/jsonic/NOTICE.md。jsonic 本身只做解析;encode()
是纯 Lua 实现,无任何依赖。
构建(需要 Rust 1.85+ / 2024 edition):
xmake build-jsonic # 等同于: cd ext/jsonic && cargo build --release使用示例:
local json = loadfile("ext/jsonic/jsonic.lua")()
local value, pos, err = json.decode('{"a":1,"b":[true,null]}')
-- value = { a = 1, b = { true, json.null } }
local str = json.encode({ a = 1, b = { true, json.null } })
-- str = '{"a":1,"b":[true,null]}'
json.encode({ a = 1 }, { indent = true, keyorder = { "a" } })
json.jstr('say "hi"') -- '"say \"hi\""'
json.jint(42) -- '42'使用 xmake build-debug 构建可启用协程引用追踪和栈完整性检查。运行时会在检测到泄漏或栈污染时触发断言并崩溃。
xmake 是标准构建系统。没有 Makefile。所有任务定义在 xmake.lua 中。
| 任务 | 描述 |
|---|---|
xmake lint |
C 安全代码检查 |
xmake check |
luacheck 静态分析 |
xmake test |
单元测试(busted) |
xmake build-release |
优化的发布构建 |
xmake build-debug |
启用追踪的调试构建 |
xmake examples-compile |
示例编译/语法检查 |
xmake sqlite3-smoke |
SQLite3 示例冒烟测试 |
xmake build-lnt-shared |
构建 lnt_shared Rust 扩展 |
xmake lnt-shared-smoke |
构建 lnt_shared 并运行冒烟测试 |
xmake build-jsonic |
构建 jsonic Rust 扩展 |
xmake jsonic-smoke |
构建 jsonic 并运行冒烟测试 |
xmake stress |
带追踪的并发压力测试 |
xmake ci |
本地 CI 一致性检查(lint + 构建 + 示例 + sqlite3 冒烟) |
xmake preflight-easy-memory |
EasyMem + ASan 预检门控 |
xmake release |
完整发布门控(lint + test + stress + 预检 + 构建) |
完整任务目录和推荐工作流请参见 docs/WORKFLOW-CN.md。
xmake test # 单元测试
xmake stress # 带追踪的并发负载测试
xmake ci # 完整的本地 CI 一致性检查Lunet 对运行期崩溃有分层的调试策略。请按顺序使用 - 每一级都更昂贵,但给出更多细节。
| 层级 | 工具 | 能捕获什么 | 构建命令 |
|---|---|---|---|
| 1 | 领域追踪 | 逻辑错误、操作序列 | xmake f --lunet_trace=y --lunet_verbose_trace=y |
| 2 | 内存追踪 + EasyMem | UAF、双重释放、泄漏、分配器完整性 | xmake f --lunet_trace=y(自动启用 EasyMem) |
| 3 | Address Sanitizer + EasyMem | 编译器级内存错误 + 分配器诊断 | xmake f -m debug --asan=y |
| 4 | lldb / 核心转储 | 寄存器级检查、完整回溯 | lldb -- ./build/.../lunet-run app.lua |
每个模块(socket、timer、fs、udp、signal)都有 *_TRACE_* 宏,输出到 stderr。启用详细追踪构建:
xmake f -c -y --lunet_trace=y --lunet_verbose_trace=y
xmake build lunet-bin
./build/.../lunet-run app.lua 2> trace.log当追踪日志戛然而止时,崩溃发生在最后打印的行与下一个操作之间。用它来锁定是哪个回调、哪一行。
ASan 是对付内存损坏 bug 最有效的工具。它为每一次内存访问插桩,并带精确源码位置地捕获 use-after-free、缓冲区溢出和栈损坏:
xmake f -c -y -m debug --lunet_trace=y --asan=y
xmake build lunet-bin
./build/.../lunet-run app.lua 2> asan.log--asan=y 时,Lunet 还会同时启用带诊断模式的 EasyMem 后端(LUNET_EASY_MEMORY_DIAGNOSTICS),分配器级完整性检查与性能画像与 ASan 并行运行。
ASan 输出到 stderr。进程以 Abort trap: 6 而不是 Segmentation fault: 11 退出。在日志里找 ERROR: AddressSanitizer:。
要同时插桩 Lunet 与 LuaJIT(不只是 Lunet 的 C 代码),构建 Debian Trixie 使用的 OpenResty LuaJIT 源码包,并把 Lunet 链接到它:
xmake luajit-asan
xmake build-debug-asan-luajit
xmake repro-50-asan-luajit这些辅助目标现在自动继承 EasyMem,因为它们配置了 --asan=y --lunet_trace=y。
这里使用 Debian 源码包 luajit_2.1.0+openresty20250117-2,并把本地 ASan 版 LuaJIT 装到 .tmp/luajit-asan/install/2.1.0+openresty20250117/。
版本钉子配置在 xmake.lua 的选项里(luajit_snapshot、luajit_debian_version)。覆盖:
xmake f --luajit_snapshot=2.1.0+openresty20250117 --luajit_debian_version=2.1.0+openresty20250117-2 -y在 macOS 上,确保 Xcode 工具链就绪,ASan 报告才会带文件/行号符号:
xcode-select -p
xcrun --find clang
xcrun --find llvm-symbolizer
export ASAN_SYMBOLIZER_PATH="$(xcrun --find llvm-symbolizer)"
export ASAN_OPTIONS="abort_on_error=1,halt_on_error=1,fast_unwind_on_malloc=0,detect_leaks=0"如果 xmake luajit-asan 报 missing: export MACOSX_DEPLOYMENT_TARGET=XX.YY,设置:
export MACOSX_DEPLOYMENT_TARGET="$(sw_vers -productVersion | awk -F. '{print $1 "." $2}')"不要只靠边界追踪去推断故障位置。先让 LuaJIT/Lunet 在 ASan 下立刻崩:
xmake build-debug-asan-luajit
xmake repro-50-asan-luajit如果 bug 对时序敏感,多跑几轮:
ITERATIONS=100 REQUESTS=100 CONCURRENCY=8 WORKERS=8 xmake repro-50-asan-luajit为了在隔离 yield/resume 记账类 bug 时降低 JIT 侧的非确定性,可以在 repro 的 Lua 入口里关掉 JIT:
local ok, jit = pcall(require, "jit")
if ok and jit then jit.off() end这让执行留在解释器模式,常能让协程/状态机类故障在 ASan 下更快复现。
lunet_co_resume() 包装器会在每次 lua_resume 前后记录 [CO_TRACE] RESUME / [CO_TRACE] RESUMED(启用详细追踪时)。它证明崩溃是在 Lua VM 内部,还是在其周围 C 代码里。
LUNET_TRACE=ON 时,socket 与 UDP 路径会为以下操作跟踪等待/resume 序号与在途状态:
- socket:
accept、read、write - udp:
recv
任何非法迁移(重复等待、没有等待就 resume、resume 序号越过等待序号)会打印 BK_FAIL 并立刻触发断言。它把协程泵/状态机记账类 bug 在第一次违规时当场抓住,而不需要从边界追踪里推断。
所有记账、追踪计数器、金丝雀和断言都只在 LUNET_TRACE 下编译。发布构建(--lunet_trace=n)把这些检查完全编译掉,调试插桩不给运行期留下任何代价。
如果崩溃发生在 libuv 回调里的 lua_rawgeti,通常意味着用于注册表操作的 lua_State* 无效(悬空或已损坏)。不要把短命的协程 lua_State* 指针存进长寿命句柄;注册表访问使用拥有它的主状态,等待中的协程用 coref 跟踪。
LUNET_TRACE 启用时,所有经 lunet_alloc() / lunet_free() 的分配都带金丝雀头与释放投毒。trace 构建也会自动启用 EasyMem,提供分配器级完整性检查和内存可视化。关机时 lunet_mem_assert_balanced() 检查泄漏。在所有 lunet C 代码里请用 lunet_alloc / lunet_free,不要裸用 malloc / free。
MIT