超硬核五千字!彻底讲明白JavaScript中的异步和同步,以及JavaScript代码执行顺序

同步操作和异步操作是编程中处理任务的两种不同方式,它们主要区别在于控制流和对程序执行的影响。不知道大家是怎么理解JavaScript中的同步和异步的?JavaScript的代码执行顺序是怎么样?下面这段代码是同步还是异步的?

console.log('Start');
const response = await fetch('https://juejin.cn');
const data = await response.json();
console.log('Data received:', data);
console.log('End');

同步操作 (Synchronous)

定义: 同步操作会阻塞当前线程或进程,直到该操作完成。这意味着当一段代码执行同步操作时,后面的代码必须等待该操作完成才能继续执行。在同步操作中,任务按顺序执行,一个任务必须完成后才能执行下一个任务。这种方式会阻塞程序的执行,直到当前任务完成。

特点:

  • 简单直观,代码按照从上到下的顺序执行。
  • 在执行耗时操作时会阻塞其他任务,可能导致UI无响应(在前端JavaScript中)。

JavaScript示例:

console.log('Start');
function synchronousTask() {
    for (let i = 0; i < 1000000000; i++) { /* 耗时任务 */}
    console.log('Task done');
}
synchronousTask();
console.log('End');

在这个例子中,循环是一个简单的模拟耗时操作。当这段代码执行时,console.log('Start'); 先执行并打印 StartsynchronousTask(); 开始执行,直到循环结束,才会打印出 “End”。在此期间,无法处理其他任务或用户交互。

异步操作 (Asynchronous)

定义: 异步操作允许程序在等待某个操作完成的同时继续执行其他任务,不阻塞当前线程。异步操作通常用于处理I/O操作、网络请求、定时器等潜在的长时间操作。

特点:

  • 程序可以继续执行其他任务,提高应用的响应性和性能,特别是在处理I/O密集型任务时。
  • 代码逻辑更复杂,需要回调函数、Promises或async/await来处理异步流程。

我们从代码中理解异步,这里使用一个定时器,JavaScript示例:

console.log('Start');

function asynchronousTask() {
    setTimeout(() => {
        console.log('Task done');
    }, 1000);
}

asynchronousTask();

console.log('End');

在这段代码中,执行顺序如下所示:

  1. console.log('Start'); 先执行并打印 Start
  2. asynchronousTask(); 开始执行,setTimeout 设置了一个异步任务,这个任务在 1 秒后执行,但不会阻塞程序
  3. console.log('End'); 立即执行并打印 End
  4. 1 秒后,异步任务完成,console.log('Task done'); 执行并打印 Task done

但是一般在写代码的时候,我们的异步操作肯定不是通过定时器完成的,下面给大家展示两种异步操作写法,分别如下所示:

1、使用 Promise 处理异步操作

console.log('Start');

function asynchronousTask() {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve('Task done');
        }, 1000);
    });
}

asynchronousTask().then(message => {
    console.log(message);
});

console.log('End');

在这个例子中:

  1. asynchronousTask 返回一个 Promise,它在 1 秒后解决(resolve)
  2. console.log('Start');console.log('End'); 立即执行
  3. 1 秒后,Promise 被解决,console.log(message); 打印 Task done

2、使用 async/await 处理异步操作

console.log('Start');

async function runAsyncTask() {
    const message = await asynchronousTask();
    console.log(message);
}

runAsyncTask();

console.log('End');

在这个例子中:

  1. async 函数 runAsyncTask 内部使用 await 等待 asynchronousTask 完成,但不会阻塞外部代码
  2. console.log('Start');console.log('End'); 立即执行
  3. 1 秒后,asynchronousTask 完成,console.log(message); 打印 Task done

现在是不是对同步和异步的概念有了基本的认识,咱们再回到文章最开始的代码,结论是那段代码不是同步的,而是异步的。在上面代码中,遇到 await 等待结果阻塞代码执行了,为什么不说它是同步?

为了更好地理解,可以将这段代码放在一个异步函数中执行:

async function fetchData() {
    console.log('Start');

    const response = await fetch('https://api.example.com/data');
    const data = await response.json();
    console.log('Data received:', data);

    console.log('End');
}

fetchData();

我们先理解一下这个关键词:await关键字会暂停当前的异步函数,等待异步操作完成,但不会阻塞外部代码,不会阻塞事件循环

在这个例子中,fetchData是一个异步函数,尽管await关键字会暂停它的执行,但它不会阻塞整个程序的执行。因此,这段代码是异步的,而不是同步的。假设fetchData函数后面有一个输出,它可能先执行的。

await 仅会暂停当前异步函数的执行,让出控制权给事件循环,以便处理其他任务。

当一个异步函数遇到 await 时,它会暂停该函数的执行,等待 Promise 被解决(resolved)或者被拒绝(rejected)。然而,这种暂停并不等同于传统的同步阻塞,因为其他任务依然可以执行。

咱们接下来通过一段代码来展示什么是阻塞整个程序的执行。在JavaScript中,阻塞代码通常是指那些在执行时会阻塞事件循环,导致其他任务无法执行的代码。常见的例子包括使用同步的I/O操作或长时间运行的计算。

以下是一个使用同步I/O操作的例子,这种操作会阻塞整个程序的执行:

const fs = require('fs');

function readFileSync() {
    // 打印"Start"到控制台
    console.log('Start');

    // 同步读取文件的内容
    // 在文件读取完成之前,这行代码会阻塞事件循环,其他任务无法执行
    const data = fs.readFileSync('/opt/xiaodou.txt', 'utf8');
    // 打印读取到的文件内容到控制台
    console.log('File data:', data);
    // 打印"End"到控制台
    console.log('End');
}

readFileSync();

在这个例子中,fs.readFileSync是一个同步的文件读取操作,它会阻塞事件循环,直到文件读取完成。在读取文件的过程中,其他任务无法执行,整个程序的执行被阻塞。

这段代码的执行顺序是严格线性的,整个程序会在读取文件时被阻塞,直到读取操作完成。

为了对比,可以看一下使用异步I/O操作的代码,这样的代码不会阻塞事件循环:

const fs = require('fs');

function readFileAsync() {
    console.log('Start');

    // 读取文件的异步操作,这不会阻塞事件循环
    fs.readFile('/opt/xiaodou.txt', 'utf8', (err, data) => {
        if (err) {
            console.error('Error reading file:', err);
            return;
        }
        console.log('File data:', data);
    });

    console.log('End');
}

readFileAsync();

在这个例子中,fs.readFile是一个异步的文件读取操作,它不会阻塞事件循环,程序可以继续执行其他任务。

现在对JavaScript中的同步是不是有一些理解:导致整个程序的执行被阻塞是同步,只是暂停当前函数执行是异步,使用await关键字时,它会暂停当前异步函数的执行,等待异步操作完成,但不会阻塞事件循环,其他任务可以继续执行。

我们来看一下阻塞整个程序的代码,不仅会阻塞当前函数,还会阻塞整个事件循环,影响所有其他任务的执行,咱们现在在上面的同步函数中增加一个定时器,那么你猜猜定时器还能定时执行吗?

修改后的代码如下所示:

const fs = require('fs');

function readFileSync() {
    console.log('Start');

    // 设置一个定时器,计划在1秒后执行
    setTimeout(() => {
        console.log('Timer executed');
    }, 1000);

    // 同步读取文件的操作,这会阻塞事件循环
    const data = fs.readFileSync('/opt/xiaodou.txt', 'utf8');
    console.log('File data:', data);

    console.log('End');
}

readFileSync();

在这个例子中,fs.readFileSync是一个同步操作,它会阻塞事件循环,假设文件在1秒后无法读取完成,那么定时器无法在预定的1秒后执行。只有当文件读取操作完成后,定时器才会有机会执行。

事件循环是啥?

在上面我们一直提到事件循环,事件循环(Event Loop)是JavaScript运行时的一个重要机制,它允许非阻塞的异步编程,即使JavaScript是单线程的。事件循环负责管理和调度异步操作的执行,使得异步任务能够在任务完成后被处理,而不会阻塞主线程。

事件循环的工作原理

调用栈(Call Stack)

  • JavaScript引擎有一个调用栈,这是一个LIFO(后进先出)数据结构,用于跟踪正在执行的函数。
  • 当一个函数被调用时,它会被推入调用栈,当函数执行完成后,它会从调用栈中弹出。

任务队列(Task Queue)

  • 任务队列是一个FIFO(先进先出)数据结构,用于存储等待执行的回调函数。
  • 异步操作(如定时器、网络请求、事件处理等)完成后,其回调函数会被放入任务队列中。

事件循环(Event Loop)

  • 事件循环不断地检查调用栈是否为空。
  • 如果调用栈为空,并且任务队列中有待处理的任务,事件循环会将任务队列中的第一个任务移到调用栈中执行。

是不是感觉很难理解呢?接下来咱们结合代码理解一这一块的内容。

1、简单的示例

还是使用定时器的代码示例,说明一下事件循环的工作原理:

console.log('Start');

setTimeout(() => {
    console.log('Timer executed');
}, 1000);

console.log('End');

执行过程

调用栈 任务队列 事件循环 console.log('Start') 输出 "Start" setTimeout 设置定时器(1秒后回调) console.log('End') 输出 "End" 1秒后 超时回调函数 将回调函数推入调用栈 执行回调函数 输出 "Timer executed" 调用栈 任务队列 事件循环

输出结果

Start
End
Timer executed

2、复杂的代码

让我们看一个更复杂的例子,包含多个异步操作:

console.log('Start');

setTimeout(() => {
    console.log('Timeout 1');
}, 500);

setTimeout(() => {
    console.log('Timeout 2');
}, 100);

Promise.resolve().then(() => {
    console.log('Promise 1');
});

Promise.resolve().then(() => {
    console.log('Promise 2');
});

console.log('End');

执行过程

调用栈 任务队列 微任务队列 事件循环 console.log('Start') 输出 "Start" setTimeout (500ms) 超时回调 1 (500ms) setTimeout (100ms) 超时回调 2 (100ms) Promise.resolve().then Promise 回调 1 Promise.resolve().then Promise 回调 2 console.log('End') 输出 "End" 检查微任务队列 执行 Promise 回调 1 输出 "Promise 1" 执行 Promise 回调 2 输出 "Promise 2" 100ms 后 执行超时回调 2 输出 "Timeout 2" 500ms 后 执行超时回调 1 输出 "Timeout 1" 调用栈 任务队列 微任务队列 事件循环

输出结果

Start
End
Promise 1
Promise 2
Timeout 2
Timeout 1

同学们,是不是发现个有趣的东西,这里多了一个微任务队列,那么微任务队列是什么?和任务队列是什么关系?

微任务和宏任务又是啥?

在JavaScript中,微任务(microtasks)和宏任务(macrotasks)是事件循环中处理异步任务的两种类型。它们代表了不同优先级的任务队列,事件循环会按照特定的顺序执行这些任务。

宏任务(Macro Task)

这个实际上就是咱们之前提到的任务队列中的任务。宏任务是那些在每个执行栈执行完毕后,从事件队列中取出并执行的任务。宏任务的执行频率低于微任务,因为它们需要等待整个执行栈清空后再执行。它们通常包括以下几类操作:

  • setTimeout
  • setInterval
  • setImmediate()(Node.js特有)
  • requestAnimationFrame()(浏览器特有)
  • I/O 操作
  • UI渲染(浏览器)
  • 其他一些异步任务

宏任务会被推入到任务队列中,事件循环会从任务队列中依次取出宏任务并执行。简单记忆就是:需要较长时间才来完成的任务,毕竟宏伟的工程往往需要很长的时间

微任务(Microtasks)

微任务是一些需要在当前任务执行完毕后立即执行的任务。它们通常是由Promises、process.nextTick(Node.js特有)或者MutationObserver等机制产生的。微任务队列优先级高于宏任务队列,这意味着在每个宏任务执行完毕后,事件循环会优先处理微任务队列中的所有任务,然后再处理下一个宏任务。它们通常包括以下几类操作:

  • Promises的.then().catch().finally()回调
  • process.nextTick()(Node.js)
  • queueMicrotask()(浏览器和Node.js)
  • MutationObserver(用于监听DOM变化)

微任务会被推入到微任务队列中,事件循环在每次执行完一个宏任务后,会检查并执行所有微任务,直到微任务队列为空。简单速记:微任务是指哪些需要尽快被完成的任务,小事要尽快做,拖久了就变大事了

干说有点费劲,给同学们来张流程图描述事件循环的基本原理:

有任务
无任务
有微任务
无微任务
有宏任务
无宏任务
开始事件循环
同步任务队列
执行同步任务
检查微任务队列
任务执行完毕
执行微任务
检查宏任务队列
执行宏任务
等待新任务

这是一个简化的时序图,事件循环的主要步骤如下:

  1. 开始事件循环:JavaScript引擎开始执行代码。

  2. 同步任务队列:引擎首先检查同步任务队列(实际上是调用栈中的任务),执行队列中的任务。

  3. 执行同步任务:引擎开始执行同步任务,直到队列为空。

  4. 检查微任务队列:同步任务执行完毕后,引擎检查微任务队列。

  5. 执行微任务:如果有微任务(如Promise的.then()回调),引擎会执行这些微任务。

  6. 检查宏任务队列:微任务执行完毕后,引擎检查宏任务队列。

  7. 执行宏任务:如果有宏任务(如setTimeout回调),引擎会执行这些宏任务。执行完毕后再次检查微任务队列。

  8. 等待新任务:如果宏任务执行完毕,引擎会等待新的任务(如新的异步操作或宏任务)。

  9. 循环:引擎会不断地循环执行上述步骤。

同学们这时候会不会有很多疑问,如果觉得上面的文字还不好理解,咱们接下来跟着代码走一遍。

代码示例讲解

咱们还是在上面代码的基础上做修改,这里增加一个包含await的方法,代码如下所示:

console.log('Start'); // 同步任务,立即执行

setTimeout(() => {
    console.log('Timeout 1'); // 宏任务,500ms后执行
}, 500);

setTimeout(() => {
    console.log('Timeout 2'); // 宏任务,100ms后执行
}, 100);

async function asyncFunction() {
    console.log('Async Function Start'); // 同步任务,立即执行
    await Promise.resolve(); // 微任务,立即执行
    console.log('Async Function End'); // 微任务,放入微任务队列
}

asyncFunction(); // 调用异步函数

Promise.resolve().then(() => {
    console.log('Promise 1'); // 微任务,放入微任务队列
});

Promise.resolve().then(() => {
    console.log('Promise 2'); // 微任务,放入微任务队列
});

console.log('End'); // 同步任务,立即执行

时序图

调用栈 微任务队列 宏任务队列 console.log('Start') setTimeout(..., 500) setTimeout(..., 100) asyncFunction() console.log('Async Function Start') await Promise.resolve() console.log('Async Function End') Promise.resolve().then(...) console.log('Promise 1') Promise.resolve().then(...) console.log('Promise 2') console.log('End') 调用栈为空,检查微任务队列 console.log('Promise 1') console.log('Promise 2') console.log('Async Function End') 微任务队列为空,检查宏任务队列 100ms后 console.log('Timeout 2') 500ms后 console.log('Timeout 1') 调用栈 微任务队列 宏任务队列

详细解释一下代码中的任务都有哪些:

同步任务

  • console.log('Start'):立即执行,输出 “Start”。
  • setTimeout(..., 500):注册一个500ms后的宏任务。
  • setTimeout(..., 100):注册一个100ms后的宏任务。
  • asyncFunction():调用异步函数,输出 “Async Function Start”。
  • await Promise.resolve():注册一个微任务。
  • Promise.resolve().then(...):注册两个微任务。
  • console.log('End'):立即执行,输出 “End”。

微任务

  • Promise 1:输出 “Promise 1”。
  • Promise 2:输出 “Promise 2”。
  • Async Function End:输出 “Async Function End”。

宏任务

  • Timeout 2:100ms后输出 “Timeout 2”。
  • Timeout 1:500ms后输出 “Timeout 1”。

代码运行输出结果如下所示:

Start
Async Function Start
End
Promise 1
Promise 2
Async Function End
Timeout 2
Timeout 1

脑洞大开,搞事情

咱们能写一个阻塞事件循环的函数吗?

现在我们已经了解了基本原理,那么我们肯定可以搞点事情,我们可以使用循环阻止其他任务的执行,如下所示,这个函数会在指定的时间内阻塞事件循环,使得其他任务(包括异步操作和定时器)无法执行。

function blockEventLoop(duration) {
    const startTime = Date.now();
    while (Date.now() - startTime < duration) {
        // 这个循环会一直运行,直到达到指定的持续时间
    }
    console.log(`Blocked for ${duration} ms`);
}

// 示例用法
console.log('Start');

setTimeout(() => {
    console.log('Timeout executed');
}, 1000);

blockEventLoop(3000);  // 阻塞事件循环3秒

console.log('End');

我们来分析一下执行过程

  1. 开始执行console.log('Start'); 被推入调用栈,执行并输出 “Start”,然后从调用栈中弹出。

  2. 设置定时器setTimeout 被推入调用栈,设置一个1秒后的定时器。定时器的回调函数被注册,并计划在1秒后执行。最后setTimeout 从调用栈中弹出。

  3. 阻塞事件循环blockEventLoop(3000) 被推入调用栈,开始执行。进入while循环,持续3秒,期间事件循环被阻塞,无法处理其他任务。3秒后,while循环结束,输出 “Blocked for 3000 ms”。最后blockEventLoop 从调用栈中弹出。

  4. 继续执行console.log('End'); 被推入调用栈,执行并输出 “End”,然后从调用栈中弹出。

  5. 事件循环处理定时器:由于事件循环被阻塞了3秒,1秒后的定时器未能在预定时间执行。事件循环恢复后,定时器的回调函数被移到调用栈中执行,输出 “Timeout executed”。

代码输出结果如下所示,不知道你对了吗?

Start
Blocked for 3000 ms
End
Timeout executed

注意事项

阻塞事件循环会导致所有其他任务无法执行,不能瞎搞,尤其是在处理用户界面或需要高响应性的应用程序中。不然会被用户骂的。

拓展:JavaScript和Java在同步和异步编程上的差异

因为我之前是做Java开发,所以在这里增加一块两者的对比,有兴趣的可以看一看,JavaScript和Java在处理同步和异步操作方面确实有一些差异,主要体现在它们的运行时环境和编程模型上。

JavaScript中的同步和异步

JavaScript是单线程的,这意味着它一次只能执行一个任务。为了处理异步操作,JavaScript依赖于事件循环(Event Loop)机制。事件循环允许JavaScript在等待异步操作(如网络请求、定时器等)完成时继续执行其他任务,从而实现非阻塞的异步编程。

  • 同步操作:在调用栈中按顺序执行,每个操作必须等待前一个操作完成。
  • 异步操作:通过回调函数、Promise、async/await等机制实现,异步操作完成后会将回调函数放入任务队列,由事件循环调度执行。

Java中的同步和异步

Java是多线程的,支持并发编程。Java中的异步操作通常通过多线程实现,每个线程可以独立执行任务,从而实现并发和并行处理。Java也有丰富的并发工具,如ExecutorServiceFutureCompletableFuture等。

  • 同步操作:在一个线程中按顺序执行,每个操作必须等待前一个操作完成。
  • 异步操作:通过多线程、线程池、回调机制等实现,异步操作可以在不同的线程中并发执行。

Java中有没有事件循环?

严格来说,Java没有像JavaScript那样的内置事件循环机制。Java的异步操作主要依赖于多线程和并发工具,而不是事件循环。然而,在某些特定场景和框架中,Java也可以实现类似事件循环的机制。例如:

  • Swing和JavaFX:Java的GUI框架(如Swing和JavaFX)使用事件调度线程(Event Dispatch Thread,EDT)处理用户界面事件。这类似于事件循环,但仅用于处理GUI事件。
  • Netty:Netty是一个异步事件驱动的网络应用框架,它实现了自己的事件循环机制,用于处理网络I/O操作。

Java中的异步编程

让咱们看一个Java中的异步编程示例,使用CompletableFuture来实现异步操作:

import java.util.concurrent.CompletableFuture;

public class AsyncExample {
    public static void main(String[] args) {
        System.out.println("Start");

        CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
            try {
                Thread.sleep(1000); // 模拟异步操作
                System.out.println("Async Task Completed");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        future.thenRun(() -> System.out.println("Callback Executed"));

        System.out.println("End");

        // 等待异步任务完成
        future.join();
    }
}

执行过程

  1. 开始执行System.out.println("Start"); 输出 “Start”。

  2. 异步任务:使用CompletableFuture.runAsync启动一个异步任务,在另一个线程中执行。异步任务模拟执行1秒钟,然后输出 “Async Task Completed”。

  3. 注册回调:使用thenRun注册一个回调函数,当异步任务完成后执行,输出 “Callback Executed”。

  4. 继续执行System.out.println("End"); 输出 “End”。

  5. 等待异步任务完成:使用future.join()等待异步任务完成,确保程序不会过早退出。

输出结果如下所示:

Start
End
Async Task Completed
Callback Executed

现在你对JavaScript中的同步和异步有了解了吗?不知道大家还有什么疑问?

  • 16
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值