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