在前面的内容中,我们详细剖析了tcp流式协议以及recv()和send()这两个关键函数,用于从套接字中读取和发送数据。不过,仅依赖这两个函数存在一个明显的缺陷:如果一个套接字阻塞了,整个进程将无法处理其他套接字,效率低下。为了解决这个问题,I/O复用模型应运而生,它使用单个线程高效地监视多个文件描述符,是高性能网络编程不可或缺的基石。本文将为你揭开I/O复用的神秘面纱,带你领略select()和poll()函数的精髓。
想详细了解tcp流式协议以及recv()和send()这两个关键函数知识的同学,请前往查阅:Socket编程权威指南(二)完美掌握TCP流式协议及Socket编程的recv()和send()。
一、I/O复用模型概述
在介绍具体函数前,我们先来了解一下I/O复用模型的工作原理。
I/O 复用模型是一种处理并发 I/O 操作的技术,它允许单个进程或线程同时处理多个 I/O 流,从而提高效率和性能。
它允许进程指示内核等待多个事件(如套接字可读、可写、出现异常等),而不是逐个ординarily地轮询。内核会在事件发生时通知进程,从而避免了轮询的低效率问题。此外,I/O复用还能自动重新传输已准备好的描述符。
常见的I/O复用函数有select()、pselect()、poll()和epoll()等,它们的功能大致相同,只是在性能和行为细节上有所差异。下面我们重点介绍其中的select()和poll()。
以下是 I/O 复用模型的工作原理:
-
基本概念: I/O 复用模型通过将 I/O 操作与特定的事件关联起来,使得进程或线程可以在数据准备好时才进行操作,而不是不断地轮询。
-
使用系统调用: I/O 复用通常依赖于特定的系统调用,如
select()
,poll()
, 和epoll()
(在 Linux 上)。这些调用允许进程监控多个 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 复用可以显著减少并发处理的开销,因为它通过单个系统调用管理多个 I/O 流。
-
select()
函数:select()
是最基本的 I/O 复用机制,它允许进程监控多个描述符的 I/O 状态,但它有一些限制,如描述符数量的限制和性能问题。 -
poll()
函数:poll()
提供与select()
类似的功能,但没有描述符数量的限制,但仍然存在性能问题,尤其是在大量描述符时。 -
epoll()
函数:epoll()
是 Linux 特有的 I/O 复用机制,它比select()
和poll()
更高效,因为它使用事件通知机制,并且可以处理大量描述符。 -
水平触发与边缘触发: I/O 复用可以工作在两种模式下:
- 水平触发(Level-triggered):只要条件满足,每次调用都会返回。
- 边缘触发(Edge-triggered):只有在状态变化时才返回,可以提高性能,但编程模型更复杂。
-
应用场景: I/O 复用适用于需要同时处理多个客户端连接的服务器应用程序,如 Web 服务器、数据库服务器等。
-
编程复杂性: 使用 I/O 复用模型编程可能比传统的多线程或多进程模型更复杂,需要仔细管理事件和描述符的状态。‘
I/O 复用模型是一种强大的技术,可以提高应用程序处理大量并发 I/O 操作的能力。然而,它也需要仔细设计和实现,以确保高效和正确的行为。
二、select()函数详解
select()
是一种 I/O 复用模型中的系统调用,它用于监视多个文件描述符(file descriptors)的 I/O 状态,以确定它们是否处于可读、可写或有异常情况的状态。这个函数广泛应用于网络编程和系统编程中,以实现高效的 I/O 多路复用。
1、工作原理
(1)、函数原型
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
nfds
:监视的文件描述符集合中最大的描述符加一。readfds
:指向需要监视读状态的文件描述符集合的指针。writefds
:指向需要监视写状态的文件描述符集合的指针。exceptfds
:指向需要监视异常状态的文件描述符集合的指针。timeout
:指向超时时间的指针,可以是NULL
表示无限期等待。
(2)、文件描述符数量
select()
还需要一个参数 nfds
,它表示最大的文件描述符值加一。这个值确保了 select()
能够监视所有传入的文件描述符。
(3)、监视集合
select()
函数接受三个集合作为参数,分别是监视的读、写和异常文件描述符的集合。
-
fd_set readfds
:需要监视的可读文件描述符集合。 -
fd_set writefds
:需要监视的可写文件描述符集合。 -
fd_set exceptfds
:需要监视的异常文件描述符集合。
(4)、超时时间
select()
允许设置一个超时时间,如果在这个时间内没有文件描述符准备好,函数将返回。如果超时设置为 NULL
,select()
将无限期等待。
(5)、返回结果
当 select()
被调用时,它会阻塞直到以下情况之一发生:
-
至少有一个文件描述符准备好了 I/O 操作。
-
发生了异常。
-
超时时间到达。
(6)、更新结果
select()
会更新传入的集合参数,以反映哪些文件描述符已经准备好 I/O 操作。
2、返回值
-
成功时,返回准备好的文件描述符的数量。
-
出错时,返回
-1
并设置errno
。
3、使用场景
select()
常用于服务器端,需要同时处理多个客户端连接的情况。通过 select()
,服务器可以在单个线程中同时监视多个连接的状态,当某个连接有数据可读或可写时,再进行相应的处理。
4、FD_ZERO()
, FD_SET()
, FD_CLR()
, 和 FD_ISSET()
宏功能说明
FD_ZERO()
, FD_SET()
, FD_CLR()
, 和 FD_ISSET()
是与 select()
函数一起使用的宏,它们用于操作文件描述符集合(fd_set
)。这些宏是定义在 <sys/select.h>
头文件中的,下面是每个宏的具体作用和用法:
(1)、FD_ZERO()
-
作用:将
fd_set
结构初始化为零,即清空集合中的所有文件描述符。 -
用法:
FD_ZERO(&fdset);
其中fdset
是fd_set
类型的变量。
(2)、FD_SET()
-
作用:将指定的文件描述符添加到
fd_set
结构中。 -
用法:
FD_SET(fd, &fdset);
其中fd
是要添加的文件描述符,fdset
是fd_set
类型的变量。 -
注意:如果文件描述符已经在集合中,再次调用
FD_SET()
不会有任何效果。
(3)、FD_CLR()
-
作用:从
fd_set
结构中删除指定的文件描述符。 -
用法:
FD_CLR(fd, &fdset);
其中fd
是要删除的文件描述符,fdset
是fd_set
类型的变量。 -
注意:如果文件描述符不在集合中,调用
FD_CLR()
没有效果。
(4)、FD_ISSET()
-
作用:检查指定的文件描述符是否在
fd_set
结构中。 -
用法:
if (FD_ISSET(fd, &fdset)) { ... }
其中fd
是要检查的文件描述符,fdset
是fd_set
类型的变量。 -
返回值:如果文件描述符在集合中,返回非零值(通常是 1);如果不在集合中,返回 0。
(5)、这些宏使用通常遵循以下步骤:
- 使用
FD_ZERO()
初始化fd_set
结构。 - 使用
FD_SET()
将需要监视的文件描述符添加到集合中。 - 调用
select()
函数,传入fd_set
结构。 - 调用
select()
后,使用FD_ISSET()
检查哪些文件描述符已经准备好 I/O 操作。 - 使用
FD_CLR()
从集合中删除已经处理过的文件描述符,以便在下一次select()
调用中不再监视它们。
(6)、示例代码:
#include <sys/select.h>
#include <unistd.h>
int main() {
fd_set readfds;
int fd;
// 初始化读集合
FD_ZERO(&readfds);
// 添加文件描述符到读集合
fd = 0; // 假设标准输入是我们要监视的文件描述符
FD_SET(fd, &readfds);
// 调用 select() 监视读集合
select(fd + 1, &readfds, NULL, NULL, NULL);
// 检查文件描述符是否准备好读取
if (FD_ISSET(fd, &readfds)) {
// 文件描述符准备好了,可以进行读取操作
}
// 从集合中删除文件描述符
FD_CLR(fd, &readfds);
return 0;
}
这些宏提供了一种方便的方式来管理 select()
函数所需的文件描述符集合。
4、注意事项
select()
有文件描述符数量的限制(通常是 1024),对于大量并发连接,可能需要使用poll()
或epoll()
等更高级的 I/O 复用技术。- 在调用
select()
之前,需要使用FD_ZERO()
,FD_SET()
,FD_CLR()
,FD_ISSET()
等宏来初始化和操作文件描述符集合。
5、案例演示
下面是一个简单的服务器端示例,使用select()同时监视连接套接字和数据套接字:
#include <iostream>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>
int main() {
int listensock = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in servaddr;
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(8000);
bind(listensock, (struct sockaddr*)&servaddr, sizeof(servaddr));
listen(listensock, 5);
fd_set readfds, masterfds;
FD_ZERO(&masterfds);
FD_SET(listensock, &masterfds);
while (true) {
readfds = masterfds;
int nfds = select(listensock + 1, &readfds, NULL, NULL, NULL);
if (FD_ISSET(listensock, &readfds)) {
struct sockaddr_in cliaddr;
socklen_t cliaddrlen = sizeof(cliaddr);
int datafds = accept(listensock, (struct sockaddr*)&cliaddr, &cliaddrlen);
FD_SET(datafds, &masterfds);
}
for (int datafds = 0; datafds < nfds; datafds++) {
if (FD_ISSET(datafds, &readfds)) {
// Handle data from datafds
}
}
}
close(listensock);
return 0;
}
在这个例子中,我们首先将监听套接字加入masterfds集合。然后在每次循环中,将masterfds复制到readfds,并调用select()进行监视。如果监听套接字就绪,则接受新的连接并将数据套接字加入masterfds。如果数据套接字就绪,则可以对其进行读写操作。
需要注意的是,select()存在一些明显的缺陷,如只能监视少于FD_SETSIZE个描述符、需要每次循环复制描述符集等,这在高并发场景下会带来较大的系统开销。
关于 select( ) 的疑问,为什么在使用 select()
函数时,参数 nfds
需要设置为监视的文件描述符集合中最大的文件描述符值加一 ?
这样做的原因有以下几点:
-
边界检查:
select()
函数内部使用数组或位图来表示文件描述符的状态。数组或位图的大小通常与nfds
相关。将nfds
设置为最大的文件描述符值加一,可以确保数组或位图有足够的大小来包含所有可能的文件描述符。 -
兼容性:历史上,
select()
函数的设计允许用户传递一个比实际需要监视的文件描述符更大的值。这样做可以避免在调用select()
时,由于文件描述符数量的增加而导致的重新初始化问题。 -
性能优化:在某些实现中,
select()
可能会根据nfds
的值来分配内存或优化内部数据结构。如果nfds
被设置为实际需要监视的最大文件描述符值,那么在文件描述符增加时,可能需要重新分配内存,这可能会影响性能。 -
API 设计:
select()
函数的 API 设计要求用户明确指定监视的文件描述符范围。通过将nfds
设置为最大文件描述符值加一,用户可以清楚地告诉select()
需要监视的文件描述符的范围。
例如,如果程序中最大的文件描述符是 fd
,那么调用 select(fd + 1, ...)
可以确保所有从 0 到 fd
的文件描述符都被包含在监视范围内。这种设计也使得 select()
函数的实现更加灵活,因为它不依赖于文件描述符的实际数量,而是依赖于用户指定的范围。
然而,需要注意的是,select()
函数有一个限制,即它通常只能监视 1024 个文件描述符(这个限制可能因系统而异)。
如果需要监视更多的文件描述符,可能需要使用其他 I/O 多路复用技术,如 poll()
或 epoll()
(在 Linux 上)。这些技术没有 select()
的文件描述符数量限制,因此可以更有效地处理大量并发连接。
三、poll()函数解析
poll()
是一种 I/O 多路复用系统调用,它提供了一种机制来监视多个文件描述符(file descriptors)的状态,类似于 select()
函数。但是,与 select()
不同的是,poll()
没有最大文件描述符数量的限制,这使得它更适合处理大量并发 I/O 操作。
poll()函数克服了select()的部分缺陷,它采用pollfd结构数组来监视,而不是描述符集,避免了每次调用时复制描述符集的开销。
其函数原型如下:
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
-
fds
:指向struct pollfd
数组的指针,数组中的每个元素都包含了要监视的文件描述符和相关的事件类型。 -
nfds
:数组fds
中元素的数量。 -
timeout
:等待时间,单位为毫秒。如果设置为-1
,表示无限期等待;设置为0
表示非阻塞调用,立即返回。
1、struct pollfd
结构
struct pollfd {
int fd; /* 文件描述符 */
short events; /* 需要监视的事件类型 */
short revents; /* 事件发生后的状态 */
};
-
fd
:需要监视的文件描述符。 -
events:需要监视的事件类型,可以是以下宏的组合:
POLLIN
:有数据可读。POLLOUT
:写入不会阻塞。POLLPRI
:有紧急数据可读。POLLERR
:发生错误。POLLHUP
:对端关闭连接。POLLNVAL
:文件描述符不是有效的监视对象。
-
revents
:实际发生的事件,函数返回后由系统填充。
2、返回值
-
成功时,返回准备好的文件描述符的数量。
-
出错时,返回
-1
并设置errno
。
3、工作原理
-
初始化
pollfd
数组:为每个需要监视的文件描述符设置一个pollfd
结构,并指定需要监视的事件类型。 -
调用
poll()
:传入pollfd
数组、数组的大小和超时时间。 -
等待事件:
poll()
函数会阻塞,直到以下情况之一发生:- 至少有一个文件描述符准备好了 I/O 操作。
- 超时时间到达。
-
处理结果:
poll()
函数返回后,检查pollfd
数组中的revents
字段,以确定哪些事件发生了。
下面是一个使用poll()的服务器端示例:
#include <iostream>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <poll.h>
#include <cstring>
#include <vector>
int main() {
int listensock = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in servaddr;
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(8000);
bind(listensock, (struct sockaddr*)&servaddr, sizeof(servaddr));
listen(listensock, 5);
std::vector<struct pollfd> fds;
fds.push_back({listensock, POLLIN, 0});
while (true) {
int nfds = poll(&fds[0], fds.size(), -1);
if (fds[0].revents & POLLIN) {
struct sockaddr_in cliaddr;
socklen_t cliaddrlen = sizeof(cliaddr);
int datasock = accept(listensock, (struct sockaddr*)&cliaddr, &cliaddrlen);
fds.push_back({datasock, POLLIN, 0});
}
for (auto it = fds.begin() + 1; it != fds.end(); ++it) {
if (it->revents & POLLIN) {
char buf[1024];
ssize_t nbytes = recv(it->fd, buf, sizeof(buf), 0);
if (nbytes <= 0) {
// Handle error or close socket
close(it->fd);
it = fds.erase(it);
--it;
} else {
// Handle data from socket
}
}
}
}
close(listensock);
return 0;
}
在这个例子中,我们首先将监听套接字加入pollfd数组。然后在每次循环中,调用poll()进行监视。如果监听套接字就绪,则接受新的连接并将数据套接字加入pollfd数组。如果数据套接字就绪,则可以对其进行读写操作。
与select()相比,poll()使用pollfd结构体数组而不是描述符集,避免了每次调用时复制描述符集的开销,在处理大量描述符时效率更高。
当然,poll()也存在一些缺陷,如每次调用都需要将全部描述符加入pollfd数组,在描述符数量过多时性能将受到影响。
这就催生了更高效的epoll()函数,它在Linux下独树一帜。
四、epoll () 函数解析
epoll
是 Linux 内核提供的一种高效的 I/O 多路复用机制,用于监视大量文件描述符的 I/O 事件。与 select()
和 poll()
相比,epoll
在处理大量并发连接时具有明显的优势,因为它使用基于事件的模型,可以减少 CPU 和内存的使用。
1、核心概念
- epoll 实例:使用
epoll_create()
创建,代表一个监视的集合。 - 事件:可以是读、写、错误等。
- 文件描述符:需要被监视的 I/O 对象。
- 回调机制:当文件描述符上的事件发生时,
epoll
会通知应用程序。
2、函数原型
- 创建 epoll 实例
#include <sys/epoll.h>
int epoll_create(int size);
size
:建议的初始大小,实际上创建的实例大小由内核决定。
- 添加/修改文件描述符
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epfd
:epoll 实例的文件描述符。
op
:操作类型,可以是 EPOLL_CTL_ADD
(添加)、EPOLL_CTL_MOD
(修改)或 EPOLL_CTL_DEL
(删除)。
fd
:需要监视的文件描述符。
event
:指向 epoll_event
结构的指针,指定了要监视的事件和相关的回调数据。
- 等待事件
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
epfd
:epoll 实例的文件描述符。
events
:用于存储发生的事件的数组。
maxevents
:数组 events
的最大容量。
timeout
:等待时间,单位为毫秒。如果设置为 -1
,表示无限期等待。
3、epoll_event
结构
struct epoll_event {
uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
- events:事件掩码,可以是以下宏的组合:
EPOLLIN
:有数据可读。EPOLLOUT
:写入不会阻塞。EPOLLPRI
:有紧急数据可读。EPOLLERR
:发生错误。EPOLLHUP
:对端关闭连接。
data
:用户自定义的数据,可以是任何类型的指针,用于在事件发生时传递额外信息。
4、工作原理
-
第一步,创建 epoll 实例:使用
epoll_create()
创建一个 epoll 实例。 -
第二步,添加文件描述符:使用
epoll_ctl()
将需要监视的文件描述符添加到 epoll 实例中,并设置要监视的事件。 -
第三步,等待事件:调用
epoll_wait()
等待事件发生。与select()
和poll()
不同,epoll_wait()
只返回已经发生的事件,减少了不必要的轮询。 -
第四步,处理事件:遍历
epoll_wait()
返回的事件数组,处理每个发生的事件。
5、示例代码
#include <sys/epoll.h>
#include <unistd.h>
#include <stdio.h>
int main() {
int epfd = epoll_create(1); // 创建 epoll 实例
struct epoll_event event, events[10];
if (epfd == -1) {
perror("epoll_create");
return 1;
}
// 初始化事件
event.data.fd = STDIN_FILENO; // 监视标准输入
event.events = EPOLLIN;
// 添加文件描述符到 epoll 实例
if (epoll_ctl(epfd, EPOLL_CTL_ADD, STDIN_FILENO, &event) == -1) {
perror("epoll_ctl");
return 1;
}
// 等待事件
int nfds = epoll_wait(epfd, events, 10, -1);
if (nfds == -1) {
perror("epoll_wait");
return 1;
}
// 处理事件
for (int i = 0; i < nfds; i++) {
if (events[i].events & EPOLLIN) {
printf("Data is available to read on fd %d\n", events[i].data.fd);
}
}
close(epfd);
return 0;
}
epoll
提供了一种高效的方式来处理大量并发 I/O 事件,特别适合于高性能的网络服务器。然而,epoll
是特定于 Linux 的,不适用于其他操作系统。
五、总结
综上所述,我们已经较为全面地掌握了recv()、send()以及I/O复用函数select()和poll() 以及 epoll () 的使用方法。相信通过实践,你一定能开发出强大而高效的网络应用程序。期待在不久的将来,我们能为你呈现一部Socket编程的不朽权威之作!