惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
V
V2EX
大猫的无限游戏
大猫的无限游戏
腾讯CDC
博客园 - Franky
WordPress大学
WordPress大学
Jina AI
Jina AI
GbyAI
GbyAI
云风的 BLOG
云风的 BLOG
B
Blog RSS Feed
Last Week in AI
Last Week in AI
The Cloudflare Blog
V
Visual Studio Blog
P
Proofpoint News Feed
博客园 - 叶小钗
L
LangChain Blog
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
Recorded Future
Recorded Future
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
T
The Blog of Author Tim Ferriss
人人都是产品经理
人人都是产品经理
Y
Y Combinator Blog
罗磊的独立博客
雷峰网
雷峰网
博客园 - 【当耐特】
Microsoft Security Blog
Microsoft Security Blog
L
LINUX DO - 热门话题
Cisco Talos Blog
Cisco Talos Blog
L
Lohrmann on Cybersecurity
Martin Fowler
Martin Fowler
Spread Privacy
Spread Privacy
MongoDB | Blog
MongoDB | Blog
Engineering at Meta
Engineering at Meta
C
Cybersecurity and Infrastructure Security Agency CISA
小众软件
小众软件
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
Recent Announcements
Recent Announcements
T
Threat Research - Cisco Blogs
Security Archives - TechRepublic
Security Archives - TechRepublic
量子位
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
宝玉的分享
宝玉的分享
D
DataBreaches.Net
T
The Exploit Database - CXSecurity.com
Vercel News
Vercel News
IT之家
IT之家
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
T
Troy Hunt's Blog
aimingoo的专栏
aimingoo的专栏

重归混沌的BLOG

给silly实现了一个ernro模块 | 重归混沌的BLOG 给silly实现了一个ernro模块 | 重归混沌的BLOG 第一次在生产环境使用 Vibe Coding | 重归混沌的BLOG 第一次在生产环境使用 Vibe Coding | 重归混沌的BLOG API 设计的艰难抉择 | 重归混沌的BLOG 十年 | 重归混沌的BLOG 十年 | 重归混沌的BLOG 在Go语言中如何使XML加载内存无限趋近于0 | 重归混沌的BLOG 在Go语言中如何使XML加载内存无限趋近于0 | 重归混沌的BLOG 对跨服玩法中的分布式一致性问题进行简单抽象 | 重归混沌的BLOG 对跨服玩法中的分布式一致性问题进行简单抽象 | 重归混沌的BLOG Go语言逃逸分析之slice和map | 重归混沌的BLOG Go语言逃逸分析之slice和map | 重归混沌的BLOG 谈谈观测 | 重归混沌的BLOG 谈谈观测 | 重归混沌的BLOG 写了个AI Agent服务端 | 重归混沌的BLOG 写了个AI Agent服务端 | 重归混沌的BLOG 谈谈代码设计中“严丝合缝” | 重归混沌的BLOG 谈谈代码设计中“严丝合缝” | 重归混沌的BLOG 一次艰难的线上游戏服务器内存排查经历 | 重归混沌的BLOG 一次艰难的线上游戏服务器内存排查经历 如何基于LanguageServerProtocol来编写lint工具 谈谈游戏服务器中RPC模块的设计 谈谈游戏服务器代码抽象 最近碰到的一个分布式一致性问题 谈谈游戏服务器的自动化测试 对Raft协议的一点理解 使用mmap来学习/proc/pid/smaps 2023(完) 再次实现了一个Lua性能分析器 终于给Silly的定时器增加了取消功能 一次虚拟内存排查经历 游戏服务器分布式数据的一种同步的思路 为silly增加了互斥锁 2022(完) Go语言之闭包篇 一例误用unsafe包引起的内存问题 Go语言之内存篇 初识Go语言 重新抽象图形API 给Lua实现了一个数学库 谈谈跨平台图形API的抽象 寻路和Flocking算法的结合 行为树的一种高效实现 内测过程中Shader出现的问题 彻底解决多国语言 谈谈数据库的选型 再谈Lua热更新(终) 初窥Rust 关于游戏服务器的服务拆分 ECS的初步实现 ECS初探 屏幕空间(SreenSpace)的想象力 一些对辐射度量学的理解 深度缓冲和半透明渲染 Mysql的间隙锁 更新一些GPU相关知识 2020 地形渲染之爬过的坑 Lua5.3 GC源码阅读(5) 实现一个数据库存储队列 再学计算机图形学入门 再谈分布式服务架构 游戏上线一个月后的反思 一次并发Bug 双向链表的三种实现 谈谈随机数的使用 再谈性能优化 2019 Lua中的函数式编程 重构登录逻辑 Unity资源管理(续) 谈谈Unity的资源管理 一次关于Cache的性能分析 历史之2018 DC3算法 移动平台native代码遭遇的坑 从CPU层面谈谈优化 开卷有益(UNIX编程艺术篇) GC竞争问题 通过Mesh投影来实现贴花系统 谈谈我对数据同步的理解 又一个类型提升引起的Bug Lua5.3 GC源码阅读(4) Lua5.3 GC源码阅读(3) Lua5.3 GC源码阅读(2) Lua5.3 GC源码阅读(1) 三角形光栅化时遇到的坑 一次git事故 再见2017 又一个lua调试器 客户端缓存落地方案 Paxos算法 HTTP服务器的特点 一次性能优化经历 关于CPU分支预测 C程序中让两个不同版本的库共存 实现了一个AOI模块 一个高可伸缩的游戏服务器架构 关于网络协议封装的一些新想法
API 设计的艰难抉择 | 重归混沌的BLOG
重归混沌 · 2025-12-02 · via 重归混沌的BLOG

自从下决心Silly进行大规模重构以来,最近三个月,我几乎把所有业余时间都投入到了这次重构中。

虽然重构还未完全结束,不过总算大局已定。趁现在记忆还算清晰,先总结一下。

这次重构,更像是一次代码风格的统一。

这个仓库存在时间太久了,几乎囊括了我所有时期的代码风格,因此即便都是我写的,也存在风格不一致的情况。

在重构过程中,我重新明确了许多规范和原则。

借助Claude Code,实际编码的时间并不算多,更多的时间花在了取舍、折中和推翻已有设计上。


先是命名规则和命名空间

所有导出函数必须控制在两个单词以内,这一设计借鉴自 C 语言。

这样既能更好地兼容业务层的多样命名风格,也符合 Lua 的习惯。

如果超过两个单词,通常意味着模块功能过于复杂,需要拆分。

有些模块仅为了可读性而拆分,但内部实现高度耦合,因此需要共享一些函数。这些共享函数统一以 _ 开头。

之所以不用 __,是因为 Lua 保留了 __ 用作元方法,我猜测 Lua 是故意将 _ 开头的命名空间留给我们使用(没有深入考究 :D)。

Silly 的所有库都位于 silly 命名空间下,通过 require "silly.xxx" 引用。

其中,属于框架核心的基础能力——如时间、网络、任务等与 IO/time 紧密相关的部分——都放在 silly 根命名空间,例如:

  • silly.time
  • silly.net
  • silly.task

其他功能则拆分到二级命名空间中(第三级一定是模块名称,而不是子命名空间,以保持目录结构扁平),例如:

  • silly.net.tcp
  • silly.encoding.json
  • silly.adt.queue
  • silly.store.etcd

采用这种布局的原因在于,我希望二级命名空间覆盖常用功能,使用时能快速定位,同时避免命名空间过多导致混乱。

即便某些功能尚未实现,也可以直接补充到已有命名空间,无需频繁新增命名空间。

这也是我之前提到的“大局已定”的原因之一。

正是这种约束,让我在 silly.storesilly.security 两个二级命名空间上反复斟酌。

silly.security 为例,其出现的原因是我找不到 JWT 算法的合适归属:它既不属于 crypto,也不属于 cipher

经过几天斟酌,我选择了 security 这一含义宽泛的词,这样后续所有安全相关功能(如 OAuth2.0)都可以放入该命名空间。

silly.store的名字则更坎坷一些。

原先仓库中有一个直白的名字 silly.db,下面共有 silly.db.mysqlsilly.db.redis 两个模块。

然而,etcd 虽有持久化和强一致性,但严格来说并不是数据库,而且几乎找不到同类模块(可能只有 Zookeeper,但也不完全相同)。

如果放在根命名空间下又不合适,我几乎就要妥协要把 etcd 放入 silly.db

无奈之下,我和几个主流大模型都讨论了一下,其中一条回复提到:“如果以后打算支持 AWSS3 操作,也可以放入 store 命名空间”。这让我最终选择了 store 这个名字。

store 可以泛化为所有存储操作:虽然 etcd 不是数据库,但它属于存储,而 mysqlredis 也可归类于存储。

更关键的是,这符合我的设计理念:二级命名空间数量要适中,每个命名空间下的模块也要数量合适。当我要查找某个模块时,时间复杂度约为 m + n/m,其中 m 是二级命名空间数量,n 是所有模块的数量。

虽然store过于泛化,以至于查找redis/mysql时会产生干扰,不过我打算接受这个缺点, 有得必有失嘛。


然后是 API 设计。

在刚写 tcp/tls 模块时,我认为网络资源是有限的,因此必须手动管理连接。

既然要手动管理,__gc 就变得不那么必要,面向对象的调用方式也就没必要了。

再加上我发现 skynet 也是这样实现的,这给了我极大的信心。

早期的 API 设计是这样的:

local fd = tcp.connect("127.0.0.1:553")
local line = tcp.readline(fd, 5)
tcp.close(fd)

在随后实现 http / websocket 等协议时,我不断对 tcp/tls 做封装。

出于性能强迫症,我甚至用闭包生成不同的 read 函数以减少哈希查找次数,例如:

local function wrap_read(io)
    local read = io.read
    local readline = io.readline
    local fn = function(fd)
        local line = readline(fd, "\n")
        local n = string.pack("<I4", line)
        return read(fd, n)
    end
    return fn
end

在性能强迫症有极大好转的今天,我重新审视了这种模式 —— 我认为这是一种过度设计:它增加了实现复杂度,且浪费了动态语言鸭子类型(Duck Typing)的优势。

最终我把 tls/tcp/udpAPI 全部重构为 OO 风格,就像下面这样:

local conn = tcp.connect("127.0.0.1:553")
local line = conn:read("\n")
conn:close()

在这种设计下,使用连接的模块仅需要在入口使用不同的连接函数, 即可无缝切换tcp/tls的支持。

相比之前的闭包方案,这次的结构更加清晰,也不会再让连接相关的细节渗透到代码的各个角落。

与此同时,我还简化了另一对 API:读指定字节和读一行。过去它们是两个函数:

local line = tcp.readline(fd, "\n")
local data = tcp.read(fd, 1024)

在我意识到DuckTyping的同时,我认为使用readline特意区分读一行也是一种过度设计, 他并不符合动态语言的惯例。

当我们要读取一行时传入的必然是字符串,如果要读取指定字节数时传入的必然是数字。

此时完全可以通过第二个参数类型来确定到底是要读指定字节数还是要读取一行。

于是我去掉了 readline,只保留一个 read,用法如下:

local line = conn:read("\n")
local data = conn:read(1024)

我还简化了“读一行”分隔符的语义。

以前的实现支持任意长度的分隔符, 例如: tcp.readline(fd, "\r\n")

虽然 RedisRESP)和 HTTP 规范要求使用 CRLF(即 \r\n),但在实践中仅以 \n 切分也能工作。

于是我移除了长度大于 1 的分隔符支持——仍然可以自定义分隔符,但只允许长度为 1。

这样做能大幅简化实现、减少复杂度,如果将来确实需要,再加回来也不迟。

这次 API 重构中最困难的,是为 conn:read 明确定义语义。

我将 conn:read 定义为 (Full Read) 模式,即:要么读取到完整满足条件的数据量(例如指定的字节数或行分隔符),要么就返回错误。它不会像传统的流式读取(Stream Read)那样,只要读到一点数据就返回(Partial read)。

以前 read 失败就返回 nil, error,但在全面考虑后发现很多场景没被覆盖,尤其是加入读取超时(read timeout)后,问题变得更加复杂,例如:

  • 如何判断是连接出错还是正常的 EOF?
  • 读取超时时,如何区分是 timeout 还是连接出错?
  • 在调用 conn:read(1024) 时连接断开或出错,如何把剩余数据读出?

经过大约3~4版的撤销和重构后,最终我选择了如下方案:

  • 如果 conn:read 能读到满足条件的数据(如指定字节数或遇到行分隔符),直接返回数据。

如果因为 EOF 导致读取失败,返回 "" , "end of file"

其他失败情况返回 nil, error

这样,在判断是否为 EOF 时,只需检查返回值是否为空字符串即可。

有一种特殊情况:调用 conn:read(0) 时,会返回 "" , nil

这是借鉴 Berkeley Socket API 的行为:调用 read(fd, buf, 0) 时会返回 0 而不是 -1

为了区分 timeout 与其他错误,我额外增加了 conn:isalive() 接口,返回布尔值表示连接是否仍存活。

发生非 timeout 错误时,conn:isalive() 总是返回 false

第三个问题最棘手。

我认为, Berkeley Socket API 之所以将阻塞式 read/recv 设计为, 只要读到至少一个字节就返回, 就是为了解决这个问题。

Lua Socket 则采用了更粗暴的三值返回:nil, error, partial,通过 partial 返回不满足条件的剩余数据。

我不想像 Berkeley Socket API 那样只要有数据就返回,一是因为他会增加别的模块的使用负担,二是因为 Lua/C 交互时会产生大量垃圾。

同时,我也不想像 Lua Socket 那样采取3返回值的设计。

斟酌再三,我引入了 conn:unreadbytes() 接口,他返回当前连接剩余未读取数据的字节数。

在现有的设计中,不管 conn:isalive()返回什么,只要缓冲区中有满足条件的数据都不会返回错误。

因此,我们完全可以使用conn:read(conn:unreadbytes())来读取剩余的数据。

终于,通过新增2个正交的 API,我终于覆盖了我能想到的所有场景。


将链接重构为 OO 模式之后,一个随之而来的问题摆在面前:到底要不要为每个 conn 对象设置 __gc 函数。

这又引发另一个更深层次的问题,在整个系统中,我们的 GC 引用链应该是怎么样的。

在以前的设计中,由于需要手动 close, 所有的 socket 对象都被全局的 socket_pool 引用着。

虽然为每个 socket 对象设置了 __gc 函数,但在强引用路径存在的情况下,这些 __gc 函数只会在进程结束时执行。

也因为此,当时的 GC 引用链是这样的:socket_pool -> socket -> suspend coroutine

其中,当coroutine执行tcp.read(fd, n)时,如果条件不满足,它会把当前coroutine挂到对应的socket对象上, 让 socket 成为协程的“锚点”,避免coroutineGC 回收。

Silly的调度器因此对挂起的coroutine仅使用弱引用,coroutine在被挂起前,必须先找到一个正确的“锚点”,否则就会被 GC 清理掉。

我不禁在思考,当前设计是否正确。

这种设计唯一的优点就是:被挂起的 coroutine 可以被 GC 回收。

但是,只有在 coroutine 之间互相等待并形成死锁(例如两个协程使用 silly.sync.channel 相互等待)时,它们才会因为无人强引用而一起被 GC 回收。

除此之外,大多数挂起的 coroutine 都必然会挂在某个全局引用链上(常见是 socket)。

缺点看起来就很显眼: 每个 C 扩展模块必须建立自己的强引用链,以保护挂起 coroutine 不被 GC 清理。

并且各个C模块的锚点并不相同,这增加了扩展模块的心智负担。并且这种设计看起来不那么简洁。

我仔细分析了线程以及 goroutine 的设计。

作为运行实体,线程与 goroutine 在系统中总是被区别对待,不会被当成普通内存对象来管理。

比如:pthread 通常要求显式退出,而挂起的 goroutine 如果没有妥善管理则会造成泄露。

如果让调度器对所有挂起的 coroutine 进行强引用,那么 conn 就可以改为弱引用,它的 __gc 也能在忘记 close 时承担兜底的清理职责。

更重要的是,扩展 C 模块时,不再需要费心为挂起的 coroutine 提供强引用锚点,整体设计会简洁不少。

当然,代价也摆在这里:挂起的 coroutine 将不再可能被 GC 回收。

好在 silly.task 模块提供了相关的观测接口,可以展示挂起 coroutine 的调用栈,方便排查问题。

权衡之后,我最终选择接受这个缺点,并将调度器中对挂起 coroutine 的引用改为强引用。