后线程时代 的 应用程序 架构

“后线程时代”, 这跟 好几个 名词 有关系,  C# async await 关键字, Socket Async, ThreadPool, 单体(Monosome),  “异步回调流”  。html

 

“异步回调流” 是  “异步回调流派”  的 意思,  node.js,  libuv,  Java Netty ,   这些 是 典型的 异步回调流 。node

async await 是 单体(Monosome),git

我在以前的 文章 《我 反对 使用 async await》   http://www.javashuo.com/article/p-kfsmekfd-cv.html    中 提到,  “async await 正带领 C# 向 Javascript 进化”  。github

 

至于  Socket Async ,  和  async await 有关系, 也跟 异步回调流 有关系 。编程

 

咱们来看看 一位网友 从 一篇文章 上 节取 下来的 2 段文字 :api

 

 

因此, 从 理论 上看, 过多的 线程切换 对 性能 的 消耗 是 挺大的, 若是能 省去 这部分 开销, “节省” 下来的 性能 是 可观 的, 也许能让 服务器 的 吞吐量(并发量) 提升 1 个 数量级 。缓存

因此,  Visual Studio 本身也在使用  async await,  从 Visual Studio 有时候 报错 的 错误信息 来看,  错误信息 中含有  “MoveNext_xx  ……”  这样的文字, 这就是 async await 。服务器

 

线程池(ThreadPool) 自己 就能 将 线程数量 控制在一个 有限 的 范围内 ,闭包

而 将 线程数量 控制在一个 有限 的 范围内 是 减小 线程切换 的 基础 。架构

 

我 猜想  async await  的 底层 是 基于 ThreadPool 的,  是以 ThreadPool 做基础的 。

若是是这样, 那么   async await   和   异步回调流   是 等价 的 。

 

什么是  异步回调流  ?

 

咱们能够把  程序 分为 3 个部分 :

1  顺序执行

2  等待  IO

3  定时轮询

 

1  把  顺序执行 的 多任务 放到 ThreadPool 的 工做队列 里 排队, 让 ThreadPool 调度执行,

2  对于 IO 调用, 采用 异步调用 的 方式, 传入 回调委托, 当 IO 完成时, 当 IO 完成时, 回调委托,

3  对于 定时轮询, 采用 ThreadPool 提供的方式, 如 Timer,

 

这样, 作到以上 3 点, 就是 纯粹 的 异步回调流 。

 

理论上, 异步回调 流 能够将 线程数量 控制在 有限 的 范围内, 或者, 只须要 使用 很小数量 的 线程 。

这样, 就像上面说的, 能够节省“可观”的 性能, 可能能让 服务器 的 吞吐量 提升 1 个 数量级 。

 

我写了一个 对 Socket 使用 各类 线程模型 的 测试项目 :     https://github.com/kelin-xycs/SocketThreadTest

从 实验 中, 咱们看到, 在 并发量 大 时,  好比 800 个 Socket 链接 以上时, ThreadPool 的 性能 优于 NewThread 的方式, NewThread 是指 为 每一个链接 建立一个 线程 。

 

可是, Async 和 Begin 的 方式 效率 低于 同步方法(Socket.Receive(),   Socket.Send()) 的 方式 。

甚至, Begin 方式 中 把 BeginSend() 改为了 Send() 后, 效率还提升了一些 。 固然 Receive 仍然是使用 BeginReceive() 。

Async 方式 中 Accept,  Receive,  Send  所有使用 Async 方法, 即  AcceptAsync(),  ReceiveAsync(),  SendAsync()  方法 。

 

因此, 若是 Server 端  Socket 的 操做 所有使用 异步 的 方式,  是否 会比 同步的  Receive()   Send()  方式 的 性能 更高, 这个 没有 看到 有说服力 的 实验 。

 

So  ……

So  ……  ?

So   ?

 

我写了一个 对 async await 性能测试 的 项目:  https://github.com/kelin-xycs/AsyncAwaitTest

解决方案 里 包括 4 个 项目,  这 4 个 项目 都是 经过  ThreadPool 来 运行 读取文件 的 任务 :

 

1  ThreadPoolRead,   使用  File.Read()  方法

2  ThreadPoolReadAsync,   使用  await  File.ReadAsync()  

3  ThreadPoolReadWait,   使用  Task t = File.ReadAsync();   t.Wait();

4  ThreadPoolBeginRead,   使用  File.BeginRead()  方法

5  ThreadPoolContinueWith,  使用  Task t = File.ReadAsync();   t.ContinueWith();

6  ThreadPoolGetAwaiter,  使用  Task t = File.ReadAsync();   t.GetAwaiter().OnCompleted();

 

任务 是 从 文件 中 读取 2 KB 的数据,  默认开启 10 万 个 任务,  能够本身修改 任务数量 。

测试结果是 :

10 万 个 任务,  完成用时 ,

Read()  :       0.43 秒, 屡次测试 表现 稳定, 基本上 稳定在 0.43 秒左右 。  CPU 占用率 高峰期 15% 左右, 可能略小 。

ReadAsync()  :       最快 0.6 秒, 屡次测试 的 表现 差距很大, 受电脑上 其它进程 的影响很大, 在 几秒 到 20 几秒 之间不等 。 CPU 占用率 高峰期 15% 左右 。

ReadWait  :       定在那里, 没有结果,  可能 ThreadPool 里不能   t.Wait() 。 定着时候  CPU 占用率  0% 。

BeginRead  :       最快 1.1 秒,  屡次测试 的 表现 差距很大, 受电脑上 其它进程 的影响很大, 在 几秒 到 20 几秒 之间不等 。 CPU 占用率 高峰期 15% 左右 。

ContinueWith  :       最快 0.83 秒, 屡次测试 的 表现 差距很大, 受电脑上 其它进程 的影响很大, 在 几秒 到 20 几秒 之间不等 。 CPU 占用率 高峰期 15% 左右 。

GetAwaiter  :       最快 0.7 秒, 屡次测试 的 表现 差距很大, 受电脑上 其它进程 的影响很大, 在 几秒 到 20 几秒 之间不等 。 CPU 占用率 高峰期 15% 左右 。

 

总的来讲, Read  的 方式 效率 最高, 且 是 稳定运行的, 其它 的 方式 效率 略低, 且不稳定 。

从我这几回的测试, 包括  Socket 和 File,  异步 问题不少, 效率 低于  Socket.Receive(),  Socket.Send(),  File.Read()  方法,  且不稳定 。

 

目前看起来  ThreadPool + 同步方法调用  是  最优的 方案,  高效稳定 。 能够这么说, 能够用这个架构 来 在 .Net 上 构建 服务器端 应用 。

 

( 注: 括号里的这段注解内容是我后来补充的, 后来经过对 “无阻塞” 编程 的 研究, 发现 异步方法 的 意义 在于 无阻塞, 因此 对于 大并发应用 来说, ThreadPool + 异步方法 无阻塞 的 方式 会 更适合, 参考 《无阻塞 编程模型》  http://www.javashuo.com/article/p-qxtbtyjk-ck.html 

             有 网友 说, 在 测试中, 同时发起多个 读取文件操做, 没有 指定 FileStream.Position, 因此 每一个任务 读取的内容 是 不肯定的 。 确实, 存在这样的问题, 但个人这个测试主要是为了观察 各类线程模型 在 大并发 包含 IO 操做 下的 表现, 因此 Position 的 问题 不影响 观察 实验结果 。 对于能够 并发读取 的 IO 操做 好比 Socket, 这个实验 是有 类比参考意义 的 。 又假设 文件操做 也是能够 并发 的, 那么 在 读取文件 的 方法(好比 Read(), BeginRead(), ReadAsync() ) 里能够传入 position 参数, 这样就能够 并发读取 。    )

 

而这些 测试 也代表了,  async await 的 表现 并非想象中那样理想 。 相对于 同步方法  不只效率没有更高, 还更低 。

也就是说, 咱们从 理论上  看到的  线程切换  带来的  性能损耗  及其 推论  的  相关理论, 和  实际 不彻底 相符,

这暗示着, 计算机 可能 在 按 另外的 规律 在 运行 。

 

技术上, 本身能够实现  状态机 和 Promise 之类的, 用相似  Task.Factory.FromAsync(  BeginXXX ……  )  这样的方式,  经过咱们本身写一个 相似 FromAsync()  这样的方法,  能够 截获  BeginXXX  方法 返回的   IAsyncResult  对象, 咱们 能够把  IAsyncResult  放入 状态机 的 队列 里,  而后, 状态机 经过  ThreadPool  的 Timer  来 定时 (好比 10 毫秒) 来  遍历 检查 这些  IAsyncResult  的 状态 看 异步调用 是否结束,  若 结束 则 调用 回调, 或者 按照 Promise .When() 的逻辑 等待 几个 任务 的 IAsyncResult  的 状态 都是 完成时, 再 调用 Then 委托 。

这样能够实现   async await  的 状态机,  也能够实现  Promise 。

但问题是   定时  和  遍历,  尤为是 遍历,  效率 不见得  高 。

 

另外,  将 代码 切割 成 多块, 频繁 的 把 小块任务 放到 ThreadPool 的 队列 里 排队, 也会 下降效率, 由于 操做队列 须要 Lock(同步 互斥), 频繁 的 把 小块任务 放入 队列 和 取出 执行 会 发生 更多的 Lock 。

同时, 将 代码 切割 成 多块, 变为 回调 的 方式, 也会增长一些工做量, 好比 闭包 封送参数,  或是 State 对象 传递参数, 以及 异步回调 相关的代码 。

 

因此, 从 这里 也说明了, 我所作 的 多次 实验,  从  Socket  到  File,  Begin  Async  等 异步方法 效率 老是 低于 同步的   Socket.Receive(), Socket.Send(), File.Read()   方法 的 缘故 。

 

async await  多是 微软 的 一支战略 吧,   不过 看起来  微软 到如今 对  async await  都  语焉不详 。

不过  async await  大概是 微软 要 实践  “单体”  这个 理论,   因此,  说它 带领  C#  向  Javascript  进化 一点 不为过 。

但 实践代表,  这个 “单体” 的 性能 不见得 是 最优 ,   减小 线程 切换 和  完全的 单线程(单体) 之间 有一个  最大公约数 。

从 通讯 上,   IO 完成时 ,   发信号 通知 线程,  进入就绪队列, 这个 是 最优 的,  但问题是 带来了 切换上下文 问题 。

但 若是 不想 切换 上下文,就要 线程 “本身” 去 看  IO 完成没 ,  就变成 轮询 。   So ……

减小 线程 切换 和  完全的 单线程(单体) 之间  有一个  折中 点,不是 彻底 偏向 哪边 就是  最好的 。

单体, 就是 一个 线程  负责 全部的 任务调度 。   

从 这几天 的 实践 能够 大概 看到,  省掉了 切换上下文,  可是  频繁 的 把  任务 放到 ThreadPool 的 工做队列 里 排队,  实际上 又 增长了 性能消耗,  实时响应性  反而很差 。

其实 从 个人  ThreadPoolRead  这个 项目,  就是 用  Read 方法 的 这个项目,  10 万 次 读取文件  0.43 秒 完成的 这个 ,

能够 推算 出   一次 线程切换    是 多少时间 。

或者说,  1 秒钟 能够切换  多少次 线程 。

由于 数据量 小, 且是 重复读取,  因此, 第一次 以后, 都是 从 缓冲区 读取,  是  内存 -> 内存  的  拷贝,  很快 。

这样, 业务操做 越简单, 越能 反映 出 线程切换 的 时间, 或者说, 1 秒 能 切换 多少次 线程 。 如今看到的 数量 是 很可观 的 。

 

有 网友 提到 性能测试 要在 “密集计算” 下 测, 所谓 密集计算, 我想 就是指 包含 大量业务逻辑 的 计算 。 在 业务逻辑 复杂 的 状况下,  线程切换 时 CPU Cache 被刷新 的 效应 可能会 更显著 。

不过 具体 对 性能 的 影响 如何,  仍是要 经过 实验 来 看 实际 的 效果 。

 

咱们来看看 docs.microsoft  对 Thread 的 说明 :     https://docs.microsoft.com/zh-cn/dotnet/api/system.threading.thread.-ctor?view=netframework-4.7.2#System_Threading_Thread__ctor_System_Threading_ThreadStart_System_Int32_

 

 

默认 最大的 栈 大小 是  1 MB,  最小的 栈 大小 大概是  256 KB,  大概是这么一个   体量 。

 

从 某个 角度 来看, 线程 使用中的 堆栈 空间 越小, 切换线程 的 时间 就 越快 。

理想的情况, 线程 的 堆栈数据 能够长期 存放在 CPU 3 级 Cache, 这样 能够 快速 的 切换线程 。

 

咱们来看看 内存 的 读写速度 :     https://zhidao.baidu.com/question/1797460631148535467.html

 

DDR 3  的 读写速度 是  12.8 GB/S,  能够认为 是 1 纳秒 能够读取  10 B,  1 微秒 能够读取  10 KB 。

1 微秒 10 KB,  100 微秒 1 MB,  因此, 彻底 刷新 一个 线程 1MB 的  栈,  须要  100 微秒,  即  0.1 毫秒 。

所谓 “刷新”,  是指 将 数据 从 内存 复制到 CPU 3 级缓存 。

这样的话,  若是 一个线程 的 栈 是 1 MB, 固然 这 算是大的了,  切换到 这个线程 的 时间 须要 0.1 毫秒 以上(由于还有其它操做),

这 有点 太 “重型” 了 。

 

实际的 状况 不彻底 是这样, 咱们看看上面 docs.microsoft 对 Thread 的 说明 :

 

能够看到, 有一个 “页大小 64KB”, 从这里咱们能够想到, 操做系统 从 内存 复制 数据 到 3 级缓存 时, 不见得会把 整个 栈 的 数据 复制过来, 而 应该是 把 当前 可能用到的 那一段 数据 复制过来 。 而 复制数据 的 单位 就是 虚拟内存页, 一个 虚拟内存页 是 64 KB 。

 

根据上面推算的 1 微秒 10 KB, 从 内存 复制 64 KB 数据 到 3 级 Cache 要 6.4 微秒 。

但, 若是 堆栈 的 数据 可以 长期 存放在 3 级 Cache, 那 这个 6.4 微秒 的 时间 也不须要了 。

 

因此, 我提出一个定理 :

若是  n 个线程 使用的 堆栈空间 大小总和 是 CPU 3 级 Cache 的 1/3, 则 这 n 个线程 的 线程切换 是 健康的, 常规的 。

好比, 有 100 个 线程, 每一个 线程 最大堆栈 空间 是 64 KB, 那么, 10 个 线程 的 堆栈空间 总和 是 64 KB * 100 约等于 6.4 MB, 

则若 CPU 的 3 级缓存 大小 是  6.4 MB * 3 = 19.2 MB 以上的话, 这 100 个线程 的 线程切换 就是 健康的, 常规的 。

从这个角度来说, 若是 硬件技术 在 CPU Cache 上可以有效进步的话, 将来若干年内, 摩尔定律 将会 继续有效 。

 

减少 线程上下文,减小 线程切换的工做量,线程切换 轻量化,线程 轻量化, 是 操做系统 轻量化 的 一个 方向 。

这一点 我也 加到了 《将来须要的是 轻量操做系统 而不是 容器》  http://www.javashuo.com/article/p-oeegsskj-gp.html  一文里 。

 

最后, 本文结论 是 :

1  用 ThreadPool 合理利用 线程资源 就能够了, 没必要 过分使用 异步回调 来 达到 节省性能 的 目的 。

2  能够 有针对性 的 改善 硬件资源 来 减少 线程切换 的 性能损耗 。 好比 CPU Cache, 尤为是 3 级 Cache 。

3  仍是 那几句 老话    “硬件是最廉价的”,  “代码是写给人看的”,  “维护软件的成本比购买硬件的成本高”,  “人是最昂贵的” 。

 

再 加上 一条,  通过这几天的研究, 发现 无阻塞 是 有利的, 能够参考 《无阻塞 编程模型》  http://www.javashuo.com/article/p-qxtbtyjk-ck.html    。

相关文章
相关标签/搜索