Hadoop 的 Server 及其執行緒模型分析

foreach_break發表於2015-07-26

一、Listener

Listener執行緒,當Server處於執行狀態時,其負責監聽來自客戶端的連線,並使用Select模式處理Accept事件。

同時,它開啟了一個空閒連線(Idle Connection)處理例程,如果有過期的空閒連線,就關閉。這個例程通過一個計時器來實現。

這裡寫圖片描述

當select操作呼叫時,它可能會阻塞,這給了其它執行緒執行的機會。當有accept事件發生,它就會被喚醒以處理全部的事件,處理事件是進行一個doAccept的呼叫。

doAccept:

void doAccept(SelectionKey key) throws InterruptedException, IOException,  OutOfMemoryError {
      ServerSocketChannel server = (ServerSocketChannel) key.channel();
      SocketChannel channel;
      while ((channel = server.accept()) != null) {

        channel.configureBlocking(false);
        channel.socket().setTcpNoDelay(tcpNoDelay);
        channel.socket().setKeepAlive(true);

        Reader reader = getReader();
        Connection c = connectionManager.register(channel);
        key.attach(c);  // so closeCurrentConnection can get the object
        reader.addConnection(c);
      }
    }

由於多個連線可能同時發起申請,所以這裡採用了while迴圈處理。

這裡最關鍵的是設定了新建立的socket為非阻塞,這一點是基於效能的考慮,非阻塞的方式儘可能的讀取socket接收緩衝區中的資料,這一點保證了將來會呼叫這個socket進行接收的Reader和進行傳送的Responder執行緒不會因為傳送和接收而阻塞,如果整個通訊過程都比較繁忙,那麼Reader和Responder執行緒的就可以儘量不阻塞在I/O上,這樣可以顯著減少執行緒上下文切換的次數,提高cpu的利用率。

最後,獲取了一個Reader,將此連線加入Reader的緩衝佇列,同時讓連線管理器監視並管理這個連線的生存期。

獲取Reader的方式如下:

//最簡單的負載均衡
    Reader getReader() {
      currentReader = (currentReader + 1) % readers.length;
      return readers[currentReader];
    }

二、Reader

當一個新建立的連線被加入Reader的緩衝佇列pendingConnections之後,Reader也被喚醒,以處理此連線上的資料接收。

public void addConnection(Connection conn) throws InterruptedException {
        pendingConnections.put(conn);
        readSelector.wakeup();
      }

Server中配置了多個Reader執行緒,顯然是為了提高併發服務連線的能力。

下面是Reader的主要邏輯:

while(true) {
		...
	   //取出一個連線,可能阻塞
       Connection conn = pendingConnections.take();
       //向select註冊一個讀事件
       conn.channel.register(readSelector, SelectionKey.OP_READ, conn);
       ...
       //進行select,可能阻塞
       readSelector.select();
       ...
       //依次讀取資料
       for(keys){
			doRead(key);
	   }
	   ...
}

當Server還在執行時,Reader執行緒儘可能多地處理緩衝佇列中的連線,註冊每一個連線的READ事件,採用select模式來獲取連線上有資料接收的通知。當有資料需要接收時,它盡最大可能讀取select返回的連線上的資料,以防止Listener執行緒因為沒有執行時間而發生飢餓(starving)。

如果Listener執行緒飢餓,造成的結果是併發能力急劇下降,來自客戶端的新連線請求超時或無法建立。

注意在從緩衝佇列中獲取連線時,Reader可能會發生阻塞,因為它採用了LinkedBlockingQueue類中的take方法,這個方法在佇列為空時會阻塞,這樣Reader執行緒得以阻塞,以給其它執行緒執行的時間。

Reader執行緒的喚醒時機有兩個:

  1. Listener建立了新連線,並將此連線加入1個Reader的緩衝佇列;
  2. select呼叫返回。

在Reader的doRead呼叫中,其主要呼叫了readAndProcess方法,此方法迴圈處理資料,接收資料包的頭部、上下文頭部和真正的資料。
這個過程中值得一提的是下面的這個channelRead方法:

private int channelRead(ReadableByteChannel channel, 
                          ByteBuffer buffer) throws IOException {

    int count = (buffer.remaining() <= NIO_BUFFER_LIMIT) ?
                channel.read(buffer) : channelIO(channel, null, buffer);
    if (count > 0) {
      rpcMetrics.incrReceivedBytes(count);
    }
    return count;
  }

channelRead會判斷資料接收陣列buffer中的剩餘未讀資料,如果大於一個臨界值NIO_BUFFER_LIMIT,就採取分片的技巧來多次地讀,以防止jdk對large buffer採取變為direct buffer的優化。

這一點,也許是考慮到direct buffer在建立時會有一些開銷,同時在jdk1.6之前direct buffer不會被GC回收,因為它們分配在JVM的堆外的記憶體空間中。

到底這樣優化的效果如何,沒有測試,也就略過。也許是為了減少GC的負擔。

在Reader讀取到一個完整的RpcRequest包之後,會呼叫processOneRpc方法,此呼叫將進入業務邏輯環節。這個方法,會從接受到的資料包中,反序列化出RpcRequest的頭部和資料,依此構造一個RpcRequest物件,設定客戶端需要的跟蹤資訊(trace info),然後構造一個Call物件,如下圖所示:

這裡寫圖片描述

此後,在Handler處理時,就以Call為單位,這是一個包含了與連線相關資訊的封裝物件。

有了Call物件後,將其加入Server的callQueue佇列,以供Handler處理。因為採用了put方法,所以當callQueue滿時(Handler忙),Reader會發生阻塞,如下所示:

callQueue.put(call);              // queue the call; maybe blocked here

三、Handler

Handler就是根據rpc請求中的方法(Call)及引數,來呼叫相應的業務邏輯介面來處理請求。

一個Server中有多個Handler,對應多個業務介面,本篇不討論具體業務邏輯。

handler的邏輯基本如下(略去異常和其它次要資訊):

public void run() {
      SERVER.set(Server.this);
      ByteArrayOutputStream buf = 
        new ByteArrayOutputStream(INITIAL_RESP_BUF_SIZE);
      while (running) {
        try {
          final Call call = callQueue.take(); // pop the queue; maybe blocked here
          CurCall.set(call);
          try {
            if (call.connection.user == null) {
              value = call(call.rpcKind, call.connection.protocolName, call.rpcRequest, 
                           call.timestamp);
            } else {
              value = 
                call.connection.user.doAs(...);
            }
          } catch (Throwable e) {
            //略 ... 
          }
          CurCall.set(null);
          synchronized (call.connection.responseQueue) {
            responder.doRespond(call);
          }
  }

可見,Handler從callQueue中取出一個Call,然後呼叫這個Server.call方法,最後呼叫Responder的doResponde方法將結果傳送給客戶端。

Server.call方法:

public Writable call(RPC.RpcKind rpcKind, String protocol,
        Writable rpcRequest, long receiveTime) throws Exception {
      return getRpcInvoker(rpcKind).call(this, protocol, rpcRequest,
          receiveTime);
    }

四、Responder

一個Server只有1個Responder執行緒。

此執行緒不斷進行如下幾個重要呼叫以和Handler協調併傳送資料:

//這個wait是同步作用,具體見下面分析
waitPending();     
...
//開始select,或許會阻塞
writeSelector.select(PURGE_INTERVAL);
...
//如果selectKeys有資料,就依次非同步傳送資料
for(selectorKeys){
	doAsyncWrite(key);
}
...
//當到達丟棄時間,會從selectedKeys構造calls,並依次丟棄
for(Call call : calls) {
  doPurge(call, now);
}

當Handler呼叫doRespond方法後,handler處理的結果被加入responseQueue的隊尾,而不是立即傳送回客戶端:

void doRespond(Call call) throws IOException {
      synchronized (call.connection.responseQueue) {
        call.connection.responseQueue.addLast(call);
        if (call.connection.responseQueue.size() == 1) {
          //注意這裡isHandler = true,表示可能會向select註冊寫事件以在Responder主迴圈中通過select處理資料傳送
          processResponse(call.connection.responseQueue, true);
        }
      }
    }

上面的synchronized 可以看出,responseQueue是爭用資源,相應的:

Handler是生產者,將結果加入佇列;
Responder是消費者,從佇列中取出結果併傳送。

processResponse將啟動Responder進行傳送,首先從responseQueue中以非阻塞方式取出一個call,然後以非阻塞方式盡力傳送call.rpcResponse,如果傳送完畢,則返回。

當還有剩餘資料未傳送,將call插入佇列的第一個位置,由於isHandler引數,在來自Handler的呼叫中傳入為true,所以會喚醒writeSelector,並註冊一個寫事件,其中incPending()方法,是為了在向selector註冊寫事件時,阻塞Responder執行緒,後面有分析。

call.connection.responseQueue.addFirst(call);

            if (inHandler) {
              // set the serve time when the response has to be sent later
              call.timestamp = Time.now();

              incPending();
              try {
                // Wakeup the thread blocked on select, only then can the call 
                // to channel.register() complete.
                writeSelector.wakeup();
                channel.register(writeSelector, SelectionKey.OP_WRITE, call);
              } catch (ClosedChannelException e) {
                //Its ok. channel might be closed else where.
                done = true;
              } finally {
                decPending();
              }
            }

再回到Responder的主迴圈,看看如果向select註冊了寫事件會發生什麼:

//執行這句時,如果Handler呼叫的responder.doResonde()正在向select註冊寫事件,這裡就會阻塞
          //目的很顯然,是為了下句的select能獲取資料並立即返回,這就減少了阻塞發生的次數
          waitPending();     // If a channel is being registered, wait.

          //這裡用超時阻塞來select,是為了能夠在沒有資料傳送時,定期喚醒,以處理長期未得到處理的Call
          writeSelector.select(PURGE_INTERVAL);
          Iterator<SelectionKey> iter = writeSelector.selectedKeys().iterator();
          while (iter.hasNext()) {
            SelectionKey key = iter.next();
            iter.remove();
            try {
              if (key.isValid() && key.isWritable()) {
                  //非同步傳送
                  doAsyncWrite(key);
              }
            } catch (IOException e) {
              LOG.info(Thread.currentThread().getName() + ": doAsyncWrite threw exception " + e);
            }
          }

重點內容都做了註釋,不再贅述。可以看出,既考慮同步,又考慮效能,這是值得學習的地方。

五、總結

本篇著重分析了hadoop的rpc呼叫中server部分,可以看出,這是一個精良的設計,考慮的很細。

  1. 關於同步:
    Listener生產,Reader消費;Reader生產,Handler消費,Handler生產,Responder消費。
    所以它們之間必須同步.在具體的hadoop實現中,既有利用BlockingQueue的put&take操作實現阻塞,以達到同步目的,也對爭用資源使用synchronized來實現同步。
  2. 關於緩衝:
    其中幾個緩衝佇列也值得關注.Server的併發請求會特別多,而Handler在執行call進行業務邏輯時,肯定會慢下來,所以必須建立請求和處理之間的緩衝。
    另外,處理和傳送之間也同樣會出現速率不匹配的現象,同樣需要緩衝。
  3. 關於執行緒模型:
    Listener單執行緒,Reader多執行緒,Handler多執行緒,Responder單執行緒,為什麼會這樣設計?Listener採用select模式處理accept事件,一個客戶端在一段時間內一般只建立有限次的連線,而且連線的建立是比較快的,所以單執行緒足夠應付,建立後直接丟給Reader,從而自己很從容地應付新連線。Handler多執行緒,業務邏輯是大頭,又很大可能會牽涉I/O密集(HDFS),如果執行緒少,耗時過長的業務邏輯可能就會讓大部分的Handler執行緒處於阻塞,這樣輕快的業務邏輯也必須排隊,可能會發生飢餓。如果Reader收集的請求佇列長時間處於滿的狀態,整個通訊必然惡化,所以這是典型的需要降低響應時間、提升吞吐量的高併發時刻,這個時刻的上下文切換是必須的,不糾結,併發為重。Responder是單執行緒,顯然,Responder會比較輕鬆,因為雖然請求很多,但經過Reader->Handler的緩衝和Handler的處理,上一批能傳送完的結果已經傳送了。Responder更多的是蒐集並處理那些長結果,並通過高效select模式來獲取結果併傳送。

    這裡,Handler在業務邏輯呼叫完畢直接呼叫了responder.doRespond傳送,是因為這是個立即返回的呼叫,這個呼叫的耗時是很少的,所以不必讓Handler因為傳送而阻塞,進一步充分發揮了Handler多執行緒的能力,減少了執行緒切換的機會,強調了其多執行緒併發的優勢,同時又為responder減負,以增強Responder單執行緒作戰的信心。

  4. 關於鎖
    對Hadoop來講,因為同步需求,所以加鎖是必不可少的。效能是需要考慮,但是從工程的角度上來看,通訊層的穩定性、程式碼可維護性、保持程式碼結構的相對簡單性(其程式碼因歷史原因已非常複雜),大部分採用了synchronized這種悲觀得、重型的加鎖方式,這樣,可以顯著減少物件之間同步的複雜性,減少錯誤的發生。

六、(補充)RpcServer 執行緒模型

NameNode啟動過程:

這裡寫圖片描述

執行緒模型

Listener 1個:

  1. 監聽並接受來自客戶端的連線.將新建連線放入pendingConnections.
  2. 清理空閒連線.
  3. 喚醒Reader.

Reader N個 : 從pendingConnections中獲取連線,讀取資料,從RpcRequest構造Call,並放入callQueue.

Handler N 個:

  1. 從callQueue獲取客戶端呼叫call,並執行.
  2. 呼叫Responder,將結果加入responseQueue的尾部.這裡會呼叫一次傳送.如果資料未傳送完,註冊WRITE事件到selector.並喚醒Responder.

Responder 1個:

  1. 從responseQueue中按照FIFO順序傳送資料.
  2. 處理selector select出的資料.
  3. 掃描callQueue,並丟棄過期的Call.

終.

相關文章