并发与并行概念深度解析及 JavaScript 实践
一、核心概念定义
1. 问题背景
在计算机科学领域,并发和并行是两个经常被交替使用但本质不同的概念。许多开发者在实际工作中对这两个概念的差异理解不够清晰,导致在设计和实现并发程序时产生困惑。
2. 概念辨析
A. 顺序执行
顺序执行是指任务按先后顺序依次完成,任务之间不存在时间重叠。例如,一个人先看手机,完成所有手机操作后再开始喝汤,这就是顺序执行。顺序执行的问题在于当某个任务被阻塞时(如等待朋友回复消息),整个流程会被阻塞,造成时间浪费。
B. 并发
并发是通过在多个任务的子任务之间交替执行来实现的。关键特征是时间片轮转,同一时刻只有一个任务在执行,但通过快速切换给人以同时进行的感觉。例如,一个人看一会儿手机,放下手机喝一口汤,然后继续看手机,这就是并发工作模式。
C. 并行
并行是指多个任务真正同时执行。关键特征是同一时刻有多个任务在同时进行。例如,一个人一只手发消息,另一只手同时喝汤,这就是并行工作模式。
graph LR
A[任务执行模式] --> B[顺序执行]
A --> C[并发执行]
A --> D[并行执行]
B --> B1[任务依次完成]
C --> C1[子任务交替执行]
D --> D1[任务同时执行]二、线程机制
1. 线程概念
线程是操作系统能够进行运算调度的最小单位。一个进程可以包含多个线程,这些线程共享进程的内存空间和资源。每个线程可以独立执行不同的任务,是现代并发编程的基础。
2. 线程执行模式
A. 并行执行
当 CPU 核心数大于或等于同时运行的线程数时,每个线程可以被分配到不同的核心上执行,实现真正的并行计算。
B. 并发执行
当 CPU 核心数小于同时运行的线程数时,操作系统需要在不同的线程之间进行切换调度,通过时间片轮转实现并发。
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. 事件循环机制
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. 阻塞示例
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)五、并发编程注意事项
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. 实践建议
在实际开发中,要根据具体场景选择合适的并发策略,充分理解所选模型的特性和潜在问题,编写出既高效又可靠的并发程序。