首页 > 解决方案 > 延续传递风格和并发性

问题描述

我发现很多博客都提到并发/非阻塞/异步编程是连续传递风格(CPS)的一个好处。我不明白为什么 CPS 提供并发性,例如,人们提到 Node.js 是使用 CPS 实现的,尽管 JavaScript 是一种同步语言。有人会评论我的想法吗?

首先,我对 CPS 的幼稚理解是将所有后续代码包装到一个函数中,并将该函数显式作为参数传递。一些博客将延续函数命名为return(),Gabriel Gonzalez 称其为hole,这两个都是绝妙的解释。

我的困惑主要来自一篇流行的博客文章Asynchronous programming and continuation-passing style in JavaScript。在文章的开头,Axel Rauschmayer 博士给出了 CPS 中的两个代码片段,一个同步程序和一个异步程序(此处粘贴以方便阅读)。

同步代码:

function loadAvatarImage(id) {
    var profile = loadProfile(id);
    return loadImage(profile.avatarUrl);
}

异步代码:

function loadAvatarImage(id, callback) {
    loadProfile(id, function (profile) {
        loadImage(profile.avatarUrl, callback);
    });
}

我不明白为什么 CPS 是异步的。在我阅读了另一篇文章By example: Continuation-passing style in JavaScript之后,我想可能对代码有一个假设:函数loadProfile()loadImage()本身是异步函数。那么使它异步的不是 CPS。在第二篇文章中,作者实际展示了 的实现fetch(),与之前的博客中的类似loadProfile()。该fetch()函数通过调用来明确假设底层并发执行模型req.onreadystatechange。这让我想到也许不是 CPS 提供了并发性。

假设底层函数是异步的,那么我进入我的第二个问题:我们可以在没有 CPS 的情况下编写异步代码吗?想想函数的实现loadProfile()。如果不是因为 CPS 是异步的,那我们为什么不能采用同样的机制来loadAvatarImage()异步实现呢?假设loadProfile()使用fork()创建一个新线程来发送请求并等待响应,而主线程以非阻塞方式执行,我们可以对loadAvatarImage().

function loadAvatarImage(id, updateDOM) {
    function act () {
        var profile = loadProfile(id);
        var img = loadImage(profile.avatarUrl);
        updateDOM (img);
    }
    fork (act());
}

我给它一个回调函数updateDOM()。没有updateDOM(), 和 CPS 版本比较是不公平的——CPS 版本有额外的信息关于获取图像后要做什么,即callback函数,但原始同步loadAvatarImage()没有。

有趣的是,@DarthFennec 指出我的 newloadAvatarImage()实际上是 CPS:fork()是 CPS,act()是 CPS(如果我们明确给出updateDOM),并且loadAvatarImage()是 CPS。链使loadAvatarImage()异步。loadProfile()并且loadImage()不需要异步或CPS。

如果到这里的推理是正确的,我能得到这两个结论吗?

  1. 给定一组同步 API,按照 CPS 编写代码的人不会神奇地创建异步函数。
  2. 如果底层异步/并发 API 以 CPS 样式提供,例如 CPS 版本的loadProfile()loadImage()fetch()fork(),则只能以 CPS 样式编写代码以确保异步 API 被异步使用,例如,return loadImage(profile.avatarUrl)将使 的并发无效loadImage()

标签: javascriptmultithreadinghaskellasynchronousconcurrency

解决方案


Javascript 的简要概述

Javascript 的并发模型是非并行协作的:

  • Javascript是非并行的,因为它在单个线程中运行;它通过交错多个执行线程而不是实际同时运行它们来实现并发。
  • Javascript 是协作的,因为调度程序仅在当前线程请求时才切换到不同的线程。另一种方法是抢占式调度,调度程序决定在任何时候随意切换线程。

通过做这两件事,Javascript 避免了许多其他语言没有的问题。并行代码和非并行抢占式调度代码不能做出基本假设,即变量不会在执行过程中突然改变它们的值,因为另一个线程可能同时处理同一个变量,或者调度程序可能决定绝对在任何地方交错另一个线程。这会导致互斥问题和令人困惑的竞争条件错误。Javascript 避免了所有这些,因为在协作调度的系统中,程序员决定所有交错发生的位置。这样做的主要缺点是,如果程序员决定长时间不创建交错,其他线程永远没有机会运行。在浏览器中,即使是轮询用户输入和对页面进行绘图更新等操作也在与 Javascript 相同的单线程环境中运行,因此长时间运行的 Javascript 线程会导致整个页面变得无响应。

一开始,CPS 最常用于 Javascript 中,用于事件驱动的 UI 编程:如果您希望每次有人按下按钮时运行一些代码,您可以将回调函数注册到按钮的'click'事件; 单击按钮时,回调将运行。事实证明,同样的方法也可以用于其他目的。假设你想等一分钟,然后做一件事。天真的方法是将 Javascript 线程停止 60 秒,这(如上所述)会导致页面在此期间崩溃。但是,如果计时器作为 UI 事件公开,则线程可能会被调度程序挂起,同时允许其他线程运行。然后,计时器将导致回调执行,就像按下按钮一样。相同的方法可用于从服务器请求资源,或等待页面完全加载,或其他一些事情。这个想法是,保持 Javascript响应尽可能地,任何可能需要很长时间才能完成的内置功能都应该是事件系统的一部分;换句话说,它应该使用 CPS 来启用并发性。

大多数支持协作调度的语言(通常以协程的形式)都有特殊的关键字和语法,必须用来告诉语言交错。例如,Python 有yield关键字,C# 有asyncandawait等。在最初设计 Javascript 时,它没有这样的语法。但是,它确实支持闭包,这是允许 CPS 的一种非常简单的方法。我预计这背后的意图是支持事件驱动的 UI 系统,并且它从未打算成为通用并发模型(尤其是当 Node.js 出现并完全删除 UI 方面时)。不过我不确定。

为什么 CPS 提供并发?

需要明确的是,连续传递样式是一种可用于启用并发的方法。并非所有 CPS 代码都是并发的。CPS 不是创建并发代码的唯一方法。CPS 对于启用并发性以外的其他事情很有用。简单地说,CPS 并不一定意味着并发,反之亦然。

为了交错线程,必须以这样一种方式中断执行,以便以后可以恢复。这意味着必须保留线程的上下文,然后再恢复。通常无法从程序内部访问此上下文。正因为如此,支持并发的唯一方法(缺少具有特殊语法的语言)是以线程上下文编码为值的方式编写代码. 这就是 CPS 所做的:要恢复的上下文被编码为可以调用的函数。这个函数被调用相当于一个线程被恢复。这可能在任何时候发生:在加载图像之后,在计时器触发之后,在其他线程有机会运行一段时间之后,甚至是立即。由于上下文都被编码到延续闭包中,所以没关系,只要它最终运行。

为了更好地理解这一点,我们可以编写一个简单的调度程序:

var _threadqueue = []

function fork(cb) {
    _threadqueue.push(cb)
}

function run(t) {
    _threadqueue.push(t)
    while (_threadqueue.length > 0) {
        var next = _threadqueue.shift()
        next()
    }
}

使用中的一个例子:

run(function() {
    fork(function() {
        console.print("thread 1, first line")
        fork(function() {
            console.print("thread 1, second line")
        })
    })
    fork(function() {
        console.print("thread 2, first line")
        fork(function() {
            console.print("thread 2, second line")
        })
    })
})

这应该将以下内容打印到控制台:

thread 1, first line
thread 2, first line
thread 1, second line
thread 2, second line

结果是交错的。虽然它本身并不是特别有用,但这种逻辑或多或少是 Javascript 并发系统之类的基础。

我们可以在没有 CPS 的情况下编写异步代码吗?

仅当您可以通过其他方式访问上下文时。如前所述,许多语言通过特殊关键字或其他语法来做到这一点。一些语言有特殊的内置函数:Scheme 有call/cc内置函数,它将当前上下文包装成一个可调用的类函数对象,并将该对象传递给它的参数。操作系统通过从字面上复制线程的调用堆栈来获得并发性(调用堆栈包含恢复线程所需的所有上下文)。

如果你的意思是专门用 Javascript,那么我相当肯定没有 CPS 就不可能合理地编写异步代码。或者它会是,但是较新版本的 Javascript 也带有asyncandawait关键字,以及yield关键字,因此使用这些成为一种选择。

结论:给定一组同步 API,按照 CPS 编写代码的人不会神奇地创建异步函数。

正确的。如果 API 是同步的,CPS 本身不会使该 API 异步。它可能会引入一定程度的并发性(如前面的示例代码),但这种并发性只能存在于线程中。Javascript 中的异步加载有效,因为加载本身与调度程序并行运行,因此使同步 API 异步的唯一方法是在单独的系统线程中运行它(这不能在 Javascript 中完成)。但即使你这样做了,除非你也使用 CPS,否则它仍然不会是异步的。

CPS 不会导致异步。然而,异步性确实需要 CPS,或 CPS 的替代方案。

结论:如果底层异步/并发API以CPS风格提供,那么就只能用CPS风格编码

正确的。如果 API 是loadImage(url, callback)并且您运行return loadImage(profile.avatarUrl),它将null立即返回并且不会给您图像。很可能它会抛出一个错误,因为callbackis undefined,因为你没有通过它。本质上,如果 API 是 CPS,而您决定不使用 CPS,则说明您没有正确使用 API。

但总的来说,如果你写一个调用 CPS 函数的函数,你的函数也需要是 CPS 是准确的。这实际上是一件好事。还记得我说过的关于变量不会在执行过程中突然改变其值的基本假设吗?CPS 通过向程序员明确交错边界的确切位置来解决这个问题。或者更确切地说,值可能会任意改变。但是,如果您可以将 CPS 函数调用隐藏在非 CPS 函数中,您将无法分辨。这也是较新的 Javascriptasyncawait关键字以它们的方式工作的原因:任何使用的函数都await必须标记为async,并且对函数的任何调用都async必须以await关键字(不仅如此,我还不想深入探讨 Promise 是如何工作的)。正因为如此,你总是可以知道你的交错边界在哪里,因为那里总会有await关键字。


推荐阅读