并发与并行概念深度解析及 JavaScript 实践

一、核心概念定义

1. 问题背景

在计算机科学领域,并发和并行是两个经常被交替使用但本质不同的概念。许多开发者在实际工作中对这两个概念的差异理解不够清晰,导致在设计和实现并发程序时产生困惑。

2. 概念辨析

A. 顺序执行

顺序执行是指任务按先后顺序依次完成,任务之间不存在时间重叠。例如,一个人先看手机,完成所有手机操作后再开始喝汤,这就是顺序执行。顺序执行的问题在于当某个任务被阻塞时(如等待朋友回复消息),整个流程会被阻塞,造成时间浪费。

B. 并发

并发是通过在多个任务的子任务之间交替执行来实现的。关键特征是时间片轮转,同一时刻只有一个任务在执行,但通过快速切换给人以同时进行的感觉。例如,一个人看一会儿手机,放下手机喝一口汤,然后继续看手机,这就是并发工作模式。

C. 并行

并行是指多个任务真正同时执行。关键特征是同一时刻有多个任务在同时进行。例如,一个人一只手发消息,另一只手同时喝汤,这就是并行工作模式。

graph LR
    A[任务执行模式] --> B[顺序执行]
    A --> C[并发执行]
    A --> D[并行执行]
    B --> B1[任务依次完成]
    C --> C1[子任务交替执行]
    D --> D1[任务同时执行]

mermaid

二、线程机制

1. 线程概念

线程是操作系统能够进行运算调度的最小单位。一个进程可以包含多个线程,这些线程共享进程的内存空间和资源。每个线程可以独立执行不同的任务,是现代并发编程的基础。

2. 线程执行模式

A. 并行执行

当 CPU 核心数大于或等于同时运行的线程数时,每个线程可以被分配到不同的核心上执行,实现真正的并行计算。

B. 并发执行

当 CPU 核心数小于同时运行的线程数时,操作系统需要在不同的线程之间进行切换调度,通过时间片轮转实现并发。

graph TD
    A[多线程程序] --> B{CPU 核心数判断}
    B -->|核心数 >= 线程数| C[并行执行]
    B -->|核心数 < 线程数| D[并发执行]
    C --> C1[每个线程分配到独立核心]
    D --> D1[操作系统调度线程切换]

mermaid

3. 开发注意事项

无论线程是并行执行还是并发执行,从开发者的角度看,使用线程的方式是一样的。开发者使用线程来提高性能和避免阻塞,而具体如何调度这些线程则由操作系统根据可用资源决定。

重要的问题是,无论并发还是并行,不同线程中指令的执行顺序都是不可预测的。开发者必须警惕可能出现的并发问题,如竞争条件、死锁、活锁等。

三、其他并发实现方式

1. 多进程方式

除了使用线程,创建多个进程也是实现并发或并行的方式。每个进程都有独立的内存空间,进程间默认不共享内存。如果需要不同进程操作相同状态,需要使用进程间通信机制,如共享内存段、管道、消息队列或数据库。

多进程方式的缺点是每个进程都有独立的内存空间分配,资源开销相对较大。

2. I/O 事件通知机制

操作系统内核实现了自己的 I/O 事件通知机制,在构建不希望被阻塞的程序时非常有用。关键思想是,内核线程不是实现并发性的唯一操作系统特定方式。

四、Node.js 用户态并发模型

1. 单线程架构

Node.js 是用户态并发的一个典型例子。虽然 JavaScript 程序运行在单线程环境中,执行流是顺序的,但阻塞任务如 I/O 操作被委托给 Node.js 工作线程。Node.js 在幕后使用线程来管理这些阻塞任务,而不向开发者暴露管理它们的复杂性。

2. 事件循环机制

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: 执行回调函数

mermaid

3. 代码示例分析

A. 阻塞示例

setTimeout(() => {
    while (true) {
        console.log("a");
    }
}, 1000);

setTimeout(() => {
    while (true) {
        console.log("b");
    }
}, 1000);

运行这段代码时,屏幕上只会不断输出"a"。这是因为 Node.js 解释器会持续执行当前回调,直到该回调中的所有指令执行完毕。当第一个 setTimeout 的回调开始执行后,它的无限循环占据了主线程,第二个回调永远不会被调用。

B. 竞争条件示例

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. 正确实现示例

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 之前,确保每次读取和更新操作是原子的,不会被其他操作打断。

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)

mermaid

五、并发编程注意事项

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
最后修改:2026 年 01 月 16 日
如果觉得我的文章对你有用,请随意赞赏