“后线程时代”, 这跟 好几个 名词 有关系, 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 。