无阻塞 编程模型

无阻塞 编程模型 涉及到   异步回调流, 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  是 良性 的 。

 

 

 

 

 

posted on 2019-01-18 15:31  凯特琳  阅读(360)  评论(0编辑  收藏  举报

导航