北航OO第二單元(多執行緒實時電梯系統)總結
本單元的總體任務是維護一個ABCDE共5樓座、10層的目標選擇電梯系統。需要接受乘客的請求和增加電梯的請求,並執行相應電梯將乘客送達目的地。輸出所有的電梯執行以及上下人行為。
一、同步塊和鎖
第五次作業中只有豎向電梯,每座各有一個,均可達1-10層,乘客請求出發地和目的地樓座一定相同。
1、第五次作業
由於只有五部電梯,並且所有請求對應的電梯是明確的,所以沒有設定全域性排程器,而是選擇由輸入執行緒與所有電梯共享一個候乘表物件,電梯直接從候乘表中取出自己對應的請求。為了簡化其他類的設計,這裡直接將候乘表設定為執行緒安全類,同步塊具體程式碼如下:
public synchronized void addRequest(PersonRequest request) {
char fromBuilding = request.getFromBuilding();
this.requests.get(fromBuilding).add(request);
notifyAll();
}
public synchronized PersonRequest getMainRequest(char nowBuilding) {
while (requests.get(nowBuilding).isEmpty() && (!close)) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
if (requests.get(nowBuilding).isEmpty()) {
return null;
}
return requests.get(nowBuilding).get(0);
}
public synchronized ArrayList<PersonRequest> getForm(char nowBuilding) {
return requests.get(nowBuilding);
}
public synchronized PersonRequest getNowFloor(char nowBuilding, int nowFloor) {
ArrayList<PersonRequest> list = requests.get(nowBuilding);
for (PersonRequest personRequest : list) {
if (personRequest.getFromFloor() == nowFloor) {
list.remove(personRequest);
return personRequest;
}
}
return null;
}
public synchronized void close() {
close = true;
notifyAll();
}
public synchronized boolean checkOver(char nowBuilding) {
if (!close) {
return false;
} else {
return requests.get(nowBuilding).isEmpty();
}
}
public synchronized void output(String in) {
TimableOutput.println(in);
}
可以看出,新增請求、電梯空時獲取請求getMainRequest,策略類獲取當前樓座候乘表getForm,電梯上人getNowFloor,輸入結束close,檢查是否結束執行緒的checkOver和輸出output都在同步塊內。在某電梯執行緒或輸入執行緒訪問時均會阻塞其他執行緒的訪問,以此保證執行緒安全。特別的,當電梯無人且主請求為空時,會執行wait()等待,輸入執行緒每當進行輸入之後會執行notifyAll()喚醒所有空置等待的電梯,以此可以避免電梯空時輪詢佔用CPU資源的問題。
PS:現在第二單元結束之後需再看這段程式碼其實有一些問題,也有可以優化的地方。
- 首先是getForm很有問題,它直接將該共享物件的一部分的指標(?)暴露給了同步塊之外的部分,電梯的策略類使用的時候如果輸入執行緒對其進行了修改,很容易出現RunTimeError,不過強測居然全都過了現在想想也是幸運。
- 之後是方法冗餘的問題,checkOver函式完全可以綜合進getMainRequest中,比如使用null作為返回值標記執行緒結束,而不需要單獨寫一個方法。
2、第六次作業
第六次作業在第五次作業的基礎上增加了橫向電梯,橫向電梯可以到達相應層的所有座。乘客請求在第五次作業的基礎上可以發出出發和目的地相同層不同座的請求,且請求保證在該層新增電梯1s後(保證了電梯一定先被完成新增)。由於出現了新增電梯的請求,在輸入執行緒設定了了兩個共享物件,一個乘客請求總佇列ScheduleQueue負責儲存所有輸入的乘客請求,一個電梯增加請求佇列ElevatorAddingQueue負責儲存所有新增電梯的請求。同時每樓座的縱向電梯和每層的橫向電梯共享一個候乘表,由排程器進行分配。與上一次思路相同,為了減輕執行緒類的思維量,這裡直接將所有的共享物件設定為執行緒安全類。
ScheduleQueue同步塊程式碼如下
public synchronized void addRequest(PersonRequest personRequest) {
waitingRequests.add(personRequest);
notifyAll();
}
public synchronized PersonRequest getRequest() {
while (waitingRequests.isEmpty()) {
if (end) {
return null;
}
try {
wait();
} catch (Exception e) {
e.printStackTrace();
}
}
return waitingRequests.remove(0);
}
public synchronized void setEnd() {
end = true;
notifyAll();
}
與第五次作業的邏輯十分相同,只是作為總表僅有新增請求、獲取請求和設定結束三個情況(讀取結束在獲取請求方法裡一併進行)。
ElevatorAddingQueue與上文程式碼幾乎完全一致,不再贅述。
RequestQueue程式碼如下(沒有再展示新增請求和設定結束方法):
public synchronized PersonRequest getRequestNowFloor(int nowFloor, int status) {
if (status >= 0) {
for (PersonRequest nowRequest : waitingRequests) {
if (nowRequest.getFromFloor() == nowFloor) {
if (nowRequest.getFromFloor() - nowRequest.getToFloor() < 0) {
waitingRequests.remove(nowRequest);
return nowRequest;
}
}
}
}
if (status <= 0) {
for (PersonRequest nowRequest : waitingRequests) {
if (nowRequest.getFromFloor() == nowFloor) {
if (nowRequest.getFromFloor() - nowRequest.getToFloor() > 0) {
waitingRequests.remove(nowRequest);
return nowRequest;
}
}
}
}
return null;
}
public synchronized PersonRequest getRequestNowBuilding(char nowBuilding) {
for (PersonRequest request : waitingRequests) {
if (request.getFromBuilding() == nowBuilding) {
waitingRequests.remove(request);
return request;
}
}
return null;
}
public synchronized boolean lookRequestNowBuilding(char nowBuilding) {
for (PersonRequest request : waitingRequests) {
if (request.getFromBuilding() == nowBuilding) {
//waitingRequests.remove(request);
return true;
}
}
return false;
}
public synchronized PersonRequest getRandomRequest() {
while (waitingRequests.isEmpty()) {
if (end) {
return null;
}
try {
wait();
} catch (Exception e) {
e.printStackTrace();
}
}
//System.out.println(waitingRequests.get(0));
return waitingRequests.get(0);
}
public synchronized boolean lookRequestNowFloor(int nowFloor, int status) {
//System.out.println(waitingRequests);
if (status >= 0) {
for (PersonRequest nowRequest : waitingRequests) {
if (nowRequest.getFromFloor() == nowFloor) {
if (nowRequest.getFromFloor() - nowRequest.getToFloor() < 0) {
return true;
}
}
}
}
if (status <= 0) {
for (PersonRequest nowRequest : waitingRequests) {
if (nowRequest.getFromFloor() == nowFloor) {
if (nowRequest.getFromFloor() - nowRequest.getToFloor() > 0) {
return true;
}
}
}
}
//System.out.println("false at look");
return false;
}
public synchronized int look(int status, int nowFloor, int id) { //-1表示結束,0表示原方向繼續,1表示轉向
//System.out.println("begin look"+id);
while (waitingRequests.isEmpty()) {
if (end) {
return -1;
}
try {
wait();
} catch (Exception e) {
e.printStackTrace();
}
}
for (PersonRequest personRequest : waitingRequests) {
//System.out.println(""+(personRequest.getFromFloor()-nowFloor>0)+status);
if ((personRequest.getFromFloor() - nowFloor > 0 && status == 1)
|| (personRequest.getFromFloor() - nowFloor < 0 && status == -1)) {
//System.out.println("panic at 0");
return 0;
}
}
//System.out.println("panic at 1");
return 1;
}
可以看出,電梯內部的策略方法被直接整合在了相應的請求佇列中,同時所有邏輯加鎖,防止某兩個同座/樓層電梯同時獲取請求的執行緒不安全導致一個人“分身”的情況。
3、第七次作業
第七次作業在第六次的基礎上增加了可定製執行速度和限乘人數的電梯,橫向電梯增加了可定製的可達樓座,並且需要支援需要換成的乘客請求。本次作業就是在排程器邏輯上做了擴充套件,共享物件和第六次作業幾乎沒有變化,只有增加了一個增加電梯執行緒和排程器執行緒之間的共享物件用於儲存已有電梯資訊方便排程器進行路徑規劃,同樣封裝成一個執行緒安全類。
二、排程器設計
1、第五次作業
本次作業本身考慮一開始對多執行緒的互動不熟,也沒有電梯的增加並且每個請求只能由一步電梯處理,故沒有使用排程器,而是直接由輸入執行緒將請求扔進總表,電梯直接從總表獲取自己想要的。
現在想想這其實是不太好的設計,直接讓總表作為所有電梯的共享物件,就導致了只要任何一個電梯使用共享物件,都會阻塞其他電梯對共享物件的讀取和使用。這使得不同的電梯雖然沒有任何協同,卻會阻礙彼此的執行。再一個,因為這一個表要儲存所有種類的請求,導致使用了一個大Hashmap的複雜結構,這讓這個類的方法理解上的複雜度升高,在寫電梯自身的排程演算法的時候增加了很多困難。
因為剛開始接觸多執行緒,又使用了這麼複雜的一個候乘表,導致我寫本次作業時想不出如何設定一個請求為主請求,但是在接到主請求之前不刪除候乘表類中的該請求。於是我在電梯內部的排程演算法沒有使用ALS,而是使用了相對簡單粗暴的一個邏輯,完全電梯內部優先:如果電梯內部有人,那麼電梯的方向就是其中距離最近的人要去的樓層方向;如果電梯內部無人,那麼電梯的方向就是候乘表中任何一個請求的方向;每當電梯到達一個樓層之後,就會在容載量的前提下讓所有人上電梯(不管請求方向)。這個排程策略的優勢就是簡單,而且會在一些特殊情況下超過ALS策略,但整體還是較慢。不過最終還是以正確性優先,於是在做了簡單的時間測試發現除了在極端情況下,該策略不會比ALS慢很多的情況下進行了提交。
2、第六次作業
本次作業考慮到不涉及換乘問題,平均分配或是隨機分配都不太能適配所有較優的情況,故最後選擇了自由競爭的方法。自由競爭方法是所有同層/同座電梯共享一個請求等待佇列,並且因為本次作業沒有換乘,排程器的需要做的事只有從未分配請求佇列中獲取請求並存入對應層/座的佇列更好的。
縱向電梯內部排程器採取look演算法,與往年的look演算法完全一致。橫向電梯採用了第五次作業的排程策略,即內部優先最近策略。因為橫向電梯只有5層且可以迴圈,所以內部優先策略在一般情況下比起橫向電梯一直向一個方向轉是有優勢的。兩種方法都用隨機生成資料測試了之後發現確實內部優先策略好的次數高,就最終使用了該策略。
3、第七次作業
本次作業需要考慮多次換乘的問題,為此我新增了MyPersonRequest類,該類繼承PersonRequest類,在新的類中新增了nowBuilding,nowFloor表示人目前所在位置,goToBuilding,goToFloor表示下一段乘坐電梯去的地方。排程器負責讀取待分配佇列scheduleQueue中的請求,根據他們的nowBuilding,nowFloor,與toBuilding,toFloor規劃路徑,將下一段需要乘坐電梯到達的位置填入goToBuilding,goToFloor,並將該請求放入對應的佇列RequestQueue中。每當人出電梯的時候,判斷當前位置與最終目的位置是否一致,如果不一致就再扔回待分配佇列中,等待下一次排程器的分配。如此實現了請求分段的最優規劃。為了方便排程器的規劃,在排程器和電梯新增佇列中新增了共享物件,一個三維陣列path[11][6][6],path[i][j][k]==1表示在i層有可以從j座直接到達k座的電梯。每當新增橫向電梯時更新該陣列,排程器傳入當前請求資訊並使用該物件的方法獲取下一路徑點。
電梯的內部排程器與第六次作業完全一致,只是將所有的fromFloor,fromBuilding,toFloor,toBuilding換成了相應的nowFloor,nowBuilding,goToFloor,goToBuilding。且橫向電梯在檢視外部請求時完全忽略自己不能運送到的請求。
三、架構模式
1、架構的迭代變化
hw5 使用一級托盤結構,所有電梯共享一個候乘表
本次作業架構相對簡單,輸入執行緒為Producer,直接將請求放入候乘表,電梯也直接從中讀取。所有執行緒都由主執行緒啟動。
hw6 實現了二級托盤結構,同時有了一個排程器雛形
本次作業的第一級托盤為ElevatorAddingQueue和ScheduleQueue,分別儲存電梯新增請求和乘客請求;第二級托盤為每層/座的請求佇列RequestQueue,電梯可以直接從佇列中讀取/移除請求。排程器僅僅負責把主佇列的請求分類放入相對應的二級請求佇列,具體電梯的請求取決於自由競爭的結果。
相較第五次作業,本次最特別的是可以動態增加電梯。為了實現這個能力,並且方便排程器監控電梯分佈情況,選擇了在排程器類的構造法方法中生成負責新增電梯的類ProcessElevator。為了增加可擴充套件性和電梯的一致性,不再在主執行緒中初始化電梯,而是在ProcessElevator類中新增init()方法來增加初始電梯,並建立二級請求佇列和電梯的對映。
hw7 通過排程器實現了乘客當前路徑的動態規劃
可以看出相比第六次作業,增加了MyPersonRequest類擴充套件了原請求類,並增加了一個pathForm作為排程器和電梯增加佇列的共享物件。這個共享物件的getPathToFloor方法會被排程器呼叫,並返回一個當前要去的樓層。排程器接收這個樓層,改寫請求當前段要去的座標並置入相應佇列。
本次作業具有一定的可擴充套件性,電梯獲取請求的策略被整合在了請求佇列類中的方法,只需要修改對應方法即可更改電梯的內部策略。同時,排程器的設計使得每個電梯可以專注於該請求這一段的運輸而不需要考慮別的,修改排程器的方法即可達到更多次換乘或是更改規劃邏輯的需求。
2、UML協作圖
主要分為四個部分:
- 主執行緒啟動輸入執行緒和排程器執行緒;排程器執行緒建立電梯新增執行緒,初始化電梯,啟動執行緒。
- 輸入執行緒將電梯請求放入電梯佇列中,喚醒正在等待的電梯新增執行緒;電梯新增執行緒新增、啟動一個電梯執行緒,並修改電梯表將新增電梯資訊傳輸給排程器。
- 輸入執行緒將乘客請求放入總表,喚醒正在等待的排程器;排程器獲取請求、根據電梯資訊規劃下一步路徑,將該請求放入電梯候乘表,喚醒等待的電梯執行緒;電梯執行緒處理該請求,到達後比對請求是否到達最終目的地,若未抵達目的地,請求重新放回總表,若抵達目的地,總表完成執行緒數+1。
- 輸入執行緒結束,將總表、電梯新增佇列的輸入結束置為真並結束執行緒;總表完成執行緒數等於匯流排程數時檢測是否輸入結束,如果輸入結束,將完全結束置真;電梯新增執行緒獲取電梯輸入佇列的輸入結束,如果為真則結束執行緒;排程器獲取總表的完全結束資訊,如果為真則將所有電梯候乘表的結束置為真,並結束執行緒;電梯在內部沒有請求時檢索候乘表,如果候乘表沒有請求且結束為真,則結束執行緒。
四、程式bug分析
因為hw5作業運氣好,然後後兩次作業自己使用了隨機資料評測機,所以三次作業的強測和互測我都沒有出bug。所以就主要介紹一下自己本地測試時發現的bug。
PS:自己的評測資料可以寫的強一些,比如每個時間一下子生成快一百個,然後一共特別多的電梯和特別多的請求一起發出,相當於對自己的程式做了個壓力測試,debug效率特別高()
hw5的bug主要出現線上程結束和執行緒不安全上。首先是因為在一開始的電梯策略類呼叫的候乘表方法裡面有2個wait()的位置,導致判定執行緒結束時可能一個notifyAll無法成功喚醒正在等待請求的電梯執行緒,後將檢測結束和策略合併就完成了修復。再是執行緒不安全問題,因為我直接將候乘表封裝成了一個執行緒安全類,所以沒有在這裡出現,而是因為輸出包的執行緒不安全而產生了時間不遞增問題。這個問題多虧了室友的分享,後來將輸出包封裝成了一個執行緒安全類,讓每個執行緒去呼叫之後解決了問題,體現了開發中交流的重要性hhhhhh。
hw6的首個bug在縱向電梯的look演算法,因為一開始設定的是除非拿到反向請求且沒有當前方向請求才轉向,導致了在沒有請求時一飛沖天的問題。第二個bug非常隱蔽,是一個RTLE的bug,甚至當時沒有找出來,而是誤認成了有執行緒沒有正常結束的問題。具體bug是橫向電梯的排程問題,下面看一塊程式碼
int near = requestsIn.get(0).getToBuilding();
for (PersonRequest request : requestsIn) { //找到最近的
if ((request.getToFloor() - nowBuilding + 5) % 5 < (nowBuilding - near + 5) % 5) {
near = request.getToBuilding();
}
}
這是橫向電梯裡找內部最近的請求的演算法,乍一看可能沒有什麼問題。但是注意這裡(request.getToFloor() - nowBuilding + 5) % 5實際上求到的並不是他的迴圈距離,而是當前位置到要去位置的某個方向的距離,比如當前在A,要去E,則會得到4,如果當前在E,要去A,則會得到1。而且最關鍵的是,後面與之比較的(nowBuilding - near + 5) % 5的當前位置和要去位置的加減號時反的,這導致了我實際上在拿順時針運動的距離與逆時針運動的距離進行比較,如此導致了橫向電梯在內部有特定順序加入的請求的時候左右橫跳(因為會出現比如說有去A和E的請求,可能會出現在B認為E近所以去C,然後再C認為A近所以去B的簡諧運動)就是到達不了。這個bug在hw7的時候才找出來,之前還以為是排程器的問題而刪掉了隨機分配改成了自由競爭,真是可憐了排程器。也是幸好第六次的強測剛好沒有卡到這個bug。
PS:感覺第六次本地沒測出bug是因為資料生成器採用等概率的隨機生成的問題,導致橫向請求不夠多也不夠集中,就基本沒能觸發這個bug、
hw7的一開始有執行緒提前結束的bug,這個原因是繼承了hw6的架構之後,輸入執行緒結束的同時總表就被setEnd並且排程器和對應的沒有請求的電梯也隨之結束。但是有一些在電梯內部的請求仍需要換乘,所以排程器此時結束之後這些請求就被扔在了半路上。debug方法是在總表中加入了統計總請求數和當前完成請求數,只有輸入結束並且完成請求數和總請求數相等才會關閉排程器和沒有請求的電梯。第二個bug就是上文hw6的bug,這裡本地測出來了我猜測是因為當時以為是多段排程的問題所以集中生成了所有請求都要換乘橫向電梯的資料,故橫向電梯被壓力測試了,就顯示了出來排程問題。debug就是把這裡的near換成真正的最近
int near = requestsIn.get(0).getGoToBuilding();
for (MyPersonRequest request : requestsIn) { //找到最近的
if (Math.min((request.getGoToBuilding() - nowBuilding + 5) % 5,
(nowBuilding - request.getGoToBuilding() + 5) % 5) <
Math.min((nowBuilding - near + 5) % 5,
(near - nowBuilding + 5) % 5)) {
near = request.getGoToBuilding();
}
}
五、hack策略
1、測試策略與有效性
hw5我理解的主要問題就是執行緒不安全的問題,這裡的測試策略就是生成時間非常集中,且排程了所有執行緒的資料。比如說在70s在每個座都一次性發出14個從10層去1層的請求,成功在第一次互測hack掉了兩個執行緒不安全的程式,又意外之喜幹掉了一個人員超載的。hw6之後因為程式碼實在太長,手造資料也非常費時間,就使用了評測機,與對自己程式進行壓力測試不同,在互測時儘量生成滿足互測要求的資料(不然本地幹掉一個人還要改很久資料才能提交也太糟心x),但是依然保證請求的發出時間相對集中,可以順帶測試執行緒不安全的問題。hack掉了一個橫向電梯排程有問題的。hw7同樣使用評測機,而且發出的100%的資料都跨樓層,成功幹掉三次換乘出問題的兩人。
2、執行緒安全問題的hack
我的理解就是同時喚醒執行所有執行緒且啟用幾乎所有執行緒通訊的資料可以最大機率幹掉執行緒安全問題,比如同一時段發出新增電梯的請求和需要喚醒所有已存在電梯的請求。
3、與第一單元的差異性
測試的差異性主要體現在輸入資料的集中構造,這裡的很多bug是需要靠資料量堆起來才能反映出來的。
六、心得體會
1、執行緒安全
本章我體會到的保證執行緒安全最好的方法我個人認為是構造執行緒安全類,這樣可以直接保證訪問時的執行緒安全,可以避免在每個執行緒設計呼叫方法時再考慮是否會出現安全問題和上鎖的情況,既能少死點腦細胞、又能最大程度上防止疏忽導致的執行緒安全問題。
2、層次化設計
對於第一單元偷懶將所有因子的解析賦給一個類,最後只用了4個類就完成的我,這一單元的12個類屬實讓我體會到了層次化設計的優勢和重要性。這單元中除了hw6對電梯策略進行了修改外,其餘部分都實現了迭代開發。可以說在明確了每個類負責的功能之後,對12個類的互動設計也就水到渠成,這可能就是層次化設計的美妙所在。其實在封裝策略類介面上我並沒有做到,而是將策略作為了請求佇列的方法,可以依靠註釋來切換,還有可以改進的空間。