從網路編程基本概念說起
我們常常使用HTTP協議來傳輸各種格式的資料,其實HTTP這個應用層協議的底層,是基于傳輸層TCP協議來實作的,TCP協議僅僅把這些資料當做一串無意義的資料流來看待,所以,我們可以說:客戶端與服務器通過在建立的連接上發送位元組流來進行通信,
這種C/S架構的通信機制,需要標識通信雙方的網路地址和埠號資訊,對于客戶端來說,需要知道我的資料接收方位置,我們用網路地址和埠來唯一標識一個服務端物體;對于服務端來說,需要知道資料從哪里來,我們同樣用網路地址和埠來唯一標識一個客戶端物體,那么,用來唯一標識通信兩端的資料結構就叫做套接字,一個連接可以由它兩端的套接字地址唯一確定:
(客戶端地址:客戶端埠號,服務端地址:服務端埠號)
有了通信雙方的地址資訊之后,就可以進行資料傳輸了,那么我們現在需要一個規范,來規定通信雙方的連接及資料傳輸程序,在Unix系統中,實作了一套套接字介面,用來描述和規范雙方通信的整個程序,
socket():創建一個套接字描述符
connect():客戶端通過呼叫connect函式來建立和服務器的連接
bind():告訴內核將socket()創建的套接字與某個服務端地址與埠連接起來,后續會對這個地址和埠進行監聽
listen():告訴內核,將這個套接字當成服務器這種被動物體來看待(服務器是等待客戶端連接的被動物體,而內核認為
socket()創建的套接字默認是主動物體,所以才需要listen()函式,告訴內核進行主動到被動物體的轉換)
accept():等待客戶端的連接請求并回傳一個新的已連接描述符
最簡單的單行程服務器
由于Unix的歷史遺留問題,原始的套接字介面對地址和埠等資料封裝并不簡潔,為了簡化這些我們不關注的細節而只關注整個流程,我們使用PHP來進行分析,PHP對Unix的socket相關介面進行了封裝,所有相關套接字的函式都被加上了socket_前綴,并且使用一個資源型別的套接字句柄代替Unix中的檔案描述符fd,在下文的描述中,均用“套接字”代替Unix中的檔案描述符fd進行闡述,一個PHP實作的簡單服務器偽代碼如下:
<?php
if (($listenSocket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP))=== false) {
echo '套接字創建失敗';
}
if (socket_bind($listenSocket, '127.0.0.1', 8888) === false) {
echo '系結地址與埠失敗';
}
if (socket_listen($listenSocket) === false) {
echo '轉換主動套接字到被動套接字失敗';
}
while (1) {
if (($connSocket = socket_accept($listenSocket)) === false) {
echo '客戶端的連接請求還沒有到達';
} else {
socket_close($listenSocket); //釋放監聽套接字
socket_read($connSocket); //讀取客戶端資料,阻塞
socket_write($connSocket); //給客戶端回傳資料,阻塞
}
socket_close($connSocket);
}
我們梳理一下這個簡單的服務器創建流程:
socket_create():創建一個套接字,這個套接字就代表建立的連接上的一個端點,第一個引數AF_INET為使用的底層協議為IPv4;第二個引數SOCK_STREAM表示使用位元組流進行資料傳輸;第三個引數SQL_TCP代表本層協議為TCP協議,這里創建的套接字只是一個連接上的端點的一個抽象概念,
socket_bind():系結這個套接字到一個具體的服務器地址和埠上,真正實體化這個套接字,引數就是你之前創建的一個抽象的套接字,還有你具體的網路地址和埠,
socket_listen():我們觀察到只有一個函式引數就是之前創建的套接字,有些同學之前可能認為這一步函式呼叫完全沒有必要,但是它告訴內核,我是一個服務器,將套接字轉換為一個被動物體,其實是有很大的作用的,
socket_accept():接收客戶端發來的請求,因為服務器啟動之后,是不知道客戶端什么時候有連接到來的,所以,需要在一個while回圈中不斷呼叫這個函式,如果有連接請求到來,那么就會回傳一個新的套接字,我們可以通過這個新的套接字進行與客戶端的資料通信,如果沒有,就只能不斷地進行回圈,直到有請求到來為止,
注意,在這里我將套接字分為兩類,一個是監聽套接字,一個是連接套接字,注意這里對兩種套接字的區分,在下面的討論中會用到:
監聽套接字:服務器對某個埠進行監聽,這個套接字用來表示這個埠($listenSocket)
連接套接字:服務器與客戶端已經建立連接,所有的讀寫操作都要在連接套接字上進行($connSocket)
那么我們對這個服務器進行分析,它存在什么問題呢?
一個這樣的服務器行程只能同時處理一個客戶端連接與相關的讀寫操作,因為一旦有一個客戶端連接請求到來,我們對監聽套接字進行accept之后,就開啟了與該客戶端的資料傳輸程序,在資料讀寫的程序中,整個行程被該客戶端連接獨占,當前服務器行程只能處理該客戶端連接的讀寫操作,無法對其它客戶端的連接請求進行處理,
IO并發性能提升之路
由于上述服務器的性能太爛,無法同時處理多個客戶端連接以及讀寫操作,所以優秀的開發者們想出了以下幾種方案,用以提升服務器的效率,分別是:
多行程多執行緒基于單行程的IO多路復用(select/poll/epoll)
多行程
那么如何去優化單行程呢?很簡單,一個行程不行,那搞很多個行程不就可以同時處理多個客戶端連接了嗎?我們想了想,寫出了代碼:
<?php
if (($listenSocket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP))=== false) {
echo '套接字創建失敗';
}
if (socket_bind($listenSocket, '127.0.0.1', 8888) === false) {
echo '系結地址與埠失敗';
}
if (socket_listen($listenSocket) === false) {
echo '轉換主動套接字到被動套接字失敗';
}
for ($i = 0; $i < 10; $i++) { //初始創建10個子行程
if (pcntl_fork() == 0) {
if (($connSocket = socket_accept($listenSocket)) === false) {
echo '客戶端的連接請求還沒有到達';
} else {
socket_close($listenSocket); //釋放監聽套接字
socket_read($connSocket); //讀取客戶端資料
socket_write($connSocket); //給客戶端回傳資料
}
socket_close($connSocket);
}
}
我們主要關注這個for回圈,一共回圈了10次代表初始的子行程數量我們設定為10,接著我們呼叫了pcntl_fork()函式創建子行程,由于一個客戶端的connect就對應一個服務端的accept,所以在每個fork之后的10個子行程中,我們均進行accept的系統呼叫,等待客戶端的連接,這樣,就可以通過10個服務器行程,同時接受10個客戶端的連接、同時為10個客戶端提供讀寫資料服務,
注意這樣一個細節,由于所有子行程都是預先創建好的,那么請求到來的時候就不用創建子行程,也提高了每個連接請求的處理效率,同時也可以借助行程池的概念,這些子行程在處理完連接請求之后并不立即回收,可以繼續服務下一個客戶端連接請求,就不用重復的進行fork()的系統呼叫,也能夠提高服務器的性能,這些小技巧在PHP-FPM的實作中都有所體現,其實這種行程創建方式是其三種運行模式中的一種,被稱作static(靜態行程數量)模式:
ondemand:按需啟動,PHP-FPM啟動的時候不會啟動任何一個子行程(worker行程),只有客戶端連接請求到達時才啟動
dynamic:在PHP-FPM啟動時,會初始啟動一些子行程,在運行程序中視情況動態調整worker數量
static:PHP-FPM啟動時,啟動固定大小數量的子行程,在運行期間也不會擴容
回到正題,多行程這種方式的的確確解決了服務器在同一時間只能處理一個客戶端連接請求的問題,但是這種基于多行程的客戶端連接處理模式,仍存在以下劣勢:
fork()等系統呼叫會使得行程的背景關系進行切換,效率很低
行程創建的數量隨著連接請求的增加而增加,比如100000個請求,就要fork100000個行程,開銷太大
行程與行程之間的地址空間是私有、獨立的,使得行程之間的資料共享變得困難
既然談到了多行程的資料共享與切換開銷的問題,那么我們能夠很快想到解決該問題的方法,就是化多行程為更輕量級的多執行緒,
對于想從事Linux后端服務器開發的或者是想轉行互聯網行業的朋友,提升自己,通過耗時比較久,技術堆疊不完善,架構不成體系,自律性差,這里給大家分享一個系統的學習視頻,點擊鏈接訂閱后免費試聽:https://ke.qq.com/course/417774?flowToken=1013189
從基礎入門開始到后端高級架構,一個完整的體系課程,原理到實戰的講解,值得推薦,有興趣了解的朋友可以看看哦,
多執行緒
執行緒是運行在行程背景關系的邏輯流,一個行程可以包含多個執行緒,多個執行緒運行在單一的行程背景關系中,因此共享這個行程的地址空間的所有內容,解決了行程與行程之間通信難的問題,同時,由于一個執行緒的背景關系要比一個行程的背景關系小得多,所以執行緒的背景關系切換,要比行程的背景關系切換效率高得多,執行緒是輕量級的行程,解決了行程背景關系切換效率低的問題,
由于PHP中沒有多執行緒的概念,所以我們僅僅把上面的偽代碼中創建行程的部分,改成創建執行緒即可,代碼大體類似,在此不再贅述,
IO多路復用
前面談到的都是通過增加行程和執行緒的數量來同時處理多個套接字,而IO多路復用只需要一個行程就能夠處理多個套接字,IO多路復用這個名詞看起來好像很復雜很高深的樣子,實際上,這項技術所能帶來的本質成果就是:一個服務端行程可以同時處理多個套接字描述符,
多路:多個客戶端連接(連接就是套接字描述符)
復用:使用單行程就能夠實作同時處理多個客戶端的連接
在之前的講述中,一個服務端行程,只能同時處理一個連接,如果想同時處理多個客戶端連接,需要多行程或者多執行緒的幫助,免不了背景關系切換的開銷,
IO多路復用技術就解決了背景關系切換的問題,IO多路復用技術的發展可以分為select->poll->epoll三個階段,
IO多路復用的核心就是添加了一個套接字集合管理員,它可以同時監聽多個套接字,由于客戶端連接以及讀寫事件到來的隨機性,我們需要這個管理員在單行程內部對多個套接字的事件進行合理的調度,
select
最早的套接字集合管理員是select()系統呼叫,它可以同時管理多個套接字,select()函式會在某個或某些套接字的狀態從不可讀變為可讀、或不可寫變為可寫的時候通知服務器主行程,所以select()本身的呼叫是阻塞的,但是具體哪一個套接字或哪些套接字變為可讀或可寫我們是不知道的,所以我們需要遍歷所有select()回傳的套接字來判斷哪些套接字可以進行處理了,而這些套接字中又可以分為監聽套接字與連接套接字(上文提過),我們可以使用PHP為我們提供的socket_select()函式,在select()的函式原型中,為套接字們分了個類:讀、寫與例外套接字集合,分別監聽套接字的讀、寫與例外事件,:
function socket_select (array &$read, array &$write, array &$except, $tv_sec, $tv_usec = 0) {}
舉個例子,如果某個客戶單通過呼叫connect()連接到了服務器的監聽套接字($listenSocket)上,這個監聽套接字的狀態就會從不可讀變為可讀,由于監聽套接字只有一個,select()對于監聽套接字上的處理仍然是阻塞的,一個監聽套接字,存在于整個服務器的生命周期中,所以在select()的實作中并不能體現出其對監聽套接字的優化管理,
在當一個服務器使用accept()接受多個客戶端連接,并生成了多個連接套接字之后,select()的管理才能就會體現出來,這個時候,select()的監聽串列中有一個監聽套接字、和與一堆客戶端建立連接后新創建的連接套接字,在這個時候,可能這一堆已建立連接的客戶端,都會通過這個連接套接字發送資料,等待服務端接收,假設同時有5個連接套接字都有資料發送,那么這5個連接套接字的狀態都會變成可讀狀態,由于已經有套接字變成了可讀狀態,select()函式解除阻塞,立即回傳,具體哪一個套接字或哪些套接字變為可讀或可寫我們是不知道的,所以我們需要遍歷所有select()回傳的套接字,來判斷哪些套接字已經就緒,可以進行讀寫處理,遍歷完畢之后,就知道有5個連接套接字可以進行讀寫處理,這樣就實作了同時對多個套接字的管理,使用PHP實作select()的代碼如下:
<?php
if (($listenSocket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP))=== false) {
echo '套接字創建失敗';
}
if (socket_bind($listenSocket, '127.0.0.1', 8888) === false) {
echo '系結地址與埠失敗';
}
if (socket_listen($listenSocket) === false) {
echo '轉換主動套接字到被動套接字失敗';
}
/* 要監聽的三個sockets陣列 */
$read_socks = array(); //讀
$write_socks = array(); //寫
$except_socks = NULL; //例外
$read_socks[] = $listenSocket; //將初始的監聽套接字加入到select的讀事件監聽陣列中
while (1) {
/* 由于select()是參考傳遞,所以這兩個陣列會被改變,所以用兩個臨時變數 */
$tmp_reads = $read_socks;
$tmp_writes = $write_socks;
$count = socket_select($tmp_reads, $tmp_writes, $except_socks, NULL);
foreach ($tmp_reads as $read) { //不知道哪些套接字有變化,需要對全體套接字進行遍歷來看誰變了
if ($read == $listenSocket) { //監聽套接字有變化,說明有新的客戶端連接請求到來
$connSocket = socket_accept($listenSocket); //回應客戶端連接, 此時一定不會阻塞
if ($connSocket) {
//把新建立的連接socket加入監聽
$read_socks[] = $connSocket;
$write_socks[] = $connSocket;
}
} else { //新創建的連接套接字有變化
/*客戶端傳輸資料 */
$data = socket_read($read, 1024); //從客戶端讀取資料, 此時一定會讀到資料,不會產生阻塞
if ($data === '') { //已經無法從連接套接字中讀到資料,需要移除對該socket的監聽
foreach ($read_socks as $key => $val) {
if ($val == $read) unset($read_socks[$key]); //移除失效的套接字
}
foreach ($write_socks as $key => $val) {
if ($val == $read) unset($write_socks[$key]);
}
socket_close($read);
} else { //能夠從連接套接字讀到資料,此時$read是連接套接字
if (in_array($read, $tmp_writes)) {
socket_write($read, $data);//如果該客戶端可寫 把資料寫回到客戶端
}
}
}
}
}
socket_close($listenSocket);
但是,select()函式本身的呼叫阻塞的,因為select()需要一直等到有狀態變化的套接字之后(比如監聽套接字或者連接套接字的狀態由不可讀變為可讀),才能解除select()本身的阻塞,繼續對讀寫就緒的套接字進行處理,雖然這里是阻塞的,但是它能夠同時回傳多個就緒的套接字,而不是之前單行程中只能夠處理一個套接字,大大提升了效率
總結一下,select()的過人之處有以下幾點:
實作了對多個套接字的同時、集中管理
通過遍歷所有的套接字集合,能夠獲取所有已就緒的套接字,對這些就緒的套接字進行操作不會阻塞
但是,select()仍存在幾個問題:
select管理的套接字描述符們存在數量限制,在Unix中,一個行程最多同時監聽1024個套接字描述符
select回傳的時候,并不知道具體是哪個套接字描述符已經就緒,所以需要遍歷所有套接字來判斷哪個已經就緒,可以繼續進行讀寫
為了解決第一個套接字描述符數量限制的問題,聰明的開發者們想出了poll這個新套接字描述符管理員,用以替換select這個老管理員,select()就可以安心退休啦,
poll
poll解決了select帶來的套接字描述符的最大數量限制問題,由于PHP的socket擴展沒有poll對應的實作,所以這里放一個Unix的C語言原型實作:
int poll (struct pollfd *fds, unsigned int nfds, int timeout);
poll的fds引數集合了select的read、write和exception套接字陣列,合三為一,poll中的fds沒有了1024個的數量限制,當有些描述符狀態發生變化并就緒之后,poll同select一樣會回傳,但是遺憾的是,我們同樣不知道具體是哪個或哪些套接字已經就緒,我們仍需要遍歷套接字集合去判斷究竟是哪個套接字已經就緒,這一點并沒有解決剛才提到select的第二個問題,
我們可以總結一下,select和poll這兩種實作,都需要在回傳后,通過遍歷所有的套接字描述符來獲取已經就緒的套接字描述符,事實上,同時連接的大量客戶端在一時刻可能只有很少的處于就緒狀態,因此隨著監視的描述符數量的增長,其效率也會線性下降,
為了解決不知道回傳之后究竟是哪個或哪些描述符已經就緒的問題,同時避免遍歷所有的套接字描述符,聰明的開發者們又發明出了epoll機制,完美解決了select和poll所存在的問題,
epoll
epoll是最先進的套接字們的管理員,解決了上述select和poll中所存在的問題,它將一個阻塞的select、poll系統呼叫拆分成了三個步驟,一次select或poll可以看作是由一次 epoll_create、若干次 epoll_ctl、若干次 epoll_wait構成:
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
epoll_create():創建一個epoll實體,后續操作會使用
epoll_ctl():對套接字描述符集合進行增刪改操作,并告訴內核需要監聽套接字描述符的什么事件
epoll_wait():等待監聽串列中的連接事件(監聽套接字描述符才會發生)或讀寫事件(連接套接字描述符才會發生),如果有某個或某些套接字事件已經準備就緒,就會回傳這些已就緒的套接字們
看起來,這三個函式明明就是從select、poll一個函式拆成三個函式了嘛,我們對某套接字描述符的添加、洗掉、修改操作由之前的代碼實作變成了呼叫epoll_ctl()來實作,epoll_ctl()的引數含義如下:
epfd:epoll_create()的回傳值
op:表示對下面套接字描述符fd所進行的操作,EPOLL_CTL_ADD:將描述符添加到監聽串列;EPOLL_CTL_DEL:不再監聽某描述符;EPOLL_CTL_MOD:修改某描述符
fd:上面op操作的套接字描述符物件(之前在PHP中是listenSocket與listenSocket與 connSocket兩種套接字描述符)例如將某個套接字添加到監聽串列中
event:告訴內核需要監聽該套接字描述符的什么事件(如讀寫、連接等)
最后我們呼叫epoll_wait()等待連接或讀寫等事件,在某個套接字描述符上準備就緒,當有事件準備就緒之后,會存到第二個引數epoll_event結構體中,通過訪問這個結構體就可以得到所有已經準備好事件的套接字描述符,這里就不用再像之前select和poll那樣,遍歷所有的套接字描述符之后才能知道究竟是哪個描述符已經準備就緒了,這樣減少了一次O(n)的遍歷,大大提高了效率,
在最后回傳的所有套接字描述符中,同樣存在之前說過的兩種描述符:監聽套接字描述符和連接套接字描述符,那么我們需要遍歷所有準備就緒的描述符,然后去判斷究竟是監聽還是連接套接字描述符,然后視情況做做出accept(監聽套接字)或者是read(連接套接字)的處理,一個使用C語言撰寫的epoll服務器的偽代碼如下(重點關注代碼注釋):
int main(int argc, char *argv[]) {
listenSocket = socket(AF_INET, SOCK_STREAM, 0); //同上,創建一個監聽套接字描述符
bind(listenSocket) //同上,系結地址與埠
listen(listenSocket) //同上,由默認的主動套接字轉換為服務器適用的被動套接字
epfd = epoll_create(EPOLL_SIZE); //創建一個epoll實體
ep_events = (epoll_event*)malloc(sizeof(epoll_event) * EPOLL_SIZE); //創建一個epoll_event結構存盤套接字集合
event.events = EPOLLIN;
event.data.fd = listenSocket;
epoll_ctl(epfd, EPOLL_CTL_ADD, listenSocket, &event); //將監聽套接字加入到監聽串列中
while (1) {
event_cnt = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1); //等待回傳已經就緒的套接字描述符們
for (int i = 0; i < event_cnt; ++i) { //遍歷所有就緒的套接字描述符
if (ep_events[i].data.fd == listenSocket) { //如果是監聽套接字描述符就緒了,說明有一個新客戶端連接到來
connSocket = accept(listenSocket); //呼叫accept()建立連接
event.events = EPOLLIN;
event.data.fd = connSocket;
epoll_ctl(epfd, EPOLL_CTL_ADD, connSocket, &event); //添加對新建立的連接套接字描述符的監聽,以監聽后續在連接描述符上的讀寫事件
} else { //如果是連接套接字描述符事件就緒,則可以進行讀寫
strlen = read(ep_events[i].data.fd, buf, BUF_SIZE); //從連接套接字描述符中讀取資料, 此時一定會讀到資料,不會產生阻塞
if (strlen == 0) { //已經無法從連接套接字中讀到資料,需要移除對該socket的監聽
epoll_ctl(epfd, EPOLL_CTL_DEL, ep_events[i].data.fd, NULL); //洗掉對這個描述符的監聽
close(ep_events[i].data.fd);
} else {
write(ep_events[i].data.fd, buf, str_len); //如果該客戶端可寫 把資料寫回到客戶端
}
}
}
}
close(listenSocket);
close(epfd);
return 0;
}
我們看這個通過epoll實作一個IO多路復用服務器的代碼結構,除了由一個函式拆分成三個函式,其余的執行流程基本同select、poll相似,只是epoll會只回傳已經就緒的套接字描述符集合,而不是所有描述符的集合,IO的效率不會隨著監視fd的數量的增長而下降,大大提升了效率,同時它細化并規范了對每個套接字描述符的管理(如增刪改的程序),此外,它監聽的套接字描述符是沒有限制的,這樣,之前select、poll的遺留問題就全部解決啦,
總結
我們從最基本網路編程說起,開始從一個最簡單的同步阻塞服務器到一個IO多路復用服務器,我們從頭到尾了解到了一個服務器性能提升的思考與實作程序,而提升服務器的并發性能的方式遠不止這幾種,還包括協程等新的概念需要我們去對比與分析,
轉載請註明出處,本文鏈接:https://www.uj5u.com/ruanti/242953.html
標籤:其他
