Zig的 “Colorblind” Async/Await是什么?
Zig 是一种新的通用编程语言,它正在从头开始重新思考语言和相关工具的工作方式。我已经讨论了编译时代码执行,现在我将介绍该语言的另一个创新概念:async/await。
但是等一下,async/await 不是也存在于其他语言中吗?
嗯,是的,但是Zig中的async/await与编译时执行相结合,允许函数隐式变为异步,并且整个库在阻塞和事件 I/O 模式下透明地工作,这是Zig独有的。
让我们看看这意味着什么
Zig 中的 Async/Await 表示法
虽然Zig非常创新,但它试图成为一种小而简单的语言。Zig 从 C 语言的简单性中汲取了很多灵感,并在您真正需要的时候保留了元编程杂技。一般来说,你应该能够在一个周末就能变得高效地使用 Zig 。因此,虽然大多数 Zig 功能会立即让您感到愉快且有些熟悉,但 async/await 语法不是一个熟悉的例子,但这是有充分理由的。
一点背景
在使用事件 I/O 时,命令式编程语言中往往有两个选项:回调或 async/await。
第一种情况的优点是不需要对语言添加任何功能,但缺点是现在一切都必须基于回调和嵌套闭包。第二种情况基本上让编译器将函数分解为不同的“阶段”,使整个翻译对用户透明(即对您来说,它看起来仍然像正常的、顺序的、命令式的代码),但不幸的是,它有引入函数着色的副作用。
函数着色
这篇博文很好地解释了函数着色的想法,这里总结一下:由于您无法从非异步代码调用异步函数,因此您最终会付出大量重复的工作,您需要重新实现标准库的一部分和所有与网络相关的库来解释 async/await。这方面的一个例子是Python,在Python 3中引入async/await催生了像aio-libs这样的项目,其目标是在AsyncIO之上重新实现流行的网络库。
Zig的 “Colorblind” Async/Await
让我们通过几个示例来了解 async/await 在 Zig 中的工作原理。要查看以下代码片段的运行,请在另一个终端中启动一个 listen 模式的 Netcat,如果一切正常,您应该看到每次运行后都会打印“Hello World!
以下是完整的 Netcat 命令:
➜ nc -lk 7000
基本
第一个示例是写入套接字的简单阻塞程序。这没什么奇怪的。
const net = @import("std").net;
pub fn main() !void {
const addr = try net.Address.parseIp("127.0.0.1", 7000);
try send_message(addr);
}
fn send_message(addr: net.Address) !void {
var socket = try net.tcpConnectToAddress(addr);
defer socket.close();
_ = try socket.write("Hello World!\n");
}
在源文件中添加一个特殊声明将启用事件 I/O。
const net = @import("std").net;
pub const io_mode = .evented;
pub fn main() !void {
const addr = try net.Address.parseIp("127.0.0.1", 7000);
try send_message(addr);
}
fn send_message(addr: net.Address) !void {
var socket = try net.tcpConnectToAddress(addr);
defer socket.close();
_ = try socket.write("Hello World!\n");
}
该声明在后台引起了一些更改,其中之一是在非阻塞模式下打开套接字。这会导致函数变得异步,但正如你所看到的,它的调用方式没有改变:它看起来仍然像一个正常的函数调用,即使它不是。
上面的Zig代码在功能上等效于以下Python代码:
import asyncio
async def main():
await send_message("127.0.0.1", 7000)
async def send_message(addr, port):
_, writer = await asyncio.open_connection(addr, port)
writer.write(b"Hello World!\n")
writer.close()
asyncio.run(main())
并发性表达
您现在已经看到,在 Zig 中,启动协程并立即等待其完成没有额外的关键字要求。那么,您如何开始一个协程,然后await
呢?当然是用了 async
!
const net = @import("std").net;
pub const io_mode = .evented;
pub fn main() !void {
const addr = try net.Address.parseIp("127.0.0.1", 7000);
var sendFrame = async send_message(addr);
// ... do something else while
// the message is being sent ...
try await sendFrame;
}
// Note how the function definition doesn't require any static
// `async` marking. The compiler can deduce when a function is
// async based on its usage of `await`.
fn send_message(addr: net.Address) !void {
// We could also delay `await`ing for the connection
// to be established, if we had something else we
// wanted to do in the meantime.
var socket = try net.tcpConnectToAddress(addr);
defer socket.close();
// Using both await and async in the same statement
// is unnecessary and non-idiomatic, but it shows
// what's happening behind the scenes when `io_mode`
// is `.evented`.
_ = try await async socket.write("Hello World!\n");
}
通过使用 async
关键字,您可以创建协程并运行它,直到它遇到挂起点(粗略地说,当它必须等待 I/O 发生时)。返回值是Zig所说的“异步帧”,在某种程度上等同于 Future
、 Task
、 Promise
或来自其他语言 Coroutine
的对象。
最后一个技巧
让我向您展示完成谜题的最后一个技巧:在阻止模式下使用 async/await。要恢复到阻塞 I/O,我们所要做的就是删除我们在开头添加的特殊声明(或使用相应的枚举大小写)。
const net = @import("std").net;
pub const io_mode = .blocking;
pub fn main() !void {
const addr = try net.Address.parseIp("127.0.0.1", 7000);
// yes, this still works
var sendFrame = async send_message(addr);
try await sendFrame;
}
fn send_message(addr: net.Address) !void {
var socket = try net.tcpConnectToAddress(addr);
defer socket.close();
// this too
_ = try await async socket.write("Hello World!\n");
}
是的,该程序按预期编译和工作。该函数不再是异步的,实际上这两个关键字基本上都变成了无操作,但关键是即使您无法利用它,您也可以表达并发性。在我们的小示例中,这似乎没什么大不了的,但它的原则允许库在单个代码库中提供阻塞和事件 I/O 功能。
一个具体的例子
不久前,我开始研究 OkRedis,这是一个用 Zig 编写的 Redis 客户端库,它试图在不影响效率的情况下为用户提供尽可能多的细节。除此之外,它还在单个代码库中完全支持阻塞和事件 I/O。如果您想了解更多信息,请查看GitHub上的可用文档,并观看Andrew Kelley(Zig的创建者)与我合著的演讲。在其中,Andrew解释了Zig中async/await的基础知识,在第二部分中,我演示了OkRedis。
虽然编译器非常聪明,并且击败函数着色有很多实际好处,但它不是灵丹妙药,所以让我立即揭开一些关于它的想法。
问:启用事件 I/O 会立即使我的程序更快吗?
否,若要使程序利用事件 I/O,需要在代码中表示并发性。如果您从未执行过这项工作,则启用事件 I/O 不会提供任何明显的优势,但是如果您正在使用的库之一已正确设计为 async/await,则可能会体验到更好的性能。
也就是说,如果你的代码被嵌入到一个使用async/await的较大项目中,那么自动转换为事件I / O将在某种程度上使你的代码与周围的上下文很好地配合。
问:所以所有异步应用程序都可以通过拨动开关来阻止?
否,有很多应用程序需要事件 I/O 才能正常运行。切换到阻止模式而不引入任何更改可能会导致它们死锁。例如,考虑一个既充当服务器又充当自身客户端的应用程序。
也就是说,在编译时,可以检查整个程序是否处于事件模式,例如,正确设计的代码可能会决定在阻塞模式下移动到线程模型。
问:所以我甚至不必考虑库中的普通函数与协程?
不,偶尔你将不得不这样做。例如,如果允许用户在运行时传递到库函数指针,则需要确保根据函数是否异步使用正确的调用约定。您通常不必考虑它,因为编译器能够在编译时为您完成工作,但对于运行时已知值,这不会发生。
一线希望是,您可以使用所有工具以简单明了的方式解释所有可能性。一旦你掌握了正确的细节,代码就不会比它必须的更复杂,你的库将很容易使用。
并发和资源分配
虽然我在写介绍时考虑到了普通开发人员,但您需要意识到 Zig 不是一种动态类型语言,最重要的是,它在资源管理方面赋予了您很大的权力(和责任)。例如,如果你知道如何在C#,JavaScript或Python中做async/await,你将无法立即知道如何在Zig中做所有事情。
特别是,垃圾收集语言会向您隐藏内存的来源。这使得程序员更容易,但这种额外便利的代价完全由机器承担。这不是什么新鲜事,它通常值得做,但是,尤其是在 async/await 方面,它是有问题的,因为您无法控制消耗的内存量,最终过度使用并在负载过重时遇到问题(有关更多信息,请参阅此博客文章)。
Zig的要点之一是使资源分配始终清晰易管理。当涉及到 async/await 时,这意味着运行协程所需的所有内存都由其底层异步帧表示。一旦你有一个帧(无论是因为它是静态内存还是因为相应的动态分配成功),那么你就知道协程将能够毫无问题地运行到完成。例如,在HTTP服务器的上下文中,这意味着您将能够预先知道是否有足够的资源来接受连接,而不会遇到不可恢复的错误情况。
关于Zig并发性的最后一句话
到目前为止,我只讨论了实现协程的语言功能。我没有提到所有这些,但更重要的是,我没有谈论事件循环。在Zig中,事件循环是标准库的一部分,其想法是使其可交换。
在撰写本文时,事件循环还有很多工作要做,但您今天已经可以尝试所有内容。当前的实现已经是多线程的,以防您想知道。只需转到 ziglang.org,下载最新版本,然后查看文档即可。
...那么 Go 呢?
啊,是的。Go - 实际上与其他一些语言一起 - 没有函数着色问题。安德鲁在前面提到的演讲中提到了 Go ,但我会在这里给我两分钱。
如果你以前读过我的博客,你就会知道我喜欢 Go 。我认为在应用程序级编程中,goroutines 通常比 async/await 更可取,尤其是在服务器端应用程序方面,我相信这是 Go 的主要领域。
我认为在这种情况下,goroutines 更可取,因为 async/await 是一个低级的工具,很容易被滥用,但是当涉及到编写具有正确性和效率关键要求的代码时,你需要 async/await 并且你需要 Zig 的哲学,即我们都应该努力编写健壮、最佳和可重用的软件。