使用Socket 模型實現網路通訊時,需要經過建立Socket、監聽埠、處理連線和讀寫請求等多個步驟,現在我們就來具體了解下這些步驟中的關鍵操作,以此協助我們分析Socket 模型中的不足。
首先,當我們需要讓伺服器端和客戶端進行通訊時,可以在伺服器端透過以下三步,來建立監聽客戶端連線的監聽套接字(Listening Socket):
呼叫socket 函數,建立一個套接字。一般情況下,我們將該套接字稱為主動套接字
呼叫bind 函數,將主動套接字和目前伺服器的IP 和監聽埠進行綁定;
呼叫listen 函數,將主動套接字轉換為監聽套接字,開始監聽客戶端的連線。
在完成上述三步驟之後,伺服器端就可以接收客戶端的連線請求了。為了能及時收到客戶端的連線請求,我們可以執行一個循環流程,在該流程中呼叫 accept 函數,用於接收客戶端連線請求。
這裡你需要注意的是,accept 函數是阻塞函數,也就是說,如果此時一直沒有客戶端連線請求,那麼,伺服器端的執行流程會一直阻塞在 accept 函數。一旦有客戶端連接請求到達,accept 將不再阻塞,而是處理連接請求,和客戶端建立連接,並傳回已連接套接字(Connected Socket)。
最後,伺服器端可以透過呼叫 recv 或 send 函數,在剛才傳回的已連接套接字上,接收並處理讀寫請求,或將資料傳送給客戶端。
程式碼:
listenSocket = socket(); //调用socket系统调用创建一个主动套接字 bind(listenSocket); //绑定地址和端口 listen(listenSocket); //将默认的主动套接字转换为服务器使用的被动套接字,也就是监听套接字 while(1) { //循环监听是否有客户端连接请求到来 connSocket = accept(listenSocket);//接受客户端连接 recv(connSocket);//从客户端读取数据,只能同时处理一个客户端 send(connSocket);//给客户端返回数据,只能同时处理一个客户端 }
不過,從上述程式碼中,你可能會發現,雖然它能夠實現伺服器端和客戶端之間的通信,但是程式每調用一次accept 函數,只能處理一個客戶端連線。因此,如果想要處理多個並發客戶端的請求,我們就需要使用多線程,來處理透過 accept 函數建立的多個客戶端連接上的請求。
使用這種方法後,我們需要在accept 函數返回已連接套接字後,創建一個線程,並將已連接套接字傳遞給創建的線程,由該線程負責這個連接套接字上後續的數據讀寫。同時,伺服器端的執行流程會再次呼叫 accept 函數,等待下一個客戶端連線。
多執行緒:
listenSocket = socket(); //调用socket系统调用创建一个主动套接字 bind(listenSocket); //绑定地址和端口 listen(listenSocket); //将默认的主动套接字转换为服务器使用的被动套接字,也就是监听套接字 while(1) { //循环监听是否有客户端连接请求到来 connSocket = accept(listenSocket);//接受客户端连接 pthread_create(processData, connSocket);//创建新线程对已连接套接字进行处理 } processData(connSocket){ recv(connSocket);//从客户端读取数据,只能同时处理一个客户端 send(connSocket);//给客户端返回数据,只能同时处理一个客户端 }
雖然這個方法能提升伺服器端的同時處理能力,但是,Redis 的主執行流程是由一個執行緒在執行,無法使用多執行緒的方式來提升並發處理能力。所以,該方法對redis並不起作用。
還有沒有其他方法,能幫助 Redis 提升並發客戶端的處理能力呢?這就要用到作業系統提供的IO多工功能。在基本的 Socket 程式設計模型中,accept 函數只能在一個監聽套接字上監聽客戶端的連接,recv 函數也只能在一個已連接套接字上,等待客戶端發送的請求。
因為 Linux 作業系統在實際應用上比較廣泛,所以這堂課,我們主要來學習 Linux 上的 IO 多路復用機制。 select、poll以及epoll是Linux所提供的IO多路復用機制的三種主要形式。下面,我們就分別來學習下這三種機制的實作想法和使用方法。接下來,我們再探討為什麼 Redis 常常選擇使用 epoll 機制來實作網路通訊。
首先,我們來了解下 select 機制的程式設計模型。
不過在具體學習之前,我們需要知道,對於一種 IO 多路復用機制來說,我們需要掌握哪些要點,這樣可以幫助我們快速抓住不同機制的聯繫與區別。其實,當我們學習 IO 多工機制時,我們需要能回答以下問題:第一,多工機制會監聽套接字上的哪些事件?第二,多工機制可以監聽多少個套接字?第三,當有套接字就緒時,多路復用機制如何找到就緒的套接字?
select 機制中的一個重要函數就是 select 函數。對於 select 函數來說,它的參數包括監聽的檔案描述符數量__nfds、、被監聽描述符的三個集合readfds、writefds、exceptfds,以及監聽時阻塞等待的逾時時長timeout。 select函數原型:
int select(int __nfds, fd_set *__readfds, fd_set *__writefds, fd_set *__exceptfds, struct timeval *__timeout)
這裡你需要注意的是,Linux 針對每一個套接字都會有一個檔案描述符,也就是一個非負整數,用來唯一標識該套接字。在多路復用機制的函數中,通常使用檔案描述符作為參數,這是 Linux 的常見做法。函數透過檔案描述子找到對應的套接字,從而實現監聽、讀寫等操作。
select函數的三個參數指定了需要監視的檔案描述子集合,實際上代表了需要監視的套接字集合。那麼,為什麼會有三個集合呢?
关于刚才提到的第一个问题,即多路复用机制监听的套接字事件有哪些。select 函数使用三个集合,表示监听的三类事件,分别是读数据事件,写数据事件,异常事件。
我们进一步可以看到,参数 readfds、writefds 和 exceptfds 的类型是 fd_set 结构体,它主要定义部分如下所示。其中,fd_mask类型是 long int 类型的别名,__FD_SETSIZE 和 __NFDBITS 这两个宏定义的大小默认为 1024 和 32。
所以,fd_set 结构体的定义,其实就是一个 long int 类型的数组,该数组中一共有 32 个元素(1024/32=32),每个元素是 32 位(long int 类型的大小),而每一位可以用来表示一个文件描述符的状态。了解了 fd_set 结构体的定义,我们就可以回答刚才提出的第二个问题了。每个描述符集合都可以被 select 函数监听 1024 个描述符。
首先,我们在调用 select 函数前,可以先创建好传递给 select 函数的描述符集合,然后再创建监听套接字。而为了让创建的监听套接字能被 select 函数监控,我们需要把这个套接字的描述符加入到创建好的描述符集合中。
接下来,我们可以使用 select 函数并传入已创建的描述符集合作为参数。程序在调用 select 函数后,会发生阻塞。一旦 select 函数检测到有就绪的描述符,会立即终止阻塞并返回已就绪的文件描述符数。
那么此时,我们就可以在描述符集合中查找哪些描述符就绪了。然后,我们对已就绪描述符对应的套接字进行处理。比如,如果是 readfds 集合中有描述符就绪,这就表明这些就绪描述符对应的套接字上,有读事件发生,此时,我们就在该套接字上读取数据。
而因为 select 函数一次可以监听 1024 个文件描述符的状态,所以 select 函数在返回时,也可能会一次返回多个就绪的文件描述符。我们可以使用循环处理流程,对每个就绪描述符对应的套接字依次进行读写或异常处理操作。
select函数有两个不足
首先,select 函数对单个进程能监听的文件描述符数量是有限制的,它能监听的文件描述符个数由 __FD_SETSIZE 决定,默认值是 1024。
其次,当 select 函数返回后,我们需要遍历描述符集合,才能找到具体是哪些描述符就绪了。这个遍历过程会产生一定开销,从而降低程序的性能。
poll 机制的主要函数是 poll 函数,我们先来看下它的原型定义,如下所示:
int poll(struct pollfd *__fds, nfds_t __nfds, int __timeout)
其中,参数 *__fds 是 pollfd 结构体数组,参数 __nfds 表示的是 *__fds 数组的元素个数,而 __timeout 表示 poll 函数阻塞的超时时间。
pollfd 结构体里包含了要监听的描述符,以及该描述符上要监听的事件类型。从 pollfd 结构体的定义中,我们可以看出来这一点,具体如下所示。pollfd 结构体中包含了三个成员变量 fd、events 和 revents,分别表示要监听的文件描述符、要监听的事件类型和实际发生的事件类型。
pollfd 结构体中要监听和实际发生的事件类型,是通过以下三个宏定义来表示的,分别是 POLLRDNORM、POLLWRNORM 和 POLLERR,它们分别表示可读、可写和错误事件。
了解了 poll 函数的参数后,我们来看下如何使用 poll 函数完成网络通信。这个流程主要可以分成三步:
第一步,创建 pollfd 数组和监听套接字,并进行绑定;
第二步,将监听套接字加入 pollfd 数组,并设置其监听读事件,也就是客户端的连接请求;
第三步,循环调用 poll 函数,检测 pollfd 数组中是否有就绪的文件描述符。
而在第三步的循环过程中,其处理逻辑又分成了两种情况:
如果是连接套接字就绪,这表明是有客户端连接,我们可以调用 accept 接受连接,并创建已连接套接字,并将其加入 pollfd 数组,并监听读事件;
如果是已连接套接字就绪,这表明客户端有读写请求,我们可以调用 recv/send 函数处理读写请求。
其实,和 select 函数相比,poll 函数的改进之处主要就在于,它允许一次监听超过 1024 个文件描述符。但是当调用了 poll 函数后,我们仍然需要遍历每个文件描述符,检测该描述符是否就绪,然后再进行处理。
首先,epoll 機制是使用 epoll_event 結構體,來記錄待監聽的文件描述符及其監聽的事件類型的,這和 poll 機制中使用 pollfd 結構體比較類似。
那麼,對於 epoll_event 結構體來說,其中包含了 epoll_data_t 聯合體變量,以及整數類型的 events 變數。 epoll_data_t 聯合體中有記錄檔案描述子的成員變數fd,而events 變數會取值使用不同的巨集定義值,來表示epoll_data_t 變數中的檔案描述子所關注的事件類型,例如一些常見的事件類型包括以下這幾種。
EPOLLIN:讀取事件,表示檔案描述子對應套接字有資料可讀。
EPOLLOUT:寫事件,表示檔案描述子對應套接字有資料要寫。
EPOLLERR:錯誤事件,表示檔案描述子對於套接字出錯。
在使用 select 或 poll 函數的時候,建立好檔案描述子集合或 pollfd 陣列後,就可以往數組中加入我們需要監聽的檔案描述子。
但對於 epoll 機制來說,我們需要先呼叫 epoll_create 函數,建立一個 epoll 實例。這個 epoll 實例內部維護了兩個結構,分別是記錄要監聽的檔案描述符和已經就緒的檔案描述符,,而對於已經就緒的檔案描述符來說,它們會被傳回給使用者程式處理。
所以,我們在使用 epoll 機制時,就不用像使用 select 和 poll 一樣,遍歷查詢哪些檔案描述子已經就緒了。因此,epoll 的效率比 select 和 poll 更高。
在建立了 epoll 實例後,我們需要再使用 epoll_ctl 函數,為被監聽的檔案描述子新增監聽事件類型,以及使用 epoll_wait 函數取得就緒的檔案描述子。
了解了 epoll 函數的使用方法了。實際上,也正是因為epoll 能自訂監聽的描述符數量,以及可以直接返回就緒的描述符,Redis 在設計和實現網路通訊框架時,就基於epoll 機制中的epoll_create、epoll_ctl 和epoll_wait 等函數和讀寫事件,進行了封裝開發,實現了用於網路通訊的事件驅動框架,從而使得Redis 雖然是單執行緒運行,但是仍然能高效應對高並發的客戶端存取。
Reactor 模型就是網路伺服器端用來處理高並發網路IO 請求的一種程式設計模型,模型特徵:
三類處理事件,即連接事件、寫事件、讀取事件;
三個關鍵角色,即reactor、acceptor、handler。
Reactor 模型處理的是客戶端和伺服器端的互動過程,而這三類事件正好對應了客戶端和伺服器端互動過程中,不同類別請求在伺服器端引發的待處理事件:
當一個客戶端要和伺服器端進行交互時,客戶端會向伺服器端發送連接請求,以建立連接,這就對應了伺服器端的一個鏈接事件
一旦連線建立後,客戶端會給伺服器端發送讀取請求,以便讀取資料。伺服器端在處理讀取請求時,需要向客戶端寫回數據,這對應了伺服器端的寫事件
無論客戶端給伺服器端發送讀取或寫入請求,伺服器端都需要從客戶端讀取請求內容,所以在這裡,讀取或寫入請求的讀取就對應了伺服器端的讀取事件
三個關鍵角色:
#首先,連線事件由acceptor 來處理,負責接收連線;acceptor 在接收連線後,會建立handler,用於網路連線上對後續讀寫事件的處理;
其次,讀寫事件由handler 處理;
最後,在高並發場景中,連結事件、讀寫事件會同時發生,所以,我們需要有一個角色專門監聽和分配事件,這就是reactor 角色。當有連線請求時,reactor 將產生的連線事件交由 acceptor 處理;當有讀寫請求時,reactor 將讀寫事件交由 handler 處理。
那麼,現在我們已經知道,這三個角色是圍繞著事件的監聽、轉發和處理來進行互動的,那麼在程式設計時,我們又該如何實現這三者的交互呢?這就離不開事件驅動。
實作 Reactor 模型時,需要編寫的總體程式碼控制邏輯,稱為事件驅動框架。事件驅動框架由兩個部分組成:事件初始化和事件捕獲、分流及處理的主循環。簡而言之。
事件初始化是在伺服器程式啟動時就執行的,它的作用主要是建立需要監聽的事件類型,以及該類別事件對應的 handler。而一旦伺服器完成初始化後,事件初始化也就相應完成了,伺服器程式就需要進入事件擷取、分發和處理的主循環。
用while迴圈來當作這個主迴圈。然後在這個主循環中,我們需要捕捉發生的事件、判斷事件類型,並根據事件類型,呼叫在初始化時建立好的事件 handler 來實際處理事件。
比如說,當有連線事件發生時,伺服器程式需要呼叫 acceptor 處理函數,建立和客戶端的連線。而當有讀事件發生時,就表示有讀或寫請求發送到了伺服器端,伺服器程式就要調用具體的請求處理函數,從客戶端連線中讀取請求內容,進而完成了讀取事件的處理。
Reactor 模型的基本工作機制:客戶端的不同類別請求會在伺服器端觸發連線、讀取、寫三類事件,這三類事件的監聽、分發和處理又是由reactor、acceptor、handler三類角色來完成的,然後這三類角色會透過事件驅動框架來實現互動和事件處理。
以上是Redis的事件驅動模型是什麼的詳細內容。更多資訊請關注PHP中文網其他相關文章!