无阻塞 编程模型
无阻塞 编程模型 涉及到 异步回调流, Task, async await, 线程池, 并发编程, 并行编程, 大并发架构, 操作系统 之上 编程模型 的 发展 等等 。
我这段时间对 这个领域 的 现状 进行了一些 收集整理 和 批判 , 请看 :
《后线程时代 的 应用程序 架构》 https://www.cnblogs.com/KSongKing/p/10228842.html
《我 支持 使用 async await》 https://www.cnblogs.com/KSongKing/p/10216913.html
单纯 从 执行效率 看, 也许 同步方法 最直接, 效率也最高 。 只要 配合 线程池 合理使用 线程 就可以 。
异步方法 的 意义 在于 实现 无阻塞 模式,
而 无阻塞 模式 的 意义 要在 大并发 且 IO 等待时间显著 、IO 可能长时间等待 、 IO 等待时间不确定(可能有意外) 的时候 才会 体现出来 。
什么是 IO 等待 ? IO 等待 本质上是 CPU 对 外部设备 的 等待 。
从 应用 上说, IO 等待 就是 访问数据库, 调用 WebApi, 读写文件, RPC 等 。
假设 线程池 有 1000 个 线程, 可以同时处理 1000 个 用户 的 请求, 每个请求 都 需要 访问数据库,
如果 数据库 的 查询缓慢, 则 这 1000 个 线程 可能 都会 去等待 数据库, 当有 第 1001 个 以上的 用户 访问 网站 时, 线程池 将 没有 多余 的 线程 去 处理 第 1001 个 以上的 用户 的 请求, 这种情况 如果 持续一段时间, 就会变成 服务器 不能提供 服务, 如果 数据库 处于 “挂掉” 的 异常状态, 则 Web 服务器 线程池 里 的 1000 个 线程 都将 长期 等待数据库 而 挂起, 这样 服务器 就 不能提供 服务, 或者 变得 异常缓慢 (对 用户而言) 。
微服务 的 “雪崩”, 大概 也是 从这里来的 。
且 从 广义 的 角度 来讲, 线程池 的 1000 个 线程 本来 还可以有一部分 去做 其它 工作(不需要 访问数据库 的 工作,或是 访问 其它数据库 的 工作), 但 都卡在 访问 A 数据库 这里了 。
但是, 我们 又不能 采用 无限制 的 创建线程(New Thread)的 方式, 过多的 线程 会 花费 比较多的 切换时间, 也会 占用 比较大 的 内存空间, 比如 1 个线程 的 堆栈 是 1 MB, 则 1024 个 线程 的 堆栈空间 总和 就是 1024 * 1 MB = 1 GB 。
所以, 需要 对 线程池 里的 线程 做一个 角色分工 来 解决 这个问题, 这就是 “m Work, n IO” ,
“m Work, n IO” 就是 m 个 工作线程, n 个 IO 线程 。
m 个 工作线程 在 无阻塞 的 状态下工作 。
如果是 单核 CPU, 则 可以 退化为 “1 Work, n IO” 。
如果 1 个 CPU 核 上 只有 1 个 工作线程, 则 称为 “单体”(monosome, monad) 。
Javascript 是 单体 。
我们可以 来 看看 3 种 方式 的 Sequence 图 :
1 调用 同步方法, 如 fileStream.Read() 方法,
2 调用 async 方法 再 task.ContinueWith() ,
3 调用 async 方法, 使用 await,
1 调用 同步方法, 如 fileStream.Read() 方法,
2 调用 async 方法 再 task.ContinueWith() ,
3 调用 async 方法, 使用 await,
“状态机” 就是 将 函数参数 、局部变量 等 上下文 保存在 “状态” 中, 将 “状态” 保存在 堆 里, 以 取代 传统的 函数调用 把 参数 、局部变量 等 上下文 保存在 栈 里的 做法 。
假设 有个 Foo() 方法,
Foo()
{
…… // Part 1
await xxxAsync();
…… // Part 2
}
编译器 会将 Foo() 方法 中 await 之前 的 代码 变成一个 Foo_Part1() 方法, Foo() 方法 中 await 之后 的 代码 变成一个 Foo_Part2() 方法,
这样 Foo() 方法 就被 “分割” 成 3 个 部分 :
1 Foo_Part1()
2 await xxxAsync()
3 Foo_Part2()
在 执行 的 时候, 状态机 就可以 按 “步骤” 调用 这 3 个 部分,
先调用 Foo_Part1() , 再调用 xxxAsync(), 之后 转入 异步方法 执行, 本次调用 结束 。
当 xxxAsync() 执行完成后, 会调用 回调, 回调 调用 状态机, 状态机 接着之前的 “步骤”, 继续执行 Foo_Part2() 。
这整个 过程 连贯起来, 就是 Foo_Part1() -> xxxAsync() -> Foo_Part2, 这正还原了 程序员 写的 源代码 中的 执行流程 。
程序员 写的 源代码 看起来 是一个 顺序 同步 的 执行过程, 但实际上是一个 异步 无阻塞 的 执行过程 。
为什么要用 状态机 ? 因为要实现 异步架构, 同时还要尽量 保持 函数层层调用 的 逻辑层次结构 。
比如, 如果 在 执行中 抛出异常, 在 异常信息 中, 可以看到 函数 的 调用层次, 可以看到 异常 是从 “Foo_Part1()” 中 抛出来 的,
这样 我们 就 清楚 异常 出现 在 那一行代码,
如果 异常 是 从 “Foo_Part2()” 中 抛出来 的, 那我们也知道 异常 出现在 await xxxAsync(); 之后的 代码 里 。
所以, async await 是一个 语法糖, 有 网友 说是 编译器 的 “黑魔法”, 我总觉得 async await 这个 语法糖 有点大, 可以叫 “语法蛋糕” 。
而要实现 真正的 “n IO” 无阻塞, 还需要 操作系统 也用 无阻塞 的 方式 来 实现 IO 。
假设有 n 个 IO 线程, 操作系统 应该 用 1 个 或 n 个 线程 去 “轮流” 等待 多个设备 的 响应 或者 一个设备 对 多个请求 的 响应,
而不应该 固定 1 个 线程 去 等待 1 个 请求 的 响应 。
这种 用 线程 “轮流” 去 等待 设备 响应 的 做法, 就是 IOCP 。
理论上, 只要 CPU 的 处理速度 足够快, 1 个 线程 可以 等待(处理) n 个 设备 对 m 个 请求 的 响应 。
反之, 如果 固定 1 个线程 “负责” 等待 1 个 请求 的 响应, 则 n 个 请求 需要 n 个线程,
如果 某设备 的 处理速度 缓慢 或者 故障, 而 对 该设备 的 请求 是 频繁 的, 则 IO 线程 都 会 去等待 这个 设备, 这就 堵塞 了 。
于是 就没有 线程 来 处理 其它 设备 的 IO 了。
这就 回到了 本文 开篇 提出的问题 。
通过 上面 3 个 Sequence 图, 我们可以看到 :
相比同步方法, 就 单次调用 而言, 异步方法 并不会 减少 线程切换 的 次数, 异步方法 的 意义 在于 无阻塞 。
但是 从 总体 来看, 无阻塞 显著 的 减少了 线程 的 数量, 更少 的 线程 意味着 更少 的 切换 。
所以, 从 总体 来看, 异步方法 也是 减少了 线程 切换 次数 的 。
无阻塞 是 有利的, 是 计算机软件体系 在 后线程时代 的 一次 发展进化 。
无阻塞 还可以用于 SOA , 比如 SOA 中会有这样的 场景, 一个业务 需要 调用 若干个 服务 来完成 。
这样, 就可以 这样 写代码 :
Foo()
{
…… // 一些操作
Task t1 = Service1Async();
Task t2 = Service2Async();
Task t3 = Service3Async();
await Task.WhenAll( { t1, t2, t3} );
…… // 3 个 服务 都 调用 完成时 要 执行 的 操作
}
由于 服务 完成的时间 可能是 不确定 的, 所以 如果 等 服务 1 完成 再 调用 服务 2, 服务 2 完成 再 调用 服务 3, 这样 效率 就比较低 。
所以, 通过 无阻塞 的 方式, 并发调用 多个 服务, 然后 等待 服务 全部 完成, 再做下一步操作, 这样 可以 提高效率 。
当然, 这里的 “等待”, 也是 无阻塞 的 。 ^^
在 无阻塞 编程 中, 不能 调用 Thread.Sleep() 来 延时, 这会 阻塞 线程, 占用 线程,
而应该用 await Task.Delay() 方法 来 延时, 或是用 Timer 来设定一个 定时任务, 把 延时后 要做的 工作 放到这个 定时任务 里,
当然, await Task.Delay() 更加的直观, 但 我猜 await Task.Delay() 内部也是用 Timer 原理 实现的 。
而 用 Timer 定时任务 来实现 延时, 这和 Javascript 的 window.setTimeout() 又是 恰如其分 的 相似 。
简单的情况, Task t; t.ContinueWith( 回调 ); 可以很好的完成 异步调用 。 Lambda 式 匿名函数 、闭包 以及 Task 的 封装 已经 使 代码 很 简洁直观 。
但是对于一些 场景, 比如 业务系统 三层架构 里 DAL 层 访问数据库, 对数据进行一些处理后 返回 BL 层, BL 层 又把 结果 返回 UI 层,
我们可以调用 Async 方法 访问数据库, 以实现 无阻塞, 但这种需要对 结果 进行处理 并 层层返回 的 场景, 用 异步回调 的话 代码 就很麻烦,
而 async await 正是 为了 解决 “过多的 异步回调 把 代码 切割的 支离破碎” 的 问题, 所以 async await 是 良性 的 。