《Linux高性能服務器編程》學習總結(六)——高級I/O函數
第六章 高級I/O函數
網絡I/O一直是Linux網絡編程中極其重要的一部分,除了前面講到的send、recv等,socket編程接口還給出了很多高級了I/O函數,這些函數大致分為三類:用於創建文件描述符的函數、用於讀寫控制的函數和用於控制I/O行為和屬性的函數。
pipe函數是用來創建一個管道,管道是較為原始的進程間通信手段,分為無名管道和有名管道,而無名管道只能用於有親緣關系的進程之間傳遞消息。pipe建立的管道是單工的,其參數是一個包含兩個元素的整形數組fd[2],創建成功後fd[0]代表管道可讀的一端,fd[1]代表可寫的一端,這兩個的本質都是文件描述符,當進程間有數據要傳輸時,數據發送的一端需要關閉fd[0],接收端要關閉fd[1],才能正常傳送數據。需要註意的是無名管道只能用低級文件編程庫中的讀寫函數進行操作,如read和write,當我們向一個空管道執行read時,函數會阻塞,直到有數據寫入才繼續執行,同理對滿的管道執行write也會進入阻塞狀態。但是如果對於這兩個文件描述符設置為非阻塞模式,則他們會有不同的行為。如果fd[1]的引用計數減少至0,即沒有寫端進程向管道中寫,則fd[0]上的read操作將會讀取到EOF標誌,返回0;反之如果fd[0]上的引用計數減少至0,即沒有讀端程序調用read,則此時fd[1]上的write操作將失敗並引發SIGPIPE信號。為了便於使用,API中還有一個函數用來創建雙向管道,是socketpair函數,使用這個函數創建的雙向管道只能使用AF_UNIX協議,即UNIX本地域協議族,它創建的兩個文件描述符是既可讀又可寫的。
dup函數和dup2函數用於復制文件描述符,區別在於dup函數是將一個文件描述符復制到當前系統可用的最小整數值,而dup2則是不小於其參數的最小整數值,註意,通過這兩個函數復制的文件描述符不繼承其原來的屬性。我們來看一個CGI服務器的例子:
1 /************************************************************************* 2 > File Name: 6-1.cpp 3 > Author: Torrance_ZHANG 4 > Mail: [email protected]5 > Created Time: Thu 01 Feb 2018 11:29:09 PM PST 6 ************************************************************************/ 7 8 #include"head.h" 9 using namespace std; 10 11 int main(int argc, char **argv) { 12 if(argc <= 2) { 13 printf("usage: %s ip_address port_number\n", basename(argv[0])); 14 return 1; 15 } 16 const char* ip = argv[1]; 17 int port = atoi(argv[2]); 18 struct sockaddr_in address; 19 bzero(&address, sizeof(address)); 20 address.sin_family = AF_INET; 21 inet_pton(AF_INET, ip, &address.sin_addr); 22 address.sin_port = htons(port); 23 24 int sock = socket(AF_INET, SOCK_STREAM, 0); 25 assert(sock >= 0); 26 27 int ret = bind(sock, (struct sockaddr*)&address, sizeof(address)); 28 assert(ret != -1); 29 30 ret = listen(sock, 5); 31 assert(ret != -1); 32 33 struct sockaddr_in client; 34 socklen_t client_addrlength = sizeof(client); 35 int connfd = accept(sock, (struct sockaddr*)&client, &client_addrlength); 36 if(connfd < 0) { 37 printf("errno is: %d\n", errno); 38 } 39 else { 40 close(STDOUT_FILENO); 41 int newfd = dup(connfd); 42 printf("abcd\n"); 43 close(connfd); 44 } 45 close(sock); 46 return 0; 47 }
使用telnet客戶端連接服務器發現有abcd的回顯,通過這個例子我們可以看到,我們關閉了標準輸出文件描述符後再調用dup,會將要復制的connfd復制到當前未使用的最小的文件描述符也就是標準輸出文件描述符上,實現了輸出的重定向。
readv和writev函數和前面提過的readmsg和writemsg函數類似,也是用來對數據的集中寫和分散讀,相當於前面兩個函數的簡化版。舉一個例子來說明,在Web服務器解析完HTTP請求後如果客戶端請求的文件存在並且有權限時,就需要返回一個HTTP首部狀態碼和狀態信息,然後再返回該文件,但是我們考慮效率問題,如果每次我們都需要將兩個不相關的存儲空間合並到一起再發送勢必會很影響效率,所以我們可以事先將HTTP不同的頭部存儲好,找到文件後使用sendv函數直接發送即可。我們建立一個test.txt文件模擬一下,服務器代碼如下:
1 /************************************************************************* 2 > File Name: 6-2.cpp 3 > Author: Torrance_ZHANG 4 > Mail: [email protected] 5 > Created Time: Fri 02 Feb 2018 01:30:46 AM PST 6 ************************************************************************/ 7 8 #include"head.h" 9 using namespace std; 10 11 #define BUFFER_SIZE 1024 12 //用於定義HTTP的成功和失敗的狀態碼和狀態信息 13 static const char* status_line[2] = {"200 OK", "500 Internal server error"}; 14 15 int main(int argc, char **argv) { 16 if(argc <= 3) { 17 printf("usage: %s ip_address port_number filename\n", basename(argv[0])); 18 return 1; 19 } 20 const char* ip = argv[1]; 21 int port = atoi(argv[2]); 22 const char* file_name = argv[3]; 23 24 struct sockaddr_in address; 25 bzero(&address, sizeof(address)); 26 address.sin_family = AF_INET; 27 address.sin_port = htons(port); 28 inet_pton(AF_INET, ip, &address.sin_addr); 29 30 int sock = socket(AF_INET, SOCK_STREAM, 0); 31 assert(sock >= 0); 32 33 int ret = bind(sock, (struct sockaddr*)&address, sizeof(address)); 34 assert(ret != -1); 35 36 ret = listen(sock, 5); 37 assert(ret != -1); 38 39 struct sockaddr_in client; 40 socklen_t client_addrlength = sizeof(client); 41 int connfd = accept(sock, (struct sockaddr*)&client, &client_addrlength); 42 if(connfd < 0) { 43 printf("errno is: %d\n", errno); 44 } 45 else { 46 char header_buf[BUFFER_SIZE]; 47 memset(header_buf, 0, sizeof(header_buf)); 48 char *file_buf; 49 struct stat file_stat; //用於獲取文件屬性的結構體 50 bool file_is_valid = true; 51 int len = 0; 52 if(stat(file_name, &file_stat) < 0) { //獲取文件信息 53 file_is_valid = false; 54 } 55 else { 56 if(S_ISDIR(file_stat.st_mode)) { //如果是目錄 57 file_is_valid = false; 58 } 59 else if(file_stat.st_mode & S_IROTH) { //如果當前用戶對文件有讀的權限 60 int fd = open(file_name, O_RDONLY); 61 file_buf = new char[file_stat.st_size + 1]; 62 memset(file_buf, 0, sizeof(file_buf)); 63 if(read(fd, file_buf, file_stat.st_size + 1) < 0) { 64 file_is_valid = false; 65 } 66 } 67 else file_is_valid = false; 68 //如果文件合法則返回正確的狀態信息以及文件內容,否則返回錯誤 69 if(file_is_valid) { 70 ret = snprintf(header_buf, BUFFER_SIZE - 1, "%s %s\r\n", "HTTP/1.1", status_line[0]); 71 len += ret; 72 ret = snprintf(header_buf + len, BUFFER_SIZE - 1 - len, "Content-Length: %d\r\n", (int)file_stat.st_size); 73 len += ret; 74 ret = snprintf(header_buf + len, BUFFER_SIZE - 1 - len, "%s", "\r\n"); 75 //將頭部信息和文件信息裝入不同的iovec中調用writev集中寫 76 struct iovec iv[2]; 77 iv[0].iov_base = header_buf; 78 iv[0].iov_len = strlen(header_buf); 79 iv[1].iov_base = file_buf; 80 iv[1].iov_len = strlen(file_buf); 81 ret = writev(connfd, iv, 2); 82 } 83 else { 84 ret = snprintf(header_buf, BUFFER_SIZE - 1, "%s %s\r\n", "HTTP/1.1", status_line[1]); 85 len += ret; 86 ret = snprintf(header_buf + len, BUFFER_SIZE - 1 - len, "%s", "\r\n"); 87 send(connfd, header_buf, strlen(header_buf), 0); 88 } 89 } 90 close(connfd); 91 delete []file_buf; 92 } 93 close(sock); 94 return 0; 95 }
使用telnet連接連接服務器端,發現正常回顯了HTTP首部和數據。
接下來還有一個較為常用的函數sendfile,它的作用是在兩個文件描述符之間直接傳遞數據,是一個零拷貝函數。所謂零拷貝函數,首先要知道內核空間和用戶空間的概念和區別。Linux操作系統的內核使用了內存中的低地址區域,這裏是我們在編程時不能訪問的,很多緩沖區都是在這裏定義,而用戶空間就是其余的內存空間,我們在編寫程序時可以進行操作。平時我們調用recv函數會將網絡I/O數據拷貝到定義的用戶緩沖區內,這樣就會在內核空間和用戶空間之間進行數據拷貝,這樣就會導致進程再內核態和用戶態之間進行頻繁轉換,降低效率。而零拷貝函數可以直接在內核態完成數據的傳遞,效率較高。其函數原型如下:
1 #include<sys/sendfile.h> 2 ssize_t sendfile(int out_fd, int in_fd, off_t* offset, size_t count);
值得註意的是,out_fd是待寫入的文件描述符,必須是一個socket,而in_fd是待讀出的文件描述符,但它必須指向真實的文件,不能使管道或者socket。我們用一個例子來看一下:
1 /************************************************************************* 2 > File Name: 6-3.cpp 3 > Author: Torrance_ZHANG 4 > Mail: [email protected] 5 > Created Time: Fri 02 Feb 2018 03:25:03 AM PST 6 ************************************************************************/ 7 8 #include"head.h" 9 using namespace std; 10 11 int main(int argc, char** argv) { 12 if(argc <= 3) { 13 printf("usage: %s ip_address port_number file_name", basename(argv[0])); 14 return 1; 15 } 16 const char* ip = argv[1]; 17 int port = atoi(argv[2]); 18 const char* file_name = argv[3]; 19 20 int filefd = open(file_name, O_RDONLY); 21 assert(filefd > 0); 22 struct stat stat_buf; 23 fstat(filefd, &stat_buf); 24 25 struct sockaddr_in address; 26 bzero(&address, sizeof(address)); 27 address.sin_family = AF_INET; 28 inet_pton(AF_INET, ip, &address.sin_addr); 29 address.sin_port = htons(port); 30 31 int sock = socket(AF_INET, SOCK_STREAM, 0); 32 assert(sock >= 0); 33 34 int ret = bind(sock, (struct sockaddr*)&address, sizeof(address)); 35 assert(ret != -1); 36 37 ret = listen(sock, 5); 38 assert(ret != -1); 39 40 struct sockaddr_in client; 41 socklen_t client_addrlength = sizeof(client); 42 int connfd = accept(sock, (struct sockaddr*)&client, &client_addrlength); 43 if(connfd < 0) { 44 printf("errno is: %d\n", errno); 45 } 46 else { 47 sendfile(connfd, filefd, NULL, stat_buf.st_size); 48 close(connfd); 49 } 50 close(sock); 51 return 0; 52 }
在上例中未在用戶空間內申請任何緩沖區即完成了文件的傳送,效率要比原始做法高得多。
splice函數用來在兩個文件描述符間移動數據,也是零拷貝操作,但是其in_fd和out_fd中必須至少有一個管道文件描述符,調用成功時返回一共轉移的字節數,以一個splice實現的簡單回射服務器為例:
1 /************************************************************************* 2 > File Name: 6-4.cpp 3 > Author: Torrance_ZHANG 4 > Mail: [email protected] 5 > Created Time: Fri 02 Feb 2018 03:45:40 AM PST 6 ************************************************************************/ 7 8 #include"head.h" 9 using namespace std; 10 11 int main(int argc, char** argv) { 12 if(argc <= 2) { 13 printf("usage: %s ip_address port_number\n", basename(argv[0])); 14 return 1; 15 } 16 const char* ip = argv[1]; 17 int port = atoi(argv[2]); 18 19 struct sockaddr_in address; 20 bzero(&address, sizeof(address)); 21 address.sin_family = AF_INET; 22 inet_pton(AF_INET, ip, &address.sin_addr); 23 address.sin_port = htons(port); 24 25 int sock = socket(AF_INET, SOCK_STREAM, 0); 26 assert(sock >= 0); 27 28 int ret = bind(sock, (struct sockaddr*)&address, sizeof(address)); 29 assert(ret != -1); 30 31 ret = listen(sock, 5); 32 assert(ret != -1); 33 34 struct sockaddr_in client; 35 socklen_t client_addrlength = sizeof(client); 36 int connfd = accept(sock, (struct sockaddr*)&client, &client_addrlength); 37 if(connfd < 0) { 38 printf("errno is: %d\n", errno); 39 } 40 else { 41 int pipefd[2]; 42 assert(ret != -1); 43 ret = pipe(pipefd); 44 ret = splice(connfd, NULL, pipefd[1], NULL, 32768, SPLICE_F_MOVE | SPLICE_F_MOVE); 45 assert(ret != -1); 46 ret = splice(pipefd[0], NULL, connfd, NULL, 32768, SPLICE_F_MOVE | SPLICE_F_MOVE); 47 assert(ret != -1); 48 close(connfd); 49 } 50 close(sock); 51 return 0; 52 }
由於我們不能將數據從connfd的輸入直接變成connfd的輸出,所以我們借助了一個管道,將connfd的輸入與管道的輸入連接,將管道的輸出與connfd的回射連接,這樣就做成了一個高效率的回射服務器。
tee函數是在兩個管道文件描述符之間復制數據,也是零拷貝操作,而它不消耗數據,原始數據仍可以用於後續操作,函數原型與返回值與splice類似,我們以一個可以同時輸出數據到終端和文件的程序為例:
1 /************************************************************************* 2 > File Name: 6-5.cpp 3 > Author: Torrance_ZHANG 4 > Mail: [email protected] 5 > Created Time: Fri 02 Feb 2018 04:28:26 AM PST 6 ************************************************************************/ 7 8 #include"head.h" 9 using namespace std; 10 11 int main(int argc, char** argv) { 12 if(argc != 2) { 13 printf("usage: %s <file>\n", basename(argv[0])); 14 return 1; 15 } 16 int filefd = open(argv[1], O_CREAT | O_WRONLY | O_TRUNC, 0666); 17 assert(filefd > 0); 18 19 int pipefd_stdout[2]; 20 int ret = pipe(pipefd_stdout); 21 assert(ret != -1); 22 23 int pipefd_file[2]; 24 ret = pipe(pipefd_file); 25 assert(ret != -1); 26 27 //將標準輸入重定向到管道pipefd_stdout中 28 ret = splice(STDIN_FILENO, NULL, pipefd_stdout[1], NULL, 32768, SPLICE_F_MORE | SPLICE_F_MOVE); 29 assert(ret != -1); 30 31 //將pipefd_stdout中的數據拷貝一份到pipefd_file中 32 ret = tee(pipefd_stdout[0], pipefd_file[1], 32768, SPLICE_F_NONBLOCK); 33 assert(ret != -1); 34 35 //分別將兩個管道的輸出端和標準輸出文件與創建的文件相連接 36 ret = splice(pipefd_stdout[0], NULL, STDOUT_FILENO, NULL, 32768, SPLICE_F_MOVE | SPLICE_F_MORE); 37 assert(ret != -1); 38 39 ret = splice(pipefd_file[0], NULL, filefd, NULL, 32768, SPLICE_F_MOVE | SPLICE_F_MOVE); 40 assert(ret != -1); 41 42 close(filefd); 43 close(pipefd_file[0]); 44 close(pipefd_file[1]); 45 close(pipefd_stdout[0]); 46 close(pipefd_stdout[1]); 47 return 0; 48 }
此程序有一個問題,使用splice將pipefd_stdout[0]連接到STDOUT_FILENO時出錯,errno的值為EINVAL,在網上查了好久資料都沒有收獲,簡單說一下我的看法:返回EINVAL的原因主要有四種,目標文件系統不支持splice,目標文件以追加方式打開,兩個文件描述符都不是管道文件和某個offset參數被用於不支持隨機訪問的設備,而我們可以輕易排除1、3、4,標準輸出文件默認應該是以追加方式打開的,這樣在輸出時才不會覆蓋之前的數據,所以splice出錯。
Linux提供了tee命令用於完成上述程序的操作,在tee函數的幫助文檔裏也有一個例子來完成上述操作,可用man 2 tee來查看。
《Linux高性能服務器編程》學習總結(六)——高級I/O函數