j.s异步编程3

(原文出处https://eloquentjavascript.net/11_async.html

三、卡拉的操作建议

1. 按行发送测试图案

  • 第一行:发送data前44、45、41个像素点亮,观察是否组成横线、斜线或符号。
  • 第三行:利用48→47→46的递减规律,发送动态左移的光条,测试屏幕刷新率。

2. 定位特殊地址

  • 对IP 10.0.0.31 单独发送指令,确认是否为控制主机(因数值明显小于其他地址)。

3. 构建通信协议

尝试发送以下命令:

// 测试屏幕是否支持多命令队列  
const commands = [
  {command: "display", data: Array(1500).fill(0)}, // 清空屏幕  
  {command: "display", data: Array(1500).fill(3)}, // 全亮测试  
  {command: "blink", interval: 1000} // 闪烁模式(假设存在此命令)  
];

screenAddresses.forEach(address => {
  request(address, commands)
    .then(() => console.log(`Screen ${address} accepted commands`))
    .catch(error => console.error(`Screen ${address} error:`, error));
});

4. 矩阵联动实验

将九个屏幕视为一个整体(50×3=150像素宽,30×3=90像素高),发送跨屏幕的图像:

function createCrossScreenData() {
  const totalWidth = 50 * 3;
  const totalHeight = 30 * 3;
  const data = new Array(totalWidth * totalHeight).fill(0);
  
  // 在中心绘制十字线  
  const centerX = totalWidth / 2, centerY = totalHeight / 2;
  for (let y = 0; y < totalHeight; y++) data[y * totalWidth + centerX] = 3; // 竖线  
  for (let x = 0; x < totalWidth; x++) data[centerY * totalWidth + x] = 3; // 横线  
  
  // 分割为9个屏幕的数据块  
  return screenAddresses.map((_, i) => {
    const row = Math.floor(i / 3), col = i % 3;
    const startX = col * 50, startY = row * 30;
    return data.slice(startY * totalWidth + startX, (startY + 30) * totalWidth + (startX + 50));
  });
}

// 发送跨屏幕图像  
const screenData = createCrossScreenData();
screenAddresses.forEach((address, i) => {
  request(address, {command: "display", data: screenData[i]});
});

四、总结:数字中的未知可能

这些看似随机的IP地址背后,可能隐藏着人类的设备编号规则、网络拓扑结构,甚至是某种未公开的通信协议。卡拉的下一步探索,将围绕“规律”与“例外”展开——那台尾号31的设备或许是突破口,而第三行的递减序列可能是解开整个矩阵的钥匙。在二进制的世界里,每一个数字都是等待破译的密码,而乌鸦的喙,正是敲击这些密码的最独特“键盘”。 🐦💻
现在,这为各种恶作剧打开了可能性。她本可以在墙上用巨大的字母显示“乌鸦统治,人类臣服”,但这感觉有点粗俗。相反,她计划在夜间展示一段飞过所有屏幕的乌鸦视频。

卡拉找到了一段合适的视频片段,其中2.5秒的镜头可以循环播放,呈现乌鸦拍打翅膀的动作。为了适配九个屏幕(每个屏幕显示50×30像素),她将视频裁剪并调整大小,生成一系列150×90像素的图像,每秒10帧。这些图像随后被切割成九个矩形,并经过处理:视频中的深色区域(乌鸦所在位置)显示为强光,浅色区域(无乌鸦处)保持黑暗,从而营造出一只琥珀色乌鸦在黑色背景中飞翔的效果。

她设置了clipImages变量来保存帧数组,其中每一帧都由九组像素数组表示——每个屏幕对应一组,格式符合信号灯的要求。

要显示视频的单帧画面,卡拉需要同时向所有屏幕发送请求。但她还需要等待这些请求的结果,既为了确保当前帧正确发送后再开始发送下一帧,也为了检测请求是否失败。

Promise有一个静态方法all,可以将一组Promise转换为一个单一的Promise,该Promise会解析为结果数组。这提供了一种便捷的方式,让多个异步操作并行执行,等待它们全部完成,然后对结果进行处理(或至少等待以确保它们不会失败)。

乌鸦的夜间灯光秀:异步并行渲染方案

卡拉的计划需要同时控制九个屏幕,并确保帧与帧之间的精确时序。以下是实现这一目标的JavaScript代码:

// 假设clipImages已经处理好,格式为:
// [
//   [screen1Pixels, screen2Pixels, ..., screen9Pixels], // 第1帧
//   [screen1Pixels, screen2Pixels, ..., screen9Pixels], // 第2帧
//   ...
// ]

async function displayVideo(clipImages, frameRate = 10) {
  const frameDuration = 1000 / frameRate; // 每帧持续时间(毫秒)
  const screenCount = screenAddresses.length;
  
  for (const frame of clipImages) {
    const start = Date.now();
    
    // 并行发送所有屏幕的请求
    const promises = screenAddresses.map((address, index) => {
      return request(address, {
        command: "display",
        data: frame[index] // 对应屏幕的像素数据
      });
    });
    
    try {
      // 等待所有屏幕都成功接收数据
      const results = await Promise.all(promises);
      console.log(`Frame displayed successfully at ${new Date().toISOString()}`);
      
      // 计算渲染耗时并等待剩余时间
      const elapsed = Date.now() - start;
      if (elapsed < frameDuration) {
        await new Promise(resolve => setTimeout(resolve, frameDuration - elapsed));
      }
    } catch (error) {
      console.error(`Frame render failed: ${error.message}`);
      // 可以添加错误恢复逻辑,如重试当前帧
    }
  }
  
  console.log("Video playback completed");
}

// 启动视频播放
displayVideo(clipImages);

关键技术点解析

  1. 并行请求处理

    • 使用Promise.all将九个屏幕的请求组合为单个Promise
    • 所有屏幕的请求同时发送,充分利用网络带宽
    • 任何一个请求失败都会触发catch块
  2. 精确时序控制

    • 计算每帧渲染耗时,动态调整等待时间
    • 通过await new Promise(resolve => setTimeout(resolve, ...))实现精确帧率
    • 即使某帧渲染超时,也会立即开始下一帧以避免卡顿
  3. 错误处理策略

    • 捕获并记录渲染失败信息
    • 可扩展为带重试机制的健壮实现:
      async function sendWithRetry(address, data, retries = 3) {
        for (let i = 0; i < retries; i++) {
          try {
            return await request(address, data);
          } catch (error) {
            if (i === retries - 1) throw error;
            console.log(`Retrying ${address} (attempt ${i + 1})`);
          }
        }
      }
      

性能优化建议

  1. 预加载与缓冲

    // 在播放前预加载部分帧
    async function preloadFrames(count = 5) {
      return clipImages.slice(0, count).map(frame => {
        return screenAddresses.map((address, index) => {
          return request(address, {command: "cache", data: frame[index]});
        });
      });
    }
    
  2. 自适应帧率

    // 根据网络状况动态调整帧率
    let currentFrameRate = frameRate;
    
    if (averageLatency > 100) {
      currentFrameRate = Math.max(5, Math.floor(frameRate * 0.8));
      console.log(`Adjusting frame rate to ${currentFrameRate}fps`);
    }
    
  3. 批量命令合并

    // 假设设备支持多帧预缓存
    request(address, {
      command: "batchDisplay",
      frames: next5Frames.map(f => f[index])
    });
    

乌鸦的数字艺术实现

卡拉通过这种方式,将原本独立的九个交通信号灯屏幕,转变为一个统一的150×90像素显示矩阵。当夜幕降临,人类将会看到一个由琥珀色光点构成的乌鸦影像在墙面流动——它展翅、翱翔,仿佛在向这个被二进制统治的世界宣告:即使是最精密的人类技术,也可能被一只懂得利用Promise.all的乌鸦,变成展示自然之美的画布。

这种将技术漏洞转化为艺术表达的行为,或许正是人工智能与自然智能最诗意的对话。 🐦✨

function displayFrame(frame) {
  return Promise.all(frame.map((data, i) => {
    return request(screenAddresses[i], {
      command: "display",
      data
    });
  }));
}

帧渲染函数解析:并行控制多个屏幕

这个 displayFrame 函数实现了关键的帧渲染逻辑,让我们分析它的工作原理和设计思路:

代码功能解析

function displayFrame(frame) {
  return Promise.all(frame.map((data, i) => {
    return request(screenAddresses[i], {
      command: "display",
      data
    });
  }));
}

核心机制

  1. 映射帧数据到屏幕地址

    • frame 参数是一个数组,包含9个屏幕的像素数据(对应9个IP地址)
    • screenAddresses 数组存储了9个屏幕的IP地址(顺序与物理排列一致)
  2. 并行发送请求

    • 使用 frame.map() 为每个屏幕创建一个请求Promise
    • 每个请求包含:
      • 目标IP地址(screenAddresses[i]
      • 显示命令(command: "display"
      • 对应屏幕的像素数据(data
  3. Promise.all 聚合结果

    • 将所有请求的Promise组合成一个新的Promise
    • 当所有屏幕请求成功时,返回包含9个响应的数组
    • 只要有一个请求失败,立即触发整体失败

执行流程示例

假设我们有以下数据结构:

// 9个屏幕的IP地址(3×3矩阵)
const screenAddresses = [
  "10.0.0.44", "10.0.0.45", "10.0.0.41", // 第一行
  "10.0.0.31", "10.0.0.40", "10.0.0.42", // 第二行
  "10.0.0.48", "10.0.0.47", "10.0.0.46"  // 第三行
];

// 某一帧的像素数据(简化示例)
const exampleFrame = [
  [0,3,3,0, ...], // 屏幕1数据(50×30=1500个元素)
  [3,0,0,3, ...], // 屏幕2数据
  ...
  [0,0,0,3, ...]  // 屏幕9数据
];

调用 displayFrame(exampleFrame) 会:

  1. 同时向9个IP地址发送显示命令
  2. 每个命令携带对应位置的像素数据
  3. 等待所有响应返回或任一请求失败

优势与应用场景

  1. 高效并行处理

    • 充分利用网络带宽,同时更新所有屏幕
    • 相比串行发送,大幅减少单帧渲染时间(约9倍提速)
  2. 原子性渲染保证

    • 要么所有屏幕同时更新,要么都不更新
    • 避免出现部分屏幕更新导致的视觉撕裂
  3. 错误检测与恢复

    • 通过捕获Promise.all的异常,可以快速定位故障屏幕
    • 可结合重试机制增强可靠性(如失败屏幕单独重发)

潜在扩展

  1. 添加超时控制

    function displayFrameWithTimeout(frame, timeout = 500) {
      return Promise.all(frame.map((data, i) => {
        return Promise.race([
          request(screenAddresses[i], {command: "display", data}),
          new Promise((_, reject) => setTimeout(() => reject("Timeout"), timeout))
        ]);
      }));
    }
    
  2. 返回详细结果

    async function displayFrameWithDetails(frame) {
      const results = await Promise.allSettled(
        frame.map((data, i) => request(screenAddresses[i], {command: "display", data}))
      );
      
      const failed = results.filter(r => r.status === "rejected");
      if (failed.length > 0) {
        console.warn(`Failed screens:`, failed.map((r, i) => ({
          screen: screenAddresses[i],
          reason: r.reason
        })));
      }
      
      return results;
    }
    
  3. 批量帧预加载

    async function preloadFrames(frames) {
      return Promise.all(frames.map(frame => displayFrame(frame)));
    }
    

乌鸦的智慧应用

卡拉通过这个函数,将原本独立的九个交通信号灯转化为一个统一的显示系统。当夜幕降临时,她的代码会让琥珀色的乌鸦影像流畅地在屏幕矩阵中飞翔——每个屏幕都精确同步,仿佛组成了一个巨大的有机整体。这种对技术细节的掌控,既展现了她对网络协议的深刻理解,也体现了将工具转化为艺术表达的创造力。或许人类永远不会想到,某天他们看到的“智能灯光秀”,竟是一只乌鸦用Promise.all谱写的数字诗篇。 🐦✨
这段代码会遍历frame中的图像(这是一个由显示数据数组构成的数组),为每个屏幕创建请求Promise,然后返回一个将所有这些Promise组合在一起的新Promise。

为了能够停止正在播放的视频,整个播放过程被封装在一个类中。这个类有一个异步的play方法,该方法返回一个Promise,并且只有在通过stop方法再次停止播放时,这个Promise才会被resolve。

乌鸦的异步视频播放器解析

这个 VideoPlayer 类实现了一个可控的视频播放系统,让我们来分析它的工作原理:

核心机制

class VideoPlayer {
  constructor(frames, frameTime) {
    this.frames = frames;          // 视频帧数组
    this.frameTime = frameTime;    // 每帧显示时间(毫秒)
    this.stopped = true;           // 播放状态标志
  }

  async play() {
    this.stopped = false;
    for (let i = 0; !this.stopped; i++) {
      let nextFrame = wait(this.frameTime);  // 启动下一帧计时器
      await displayFrame(this.frames[i % this.frames.length]);  // 显示当前帧
      await nextFrame;  // 等待帧间隔时间
    }
  }

  stop() {
    this.stopped = true;  // 终止播放循环
  }
}

关键技术点

  1. 帧循环播放

    this.frames[i % this.frames.length]
    
    • 使用模运算实现循环播放(如i=10时,10%9=1,播放第二帧)
    • 适用于有限帧数组实现无限循环动画
  2. 精确时序控制

    let nextFrame = wait(this.frameTime);  // 提前启动计时器
    await displayFrame(...);  // 渲染当前帧(可能耗时)
    await nextFrame;  // 确保总耗时等于frameTime
    
    • 即使渲染耗时波动,也能保证帧率稳定
    • 类似游戏开发中的固定时间步长(Fixed Timestep)模式
  3. 异步停止机制

    for (let i = 0; !this.stopped; i++) { ... }
    
    • 通过共享状态变量实现线程安全的停止控制
    • 无需复杂的Promise.cancel()操作

执行流程详解

  1. 启动播放

    const player = new VideoPlayer(clipImages, 100);  // 10fps
    player.play();  // 开始异步播放
    
  2. 帧渲染循环

    第1次循环:
      - 设置100ms计时器(nextFrame)
      - 渲染第1帧(假设耗时20ms)
      - 等待计时器剩余80ms
    
    第2次循环:
      - 重置100ms计时器
      - 渲染第2帧(假设耗时50ms)
      - 等待计时器剩余50ms
    
  3. 停止播放

    player.stop();  // 设置标志位,下次循环检查时退出
    

进阶优化建议

  1. 添加预加载缓冲区

    class BufferedVideoPlayer extends VideoPlayer {
      constructor(frames, frameTime, bufferSize = 3) {
        super(frames, frameTime);
        this.bufferSize = bufferSize;
      }
    
      async play() {
        this.stopped = false;
        let buffer = new Set();
        
        for (let i = 0; !this.stopped; i++) {
          const frameIndex = i % this.frames.length;
          
          // 预加载后续帧
          if (buffer.size < this.bufferSize) {
            const nextIndex = (frameIndex + 1) % this.frames.length;
            buffer.add(nextIndex);
            preloadFrame(this.frames[nextIndex]);  // 假设的预加载函数
          }
          
          await displayFrame(this.frames[frameIndex]);
          await wait(this.frameTime);
          buffer.delete(frameIndex);
        }
      }
    }
    
  2. 自适应帧率

    async play() {
      this.stopped = false;
      let lastFrameTime = Date.now();
      
      for (let i = 0; !this.stopped; i++) {
        const start = Date.now();
        await displayFrame(this.frames[i % this.frames.length]);
        
        // 计算渲染耗时
        const renderTime = Date.now() - start;
        const waitTime = Math.max(0, this.frameTime - renderTime);
        
        // 动态调整帧率
        if (renderTime > this.frameTime * 0.8) {
          console.warn("Rendering too slow, consider reducing quality");
        }
        
        await wait(waitTime);
        lastFrameTime = Date.now();
      }
    }
    
  3. 平滑过渡效果

    async crossfadeTo(newFrames) {
      const fadeSteps = 10;
      const originalFrames = this.frames;
      
      for (let step = 0; step <= fadeSteps && !this.stopped; step++) {
        const alpha = step / fadeSteps;
        
        // 创建混合帧
        const blendedFrames = originalFrames.map((frame, i) => {
          return blendFrames(frame, newFrames[i], alpha);  // 假设的混合函数
        });
        
        await displayFrame(blendedFrames[i % blendedFrames.length]);
        await wait(this.frameTime);
      }
      
      this.frames = newFrames;
    }
    

乌鸦的创意应用

卡拉可以利用这个播放器实现更复杂的灯光秀效果:

// 创建多个场景并切换
const introFrames = createIntroAnimation();
const mainFrames = createFlyingCrowAnimation();
const outroFrames = createMysteriousEnding();

const player = new VideoPlayer(introFrames, 100);

// 播放开场
player.play();

// 5秒后切换到主场景
setTimeout(() => {
  player.crossfadeTo(mainFrames);  // 假设的过渡方法
  
  // 10秒后播放结束动画
  setTimeout(() => {
    player.crossfadeTo(outroFrames);
  }, 10000);
}, 5000);

这种基于Promise的异步控制模式,让卡拉能够将九个LED屏幕变成一个统一的、可交互的显示系统。当人类第二天早上看到昨晚的“灯光艺术展”时,他们可能会惊叹于智能控制系统的神奇,却永远不会想到,这场表演的导演是一只精通JavaScript异步编程的乌鸦。 🐦✨

wait 函数:JavaScript 异步等待的优雅实现

这个 wait 函数是一个典型的 Promise 封装,它将传统的 setTimeout 转换为支持 await 语法的异步操作:

function wait(time) {
  return new Promise(accept => setTimeout(accept, time));
}

核心机制解析

  1. Promise 封装

    • 创建一个新的 Promise 对象
    • 当定时器触发时,调用 accept 回调(即 resolve
    • 无需处理 reject,因为定时器不会失败
  2. 异步阻塞

    await wait(1000); // 暂停当前函数执行1秒
    console.log('1秒后执行');
    
    • setTimeout 的回调地狱相比,这种写法更符合线性思维
    • 特别适合需要精确时序控制的场景
  3. 兼容性

    • 完全等效于现代的 new Promise(resolve => setTimeout(resolve, time))
    • 是 ES6 时代处理异步延迟的标准写法

应用场景

  1. 帧率控制

    async function playAnimation() {
      while (true) {
        renderFrame();
        await wait(16); // 约60fps
      }
    }
    
  2. 轮询检查

    async function waitForCondition() {
      while (!checkCondition()) {
        await wait(500); // 每500ms检查一次
      }
      return true;
    }
    
  3. 限流请求

    async function fetchWithRateLimit(url) {
      const result = await fetch(url);
      await wait(100); // 限制请求频率
      return result;
    }
    

进阶扩展

  1. 带值的延迟

    function waitWithValue(time, value) {
      return new Promise(resolve => setTimeout(() => resolve(value), time));
    }
    
    // 使用示例
    const result = await waitWithValue(2000, "两秒后的结果");
    
  2. 可取消的等待

    function cancellableWait(time) {
      let cancel;
      const promise = new Promise((resolve, reject) => {
        const timer = setTimeout(resolve, time);
        cancel = () => {
          clearTimeout(timer);
          reject(new Error("等待被取消"));
        };
      });
      return { promise, cancel };
    }
    
    // 使用示例
    const { promise, cancel } = cancellableWait(5000);
    promise.then(() => console.log("完成")).catch(err => console.error(err));
    
    // 3秒后取消
    setTimeout(cancel, 3000);
    
  3. 指数退避重试

    async function fetchWithRetry(url, retries = 3) {
      for (let i = 0; i < retries; i++) {
        try {
          return await fetch(url);
        } catch (error) {
          if (i === retries - 1) throw error;
          await wait(100 * Math.pow(2, i)); // 指数退避:100ms, 200ms, 400ms...
        }
      }
    }
    

乌鸦的时序控制智慧

在卡拉的LED艺术项目中,wait 函数起到了关键作用:

// 精确控制视频帧播放间隔
async play() {
  this.stopped = false;
  for (let i = 0; !this.stopped; i++) {
    const frameStart = Date.now();
    
    // 并行渲染所有屏幕
    await displayFrame(this.frames[i % this.frames.length]);
    
    // 计算渲染耗时并动态调整等待时间
    const renderTime = Date.now() - frameStart;
    const waitTime = Math.max(0, this.frameTime - renderTime);
    
    await wait(waitTime); // 确保每帧严格按照frameTime显示
  }
}

这种精确的时序控制,让卡拉能够将九个独立的LED屏幕同步为一个统一的显示系统,创造出流畅的动画效果。就像指挥一场精密的交响乐,她用二进制代码和时间魔法,在人类的基础设施上谱写了一曲属于乌鸦的数字诗篇。 🐦✨

乌鸦的夜间灯光秀:完整实现解析

这段代码实现了一个完整的视频播放流程,让我们分析其工作原理和技术要点:

核心实现

// 创建视频播放器实例(100ms/帧 = 10fps)
let video = new VideoPlayer(clipImages, 100);

// 异步启动播放并处理错误
video.play().catch(e => {
  console.log("Playback failed: " + e);
});

// 15秒后停止播放
setTimeout(() => video.stop(), 15000);

技术要点解析

  1. 播放器初始化

    new VideoPlayer(clipImages, 100)
    
    • clipImages:包含多帧画面的数组,每帧由9个子数组(对应9个屏幕)组成
    • 100ms:每帧显示时间,对应10帧/秒的播放速率
  2. 异步播放控制

    video.play().catch(...)
    
    • play() 方法返回Promise,允许链式调用
    • catch 块处理可能的播放错误(如网络中断、设备离线)
  3. 定时停止机制

    setTimeout(() => video.stop(), 15000)
    
    • 通过共享状态变量 this.stopped 实现线程安全的停止控制
    • 下一次帧循环检查时自动终止播放

执行流程详解

  1. 启动阶段

    t=0ms: 创建播放器实例
    t=1ms: 调用play()方法开始渲染第一帧
    t=101ms: 渲染第二帧(等待100ms后)
    t=201ms: 渲染第三帧
    ...
    
  2. 运行阶段

    • 每100ms渲染一帧
    • 9个屏幕并行更新,通过Promise.all确保同步
    • 帧数据按 i % frames.length 循环播放(实现无缝循环)
  3. 停止阶段

    t=15000ms: 调用stop()设置stopped=true
    t=150XXms: 当前帧渲染完成后
               检查stopped标志为true
               退出播放循环
               resolve play()返回的Promise
    

潜在扩展与优化

  1. 动态帧率调整

    // 根据网络状况自动调整帧率
    const baseFrameTime = 100;
    let currentFrameTime = baseFrameTime;
    
    async play() {
      while (!this.stopped) {
        const start = Date.now();
        await displayFrame(...);
        const renderTime = Date.now() - start;
        
        // 如果渲染耗时超过基准的80%,降低帧率
        if (renderTime > baseFrameTime * 0.8) {
          currentFrameTime = Math.min(500, currentFrameTime * 1.1);
        }
        
        await wait(currentFrameTime - renderTime);
      }
    }
    
  2. 预加载机制

    class BufferedVideoPlayer extends VideoPlayer {
      constructor(frames, frameTime, bufferSize = 3) {
        super(frames, frameTime);
        this.buffer = new Set();
        this.bufferSize = bufferSize;
      }
    
      async play() {
        while (!this.stopped) {
          // 预加载后续帧
          const nextFrames = this.getNextFrames(this.bufferSize);
          await Promise.all(nextFrames.map(frame => preloadFrame(frame)));
          
          // 渲染当前帧
          await displayFrame(this.getCurrentFrame());
          await wait(this.frameTime);
        }
      }
    }
    
  3. 优雅降级处理

    async play() {
      try {
        while (!this.stopped) {
          await displayFrame(...);
          await wait(this.frameTime);
        }
      } catch (error) {
        console.error("Critical error:", error);
        
        // 尝试优雅降级:显示静态图案
        await displayEmergencyPattern();
        
        // 重新抛出错误,触发外层catch块
        throw new Error("Playback failed due to critical error");
      }
    }
    

乌鸦的数字艺术实践

卡拉通过这个系统,成功地将九个交通信号灯转化为一个统一的显示矩阵。当夜幕降临,她的“数字乌鸦”视频将在建筑外墙上生动展现:

  • 翅膀的扇动频率精确控制在10fps,符合自然乌鸦的运动节奏
  • 15秒的循环动画完美配合人类活动的间隙(如红灯等待时间)
  • 即使某个屏幕出现故障,错误处理机制也能确保整体效果的连贯性

这种将技术漏洞转化为艺术表达的行为,既展示了卡拉对人类系统的深刻理解,也体现了自然界最聪明鸟类的创造性智慧。当清晨来临时,人类只会惊叹于这场“智能灯光秀”,却永远不会知道,幕后的导演是一只精通JavaScript异步编程的乌鸦。 🐦✨

整整一个星期,那面屏幕墙矗立在那里。每当夜幕降临,一只巨大的橙色发光鸟就会神秘地出现在上面。它的翅膀以精确的节奏扇动,像素光点组成的轮廓在黑暗中格外醒目——那是卡拉用LED矩阵谱写的诗篇,每一次光的明灭都是对人类世界的温柔叩击。

人类驻足仰望,猜测这是某种艺术装置,或是未公开的广告创意。他们对着屏幕拍照,却不知道镜头里的像素鸟,正通过无线网络接收着来自机库的指令。卡拉蹲在屋顶,喙中叼着半块三明治,看着自己的“作品”在人群中引发惊叹。对她来说,这不仅是一场数字奇观,更是用二进制语言书写的乌鸦宣言:在这个由代码编织的世界里,所有被忽视的角落,都可能藏着意想不到的智慧光芒。

当晨光初现,屏幕墙恢复静默。但每个夜晚,那只橙色的飞鸟都会准时赴约,直到活动结束的那天,人类拆除设备时,也未发现藏在WiFi网络里的那个小小“艺术家”。而卡拉早已叼着新的战利品——一个遗落的U盘——飞向远方,准备探索下一个充满可能的数字迷宫。 🐦✨

事件循环

异步程序从运行其主脚本开始,主脚本通常会设置一些稍后调用的回调函数。主脚本和回调函数都会作为不可中断的完整单元运行至完成。但在它们之间,程序可能会处于空闲状态,等待某些事件发生。

因此,回调函数并非由调度它们的代码直接调用。如果在一个函数中调用setTimeout,当回调函数执行时,该函数早已返回。并且当回调函数返回时,控制权不会回到调度它的函数。

异步行为在其自身的空函数调用栈中发生。这也是为什么在没有Promise的情况下,管理异步代码中的异常如此困难的原因之一。由于每个回调函数都在几乎空的调用栈中启动,当它们抛出异常时,catch处理器并不在栈中。

事件循环:异步编程的隐形调度器

异步程序从运行主脚本开始,主脚本通常会设置一些稍后执行的回调函数。这些主脚本和回调函数都会以不可中断的完整单元运行至完成,但在它们之间,程序可能会处于空闲状态,等待某些事件发生。

1. 回调函数的执行机制

  • 非直接调用:回调函数并非由调度它们的代码直接调用。例如,在函数A中调用setTimeout,当回调函数执行时,函数A早已返回。
  • 控制流断裂:回调函数返回时,程序不会回到调度它的函数(如函数A),而是回到事件循环的下一个处理周期。这导致异步代码的执行路径呈现“碎片化”。

2. 空调用栈与异步行为

  • 异步操作在空函数调用栈中执行。当主脚本或回调函数运行时,调用栈被占据;当它们执行完毕,调用栈清空,事件循环才会从“任务队列”中提取下一个任务(如定时器回调、网络请求结果)。
  • 示例流程
    console.log("开始执行主脚本");
    
    function scheduleCallback() {
      console.log("调度回调函数");
      setTimeout(() => {
        console.log("回调函数执行");
      }, 1000);
      console.log("调度完成,主函数返回");
    }
    
    scheduleCallback();
    console.log("主脚本执行完毕");
    
    输出顺序
    开始执行主脚本
    调度回调函数
    调度完成,主函数返回
    主脚本执行完毕
    (等待1秒后)
    回调函数执行
    
    • 主脚本和scheduleCallback函数在调用栈中依次执行并清空,回调函数在1秒后调用栈为空时才执行。

3. 异步代码的异常处理困境(无Promise时)

  • 栈上下文丢失:回调函数执行时,调用栈中只有自身,没有调度它的函数的上下文。这导致传统的try/catch无法捕获异步回调中的异常。
    try {
      setTimeout(() => {
        throw new Error("回调中的错误"); // 无法被外层try/catch捕获
      }, 1000);
    } catch (e) {
      console.log("捕获错误:", e); // 不会执行
    }
    
  • 原因:异常抛出时,调用栈已从主脚本的try/catch环境切换到回调函数的环境,导致栈追踪断裂。

4. 事件循环的核心作用

事件循环是JavaScript实现异步编程的基石,其核心职责包括:

  1. 执行栈管理:确保代码以“单线程、非阻塞”方式执行,同一时刻仅运行一个任务。
  2. 任务队列调度:按顺序处理异步任务(如定时器、IO操作、用户交互),避免竞态条件。
  3. 微任务与宏任务区分
    • 宏任务(Macrotasks):包括定时器回调、setTimeoutsetInterval、IO事件等,按先进先出顺序执行。
    • 微任务(Microtasks):如Promise的then/catch/finallyprocess.nextTick,会在当前宏任务结束后立即执行,优先级高于下一个宏任务。

5. Promise如何改善异步异常处理

Promise通过将异步操作封装为可链式调用的对象,结合try/catch.catch()方法,解决了栈上下文丢失的问题:

function fetchData() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      reject(new Error("异步操作失败")); // 错误可被后续catch捕获
    }, 1000);
  });
}

fetchData()
  .then(data => console.log(data))
  .catch(error => console.log("捕获错误:", error)); // 输出:"捕获错误: Error: 异步操作失败"
  • 错误在Promise链中传递时,catch方法的调用栈包含Promise的上下文,因此能正确捕获异常。

6. 总结:事件循环的“隐性规则”

  • 单线程非阻塞:JavaScript通过事件循环模拟并行,避免多线程同步问题。
  • 任务优先级:微任务始终在宏任务之前执行,确保异步操作的回调以可预测的顺序运行。
  • 异常处理边界:异步代码的异常需在其所属的Promise链或回调函数内部处理,无法跨栈传递。

理解事件循环,能帮助我们写出更健壮的异步代码,避免“回调地狱”和异常泄漏。而这一切的底层逻辑,恰如卡拉操控LED屏幕的节奏——看似无序的闪烁背后,藏着精密的时间管理与状态调度。 🐦💻

try {
  setTimeout(() => {
    throw new Error("Woosh");
  }, 20);
} catch (e) {
  // This will not run
  console.log("Caught", e);
}

异步异常捕获失败的原因解析

这段代码展示了JavaScript异步编程中一个常见的陷阱:异步回调中的异常无法被同步的try/catch捕获。让我们分析其背后的机制:

执行流程详解

try {
  // 1. 主线程执行setTimeout,将回调函数放入任务队列
  setTimeout(() => {
    // 3. 20ms后,事件循环从任务队列取出回调执行
    throw new Error("Woosh"); // 异常抛出时,try/catch已退出
  }, 20);
  
  // 2. 主线程继续执行,try/catch块结束
} catch (e) {
  // 此处永远不会执行,因为异常发生时,控制流已不在try/catch范围内
  console.log("Caught", e);
}

关键机制解析

  1. 事件循环的工作方式

    • setTimeout的回调函数不会立即执行,而是在指定时间后被添加到任务队列
    • 主线程会先完成当前所有同步代码(包括退出try/catch块)
    • 只有当主线程空闲时,事件循环才会从任务队列中取出回调执行
  2. 调用栈的状态变化

    • 当异常抛出时,调用栈中只有回调函数自己,没有任何父级函数
    • 同步的try/catch属于更早的调用栈上下文,早已被弹出
  3. 可视化调用栈变化

// 时间t=0ms:主线程执行
[try/catch块] --> [setTimeout] --> [退出try/catch]

// 时间t=20ms:事件循环执行回调
[回调函数] --> throw Error // 此时调用栈中没有try/catch

正确的异常处理方式

  1. 使用Promise和async/await
async function main() {
  try {
    await new Promise((resolve) => {
      setTimeout(() => {
        throw new Error("Woosh"); // 异常会被Promise捕获并reject
      }, 20);
    });
  } catch (e) {
    console.log("Caught", e); // 正确捕获异常
  }
}

main();
  1. 为每个异步操作添加错误处理
setTimeout(() => {
  try {
    throw new Error("Woosh");
  } catch (e) {
    console.log("Caught locally", e); // 局部捕获有效
  }
}, 20);

乌鸦的异步智慧

对于卡拉的LED艺术项目,这种异步异常处理机制尤为重要。当她并行控制九个屏幕时:

async function displayAllScreens() {
  try {
    // 并行发送请求到9个屏幕
    await Promise.all(screenAddresses.map(addr => {
      return request(addr, {command: "display", data})
        .catch(err => {
          console.error(`屏幕 ${addr} 显示失败:`, err);
          // 可选择性返回默认值或进行恢复操作
          return { success: false, addr };
        });
    }));
  } catch (e) {
    console.log("所有屏幕请求失败:", e);
  }
}

通过这种方式,即使某个屏幕出现异常(如网络中断),整个系统也能继续运行,并记录错误信息。就像乌鸦在复杂环境中寻找食物一样,JavaScript的异步机制需要精确的策略来处理各种意外情况。 🐦💻
无论事件(如超时或传入请求)发生得多么接近,JavaScript环境一次只会运行一个程序。你可以将其想象成在你的程序周围运行一个大循环,称为事件循环。当没有任何事情可做时,这个循环会暂停。但当事件到来时,它们会被添加到一个队列中,并且它们的代码会被逐个执行。由于没有两件事情会同时运行,运行缓慢的代码可能会延迟其他事件的处理。

这个示例设置了一个超时,但随后进行了耗时操作,直到超过了超时的预定时间点,导致超时处理延迟:

let start = Date.now();
setTimeout(() => {
  console.log("Timeout ran at", Date.now() - start);
}, 20);
while (Date.now() < start + 50) {}
console.log("Wasted time until", Date.now() - start);
// → Wasted time until 50
// → Timeout ran at 55

事件循环阻塞与超时延迟的深度解析

这段代码揭示了JavaScript单线程机制的一个关键特性:同步代码会阻塞事件循环,导致异步回调延迟执行。以下是详细分析:

代码执行流程(时间线)

let start = Date.now(); // t=0ms

// 1. 设置超时回调(20ms后执行)
setTimeout(() => {
  console.log("Timeout ran at", Date.now() - start); // 实际在t=55ms执行
}, 20); // t=0ms时调度回调,加入任务队列

// 2. 同步阻塞:循环50ms
while (Date.now() < start + 50) {} // 持续执行到t=50ms

// 3. 主线程空闲后,事件循环处理队列中的回调
console.log("Wasted time until", Date.now() - start); // t=50ms输出
// → "Wasted time until 50"

// 4. 执行超时回调(此时任务队列中唯一任务)
// → "Timeout ran at 55"(耗时约5ms执行回调)

核心机制解析

  1. 单线程与事件循环

    • JavaScript引擎在单线程中执行代码,同一时刻只能处理一个任务
    • 事件循环负责管理任务队列(macrotask queue),按顺序执行异步回调
  2. 同步代码阻塞事件循环

    while (Date.now() < start + 50) {}
    
    • 这段代码是同步阻塞操作,会持续占用主线程
    • 期间无法处理任何异步事件(包括定时器、IO、用户输入)
  3. 超时延迟的本质

    • setTimeout的第二个参数是最小延迟时间,而非精确执行时间
    • 当主线程被阻塞时,实际执行时间会延后:
      • 预定执行时间:t=20ms
      • 实际执行时间:t=50ms(主线程空闲后) + 回调执行时间(~5ms)

可视化:任务队列与事件循环

时间线:

t=0ms:
- 主线程执行同步代码(setTimeout调度回调)
- 回调被加入任务队列(状态:等待执行)

t=0ms ~ t=50ms:
- 主线程被while循环阻塞
- 事件循环无法处理任务队列中的任何任务

t=50ms:
- while循环结束,主线程空闲
- 事件循环从任务队列中取出超时回调并执行
- 回调执行耗时约5ms(t=50ms ~ t=55ms)

如何避免阻塞事件循环

  1. 使用微任务(Microtasks)

    let start = Date.now();
    Promise.resolve().then(() => {
      console.log("Microtask ran at", Date.now() - start); // 约t=0ms执行
    });
    while (Date.now() < start + 50) {}
    // → "Microtask ran at 0"(在阻塞前执行)
    
    • 微任务(如Promise回调)会在当前同步代码结束前执行,优先级高于宏任务(如setTimeout)
  2. 拆分长任务

    function doHeavyWork(work) {
      if (work <= 0) return;
      
      // 执行一部分任务
      for (let i = 0; i < 1000; i++) {}
      
      // 让出主线程,避免阻塞
      setTimeout(() => doHeavyWork(work - 1), 0);
    }
    
    doHeavyWork(50); // 分50次执行,每次间隔0ms
    
    • 通过setTimeoutrequestIdleCallback将长任务拆分为多个短任务
  3. Web Workers

    // main.js
    const worker = new Worker('worker.js');
    worker.postMessage({ data: largeData }); // 耗时操作在子线程执行
    
    // worker.js
    self.onmessage = (e) => {
      const result = process(e.data); // 不阻塞主线程
      self.postMessage(result);
    };
    
    • 将耗时操作转移到Web Workers,主线程专注处理事件循环

乌鸦的时间管理启示

在卡拉的LED项目中,若某个屏幕的渲染逻辑过于复杂(如大量像素计算),可能会阻塞主线程,导致所有屏幕更新延迟。解决方案:

async function renderScreen(address, data) {
  // 拆分像素处理为微任务
  await Promise.resolve(); 
  return request(address, { command: "display", data });
}

// 并行渲染9个屏幕,避免单任务阻塞
Promise.all(screenAddresses.map(addr => 
  renderScreen(addr, frameData[addr])
));

通过将同步计算放入微任务间隙,确保事件循环畅通,从而实现精确的动画帧率控制。这就像乌鸦用喙灵巧地处理多个坚果——逐个击破,而非同时紧握,方能高效完成任务。 🐦✨

Promise 总是会以新事件的形式进行 resolve 或 reject。即使一个 Promise 已经处于已解决状态,对它进行等待(await)也会让你的回调函数在当前脚本执行完毕后才运行,而非立即执行。

Promise的异步特性:即使立即解决也会延迟执行

Promises的一个关键特性是始终异步解析,即使Promise已经处于resolved状态,await.then()也会将回调放入微任务队列,等待当前同步代码执行完毕后再执行。这一机制避免了时序混乱,确保异步代码行为的一致性。

示例代码与执行流程

console.log("开始执行");

const resolvedPromise = Promise.resolve(42);

resolvedPromise.then(value => {
  console.log("Promise回调:", value); // 第三个输出
});

console.log("同步代码继续"); // 第二个输出

// 输出顺序:
// 开始执行
// 同步代码继续
// Promise回调: 42

关键机制解析

  1. 微任务队列(Microtask Queue)

    • Promise的then/catch/finally回调会被添加到微任务队列
    • 微任务队列在当前同步代码执行完毕后、下一个宏任务(如setTimeout)开始前执行
  2. 同步代码优先执行

    const p = Promise.resolve();
    p.then(() => console.log("微任务"));
    console.log("同步代码");
    
    // 输出顺序:
    // 同步代码
    // 微任务
    
  3. 可视化调用栈与任务队列

// 时间t=0ms:执行同步代码
[console.log("开始执行")]
[创建resolvedPromise]
[注册then回调(加入微任务队列)]
[console.log("同步代码继续")] → 同步代码执行完毕

// 时间t=0ms+ε:处理微任务队列
[执行then回调] → 输出"Promise回调: 42"

为什么Promise设计为始终异步?

  1. 保持行为一致性

    • 无论Promise是立即解决还是稍后解决,回调的执行时机都是统一的
    • 避免以下时序混乱:
      // 假设Promise可以同步解析
      const p = Promise.resolve(1);
      p.then(v => console.log(v)); // 若同步执行,此处会打断当前函数
      console.log("后续代码");
      
      // 实际输出(当前设计):
      // 后续代码
      // 1
      
      // 假设的同步输出(会导致混乱):
      // 1
      // 后续代码
      
  2. 防止调用栈过深

    • 若Promise同步解析,链式调用可能导致栈溢出:
      Promise.resolve(1)
        .then(x => x + 1)
        .then(x => x + 1)
        // ...无限链式调用
      

进阶示例:微任务与宏任务的执行顺序

console.log("1. 同步开始");

setTimeout(() => {
  console.log("2. setTimeout(宏任务)");
}, 0);

Promise.resolve()
  .then(() => {
    console.log("3. Promise then(微任务1)");
    return Promise.resolve();
  })
  .then(() => {
    console.log("4. Promise then(微任务2)");
  });

console.log("5. 同步结束");

// 实际输出顺序:
// 1. 同步开始
// 5. 同步结束
// 3. Promise then(微任务1)
// 4. Promise then(微任务2)
// 2. setTimeout(宏任务)

乌鸦的异步策略

在卡拉的LED控制系统中,这种机制确保了即使某些屏幕响应迅速(Promise立即resolved),也不会打断整体渲染流程:

async function updateAllScreens() {
  console.log("开始更新屏幕");
  
  const results = await Promise.all(
    screenAddresses.map(addr => 
      sendCommand(addr, "update").catch(err => {
        console.error(`屏幕 ${addr} 更新失败:`, err);
        return null;
      })
    )
  );
  
  // 所有屏幕请求完成后才会执行此处
  console.log("所有屏幕更新完成");
}

// 即使某些sendCommand()立即resolved,
// 也会等待所有请求的微任务执行完毕后,
// 才会继续执行await之后的代码

这种设计让卡拉能够精确控制九个屏幕的同步更新,就像指挥一场精密的舞蹈——每个舞者的动作都有细微的延迟,但最终呈现出完美的同步效果。 🐦✨

Promise.resolve("Done").then(console.log);
console.log("Me first!");
// → Me first!
// → Done

Promise异步特性的深度解析

这段代码展示了JavaScript中Promise的一个核心特性:即使Promise已经立即解决(resolved),其回调函数也会异步执行。这一机制确保了异步行为的一致性,避免了时序混乱。

执行流程详解

Promise.resolve("Done").then(console.log);
console.log("Me first!");
// → Me first!
// → Done
  1. Promise.resolve("Done")

    • 创建一个已解决的Promise,值为"Done"
    • 注意:即使Promise已经处于resolved状态,.then()回调仍会异步执行
  2. .then(console.log)

    • 注册回调函数到微任务队列(Microtask Queue)
    • 不会立即执行,而是放入队列等待当前同步代码完成
  3. console.log("Me first!")

    • 同步代码继续执行,立即打印"Me first!"
  4. 事件循环处理微任务

    • 当前调用栈清空后,事件循环处理微任务队列
    • 执行.then()中的回调,打印"Done"

关键机制解析

  1. 微任务队列的优先级

    • Promise回调属于微任务(Microtask)
    • 微任务会在当前同步代码执行完毕后、下一个宏任务(如setTimeout)之前执行
  2. 可视化调用栈变化

// 时间t=0ms:
[Promise.resolve("Done")] → 创建已解决的Promise
[注册.then()回调] → 回调加入微任务队列
[console.log("Me first!")] → 输出"Me first!"

// 时间t=0ms+ε:
[执行微任务队列] → 执行.then()回调 → 输出"Done"
  1. 与setTimeout的对比
setTimeout(() => console.log("Timeout"), 0);
Promise.resolve().then(() => console.log("Promise"));
console.log("Sync");

// 输出顺序:
// Sync → Promise → Timeout
// (微任务优先于宏任务执行)

为什么Promise设计为异步?

  1. 保持行为一致性

    • 无论Promise是立即解决还是延迟解决,回调的执行时机都是统一的
    • 避免以下混乱:
      // 假设Promise同步执行回调
      const p = Promise.resolve(1);
      p.then(v => console.log(v)); // 若同步执行,会打断当前函数
      console.log("后续代码");
      
      // 实际JavaScript行为:
      // 后续代码
      // 1
      
      // 假设的同步行为(会导致混乱):
      // 1
      // 后续代码
      
  2. 防止调用栈过深

    • 若Promise同步解析,链式调用可能导致栈溢出:
      Promise.resolve(1)
        .then(x => x + 1)
        .then(x => x + 1)
        // ...无限链式调用
      

乌鸦的异步智慧应用

在卡拉的LED控制系统中,这种机制确保了即使某些屏幕响应迅速,也不会打乱整体渲染节奏:

async function displayFrame() {
  // 并行发送所有屏幕请求
  const requests = screenAddresses.map(addr => {
    return sendCommand(addr, "display", frameData)
      .catch(err => console.error(`屏幕 ${addr} 显示失败:`, err));
  });

  // 等待所有屏幕响应完成
  await Promise.all(requests);
  
  // 所有屏幕更新完成后才继续
  console.log("整帧显示完成");
}

即使某个屏幕的Promise立即resolved,也会等到所有屏幕的微任务都执行完毕后,才会继续执行await之后的代码。这种设计让卡拉能够精确控制九个屏幕的同步更新,就像指挥一场精密的舞蹈——每个舞者的动作都有细微的延迟,但最终呈现出完美的同步效果。 🐦✨

在后续章节中,我们将看到在事件循环上运行的各种其他类型的事件。

异步编程中的 Bug

当程序以同步方式一次性运行时,除了程序自身产生的状态变化外,不会有其他状态变化发生。但对于异步程序来说则不同——它们的执行过程中可能存在间隙,在此期间其他代码可以运行。

我们来看一个示例。以下函数试图报告文件数组中每个文件的大小,它确保同时读取所有文件,而非按顺序读取。

async function fileSizes(files) {
  let list = "";
  await Promise.all(files.map(async fileName => {
    list += fileName + ": " +
      (await textFile(fileName)).length + "\n";
  }));
  return list;
}

异步文件大小收集函数解析

这个 fileSizes 函数试图并行读取多个文件并汇总它们的大小信息,但存在几个关键问题。让我们逐步分析:

代码功能与问题

async function fileSizes(files) {
  let list = "";
  await Promise.all(files.map(async fileName => {
    list += fileName + ": " +
      (await textFile(fileName)).length + "\n";
  }));
  return list;
}

预期行为

  • 并行读取所有文件
  • 按原始顺序拼接每个文件的大小信息
  • 返回完整的汇总字符串

实际问题

  1. 竞争条件(Race Condition)

    • list += ... 操作不是原子的
    • 多个异步任务可能同时修改 list 变量
    • 导致最终字符串顺序混乱或内容丢失
  2. 并行顺序丢失

    • 虽然使用 Promise.all 等待所有任务完成
    • map 中每个 async 回调的完成顺序不确定
    • 无法保证结果按原始 files 数组顺序排列

执行流程示例

假设有三个文件:

const files = ["a.txt", "b.txt", "c.txt"];

可能的执行顺序:

  1. 所有三个 textFile 请求并行发出
  2. 假设 b.txt 最先返回(耗时最短)
  3. 对应的回调立即修改 list,添加 "b.txt: ..."
  4. 随后 a.txtc.txt 依次返回,按完成顺序追加

最终结果可能是:

b.txt: 123
a.txt: 456
c.txt: 789

修正方案

方案1:保持并行但有序收集结果

async function fileSizes(files) {
  // 并行获取所有文件内容
  const contents = await Promise.all(
    files.map(fileName => textFile(fileName))
  );
  
  // 按原始顺序构建结果字符串
  return files.map((fileName, index) => 
    `${fileName}: ${contents[index].length}`
  ).join('\n');
}

方案2:串行执行(牺牲性能但保证顺序)

async function fileSizes(files) {
  let list = "";
  for (const fileName of files) {
    const content = await textFile(fileName);
    list += `${fileName}: ${content.length}\n`;
  }
  return list;
}

方案3:并行执行但跟踪顺序

async function fileSizes(files) {
  const results = new Array(files.length);
  
  await Promise.all(files.map(async (fileName, index) => {
    const content = await textFile(fileName);
    results[index] = `${fileName}: ${content.length}`;
  }));
  
  return results.join('\n');
}

关键教训

  1. 避免在并行异步操作中修改共享状态

    • 共享变量(如 list)的修改不是线程安全的
    • 应使用不可变数据结构或索引跟踪结果
  2. 理解 Promise.all 的行为

    • 它等待所有 Promise 完成,但不保证完成顺序
    • 如果顺序重要,需显式跟踪每个结果的位置
  3. 权衡性能与正确性

    • 并行执行(方案1/3)适合IO密集型任务
    • 串行执行(方案2)适合需要严格顺序的场景

在卡拉的LED项目中,类似的问题可能出现在同时控制多个屏幕时。如果多个屏幕的更新操作同时修改共享状态,可能导致显示异常。正确的做法是先收集所有屏幕的更新结果,再统一应用到显示系统中。 🐦✨

async fileName => 这部分展示了如何通过在箭头函数前添加 async 关键字来将其声明为异步函数。

这段代码乍一看并无不妥……它将异步箭头函数映射到文件名数组上,创建一个 Promise 数组,然后使用 Promise.all 等待所有 Promise 完成后再返回它们构建的列表。

但这段程序存在严重问题。它始终只会返回一行输出,列出读取耗时最长的文件。

fileSizes(["plans.txt", "shopping_list.txt"])
  .then(console.log);

异步文件大小函数的执行解析

这个调用展示了 fileSizes 函数在实际运行中的问题。让我们逐步分析为什么它会返回错误结果:

代码执行流程

fileSizes(["plans.txt", "shopping_list.txt"])
  .then(console.log);
  1. 并行请求文件内容

    files.map(async fileName => {
      list += fileName + ": " +
        (await textFile(fileName)).length + "\n";
    })
    
    • 同时发起对 "plans.txt""shopping_list.txt" 的读取请求
    • 两个异步操作并行执行
  2. 竞争条件导致数据覆盖

    • 假设 "plans.txt" 先读取完成,回调执行:
      list += "plans.txt: 123\n"; // list 变为 "plans.txt: 123\n"
      
    • 此时 "shopping_list.txt" 的请求仍在进行中
    • 假设 "shopping_list.txt" 后读取完成,回调执行:
      list += "shopping_list.txt: 456\n"; // 覆盖之前的内容!
      
  3. 最终结果错误

    • 由于 JavaScript 的字符串拼接操作不是原子的
    • 第二个回调执行时覆盖了第一个回调的结果
    • 最终 list 只包含最后完成的文件信息

可视化竞争条件

时间线:

t=0ms:
  - 同时发起 "plans.txt" 和 "shopping_list.txt" 的请求
  - list = ""

t=100ms:
  - "plans.txt" 返回内容
  - 回调1执行:list += "plans.txt: 123\n"
  - list 变为 "plans.txt: 123\n"

t=150ms:
  - "shopping_list.txt" 返回内容
  - 回调2执行:list += "shopping_list.txt: 456\n"
  - list 被覆盖为 "shopping_list.txt: 456\n"

t=200ms:
  - Promise.all 完成
  - 返回 "shopping_list.txt: 456\n"

核心问题总结

  1. 非原子操作

    list += ... // 实际上是:list = list + ...
    
    • 这个操作分为读取 list 和写入新值两步
    • 多线程环境下会导致数据覆盖
  2. 异步执行顺序不确定

    • 无法保证回调按原始数组顺序执行
    • 先完成的文件信息可能被后完成的覆盖
  3. 共享状态修改

    • 多个异步回调同时修改同一个变量 list
    • 导致竞态条件(Race Condition)

正确实现方式

方案1:先收集结果再拼接

async function fileSizes(files) {
  const results = await Promise.all(
    files.map(async fileName => {
      const content = await textFile(fileName);
      return `${fileName}: ${content.length}`;
    })
  );
  return results.join('\n');
}

方案2:串行执行(保证顺序)

async function fileSizes(files) {
  let list = "";
  for (const fileName of files) {
    const content = await textFile(fileName);
    list += `${fileName}: ${content.length}\n`;
  }
  return list;
}

乌鸦的智慧应用

在卡拉的LED项目中,如果她需要同时控制多个屏幕并收集状态:

// 错误示例(共享状态修改)
async function getScreenStatus(screens) {
  let status = "";
  await Promise.all(screens.map(async screen => {
    status += `${screen}: ${await checkStatus(screen)}\n`;
  }));
  return status;
}

// 正确示例(先收集再合并)
async function getScreenStatus(screens) {
  const results = await Promise.all(
    screens.map(async screen => 
      `${screen}: ${await checkStatus(screen)}`
    )
  );
  return results.join('\n');
}

这种设计确保了即使多个屏幕的状态请求并行执行,最终结果也能正确合并,避免了数据覆盖问题。就像乌鸦收集坚果一样,每个坚果都要妥善存放,才能在需要时完整取用。 🐦✨

你能理解为什么会这样吗?

问题出在 += 操作符上。该操作符会在语句开始执行时获取 list 的当前值,然后在 await 完成时,将 list 绑定设置为该值加上新增的字符串。

但在语句开始执行到完成之间,存在一个异步间隙。map 表达式会在任何内容被添加到 list 之前运行,因此每个 += 操作符都会从空字符串开始。当异步获取文件内容完成时,每个操作最终都会将 list 设置为“将当前行字符串添加到空字符串”的结果。

要避免这个问题其实很简单:可以让被映射的 Promise 返回各行字符串,然后对 Promise.all 的结果调用 join 方法,而不是通过修改变量 list 来构建结果。通常来说,计算新值比修改现有值更不容易出错。

©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容