Loading... # 并发与并行概念深度解析及 JavaScript 实践 # 一、核心概念定义 ## 1. 问题背景 在计算机科学领域,并发和并行是两个经常被交替使用但本质不同的概念。许多开发者在实际工作中对这两个概念的差异理解不够清晰,导致在设计和实现并发程序时产生困惑。 ## 2. 概念辨析 ### A. 顺序执行 顺序执行是指任务按先后顺序依次完成,任务之间不存在时间重叠。例如,一个人先看手机,完成所有手机操作后再开始喝汤,这就是顺序执行。顺序执行的问题在于当某个任务被阻塞时(如等待朋友回复消息),整个流程会被阻塞,造成时间浪费。 ### B. 并发 并发是通过在多个任务的子任务之间交替执行来实现的。关键特征是时间片轮转,同一时刻只有一个任务在执行,但通过快速切换给人以同时进行的感觉。例如,一个人看一会儿手机,放下手机喝一口汤,然后继续看手机,这就是并发工作模式。 ### C. 并行 并行是指多个任务真正同时执行。关键特征是同一时刻有多个任务在同时进行。例如,一个人一只手发消息,另一只手同时喝汤,这就是并行工作模式。 ```mermaid graph LR A[任务执行模式] --> B[顺序执行] A --> C[并发执行] A --> D[并行执行] B --> B1[任务依次完成] C --> C1[子任务交替执行] D --> D1[任务同时执行] ```  # 二、线程机制 ## 1. 线程概念 线程是操作系统能够进行运算调度的最小单位。一个进程可以包含多个线程,这些线程共享进程的内存空间和资源。每个线程可以独立执行不同的任务,是现代并发编程的基础。 ## 2. 线程执行模式 ### A. 并行执行 当 CPU 核心数大于或等于同时运行的线程数时,每个线程可以被分配到不同的核心上执行,实现真正的并行计算。 ### B. 并发执行 当 CPU 核心数小于同时运行的线程数时,操作系统需要在不同的线程之间进行切换调度,通过时间片轮转实现并发。 ```mermaid graph TD A[多线程程序] --> B{CPU 核心数判断} B -->|核心数 >= 线程数| C[并行执行] B -->|核心数 < 线程数| D[并发执行] C --> C1[每个线程分配到独立核心] D --> D1[操作系统调度线程切换] ```  ## 3. 开发注意事项 无论线程是并行执行还是并发执行,从开发者的角度看,使用线程的方式是一样的。开发者使用线程来提高性能和避免阻塞,而具体如何调度这些线程则由操作系统根据可用资源决定。 重要的问题是,无论并发还是并行,不同线程中指令的执行顺序都是不可预测的。开发者必须警惕可能出现的并发问题,如竞争条件、死锁、活锁等。 # 三、其他并发实现方式 ## 1. 多进程方式 除了使用线程,创建多个进程也是实现并发或并行的方式。每个进程都有独立的内存空间,进程间默认不共享内存。如果需要不同进程操作相同状态,需要使用进程间通信机制,如共享内存段、管道、消息队列或数据库。 多进程方式的缺点是每个进程都有独立的内存空间分配,资源开销相对较大。 ## 2. I/O 事件通知机制 操作系统内核实现了自己的 I/O 事件通知机制,在构建不希望被阻塞的程序时非常有用。关键思想是,内核线程不是实现并发性的唯一操作系统特定方式。 # 四、Node.js 用户态并发模型 ## 1. 单线程架构 Node.js 是用户态并发的一个典型例子。虽然 JavaScript 程序运行在单线程环境中,执行流是顺序的,但阻塞任务如 I/O 操作被委托给 Node.js 工作线程。Node.js 在幕后使用线程来管理这些阻塞任务,而不向开发者暴露管理它们的复杂性。 ## 2. 事件循环机制 ```mermaid sequenceDiagram participant M as 主线程 participant E as 事件循环 participant W as 工作线程 participant FS as 文件系统 M->>E: 注册 I/O 回调 E->>W: 委托阻塞任务 W->>FS: 执行 I/O 操作 W-->>E: 任务完成 E->>M: 执行回调函数 ```  ## 3. 代码示例分析 ### A. 阻塞示例 ```javascript setTimeout(() => { while (true) { console.log("a"); } }, 1000); setTimeout(() => { while (true) { console.log("b"); } }, 1000); ``` 运行这段代码时,屏幕上只会不断输出"a"。这是因为 Node.js 解释器会持续执行当前回调,直到该回调中的所有指令执行完毕。当第一个 setTimeout 的回调开始执行后,它的无限循环占据了主线程,第二个回调永远不会被调用。 ### B. 竞争条件示例 ```javascript const test = async () => { let x = 0 const foo = async () => { let y = x await scheduler.wait(100) x = y + 1 } await Promise.all([foo(), foo(), foo()]) console.log(x) // 输出 1,而非 3 } ``` 问题分析:当三个 foo 函数被调用时,它们遇到 await 语句后立即挂起。三个函数几乎同时读取 x 的值(此时为 0),然后各自等待 100 毫秒。当回调函数执行时,它们都使用之前读取的值 0 来更新 x,因此最终结果是 1 而不是 3。 ### C. 正确实现示例 ```javascript const test2 = async () => { let x = 0 const foo = async () => { await scheduler.wait(100) let y = x x = y + 1 } await Promise.all([foo(), foo(), foo()]) console.log(x) // 输出 3 } ``` 改进原理:将 await 语句放在读取 x 之前,确保每次读取和更新操作是原子的,不会被其他操作打断。 ```mermaid sequenceDiagram participant M as 主线程 participant F1 as foo() 1 participant F2 as foo() 2 participant F3 as foo() 3 M->>F1: 调用 foo() F1->>M: 读取 x=0,挂起 M->>F2: 调用 foo() F2->>M: 读取 x=0,挂起 M->>F3: 调用 foo() F3->>M: 读取 x=0,挂起 Note over M: 所有回调按顺序执行 F1->>M: x = 0 + 1 F2->>M: x = 0 + 1(但此时 x 已为 1) F3->>M: x = 0 + 1(但此时 x 已为 2) ```  # 五、并发编程注意事项 ## 1. 并发问题 虽然 Node.js 的单线程模型降低了竞争条件等问题的发生概率,但并不能完全避免。与 C 语言等多线程语言不同,Node.js 主要在回调级别进行切换,而不是在指令级别。 ## 2. 风险场景 当程序逻辑包含大量基于异步回调的函数(如 fs.readFile()、setTimeout()、setImmediate() 或 Promise.then())时,竞争条件很容易出现。使用 await 语句时也要注意,因为 await 可以被视为将当前作用域内剩余代码包装成一个回调函数的语法糖。 ## 3. 最佳实践 ### A. 避免共享可变状态 尽量减少不同异步操作之间的共享状态,或者使用原子操作来保护共享状态。 ### B. 合理使用 await 将 await 语句放在正确的位置,确保关键操作不会被中断。 ### C. 使用并发原语 对于复杂的并发场景,考虑使用锁、信号量等并发控制原语。 # 六、总结 ## 1. 核心要点 实现并发没有唯一的方式,不同的实现方式会影响程序的性能、可能遇到的问题以及需要注意的事项。在编写并发或并行程序时需要谨慎,因为事情很容易出错。 ## 2. 关键区别 - 并发是交替执行多个任务,通过时间片轮转实现 - 并行是同时执行多个任务,需要多核 CPU 支持 - 线程是操作系统级别的并发原语 - Node.js 通过事件循环和工作线程实现了用户态的并发模型 ## 3. 实践建议 在实际开发中,要根据具体场景选择合适的并发策略,充分理解所选模型的特性和潜在问题,编写出既高效又可靠的并发程序。 *** ## 参考资料 1. [Understanding Concurrency, Parallelism and JS | rugu](https://www.rugu.dev/en/blog/concurrency-and-parallelism/) 最后修改:2026 年 01 月 16 日 © 允许规范转载 赞 如果觉得我的文章对你有用,请随意赞赏