首页 / 文章

等待与计算:Node.js与Go中的并发与并行

展示如何区分并发与并行性的可运行的 Node.js 和 Go 示例,说明在何种情况下使用 Promise 即可,在何时需要工作线程,以及 goroutine 与它们的区别。

发布者:REACTAPP.TOP阅读约 8 分钟1761 词

来源: Concurrency vs parallelism, explained with Node.js and Go

将并发与并行视为同义词会导致实际故障,比如 API 在高负载下卡住。通过对比 Node.js 和 Go 可以清晰看出二者的区别:Node.js 能让开发者毫不费力地实现并发,而并行则需要自己编写代码;Go 则仅通过一个关键字就能同时提供这两种功能。通过用每种语言编写简单的可运行程序,你就能了解自己的工作负载需要哪种技术。

两种定义与生活类比

并发指的是一个处理程序在当前任务等待时切换到其他任务,从而同时处理多项任务;并行则是指多个处理程序在完全相同的时刻执行多项任务。

想象一个厨房。一名厨师同时操作三个锅,这就是并发:厨师搅拌第一个锅中的食物,当第二个锅里的内容开始炖煮时,他便去切第三个锅所需的蔬菜。没有任何一刻会有两件事同时发生,但厨师却从未闲着。如果在厨房里安排三名厨师,每人负责一个锅,那就是并行。

Node.js:内置并发支持

一个线程,处理多个请求

Node.js在单线程上执行JavaScript代码。尽管如此,它依然能够很好地处理高并发流量,因为大多数服务器任务都需要等待数据库、文件或其他服务的响应。Node会将这些等待任务交给操作系统(对于某些操作,则交给libuv的内部线程池),然后继续处理下一段JavaScript代码。这种调度机制就是事件循环。如需更深入地了解其工作原理,请参阅libuv、事件循环和线程池是如何协同工作的。

以下程序通过基于Promise的定时器模拟了三个各需一秒的请求:

function fakeRequest(name, ms) {
  return new Promise((resolve) => {
    setTimeout(() => resolve(`${name} done`), ms);
  });
}

async function main() {
  console.time("total");

  const results = await Promise.all([
    fakeRequest("users", 1000),
    fakeRequest("orders", 1000),
    fakeRequest("products", 1000),
  ]);

  console.log(results);
  console.timeEnd("total");
}

main();

由于 Promise.all 会同时启动这三个任务,且计时器也同步倒计时,因此 console.timeEnd 显示的时间约为1秒而非3秒。单个线程同时处理了三个任务,从未空闲等待过任何任务完成。这就是并发。

真正的CPU运算会怎样

事件循环只能重叠处理等待任务。当任务仅为纯计算时,就没有什么可以交给操作系统处理的。该程序会对一个较大的数值范围进行三次求和:

function countTo(n) {
  let sum = 0;
  for (let i = 0; i < n; i++) sum += i;
  return sum;
}

console.time("total");
countTo(2_000_000_000);
countTo(2_000_000_000);
countTo(2_000_000_000);
console.timeEnd("total");

总耗时大约是单次调用耗时的三倍。将这些调用包裹在 Promise.all 中也无济于事,因为每个 countTo 函数都会从开始到结束同步执行,其他任何操作都无法进行。用厨房作比喻的话,就是只有一名厨师需要依次处理每一口锅里的食物。更糟糕的是,在服务器环境中,当循环正在运行时,进程无法处理任何 HTTP 请求。

利用工作线程实现并行处理

要使用多个 CPU 核心,就需要多个线程,而 Node 中实现这一功能的工具就是 worker_threads 模块。首先来看工作线程:它是一个独立的文件,从 workerData 中读取输入数据,执行计算后把结果传回父进程。

// worker.js
const { parentPort, workerData } = require("worker_threads");

let sum = 0;
for (let i = 0; i < workerData; i++) sum += i;

parentPort.postMessage(sum);

主文件将每个工作进程封装在 Promise 中,当工作进程的 message 事件触发时该 Promise 才会 resolve,而一旦发生 error 则会 reject,随后同时启动三个工作进程:

// main.js
const { Worker } = require("worker_threads");
const path = require("path");

function countInWorker(n) {
  return new Promise((resolve, reject) => {
    const worker = new Worker(path.join(__dirname, "worker.js"), {
      workerData: n,
    });
    worker.on("message", resolve);
    worker.on("error", reject);
  });
}

async function main() {
  console.time("total");

  await Promise.all([
    countInWorker(2_000_000_000),
    countInWorker(2_000_000_000),
    countInWorker(2_000_000_000),
  ]);

  console.timeEnd("total");
}

main();

在拥有三个或更多核心的机器上,总处理时间会降至大约一次调用的时长。相当于三个厨师同时使用三个锅进行操作,而主线程仍可继续处理其他请求。

不过请注意所需的条件:一个专门的文件、一个消息通道以及一个 Promise 封装结构。在 Node.js 中,并行处理是需要手动构建的。在生产环境中,应避免为每个任务都创建新的工作进程,因为启动一个工作进程本身就有相当的成本;通常的做法是使用可重用的工作进程池,相关内容可在我们的工作线程与进程池指南中找到。

Go 语言:一个关键字即可实现两者功能

使用 goroutine 而非事件循环

在 Go 语言中,通过在函数调用前加上 go 即可启动一个 goroutine。每个 goroutine 初始只有几千字节的栈空间,会根据需求动态扩展,因此同时运行数千个 goroutine 也是常规操作。Go 运行时的调度器会将 goroutine 映射到操作系统线程上,默认情况下允许与 CPU 核心数量相同的 goroutine 同时执行,这一限制可通过 GOMAXPROCS 来调整。

以下是再次进行的三次请求模拟,这次使用了 sync.WaitGroup 来等待所有 goroutine 完成:

package main

import (
 "fmt"
 "sync"
 "time"
)

func fakeRequest(name string, wg *sync.WaitGroup) {
 defer wg.Done()
 time.Sleep(time.Second)
 fmt.Println(name, "done")
}

func main() {
 start := time.Now()
 var wg sync.WaitGroup

 for _, name := range []string{"users", "orders", "products"} {
  wg.Add(1)
  go fakeRequest(name, &wg)
 }

 wg.Wait()
 fmt.Println("total:", time.Since(start))
}

每个 goroutine 都通过 defer 调用 wg.Done(),而 wg.Wait() 会阻塞直到所有 goroutine 都完成。其结果与 Node.js 的 Promise.all 版本一致:总耗时大约为一秒。

依赖 CPU 的任务无需额外机制

现在来看计数示例。在 Node.js 中需要工作进程文件和消息传递,而 Go 版本则保持了与之前完全相同的结构:

package main

import (
 "fmt"
 "sync"
 "time"
)

func countTo(n int, wg *sync.WaitGroup) {
 defer wg.Done()
 sum := 0
 for i := 0; i < n; i++ {
  sum += i
 }
 fmt.Println("sum:", sum)
}

func main() {
 start := time.Now()
 var wg sync.WaitGroup

 for i := 0; i < 3; i++ {
  wg.Add(1)
  go countTo(2_000_000_000, &wg)
 }

 wg.Wait()
 fmt.Println("total:", time.Since(start))
}

在多核机器上,这一操作的执行时间大约相当于单次调用的时间。调度器找到了三个可运行的 goroutine,并同时在这三个线程上执行它们。你以并发的方式表达了任务,而运行时则将其转换为并行执行。

这正好体现了罗布·派克在那篇著名的演讲《并发不等于并行》中所阐述的区别:并发是指将程序结构化为独立执行的各个部分,而并行则是当有多个核心可用时,运行时能够对这种结构所做的处理。

共享内存的代价

Node.js 的工作进程各自拥有独立的堆内存,无法读取彼此的变量;它们通过传递消息来进行通信。(如果真的需要在 Node.js 中实现共享内存,可以使用带有 Atomics 的 SharedArrayBuffer,但这依然会带来下文所述的同步问题。)

相比之下,协程则共享同一块内存。如果两个协程在没有协调的情况下同时增加同一个计数器,就会发生数据竞争,最终得到的数值将是错误的。Go 语言的常规解决方案是通过通道传递数据,并且仅在不可避免需要共享状态时才使用互斥锁。

这个工作进程池展示了基于通道的实现方式。三个协程从一个通道读取任务,然后将处理后的双倍数值写入另一个通道:

package main

import "fmt"

func worker(jobs <-chan int, results chan<- int) {
 for j := range jobs {
  results <- j * 2
 }
}

func main() {
 jobs := make(chan int, 100)
 results := make(chan int, 100)

 for w := 1; w <= 3; w++ {
  go worker(jobs, results)
 }

 for j := 1; j <= 9; j++ {
  jobs <- j
 }
 close(jobs)

 for r := 1; r <= 9; r++ {
  fmt.Println(<-results)
 }
}

除了通道本身之外没有其他共享内容,因此无需加锁,而增加工作线程可以让任务池利用更多核心。结果会按照工作线程完成的顺序呈现,这正是该模式的预期表现。使用 go run -race main.go 运行程序可启用竞态条件检测器,它会报告任何对共享内存的意外非同步访问。

两种模型的对比

  • 并发单位:Node.js 使用在单个事件循环上调度的承诺和回调;Go 则使用协程。
  • 并行性:在 Node.js 中,通过 worker_threads 或 cluster 模块(可运行多个进程)来实现并行,属于可选功能;而在 Go 中,默认即可实现并行,调度器会利用所有核心。
  • 高CPU消耗的任务:在Node.js中,除非将其移到工作进程,否则会阻塞事件循环;而在Go中则只是另一个协程而已。
  • 数据共享:Node.js的工作进程通过消息传递进行通信;协程则使用通道或受互斥锁保护的共享内存。
  • 常见故障模式:在Node.js中是事件循环被阻塞;而在Go中则是数据竞争问题。
  • 选择合适的工具:等待还是计算?

    大多数情况下只需回答一个问题即可:任务中的慢速环节是在等待,还是在计算?

    如果是等待(如等待数据库、文件系统或网络请求),那么并发机制就足够了。在Node.js中这意味着使用承诺对象和async/await;在Go中则意味着使用协程,调度器会暂停那些被阻塞的协程,使其不占用CPU资源。

    如果是在进行计算操作(如调整图片大小、哈希处理、解析超大文件),就需要并行处理。在 Node.js 中,可将任务交给工作线程处理,或使用 cluster 将请求处理分散到多个进程上。在 Go 语言中,则可通过启动协程,并确保它们通过通道交换数据或使用互斥锁来保护共享状态。

    并行处理的效率受硬件限制:在单核容器中,无论使用哪种语言,三个依赖 CPU 的任务的执行速度都不会快于顺序执行。

    关键要点

    • 并发性关乎任务结构:合理安排工作,避免在某部分等待时其他部分闲置。并行性则与硬件相关:真正能同时运行的任务数量。
    • 在 Node.js 中,Promise.all 可以并行处理等待任务,但对同步计算毫无帮助,因为这会导致线程上的所有请求都被阻塞。
  • Node.js 的并行处理需要显式的工作者线程(最好是池化线程)或多个进程。
  • Go 的调度器会自动将并发的 goroutine 转换为并行执行,但由于存在共享内存,因此必须防范数据竞争问题;建议使用通道,并启用竞态条件检测器。