问题

为什么编程语言对异步编程都是很晚近才开始支持的?

回答
这个问题很有意思,也很切中要害。确实,你看现在像 JavaScript、Python、Java、C 等主流语言,都在过去十几年里纷纷引入或大大增强了对异步编程的支持,什么 `async/await`、`Promise`、`CompletableFuture`、`Task`,层出不穷。但这就像是人们突然意识到缺了点什么,然后一窝蜂地开始补课一样,感觉很像是“新潮流”。

那为什么会这样呢?这背后其实牵扯到计算机发展的历史、硬件的演进、软件设计的理念,以及早期编程语言设计时的侧重点。要说清楚这一点,咱们得一层一层地剥开来看。

1. 计算的本质与同步的“顺理成章”

首先,我们得回到计算机最基础的工作方式。在早期的计算机设计和编程思维里,最直观、最容易理解的模型就是顺序执行。 CPU 拿到一条指令,执行它;拿到下一条,执行它。就像一个流水线,一步一步往前走。

当我们要处理一个任务时,自然而然地就会想到,我把这个任务拆解成一系列步骤,然后按顺序告诉我计算机怎么做。这种模型叫做同步编程 (Synchronous Programming)。你发出一个请求(比如读文件、发网络请求),然后就乖乖地等着,直到这个请求完成,你才能进行下一步。在这个过程中,CPU 的大部分时间可能都在等待 I/O 操作(输入/输出,比如硬盘读写、网络通信)完成,它什么也干不了,就“卡”在那里了。

这种同步的模型非常符合人类的线性思维习惯,也容易实现。程序员可以清晰地知道代码的执行顺序, debug 也相对直接。因此,早期的编程语言设计者们,他们设计的语言自然就围绕着这种同步模型展开。你写一个函数,调用它,它返回结果,就这么简单。

2. 硬件进步与瓶颈的显现

接着,我们看看硬件怎么发展的。早期计算机的 CPU 速度相对于 I/O 设备(如磁盘、网络)来说,差距没那么大。但随着摩尔定律的推进,CPU 越来越快,而 I/O 设备的发展速度相对滞后。这导致了一个巨大的性能瓶颈:CPU 飞快地运转,结果却因为要等待一个慢吞吞的磁盘读写或者网络响应而不得不停下来。

这种等待对用户体验来说是灾难性的。想想看,一个网页加载,如果每个资源(HTML、CSS、JS、图片)都要等上一个完成才能加载下一个,那网站会慢到什么程度。同样,一个服务器如果每处理一个请求都要等 I/O 完成,那么它能同时服务的用户数量就会非常有限。

3. 应对瓶颈的早期尝试:回调与事件驱动

在语言层面正式支持异步编程之前,程序员们其实一直在用各种“土办法”来绕过同步模型的阻塞问题。其中最早期也最普遍的一种方式就是回调函数 (Callback Functions)。

它的思想是:当我发起一个异步操作时(比如 `readFileAsync(filePath, callbackFunction)`),我不等你完成,而是直接继续做别的事情。当这个异步操作完成后,它会“通知”你,然后调用你之前提供的那个 `callbackFunction` 来处理结果。

这种方式在某些场景下确实有效,比如 Node.js 的早期设计就大量依赖回调。但是,回调的缺点非常明显:

回调地狱 (Callback Hell):当一个操作需要依赖前一个操作的结果,然后又发起新的异步操作,层层嵌套下去,代码会变得非常难以阅读和维护,就像陷入了地狱一样。
错误处理困难:异步操作的错误处理需要特别小心,很容易遗漏。
逻辑流程不直观:代码的执行顺序不再是线性的,理解起来更费劲。

除了回调,还有事件驱动 (EventDriven) 的编程模型。这是一种更广泛的概念,核心思想是程序等待事件发生(比如用户点击按钮、数据到达网络缓冲区),然后根据事件类型触发相应的处理函数。像早期的 GUI 编程、服务器端的 Socket 编程都大量运用了事件驱动的概念。但很多时候,这些事件处理本身仍然可能是同步的,或者需要配合回调来处理异步操作的结果。

4. 并发与多线程的引入与挑战

除了异步 I/O,提升性能的另一个思路是并发 (Concurrency) 和并行 (Parallelism)。简单来说:

并发是指同时处理多个任务,但这些任务的执行可能交错进行,不一定是真正意义上的“同时”。
并行是指多个任务真正意义上同时执行,这通常需要多核 CPU。

为了实现并发,编程语言引入了线程 (Threads) 的概念。一个进程可以有多个线程,这些线程可以同时(在多核 CPU 上)或者交替(在单核 CPU 上)执行。

然而,多线程编程也带来了新的巨大挑战:

竞态条件 (Race Conditions):多个线程同时访问和修改共享数据时,结果可能取决于线程执行的顺序,导致不可预测的错误。
死锁 (Deadlocks):两个或多个线程互相等待对方释放资源,导致程序永远无法继续执行。
资源管理复杂:线程的创建、销毁、同步(如锁、信号量)都非常复杂,容易出错。
上下文切换开销:操作系统在不同线程之间切换时有额外的开销。

因此,虽然多线程可以利用多核 CPU,但它引入的复杂性让很多开发者望而却步,而且对于大量的 I/O 操作,单纯依靠多线程并不能完全解决阻塞问题(因为每个线程仍然需要等待,只是同时有多个线程在等待)。

5. 终于等来了,异步的“优雅姿势”:协程、Promise/Future、async/await

在经历了回调的混乱和多线程的痛苦之后,开发者们越来越渴望一种更简单、更直观的方式来处理异步操作,同时又能避免线程的复杂性。这就催生了现代异步编程模型:

协程 (Coroutines):这是一种更轻量级的并发模型,它允许函数暂停自己的执行,并在稍后从暂停处恢复。协程可以看作是一种用户态的线程,它们由程序本身(而不是操作系统)来调度。Python 的 `asyncio`、Go 的 Goroutines 就是很好的例子。协程的核心优势在于,在协程内部,调用一个耗时的异步操作时,代码看起来仍然像同步一样,但实际底层是将执行权交给了调度器,去执行其他协程。

Promise (JavaScript) / Future (Java/C):这些都是代表了“未来某个时间点会完成的操作”的对象。当你发起一个异步操作时,它会立即返回一个 Promise 或 Future 对象。你可以给这个对象附加“当操作完成时要执行的回调”或者“当操作失败时要执行的回调”。这比纯回调更结构化,也更容易链式调用,从而缓解了回调地狱。

async/await 语法糖:这是对 Promise/Future 模型的一种更高级的抽象。它让异步代码写起来几乎和同步代码一样直观。你可以在一个异步函数前加上 `async` 关键字,然后在函数内部使用 `await` 关键字来等待另一个异步操作的完成。`await` 会自动处理暂停和恢复的逻辑,并返回异步操作的结果。这极大地提升了异步编程的可读性和易用性。

6. 为什么是“晚近”才流行?

综合以上几点,我们可以看出为什么异步编程的成熟支持是“晚近”才普及的:

1. 历史原因:早期计算机和编程语言设计时,同步模型是主流且容易理解,没有迫切的需求去解决异步带来的复杂性。
2. 硬件瓶颈凸显:随着硬件发展,CPU 速度远超 I/O,异步 I/O 的重要性才变得日益突出。
3. 早期解决方案的不足:回调虽然能解决问题,但体验很差;多线程虽强大,但复杂度和风险太高。
4. 技术成熟度与抽象的演进:协程、Promise/Future、async/await 等更优雅、更易用的异步模型,是经历了多年的探索和实践后,逐步发展和成熟起来的。语言设计者们看到了这种需求,并提供了更高级别的抽象来降低异步编程的门槛。
5. 生态系统的支持:随着 Node.js、WebAssembly 等技术的发展,异步编程在前端和后端都变得至关重要,推动了语言层面和框架层面对异步的支持。

所以,与其说编程语言“晚近才支持”异步编程,不如说是因为异步编程的模式和工具,直到最近才发展到足够成熟、足够易用,能够真正大规模地普及和被开发者们广泛接受。这就像一项技术,不是它不存在,而是直到找到了最适合它的“表达方式”和“实现工具”,才能真正发扬光大。

网友意见

user avatar

因为“直接在语言层面支持异步”并不是必需的;尤其是在经过大量实践、真正把各种异步模型的优缺点彻底摸清之前,“语言层面的异步支持”反而是笨拙的、多余的。


异步模型本来就有很多很多种。从早期的中断服务模型、多进程协同模型再到轻量级进程、线程乃至协程,业界也走过了很多弯路。

比如,早期的Windows 3.X搞的协作时多任务就是协程思路管理的进程,在开发者良莠不齐甚至抱有敌意的环境下这么搞完全是自寻烦恼;于是到了Windows 95就改用了“抢夺式多任务”调度方案。

再比如,线程刚刚兴起时,Linus坚持认为Linux的进程已经足够用了,而且还有轻量级进程可以用;这致使Linux有很多年都不能支持线程(可以通过库来支持,但库做不到“一个进程内的多个线程同时利用多个CPU核心”:其实不怕麻烦的话,用进程来实现线程、用共享内存模拟“可共享的进程内资源”,也还是可以写出“有多CPU支持的‘线’程库”的,但那就有点行为艺术了)。


那时只有服务器才较多使用了多CPU主板——注意和现在的多核心不同,多CPU主板上面有两个以上的CPU插槽,允许插多颗物理CPU;而且多颗物理CPU各自有自己的内存,不同CPU之间要访问对方的内存就必须通过进程间通讯机制挤总线传输数据。

那么,共享进程资源的线程显然并不能从中得到任何好处——因此,Linus的决定显然极有道理。


但是,CPU频率提升遇到了瓶颈,多核多线程时代来临。

“多线程CPU”指的是单个CPU核心内部制作了超量的逻辑单元,比如多个ALU、多个取指/译码电路,等等;这种CPU除非跑MMX/SSE/AVX之类指令,否则内部的大部分逻辑单元是不可能被充分利用的;但如果你有两条线程,它们就可以较为充分的利用这一颗CPU核心内部的大量逻辑单元了。

“多核CPU”则是把多个CPU核心封装在同一块芯片内部,物理上看是一颗CPU,插在只有一个CPU插槽的主板上、使用同一组内存条;但实际上,这颗CPU内部有若干个物理的CPU核心,你完全可以给它们分别安排不同的任务……

不仅如此。

当年AMD最早把两颗CPU核心做到同一片硅晶片上,首先推出了双核CPU;Intel仓皇应对,把两颗奔腾D晶片封装进去,也推出了自己的双核产品——后者被网友调侃为“胶水双核”。双方很是打了一番口水战。


实际上,当年的竞争还要激烈得多,复杂得多。

比如,RISC和CISC之争:CISC太复杂了,为了兼容,甚至连8086的指令都能在Pentium上跑,这得浪费多少芯片面积、给编译器造成多少麻烦……RISC决定抛掉兼容性包袱,精心优化指令集,限制缓慢的内存访问指令的数量、把节省下来的芯片面积多造寄存器、通过寄存器窗口切换寄存器组,使得函数调用/线程切换不影响执行效率;同时由于它的指令集更简洁,编译器优化就更容易……

又有人说,我们干嘛不在一颗CPU里面造很多很多逻辑单元呢?然后在编译器上下功夫,把大量用户操作整合进一条指令——就好像“背包问题”一样,一条指令整合尽可能多的操作、尽可能的充分利用CPU资源……这不就可以最大限度的提升指令执行效率了吗?

这就是所谓的“超长指令字计算机”。

此外,还有超级标量CPU等很多奇思妙想。这些东西都失败了;但这并不等于说它们都是错的;相反,它们仅仅是“商业上未能取得成功”而已;它们的思路还是被现代CPU汲取、整合进来了。比如,指令多发射、SIMD以及RISC的很多先进经验,现在都整合在x86里面了。


显然,CPU架构该如何发展,就连Intel/AMD都说不准。人类的能力,只能做到“走一步看一看”“提出一大堆看起来很美好的理论,到市场上比比优劣”,然后淘汰掉不合时宜的、保留其中更为优秀的。

甚至于,很多东西的走向仅仅决定于一个偶然——比如,如果不是AMD逼迫,或许intel就不会搞“胶水双核”方案;那么现在多核CPU的核心之间的联系可能就会紧密得多,甚至直接走“超长指令字计算机”的路子都有可能。

但一旦“胶水双核”的框架出来了、片内总线、共用cache以及有效性算法等等发展起来了、适应这个架构的高级指令集设计出来了,CPU的发展路径就被固定到现在这个模式上了。


类似的,显卡搞通用计算、做GPGPU深入人心了,那么CPU上面再搞AVX就要受限了——小打小闹你可以靠低延迟获胜;但真玩大的……怎么可能玩的过GPU?

但另一方面,CPU做核芯显卡,直接整合个GPU进去、然后再把GPU计算单元和CPU内部单元无缝融合……就好像80386CPU和80387数学协处理器被整合进486CPU一样,这会不会是未来的发展方向呢?


回到问题:在多核多线程架构完全确立下来之前,你想让编程语言如何支持异步编程呢?

在相关领域的研究/实践足够多、方案足够成熟之前,你连“异步究竟应该做成‘协作式多任务’还是‘抢占式多任务’”都不可能知道——async/await并不是表面看来那样,仅仅是一个简单的关键字;它的背后必需存在一个合理的体系,一个异步执行框架,不然就没法实现功能。


显然,过早的和一个不成熟的方案绑定(比如你的语言特性完全绑定于Windows3.1的协作式模型),只会让你的心血跟着这个不成熟的方案一起付诸东流。

因此,在时代来临之前,在编程语言中添加“异步编程的支持”,显然是有百害而无一利的。

这种东西就应该用库支持。将来架构发展方向变了,也就是废掉一个库的问题,不会拖死一整个语言,对吧。


但到了现在,由于硬件架构发展方向越发清晰、固定,我们终于可以确定“异步编程模型”应该是什么样子了:

1、最上层是进程;进程是持有资源的最小单位

2、中层是线程;线程不持有资源,是CPU调度的最小单位

3、下层是协程;协程既不持有资源、也不必在意CPU调度,它仅仅关注“协作式的、自然的执行流程切换”


当然,细节肯定还是会千变万化的;但大致来说这个整体图景不太可能有大的变动了。


底层稳定下来了,语言的直接支持才可能跟上。

不然的话,你见过哪门编程语言换个新一代的CPU就得禁用若干个关键字、或者把某些关键字的含义改变一番的?

python 2 to 3不过是风格上的少许改变,至今都还鸡飞狗跳的。它要告诉你“因为windows 95上进程的含义和Windows 3.1有所不同,因此你必须检查你的程序,在如下(省略五千字)情况下,请不要使用async关键字”或者“由于zen4改用了超长指令字架构,await在如下(省略一万字)情况下无法正常工作”,你还不得去刨Guido的祖坟啊。


反过来说也对:一旦大部分语言和OS和CPU的某个特性绑定,那么OS和CPU就没法改变设计了。比如,如果现在有人提出了一个比线程/进程模型更优越的新架构,OS/CPU制造商就不得不在“抛弃一部分语言和它们的用户”和“使用新架构但给出一个性能有所损失的兼容层”和“挺好的,但……算了,雪藏起来吧”之间做出选择。


再换句话说:程序设计语言本就应该和OS和CPU的具体实现脱耦;在进程-线程-协程模型确立之前,提供async/await就产生了“与OS或CPU的紧密耦合”,无论对编译器商还是对OS开发者还是CPU制造者,这都是个极大的不利。


注意这是“条件不成熟”,并不是什么“滞后”。

举例来说,AIO至今没有一个统一的方案(Windows仍然坚持自己的‘完成端口’,Linux则继续它的epoll,而BSD觉得kqueue挺好的);因此没有任何语言提供AIO的直接语法支持——你完全可以用诸如libevent之类库写出跨平台的高性能网络服务程序;这样将来倘若有人搞出来一个真正天才的、足以一统天下的方案,你只需提供一个接口兼容的转接层,原本的程序就仍然能运行。

但让某种语言直接提供支持?将来必定是天下大乱:改变语义,原有的项目统统死掉;内部做判断、给新项目用新语义,无论编译器还是程序编写的工作都会变得极其复杂、易错。

想想新的入门者必须区分3.7.13之前和之后的版本,不同版本await关键字含义大体相同但又有微妙的差别……

这也太疯狂太不负责任了,对吧。

user avatar

讲点儿别的……

你有两个前提搞错了

一是对异步的需求

一是编程语言的发展与变化



这两者都是近期才出现的

异步的需求是随着互联网时代才变得重要的

编程语言的蓬勃发展是虚拟机和中间语言成熟后才发生的

类似的话题

本站所有内容均为互联网搜索引擎提供的公开搜索信息,本站不存储任何数据与内容,任何内容与数据均与本站无关,如有需要请联系相关搜索引擎包括但不限于百度google,bing,sogou

© 2025 tinynews.org All Rights Reserved. 百科问答小站 版权所有