异步不等于并发
这篇博客的标题并不是人们常说的内容,甚至可能从未听过。人们常说的是“并发不等于并行”,但在我看来,这并不那么有用。
让我们看看维基百科是如何定义这些术语的:
并发指的是系统通过同时执行或时间共享(上下文切换)来执行多个任务的能力。
并行计算是一种计算方式,其中许多计算或过程同时进行。
如果我告诉你,我们缺少一个术语来描述并发编程的另一个方面,而正因如此,我们集体错过了对软件生态系统产生负面影响的关键理解,你会怎么想?
好吧,我在标题中已经剧透了:缺失的术语是“异步”,但为什么?

两个文件
假设你需要保存两个文件,且顺序无关紧要:
io.async(saveFileA, .{io});
io.async(saveFileB, .{io});
A可以先于B保存,也可以B先于A保存,这样都没问题。你也可以先写入A的一部分,再写入B的一部分,然后再回到A,最后完成B的写入。这样做也是正确的,事实上,当使用事件驱动的I/O并行保存足够复杂的文件时,这种情况往往会发生。
但最重要的是,先完成一个文件的保存工作,然后再开始保存第二个文件,这种做法完全合法。虽然这可能不是最高效的做法,但代码仍然是正确的。
两个套接字
现在让我们看看另一个例子:假设你需要创建一个TCP服务器,并从同一程序内部连接到它。
// assume that server.listen has already been called
io.async(Server.accept, .{server, io});
io.async(Client.connect, .{client, io});
与之前一样,顺序并不重要:客户端可以在服务器开始接受连接之前建立连接(操作系统会在 meantime 缓冲客户端请求),或者服务器可以先开始接受连接,然后等待一段时间再看到传入连接。
与之前不同,两个任务的执行必须重叠。
在文件示例中,先完成 A 的所有工作再完成 B 的所有工作是可以的,但在这种情况下不行,因为服务器需要在客户端尝试连接时保持活跃状态。
异步、并发、并行
在日常用语中,我们会将上述两个代码片段都描述为“并发”,但这会丢失一些细节,因此我提出以下对这些术语的定义:
异步:任务可以以非顺序方式执行且仍保持正确性的可能性。
并发:系统同时处理多个任务的能力,无论是通过并行还是任务切换实现。
并行:系统在物理层面上同时执行多个任务的能力。
有了这些定义,我们可以更准确地描述之前的两个代码片段:两个脚本都体现了异步,但第二个脚本需要并发。
为什么要这样做?
好,现在我们能更精准地描述代码片段,那我们获得了什么?
为了强调效果,让我反问:由于对异步与并发差异认识不足,我们究竟失去了什么?
由于缺乏理解:
我们创建了语言生态系统,其中库作者必须重复努力(例如 redis-py 与 asyncio-redis) 或 更糟糕。
我们为库用户创造了更糟糕的体验(https://bitbashing.io/async-rust.html),其中异步代码具有传染性,甚至一个包含异步代码的依赖项都会迫使用户放弃编写正常同步代码的能力。
为了缓解所有这些问题,我们创建了不道德的逃生通道,这些通道在最佳情况下会导致次优行为,在最坏情况下会导致死锁。
让我们回到正面回答这个问题。
在 Zig 中,异步不是并发
我已经写了一篇关于 Zig 的新异步 I/O 的博客文章,但只在结尾简要提到了这一方面,这促使我在本文中进一步展开。
在 Zig 中,异步并非并发,因为使用 io.async
并不意味着并发。换句话说:使用 io.async
的代码可以以单线程阻塞模式运行。
让我们再次看看文件示例:
io.async(saveFileA, .{io});
io.async(saveFileB, .{io});
当以单线程阻塞模式运行时,上述代码将等同于以下形式:
saveFileA(io);
saveFileB(io);
不难想象这种实现方式,io.async
只需立即执行给定函数(而非为其创建新线程或进行任何形式的任务切换)。
这意味着库作者可以在代码中使用 io.async
,而无需强迫用户放弃单线程阻塞 I/O。
相反,不使用 io.async
的代码仍可利用并发。但这样做不会导致死锁吗?
答案是这是一个表述不当的问题。使同步代码在并发环境中表现良好的因素有两点(除了简单的多线程):
- 使用事件驱动的 I/O 系统调用(如 io_uring、epoll、kequeue 等)而非阻塞式调用。
- 使用任务切换原语,以便在操作系统执行 I/O 操作时继续处理其他任务。
这两点都不是表面上可见的(因此查看同步代码通常无法提供太多信息),尤其是关于第二点,async
并不是任务切换原语,因为它只关注异步,而非并发(而任务切换——根据我上述的定义——是并发特有的概念)。
任务切换原语通常称为yield
,让我们看看在绿色线程中它是如何工作的:
再次使用同步代码示例,将其包裹在一个函数中以增加清晰度,并传递要写入的两个文件的名称。这一最后的改动将在稍后派上用场。
fn saveData(
io: Io,
nameA: []const u8,
nameB: []const u8
) !void {
try saveFileA(io, nameA);
try saveFileB(io, nameB);
}
当我们执行 saveData
时,会调用 saveFileA
,而 saveFileA
将在某个时刻调用一个函数来向文件写入字节。在 Zig 的设计中,这是通过使用 io
参数实现的,但实现方式多种多样。关键在于,最终会到达一个针对绿色线程执行模型实现的 write
函数。
write
函数将请求对文件执行写入操作,然后,在操作执行期间,系统调用不会阻塞,而是立即返回(在 io_uring 的情况下,这甚至不是系统调用,只是对环形缓冲区的内存写入)。
此时写入操作已提交,我们的程序需要切换到其他任务,而该任务则等待操作完成。换句话说,我们需要进行让步。
在绿色线程的情况下,让出是通过栈交换实现的。我们在内存中的某个位置保存 CPU 中所有通用寄存器的状态(包括程序计数器和栈指针),然后从内存中加载另一个“快照”到 CPU(同样包括程序计数器和栈指针,现在它们指向可执行文件中不同部分的机器码,以及内存中不同的栈)。
我们加载的快照是事件循环之前使用相同技术保存的,当时事件循环让出控制权以恢复一个由操作系统通知为已准备好恢复的任务。现在我们切换回事件循环,同样的情况会再次发生。
虽然我对栈交换进行了详细描述,但需要指出的是,这与操作系统在 CPU 核心上调度线程的方式大致相同。如果你想看到栈切换的具体示例,我已经在 Twitch 上用一个精简的 RISCv32 内核实现了它(https://github.com/kristoff-it/kristos/)。
我不会深入探讨无栈协程的实现细节,但核心原理完全相同:设计一个让步原语,使你能够切换任务。这里你可以看到Zig的最新提案,其中无栈任务切换原语历来通过suspend
和resume
实现。
现在任务切换已明确,让我们回到事件循环。如果 saveData
以同步方式编写,那么在等待事件驱动的 I/O 完成时,事件循环能做什么?
答案是这取决于程序的其他部分。并发需要利用异步,如果没有异步,那么就无法同时执行多个任务,例如在这个例子中:
pub fn main() !void {
const io = newGreenThreadsIo();
try saveData(io, "a.txt", "b.txt");
}
但 saveData
没有表达异步并不妨碍程序的其他部分表达异步:
pub fn main() !void {
const io = newGreenThreadsIo();
io.async(saveData, .{io, "a", "b"});
io.async(saveData, .{io, "c", "d");
}
在这种情况下,对 saveData
的两次不同调用可以并发调度,因为它们彼此之间是异步的,而它们内部没有异步并不影响执行模型。
这使得正常同步代码和异步代码可以在同一个程序中并发运行而不会出现任何问题。无需复制库代码,也无需用户接受“ Faustian bargains ”来使用“异步库”(请注意,在我们新的理解下,这种术语是没有意义的,现在它只是“一个库”!)。
如果这个结果让你感到意外,那可能是因为你习惯了异步操作与无栈协程绑定,这通常会导致 async
和 await
关键字在代码中像病毒一样蔓延。但以 Go 语言为例,它并不存在这个问题:大多数代码是同步的,而 Go 却能并发运行 goroutines(因为所有 I/O 都是事件驱动的,而且 Go 能够进行任务切换)。
我们终于可以看看最后一个例子来完成我们的理解!
并发作为要求
为了方便起见,以下是开头介绍的定义的副本:
异步:任务可以以非顺序方式运行且仍保持正确性的可能性。
并发:系统同时处理多个任务的能力,无论是通过并行处理还是任务切换。
并行处理:系统在物理层面上同时执行多个任务的能力。
现在让我们再次看看客户端-服务器示例:
// assume that server.listen has already been called
io.async(Server.accept, .{server, io});
io.async(Client.connect, .{client, io});
如前所述,这种情况与saveData
不同。这里Server.accept
和Client.connect
需要并发,因为阻塞在Server.accept
上将阻止Client.connect
执行。
不幸的是,这段代码没有表达这一要求,这就是为什么我在关于Zig新异步I/O的帖子中将此示例称为编程错误。
在 Zig 中,你将这样解决它:
try io.asyncConcurrent(Server.accept, .{server, io});
io.async(Client.connect, .{client, io});
asyncConcurrent
保证 Server.accept
将与代码的其余部分并发执行。这在代码中明确了并发是正确性的必要条件,当尝试在非并发 Io
实现上运行程序时,程序也会报错。但这还不是全部!
你注意到 io.async
不用加 try
了吗?
假设我们使用一个为每个异步任务创建新操作系统线程的 Io
实现运行程序。如果无法抛出错误,当同时活跃的线程过多时会发生什么?会直接崩溃吗?
不会,它会直接运行函数!
这是当前使用绿色线程的 Io
实现中的一个片段,其中类似的概念适用:每个绿色线程(在实现中称为 Fiber
)都需要在内存中分配,如果分配失败,函数将立即运行:
const fiber = Fiber.allocate(event_loop) catch {
// The next line runs the function
// passed as an argument to io.async
start(context.ptr, result.ptr);
return null;
};
为了再次强调这一点:这是合法的做法,因为异步并不意味着并发。io.asyncConcurrent
确实保证了并发,因此它必须是一个可能失败的函数。
在跳转到最后的结论部分之前,我想指出,上述代码片段都是现实的,但为了简洁起见,我省略了错误处理和等待异步调用返回的未来值的代码。阅读我的另一篇博客文章以查看完整的代码片段,尽管这并非理解本文的必要条件。
结论
首先,我希望已说服您异步并非并发。
其次,我希望给您带来希望,即我们可以摆脱当前困扰大多数实现的异步/等待局部最优解,代码无需重复,且异步和同步代码可以在同一代码库中无任何妥协地共存。
最后,我希望已让您对Zig中的异步I/O工作原理有了直观理解。
若您想提前一窥即将推出的Zig异步I/O重构方案,2025年7月21日星期一晚上7点CEST,我将在Twitch直播(https://twitch.tv/kristoff_it) (更多时区和信息请见此处)与Andrew一起阅读线程池实现、绿色线程实现,并亲自编写一个非并发版本的Io
,以实时测试本文中提到的所有内容是否真实有效。
剧透:我已经尝试过,一切都正常工作。欢迎来到我们一直期待的Future
。
本文文字及图片出自 Asynchrony is not Concurrency
定义异步操作并不容易。而我正是参与设计 JavaScript 中异步操作的众多人之一。
我并不完全认同这篇帖子中的定义:仅仅因为它是异步的,并不意味着它是正确的。无论是否使用
async
/await
(在需要/支持它的语言中),异步代码都可能引发各种用户空间竞态条件。我最新的表述(我认为它仍需完善)是:异步意味着代码是明确为并发而设计的。
我最近就这个话题写了一些内容:https://yoric.github.io/post/quite-a-few-words-about-async/ 。
对我来说,关键在于区分异步性的抽象概念及其具体实现方式,后者既包括编程语言层面的抽象实现,也包括机器层面的技术协调手段。在最高抽象层面上,异步性本质上是同步性的对立面:两个(或多个)需要协作的实体(即一方对另一方存在依赖关系,某些事情必须在另一方继续之前发生)处于非同步状态,这意味着无法确定或未定义在某件事之后需要发生的事情何时完成。从这个角度看,这一定义并不复杂。困难之处可能在于语言中设计的抽象机制:理解和/或使用它们(以无错误的方式)所需的认知努力。
基本同意:异步意味着代码以一种结构化方式编写,唯一能确定任务完成的方式是进行某种会合。由此扩展,它涵盖了在代码中实现这一过程的机制。
我对这个问题了解不深,但我会给出这样的答案:异步代码是将原本会阻塞的代码改为非阻塞方式,以便在执行过程中其他操作仍可继续进行。
由于我在嵌入式循环中工作时,长时间阻塞的代码片段可能会直接导致I/O中断、出现可见/可听的掉帧等严重问题,因此这显然是合理的答案。
但关键在于:异步本身并不能保证任何操作都是非阻塞的。要让你的纤维(或其他用户空间抽象)成为非阻塞的,你必须确保它不会执行任何阻塞调用。
异步所做的只是为你提供(部分)工具,使代码成为非阻塞的。
是的,当然,在嵌入式系统中,任何异步代码片段都可能执行多种操作,比如触发一个延迟命令,让整个处理器进入休眠状态。
这可能通过足够智能的编译器或运行时环境来避免,但我并不确定这样做是否真的有益。
我倾向于让事情明确化,因此人们对异步的理解与现实越一致越好。或者,我们应该首先明确异步操作“应该是什么”,然后调整抽象层,使其提供人们自然会假设的保证。
是的,我坚持这一点,因为我最近审查了一个包含异步代码调用阻塞代码的PR,这使得整个努力变得毫无意义。而这还是来自一位有经验的开发者。
过去曾有少数编译器使用静态分析来预测调用成本(其中 I/O 成本被视为无限大),并且你可以强制要求一个分支的预算仅为 N。现代架构倾向于破坏任何有限的 N 值,但你可以相对容易地适应这些技术来检测无限值。
整个目的可能是将阻塞调用卸载到后台,并在阻塞期间执行其他任务。
有一种“异步编程”风格,其中一切都设计为非阻塞的,同时也可以存在包含阻塞代码的异步编程。事实上,第一种风格可以通过将每个阻塞调用卸载到不同的线程/绿线程/纤维来模拟,而这基本上就是底层发生的事情,除非在更底层(有时甚至到底层硬件)有对非阻塞的根本支持。
你可以异步地从其他代码中运行阻塞代码。我认为父级的意思是,如果你在一个线程上有一个阻塞操作(例如函数调用),你可以创建另一个线程并在那里运行该阻塞操作,神奇地将一个阻塞调用转换为看起来像非阻塞调用的东西(从而允许其他代码做其他事情而不是等待)
在 Python asyncio 中哭泣
表示同情
如果从积极的一面来看,自由线程似乎即将到来,而OCaml已经展示了如何通过移除全局解释器锁(GIL)并添加一个原始操作,将一种足够强大的语言转变为并发/并行计算的强大工具,且几乎不需要用户可见的更改!
我甚至不确定我们是否应该定义异步
这可能很难(确实很难),因为它无法与某一特定事物对应
问题是:定义异步或事件循环是否有意义?在物理芯片领域,一定存在大量我完全不了解的概念,正是这些概念使得真正的并行计算成为可能
我完全接受“用户指尖”和“快速任务”、任务队列以及阻塞或非阻塞API
“指尖”象征着触摸事件、鼠标点击、键盘输入等用户主动触发的事件,我需要将这些事件与“快速任务”匹配,这些任务是执行时间极短的阻塞(!)任务,将由浏览器进行队列处理
为了实现我的目标,我更倾向于使用非阻塞API,因为我可以将一些耗时的任务交给底层系统,而我只需编写一个快速任务来实现我想要的功能(例如将数据存储到索引数据库中),并处理成功或失败等情况 (不同的快速任务)
同步、异步对我来说并没有太大帮助,当然我需要理解他人讨论时或看到、使用(或我偏好的AI编码器)异步时的情况,但它本质上只是指非阻塞API
但再次强调,异步编程模型实际上是在编写大量阻塞式快速实现,其中非阻塞特性体现在阻塞任务的执行时间上具有原子性,我试图将这些任务与由手指、浏览器或其他触发器引发的混沌、非确定性事件相匹配
我其实不在乎,只是希望浏览器代码能采用优秀的并发模型(如C++或Rust等),设备具备多个执行单元(操作系统线程),而操作系统调度器能高效管理资源,无论可用执行单元是多个还是仅有一个
异步对我来说是一个定义不明确的概念,即使它被定义了,我也不确定它对我是否有用。
有用的概念是事件、我在 JavaScript 中编写的任务的阻塞性质、我的函数所看到的(闭包吧)、如果我使用 API 时作为任务运行的内容,以及在浏览器触发事件(就绪、错误等)后作为不同任务运行的内容。
甚至“回调”这个名称也极具迷惑性,因为我当时真的以为代码会在那里停止并等待回调……不,它会运行到该段落的结尾,你必须真正理解在“回调”时其他什么会运行,以及该代码看到什么
说实话,我认为这既是一团糟,又是天才之作…… 但理解“异步”或更准确地说,理解这个模型非常困难,因为我认为这根本没有意义
实际上,用不同的概念来理解,比如事件、阻塞任务、任务队列、非阻塞API,就简单多了
我认为了解我们正在做什么以及其他人(如浏览器代码、操作系统等)在做什么也非常重要……这有点像C++代码声明一个并发模型并使用线程,但操作系统会决定……在JavaScript中,我们使用非阻塞API,这隐式声明了一个可能的并发模型,浏览器或Node或其他系统应该使用,我确信它们总是这样做
最重要的是保持任务快速执行,最好在30-50毫秒内完成。非阻塞API非常出色,因为任务只需声明意图即可完成。而C++、Rust等语言会明确告知操作系统希望任务并发执行,因此即使操作系统只有一个物理线程,界面仍能保持响应,因为操作系统会在界面代码执行与“实际任务”(如网络操作或数据库操作)执行之间切换。
但一个“异步”程序员需要做的就是创建一个优秀的用户体验模型,并将事件与快速任务匹配
我不太确定你的意思。
如果你写的是你不需要理解浏览器的工作原理,只要让事情足够快……好吧,当然可以。
但任何希望提升理解层次的人,都需要更深入地了解底层机制。
我认为作者只是将“让出执行权”的概念从并发性的定义中抽离出来,纳入了这个新的“异步”术语。然后他们认为这个术语是必要的,因为没有它,整个并发性概念就会崩溃。
确实如此,但我认为,如果没有让步的能力,并发性就毫无意义,因此它本质上是并发性的一部分。这是一个非常重要的概念,但将其拆分为一个新术语只会增加混淆,而不是减少它。
我认为纯粹的一对一并行性是一种不涉及任何让步的并发形式。但除此之外,我同意所有非并行并发形式都必须以某种节奏进行让步执行,即使是在指令级别。(例如,在CUDA中,波束中分支的线程会交错执行其指令,以防一个分支尝试阻塞另一个分支。)
>我认为作者只是将“让步执行”的概念从并发性的定义中抽离出来,并将其纳入这个新的“异步性”术语中。
文章中明确相反的表述:
>(而任务切换——根据我上述的定义——是并发性特有的概念)
我实在难以理解这篇文章想表达什么。经过第三次和第四次阅读,我认为他们可能指的是任务(依赖性)跟踪是一个基本概念。独立任务具有“异步性”。(我们能否直接说依赖性和独立性?)
但即使采用这个定义,承诺、任务跟踪等概念似乎已是老生常谈。
然后他们得出结论,称“火与忘”任务能解决着色问题,但这不就是“同步覆盖异步”的反模式吗?我不会对UI工作因没有绿色线程而暂停运行某事感到兴奋,但他们似乎对此很兴奋。
不管怎样,我猜我被文章中“这是思维方式的根本性变革”这类高概念的空洞说辞分散了注意力。
并发并不意味着让步…
同步逻辑确实意味着某种同步,让步可能是同步的一种方式——这大概就是你所指的意思。
异步逻辑是无需同步或让步的并发。
并发和异步逻辑在冯·诺伊曼机器中并不以真实形式存在
你能详细说明你的意思吗?
在此上下文中,异步是一种抽象,它将请求的准备和提交与结果的收集分离。
这种抽象使得可以提交多个请求,然后再开始查询它们的结果。
这种抽象允许但不要求并发实现。
然而,抽象背后的意图是实现并发。动机是获得某些好处,而这些好处在没有并发的情况下无法实现。
一些异步抽象无法在没有并发的情况下实现。假设请求者获知请求完成的方式不是对完成队列的阻塞请求,而是回调。
现在,是的,回调可以在请求线程的上下文中发出,因此一切都是单线程的。但如果请求线程持有非递归互斥锁,这种欺骗行为将通过引发死锁暴露出来。
换句话说,我们可以有一个异步请求抽象,它绝对无法在单线程环境下工作;
1 调用者锁定互斥锁
2 调用者提交请求
3 调用者解锁互斥锁
4 完成回调发生
如果步骤 2 在同一线程中生成回调,那么步骤 3 将永远不会被执行。
实现必须使用某种最小并发性,以便有一个线程等待步骤 3,同时允许请求者到达该步骤。
完全同意。帖子中的服务器/客户端示例只是程序无法继续进行的一个例子,你刚刚给出了另一个无法以相同方式解决的例子,我敢打赌随着时间的推移,他们还会发现更多这样的例子。依我之见,当使用异步时,必须确保并发性。
这取决于 API。如果获取异步调用结果的唯一方式是通过完成队列进行同步操作,那么除了性能问题外,单线程实现不会出现故障。
然而,我们可以争论说,如果只有同步操作来收集结果,那么它就不是真正的异步。异步应该意味着不仅可以发起请求而不等待结果,而且完成可以在任何时候发生。
新的 Zig I/O 理念似乎是个相当巧妙的想法,如果你主要编写应用程序且不需要无栈协程。我怀疑使用这种风格编写库会相当容易出错,因为库作者无法确定提供的 I/O 是单线程还是多线程,是否使用事件驱动 I/O 等等。即使你对所使用的 I/O 栈有完美的了解,编写并发/异步/并行/等等代码本身就已经足够困难。在这里,库作者将完全依赖于外部提供的 I/O 实现。而且,由于 I/O 接口看起来像是一个真正的“万能工具箱”,本质上是一个“小型操作系统”的实现,因此可能非常难以测试所有潜在的交互和行为组合。我不确定接口提供的几个异步原语在实际中是否足以应对所有可能遇到的特殊边界情况。为了支持多种I/O实现,我认为代码必须非常谨慎,并基本上假设将使用最并行/并发版本的I/O。
我认为,将无栈协程与这种方法结合起来也将相当困难,尤其是如果你想避免不必要的协程创建,因为提供的原语似乎不允许显式地轮询协程(即使它们允许,大多数人可能也不会费心去写这样的代码,因为这本质上会使代码看起来像“正常”的异步/ await 代码,而不是像 Go 那样有隐式 yield 点)。结合动态分派,似乎 Zig 在语言设计上走得更高层次。最终可能是个不错的选择。
在这种方法尚未在实际环境中经过验证的情况下,称其为“毫无妥协”确实相当大胆——或许在更广泛的生态系统中使用 1-2 年后才能如此断言。时间会证明一切 🙂
> 我认为将无栈协程与这种方法结合使用也将相当困难
可能会有意想不到的问题,但他们承诺将提供无栈协程;因为这对于 WASM 目标是必要的,而他们已承诺支持该目标。
> 结合动态分派
动态分派仅在你的程序使用多个IO实现时才会被使用。对于常见情况,即你仅使用单一IO实现时,动态分派将被直接调用取代。
> 在尚未在实际环境中验证的情况下,称这种方法“毫无妥协”确实相当大胆。
你说得对。虽然这似乎与“Jai”声称取得成功的方法非常接近(尽管是隐式IO上下文,而非显式传递的上下文)。但是否能将其视为在实际环境中应用也值得商榷……
> 我认为代码必须非常谨慎,并本质上假设使用的是最并行/并发版本的IO。
完全正确,但当目标是同时支持同步和异步执行时,为什么有人会持不同看法?
然而,如果在IO事件处理器的底层实现了良好的异步性,那么遵循这些原则在任何地方实现都应简单——最坏的情况是你的代码顺序执行(因此较慢),但不会遇到竞争条件或死锁。
因此,“协作式多任务处理并非抢占式多任务处理”。
“异步”一词的典型用法指的是,该语言是单线程的,采用协作式多任务处理(包含让步点)和事件驱动模式,外部计算可并发执行而非阻塞,并以事件形式报告结果。
在多线程或并发执行模型中,异步性没有意义,你可以使用阻塞 I/O,同时程序在该执行线程被阻塞时仍能继续执行。此时无需显式指定让步点。
虽然这是最常见的用法,但我将以Rust(或C#、F#、OCaml 5+)为例,这些语言同时支持操作系统线程和异步。操作系统线程适用于CPU密集型任务,异步适用于I/O密集型任务。
异步(或 Go 风格的 M:N 调度)的主要优势在于,只要你有足够的内存,就可以启动任意数量的任务/纤维/goroutine/…。如果你使用操作系统线程,你需要灵活地池化它们,以避免因上下文切换过多而堵塞CPU,耗尽操作系统线程,耗尽内存等。——这并非不可能,但如果你做的不只是I/O,你可能会遇到有趣的死锁。
> 异步(或 Go 风格的 M:N 调度)的主要优势在于,你可以随意启动任意数量的任务/纤维/goroutine/…
有人认为解决此问题的真正方法是“仅仅”修复操作系统线程。据传谷歌已采取此措施,但对此保密:
https://www.youtube.com/watch?v=KXuZi9aeGTw
https://lwn.net/Articles/879398/
与之相关且同样由谷歌推出的还有 WebAssembly 承诺集成,该技术可将阻塞代码转换为非阻塞代码,且无需语言支持:
https://v8.dev/blog/jspi
我预见未来“async/await”概念可能在非核心应用场景中逐渐淡出。
我认为 PI 会有一定作用,但怀疑它可能以意想不到的方式迅速破坏 wasm 代码的性能。
至于修复操作系统线程,这确实可能改变生态系统,但许多开发者期望代码具备跨平台性,因此可能需要一段时间才能出现适用于所有环境的解决方案。
我认为无需使用两个库是个好主意——因此,任何允许这样做的想法都值得我支持。
我对异步代码最困扰的一点是,如何测试它才能有把握地确保:如果今天通过了测试,就意味着已经覆盖了生产环境中可能出现的全部场景/顺序。
当然,线程也存在同样的问题,而我一直觉得多线程程序更难编写和调试……因此我个人只在不得不使用时才会用到线程。
真正的问题在于,如何向开发人员传达这种谨慎态度。我最近不得不参与一个Python系统项目,其中开发人员显然有一半时间都在做JavaScript。所以……太棒了……他们推出了一大堆更改,让系统变得异步……并且多线程化。奇怪的是,他们中没有人听说过GIL,当我向他们解释时,他们一脸茫然,我感觉自己像个令人讨厌的老家伙。无所谓。线程是好的。然后我指出,他们的测试现在总是通过,无论他们是否破坏了代码。茫然的眼神。他们没有意识到,Mangum强制所有后台任务和异步操作在HTTP请求结束时完成,因此他们试图将处理转移到后台以加快响应速度的努力是徒劳的。
知道事情并不总是重要,如果你无法让其他人看到它们。
我们计划在Zig中实现一个测试`Io`库,该库可能使用模糊测试在并发执行模型下对代码进行压力测试。
不过,我认为关键在于,我们预计大多数现有库代码都不会调用`io.async`或`io.asyncConcurrent`。例如,大多数数据库库并不需要这些功能,仍将包含简单的同步代码。但这些代码仍可被应用程序开发者用于在更高层次上表达异步操作:
这比在代码中到处散落async/await语句要少出错且更易理解。
比“模糊测试”更强大的是确定性测试。即,你可以确定性地向前推进各种并发分支,以证明各种竞争条件已被安全处理。这使得捕获所有“如果线程 A 先执行这行代码,然后 B 被执行”等情况成为可能。这是大多数并发框架中缺失的功能。
这很有道理。测试异步和多线程代码的所有可能交织情况向来困难。即使使用高级模糊测试器或并发测试框架,也罕有在不经历痛苦的生产环境学习的情况下获得完全信心。
在分布式系统中,情况更糟。例如,在设计 webhook 交付基础设施时,你不仅要处理服务内部的异步代码,还要应对跨系统的网络重试、超时和部分故障。我们在构建可靠的 webhook 管道时遇到了这个问题;在高并发环境下确保重试、去重和幂等性本身就成了一个完整的工程问题。
这就是为什么许多团队现在将此任务交给专门的服务,如Vartiq.com(我在此工作),它提供保证的webhook交付,自动重试和可观察性。它不会消除你自己代码中的异步测试问题,但通过抽象掉一部分运营并发复杂性,它减少了影响范围。
完全同意——异步、线程和分布式并发会相互放大风险。沟通和系统设计上的谨慎比任何语法或库选择都更重要。
> 这就是为什么许多团队现在将此任务交给专门的服务,如Vartiq.com
最好添加一个免责声明,说明这是您正在开发的系统。
感谢指出。已修改。 🙂
我认为作者对并发性的定义存在混淆。
https://lamport.azurewebsites.net/pubs/time-clocks.pdf
你能详细解释一下,而不是直接链接论文吗?我觉得这些定义已经挺清楚了。
> 异步性:任务可以以非顺序方式执行且仍保持正确性的可能性。
> 并发性:系统同时处理多个任务的能力,无论是通过并行执行还是任务切换实现。
> 并行性:系统在物理层面上同时执行多个任务的能力。
它们与Lamport最初提出的概念有所不同。异步性的定义大致相当于Lamport对分布式系统作为部分有序系统的描述,即某些事件对无法确定其发生顺序。
文章中对并发性的定义似乎存在一个问题,即所有并发系统都不会发生死锁,因为根据定义,所有并发系统都能执行任务。拉姆波特(Lamport)使用“并发性”一词指代其他概念:“如果两个事件彼此之间无法产生因果影响,则它们是并发的。”
作者在“两个文件”示例中提到的“(非)因果关系”概念,可能就是他所指的内容:保存两个文件时,顺序并不重要。如果代码改为“保存文件A;读取文件A的内容”,那么与客户端连接/服务器接受的例子类似,根据拉姆波特的术语,“保存”语句和“读取”语句将不具有并发性,因为“保存”会因果性地影响“读取”。
只是两个任务之间的因果关系与这些任务在软件模型中如何组合在一起是不同的概念,这与这些任务在裸机上如何物理协调也是不同的概念,也与事件的顺序不同。
异步性的定义有问题。异步请求可以保证顺序,即如果一个线程以特定顺序异步提交两个请求A和B,它们将按该顺序发生。
异步性意味着请求方在提交请求时不会被阻塞,无需等待该请求的结果。
异步抽象可能提供一种同步方式来等待异步提交的结果。
> 异步性的定义有问题。异步请求可以保证顺序,即如果一个线程以特定顺序异步提交两个请求 A 和 B,它们将按该顺序执行。
确实有可能——两个异步任务可以像使用`Promise.then()`等方法一样按顺序绑定在一起。…
但这并非必然发生,因此称为“部分顺序”,且存在“任务可能以非顺序方式执行的可能性”。
例如 –
a.then(b)
可能将任务a
和b
异步绑定在一起,使得a
先执行,然后b
执行——但在a
执行之后、b
执行之前,可能会有其他异步任务插入在a
和b
之间。`a`、`b` 以及这些插入事件之间的顺序完全未定义,因此我们有一个偏序关系,其中我们可以将 `a` 和 `b` 按顺序绑定在一起,但对这两个事件相对于运行时管理的其他所有异步任务的顺序一无所知。
我的意思是,这种顺序在设计上被保证为可能的;即针对某个 API 对象发起的异步操作将按提交顺序执行,类似于 FIFO 队列。
我不是指“promise.then”,即下一个请求的发起取决于第一个请求的完成。
一个例子可能是对文件的异步写入。如果我们在第一个请求中在文件开头写入“abc”,在第二个请求中从第二个字节开始写入“123”,可以保证结果将是“a123”,而不是“abc2”,而无需等待第一个请求完成后再启动另一个请求。
异步并不意味着无序;它意味着请求发起者不会将请求的完成视为单一操作进行同步。
并发性是程序可被划分为部分有序或完全无序的执行单元的能力。它不描述最终如何执行程序,例如是否利用这些特性进行并行执行或任务切换。或者你可能在单线程上运行且不进行任务切换或并行处理。
如需更多信息,可查阅Rob Pike关于Go并发性的讨论。
文章理解了这一点。
> 并发性:系统同时处理多个任务的能力,无论是通过并行执行还是任务切换实现。
可以,但不要采用这个定义。
问题出在“同时”这个词上吗?如果改成“在给定时间内”会怎样?
对于单线程程序,无论是JS的事件循环、Racket的协作线程,还是类似机制,只要Δt足够小,就只会看到一个任务在执行。
并发性是并行性和/或异步性的集合,即两者的超集。
异步性意味着事件以非顺序、交错、中断或抢占等方式发生,但仍可能以顺序方式一次只处理一件事。
并行性意味着物理时间消耗小于总时间消耗之和,因为事件同时发生。
异步性在实践中也暗示着非确定性,但单线程并发程序并不一定必须表现出非确定性行为。
注意:在许多编程场景中,并行性和并发性是互斥的概念,有时它们还被归类到异步(async)的范畴下,而异步是一个适用于不同领域的术语。
在其他场景中,这些术语并不描述互斥的事物集合,因此在讨论软件时,明确界定术语至关重要。
是的,并发性不等于并行性。https://go.dev/blog/waza-talk
这与我的陈述一致:并非所有并发都是并行,但所有并行都是并发。
人们所说的“并发不是并行”是指它们是不同的问题。并发问题是定义一个应用程序,使其具有与程序其他部分没有因果关联的部分。并行性问题则是实际同时运行程序多个部分的调度逻辑。如果我编写了一个结构良好的并发系统,就不需要知道或关心系统中两个特定部分是否正在并行执行。
在拥有良好分布式系统架构的生态系统中,这在实践中表现为:并发性是应用程序开发者的问题,而并行性则是调度器设计者的问题。
SIMD是无并发性的并行执行。
是的,有些人混淆了并发性和异步性的含义。但几乎所有异步实现都使用主事件循环、全局解释器锁、协程等,因此最终只能一次执行一件事。
因此我认为这个定义在实际应用中最有意义。将并发定义为超集是一个有用的概念,因为在两种情况下都需要处理相同的问题。而区分异步和并行是有意义的,因为它改变了延迟与能耗的权衡(如果带宽是固定的)。
> 异步性:任务可以以非顺序方式运行且仍保持正确性。
异步性指事件或过程不发生在同一时间或同一阶段,即与同步性相反。它描述了时间上的缺乏协调或并发性,通常一个事件或过程独立于另一个发生。
正确性陈述没有帮助。当事情以异步方式发生时,你无法保证顺序,这可能与“程序的正确性”相关。
> 正确性陈述没有帮助
但……这就是全部,也是为什么它被包含在内。
异步计算中的未定义行为不值得研究或投资,除非是为了避免它。
过去几十年(从超标量处理器到Map/Reduce算法和Nvidia架构)的大部分努力都涉及使非SSE操作正确。
因此,作为一个脱离当今计算上下文的抽象术语,异步性并不保证正确性——这就是困难所在。但我们关心的唯一异步计算形式会提供某种正确性保证(通常是新类型,例如“最终一致性”)。
根据Lamport的观点,“同时执行多个任务”这一表述是模糊的,因为你依赖的是谁的时钟。
对于Lamport来说,并发并不意味着我们日常或非正式意义上的并发(比如“与此同时”)。Lamport的正式定义中,并发仅与顺序相关。如果一个任务依赖于或受另一个任务影响,那么第一个任务的顺序在第二个任务之后。否则,它们被视为“并发”,即使一个任务发生在数年之后或之前。
> 异步性:任务可以以非顺序方式运行且仍保持正确性的可能性。
我们不能直接称之为“独立”吗?
不完全是。可能存在因果关系。
你能举个例子吗?
锁、调度等机制会引入某种同步性,从而产生某种顺序。但这种同步性是由系统强制执行的,而非必需的机制。
但如果我在一台机器上运行“ls”命令,而另一用户在同一台机器上运行“ls”命令,难道你不认为它们是独立的,尽管操作系统在底层使用了各种锁和其他机制?
这不是原帖,但在正式定义中,如通信顺序进程(CSP),并发意味着任务可以以非顺序方式运行且仍保持正确性,只要其他同步事件发生
并发意味着异步(两个系统可能在不等待对方的情况下同时执行工作),但反之则不成立。
单个进程可以以无序(异步)的方式执行工作。
并行性意味着并发性,但不意味着异步性。
多个任务同时执行难道不是同时性的吗?
我认为这里需要一个更严格的定义。
并发性是指系统将一个任务分解为多个微小任务的能力。这种能力的副作用是,如果系统将所有任务分解为微小任务并以一种随机的方式运行它们,看起来就像是并行处理。
这就是为什么我完全停止使用这个术语,我所接触的每个人似乎对它的理解都不同。它不再对沟通有任何作用。
作者清楚自己博客中使用的术语已有既定定义。他提议对这些定义进行修订。只要他对新定义保持精准,这无可厚非。是否采纳这些定义由读者自行决定。
他正在以与大多数文献和许多开发者不同的方式重新定义异步性,这种转变正在为特定的Zig API拆分进行修辞上的辩护。
不用了。
请记住,你无法用大多数语言表达Lamport论文中的一半概念。在创建线程时,你不会真正讨论全局和局部时钟排序。你只会在设计协议时在TLA+中这样做。
话虽如此,我同意我们不需要新术语来表达“Zig在异步API中有一个函数,当在非并发执行中运行时会抛出编译错误。Zig允许你这样做。”这样做无需提出新理论。
“异步性”是一个非常不恰当的术语,而我们已经有一个非常明确的数学术语:交换性。某些操作是交换的(顺序无关紧要:加法、乘法等),而其他操作是非交换的(顺序重要:减法、除法等)。
通常,代码中操作的顺序由行号表示(第一行发生在第二行之前,依此类推),但我明白在异步代码中这可能不再适用。因此,我的直觉告诉我,这最好通过(令人不快的)
.then(...)
范式来实现。虽然它很糟糕,但宁可选择已知的恶魔,也不要选择未知的恶魔。如代码所示,`asyncConcurrent(…)` 让人一头雾水,除非你背下这篇博客,否则根本无法理解这段代码的含义。我明白 Zig(就像我喜欢的 Rust 一样)在尝试各种新潮的东西,但一半时候这些东西只是让人感到不直观和困惑。要么以某种方式实现(基于异步的)交换性/操作顺序(比如 Rust 的生命周期?),要么就使用人们已经习惯的东西。
_> 目前,
asyncConcurrent(...)
让人一头雾水,除非你背下这篇博客,否则根本不知道这段代码是什么意思。我明白Zig(就像我非常喜欢的Rust一样)正在尝试各种新潮的东西,但一半的时候它们只是变得难以理解和令人困惑。要么以某种方式实现(基于异步的)交换性/操作顺序(比如Rust的生命周期?),要么就使用人们已经习惯的东西。我不同意。它之所以令人困惑,是因为你需要记住这篇博客文章,如果你能理解核心概念,它一点也不会令人困惑。问题是:值得去理解这个概念吗?我不知道,但我清楚的是,有些人会理解它并试图用这个概念做很多事情,过一段时间后,我们就能看到这条路会通向哪里。到那时,我们就能决定这是否是个好主意。
_> “异步性”是个非常糟糕的术语,我们已经有一个非常明确的数学术语:交换性。
使用“交换性”来描述这一点是有风险的。Zig 有运算符,其中一些是交换的。这会让人感到困惑。例如,如果我写 `f() + g()`。加法是交换的,那么 Zig 可以自由选择并行执行 f() 和 g()。执行顺序和交换性是两回事。可能可以通过交换/非交换运算符将它们结合成一个概念,但我不确定这是个好主意,而且我确信这与异步实验是完全不同的问题。
我不确定它们有那么大的区别,你也可以将函数调用存储在一些常量中,每个常量占一行,然后将结果相加到第三行。这只是语法上的区别,不是概念上的区别。在实际层面上,区别在于运算符可以直接与某些机器指令匹配,操作数是原生数据类型,如整数。
不过,你可能更倾向于使用“可交换性”或“可互换性”这样的术语。
严格来说,交换性是针对(二元)运算定义的——因此,如果有人说两个异步语句(如connect/accept)是可交换的,我不得不问:“在什么运算下?”
目前我对此问题的最佳答案是绑定运算符(>>=)(包括其一个实例
.then(...)
),但这只是模糊的直觉,如果算得上是直觉的话。这是一个很好的直觉。这一问题已被广泛研究,允许任意副作用但又能保证此类结果的组合规则正是(>>=)。我们可以继续尝试绕过这一规则,但最终还是要依赖bind。
交换运算(我认为所有交换运算?)都可以轻松推广为 n 元运算(事实上,我们通过 ∑ 和 ∏ 分别对加法和乘法进行这种推广)。你说的没错,这里涉及的“运算”概念确实有些模糊;但我敢打赌,它很可能属于增量运算的范畴(N++ === N + 1 = 1 + N),因为我们一直在评估下一行代码,就像图灵机的头部一样。
编辑:也许实际上是蕴含?因为前一行(或多行)逻辑上蕴含下一行。L_0 → L_1 → L_2 → L_n?不过这是非交换的。不确定,自从我上次上元逻辑课已经好几年了 😛
蕴含关系听起来更合适。在不进行进一步分析的情况下,按顺序执行每行代码是正确的(无论“顺序”如何由语言定义,假设为命令式语言)。
编译器可以识别出例如 L_2 不依赖于 L_1,因此可以自由地重新排列它们。编译器确实会根据操作的数据依赖性来识别这一点。
将一个结合律二元运算泛化为 n 元运算只需一个单位元素 Id(这并不总是显而易见,例如 Id_AND=true 但 Id_OR=false)。
> 将结合律二元运算推广为 n 元运算只需定义一个单位元素 Id(这并不总是显而易见的,例如 Id_AND=true 但 Id_OR=false)。
仅当 n = 0 时成立,我认为。否则,将结合律二元运算 f_2 推广为对所有正整数 n 的 f_n,可通过归纳法轻松实现:f_1(x) = x,且 f_{n + 1}(x_1, …, x_n, x_{n + 1}) = f_2(f_n(x_1, …, x_n), x_{n + 1}),无需提及恒等元。(事实上,即使 f_2 不是结合的,该定义仍有意义,但由于任意选择“左括号”,可能不太有用。)
恒等元是空操作/跳过
> “在什么运算下?”
你可以将分号视为一个运算符,就像矩阵乘法一样,它仅对一般类型的子集具有交换性。
没错,正是如此。有人说(>>=)是一个可编程的分号。
[0] https://news.ycombinator.com/item?id=21715426
或者使用回车符/换行符。
在此情况下,“操作符”指的是 CPU 执行 2 或 N 个过程(或函数)。
交换律是一种非常轻量级的模式,因此可正确应用于许多场景,且在任何操作层级均适用,只要上下文清晰。
在函数组合 `;` 中,左右两侧均被视为作用于整个环境状态的函数。
没错;不过这是一种特殊的函数组合(Kleisli组合),通常以不同形式呈现(bind、>>=)。
`.then()` 显得笨拙,`await` 则较为优雅,但保证交换性(在 JavaScript 中)的关键部分难道不是 `Promise.all([])` 这一部分吗?
异步性也允许部分排序。两个操作可能仍需以特定顺序退休,而无需按该顺序执行。
例如减法是非交换的。但你可以将余额和扣款作为两个独立的查询计算,然后按适当顺序应用结果。
> “异步性” 是一个非常不恰当的术语,我们已经有一个非常明确的数学术语:交换性。
我认为仅凭另一个术语定义了这个概念,就断定它更好或更差,是不够的。“交换性” 在我看来,听起来、读起来都像一团糟。异步性在语感上要好得多
交换性也不正确,因为1) 它包含的含义远不止时间顺序,2) 存在一些奇特的时序方案(例如以奇特的时间依赖方式交错多个异步/等待操作),这些方案无法用简单的数学交换性概念来描述。
交换性是一个更弱的命题,因为一个事件完全在另一个事件之前或之后发生。例如,AB可能与C交换,即ABC=CAB,但这并不意味着ACB也成立。在异步情况下,你保证ABC=ACB=CAB。(可能存在一个现有的数学术语来描述这一点,但我不知道它)
你可以从两项式证明三项式的交换性(我多年前做过,我记得大概是这样的[1]),所以顺序并不重要。
[1] https://math.stackexchange.com/questions/785576/prove-the-co…
我不是在谈论一个所有元素都可交换的宇宙,而是在谈论一种情况,即A、B和C不一定可交换,但(AB)和C可交换。对于一个严格的定义:给定半群G中的X和Y,如果对于任何有限分解X=Z_{a_1}Z_{a_2}… Z_{a_n}和Y=Z_{b_1}Z_{b_2}…Z_{b_m}(其中Z属于G),那么对于任何保持a’s和b’s顺序的排列c_1,…,c_{n+m} of a_1,…,a_n,b_1,…, b_m,使得a’s和b’s的顺序保持不变,则有XY=Z_{c_1}Z_{c_2}…Z_{c_{n+m}}。我提出以下论点:若G是交换群,则所有元素均为异步元素;但对于非交换群G,可能存在元素X和Y满足可交换(即XY=YX),但X和Y并非异步元素。
以具体例子说明,矩阵乘法通常不满足交换律(AB ≠ BA),但例如与单位矩阵的乘法满足交换律(AI = IA)。因此 AIB = ABI ≠ BAI。
或应用于编程示例,以下语句:
123 = 312 ≠ 321。
严格来说,这还需要满足结合律。
我同意。T 和 U 之间异步意味着至少可以将 T 和 U 分解为任务 t1、t2、t3、… tn 和 u1、u2、… un,使得它们可以以任何顺序交错执行,但通常我们仍然要求 t 任务以顺序方式执行。任务之间的划分点是它们放弃控制权的地方,例如在等待数据加载到内存中或进行网络调用时。
这仍然是我们所说的“彼此异步”的特殊情况,因为根据每个步骤的交错方式以及例如加载到内存中的数据,任务的数量可能会发生变化,但核心思想是它们最终仍会以正确状态终止。
> 直觉告诉我,这最好通过(令人不快的)`.then(…)`范式实现。虽然它很糟糕,但宁可选择已知的恶魔,也不要未知的恶魔。
`await`的整个设计理念是让旧有的直觉在不使用`.then()`的丑陋方式下生效。`f(); await g(); h()` 具有完全预期的执行顺序。
可以确认。
在 JS 中,我们专门设计了 `await` 来隐藏 `.then()`,就像我们设计 `.then()` 因为回调函数使得跟踪控制流(特别是错误)变得过于复杂。
使用 await 有什么好处?我能参考哪些资料来了解这一点?
好吧,考虑以下两者的区别:
与
即使是这个简单案例,我认为前者清晰得多。再看一个更复杂的案例:
现在尝试用then()重写并比较差异。
对于第一个示例,感觉只是行分隔方式的问题。当然,匿名函数语法也是一个因素,但
后者更公平,以下是一个可能的解决方案:
或者使用 reduce 实现类似效果。但两种情况都说明了问题,我猜。
但如果我们到了可以引入新关键字/语法的阶段,也可以设计类似以下的方案
如果需要传递参数
对于后一种情况
当然,如果你有 `yield`,你基本上已经有了 `await`,因为在 OCaml 以外的所有语言中,`await` 只是 `yield` 的语法糖。
嗯,我们向 Google 推广 async/await 的一种方式是展示如何改进基于 Promise 的测试。
我记得我们的一个测试套件有数万行使用 `then()` 的代码。这些代码足够复杂,以至于这些行大多被视为只读代码,部分是因为异步循环非常难以编写,部分是因为错误处理并不简单。
我使用`Task.spawn`(我们异步/等待的原型)重写了那个测试套件。我没有确切的数字,但这使代码行数减少了2-3倍,突然间人们可以看到熟悉的循环和`try`/`catch`的使用。
> 某些操作是交换的(顺序无关紧要:加法、乘法等)
有趣的事实:加法的顺序确实重要。(当添加许多浮点数且指数差异较大时。)
> 通常,代码中操作的顺序由行号指示
除了允许向后遍历的循环,以及允许临时跳转到其他局部线性操作的程序。
我们有足够的语法来实现非线性操作。
> 通常,代码中操作的顺序由行号指示(第一行发生在第二行之前,依此类推),但我明白这在异步代码中可能不适用
这在语言层面并不总是成立,更不用说在 CPU 管道和微代码层面了。
逻辑语言如 Prolog 会按设计顺序执行语句。其他语言如 Mercury 使用 IO 单子来表示串行操作
我不确定你所说的 Prolog 中的“语句”指的是什么,因为这不是该语言定义的术语。如果你指的是子句,那么执行顺序并非无序:Prolog解释器会尝试按子句在知识库中出现的顺序,将目标与子句进行统一。这种顺序对控制流具有语义意义。
如果你指的是子句主体中的目标,这也并不正确。目标会严格按从左到右的顺序进行评估,且每个目标必须成功后才能尝试下一个。这种评估顺序同样是必要的且可观察的,尤其是在存在副作用的情况下。
> Prolog解释器会尝试按照它们在知识库中出现的顺序,将目标与子句进行统一。
我以为在统一过程中填补空缺时,这些语句/子句可以以任何顺序出现,就像你解决填字游戏一样
本文中提到的“并发性≠并行性”这一论点常被引用,但很少具有实用性或信息量,且其模型无法足够准确地反映实际系统,甚至在实践中也难以成立。
示例:Python支持并发性但不支持并行性。但实际上并非如此,因为Python中存在大量并行性的例子。Numpy 既释放了 GIL,又在内部使用 open-mp 和其他策略来并行化工作。还有成千上万的其他例子,这里无法一一列举,这就是我的观点。
示例:gambit/mit-scheme 通过并行执行实现并行性。嗯,某种程度上是这样,但实际上更像 Python 的 multiprocess 库中的池化机制,它会 fork 进程并随后将结果 marshal 回来。
除此之外,并行执行往往只是管理并发调用的方式。使用线程进行 HTTP 请求是一个简单示例,尽管线程能够并行执行(取决于大量细节),但它们实际上几乎 100% 的时间都阻塞在某个 socket.read() 调用上。那么这是并行还是并发?它就是它本身,主要是线程在系统调用上阻塞。并行与并发的区别在这里毫无意义,因为在实际应用中这是一个毫无意义的区分。
那么使用异步调用执行进程呢?这是并发还是并行?这是利用并发来允许并行工作。同样,它既是并发又是并行,但你只需要直接讨论它,而不是试图通过某种不存在的二分法来简化它。
你真的需要深入细节,并发与并行是错误的思考方式,它无法涵盖实现中真正重要的内容,而且通常被那些试图回避细节或在在线辩论中显得聪明的人引用,而非真正解决问题。
这种区别非常有用且富有信息量。事实上,大多数地方似乎都没有强调这一点:并发是一种编程模型。并行是一种执行模型。
并发编程是指编写具有多个线性线程外观的代码,这些线程可以交错执行。值得注意的是,这涉及到编写代码。任何并发系统都可以被实现为一个状态机,同时跟踪所有状态。但这非常困难,因此我们定义了模型,允许单一功能的线性代码块交错执行,然后让语言、库和操作系统处理细节。是的,甚至包括操作系统。你认为在多核 CPU 出现之前,多任务处理是如何实现的?内核有一个复杂的状态机,用于跟踪多个允许交错执行的线程的执行情况。(实际上它仍然这样做。添加多个核心只是让事情变得更复杂。)
并行性是在多个执行单元上运行代码。这是执行。它不关心代码是如何编写的,只关心它如何执行。如果你正在做的事情可以利用多个执行单元,它就可以是并行的。
代码可以是并发的而无需是并行的(参见JavaScript中的async/await)。代码可以是并行的而无需是并发的(参见数据并行数组编程)。代码可以两者兼具,而且通常是这样设计的。这是因为它们描述的是完全不同的事情。没有规则规定代码必须是其中之一。
当你定义某些概念时,这些定义和概念应该帮助你更好地理解和简化对事物的描述。这就是定义和术语的意义所在。你并没有实现这个目标,事实上恰恰相反,你的描述令人困惑,且在理解、调试或编写软件时毫无用处。
换句话说:如果我们不讨论并发与并行,我们对代码实际执行的细节以及对正在发生的事情的理论理解将保持完全相同的水平。试图强行将两个类别套用到任何真实系统上,并试图为真实系统中不存在的概念创建定义,这是徒劳的。
讨论并行与并发是无意义且浪费时间的。更具实用价值的是探讨系统中哪些操作可以在时间上重叠,哪些操作不能在时间上重叠。操作在时间上重叠的能力可能源于技术限制(如Python的GIL)、系统限制(如单核处理器),也可能是故意设计的(如显式锁定),但这才是你需要理解的核心,而“并行与并发”的区分根本无法提供任何信息或洞见。
以下是我确信自己正确的理由: 取任何实际存在的软件、编程语言、库或其他内容,将其描述为并行或并发,然后提供那些未被“并行”和“并发”涵盖的额外细节。然后回去删除任何关于“并行”和“并发”的提及,你会发现你需要知道的一切仍然存在,删除这些术语并没有实际删除任何信息内容。
这忽略了“操作”的(部分)顺序。发生在之前/之后/同时只是一个重要的基本概念。
讨论加法与乘法的区别是毫无意义且浪费时间的。更重要的是讨论最终得到的结果。你可能通过加一次、两次或更多次得到那个数字,但最终的数字才是你需要理解的真正内容,而“加法与乘法”的讨论完全没有提供任何信息或见解。
它们只是不同事物的不同名称。不关心它们的不同之处会让沟通变得困难。为什么要对你打算沟通的人这样做?
异步 JavaScript 代码也是并行的。例如,await Promise.all(…) 会同时等待多个函数。JavaScript 事件循环一次只解释一条语句,但与此同时,计算机的其他部分(文件句柄、TCP/IP 堆栈,甚至可能包括 GPU/CPU,具体取决于 JavaScript 库)实际上是在完全并行地执行任务。一个更有用的区分是,JS解释器是单线程的,而C代码可以是多线程的。
我无法想到任何在实践中是并发但不是并行的情况。即使是单核CPU运行2个线程,因为它们也可以并行使用其他资源,如磁盘,甚至通过流水线技术使用CPU的不同部分。
> 一个更有用的区分是,JavaScript 解释器是单线程的,而 C 代码可以是多线程的。
…这似乎是在绕弯子说“JavaScript 代码不是并行的,而 C 代码可以是并行的”。
换句话说,我认为并行性是一个应用于“自己的代码”的概念,而非计算机世界中所有代码。计算机其他部分执行其他任务与这一点无关,否则在当今几乎每颗CPU都拥有多个核心的时代,“并行性”将是一个完全多余的概念。
编辑:作者已修正。
是的,我完全同意这篇文章的观点
或许可以将“this”改为非人称代词?比如“罗布·派克的论点”
已更新,感谢反馈
感谢澄清!
我认为试图定义这些概念没什么意义,因为人们对它们的含义没有共识。不同的人对每个概念都有清晰的理解,但他们就是无法达成一致。
这就像集成测试与单元测试的区别……大多数开发者认为自己对每个概念的含义有清晰的理解,但根据我的经验,关于单元测试与集成测试之间的界限,几乎没有共识。有些人会说单元测试需要模拟或替换所有依赖项,另一些人则认为这并非必要;只要模拟了I/O调用即可…… 还有人认为单元测试可以进行I/O调用,但不能进行数据库调用或与外部服务交互的调用……有些人则认为,如果测试覆盖了模块而没有模拟I/O调用,那么它就不是集成测试,而是端到端测试。
总之,异步、并发和并行也是同样的情况。
我认为大多数人会同意,并发性可以不依赖于并行性或异步性来实现。对许多人来说,异步性意味着它发生在同一进程和线程(同一 CPU 核心)中。一些使用高级语言的人可能会说,异步是一种上下文切换(因为在回调被调用或承诺解决时,栈中的上下文会发生切换),但系统开发人员会说,上下文切换比这更细粒度,不受特定操作持续时间的限制,他们会说这是 CPU 级别的概念。
作者似乎没有参与过任何涉及异步性的非trivial项目。
并发中的所有陷阱都存在——特别是当在前一次执行完成前多次执行非幂等函数时,你需要使用互斥锁!
> 并发中的所有陷阱都存在于异步API中 [in async APIs]
这是那种“实践中,理论与实践不同”的情况。
在异步世界中,没有类似并行竞争条件的东西。代码会运行到完成,直到它确定性地让出控制权,100% 的时间都是如此,即使这些让出控制权的位置可能难以推断。
因此,任何曾经需要调试和分析并行竞争条件的人都会对这一说法嗤之以鼻。这根本不是一回事。
> 代码会运行到完成,直到它确定性地让出控制权
不,因为异步可以(且经常被)用于执行 I/O 操作,而 I/O 操作的完成时间并不需要是确定性或可预测的。在多个任务中选择并继续执行第一个完成的任务,是异步编程中完全普通的功能。即使你不需要承受操作系统线程调度器带来的额外非确定性,异步编程本身也没有规定你不能使用线程作为其实现的一部分。
我再重复一遍,如果你认为不可预测的 I/O 完成顺序带来的影响与硬件并行竞争的调试场景相当,我只能笑笑。
是的,从理论上讲它们是相同的。这就是笑话所在。
你并没有自曝其短,因为你在最初的评论中根本没有明确指出你在谈论硬件。你之前的评论只在讨论软件生态系统时提到了“并行数据竞争”,而这两个术语经常被用来描述发生的事情。你正在嘲笑那些在你冲到足球场中间时的人;没有人阻止你得分,因为你没有告诉他们你显然在玩一个完全不同的游戏。
“硬件并行”这个术语是为了明确我讨论的是真正的并行性。SMP 错误是 100% 的软件问题,用软件技术解决。你在学校的软件课程中会学到这些。它们只是比让你的异步系统正常工作难得多。
你可能说得对。我只是觉得你之前表达的观点远不如之后澄清时清晰,因此你的澄清显得有些自鸣得意,而实际上只需像这里这样澄清就已足够。
我不知道,我虽然用的是TypeScript,但确实遇到过与并发软件中完全相同的竞态条件。
特别是promise.all([f,f,f])这种情况,我希望确保f的本体只被执行一次。
互斥锁是 Zig 语言中 Io 接口的一部分。同样,睡眠、选择、网络调用、文件 I/O、取消等功能也包含在内。
在异步代码中不需要互斥锁,因为根本不存在并行执行。事实上,将异步编程作为第一类公民的语言(如 JavaScript)甚至没有实现互斥锁的构造。
如果你需要在程序中同步某些内容,可以使用普通变量,因为可以保证你的任务在执行 await 操作将控制权交还给调度器之前绝不会被中断。
某种程度上,异步代码可以自行实现互斥锁(或类似机制):这是我在 JavaScript 中常用的技巧,通过承诺(Promise)同步操作来实现类似互斥锁或信号量的功能(例如,若需确保一个内部包含异步操作的函数不会被中断,可通过承诺和普通 JavaScript 变量实现)。
> 在异步代码中无需使用互斥锁,因为根本不存在并行执行。事实上,将异步编程作为第一类公民的语言(如 JavaScript)甚至没有实现互斥锁的构造。
这完全不正确;许多语言同时支持异步和并发,可能比不支持的语言更多。C# 是第一个引入异步/等待的语言,而不是 JavaScript,它当然支持并发,Swift、Python、Rust 以及许多其他语言也支持。你正在混淆 JavaScript 作为语言的两个独立属性,并错误地推断出它们之间实际上不存在的联系。
这就是原帖所指的,这仅适用于没有并行性的异步实现。这并不一定成立,你的 JavaScript 运行时完全可以
并透明地在两个线程中执行它们。就像 Python 的 GIL 一样,它只是没有这样做。你的 JavaScript 实现实际上已经有了互斥锁,因为带有共享内存的 Web 工作者带来了真正的并行化,同时也带来了随之而来的挑战。
在 JavaScript 的情况下,只有在无法检测到影响时才允许这样做,即 foo 不影响 bar 的输出。因此,就陷阱而言,它一次只做一件事。其余部分是优化器的隐藏实现细节。
https://developer.mozilla.org/en-US/docs/Web/API/LockManager…
请不要自行实现此功能
我觉得这里可能有些地方我没弄明白,但最有趣的部分是无栈协程在 Zig 中是如何工作的?
既然任何函数都可以转换为协程,那么红/蓝问题是否被移到了编译器中?如果我调用:
这是函数调用吗?还是某个“结构体”被分配到栈上并传递给事件循环?
此外,如果只使用纯Zig,这没问题,但如果使用任何FFI,你仍然可能触发阻塞系统调用。
我希望这不是一个糟糕的答案,因为我过去一周一直在努力理解无栈协程的原理。
1. Zig计划为函数调注释最大可能的栈大小https://github.com/ziglang/zig/issues/23367 。正如人们所说,这将为编译器提供足够的信息来实现无栈协程。我还不完全理解为什么会这样。
2. 据称,这仅因 Zig 使用单一编译单元而成为可能。你极少会遇到独立编译的模块。若 Zig 中的函数未被调用,则不会被编译。我能理解这如何有助于解决第 1 点。
3. 跨 FFI 边界时,这是所有语言都面临的问题。理论上,调用共享库后总可能发生意外操作。一个随机的C库可以随时创建线程并执行调用者未预期的操作。你需要在Rust中使用不安全块,原因相同。
4. 理论上,Zig在编译C代码时控制C标准库。在某些情况下,例如仅使用一个Io实现时,Zig可以替换C标准库中的函数以使用该Io虚函数表。
无论如何,我希望 Kristoff/Andrew 能在某篇文章中解释一下无栈协程(针对初学者)的概念。我不确定人们在提到这个术语时是否在讨论同一件事。我愿意等到 Zig 尝试使用新的异步模型实现该功能时再阅读那篇文章。
_> 正如人们所说,这将为编译器提供足够的信息来实现无栈协程。
无栈协程需要创建一个足够大的结构来容纳拟定函数的所有局部变量,但Zig已经拥有这些信息,Rust也是如此,而且,从定义上讲,任何已经支持无栈协程的语言都具备这一特性。
该问题的核心在于,Zig希望静态计算整个程序的总栈使用量,但困难不在于计算任何给定函数的栈大小(在大多数语言中这通常是简单的;通过类似C语言的
alloca
支持可扩展栈是例外,而非常规)。困难在于递归函数可能向栈中压入无限数量的函数调用。因此 Zig 希望禁止递归(包括相互递归),除非你主动选择启用。而这里提到“据称,这仅因 Zig 使用单一编译单元才可能实现”的原因在于,检测相互递归颇具挑战性,尤其当涉及虚拟分派时。
但不,你不需要 Zig 风格的全程序编译来实现这一点。你只需要 1) 能够在单个编译单元内检测到相互递归(再次受虚拟调度阻碍),然后 2) 防止编译单元之间的循环依赖。Go 和 Rust 都实现了后者,因此它们可以使用相同的分析,假设你能找到一个好的解决方案来解决前者。
精彩的文章。我期待Zig即将推出的异步I/O功能。
判断“异步性”是否是一个我们需要的术语的好方法,是测试它在其他上下文中是否有用,而不仅仅是在单一语言或单一并发设计中。
如果它在广泛的并发模型中都需要正确推理,那么我认为它将是一个有用的补充。如果不是,那么我认为在更大的范围内使用它并不值得。
例如,这个概念在Haskell、Erlang、OCaml、Scheme、Rust、Go等语言中是否成立?(假设我们从Haskell、Rust和OCaml中选择一种并发模型)
更一般地说:如果系统采用协作式调度,那么就需要关注更多细节。这是因为,一段糟糕的代码更容易影响整个系统,例如导致系统锁死或产生延迟问题。在预先调度的世界中,这类问题会立即消失,因为你无法以同样的方式锁死系统。
“异步性不等于并发性”:这是正确的
同样正确的是,没有并发性的异步性是有害的
我可能更准确地说:没有并行性的异步性是有害的,因为你引入了一整套新的计算操作,却没有带来任何好处
这在某种程度上是正确的…
我可以同时异步地做很多事情。比如,我同时运行洗碗机和洗衣机来洗衣服。我认为这些事情并非“同时”发生,因为它们彼此独立。如果我站在那里看着一个完成后再启动另一个,那将是一种同步情况。
但,我“不在乎”。我认为事情是通过最外层的异步任务协调来实现并发的。存在一种对独立进程的治理,而我的最外层线程正是将异步任务转化为并发任务的关键。
换句话说,我对你家中家电的运行状况毫不在意。从某种意义上说,它们与我的日程安排不同步,因此是异步的,但并非严格意义上的“并发”。
因此,我将“并发”理解为“有组织的异步进程”。
这样说有道理吗?
啊,另外,异步和并发都不意味着它们同时发生……那是并行,与这两者都不相同。
好,现在我来读这篇文章,哈哈
我认为“异步”意味着“不同步”,这暗示着两个任务之间需要存在同步性。
在这种情况下,“异步”仅仅意味着两个或多个本应在某种程度上同步以实现预期行为的任务,未能正确同步,而是处于不同步状态。
然后我认为异步行为可能由多种原因引起,可能是由于并发执行、并行执行,或是由于同步机制存在缺陷等。
因此,我认为异步编程是指可以用来同步异步行为的机制。
但我想你也可以认为异步意味着不需要同步。
我还没读过那篇文章,哈哈
> 我认为这些事情不会在“同一时间”发生,因为它们彼此独立。
那么,你认为它们在同一时间运行的条件是什么?
最终,服务器和客户端能够建立连接。
洗碗机和洗衣机?
我不明白——特别是客户端/服务器示例中的“问题”(似乎在解释中至关重要)。但我对Zig也不熟悉,也许这是先决条件。(不过我熟悉异步、并发和并行)
示例1
你可以先写入一个文件,等待,然后再写入第二个文件。
无需并发。
示例2
你不能先调用Server.accept,等待,然后再调用Client.connect,因为Server.accept会永远阻塞。
需要并发。
哦,我明白了。文章说的是异步是必需的。我以为它说的是并行是必需的。文章的写法让人觉得代码示例有问题,而不是代码示例是正确的。
文章后来提到(关于服务器/客户端示例)
> 遗憾的是,这段代码并未表达这一并发要求,这就是我称其为编程错误的原因
我推测这是 zig 中异步实现的特殊性,因为在我熟悉的所有异步运行时中(如 Python、JS、Go),这段代码都是正确的。
我现有的思维模型是,“async”只是用于表达并发程序的语法工具。我认为我需要进一步了解Zig中async的工作原理。
我认为关键区别在于,在许多应用层语言中,你等待的每个对象都独立存在,并会在后台继续执行操作,无论你是否等待它。而在系统级语言如Rust(以及推测中的Zig)中,你等待的对象通常是被动的,只有在调用方等待它们时才会继续执行。
这是为了在“线程”和“内存分配”等概念不成立的环境中编写异步代码而产生的设计选择。
Rust确实有“任务”这一概念,它具备独立存在的特性。
我认为这一概念非常特定于Rust的设计。
例如,Golang 就没有这个特性,用户(或其运行时)必须通过轮询来推动未来完成。
没错,但 Go 是一种应用程序级语言,并不针对线程没有意义的环境。这更像是想要针对嵌入式环境的产物,而不是 Rust 的特定特性。
感谢你的解释,这让我明白了。
我想我混淆了文章中定义的“异步性”与我熟悉的语言中“async”作为语法特性的概念。
非常感谢你提供的额外背景。这很好地解释了文章的内容。
但为什么这是一个新概念?饥饿问题早已为人所知,而且你不需要并行性就能受到它的影响。Zig到底做了什么来解决这个问题?
许多其他语言已经在单线程上下文中使用async/await,搭配一个极其愚蠢的调度器,它从不切换,但没有人想要这样。
我正在努力理解,但我需要明确为什么这很有趣。
新颖之处在于明确区分何时只需非并发调度器(async),何时需要并发调度器(async-concurrent)。作为好处,你可以直接从同步上下文中调用异步函数,这是常规 async/await 无法实现的,从而避免了为每个函数同时维护同步和异步版本的必要性。
借助绿色线程,你可以实现从异步到同步再到异步的调用链,同时允许内部异步函数将控制权传递给外部异步函数。这使得即使包装库仅使用同步函数,也能保留异步系统调用的优势。
作为一名编写了大量并发并行异步代码的网络程序员[0],这篇文章令人困惑。它似乎在漏水的抽象层面上做花哨的动作。如果能如此轻易地搞砸,那么工具选择和实现方式都是错误的。
[0] 调试之所以有趣,正是因为看着人们害怕调试多线程怪兽的过程令人发笑。
我喜欢这样思考:库在支持的环境上有所不同。编写可在任何环境中运行的可移植库固然不错,但往往没有必要。有时你不在乎代码是否在Windows上运行,或是否在没有绿色线程的环境中运行,或(在Rust中)是否在没有标准库的环境中运行。
因此,我认为当类型系统允许你声明函数支持的环境时,这很不错。这将捕获你在可移植库中调用较不兼容函数的错误;你会得到一个编译错误,表明你需要检测这种情况并条件性地调用函数,同时提供备用方案。
关于这个主题,有一本很棒的经典著作,有兴趣的人可以查阅:《通信序列过程》(Communicating Sequential Processes),作者是霍尔(Hoare)。Go语言的通道(channels)和并发编程方法正是受此启发而设计。
我之前在公司做演讲时也写过一篇博客文章,虽然是针对Go语言的,但我认为还是值得一读。
[0] https://bognov.tech/communicating-sequential-processes-in-go…
大多数语言缺乏表达懒惰返回值的能力。例如:
await f1() + await f2()
,而要以并发方式表达这一点,则需要手动处理未来值。你是说像这样吗?
虽然更现实的是
但未来值本身就是懒惰值的表达,所以我不是很确定你还需要什么。
这就是我所说的“手动处理未来值”。在这种情况下,你需要编写
我认为这只是过多的语法噪音。
另一方面,这是必要的,因为底层的一些异步调用可能依赖于顺序。
例如
检查第一个接收的套接字字节是A,第二个是B。这显然是顺序依赖的,不能以无序方式并发执行。
我猜你得写
但这似乎难以实现,对吧?你的语言必须假设顺序依赖性或独立性,并明确指定另一种。大多数语言似乎都遵循“词法顺序暗示执行顺序”的原则。
我认为有些语言使用大括号作用域来打破依赖性。我记得Kotlin就是这样做的。
这就是为什么人们说异步是一种流行模式,但依我之见,这是因为你添加了特异性,而函数着色是必要且有益的。
哪些语言有这样的特性?
Rust 就是这样做的,如果你不调用 await 它们。然后你可以对两者的合并进行 await。
“join”语法是语言的一部分吗?
为什么需要或有益于拥有这样的语法?
有人可能会说:“Rust现有的功能集已经使这成为可能,为什么要在不需要的地方专门设计语法?”
(……我认为这也是一个相当务实的立场。连接/选择操作相对罕见,显式编写连接语句对程序的阻碍相对较轻……这能解决什么问题?
相比之下,
?
语法糖表示的是一种常见操作(一个 try! 宏即可替代?
),但其对开发者的负担要高得多,尤其在代码可读性和可写性方面。不
https://doc.rust-lang.org/std/future/macro.join.html
那么这种情况不适用。
为什么?
我认为Haskell可以做到,因为
(+) <$> f1 <*> f2
。其中还有ApplicativeDo,与这很好地配合。
这与 Applicative 的评估方式相同。
这是因为 f2 的结果可能取决于 f1 是否已执行。
异步、并行、并发,甚至确定性执行(尽管是退化情况)都是非确定性的子类。迪杰斯特拉和施尔滕在这方面的研究遗憾地未得到应有的重视。而且,别以为这是象牙塔里的理论,迪杰斯特拉在成为教授之前,曾是一名系统工程师,在我们看来极其糟糕的硬件上编写操作系统。
我认为这些定义并不完全准确:
>异步性:任务可以以非顺序方式运行且仍保持正确性的可能性。
我喜欢这个定义。这是一个很好的补充,确实之前缺失了。
>并发性:系统同时处理多个任务的能力,无论是通过并行处理还是任务切换实现。
我认为这里应表述为“无论是通过多处理器还是任务切换实现”。
>并行性:系统在物理层面上同时执行多个任务的能力。
这在技术上就是上述提到的多进程。
那么,并行性和并发性有什么区别?
并行任务就像着色器。它们是同一个任务,在物理层面上同时运行多个实例。
例如,GPU设备具备并行计算能力。
并发任务是在物理层同时运行的不同任务。通常,数据也不同。例如,物理层上同时运行的精灵引擎和视频显示驱动程序。
着色器可以运行相同的代码,但处理不同的数据元素,例如每个像素都有一个位置,并且是更大渲染的一部分。
GPU是一个大规模并行多处理器。
Threadripper 是一款强大的并行多处理器。它也可以作为一款性能适中的并行多处理器运行。
区别在于各个计算单元能够执行什么任务以及它们实际在执行什么任务。
换句话说,一款 10GHz 单核 CPU 并非多处理器。它执行顺序计算,并可以通过任务切换来处理与低时钟频率多处理器相同的任务负载。
一个 10GHz 多核 CPU 是并行多处理器,但不是 GPU。它可以运行与较低时钟频率 GPU 相当的着色器。但是,较低时钟频率的 GPU 无法以相同的方式运行多种任务。
> 并行任务就像着色器。这是同一个任务,在物理层同时运行多个实例。
这是单指令多数据(SIMD),我认为这是另一个独立的问题。并行计算的更好例子是FPGA。所有门电路同时切换*,你必须想办法同步所有电路才能得到有用的结果。
* 忽略PLL
是的,SIMD是一种并行计算形式。但这并非唯一形式。着色器也是其中一种,区别在于大量指令和大量数据。只是相同的指令在所有实例中执行。
并发是指多个任务同时运行,每个任务包含针对相同数据或不同数据的不同指令。
单线程 CPU 完全可以进行并发处理——这是与并行计算的重要区别,我认为你在这里有点混淆了两者。
我同意你的观点,并认为自己已经说得很清楚了。
事实上,一个非常快的单线程、顺序计算的 CPU 与多处理器或多核 CPU 之间没有实质性区别。
阻塞式异步代码并非异步。若要让某项操作“跳过顺序执行”,必须从该任务中提供逃逸机制,而该机制本质上决定了一种并发形式。异步必须是并发的,否则它就不再是异步,而变成了同步。
考虑:
从应用程序开发者的角度来看,readA“阻塞”了readB。它们不是并发的。
在此示例中,两个操作交错进行,读取操作并发执行。作者做出这一区分,我认为这很有用,即使没有明确的名称,大多数人可能也对此有所了解。
我认为这混淆了范式与配置。例如,如果一个线程等待另一个线程完成,这并不意味着代码突然变成了“单线程”,它只是意味着在该实例中,你的两个线程处于串行化配置。同样,当异步代码变得串行化时,它并不停止异步:使它并发的架构仍在,只是在特定配置中未被使用。
例如,C# 使用以下语法:
当你有这两行代码时,第一个 I/O 操作在 `await` 期间仍会将控制权交还给主执行器,而其他网络请求可以在同一线程中继续执行,即使 “readA()” 正在运行。它本质上是并发的,不是在你的两行代码范围内,而是在你的程序范围内。
Zig 有什么不同吗?
这正是文章试图澄清的内容。
如果你需要按顺序执行 A 和 B,但实际执行的是 B 然后 A。即使在单线程中执行 B 然后 A,操作也是不同步的。
所以,我猜你可以将这种场景定义为异步。
那么,他们所说的“异步”实际上是指“依赖”吗?
> 那么,他们所说的“异步”实际上是指“依赖”吗?
不,异步的定义是:
>> 异步:任务可以以不同顺序执行且仍保持正确性。
这并非依赖,而是不依赖。在他们的定义中,异步是指任务可以并发执行,无需同步或协调。一个与之对比的例子是客户端和服务器模型,尽管它们也是并发的,但并非异步,因为顺序至关重要(例如,如果在客户端启动后才启动服务器,或在客户端启动前终止服务器,系统将无法正常工作)。
> 这并非依赖关系,而是独立性
好吧,对我来说已经足够了。依赖跟踪意味着独立性跟踪。如果这就是本文所讨论的内容,我认为这个术语要清晰得多。
> 其中顺序至关重要
我认为你误解了这个例子。文章中提到:
> 和之前一样,*顺序并不重要:*客户端可以在服务器开始接受连接之前建立连接(操作系统会在此时缓冲客户端请求),或者服务器可以先开始接受连接,然后等待一段时间再看到传入的连接。
唯一必须发生的情况是,在请求打开时服务器必须正在运行。如果客户端任务要完成,则服务器任务必须启动并保持未完成状态,直到客户端任务完成。
我正准备回复作者,因为他的文章写得实际上令人困惑。他对示例的定义存在矛盾。
> 另一个仍为并发但非异步的对比示例是客户端和服务器示例
引用帖子中相反的陈述:
> 基于这些定义,以下是对之前两个代码片段的更好描述:两个脚本都表达了异步性,但第二个需要并发性。
你可以按任意顺序执行Server.accept和Client.connect,但之后两者必须“同时运行”(更准确地说,是并发运行)。
你的示例和定义不一致。
如果异步性,如我直接引用你文章中所说,坚持认为顺序无关紧要,那么客户端和服务器就不是异步的。如果客户端在服务器之前执行并未能连接(因为服务器未运行以接受连接),那么你的系统已失败,服务器将在稍后运行并永远等待一个已经终止的客户端。
根据你自己的定义,客户端/服务器示例并非异步,尽管它是并发的。
需要引入第四个术语:同步性。任务是并发的(可以交错运行),但任务之间的顺序很重要。
> 如果客户端在服务器之前执行并未能连接(服务器未运行以接受连接),那么你的系统已经失败,服务器将在稍后运行并永远等待一个已经死亡的客户端。
文章中提到:
> 与之前一样,顺序无关紧要:客户端可以在服务器开始接受连接之前发起连接(操作系统会在 meantime 缓冲客户端请求),或者服务器可以先开始接受连接,然后等待一段时间再看到传入连接。
当你创建服务器套接字时,需要调用 `listen`,之后客户端就可以开始连接。你不需要已经调用 `accept`,正如文章中所解释的。
不一定,但我猜这取决于你如何定义“依赖性”。
例如,可能只需要部分排序,因此 B 不完全依赖于 A,但 B 的某些部分必须在 A 的某些部分之后发生。
这也不意味着 B 必须消费 A 的输出。
等等。
但确实存在依赖关系,只是系统的行为可能取决于两者以某种部分顺序发生。
区别在于,异步情况下,时间顺序不重要,只需满足部分或完全顺序即可。因此 B 可以在 A 之后一年发生,最终仍会正确,或至少在超时内完成。换句话说,中间发生其他事情是可以的。
对于同步操作,时间顺序通常很重要,它们必须依次发生,中间不能有其他事情。或者它们甚至可能需要同时发生。
更准确地说,回调和异步/等待可以促进并发。
非阻塞 I/O 并非异步,作者应该清楚这一点。非阻塞 I/O 是异步系统的基础组件——它本身并非异步。当今的异步编程在 80 年代 Unix 实现非阻塞 I/O 时并不存在。
我总是将异步并发视为状态机。
> 并发指的是系统通过同时执行或时间共享(上下文切换)来执行多个任务的能力
维基百科对微内核的理解也错误了大约十年,所以……我们现在就是这样吧。
这不是一个“错误”的描述,但它是不完整的……
考虑像Haskell这样的语言中的非严格评估。你可以评估来自一个“无限”计算的thunks,提前终止,然后由于评估模式而继续执行其他内容。
这可以通过其他语言中的生成器和“yield”来模拟,语义上会非常相似。
再考虑Lisp家族语言中的延续……或用于错误处理的异常。
你必须假设所有事情都可能在相对彼此的“中断控制流”中同时发生,才能处理它。从外部看,并发与序列化事物并无不同。
是否进行并行评估?谁知道呢……这是一种可应用于并发计算的策略,但并非必要。同样,“上下文切换”也并非必要,除非你指的是控制流的切换。
这篇文章非常出色,但如果我们仅依据“字典定义”(编程环境通常只能“部分正确”地理解这一概念),那么我认为我们有点偏离了重点。
我们所称的“异步”通常是现实世界中异步事物的子集。我们视为任务切换的内容是并发的一种形式。但我们似乎都认同并行性!
这是文字游戏。
如果我从异步 JavaScript 发起 2 个网络请求且两者均在传输中,那便是并发。
牛津词典的定义:形容词 1. 同时存在、发生或完成的。 “城市里有三个同时举办的艺术博览会”
这是试图用一个新术语(实际上是在编程语境中重新定义它,使其更严格)来划分并发与并行编程挑战的类别。
牛津词典在此处无关紧要,除非它已经从该领域借用了定义(例如,查阅“文件”:我猜它会定义计算机文件)——但由于它默认滞后,无法提供具体定义。
> 如果我从异步 JavaScript 中发起两个网络请求,且两者均在执行中,那么这就是并发。
这是因为 JavaScript 将两者混为一谈。JavaScript 中的 async 关键字会将任务排入事件循环队列(该循环在不同线程中运行),即使这些任务从未被等待,进度仍会继续。例如在 Rust 中,除非这些 Futures 被等待,否则什么也不会发生。
并发和并行这两个概念足够接近,以至于经常被混淆。许多语言都提供了这两个概念的基本实现,但使用不同的框架来实现它们。因此,在这种情况下,差异确实很重要。或者,这些框架只是稍微低级一些,差异确实很重要,因为你需要考虑并意识到不同的问题。
我最近几年一直在使用 Kotlin。尽管它并非没有问题,但其协程(co-routines)方法堪称一绝,因为它通过一个专门设计且经过深思熟虑的框架,覆盖了整个并发领域。它提供了一种更高层次的并发实现方式,即结构化并发,这正是Zig在此处所探讨的内容(如果我理解正确的话,我对Zig并不熟悉,如有错误请指正),而目前许多语言(如Java、JavaScript、Go、Rust、Python等)尚未提供此类功能。不过,其中部分语言正在相关领域进行开发。随着 Python 移除了 GIL,我认为它有可能朝这个方向发展。但它还需要一些时间来赶上。其他一些语言也提供了类似的优雅且复杂的解决方案,甚至有人认为它们更好。
在 Kotlin 中,一个函数是否异步被称为“悬挂”(suspending)。“悬挂”意味着“这个函数有时会将控制权交还给调用它的函数”。这种情况通常发生在进行事件驱动的 I/O 操作或调用其他悬挂函数时。
使其成为结构化并发的原因在于,悬挂函数在名为“调度器”和“上下文”(关于作用域的元数据)的作用域中执行。Kotlin 通过带颜色的“suspend”函数强制执行这一规则。在协程作用域外调用它们会导致编译错误。函数颜色对某些人来说有争议。但它们有效且足够简单易懂。关于这个主题没有混淆。你会在做错时知道。
一些调度器是单线程的,一些调度器是多线程的,一些调度器是绿色线程的(例如在 JVM 上)。在 Kotlin 中,协程作用域通过一个接受块作为参数的函数获得。该块会将作用域作为上下文参数接收(通常为 ‘this’)。当块退出时,作用域所包含的整个子协程树保证已完成或失败。若发生异常,整个树将被取消。取消是许多其他语言处理得不太好的棘手问题。作用域失败是一个简单的异常,如果发生取消,那就是 CancellationException。如果这听起来很复杂,其实没那么糟糕(因为 Kotlin 的 DSL 特性)。但请将其视为必要的复杂性。因为不同调度器的工作方式存在实质性差异。Kotlin 明确了这一点。但否则,它们基本上都是一样的。
如果在协程内部,你想异步执行两件事,只需调用 launch 或 async 等函数并传入另一个代码块。这些函数由协程作用域提供。如果你没有协程作用域,就无法调用这些函数。该代码块将由调度器执行。如果你想使用不同的线程,你可以将一个可选的新的协程作用域作为参数传递给 async/launch,该作用域拥有自己的调度器和上下文(你实际上可以使用 + 运算符将这些参数组合在一起)。如果你没有提供可选参数,它将简单地使用父作用域在运行时构建一个新的作用域。这里的结构化并发意味着你有一个嵌套的协程树,每个协程都有自己的上下文和调度器。
调度器可以是多线程的(每个协程获得自己的线程)并由线程池支持,也可以是简单的单线程调度器,仅让每个协程运行直至挂起,然后切换到下一个。如果你在 JVM 上,绿色线程池看起来与普通线程池相同(这是设计使然),你可以轻松创建一个绿色线程池调度器,并将协程分派到绿色线程。注意,这仅在调用已适配绿色线程的 Java 阻塞 I/O 框架时有用(此处存在大量例外情况)。从技术上讲,绿色线程的上下文切换开销比 Kotlin 自身的协程调度器稍高。因此,若需要性能优化则使用绿色线程调度器;否则应避免使用,以免代码运行速度变慢。
当然还有更多细节,但重点是无论使用哪种调度器,生成的代码看起来都非常相似。无论是并发还是并行操作。这里的范式是所有函数都是悬挂函数,且在概念上没有区别。如果你想分叉和合并协程,你可以使用像 async 和 launch 这样的函数,它们返回可以等待的任务。你可以将一组元素映射为异步任务,然后对生成的列表调用 awaitAll。这只是让父协程暂停,直到所有任务完成。无论使用 1 个线程还是 100 万个线程,效果完全相同。
如果你想在协程之间共享数据,你仍然需要担心并发问题并使用锁/互斥量等。但如果你的协程不做这些,只是返回一个值而不会对内存产生副作用(想想函数式编程),那么事情自然就是线程安全的,并且可以用于结构化并发。
这种方法存在许多合理的批评。带颜色的函数具有争议性。我认为这种批评是合理的,但在 Kotlin 中并没有像人们所说的那样严重。Go 的方法更简单,但代价是无法优雅地处理失败和取消。所有函数都是同一种颜色。但这种简单性是有代价的(例如没有结构化并发)。它某种程度上将并行性置之不理。而且它迫使用户使用大量冗余代码,因为缺乏适当的异常处理和任务取消机制。失败是混乱的。它很简单。但有代价。
Zig方法背后的想法是将并发代码扩展为可在协程上下文之外执行,使其在可能的情况下轻松序列化(因此有网络示例)。
一般来说,繁重的工作应该始终移至底层基础设施(编译器、标准库、RDBMS系统在ACID保证的情况下…)——让开发者专注于业务逻辑。
这需要尽可能少的“函数着色”,我更希望Python采用这种方法。
>异步性:任务可以以非顺序方式运行且仍保持正确性。
这完全错误。异步性对两个无关任务不做任何假设,因此“顺序”在此无关紧要。就每个任务而言,我们仍期望给定任务的执行是按顺序进行的。因此,无论如何解释,此陈述均不成立。
文章中还有很多其他错误,这在起始前提错误的情况下是意料之中的。
“并发权限并非并发义务。Zig允许你显式地‘允许而不强制’,以支持设计在同步/异步‘函数颜色’上多态的库。”
这篇文章有什么新内容吗?
或许没有,但有时从不同角度的描述能帮助他人更好地理解概念。
我不知道自己读了多少篇“单子教程”才终于明白,甚至不确定是否完全明白!
大多数HN上的通用教育文章都不是新想法,只是可以作为教科书页面的文章。
核心问题是“async”这个术语本身就有各种问题,同步通常意味着“同时发生”,而当你不使用`async`时,情况并非如此。
就像可燃物和易燃物的区别一样
> 异步不是并发
这就是我告诉老板我缺席站立会议时说的话。