Zig语言异步程序的新方案
既不大幅增加语言复杂度,又能精确控制异步操作,同时让编写高性能事件驱动I/O变得相对轻松。新方案通过将异步操作封装在通用接口Io中实现这一目标
Zig编程语言的设计者们长期致力于寻找适合异步代码的解决方案。Zig作为精心设计的极简语言,其初始异步I/O方案与其他特性存在兼容性问题。如今该项目通过Zig SHOWTIME视频宣布了异步I/O的新方案,承诺解决函数着色问题,并支持编写无论采用同步还是异步I/O都能正确执行的代码。
在许多语言(包括Python、JavaScript和Rust)中,异步代码需使用特殊语法。这使得程序中同步与异步部分的代码难以复用,给库作者带来诸多困扰。不作语法区分的语言(如Haskell)通过将所有操作异步化来解决问题,这通常要求语言运行时内置程序执行机制的设计理念。
这两种方案均不适用于Zig。其设计者希望找到一种方案:既不大幅增加语言复杂度,又能精确控制异步操作,同时让编写高性能事件驱动I/O变得相对轻松。新方案通过将异步操作封装在通用接口Io中实现这一目标。
任何需要执行I/O操作的函数都必须访问该接口的实例。通常做法是将实例作为参数传递给函数,类似于Zig中用于内存分配的Allocator接口。标准库将内置两个接口实现:Io.Threaded与Io.Evented。前者默认采用同步操作,仅在显式请求并行执行时(通过特殊函数实现,详见下文)使用线程;后者(仍在开发中)则基于事件循环与异步I/O。设计上并未限制 Zig 程序员实现自定义版本,因此用户仍可精细控制程序执行方式。
Zig 社区组织者洛里斯·克罗撰写了说明文档,阐释了新行为背后的设计理念。他解释道,同步代码基本保持不变,仅需注意标准库函数已移至Io模块。如下例所示不涉及显式异步性的函数仍可正常运行:该示例创建文件、设置函数结束时关闭文件,随后将数据缓冲区写入文件。它使用Zig的try关键字处理错误,并通过defer确保文件关闭。返回类型 !void 表示可能返回错误,但不会返回任何数据:
const std = @import(“std”);
const Io = std.Io;
fn saveFile(io: Io, data: \[\]const u8, name: \[\]const u8) !void {
const file = try Io.Dir.cwd().createFile(io, name, .{});
defer file.close(io);
try file.writeAll(io, data);
}
若该函数接收Io.Threaded实例,则会通过常规系统调用创建文件、写入数据并关闭文件。若接收 Io.Evented 实例,则改用 io_uring、kqueue 或其他适合目标操作系统的异步后端。在此过程中,它可能暂停当前执行并转而处理其他异步函数。无论采用何种方式,操作都保证在writeAll()返回时完成。编写涉及I/O函数的库作者无需关心最终用户选择哪种实现方式。
另一方面,假设某个程序需要保存两个文件。这些操作可以高效地并行执行。如果库作者希望支持这种并行性,他们可以使用Io接口的async()函数来表达两个文件的保存顺序无关紧要:
fn saveData(io: Io, data: \[\]const u8) !void {
// 调用 saveFile(io, data, “saveA.txt”)
var a\_future = io.async(saveFile, .{io, data, “saveA.txt”});
var b_future = io.async(saveFile, .{io, data, “saveB.txt”});
const a_result = a_future.await(io);
const b_result = b_future.await(io);
try a\_result;
try b\_result;
const out: Io.File = .stdout();
try out.writeAll(io, “保存完成”);
}
使用 Io.Threaded 实例时,async() 函数实际上无需执行任何异步操作[尽管具体实现可能根据配置将函数分派至独立线程]——它可直接运行传入的函数。因此,采用该接口版本时,函数会先保存文件A再保存文件B。而使用Io.Evented实例时,操作真正实现异步,程序可同时保存两个文件。
此方法的真正优势在于将异步代码转化为性能优化手段。程序或库的初始版本可编写常规线性代码。后续若发现异步机制有助于性能提升,开发者可回过头来重构为异步操作。若最终用户未启用异步执行,程序运行毫无变化;但若启用,函数将透明地加速运行——函数签名及与代码库的交互方式均保持不变。
但存在一个问题:某些程序需要两个部分同时执行才能保证正确性。例如,假设程序需要监听端口连接并同时响应用户输入。在此场景下,若先等待连接建立再请求用户输入则会导致错误。针对此场景,Io接口提供了独立函数asyncConcurrent()concurrent() [该函数在开发过程中更名;concurrent()为最新名称],用于显式要求并行执行传入函数。Io.Threaded通过线程池中的线程实现此功能,而Io.Evented则将其视为常规async()调用。
const socket = try openServerSocket(io);
var server = try io.concurrent(startAccepting, .{io, socket});
defer server.cancel(io) catch {};
try handleUserInput(io);
若程序员在应使用 concurrent() 的场景下误用了 async(),则构成错误。Zig 的新模型无法(也不可能)阻止程序员编写错误代码,因此在将现有 Zig 代码迁移至新接口时仍需注意某些细节。
这种设计产生的代码风格比那些为异步函数提供特殊语法的语言略显冗长,但语言创建者安德鲁·凯利表示:“它读起来像标准的、符合惯例的Zig代码。” 他特别指出,这种方法允许程序员使用Zig所有典型的控制流原语(如try和defer),并未为异步代码引入任何新语言特性。
为展示该特性,Kelley给出了使用新接口实现异步DNS解析的示例。标准的getaddrinfo()函数在查询DNS信息时存在缺陷:虽然它能并行向多台服务器(同时处理IPv4和IPv6)发送请求,但必须等待所有查询完成才会返回结果。凯利的示例Zig代码会返回首个成功响应,并取消其他正在处理的请求。
然而 Zig 中的异步 I/O 远未完善。Io.Evented 仍处于实验阶段,尚未在所有支持的操作系统上实现。第三种兼容 WebAssembly 的 Io 实现方案正在规划中(尽管如该议题所述,其实现依赖于其他新语言特性)。最初的Io拉取请求列出了24项后续计划,其中多数仍需完善。
尽管如此,Zig语言中异步代码的整体设计似乎已定型。Zig尚未发布1.0版本,因为社区仍在探索许多功能的正确实现方式。异步I/O是剩余的重要优先事项之一(另一项是本机代码生成,今年已在某些架构的调试构建中默认启用)。Zig正稳步推进最终设计方案——这将减少开发者因接口变更而反复重写I/O代码的情况再次发生。
本文文字及图片出自 Zig's new plan for asynchronous programs
总体而言,本文内容准确且研究充分。感谢Daroc Alden的尽职调查。以下是若干细微修正:
> 使用Io.Threaded实例时,async()函数实际上并未执行异步操作——它只是立即运行传入的函数。
虽然这是合法的实现策略,但并非 std.Io.Threaded 的默认行为。默认情况下,它会使用可配置大小的线程池分派异步任务。不过,通过 init_single_threaded 静态初始化时,确实会呈现文章所述的行为。
我发现的另一个问题是:
> 针对该用例,Io接口提供了独立函数asyncConcurrent(),该函数会显式要求并行执行传入的函数。
我们曾短暂使用过asyncConcurrent(),但现已将其更名为更简洁的concurrent()。
我是Daroc——已根据此评论对文章进行了两处修正。若您希望确保未来反馈或修正能及时送达(而非像这次因我熬夜刷HN而非准备就寝所致),欢迎发送邮件至lwn@lwn.net。
感谢您的修正,也感谢您对Zig的贡献!
已知悉,谢谢!
Andrew你好,想请教文章略有提及但未深入讨论的问题:
若调用错误函数会引发何种缺陷?是取决于IO模型的“未定义行为”、逻辑问题,还是其他情况?
会导致死锁。
例如:函数被立即调用而非在独立线程中运行,导致其在 accept() 处永久阻塞——因为 connect() 调用发生在 async() 之后。
若改用 concurrent(),I/O 实现将为该函数创建新线程,使 accept() 在新线程中处理,否则将返回 error.ConcurrencyUnavailable。
async() 绝对可靠。concurrent() 存在风险。
我真正欣赏 concurrent() 的地方在于它提升了代码可读性和表达力,在编写和阅读时能明确传达“此代码必须并行执行”的意图。
我认为这种设计非常合理。但Zig对此的解释令人困惑:他们刻意强调它解决了函数着色问题,实则不然——它将I/O封装为效果类型,本质上要求调用方保留一个令牌。这虽是更符合人体工程学的设计,但本质仍是着色机制。
(据我理解,这与Go语言处理异步性的方式相当类似,只不过在Go中该“令牌”由运行时管理。)
若调用相同函数但传入不同参数就被视为“函数着色”,那么程序中每个函数都处于“着色”状态,这个词就失去意义了 😉
Zig语言其实在旧版已弃用的async-await方案中也解决了着色问题——编译器会根据调用上下文自动生成同函数的同步/异步版本(因所有内容属于单一编译单元而可行)。
那么JS同样不存在着色问题,因为异步函数本质上只是返回Promise的普通函数。
据我理解,着色指的是异步与同步函数拥有相同的调用语法和接口,例如:
这两行共享相同调用语法。而
则存在差异。
若需使用不同语法或接口调用异步函数,则视为着色。
> 这种情况下 JS 同样不会出现着色问题,因为异步函数本质上只是返回 Promise 的普通函数。
完全正确,至少我认为 JS 不存在着色问题,因为你可以从同步函数中调用异步函数(JS 的 Promise 机制允许回退到完成回调,而非强制使用 await)。造成着色问题的根源在于 await 的“传染性”,但在 JS 中可自由混合 await 与完成回调处理异步操作。
await 本身不具传染性,它只是纯粹的局部转换。真正的传染源来自 CPS/回调机制与 Promise。
让我们重温原文[1]。它并非讨论参数问题,而是探讨编写回调函数乃至异步/等待时相较于Go语言的痛苦。文中针对带颜色标识函数的语言提出了5条明确主张:
1. 每个函数都有颜色标识。
Zig的新方法正是如此:处理IO的函数为红色,无需处理IO的函数为蓝色。
2. 函数调用方式取决于其颜色。
Zig同样遵循此原则:红色函数需要Io参数,蓝色函数则不需要。调用红色函数意味着必须提供Io参数。
3. 只能在另一个红色函数内部调用红色函数。
在Zig中,若上下文未持有Io对象,则无法调用需要Io对象的函数。
理论上可使用全局变量或初始化新Io实例,但这与从非异步函数调用异步函数的变通方案本质相同——例如C#中可编写'Task.Run(() -> MyAsyncMethod()).Wait()'。
4. 红色函数的调用过程更为繁琐。
Zig中同样如此,因为必须传递Io实例。
或许有人认为这并非大碍,毕竟几乎所有函数都需要参数…但以此标准衡量,async/await机制反而更简洁。对比JavaScript中调用异步函数与Zig中调用Io类函数的差异:
而在Zig中:
Zig更麻烦,因为你不能直接添加固定关键字:需要添加一个变量并传递到某个地方。
5. 部分核心库函数是红色的。
Zig 同样如此:某些核心库函数需要 Io 实例。
我并非认为 Zig 在此做出了错误选择,但这显然不是无色 I/O。不过没关系,毕竟无色 I/O 本就是炒作。
—
[1] https://journal.stuffwithstuff.com/2015/02/01/what-color-is-…
> Zig语言同样如此:红色函数需要Io参数,蓝色函数则不需要。调用红色函数意味着必须提供Io参数。
我认为这未必正确。如同内存分配器,理论上应能将IO指针一次性传递给库的初始化函数,随后在任何需要IO操作的库函数中复用该指针。虽然Zig标准库已不再采用这种方式处理分配器,但并非受技术限制所致。
现在的问题是:库初始化函数中的IO参数是否会影响整个库,还是仅限于初始化函数本身?;P
PS:甚至可将IO指针存储为公共全局变量,使其对所有需要IO的代码可见,这会让作用域覆盖问题更复杂。不过尚未实现的无栈协程(如'code-transform-async')IO系统将如何处理此类场景,倒是值得期待。
我认为函数着色机制是 必需 的,没有它就无法实现(通常意义上的)异步操作。若将问题分解:当一个函数依赖异步执行引擎而另一个不依赖时,这种依赖关系本身就决定了它们的着色状态。多数语言只是改变了依赖关系的表达方式,这可能影响操作体验。
> 若不同参数调用同一函数也算“函数着色”,那程序中每个函数都“着色”了,这个词就失去意义了 😉
确实如此,但此处的“着色”(即效果)至关重要。效果在系统中的传递具有非同小可的意义,因此部分语言选择将其提升为语法层级(如Rust),另一些则将其作为潜在不变量处理(如Java的运行时异常机制)。Zig则另辟蹊径,其方案与Haskell的IO机制颇为相似。
> Zig其实在旧版已弃用的async-await方案中就解决了着色问题——编译器会根据调用上下文自动生成同函数的同步/异步版本(这在单编译单元环境下可行)。
据我所知,该机制仍会通过函数指针泄露(指针本身仍保留同步/异步属性,且类型声明不可见)
相信Zig团队已意识到此问题,并计划在重新发布async前予以修复。
函数着色文章讨论的是Node中的回调API,因此需要传递给IO函数的参数完全符合着色函数的理念,也存在相同限制。
在 Zig 的实现中,你传递的是参数是否异步的标记,而非参数本身。调用方控制行为,而非被调用函数。
着色标记并非传递的具体参数(Io 实现),而是函数是否具有 Io 参数。函数实现是否执行 IO 操作,本质上属于未来可能变更的实现细节。若函数本身不接受Io参数却需调用要求Io参数的其他函数,则无法实现。因此开发者往往为防万一添加Io参数,进而要求所有调用方也如此操作——这与函数着色机制极为相似。
在支持对象或闭包的语言中(Zig尚未提供一等公民支持),Io对象方案的灵活性优势在于可将其移至对象/闭包创建阶段,从而使函数/方法签名保持纯净。但你仍需在 某个环节 传递该参数。
> 函数实现是否执行 IO 原则上属于未来可能变更的实现细节。
我认为这正是你与 Zig 开发者的分歧所在。
在我看来,执行 IO 绝对 不 属于实现细节。正如堆内存分配在规范 Zig 中不属于实现细节一样。
我不希望发现数学库在磁盘缓存结果,或为备忘分配兆级内存。我需要明确哪些函数能在独立环境或资源受限场景中使用。
> 在我看来,执行IO操作绝非实现细节。正如在规范Zig中,堆内存分配也绝非实现细节。
看来你们两人立场相左。从库作者角度看,Zig将IO视为实现细节,这对可移植性极有益处。它允许库作者根据问题需求自由使用IO抽象层。
这使你作为应用程序开发者,能够决定此类库的具体行为细节。不想让数学库缓存到磁盘?用分配写入器[0]替代文件写入器即可。要在无多线程的嵌入系统上使用异步库?传递单线程io[1]运行时实例,自行实现最适合目标环境的io接口。
当然,总得有人来决定实现细节。Zig的设计选择往往聚焦于为库作者提供实用抽象,使应用程序作者能对应用程序的关键决策进行有意义的控制。
[0] https://ziglang.org/documentation/master/std/#std.Io.Writer….
[1] https://ziglang.org/documentation/master/std/#std.Io.Threade…
这也正是函数着色并非问题所在的原因,事实上它在多数情况下反而值得推崇。
函数着色的问题在于,它使得库难以同时兼容同步与异步代码的方式实现。
在 Python 中,我曾为某个 HTTP 项目同时编写过同步和异步 API 客户端。该项目逻辑操作由多个顺序 HTTP 请求组成,这意味着我需要将核心业务逻辑实现为生成器:该生成器在最终返回结果前会递出请求并接收响应。随后我分别编写了同步和异步驱动程序,它们各自在循环中运行生成器,提取请求、通过 HTTP 实现进行事务处理,并将响应反馈给生成器。
这种无IO的方案——即库将业务逻辑与IO分离,然后提供或要求调用方实现自己的简单事件循环,以选择的方式执行IO并将其馈送给业务逻辑状态机——已开始作为Rust中函数着色的解决方案出现,但它支持多种IO并发策略的方式有些晦涩。
另一方面,我认为这种模式对可测试性极具价值——它能实现高度模糊测试友好的业务逻辑、隔离副作用代码,并构建出几乎无漏洞空间的核心IO循环。因此尽管编写过程略显繁琐,即便仅需支持单一函数着色模式时,我仍认为其具有实用价值。
我的观点是:若库或函数涉及IO操作,就应采用异步实现——完全没有理由支持“同步I/O”。
此外,这种“无IO”趋势虽有意思,但代码本质上只是Rust中异步实现的低效版本——更冗长、更不美观。它本质是步骤更繁琐的async/await,而这些额外步骤并无实质价值。
没错,函数着色本身并无不妥,这只是设计选择。
着色函数更易于理解,因为潜在的异步性会被醒目标注。
无色函数则更具灵活性,因为将函数改为异步时,不会病毒式破坏其接口及其所有调用者的接口。
Zig语言采用彩色函数机制,这完全合理。问题在于某些(无意的)误导性说法——明明函数带有颜色标记,却声称Zig是无色设计。
如前所述,彩色函数的问题不在于颜色本身,而在于无法对颜色进行抽象处理。
带副作用的语言本质上添加了用户可定义的“颜色”,但它们允许你编写如`map`函数——该函数会根据参数动态改变自身颜色(例如接收异步函数时转为异步)。
> 我不想发现数学库在磁盘缓存结果,或为备忘分配兆字节内存。我需要知道哪些函数能在独立环境或资源受限场景中使用。
基于此,我常需确认函数是否会创建任务/线程/绿线程等在返回后仍并发执行的实体。将此特性纳入函数签名大致称为“结构化并发”,而Zig的设计似乎将其与接收io参数混为一谈,这令人稍感失望。
> 不带Io参数却需调用要求Io参数的函数时,将无法实现。
为何如此?难道不能创建任意类型的Io实例来使用吗?或者保留实例供重复使用?
所谓“用语言语法隐藏全局事件循环”正是抽象泄漏的典型,且具有限制性。当前方案采用显式设计,避免函数与隐藏的全局状态绑定。
可以,但这会剥夺调用方对Io的控制权。这与异步函数着色机制并无本质区别:https://news.ycombinator.com/item?id=46126310
IO操作的调度机制并非隐藏的全局状态。若真如此,那么操作系统管理线程调度也应归为此类。
但这在实践中真有问题吗?Zig语言的内存分配器早已存在类似情况:必须传入参数才能分配内存。现在只需同时传入内存分配器和额外的IO对象。听起来不太符合人体工学,但若所有Zig代码都遵循此方案,实践中自然会形成唯一实现方式。因此其中一种颜色永远不会被需要或使用。
> 若将同个函数带不同参数的调用视为“函数着色”,那么程序中每个函数都“着色”了,这个词就失去意义了 😉
我的意思是,“函数着色”这个概念本身就是人为区分的产物,旨在抱怨处理“立即执行I/O”与“等待I/O完成”这两种截然不同的I/O方式——这两种方式差异如此之大,以至于应用程序的设计必须针对它们做出根本性调整: 在同步I/O场景下,我会设计解析器直接输出DOM,因为不这么做几乎毫无益处;而在异步I/O场景下,我则会采用流式API。
我至今仍对“函数着色”成为理解异步语义的默认视角感到惊讶,因为这严重偏离了不同实现设计的核心权衡本质。
完全赞同,但所幸我认为这并非“默认视角”。若真是如此,就不会有人为语言添加新的异步机制了——毕竟“你的函数是什么颜色”本就是针对异步机制的自我标榜式抨击,旨在倡导轻量级线程。不过这个说法确实成了异常顽固的流行梗。
我理解的设计理念是:逻辑编写与“立即执行I/O”或“等待I/O完成”的决策可分离处理。
你可以编写输出DOM的解析器在流上运行,也可编写带流式API的解析器在缓冲区上同步运行。应根据场景选择最优工具,但不再存在路径依赖性。
函数着色是实践中出现的问题,因此人们才讨论某些方案能否解决它。
你为何认为异步I/O必然会衍生出流式API?异步I/O与同步I/O同样能返回完整结果,区别在于你无需等待结果生成——被调用的异步过程会在结果计算完成后回调。我认为流式API需要额外实现工作量,并非仅需异步支持。
如果你的函数突然需要一个(当前)无法构造的“Magic”实例,而你现在必须从某个顶级位置传入它,这确实与async/await面临相同的问题。即函数着色问题。
但大多数函数并非如此。它们需要的是POD类型、浮点数、字符串等可就地轻松廉价构造的参数。
两种IO实现方式的着色差异与是否进行IO操作的着色差异截然不同,将二者统称为“函数着色问题”实属混淆。唯有前者会导致所有内容的重复(同步版本与异步版本)。若仅存在后者,便无人会创造这个术语并撰写博客文章。
我认为问题核心从来不在于IO操作本身或异步行为,而在于无法从同步函数调用异步函数。根据经验,全面迁移同步代码为异步几乎不可能实现,我甚至认为这是极其危险的做法。
Zig的IO并非病毒式传播类型,理论上可声明全局IO变量供所有库调用。虽非库开发者的最佳实践,但构建应用时可自由选择。
此处涉及两层概念:函数着色与函数着色问题。函数着色问题包含五项要素:
https://journal.stuffwithstuff.com/2015/02/01/what-color-is-…
1. 每个函数都拥有专属颜色。
2. 函数调用方式取决于其颜色属性。
3. 仅能在红色函数内部调用红色函数。
4. 红色函数的调用成本更高。
5. 部分核心库函数属于红色函数。
你需要证明zig方案满足第4条。而它几乎肯定无法满足第5条。
当然,zig方案能否真正奏效本身也值得商榷。
> 1) Zig的io并非效果类型,理论上可声明全局io变量并在所有库调用处使用。
这本质是效果机制,类似托管运行时中的全局中介I/O。
直观来说:若存在全局I/O令牌,并发程序是否需要通过同步操作才能正确运行?未能获取令牌的程序能否正常行为?
如何“获取令牌失败”?
令牌守护着易出错的资源(I/O)。任何影响底层I/O的因素都可能导致你(暂时或永久)无法获取令牌。
I/O并非单一资源?它本质上是将大量代码封装的模块集合,且可替换实现方案。I/O模块应负责分配 多个 可能出错的资源,同步机制取决于I/O模块内部代码——无论采用全局化还是传递机制。
实际上他们似乎只是给所有操作贴上异步标签,开发者自行选择是否启用工作线程。
我怀疑其中存在更多奥秘,毕竟其他语言实现这类功能并非难事。问题在于混用异步时会埋下巨大隐患。
例如你的代码同步运行时正常,异步时却会死锁——因为你未考虑方法并行执行的情况。
换言之,部分代码是线程安全的,部分则不是。标记机制确实能解决这个问题。
> 实际上他们似乎只是给所有异步操作贴了标签,开发者自行选择是否启用工作线程。
新Zig IO系统目前尚未引入任何“async”特性(即编译器对异步函数执行“状态机代码转换”)。
据我所知,当前IO运行时仅使用传统线程或带栈切换的协程。恢复代码转换式async-await功能仍在待办事项列表中。
核心设计理念是:调用IO接口的代码无需了解IO运行时如何实现并发。不过我认为通过
.async()封装器调用的函数,在多线程和单线程环境下都应能正常工作。> 这里没有'async'
我只是想将其类比为其他语言的开发模式。
> 恢复代码转换为异步-等待的方案仍在待办事项列表中。
文章似乎暗示“计划已定”,因此我好奇这个待办事项具体指什么。这仅仅是异步IO的计划吗?
> 预期在多线程和单线程环境中均能正常工作。
嗯…关于这点…
我也好奇具体解决方案。难道要“RTFM”(阅读手册)?或许可以约定:公共API必须线程安全,若存在线程不安全模式则必须私有化?还是另有规划?
这篇文章似乎暗示“计划已定”,因此我不禁好奇这个待办事项清单具体是什么样子的。这仅仅是异步IO的计划吗?
目前有项提案提议将无栈协程作为语言原语:https://github.com/ziglang/zig/issues/23446
同意。我体内的Haskell开发者在呐喊:“你根本是在语言支持缺失的情况下实现了IO单子!”
这并非单子,因为它并未返回如何执行由独立系统处理的I/O的描述;它是在函数内部完成I/O操作后才返回结果。这只是常规接口,而非单子。
那它算是reader单子? 😉
没错。
能否为不太熟悉Haskell(及单子概念)的我们解释下?
单子是种_超级_通用的接口,可为大量结构/类型实现。人们谈论“单子”时,通常指特定实例。此处读者单子就是具体实例,大致等同于接受特定类型参数并返回任意类型结果的函数。即任何形式为 (r -> a) 的函数,其中
r固定为某种类型,而a可为任意类型。此类函数实际上可实现单子接口,并能利用Haskell为此提供的语法支持。
Reader单子模式的一个常见应用场景是传递接口类型(例如包含多个函数或其他数据的结构体)。因此,此处所指的将
Io类型作为函数参数传递的行为,本质上就是Haskell中的“Reader单子”模式。若稍作简化,这实际上正是Haskell实现IO的方式。存在一个RealWorld类型,稍作抽象后,它几乎就是你的
Io类型。现在,传递RealWorld类型的细节在Haskell中被隐藏在IO类型背后,因此你不会看到
RealWorld参数被传递给putStrLn函数。取而代之的是,putStrLn函数的类型为String -> IO ()。但你可以将IO ()视为等同于RealWorld -> (),若代入该类型,便会得到String -> RealWorld -> ()——这与你在Zig中实现的类型结构高度相似。由此可见,Zig的Io类型本身并非读取单子,但函数以该类型为参数的模式与之相符。
希望这能有所帮助。
—
由于Haskell的惰性特性,IO实际上并非读取单子,而是更接近状态单子——但在严格语言中则无需如此。
阅读器本质上是一种接口,允许你构建最终以环境为参数并返回值的计算过程。
关键在于:
若您不熟悉Haskell,这个Monad实例可能是最令人望而生畏的部分。函数(>>=)接受一个Monad(此处为Reader)及其内容的延续操作。它将环境传递给两者处理。
使用示例如下:
不确定这与Zig的实现有何不同!
https://stackoverflow.com/questions/14178889/what-is-the-pur…
编辑:已添加Applicative实例使代码能在现代Haskell运行。欢迎批评指正!另补充示例。
以下是关键部分的最小化Python实现:
诚然,这在Python中略显笨拙。Haskell的
do语法会编译为重复的绑定操作(因此需要对象具备单子特性),并承担了大量繁琐工作。我发现自己抢了头筹,不过还是把我的尝试发出来吧。
你关于IO由外部系统处理的评论,是对前两条评论中关于单子更普遍概念的回应——虽然提得有些突然。
Haskell中的IO单子具有某种“魔力”——它封装了特定单子实例,该实例编码了Haskell委托外部系统执行的计算操作。Haskell选择用单子结构来实现这种编码。
具体而言:
Reader单子是Haskell中泛指“环境”单子的实例。其模式是利用单子结构封装调用上下文概念,然后将不带Context变量的函数与封装单子结合,为需要上下文的函数提供使用环境。
基于新系统中的流设计,我未见单子踪迹——主要因为Reader实例本质上会将IO参数管道化传递至函数内部,而Zig要求显式将IO参数传递给每个使用它的函数(除非将IO设为全局变量,但这并非单子,只是全局状态)。
在我看来,Zig的IO更像是传递在类型系统“正规”范畴之外的效果令牌,仅通过特殊情况进行编译时检查。
Reader单子本质上是“在计算过程中读取某个常量值的能力”的华丽表述。此处所指的正是函数间传递的io值。
我认为这种说法完全不成立。在Zig中,Io实例是作为参数传递的接口。虽然Zig与Haskell的做法存在某些关联,但这并非单子。它只是普通的接口和参数机制,就像Allocator一样。
将接口作为参数传递 本身 就是单子。(Io -> _)在Haskell中是单子(Monad)的实例。
Haskell 仅通过语法使(任何)单子的使用更优雅。在此场景中,若需向多个函数传递相同 Io 实例,语法允许省略 `Io` 参数。但该参数本质上依然存在。
且看我能否避免深入探讨。我认为你将_IO类型_描述为“对独立系统执行I/O操作的规范”相当准确。但这是IO类型的特性,而非单子的本质。编程中的单子通常被视为类型构造器M(接受并返回类型),并附带满足特定条件(称为“单子律”)的函数集。
IO类型作为单参数类型构造器(接受类型参数),返回类型:我们称其具有类型种类Type -> Type,此处“种类”意指“类型的类型”。(若对您有帮助,我认为Zig函数std.ArrayList也可视为类型构造器。)IO String是可能产生副作用的计算类型,其结果为String类型,可作为参数传递给其他使用IO的函数。readLine就是此类值的典型示例。Haskell 函数箭头
(->)同样是类型构造器,但需两个参数。若向(->)提供类型a和b,则得到从a到b的函数类型:(->)的类型类型为Type -> Type -> Type。`(->) Char` 的类型种类为 `Type -> Type`。
`(->) Char Bool` 的类型种类为 `Type`,通常写作 `Char -> Bool`。`isUpper` 是具有此类型的值示例。
部分应用的类型构造器
(->) r(读作“接受r的函数类型构造器”)与IO同类:Type -> Type。事实上,通过为 `(->) r` 实现单子接口所需的函数,可以满足将其称为单子的必要条件,这通常被称为“读取单子”。使用该类型构造器的单子接口会生成“自动”将值传递给计算中函数首参数的代码。这常用于在多个函数间传递配置结构,无需手动编写管道代码。而使用IO类型的单子接口则能构建更复杂的副作用计算。单子体系还包含众多其他类型,而在Haskell这类语言中命名“单子”概念的价值在于:你能够编写适用于任意单子值的函数,无需关注具体类型。我试图保持简洁,但不确定哪些部分需要解释,也不想面面俱到写成没人会读的长文。希望对你有帮助。如有疑问请随时告知。
这段描述相当简洁,但依然非常技术化。撇开技术细节不谈,我认为真正的争议点在于Zig的IO并非Reader式的结构。我阅读的演讲和文章都指出,需要IO“上下文”的函数必须将该上下文作为参数传递。除非使用全局变量使其在任何地方都可用,但正如我在另一个评论中所说,那只是全局状态而非单子。
某种意义上,Zig创造了没有单子的IO单子(本质上只是脱离类型系统的效果令牌)。Zig的新机制将大量“副作用”封装在独特接口中,实现了类似Haskell逻辑上分离“纯计算”与“副作用计算”的IO使用方式。然而Zig缺乏语言/类型系统层面的支持,无法在语法和语义层面将IO作为不可逃逸的单子实例使用。因此尽管通过IO参数的“令牌”要求实现了副作用隔离,其计算方式仍与所有Zig代码相同。最后,由于Zig的IO并非单子的特例,因此对函数返回IO结果并将其作为“纯”值使用没有任何限制。
一系列传递相同
io: IO值的函数链,恰恰展现了reader单子的行为模式。我的意思是这根本不算?它完全没能将有状态的不纯性隔离到理论上无状态的令牌中
这解决了库作者面临的问题:阻塞式与事件驱动式IO实现看似相同,实则不同,导致用户抱怨某功能只支持其中一种实现。
但新增了需在程序中传递全局IO类型的负担。我认为这通常不成问题,因为优质程序设计会将IO置于外围,因此无需将此IO对象传递至“深层”。这与Haskell中IO的类型系统效果并无本质差异(除非我记错,Haskell仅支持事件驱动IO)。实际影响并不严重,因为它仅作用于输入类型(可闭包的数据类型),而非输出类型。例如在Haskell中需借助特殊函数将[ IO a ]转换为IO[ a ],而在Zig模型中,用户可直接使用外部作用域的IO值以常规方式遍历列表。
Haskell中IO着色令我困扰的唯一场景是添加printf调试(为此存在欺骗类型系统的函数)。Zig可能有其他解决方案,例如在调试构建中使用全局IO值阻塞IO,或采用全局日志系统。
Haskell中的[IO a] -> IO [a]并无特殊之处。使用常规迭代方法完全可以遍历它:
当然存在更优方案(如序列),但这些也绝非IO专属特性,而是任何单子都能使用的通用抽象。
函数着色问题实际出现在使用无栈协程(如Rust)或回调机制(如JavaScript)实现异步部分时。
Zig的新I/O目前未采用上述任一方案,因此未受此问题困扰。但这并非“解决”了问题,而是通过提供具有相似特性但权衡取舍不同的实现方案来规避了该问题。
这些权衡差异究竟何在?试想:若无需传递`Io`对象,只需在函数前添加`async`关键字——这本质上是隐含`Io`参数的语法糖;同时可使用`await`关键字作为语法糖,将调用方持有的任意`Io`对象传递给被调用方。
我看不出这和实际情况有何不同。
在JS示例中,同步函数无法轮询Promise的结果。这在实现循环和流时具有实质性差异,例如游戏循环、动画帧、流轮询等场景。
React Suspense就是绝佳范例。为挂起组件,渲染函数抛出Promise;为触发父级错误边界,渲染函数抛出错误;为恢复组件,渲染函数返回结果。React从未公开Suspense API,因为它本质是自掘坟墓的陷阱。
若JS Promise可被检查,同步渲染函数就能轮询其结果,挂起组件便无需通过抛错来扩展语言特性。
.NET 的 Promise 支持同步轮询。其问题在于:单线程环境下,当同步代码运行时,所有异步回调必然处于阻塞状态。因此若轮询的 Task 未完成,你根本无从等待其执行完毕。
嗯,严格来说你可以运行嵌套事件循环,不过这种方案需要大量同步包装异步操作,极其笨重,除了在遗留代码中作为临时权宜之计外几乎无人使用。
原来如此。看来只有JS存在着这种着色问题,这很奇怪,毕竟它是少数内置事件循环的语言之一。
Io机制本质上等同于Rust或Python中的async/await[1]。Go语言虽也内置类似“事件循环”的机制,但显然 不存在 着色问题。除JS外,我实在想不出还有哪种语言存在此类问题。
[1]: https://news.ycombinator.com/item?id=46126310
> Go语言同样内置了“事件循环”机制,但显然不存在着色问题。
在Go中上下文既是函数的着色机制,也是函数的参数。
这并非相同情况,因为使用async/await会导致每个函数或库都存在两个版本(参见Rust的std库与async_std等crates,Node的readFile和readFileSync)。而在Zig中,你始终通过传递“io”参数来执行I/O操作,无需重复实现所有功能。
或许我理解有误,但区别在于:在没有io参数的函数中仍可创建Io实例
在 Rust 中,你总能创建新的 Tokio 运行时,并用它从同步函数调用异步函数。Python 亦然:创建新的 asyncio 事件循环并调用
run即可。这实际上正是 Zig 中 Io 对象的本质,只是换了个新名字。回顾最初的函数着色帖子[1],其中提到:
因此若这与 async/await 同构,它并未“解决”最初提出的着色问题——但我开始认为这根本不算问题。某些函数本就与其他函数具有不同的签名。它之所以成为 JavaScript 的巨大难题,纯粹是因为整个生态系统决定一次性改变绝大多数函数的类型签名,从回调迁移到异步。
[1]: https://journal.stuffwithstuff.com/2015/02/01/what-color-is-…
这是语言层面的无IO设计,我欣赏这个理念。
因此我深入研究了Zig在编译层面的底层实现机制。
首先需要明确的是,Zig确实会将异步函数编译为状态机:https://github.com/ziglang/zig/issues/23446
编译器会在编译时决定将函数编译为哪种模式(可能同时包含两种模式)。这个想法很巧妙,但… https://github.com/ziglang/zig/issues/23367
> 若通过指针间接调用受限函数类型,而该指针值未包含在编译时分析的可能被调用者集合中,则会被判定为非法行为。
这简直是…相当糟糕的权衡。Rust的对象安全机制对异步操作已够烦人,而这方案闻起来很像它的翻版。主要区别在于它以某种神秘方式实现了模糊的晚绑定:你可能遭遇意外的运行时错误,更糟的是——甚至可能缺乏工具迫使编译器将函数添加到可调用者集合中。
我依然认为语言层面的无IO设计可能是未来方向,但这并非完整解决方案。或许我们应该直接将所有函数编译为状态机(借助Rust的轮询实现细节,通过无IO接口可轻松实现同步化——只需执行系统调用并返回完成的Future)。
> 我依然认为语言层面的无I/O设计可能是未来方向,但这并非完整解决方案。或许我们应直接将所有函数编译为状态机(借助Rust轮询实现细节,通过无I/O接口可轻松实现同步——只需执行系统调用并返回完成的Future)。
能否具体说明静态/动态分析中显式状态机缺失哪些要素导致非完整方案?将状态机序列化对静态/动态分析似乎非常理想。我猜想优化阶段的调试基础设施和运行时调试功能缺失,还是另有原因?
这正是他们自己披露的局限性:某些场景过于动态,无法进行静态分析。
若程序接受IO参数并在读写时阻塞/等待(无论通过线程还是事件循环实现),我认为这已不符合Sans-IO的定义。
Sans-IO 的 I/O 完全位于外部。完全不涉及读写操作。
哎呀,你说得完全正确。我也不清楚自己哪里搞混了。
确实需要传递一个令牌,但由于异步和同步代码共用同一令牌,我认为将其类比为典型的异步函数颜色问题并不恰当。
我业余玩过些zig。为何它更符合人体工学?对我来说,使用await和传递令牌的人体工学体验相当。唯一能说的是,使用某种令牌能极大简化不同令牌的区分。但这在异步编程中我几乎从未遇到过。
> 唯一能说的是,使用某种令牌能极大简化不同令牌的区分。但这在异步编程中我几乎从未遇到过。
这对库作者很有价值——他们现在能编写与用户选择的运行时无关的代码,同时仍能表达某些代码路径支持异步处理。
但这通过async await已能实现。例如在Rust中编写异步函数时,你可以自由选择任何异步运行时调用它。
但无法从同步Rust调用。Zig正推动所有同步代码也采用Io接口。
可以:
https://play.rust-lang.org/?version=stable&mode=debug&editio…
让我换个说法:你不能像调用普通函数那样调用它。
在Zig中,执行IO的函数无论是否进行异步操作,调用方式都相同。如果这些异步操作不需要并发(Zig将并发与异步性分开表达),那么它们在同步IO运行时上也能同样高效地运行。
> 在 Zig 中,执行 IO 的函数无论是否进行异步操作,调用方式都相同。
不,你不能这样做,必须传递 IO 参数
同步 IO 同样需要传递该参数。标准库中的 所有IO操作 都将迁移至Io接口,涵盖同步与异步场景。
若需调用异步IO函数,应使用:
若需调用同步IO函数,则写为:
若需表达上述任一操作在可能时可异步执行,则写为:
若需强调必须并发执行,则写为:
上述代码中均未区分foo是否执行同步或异步IO操作。我仅通过传递std.Io类型的参数来标记其涉及IO操作。
那么非IO代码呢?
这有什么问题?它在调用时无需Io参数。就像不分配内存的函数不需要分配器一样。
我觉得你这是在设陷阱:“看吧,Zig能区分IO函数和非IO函数,所以它能实现颜色函数!”
没错,确实如此。至少使用标准库的Zig代码会用Io参数标记涉及I/O的函数。但你应该明白,相比Rust的同步/异步机制,这种做法会导致生态系统分裂程度降低?
这会引发类似React props的层层传递问题——我们不得不将对象在调用链中传递,只为在后续某个环节使用它。
React通过上下文钩子解决了这个问题,当上下文在更高层级注入时可隐式访问。
请问Zig是否支持类似机制?
根据开发者公开讨论的内容(我对Zig的了解仅限于业余爱好者水平):
React 的实现逻辑“大致”偏向函数式,因此需要为特定类型的上下文对象提供特殊“钩子”。而 Zig 作为允许全局状态(包括可变全局状态)的命令式语言,始终存在访问全局变量的方式,无需额外钩子。另一方面,我相当确定(坦白说几乎100%确定)无法通过隐式包含机制,将上下文/IO或任何数据/变量传递给调用栈上层函数,再由其传播至下层。
它不会,而且很可能永远不会。
多年来在Allocator中这从未成为问题。我不明白为何IO会成为问题。
所谓“非问题”指什么?你是指默许在每个函数中传递参数,现在还要额外传递IO参数?
还是说创建上下文结构体进行传递?
> 你指的是默许在每个函数中传递参数
在需要分配内存的每个函数中都需要分配。有时它会被存储在结构体中,但这种情况很少见。而且并非每个函数都需要分配内存。
> 现在还要额外传递一个用于I/O的参数吗?
是的。并非所有操作都需要进行I/O。
你应该尝试阅读一些符合Zig惯例的代码。Ghostty就是一个例子(Zig标准库中的大部分代码也是如此)。
我认为认为这无关紧要的观点源于语言使用习惯。我属于“一切显式化”阵营,对Allocator或提议的IO机制均无异议。但来自其他语言的程序员——尤其是那些将隐式视为语义和语法特征的开发者——无法想象失去所谓“省时省力”的编程方式。
我曾多次论证语言设计中显式化的逻辑优势、实际零时间损耗(在任何有意义的比较周期内)以及长期维护效益。我从未说服过任何一位“隐式派”开发者认同我的观点。不过无妨,我将继续坚持自己的理念,并尽我所能支持那些优先考虑显式性的语言及语言发展。
严格来说这并非阻碍性“问题”。但它也未能超越其他语言的标准异步await机制——请别误会,这本身并非缺点。
> 但你总能看出这将如何减少生态系统分裂吧?相较于同步/异步Rust的分裂?
尚未
这里存在一个问题:
https://play.rust-lang.org/?version=stable&mode=debug&editio…
已为你修复
https://play.rust-lang.org/?version=stable&mode=debug&editio…
就像在Zig里那样传递句柄就行,明白吗?
另外:阻塞代码用spawn_blocking
但关键在于,符合Rust惯例的同步代码几乎从不传递句柄,即使需要做I/O操作也是如此。
你的做法或许不同,也可能在代码中开始这么做,但几乎没有标准库或第三方库会配合你。
Zig的差异不在于功能本身,而在于其标准库生态的构建方式。
若在Rust中实现类似效果,相当于将标准库中几乎所有I/O函数都改为异步;但考虑到异步机制的运作方式,这显然代价过高且破坏性太大。
> 但关键在于,符合Rust惯例的同步代码几乎从不传递句柄,即使需要进行I/O操作也是如此。
因为它们内部不使用async。
Zig代码在非I/O场景下传递句柄?
> 因为它们内部不使用async。
但内部确实涉及I/O操作,这就引出了问题:
我在编写异步代码时需要调用std::fs::read。但这会阻塞线程;虽然可以使用spawn_blocking,但这违背了异步的初衷。因此我不得不另寻其他库(如tokio)中功能相近的函数。
在 Zig 中,同步编程时直接调用标准库文件读取函数;异步编程时同样调用 相同的 库函数。最终由
io对象的创建者决定整体是同步还是异步。实现不同标记的极简化正是目标所在。近期脑海中浮现的几个例子:
背景说明:你或许会质疑为何需要不同运行时?既然语言能隐藏复杂性,为何不直接将所有操作异步化?
1. 在系统语言场景下,这根本不可行。你可能在编写操作系统、嵌入式代码、具有特殊性能需求的需要更精细IO控制的游戏,或是某些绕过内核的操作等。即便仅在几个内置选项间选择(如单线程异步 vs 多线程异步 vs 单线程同步),也无法为用户可能编写的各类程序提供足够的灵活性。
2. 同样地,即使在编译时初始化一次真正随机的IO效果,也未必总能满足需求。或许你通常需要多线程解决方案,但在某些关键段落中需更谨慎处理并发问题,此时就需要切换到不同的IO模式。或许你通常在常规网络环境下交互,但存在特定模式/区段/接口等场景,需要在异常网络条件下发送消息(如20秒延迟、99%丢包率、远端0.1kbps上传速率、定制硬件等)。应用程序某些部分可能需要有界延迟且容忍丢包,而另一些部分则要求高吞吐量且无论延迟代价都必须零丢包。或许你的磁盘硬件特性决定了网络应采用异步处理而磁盘需同步处理。诸如此类。在单IO实现的环境中,你或许能通过不同编译单元等手段绕过限制,但这会变得极其复杂。
因此解决方案之一是:你需要(或强烈期望)具备不同IO运行时等效功能的机制,且能在每次函数调用时热插拔切换。我已从宏观层面阐述了这种需求产生的原因,但抽象概念往往难以引发共鸣,下面通过具体案例说明
await为何不够人性化:1. 以TLS协议为例(标准库或第三方实现皆可)。握手代码相当复杂,因此常规实现会调用IO抽象层来执行实际读写操作(区别于纯状态机实现——这类实现仅返回后续操作的元数据,我曾拼凑过一个糟糕的版本[0]供参考)。若要在嵌入式设备上运行呢?若采用异步编写,可能因额外负担过重而无法适配或运行失效。若需将传输内容隐藏在其他数据中以躲避监视(隐写术——有趣的是如今借助LLM实现相对容易,可将任意数据嵌入人类可读的消息中,表面讨论完全无关内容,同时避免暴露高低位模式等通常会破坏隐写术的特征)?此时内核套接字抽象层完全失效,而“直接使用await”也无法解决问题。本质上,任何需要调用该库的场景(且这类场景本就该使用现成库而非自行实现),若开发者抱持“直接用await”的思维,那么当你需要在其他任何上下文中使用它时,就只能自认倒霉了。
本想举更多具体案例,但评论已过长。核心观点在于“直接用await”阻碍代码复用。若仅为个人使用编写代码且无需其他场景,这倒不成问题;但通过巧妙的抽象设计,或许能实现(旧版Zig的方案在我看来未达标,新版方案是否足够优秀尚待时间检验, 但我持乐观态度)。
内存分配接口的设计堪称典范,若此方案可行,我唯一的顾虑便是泛泛的“下一步计划”——它正朝向效果系统推进,但将效果系统与系统语言整合仍是未解难题,而给几乎每个函数添加第三、第四个显式参数很快会变得难以驾驭(我构思的简易方案是:若开发完整“主流”语言,就基本沿用Zig的思路,将所有“效果”封装为单一效果参数传递给每个函数,同时保留对每个函数调用的自定义空间)。:若真要设计“主流”语言,可借鉴Zig的方案——将所有“效果”封装为单一效果参数传递至函数,既保留每次调用的定制性,又能检查函数是否需分配器等资源,同时通过子效果的语法糖和父类型类的运行时感知机制提升体验)。
[0] https://github.com/hmusgrave/rayloop/blob/d5e797967c42b9c891…
我主张的并非不同IO上下文本身有益,而是强调混合使用几乎永远不是必需的。虽然见过合理应用的案例,但这绝非“常用”模式。因此我更倾向于Rust风格传统async await的优越操作体验——即便它牺牲了极简的运行时切换能力。毕竟前者被使用的频率高出数千倍。
若我理解无误(即多数代码及/或您个人编写的代码无需这种灵活性),那么这确实是有效的使用场景。
实际上这不过是“potato/potato”的场景——将几个符号和关键词替换成名称相似的函数调用而已。若仅此而已,那么传递类似IO的对象(或根据应用需求直接全局存储一次而不必遵循传递约定)其实并不比替代方案更符合人体工学。这并非更差的选择(顶多在字符细节上多费些无谓的争论),但也谈不上更优。
真正耐人寻味的是,多数非平凡项目都存在某种特殊机制。一旦语言/框架/运行时等对使用场景做出单向门禁假设,你就注定无法在自设的围墙内处理这些特殊机制。
或许.NET框架在特定使用模式下存在无法避免的内存泄漏,迫使你在应用中彻底绕开其依赖注入代码;或许你的GraphQL库对套接字的限制性假设,让你不得不重写库中上千行入口代码(更糟的是重写整个库);或许标准库缺乏足够灵活性来适应你非典型的IO使用场景。
在单个应用中,你或许不太可能遇到这类情况——尤其在IO领域(粗略估算显示,需要实现特殊功能的应用中,约30%的情况需要更灵活的IO处理)。然而,当使用频繁预设单向门禁的语言/框架/运行时等环境时,你极可能不得不绕过某种缺陷进行权宜之计。增强IO的健壮性只是众多选择之一,旨在让开发者能实现理想中的软件。当探讨基于参数的IO为何更符合人体工学时,其核心价值恰恰在于满足此类使用场景。若你确实永远不需要这些特性(甚至间接需求),或许你确实无所谓。但仍有许多人需要这些特性,更有甚者渴望一种在任何场景下都能“直接可用”的语言——包括处理此类问题时。
=== 这里开始吐槽Rust的异步机制 ===
你提到Rust的async/await机制在人体工学上优于TFA,而…我觉得值得就此展开更深入的讨论?
(1) 假设你只想编写一个基础的IO应用。你被迫使用Tokio并深入研究静态生命周期等Rust特性(远超预期),否则就得放弃生态系统的多数功能(函数着色之类)。这些限制虽可接受,却算不上理想的开发体验典范。要么被迫学习无关紧要的内容,要么被迫编写本不该存在的代码。Rust异步编程普遍缺乏组合性已是公认事实,也是该语言最常被诟病的痛点之一。
(2) 假设你只想编写一个基础的异步IO应用。此时Tokio库或许符合你的设想,但实现过程依然不易。Tokio采用的异步实现方案会强加大量冗余特性和生命周期问题到应用代码中,导致编写困难。更棘手的是,这些问题并非Rust特有。Rust能在开发周期早期暴露这些问题,但关键在于Tokio对代码存在诸多隐含假设——必须满足这些假设才能正常运行。其他语言的同类库(及生态系统问题)同样会做出类似假设,并要求最终用户进行同类代码修改。相较于Python那种“开箱即用”的单线程异步模式(或C#等多线程方案——若你偏好多线程且能忽略语法尖锐性),Tokio式的开发流程极其艰辛。若你的应用实际需求无法通过异步实现,这种折磨恐怕得不偿失。直接用Go语言的绿线程写代码,然后继续过你的生活吧。
(3) 假设你的目标更复杂些。那你彻底完蛋了。这种能力根本不会暴露给你(虽然暴露了一点,但你得自己写所有该死的代码,这剥夺了选择流行语言的主要吸引力之一)。
我理解Zig语言冗长且并非人人喜爱,也绝不想将此变成Rust与Zig的对决,但Rust的异步机制堪称该语言最糟糕的部分,也是我见过最差劲的异步实现之一。对于TFA的实现我暂无太多评论(看似合理,但实际使用后可能改变看法),但读到Rust拥有优秀异步模型时我深感震惊。我究竟漏掉了什么?
函数着色特指为函数强制要求语法,例如
async关键字。因此若需同时实现异步与非异步函数,必须在代码中分别编写两者。若将“着色”作为参数传递,则可避免额外语法和多重函数定义的需求,从而使函数保持无着色状态。虽然存在多种解决方案及其权衡取舍,但只要语法上存在单一函数,即可满足着色需求。> 函数着色特指要求函数具备特定语法,例如 async 关键字。
有人该提醒这个术语的发明者,因为他们完全没提及 async 关键字[1]。按原文所述,函数着色其实指回调机制(因为这是 JavaScript 选择的异步模型语义机制)。
函数着色只是描述函数 效果 的一种非正式方式。你可以通过语法实现(如
async关键字),或通过类型系统实现(返回() -> T而非T),或通过运行时本身实现(控制所有I/O并统一处理)。但你无法回避它。[1]: https://journal.stuffwithstuff.com/2015/02/01/what-color-is-…
他们特别指出这是语法层面的问题,核心在于必须使用'red'或'blue'关键字。在“2. 函数调用方式取决于其颜色”章节中对此有明确说明…
我不这么认为?这暗示着它本质是回调机制,当然需要额外调用才能实现评估。但它本身并非新关键字;该关键字只是延迟评估的糖衣包装。
> 函数着色机制的核心在于强制要求函数语法规范,例如async关键字。
并非如此。其核心在于区分两类函数(异步与同步), 且无法在同步函数中等待异步函数执行 。
该概念最初源于JavaScript——由于运行时机制,在同步函数中虽可调用异步函数,但其返回的是Promise对象。在同步函数返回前,无法获取实际结果。
但并非所有语言都如此。例如Rust中:https://docs.rs/futures/latest/futures/executor/fn.block_on….
我认为Python或许能实现类似功能,但请勿引述此观点。
与此密切相关的是函数同步性泛型化的难题,人们试图通过效果、单子等手段解决。如今或许称之为“函数着色”,但这并非原始概念的精确表述。
这个设计与Scala的async非常相似,区别在于Scala的执行上下文是隐式参数而非显式参数。我发现对于多数用例而言,该API并未显著优于编写线程并通过并发队列通信的方式。它还存在显著弊端——程序行为高度依赖执行上下文,导致无关任务产生“幽灵般的远距离作用”干扰,且执行上下文管理极为棘手。不过我认为Zig团队对Scala经验有限,因此未能意识到这种方案既非创新之举,也绝非万能良方。
> 在许多使用场景中,我认为该API并未比编写线程并通过并发队列通信更具显著优势。
使用操作系统线程的问题在于,会因小斯定律引发扩展性困境。在JVM环境中,虚拟线程能规避此限制。但JVM实现用户模式线程的效率仍优于低级语言——因其具备多重优势(JIT能解析所有虚拟调用,对栈内指针的限制更合理,优质GC机制使内存管理成本极低,仅需付出稍高的内存占用代价)。因此追求可扩展性时,低级语言需另寻解决方案。
https://www.scala-lang.org/api/current/scala/concurrent/Exec… 供感兴趣者参考。
旧版Zig的异步/等待系统理论上允许我实现暂停/恢复功能——我不确定在新io系统中如何实现,除非手动编写代码。具体来说,你可以暂停函数的执行帧,稍后再恢复执行。我迟迟未尝试在Zig中开发操作系统,正是因为非常期待能利用这个精妙特性:配置设备或向队列提交命令后,暂停执行该命令的函数,待接收到设备中断时再恢复执行。这原本是我的构想。虽然不确定实际效果如何,但确实是个值得尝试的有趣思路。
我推测最终目标是将暂停/恢复(或类似机制)作为用户空间标准库函数来实现Io.Evented。这最终会演变成类似GitHub上众多C协程库(minicoro、llco等)那样的用户空间实现方案。
编辑:观察Io.Evented的工作原型后,实际情况可能并非如此。或许这属于第三方库的领域,但需结合无栈协程?
但另有一点值得关注:当前关于在WASM实现事件驱动IO的提案——即作为语言特性的无栈协程:https://github.com/ziglang/zig/issues/23446
能否创建仅含单线程的线程池,并实现线程挂起/恢复?
这岂不是违背了协程的初衷?轻量级并发才是核心价值啊。
确实如此,可惜了。
既然有抢占式多线程(async),为何还要实现协作式“多线程”(协程)?
曾有过一段美好的时光,async/await本质就是可恢复函数。我曾滥用它来实现生成器。
这个示例颇有意思:
在Rust或Python中,若创建协程(例如调用异步函数),该协程通常不会自动推进进程——除非有程序主动等待(即按需轮询)。相反,若将协程封装为任务,则由运行时调度该任务,并在运行时具备调度能力时推进执行。但创建任务是显式操作,程序员可选择采用结构化方式(常称“结构化并发”)进行,即任务始终在特定作用域内创建并执行。
由此例推断,若示例允许“io.async”处理的对象自行推进,则其创建的任务将持续存在直至完成或被销毁取消。
这无疑是 有效的 设计方案,但似乎并非其他语言选择的方向。
C#也是这样运作的,对吧?事实上C#会(必须?)在调用线程上运行异步函数,直到遇到yield语句为止。
Python和JavaScript也是如此。我认为大多数支持async/await的语言,在未来值已确定时都会跳过yield操作。只有创建新任务/承诺时,才会保证调度而非立即执行。
我没完全理解你的意思。
Python的工作原理如下:
运行结果如下:
可见,仅创建协程的行为不会触发运行时执行它。但若显式创建任务,则会立即执行:
我个人偏好协程在未被显式调用时保持暂停的行为——这更便于推导代码的执行时序。但我不太喜欢Python混淆协程型未来对象与任务型未来对象的区别。
这正是我所描述的行为。
sleepy_future = sleepy()创建状态机但不执行任何操作,create_task通过队列实际调度任务运行,asyncio.sleep挂起主任务以便新调度任务执行,而await sleepy_task要么让主任务等待sleepy_task完成,要么在任务已完成时立即执行空操作而不阻塞主任务。我的核心观点在于:最后这段代码是异步/等待语言中非常常见的优化方案。因为如果未来对象已解析完成,且当前任务未因等待任何操作而阻塞,那么就没有必要挂起当前任务并承担切换开销。
> 我个人偏好协程的默认行为——除非主动调用否则不运行,这能更清晰地推导代码的执行时序。
在.NET中,这种差异被称为“热任务”与“冷任务”。
“热”任务(即.NET中C# async/await的实现方式)的优势在于:能立即执行参数验证代码,并在调用点直接抛出异常,这更便于调试。
但有人认为此类验证本应与函数主体分离——用DbC术语来说,这属于函数契约范畴。
在C#中,该Task始终处于热状态,即被调度执行。
Zig语言中执行是否发生在a_future.await中?
我推测:
io.async 1 在io中存储“嘿,请处理这个”
io.async 2 将任务存储在io中:“嘿,这个也请处理”
当io通过某个“提供的事件循环”进行事件处理时:
await #1 会交替执行1和2,若2先于1完成,则标记2的完成状态,待1完成后返回a_result。
await #2 若1在2之后完成则“不执行”,但若2仍有待处理工作,则持续执行直至2的所有结果都到位。
除非你选择线程IO,否则不存在“神秘运行的任务”——此时io.async确实会启动进程,若在异步调用与await之间主线程陷入深度休眠,进度可能已推进(若采用事件驱动则不会发生这种情况)。
这里存在实质区别。根据我对文章的理解(尚未实际测试),Zig的实现如你所述:
> 等待#1会交错执行1和2,若2先于1完成,则为其添加标记,待1完成后返回a_result。
而在Rust或Python中,等待未来值会执行 该未来值 及可能的其他 任务 ,但不会执行非任务类型的未来值。第二个异步操作属于非任务型未来值,因此等待第一个未来值并不会推动其进展。
Zig的io.async似乎有时会创建其他语言所称的任务。
我不熟悉Rust,且多年前就放弃了Python异步编程,因此对此缺乏参照框架。但我实在不明白为何需要区分任务与非任务?
关键在于Zig的执行范围不仅限于#1和#2。例如若该函数调用方在所有操作前已启动#3,该操作也可能被塞进.await中执行。
这正是JS的工作原理
Zig同样无法保证。
任何任务未来在调用.await(io)前都不会被强制执行。它究竟会立即启动(可能在同一线程)、加入线程池队列,还是交由事件循环处理,完全取决于用户选择的Io运行时。
虽然无法保证,但根据文章所述,这正是事件驱动模型的工作机制:
使用 Io.Threaded 实例时,async() 函数实际上并未执行异步操作——它只是立即运行传入的函数。因此,采用该接口版本时,函数会先保存文件 A 再保存文件 B。而使用 Io.Evented 实例时,操作真正实现了异步处理,程序可同时保存两个文件。
Andrew Kelley 的博客(https://andrewkelley.me/post/zig-new-async-io-text-version.h…)探讨了 io.concurrent 的实现机制,该库强制执行真正的并发,其设计具有明显的非结构化特征。该方案甚至要求调用方确保任务存活时间不会超过其引用的对象生命周期:
经过对该设计领域的初步思考,我认为Zig的方案比C/C++的对应思路更胜一筹——至少Zig提供了defer机制,并在避免明显错误方面略显用心。但我更倾向于Rust的方案或真正的GC/引用计数系统(如Python、Go、JS等):在非玩具示例中,异步操作的概念生命周期往往超出单次函数调用,且极易出现对象生命周期分析失误。此时若能通过语言机制阻止代码访问已失效的对象,将极具价值。无论是Rust通过静态验证生命周期的方案,还是GC通过自动延长生命周期的方案,都基本解决了问题。
不过这些功能在Zig中是全新的,我从未编写过Zig代码,或许它实际运行效果会非常出色。
啊,我们可能在各说各话。我指的是接口本身不作任何保证,而非具体实现。Io接口本身无法保证在await调用返回前任何操作已启动。
我期待看到最终成果。我每天都在用Go开发,认为Io修正了许多Go的缺陷。我好奇的是Zig是否计划引入通道机制。在Go中我常希望IO能通过通道实现——语言里明明有select关键字,却无法用于套接字操作,这实在奇怪。
将每项IO操作封装为通道操作的开销相当大。你可以通过实际操作来感受其速度:用一个协程将一系列IO操作传递给另一个协程。
虽然不会像经典问题“Go明明很快,为什么启动完整协程并进行多次通道操作来计算两亿次整数加法反而变慢”那样糟糕,但仍会相当耗费资源。另需注意:在近期新增迭代支持前,Go通过通道范围遍历实现的迭代语义其实相当合理——只要你不在意每次迭代都需执行完整的通道操作和内部上下文切换。而事实上,我们多数人对此颇有微词。
(优化纯Python代码的技巧之一,是确保充分利用Python所有相对昂贵的单项操作。例如它已在每个操作码中处理异常,因此某些情况下可巧妙利用异常机制跳过部分代码执行。Go通道类似:它们 相对 昂贵,耗费数十个时钟周期,因此需确保其价值与成本匹配。不必过度追求,毕竟操作成本并非每毫秒级,但应通过传输非微量工作负载或充分利用其多对多协调能力来实现成本效益。I/O操作常涉及移动小字节切片(甚至单字节),此时成本效益极低。批量传输千字节数据时成本效益尚可,但并非所有I/O都符合这种模式,因此不应将此硬编码到I/O规范中。
你试过Odin吗?这是种优秀的语言,既是“更优的C”,又比Zig更汲取了Go的精髓。
我也推荐Odin,但需稍作说明:
Odin未实现(且据其创建者所言永远不会实现)特定并发策略。无异步、协程、通道、纤维等机制… 其设计者认为并发策略(以及内存管理)属于更高层次的领域,超出了该语言的设计范畴。
我对此表示理解,但我知道许多人正在寻找“杀手级”特性。
> 我很好奇的是,Zig语言是否有计划引入通道机制。
Zig中与Go语言通道对应的标准库是
std.Io.Queue[0]。你可以实现类似以下功能:在 Zig 中实现为:
显然不够优雅,但能兼容任意 IO 运行时,且无需运行时垃圾回收器就能实现这种并发模式,这种权衡非常有趣。
[0] https://ziglang.org/documentation/master/std/#std.Io.Queue.
至少Go没有走async/await关键字这条黑暗道路。在C#中这简直是噩梦,除非愿意重写所有代码,否则必须使用sync覆盖async这种反模式。我很高兴Zig选择了这种“无色”的方案。
你觉得Io参数从何而来?若将某个函数改为异步执行,突然就需要Io实例了。我看不出来修改调用树实现异步与修改调用树传递Io令牌有何区别。
同步Io现在也使用Io实例。着色机制不再是“是否异步?”而是“是否执行Io操作?”
这使得库作者能以与Io运行时无关的方式编写代码——无论用户选择同步、线程化、带栈协程的事件驱动,还是无栈协程的事件驱动。
核心问题始终是“是否执行IO操作”。
但现在库代码失去了运行环境的上下文。若你设计为同步操作,而调用方提供多线程IO时,代码可能以意外方式失败。
具体如何?除了常规的线程安全问题之外。
这正是核心问题——线程安全。调用std.Io的函数必须识别底层实现类型(如std.Io.Threaded),以便采取线程安全防护措施。若该函数按同步模式设计,如何避免因防范线程化IO而承受性能开销?
被调用的函数本就必须考虑线程安全,即便它不涉及IO操作。这是完全独立的问题,因此我无法将其视为对Zig设计思路的有效批评。无论是否涉及IO,库的设计都应确保线程安全或明确标注例外情况——因为调用程序很可能创建多个线程进行重复调用。
> 若该函数本就为同步场景设计,如何避免其因防范线程化IO而承受性能开销?
通过文档说明:多线程模式下会产生性能开销?这与此前任何库的设计逻辑并无二致。
Rust同样支持编写与异步运行时无关的异步代码。将async纳入Io框架在我看来影响不大。
Go语言造成的危害之一,是让人们误以为其并发模型具有特殊性。所谓“Goroutines”本质是绿色线程,而“通道”不过是线程安全的队列——Zig的标准库中就有类似实现https://ziglang.org/documentation/master/std/#std.Io.Queue
通道不仅是线程安全的队列,更是可用于select调用的线程安全队列。select才是其核心特性,而非队列功能本身。我对Zig语言了解有限,无法判断是否能编写“当队列就绪时,从这个队列或那个队列中提取数据”的代码;若能实现,则可视为合适的替代方案;否则则不然。
当然,即使该队列本身不可选择,你仍可在Zig中实现具备select功能的Go通道。我相信此类实现早已存在。Go并未独占任何其他语言无法使用的特殊CPU指令。而编程语言(或支持此功能的语言库)能实现比Go内置更强大的“select”变体,支持对更多类型对象进行选择(尽管具体实现成本因涉及机制而异)。但这已超越队列范畴,这也解释了为何Go通道操作稍显昂贵——它们实现的功能远超简单队列。
> 我对Zig语言了解有限,无法判断能否编写“当队列就绪时从当前队列或备用队列提取”的代码;若可行则可替代,否则则不可。
感谢你让我有机会探究Zig的实现机制。
Zig提供泛型select函数[1]用于处理未来值。与常见实现类似,Blub语言特性对应的是Zig的comptime函数。其io实现包含select函数[2],该函数“阻塞直至列表中某未来值准备就绪(此时等待不会阻塞),并返回该索引值”。泛型select通过切换该索引返回结果。具体细节尚不明确。
[1] https://ziglang.org/documentation/master/std/#std.Io.select
[2] https://ziglang.org/documentation/master/std/#std.Io.VTable
从多个队列获取简单未来对象后等待首个对象的操作,并不符合Go通道语义。若对三个通道执行select操作,你将从其中一个通道获取结果,但对另外两个通道不会获得任何未来值的索取权。其他协程可能抢占这些通道。若其他协程确实从这些通道获取了值,这将构成一次性的确定性通信,原始协程将无法再访问该值——该未来值不会“解析”。
通道语义与未来语义存在本质差异。顾名思义,通道是数据流,而未来是单一未来值——该值可能已解析也可能尚未解析。
重申一次:Zig语言完全可以采用多种方式实现Go通道,但绝非“给线程队列的.get方法套个未来包装”这么简单。
同理可知,通道本身也并非直接实现未来值的方案。虽然通过通道配合简单方法就能轻松构造未来值——我每月都能看到约1个声称“实现未来值”的Go库——但这种实现是必要的,因为通道并非未来值,未来值也非通道。
(注:我并非在讨论二者孰优孰劣。这类争论实则颇为困难——尽管实践中它们差异显著,但二者都相当完整地覆盖了解决方案空间,我认为全球范围内并无绝对优势可言。但它们确实截然不同。)
> 通道不是期货,期货也不是通道。
在我看来,队列的getOne操作类似于Go通道的a
<-操作。虽然不清楚如何将getOne调用封装到Future中传递给Zig的select,但完成这些后应该能形成直观的模式。你对语义的严格要求让我深表赞赏。坦白说,我最困惑的是Go/Zig在select中如何实际定位首个完成的未来对象,除此之外还有其他疏漏吗?
https://ziglang.org/documentation/master/std/#std.Io.Queue.g…
或许是我理解有误,但如何从通道接收数据来获取一个
Future?更关键的是,如何编写自定义
Future才能支持这种select操作,并兼容任何合理的Io实现?如果我们只是在争论苏格兰人的本质,那么“选择通道”不就是等待条件成立的一种便利方式吗?
这并非“真正的苏格兰人”谬误。这是Go通道的独特特性。在多线程队列中,虽然可从其他线程调用“.get()”,但该操作是阻塞的且无法同时处理其他队列,因此无法编写如下代码:
更复杂的结构也同样受限。
或者换个说法,当有人声称“我在X语言中实现了Go通道”时,我关注的不是他们是否实现了线程队列,而是是否具备select的等效功能。毕竟X语言里可能早已存在十几个“线程队列”实现,但select这类功能反而较为罕见。
再次强调“独特”与“唯一”的区别。Go的任何单一特性都谈不上唯一——毕竟它并未独占某些特殊CPU指令集。但相较于寻常的线程队列,这种特性确实更具代表性。
当然实现方式多种多样。它并非等同于简单的条件等待,但通过足够的努力,或许能用条件语句实现类似功能——可能需要编译器辅助以简化使用,毕竟需要以某种方式组合多个条件。
它更类似于等待列表中*任意*条件的实现。
还有哪些主流语言支持不带函数着色的抢占式绿色线程?我只想到Erlang。
据说现代Java(loom?)支持。但遗憾的是,这可能就是全部了。
或许不算主流,但Racket支持。
它很特别。2009年CSP还远未成为通用概念。信道提供了处理同步的全新方式。
若忽视抽象化的优势,一切都只是“另一种实现”。
我认为文章中展示的简单示例中,新的异步IO表现出色。但对于服务器所需的复杂I/O场景,我对其适用性持保留态度。相关问题已提交至:https://github.com/ziglang/zig/issues/26056
https://danieltan.weblog.lol/2025/08/function-colors-represe…
核心问题在于语言/库作者需要提供某种方式来连接不同的执行上下文,例如将这些不同上下文(同步/异步)封装在有限状态机中,然后在两者之间建立某种通信通道。
有趣的是看到Zig如何处理异步。io_uring优先的方法对现代系统有意义,但挑战始终在于如何在不牺牲Zig显式控制哲学的前提下实现舒适的异步操作。好奇带颜色的函数在实践中会如何发挥作用。
虽不了解细节,但文章所述方案确实做对了。这正是我对Rust的主要不满——在我看来它完全搞砸了。或者说,他们搞砸了易用性设计。Rust依然能很好地实现低级控制…(但普通显式事件循环也能做到)。Go做得更好,成功实现了易用性却牺牲了低级控制(准确说是“成功失败”,因为这本就不是目标)。
兼顾易用性与底层控制的关键,恰恰在于创建第二层——“运行时”层,负责调度高级任务、I/O和进程间通信。这虽非易事,却是唯一途径。否则就会陷入互操作性困境,正如Rust中色彩标记和生态碎片化所反映的问题。
显式分配器和显式I/O是系统语言中典型的代码异味。
我认为Zig在这方面确实做对了,很期待实际使用并体验它。
反复将I/O传递给其他组件似乎很烦人。比如用I/O获取File实例后,还得把I/O传递给它的读写方法?谁会用一种io实现创建文件,却想用另一种io操作它?
没人能阻止你定义全局Io值,就像全局分配器那样。不过对库代码绝对是个坏主意。
我认为Java虚拟线程比大多数语言更优雅地解决了这个问题。但我不确定在Zig这样底层的语言中是否可行。
我很期待后续发展。最近我在Zig中做了一些io_uring相关的工作,实现起来相当棘手。
不过依赖注入似乎正在Zig中成为流行趋势,先是内存分配器,现在又是输入输出。我好奇如果在标准库中引入依赖注入框架,能否减少所有函数所需的冗余代码量。现在每个结构体或裸函数默认都需要(2)个字段/参数。
> 现在每个结构体或裸函数默认都需要(2)个字段/参数。
在Zig语言中,将接口存储为结构体字段正逐渐成为一种反模式。虽然仍有适用场景,但若将其作为首选策略需三思。标准库近期正转向“非托管”容器,这类容器不存储Allocator接口的副本,而是将Allocator实例传递给所有涉及内存分配的成员函数。
以前的写法是:
现在改为:
更优写法:
我不太明白每个示例相较前例的改进之处(当然,我确实不太了解Zig语言)。
若使用两个不同分配器调用append()会怎样?或者用与实际管理内存不同的分配器调用deinit()又会怎样?
将分配器与容器并存会额外占用16字节。虽然不多,但当容器内存中包含其他持有分配器的对象时,这种开销会累积。此举可提升缓存局部性。
同时有助于消除虚化,因为最常见的情况是应用程序中仅使用单个分配器(偶尔由Arena分配器封装用于分组分配)。当分配器接口存储在容器中时,优化器更难证明其未发生变更。
> 若使用两个不同分配器调用append()会怎样?或者使用与实际管理内存不同的分配器调用deinit()呢?
这属于未定义行为,但实践中从未引发问题。如前所述,整个程序中通常仅使用单一分配器处理长生存期对象。区域分配器用于分组分配,其作用域通常明确界定,因此释放位置清晰可辨。固定缓冲区分配器也多用于相同有限作用域。
我认为介于DI框架与逐项传递之间的折中方案是采用Context对象。该对象可承载分配器、IO实现,以及诊断结构体(因Zig不支持在错误中附加额外信息)。随后可根据需求传递整个Context结构体或其部分组件。
没错,这样设计很合理。
但请千万别搞依赖注入框架。所有参数和依赖都应显式声明。
我认为且希望他们不会这么做。据我所知,他们的信条是“拒绝魔法,一切操作皆可视化”。他们追求的是简单直观的语言特性。
这观点合理,但同样的论点也可用于Go语言冗长的错误处理机制。按此逻辑,我们甚至可以质疑
try语句是否具有魔法特性——尽管我认为没人愿意放弃它。https://news.ycombinator.com/item?id=46065366
这个想法很有前景,但我对它能否真正透明地与其他执行器(比如无栈协程)协同工作持保留态度,而且它很可能无法与使用ffi的代码兼容。
是否有可能在std.Io原语之上实现结构化并发?
若你看到这种模式,说明你正在实践结构化并发。
同理:
这与Scala库Zio或Kyo实现并发的方式极为相似,只是缺少了函数式效果部分。
这解释得很糟糕,因为它根本没说明并发机制如何运作。是基于栈的实现吗?存在沉重的运行时开销吗?还是无栈设计且所有内容都需双重编译?
我认为所有底层语言的异步实现都糟糕且半吊子,更厌恶这种仓促之作如今竟被奉为圭臬。
(个人观点:我们需要一种将调用栈视为显式数据结构的语言,如汇编语言那样具备线性特性、“存在性生命周期”,以及随控制流改变类型的位置标记,才能真正解决这个问题。目前尚无语言接近此目标。)
> 不作语法区分的语言(如Haskell)本质上通过将一切异步化来解决问题
我刚刚到底读了什么鬼东西。只能猜测他们把Haskell和OCaml搞混了;前者以要求所有I/O都必须表示为某种类型值而闻名,该类型需完整编码整个I/O计算过程。着色机制依然存在,因为你无法隐藏它,只能将其提升为更通用的颜色。
再说,Go不是当今这种模型的典型代表吗?
Haskell有绿线程。如今Java也有虚拟线程了。
我敢打赌那些绿线程仍需某种IO类型来编码非纯操作,通常还需do语法。单纯将并发计算与I/O异步相比较实在荒谬。事实上,我怀疑那些绿线程本身就已存在“多彩”类型,只是目前无法验证。
纯操作可通过https://hackage-content.haskell.org/package/parallel/docs/Co…实现并行执行
不纯操作则如Haskell惯例使用IO单子:https://hackage.haskell.org/package/base-4.21.0.0/docs/Contr…(或更高阶的async库)
我猜函数着色论点的极端版本可能是:所有类型都是颜色。
某种程度上算是吧?函数着色问题的症结在于:若不修改整个调用链(或阻塞整个线程),无法从非异步调用者处调用异步API。而在async/await语言中,冲突源于返回类型的变更;语法本身是正交的。
实践中某些代码属性或许更适合以全局程序形式存在,而非通过类型表示——即便这会降低模块化程度或增加检查难度。
感谢推荐,我已多年未碰Haskell,更偏好F#和OCaml。
赞同,异步代码在多数语言中都是头疼问题。
微软引入任务/异步等待机制后,我终于不再频繁编写单线程代码了——因为思维负担大幅减轻。Python 3也是如此。
这不正是Zig试图解决的混乱局面吗?
我见过的其他所有示例都把执行模型硬编码在源代码里。
这恰恰是JavaScript比其他语言更擅长的领域——得益于其运行时本身基于事件驱动的单线程特性。虽然功能不如后者强大,但实用性极强且极其符合人体工学。
我欣赏Zig,也认同他们在此处的处理方式。
摘自文章:
是否应将
std.Io.Threaded拆分为std.Io.Threaded和std.Io.Sequential?单线程本质就是“非线程化”的另一种表述,还是我理解有误?专业建议:采用后缀关键字语法。
例如:
doSomethingAsync().defer
此法可因运算符优先级规则省去多余括号。
这是其他语言中async/await机制的最大痛点。
我喜欢这个方向的设计。我不喜欢某些语言中流行起来的
async关键字,它会污染代码库。在JavaScript中,我喜欢
async关键字,它很好地标识了需要跨网络传输的内容。异步操作总让我困惑:函数究竟何时会创建新线程?
为何困惑?异步与多线程无关。事实上单线程环境同样能实现异步!
异步操作往往演变成无边界的着色函数,一旦启用便难以收敛。
我始终不解这种质疑。区分哪些函数可能执行异步操作、哪些能保证不阻塞运行,这种认知对我极具价值。
在当前工作中,我主要编写(非异步)Python代码。我认为无法简单判断方法调用何时会触发I/O操作是性能上的陷阱,这极易导致开发者在不知不觉中写出N+1式查询。
而采用async/await机制时,开发者必须时刻关注这些操作的发生位置,从而更可能有效管理它们。
顺带一提:Zig语言的做法在此也颇具启发性——显式的Io函数参数似乎能迫使开发者形成类似认知,且无需引入新语法!期待见证其实际应用效果。
在我(Rust视角)看来,async关键字存在两大核心问题:
它追踪代码属性,而这些属性在同步代码中通常会被忽略(即大多数语言不会用“does IO”标记函数)。为何IO比“可能引发panic”、‘使用有限栈’、“可能执行分配”等属性更重要?
它实现了一个临时拼凑的问题特异性效应系统,存在诸多缺陷。而规避这些缺陷需要重构语言的一半功能。
> 为什么IO比“可能引发panic”、‘使用有限栈’、“可能进行内存分配”等更重要?
Rust同样可以使用这些标记。
我同意。但应通过规范的效应系统实现,而非在类型系统滥用基础上堆砌临时性黑客方案。
async本就属于类型系统范畴。在你设想中,如何标记并上报恐慌性函数等?具体实现形式会是怎样?我认为给函数添加`panic`标签是个好主意,但若堆叠标签会变得臃肿:
这显得过于密集。
理想方案应是让读者能一眼识别,而非依赖推断。
>`async` 属于类型系统范畴。
并非如此。`async` 仅是语法糖,其“效果”通过 `Future` 在类型系统中模拟实现。这正是 `async` 系统显得如此陌生、需要大量语言改动才能勉强可用的重要原因之一。
const更接近“真实”的效应(严格来说是反效应,但现阶段不必深究)。此外,我认为区分效应系统与类型系统很有必要,而非简单归为“类型系统”。前者适用于代码,后者适用于数据。
>这感觉很晦涩。
是的。但为何
async比alloc更重要?对某些应用而言,了解潜在内存分配的重要性,恰如对其他应用了解代码是否可能阻塞。明确列出所有效应是最直接的方法,但我认为更实用的方案是建立“默认效应”列表,允许在仓库(甚至模块)层面覆盖。函数层面则可根据需要选择性启用或禁用效应。
>理想情况下,读者应能一眼识别这些效果
嗯,要么采用“密集型”签名,要么选择显式“一目了然”的签名。
这是Django吗?在那种场景下或许能理解这种设计。某些框架和ORM确实会模糊这种界限。但就我编写的代码而言,是否会引发IO操作通常非常明确。
我见过许多变更:非异步函数先使用异步调用,最终整个函数被标记为异步。当多数函数都标记为异步后,那些冗余代码还有什么意义?