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

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

 

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

async await 是 单体(Monosome),

我在之前的 文章 《我 反对 使用 async await》   https://www.cnblogs.com/KSongKing/p/10216913.html    中 提到,  “async await 正带领 C# 向 Javascript 进化”  。

 

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

 

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

 

 

所以, 从 理论 上看, 过多的 线程切换 对 性能 的 消耗 是 挺大的, 如果能 省去 这部分 开销, “节省” 下来的 性能 是 可观 的, 也许能让 服务器 的 吞吐量(并发量) 提高 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 + 异步方法 无阻塞 的 方式 会 更适合, 参考 《无阻塞 编程模型》  https://www.cnblogs.com/KSongKing/p/10287882.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 上能够有效进步的话, 未来若干年内, 摩尔定律 将会 继续有效 。

 

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

这一点 我也 加到了 《未来需要的是 轻量操作系统 而不是 容器》  https://www.cnblogs.com/KSongKing/p/9259628.html  一文里 。

 

最后, 本文结论 是 :

1  用 ThreadPool 合理利用 线程资源 就可以了, 不必 过度使用 异步回调 来 达到 节省性能 的 目的 。

2  可以 有针对性 的 改善 硬件资源 来 减小 线程切换 的 性能损耗 。 比如 CPU Cache, 尤其是 3 级 Cache 。

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

 

再 加上 一条,  经过这几天的研究, 发现 无阻塞 是 有利的, 可以参考 《无阻塞 编程模型》  https://www.cnblogs.com/KSongKing/p/10287882.html    。

 

 

 

 

posted on 2019-01-06 14:21  凯特琳  阅读(449)  评论(0编辑  收藏  举报

导航