在非异步函数中使用"await"

113

我有一个异步函数,在我的代码中由setInterval运行。该函数按照一定的时间间隔更新一些缓存。

我还有一个不同的同步函数,需要检索值-最好是从缓存中,但如果缓存未命中,则从数据来源中获取(我意识到以同步方式进行IO操作是不明智的,但让我们假设在这种情况下需要这样做)。

我的问题是我希望同步函数能够等待来自异步函数的值,但无法在非async函数中使用await关键字:

function syncFunc(key) {
    if (!(key in cache)) {
        await updateCacheForKey([key]);
    }
}

async function updateCacheForKey(keys) {
    // updates cache for given keys
    ...
}

现在,可以通过将 updateCacheForKey 中的逻辑提取到一个新的同步函数中,并从现有函数中调用这个新函数来轻松规避这个问题。

我的问题是为什么要在第一时间绝对防止这种用例?我唯一的猜测是这与“白痴证明”有关,因为在大多数情况下,从同步函数等待异步函数是错误的。但我是否错了,有时它确实有其有效的用途?

(我认为在C#中也可以使用 Task.Wait ,但我可能搞混了)。


4
如果您在函数内部使用了 await,根据定义,该函数就是异步的。为什么不将 async 添加到 syncFunc 中呢?或者您是想让 syncFunc 在等待期间阻塞线程吗? - Mark
不确定现有代码存在什么问题? - guest271314
6
好的,我会尽力将“syncFunc”阻塞的意思表达清楚并保持原意不变。 - crogs
2
我跟你一起。每次我添加一个异步调用,我都必须遍历堆栈并在其调用函数中添加await,这需要在父函数中添加async,这又需要在那个函数中添加await,如此循环下去。 - john k
5个回答

98
我的问题是我希望同步函数能够等待异步函数返回值......但它们不能,因为:
1. JavaScript基于线程处理“作业队列”,其中作业具有完成语义。 2. JavaScript实际上没有异步函数——即使是async函数,在内部也是返回promise的同步函数。
作业队列(事件循环)的概念非常简单:当需要执行某些任务时(脚本的初始执行、事件处理程序回调等),该工作被放入作业队列中。服务于该作业队列的线程会获取下一个挂起的作业,运行到完成,并返回下一个作业。所以,当调用函数时,它是作业处理的一部分,作业总是在下一个作业运行之前处理完成。
处理完成意味着如果作业调用了函数,则该函数必须在作业完成之前返回。作业不会在线程运行其他操作时中途暂停。这使得编写正确且易于理解的代码比允许作业在进行其他操作时中途暂停要简单得多。因此,JavaScript实际上没有异步函数!
尽管我们谈论“同步”与“异步”函数,并且甚至可以对函数应用“async”关键字,但在JavaScript中,函数调用始终是“同步”的。异步函数是一种函数,它同步返回一个承诺,该函数的逻辑稍后将实现或拒绝,并排队回调环境稍后将调用的回调函数。
假设“updateCacheForKey”看起来像这样:
async function updateCacheForKey(key) {
    const value = await fetch(/*...*/);
    cache[key] = value;
    return value;
}

实际上,{{在内容底层}}它所做的是这样的:(非字面意思)

function updateCacheForKey(key) {
    return fetch(/*...*/).then(result => {
        const value = result;
        cache[key] = value;
        return value;
    });
}

我在我的最新书的第9章中详细讨论了这个问题,书名为JavaScript: The New Toys

它要求浏览器开始获取数据的过程,并通过then向其注册回调以便在数据返回时进行调用,然后退出,并从then 返回承诺。数据尚未被获取,但是updateCacheForKey已完成。它已返回。它同步地完成了它的工作。

稍后,当获取完成时,浏览器会排队调用该承诺回调的任务;当从队列中选择该任务时,回调将被调用,并且其返回值将用于解析then返回的承诺。

我的问题是为什么要在第一时间完全阻止这种使用情况?

让我们看看那会是什么样子:

  1. 线程接收到一个工作,其中涉及调用syncFunc,该函数调用updateCacheForKeyupdateCacheForKey请浏览器获取资源并返回它的承诺。通过这种非异步await的魔法,我们同步等待该承诺被解析,阻止工作的进行。

  2. 某个时刻,浏览器的网络代码完成了检索资源的工作,并排队调用我们在updateCacheForKey中注册的承诺回调的任务。

  3. 然后,永远不会再有任何事情发生。 :-)

因为任务具有运行至完成的语义,线程不允许在完成前一个任务之前选择下一个任务。线程不允许在调用syncFunc的任务中间挂起该任务,以便处理将解决承诺的任务。
这似乎是武断的,但再次强调,其原因在于它使编写正确代码和理解代码正在执行的内容变得极为容易。
但这意味着“同步”函数无法等待“异步”函数完成。
上面的细节和手势非常多。如果您想深入了解详情,可以查看规范。带上很多衣物和温暖的衣服,您需要花费一些时间。:-)

1
我知道这里只有一个线程,但为什么不让代码作者决定是否独占它呢?正如我之前提到的,我的问题很容易规避,我只是想知道为什么一开始就明确禁止这样做。 - crogs
1
@crogs:因为它们无法独占资源。为了完成异步操作,必须接收事件,这意味着必须排队一个作业来调用事件处理程序,然后必须从作业队列中选择该作业。如果运行同步函数的作业被阻塞,那么它就无法执行此操作,因为在同一环境中无法同时运行两个作业(即使其中一个被阻塞)。 - T.J. Crowder
如果 syncFunc 调用 updateCacheForKey 会发生什么?它会继续执行异步函数调用下面的语句,因为它无法挂起作业吗?顺便说一句,感谢您的好答案! - manuelgr
1
@ErebosM- 是的,没错。如果要查看updateCacheForKey的结果,它需要在返回的Promise上使用then方法(async函数返回Promise)。 - T.J. Crowder
1
该死!这不是正确的答案!(但它清楚地解释了为什么我不能做我想做的事情。) - Martin Bonner supports Monica

59

您可以通过立即调用的函数表达式 (IIFE)从一个异步函数内部调用异步函数:

(async () => await updateCacheForKey([key]))();

并且适用于您的示例:

function syncFunc(key) {
   if (!(key in cache)) {
      (async () => await updateCacheForKey([key]))();
   }
}

async function updateCacheForKey(keys) {
   // updates cache for given keys
   ...
}

1
另请参阅如何在顶层使用async/await? - DavidRR
42
这似乎不会等到函数执行完再继续! - Ayyappa
8
这只是创建了另一个异步函数并在其中放置了await,然后调用了外部异步函数而没有使用await。因此,外部函数将运行直到达到await,然后将控制权返回给syncFunc。然后,在await后面的所有内容都将在syncFunc完成后运行。你可以通过直接从runSync调用updateCacheForKey来实现类似的效果。 - Matthias Fripp
4
我已经投了反对票,因为这个没有更多解释是具有误导性的:如果updateCacheForKey()确实执行任何异步操作,它将不会立即调用。在这种情况下,立即发生的一切都只是创建了一个Promise(稍后执行)。然而,如果updateCacheForKey()确实是同步的,没有返回Promise或进行await,则会立即调用它。在这种情况下,这是一个非常酷的技巧,可以绕过仅async函数才能使用await的限制。(顺便说一句,被调用的函数有时可以是同步的,有时可以是异步的!) - Darren Cook
3
这个 hack 根本解决不了任何问题,完全没有用。你只是把一个悬挂的 promise 包裹在另一个异步函数中而已。你可以将异步函数放在外面,而不是在 IIFE 中,然后调用它时不使用 await,显然会遇到同样的问题。将其变成 IIFE 对问题毫无帮助。不知道为什么这个回答会有这么多赞,但它完全是错误的。 - Zachiah
显示剩余6条评论

7
这显示了一个函数可以同时是同步和异步的方式,以及即时调用函数表达式习惯只有在调用函数的路径执行同步操作时才是“即时的”。
function test() {
    console.log('Test before');
    (async () => await print(0.3))();
    console.log('Test between');
    (async () => await print(0.7))();
    console.log('Test after');
}

async function print(v) {
    if(v<0.5)await sleep(5000);
    else console.log('No sleep')
    console.log(`Printing ${v}`);
}

function sleep(ms : number) {
    return new Promise(resolve => setTimeout(resolve, ms));
  }

test();

(基于另一个答案评论中的Ayyappa代码。)

console.log看起来像这样:

16:53:00.804 Test before
16:53:00.804 Test between
16:53:00.804 No sleep
16:53:00.805 Printing 0.7
16:53:00.805 Test after
16:53:05.805 Printing 0.3

如果将0.7更改为0.4,则所有内容都将异步运行:

17:05:14.185 Test before
17:05:14.186 Test between
17:05:14.186 Test after
17:05:19.186 Printing 0.3
17:05:19.187 Printing 0.4

如果您将两个数字都更改为大于0.5,则一切都同步运行,不会创建任何Promise:

17:06:56.504 Test before
17:06:56.504 No sleep
17:06:56.505 Printing 0.6
17:06:56.505 Test between
17:06:56.505 No sleep
17:06:56.505 Printing 0.7
17:06:56.505 Test after

然而,这确实为原问题提供了一个答案。你可以编写一个类似这样的函数(免责声明:未经测试的nodeJS代码):

const cache = {}

async getData(key, forceSync){
if(cache.hasOwnProperty(key))return cache[key]  //Runs sync

if(forceSync){  //Runs sync
  const value = fs.readFileSync(`${key}.txt`)
  cache[key] = value
  return value
  }

//If we reach here, the code will run async
const value = await fsPromises.readFile(`${key}.txt`)
cache[key] = value
return value
}

6
现在,可以通过将updateCacheForKey中的逻辑提取到一个新的同步函数中,并从两个现有函数中调用这个新函数来轻松地规避这个问题。 T.J. Crowder完美地解释了JavaScript中异步函数的语义。但是在我看来,上面的段落值得更多的讨论。根据updateCacheForKey所做的事情,可能无法将其逻辑提取为同步函数,因为在JavaScript中,有些事情只能异步完成。例如,没有办法同步执行网络请求并等待其响应。如果updateCacheForKey依赖服务器响应,则无法将其转换为同步函数。
即使在异步函数和承诺出现之前,这也是真实的:例如,XMLHttpRequest获取回调并在响应准备就绪时调用它。无法同步获取响应。承诺只是回调的抽象层,而异步函数只是承诺的抽象层。
现在,这个问题可以以不同的方式解决。在某些环境中,这确实是这样做的:
PHP中,几乎所有的操作都是同步的。你使用curl发送一个请求后,脚本会阻塞,直到收到响应。 Node.js有一些同步版本的文件系统调用(readFileSyncwriteFileSync等),这些调用会阻塞,直到操作完成。
甚至普通的浏览器JavaScript也有alert和其他函数(confirmprompt),这些函数会阻塞,直到用户关闭弹出窗口。
这表明JavaScript语言的设计者们 本可以 选择使用同步版本的XMLHttpRequestfetch等函数。但他们为什么没有呢?

[W]hy absolutely prevent this use case in the first place?

这是一个设计决策。

alert,例如,阻止用户与页面的其他部分交互,因为JavaScript是单线程的,唯一的执行线程被阻塞直到alert调用完成。因此没有办法执行事件处理程序,这意味着无法变得交互式。如果有一个syncFetch函数,它会阻止用户做任何事情,直到网络请求完成,这可能需要几分钟,甚至几小时或几天。

这显然违反了我们所谓的“Web”互动环境的本质。回顾起来,alert是一个错误,除非在非常少的情况下使用,否则不应使用。

唯一的替代方案就是允许JavaScript多线程,这在编写正确程序方面是出了名的困难。你是否对异步函数感到困惑?试试信号量!


2

可以在异步函数中添加一个好的.then()并且它会工作。

不过应该考虑将当前的普通函数改为异步函数,一直到返回的promise不再需要,即没有任何工作需要处理来自异步函数的返回值。在这种情况下,它实际上可以从同步函数中调用。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接