目录
前言
本篇文章主要从网络IO角度讲解IO模型,着重讲解多路复用在网络编程上的的应用
一.I/O基本概念
I/O 操作(Input/Output Operation)指的是计算机系统中与外部环境(如网络、磁盘、键盘、显示器等)进行数据交换的过程。对于网络编程而言,I/O 操作主要包括以下几种:
网络输入(接收数据):如 recv() 或 read() 函数,用于从网络连接中读取数据。
网络输出(发送数据):如 send() 或 write() 函数,用于向网络连接发送数据。
网络连接管理:如 accept() 函数,用于接受来自客户端的连接请求。
网络监听:如 listen() 函数,用于监听进入的连接请求。
通常用户进程中的一个完整I/O分为两个阶段:
用户进程空间→内核空间 ,内核空间→设备空间;
I/O分为内存I/O、网络I/O和磁盘I/O三种:
内存I/O:涉及直接与计算机内存进行数据交换,如读取和写入内存中的数据。
网络I/O:涉及通过网络接口进行的数据交换,例如从服务器下载文件或向服务器发送请求。
磁盘I/O:涉及与存储设备(如硬盘驱动器或固态硬盘)进行数据交换,例如读取和写入磁盘上的文件。
而文件I/O和标准I/O是对这些操作的API实现方式。
Linux中进程无法直接操作I/O设备,其必须通过系统调用请求内核来协助完成I/O操作。 内核会为每个I/O设备(例如硬盘、网络接口)维护一个缓冲区,缓冲区是内存中的一块区域,用于临时存储数据,以提高I/O操作的效率。
对于一个输入操作来说,进程I/O系统调用(如 read())读取数据时,它会先看为该设备维护的缓冲区,看看是否已经有待处理的数据。没有的话再到设备(比如网卡设备)中读取(因为设备I/O一般速度较慢,需要等待)。如果缓冲区中没有数据,内核会发起设备I/O操作,从设备(例如网络接口卡、磁盘等)中读取数据。由于设备I/O操作通常较慢,需要时间来完成,内核会先等待数据到达设备缓冲区,再进行处理。 所以,对于一个网络输入操作通常包括两个不同阶段:
(1)等待网络数据到达网卡,把数据从网卡读取到内核缓冲区,准备好数据。
(2)从内核缓冲区复制数据到用户进程空间。
如果缓冲区中有数据:内核会直接从缓冲区中获取数据,并将这些数据复制到用户进程的地址空间。这种情况通常较快,因为数据已经在内核空间中,可以快速传递给用户进程。
网络I/O的本质是对socket的读取,socket在Linux系统中被抽象为流,I/O可以理解为对流的操作。
Socket:在Linux中,socket是一种用于网络通信的抽象接口,提供了数据传输的机制。socket可以被看作是一个端点,允许进程通过它进行数据的发送和接收。
流的抽象:在Linux中,socket被视为一种流(例如TCP流),它允许进程以流式方式读写数据。对于TCP协议来说,数据流是有序的、可靠的。流的抽象可以理解为像流水一样的数据传输方式,强调了数据传输的连续性和按序性,而不关心数据的具体格式。当使用socket进行网络通信时,网络层和传输层只处理数据的流动,应用层则负责数据的解析和处理。
I/O操作:对socket的读写操作实际上是对网络流的操作。当进程发起对socket的读取操作时,内核会检查缓冲区是否有数据可用。如果有,数据会被传递给进程;如果没有,进程会被阻塞直到数据到达。
网络I/O的模型可分为两种:
异步I/O(asynchronous I/O):操作发起后,进程不需要等待操作完成,可以继续执行其他任务。操作完成后,系统会通知进程,或者提供回调函数来处理结果。
大概流程是进程调用异步I/O相关的系统调用或库函数发起I/O操作。发起的I/O操作在后台进行。进程不需要阻塞等待操作完成,而是继续执行其他任务。一旦I/O操作完成,系统会通过预设的机制(如事件、信号或回调函数)通知进程。通知的方式取决于具体的异步I/O实现。
同步I/O(synchronous I/O) :进程调用同步I/O相关的系统调用或库函数发起I/O操作(例如,读取数据或写入数据)。调用I/O操作的进程会被阻塞,直到I/O操作完成。在这段时间内,进程不会继续执行其他任务。例如,如果一个进程发起了文件读取操作,它会等待数据从磁盘读取到内存中。在这个过程中,进程会被挂起,直到数据完全读取完毕。一旦I/O操作完成,系统会将结果(例如读取的数据)返回给进程。进程继续执行后续操作,基于这些结果进行处理。
在读取操作完成后,进程可以处理数据;在写入操作完成后,进程可以进行后续处理或其他任务。
示例
同步I/O又包括
阻塞I/O(blocking I/O):进程发起I/O操作后,会被阻塞,直到I/O操作完成。在此期间,进程不能继续执行其他任务。阻塞I/O操作的特征是进程必须等待操作完成才能继续执行,因此它是同步的。
非阻塞I/O(non-blocking I/O):尽管非阻塞I/O允许进程在I/O操作未完成时继续执行其他任务,但它的主要工作流程仍然是同步的。进程会不断检查I/O状态,如果I/O操作未完成,进程需要重复检查或等待结果。它的同步性可以从宏观角度来看,尽管非阻塞I/O允许进程在等待I/O操作完成时继续执行其他任务,但它必须在某个时刻检查I/O操作的状态,这种状态检查需要等待结果的到来。与非阻塞I/O不同,异步I/O模型允许进程发起I/O操作后不需要显式等待或检查状态。操作完成后,系统会通过回调函数或通知机制告知进程。
多路复用I/O(multiplexing I/O):多路复用 I/O 的同步性体现在
select()
等系统调用的阻塞等待阶段。尽管多路复用 I/O 提供了一种有效的方式来处理多个 I/O 操作,但它在本质上仍然涉及同步等待和处理 I/O 事件。它使用select()
、poll()
或epoll()
等系统调用来监视多个文件描述符或套接字,这些系统调用会阻塞进程,直到至少一个文件描述符变得可读、可写或发生了错误。进程在这些调用期间会被阻塞,等待 I/O 事件的发生。注意:监视的I/O 的操作的阻塞或非阻塞特性影响的是 I/O 操作本身的行为,而不是多路复用技术的同步性,意思是,多路复用的同步性不是与监视的IO的操作本身是阻塞或非阻塞特性直接相关。信号驱动I/O(signal-driven I/O):进程首先设定一个文件描述符为信号驱动模式(FASYNC),并注册信号处理程序。当文件描述符变得可读或可写时,内核会向进程发送一个信号(如 SIGIO),通知进程有 I/O 事件发生。当信号到达时,进程的信号处理程序会被调用。在信号处理程序中,进程会同步地处理 I/O 事件。它的同步性具体体现在,当进程接收到 I/O 相关的信号(如 SIGIO)时,操作系统会中断进程的正常执行流程,立即调用预设的信号处理程序。在信号处理程序中,进程会同步地处理 I/O 操作。例如,如果信号表示某个文件描述符可读,信号处理程序中会立即执行读取操作。在信号处理程序执行期间,进程会暂停其他任务。信号处理程序必须完成后,进程才能继续执行其他代码或任务。这种行为保证了信号处理和 I/O 操作的同步性。同步性不体现在信号处理程序中执行的具体 I/O 操作是阻塞或者非阻塞。
强调一下:信号驱动I/O属于同步I/O。 信号驱动I/O和异步I/O只作概念性的讲解,不作为学习重点。
1.同步和异步
(1)对于一个线程的请求调用来讲,同步和异步的区别在于是否要等这个请求出最终结果
(2)对于多个线程而言,同步或异步就是线程间的步调是否要一致、是否要协调
(3)同步也经常用在一个线程内先后两个函数的调用上
(4)异步就是一个请求返回时一定不知道结果,还得通过其他机制来获知结果,如:主动轮询或被动通知
2.阻塞和非阻塞
阻塞与非阻塞与等待消息通知时的状态(调用线程)有关
阻塞和同步是完全不同的概念。同步是对于消息的通知机制而言,阻塞是针对等待消息通知时的状态来说的
进程从创建、运行到结束总是处于下面五个状态之一:新建状态、就绪状态、运行状态、阻塞状态及死亡状态
二.五种网络I/O模型
1.阻塞I/O模型
对于一个套接字上的输入操作:
第一步通常涉及等待数据从网络中到达,当所有等待分组到达时,它被复制到内核中的某个缓冲区。
第二步是把数据从内核缓冲区复制到应用程序缓冲区。 同步阻塞I/O模型是最常用、最简单的模型。
在Linux中,默认情况下,所有套接字都是阻塞的。
下面我们以阻塞套接字的recvfrom的调用图来说明阻塞,如图所示
2.非阻塞式I/O模型
非阻塞的recvform系统调用之后,进程并没有被阻塞,内核马上返回给进程,如果数据还没准备好,此时会返回一个error
(EAGAIN
或EWOULDBLOCK
)。 进程在返回之后,可以先处理其他的业务逻辑,稍后再发起recvform系统调用。 采用轮询的方式检查内核数据,直到数据准备好。再拷贝数据到进程,进行数据处理。 在Linux下,可以通过设置套接字选项使其变为非阻塞。非阻塞的套接字的recvfrom操作如图所示
可以看到前三次调用recvfrom请求时,并没有数据返回,内核返回errno
(EWOULDBLOCK
),并不会阻塞进程。 当第四次调用recvfrom时,数据已经准备好了,于是将它从内核空间拷贝到程序空间,处理数据。但是将数据从内核拷贝到用户空间,这个阶段阻塞。
3.多路复用
I/O多路复用的好处在于单个进程就可以同时处理多个网络连接的I/O。它的基本原理是不再由应用程序自己监视连接,而由内核替应用程序监视文件描述符。通过 select、poll、epoll 等机制,允许一个进程同时监视多个文件描述符,当某个文件描述符就绪时再进行 IO 操作。这种模型下,程序可以同时处理多个连接,提高了并发处理能力。 以select函数为例,当用户进程调用了select,那么整个进程会被阻塞,而同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好,select就会返回。 这个时候用户进程再调用read操作,将数据从内核拷贝到用户进程,如下图所示。
4.信号驱动式I/O模型
该模型允许socket进行信号驱动I/O,并注册一个信号处理函数,进程继续运行并不阻塞。当数据准备好时,进程会收到一个SIGIO信号,可以在信号处理函数中调用I/O操作函数处理数据,如图所示
注意:虽然信号驱动IO在注册完信号处理函数以后,就可以做其他事情了。但是第二阶段拷贝数据的过程当中进程依然是被阻塞的,而后要介绍的异步IO是完全不会阻塞进程的,所以信号驱动虽然具有异步的特点,但依然属于同步IO 。
5. 异步I/O模型
相对于同步I/O,异步I/O不是按顺序执行。用户进程进行aio_read
系统调用之后,就可以去处理其他逻辑了,无论内核数据是否准备好,都会直接返回给用户进程,不会对进程造成阻塞。这是因为aio_read
只向内核递交申请,并不关心有没有数据。 等到数据准备好了,内核直接复制数据到进程空间,然后内核向进程发送通知,此时数据已经在用户空间了,可以对数据进行处理。
三.五种I/O模型比较
前四种I/O模型都是同步I/O操作,它们的区别在于第一阶段,而第二阶段是一样的:在数据从内核复制到应用缓冲区期间(用户空间),进程阻塞于recvfrom调用。
相反,异步I/O模型在等待数据和接收数据的这两个阶段都是非阻塞的,可以处理其他的逻辑,用户进程将整个I/O操作交由内核完成,内核完成后会发送通知。在此期间,用户进程不需要检查I/O操作的状态,也不需要主动拷贝数据。
四.I/O代码示例
1. 阻塞IO
通常情况下在 linux 中,Socket 在创建时会默认采用阻塞式 IO。这意味着当调用 Socket 的接收数据或发送数据函数时,如果没有数据可用或者无法立即发送数据,程序会被阻塞,直到数据准备好或者可以发送数据为止。阻塞IO一个简单的改进方案是在服务器端使用多线程(或多进程)。多线程(或多进程)的目的是让每个连接都拥有独立的线程(或进程),这样任何一个连接的阻塞都不会影响其他的连接。具体使用多进程还是多线程,并没有一个特定的模式。传统意义上,进程的开销要远远大于线程,所以如果需要同时为较多的客户机提供服务,则不推荐使用多进程;如果单个服务执行体需要消耗较多的 CPU 资源,譬如需要进行大规模或长时间的数据运算或文件访问,则进程较为安全。
server.c 服务器端代码
#include <stdio.h>
#include <sys/socket.h> // 包含 socket()、bind()、listen()、accept() 函数的声明
#include <sys/types.h> // 包含数据类型的定义
#include <stdlib.h> // 包含标准库函数的声明,如 exit()
#include <arpa/inet.h> // 包含网络地址转换的函数声明,如 htons()
#include <unistd.h> // 包含 POSIX 操作系统 API 的声明,如 close()
#define PORT 5001 // 定义服务器监听的端口号
#define BACKLOG 5 // 定义最大连接队列长度
int main(int argc, char *argv[])
{
int fd, newfd; // fd 是服务器套接字,newfd 是与客户端通信的套接字
char buf[BUFSIZ] = {}; // 定义一个缓冲区,用于存储从客户端读取的数据,BUFSIZ 是一个常量,表示缓冲区大小
struct sockaddr_in addr; // 用于存储服务器地址信息的结构体
// 创建一个 TCP 套接字
fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0)
{
perror("socket"); // 如果创建套接字失败,打印错误信息
exit(0); // 退出程序
}
// 配置服务器的地址信息
addr.sin_family = AF_INET; // 地址族:IPv4
addr.sin_port = htons(PORT); // 端口号,转换为网络字节序
addr.sin_addr.s_addr = 0;//自己的ip地址
// 将套接字绑定到指定的地址和端口
if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) == -1)
{
perror("bind"); // 如果绑定失败,打印错误信息
exit(0); // 退出程序
}
// 将套接字设置为监听模式,准备接受连接
if (listen(fd, BACKLOG) == -1)
{
perror("listen"); // 如果设置监听失败,打印错误信息
exit(0); // 退出程序
}
// 阻塞等待客户端的连接请求,创建一个新的套接字用于与客户端通信
newfd = accept(fd, NULL, NULL);
if (newfd < 0)
{
perror("accept"); // 如果接受连接失败,打印错误信息
exit(0); // 退出程序
}
// 打印 BUFSIZ 的大小(通常为 8192 字节)
printf("BUFSIZ = %d\n", BUFSIZ);
// 从客户端套接字读取数据到缓冲区
read(newfd, buf, BUFSIZ);
// 打印从客户端读取的数据
printf("buf = %s\n", buf);
// 关闭套接字
close(fd);
close(newfd); // 不要忘记关闭与客户端通信的套接字
return 0; // 结束程序
}
client.c 客户端代码
#include <stdio.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <stdlib.h>
#include <arpa/inet.h>
#include <unistd.h>
#define PORT 5001 // 定义服务器监听的端口号
#define BACKLOG 5 // 定义最大连接队列长度(在客户端代码中用到)
#define STR "Hello World!" // 定义要发送给服务器的字符串
int main(int argc, char *argv[])
{
int fd; // 套接字描述符
struct sockaddr_in addr; // 用于存储服务器地址信息的结构体
// 创建一个 TCP 套接字
fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0)
{
perror("socket"); // 如果创建套接字失败,打印错误信息
exit(0); // 退出程序
}
// 配置服务器的地址信息
addr.sin_family = AF_INET; // 地址族:IPv4
addr.sin_port = htons(PORT); // 端口号,转换为网络字节序
addr.sin_addr.s_addr = inet_addr("127.0.0.1"); // 设置要连接的 IP 地址为本机地址
// 向服务器发起连接请求
if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) == -1)
{
perror("connect"); // 如果连接失败,打印错误信息
exit(0); // 退出程序
}
// 向服务器发送字符串
write(fd, STR, sizeof(STR)); // 发送定义的字符串到服务器
printf("STR = %s\n", STR); // 打印要发送的字符串
// 关闭套接字
close(fd);
return 0; // 结束程序
}
2.非阻塞I/O
实现:设置文件描述符为非阻塞模式(通过 fcntl(fd, F_SETFL, O_NONBLOCK)
),
在非阻塞模式下,accept()
和 recv()
函数会立即返回,如果没有数据或连接请求。进程不会因等待而阻塞,而是可以继续执行其他任务,或者循环检查状态。
服务器端代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/socket.h>
#include <netinet/in.h>
#define PORT 8080
#define BUFFER_SIZE 1024
int main()
{
int server_fd, new_socket;
struct sockaddr_in address;
int addrlen = sizeof(address);
char buffer[BUFFER_SIZE] = {0}; // 用于接收数据的缓冲区
const char *response = "Hello from server"; // 服务器的响应消息
// 创建 TCP 套接字
if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0)
{
perror("socket failed");
exit(EXIT_FAILURE);
}
// 设置服务器地址和端口
address.sin_family = AF_INET; // IPv4 地址族
address.sin_addr.s_addr = INADDR_ANY; // 绑定到所有可用的接口
address.sin_port = htons(PORT); // 设置端口号(网络字节序)
// 将套接字设置为非阻塞模式
if (fcntl(server_fd, F_SETFL, O_NONBLOCK) < 0)
{
perror("fcntl failed");
exit(EXIT_FAILURE);
}
// 将套接字绑定到服务器地址和端口
if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0)
{
perror("bind failed");
exit(EXIT_FAILURE);
}
// 监听连接
if (listen(server_fd, 3) < 0)
{
perror("listen failed");
exit(EXIT_FAILURE);
}
printf("Server started. Waiting for connections...\n");
while (1)
{
// 非阻塞地等待并接受客户端连接
if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen)) >= 0)
{
// 设置新连接的套接字为非阻塞模式
if (fcntl(new_socket, F_SETFL, O_NONBLOCK) < 0)
{
perror("fcntl failed");
exit(EXIT_FAILURE);
}
printf("New client connected\n");
}
// 从客户端接收数据
int bytes_received;
while ((bytes_received = recv(new_socket, buffer, BUFFER_SIZE, 0)) > 0)
{
printf("Received: %s\n", buffer);
// 发送响应给客户端
send(new_socket, response, strlen(response), 0);
// 清空缓冲区
memset(buffer, 0, BUFFER_SIZE);
}
if (bytes_received == 0)
{
// 客户端断开连接
printf("Client disconnected\n");
close(new_socket);
}
else if (bytes_received < 0)
{
// 处理接收数据时的错误
perror("recv failed");
close(new_socket);
}
}
close(server_fd);
return 0;
}
客户端代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define SERVER_IP "127.0.0.1" // 服务器的 IP 地址(本地回环地址)
#define PORT 8080 // 服务器的端口号
#define BUFFER_SIZE 1024 // 缓冲区大小
int main()
{
int sock = 0; // 套接字描述符
struct sockaddr_in serv_addr; // 服务器地址结构体
char buffer[BUFFER_SIZE] = {0}; // 用于接收数据的缓冲区
const char *message = "Hello from client"; // 发送给服务器的消息
// 创建 TCP 套接字
if ((sock = socket(AF_INET, SOCK_STREAM, 0)) < 0)
{
perror("socket creation failed"); // 输出错误信息并退出
exit(EXIT_FAILURE);
}
// 设置服务器地址和端口
serv_addr.sin_family = AF_INET; // IPv4 地址族
serv_addr.sin_port = htons(PORT); // 将端口号从主机字节序转换为网络字节序
// 将服务器 IP 地址转换为网络字节序并设置到 sockaddr_in 结构体中
if (inet_pton(AF_INET, SERVER_IP, &serv_addr.sin_addr) <= 0)
{
perror("invalid address"); // 输出错误信息并退出
exit(EXIT_FAILURE);
}
// 连接到服务器
if (connect(sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0)
{
perror("connection failed"); // 输出错误信息并退出
exit(EXIT_FAILURE);
}
printf("Connected to server\n"); // 连接成功
// 发送数据给服务器
send(sock, message, strlen(message), 0);
printf("Message sent to server: %s\n", message); // 输出发送的消息
// 接收服务器的响应
int bytes_received = recv(sock, buffer, BUFFER_SIZE, 0); // 从服务器接收数据
if (bytes_received > 0)
{
printf("Response from server: %s\n", buffer); // 输出接收到的消息
}
else if (bytes_received == 0)
{
printf("Server disconnected\n"); // 服务器断开连接
}
else
{
perror("recv failed"); // 输出错误信息
}
close(sock); // 关闭套接字
return 0;
}
3.多路复用
(1)select
select:select 使用了一个 fd_set 集合来保存需要监控的文件描述符,并提供了 select() 函数来检查这些文件描述符的状态。当调用 select() 函数时,内核会遍历这个 fd_set 集合,检查每个文件描述符的状态是否就绪。如果某个文件描述符就绪,select() 函数就会返回,否则会阻塞程序直到有文件描述符就绪或超时。
客户端操作服务器时就会产生这三种文件描述符(简称fd):writefds(写)、readfds(读)、和 exceptfds(异常)。select 会阻塞住监视 3 类文件描述符,等有数据、可读、可写、出异常或超时就会返回;返回后通过遍历 fdset 整个数组来找到就绪的描述符 fd,然后进行对应的 IO 操作。
优点:能够在多个平台上使用,是标准的 POSIX 调用。
适用于小规模连接,文件描述符数量不大的情况。
缺点:监视的文件描述符数量有最大限制,通常为1024,增加数量会降低性能。
需要复制大量的句柄数据结构,可能产生大量开销。
返回的数组中包含所有就绪的句柄,需要遍历整个数组才能找到发生事件的句柄。
触发方式是水平触发,如果未完成I/O操作,每次调用都会通知文件描述符就绪。意思就是文件描述符的状态会持续被报告,直到应用程序处理完所有相关的 I/O 操作。
内核实现使用轮询方法,性能有限。
select函数
int select(int nfds, fd_set* readfds, fd_set* writefds, fd_set *exceptfds, struct timeval *timeout);
nfds: 是三个集合中编号最高的文件描述符,加上 1
readfds/writefds/exceptfds: 可读集合/可写集合/异常集合
timeout:
NULL:永久阻塞
0:非阻塞模式
fd_set结构体
编程流程
Ⅰ.准备文件描述符集合
在使用 select() 函数之前,需要准备三个文件描述符集合,分别是读文件描述符集合、写文件描述符集合和异常文件描述符集合。可以使用 fd_set 类型的变量来表示这些集合,并使用 FD_ZERO() 宏将其初始化为空集。
Ⅱ.设置需要监视的文件描述符
将需要监视的文件描述符添加到相应的文件描述符集合中,可以使用 FD_SET() 宏将文件描述符添加到集合中。
Ⅲ.调用 select() 函数
调用 select() 函数来监视文件描述符的状态变化,函数返回时,返回值表示就绪文件描述符的数量,如果出现错误则返回 -1。
Ⅳ.检查就绪文件描述符
在 select() 函数返回后,可以使用 FD_ISSET() 宏来检查具体哪些文件描述符已经就绪。
Ⅴ.处理就绪文件描述符
根据 FD_ISSET() 的返回值来判断哪些文件描述符已经就绪,然后进行相应的操作,比如读取数据、发送数据等。
Ⅵ.重复以上步骤可以循环调用 select() 函数来重复监视文件描述符的状态变化,实现长时间的事件驱动循环。
代码:
net.h
#ifndef _NET_H_
#define _NET_H_
#include <stdio.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <strings.h>
#include <errno.h>
typedef struct sockaddr Addr;
typedef struct sockaddr_in Addr_in;
#define BACKLOG 5
#define ErrExit(msg) do { perror(msg); exit(EXIT_FAILURE); } while(0)
void Argment(int argc, char *argv[]);
int CreateSocket(char *argv[]);
int DataHandle(int fd);
#endif
socket.c
#include "net.h"
void Argment(int argc, char *argv[]){
// 检查命令行参数数量,确保提供了地址和端口
if(argc < 3){
fprintf(stderr, "%s <addr> <port>\n", argv[0]);
exit(0);
}
}
int CreateSocket(char *argv[]){
/* 创建 TCP 套接字 */
int fd = socket(AF_INET, SOCK_STREAM, 0);
if(fd < 0)
ErrExit("socket"); // 创建套接字失败,输出错误信息并退出
/* 允许地址快速重用 */
int flag = 1;
if(setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &flag, sizeof(flag)))
perror("setsockopt"); // 设置选项失败,输出错误信息
/* 设置通信结构体 */
Addr_in addr;
bzero(&addr, sizeof(addr)); // 清零结构体
addr.sin_family = AF_INET; // 地址族为 IPv4
addr.sin_port = htons(atoi(argv[2])); // 设置端口,转换为网络字节序
/* 绑定通信结构体 */
if(bind(fd, (Addr *)&addr, sizeof(Addr_in)))
ErrExit("bind"); // 绑定失败,输出错误信息并退出
/* 设置套接字为监听模式 */
if(listen(fd, BACKLOG))
ErrExit("listen"); // 监听失败,输出错误信息并退出
return fd; // 返回套接字文件描述符
}
int DataHandle(int fd){
char buf[BUFSIZ] = {}; // 缓冲区初始化为空
Addr_in peeraddr; // 存储对方地址信息
socklen_t peerlen = sizeof(Addr_in);
// 获取对方的地址信息
if(getpeername(fd, (Addr *)&peeraddr, &peerlen))
perror("getpeername"); // 获取对方地址失败,输出错误信息
// 接收数据
int ret = recv(fd, buf, BUFSIZ, 0);
if(ret < 0)
perror("recv"); // 接收数据失败,输出错误信息
if(ret > 0){
// 输出接收到的数据和对方地址信息
printf("[%s:%d] data: %s\n",
inet_ntoa(peeraddr.sin_addr), // 对方 IP 地址
ntohs(peeraddr.sin_port), // 对方端口号
buf); // 接收到的数据
}
return ret; // 返回接收到的字节数,0 表示对方关闭连接,负值表示错误
}
server.c
监听套接字用于接受新的客户端连接,并在 select() 中检测到有新连接请求时,使用 accept() 创建新的连接套接字,当有新的客户端尝试连接到服务器时,表示有事件发生,监听套接字会变得“就绪”。
连接套接字用于与客户端进行数据通信,并在 select() 中监视数据是否到达或是否需要处理。当已经建立的连接套接字有数据到达时,表示有事件发生,这些套接字变得“就绪”。
#include "net.h"
#include <sys/select.h>
#define MAX_SOCK_FD 1024 // 最大的文件描述符数量
int main(int argc, char *argv[])
{
int i, ret, fd, newfd;
fd_set set, tmpset; // 文件描述符集合
Addr_in clientaddr; // 客户端地址信息
socklen_t clientlen = sizeof(Addr_in);
/* 检查命令行参数,确保提供了地址和端口 */
Argment(argc, argv);
/* 创建并设置为监听模式的套接字 */
fd = CreateSocket(argv);
/* 初始化文件描述符集合 */
FD_ZERO(&set); // 清空集合
FD_ZERO(&tmpset); // 清空临时集合
FD_SET(fd, &set); // 将监听套接字添加到集合中
while(1) {
/* 每次循环时,将临时集合设置为原始集合 */
tmpset = set;
/* 使用 select 函数来监视文件描述符集合 */
if ((ret = select(MAX_SOCK_FD, &tmpset, NULL, NULL, NULL)) < 0)
ErrExit("select"); // select 出错,输出错误信息并退出
/* 检查是否有新的连接请求 */
if (FD_ISSET(fd, &tmpset)) {
/* 接受新的客户端连接,并生成新的文件描述符 */
if ((newfd = accept(fd, (Addr *)&clientaddr, &clientlen)) < 0)
perror("accept"); // 接受连接失败,输出错误信息
else {
printf("[%s:%d] 已建立连接\n",
inet_ntoa(clientaddr.sin_addr), // 客户端 IP 地址
ntohs(clientaddr.sin_port)); // 客户端端口号
FD_SET(newfd, &set); // 将新的套接字添加到集合中
}
} else { // 处理客户端的数据
for (i = fd + 1; i < MAX_SOCK_FD; i++) {
if (FD_ISSET(i, &tmpset)) { // 检查是否有数据可读
if (DataHandle(i) <= 0) { // 处理数据
if (getpeername(i, (Addr *)&clientaddr, &clientlen))
perror("getpeername"); // 获取对方地址失败,输出错误信息
printf("[%s:%d] 断开连接\n",
inet_ntoa(clientaddr.sin_addr), // 客户端 IP 地址
ntohs(clientaddr.sin_port)); // 客户端端口号
FD_CLR(i, &set); // 从集合中移除已断开的套接字
close(i); // 关闭套接字
}
}
}
}
}
return 0;
}
(2)poll
基本原理与 select 一致,也是轮询 + 遍历。唯一的区别就是 poll 没有最大文件描述符限制(使用链表的方式存储 fd)。
优点:相对于 select,没有监视文件数量限制,使用链表保存文件描述符。
缺点:仍然需要复制大量的数据结构。
需要遍历整个链表来找到就绪的文件描述符。
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
fds: 一个指向 pollfd 结构体数组的指针,pollfd 结构体用于描述要监视的文件描述符及其感兴趣的事件。
nfds: fds 数组中包含的 pollfd 结构体的数量。
timeout: 等待事件发生的时间(以毫秒为单位)。如果为 0,poll() 立即返回,不进行阻塞(非阻塞模式)。如果为负数,poll() 会永久阻塞,直到至少有一个事件发生。正值表示阻塞的时间长度(毫秒)。
struct pollfd {
int fd; /* 文件描述符 */
short events; /* 请求的事件 */
short revents; /* 返回的事件 */
};
fd: 要监视的文件描述符。
events: 该字段用于指定感兴趣的事件。可以是以下事件的组合:
POLLIN:表示文件描述符有数据可以读取。
POLLOUT:表示文件描述符可以写入数据。
POLLERR:表示文件描述符有错误。
POLLHUP:表示文件描述符被挂断。
POLLPRI:表示文件描述符有紧急数据。
revents: 返回的事件。poll() 调用后,系统会设置此字段,指示哪些事件已发生。可以与 events 中的值进行比较,以确定实际发生的事件。
使用流程:
Ⅰ准备 pollfd 数组
在使用 poll() 函数之前,需要准备一个 struct pollfd 类型的数组,数组中的每个元素代表一个待监视的文件描述符。这个结构体的定义如下:
Ⅱ设置需要监视的文件描述符
对于每个待监视的文件描述符,将其添加到 pollfd 数组中,并设置所关心的事件类型。
Ⅲ调用 poll() 函数调用
poll() 函数来监视文件描述符的状态变化,函数返回时,返回值表示就绪文件描述符的数量,如果出现错误则返回 -1。
Ⅳ.检查就绪文件描述符
在 poll() 函数返回后,遍历 pollfd 数组,检查每个文件描述符的 revents 成员,以确定哪些文件描述符已经就绪。
Ⅴ.处理就绪文件描述符
根据 revents 成员的值来判断哪些文件描述符已经就绪,然后进行相应的操作,比如读取数据、发送数据等。
Ⅵ.重复以上步骤可以循环调用 poll() 函数来重复监视文件描述符的状态变化,实现长时间的事件驱动循环。
代码:
net.h
#ifndef _NET_H_
#define _NET_H_
#include <stdio.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <strings.h>
#include <errno.h>
typedef struct sockaddr Addr;
typedef struct sockaddr_in Addr_in;
#define BACKLOG 5
#define ErrExit(msg) do { perror(msg); exit(EXIT_FAILURE); } while(0)
void Argment(int argc, char *argv[]);
int CreateSocket(char *argv[]);
int DataHandle(int fd);
#endif
socket.c
#include "net.h"
void Argment(int argc, char *argv[]){
if(argc < 3){
fprintf(stderr, "%s<addr><port>\n", argv[0]);
exit(0);
}
}
int CreateSocket(char *argv[]){
/*创建套接字*/
int fd = socket(AF_INET, SOCK_STREAM, 0);
if(fd < 0)
ErrExit("socket");
/*允许地址快速重用*/
int flag = 1;
if( setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &flag, sizeof(flag) ) )
perror("setsockopt");
/*设置通信结构体*/
Addr_in addr;
bzero(&addr, sizeof(addr) );
addr.sin_family = AF_INET;
addr.sin_port = htons( atoi(argv[2]) );
/*绑定通信结构体*/
if( bind(fd, (Addr *)&addr, sizeof(Addr_in) ) )
ErrExit("bind");
/*设置套接字为监听模式*/
if( listen(fd, BACKLOG) )
ErrExit("listen");
return fd;
}
int DataHandle(int fd){
char buf[BUFSIZ] = {};
Addr_in peeraddr;
socklen_t peerlen = sizeof(Addr_in);
if( getpeername(fd, (Addr *)&peeraddr, &peerlen) )
perror("getpeername");
int ret = recv(fd, buf, BUFSIZ, 0);
if(ret < 0)
perror("recv");
if(ret > 0){
printf("[%s:%d]data: %s\n",
inet_ntoa(peeraddr.sin_addr), ntohs(peeraddr.sin_port), buf);
}
return ret;
}
server.c
#include "net.h"
#include <poll.h>
#define MAX_SOCK_FD 1024 // 最大支持的文件描述符数量
int main(int argc, char *argv[])
{
int i, j, fd, newfd;
nfds_t nfds = 1; // 当前监视的文件描述符数量,初始化为1(监听套接字)
struct pollfd fds[MAX_SOCK_FD] = {}; // 用于poll的文件描述符数组
Addr_in addr;
socklen_t addrlen = sizeof(Addr_in);
/* 检查命令行参数,确保至少有两个参数(地址和端口号) */
Argment(argc, argv);
/* 创建一个已设置为监听模式的套接字 */
fd = CreateSocket(argv);
/* 初始化pollfd数组,监视监听套接字 */
fds[0].fd = fd;
fds[0].events = POLLIN; // 监视可读事件
while(1){
/* 使用poll函数监视文件描述符的事件 */
if(poll(fds, nfds, -1) < 0)
ErrExit("poll"); // 调用poll失败,打印错误信息并退出
for(i = 0; i < nfds; i++){
/* 如果监听套接字有可读事件,则接受新的连接 */
if(fds[i].fd == fd && fds[i].revents & POLLIN){
if((newfd = accept(fd, (Addr *)&addr, &addrlen)) < 0)
perror("accept"); // 接受连接失败,打印错误信息
/* 将新连接的文件描述符添加到pollfd数组中 */
fds[nfds].fd = newfd;
fds[nfds++].events = POLLIN;
printf("[%s:%d][nfds=%lu] connection successful.\n",
inet_ntoa(addr.sin_addr), ntohs(addr.sin_port), nfds);
}
/* 处理客户端数据,如果有可读事件 */
if(i > 0 && fds[i].revents & POLLIN){
if(DataHandle(fds[i].fd) <= 0){
/* 处理客户端数据失败或连接关闭 */
if(getpeername(fds[i].fd, (Addr *)&addr, &addrlen) < 0)
perror("getpeername"); // 获取对等端信息失败,打印错误信息
printf("[%s:%d][fd=%d] exited.\n",
inet_ntoa(addr.sin_addr), ntohs(addr.sin_port), fds[i].fd);
close(fds[i].fd); // 关闭连接
/* 从pollfd数组中移除已关闭的文件描述符 */
for(j = i; j < nfds - 1; j++)
fds[j] = fds[j + 1];
nfds--; // 更新监视的文件描述符数量
i--; // 重新检查当前位置的文件描述符
}
}
}
}
close(fd); // 关闭监听套接字
return 0;
}
(3)epoll
epoll可以理解为event poll,它是一种事件驱动的I/O模型,可以用来替代传统的select和poll模型。epoll的优势在于它可以同时处理大量的文件描述符,而且不会随着文件描述符数量的增加而降低效率。
epoll的实现机制是通过内核与用户空间共享一个事件表,这个事件表中存放着所有需要监控的文件描述符以及它们的状态,当文件描述符的状态发生变化时,内核会将这个事件通知给用户空间,用户空间再根据事件类型进行相应的处理。
epoll 使用了一个事件表(event table)来保存需要监控的文件描述符和相应的事件类型,并提供了 epoll_ctl() 函数来向事件表中添加、修改或删除文件描述符。与 select 和 poll 不同的是,epoll 的设计更加高效,它使用了内核中的事件通知机制,可以避免遍历文件描述符集合,当文件描述符的状态发生变化时,内核会立即通知应用程序。这样可以避免遍历文件描述符集合,减少了不必要的 CPU 消耗,从而提高了效率。当调用 epoll_wait() 函数时,它会返回一个包含已就绪文件描述符的列表,并且它只返回那些真的有事件发生的文件描述符。这意味着它避免了遍历整个事件表,直接返回你感兴趣的文件描述符。
没有 fd 个数限制,用户态拷贝到内核态只需要一次,使用时间通知机制来触发。通过 epoll_ctl 注册 fd,一旦 fd 就绪就会通过 callback 回调机制来激活对应 fd,进行相关的 io 操作。epoll 之所以高性能是得益于它的三个函数:
epoll_create() 系统启动时,在 Linux 内核里面申请一个B+树结构文件系统,返回 epoll 对象,也是一个 fd。
epoll_ctl() 每新建一个连接,都通过该函数操作 epoll 对象,在这个对象里面修改添加删除对应的链接 fd,绑定一个 callback 函数
epoll_wait() 轮询所有的 callback 集合,并完成对应的 IO 操作
优点:适用于大规模连接,仅监听已准备好的文件描述符,效率较高。
使用边缘触发(只通知状态变化),提高了效率。
在 Linux 上有较好的性能,采用更先进的事件通知机制。
无文件描述符数量限制。
缺点:只能在Linux操作系统上可用。
相关API详解:
epoll_create()
在系统启动时,调用 epoll_create() 函数创建一个 epoll 对象,该对象在内核中管理一个事件表。这个事件表通常使用的是一个高效的数据结构,例如 B+ 树。
epoll_create() 返回一个文件描述符(epoll 对象的引用),应用程序可以通过这个文件描述符与 epoll 对象进行交互。
int epoll_fd = epoll_create(int size);
size 参数在现代 Linux 版本中通常被忽略,建议传递 0。
epoll_ctl()
epoll_ctl() 函数用于在 epoll 对象中添加、修改或删除文件描述符。这些文件描述符是我们希望监控的,可能是网络套接字或其他 I/O 资源。
在调用 epoll_ctl() 时,我们指定文件描述符及其要监控的事件(例如读事件、写事件)。这个函数将这些文件描述符及其事件信息记录在 epoll 对象的事件表中。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epfd 是由 epoll_create() 返回的 epoll 文件描述符。
op 是操作类型,包括 EPOLL_CTL_ADD(添加)、EPOLL_CTL_MOD(修改)、EPOLL_CTL_DEL(删除)。
fd 是要监控的文件描述符。
event 是一个 epoll_event 结构体,包含要监控的事件类型和事件数据。
epoll_wait()
epoll_wait() 函数用于等待和获取已经就绪的文件描述符列表。当文件描述符发生事件时,内核将它们的状态更新到 epoll 对象,并在调用 epoll_wait() 时返回这些已就绪的文件描述符。
这个函数会返回发生事件的文件描述符集合,应用程序可以据此进行进一步的 I/O 操作。
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
epfd 是由 epoll_create() 返回的 epoll 文件描述符。
events 是一个数组,用于存放已就绪的事件。
maxevents 是 events 数组的大小。
timeout 指定等待事件的时间。可以是:
正数:指定超时时间(毫秒)。
0:非阻塞模式,立即返回。
-1:阻塞模式,直到有事件发生。
epoll_event 结构体定义:
struct epoll_event {
uint32_t events; // 事件类型
epoll_data_t data; // 事件相关的数据
};
events:
类型:uint32_t
作用:表示你希望监控的事件类型。它是一个位掩码,可以是以下值的组合:
EPOLLIN:表示对应的文件描述符可读。
EPOLLOUT:表示对应的文件描述符可写。
EPOLLERR:表示对应的文件描述符发生了错误。
EPOLLHUP:表示对应的文件描述符被挂起(例如对端关闭连接)。
EPOLLRDHUP:表示对端关闭连接的一部分。
EPOLLONESHOT:表示该事件触发一次后自动注销(即,事件只会触发一次)。
EPOLLET:表示使用边缘触发模式(Edge Triggered),即在状态变化时才通知。
data:
类型:epoll_data_t(通常是一个联合体)
作用:用来存储与事件相关的数据。你可以通过这个字段存储与文件描述符相关的信息,如文件描述符本身或其他用户定义的数据。
结构:
typedef union epoll_data {
void *ptr; // 指针
int fd; // 文件描述符
uint32_t u32; // 无符号32位整数
uint64_t u64; // 无符号64位整数
} epoll_data_t;
使用流程:
Ⅰ.创建 epoll 实例
首先,需要调用 epoll_create() 函数创建一个 epoll 实例,并获取一个文件描述符用于操作 epoll 实例。函数返回一个文件描述符,用于引用新创建的 epoll 实例。
Ⅱ.添加文件描述符到 epoll 实例
使用 epoll_ctl() 函数向 epoll 实例中添加或删除文件描述符,或者修改文件描述符上关注的事件。在添加文件描述符时,需要先设置 event 结构体的 events 成员,指定文件描述符关注的事件类型,然后调用 epoll_ctl() 函数进行添加操作。
Ⅲ.等待就绪事件使用
epoll_wait() 函数等待就绪事件的发生,该函数会阻塞程序直到有事件发生或超时。函数返回就绪事件的数量,如果超时则返回 0,如果出现错误则返回 -1。
Ⅳ.处理就绪事件
在 epoll_wait() 函数返回后,遍历 events 数组,处理每个就绪事件。可以通过 events[i].data.fd 获取就绪事件对应的文件描述符,并通过 events[i].events 获取该文件描述符上发生的事件类型。
Ⅴ.重复以上步骤可以循环调用 epoll_wait() 函数来重复等待就绪事件的发生,从而实现长时间的事件驱动循环。
代码:
net.h
#ifndef _NET_H_
#define _NET_H_
#include <stdio.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <strings.h>
#include <errno.h>
typedef struct sockaddr Addr;
typedef struct sockaddr_in Addr_in;
#define BACKLOG 5
#define ErrExit(msg) do { perror(msg); exit(EXIT_FAILURE); } while(0)
void Argment(int argc, char *argv[]);
int CreateSocket(char *argv[]);
int DataHandle(int fd);
#endif
socket.c
#include "net.h"
void Argment(int argc, char *argv[]){
// 检查命令行参数是否足够(需要至少两个参数:地址和端口)
if(argc < 3){
fprintf(stderr, "%s<addr><port>\n", argv[0]);
exit(0);
}
}
int CreateSocket(char *argv[]){
/* 创建套接字 */
int fd = socket(AF_INET, SOCK_STREAM, 0);
if(fd < 0)
ErrExit("socket"); // 创建套接字失败,输出错误信息并退出
/* 允许地址快速重用 */
int flag = 1;
if(setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &flag, sizeof(flag)))
perror("setsockopt"); // 设置套接字选项失败,输出错误信息
/* 设置通信结构体 */
Addr_in addr;
bzero(&addr, sizeof(addr)); // 将 addr 结构体清零
addr.sin_family = AF_INET; // 地址族设置为 IPv4
addr.sin_port = htons(atoi(argv[2])); // 设置端口号,转换为网络字节序
/* 绑定通信结构体到套接字 */
if(bind(fd, (Addr *)&addr, sizeof(Addr_in)))
ErrExit("bind"); // 绑定失败,输出错误信息并退出
/* 设置套接字为监听模式 */
if(listen(fd, BACKLOG))
ErrExit("listen"); // 设置监听失败,输出错误信息并退出
return fd; // 返回监听套接字的文件描述符
}
int DataHandle(int fd){
char buf[BUFSIZ] = {}; // 数据缓冲区
Addr_in peeraddr; // 存储对端地址信息
socklen_t peerlen = sizeof(Addr_in); // 地址长度
// 获取对端的地址信息
if(getpeername(fd, (Addr *)&peeraddr, &peerlen))
perror("getpeername"); // 获取地址信息失败,输出错误信息
// 接收数据
int ret = recv(fd, buf, BUFSIZ, 0);
if(ret < 0)
perror("recv"); // 接收数据失败,输出错误信息
// 如果接收到数据,则输出数据和对端地址信息
if(ret > 0){
printf("[%s:%d]data: %s\n",
inet_ntoa(peeraddr.sin_addr), ntohs(peeraddr.sin_port), buf);
}
return ret; // 返回接收到的字节数,0 表示对端关闭连接,负值表示错误
}
server.c
#include "net.h"
#include <sys/epoll.h>
#define MAX_SOCK_FD 1024
int main(int argc, char *argv[])
{
int i, nfds, fd, epfd, newfd;
Addr_in addr;
socklen_t addrlen = sizeof(Addr_in);
struct epoll_event tmp, events[MAX_SOCK_FD] = {};
/* 检查命令行参数是否足够(需要至少两个参数:地址和端口) */
Argment(argc, argv);
/* 创建已设置监听模式的套接字 */
fd = CreateSocket(argv);
/* 创建 epoll 实例 */
if((epfd = epoll_create(1)) < 0)
ErrExit("epoll_create"); // 创建 epoll 实例失败,输出错误信息并退出
/* 将监听套接字 fd 添加到 epoll 实例中,监视 EPOLLIN 事件 */
tmp.events = EPOLLIN;
tmp.data.fd = fd;
if(epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &tmp))
ErrExit("epoll_ctl"); // 添加套接字到 epoll 实例失败,输出错误信息并退出
while(1) {
/* 等待事件的发生,阻塞直到有事件就绪 */
if((nfds = epoll_wait(epfd, events, MAX_SOCK_FD, -1)) < 0)
ErrExit("epoll_wait"); // 等待事件失败,输出错误信息并退出
printf("nfds = %d\n", nfds); // 输出当前就绪的文件描述符数量
for(i = 0; i < nfds; i++) {
if(events[i].data.fd == fd) { //检查事件是否是监听套接字上的新连接请求。
/* 处理新的客户端连接 */
if((newfd = accept(fd, (Addr *)&addr, &addrlen)) < 0)
perror("accept"); // 接受连接失败,输出错误信息
printf("[%s:%d] connection.\n", inet_ntoa(addr.sin_addr), ntohs(addr.sin_port));
/* 将新连接的套接字添加到 epoll 实例中,监视 EPOLLIN 事件 */
tmp.events = EPOLLIN;
tmp.data.fd = newfd;
if(epoll_ctl(epfd, EPOLL_CTL_ADD, newfd, &tmp))
ErrExit("epoll_ctl"); // 添加新套接字到 epoll 实例失败,输出错误信息并退出
} else {//说明是现有连接的事件,处理现有连接的数据
/* 处理客户端数据 */
if(DataHandle(events[i].data.fd) <= 0) {
/* 从 epoll 实例中删除已关闭的套接字 */
if(epoll_ctl(epfd, EPOLL_CTL_DEL, events[i].data.fd, NULL))
ErrExit("epoll_ctl"); // 删除套接字失败,输出错误信息并退出
/* 获取对端的地址信息,并打印断开连接的信息 */
if(getpeername(events[i].data.fd, (Addr *)&addr, &addrlen))
perror("getpeername"); // 获取地址信息失败,输出错误信息
printf("[%s:%d] exited.\n", inet_ntoa(addr.sin_addr), ntohs(addr.sin_port));
/* 关闭套接字 */
close(events[i].data.fd);
}
}
}
}
/* 关闭 epoll 实例和监听套接字 */
close(epfd);
close(fd);
return 0;
}
(4)select,poll和epoll各自优缺点
select
-
单个进程能够监视的文件描述符的数量有最大限制,通常是1024,虽然可以更改数量,但由于select采用轮询的方式扫描文件描述符,文件描述符数量越多,性能越差
-
内核/用户空间内存拷贝问题,select需要复制大量的句柄数据结构,会产生巨大的开销
-
select返回的是含有整个句柄的数组,应用程序需要遍历整个数组才能发现哪些句柄发生了事件
-
select的触发方式是水平触发,应用程序如果没有完成对一个已经就绪的文件描述符进行I/O操作,那么之后每次select调用还是会将这些文件描述符通知进程
-
内核中实现select是用轮询方法,即每次检测都会遍历所有FD_SET中的句柄
假设服务器需要支持100万的并发连接,在__FD_SETSIZE
为1024
的情况下,则我们至少需要开辟1000个进程才能实现100万的并发连接
poll
poll使用链表保存文件描述符,因此没有了监视文件数量的限制,但其他三个缺点依然存在 select与poll目前在小规模服务器上还是有用武之地,并且维护老系统代码的时候,经常会用到这两个函数;
epoll
epoll是Linux下多路复用I/O接口select/poll的增强版本,epoll只需要监听那些已经准备好的队列集合中的文件描述符,效率较高