Skip to content

Commit

Permalink
chore: 文章内容调整
Browse files Browse the repository at this point in the history
  • Loading branch information
dlzht committed May 18, 2024
1 parent 6787fc4 commit 112b571
Show file tree
Hide file tree
Showing 5 changed files with 73 additions and 69 deletions.
1 change: 1 addition & 0 deletions config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ taxonomies = [
# Theme can be customised by setting the `highlight_theme` variable to a theme supported by Zola
highlight_code = true
highlight_theme = "monokai"
external_links_target_blank = true

[extra]
# Put all your custom variables here
Expand Down
66 changes: 31 additions & 35 deletions content/004_rust_异步_01.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,107 +5,103 @@ date = 2024-04-30
tags = ["Rust", "异步"]
+++

这篇文章采用一问一答的形式, 主要介绍Rust异步相关的"基础"问题. "异步编程, 是一种越来越多语言都提供支持的并发模型, 可以让你在少量的系统线程上并发处理大量的任务, 而且借由async和await语法, 有着和同步一样的编程体验". 这是[async-book](https://rust-lang.github.io/async-book/01_getting_started/02_why_async.html#why-async)里的一段话, 我们的文章也由此开始.
这篇文章采用一问一答的形式, 主要介绍Rust异步相关的"基础"问题. "异步编程, 是一种越来越多语言都提供支持的并发模型, 可以在少量的系统线程上并发处理大量的任务, 而且借由async和await语法, 有着和同步一样的编程体验". 这是[async-book](https://rust-lang.github.io/async-book/01_getting_started/02_why_async.html#why-async)里的一段话, 我们的文章也由此开始.

<!-- more -->

> Asynchronous programming, or async for short, is a concurrent programming model supported by an increasing number of programming languages. It lets you run a large number of concurrent tasks on a small number of OS threads, while preserving much of the look and feel of ordinary synchronous programming, through the async/await syntax.
Q: Rust的异步(async, 下文用Future指代)指的是什么?
Q: 异步编程(Rust里是Future模式)指的是什么?

A: 异步是编程语言提供的并发编程模式.
A: 异步是编程语言提供的一种并发编程模式.

---

Q: 所以Future是Rust提供给开发者, 用来在程序中实现并发的一种方式, 是这样吗?
Q: 所以Future是Rust提供给开发者实现并发的一种方式, 是这样吗?

A: 是的, 标准库提供了async/await关键字和Future特征, 第三方库则提供运行时实现和相关的工具组件

---

Q: 并发模型, 并发又是什么?
Q: 并发模型里的并发指的又是什么?

A: [并发](https://en.wikipedia.org/wiki/Concurrent_computing)与串行(这篇文章里特指单线程的串行)相对, 指可以同时进行多个任务, 而不必等到上一个任务完成再进行下一个.
A: [并发](https://en.wikipedia.org/wiki/Concurrent_computing)指可以同时处理多个任务, 而不必等到完成一个再进行下一个.

比如我们的web服务是这样处理请求的: *接收请求 -> 处理过程 -> 返回响应*
例如我们有个服务是这样: **接收请求 -> 中间处理 -> 返回响应**, 这个过程就可以看作一个任务.

串行模式: *接收请求A -> 处理过程A -> 返回响应A -> 接收请求B -> 处理过程B -> 返回响应B*
逐个处理: **接收请求A -> 中间处理A -> 返回响应A -> 接收请求B -> 中间处理B -> 返回响应B**

并发模式: *接收请求A -> 处理过程A -> 接收请求B -> 处理过程B -> 返回响应B -> 返回响应A*
并发处理: **接收请求A -> 中间处理A -> 接收请求B -> 中间处理B -> 返回响应B -> 返回响应A**

上面只是并发模式下一种可能的情况, 这里想说明的是A和B两个任务在时间上可以有重叠的部分, 也就是我们可以同时进行A与B两个任务.
上面只是并发处理的一种可能情况, 实际的处理过程可以是完全不同的顺序. 不同于逐个处理的情况, A和B两个任务在时间上可以有重叠的部分, 也就是我们可以"同时"进行A与B两个任务.

---

Q: 并发对我们有什么用呢?
Q: 为什么我们需要并发这样一种看上去更复杂的模式呢?

A: 简单地说是为了提高程序的运行效率. 还是上面web服务器的例子, 假设第二步 *处理过程* 是任务等待(休眠)1秒, 第一和第三步的耗时忽略不计. 如果是串行模式, 处理两个请求就大概需要2秒, 而并发模式下只需要1秒, 甚至在一定程度内, 处理n个请求, 串行要n秒, 而并发还是要1秒.
A: 简单地说是为了提高程序的运行效率, 特别是像网络服务这样的程序, 需要同时面对很多的连接, 处理很多的请求.

---

Q: 看上去效率确实提高了, 并发是怎么做到这一点的?
Q: 并发又是怎么提高程序运行效率的呢?

A: 秘诀在于让CPU(这边我们只考虑单核)尽可能地忙碌起来. 上面的 *处理过程* 是休眠1秒, 当第一个A任务到了休眠这一步, 在串行模式下就意味这CPU无事可做了, 而在并发模式下, CPU还可以去执行B任务. 当B任务也到了需要休眠这一步, CPU确实只能空等了, 但此时A与B在同时休眠, 1秒钟后两个响应就都能返回, 所以处理两个请求需要差不多1秒.
A: 问题的根源在于任务需要"等待", 比如上面 **接收请求** 这一步, 就需要等待网络数据先达到, 我们的任务在这边只能被动地等待. 而并发模式的目地就是让我们的计算机(这边我们考虑CPU)尽可能地忙碌起来, 如果一个任务需要等待了, 就先去执行其他的任务, 之后在适当的时候再回来继续执行.

---

Q: 也就是说, 并发是把CPU空闲的时间利用起来了, 对吗?

A: 是的, 如果第二步处理过程中也需要CPU一直工作, 比如计算一个很复杂的方程, 那并发就没什么作用了. 而且并发实现本身也是有开销的, 如果CPU已经很忙了, 再用并发也不一定划算. 不单单是CPU, 很多资源比如磁盘, 也是一样的情况, 不过以CPU来说明问题也足够.
A: 是的, 如果我们的任务都是"计算型"的, 比如[bcrypt](https://bcrypt.sourceforge.net/)这样需要CPU一直工作的, 那并发的作用就比较小了. 而且实现并发本身也是有开销的, 如果CPU已经很忙了, 再用并发会加重负担, 反过来降低程序的运行效率.

---

Q: 但我的程序并没有sleep(休眠), 是不是并发对我就没用了?
Q: 但我的程序里并没有调用sleep或者wait函数, 是不是就不需要并发了?

A: 除了sleep调用, 文件相关的, 网络相关的, 各种锁, 互斥量以及信号等等操作, 都会让我们的任务停下来, 我们也把这些操作叫做同步或者阻塞调用(不那么严谨). 比如下载一个网络文件的过程中, DNS解析, 发送网络请求, 接收数据响应, 保存到文件, 这些都会让任务停顿. 同步调用是如此的普遍, 相对部分的程序都要与之打交道, 所以并发的重要性也愈发的凸显.
A: 不止这些"显然"会让任务等待函数, 像发送网络数据, 读取终端输入, 接收系统信号, 进入互斥量等等, 都会让任务开始等待. 会导致任务等待的阻塞([blocking](https://en.wikipedia.org/wiki/Blocking_(computing)))调用是如此普遍, 相当的程序都要与之打交道, 所以并发也就有了用武之地.

---

Q: 如果我不用Rust了, 换成其他语言, 比如C, 还需要考虑并发相关的问题吗?
Q: 如果不用Rust, 换成其他语言, 比如C, 还需要考虑并发的问题吗?

A: 这些同步调用究其根源是操作系统提供的, 包括Linux, Windows, Unix等等, 任务停下来表现为系统线程的阻塞. 我们的程序一方面通常都运行在操作系统之上, 另一方面又通常需要文件和网络这些功能, 可以说"无论"用什么语言都无法避开. 也许在某些语言中开发者基本不用关注, 比如Go, 因为语言已经封装好然后隐藏起来了, 但有些问题依然是需要考虑的(比如GOMAXPROCS).
A: 这些阻塞调用是操作系统提供的, 包括Linux, Windows, Unix等都是如此. 我们的程序通常都是运行在操作系统上的, 所以"无论"用什么语言都避免不了阻塞调用的问题. 像Go这样可以不用去考虑阻塞的问题, 是因为在Go在语言层面帮我们处理好了, 但也不是完全不用考虑, 比如有时候需要配置更合理的[GOMAXPROCS](https://pkg.go.dev/runtime#GOMAXPROCS).

---

Q: 操作系统提供的这些调用为什么是同步的呢?
Q: 操作系统提供的这些调用为什么是阻塞的呢?

A: 一方面, 比如读取终端输入这类的操作是不确定的事件, 我们不知道什么时候用户会完成输入, 处理这样的事件"等待"似乎没法避免; 另一方面, 早期时代的CPU还是单核的, 程序也远没有现在这么复杂, 同步的设计看上去也是合理的. 虽然时至今日情况已大为不同, 但要摆脱历史的包袱并不容易, 当年的设计和实现依然在影响我们. 事实上, 我们处在一个同步与异步并存的时期, 还存在很多问题, 还有很多工作要做.
A: 像读取终端输入, 接收网络数据这样"不确定"的事件, 操作系统也没有魔法避免等待, 而能马上拿到结果. 阻塞调用在这边是指线程会暂时被挂起. 其实系统也提供了非阻塞的调用方式, 比如Linux下的O_NONBLOCK, epoll, io_uring等这些内容, 不过阻塞的方式更加成熟和完善.

---

Q: 也就是说操作系统也提供异步的调用了吗?
*时间事件(定时, 超时等)在某种角度也可以看作不确定事件, 因为时间要依赖于时钟系统的正确工作*

A: 是的, 系统提供了异步的方式(比如Linux下的O_NONBLOCK, epoll, io_uring这些内容), 这也是编程语言可以提供并发模型的基础. 坏消息是目前并不完美, 好消息是一切都在改善中.

---
---

Q: 下载网络文件, 我好像也做过, 好像还用了什么多线程?
Q: 也就是说这些不确定事件导致等待, 而阻塞调用会让线程陷入等待, 是这样吗?

A: 是的, 多线程是操作系统提供的并行模型, 可以用这种方式实现并发. 在多线程的模式下, 我们把任务分散到不同的线程里去处理, 虽然我们的任务依然会停下来(线程会阻塞), 但此时操作系统会进行线程调度, 切换到其他线程去处理另外的任务, 任务之间是并发的. 线程是操作系统提供的, 而且绝大多数语言都会支持系统线程, 所以多线程的并发模型最为广泛.
A: 是的, 不确定事件是需要等待的原因, 而阻塞调用这样方式选择了让线程陷入等待. 当线程执行阻塞调用的时候就会被挂起, 然后其他的线程会被系统调度到CPU上执行. 所以如果让多个线程来处理任务, 也可以实现并发, 这就是多线程模式. 大多数系统都能直接支持多线程, 所以多线程是使用最广泛, 兼容性最好的并发方式.

---

Q: 这种方式听起来也不错, 为什么Rust还要提供Future的并发模型呢?
Q: 多线程方式听起来也不错, 为什么Rust还要提供自己的并发模型呢?

A: 多线程模式有兼容性好, 易于上手, 容易理解等等优势, 所以Rust也是支持这种模式的, 一个`std::thread::spawn(|| { ... })`方法就可以开启多线程了. 但多线程模式也有缺点, 主要有两点, 一是系统线程需要占据很大的内存空间, 二是线程竞争和切换会带来开销. 这使得我们不能创建太多的线程, 也就在某些情况下限制了我们的并发数, 而Future方案可以解决相当一部分问题.
A: 多线程模式有兼容性好, 易于上手, 容易理解等优点, Rust当然也支持这种模式, 调用标准库提供的`std::thread::spawn(...)`就可以开启多线程了. 但多线程模式也有缺点, 主要有两点, 一是系统线程占据的内存空间比较大, 二是线程竞争和切换会带来额外开销. 这使得创建太多线程会产生问题, 也就限制了并发的规模, 而Future方案就可以避免这些困扰.

---

Q: 除了多线程和Future, 还有其他的并发模型吗?

A: 当然, 比如Erlang的Actor模型, Go的Goroutine协程模型, JS的事件驱动模型等等. 模型本身是不限于用哪种语言实现的, 也不限于在哪里实现, 不过这些语言比较有代表性.
A: 当然, 比如Erlang的Actor模型, Go的Goroutine协程模型, JS的事件驱动模型等等. 模型本身是不限于用哪种语言实现的, 也不限于是程序实现的还是语言实现的, 不过这些语言的例子比较有代表性.

---

简单总结一下:

1. 并发是同时处理多个任务, 可以提高程序的运行效率, Rust提供了Future的并发模型
2. 同步调用是单线程很难并发的原因, 线程会阻塞在调用上, 而且这些调用是系统提供的
2. 不确定事件是等待的原因, 并发是为了让计算机少等待, 阻塞的调用会让线程陷入等待
3. 多线程可以用来实现并发, 操作系统会进行线程调度, 但存在内存占用和竞争切换问题

还可以从阻塞粒度的角度来看串行, 多线程和Future这三种模式, 上面说到, 要处理读取终端输入或者接收网络数据这类不确定的事件, 等待似乎是必然的. 但"谁"去等待, 是CPU核心, 还是系统线程, 还是用户任务, 阻塞的粒度是可以变化的.
还可以从阻塞的粒度的角度来看批处理, 多线程和Future这三种模式. 对于读取终端输入或者接收网络数据这类不确定的事件, 被动地等待是无法避免的. 但"谁"去等待, 是CPU核心, 还是系统线程, 还是用户任务, 也就是阻塞的粒度是可以变化的.

串行模式下, 任务陷入等待, 线程也被阻塞了, CPU核心也被阻塞了; 多线程模式下, 任务陷入等待, 对应的线程会阻塞, 但CPU核心可以运行其他线程; 而由于系统线程带来的开销, 所以Future的出发点就是进一步缩小阻塞粒度, 只阻塞用户任务本身, 而线程和CPU核心都可以去做其他事情. 这和锁的优化过程很像, 通过对粒度控制, 提高程序的效率.
批处理模式下, 任务陷入等待, 线程也被阻塞了, CPU核心也被阻塞了; 多线程模式下, 任务陷入等待, 对应的线程会阻塞, 但CPU核心可以运行其他线程; 而由于系统线程的开销, Future模式进一步缩小阻塞粒度, 只阻塞用户任务本身, 而线程和CPU核心都可以去做其他事情. 这和锁的优化过程很像, 通过对粒度控制, 提高程序的效率.

</br>

Expand Down
Loading

0 comments on commit 112b571

Please sign in to comment.