前言

接上一篇 通过一个示例形象地理解C# async await异步

我在 .NET与大数据 中吐槽前同事在双层循环体中(肯定是单线程了)频繁请求es,导致接口的总耗时很长。这不能怪前同事,确实难写,会使代码复杂度增加。

评论区有人说他的理解是使用异步增加了系统吞吐能力,这个理解是正确的,但对于单个接口的单次请求而言,它是单线程的,耗时反而可能比同步还慢。如何缩短单个接口的单次请求的时间呢(要求:尽量不增加代码复杂度)?请看下文。

示例的测试步骤

先直接测试,看结果,下面再放代码

  1. 点击VS2022的启动按钮,启动程序,它会先启动Server工程,再启动AsyncAwaitDemo2工程
  2. 分别点击三个button
  3. 观察思考输出结果

测试截图

非并行异步(顺序执行的异步)



截图说明:单次请求耗时约0.5秒,共10次请求,耗时约 0.5秒×10=5秒

并行异步



截图说明:单次请求耗时约0.5秒,共10次请求,耗时约 0.5秒

并行异步(控制并发数量)



截图说明:单次请求耗时约0.5秒,共10次请求,并发数是5,耗时约 0.5秒×10÷5=1秒

服务端

服务端和客户端是两个独立的工程,测试时在一起跑,但其实可以分开部署,部署到不同的机器上

服务端是一个web api接口,用.NET 6、VS2022开发,代码如下:

[ApiController]
[Route("[controller]")]
public class TestController : ControllerBase
{
[HttpGet]
[Route("[action]")]
public async Task<Dictionary<int, int>> Get(int i)
{
var result = new Dictionary<int, int>(); await Task.Delay(500); //模拟耗时操作 if (i == 0)
{
result.Add(0, 5);
result.Add(1, 4);
result.Add(2, 3);
result.Add(3, 2);
result.Add(4, 1);
}
else if (i == 1)
{
result.Add(0, 10);
result.Add(1, 9);
result.Add(2, 8);
result.Add(3, 7);
result.Add(4, 6);
} return result;
}
}

客户端

大家看客户端代码时,不需要关心服务端怎么写

客户端是一个Winform工程,用.NET 6、VS2022开发,代码如下:

public partial class Form1 : Form
{
private readonly string _url = "http://localhost:5028/Test/Get"; public Form1()
{
InitializeComponent();
} private async void Form1_Load(object sender, EventArgs e)
{
//预热
HttpClient httpClient = HttpClientFactory.GetClient();
await (await httpClient.GetAsync(_url)).Content.ReadAsStringAsync();
} //非并行异步(顺序执行的异步)
private async void button3_Click(object sender, EventArgs e)
{
await Task.Run(async () =>
{
Log($"==== 非并行异步 开始,线程ID={Thread.CurrentThread.ManagedThreadId} ========================");
Stopwatch sw = Stopwatch.StartNew();
HttpClient httpClient = HttpClientFactory.GetClient();
var tasks = new Dictionary<string, Task<string>>();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 2; i++)
{
int sum = 0;
for (int j = 0; j < 5; j++)
{
Dictionary<int, int> dict = await RequestAsync(_url, i);
if (dict.ContainsKey(j))
{
int num = dict[j];
sum += num;
sb.Append($"{num}, ");
}
}
Log($"输出:sum={sum}");
}
Log($"输出:{sb}");
sw.Stop();
Log($"==== 结束,线程ID={Thread.CurrentThread.ManagedThreadId},耗时:{sw.Elapsed.TotalSeconds:0.000}秒 ========================");
});
} // 并行异步
private async void button4_Click(object sender, EventArgs e)
{
await Task.Run(async () =>
{
Log($"==== 并行异步 开始,线程ID={Thread.CurrentThread.ManagedThreadId} ========================");
Stopwatch sw = Stopwatch.StartNew();
HttpClient httpClient = HttpClientFactory.GetClient();
var tasks = new Dictionary<string, Task<Dictionary<int, int>>>();
StringBuilder sb = new StringBuilder();
//双层循环写第一遍
for (int i = 0; i < 2; i++)
{
for (int j = 0; j < 5; j++)
{
var task = RequestAsync(_url, i);
tasks.Add($"{i}_{j}", task);
}
}
//双层循环写第二遍
for (int i = 0; i < 2; i++)
{
int sum = 0;
for (int j = 0; j < 5; j++)
{
Dictionary<int, int> dict = await tasks[$"{i}_{j}"];
if (dict.ContainsKey(j))
{
int num = dict[j];
sum += num;
sb.Append($"{num}, ");
}
}
Log($"输出:sum={sum}");
}
Log($"输出:{sb}");
sw.Stop();
Log($"==== 结束,线程ID={Thread.CurrentThread.ManagedThreadId},耗时:{sw.Elapsed.TotalSeconds:0.000}秒 ========================");
});
} // 并行异步(控制并发数量)
private async void button5_Click(object sender, EventArgs e)
{
await Task.Run(async () =>
{
Log($"==== 并行异步(控制并发数量) 开始,线程ID={Thread.CurrentThread.ManagedThreadId} ===================");
Stopwatch sw = Stopwatch.StartNew();
HttpClient httpClient = HttpClientFactory.GetClient();
var tasks = new Dictionary<string, Task<Dictionary<int, int>>>();
Semaphore sem = new Semaphore(5, 5);
StringBuilder sb = new StringBuilder();
//双层循环写第一遍
for (int i = 0; i < 2; i++)
{
for (int j = 0; j < 5; j++)
{
var task = RequestAsync(_url, i, sem);
tasks.Add($"{i}_{j}", task);
}
}
//双层循环写第二遍
for (int i = 0; i < 2; i++)
{
int sum = 0;
for (int j = 0; j < 5; j++)
{
Dictionary<int, int> dict = await tasks[$"{i}_{j}"];
if (dict.ContainsKey(j))
{
int num = dict[j];
sum += num;
sb.Append($"{num}, ");
}
}
Log($"输出:sum={sum}");
}
sem.Dispose(); //别忘了释放
Log($"输出:{sb}");
sw.Stop();
Log($"==== 结束,线程ID={Thread.CurrentThread.ManagedThreadId},耗时:{sw.Elapsed.TotalSeconds:0.000}秒 ========================");
});
} private async Task<Dictionary<int, int>> RequestAsync(string url, int i)
{
Stopwatch sw = Stopwatch.StartNew();
HttpClient httpClient = HttpClientFactory.GetClient();
var result = await (await httpClient.GetAsync($"{url}?i={i}")).Content.ReadAsStringAsync();
sw.Stop();
Log($"线程ID={Thread.CurrentThread.ManagedThreadId},请求耗时:{sw.Elapsed.TotalSeconds:0.000}秒");
return JsonSerializer.Deserialize<Dictionary<int, int>>(result);
} private async Task<Dictionary<int, int>> RequestAsync(string url, int i, Semaphore semaphore)
{
semaphore.WaitOne();
try
{
Stopwatch sw = Stopwatch.StartNew();
HttpClient httpClient = HttpClientFactory.GetClient();
var result = await (await httpClient.GetAsync($"{url}?i={i}")).Content.ReadAsStringAsync();
sw.Stop();
Log($"线程ID={Thread.CurrentThread.ManagedThreadId},请求耗时:{sw.Elapsed.TotalSeconds:0.000}秒");
return JsonSerializer.Deserialize<Dictionary<int, int>>(result);
}
catch (Exception ex)
{
Log($"错误:{ex}");
throw;
}
finally
{
semaphore.Release();
}
} #region Log
private void Log(string msg)
{
msg = $"{DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff")} {msg}\r\n"; if (this.InvokeRequired)
{
this.BeginInvoke(new Action(() =>
{
txtLog.AppendText(msg);
}));
}
else
{
txtLog.AppendText(msg);
}
}
#endregion private void button6_Click(object sender, EventArgs e)
{
txtLog.Text = string.Empty;
}
}

思考

1. Semaphore的使用要小心

  1. 这里是Winform,它是在button事件中定义的局部变量,如果是WebAPI接口,那就在接口方法中定义Semaphore局部变量。可造成别定义成全局的,或者定义成静态的,或者定义成Controller的成员变量,那样会严重限制使用它的接口的吞吐能力!
  2. 用完调用Dispose释放

2. 尽量不增加代码复杂度

请思考代码中的注释"双层循环写第一遍""双层循环写第二遍",这个写法尽量不增加代码复杂度,试想一下,如果你用Task.Run且不说占用线程,就问你怎么写能简单?

有人说,我会,这样写不就行了:

Dictionary<int, int>[] result = await Task.WhenAll(tasks.Values);

那请问,你接下来怎么写?我相信你肯定会写,但问题是,代码的逻辑结构变了,代码复杂度增加了!

所以"双层循环写第一遍""双层循环写第二遍"是什么意思?你即能方便合并,又能方便拆分,代码逻辑结构没变,只是复制了一份。

3. RequestAsync的复杂度可控

RequestAsync的复杂度并没有因为Semaphore的引入变得更复杂,增加的代码可以接受。

我写这篇博客不是空穴来风,不只是写个Demo,我确实有实际项目中的问题需要解决,代码如下:

WebAPI的Controller层:

[HttpPost]
[Route("[action]")]
public async Task<List<NightActivitiesResultItem>> Get([FromBody] NightActivitiesPostData data)
{
return await ServiceFactory.Get<NightActivitiesService>().Get(data.startDate, data.endDate, data.startTime, data.endTime, data.threshold, data.peopleClusters);
}

WebAPI的Service层:

public async Task<List<NightActivitiesResultItem>> Get(string strStartDate, string strEndDate, string strStartTime, string strEndTime, decimal threshold, List<PeopleCluster> peopleClusterList)
{
List<NightActivitiesResultItem> result = new List<NightActivitiesResultItem>(); DateTime startDate = DateTime.ParseExact(strStartDate, "yyyyMMdd", CultureInfo.InvariantCulture);
DateTime endDate = DateTime.ParseExact(strEndDate, "yyyyMMdd", CultureInfo.InvariantCulture);
string[][] strTimes;
if (string.Compare(strStartTime, strEndTime) > 0)
{
strTimes = new string[2][] { new string[2], new string[2] };
strTimes[0][0] = strStartTime;
strTimes[0][1] = "235959";
strTimes[1][0] = "000000";
strTimes[1][1] = strEndTime;
}
else
{
strTimes = new string[1][] { new string[2] };
strTimes[0][0] = strStartTime;
strTimes[0][1] = strEndTime;
} foreach (PeopleCluster peopleCluster in peopleClusterList)
{
for (DateTime day = startDate; day <= endDate; day = day.AddDays(1))
{
string strDate = day.ToString("yyyyMMdd");
int sum = 0;
foreach (string[] timeArr in strTimes)
{
List<PeopleFeatureAgg> list = await ServiceFactory.Get<PeopleFeatureQueryService>().QueryAgg(strDate + timeArr[0], strDate + timeArr[1], peopleCluster.ClusterIds);
Dictionary<string, int> agg = list.ToLookup(a => a.ClusterId).ToDictionary(a => a.Key, a => a.First().Count); foreach (string clusterId in peopleCluster.ClusterIds)
{
if (agg.TryGetValue(clusterId, out int count))
{
sum += count;
}
}
}
if (sum >= threshold) //大于或等于阈值
{
NightActivitiesResultItem item = new NightActivitiesResultItem();
item.peopleCluster = peopleCluster;
item.date = strDate;
item.count = sum;
foreach (string[] timeArr in strTimes)
{
PeopleFeatureQueryResult featureList = await ServiceFactory.Get<PeopleFeatureQueryService>().Query(strDate + timeArr[0], strDate + timeArr[1], peopleCluster.ClusterIds, 10000);
item.list.AddRange(featureList.list);
}
item.dataType = "xxx";
result.Add(item);
}
}
} var clusters = result.ConvertAll<PeopleCluster>(a => a.peopleCluster);
await ServiceFactory.Get<PersonScoreService>().Set(OpeType.Xxx, peopleClusterList, clusters, startDate.ToString("yyyyMMddHHmmss"), endDate.ToString("yyyyMMddHHmmss")); return result;
}

思考

上述接口代码,它有三层循环,在第三层循环体中await,第一层循环的数量会达到1000甚至10000,第二层循环的数量会达到30(一个月30天),甚至90(三个月),第三层循环的数量很少。

那么总请求次数会达到3万甚至90万,如果不使用并行异步请求,那耗时将会很长。

请问:在尽量不增加代码复杂度的前提下,怎么优化,缩短该服务接口的执行时间?

我知道肯定有人要说我了,你傻啊,请求3万次?你可以改写,只请求一次,或者按天来,每天的数据只请求一次,那最多也才90次。然后在内存中计算,这不就快了?

确实是这样的,确实不应该请求3万次。但问题没这么简单:

  1. 且不说代码的复杂度,代码的复杂度你们自己想。你写的也不是一个接口,你可能会有几十个这样的接口要写,复杂度增加一点这么多接口都要写死人。
  2. 这3万请求,可都是精确查询,es强大的缓存机制,肯定会命中缓存,也就是这些请求实际上基本是直接从内存中拿数据,连遍历集合都不需要,直接命中索引。只是网络来回太多。
  3. 你这1次请求,或30次请求,对es来说,变成了范围查询,es要遍历,要给你查询并组织数据,返回集合给你。当然es集群的运算速度肯定很快。
  4. 你1次请求,或30次请求,那结果返回后,你就要在内存中计算了,我有的接口就是这样写的,但要多写代码,比如在内存中计算,为了提高效率,先创建字典相当于建索引。
  5. 只是逻辑复杂了吗?你还要多定义一些临时的变量啊!
  6. 代码写着写着就变懒了,每个接口1次请求,然后在内存中再遍历再计算,心智负担好重
  7. 我在网上看到es集群默认最多支持10000个并发查询,需要请求es的业务程序肯定不止一个,对一个业务程序而言,确实要控制并发量
  8. 根据我的观察,一个WebAPI程序,线程数一般也就几十,多的时候上百,在没有异步的时候,并发请求数量实际上受限于物理线程。
  9. 使用异步之后,并发请求数量实际上受限于虚拟线程。确实会增加请求es的并发数量,压力大的时候,这个并发数量能达到多少,还需要研究,以进一步确定,怎么限制并发数量。也许可以搞个全局的Semaphore sem = new Semaphore(500, 500);来限制一下总的es请求并发量。

怎么查看并发请求数

windows的cmd命令:

netstat -ano | findstr 5028

所以,上述并行异步不能滥用

所以,上述并行异步不能滥用,需要根据实际情况,确定,是否按这种方式优化。

还有两个问题,博客中没有体现

1. 客户端程序执行请求时,客户端线程数量

非并行异步,线程数很少了,请求开始后只增加了一两个线程。并行异步线程数较多。并行异步并控制并发量的活,线程数相对少一些。

2. Semaphore不要轻易使用

semaphore.WaitOne()阻塞线程一直阻塞到semaphore.Release(),而一个WepAPI服务程序一般也就几十上百个物理线程,想象一下,如果你这个使用semaphore的接口被大量请求,你的WebAPI程序的吞吐量会怎么样?会不会惨不忍睹。

思考

.NET只有一个CLR线程池和一个异步线程池(完成端口线程池),当线程池中线程数量不够用时,.NET每秒才增加1到2个线程,线程增加的速度非常缓慢。结合异步,考虑一下这是为什么?

我认为(不一定对):

  1. 异步不需要大量物理线程,少量即可
  2. 如果线程增加速度很快,以异步的吞吐量,怕不是要把es请求挂!因为并发请求数太多了。

总结

  1. 并行异步,会有并发量太大,导致诸如数据库或者es集群抗不住的问题,谨慎使用。
  2. 并行异步(控制并发数量),并发量控制住了,但Semaphore会阻塞线程!导致整个程序的吞吐量下降。

完整测试源码

注意是AsyncParallel分支

https://gitee.com/s0611163/AsyncAwaitDemo2/tree/AsyncParallel/

通过一个示例形象地理解C# async await 非并行异步、并行异步、并行异步的并发量控制的更多相关文章

  1. MVC+Spring.NET+NHibernate .NET SSH框架整合 C# 委托异步 和 async /await 两种实现的异步 如何消除点击按钮时周围出现的白线? Linq中 AsQueryable(), AsEnumerable()和ToList()的区别和用法

    MVC+Spring.NET+NHibernate .NET SSH框架整合   在JAVA中,SSH框架可谓是无人不晓,就和.NET中的MVC框架一样普及.作为一个初学者,可以感受到.NET出了MV ...

  2. 理解 es7 async/await

    简介 JavaScript ES7 中的 async / await 让多个异步 promise 协同工作起来更容易.如果要按一定顺序从多个数据库或者 API 异步获取数据,你可能会以一堆乱七八糟的 ...

  3. async await 同时发起多个异步请求的方法

    @action getBaseInfo = async() => { let baseInfo; try { baseInfo = await getBaseInfo(this.id); if ...

  4. 浅谈C#中的 async await 以及对线程相关知识的复习

    C#5.0以后新增了一个语法糖,那就是异步方法async await,之前对线程,进程方面的知识有过较为深入的学习,大概知道这个概念,我的项目中实际用到C#异步编程的场景比较少,就算要用到一般也感觉T ...

  5. async/await的实质理解

    async/await关键字能帮助开发者更容易地编写异步代码.但不少开发者对于这两个关键字的使用比较困惑,不知道该怎么使用.本文就async/await的实质作简单描述,以便大家能更清楚理解. 一.a ...

  6. 深入理解理解 JavaScript 的 async/await

    原文地址:https://segmentfault.com/a/1190000007535316,首先感谢原文作者对该知识的总结与分享.本文是在自己理解的基础上略作修改所写,主要为了加深对该知识点的理 ...

  7. 理解C#中的 async await

    前言 一个老掉牙的话题,园子里的相关优秀文章已经有很多了,我写这篇文章完全是想以自己的思维方式来谈一谈自己的理解.(PS:文中涉及到了大量反编译源码,需要静下心来细细品味) 从简单开始 为了更容易理解 ...

  8. 从async await 报错Unexpected identifier 谈谈对上下文的理解

    原文首发地址: 先简单介绍下async await: async/await是ES6推出的异步处理方案,目的也很明确:更好的实现异步编程.   详细见阮大神 ES6入门 现在说说实践中遇到的问题:使用 ...

  9. promise async await使用

    1.Promise (名字含义:promise为承诺,表示其他手段无法改变) Promise 对象代表一个异步操作,其不受外界影响,有三种状态: Pending(进行中.未完成的) Resolved( ...

  10. [.NET] 利用 async & await 的异步编程

    利用 async & await 的异步编程 [博主]反骨仔 [出处]http://www.cnblogs.com/liqingwen/p/5922573.html  目录 异步编程的简介 异 ...

随机推荐

  1. nodered获取简单的时间

    1.添加simpletime 的节点 2. 添加一个inject节点用来每1s循环获取当点的信息 3.添加一个函数节点对simpletime发来的msg进行解析 var payload=msg;var ...

  2. 小菜鸡学习---<正则表达式学习笔记2>

    正则表达式学习笔记2 一.修饰符 前面我们学习的都是用于匹配的基本的关键的一些表达式符号,现在我们来学习修饰符.修饰符不写在正则表达式里,修饰符位于表达式之外,比如/runoob/g,这个最后的g就是 ...

  3. CodeQL(1)

    前言 开始学习使用CodeQL,做一些笔记,可供参考的资料还是比较少的,一个是官方文档,但是Google翻译过来,总觉得怪怪的,另一个就是别人的一个资源整合,其中可供参考的也不是很多,大多也是官方文档 ...

  4. 记一次spark数据倾斜实践

    参考文章: 大数据项目--倾斜数据的分区优化 数据倾斜概念 什么是数据倾斜   大数据下大部分框架的处理原理都是参考mapreduce的思想:分而治之和移动计算,即提前将计算程序生成好然后发送到不同的 ...

  5. 编译安装oh-my-zsh

    1.前言 oh-my-zsh是基于zsh的一套美化工具,其内部也提供很多主题以及插件.github介绍 2.有啥用 对我来说可能查看git分支更加直观,另外其强大的补全功能 又或者更加直观的查看上一条 ...

  6. MySQL 常用到的几个字符处理函数

    修改某字段的内容,用于英文 首先解释用到的函数: CONCAT(str1,str2)字符连接函数 UPPER(str)将字符串改为大写字母 LOWER(str)将字符串改为小写字母 LENGTH(st ...

  7. MyEclipse连接MySQL

    在官网http://www.mysql.com/downloads/下载数据库连接驱动 本文中使用驱动版本为mysql-connector-java-5.1.40 一.创建一个java测试项目MySQ ...

  8. 2.5:Python常用内置数据结构、多维数组ndarray、Series和DataFrame

    一.Python内置数据结构 1.赋值生成列表 la=[1,2,3,4] la 2.强制转换为列表 lb=list("Hello") lb 3.推导式生成列表 s="ab ...

  9. 【Java SE进阶】Day12 函数式接口、函数式编程(Lambda表达式)

    一.函数式接口介绍 1.概念 仅有一个抽象方法的接口 适用于函数式编程(Lambda使用的接口) 语法糖:方便但原理不变,如for-each是Iterator的语法糖 Lambda≈匿名内部类的语法糖 ...

  10. L1-049 天梯赛座位分配 (20分)

    L1-049 天梯赛座位分配 (20分) 天梯赛每年有大量参赛队员,要保证同一所学校的所有队员都不能相邻,分配座位就成为一件比较麻烦的事情.为此我们制定如下策略:假设某赛场有 N 所学校参赛,第 i ...