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