Ray-D-Song's Blog

单线程并发

2023-08-17 6min

本文封面和思路来源于 Ruby China 2021 By 东仙队长的分享: Ruby 高并发编程指北

并发

假设有一个处理器, 因为只有一个核心, 所以同时他只能运行一个进程, 又因为一个进程上同一时间只能运行一个线程(task). 所以你可以认为, 同一时间, 这颗处理器只能处理一个任务.

操作系统会将cpu 使用时间这一资源进行切分, 这就是时间分片. 划分好的资源会按照调度算法分配给各个线程去使用. 进程内线程切换大概就是下面这种感觉: 线程调度

因为现代计算机性能足够强劲, 所以使用者体感上是 task_1、2、3 在同时运行, 这就是并发.

我并不想用「多个任务同时发生」这种模糊的表达, 在我看来「并发」之所以被称为「并发」单纯是翻译后中文表达的问题.
无论如何, 同时确实只有一个任务在进行

并行

并行就要好理解的多, 你有一个多核心处理器, 例如拥有两个核心的老古董E6600. 因为有两个核心, 同时能运行两个进程, 每个进程可以运行一个线程. 因此, 你的电脑就能真正的在同一时间运行两个任务.
大概就是下面这种感觉: 并行

程序设计

之前刷知乎时, 看到这样一种说法:「你的程序首先必须支持并发, 才能支持并行」
因为并发不仅是对程序执行状态的一种描述, 也是一种程序设计的方法论:
设计并发程序的条件就是「程序的执行不依赖先后顺序、不依赖精确时序」.
而一段不依赖先后顺序、不依赖精确时序的程序, 自然可以拆分成多段, 在多个线程上运行. 这多个线程又可以运行在不同的进程之上, 进而实现并行.

首先我们来看看最常见的 web 应用场景, 从接收到用户请求到返回, 需要这些步骤: web app 其中DB operation占据了最大的空间, 因为数据库操作确实是最耗时的操作.

多线程并发

我们来假设一种极端的情况, 如果一直只有一个线程在运行, 那么运行过程就会变成这样: 单线程 如果是这样的话, 我想我们这台单核的机器没几个用户就 burn out 了. 应对这种情况, 最简单的思路就是多开几个线程, 虽然我们只有一个进程, 但当第一个线程卡在数据库操作时, 可以将控制权转移到其他线程进行操作. 多线程

协程

多线程并发, 一切看起来都很美好, 但有两个问题, 一个是计算机能启动的线程数量是有限的, 还有就是线程的切换有着客观的成本. 那么如果我们可以自己在程序中模拟线程, 切换的不再是线程, 而是执行的程序方法. 岂不是可以用极低的成本去并发? 这个模拟线程, 或者说轻量线程/用户线程, 就是协程. 有了协程, 我们可以更细粒度的控制程序的执行. 以下是 node.js 协程(async异步)的例子:

async function App(request, response) {
  const id = request.query.id // <- read req and parsing
  try {
    const result = await DB.find({ id }) // <- DB operation
    res.json({ // generate json and response
      code: 200,
      data: result
    })
  } catch(e) {
    // ...
  }
}

当程序执行到await标识的数据库操作时, 就会在当前方法内形成阻塞, 等待数据库的返回结果. 但对于调用 App() 的方法来说, 程序则会继续向下执行(处理其他的请求).

整个过程的简略图大概如下: async

关于协程更具体的介绍, 可以看我的这篇文章协程(Coroutine)和纤程(Fiber)

实际上这种单线程+协程并发模型, 就是 Node.js 的并发模型. 这几年协程的火爆, jdk21、php8 都支持了协程方案. 足以提现这种模式的优势.

说到这里, 有没有什么比单线程异步并发更强的方案呢? 那就是多线程+协程, Go 就是这样的方案. Go 实现了一种更复杂的 Goroutine 到实际线程资源的映射, 使其可以以极低的配置达到恐怖的并发性能