文章目录
IO模型
在还在学习语言的阶段,C++里使用cin,或者是C使用scanf的时候,总是要等着我们输入数据才执行,这种IO是阻塞IO。下面是比较正式的说法。
阻塞IO: 在内核将数据准备好之前,系统调用会一直等待数据的获取数据的做法,就是阻塞IO。
所以网络套接字,在你没有自行设置的情况下,用的也是阻塞方式IO。
上面说的仿佛只有读取一种情况,那么写呢?
实际上写也是有一样的问题,内核有缓冲区的空间才写,没有缓冲区空间就一样得阻塞。
非阻塞IO:顾名思义,如果需求的数据,内核还没卓备好,那么操作系统就会直接返回。
在C语言里,如果你使用C接口的非阻塞IO,如果没收到数据系统调用就返回,那么 宏变量 errno就会被设置,其值是EWOULDBLOCK 错误码。
C在C11标准之后,C++11标准之后,都是支持线程安全的。
那么问题来了,究竟什么是IO呢?
等待数据 + 拷贝数据
我们发现不论是网络的套接字,亦或者是我们常用的自己的输入输出,其实无非都在等待一些数据,把这些数据拷贝进我们的内存交由程序处理。
多路转接
理解多路转接之前,我们先思考一个问题,
IO=等待+拷贝。
那我们该什么时候区拷贝呢?
一些比较经典的操作就是,轮询,信号。
所谓轮询就是,每当我需要数据,我就问问你,数据好了没,没有我就稍等一会再来接着问,知道数据好了我取走。
所谓信号就是,当你数据好了你来通知我,让我来取走数据。
我们知道,一个主机可以和其他多个主机建立TCP链接,也就是需要使用多个套接字。
那么每当有一个新的连接来临,我们不想中断我主线程的业务,但是新连接的数据收发也要管理。该如何呢? 其中一般想到的是,开多个线程。
开多线程固然是一种解决方案,其对于一般服务器负载也没问题。
那么有成千上万的连接来临呢?要知道创建新线程也是有开销的,根据我的Linux下的POSIX线程库正常创建线程(不重新设置栈大小等),那么每一个线程约需要10MiB的空间。
算下来4GiB的内存,用户一般有3GiB,那么就是说约莫只有300个线程的情况,显然算不上高并发。
因此就有一种IO模型,其处于非阻塞IO,你的每一个文件描述符(windows下叫文件句柄),都有一个中间者来给你管理,当这些句柄有数据来临时,他来通知你,告诉你改处理这些数据了。而这就是多路转接。
下面介绍Linux多路转接常用的函数,select,poll,和epoll
select 和 poll
cpp
int select (int __nfds, fd_set *__restrict __readfds,
fd_set *__restrict __writefds,
fd_set *__restrict __exceptfds,
struct timeval *__restrict __timeout);
void FD_CLR(int fd, fd_set *set);
int FD_ISSET(int fd, fd_set *set);
void FD_SET(int fd, fd_set *set);
void FD_ZERO(fd_set *set);
fd_set # 文件描述符集的类型
注:select和poll在2.6版本之后用的较少,因为后来的机器内存都相对较大,同时主要因为有了epoll的出现,使之取代了select。
select维护了一个文件描述符集,FDS,其类型如上面的 fd_set,你可以用一些列函数接口来操作。当你有一个文件描述符是5号文件描述符,那么你就可以调用 FD_SET取设置入你的fd_set的数据类型里面。然后最后交给select帮你管理。
虽然答题过程如上输代码一言,但是其实际使用并不方便。原因也十分简单,select的思想处理其实是一种轮询的方式。、,这导致你每次都要设置文件描述符。
下面是一段示例代码。
cpp
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/select.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
#define MAX_FD 10 // 最大文件描述符数量
#define BUFFER_SIZE 1024 // 读取缓冲区大小
int main() {
int fd[MAX_FD]; // 存储文件描述符的数组
fd_set read_fds; // select的可读文件描述符集合
int max_fd = 0; // 当前最大的文件描述符
char buffer[BUFFER_SIZE]; // 读取数据的缓冲区
int i, ret;
// 初始化文件描述符,这里只是示例,实际情况可能是套接字
for (i = 0; i < MAX_FD; i++) {
fd[i] = -1; // 初始化为-1,表示未使用
}
// 假设我们监听标准输入(文件描述符0)
fd[0] = 0;
max_fd = 0; // 标准输入的文件描述符是0
while (1) {
// 清空fd集合
FD_ZERO(&read_fds);
// 将需要监听的文件描述符加入到fd集合
for (i = 0; i <= max_fd; i++) {
if (fd[i] != -1) {
FD_SET(fd[i], &read_fds);
}
}
// 设置超时时间,这里设置为永远等待
struct timeval timeout;
timeout.tv_sec = 10; // 10秒
timeout.tv_usec = 0; // 0微秒
// 调用select
ret = select(max_fd + 1, &read_fds, NULL, NULL, &timeout);
if (ret == -1) {
perror("select error");
exit(EXIT_FAILURE);
} else if (ret == 0) {
printf("select timeout\n");
continue;
}
// 检查哪个文件描述符可读
for (i = 0; i <= max_fd; i++) {
if (fd[i] != -1 && FD_ISSET(fd[i], &read_fds)) {
// 这里处理文件描述符i的数据
memset(buffer, 0, BUFFER_SIZE);
ssize_t count = read(fd[i], buffer, BUFFER_SIZE - 1);
if (count > 0) {
printf("Read from fd %d: %s\n", fd[i], buffer);
} else if (count == 0) {
// EOF,可能需要关闭文件描述符
close(fd[i]);
fd[i] = -1;
} else {
// 读取错误
perror("read error");
}
}
}
}
return 0;
}
你会发现意见很让人觉得效率低且麻烦的事情,那就是select每一次都需要遍历。如同轮询一般,因为你放进去的select文件描述符发生事件时,select并不会告诉你具体是谁发生了,只知道在 FD_MAX(目前最大的文件描述符为止),有事件发生,这就显得麻烦且效率低下。不过因为select上限文件描述符大多数都是1024,也就是 FD_SETSIZE 宏。所以select的整体效率不算高,但是其适用于一些比较没有那么支持性能的机器。
总结:
1.每次调用select都需要把fd集合从用户态往内核态拷贝一次,而每次拷贝都需要通过系统调用进入内核态,且在内核也是遍历访问这个开销在fd很多时会很大
2.select支持的文件描述符数量太小了,默认是1024
3.select返回后,需要遍历文件描述符集合,来获取已经就绪的socket
4.select不支持O_NONBLOCK
5.每次对要用第三方数组,动不动就需要遍历,十分耗时
poll类似select,解决了文件描述符上限,同时解决了输入输出每次重置的问题。(也就是select每次都要传一个表进去,同时也要传出来。)
具体用法不多叙述,可以自行百度。
epoll
epoll整体设计理念相较于select就比较人性化,我们知道每当有数据来临的时候,目前许多OS采用的都是硬件中断,让CPU临时去被数据接受之后存储起来。比如你的键盘输入就是如此。
那么为什么不把每个文件描述符有数据需要处理时,都会有信号,那么既然如此我维护这份记录就行,因此epoll就是如此做的。每当一个进程调用epoll时,会创建一个红黑树,将你关心的文件描述符添加进去,每当有事件来临,他就去红黑树里面找关心了这个事件与否,然后如果发现时关心了的,那么就通过回调(ep_poll_callback )把这个节点给放到 另外的就绪队列上去,如此你就知道这个事件需要用了。
因此总结一下:使得epoll关心文件描述符的方法
调用epoll_create创建一个epoll句柄;
调用epoll_ctl, 将要监控的文件描述符进行注册;
调用epoll_wait, 等待文件描述符就绪;
具体调用可查询手册,下面是例子
cpp
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <sys/epoll.h>
#define MAX_EVENTS 10
#define PORT 8080
int main() {
int listen_sock, conn_sock, epfd;
struct sockaddr_in serv_addr;
struct epoll_event event;
struct epoll_event events[MAX_EVENTS];
int num_fds;
// 创建监听socket
listen_sock = socket(AF_INET, SOCK_STREAM, 0);
if (listen_sock == -1) {
perror("socket");
exit(EXIT_FAILURE);
}
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_addr.sin_port = htons(PORT);
// 绑定socket
if (bind(listen_sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) == -1) {
perror("bind");
exit(EXIT_FAILURE);
}
// 监听socket
if (listen(listen_sock, 5) == -1) {
perror("listen");
exit(EXIT_FAILURE);
}
// 创建epoll实例
epfd = epoll_create1(0);
if (epfd == -1) {
perror("epoll_create");
exit(EXIT_FAILURE);
}
// 添加监听socket到epoll实例
event.data.fd = listen_sock;
event.events = EPOLLIN;
if (epoll_ctl(epfd, EPOLL_CTL_ADD, listen_sock, &event) == -1) {
perror("epoll_ctl: listen_sock");
exit(EXIT_FAILURE);
}
// 事件循环
while (1) {
num_fds = epoll_wait(epfd, events, MAX_EVENTS, -1);
if (num_fds == -1) {
perror("epoll_wait");
exit(EXIT_FAILURE);
}
for (int i = 0; i < num_fds; i++) {
if (events[i].data.fd == listen_sock) {
// 处理新的连接
conn_sock = accept(listen_sock, NULL, NULL);
if (conn_sock == -1) {
perror("accept");
exit(EXIT_FAILURE);
}
printf("Accepted connection on fd %d\n", conn_sock);
// 将新的连接添加到epoll实例
event.data.fd = conn_sock;
event.events = EPOLLIN | EPOLLET; // 边缘触发模式
if (epoll_ctl(epfd, EPOLL_CTL_ADD, conn_sock, &event) == -1) {
perror("epoll_ctl: conn_sock");
exit(EXIT_FAILURE);
}
} else {
// 处理已连接socket的数据
if (events[i].events & EPOLLIN) {
char buffer[1024];
ssize_t count;
count = read(events[i].data.fd, buffer, sizeof(buffer));
if (count == -1) {
perror("read");
close(events[i].data.fd);
} else if (count == 0) {
// 连接关闭
printf("Closed connection on fd %d\n", events[i].data.fd);
close(events[i].data.fd);
} else {
// 处理读取到的数据
printf("Read %zd bytes from fd %d\n", count, events[i].data.fd);
// 这里可以将数据发送回去或者进行其他处理
}
}
}
}
}
close(listen_sock);
return 0;
}