连续传递样式和并发性

时间:2018-06-20 22:41:27

标签: javascript multithreading haskell asynchronous concurrency

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

首先,我对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使它异步。在the second article中,作者实际上显示了fetch()的实现,这与前面博客中的loadProfile()相似。 fetch()函数通过调用req.onreadystatechange对底层并发执行模型进行了显式假设。这使我认为提供并发的可能不是CPS。

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

loadAvatarImage()

我给它提供了一个回调函数function loadAvatarImage(id, updateDOM) { function act () { var profile = loadProfile(id); var img = loadImage(profile.avatarUrl); updateDOM (img); } fork (act()); } 。如果没有updateDOM(),将它与CPS版本进行比较是不公平的-CPS版本具有有关在获取图像后要执行的操作的更多信息,即updateDOM()函数,但原始同步callback不会。

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

如果这里的推理是正确的,我可以得出这两个结论吗?

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

1 个答案:

答案 0 :(得分:2)

JavaScript的简要概述

Javascript的并发模型是非并行合作

  • JavaScript是 non-parallel ,因为它在单个线程中运行;它通过交织多个执行线程,而不是同时运行它们来实现并发。
  • Javascript是 cooperative ,因为调度程序仅在当前线程要求时才切换到其他线程。另一种选择是抢先调度,其中调度程序决定在需要时任意切换线程。

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

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

大多数支持协作调度的语言(通常以协程的形式)具有特殊的关键字和语法,必须使用这些关键字和语法来告诉该语言进行交织。例如,Python使用yield关键字,C#使用asyncawait,依此类推。最初设计Javascript时,它没有这样的语法。但是,它确实支持闭包,这是允许CPS的非常简单的方法。我希望其背后的意图是支持事件驱动的UI系统,并且永远不要将其变成通用的并发模型(尤其是一旦Node.js出现并完全删除UI方面时)。不过我不确定。

为什么CPS提供并发?

很明显,连续传递样式是可以使用 启用并发的一种方法。并非所有CPS代码都是并发的。 CPS不是创建并发代码的唯一方法。 CPS除了启用并发外,还可以用于其他事情。简而言之,CPS不一定意味着并发,反之亦然。

为了交织线程,必须以某种方式中断执行,以便以后可以恢复执行。这意味着必须保留线程的 context ,然后再将其恢复。通常无法从程序内部访问此上下文。因此,支持并发的唯一方法(缺少具有特殊语法的语言)是以这样的方式编写代码,即将线程上下文编码为值。这就是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还带有asyncawait关键字以及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,并且不会给您图像。因为您未通过callbackundefined,所以很可能会引发错误。本质上,如果API是CPS,并且您决定不使用CPS,则说明您没有正确使用API​​。

尽管如此,总的来说,准确地说,如果您编写一个调用CPS函数的函数,那么您的函数也必须是CPS。这实际上是一件好事。还记得我所说的关于变量不会在执行过程中突然改变其值的基本假设吗? CPS通过使程序员非常清楚交织边界的确切位置来解决此问题。或者更确切地说,值可能会任意更改。但是,如果您可以将CPS函数调用隐藏在非CPS函数内部,则您将无法分辨。这也是较新的Javascript asyncawait关键字起作用的原因:任何使用await的函数都必须标记为async,并且任何对async函数必须以await关键字为前缀(功能不止于此,但我不想立即了解诺言如何工作)。因此,您总是可以知道交织边界在哪里,因为那里总是有await个关键字。