前言
這篇文章主要介紹整個框架用到的最核的一個設計模式:反應器模式。這個設計模式可以在《物件導向的軟體架構》中詳細瞭解,沒有這本書的小夥伴不要急,我通過我們們的SimpleRpc來告訴大家這個設計模式是如何運用的。之所以它叫反應器模式,是因為它是處理事件的一種比較優美的框架。如何優美,我們慢慢道來。
如何設計一個高吞吐量的web服務?
web服務會面對大量的網路請求,服務要對這些請求進行處理,如何設計我們的web服務呢?有如下幾種模型供參考。
- 單執行緒模型 系統中使用唯一的一個執行緒處理網路資料的讀,對請求資料的處理,以及傳送響應。當一個網路請求到來時,工作執行緒通過accept獲取到活動socket,接下來該執行緒順序的讀取資料、處理資料、生成響應資料、寫回socket。這個模型有一個明顯的缺點:當服務處理一個請求時,其它請求將阻塞得不到響應。它難以勝任高併發大吞吐量的web服務。
- 多執行緒模型 每當accept返回一個新的活動socket後,主執行緒就建立一個新執行緒進行資料的讀、處理、響應。這樣做的好處就是當一個執行緒處理請求時,其它執行緒可以接收新的請求並進行響應。它的缺點也很明顯:當請求的併發量多的時候,系統會同時有多個執行緒工作。當執行緒非常多時,cpu需要在多個執行緒之間做切換,切換的開銷將會增大,不是一個伸縮性很好的設計。我們可以使用執行緒池來優化這個設計,但是這麼做也有一個問題:如果某些客戶端與服務端建立連線後並不是馬上傳送資料,那麼此時服務端會有大量的執行緒就都hang在socket的讀上面(因為網路資料遲遲不傳送),cpu使用率不高。
如何克服上面兩種模型的弊端呢?我們的反應器模式開始大展身手。
反應器模式
- Select/Epoll簡介: 上面兩種模型沒有用到作業系統提供的更高階的網路資料處理機制:select模型/Epoll模型。這是一個能夠同時監聽多個socket控制程式碼上的動作的機制,由作業系統支援。它維護一個監聽列表,使用者可以動態新增和刪除需要監聽的socket控制程式碼。如果一個控制程式碼被加入了它的監聽列表,當控制程式碼有新資料到來或者控制程式碼可寫時就會產生一個電位差提醒作業系統有socket控制程式碼可以操作(讀或者寫),這時select/Epoll就會告訴使用者可以在哪些socket控制程式碼上做操作。它的優點是不會因為其中任何一個控制程式碼的阻塞而忽略其它控制程式碼上的可操作事件。
有了Select/Epoll這個利器,我們就可以設計反應器了:
- Reactor(反應器)介面定義:
regist:在一個socket控制程式碼上註冊操作函式。
remove:從反應器中移除對某個控制程式碼的監聽。
handle_events:通過系統提供的select/epoll_wait獲取所監聽控制程式碼上的事件通知,並呼叫對應控制程式碼所註冊的函式進行處理。
- EventHandler介面定義:
handle_read: 對控制程式碼上的讀事件進行操作
handle_write: 對控制程式碼上的寫事件進行操作
SimpleRpc中的核心框架實現
Reactor使用Epoll實現,Epoll的具體使用方式這裡就不贅述了,因為它不是本文的主要寫作目的。這裡面講述一下SimpleRpc網路事件處理的最核心的三個類:Acceptor,UpstreamHandler,DownstreamHandler。
- Acceptor接受器:
Acceptor::Acceptor(const InetAddr &addr, Reactor *reactor){ _reactor = reactor; int sock_fd = socket(AF_INET, SOCK_STREAM, 0); sockaddr sock_addr = addr.addr(); int ret = bind(sock_fd, &sock_addr, sizeof(sockaddr)); if(ret != 0){ LOG("bind error"); exit(0); } ret = listen(sock_fd, 1000); if(ret != 0) { LOG("listen error"); exit(0); } _reactor->regist(sock_fd, this); }
首先,服務端使用socket函式建立一個socket_fd,繫結好IP和埠後,acceptor把這個socket_fd加入到reactor監聽列表中,當有客戶端主動發起與該服務的連線時,服務端該socket_fd上會有讀事件產生,Acceptor的handle_read函式將會被呼叫。
void Acceptor::handle_read(int sock_fd) { struct sockaddr addr; socklen_t size = sizeof(struct sockaddr_in); int fd = accept(sock_fd, &addr, &size); _reactor->regist(fd, new UpstreamHandler(fd, _reactor)); //UpstreamHandler用來處理客戶端請求。 }
handle_read函式接收到這個socket_fd後,知道這是客戶端的請求連線,於是呼叫accept函式獲取這個新連線的socket控制程式碼fd。站在服務端角度看,客戶端就是它的上游,對於上游事件的處理需要用UpstreamHandler。Acceptor在reactor上繫結fd與UpstreamHandler,reactor等待後續的事件的到來。Acceptor就像一隻老母雞,不斷的下蛋(蛋就是新生產出的fd),並把蛋放入到reactor等待進一步孵化。
- UpstreamHandler:上游請求事件處理
void UpstreamHandler::handle_read(int fd) { if(fd != _sock_fd){ return; } StreamEvent e; e.fd = _sock_fd;
e.type = 0; //客戶端請求事件 _reactor->remove(fd); //移除fd //由每個工作執行緒自己去讀fd,客戶端請求事件 ThreadPool<UpstreamEvent>::get_instance()->put_event(e); //放入佇列 //自殺 delete this; }
客戶端請求服務端建立連線後,第一步就是要向服務端傳送請求資料。客戶端傳送資料後,服務端reactor發現socket控制程式碼上有讀事件,就呼叫對應控制程式碼上的事件處理函式(UpstreamHandler::handle_read())。該事件處理函式並不直接進行資料的讀取和計算,而是先從reactor中移除該fd(防止後續資料到來,reactor重複獲取讀事件),之後把fd封裝成一個事件結構體放入阻塞佇列中,由共享該阻塞佇列的執行緒池進行後續處理。
- DownstreamHandler:下游事件處理
void DownstreamHandler::handle_read(int fd) { char head[4]; if(fd != _sock_fd){ return; } _reactor->remove(fd); Connection conn(fd); conn.recv_n(head, 4); int size = *((int *)head); char *buf = new char[size]; conn.recv_n(buf, size); close(fd); printf("Downstream Handler close fd:%d\n", fd); //下游響應 _response->deserialize(buf, size); if(_result_handler != NULL) { _result_handler->data_comeback(); } delete[] buf; //自殺 delete this; }
站在客戶端的角度來看,服務端就是其下游,客戶端對服務端的響應資料的處理需要使用DownstreamHandler。當服務端返回響應資料時,客戶端的reactor會檢測到對應socket控制程式碼上的讀事件,隨後呼叫對應事件處理函式(handle_read)。該函式首先從reactor移除對該fd的監聽,防止reactor重複檢測事件並呼叫處理函式;之後就接收資料、反序列化得到響應結構體。