BUAA OO 第二單元總結

BUAA OO 第二單元總結

Part 1 設計策略

這三次做業採用了主線程獲取請求,多級調度器逐級分派,電梯模擬運行的策略。具體來講,主線程實例化ElevatorInput類,經過阻塞讀取方式得到請求Request,以後將請求分配給調度器Scheduler,調度器負責處理請求(既能夠本身處理,也能夠分配給其餘子調度器處理),每個電梯與一個ElevatorScheduler綁定,用於具體的調度、睡眠控制、打印輸出。java

本次做業的難點主要有如下幾點:python

  • 如何控制調度器、電梯線程的終止:簡單的生產者-消費者模型中,生產者不斷生產,消費者不斷消費,不存在線程終止現象;現實中的電梯,一天24小時運行,沒有異常狀況也不會終止。可是更多的多線程問題是須要考慮線程終止的。這三次做業也是如此:主線程將全部請求都發送給調度器後,告知調度器準備結束,調度器處理完本身隊列中剩餘請求後,結束線程。git

    這種通常性的線程生命週期能夠用這個比喻來講明:假如你是一個員工,在上班的時候你可能在幹活,也可能閒着,但即便閒着,也不能回家;到了下班時間後,若是你的任務尚未完成,那就繼續工做(加班),若是完成了,就能夠回家了。因而,咱們能夠抽象出兩個因素:是否空閒(idle),是否到達下班時間(在電梯的問題中是輸入是否結束,可用inputEnd變量表示)。是否空閒是能夠本身判斷的,而是否到達下班時間則須要外界的通知。如下討論這種通知輸入結束機制。github

    一種比較容易想到的方法是採用interrupt機制算法

    while (true) {
        Request request = elevatorInput.nextRequest();
        if (request == null) {
            scheduler.interrupt();
        } else {
            // pass
        }
    }

    可是這種方法並不能實現精確控制:咱們但願的是,若是調度器在等待下一個輸入(wait()函數中),就打斷;而若是它在執行別的任務,好比sleep(100),或者是其餘的同步任務,就不打斷。雖然在這一單元的做業中沒有出現這種狀況,可是多作這種考慮也是合情合理的。編程

    另外一種方法是專門設定一個setInputEnd()方法,由主線程調用,告知調度器輸入結束。安全

    class Scheduler {
        boolean inputEnd = false;
        Queue<Request> requestQueue;
        public synchronized void setInputEnd() {
            this.inputEnd = true;
            notifyAll();
        }
        public synchronized void addRequest() {}
        public synchronized void getRequest() {
            while (requestQueue.isEmpty()) {
                wait();
            }
            return requestQueue.poll();
        }
    }

    我在做業中採用的是這種方法,可是後來發現,其實還能夠用一種更加簡潔的方法解決:創建一個TerminationRequest類。多線程

    interface Request {}
    class PersonRequest implements Request {}
    class ElevatorRequest implements Request {}
    class TerminationRequest implements Request {}

    這樣,經過歸一化處理,能夠用一個隊列來統一管理(更通常的來講,是把全部線程關心的狀態改變的通知都放到統一的容器中管理,對外用一樣的接口,對內採起不一樣的處理策略)。同時,這種歸一化也方便了線程安全容器的使用。架構

  • 區分兩種獲取請求的模式:在簡單的生產者-消費者模式中,消費者在沒有商品的狀況下老是會在共享區中的wait函數中等待,可是在實際生活的不少狀況下,這種狀況是不可接受的——消費者可能還有其餘事情要完成,使用wait函數等待並釋放CPU資源當然是一種進步,但這種方案同時也制約了消費者進行其餘活動的自由。回到本單元做業,每個電梯在行爲上都須要實現:獲取新的請求,決定電梯的行爲(開門、關門、上行、下行等)。可是這兩種行爲並非時時刻刻都須要進行的:若是電梯局部隊列爲空,電梯內部沒有乘客,則電梯處於空閒狀態,此時不須要頻繁決定電梯的行爲,只須要等待下一個請求的到來。所以,當電梯空閒時,應當阻塞地讀取請求,即在請求處等待下一個請求;而當電梯忙碌時,則只須要查看並更新請求便可,沒有新請求也不阻塞app

    這兩種不一樣的模式,能夠本身解決,如:

    class ElevatorScheduler {
        private Queue<Request> queue;
        private Queue<Request> localQueue;
        private synchronized Request getRequest(boolean quickRetreat) {
            if (quickRetreat) {
                return queue.poll(); // if the queue is empty, return null
            } else {
                while (queue.isEmpty()) {
                    wait();
                }
                return queue.poll();
            }
        }
    }

    也能夠採用Java內置的BlockingQueue來解決:

    private BlockingQueue<Request> queue;
    private void update() {
        if (idle) {
            localQueue.add(queue.take());
        }
        queue.drainTo(localQueue);
    }
  • 靈活的分配器:第一次做業只有一個電梯;第二次做業有多個電梯,但只有一種型號;第三次做業有不一樣型號的電梯,每一種電梯型號下的電梯數是不一樣的。能夠這樣認爲,第一次做業的電梯只須要一級調度器(直接指揮電梯的調度器),第二次做業的電梯是兩級調度(一級負責電梯見的負載均衡,另外一級負責直接指揮電梯),第三次做業的電梯是三級調度(一級總調度器負責換乘相關管理,一級負責同一個類型的電梯的負載均衡,一級負責直接指揮電梯)。以下圖:

    爲了使得分配更加靈活,給這些Scheduler設計一個統一的接口RequestReceiver便可,至於內部的處理,或分配或自行指揮電梯,請求提供者都沒必要關心。

    interface RequestReceiver {
        void addRequest (Request r);
    }
    class CentralScheduler implements RequestReceiver {}
    class Scheduler implements RequestReceiver {}
    class ElevatorScheduler implements RequestReceiver {}
  • 反饋和閉環控制:在實際多線程編程中,反饋和閉環控制也是十分常見的。本單元做業也不例外:換乘須要進行請求的反饋,即電梯運行一部分請求後,由另外一個電梯繼續完成另外一部分請求。既然電梯是逐級控制的,電梯處理完本身應該處理的那一部分請求後,須要將請求反饋給上級調度器,由上級調度器進行二次分配。另外一方面,調度算法在進行調度時,也須要考慮各電梯的負載均衡問題,於是電梯也要上報自身的負載狀況。

    這幾回做業中,能夠經過相應線程類提供反饋接口,進行逐級反饋狀態:

    interface FeedbackReceiver {
        void offerFeedback (Feedback fb);
        void offerRequestFeedback (Collection<PersonRequest> requests);
    }
    class CentralScheduler implements RequestReceiver, FeedbackReceiver {}
    class Scheduler implements RequestReceiver, FeedbackReceiver {}

    在反饋反向傳播的時候,每一級Scheduler也能夠對反饋進行處理,好比做業3中的負載,每一類電梯的負載能夠取這一類全部電梯中負載最小的電梯的負載。

  • 樓層映射:這個問題其實並無什麼面向對象的困難,主要是一個小技巧。每一個電梯調度器(直接指揮電梯進行運動的調度器,它實現了調度算法)有一個映射,實現樓層到樓層下標的快速轉換。

    與其經過數學方法實現(分段函數):

    int flr_to_ind (int flr) {
        if (/* some conditions */) {
            // do something
        } else if (/* ... */) {
            // do something
        } else {
            // pass
        }
    }

    不如用Java自帶的方法:

    List<Integer> flrs = Arrays.asList(-3, -2, -1, 1, 2, 3);
    index = flrs.indexOf(flr);
    flr = flrs.get(index);

Part 2 第三次做業的可擴展性

假如個人第三次做業真正實現了第一部分中所敘述的思想和方法,那麼再進行擴展也不會很複雜了。但事實上個人第三次做業並無徹底實現這些方法和技巧——程序的主題部分是第五次做業時構建的,以後只作了些小修小補。可是畢竟結構是相似的,也能夠作一些分析:

  • 實現緊急制動:從Request接口下增長一個緊急制動請求的實現,調度器將這一請求分派到對應電梯。電梯到達下一個停靠點時,經過反饋渠道反饋全部的未完成請求,由上層調度器二次分配,同時電梯線程結束運行。
  • 更復雜的電梯類型:構造電梯工廠,採用工廠模式,根據所提供的電梯型號生產相應電梯。在調度器方面,增長若干二級調度器,使每個電梯類型對應一個調度器。(固然若是類型增量不大,把這一調度器與主調度器合併也是可行的)
  • 更大的規模:增長調度級數,實現更細粒度的調度。

SOLID角度看:

  • Single Responsibility Principle:調度器總的說來比較符合這個原則,而電梯類符合度較低。在本人的設計中,電梯類既做爲一個容器,管理電梯內的乘客,同時又負責輸出、睡眠,並且請求的管理與ElevatorScheduler類的職責部分重疊,耦合太高。在一開始的設計中,電梯的職能被規定爲負責輸出和睡眠(由於這兩方面相對固定,能夠與易變的ElevatorScheduler分離,可是在以後的迭代開發中,逐漸職能擴充。
  • Open Close Principle:這三次做業在函數層面(即每一個類的方法層面)比較符合這一原則,將易變的類和不易變的類分開,迭代開發時主要替換一些函數,沒必要大規模修改函數。但在類的層次對這一原則符合度較低。在最初的設計中,我本是打算每個主要的類先寫成抽象類,再經過繼承抽象類進行實現,但最後感受不太現實,就沒有真正實施,而是直接把抽象類改爲具體類……[捂臉](也許小型工程不太容易作到OCP吧,畢竟就那麼幾個類)
  • Liskov Substitution Principle:這三次做業關係比較少,可是基本上全部存在的繼承關係都知足LSP原則了。
  • Interface Segregation Principle:其實第三次做業並無用到接口,可是若是按照第一部分的分析,每個調度器都實現RequestReceiverFeedBackReceiverRunnable方法,也算是有一點ISP的意思了。
  • Dependency Inversion Principle:畢竟沒有接口,繼承關係也比較少,第三次做業的具體實現其實沒有體現這一原則,不過若是按照第一部分的分析,main函數線程只依賴RequestReceiver接口,也有一點DIP的意思了(雖然沒有實現)。

Part 3 經典度量

考慮到三次做業結構一脈相承,每次迭代又沒有什麼重大改動,就只分析最後一次做業了。

UML圖

這裏只實現了二級分派結構,其中PersonTransfer是課程組提供的PersonRequest類的子類,表示須要換乘的乘客請求。二級分派結構能夠解決這三次做業的問題,main函數獲取請求,再由高級調度器分派給低級調度器,低級調度器與電梯類協做,實現look電梯調度算法。在算法的實現過程當中,須要管理樓層信息、管理用戶請求信息,這些管理由building類和floor類處理,同時設置FloorNumberManager類,提供一些靜態方法管理樓層映射、可達性查詢等服務。

這種結構的主要問題是沒有處理好電梯類Elevator和電梯調度器類ElevatorScheduler之間的關係。電梯調度器類只擁有一個電梯,負責這個電梯更細緻的調度管理,如每個時間節點,決定電梯上行、下行、開門、關門等動做,主要實現了算法。但同時,電梯類不只負責輸出、睡眠,還負責管理電梯內部人員,檢查到達目的地的乘客,反饋電梯內部乘客信息等。在具體實現中,電梯類又將自身容器暴露給電梯管理類,使得兩個類之間耦合度較高。

此外,電梯類Elevator並無成爲一個獨立的線程,因此在電梯睡眠時,實際上時是在ElevatorScheduler線程中睡眠,致使電梯睡眠和電梯調度算法運行沒法並行,下降效率。

複雜度

能夠看出,調度器是比較複雜的類,而調度器中負責算法的方法又是調度器中比較複雜的方法。可是除了調度器以外,電梯類也比較複雜,這是與設計初衷不符的。緣由在上文也提到過,主要是隨着代碼實現的推動,電梯類的職能不斷擴充,與調度類有所交疊,沒有很好處理這一問題。

協做圖

Part 4 Bug分析

這一單元的做業主要容易出bug的地方包括:

  • 程序死鎖,尤爲是輸出終止,準備線程結束的時候。
  • 僞多線程,主要是指程序實質上是在輪詢,沒有很好實現資源讓出。
  • 需求理解誤差。

我在三次做業的強測和互測中均沒有發現bug,可是個人第一次做業(整個課程的第五次做業)有一個很是嚴重的錯誤(強測和互測,因爲測試機制固定,都沒有檢測出來):若是全部輸入結束後沒有馬上提供輸入結束信號,程序將會進入死鎖,沒法終止。部分代碼以下:

// methods of ElevatorScheduler
public synchronized void setInputEnd() {
    this.inputEnd = true;
    notifyAll();
}
private synchronized void update(boolean quickRetreat) {
    if ( (inputEnd || quickRetreat) && buffer.isEmpty() ) {
        return;
    }
    while (buffer.isEmpty()) {
        try {
            wait();
            /* when the thread is notified, it's still in the while loop */
        } catch (InterruptedException e) {
            return;
        }
    }
    // some updates here
}

可見,雖然程序退出了wait()函數,但仍是會再次進入wait()函數,致使死鎖。一個簡單的修復是在while循環上增長一個條件;設計上的修復我在第一部分已經提到過,線程檢測到TerminationRequest後就再也不調用update函數,把這個問題在線程內部解決

另外一個問題是沒有處理好CPU資源的讓出,表如今輪詢所致使的CPU_TLE。通常來講,線程空閒時須要等待,可以使用wait()函數,一旦線程須要被喚醒,相應鎖的notifyAll()函數必須被調用。我在第一次電梯做業的互測中用相似如下數據的數據點發現了兩個A屋的solid bug:

[5.0]1-FROM-2-TO-3
[150.0]2-FROM-14-TO-5

Part 5 測試程序分析

本單元測試程序主要考慮本身的使用,包括三部分:

  1. 輸入文件的時間映射器,將帶時間的文件輸入映射到時間軸上,實現定時輸入。具體代碼見:https://github.com/YushengZhao/BUAA_OO_elevator_input_mapper

  2. 電梯仿真器,模擬電梯的真實運行,在運行過程當中檢查相關問題。主要思路是:將電梯請求和被測程序輸出轉化成若干電梯指令,按照時間排序,在仿真器上模擬運行,在運行過程當中記錄參數、檢查行爲,最終能夠給出性能報告。

  3. 請求指令生成器,能夠定製若干段請求序列,每一段能夠設置參數,可參考如下代碼:

    def generator(size=10, timescale=10, id_start=1, time_shift=1):
        # generate one segment of requests
        pass
    
    def generate():
        periods	 = 	[12,19,8,4,13]
        sizes	 = 	[8,4,10,13,16]
        s = []
        id = 1
        time_shift = 0.3
        for i, period in enumerate(periods):
            s += generator(size=sizes[i],timescale=period,
                           id_start=id,time_shift=time_shift)
            id += (sizes[i]+1)
            time_shift += (period+0.1)

將這些組件鏈接起來就能夠生成測評機了,可是考慮到自身實際需求,就沒有具體實現了。

一個值得注意的地方:許多同窗採用python腳本進行時間映射,我在參考了以前一些學長的博客以後發現,這種方法容易產生時間誤差,時間控制不是很精確,而將時間映射器內嵌到Java語言內部則能夠實現更精確的控制。同時,這樣也便於調試。

關於請求生成策略:實際應用中可能會出現不一樣時段負載不一樣的狀況,我在測評機中按段生成請求,能夠模擬這種狀況。在進行幾回到幾十次測試後(總請求量1e2量級),通常沒有什麼顯著問題;進行大量測試(1e3,1e4量級的測試)也許能夠發現一些問題,但考慮到每個測試所消耗的時間成本,就沒有過多測試了。真正實際應用中,大量的測試確定是必要的。

這一單元的調試和測試與上一單元相比,主要是多了時間因素,在測試時要考慮輸入隨時間分佈的不一樣特徵,如在一個時間點大量輸入,在一段時間內沒有輸入,等等。而在調試時,因爲不能使用斷點調試法,我廣泛採用了日誌記錄的方法,增長一個可插拔的logger:

private static final boolean LOGGER = true;
private static final boolean ASSERTION = true;
public static void log(String msg) {
    if (LOGGER) {
        System.out.println(msg);
    }
}
public static void ast(boolean condition, String msg) { // ast == assert
    if (ASSERTION && !condition) {
        System.out.println("Assertion failed: "+msg);
    }
}

或者實現一個帶level的不一樣重要性的日誌輸出:

private static final int LEVEL = 5;
public static void log(int level, String msg) {
    if (level >= LEVEL) {
        System.out.println(msg);
    }
}

固然,Java也有相應自帶的日誌記錄機制,不過考慮到這一單元做業並不須要複雜的日誌,就沒有使用了。

Part 6 心得體會

  1. 多線程方面,我在作電梯第一次做業時花了比較長的時間(甚至一度覺得本身就要止步於此了),當時有許多問題想不清楚,最後實現的代碼也有許多邏輯混亂的地方。多線程之因此容易出現各類安全問題,歸根結底仍是線程自身行爲邏輯複雜,好比,簡單的生產者-消費者模型,基本不會有人寫出線程安全的問題,可是複雜一些的生產者-消費者模型(如消費者在沒有產品時不調用wait函數,而是進行其餘活動),就容易產生線程安全問題了。所以,根據需求創建簡單通用的線程模型很是重要——簡單的邏輯每每不容易出錯。好比觀察者模式,構建了一個線程發佈消息,若干線程接收消息的模型;反過來,又有時也須要若干線程發送消息,某一個線程接收消息的狀況,這時即可以採用消息隊列:

    class Scheduler {
        private Queue<Message> messageQueue;
        public synchronized void addMsg() {/*some code here*/}
        public synchronized Message getMsg() {/*some code here*/}
    }
    interface Message {}
    class Feedback implements Message {}
    interface Request extends Message {}

    全部須要通知Scheduler的消息,都經過addMsg方法傳入,不管其具體內容,以後再由其餘函數分別處理。所以,當Scheduler處理完剩餘任務後,即可以直接在getMsg方法內等待。

  2. 設計原則方面,我在完成第一次電梯做業時,並無太多的設計原則方面的意識,而因爲後兩次做業都沿用第一次做業的結構,全部後兩次做業也沒有體現多少設計原則,這是遺憾的。

  3. 代碼重構方面,我從這一單元開始堅決了能不重構就不重構的態度。從計組到如今,在各類須要迭代開發的工程中,我老是感受以前寫的不夠好,想要重構,但又常常忽視了重構的風險以及不重構的可能性。實際開發中,重構確定是要儘可能避免的,在原來的代碼(多是看似比較亂的代碼)上修改其實才是常態。事實上,看似亂糟糟的代碼其實可能並無想象中那麼差。我在第二次電梯做業時曾經嘗試了重構,可是等到真正動手寫重構代碼的時候才發現,若是重構,不少代碼都是差很少的,原來不少設計上的考慮都是頗有道理的。

  4. 算法方面,這一單元的做業給算法留出了很大的空間。不過想拿高分並不須要很複雜的算法。最基本的look算法,在強測不出現錯誤的狀況下,基本就能夠達到95分以上了。後兩次做業再加一些負載均衡的考慮,若是沒有錯誤,基本也能拿到95分以上。儘管如此,討論一些算法也是沒有壞處的:

    • 將電梯的打印輸出/睡眠與電梯運行邏輯解耦。這樣作的目的是,能夠在不真正運行電梯的前提下(運行一個虛擬電梯),估計電梯的總運行時間,進一步地,加入一個請求後的總運行時間。理論上,這樣老是能夠作到局部最優規劃。並且這種估計是不依賴於特定算法的,靈活性比較強。
    • 用馬爾可夫決策過程建模。這樣作是考慮到如今有許多針對馬爾可夫決策過程(Markov Decision Process)的算法可供使用。

    固然這些算法都是我沒有實現的,畢竟課程的主要目的也不是算法。


其餘:

  1. 有些問題其實並非OOP的問題,有些bug其實也不是由於線程安全。好比look算法,我就花了很多時間實現、調試。
  2. 不少時候模仿現實世界的實體和關係也是一種很好的方法。好比,給電梯安排一個Building,給每個Building安排若干Floor,並提供「向上的按鈕」和「向下的按鈕」接口,就像現實生活中的電梯同樣。電梯不知道一個樓層有多少人,只知道某一樓層有沒有人想上樓、有沒有人想下樓。
  3. 第二點的想法雖然對咱們寫做業頗有幫助,可是這是否是就是在參考真正給電梯寫程序的編程人員的代碼架構呢?其實課程的者幾回做業,我都是或多或少參考了前一屆同窗的博客,假如沒有這種參考,我又能寫出什麼樣的結構呢?
相關文章
相關標籤/搜索