目录
I/O理解
Linux核心思想之一,便是认为一切皆文件。操作系统会对磁盘中所有文件统一管理,文件又是由文件的内容和文件的属性组成。重点关注的内容不再是语言级别的文件调用接口,而是重点关注系统级别的文件调用接口。
Linux访问文件本质上还是进程去访问文件,进程是访问文件的主体,进程写入和读取文件本质上没有区别。深入到系统级别看待文件写入和读取,可以将输入和输出设备都看做文件。例如通过键盘输入数据,首先将键盘中输入的数据输入到内存中,然你后内存再通过相应的操作将内容输入到显示器中。
Linux支持的常见的几种IO模型
- 阻塞I/O(Blocking I/O):进程在I/O操作完成之前会被阻塞,无法继续执行。
- 非阻塞I/O(Non-blocking I/O):进程在I/O操作未完成时不会被阻塞,可以继续执行其他操作。
- I/O多路复用(I/O Multiplexing):通过系统调用如
select
、poll
、epoll
,可以同时监视多个文件描述符,任何一个文件描述符准备好I/O操作时,通知进程进行处理。- 信号驱动I/O(Signal-driven I/O):当文件描述符准备好进行I/O操作时,内核向进程发送一个信号。
- 异步I/O(Asynchronous I/O, AIO):进程发出I/O请求后立即返回,内核完成I/O操作后通知进程。
I/O性能优化方法 (主要两种,减少等待时间和提高拷贝效率)
- 异步I/O:使用AIO来提高I/O并发性。
- I/O多路复用:使用
epoll
等机制管理多个I/O操作。- 直接I/O(Direct I/O):绕过操作系统缓存,直接对硬件进行读写操作。
- 零拷贝(Zero-copy):减少数据在用户空间和内核空间之间的拷贝次数,提高传输效率。
系统文件I/O
文件描述符
每个进程创建后,PCB结构体重点文件描述会默认打开三个缺省的文件描述符,分别是标准输入(键盘)、标准输出(显示器)、标准错误(显示器)。下标分别是0、1、2。文件描述符底层用数组维护,数组的每一个下标都对应着进程所使用的文件。所以只要拿到了对应进程文件描述符,便可以拿到对应的文件。
进程结构体和文件描述符之间的关系,文件描述符在进程结构中,文件描述符表中存储这多个文件指针,通过这些文件指针可以找到对应的文件。
文件描述符的分配原则,新的文件按照文件描述符表中最小的下标进行存储。
重定向
重定向的本质是操作系统层面,改变了文件fed的内容指向,例如之前指向的输出到屏幕上的资源,重新定向后输出到特定的文件中。首先取消file_struct与标准输出的关系,然后建立新文件和1号文件描述的关系即可。
dup和dup2用于复制文件描述符的系统调用,用于重定向输入输出
dup创建一个新的文件描述符,指向与旧文件描述符相同的文件,新文件描述符使用当前可分配的最小下标。创建的新的文件描述符与旧文件描述符共享同样的文件偏移量和文件状态标志。
//测试代码:新文件描述符和旧文件描述符指向同一个位置
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
int main() {
int fd = open("example.txt", O_RDONLY);
if (fd == -1)
{
perror("open");
return 1;
}
int newfd = dup(fd);
if (newfd == -1)
{
perror("dup");
close(fd);
return 1;
}
// 使用新文件描述符读取数据
char buffer[128];
// ssize_t bytesRead = read(newfd, buffer, sizeof(buffer) - 1);
ssize_t bytesRead = read(fd, buffer, sizeof(buffer) - 1);
if (bytesRead == -1)
{
perror("read");
close(fd);
close(newfd);
return 1;
}
buffer[bytesRead] = '\0';
// printf("Read %ld bytes from newfd: %s\n", bytesRead, buffer);
printf("Read %ld bytes from fd: %s\n", bytesRead, buffer);
close(fd);
close(newfd);
return 0;
}
dup2:将oldfd复制到newfd,如果newfd已经打开,则先关闭newfd。如果newfd等于oldfd,则返回newfd,不需要执行其他任何操作。从而让newfd指向与oldfd相同的文件描述符。
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
int main() {
int fd = open("example.txt", O_RDONLY);
if (fd == -1)
{
perror("open");
return 1;
}
int newfd = 5; // 指定新的文件描述符
if (dup2(fd, newfd) == -1)
{
perror("dup2");
close(fd);
return 1;
}
// 使用新的文件描述符读取数据
char buffer[128];
ssize_t bytesRead = read(newfd, buffer, sizeof(buffer) - 1);
if (bytesRead == -1)
{
perror("read");
close(fd);
close(newfd);
return 1;
}
buffer[bytesRead] = '\0';
printf("Read %ld bytes from newfd: %s\n", bytesRead, buffer);
close(fd);
close(newfd);
return 0;
}
深入理解Linux下的文件系统
Linux一切皆文件
Struct_file结构体,对不同类型硬件,针对性的设计出不同读取和写入文件操作。Linux底层对其进行了统一封装,让这些不同的类型的硬件变成统一类型的struct_file,然后上层调用硬件对应的操作时,直接调用封装好的函数指针即可。所以struct_file结构体是一种利用指针数组,针对不同的硬件设计出不同类型读取和写入文件。然后在底层进行封装,最终统一用read/write接口调用。
综上对文件描述符再理解,文件描述符表在系统内核中的表现形式是struct_file,该结构体中包含许多函数指针,例如int(*readp)(int fd , void*buffer , int Len);通过该函数指针调用该函数后,能够以指定的方式打开fd文件,从而实现对应的操作。
重定向操作,从内核层面看,只是对该struct_file中某个函数指针的目标文件进行了更改。
Linux设计出这一套的目的本质还是为了消除在系统运行的时候消除这些硬件读取和写入方式不同的差别,这些也体现出了Linux设计思想"一切皆文件"。
Linux一切皆文件的思想通过将各种资源抽象成为文件,从而让系统编程更加的简洁。
使用统一文件接口不仅简化了用户和应用程序对系统资源的访问,同时增强了系统的灵活性和拓展性。
Linux文件
- 广义文件
- 磁盘文件、设备文件、管道套接字以及进程间通信机制都属于Linux文件范围
- 文件描述符
- 操作系统用于标识和访问已经打开文件的数组下标值。
- Linux中每一个打开的文件都有一个唯一的文件描述符,允许进程通过统一的接口即系统调用访问各种类型的文件。
- 文件的系统调用接口
- open:打开文件
- read:从文件中读取数据
- write:向文件中写入数据
- .....................
- (文件的系统调用不仅适用于普通文件,适用于Linux系统中的所有文件,也就是包括设备文件、管道、套接字等)
- 设备文件
- 设备文件存储在/dev下,用于表示硬件设备。
- 管道和FIFO
- 管道:父子进程之间传输数据。
- FIFO(命名管道):允许无亲缘关系的进程进行通信。
- 套接字
- 本地套接字:同一台机器上进程间通信
- 网络套接字:网络进程之间通信,IP和端口号确定
缓冲区
为提高系统中的I/O效率,如果发送一个接收一个,便会不停的调用I/O操作,从而降低系统的运行效率。缓冲区具体体现就是内存中的一片空间。
不同设备的刷新策略是不同的,多数设备依旧倾向于使用全缓冲,等待缓冲区满了后统一进行刷新,这样做能够最大限度的提高系统运行效率。但是像显示器这样的设备使用的便是行刷新,因为如果使用的全刷新会影响用户体验,所以采用何种刷新方式需要根据实际情况。
Printf fwrite 库函数自带缓冲区,write系统调用没有带缓冲区。这些缓冲区都是用户级别的缓冲区,操作系统内核也有属于自己的缓冲区。类似于库函数调用,如果发生了重定向,缓冲区中的内容如果不强行进行刷新,则缓冲区中的数据是无法写入到重定向的文件中。
缓冲区的类型
- 用户态缓冲区:位于用户进程的内存空间,由应用程序直接控制。常见的如标准输入输出库的缓冲区。
- 内核态缓冲区:位于内核空间,用于内核和硬件设备之间的数据传输。主要包括页缓存(page cache)、磁盘缓存(buffer cache)等。
缓冲区的策略
写回策略:数据首先写入缓存,当缓存达到一定条件(如满了或定时器到期)时,才写入磁盘。这种策略可以减少磁盘I/O,提高写入性能,但存在数据丢失的风险。
写通策略:每次写入操作都立即写入磁盘,不经过缓存。这种策略降低了写入性能,但确保了数据的一致性和安全性
文件系统理解(inode)
INode
- 该结构存储文件的元数据,同时是文件系统管理文件和目录的基本单位
- 作用
- 负责存储文件的元数据,不直接存储文件数据
- 主要存储信息
- 文件类型(如普通文件、目录、符号链接等)
- 文件权限(读、写、执行权限)
- 文件所有者(用户ID)
- 文件所属组(组ID)
- 文件大小
- 文件的创建时间、修改时间和访问时间
- 链接计数(硬链接的数量)
- 指向文件数据块的指针
- INode分配和管理
- 文件系统在创建文件时分配一个INode,同时初始化元数据。文件系统中包含一个INode表,记录着所有的INode信息。、
- INode的主要操作
- 创建和删除文件:创建文件时分配INode,删除文件时释放INode
- 修改元数据:修改权限等
- 读取和写入数据:通过INode中的指针访问文件数据块
- 链接计数管理:硬链接着呢宫颈癌INode的链接计数,删除链接的时候减少计数
Ext2文件系统
Ext2(Second Extended Filesystem)是Linux操作系统中一种经典的文件系统,它由Rémy Card设计,是对最早的Ext文件系统的改进。尽管Ext2如今已被Ext3和Ext4等更先进的文件系统所取代,但它的简单性和稳健性使其仍然在某些嵌入式系统或其他特定应用场景中被使用。
Ext2文件系统主要组成部分
- 超级块
- 文件系统大小
- 每个块组大小
- 文件系统状态
- 块大小
- INode数量和Blocks数量
- 文件系统的创建和修改时间
- (超级块通常存储在文件系统中的第二个块,其在文件系统中还有多个备份,目的是为了防止超级块损坏)
- 块组描述符表
- 块位图的起始块
- INode位图的起始块
- INode表的起始块
- 块组中的空闲块和空闲INode数量
- 块组
- 块位图
- 本质是一个位图,每一位表示文件系统中的一个数据块,位图用于显示块组中哪些数据块是空闲的,哪些是已用的。
- INode位图
- 每一位表示文件系统中的一个INode,用于跟踪块组中哪些INode是空闲的,哪些是已用的。
- INode表
- INode表是一个数组,存储了所有的Node结构,每个INode记录了文件或者目录元数据
- 数据块
- 存储实际的数据文件内容,每个文件的数据通过INode中的数据块指针来引用
- Data blocks
- 基本单位是4kb(8*512kb),目的是降低I/O操作
- 文件系统中可以同时存放多个
- 数据块的多少由操作系统向内存中申请多少空间决定
- Inode table
- 拥有属于自己唯一编码信息,保存着文件的属性信息
- Inodebitmap:表示Inode的使用情况
- Blockbitmap:标记datablock是否已经被占用
内核处理系统调用
- 检查权限
- 内核首先检查用户进程是否有权限在指定目录中创建文件,其中包括检查目录的写权限和执行权限。
- 分配INode
- 内核从INode表(包含所有文件和目录元数据的数组或列表)中分配一个空闲的INode。
- 初始化INode:将文件类型设置为普通文件,同时设置文件权限、所有者等信息
- 设置时间戳:设置文件的创建、修改、访问时间
- 初始化链接计数:新文件创建后的链接计数通常为1
- 分配数据块
- 按照文件系统的配置,内核可能会立即分配一个或者多个用于存储数据的文件数据
- 更新目录结构
- 内核在目标目录中添加新的目录项,同时将新文件名称和INode编号关联起来
- 读取目标目录的INode
- 在目录内容中添加一个新的目录项(文件名+INode编号)
- 更新超级块
- 更新文件系统超级块的信息
文件创建一般在内核中需要经历四个阶段,首先内核在物理内存中找到一个空闲节点,内核记录文件信息。然后计算内核缓冲区中数据存储需要多少内存的空间,根据需要的空间找到对应的磁盘块,将数据复制到磁盘块中。然后记录分配情况,内核应该在iode上的磁盘分区记录上述列表信息。最后添加文件名到目录中。
测试代码:获取文件中部分INode信息
int main() {
struct stat fileStat;
if (stat("example.txt", &fileStat) < 0)
{
perror("stat");
return 1;
}
printf("INode Number: %lu\n", fileStat.st_ino);
printf("File Size: %ld bytes\n", fileStat.st_size);
printf("Number of Links: %ld\n", fileStat.st_nlink);
printf("File Permissions: %o\n", fileStat.st_mode);
printf("Last Access Time: %ld\n", fileStat.st_atime);
printf("Last Modification Time: %ld\n", fileStat.st_mtime);
printf("Last Change Time: %ld\n", fileStat.st_ctime);
return 0;
}
拓展:Ext3和Ext4文件系统
Ext3文件系统
- 总结:Ext2的改进版本,主要引入了日志功能,目的是提高文件系统的可靠性和恢复能力
- 特点
- 日志功能:通过日志记录文件系统中的元数据的变化,减少崩溃后系统检查和恢复时间
- 兼容Ext2
- 在线文件系统检查:支持在挂载状态下进行文件系统检查
Ext4文件系统
- 总结:进一步改进了Ext3,提供了更大的文件系统和文件支持,更快的文件系统检查,延迟分配和多块分配等技术,大幅提升了性能和扩展性,适用于现代大规模存储需求
- 特点
- 支持更大的文件系统和文件支持,Ext4最大支持1EB的文件系统,以及16TB的文件
- 文件系统检查速度更快:Ext4使用多块分配、延迟分配等技术,提高文件系统检查和修复的速度
- 延迟分配:通过延迟数据块的分配,从而优化数据布局提高写入性能
- 多块分配:同时分配多个数据块,减少文件碎片,提高读写性能
- 无日志模式:支持禁用日志,提高在堆性能有高要求场景下的性能
- 在线碎片整理:支持碎片整理,提高文件系统的性能
硬链接和软链接
硬链接
综上对与Linux文件的理解可以得知,当我们访问磁盘上的文件时不是依据文件名,而是inode,那么在Linux中就可以实现多个文件名指向同一个inode。创建硬链接不是创建新的文件,而是在指定目录下,建立文件名和inode之间的映射关系。
硬链接文件不能够直接删除,只有当文件中的inode计数变成0的时候文件才可以真正的删除。细节问题,当创建一个目录硬链接数为2的原因,因为该文件自己有一个inode对应,还有一个'.'当前文件调用也是链接到该文件的。
硬链接是文件系统中的一个目录项,指向文件的INode。多个硬链接可以指向同一个INode,从而让多个文件名实际上指向同一个文件数据。
特点
- 共享INode:硬链接与源文件共享相同的INode,因此它们指向相同的数据块
- 文件同步:任何对硬链接或源文件的修改都会影响到其他所有硬链接,因为它们指向相同的数据
- 删除文件:删除一个硬链接或源文件不会删除数据,只有当所有指向该INode的硬链接都被删除时,数据才会被删除
- 同一文件系统:硬链接只能在同一个文件系统内创建,不能跨文件系统
软链接
软链接是一个特殊类型的文件,其使用类似于window下的快捷方式。软链接只想另一个文件或者目录的路径,软链接与源文件的INode不同
特点
- 独立INode:软链接有自己的INode和数据块,数据块中存储指向目标文件或目录的路径
- 可以指向目录:软链接可以指向文件或目录
- 跨文件系统:软链接可以跨文件系统创建
- 删除行为:删除软链接不会影响目标文件,但删除目标文件会导致软链接成为“断链”,即指向不存在的文件
五种基础I/O模型的理解
I/O本质上可以理解成等待数据准备就绪和数据拷贝的过程。I/O类接口也就是进行这两种操作的。所以判定I/O是否高效的重点是让等待时间减少。
同步通信和异步通信
同步通信会一直等待结果到达,结果不到达则不返回。发出调用后一直到得到结果前都会一直等待结果。类似于去购物排队,没有排队买到东西之前是不会离开的。
异步通信则与同步通信相反,异步通信调用后并不是立刻得到结构,被调用者通过状态、通知或者回调函数处理这个调用。类似于找个黄牛去帮忙排队,排到了再去购买物品
IO的同步和进程线程的同步是不同的,进程和线程的同步是为了完成某种任务建立多个线程去访问临界资源,线程同步存在竞争关系,因为需要抢夺临界资源。而IO通信上的同步则是等待资源然后进行拷贝。
阻塞和非阻塞
阻塞调用:调用结果返回之前,当前线程会被挂起,调用线程只有在得到结果之后才会返回。
非阻塞调用:不能立刻返回结果之前,该调用不会阻塞当前线程。
int main()
{
SetNonBlock(0);
char buffer[1024];
while(true)
{
ssize_t s = read(0,buffer,sizeof(buffer)-1);
if(s>0)
{
buffer[s-1] =0;
std::cout<<"echo:"<<buffer<<"-----errno[]"<<errno<<"errstring:"<<strerror(errno)<<std::endl;
}
else
{
std::cout<<"read error:"<<errno<<"errstring:"<<strerror(errno)<<std::endl;
if(errno = EAGAIN||errno == EWOULDBLOCK)
{
std::cout<<"0号文件描述符尚未就绪"<<std::endl;
continue;
}
else if(errno == EINTR)
{
std::cout<<"io信号被中断 等一下再来试试"<<std::endl;
}
else
{
}
}
}
return 0;
}
//1. 阻塞式IO
//总结:一旦缓冲区收到信息,就会回显到屏幕中,在没有收到信息的时候会一直等待。
int main()
{
char buffer[1024];
while(true)
{
ssize_t s = read(0,buffer,sizeof(buffer)-1);
if(s>0)
{
buffer[s-1] =0;
std::cout<<"echo:"<<buffer<<"-----errno[]"<<errno<<"errstring:"<<strerror(errno)<<std::endl;
}
else
{
std::cout<<"read error:"<<errno<<"errstring:"<<strerror(errno)<<std::endl;
}
}
}
多路转接
Select
主要步骤
select使用三个文件描述符集合分别监控读写和异常事件。
初始化文件描述符集合。使用
FD_ZERO
初始化集合,FD_SET
添加文件描述符,FD_CLR
移除文件描述符,FD_ISSET
检查文件描述符是否在集合中。
调用select函数
nfds
:文件描述符范围,是集合中最大文件描述符加一。readfds
:监控可读事件的文件描述符集合。writefds
:监控可写事件的文件描述符集合。exceptfds
:监控异常事件的文件描述符集合。timeout
:等待时间,设置为NULL
表示无限等待。
作用
select系统调用会一次监视多个文件描述符的状态变化。程序当运行到select的时候,会逗留在select处等待,直到被监视的文件描述符发生了变化。当监视的文件描述符就绪时,则通知用户,用户此时再调用IO接口去处理就绪的文件描述符。
函数使用
select参数分析
- nfds:需要监视的最大文件描述符值+1
- readfds\writefds\exceptfds:需要检测的可读文件描述符集合。输入型参数时,用户告诉内核需要关心sock是否准备就绪;输出型参数时,内核告诉用户,关心的哪些sock已经准备就绪了。
- timeout用来设置select的等待时间
- 返回值:就绪fd的个数,只有存在1个以上就会返回
fd_set
typedef struct
{
/* XPG4.2 requires this member name. Otherwise avoid the name
from the global namespace. */
#ifdef __USE_XOPEN
__fd_mask fds_bits[__FD_SETSIZE / __NFDBITS];
# define __FDS_BITS(set) ((set)->fds_bits)
#else
__fd_mask __fds_bits[__FD_SETSIZE / __NFDBITS];
# define __FDS_BITS(set) ((set)->__fds_bits)
#endif
} fd_set;
理解:一种位图结构,文件描述符集合,需要使用专门的系统调用对其进行设置。 用户层告诉内核需要关注哪些文件描述符的操作就绪。
fd_set时一个用于表示文件描述符集合的数据结构,应用于select多路复用机制中,主要作用就是监视多个文件描述符,检测哪一个文件描述符可以进行IO操作
timeval
struct timeval
{
__time_t tv_sec; /* Seconds. */
__suseconds_t tv_usec; /* Microseconds. */
};
参数分析
- 返回值
- 成功:返回文件描述符状态已经改变的个数
- 失败:返回-1,存在错误,后面四个参数有问题
- 返回0,描述词的状态改变已经超过timeout时间,没有返回
- 意义:可以根据阻塞时间去决定如何等待
- timeout设置成nullptr时,程序会一直等待到最后
- timeout设置成【0,0】则表示没有文件立刻返回
- timeout设置成【5,0】则表示5秒到了立刻返回
- 当timeout作为输出型参数的时候,表示在等待时间内,出现fd已经就绪,输出距离下次timeout剩余多少时间
timeval结构体定义在sys/time.h头文件中
应用场景
- select进行超时操作:监视多个文件描述符的时候设置超时时间
- 获取当前系统时间:将系统当前时间存放在timeval结构体中
- 时间计算:时间间隔计算
#include <stdio.h>
#include <sys/time.h>
int main() {
struct timeval tv;
// 获取当前时间
gettimeofday(&tv, NULL);
printf("Seconds: %ld\n", tv.tv_sec);
printf("Microseconds: %ld\n", tv.tv_usec);
return 0;
}
计算工作时间差
#include <stdio.h>
#include <sys/time.h>
#include <unistd.h>
int main()
{
struct timeval start, end;
// 获取开始时间
gettimeofday(&start, NULL);
// 模拟处理时间
sleep(2); // 睡眠2秒
// 获取结束时间
gettimeofday(&end, NULL);
// 计算时间差
long seconds = end.tv_sec - start.tv_sec;
long microseconds = end.tv_usec - start.tv_usec;
if (microseconds < 0) {
seconds -= 1;
microseconds += 1000000;
}
printf("相差时间: %ld seconds and %ld microseconds\n", seconds, microseconds);
return 0;
}
深入理解readfds参数
作为输入型参数时,用户到内核层,比特标记是否关注写的内容。例如1010(位图标记)(从右往左),则表示0号文件关心写,1号文件描述符中的文件不关心写。
作为输出型参数时:内核到用户,操作系统返回给用户哪些文件描述符的写操作已经准备就绪。例如1000(位图),则表示3号文件描述符的写已经准备就绪,所以用户可以直接读取3号文件描述符,不会被阻塞。
所以rfds作为输入输出参数,用户到内核以及内核到用户相互交互的数值是不一样的,
select服务器
#ifndef __SELECT_SVR_H__
#define __SELECT_SVR_H__
#include <iostream>
#include <string>
#include <vector>
#include <sys/select.h>
#include <sys/time.h>
#include "Log.hpp"
#include "Sock.hpp"
#define BITS 8
#define NUM (sizeof(fd_set)*BITS)
#define FD_NONE -1
using namespace std;
class SelectServer
{
public:
SelectServer(const uint16_t&port =8080):_port(port)
{
_listensock = Sock::Socket();
Sock::Bind(_listensock,_port);
Sock::Listen(_listensock);
logMessage(DEBUG,"%s","create base socket success");
for(int i =0;i<NUM;i++)
{
_fd_array[i] = FD_NONE;
}
_fd_array[0] = _listensock;
}
void Start()
{
while(true)
{
DebugPrint();
fd_set rfds;
FD_ZERO(&rfds);
int maxfd = _listensock;
for(int i =0;i<NUM;i++)
{
if(_fd_array[i] == FD_NONE) continue;
FD_SET(_fd_array[i],&rfds);//将自己维护的数组加入到位图中;在河边设置一百个鱼竿
if(maxfd<_fd_array[i]) maxfd = _fd_array[i];
}
//select处阻塞等待
int n = select(maxfd+1,&rfds,nullptr,nullptr,nullptr);
switch (n)
{
case 0:
logMessage(DEBUG,"%s","time out.....");
break;
case -1:
logMessage(WARNING,"select error : %d:%s",errno,strerror(errno));
break;
default:
logMessage(DEBUG,"get a new link event...");
HandlerEvent(rfds);
break;
}
}
}
~SelectServer()
{
if(_listensock >=0)
{
close(_listensock);
}
}
private:
void HandlerEvent(const fd_set&rfds)
{
for(int i =0;i<NUM;i++)
{
if(_fd_array[i]==FD_NONE) continue;
//鱼竿此时表示有鱼,可以拿起鱼竿了
if(FD_ISSET(_fd_array[i],&rfds))
{
//select 此时将位图中已经就绪的文件描述符拷贝到用户层;用户层根据套接字处理请求
if(_fd_array[i]==_listensock) Accepter();
else Recver(i);
}
}
}
void Accepter()
{
string clientip;
uint16_t clientport =0;
int sock = Sock::Accept(_listensock,&clientip,&clientport);
if(sock<0)
{
logMessage(WARNING,"accept error");
return ;
}
logMessage(DEBUG,"get a new line success : %s-%d:%d",clientip.c_str(),clientport,sock);
int pos =1;
for(;pos<NUM;pos++)
{
if(_fd_array[pos] == FD_NONE) break;
}
if(pos == NUM)
{
logMessage(WARNING,"%s:%d","select server already full ,close :%d",sock);
close(sock);
}
else
{
_fd_array[pos] = sock;
}
}
void Recver(int pos)
{
logMessage(DEBUG,"MESSAGE IN , get io event :%d",_fd_array[pos]);
char buffer[1024];
int n = recv(_fd_array[pos],buffer,sizeof(buffer)-1,0);
if(n>0)
{
buffer[n] =0;
logMessage(DEBUG,"client[%d]:%s",_fd_array[pos],buffer);
}
else if(n==0)
{
logMessage(DEBUG,"client%d quit,me too//",_fd_array[pos]);
close(_fd_array[pos]);
_fd_array[pos] = FD_NONE;
}
else
{
logMessage(WARNING,"%d sock recv error , %d : %s",_fd_array[pos],errno,strerror(errno));
close(_fd_array[pos]);
_fd_array[pos] = FD_NONE;
}
}
void DebugPrint()
{
cout<<"_fd_array[]:";
for(int i =0;i<NUM;i++)
{
if(_fd_array[i] == FD_NONE) continue;
cout<<_fd_array[i]<<" ";
}
cout<<endl;
}
private:
uint16_t _port;
int _listensock;
int _fd_array[NUM];//select 维护的队列
};
#endif
FD_ISSET()
if(_fd_array[i]==FD_NONE) continue;
//鱼竿此时表示有鱼,可以拿起鱼竿了
if(FD_ISSET(_fd_array[i],&rfds))
{
//select 此时将位图中已经就绪的文件描述符拷贝到用户层;用户层根据套接字处理请求
if(_fd_array[i]==_listensock) Accepter();
else Recver(i);
}
内核告诉用户哪些文件描述符已经就绪,通过FD_ISSET判断_fd_array[i]描述符在rfds位图结构中是否已经标记就绪。上图中代码表示连接事件已经就绪,所以此时就可以获取连接。
Accept()sock加入select队列
新连接获取后,不可以直接通过获取的sock数值去读取套接字中的内容,而是应该将其添加到select维护的等待队列中,由select统一进行处理。首先要找到队列中不为空的地方,其次需要防止队列满的情况,判断的目的在于防止Pos指针越界访问。
细节问题:每一次都要设定rfds数值的原因
因为select内核返回给用户的时候会改变rfds中位图的数值,此时返回的数值和用户想要关心的文件描述符不一致。所以每次都需要进行更新。
如果fd_array是空,则跳过,如果里面有需要关注的文件描述符,则将其加入rfds中交给内核进行监视文件描述符是否就绪。
maxfd的更新,通过不断与fd_array数组元素进行比较,从而确定最大的文件描述符是多少。
如果返回的文件描述符没有数据,则关闭该文件描述符,然后将其位图信息设置为空即可。
socket就绪条件判断
- 读就绪
- socket内核中,接收缓冲区的字节数大于或者等于低水平标记位SO_RCVLOWAT,此时可以无阻塞的读该文件描述符,并返回值大于0
- socket TCP通信中,对端关闭连接,此时对该scket读,则返回0
- 监听的socket上有新的连接请求
- socket上有未处理的错误
- 写就绪
- socket内核中,发送缓冲区的可用字节数大于等于低水平标记位SO_RCVLOWAT,时可以无阻塞的读该文件描述符,并返回值大于0
- socket的写操作被挂壁,对一个写操作被关闭的socket进行写操作,会触发SIGPIPE信号
- socket使用非阻塞connect连接成功或者失败之后
- socket上有未处理的错误
select特点
- 优点
- 效率高:将所有sock文件描述符都集成在一个数组中,因为等待的sock数量多,所以等到的概率也就大。
- 同时处理多个连接,避免了大量的线程或者进程切换,提高了系统的并发性能
- 缺点
- select存在大量遍历操作,因为需要维护数组,监视sock是否准备就绪
- 每一次都需要对select输出型参数进行重新设置
- 同时管理的fd存在数量上限
- 内部参数都是输入和输出型,所以会频繁的进行用户和内核之间的拷贝
select原理分析
- 复制文件描述符集合:用户传递的文件描述符集合会被复制到内核空间,内核对这些传入的文件描述符进行监控。(即上述描述的位图结构)
- 内核等待:内核阻塞等待,直到有一个或者多个文件描述符准备就绪,或者超时未到达。
- 内核使用高效数据结构和算法来监控文件描述符的状态变化
- 内核在等待期间会被中断,并处理其他内核事件
- 文件描述的状态检查:一旦一个或者多个文件描述符准备就绪,内核会检查这些文件描述符的状态,并更新内核中的文件描述符集合。
- 返回结果:内核将更新后的文件描述符集合复制回用户空间,并返回就绪文件描述符的数量。
poll
poll作用
同时监控多个文件描述符是否有IO事件发生。通过poll程序可以在单个线程中高效处理多个IO操作,从而避免了阻塞和资源浪费。
poll只负责等待,主要目的是为了解决select中拷贝频次高以及每次都需要对关心的fd进行事件重置的缺陷。
poll主要步骤
定义pollfd结构体数组
- 用户到内核:关心fd文件描述符的events事件
- 内核到用户:fd文件描述符的revents事件已经就绪
- 与select不同之处在于Poll使用该结构体实现用户和内核的交互,将输入和输出事件进行了分离
初始化pollfd数组
调用Poll函数
fds
:pollfd
结构数组。nfds
:数组中文件描述符的数量。timeout
:超时时间,单位是毫秒,-1 表示无限等待,0表示非阻塞Poll。
检查poll的返回值
poll监控的事件类型总结
POLLIN
:有数据可读。POLLOUT
:写数据不会导致阻塞。POLLERR
:错误条件。POLLHUP
:挂起。POLLNVAL
:描述符不是一个合法的打开文件。
函数分析
参数分析
- fds:poll函数监听的数据结构列表,
- 文件描述符
- 监听事件的集合
- 返回的事件集合
- nfds:fds数组的长度
- timeout:poll函数的超时时间
poll服务器实现
#include <iostream>
#include <string>
#include <vector>
#include <poll.h>
#include <sys/time.h>
#include "Log.hpp"
#include "Sock.hpp"
#define FD_NONE -1
using namespace std;
class PollServer
{
private:
uint16_t _port;
int _listensock;
struct pollfd *_fds;
int _nfds;
int _timeout;
private:
void HandlerEvent()
{
for (int i = 0; i < _nfds; i++)
{
if (_fds[i].fd == FD_NONE)
continue;
if (_fds[i].revents & POLLIN)
{
// 读就绪的时候,将sock加入“列表”中
if (_fds[i].fd == _listensock)
Accepter();
else
Recver(i);
}
}
}
void Accepter()
{
string clientip;
uint16_t clientport = 0;
int sock = Sock::Accept(_listensock, &clientip, &clientport);
if (sock < 0)
{
logMessage(WARNING, "accept error");
return;
}
logMessage(DEBUG, "get a new line success:%d", sock);
int pos = 1;
for (; pos < _nfds; pos++)
{
if (_fds[pos].fd == FD_NONE)
break;
}
if (pos == _nfds)
{
logMessage(WARNING, "%s:%d", "poll server already full , close :%d", sock);
close(sock);
}
else
{
// 将获取的连接加入到Poll中
_fds[pos].fd = sock;
_fds[pos].events = POLLIN;
}
}
void Recver(int pos)
{
logMessage(DEBUG, "messge in , get io event %d", _fds[pos]);
char buffer[1024];
int n = recv(_fds[pos].fd, buffer, sizeof(buffer) - 1, 0);
if (n > 0)
{
buffer[n] = 0;
logMessage(DEBUG, "client[%d]:%s", _fds[pos].fd, buffer);
}
else if (n == 0)
{
logMessage(DEBUG, "client[%d] quit,me too...", _fds[pos].fd);
close(_fds[pos].fd);
_fds[pos].fd = FD_NONE;
_fds[pos].events = 0;
}
else
{
logMessage(DEBUG, "%d sock recv error ,%d:%s", _fds[pos].fd, errno, strerror(errno));
close(_fds[pos].fd);
_fds[pos].fd = FD_NONE;
_fds[pos].events = 0;
}
}
void DebugPrint()
{
cout << "_fd_array[]: ";
for (int i = 0; i < _nfds; i++)
{
if (_fds[i].fd == FD_NONE)
continue;
cout << _fds[i].fd << " ";
}
cout << endl;
}
public:
static const int nfds = 100;
PollServer(const uint16_t &port = 8080) : _port(port), _nfds(nfds)
{
_listensock = Sock::Socket();
Sock::Bind(_listensock, _port);
Sock::Listen(_listensock);
logMessage(DEBUG, "%s", "mag create base socket success!");
_fds = new struct pollfd[_nfds];
for (int i = 0; i < _nfds; i++)
{
_fds[i].fd = FD_NONE;
_fds[i].events = _fds[i].revents = 0;
}
_fds[0].fd = _listensock;
_fds[0].events = POLLIN;
_timeout = 1000;
}
~PollServer()
{
if (_listensock >= 0)
{
close(_listensock);
}
if (_fds)
delete[] _fds;
}
void Start()
{
while (true)
{
int n = poll(_fds, _nfds, _timeout);
switch (n)
{
case 0:
logMessage(DEBUG, "%s", "time out");
break;
case -1:
logMessage(WARNING, "%d:%s", errno, strerror(errno));
break;
default:
HandlerEvent();
break;
}
}
}
};
poll特点
- 优点
- 没有最大数量限制,但是数量变大后还是会影响性能
- 效率高
- 有大量连接,但是只有少量链接活跃,节省资源
- 输入和输出参数分离,不需要用户层和内核层之间反复复制
- 缺点
- poll返回后,依然需要遍历哪些sock文件描述符准备就绪
- 每次调用需要吧大量的pollfd结构从用户态拷贝到内核中
- 同时连接的大量客户端在一个时刻只有很少处于就绪状态,所以随着监视描述符数量的增长,效率也会相应下降。
epoll
epoll是为了处理大量sock而改进的poll,继承之前所有的优点。
epoll主要步骤
epoll_create 创建epoll实例
epoll_ctl 注册事件:向epoll实例中添加需要监听的文件描述符及其事件
- 将listen_fd文件描述符及其关联的读事件添加到epoll实例的红黑树中
- 参数分析
- epfd:epoll文件描述符,由epoll_create创建
- op:要执行的操作
EPOLL_CTL_ADD
: 添加新的监听事件。EPOLL_CTL_MOD
: 修改现有的监听事件。EPOLL_CTL_DEL
: 删除监听事件。- fd:要操作的目标文件描述符
- event:指向epoll_event结构体指针,指定要监听的事件和用户数据
epoll_wait 等待事件:等待文件描述符上的事件发生
- epoll_wait一直阻塞到有事件发生,然后返回触发的事件数组。其通过遍历红黑树,将触发事件从红黑树中转移到双向链表中,并将这些事件信息返回给用户
- 参数含义
- epfd:epoll_create的返回值
- 中间两个参数:返回已经就绪的文件描述符和事件
- timeout:超时时间,0非阻塞,-1表示阻塞等待
- 返回值:已经就绪的fd个数
epoll_event
- 用于描述文件描述符上的事件的结构体,通过epoll_event结构体来指定和接收事件
- events:事件类型
EPOLLIN
:表示对应的文件描述符可以读(包括对端套接字关闭)。EPOLLOUT
:表示对应的文件描述符可以写。EPOLLERR
:表示对应的文件描述符发生错误。EPOLLRDHUP
:表示对端关闭连接或半关闭。EPOLLET
:设置边缘触发模式(Edge Triggered)- data:用户数据
处理事件:处理epoll_wait返回的事件
epoll高效原因分析
- 增量更新:
epoll_ctl
仅在添加、修改或删除文件描述符时更新红黑树,不需要每次调用都遍历整个树- 就绪事件集合:
epoll_wait
返回的是已经就绪的事件集合,而不是所有文件描述符的状态。这避免了不必要的检查和遍历- 内存复用:
epoll
内部使用双向链表存储就绪事件,避免了频繁的内存分配和释放,提高了性能
epoll原理
操作系统从硬件层面上如何得知网卡上有数据?
1.中断机制
硬件中断:网卡作为硬件,通过生成硬件中断来通知CPU有数据到达
- 数据接收: 网卡接收到数据包时,将数据包存储在网卡的内部缓冲区中
- 生成中断:网卡通过触发一个硬件中断信号来通知CPU有数据到达
- 中断处理程序:CPU响应中断信号,暂停当前执行的任务,转而执行与该中断信号对应的中断处理程序
- 数据处理:在中断处理程序中,操作系统的驱动程序从网卡的内部缓冲区中读取数据,并将其放入系统那内容中,供上层网络协议进行处理
2. 轮询机制
操作系统通过定期检查网卡状态寄存器来判断是否有新的数据到达
- 数据接收:网卡接收到数据包时,会将其存储在网卡的内部缓冲区中,并更新状态寄存器
- 定期检查:操作系统定期检查网卡的状态寄存器,判断是否有数据到达
- 数据处理:如果状态寄存器标识有新的数据到达,操作系统的驱动程序从网卡的内部缓冲区中读取数据,并将其存放到系统内存中,供上层网络协议栈进行处理
现代网络设备通常使用中断和轮询机制结合的方法,例如NAPI机制
- 初始中断: 网卡在接收到数据时,首先生成中断通知CPU。
- NAPI调度: 在处理中断时,操作系统将网卡驱动程序标记为可运行的,并关闭网卡的中断。
- 软中断处理: 在软中断上下文中,操作系统通过轮询方式从网卡读取数据。
- 恢复中断: 在轮询处理结束后,操作系统重新启用网卡的中断
epoll中红黑树的作用
epoll中红黑树用于存储和管理所有注册的文件描述符及其关联的事件
epoll红黑树节点结构
struct epitem {
int fd; // 文件描述符
struct epoll_event event; // 事件信息
struct rb_node rb_node; // 红黑树节点
struct list_head rdllink; // 就绪链表
// 其他需要的数据
};
epoll中红黑树的核心操作分析
插入节点:将新的文件描述符添加到epoll实例中,创建新的红黑树节点将其插入
添加节点:处理epoll_ctl的修改或者删除操作的时候,需要对红黑树中查找对应节点
删除节点:删除文件描述符的时候,需要从红黑树中移除对应的节点
epoll中就绪链表
就绪链表用于管理已经触发的事件,红黑树中出现就绪节点时,即把该节点放入就绪链表中。就绪队列的节点是用结构体来进行存储,里面有已经就绪的文件描述符和已经就绪的事件。
操作系统将红黑树中的节点放入就绪队列中的逻辑
- 检测事件:检测到文件描述符上的事件触发(系统通过调用回调函数)
- 从红黑树上移除节点:将已经就绪的节点从红黑树上移除
- 插入到就绪队列中:将从红黑树移除节点的信息放入到就绪队列中
文件描述符中回调函数
文件描述符的回调函数是通过内核中的事件通知机制起作用的。当文件描述符上的事件(读写)发生的时候,内核调用相应的回调函数。这些回调函数的主要作用是将事件从红黑树移动到就绪队列中,从而让epoll_wait()的时候快速的找到这些事件
- 事件发生:文件描述符上的事件发生(数据可读可写等)
- 中断处理:硬件中断或系统调用触发事件检测
- 调用回调函数:内核调用文件描述符的回调函数,将事件从红黑树中移动到就绪队列中
我们不需要设置回调函数,因为回调函数是由Linux内核实现的。Linux内核中设计了一套复杂的事件处理机制,这些机制使得文件描述符上的事件发生时,内核能够自动将事件移动到就绪队列中,从而便于用户能够使用epoll_wait调用获取事件。
内核中的回调机制
内核中的文件描述符通常会绑定到特定的设备驱动程序或者协议栈中。当这些文件描述符上的事件发生时候,设备驱动成程序或者协议栈会调用内核中的回调函数。这些回调函数负责将事件从红黑树移动到就绪队列中。
- 事件检测:文件描述符上有事件发生的时候,设备驱动程序或者协议栈检测到这些事件。
- 设备驱动程序:负责与硬件设备进行交互,当网卡接收到数据包时,网卡驱动程序会检测到这个事件。
- 协议栈:例如TCP/IP协议栈检测到有新的网络数据到达的时候,会进行相应的处理。
- 触发回调函数
- epoll实例创建和文件描述符注册过程中,内核为每一个文件描述符都关联了一个回调函数,这些回调函数在事件发生时被调用。
就绪队列的优点
- 事件的快速访问:当一个文件描述符上的事件触发的时候,该文件描述符会被从红黑树中移动到就绪列表中。所以在调用epoll_wait的时候,操作系统就可以快速返回已经触发的事件,不需要遍历整个红黑树
- 分离事件管理和事件管理:红黑树主要用于事件管理(添加、删除、修改),就绪列表则用户事件的处理。通过将两者分离,从而可以优化性能,主要体现在时间复杂度的优化,因为链表的插入和删除都是O(1)。
- 防止重复处理:当一个事件触发的时候,该事件从红黑树中移动到就绪链表中,避免了在同一个事件处理周期内重复处理同一个事件。只有在用户调用epoll_wait并处理完事件后,如果需要继续监听,此时这些事件才会重新加入到红黑树中。
epoll实现分析
main函数
生成一个epoll服务,传入处理请求的方式,同时启动该服务;启动服务需要IP地址和端口号,如果参数数量不对则判错。
#include<memory>
#include"EpollServer.hpp"
using namespace std;
using namespace ns_epoll;
void change(std::string request)
{
std::cout<<"change:"<<request<<std::endl;
}
int main(int argc , char*argv[])
{
if(argc !=2)
{
change(argv[0]);
exit(10);
}
uint16_t port = atoi(argv[1]);
unique_ptr<EpollServer> epoll_server(new EpollServer(change));
epoll_server->Start();
return 0;
}
Epoll.hpp
- 创建epoll实例
- 设计静态方法原因
- 避免实例化Epoll类:将该方法设计成静态则表明可以直接通过类名调用该方法,不需要创建Epoll类的实例。从而更加的简洁高效。
- 全局唯一性:epoll实例在程序中保持全局唯一性,不需要通过实例化多个epoll对象来管理多个epoll实例。
- 简化调用方式:静态方法调用的方式更加直接,不需要先创建对象再调用方法。
- 控制epoll实例
- 参数:
epfd
:epoll
实例的文件描述符。oper
:操作类型,可能的值包括EPOLL_CTL_ADD
、EPOLL_CTL_MOD
和EPOLL_CTL_DEL
。sock
:需要操作的文件描述符。events
:需要监控的事件类型,如EPOLLIN
、EPOLLOUT
等。- 返回值:
- 返回
true
表示操作成功,返回false
表示操作失败。- 等待事件
- 参数:
epfd
:epoll
实例的文件描述符。revs
:存储发生事件的数组。num
:数组revs
的大小,即最多返回的事件数量。timeout
:等待的超时时间(毫秒)。- 返回值:
- 返回发生事件的数量,如果失败则返回 -1
#include<iostream>
#include<sys/epoll.h>
#include<unistd.h>
class Epoll
{
public:
static const int gsize =256;
public:
static int CreateEpoll()
{
int epfd = epoll_create(gsize);
if(epfd>0) return epfd;
exit(5);
}
static bool CtlEpoll(int epfd ,int oper,int sock,uint32_t events)
{
struct epoll_event ev;
ev.events = events;
ev.data.fd = sock;
int n = epoll_ctl(epfd,oper,sock,&ev);//成功返回0
return n==0;
}
static int WaitEpoll(int epfd , struct epoll_event revs[],int num,int timeout)
{
return epoll_wait(epfd,revs,num,timeout);
}
};
EpollServer.hpp
(处理客户端连接和数据传输)
构造函数
参数:
HandlerRequest
: 处理请求的回调函数。port
: 监听端口,默认为 8080。初始化:
- 分配
epoll_event
数组用于存储事件。- 创建、绑定和监听套接字。
- 创建
epoll
实例。
namespace ns_epoll
{
const static int default_port = 8080;
const static int gnum = 64;
class EpollServer
{
using func_t = std::function<void(std::string)>;
public:
EpollServer(func_t HandlerRequest, const int &port = default_port)
: _port(port), _revs_num(gnum), _HandlerRequest(HandlerRequest)
{
_revs = new struct epoll_event[_revs_num];
_listensock = Sock::Socket();
Sock::Bind(_listensock, _port);
Sock::Listen(_listensock);
_epfd = Epoll::CreateEpoll();
logMessage(DEBUG, "mag add listensock to epoll success!");
}
void HandlerEvents(int n);
void Accepter(int listensock);
void Recver(int sock);
void LoopOnce(int timeout);
void Start();
~EpollServer();
private:
int _listensock;
func_t _HandlerRequest;
uint16_t _port;
struct epoll_event *_revs;
int _epfd;
int _revs_num;
};
}
处理就绪事件HandlerEvents
- 参数:
n
: 需要处理的事件数量。- 功能:
- 遍历所有事件,处理读事件(
EPOLLIN
),如果是监听套接字,调用Accepter
处理新连接;否则调用Recver
读取数据
void HandlerEvents(int n)
{
assert(n > 0);
for (int i = 0; i < n; i++)
{
uint32_t revents = _revs[i].events;
int sock = _revs[i].data.fd;
if (revents & EPOLLIN)
{
if (sock == _listensock)
Accepter(_listensock);
else
Recver(sock);
}
if (revents & EPOLLOUT)
{
// 处理写事件
}
}
}
Accepter
接受服务端请求的新连接,并将其套接字添加到epoll实例中
void Accepter(int listensock)
{
std::string clientip;
uint16_t clientport;
int sock = Sock::Accept(listensock, &clientip, &clientport);
if (sock < 0)
{
logMessage(WARNING, "accept error");
return;
}
if (!Epoll::CtlEpoll(_epfd, EPOLL_CTL_ADD, sock, EPOLLIN))
return;
logMessage(DEBUG, "add new sock : %d to epoll success!", sock);
}
Recver
读取数据并调用回调函数进行处理;如果连接关闭或者错误,删除epoll中的文件描述符并关闭其套接字
void Recver(int sock)
{
char buffer[102400];
ssize_t n = recv(sock, buffer, sizeof(buffer) - 1, 0);
if (n > 0)
{
buffer[n] = 0;
_HandlerRequest(buffer);
}
else if (n == 0)
{
bool res = Epoll::CtlEpoll(_epfd, EPOLL_CTL_DEL, sock, 0);
assert(res);
(void)res;
close(sock);
logMessage(NORMAL, "client %d quit ,me too...", sock);
}
else
{
bool res = Epoll::CtlEpoll(_epfd, EPOLL_CTL_DEL, sock, 0);
assert(res);
(void)res;
close(sock);
logMessage(NORMAL, "client %d errror ,close error sock", sock);
}
}
LoopOnce
调用epoll_wait等待事件发生,并根据返回值处理不同的情况
void LoopOnce(int timeout)
{
int n = Epoll::WaitEpoll(_epfd, _revs, _revs_num, timeout);
switch (n)
{
case 0:
logMessage(DEBUG, "timeout...");
break;
case -1:
logMessage(WARNING, "epoll wait error: %s", strerror(errno));
break;
default:
logMessage(DEBUG, "get a event");
HandlerEvents(n);
break;
}
}
Start
不断调用LoopOnce处理事件
void Start()
{
int timeout = 10;
while (true)
{
LoopOnce(timeout);
}
}
水平触发和边缘触发
水平触发下,只要文件描述符处于就绪状态,内核就会不停的通知应用程序,应用程序每次收到通知后,都需要处理该文件描述符的所有就绪数据或者状态。适用于select/poll/epoll。
特点
- 多次通知:只要文件描述符保持就绪状态,内核会不断通知。
- 简单易用:适合大多数应用场景,编程简单。
- 可能造成忙等:如果应用程序处理速度较慢,可能会反复收到相同的通知。
边缘触发,只使用于epoll模式下。边缘触发模式下,内核仅在文件描述符从未就绪到就绪的状态变化时通知应用程序。所以,应用程序必须一次性处理完所有的就绪数据或者状态,否则不会再收到通知。
特点
- 单次通知:仅在状态变化时通知,减少系统调用次数,提高性能。
- 高效:适合高性能应用,避免忙等。
- 编程复杂:应用程序必须确保一次性处理完所有数据,避免遗漏。
记忆:水平触发类似于外卖员不停的打电话催你拿外卖;边缘触发则是外卖员只给你打一次电话通知你拿外卖。