周沭玲,金 楠,侯海平
隨著互聯(lián)網技術的快速發(fā)展,各種應用軟件層出不窮,例如即時通訊軟件、辦公軟件、信息資訊、購物娛樂軟件等,用戶花在軟件上的時間越來越多,時間不斷被各種軟件割裂,用戶時間的碎片化越趨明顯.通過應用軟件占領用戶的時間、增加用戶粘度是企業(yè)追求的目標,實現這一目標的關鍵就是提升應用軟件操作的用戶體驗.用戶的操作響應速度則是提升應用軟件用戶體驗的關鍵因素之一,通常一個用戶無法忍受3~5 秒以上的響應等待.例如Android 操作系統(tǒng)中服務的響應時間要求為10 秒以內,廣播消息的響應時間為10~60 秒,UI 操作的響應時間為5 秒以內.當程序響應超出這個時間,就會出現卡頓假死狀態(tài),用戶被迫等待,無法進行軟件的下一步操作[1].這就會造成用戶放棄使用或者卸載該軟件,軟件用戶的流失對于互聯(lián)網時代的企業(yè)是無法接受的,因此減少UI 線程阻塞成為優(yōu)化軟件性能的主要研究對象[2].
大多數基于不同平臺的開發(fā)框架都支持線程技術,開發(fā)者可以將耗時費力的工作任務遷移到子線程中去運行,從而減少主線程或者UI 線程壓力[3],做到快速響應用戶操作,然而這種解決傳統(tǒng)單UI 線程阻塞問題的方法并不適合多UI 控件高并發(fā)訪問場景.
本文提出一套多UI 線程高并發(fā)的解決方案,涉及多UI 線程、操作系統(tǒng)消息機制、子線程通信等知識.整體實驗過程如下:首先,還原傳統(tǒng)單UI 線程阻塞問題的解決方法;再次,模擬單個UI 線程高并發(fā)訪問的問題場景,發(fā)現使用傳統(tǒng)方法無法解決阻塞問題,從而引出操作系統(tǒng)消息機制;第三,使用操作系統(tǒng)消息機制解決單個UI 控件高并發(fā)訪問的阻塞問題;最后,模擬多個UI 控件高并發(fā)訪問的阻塞問題,提出將每個UI 控件放入獨立UI 線程中的解決方法,利用操作系統(tǒng)消息機制實現多個子線程與多個UI 線程通信,最終實現多個UI 控件高并發(fā)條件下也能夠實時刷新.
以下實驗全部在Windows 操作系統(tǒng)環(huán)境中完成,采用Windows Presentation Foundation開發(fā)框架技術實現實驗功能,下文統(tǒng)一簡稱WPF.
傳統(tǒng)業(yè)務場景中常見的問題如“下載數據時更新UI 界面中的進度條”“用戶使用網絡時實時監(jiān)控網絡流量和速度”“接收通知消息顯示到UI 界面中”等,通過建立子線程或服務程序都能得到很好地解決[4].在主線程或UI線程中開啟子線程或服務,將上述耗時且易產生線程阻塞的工作任務添加到子線程或服務中執(zhí)行,等到子線程或服務中的任務執(zhí)行完成后,系統(tǒng)再回傳完成的消息給主線程,繼而完成一次耗時任務處理[5].可以看出,開啟子線程或服務這種方法能夠較好處理此類問題.
如果軟件在相對較短的時間內加載較少圖片時(加載圖片相當于線程中的工作任務),并不會暴露出軟件的性能問題.但如果切換到新場景中,多人共同操作UI 界面,需要將軟件加載圖片的數量增加到10 000 張(相當于UI 線程工作任務量較大,此處可以看成是一個耗時任務),甚至更多的圖片,實驗結果發(fā)現此時UI 控件則會出現假死狀態(tài),即使提高計算機性能配置也很難改變這一現象,因為無法知道用戶是否需要加載更多的圖片.
為了能夠解決上述問題,嘗試采用1.1 中常規(guī)處理方法.模擬實驗過程為:主線程創(chuàng)建ListView 控件用于呈現10 000 張圖片,而呈現10 000 張圖片是一件非常耗費時間的任務,按照1.1 中處理方法需要將這個耗時任務放到子線程中去完成,結果發(fā)現這樣并不能啟動這個子線程,因為它違背了WPF 線程親緣性規(guī)則.WPF 線程親緣性要求控件的創(chuàng)建和使用必須在同一個線程中,而當前情形是在主線程中創(chuàng)建Listview,在子線程中訪問Listview,兩個線程同時擁有一個控件,這是不被WPF開發(fā)框架允許的,因此實驗失敗.
在監(jiān)控多個客戶端數據的場景中,作為服務器一端實時獲取多個客戶端數據,并同時呈現到UI 界面上多個控件中,服務器端程序UI 界面中每一個控件對應一個客戶端,并負責呈現對應客戶端的數據,如果其中一個控件處在高并發(fā)處理數據中,則整個UI 界面會出現假死狀態(tài),其他控件更是無法處理對應的客戶端數據.
同樣嘗試采用上述子線程方法處理多個客戶端數據.模擬實驗過程為:主線程中創(chuàng)建多個UI 控件,根據客戶端創(chuàng)建對應的子線程,有多少客戶端就建立多少個子線程,每個子線程用于接收客戶端數據,根據前述實驗結論可以發(fā)現不能在子線程中操作其他線程創(chuàng)建的控件.另外,如果在子線程中采用循環(huán)方式采集對應客戶端的數據,也非常容易造成UI 線程阻塞,因為這些數據最終還是要通過循環(huán)方式加載到UI 控件中,循環(huán)加載是造成UI 線程阻塞的主要因素.因此,采用傳統(tǒng)子線程處理耗時任務的方式并不能成功解決UI 控件高并發(fā)問題.
WPF 中UI 控件造成卡頓假死現象是由于UI 控件所在線程阻塞造成的.WPF 消息機制給解決此類問題提供了可能性.其原理如圖1所示.
步驟1:Windows 操作系統(tǒng)收到中斷消息,使用PostMessage()方法將消息發(fā)送給Message Queue(消息隊列),這些中斷消息可以是用戶鼠標的點擊、鍵盤的輸入,也可以是封裝的Message 消息.在使用PostMessage()發(fā)送消息時,將最新的Message 插入到Message Queue 尾部.
步 驟2:調 用Dispatcher.PushFrameImpl()方法消費Message Queue 中的消息,這是一種循環(huán)機制,Dispatch 內部通過GetMessage()方法不斷地從Message Queue 中獲取消息.
步驟3:在WPF 中,Dispatcher 將獲取的消息分發(fā)到指定的窗口,對于一個WPF 程序來說將會有一個隱藏的窗口來接收分發(fā)的消息.
步驟4:這個隱藏窗口使用類似Win32 系統(tǒng)中WndProc()方法處理收到的消息,從而更新UI 界面.
步驟5:如果當前窗口又產生新的消息,將再交由Windows 操作系統(tǒng)來處理消息,進入下一輪循環(huán).
通常UI 線程阻塞是因為在當前窗口處理的任務過大,耗時過多,任務不能及時處理,造成UI 線程不能接收Windows 操作系統(tǒng)傳來的消息造成的.例如UI 線程在處理一個大任務(加載10 000 張圖片)時,Windows 操作系統(tǒng)的消息就無法及時傳遞到當前窗口,導致UI界面假死狀態(tài).窗口標題欄會出現“沒有響應”字樣.
圖1 WPF 框架中Windows 消息機制時序圖
對以上過程中步驟4 進行分析,如果UI線程接收到的操作系統(tǒng)消息指令是處理一個工作量較大的任務時,可以將工作量過大的任務切分成一個個小的任務,每一個小的任務完成后,向Windows 操作系統(tǒng)傳遞一個消息,從而保證UI 線程可以正常接收到Windows 操作系統(tǒng)的消息,讓當前UI 線程有響應.例如:在處理加載10 000 張圖片時,如果等10 000 張圖片加載完成再去更新UI 線程,就會造成長時間阻塞UI 線程.因此不必一次加載全部圖片,可以一次只加載10 張圖片,然后通過發(fā)送消息給操作系統(tǒng)更新一次UI 線程,總的任務就可以分解成1 000 次去更新UI 線程,從而在界面響應上保證用戶的體驗,這種方法稱為“拆分任務”.通過“拆分任務”在上一個任務處理和下一個任務處理的空檔中,利用WPF 的消息機制將消息傳遞到當前窗口進行處理,保證UI 線程及時響應.
WPF 對應用程序中產生的消息使用DispatcherOperation 進行了封裝,這種封裝暴露了消息的優(yōu)先級Priority,定義了DispatcherOperation 消息的結束事件和取消事件.通過Dispatcher 對象創(chuàng)建消息、處理窗口消息形成消息產生到消費的閉環(huán),這就為解決UI 線程阻塞提供了可能性.具體做法如下:
步驟1:開發(fā)者調用Dispatcher 的Invoke 或BeginInvoke,發(fā)送DispatcherOperation 消息,確定消息的Priority 級別.
步驟2:該DispatcherOperation 消息加入到DispatcherOperation 消息隊列中,也就是之前所說的Message Queue 中.
步驟3:對應的隱藏窗口收到Dispatcher-Operation 消息,按優(yōu)先級執(zhí)行該消息中包含的任務.
步驟4:UI 線程更新.
根據這一過程分析得到,在拆分任務時使用Dispatcher 向系統(tǒng)消息隊列發(fā)送任務消息,能夠保證UI 線程及時更新,運行過程不阻塞.
Dispatcher 對象給開發(fā)者解決UI 線程阻塞帶來了可能性,Dispatcher 提供了Invoke 和BeginInvoke 方法,使用這兩個方法向Dispatcher-Operation 的消息隊列發(fā)送消息,一方面可以保證在UI 線程中進行任務拆分并及時更新UI 線程,另一方面也可以保證子線程完成耗時任務后發(fā)送消息回到UI 線程,更新UI 線程.這兩個方法的具體描述如下:
(1)Invoke 的方法簽名及使用場景
object Invoke(Delegate method,object[]args);
參數1method 是一個委托類型,可以理解為是一個方法的地址,表示發(fā)送到Dispatcher-Operation 消息隊列中的一個任務方法;參數2args 是這個方法調用時傳入的參數值,這樣就將一個要執(zhí)行的任務傳遞給Windows 消息機制處理.
Invoke 方法用于同步處理場景,當用戶需要等待方法執(zhí)行返回結果才能繼續(xù)往下執(zhí)行時采用Invoke 方法,它可以保證消息傳遞過程中消息保持一定順序被執(zhí)行.但是如果該消息中含有較大執(zhí)行任務,也就是該委托對應的方法中執(zhí)行的程序耗時比較長時,會造成線程阻塞.
(2)BeginInvoke 的方法簽名及使用場景
IAsyncResultBeginInvoke(Delegate method,object[]args);
參數的表達意思同Invoke 方法.參數1 表示委托,參數2 表示方法執(zhí)行時傳遞的參數值.不同的是該方法用于異步處理場景,當用戶不需要等待method 參數方法執(zhí)行完畢就繼續(xù)往下執(zhí)行其他程序時,可以采用該方法.它雖然不能保證消息按順序地執(zhí)行完成,但是可以保證程序很好的性能,從而提供給用戶較好的體驗.
對于使用BeginInvoke 方法產生消息的亂序,可以通過在進行參數傳遞時提供時間戳來標記消息的先后順序.然后通過定時器定時獲取一組已經有序的消息并執(zhí)行它們,為了保證性能問題,需要通過多輪測試最終選取定時器的間隔時間和一組消息的組大小.
為了方便展示實驗過程,建立一個數據采集系統(tǒng),設置兩個終端持續(xù)不斷地將數據發(fā)送到程序主界面,接收方軟件主界面通過兩個區(qū)域的UI 可視化控件來展示這些由終端1 和終端2 發(fā)出的數據.為了方便觀察效果,這里將數據以點的形式繪制在界面上.具體場景結構關系如圖2 所示.
圖2 多終端數據展示過程
終端1 持續(xù)不斷地將數據發(fā)送到區(qū)域1控件,區(qū)域1 持續(xù)不斷地將這些數據以點的形式繪制在區(qū)域1 的位置;終端2 持續(xù)不斷地將數據發(fā)送到區(qū)域2 控件,區(qū)域2 持續(xù)不斷地將這些數據以點的形式繪制在區(qū)域2 的位置.問題場景中,終端與程序之間的通信是建立在網絡環(huán)境中,終端需要知道程序所在服務器的IP 地址,程序需要知道終端的唯一標識,讓服務器程序能清楚知道是誰發(fā)送過來的.實驗過程中發(fā)現存在兩個問題.
(1)兩個終端的數據展示工作使用單UI線程將無法完成,必須為每一區(qū)域內的數據展示過程建立獨立的UI 線程去處理數據繪制工作,兩個終端需要建立兩個UI 線程.
(2)終端數據是通過循環(huán)不斷向外發(fā)出的,如果將這些點直接繪制在UI 控件上,UI線程會立即造成阻塞.實驗時看到的畫面將是所有消息發(fā)送完畢,這些點一次展示到UI界面上,這與實時展示數據點是不相符的.
WPF 開發(fā)框架提供了VisualTarget 類給程序創(chuàng)建多UI 線程帶來了可能性,創(chuàng)建多UI 線程的好處就是為每一個UI 線程建立自己的消息循環(huán)隊列,每個UI 控件可以在自己的消息循環(huán)隊列中使用GetMessage()獲取消息,相互不干擾,根據前面問題場景的模擬,可以建立兩個UI 線程,以下是使用VisualTarget 類創(chuàng)建多UI 線程的步驟.
步驟1:創(chuàng)建一個自定義類繼承FrameworkElement,其目的是建立新UI 線程中控件的宿主,將新的UI 線程中的UI 控件加入到當前UI 控件的可視化樹中.
步驟2:實例化剛剛創(chuàng)建的可視化宿主類,將WPF 框架提供的HostVisual 實例化后加入其中,并將可視化宿主類實例加入到當前UI 控件可視化樹中.
步驟3:建立子線程,在子線程中創(chuàng)建每個區(qū)域繪制數據點的UI 控件,這里選擇WPF框架中的InkCanvas 控件,并使用VisualTarget類創(chuàng)建一個實例,將HostVisual 實例加入其中,這樣就將子線程中UI 控件與可視化樹中的宿主建立了聯(lián)系,在WPF 中每一個UI 控件必須在可視化樹中掛載才可以顯示.
步驟4:將創(chuàng)建的線程設置為單線程單元狀態(tài),也就是讓當前線程可以建立獨立消息循環(huán)隊列.
步驟5:重復上面步驟,創(chuàng)建第二個UI 線程,至此兩個UI 線程創(chuàng)建完畢.
根據之前實驗結論可以知道使用循環(huán)方式在InkCanvas 控件上展示數據點,會出現線程阻塞狀態(tài),直接導致所有數據收集完畢后所有數據點一次性展示,給用戶造成的視覺感受就是沒有中間過程,要么沒有數據點,要么一次將一萬個點一次展示,中間過程界面是假死狀態(tài).要想解決UI 線程阻塞問題就必須 引 入Dispatcher 的Invoke 和BeginInvoke 方法.通過前述分析,可以知道Invoke 方法對于數據量大時,也會造成當前線程阻塞,使用BeginInvoke 方法則會造成執(zhí)行亂序,這里可以采用將兩種方法結合的方式來處理,將每次執(zhí)行BeginInvoke 方法之間的間隔時間稍微增大,降低亂序發(fā)生的可能性,將數據累積到一個小批量后使用Invoke 方法執(zhí)行,保證順序的正確性.所以,在外側循環(huán)使用BeginInvoke 方法,在內側循環(huán)使用Invoke,降低Invoke循環(huán)執(zhí)行的次數,例如100 以內,因為數字過大執(zhí)行時間過長會造成線程阻塞.
通過使用VisualTarget 創(chuàng)建了兩個UI 線程,并且在每一個UI 線程中建立展示數據的InkCanvas,解決了多線程創(chuàng)建和數據展示問題;通過使用Dispatcher 的Invoke 和BeginInvoke方法,利用WPF 消息機制很好地解決了大量數據處理造成多UI 線程阻塞的問題.最終可以看到圖3 效果.
圖3 數據點展示過程
圖3 中(a)(b)(c)呈現了程序執(zhí)行的動態(tài)過程.點擊按鈕后,開始收集數據,數據點展示是動態(tài)變化的,并且是左右兩個區(qū)域同時展示,展示過程中UI 界面按鈕是可以點擊的狀態(tài),表示兩個UI 線程并沒有阻塞.
UI 線程阻塞問題是軟件開發(fā)過程中處理用戶體驗問題的關鍵,在WPF 開發(fā)框架中可以利用Windows 消息機制很好地處理UI 線程阻 塞問題,WPF 提供了Dispatcher 的Invoke 和BeginInvoke 方法,可以使用這兩種方法向Windows 消息隊列發(fā)送信息更新UI 線程.同時,在多UI 線程場景中,可以利用VisualTarget 和HostVisual 建立多UI 線程控件的宿主,將多個UI 線程中的控件連接到同一個WPF 可視化樹中,再運用Windows 消息機制解決多UI 線程阻塞問題.該解決方案優(yōu)化了多UI 線程高并發(fā)訪問的處理效率,即使在并發(fā)處理的任務較大時,也能通過“拆分任務”很好地解決多UI 線程的阻塞問題,大大改善和提升了應用軟件的用戶體驗.