深入剖析 I/O 復用之 select 機制
在網絡編程中,I/O 復用是一項關鍵技術,它允許程序同時監控多個文件描述符的狀態變化,從而高效地處理多個 I/O 操作。select
作為 I/O 復用的經典實現方式,在眾多網絡應用中扮演著重要角色。本文將深入探討 select
的原理、使用方法、相關數據結構以及實際應用示例。
一、I/O 復用概述
I/O 復用使得程序能夠同時監聽多個文件描述符,適用于多種場景:
- 客戶端程序需要同時處理多個套接字。
- 客戶端要兼顧用戶輸入和網絡連接處理。
- TCP 服務器需同時管理監聽套接字和已連接套接字。
- 服務器要同時處理 TCP 請求和 UDP 請求。
- 服務器需要監聽多個端口。
二、select 原理
select
系統調用通過維護三個文件描述符集合(讀集合、寫集合和異常集合)來監視不同類型的事件。它會阻塞當前進程,直到有一個或多個文件描述符就緒(有數據可讀、可寫或發生異常),或者達到指定的超時時間。
三、select 使用方法
函數原型
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
nfds
:需要監視的最大文件描述符編號加 1。readfds
:讀文件描述符集合,用于監視文件描述符是否有數據可讀。writefds
:寫文件描述符集合,用于監視文件描述符是否可寫。exceptfds
:異常文件描述符集合,用于監視文件描述符是否發生異常。timeout
:超時時間,指定select
函數的最大阻塞時間。如果為NULL
,則會一直阻塞直到有文件描述符就緒。
fd_set 數據結構
fd_set
用于存儲文件描述符集合,本質上是一個位圖(bitmask)。每個文件描述符對應位圖中的一位,若該位置為 1,則表示該文件描述符在集合內;若為 0,則表示不在集合內。
操作 fd_set
的宏函數
FD_ZERO(fd_set *set)
:將fd_set
集合初始化為空,即把集合中所有位都置為 0。FD_SET(int fd, fd_set *set)
:將指定的文件描述符fd
添加到fd_set
集合中,把對應位設置為 1。FD_CLR(int fd, fd_set *set)
:從fd_set
集合中移除指定的文件描述符fd
,將對應位設置為 0。FD_ISSET(int fd, fd_set *set)
:檢查指定的文件描述符fd
是否在fd_set
集合中。若在集合中則返回非零值,否則返回 0。
timeout
結構體
struct timeval { long tv_sec; // 秒數 long tv_usec; // 微秒數
};
用于指定 select
函數的超時時間。
四、使用 select
實現 TCP 服務器示例代碼解析
代碼功能
此代碼創建了一個 TCP 服務器,借助 select
函數實現 I/O 復用,能夠同時處理多個客戶端的連接與數據收發。服務器監聽本地地址 127.0.0.1
的 6000
端口,當有新的客戶端連接時會接受連接,接收客戶端發送的數據,并向客戶端回復 "ok"
。
代碼逐段解析
頭文件與常量定義
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h> #define MAXARR 10
引入所需頭文件,并定義常量 MAXARR
表示存儲文件描述符數組的最大長度。
函數聲明與實現
socket_init
函數:
該函數用于初始化服務器套接字,包括創建套接字、綁定地址和端口、開始監聽連接。若出現錯誤則返回int socket_init() { int sockfd = socket(AF_INET, SOCK_STREAM, 0); if (sockfd == -1) { perror("socket err"); return -1; } struct sockaddr_in saddr; memset(&saddr, 0, sizeof(saddr)); saddr.sin_family = AF_INET; saddr.sin_port = htons(6000); saddr.sin_addr.s_addr = inet_addr("127.0.0.1"); int res = bind(sockfd, (struct sockaddr *)&saddr, sizeof(saddr)); if (res == -1) { perror("bind err"); return -1; } if ((res = listen(sockfd, 5) < 0)) { perror("listen err"); return -1; } return sockfd; }
-1
。arr_init
函數:
把存儲文件描述符的數組初始化為void arr_init(int arr[]) { for (int i = 0; i < MAXARR; i++) { arr[i] = -1; } }
-1
,表示數組中沒有有效的文件描述符。arr_add
函數:
把新的文件描述符添加到數組中,找到第一個值為void arr_add(int arr[], int fd) { for (int i = 0; i < MAXARR; i++) { if (arr[i] == -1) { arr[i] = fd; break; } } }
-1
的位置,將新的文件描述符存入該位置。arr_del
函數:
從數組中刪除指定的文件描述符,找到該文件描述符所在的位置,將其值置為void arr_del(int arr[], int fd) { for (int i = 0; i < MAXARR; i++) { if (arr[i] == fd) { arr[i] = -1; break; } } }
-1
。accept_cli
函數:
接受新的客戶端連接,若接受成功則將客戶端的套接字文件描述符添加到數組中。void accept_cli(int sockfd, int arr[]) { int c = accept(sockfd, NULL, NULL); if (c == -1) { perror("accept err"); return; } printf("cli(%d) accept\n", c); arr_add(arr, c); }
recv_cli
函數:
接收客戶端發送的數據,若接收出錯或者客戶端關閉連接,則關閉對應的套接字并從數組中刪除該文件描述符;若接收到數據,則打印數據并向客戶端回復void recv_cli(int fd, int arr[]) { char buff[128] = {0}; int n = recv(fd, buff, 127, 0); if (n < 0) { perror("recv err"); printf("cli(%d) close\n", fd); close(fd); arr_del(arr, fd); return; } if (n == 0) { printf("cli(%d) close\n", fd); close(fd); arr_del(arr, fd); return; } printf("buff(c=%d):%s\n", fd, buff); send(fd, "ok", 2, 0); }
"ok"
。
main
函數
int main() { int sockfd = socket_init(); if (sockfd == -1) { exit(1); } int arr[MAXARR]; arr_init(arr); arr_add(arr, sockfd); fd_set fdset; while (1) { FD_ZERO(&fdset); int maxfd = -1; for (int i = 0; i < MAXARR; i++) { if (arr[i] == -1) { continue; } FD_SET(arr[i], &fdset); if (arr[i] > maxfd) { maxfd = arr[i]; } } struct timeval tv = {5, 0}; int n = select(maxfd + 1, &fdset, NULL, NULL, &tv); if (n == -1) { perror("select err"); continue; } else if (n == 0) { printf("TIME OUT\n"); continue; } else { for (int i = 0; i < MAXARR; i++) { if (arr[i] == -1) { continue; } else { if (FD_ISSET(arr[i], &fdset)) { if (arr[i] == sockfd) { accept_cli(arr[i], arr); } else { recv_cli(arr[i], arr); } } } } } }
}
- 初始化服務器套接字,若失敗則退出程序。
- 初始化存儲文件描述符的數組,并將服務器套接字文件描述符添加到數組中。
- 進入無限循環:
- 每次循環開始時,清空
fd_set
集合。 - 遍歷數組,將有效的文件描述符添加到
fd_set
集合中,并找出最大的文件描述符。 - 設置
select
函數的超時時間為 5 秒。 - 調用
select
函數進行監聽,根據返回值判斷情況:若返回-1
表示出錯,打印錯誤信息并繼續循環;若返回0
表示超時,打印超時信息并繼續循環;若返回大于0
的值,表示有文件描述符就緒。 - 再次遍歷數組,檢查哪些文件描述符就緒。若為服務器套接字,則調用
accept_cli
函數接受新的連接;若為客戶端套接字,則調用recv_cli
函數接收數據。
- 每次循環開始時,清空
五、select 的優缺點
優點
- 跨平臺支持:
select
是一種標準的系統調用,幾乎所有的 Unix/Linux 系統和 Windows 系統都支持,具有良好的跨平臺性。 - 簡單易用:
select
的接口相對簡單,使用起來比較方便,對于小規模的應用場景非常適用。
缺點
- 文件描述符數量限制:
select
有最大文件描述符數量的限制,一般為 1024。如果需要處理大量的文件描述符,可能會受到限制。 - 性能問題:
select
需要遍歷所有的文件描述符來檢查其狀態,時間復雜度為 O(n),當文件描述符數量較多時,性能會受到影響。 - 內核和用戶空間數據拷貝:每次調用
select
時,都需要將文件描述符集合從用戶空間拷貝到內核空間,在文件描述符數量較多時,會帶來一定的開銷。
六、適用場景
由于 select
存在一些局限性,它適用于文件描述符數量較少、對性能要求不是特別高的場景,例如一些簡單的網絡服務器、嵌入式系統等。在實際應用中,若需要處理大量文件描述符或對性能有更高要求,可以考慮使用 poll
或 epoll
等更高級的 I/O 復用機制。
通過深入理解 select
的原理、使用方法和優缺點,我們能夠在網絡編程中更好地運用這一技術,構建高效穩定的網絡應用。