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