面向對象第二單元訓練總結
一、前言
第二單元的三次作業是很有特點的三次作業。多線程電梯的設計思路和前兩次電梯作業迥然不同,導致我花費了大量的時間去重構之前的代碼,使其適應多線程電梯的作業要求;文件監視器是一個獨立的作業,不像電梯和出租車那樣是一個系列,因此寫起來沒什麽包袱,感覺並不困難;出租車調度和多線程電梯寫起來感覺比較相似,但出租車幾乎沒有算法上的難度,因此主要的工作都花費在了如何構建一個好的設計上面。這三次作業之間看起來沒有什麽關聯,但卻環環相扣,一步一步加深著我對多線程編程的理解。
我對這三次作業的總體難度評價為:多線程電梯 > 出租車調度 >= 文件監視器。(這個難度基本上是根據我的熬夜時間來判斷的)
之所以排出這樣的難度順序,是因為多線程電梯和出租車調度有著一個共同的難點,而這個難點是文件監視器所不具備的——程序的運行時間需要與這個世界的真實時間保持同步。這是這兩次作業的一個大坑,也是我在好幾個深夜裏不睡覺而被迫面對著電腦屏幕的罪惡源泉。雖然多線程極大地增強了用戶與程序交互的即時性,但是為了同時保證交互的即時性和邏輯正確性,編程者需要付出許多額外的努力和工作。
二、多線程電梯
電梯系列作業是讓我寫得很不爽的三次作業。第一次的傻瓜調度,我設計了一套我自認為十分精妙的判斷同質的算法,從而幾乎沒有阻力地無傷通過了公測和互測;但到了第二次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方法,即先計算出租車應該在什麽時候到達,然後再讓程序睡到那個時間。這樣做雖然有一點點耍賴,但確實很好地完成了指導書中的要求。
這次作業是系列作業,因此需要一開始就打好一個設計的基礎。但很可惜的是,我並沒有完成這個任務,因為在這次作業快要截止的時候,我發現自己的程序無法很好地處理同時有很多個請求一起輸入的情況。這個問題也在互測中被測我的大佬一下就挑了出來。究其原因,是因為我為每一個請求都開啟了一個線程,並讓其運行三秒鐘後自行終止,這雖然非常符合真實的邏輯,但卻不適用於程序本身。因為每一個請求線程都可能會改變出租車的狀態,因此需要為這個請求線程中涉及到變更出租車狀態的地方加鎖,一旦請求變多,達到百條的量級,就會使得線程之間互相阻塞,後面的請求得不到執行。此外,由於用戶可以自由輸入請求,所以實際上程序的線程數是由用戶控制的,這顯然是一種極不安全也極不合理的設計。在進行下一次出租車作業之前,我會想辦法解決這個問題,把線程數控制在一個自己可控的範圍內。
本次作業的經典OO度量如下:
這次作業的代碼量並不大,1473行是包含了GUI的統計,將GUI排除在外後,實際只有900行不到。但我仍然覺得程序在許多地方顯得過於臃腫,請求隊列類幾乎形同虛設,出租車線程設計得也不夠優雅。這些需要在重構的時候加以解決。
本次作業的類圖如下:
在類設計中,幾乎所有的數據操作都是圍繞TaxiSet類展開的。TaxiSet包含了所有出租車的信息,請求線程只能訪問到TaxiSet類,而不能直接對Taxi進行操作。這使得多個請求線程可以使用synchronized以保證不會出現數據沖突的情況。
本次作業的時序圖如下:
TaxiDispatcher出租車派遣類是整個程序執行流程的核心。TaxiDispatcher就是我所說的只會運行3秒鐘的線程,它會從請求隊列中提取請求,並通知乘客出發點周圍的出租車搶單,並最終決定調度哪一輛出租車為乘客服務。
五、Bug分析
我的程序在多線程電梯和出租車調度中各被報告了一個Bug,其中多線程電梯是由於忘記對某一塊輸入部分進行處理而導致的公測格式錯誤,出租車調度則是上文中提到的無法同時處理大量請求的錯誤。前者是由於粗心馬虎和測試不周全而導致的Bug,後者則純粹是由設計導致。值得註意的是出租車調度的Bug,它使我對程序內線程數量和程序性能的關系有了更深的理解。
多線程電梯的互測中,我找到別人的Bug主要集中在捎帶的判斷上。可能是由於模擬時間和真實時間的同步沒有做好,有些應該判斷為捎帶的地方對方並沒有判斷成功。我想這種問題很難從代碼層面直接挑出來,只有通過大量樣例的測試才能發現。文件監視器的互測中,我主要通過閱讀別人的代碼發現了Bug。對方沒有做好重命名時的多映射檢測,也沒有完成指導書中要求的繼續監控移動後文件的任務,這些Bug都可以在仔細閱讀代碼以後直接找到。更深層次的原因是我在寫程序的時候也遇到了這些問題,因此在互測的時候就會對它們格外關註。出租車調度的互測中,由於代碼量較大,且直接從代碼中找邏輯Bug相對困難,我采用了集中壓力測試的方法,即一開始就讓所有的出租車集中在地圖的左上角,然後集中輸入請求進行壓力測試。通過這樣的方法,一些隱蔽的Bug才能被發現。
多線程程序的代碼邏輯相比單線程程序復雜很多,有時候直接閱讀代碼也難以找到其中的漏洞。這個時候,測試樣例的廣度覆蓋和壓力測試的深度覆蓋就顯得很有必要了。此外,找到別人Bug的另一個好方法是回顧自己的設計過程,細數自己在寫代碼的時候踩過哪些坑,然後再去看別人是否犯了相同的錯誤。
六、心得與體會
很多同學都將多線程稱之為"玄學",我想這是有一定道理的。不同於單線程程序的完全可控,多線程程序在運行的過程中可能會出現許多難以預料的行為,甚至有些行為不可復現,但對程序卻有著致命的影響。編程者該做的,不應該是想著如何回避甚至掩蓋這些問題,而是應該努力地去暴露問題,並利用自己的智慧對其加以修復。
提高多線程程序的性能並不困難,保證多線程程序的線程安全也不困難,但要想同時做好這兩點,就變得非常困難。在這三次的作業中,我遇到的幾乎所有多線程問題都可以歸根結底為一句話:如何在性能和安全之間做出取舍。程序的時間需要和真實時間保持一致,這是對性能的要求,然而為了兼顧多線程的安全性,編程者可能需要采取一些同步控制的方法,這其中的時間差勢必會導致程序時間和真實時間的不同步。這三次作業中,我嘗試了一些解決這個問題的方法,最終發現,將模擬時間和真實時間結合起來,先計算出程序應該運行的時間,然後再讓它睡到那個時間,這種方式既省腦子,也省資源,還能確保程序運行的正確性。這可能是耍賴,但卻是一個好的策略,與其花費大量的時間去解決這些無關緊要的邊角問題,不如仔細設計程序的整體架構,為迎接下一次作業做準備。
面向對象第二單元訓練總結