1. 程式人生 > >.Net4.6 Task 異步OA現金盤平臺出租函數 比 同步函數 慢5倍 踩坑經歷

.Net4.6 Task 異步OA現金盤平臺出租函數 比 同步函數 慢5倍 踩坑經歷

數字 沒有 人員 猜想 需要 信道 sleep nlog com

異步Task簡單介紹
本標題有點 嘩眾取寵OA現金盤平臺出租QQ2952777280【話仙源碼論壇】hxforum.com【木瓜源碼論壇】papayabbs.com ,各位都別介意(不排除個人技術能力問題) —— 接下來:我將會用一個小Demo 把 本文思想闡述清楚。

.Net 4.0 就有了 Task 函數 —— 異步編程模型

.Net 4.6 給 Task 增加了好幾個 特別實用的方法,而且引入了 await async 語法糖

當然,這是非常不錯的技術,奈何我有自己的線程隊列封裝,也就沒有著急使用這個東西。

終究入局 Task異步函數
近來,有項目需要使用到 DotNetty 這個異步Socket框架。

這個框架是 微軟團隊 移植的 Java的 Netty —— 而且還能與 Java 現有的 Netty 對接。

Netty 如何的牛逼 我就不多介紹了。

DotNetty 基於 .Net 4.3 (實際至少需要 .Net 4.5) —— 是的,你沒有看錯,是 .Net 4.3

好了,跟著我一起踩坑,一起學些 異步Task函數的 使用規範。

先看一個最簡單的 Demo,領教一下 Task 的異步威力
復制代碼
1 static void Main(string[] args)
2 {
3 //模擬一個業務需求: 有 200 個字符串需要處理
4 List<string> list = new List<string>();

5 for (int i = 0; i < 200; i++) list.Add("AAAA" + i);
6
7
8
9 DateTime time0 = DateTime.Now;
10
11 //用多個Task 處理這些字符串
12 List<Task> listTask = new List<Task>();
13 foreach (string item in list)
14 {
15 Task task = Task.Run(() =>
16 {
17 Handle(item); //執行一個方法, 處理這200多個字符串
18 });
19 listTask.Add(task);
20 }
21 Task.WaitAll(listTask.ToArray()); //等待200個字符串 都處理完成
22
23 DateTime time1 = DateTime.Now;
24
25
26
27 Console.WriteLine("200個字符串處理完成, 同步執行需要200秒, 實際Task執行耗時: " + (time1 - time0).TotalSeconds + " 秒");
28
29
30
31 }
32
33
34 public static void Handle(string item)
35 {
36 Thread.Sleep(1000); //處理耗時1秒
37 Console.WriteLine("處理 " + item);
38 }
復制代碼

業務的處理邏輯沒這麽簡單
實際上,我們有 AAAA0 ~ AAAA199 總計 200 個字符串

但是,實際處理字符串 需要一個 StrHandler 類。

並且,StrHandler 有個屬性 Type, 如果 StrHandler.Type==1,則這個 StrHandler 就只能處理 AAAA1 AAAA11 .... AAAA191 這些以 1 結尾的字符串

那麽:200個 字符串 就需要 最少10個 StrHandler 來處理。 理論:200個 字符串,創建 200個 StrHandler 來處理 不就得了? 但是:StrHandler 的 構造函數有一些 初始化操作,非常耗時,需要 5秒。

我們先看一下 new 200 個 StrHandler 會有多慢。 如果使用同步函數,那就是 (1+5)*200 = 1200 秒

復制代碼
1 class Program
2 {
3 static void Main(string[] args)
4 {
5 //模擬一個業務需求: 有 200 個字符串需要處理
6 List<string> list = new List<string>();
7 for (int i = 0; i < 200; i++) list.Add("AAAA" + i);
8
9
10
11 DateTime time0 = DateTime.Now;
12
13 //用多個Task 處理這些字符串
14 List<Task> listTask = new List<Task>();
15 foreach (string item in list)
16 {
17 Task task = Task.Run(() =>
18 {
19 Handle(item); //執行一個方法, 處理這200多個字符串
20 });
21 listTask.Add(task);
22 }
23 Task.WaitAll(listTask.ToArray()); //等待200個字符串 都處理完成
24
25 DateTime time1 = DateTime.Now;
26
27
28
29 Console.WriteLine("200個字符串處理完成, 同步執行需要200秒, 實際Task執行耗時: " + (time1 - time0).TotalSeconds + " 秒");
30
31
32
33 }
34
35
36 public static void Handle(string item)
37 {
38 //字符串最末位的數字 就是 StrHandler 的 Type 值
39 int temp = Convert.ToInt32(item.Substring(item.Length - 1));
40
41 StrHandler handler = new StrHandler(temp);
42 handler.Handle(item);
43 }
44
45 }
46
47 //字符串的 處理類
48 public class StrHandler
49 {
50 public StrHandler(int type)
51 {
52 Type = type;
53 Thread.Sleep(5000); //創建一個 StrHandler 需要5秒
54 }
55
56 public int Type { get; set; }
57
58
59 public void Handle(string item)
60 {
61 Thread.Sleep(1000); //函數本身調用需要 1秒鐘
62 Console.WriteLine("處理器 {0} 處理字符串 {1}", Type, item);
63 }
64 }
復制代碼

復用 StrHandler 減少開銷
因為 StrHandler 需要創建 Tcp 通訊信道,開辟多個將占用不必要的網絡端口。 200個字符串,最少需要 10個 StrHandler —— 所以:我們就只創建 10個 StrHandler。

我們修改 static Handle() 函數如下

復制代碼
1 //public static void Handle(string item)
2 //{
3 // int temp = Convert.ToInt32(item.Substring(item.Length - 1)); //字符串最末位的數字 就是 StrHandler 的 Type 值
4
5 // StrHandler handler = new StrHandler(temp);
6 // handler.Handle(item);
7 //}
8
9 public static void Handle(string item)
10 {
11 StrHandler handler = GetHandler(item);
12 handler.Handle(item);
13 }
14
15 private static Hashtable hash = Hashtable.Synchronized(new Hashtable());
16
17 //不同的字符串有不同的 StrHandler
18 //StrHandler 是一種昂貴資源, 初始化 StrHandler 需要5秒, 所以需要 對 GetHandler 進行緩存
19 private static StrHandler GetHandler(string item)
20 {
21 //lock (hash) //加不加lock 不影響 本文最終理論
22 {
23 int temp = Convert.ToInt32(item.Substring(item.Length - 1));
24
25 StrHandler handler = hash[temp] as StrHandler;
26 if (handler != null) return handler;
27
28 //如果沒有緩存, 則創建 StrHandler
29 handler = new StrHandler(temp);
30 hash[temp] = handler;
31 return handler;
32 }
33 }
復制代碼

我們使用 Hashtable 緩存了 StrHanlder 類 —— 再看一下性能

StrHandler 初始化 DotNetty 通訊
上面的幾次演變,把性能逐步提高了不少。 業務要求: StrHandler 需要和 DotNetty 通訊,在 調用 StrHandler 的 Handle(string) 之前,就必須讓 DotNetty 完成初始化。

看一下改進後的 StrHandler

復制代碼
1 //字符串的 處理類
2 public class StrHandler
3 {
4 public StrHandler(int type)
5 {
6 Type = type;
7 DotNetty = new DotNetty();
8
9 //Thread.Sleep(5000);
10 DotNetty.Start(); //耗時的 5秒, 其實就是 DotNetty 的時間消耗 //調用的是假設的同步方法
11 }
12
13 public int Type { get; set; }
14 public DotNetty DotNetty { get; set; } //增加了 DotNetty 的類(這可是一個重量級對象,可不是 說new就new 的)
15
16 //函數本身調用需要 1秒鐘
17 public void Handle(string item)
18 {
19 if (!DotNetty.Active)
20 throw new Exception(string.Format("{0} DotNetty 沒有激活, 無法執行 Handle", Type));
21
22 Thread.Sleep(1000);
23 Console.WriteLine("處理器 {0} 處理字符串 {1}", Type, item);
24 }
25 }
26 我們再看一下 DotNetty 的定義(模擬定義)
27
28 //以下代碼模擬 DotNetty 框架 —— 這個框架 只提供了 異步Task 方法 StartAsync();
29 //所以: StartAsync() 定義不能修改
30 public class DotNetty
31 {
32 //DotNetty 只提供了 異步函數
33 public async Task StartAsync()
34 {
35 await Task.Run(() =>
36 {
37 //DotNetty 是一個著名的通訊框架, 正常情況下 初始化只需要1秒。
38 //但 特殊情況下,初始化需要 5秒 (比如 目標的 IP端口 壓根不存在)
39 Thread.Sleep(5000);
40 });
41
42 Active = true; //DotNetty 初始化完成還有, 將 DotNetty 置為激活狀態
43 }
44
45 //假設給 DotNetty 提供一個 同步的 Start() 方法
46 //實際上: DotNetty 沒有這個同步方法
47 public void Start()
48 {
49 Thread.Sleep(5000);
50 Active = true; //DotNetty 初始化完成還有, 將 DotNetty 置為激活狀態
51 }
52
53 public bool Active { get; set; }
54 }
復制代碼

DotNetty 不提供 Start() 方法,我們假設增加一個 同步方法 Start()

—— 這次測試的是 假設有個 同步函數 Start() 的性能。

DotNetty 只提供異步Task方法 StartAsync()
我們上面也說了,DotNetty 只提供 StartAsync() 這個方法。

我們剛才模擬的 Start() 是不存在的。

這時候,有經驗的小夥伴 一定能指出來:

沒有提供同步函數,我們可以把 異步Task 函數 封裝成 同步函數啊!

說的很對,我們可以給 DotNetty 擴展一個 Start() 方法,

復制代碼
1 public static class Extend
2 {
3 public static void Start(this DotNetty dotNetty)
4 {
5 Task task = dotNetty.StartAsync();
6 task.Wait(); //讓 異步Task 等待完成, 這不就是一個 同步方法了麽?
7 }
8 }
復制代碼
為了證實猜想,我還特意 寫了個 測試代碼。

復制代碼
1 static void Main(string[] args)
2 {
3 DotNetty dotNetty = new DotNetty();
4 dotNetty.Start();
5 Console.WriteLine("DotNetty.Active : " + dotNetty.Active);
6 }
復制代碼

增加了擴展方法之後,程序編譯通過了

正式運行

本文總結
本文,通過一個簡單的 Demo,演示了 如何將 Task異步編程 搞死的案例。

終究得出了如下結論:

Task異步函數 通過 Wait() 封裝的 偽同步函數 是靠不住的。

Task.WaitAll() 函數 是最大的坑 (這是 .Net 4.6 新增加的函數?)

DotNetty 不提供 同步函數 Start(),只提供 StartAsync() 是不厚道的。

建議:所有底層庫,你可以有 Task函數,但請保留 同步函數。

絕不小心求證、只管大膽胡說
這個段落,可以當作開玩笑 —— 各位不要較真。

復制代碼
PS.
以前我們寫函數, 會準備 同步函數、回調函數
.Net 4.5 後, 引入了 異步函數模型

上面的案例中, 我們看到: 一個異步Task 方法, 既能當 回調函數用, 又能當同步函數用
—— 你或許覺得: 異步Task方法 好強大

但是, 警告建議:

無數網友(包括大神) 異步Task 踩坑經歷, 包括自己這次的 踩坑,
都得出了一個結論: 異步Task只能一條道走到黑

即: 你一個地方使用了 異步Task, 其他引用的地方 往上, 都得改成 異步.
你反駁: 我可以把 異步Task 封裝成 一個同步函數啊, task.Wait(); return task.Result;
—— 這就是你踩坑的開始: 你可能會看到 線程飆升到 900個, 但是 CPU利用率為 0%
—— 於是最後, 你就會回到最開始的建議: 異步Task只能一條道走到黑
—— 900個線程, CPU 0%, 如何查錯就是問題了: 每一個函數都看起來沒問題(是真的沒問題), 串在一起運行後 就假死
—— 你以為是 死鎖? 可能幾分鐘後 900個線程 全部瞬間又運行起來了
—— 並沒有死鎖, 似乎就是 Task 內核實現的一種資源分配 BUG(為了不導致死鎖,所以才飆升900個線程的資源分配方案)

Task異步函數 是強大的, 但請不要濫用

同步函數 封裝成 異步函數 不會有任何問題
但是Task異步函數 封裝成 同步函數(就是偽同步函數) —— 這會是你噩夢的開始

有個直覺猜想(可能不正確):
A() 是個內核異步Task函數 開辟了10個Task做事(做完就全部釋放),
B() 是個底層異步Task函數, 因為某些原因, B() 調用 A()的偽同步函數, B開辟了10個Task做事
C() 是個上層調用函數, 他調用了 B()的偽同步函數, C開辟了10個Task做事
—— 最後,一旦假死發生, 線程數或許會等於 101010 = 1000 個, 或者 2000 個
—— 假死發生時的 線程數, 和異步Task的偽同步函數 嵌套層次 有關系
—— 設想一下, A() 引用了 NLog 這種更內核的庫, 哪天 NLog 作者將自己的代碼改成了異步Task, A() 為了代碼改動最小, 封裝了一個 NLog 的 偽同步函數(保持了之前代碼調用的一致性), 假設NLog開辟了5個Task
51010*10 = 5000 個線程 估計是逃不掉了 【個人亂猜,都別介意】

日月輪轉、滄海桑田 —— 可以提供 Task異步函數, 但盡量同時保留 同步函數(尤其是底層框架)
復制代碼

寫在最後的話
總有程序員,能理解 同步函數、勉強理解 回調函數,完全不懂 異步函數 —— 完全是在模仿著寫 await async。

作為一個底層 架構師,如果你的底層 都是高大上 的異步函數 —— 會不會讓使用你的框架的 開發人員 也遇到今天這樣的 BUG呢?

同步函數 就像 親力親為, 回調函數 就像 軟件外包, 異步函數 就像 管理一家大公司 —— 人多力量大的同時,如果管理不好,就可能發生 一件小事 幾個人做,結果還扯皮 的尷尬

.Net4.6 Task 異步OA現金盤平臺出租函數 比 同步函數 慢5倍 踩坑經歷