天天看點

面向對象第二單元訓練總結

一、前言

第二單元的三次作業是很有特點的三次作業。多線程電梯的設計思路和前兩次電梯作業迥然不同,導緻我花費了大量的時間去重構之前的代碼,使其适應多線程電梯的作業要求;檔案螢幕是一個獨立的作業,不像電梯和計程車那樣是一個系列,是以寫起來沒什麼包袱,感覺并不困難;計程車排程和多線程電梯寫起來感覺比較相似,但計程車幾乎沒有算法上的難度,是以主要的工作都花費在了如何建構一個好的設計上面。這三次作業之間看起來沒有什麼關聯,但卻環環相扣,一步一步加深着我對多線程程式設計的了解。

我對這三次作業的總體難度評價為:多線程電梯 > 計程車排程 >= 檔案螢幕。(這個難度基本上是根據我的熬夜時間來判斷的)

之是以排出這樣的難度順序,是因為多線程電梯和計程車排程有着一個共同的難點,而這個難點是檔案螢幕所不具備的——程式的運作時間需要與這個世界的真實時間保持同步。這是這兩次作業的一個大坑,也是我在好幾個深夜裡不睡覺而被迫面對着電腦螢幕的罪惡源泉。雖然多線程極大地增強了使用者與程式互動的即時性,但是為了同時保證互動的即時性和邏輯正确性,程式設計者需要付出許多額外的努力和工作。

二、多線程電梯

電梯系列作業是讓我寫得很不爽的三次作業。第一次的傻瓜排程,我設計了一套我自認為十分精妙的判斷同質的算法,進而幾乎沒有阻力地無傷通過了公測和互測;但到了第二次ALS排程,噩夢就開始了:我發現自己的傻瓜排程算法完全無法移植到ALS上面,因而不得已更換了算法,并大面積重構了程式;到了多線程電梯,我又一次痛苦地發現,之前的ALS排程算法與多線程電梯的即時輸入是不相容的,隻好被迫又一次地重構。三次作業,三套算法,三種設計,如果有一個人連續三次配置設定到這樣的代碼,恐怕他根本不會認為這三次作業都出自同一人之手。早知如此,我在第一次電梯作業就應該使用模拟爬樓的算法,這樣就不會有後面這麼多糟心事兒了。

抛開這些悲傷的過往不談,我的多線程電梯采用了與老師總結課上PPT相似的設計:當一條請求輸入進來之後,會被發送到一個總請求隊列中。主排程器根據目前三部電梯的狀況,把這條請求派發到合适的電梯中去。每一個電梯保有一個自己的小請求隊列和小排程器,主排程器派發的請求進入某一部電梯的小請求隊列之後,會由這部電梯的小排程器來判斷是否需要進行捎帶。這樣設計的好處是,把判斷同質的過程和判斷捎帶的過程分離,将一個大的排程器類拆成兩個排程器類,進而減少排程器類的代碼量。

這次作業遇到的一個難題是:怎樣讓電梯精确地滿足"運作一層樓花費3.0s,開關門一次花費6.0s"。因為是第一次接觸多線程程式設計,對sleep和wait的用法還不太熟悉,為了保證公測能夠通過,我采用了模拟時間的方法,即輸出的是所謂的"電梯系統時間",是假的、事先計算出來的,而非直接取自系統時間。在電梯運作的過程中,讓電梯線程sleep三秒鐘或者六秒鐘,以使模拟時間和真實時間同步。當然,既然使用了這種方式,就勢必面臨着時間差的問題。我解決這個問題的方式是:在電梯線程的無限循環裡面,每一次循環體開始的時候先擷取一下目前的系統時間,到循環體的最後判斷一下已經過去了多少毫秒,并從睡眠的時間中把這個數字減掉。通過使用這種方式,我的程式運作得還算精準,整體誤差不會大到一個不可接受的程度,互測中很難被發現與此相關的Bug。

本次作業的經典OO度量情況如下:

面向對象第二單元訓練總結

可見多線程電梯的實際代碼量并不多,隻有1000行左右(這其中還包含了實質上并沒有被用到的ALS排程器和傻瓜排程器)。但是由于第一次使用多線程程式設計,對run方法和臨界區域還不太熟悉,導緻在電梯線程裡的代碼嵌套層數過多,如上圖中紅字所示。

本次作業的類圖如下:

面向對象第二單元訓練總結

從雷圖中可以看出來,本次作業在設計上存在着過度封裝的問題。為了滿足同步控制的要求,我在電梯類之外建立了一個Elevators類,其中用數組将三個Elevator類的執行個體包含在裡面,排程器隻能與Elevators類進行互動,而不能直接通路某一部電梯。這樣做看似合理,但實際上是完全沒有必要的。過度的封裝使代碼變得醜陋和臃腫不堪,需要無數個getter和setter才能完成全部所需的操作,這毫無疑問對代碼品質是有害的。此外,由于害怕線程安全問題,我對Elevators類中的幾乎所有方法都使用了synchronized辨別,這樣做雖然增強了程式的線程安全性,卻極大地損害了并發性,同時相當程度上降低了性能。這些都是在之後的作業中需要改進的地方。

本次作業的時序圖如下:

面向對象第二單元訓練總結

這次作業的線程協作設計較為合理,主排程器将請求派發至各個電梯保有的小請求隊列,并在内部進行捎帶判斷,這極大地減輕了主排程器的工作量。

三、檔案螢幕

檔案螢幕作業是我認為自己寫的比較順利的一次,各種功能都很完備,也沒有被别人挑出什麼Bug。我想一方面原因是,這次作業的指導書規定不夠明确,Readme的作用被無限放大,導緻任何事情隻要在Readme裡提一句,就可以讓對方無法扣自己分。例如,設計者甚至可以強制要求測試者在兩次檔案操作之間加入間隔,這使得程式的算法難度幾乎降為0,甚至失去意義。再者,指導書明确規定,兩次檔案掃描操作間隔内不允許對同一個檔案實施兩次或以上的修改,這也很大程度上讓這次作業變得很水。

檔案螢幕的主要訓練目标是讓同學們能夠做出一個線程安全的設計,但并沒有強調對于性能的要求,這是我認為這次作業一個很大的不足。如果沒有性能要求,設計者完全可以把所有的方法都加上同一個鎖,這樣就可以保證不會出現資源争奪的現象。但是這樣做對學習是沒有幫助的,甚至是有害的,我覺得在下一屆的課程中,應該對檔案螢幕的性能有着更高的要求。

導緻這次作業難度不大的另一方面原因是,檔案螢幕并沒有時間上的要求,即程式的時間不需要與外部真實時間保持同步。是以,設計者可以采用各種手段使自己的程式滿足指導書中規定的要求,即使這些手段是以性能的損失為代價的。總體來講,檔案螢幕是一個很獨特的作業,既不承上也不啟下,大概可以算作是兩次系列作業(電梯和計程車)之間的一個小插曲。

本次作業的經典OO度量如下:

面向對象第二單元訓練總結

從經典OO度量中可以看出,本次作業的代碼規模控制得很好,隻有752行,且各種方法調用的嵌套深度都保持在一個合适的範圍内。圖中的紅色警告是main方法,這是因為我将記錄Detail和Summary的線程以匿名内部類的方式直接寫在了main方法裡,是以導緻塊調用深度大于均值。

面向對象第二單元訓練總結

檔案螢幕的設計難度并不大,各個子產品之間的層次也比較清晰。我設定了一個Snapshot類不斷捕獲檔案結構快照,并在其内部對新舊兩次快照進行對比,進而判斷是否有檔案發生了變動。在資料結構方面,我選擇了HashMap而非樹形結構,因為對于此次作業的要求(不需要比對檔案夾,隻需要比對監控區下的所有檔案)來講,樹形結構的性能并不是很好,遠遠比不上HashMap的效率。

面向對象第二單元訓練總結

可見程式整體的邏輯并不複雜,無非就是在一個無限循環中不斷捕獲快照并進行對比。

四、計程車排程

相比于檔案螢幕,計程車排程要難寫得多。這個難寫不在于其算法,而在于計程車的要求多且雜。最令人痛苦的一個要求是一輛車移動一格的時間必須嚴格保證為200ms,這幾乎就直接限制了程式的時間方式,即必須采用模拟時間,然後讓程式的sleep時間向模拟時間靠攏。為了解決這個問題,我采用了sleepUntil方法,即先計算計程車應該在什麼時候到達,然後再讓程式睡到那個時間。這樣做雖然有一點點耍賴,但确實很好地完成了指導書中的要求。

這次作業是系列作業,是以需要一開始就打好一個設計的基礎。但很可惜的是,我并沒有完成這個任務,因為在這次作業快要截止的時候,我發現自己的程式無法很好地處理同時有很多個請求一起輸入的情況。這個問題也在互測中被測我的大佬一下就挑了出來。究其原因,是因為我為每一個請求都開啟了一個線程,并讓其運作三秒鐘後自行終止,這雖然非常符合真實的邏輯,但卻不适用于程式本身。因為每一個請求線程都可能會改變計程車的狀态,是以需要為這個請求線程中涉及到變更計程車狀态的地方加鎖,一旦請求變多,達到百條的量級,就會使得線程之間互相阻塞,後面的請求得不到執行。此外,由于使用者可以自由輸入請求,是以實際上程式的線程數是由使用者控制的,這顯然是一種極不安全也極不合理的設計。在進行下一次計程車作業之前,我會想辦法解決這個問題,把線程數控制在一個自己可控的範圍内。

面向對象第二單元訓練總結

這次作業的代碼量并不大,1473行是包含了GUI的統計,将GUI排除在外後,實際隻有900行不到。但我仍然覺得程式在許多地方顯得過于臃腫,請求隊列類幾乎形同虛設,計程車線程設計得也不夠優雅。這些需要在重構的時候加以解決。

面向對象第二單元訓練總結

在類設計中,幾乎所有的資料操作都是圍繞TaxiSet類展開的。TaxiSet包含了所有計程車的資訊,請求線程隻能通路到TaxiSet類,而不能直接對Taxi進行操作。這使得多個請求線程可以使用synchronized以保證不會出現資料沖突的情況。

面向對象第二單元訓練總結

TaxiDispatcher計程車派遣類是整個程式執行流程的核心。TaxiDispatcher就是我所說的隻會運作3秒鐘的線程,它會從請求隊列中提取請求,并通知乘客出發點周圍的計程車搶單,并最終決定排程哪一輛計程車為乘客服務。

五、Bug分析

我的程式在多線程電梯和計程車排程中各被報告了一個Bug,其中多線程電梯是由于忘記對某一塊輸入部分進行處理而導緻的公測格式錯誤,計程車排程則是上文中提到的無法同時處理大量請求的錯誤。前者是由于粗心馬虎和測試不周全而導緻的Bug,後者則純粹是由設計導緻。值得注意的是計程車排程的Bug,它使我對程式内線程數量和程式性能的關系有了更深的了解。

多線程電梯的互測中,我找到别人的Bug主要集中在捎帶的判斷上。可能是由于模拟時間和真實時間的同步沒有做好,有些應該判斷為捎帶的地方對方并沒有判斷成功。我想這種問題很難從代碼層面直接挑出來,隻有通過大量樣例的測試才能發現。檔案螢幕的互測中,我主要通過閱讀别人的代碼發現了Bug。對方沒有做好重命名時的多映射檢測,也沒有完成指導書中要求的繼續監控移動後檔案的任務,這些Bug都可以在仔細閱讀代碼以後直接找到。更深層次的原因是我在寫程式的時候也遇到了這些問題,是以在互測的時候就會對它們格外關注。計程車排程的互測中,由于代碼量較大,且直接從代碼中找邏輯Bug相對困難,我采用了集中壓力測試的方法,即一開始就讓所有的計程車集中在地圖的左上角,然後集中輸入請求進行壓力測試。通過這樣的方法,一些隐蔽的Bug才能被發現。

多線程程式的代碼邏輯相比單線程程式複雜很多,有時候直接閱讀代碼也難以找到其中的漏洞。這個時候,測試樣例的廣度覆寫和壓力測試的深度覆寫就顯得很有必要了。此外,找到别人Bug的另一個好方法是回顧自己的設計過程,細數自己在寫代碼的時候踩過哪些坑,然後再去看别人是否犯了相同的錯誤。

六、心得與體會

很多同學都将多線程稱之為"玄學",我想這是有一定道理的。不同于單線程程式的完全可控,多線程程式在運作的過程中可能會出現許多難以預料的行為,甚至有些行為不可複現,但對程式卻有着緻命的影響。程式設計者該做的,不應該是想着如何回避甚至掩蓋這些問題,而是應該努力地去暴露問題,并争取對其加以修複。

提高多線程程式的性能并不困難,保證多線程程式的線程安全也不困難,但要想同時做好這兩點,就變得非常困難。在這三次的作業中,我遇到的幾乎所有多線程問題都可以歸根結底為一句話:如何在性能和安全之間做出取舍。程式的時間需要和真實時間保持一緻,這是對性能的要求,然而為了兼顧多線程的安全性,程式設計者可能需要采取一些同步控制的方法,這其中的時間差勢必會導緻程式時間和真實時間的不同步。這三次作業中,我嘗試了一些解決這個問題的方法,最終發現,将模拟時間和真實時間結合起來,先計算出程式應該運作的時間,然後再讓它睡到那個時間,這種方式既省腦子,也省資源,還能確定程式運作的正确性。

除此之外,程式的架構需要有一個足夠優秀的設計才能經得起需求變更的考驗。在寫代碼之前,先花一兩天的時間在紙上寫寫畫畫,大緻勾勒出程式的框圖;然後先不寫方法的主體定義,隻寫方法名和傳回值,用這些尚待完善的半成品方法和類的屬性搭出一個程式;最後,為每個方法填上具體的内容,完成整個程式。這一套流程可以有效地檢驗程式設計是否合理,也一定程度上減輕了工作量。我在計程車排程作業中使用了這個方法,并取得了令我滿意的成果。

多線程程式設計還是很有意思的,當看到計程車在GUI上動起來的那一刻,我的心中真的有一種巨大的成就感。希望接下來的三次作業也能像前兩個單元一樣順利。