文章目录
- 1 五种I/O模型
- 1.1 阻塞I/O模型
- 1.2 非阻塞I/O模型
- 1.3 I/O复用模型
- 1.4 信号驱动I/O模型
- 1.5 异步I/O模型
- 2 select函数
- 3 select实战:实现多个套接字监听
- 3.1 客户端
- 3.2 服务端
- 3.3 实验结果
- 3.4 完整代码
在之前的网络编程中,我们遇到了一个问题:
-
客户端需要一边监听来自
stdin
的键盘输入,一边监听来自服务端的消息 -
服务端要一边获取来自客户端的消息,一边
accept
新的设备连接
也就是我们希望在一个或多个I/O条件准备就绪时,能够得到通知。在前面的文章中,我们使用Linux中的fork
实现这些功能:利用fork实现服务端与多个客户端建立连接。但在Linux中还有一个I/O多路复用的概念,它由select
和poll
函数实现,这篇文章就来介绍一下多路复用的概念。
1 五种I/O模型
通常,一个输入操作有两个阶段:
- 等待数据准备就绪:等待数据在网络上到达,当数据包到达时,它被复制到内核的缓冲区中
- 将数据从内核复制到进程:将准备好的数据从内核缓冲区复制到应用程序缓冲区
下面来了解一下Linux中的五种I/O模型:
1.1 阻塞I/O模型
I/O的最常见模型是阻塞I/O模型。默认情况下,所有套接字都是阻塞的。用户通过调用recv
/recvfrom
/read
等函数阻塞等待数据的到来,这些函数称为系统调用,会从应用程序中切换到内核中运行,在得到了一定的数据后返回到应用程序。如下图所示:
在上图中,进程调用recvfrom
,系统调用在数据到达时将数据复制到应用程序缓冲区,然后返回。或者发生错误时也会返回,最常见的错误是系统调用被信号中断。
1.2 非阻塞I/O模型
当一个套接字被设置为非阻塞时,recv
/recvfrom
/read
会立即返回,有数据则返回数据,而没数据也不会等待条件满足,而是立即返回一个错误EWOULDBLOCK
。
对于前三次的recvfrom
,没有要返回的数据,内核立即返回EWOULDBLOCK
错误。 在第四次调用recvfrom
时,一个数据报已准备好,它被复制到我们的应用程序缓冲区中,recvfrom
成功返回。应用程序不断地轮询内核,以查看是否有某个操作准备就绪,这通常很占CPU的资源,一般很少使用。
当然除了用在读函数中,一个我实际中用到的例子是在connect
函数中使用非阻塞。比如我们设备中有一个以太网,正常阻塞连接的话还需要以太网相关的驱动支持,如果在没插网线或者对端服务器不存在的情况下进行连接,根据不同内核代码的处理就会阻塞在connect
函数十几二十秒再返回,而影响后面代码的执行。此时就可以设置为非阻塞,然后后续再监听套接字上是否有消息以判断是否连接成功。
1.3 I/O复用模型
I/O多路复用调用select
或poll
,然后阻塞在这两个系统调用中,而不是在实际的I/O系统调用中阻塞。
select
可以监听多个套接字,当select
返回时,表示某个套接字可读/写,我们就可以执行相应的操作。
1.4 信号驱动I/O模型
内核在描述符准备就绪时通过SIGIO
信号通知应用层。如下图所示:
- 激活信号驱动模型: 应用程序通过系统调用(如
sigaction
)向内核注册信号处理程序。信号处理程序是在特定事件发生时由内核调用的函数。对于信号驱动I/O,常用的信号包括SIGIO
和SIGURG
。 - 启用信号驱动套接字: 对于需要信号驱动的套接字,应用程序需要调用
fcntl
系统调用,将套接字设置为非阻塞,并启用FASYNC
标志。这样,当套接字上的I/O事件发生时,内核会发送相应的信号给应用程序。 - 事件发生时的处理: 当套接字上发生I/O事件时,内核会生成相应的信号(如
SIGIO
)。这时,注册的信号处理程序将被调用。在信号处理程序中,应用程序可以执行必要的操作,比如读取数据、处理数据或通知主循环有关事件的发生。 - 异步通知: 信号驱动模型提供了一种异步通知的机制,使得应用程序可以继续执行其他任务而无需等待事件的发生。这与阻塞I/O模型不同,后者需要在等待事件时阻塞应用程序。
1.5 异步I/O模型
异步I/O由POSIX规范定义,它允许应用程序启动一个I/O操作,而无需等待这个操作完成。相比于阻塞I/O,异步I/O能够在I/O操作进行的同时执行其他任务,而不必一直等待数据的读取或写入完成。请参见下图示例:
- POSIX异步I/O函数: 在POSIX标准中,异步I/O由一组函数组成,这些函数的名称通常以
aio_
或lio_
开头,如aio_read
、aio_write
等。 - 启动异步操作: 应用程序通过调用异步I/O函数来启动I/O操作,通常需要指定文件描述符、缓冲区指针、缓冲区大小、文件偏移等参数。
- 通知机制: 异步I/O操作的关键特点是通知机制。应用程序可以指定在I/O操作完成时如何通知它,通常是通过信号、回调函数或事件通知等方式。
- 立即返回: 异步I/O函数通常是非阻塞的,它们在启动I/O操作后会立即返回,而不会等待操作完成。这使得应用程序能够继续执行其他任务。
- 适用场景: 异步I/O常用于需要处理大量并发I/O操作的情况,例如网络服务器或高性能的文件处理应用程序。通过异步I/O,程序能够更有效地利用系统资源,提高响应性能。
同步I/O(前四种都是同步I/O)操作会导致请求的进程被阻塞,直到该I/O操作完成。而异步I/O操作不会导致请求的进程被阻塞。
2 select函数
这里主要介绍一下select
在网络编程的使用。select
函数可以等待多个事件中的任何一个发生,并且仅在其中一个或多个事件发生时唤醒进程。这意味着我们告诉内核我们对哪些描述符感兴趣(用于读取、写入或异常条件),以及等待的时间有多长。我们可以使用select
监听任何描述符,而不仅限于套接字。
int select(int maxfdp1, fd_set *readset, fd_set *writeset, fd_set *exceptset,
const struct timeval *timeout);
- maxfdp1 :这是待测试的最大文件描述符值加一。它指定了被测试的文件描述符的范围。例如,如果最大的文件描述符是N,那么
maxfdp1
的值应该是N + 1。这个参数告诉select
要检查多少个文件描述符。 - readset :这是一个指向
fd_set
结构的指针,用于指定希望监视其可读性的文件描述符集合。如果readset
中的任何一个文件描述符变得可读,select
将返回。 - writeset: 类似于
readset
,这是一个指向fd_set
结构的指针,用于指定希望监视其可写性的文件描述符集合。如果writeset
中的任何一个文件描述符变得可写,select
将返回。 - exceptset: 同样是一个指向
fd_set
结构的指针,用于指定希望监视其异常条件的文件描述符集合。如果exceptset
中的任何一个文件描述符发生异常,select
将返回。 - timeout:这是一个指向
struct timeval
结构的指针,表示等待的最长时间。如果timeout
为NULL
,select
将一直等待,直到有文件描述符就绪或出错。如果timeout
不为NULL
,则指定等待的最大时间,当超过这个时间后,即使没有文件描述符就绪,select
也将返回。
select
函数在成功时返回就绪文件描述符的总数,如果超时返回0,如果出错返回-1。可以通过检查readset
、writeset
和exceptset
中的具体位来确定哪些文件描述符已经就绪。
这里的fd_set
需要使用以下几个API来设置:
void FD_ZERO(fd_set *fdset); /* 清除fdset中的所有位 */
void FD_SET(int fd, fd_set *fdset); /* 置fdset中的某个位 */
void FD_CLR(int fd, fd_set *fdset); /* 清除fdset中的某个位 */
int FD_ISSET(int fd, fd_set *fdset); /* 判断某个位是否被置 */
3 select实战:实现多个套接字监听
这里就来实现一个服务端和客户端的模型,从代码中来深入理解select
函数的使用。
3.1 客户端
客户端需要能够监听标准输入stdin
的消息,然后转发个服务端;还需要监听服务端的套接字,以接收服务端发来的消息。代码如下:
fd_set readfds;
while (1)
{
FD_ZERO(&readfds); //清空读描述符
FD_SET(STDIN_FILENO, &readfds); //设置标准输入描述符
FD_SET(clientSocket, &readfds); //设置要监听的(服务端的)套接字
/* 由于输入描述符为0,所以这里maxfdp1就为clientSocket+1 */
select(clientSocket + 1, &readfds, NULL, NULL, NULL);//无限阻塞到有消息
/* 判断哪个套接字上有消息 */
if (FD_ISSET(STDIN_FILENO, &readfds)) {//标准输入有消息:发送给服务端
fgets(buffer, BUFFER_SIZE, stdin);
send(clientSocket, buffer, strlen(buffer), 0);
}
if (FD_ISSET(clientSocket, &readfds)) {//服务端套接字有消息:接收并打印出来
memset(buffer, 0, sizeof(buffer));
recv(clientSocket, buffer, BUFFER_SIZE, 0);
printf("Server: %s", buffer);
}
}
STDIN_FILENO
是(通常为0)一个在头文件<unistd.h>
中定义的宏,用于表示标准输入文件描述符的。除此之外还有标准输出的描述符STDOUT_FILENO
(通常为1)和标准错误的描述符STDERR_FILENO
(通常为2)。
3.2 服务端
服务端则是一边要accept
新的客户端连接请求,一边接收来自客户端的消息并回显回去。代码如下:
#define BUFFER_SIZE 1024
int serverSocket, clientSockets[BUFFER_SIZE], maxSockets, activity, i, valread;
//serverSocket为服务端自身的套接字,代码略
maxSockets = serverSocket;
memset(clientSockets, 0, sizeof(clientSockets));//记录建立连接的客户端套接字,0表示没有使用
while (1)
{
FD_ZERO(&readfds); //清空读描述符
FD_SET(serverSocket, &readfds); //设置服务端套接字,用于accept客户端连接请求
/* 设置所有已连接上的客户端的套接字 */
for (i = 0; i < MAX_CLIENTS; i++)
{
int clientSocket = clientSockets[i];
if (clientSocket > 0)
{
FD_SET(clientSocket, &readfds);
if (clientSocket > maxSockets)//更新maxfdp1
maxSockets = clientSocket;
}
}
/* 监听套接字 */
activity = select(maxSockets + 1, &readfds, NULL, NULL, NULL);
/* 有新的客户端连接请求,这里accept它们 */
if (FD_ISSET(serverSocket, &readfds))
{
int newSocket;
socklen_t addrlen = sizeof(address);
if ((newSocket = accept(serverSocket, (struct sockaddr*)&address, &addrlen)) < 0)
{
perror("Accept failed");
exit(EXIT_FAILURE);
}
printf("New connection, socket fd is %d, ip is : %s, port : %d\n", newSocket, inet_ntoa(address.sin_addr), ntohs(address.sin_port));
/* 找一个数组中没使用的项目填入 */
for (i = 0; i < MAX_CLIENTS; i++)
{
if (clientSockets[i] == 0)
{
clientSockets[i] = newSocket;
break;
}
}
}
/* 处理所有连接上的客户端的消息 */
for (i = 0; i < MAX_CLIENTS; i++)
{
int clientSocket = clientSockets[i];
if (FD_ISSET(clientSocket, &readfds))
{
valread = read(clientSocket, buffer, BUFFER_SIZE);
/* 返回0表示客户端断开连接,这里也断开连接 */
if (valread == 0)
{
// Client disconnected
printf("Host disconnected, socket fd is %d\n", clientSocket);
close(clientSocket);
clientSockets[i] = 0;
}
/* 将收到的客户端的消息回显给客户端 */
else
{
buffer[valread] = '\0';
printf("Received: %s", buffer);
send(clientSocket, buffer, strlen(buffer), 0);
}
}
}
}
3.3 实验结果
运行服务端程序,然后打开一个客户端程序发送hello
,再打开一个客户端程序发送hi!
,实验效果如下:
3.4 完整代码
客户端
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#define BUFFER_SIZE 1024
int main() {
int clientSocket;
struct sockaddr_in serverAddress;
fd_set readfds;
char buffer[BUFFER_SIZE];
// Create client socket
if ((clientSocket = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
perror("Socket creation failed");
exit(EXIT_FAILURE);
}
serverAddress.sin_family = AF_INET;
serverAddress.sin_port = htons(8888);
serverAddress.sin_addr.s_addr = inet_addr("127.0.0.1");
// Connect to server
if (connect(clientSocket, (struct sockaddr*)&serverAddress, sizeof(serverAddress)) < 0) {
perror("Connection Failed");
exit(EXIT_FAILURE);
}
printf("Connected to server\n");
while (1) {
FD_ZERO(&readfds);
FD_SET(STDIN_FILENO, &readfds);
FD_SET(clientSocket, &readfds);
select(clientSocket + 1, &readfds, NULL, NULL, NULL);
if (FD_ISSET(STDIN_FILENO, &readfds)) {
// Read from stdin and send to server
fgets(buffer, BUFFER_SIZE, stdin);
send(clientSocket, buffer, strlen(buffer), 0);
}
if (FD_ISSET(clientSocket, &readfds)) {
// Read from server and print
memset(buffer, 0, sizeof(buffer));
recv(clientSocket, buffer, BUFFER_SIZE, 0);
printf("Server: %s", buffer);
}
}
return 0;
}
服务端
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#define MAX_CLIENTS 10
#define BUFFER_SIZE 1024
int main() {
int serverSocket, clientSockets[MAX_CLIENTS], maxSockets, activity, i, valread;
int opt = 1;
struct sockaddr_in address;
fd_set readfds;
char buffer[BUFFER_SIZE];
// Create server socket
if ((serverSocket = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
perror("Socket creation failed");
exit(EXIT_FAILURE);
}
// Set socket options
if (setsockopt(serverSocket, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt))) {
perror("Setsockopt failed");
exit(EXIT_FAILURE);
}
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(8888);
// Bind the socket
if (bind(serverSocket, (struct sockaddr*)&address, sizeof(address)) < 0) {
perror("Bind failed");
exit(EXIT_FAILURE);
}
// Listen for incoming connections
if (listen(serverSocket, MAX_CLIENTS) < 0) {
perror("Listen failed");
exit(EXIT_FAILURE);
}
printf("Server listening on port 8888\n");
maxSockets = serverSocket;
memset(clientSockets, 0, sizeof(clientSockets));
while (1) {
FD_ZERO(&readfds);
FD_SET(serverSocket, &readfds);
for (i = 0; i < MAX_CLIENTS; i++) {
int clientSocket = clientSockets[i];
if (clientSocket > 0) {
FD_SET(clientSocket, &readfds);
if (clientSocket > maxSockets) {
maxSockets = clientSocket;
}
}
}
activity = select(maxSockets + 1, &readfds, NULL, NULL, NULL);
if (FD_ISSET(serverSocket, &readfds)) {
// Handle new connection
int newSocket;
socklen_t addrlen = sizeof(address);
if ((newSocket = accept(serverSocket, (struct sockaddr*)&address, &addrlen)) < 0) {
perror("Accept failed");
exit(EXIT_FAILURE);
}
printf("New connection, socket fd is %d, ip is : %s, port : %d\n", newSocket, inet_ntoa(address.sin_addr), ntohs(address.sin_port));
for (i = 0; i < MAX_CLIENTS; i++) {
if (clientSockets[i] == 0) {
clientSockets[i] = newSocket;
break;
}
}
}
for (i = 0; i < MAX_CLIENTS; i++) {
int clientSocket = clientSockets[i];
if (FD_ISSET(clientSocket, &readfds)) {
// Handle data from client
valread = read(clientSocket, buffer, BUFFER_SIZE);
if (valread == 0) {
// Client disconnected
printf("Host disconnected, socket fd is %d\n", clientSocket);
close(clientSocket);
clientSockets[i] = 0;
} else {
// Echo received message back to client
buffer[valread] = '\0';
printf("Received: %s", buffer);
send(clientSocket, buffer, strlen(buffer), 0);
}
}
}
}
return 0;
}
这里都没有判断select函数的返回值,我们最好也判断一下:
if (activity == -1) {
// 错误发生
if (errno == EINTR)
continue;//继续下一次select
perror("select");
exit(EXIT_FAILURE);
} else if (activity == 0) {
// 超时
} else {
// 有文件描述符准备好
}
- 当
select
返回值-1且errno
为EINTR
时,表示select
被中断。这通常是由于接收到信号而导致的中断。在这种情况下,你可以选择重新调用select
。