處理大型資料的技巧 – Async / Await

原本只是很單純的把大型檔案 (100mb 左右的 video) 放到 Azure Storage 的 BLOB 而已,結果效能一直不如預期般的理想。才又把過去的 thread 技巧搬出來用,結果又花了點時間改寫,用 async / await 的效果還更漂亮一點,於是就多了這篇文章 :D

其實這次要處理的問題很單純,就是 WEB 要從 Azure Storage Blob 讀取大型檔案,處理前端的認證授權之後,將檔案做編碼處理後直接從 Response 輸出。主要要解決的問題是效能過於糟糕… 透過層層的處理,效能 (3.5 Mbps) 跟直接從 Azure Storage 取得檔案 (7.3 Mbps) 相比只剩一半左右.. 過程中監控過 SERVER 的 CPU,頻寬等等,看來這些都不是效能的瓶頸。

為了簡化問題,我另外寫了個簡單的 Sample Code, 來呈現這問題。最後找出來的原因是,程式碼就是單純的跑 while loop, 不斷的把檔案內容讀進 buffer 並處理後,將 buffer 輸出。結果因為程式完全是 single thread 的處理方式,也沒有使用任何非同步的處理技巧,導致程式在讀取及處理時,輸出就暫停了,而在輸出時,讀取及處理的部份就暫停了,讓輸入及輸出的 I/O, 還有 CPU 都沒有達到滿載… 於是效能就打對折了。用時間軸表達,過程就如下圖:

這樣的設計方式,同一時間只能做一件事。若把上圖換成各種資源的使用率,會發現不論是 DISK、NETWORK、CPU等等資源,都沒有同時間保持忙碌。換句話說好像公司請了三個員工,可是同時間只有一個人在做事一樣,這樣的工作安排是很沒效率的。要改善的方法就是讓三個員工都保持忙碌,同時還能亂中有序,能彼此協調共同完成任務。

同樣的狀況應該很普遍吧? 不要說別人了,就連我自己都寫過很多這樣的 CODE … 光是 COPY 大型檔案,大家一定都是這樣寫的: 用個 while loop, 把來源檔讀進 buffer, buffer 滿了寫到目地檔,然後不斷重複這動作,直到整個檔案複製完成為止。這不是一模一樣的情況嗎? 只是大部份的人不會去考量如何加速這樣的動作而已…

我先把目前的CODE簡化一下,拿掉一些不相關的部份,單純的用 Read() / Process() / Write() 三個空的 method 代表執行這三部份的工作,執行過程需要的時間,就用 Task.Delay( 100 ) 來取代。經簡過後的 Code 如下:

經簡後的示意程式碼:

public class Program
{
    static Stopwatch read_timer = new Stopwatch();
    static Stopwatch proc_timer = new Stopwatch();
    static Stopwatch write_timer = new Stopwatch();
    static Stopwatch overall_timer = new Stopwatch();
    public static void Main(string[] args)
    {
        overall_timer.Start();
        for (int count = 0; count < 10; count++)
        {
            Read();
            Process();
            Write();
        }
        overall_timer.Stop();
        Console.WriteLine("Total Time (over all): {0} ms", overall_timer.ElapsedMilliseconds);
        Console.WriteLine("Total Read Time:       {0} ms", read_timer.ElapsedMilliseconds);
        Console.WriteLine("Total Process Time:    {0} ms", proc_timer.ElapsedMilliseconds);
        Console.WriteLine("Total Write Time:      {0} ms", write_timer.ElapsedMilliseconds);
    }
    public static void Read()
    {
        read_timer.Start();
        Task.Delay(200).Wait();
        read_timer.Stop();
    }
    public static void Process()
    {
        proc_timer.Start();
        Task.Delay(300).Wait();
        proc_timer.Stop();
    }
    public static void Write()
    {
        write_timer.Start();
        Task.Delay(500).Wait();
        write_timer.Stop();
    }
}

程式執行結果:

程式總共要花掉 10 秒鐘才執行完畢,由於完全沒有任何並行的處理,因此就是很簡單的 Read 花掉 2 秒,Process 花掉 3 秒,Write 則花掉 5 秒,加起來剛好就是總執行時間 10 秒。

回顧一下,過去寫過幾篇如何善用多執行緒來解決各種效能問題的文章,其中兩篇跟這次的案例有關:

  1. MSDN Magazine 閱讀心得: Stream Pipeline, (2008/01/19)
  2. 生產者 vs 消費者 - BlockQueue 實作, (2008/10/18)
  3. 生產線模式的多執行緒應用, ([RUN! PC] 2008 十一月號, 2008/11/04)
  4. RUN!PC 精選文章 - 生產線模式的多執行緒應用, (2009/01/16)

其實這些方法的目的都一樣,都是透過各種執行緒的操作技巧,讓一件大型工作的不同部份,能夠重疊在一起。這樣的話,整體完成的時間就能縮短。不過,隨著 .NET Framework 一直發展,C# 5.0 提供的 Syntax Sugar 也越來越精彩,到了 .NET Framework 4.5 開始提供了 Async / Await 的語法,能夠大幅簡化非同步模式的設計工作。

非同步的程式設計,其實也是 multi-threading 的一種運用。簡單的說,它就是把要非同步執行的任務丟到另一條執行緒去執行,等到它執行結束後再回過頭來找它拿結果。只是為了這樣的一個動作,往往得寫上數十行程式碼,加上原本程式的結構被迫切的亂七八糟,過去往往非絕對必要,否則不會用這樣的模式。

這次我的目的,其實用前面那幾篇的技巧就能解決了。不過這次實作我想換個方法,都已經 2013 了,有 Async / Await 為何要丟著不用? 這次就用新方法來試看看。先用上面的時間軸那張圖,來看看改進後的程式執行狀況,應該是什麼樣子:

解釋一下這張圖: 橘色的部份代表是用非同步的方式呼叫的,呼叫後不會 BLOCK 原呼叫者,而是會立即 RETURN,兩邊同時進行。而圖中有個箭頭 + await, 則代表第二個非同步呼叫 Write() 的動作,會等待前一個 Write() 完成後才會繼續。

Write() 跟下一次的 Read() 其實並無相依性,因此在開始 Write() 時,其實可以同時開始下一回的 Read(), 因此時間軸上標計的執行順序就可以被壓縮,調整一下執行的順序,馬上得到大幅的效能改進。這次要改善的,就是把 Read() + Process()Write() 重疊在一起,預期會有一倍的效能提升。

想要瞭解 C# 的 async / await 該怎麼用,網路上的資源有很多,我習慣看官方的文件,有需要參考的可以看這幾篇:

  1. async (C# Reference)
  2. Asynchronous Programming with Async and Await (C# and Visual Basic)

Async / Await 的細節我就不多說了,簡單的說在 method 宣告加上 async 的話,代表它的傳回值會被改成 Task<>, 而呼叫這個 method 會變成非同步的,一旦呼叫就會立刻 Return, 若需要這個 method 的執行結果,可用 await 等待,直到 method 已經執行完畢才會繼續…

廢話不多說,過程就沒啥好說的了,直接來看改好的程式碼跟執行結果:

改寫為非同步模式的 CODE:

public class Program
{
    static Stopwatch read_timer = new Stopwatch();
    static Stopwatch proc_timer = new Stopwatch();
    static Stopwatch write_timer = new Stopwatch();
    static Stopwatch overall_timer = new Stopwatch();
    public static void Main(string[] args)
    {
        overall_timer.Start();
        DoWork().Wait();
        overall_timer.Stop();
        Console.WriteLine("Total Time (over all): {0} ms", overall_timer.ElapsedMilliseconds);
        Console.WriteLine("Total Read Time:       {0} ms", read_timer.ElapsedMilliseconds);
        Console.WriteLine("Total Process Time:    {0} ms", proc_timer.ElapsedMilliseconds);
        Console.WriteLine("Total Write Time:      {0} ms", write_timer.ElapsedMilliseconds);
    }
    public static void Read()
    {
        read_timer.Start();
        Task.Delay(200).Wait();
        read_timer.Stop();
    }
    public static void Process()
    {
        proc_timer.Start();
        Task.Delay(300).Wait();
        proc_timer.Stop();
    }
    public static async Task Write()
    {
        write_timer.Start();
        await Task.Delay(500);
        write_timer.Stop();
    }
    private static async Task DoWork()
    {
        Task write_result = null;
        for (int count = 0; count < 10; count++)
        {
            Read();
            Process();
            if (write_result != null) await write_result;
            write_result = Write();
        }
        await write_result;
    }   
}

程式碼幾乎都沒有動,不過就是把 Write() 改寫為 Async 版本,同時在主程式 DoWork() 用 Task 形別,把 Write() 傳回的 Task 物件,保留到下一次呼叫 Write() 前,用 await 來確保上一個 Write() 已經完成。

改寫過的版本,程式碼很簡單易懂,90% 以上的程式碼結構,都跟原本同步的版本是一樣的,大幅維持了程式碼的可讀性,完全不像過去用了多執行緒或是非同步的版本,整個結構都被切的亂七八糟。看看程式的執行結果,果然跟預期的一樣,整體執行時間大約為 5 秒。多出來的 660 ms, 就是第一次的 Read() + Process(), 跟最後一次的 Write() 是沒有重疊的,因此會多出 500 ms, 再加上一些執行的誤差及額外負擔,就是這 660ms 的來源了。

最後,來看一下效能的改善。在我實際的案例裡,Read 是受限於 VM 與 Storage 之間的頻寬,固定為 200Mbps, 而 Process 是受限於 VM 的 CPU 效能,也是固定可控制的, 最後 Write 則是受限於 client 到 VM 之間的頻寬,可能從 2Mbps ~ 20Mbps 不等,這會直接影響到到 Write 需要的時間。

不管是用 thread 或是 async ,都不是萬靈丹,主要還是看你的狀況適不適合用這方法解決。這次我的案例是用 async 的方式,將 Read / Write 閒置的時間重疊在一起,節省的時間就反應在整個工作完成的時間縮短了。因此兩者花費的時間差距如果過大,則就沒有效果了。

我簡單列了一張表,來表達這個關係。分別針對 client 端的頻寬,從 2Mbps ~ 200Mbps, 列出使用 async 改善前後的花費時間,及效能改善的幅度:

  *200M 100M 80M 50M 20M 10M 5M 2M
原花費時間(ms) 7000 9000 10000 13000 25000 45000 85000 205000
ASYNC花費時間(ms) 5500 5500 5500 8500 20500 40500 80500 200500
效能改善% 127.27% 163.64% 181.82% 152.94% 121.95% 111.11% 105.59% 102.24%

以執行時間來看,頻寬低於 80M 之後,改善的程度就固定下來了,隨著頻寬越來越低,WRITE 需要花費的時間越來越長,改善的幅度就越來越不明顯。同樣這些數據,換成改善的百分比,換成下一張圖:

改善幅度最好的地方,發生在 80Mbps, 這時正好是 Read() + Process() 的時間,正好跟 Write() 花費的時間一樣的地方。頻寬高於或低於這個地方,效果就開始打折扣了。通常改善幅度若低於 10%, 那就屬於 “無感” 的改善了。

簡單的下個結論,其實任何效能問題都很類似,能用 async 改善的效能問題,一定有這種模式存在: 整個程式執行過程中,有太多等待的狀況發生。不論是 IO 等待 CPU,或是 DISK IO 等待 NETWORK IO 等等,都屬此類。從外界能觀察到的狀況,就是幾個主要的硬體資源,如 Network, CPU, DISK, Memory 等等,都沒有明顯的負載過重,但是整體效能就是無法提升,大概就屬於這種模式了。找出流程能夠重新安排的地方後,剩下的就是如何善用這些技巧 (async),把它實作出來就結束了。

而 async / await, 處理這類問題,遠比 thread 來的有效率。就我看來,若需要大規模的平行處理,還是 thread 合適。但是像這次的案例,只是希望將片段的任務以非同步的模式進行,重點在精確的切割任務,同時要在特定的 timing 等待先前的任務完成,這時 async / await 會合適的多。






安德魯部落格 GPTs

試試用 GPTs 幫你讀文章!
直接用白話文詢問,"安德魯的部落格 GPTs" 會幫你找到相關文章,也會用我文章的知識來回答你的問題。

Facebook Pages

Edit Post (Pull Request)

Post Directory