原文地址:http://unbe.cn/hight_performance_socket_server_02/
上一章,我向大家示範了一個最基本的socket伺服器結構,它一次只能響應一個串連請求,而“能同時響應多個串連和請求”無疑是現實生活中對socket伺服器的最基本要求。要如何讓socket伺服器可以同時響應多個串連和請求呢?多線和多進程程肯定是大部分人首先想到的,可能很多人不一定真正清楚多線程和多進程的socket伺服器架構具體意味著什麼,但是至少大家都或多或少聽說過這兩種技術。不過本章中,我們暫時還不會涉及到多線程和多進程的伺服器架構,我它們歸類為設計範疇,而我們暫時還沒有脫離泥水匠身份,所以還要繼續學習“泥沙之用途“,設計的事情需要等到我們泥水匠畢業,升級建築設計師的時候再說。
那麼本章具體的內容是什麼呢?真是沒有懸念,在上一章中我已經提前透露了:IO重用。下面就正式進入主題吧。
什麼是IO重用
我們先來想像這樣一個情境:一個只有一個櫃檯一個營業員的銀行營業廳。這個銀行所有業務都需要填表格,而且如果填了表格,熟練的營業員能在一瞬間幫你把事情辦完。在繁忙的時候,大家排成長隊等待輪到自己,當排到的時候從營業員手中拿到表格,然後填寫一番,接著交給營業員處理。很顯然,這個營業廳很低效,低效在哪裡呢?它有優秀的營業員,但是缺少合理的運作模式。實際上後面的人排隊等待的時間不是業務處理時間,而是前面的人的填表事件。萬一遇到需要連續辦多件事情的客戶,他堵在那裡填了一份又一份表格,後面的人只能一直等著了。
我們怎麼改進這個營業廳呢?我們可以在大廳設定一個自行領取表格和填寫表格的桌子,讓大家先填好了表格再到櫃檯辦理業務,這樣高效的營業員就能非常快速的處理業務,就幾乎不可能出現排隊了(注意,我說的是"幾乎"不是絕對)。
而我們前一章示範的socket伺服器程式,就像改進之前的銀行營業廳,它的低效在於它沒有充分利用IO,而不是它有複雜的商務邏輯。所以人們為瞭解決這類問題就在設計作業系統的時候加入了IO重用機制,讓編程人員可以更有效利用IO,就像提供了領取和填寫表格的桌子一樣。
IO重用技術有很多種,有些是誇平台的,有些是平台專屬的,這裡就列舉一些我知道的:
| 名稱 |
平台 |
| select |
Linux, *BSD, Mac OS X, Solaris, Windows |
| poll |
Linux, *BSD, Mac OS X |
| epoll |
Linux |
| /dev/poll |
Solaris |
| kqueue |
FreeBSD |
| IOCP |
Windows |
每一種IO重用技術都是通過作業系統提供的一組特定的API函數調用來提供支援的,我們所要學的就是學會怎麼用這些API並且瞭解每種技術背後的原理和來龍去脈。
本章就從最通用應該也是最早出現的IO重用技術select開始。
補充說明一點,IO重用並不局限於用在socket編程上,只要涉及到IO的編程都可以應用。
select的用法
select技術主要由一個函數和幾個宏來提供支援,下面是它們的說明:
/* * 功能:監視多個檔案描述符,直到有一個或多個檔案描述符準備好做某種IO操作時返回 * 返回:當調用成功時,返回已準備好的檔案描述符個數;發生錯誤時返回-1,可以通過errno得到錯誤類型 * 參數: * nfds - 後面三個檔案描述符集合中最大的檔案描述符加1(想想為什嗎?) * readfds - 等待進行讀操作的檔案描述符,指標傳遞,在select返回時會改變這個參數的值,只保留已準備好的檔案描述符 * writefds - 等待進行寫操作的檔案描述符,指標傳遞,在select返回時會改變這個參數的值,只保留已準備好的檔案描述符 * exceptfds - 監視異常的檔案描述符號,很少用到 * timeout - 程式會在select調用的地方阻塞,你可以通過設定逾時讓程式可以在一定時間間隔後繼續執行 */int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);/* * 功能:將指定檔案描述符從指定的描述符集合中清除 * 參數: * fd - 檔案描述符 * set - 檔案描述符集合 */void FD_CLR(int fd, fd_set *set);/* * 功能:檢查一個檔案描述符是否在集合中 * 返回:存在時返回非0整數,不存在則返回0 * 參數: * fd - 檔案描述符 * set - 檔案描述符集合 */int FD_ISSET(int fd, fd_set *set);/* * 功能:設定一個檔案描述符到集合中 * 參數: * fd - 檔案描述符 * set - 檔案描述符集合 */void FD_SET(int fd, fd_set *set);/* * 功能:將一個檔案描述符集合清零 * 參數: * set - 檔案描述符集合 */void FD_ZERO(fd_set *set);
在socket編程中,典型的select用法是:在建立監聽的伺服器socket檔案描述符後,使用FD_ZERO初始化一個空的檔案描述符集合,然後把監聽的socket檔案描述符通過FD_SET放入"讀"集合中,然後進入伺服器迴圈,當select返回時,用FD_ISSET檢查readfds中是否有監聽的socket檔案描述符時,如果有說明有新的串連請求,這時候就調用accept接受串連,把accept返回的串連檔案描述符放入自己維護的一個串連集合中,並把串連的檔案描述符通過FD_SET放入"讀"集合中,通過迴圈自己維護的串連集合中的檔案描述符,調用FD_ISSET來判斷是否某個串連有資料可讀取,如果有就讀取並處理。
上面這麼說可能很籠統也很模糊,下面通過具體的代碼示範select的用法和串連的維護讓大家對select有一個具象的瞭解:
#include <stdio.h>#include <stdlib.h>#include <string.h>#include <sys/socket.h>#include <netinet/in.h>#define SD_PORT 10086#define SD_BACK_LOG 10#define SD_MAX_CLIENT 3int sd_listener_fd;void sd_init (){ int reuse = 1; struct sockaddr_in addr; if ((sd_listener_fd = socket(PF_INET, SOCK_STREAM, 0)) == -1) { perror("Create listener socket failed"); exit(-1); } if (setsockopt(sd_listener_fd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)) == -1) { perror("Setup listener socket failed"); exit(-1); } bzero(&(addr.sin_zero), 8); addr.sin_family = AF_INET; addr.sin_port = htons(SD_PORT); addr.sin_addr.s_addr = htonl(INADDR_ANY); if (bind(sd_listener_fd, (struct sockaddr *)&addr, sizeof(addr)) == -1) { perror("Bind listener socket address failed"); exit(-1); } if (listen(sd_listener_fd, SD_BACK_LOG) == -1) { perror("Listen port failed"); exit(-1); }}void sd_loop (){ char buf[1024]; int i = 0, j = 0; int ret = 0; int client_fd; int client_addr_len; int client_fds[SD_MAX_CLIENT]; int client_fd_max_i = -1; int max_fd = sd_listener_fd; struct sockaddr_in client_addr; fd_set read_fds, ready_read_fds; FD_ZERO(&read_fds); FD_SET(sd_listener_fd, &read_fds); for (i = 0; i < SD_MAX_CLIENT; i++) { client_fds[i] = -1; } printf("Waiting connect on port %d\n", SD_PORT); for (;;) { ready_read_fds = read_fds; ret = select(max_fd + 1, &ready_read_fds, NULL, NULL, NULL); if (ret == -1) { perror("Select failed"); break; } if (ret == 0) continue; if (FD_ISSET(sd_listener_fd, &ready_read_fds)) { client_fd = accept(sd_listener_fd, (struct sockaddr *)&client_addr, &client_addr_len); FD_SET(client_fd, &read_fds); for (i = 0; i < SD_MAX_CLIENT; i++) { if (client_fds[i] == -1) { client_fds[i] = client_fd; break; } } printf("Client connected\n"); if (i == SD_MAX_CLIENT - 1) { FD_CLR(sd_listener_fd, &read_fds); printf("Connection is full\n"); } if (client_fd > max_fd) max_fd = client_fd; if (i > client_fd_max_i) client_fd_max_i = i; if (-- ret == 0) continue; } for (i = 0; i <= client_fd_max_i; i ++) { client_fd = client_fds[i]; if (client_fd == -1) continue; if (FD_ISSET(client_fd, &ready_read_fds)) { if ((ret = read(client_fd, buf, 1024)) == 0) { close(client_fd); FD_CLR(client_fd, &read_fds); client_fds[i] = -1; printf("Client closed\n"); if (!FD_ISSET(sd_listener_fd, &read_fds)) { FD_SET(sd_listener_fd, &read_fds); printf("Listener come back\n"); } max_fd = sd_listener_fd; for (j = 0; j <= client_fd_max_i; j ++) { if (client_fds[j] > max_fd) max_fd = client_fds[j]; } if (i == client_fd_max_i) { while ((client_fd_max_i -= 1) < -1) { if (client_fds[client_fd_max_i] != -1) break; } } } else { write(client_fd, buf, ret); } if (-- ret == 0) break; } } }}voidsd_down (){ close(sd_listener_fd); printf("Server shutdown\n");}intmain (int argc, char *argv[]){ sd_init(); sd_loop(); sd_down(); return 1;}
上面的代碼比前一章的例子複雜了很多,因為現在我們的echo伺服器已經具備了同時響應多個用戶端的能力。編譯方式還是跟前一章一樣,這裡就不再重複說明。你現在可以同時用多個telnet串連到伺服器上進行測試,為了方便測試,我通過SD_MAX_CLIENT指定了最大串連數是3,當達到最大串連數時,伺服器就不再接受新的串連了。
比起之前的例子,代碼增加都在sd_loop中,看起來很長,但如果分解開來其實邏輯很清晰。前面一大塊是變數聲明,接著進入無限迴圈,重複持續時間的開頭阻塞在select調用,直到有檔案描述準備好做操作select才返回,然後就是一個if包圍的新串連接入處理,接著是for迴圈包圍的串連請求處理。
因為select返回時會改變參數的值,所以在每次select之前,我們都會把read_fds賦值給ready_read_fds,然後把ready_read_fds傳遞給select函數,這樣read_fds本身就不會受到影響了。
樣本中沒有的東西
上面的樣本只有一個模式,而select函數實際上有三種使用模式,具體內容大家可以通過man select查閱文檔。
樣本中代碼只體現了select的用法,並沒有直接告訴我們select背後的機制和原理,不過我們通過使用方式來自己推測個大概。當然,我們也可以通過閱讀Linux核心代碼做到100%瞭解,但這就離我們主題有些遠了,留給大家自己研究吧。我們把監聽的檔案描訴符集合丟給select,自然select內部會遍曆監視這些檔案描訴符,然後把具備特定狀態的檔案描述符保留在監視集合中其餘的清除,然後把集合返回給調用者。所以select內部應該是一個遍曆過程,而遍曆過程需要有遍曆結束的判斷,所以才會需要我們傳入maxfd作為參數。
樣本中不能體現出的還有一點就是select的局限性,select能較好的解決io重用的問題,至少大幅度的提高了我們程式中io的使用效率(從無到有),但是它並不是完美的,它也有一些需要改進的地方。select在有些作業系統上有單個進程監聽的檔案描述符個數限制,至少通過萬能的網路,我可以確認在Linux和Windows核心中的確有這樣的限制,Linux核心代碼通過一個FD_SETSIZE的宏約束了檔案描訴符集合的元素上限,這個值預設是1024,也就是說我們樣本程式監視的用戶端串連最多隻能有1024個,網上也有人提供各種奇技淫巧來突破這個限制,不過我不推薦這麼做,因為這樣做不自然,並且還有其他的io重用技術可以讓我們選用。
我們的範例程式碼商務邏輯很簡單,它只是簡單返回收到的內容,但假設我們現在做的是一個MMORPG遊戲的伺服器,它在收到請求是可能需要執行複雜的遊戲操作邏輯,那麼socket伺服器是不是很可能在商務邏輯處理的地方阻塞了呢?就像本章開頭的銀行裡子中,我們假設了營業員充分高效,但現實生活中並不一定是這樣。這就是有待我們解決的問題了。
樣本中只用到select的readfds參數。原因很簡單,為了讓代碼簡單,如果用上writefds意味著就要區分開用戶端的讀和寫處理,這會讓代碼更加複雜,對於我們示範select的基本使用來說這是不利的。但是對於高效能socket伺服器來說區分讀和寫是必需的,因為你得用盡一切辦法避免阻塞,上面的例子在實際應用中,很可能會在write的地方由於用戶端沒有準備好或者網路不暢導致程式在此位置阻塞,這就像是銀行那個例子中有人沒填好表格就跑到櫃檯前佔著位置填表格,其實沒有在做正事但後面的人都得等他一樣,會影響到整體的執行效率。
下面是經過進一步抽象和細化的echo伺服器,它已經將讀寫事件區分開,並把伺服器狀態和串連抽象成不同的資料結構,由不同的函數負責不同操作:
#include <stdio.h>#include <stdlib.h>#include <string.h>#include <sys/socket.h>#include <netinet/in.h>#define SD_PORT 10086#define SD_BACK_LOG 10#define SD_MAX_CONN 10#define sd_MAX_CONN 2000typedef struct{ int buf_len; char* buf;}sd_conn_state;typedef struct{ int id; int fd; struct sockaddr_in addr; sd_conn_state* state;}sd_conn;typedef struct{ int max_fd; int listener_fd; fd_set read_fds; fd_set can_read_fds; fd_set write_fds; fd_set can_write_fds; int conn_max; int conn_free; int conn_count; sd_conn* conn_items;}sd_state;void sd_init (sd_state* state){ int i; sd_conn* conn; int reuse = 1; struct sockaddr_in addr; if ((state->listener_fd = socket(PF_INET, SOCK_STREAM, 0)) == -1) { perror("Create listener socket failed"); exit(1); } if (setsockopt(state->listener_fd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)) == -1) { perror("Setup listener socket failed"); exit(1); } bzero(&(addr.sin_zero), 8); addr.sin_family = AF_INET; addr.sin_port = htons(SD_PORT); addr.sin_addr.s_addr = htonl(INADDR_ANY); if (bind(state->listener_fd, (struct sockaddr *)&addr, sizeof(addr)) == -1) { perror("Bind listener socket address failed"); exit(1); } if (listen(state->listener_fd, SD_BACK_LOG) == -1) { perror("Listen port failed"); exit(1); } state->max_fd = state->listener_fd; FD_ZERO(&state->read_fds); FD_ZERO(&state->can_read_fds); FD_ZERO(&state->write_fds); FD_ZERO(&state->can_write_fds); FD_SET(state->listener_fd, &state->read_fds); state->conn_max = -1; state->conn_free = SD_MAX_CONN; state->conn_count = SD_MAX_CONN; state->conn_items = calloc(SD_MAX_CONN, sizeof(sd_conn)); for (i = 0; i < SD_MAX_CONN; i++) { conn = &state->conn_items[i]; conn->id = -1; conn->fd = -1; conn->state = NULL; }}voidsd_conn_accept (sd_state* state){ int i; int addr_size; sd_conn* conn; for (i = 0; i < state->conn_count; i ++) { conn = &state->conn_items[i]; if (conn->fd >= 0) continue; conn->id = i; conn->state = NULL; addr_size = sizeof(conn->addr); conn->fd = accept(state->listener_fd, (struct sockaddr *)&conn->addr, &addr_size); if (conn->fd > state->max_fd) state->max_fd = conn->fd; if (conn->id > state->conn_max) state->conn_max = conn->id; FD_SET(conn->fd, &state->read_fds); state->conn_free --; printf("Client connected\n"); break; }}voidsd_conn_close (sd_state* state, sd_conn* conn){ int i; sd_conn* temp_conn; if (conn->id == state->conn_max) { for (i = conn->id - 1; i >= 0; i --) { if (i < 0) { state->conn_max = -1; break; } temp_conn = &state->conn_items[i]; if (temp_conn->fd > 0) { state->conn_max = temp_conn->id; break; } } } if (conn->fd == state->max_fd) { if (state->conn_max >= 0) { state->max_fd = state->listener_fd; for (i = 0; i <= state->conn_max; i ++) { temp_conn = &state->conn_items[i]; if (temp_conn->fd > state->max_fd) { state->max_fd = temp_conn->fd; } } } } FD_CLR(conn->fd, &state->read_fds); FD_CLR(conn->fd, &state->write_fds); close(conn->fd); conn->id = -1; conn->fd = -1; if (conn->state != NULL) { free(conn->state); } state->conn_free ++; printf("Client close\n");}voidsd_conn_proc (sd_state* state, sd_conn* conn){ int ret; char* buf; buf = calloc(1024, sizeof(char)); if ((ret = read(conn->fd, buf, 1024)) == 0) { sd_conn_close(state, conn); free(buf); } else { conn->state = calloc(1, sizeof(sd_conn_state)); conn->state->buf = buf; conn->state->buf_len = ret; FD_SET(conn->fd, &state->write_fds); }}voidsd_conn_repo (sd_state* state, sd_conn* conn){ if (conn->state == NULL) return; write(conn->fd, conn->state->buf, conn->state->buf_len); free(conn->state->buf); free(conn->state); conn->state = NULL; FD_CLR(conn->fd, &state->write_fds);}void sd_loop (sd_state* state){ int i = 0; sd_conn* conn; for (;;) { state->can_read_fds = state->read_fds; state->can_write_fds = state->write_fds; int num_ready = select(state->max_fd + 1, &state->can_read_fds, &state->can_write_fds, NULL, NULL); if (num_ready == 0) continue; if (num_ready == -1) { perror("Select failed\n"); exit(1); } if (FD_ISSET(state->listener_fd, &state->can_read_fds)) { sd_conn_accept(state); if (-- num_ready == 0) continue; } for (i = 0; i <= state->conn_max; i ++) { conn = &state->conn_items[i]; if (conn->fd == -1) continue; if (FD_ISSET(conn->fd, &state->can_read_fds)) { sd_conn_proc(state, conn); if (-- num_ready == 0) break; } if (FD_ISSET(conn->fd, &state->can_write_fds)) { sd_conn_repo(state, conn); if (-- num_ready == 0) break; } } }}voidsd_down (sd_state* state){ close(state->listener_fd); printf("Server shutdown\n");}intmain (int argc, char *argv[]){ sd_state state; sd_init(&state); sd_loop(&state); sd_down(&state); return 1;}本章總結
本章通過示範select的使用向大家展示了io重用技術是怎樣提高io使用效率的,讓我們的socket伺服器程式的可用向前邁了一大步。
但是邁出這步後,還有無數的挑戰的等著我們,例如上面說的select的限制、複雜商務邏輯阻塞,等等。
後續我可能會繼續花一到兩章來描述poll和epoll,但也可能直接就介紹如何使用誇平台的libev和libevent庫。特別是poll實際上它對select來說沒有改進的地方,屬於同一水平,實際上它們只是起源不一樣,但是時代差不多所以水平也就差不多,而後續的epoll、dev/poll、iocp則是百家爭鳴時期各個作業系統平台為了進一步提高io重用效率而設計的新機制,它們才本質上對select和poll等老模式進行了改進,所以我可能會跳過poll介紹epoll。
libev的實驗代碼我其實已經做好了,poll和epoll有點懶得重複了,具體怎麼樣還不確定就留個懸念吧,呵呵。