通常我們寫一個linux的client和server如下圖:
但是怎么提升性能?系統(tǒng)是如何快速處理網(wǎng)絡(luò)事件?因此本文就來談?wù)処O復(fù)用和模式。
第一部分:模式
我們都知道socket
分為阻塞和非阻塞,阻塞情況就是卡住流程,必須等事件發(fā)生;而非阻塞是立即返回,不管事件是否有沒有準備好,需要上層代碼通過EAGAIN
,EWOULDBLOCK
和EINPROGRESS
等errno返回值來判斷,基于非阻塞有兩種網(wǎng)絡(luò)編程模式:Reactor和Proactor事件處理。
1、Reactor
同步IO模型一般使用Reactor,如果使用線程模式,Reactor是遇到事件就通知工作線程處理,然后主線程繼續(xù)循環(huán)等待事件的發(fā)生:
reactor
(1)對于網(wǎng)絡(luò)讀寫,先將socket
注冊到epoll
內(nèi)核事件表中;
(2)使用epoll_wait
等待句柄的讀寫事件;
(3)當(dāng)句柄的可讀可寫事件發(fā)生,通知工作線程執(zhí)行對應(yīng)的讀寫動作;
(4)當(dāng)工作線程處理完讀寫動作,如果還有后續(xù)讀寫,工作線程可以將句柄繼續(xù)注冊到epoll
內(nèi)核事件表中;
(5)主線程繼續(xù)用epoll_wait
等待事件發(fā)生,然后繼續(xù)告知工作線程處理;
2、Proactor
在講Proactor之前我們先說說一個例子:
...
#include < libaio.h >
int main() {
io_context_t context;
struct iocb io[1], *p[1] = {&io[0]};
struct io_event e[1];
...
// 1. 打開要進行異步IO的文件
int fd = open("xxx", O_CREAT|O_RDWR|O_DIRECT, 0644);
if (fd < 0) {
printf("open error: %dn", errno);
return 0;
}
// 2. 創(chuàng)建一個異步IO上下文
if (0 != io_setup(nr_events, &context)) {
printf("io_setup error: %dn", errno);
return 0;
}
// 3. 創(chuàng)建一個異步IO任務(wù)
io_prep_pwrite(&io[0], fd, wbuf, wbuflen, 0);
// 4. 提交異步IO任務(wù)
if ((ret = io_submit(context, 1, p)) != 1) {
printf("io_submit error: %dn", ret);
io_destroy(context);
return -1;
}
while (1) {
// 5. 獲取異步IO的結(jié)果
ret = io_getevents(context, 1, 1, e, &timeout);
if (ret < 0) {
printf("io_getevents error: %dn", ret);
break;
}
...
}
...
}
以上就是linux的aio處理一個讀寫文件的流程,可以看到整個流程不需要工作線程處理,而是由內(nèi)核直接處理后,主線程只需要等待處理結(jié)果即可。
proactor
3、Half-Reactor
前面提到Reactor大家從圖中看出,都是主線程等待事件,分發(fā)事件,然后工作線程爭搶事件后處理,這里會有幾個缺點:
(1)工作線程需要加鎖取出自己的工作任務(wù),浪費CPU;
(2)工作線程取出隊列一次只能處理一個,對于CPU密集型的任務(wù)可以跑滿CPU,但是如果是IO密集型任務(wù),這個工作線程又會切換到休眠或者等待其他任務(wù),不能充分利用CPU;
為了解決以上缺點,于是提出了Half-Reactor
半反應(yīng)堆模式:
Half-Reactor
第二部分:IO復(fù)用
在開發(fā)一些業(yè)務(wù)面前,我們可能會面對C10K,C100K或者C10M等問題,只是靠堆服務(wù)器可能不能完全解決,所以我們就需要從IO復(fù)用來處理服務(wù)的并發(fā)能力,這里我們就直接講epoll(對于select,poll和epoll的大概區(qū)別應(yīng)該都知道了,所以就不詳細說了,如果有疑問可以留言給我),同時找了一張libevent的幾個事件處理性能對比:
libevent
1、epoll的使用
#include < sys/epoll.h >
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);
(1)epoll_create
創(chuàng)建一個內(nèi)核事件表,size
可以指定大小,但是并沒有作用;
(2)epoll_ctl
操作事件,epfd
就是epoll事件表,op
指定操作類型(EPOLL_CTL_ADD往事件表添加fd,EPOLL_CTL_MOD往事件表修改fd,EPOLL_CTL_DEL往事件表刪除fd);
(3)struct epoll_event
其結(jié)構(gòu)體:
sturct epoll_event
{
_uint32_t events; // EPOLLIN(數(shù)據(jù)可讀),EPOLLOUT(數(shù)據(jù)可寫)...
epoll_data_t data; // 用于存儲用戶監(jiān)聽事件句柄需要在上下文攜帶的用戶數(shù)據(jù)
}
(4)epoll_wait
等待事件發(fā)生,events
返回發(fā)生事件的列表,timeout
等待一定的超時時間,如果沒有事件發(fā)生依舊返回,maxevents
最多一次監(jiān)聽集合的大小;
2、LT和ET
(1)LT是epoll對文件操作符的模式,表示電平觸發(fā)(Level Trigger),當(dāng)epoll_wait
監(jiān)聽了事件,上層可以不處理該事件,下一次epoll_wait
依舊會觸發(fā);
(2)ET是epoll對文件描述符的高效模式,表示邊緣觸發(fā)(Edge Trigger),當(dāng)epoll_wait
監(jiān)聽了事件,如果不處理下一次不會再觸發(fā),需要應(yīng)用層一次就處理完,這樣可以減少觸發(fā)的次數(shù),從而提升性能。
所以要注意對于read使用將套接口設(shè)置為非阻塞,再用while循環(huán)包住read一直讀到產(chǎn)生EAGAIN錯誤,采用非阻塞套接口的原因在于防止read被阻塞住。
3、樣例
詳細代碼由于篇幅原因,就不寫了,大概流程如下:
...
listen_fd = bind(...);
listen(listen_fd, LISTENQ);
int epoll_fd;
struct epoll_event events[10];
int nfds, i, fd;
...
// 創(chuàng)建一個描述符
epoll_fd = epoll_create(...);
// 添加監(jiān)聽描述符事件
epoll_ctl(epoll_fd, ... listen_fd, ... EPOLLIN);
for ( ; ; )
{
nfds = epoll_wait(epoll_fd, events, sizeof(events)/sizeof(events[0]), 1000);
for (i = 0; i < nfds; i++)
{
fd = events[i].data.fd;
if (fd == listen_fd)
{
// 創(chuàng)建新連接
...
}
else if (events[i].events & EPOLLIN)
{
// 讀取socket數(shù)據(jù)
....
}
else if(events[i].events & EPOLLOUT)
{
// 寫入socket數(shù)據(jù)
...
}
}
}
close(epoll_fd);
4、epoll
的實現(xiàn)
epoll
底層數(shù)據(jù)結(jié)構(gòu)是紅黑樹和鏈表組成,通過epoll_ctrl
增加、減少事件,其中epoll
結(jié)構(gòu)體如下:
struct eventpoll
{
wait_queue_head_t wq;
struct list_head rdlist;
struct rb_root rbr;
...
}
epoll
(1)wq
是等待隊列,用于epoll_wait
;
(2)rdlist
是就緒隊列,當(dāng)有事件觸發(fā)時候,內(nèi)核會將句柄等信息放入rdlist,方便快速獲取,不需要遍歷紅黑樹;
(3)rbr
是一顆紅黑樹,支持增加,刪除和查找,管理用戶添加的socket信息;
第三部分:提升網(wǎng)絡(luò)編程中服務(wù)器性能的建議
在網(wǎng)絡(luò)編程中我們會遇到各種各樣的處理任務(wù),比如純轉(zhuǎn)發(fā)的proxy,需要處理https的server,需要處理任務(wù)的業(yè)務(wù)邏輯server等等,而且在微服務(wù)時代和云原生時代可能這些問題更加復(fù)雜,比如我們需要在server前加上斷路器,在容器服務(wù)中我們都適用多線程模式等等。雖然面臨很多問題,但是網(wǎng)絡(luò)編程中服務(wù)器性能還是最基礎(chǔ)的那些問題,于是基于我的一些經(jīng)驗,我整理了一些。
1、復(fù)用
(1)線程復(fù)用 :前面提到的工作線程,我們不應(yīng)該對于每個客戶端都開一個線程,而是構(gòu)建一個線程pool,當(dāng)某些線程空閑就可以從隊列中取事件或者數(shù)據(jù)進行處理,畢竟linux中的線程和進程調(diào)度方式一樣,線程太多必然加劇內(nèi)核的負載;
(2)內(nèi)存復(fù)用 :在網(wǎng)絡(luò)狀態(tài)流轉(zhuǎn)和工作線程流轉(zhuǎn)過程中,我們需要盡可能考慮內(nèi)存復(fù)用,而不是在每一層中都拷貝,比如一個請求從內(nèi)核讀到數(shù)據(jù)以后,盡可能在當(dāng)前請求的什么周期內(nèi),一直使用相同的內(nèi)存塊(包括在業(yè)務(wù)層,盡量使用指針偏移量操作),減少拷貝;
當(dāng)然減少內(nèi)存拷貝以外,還需要做的就是同一塊內(nèi)存用完不是讓系統(tǒng)回收,而是自己放到內(nèi)存pool中,等待下一次請求需要再復(fù)用;
2、減少內(nèi)存拷貝
這里上一篇文章提到的零拷貝,就是減少內(nèi)存拷貝的一種方式,比如在文件讀寫方面能提升性能(可以參考nginx的sendfile),另一種可以使用共享內(nèi)存,通過一寫多讀的方式解決一些場景下的內(nèi)存拷貝;
3、減少上下文切換和競爭
上下文切換是阻礙性能提升的一個問題,比如頻繁的事件觸發(fā)會導(dǎo)致主線程和工作線程之間切換,其CPU時間會被浪費;小量的數(shù)據(jù)包多次觸發(fā)讀處理等。因此我們在寫server過程中對于能在同一個上下文處理的,就不必要再丟該其他線程處理,對于多個小塊數(shù)據(jù)可以等待一段超時時間一起處理(當(dāng)然具體問題可以分析);
競爭也是阻礙性能提升的一個問題,掙搶共享資源會一段CPU時間片內(nèi)阻塞操作,減少鎖的使用或者將鎖拆分更加細粒度的鎖,減少鎖住臨界區(qū)的范圍,是我們需要注意的;
4、利用CPU親和性
這里以nginx為例,提供了一個worker_cpu_affinity
,cpu的親和性能使nginx對于不同的work工作進程綁定到不同的cpu上面去。就能夠減少在work間不斷切換cpu,進程通常不會在處理器之間頻繁遷移,進程遷移的頻率小,來減少性能損耗。
這種可以參考CPU性能提升方式,比如在NUMA下,處理器訪問它自己的本地存儲器的速度比非本地存儲器(存儲器的地方到另一個處理器之間共享的處理器或存儲器)快一些,所以針對NUMA架構(gòu)系統(tǒng)的特點,可以通過將進程/線程綁定指定CPU(一個或多個)的方式,提高CPU CACHE的命中率,減少進程/線程遷移CPU造成的內(nèi)存訪問的時間消耗,從而提高程序的運行效率。
5、協(xié)程
協(xié)程是一種用戶態(tài)線程,在現(xiàn)在主流的server框架,協(xié)程已經(jīng)成為一個提升性能的銀彈(比如golang寫server又快又方便),后續(xù)文章會專門介紹協(xié)程(在此埋一個坑),但是協(xié)程也不是萬能的,需要定位本身業(yè)務(wù)特點,比如IO密集型就適合(當(dāng)然這里也需要情況而定,比如純轉(zhuǎn)發(fā)類型的面對長尾延時,可能協(xié)程也不合適),CPU密集型自己調(diào)度協(xié)程還是比較麻煩的,所以在做優(yōu)化的適合可以拷貝業(yè)務(wù)的特性和后續(xù)的擴展而定,畢竟沒有一個框架是萬能的。
評論
查看更多