← 返回信息流
AI 资讯Hacker News·4 天前

看似bug的.join()方法

原标题:The .join() that should be a bug

速览

该文探讨了Python的str.join()方法在何种情况下可能被误认为是一个bug。作者指出其行为与常见预期存在差异,并分析了背后的设计原因。文章旨在帮助开发者避免因误解而写出错误代码。

AI 深度解读

背景

在数据库系统中,连接管理有两种经典模型,各自有代价。Kronotop 采用第三种方式。Kronotop 将元数据存储在 FoundationDB 中,文档主体存储在本地文件系统,因此几乎所有客户端请求都会触发一次对 FoundationDB 的网络调用以及一次磁盘读取。网络调用耗时毫秒级,而非纳秒级;读取文档、提交事务、查找索引——所有这些操作都在等待 I/O。Kronotop 的命令路径并非内存内计算,而是以等待为主。

核心内容

两种经典模型

  • Redis(6.0 之前):单线程模型。一个线程监听所有连接并逐条处理命令。这种模型能很好地扩展连接数,但禁止阻塞。命令路径中任何等待都会导致其他客户端一起等待。当所有数据都在内存中时,这是可行的,但若命令需要执行更多操作(如网络调用或磁盘 fsync)则无法工作——Kronotop 的每个命令都需要等待这些操作。
  • Postgres:每个连接分配一个独立进程。阻塞不再是问题,每个连接独立运行,可以编写简单的顺序代码。代价是每个连接对应一个操作系统进程,开销很大,几千个连接就会过载。这就是为什么 Postgres 部署几乎总要配合独立的连接池。

Kronotop 需要同时具备两种优点:既能扩展大量连接,又能允许在 I/O 操作中等待。

将连接与工作分离

关键思路是:不再将“连接”和“工作”视为同一事物。

  • 连接侧:采用 Redis 的方式,基于 Netty 构建。少量事件循环线程监听大量 socket,响应就绪的 socket,解析传入命令并写回回复。这部分永不阻塞、永不等待,少量线程即可维持数千个连接。
  • 工作侧:执行磁盘和网络 I/O 的部分,运行在虚拟线程(virtual thread)上。虚拟线程允许以普通阻塞、自上而下的代码风格编写 I/O 调用——这是 Postgres 风格的代码,但没有 Postgres 的代价。当虚拟线程在等待 FoundationDB 或磁盘时阻塞,Java 运行时将其挂起,释放底层的载体线程(carrier thread)供其他工作使用。数千个虚拟线程可以同时等待 I/O,而实际只有少量真实线程在执行。当 I/O 完成时,虚拟线程从暂停处继续执行。

这样,网络线程始终保持空闲,能够服务于所有其他连接。慢速部分在侧边执行,等待的开销极低。结果再交回连接线程,由它写回复。这个交接是设计上严格保持的唯一规则。

在代码层面,整个卸载(offload)机制只涉及两个执行器(executor)和两个阶段:

  • supplier(供应者)是慢速部分,运行在虚拟线程执行器上,允许在 FoundationDB 上阻塞。
  • action(动作)是回复部分,运行在 response.getCtx().executor()(该连接所属的 Netty 事件循环)上。等待的工作和必须不移动的写操作分离在不同的线程上。后者仅在前者完成后启动。

以读命令为例:第一个块(supplier)获取值,第二个块(action)发送值。中间的关键行是 .join()。在普通线程上,.join() 是浪费——线程会阻塞并闲置;但在虚拟线程上,这正是关键所在:运行时将调用挂起,释放真实线程,当 FDB 响应时恢复调用。处理程序从上到下读取,像普通的阻塞代码,而等待的代价消失了。

一旦 supplier 返回,执行器进入回复阶段。如果命令是自动提交,则清理事务;如果抛出异常,则向客户端写回错误。

连接上驻留的状态

一条打开的连接携带少量状态:它知道自己是哪个客户端、打开了哪些命名空间、是否已认证,还持有当前正在执行的事务的详细信息。

这一点很重要:在 Kronotop 中,事务属于连接。你开始一个事务,在其中执行若干命令,然后提交或回滚。整个过程中,该事务与你的会话绑定。如果连接中途断开,打开的事务会被取消并清理,不会遗留任何东西。你还可以在不断开连接的情况下重置会话状态——这会一次性清除游标、被监视的键以及任何未完成的事务。这个重置操作也使得连接易于在客户端连接池中复用:归还连接、重置状态、再分配出去。

尝试 Kronotop

Kronotop 是一个基于 FoundationDB 的分布式、事务性文档数据库。查阅快速入门指南可在几分钟内启动一个集群。

关键要点

  • 两种传统模型各有短板:Redis 的线程模型可扩展连接数但禁止阻塞;Postgres 的进程模型允许阻塞但无法低成本支持大量空闲连接。
  • Kronotop 的解决方法:将连接处理与工作执行分离。连接侧使用 Netty 事件循环(非阻塞),工作侧使用 Java 虚拟线程(允许阻塞且开销低)。
  • 虚拟线程实现“伪阻塞”:虚拟线程在等待 I/O 时会被运行时挂起,释放底层平台线程,使得少量真实线程可同时支持数千个虚拟线程的等待。
  • 严格分离写回复:回复(写回客户端)必须由连接所属的 Netty 事件循环线程执行,不能移动——这是保持非阻塞的关键规则。
  • 事务与连接绑定:事务属于连接,连接断开则自动清理;支持重置会话状态以方便池化复用。
  • .join() 的巧妙使用:在普通线程上 .join() 会导致阻塞浪费,但在虚拟线程上正是利用了虚拟线程的挂起机制,使代码写成同步风格却无阻塞代价。

意义与影响

  • 为 I/O 密集型数据库提供新范式:Kronotop 的设计展示了如何在不牺牲连接扩展性的前提下,优雅地处理阻塞 I/O。这对于构建需要访问外部存储(如分布式 KV、对象存储、本地磁盘)的数据库或后端服务具有重要参考价值。
  • Java 虚拟线程的实战验证:该实现充分利用了 Project Loom 引入的虚拟线程,证明了其在真实数据库场景中的有效性——既能编写简单直观的阻塞代码,又能避免传统线程模型的开销。
  • 对 Redis 和 Postgres 模式的互补:Kronotop 不是取代两者,而是结合两者的优点。它在连接管理上沿袭 Redis 的高效,在工作执行上采用类似 Postgres 的阻塞方式但成本极低。
  • 推动连接池的简化:由于连接状态可以重置而复用,客户端侧连接池的设计变得更简单,减少了上下文重连开销和数据残留风险。 -展示了如何将异步复杂的、基于回调的设计转换为易于理解的顺序代码风格.join() + .supplyAsync()的组合模式可作为构建类似系统的经典代码骨架。
查看原文 →kronotop.com