服務器簡介
服務器是提供計算服務的設備, 由於服務器需要響應用戶請求,因此在處理能力、穩定性、安全性、可擴展性、可管理性等方面提出了較高要求。隨着虛擬化技術的進步, 雲服務器(ECS) 已經快速的在國內普及開來, 其管理方式比物理服務器更簡單高效。用戶可迅速創建或釋放任意多臺雲服務器, 幫助企業降低開發運維的難度和整體 IT 成本, 使整個研發週期更專注於核心業務的創新。在網絡環境下,根據服務器提供的服務類型不同,分爲文件服務器、 數據庫服務器、應用程序服務器、 WEB 服務器等。
此次學習總結的主要內容:
- 如何處理多個客戶端連接。
- 探討面對百萬千萬級客戶端連接時的性能優化。
- 服務器如何高效處理併發的數據。
- 深度分析大數據通信時, Linux 內核瓶頸。
- 如何攻克瓶頸
I/O複用技術
循環方式
當服務器有多個網絡連接需要看管,那麼循環遍歷打開的網絡連接的列表,來判斷是否有要讀取的數據。
缺點:
- 速度緩慢(必須遍歷所有的網絡連接)
- 效率低(處理一個連接時可能發生阻塞,妨礙其他網絡連接的檢查和處理)
示例:
typedef struct ClientInfo{
int client_fd;
string client_ip;
}ClientInfo;//客戶端結構體
std::deque<ClientInfo> m_client1;//客戶端隊列 1
std::deque<ClientInfo> m_client2;//客戶端隊列 2
void MServer::ClientHandel(std::deque<ClientInfo> *client){
char data[1024] = {0};
int len = 0;
for(int i = 0; i < client->size(); ++i){
//當沒有數據可讀時, 發生阻塞
len = read(client->at(i).client_fd, data, sizeof data);
//處理數據
bzero(data,sizeof data);//清空緩存
}
}
select 方式
select 首先將第二三四個參數指向的 fd_set 拷貝到內核,對每個被 SET 的描述符進行 poll,記錄在臨時結果中(fdset),如果有事件發生, select 會將臨時結果寫到用戶空間並返回。
缺點:
select 返回後,需要逐一檢查描述符是否被 SET(事件是否發生)。(select 支持的
文件描述符數量太小了,默認是 1024)。
示例:
void MServer::ClientHandel(std::deque<ClientInfo> *client){
char data[1024] = {0};
fd_set input;// fdset 記錄 poll 結果
int len = 0;
int retval = 0;
FD_ZERO(&input);//清空記錄
for(int i = 0; i < client->size(); ++i){
FD_SET(client->at(i).client_fd, &input);
retval = select(client->at(i).client_fd + 1, &input, NULL, NULL, NULL);
//檢測事件是否發生
if(retval > 0 && FD_ISSET(client->at(i).client_fd, &input)){
//讀取數據
len = read(client->at(i).client_fd, data, sizeof data);
//處理數據
bzero(data,sizeof data);
}
//處理其他事情
}
}
poll方式
poll 與 select 不同,通過一個 pollfd 數組向內核傳遞需要關注的事件,故沒有描述符個數的限制, pollfd 中的 events 字段和 revents 分別用於標示關注的事件和發生的事件,故 pollfd 數組只需要被初始化一次。 poll 的實現機制與 select 類似,其對應內核中的 sys_poll,只不過poll 向內核傳遞 pollfd 數組,然後對 pollfd 中的每個描述符進行 poll,相比處理 fdset 來說, poll 效率更高。
缺點:
poll 需要對 pollfd 中的每個元素檢查其 revents 值,來得知事件是否發生。
示例:
std::vector<struct pollfd> pollfds;
void MServer::ClientHandel(std::deque<ClientInfo> *client){
int nready = 0;
int len = 0;
char data[1024] = {0};
//初始化 pollfds 容器
for(int i = 0; i < client->size(); ++i){
struct pollfd pfd;
pfd.fd = client->at(i).client_fd;//設置 pollfd
pfd.events = POLLIN;//設置 pollin 事件
pfd.revents = 0;//設置沒有任何事件返回,置爲零pollfds.push_back(pfd);
}
while(1){
nready = poll(&*pollfds.begin(), pollfds.size(), -1);//負數表示無限等待,直到發生事
件才返回
for(PollFdList::iterator it = pollfds.begin(); it != pollfds.end() && nready > 0; ++it){ //遍歷查看 fd 產生的事件
if (it->revents & POLLIN){
len = read(it->fd, buf, sizeof data);
//處理數據
bzero(data,sizeof data);
}
}
//處理其他事情
}
}
epoll 方式
epoll 與 select、 poll 不同,其不用每次調用都向內核拷貝事件描述信息,在第一次調用後,事件信息就會與對應的 epoll 描述符關聯起來。其次, epoll 不是通過輪詢,而是通過在等待的描述符上註冊回調函數,當事件發生時,回調函數負責把發生的事件存儲在就緒事件鏈表中,最後寫到用戶空間。
epoll 返回後,該參數指向的緩衝區中即爲發生的事件,對緩衝區中每個元素進行處理即可,而不需要像 poll、 select 那樣進行輪詢檢查。
示例:
void MServer::ClientHandel(std::deque<ClientInfo> *client){
int wait_fds;//事件產生的數量
int i = 0;
int len = 0;
char data[1024] = {0};
int epoll_fd = epoll_create(1024);//創建 epoll
for(i = 0; i < client->size(); ++i){
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET;//設置觸發事件的類型
ev.data.fd = client->at(i).client_fd;
//向 epoll 中增加 client_fd
if( epoll_ctl( epoll_fd, EPOLL_CTL_ADD, client->at(i).client_fd, &ev ) < 0 ){
printf("Epoll Error : %d\n", errno);
exit( EXIT_FAILURE );
}
}struct epoll_event evs[1024];//epoll 事件緩存區
while(1){
if( ( wait_fds = epoll_wait( epoll_fd, evs, 0, -1 ) ) == -1 ){
break;
}
for( i = 0; i < wait_fds; ++i){
len = read( evs[i].data.fd, data, sizeof data);
//處理數據
bzero(data,sizeof data);
}
}
}
多線程方式
多線程技術也可以處理高併發的客戶端連接,因爲在服務器中可以創建大量的線程來監視連接。
缺點:
多線程技術則不太適合處理長連接,因爲建立一個線程 linux 中會消耗棧空間, 當產生大量的連接後, 會導致系統內存消耗殆盡。
示例:
typedef struct ClientInfo{
int client_fd;
pthread_t pid;
bool pthread_enlable;
}ClientInfo;
std::deque<ClientInfo> client;//客戶端隊列
void MServer::ClientHandel(){
int i = 0;
//創建多線程處理連接
for(i = 0; i < client.size(); ++i){
if(pthread_create(&client[i].pid, NULL, ClientPthread, &client[i]) != 0){
client[i].pthread_enlable = true;
}
}
//等待線程結束
for(i = 0; i < client.size(); ++i){
if(client[i].pthread_enlable)
pthread_join(client[i].pid, NULL);
}
}//end func ClientHandel
void *MServer::ClientPthread(void *arg){
char data[1024] = {0};
int len = 0;
while(1){
len = read(((MServer*)arg)->client_fd, data, sizeof data);
//處理數據
bzero(data,sizeof data);//清空緩存
}
pthread_exit(NULL);
}
多線程 + I/O 複用技術,使用一個線程負責監聽一個端口和描述符是否有讀寫事件產生,
再將事件分發給其他的工作線程處理數據。
模型架構:
這種架構主要是基於單線程 I/O 多路複用(select/poll/epoll),達到高併發效果,同時避免了多線程 I/O 來回切換的各種開銷,而基於線程池的多工作者線程,進一步提高業務處理能力和避免產生過多線程。
CPU多核並行計算
程序的線程是指能同時併發執行的邏輯單元的個數,是通過時間片分配算法實現的;
CPU 的線程是指將 CPU 的指令執行過程(取指、譯指、執行、 訪存、寫數)做出流水線從而提高併發度的方法。
並行計算和多線程的區別:
- 並行計算比多線程具有更高的 CPU 利用率,因此效率相對更高。
- 並行計算是利用 CPU 的多核進行計算,而多線程是利用 CPU 一個核在不同時間段內
進行計算。 - 並行計算是多個線程運行在多核 CPU 上,多線程是多線程運行在單核 CPU 上。
綜合上述得出多線程並不能真正提高數據處理能力, 其侷限於單核 CPU 的性能, 當服務器
需要進行大量的數據運算(如圖形處理、 複雜的算法) 時考慮多核並行計算。
深度分析內核性能
中斷處理
當網絡中大量數據包到來時,會產生頻繁的硬件中斷請求,這些硬件中斷可以打斷之前較低優先級的軟中斷或者系統調用的執行過程,如果這種打斷頻繁的話,將會產生較高的性能開銷。
內存拷貝
正常情況下,一個網絡數據包從網卡到應用程序需要經過如下的過程:數據從網卡通過 DMA (直接存儲器訪問) 等方式傳到內核開闢的緩衝區,然後從內核空間拷貝到用戶態空間,在 Linux 內核協議棧中,這個耗時操作甚至佔到了數據包整個處理流程的 57.1%。
上下文切換
頻繁到達的硬件中斷和軟中斷都可能隨時搶佔系統調用的運行,這會產生大量的上下文切換開銷。另外,在基於多線程的服務器設計框架中,線程間的調度也會產生頻繁的上下文切換開銷,同樣,鎖競爭的耗能也是一個非常嚴重的問題。
局部性失效
如今主流的處理器都是多個核心的,這意味着一個數據包的處理可能跨多個 CPU 核心,比如一個數據包可能中斷在 cpu0,內核態處理在 cpu1,用戶態處理在 cpu2,這樣跨多個核心,容易造成 CPU 緩存失效,造成局部性失效。
內存管理
傳統服務器內存頁爲 4K,爲了提高內存的訪問速度,避免 cache miss,可以增加 cache 中映射表的條目,但這又會影響 CPU 的檢索效率。綜合以上問題,可以看出內核本身就是一個非常大的瓶頸所在, 解決方案就是想辦法繞過內核。
高性能網絡框架DPDK
DPDK 爲 Intel 處理器架構下用戶空間高效的數據包處理提供了庫函數和驅動的支持,它不同於 Linux 系統以通用性設計爲目的,而是專注於網絡應用中數據包的高性能處理。
DPDK 官網: https://www.dpdk.org/
DPDK 架構圖:
Linux 內核網絡數據流程:
硬件中斷--->取包分發至內核線程--->軟件中斷--->內核線程在協議棧中處理包--->處理
完畢通知用戶層
用戶層收包-->網絡層--->邏輯層--->業務層
DPDK 網絡數據流程:
硬件中斷--->放棄中斷流程
用戶層通過設備映射取包--->進入用戶層協議棧--->邏輯層--->業務層
下面就具體看看 dpdk 做了哪些突破?
UIO (用戶空間的 I/O 技術)的加持, dpdk 能夠繞過內核協議棧,本質上是得益於 UIO技術,通過 UIO 能夠攔截中斷,並重設中斷回調行爲,從而繞過內核協議棧後續的處理流程。