findxc / blog

88 stars 5 forks source link

进程?线程? #61

Open findxc opened 2 years ago

findxc commented 2 years ago

物理核心数和逻辑核心数

每个电脑都会有一个 CPU ,全称 Central Processing Unit ,中文翻译为中央处理器。

现在的 CPU 一般是多核的。比如你的电脑是双核,这个双核是指硬件/物理层面 CPU 包含两个核心。

F158967F-04CF-472E-A401-D1D7F07D8985

终端输入 sysctl hw.physicalcpu 来查看物理核心数,也是显示的 2 。

终端输入 sysctl hw.logicalcpu 来查看逻辑核心数,会发现显示的是 4 。因为英特尔的超线程技术,使得从软件层面来看,变成 4 个核心了。

在活动监视器中,双击底部的 CPU 负载区域,会展示 CPU 历史记录,也会发现是 4 个核心。

CDF1388A-DD67-41C4-A305-B120A760A961

进程

进程是指运行中的某个程序。

当我们启动一个软件后,进程列表中就会出现这个软件,并且可能出现多个这个软件相关的进程(说明这个软件是多个进程配合来工作的)。

AF559D77-CD43-4B96-A189-F3AEFC81D07F

我们可以在 Chrome 自己的任务管理器中看到这些进程具体是啥。点击 Chrome 右上角竖着的三个点,然后选择「更多工具」,再选择「任务管理器」。

77DFC6D2-C588-4D6D-B793-B279644C1EFC

AD97A534-C904-427A-9418-EB91E34DC3C1

Chrome 任务管理器的进程和活动监视器中的进程是一致的。(活动监视器中多了一个 chrome_crashpad_handler ,是操作系统启动的)

我们可以发现 Chrome 一共有以下进程:

为什么 Chrome 要启动这么多进程呢?

由于 Chrome 需要加载第三方插件、第三方的网页,进程之间天然的隔离性会让 Chrome 更加安全。进程是资源分配的最小单位。不同进程之间无法读写私有数据。

线程

先看一下维基上的解释。

线程(thread)是操作系统能够进行运算调度的最小单位。大部分情况下,它被包含在进程之中,是进程中的实际运作单位。

同一进程中的多条线程将共享该进程中的全部系统资源,如虚拟地址空间,文件描述符和信号处理等等。但同一进程中的多个线程有各自的调用栈,自己的寄存器环境,自己的线程本地存储。

我们可以把进程理解为一个工厂,线程理解为工厂里的流水线。一个工厂里至少会有一条流水线,也允许有多条流水线,这些流水线是共用工厂的资源的。

一个逻辑核心就是一个机器人。操作系统会调度这个机器人为所有流水线服务。

7F6A92A8-6B72-460C-A564-71E71FB4DB07

当只有一个逻辑核心时,那它就会按一定规则去轮流服务流水线 m-1 、 m-2 、 n-1 、 n-2 等等。看起来这些流水线就像在“同时”运行,其实同一时刻只有一条流水线在运行,毕竟只有一个机器人。这种在一段时间内多个任务能一起推进的场景我们叫做并发执行。

如果只能等流水线 a 整体运行结束,再开始处理流水线 b ,这种我们就叫做不支持并发。

当你有多个逻辑核心时,那就相应有那么多个机器人,这些机器人可以同时工作,同一时刻多个任务一起推进的场景叫做并行执行。

699DC413-3750-4440-875D-140DF96D2D21

干活的人多了,活干得会更快,所以并行是会增大并发的能力的。

JS 是单线程的是指什么

就是字面意思, JS 的执行是单线程的。

想一下现在有两个逻辑核心,但是只有一条流水线,这时候两个逻辑核心会轮流为这一条流水线服务,但是两个逻辑核心其实都没有充分利用,因为核心 1 工作的时候核心 2 就在休息,核心 2 工作的时候核心 1 就在休息,毕竟只有一条流水线需要服务。

浏览器环境

一般显示器的刷新率是 60Hz ,也就是一秒钟刷新 60 次,时间间隔约为 16.7ms 。

如果用户点了一个按钮,里面执行了 JS 代码,然后界面要更新,这个整体时间应尽量不超过 16.7ms ,否则显示器显示的就还是旧的界面。如果这个时间再长点,用户就会觉得界面卡住了。

比如下面这个例子, 在 while 里等待了 1s ,那么这 1s 内界面不会再响应用户其它交互了。

<input id="nameInput" /><br />
<div>hello, <span id="name"></span></div>

<script>
  const nameInput = document.getElementById('nameInput')
  const name = document.getElementById('name')

  nameInput.addEventListener('input', e => {
    const d = new Date()
    // 会等待 1s,这 1s 内界面不再响应用户的交互
    while (new Date() - d < 1000) {}
    name.innerText = e.target.value
  })
</script>

由于一般来说用户交互时需要执行的 JS 代码不会很多,所以 JS 的单线程其实也不影响。

但总还是有一些 web 应用会需要做大量计算,那咋整呢?

Web Workers

一个方案是使用 Web Workers , worker 会启动新的线程,所以不会阻塞用户界面。并且多个 worker 会启动多个线程,这样能充分利用多核。你把计算工作放 worker ,等 worker 计算完了可以通知你。

至于具体启动几个 worker ,可以参考 navigator.hardwareConcurrency 返回的逻辑核心数,详见 Navigator.hardwareConcurrency - Web APIs | MDN

我用下面的代码测试了一下 worker 对多核的利用。

index.html 内容如下:

<button id="logBtn">log</button>

<script>
  // worker 工作时点击按钮是有响应的
  const logBtn = document.getElementById('logBtn')
  logBtn.addEventListener('click', () => {
    console.log('in')
  })

  const workerCount = 8
  new Array(workerCount).fill('').forEach((x, index) => {
    // 实例化一个 worker
    var myWorker = new Worker('worker.js')
    // 给 worker 发信息,worker 接受信息后做相应处理
    myWorker.postMessage(index + 1)
    // worker 处理完之后再发回信息
    myWorker.onmessage = function (e) {
      console.log(`worker${index + 1} 发来信息:`, e.data)
    }
  })
</script>

worker.js 内容如下:

onmessage = function (e) {
  console.log('worker 接到任务:', e.data)

  // 用循环来模拟大量计算
  for (let i = 0; i < 900000; i++) {
    for (let j = 0; j < 900000; j++) {}
  }

  console.log('worker 完成任务')
  postMessage('end')
}

当启动 2 个 worker 时:

B984BC83-B5FA-4113-9F25-C37910C6A297

当启动 4 个 worker 时:

50CE181C-6385-4A4A-9F4C-57091C3E7857

当启动 8 个 worker 时:

3CB658C6-B604-4152-90FA-E9BB43012CA3

启动 8 个 worker 时 CPU 占用并没有达到 800% ,依然是接近 400% 。其实从启动 2 个 worker 时就有端倪了,当时就只有 4 个核心在工作,另外 4 个没有。呃,为啥不是 8 个一起平分工作呢?猜测和超线程有关系,具体是啥原因还没弄清楚。

只能说从我的这次测试来看, worker 确实利用了多核,但又没完全利用 =.=

再补充一点,如果确实会去使用多个 worker ,那么多线程场景可能涉及到的锁(多个线程想修改同一资源时需要加锁)、死锁问题等就需要在编程时多多注意了。

WebAssembly

WebAssembly 是一种新的编码方式,其它语言比如 C++ , Go, Rust 等可以编译为 .wasm 二进制文件在 JS 中使用。

呃,我原本想吹一波 WebAssembly 性能的,然后想说用 WebAssembly 的方式计算会快很多,然后我本地用 Go 测试了一波,发现也不一定,我就突然不知道写啥了 ...

C4229A91-BAD7-4376-8571-B9B925F3373F

D1D129A9-B647-4DED-8CCA-325E17B7E944

呃,如果对 WebAssembly 性能感兴趣,可以去看这个: 一个白学家眼里的 WebAssembly - 知乎

如果是真的想在项目中使用,最好写个小 demo 先对比一下,万一用了 WebAssembly 发现并没有变快就人傻了 =.=

WebAssembly 本身是无法多线程的,可以通过结合 Web Workers 来实现多线程,详见 Using WebAssembly threads from C, C++ and Rust

就这样吧,可能得遇到相应的场景才能感受到 WebAssembly 的魅力。

Node.js 中

如果是使用 Node.js 来写后端服务,由于 JS 是单线程的,如果某个请求中需要进行大量同步的计算,当收到这个请求后,会无法响应其它请求。

比如下面这个例子,启动服务后,先访问 /slow 然后马上访问 /xxx/xxx 会被 /slow 中的同步代码阻塞,等到 /slow 返回后才会继续处理 /xxx

const express = require('express')
const app = express()
const port = 4000

app.get('/slow', (req, res) => {
  for (let i = 0; i < 5000000000; i++) {}
  res.send('Hello World!')
})

app.get('/xxx', (req, res) => {
  res.send('Hello World!')
})

app.listen(port, () => {
  console.log(`Example app listening at http://localhost:${port}`)
})

1B73D499-B9A3-4152-A89F-923A78D12088

需要说明,其实 node 服务本身是包含多个线程的。

D0BC37CE-18C3-4216-B8A4-2EDD6CCF9D88

但是这和 JS 是单线程的并不冲突。可以这样去理解,在 node 服务中,它的主逻辑是单线程的。

就像我们自己写代码时,主函数定义为 main 一样,然后 main 函数里面去调用这调用那的。当 node 服务接收到 /slow 请求后, main 函数就转去处理这个请求了,然后会一直处理里面的同步代码,这个过程中如果接收到别的请求,别的请求就只有排队。

更多细节见 不要阻塞你的事件循环(或是工作线程池) | Node.js

感受一下 Go 的多线程

我用 Go 写了一个同样的服务,由于 Go 是多线程的,所以 /xxx 不会被 /slow 阻塞。

package main

import (
    "net/http"
    "github.com/gin-gonic/gin"
)

func getXXX(c *gin.Context) {
    c.String(http.StatusOK, "Hello World!")
}

func getSlow(c *gin.Context) {
    for i := 0; i <= 5000000000; i++ {
    }
    c.String(http.StatusOK, "Hello World!")
}

func main() {
    router := gin.Default()
    router.GET("/xxx", getXXX)
    router.GET("/slow", getSlow)
    router.Run("localhost:3001")
}

975999B0-7E25-4808-BFDE-D53C4E9D7493

我本地测试的这个电脑物理核心是 4 逻辑核心是 8 ,然后试了下同时发 4 个 /slow ,请求时间和发 1 个是差不多的, 8 条流水线处理 4 个工作绰绰有余,这就是并行的好处了。

403ED291-9142-4377-8F07-EE4A1B907202

但是如果是同时发 8 个,时间就长很多了。和我预期不太一样,因为逻辑核心是 8 个,那么 8 个线程一起处理,即使时间会更长点,也没想到会长这么多。呃,不太清楚具体是为什么。

6E1B85E1-C1C6-43C6-A30B-8113B7937FDB

然后我把接口里面的 for 循环弄得比较久,来测一下 CPU 利用率。

同时发 4 个请求时大概就是 400% 。注意这里看起来像只利用了 4 个核心。不知道为啥不是 8 个平分工作。

248BC996-8D02-45C0-9E00-987CDB093D7F

同时发 8 个请求是能达到大概 800% 的,说明确实所有逻辑核心都有用上。

2A27B124-9680-437B-8DC8-6C427D2A4A3C

总结

遗留问题:超线程技术使得 1 个物理核心可以表现为 2 个逻辑核心,但是在 web worker 多线程测试中,多出来的逻辑核心并没有利用上。同时在测试 Go 多线程时, 4 个物理核心 8 个逻辑核心同时发 4 个请求,看起来也只有 4 个逻辑核心在工作,如果是同时发大于 4 个请求比如 5 个,就确实是 8 个逻辑核心一起工作了。逻辑核心的具体调度细节还不太清楚。

aifuxi commented 2 years ago

写的真好👍

NoirVoider commented 2 years ago

👍