操作系统常见问题
- 调度相关
- 调度算法
- 进程、线程、协程
- 同步相关
- 进程间通信方式
- 死锁(deadlocks)是指两个或多个进程在等待对方释放资源时发生的一种状态。
- 操作系统原子操作
- 多线程锁
- 内存相关
- 虚拟内存
- 页表
- 用户空间分布
- 线程切换上下文
- 线程拥有哪些资源
- 栈中主要保存内容
- 函数调用压栈操作:
- 堆与栈区别
- 文件相关
- 驱动相关
- 网络相关
- 其他
- 用户态(User Mode)和内核态(Kernel Mode)区别
- fork创建子进程的特点
- epoll具体工作流程
- epoll、select、poll的区别
- 零拷贝
- BIO、NIO和AIO
- 消息队列上的消息堆压
- 工具
- 参考
调度相关
调度算法
-
先来先服务(First Come First Serve, FCFS)算法,从就绪队列选择最先进入队列的进程,然后一直运行,直到进程退出或被阻塞,才会继续从队列中选择第一个进程接着运行。
-
最短作业优先(Shortest Job First, SJF)调度算法同样也是顾名思义,它会优先选择运行时间最短的进程来运行,这有助于提高系统的吞吐量。
-
高响应比优先(Highest Response Ratio Next, HRRN)调度算法主要是权衡了短作业和长作业。每次进行进程调度时,先计算 “响应比优先级”,然后把 “响应比优先级” 最高的进程投入运行。
-
时间片轮转(Round Robin, RR)调度算法,每个进程被分配一个时间段,称为时间片(Quantum),即允许该进程在该时间段中运行。
-
最高优先级(Highest Priority First,HPF)调度算法,调度程序能从就绪队列中选择最高优先级的进程进行运行,也有两种处理优先级高的方法,非抢占式和抢占式。
-
多级反馈队列(Multilevel Feedback Queue)调度算法是 “时间片轮转算法” 和 “最高优先级算法” 的综合和发展。
「多级」表示有多个队列,每个队列优先级从高到低,同时优先级越高时间片越短。
「反馈」表示如果有新的进程加入优先级高的队列时,立刻停止当前正在运行的进程,转而去运行优先级高的队列工作原理:
- 设置了多个队列,赋予每个队列不同的优先级,每个队列优先级从高到低,同时优先级越高时间片越短;
- 新的进程会被放入到第一级队列的末尾,按先来先服务的原则排队等待被调度,如果在第一级队列规定的时间片没运行完成,则将其转入到第二级队列的末尾,以此类推,直至完成;
- 当较高优先级的队列为空,才调度较低优先级的队列中的进程运行。如果进程运行时,有新进程进入较高优先级的队列,则停止当前运行的进程并将其移入到原队列末尾,接着让较高优先级的进程运行;
可以发现,对于短作业可能可以在第一级队列很快被处理完。对于长作业,如果在第一级队列处理不完,可以移入下次队列等待被执行,虽然等待的时间变长了,但是运行时间也变更长了,所以该算法很好的兼顾了长短作业,同时有较好的响应时间。
进程、线程、协程
- 进程(Process):进程是操作系统中的一个执行实例,它拥有独立的内存空间和资源。每个进程都是独立运行的,拥有自己的地址空间、文件句柄、环境变量等。进程间通信需要通过特定的机制,如管道、消息队列、共享内存等。
- 线程(Thread):线程是进程的一部分,是在同一进程内并发执行的执行单元。不同线程共享同一进程的内存空间和资源,包括全局变量、堆、文件描述符等。线程可以更轻量级地创建、切换和销毁,相对于进程而言,线程间的切换开销较小。线程之间可以通过共享内存等机制进行通信。
- 协程(Coroutine):协程是一种用户级的轻量级线程。协程由用户控制,而不是由操作系统内核控制。在协程中,执行流可以在不同协程之间进行切换,切换由程序员手动控制,而不需要内核介入。协程可以在一个线程内实现并发,但无法利用多核心处理器。协程通常用于实现高效的异步编程和协作任务。
进程和线程的区别
- 资源分配:进程是操作系统中的一个执行实体,拥有独立的地址空间、文件描述符、打开的文件等资源。每个进程都被分配了独立的系统资源。而线程是进程中的一个执行单元,多个线程共享同一个进程的地址空间和其他资源,包括文件描述符、打开的文件等。
- 调度和切换:进程的调度是由操作系统内核进行的,切换进程需要进行上下文切换,涉及用户态和内核态之间的切换,开销相对较大。而线程的调度是在用户程序中完成,切换线程可以在用户态下快速切换,减少了系统调用的开销。
- 并发性:进程是独立的执行实体,不同进程之间通过进程间通信(IPC)来进行数据交换和共享。进程间通信的方式包括管道、信号量、共享内存等。而线程是在同一个进程中执行的,多个线程之间共享同一进程的资源,可以通过共享内存的方式进行数据交换和共享。
进程切换比线程慢
- 操作系统会给每个进程分配一个虚拟地址空间(vitural address),每个进程包含的栈、堆、代码段这些都会从这个地址空间中被分配一个地址,这个地址就被称为虚拟地址。底层指令写入的地址也是虚拟地址。
- 每个进程都拥有一个自己的虚拟地址空间,并且独立于其他进程的地址空间。
- 进程切换会涉及到虚拟地址空间的切换,而这正是导致进程切换比线程切换慢的原因所在!
协程与我们普通的线程有区别
协程和线程区别
-
调度方式:线程的调度是由操作系统内核进行的,而协程的调度是由程序员或者特定的协程调度器进行的。线程的调度是由操作系统内核控制,切换线程需要进行系统调用,涉及用户态和内核态之间的切换,相对较为耗时。而协程的调度是在用户程序中完成,切换协程可以在用户态下快速切换,减少了系统调用的开销。
-
并发性:线程是操作系统提供的轻量级进程,多个线程之间可以并发执行,但在多核处理器上,线程的并发性是通过操作系统的线程调度实现的。而协程是在单个线程中执行的,多个协程之间通过协程调度器进行切换,实现了更细粒度的并发性。
-
系统资源消耗:线程是操作系统管理的实体,它占用系统资源比较大,包括内存、线程栈、CPU 时间片等。而协程则是在用户空间中实现的,不需要操作系统的支持,因此占用的资源比较少。线程的创建和销毁需要操作系统进行一系列的资源分配和回收,包括线程的栈空间和线程控制块等。而协程在单个线程中执行,不需要额外的系统资源分配,只需要协程调度器保存和恢复协程的上下文。
-
同步方式:线程之间的通信和同步需要使用锁、条件变量等机制来进行,这些机制需要进行加锁和解锁的操作,容易引发死锁和竞态条件等问题。而协程可以使用更轻量级的方式进行通信和同步,如使用通道(Channel)来实现协程之间的消息传递。
linux进程创建线程的流程
linux把所有线程都当做进程来实现,线程仅仅被视为一个与其他进程共享某些资源的进程。每个线程都有唯一的task_struct。
用户程序调用 fork(),主线程调用clone()产生系统调用,陷入内核,, clone()调用do_fork(),完成创建工作的大部分, 调用copy_process()让进程开始运行。
同步相关
进程间通信方式
-
管道(Pipe):管道是一种半双工的通信方式,可以在具有亲缘关系的进程之间进行通信。它可以分为匿名管道(使用pipe函数创建)和命名管道(使用mkfifo函数创建)。匿名管道只能在具有共同祖先的进程之间使用,而命名管道可以在不具有亲缘关系的进程之间使用。
匿名管道顾名思义,它没有名字标识,匿名管道是特殊文件只存在于内存,没有存在于文件系统中,shell 命令中的「|」竖线就是匿名管道,通信的数据是无格式的流并且大小受限,通信的方式是单向的,数据只能在一个方向上流动,如果要双向通信,需要创建两个管道,再来匿名管道是只能用于存在父子关系的进程间通信,匿名管道的生命周期随着进程创建而建立,随着进程终止而消失。
命名管道需要在文件系统创建一个类型为p的设备文件,那么毫无关系的进程就可以通过这个设备文件进行通信。另外,不管是匿名管道还是命名管道,进程写入的数据都是缓存在内核中,另一个进程读取数据时候自然也是从内核中获取,同时通信数据都遵循先进先出原则,不支持lseek之类的文件定位操作。
优点:简单易用,无需额外的系统调用和复杂的设置。
缺点:只能在具有亲缘关系的进程之间进行通信,且只能实现单向通信,如果要双向通信,需要创建两个管道。 -
信号(Signal):信号是一种异步的通信方式,用于通知进程发生了某种事件。一个进程可以向另一个进程发送信号,接收信号的进程可以选择采取相应的行动。进程可以通过系统调用signal或sigaction来注册信号处理函数,当接收到特定信号时,会调用相应的处理函数进行处理。信号可以在应用进程和内核之间直接交互,内核也可以利用信号来通知用户空间的进程发生了哪些系统事件,信号事件的来源主要有硬件来源(如键盘)和软件来源(如 kill 命令),一旦有信号发生,进程有三种方式响应信号 1. 执行默认操作、2. 捕捉信号、3. 忽略信号。有两个信号是应用进程无法捕捉和忽略的,即 SIGKILL 和 SIGSTOP,这是为了方便我们能在任何时候结束或停止某个进程。
优点:简单、快速,适用于简单的通信需求。
缺点:信号的发送和接收是异步的,无法传递大量数据,且不支持双向通信。 -
消息队列(Message Queue):消息队列是一种消息传递的机制,可以在不同进程之间传递特定格式的消息。进程可以通过消息队列发送和接收消息。消息队列提供了一种可靠的通信方式,可以实现进程之间的异步通信。消息队列通信的速度不是最及时的,毕竟每次数据的写入和读取都需要经过用户态与内核态之间的拷贝过程。
优点:支持多对多的进程通信,每个消息都有特定的格式。
缺点:消息的发送和接收是同步的,且不支持实时性要求较高的通信。 -
共享内存(Shared Memory):共享内存是一种高效的通信方式,多个进程可以将同一块内存空间映射到各自的地址空间中,从而实现共享数据。可以解决消息队列通信中用户态与内核态之间数据拷贝过程带来的开销,它直接分配一个共享空间,每个进程都可以直接访问,就像访问进程自己的空间一样快捷方便,不需要陷入内核态或者系统调用,大大提高了通信的速度,享有最快的进程间通信方式之名。但是便捷高效的共享内存通信,带来新的问题,多进程竞争同个共享资源会造成数据的错乱,需要信号量来保护共享资源,以确保任何时刻只能有一个进程访问共享资源。
优点:传输效率高,适用于大量数据的共享。
缺点:需要额外的同步机制来保证数据的一致性和互斥访问,容易造成数据竞争和死锁。 -
信号量(Semaphore):信号量是一种用于进程间同步的机制,可以用来保护共享资源的互斥访问。信号量其实是一个计数器,表示的是资源个数,其值可以通过两个原子操作来控制。
优点:可以用于进程间的同步和互斥。
缺点:只提供了同步和互斥的功能,无法传递大量数据。 -
套接字(Socket):套接字是一种网络编程接口,也可以用于进程间通信。进程可以通过套接字进行网络通信,实现在不同主机上的进程之间进行通信,也可以通过本地套接字(Unix Domain Socket)实现本地进程间通信。
可根据创建 Socket的类型不同,分为三种常见的通信方式,一个是基于 TCP 协议的通信方式,一个是基于 UDP 协议的通信方式,一个是本地进程间通信方式。
优点:支持网络通信,可以在不同主机上的进程之间进行通信。
缺点:相对于其他IPC方式,套接字的使用和编程复杂度较高。
死锁(deadlocks)是指两个或多个进程在等待对方释放资源时发生的一种状态。
在死锁状态下,进程将被永久阻塞,直到外部干预。为了避免死锁,可以使用一些技术,如资源分配图算法、银行家算法和避免相互等待。
死锁的4个必要条件:
- 互斥条件:一个资源每次只能被一个进程使用。
- 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件:进程已获得的资源,在末使用完之前,不能强行剥夺。
- 循环等s待条件:若干进程之间形成一种头尾相接的循环等待资源关系。
三种预防措施:
- 采用资源静态分配策略,破坏“部分分配”条件。
- 允许进程剥夺使用其他进程占有的资源,从而破坏“不可剥夺”条件。
- 采用资源有序分配法,破坏“环路”条件。
操作系统原子操作
操作系统中的原子性操作是通过硬件和软件的支持来实现的。在多核处理器上,原子性操作需要保证在多个核心之间的并发执行中的正确性和一致性。
-
硬件层面上,现代处理器提供了一些特殊的指令或机制来支持原子性操作,例如原子交换(atomic exchange)、原子比较并交换(atomic compare-and-swap)等。这些指令能够在执行期间禁止中断或其他核心的干扰,确保操作的原子性。
-
软件层面上,操作系统提供了一些原子性操作的接口或函数,例如原子操作函数(atomic operation),它们使用了硬件提供的原子性指令来实现原子性操作。这些函数通常是在内核态下执行,可以保证在多个进程或线程之间的原子性。
-
操作系统还可以使用锁机制来实现原子性操作。例如,互斥锁(mutex)可以用来保护共享资源的访问,只有持有锁的进程或线程可以访问共享资源,其他进程或线程需要等待锁的释放。通过锁的机制,可以保证对共享资源的原子性操作。
多线程锁
- 多线程锁是一种用来保护共享资源的机制。在多线程编程中,如果多个线程同时访问同一个共享资源,可能会发生竞态条件(Race Condition),导致程序的行为出现未定义的情况。为了避免这种情况的发生,可以使用多线程锁来保护共享资源。
- 多线程锁的基本思想是,在访问共享资源之前先获取锁,访问完成之后再释放锁。这样可以保证同一时刻只有一个线程可以访问共享资源,从而避免竞态条件的发生。
- 常见的多线程锁包括互斥锁、读写锁、条件变量等。其中,互斥锁用于保护共享资源的访问,读写锁用于在读多写少的情况下提高并发性能,条件变量用于线程之间的同步和通信。
内存相关
虚拟内存
虚拟内存是一种操作系统技术,它允许程序访问比物理内存更大的内存空间。虚拟内存通过将程序使用的内存映射到磁盘上的一个交换文件中来实现。当程序需要访问物理内存中不存在的内存页时,操作系统会将这些页从磁盘中加载到物理内存中。
32 位系统的内核空间占用 1G,位于最高处,剩下的 3G 是用户空间,所以在 32 位操作系统场景下,执行malloc申请大于3G内存,会失败。
操作系统为每个进程分配独立的一套 “虚拟地址” ,每个进程都不能访问物理地址,互不干涉。操作系统会提供一种机制,将不同进程的虚拟地址和不同内存的物理地址映射起来。如果程序要访问虚拟地址的时候,由操作系统转换成不同的物理地址,这样不同的进程运行的时候,写入的是不同的物理地址,这样就不会冲突了。
虚拟地址到物理地址的转换是通过操作系统中的内存管理单元(MMU,Memory Management Unit)来完成的。
虚拟地址到物理地址转换过程:
- 程序发出内存访问请求时,使用虚拟地址进行访问。
- 虚拟地址被传递给MMU进行处理。
- MMU中的地址映射表(页表)被用来将虚拟地址转换为物理地址。页表是一种数据结构,用于存储虚拟地址和物理地址之间的映射关系。
- MMU根据页表中的映射关系,将虚拟地址转换为对应的物理地址。
- 转换后的物理地址被传递给内存系统,用于实际的内存访问操作。
页表
页表是一种数据结构,用于存储虚拟地址和物理地址之间的映射关系。多级页表将页表分为多个层级,每个层级的页表项存储下一级页表的物理地址。通过多级索引,可以逐级查找,最终找到对应的物理页。
对于 64 位的系统,主要有四级目录,分别是:
- 全局页目录项 PGD
- 上层页目录项 PUD
- 中间页目录项 PMD
- 页表项 PTE
用户空间分布
通过这张图你可以看到,用户空间内存,从低到高分别是 6 种不同的内存段:
- 代码段,包括二进制可执行代码;
- 数据段,包括已初始化的静态常量和全局变量;
- BSS 段,包括未初始化的静态变量和全局变量;
- 堆段,包括动态分配的内存,从低地址开始向上增长;
- 文件映射段,包括动态库、共享内存等,从低地址开始向上增长;
- 栈段,包括局部变量和函数调用的上下文等。栈的大小是固定的,一般是 8 MB。当然系统也提供了参数,以便我们自定义大小;
上图中的内存布局可以看到,代码段下面还有一段内存空间的(灰色部分),这一块区域是 “保留区”,之所以要有保留区这是因为在大多数的系统里,我们认为比较小数值的地址不是一个合法地址,例如,我们通常在 C 的代码里会将无效的指针赋值为 NULL。因此,这里会出现一段不可访问的内存保留区,防止程序因为出现 bug,导致读或写了一些小内存地址的数据,而使得程序跑飞。
在这 7 个内存段中,堆和文件映射段的内存是动态分配的。比如说,使用 C 标准库的 malloc() 或者 mmap() ,就可以分别在堆和文件映射段动态分配内存。
线程切换上下文
- 寄存器上下文:线程切换时需要保存和恢复线程所使用的寄存器的值,以便切换后能够继续执行。常见的寄存器包括通用寄存器(如EAX、EBX、ECX等)、指令指针寄存器(如EIP)、堆栈指针寄存器(如ESP)等。
- 栈:线程的栈用于保存局部变量、函数调用信息等。在线程切换时,需要保存和恢复当前线程的栈指针,以及相应的栈帧信息。
- 线程上下文信息:线程切换时,需要保存和恢复与线程相关的其他上下文信息,如线程状态、调度优先级等。
线程拥有哪些资源
线程在操作系统中有一些特定的资源,包括:
- 线程控制块(Thread Control Block,TCB):用于保存线程的状态信息,如程序计数器(Program Counter,PC)、寄存器值、线程 ID、线程优先级等。
- 栈(Stack):每个线程都有自己的栈空间,用于保存函数调用的局部变量、函数的返回地址以及其他临时数据。栈是线程私有的,不同线程之间的栈是相互独立的。
- 寄存器(Registers):线程在执行过程中会使用到寄存器,包括通用寄存器(如EAX、EBX等)、程序计数器(PC)等。寄存器保存了线程当前的执行状态。
- 共享资源:线程可以共享所属进程的资源,如打开的文件、信号处理器等。这些资源可以在线程之间共享和访问。
创建进程所分配资源
- 会分配虚拟内存空间、文件描述符、信号资源。
线程的资源怎么回收?
- linux 线程退出有多种方式,如return,pthread_exit,pthread_cancel等;线程分为可结合的(joinable)和 分离的(detached)两种。
- 如果没有在创建线程时设置线程的属性为PTHREAD_CREATE_DETACHED,则线程默认是可结合的。可结合的线程在线程退出后不会立即释放资源,必须要调用pthread_join来显式的结束线程。
- 分离的线程在线程退出时系统会自动回收资源。
栈中主要保存内容
- 函数调用的局部变量:当一个函数被调用时,其局部变量会被保存在栈中。这些局部变量在函数执行结束后会被销毁。
- 函数的返回地址:当一个函数执行完毕后,需要返回到调用该函数的地址。返回地址会被保存在栈中,以便函数执行完毕后能够正确返回。
- 函数调用过程中的临时数据:在函数执行过程中,可能会需要保存一些临时数据,如函数的参数、中间计算结果等,这些数据会保存在栈中。
函数调用压栈操作:
- 保存返回地址:在函数调用前,调用指令会将下一条指令的地址(即函数调用后需要继续执行的地址)压入栈中,以便函数执行完毕后能够正确返回到调用点。
- 保存调用者的栈帧指针:在函数调用前,调用指令会将当前栈帧指针(即调用者的栈指针)压入栈中,以便函数执行完毕后能够恢复到调用者的执行状态。
- 传递参数:函数调用时,会将参数值依次压入栈中,这些参数值在函数内部可以通过栈来访问。
- 分配局部变量空间:函数调用时,会为局部变量分配空间,这些局部变量会被保存在栈中。栈指针会相应地移动以适应新的局部变量空间。
堆与栈区别
- 管理方式:栈的管理由编译器自动完成,通过分配和释放栈帧来管理栈上的变量。而堆的管理需要手动进行,程序员负责分配和释放堆上的内存。
- 内存分配:栈上的内存分配是连续的,以栈指针为基准,每次分配的内存大小是固定的,而且自动释放。堆上的内存分配是动态的,大小不固定,需要手动分配和释放。
- 存储内容:栈主要用于存储局部变量、函数调用、函数返回地址等临时数据,它们的生命周期与函数的调用和返回相关。而堆用于存储动态分配的数据,如对象、数组等,它们的生命周期由程序员控制。
- 空间大小:栈的空间通常比较小,因为它受限于系统的栈大小和函数调用的嵌套深度。而堆的空间较大,通常受限于系统的可用内存大小。
文件相关
驱动相关
网络相关
其他
用户态(User Mode)和内核态(Kernel Mode)区别
用户态和内核态是操作系统中的两种特权级别。
- 访问权限:在用户态下,应用程序只能访问受限的资源和执行受限的操作,例如用户空间的内存、文件和设备。而在内核态下,操作系统具有完全的访问权限,可以访问系统的所有资源和执行所有操作。
- CPU指令集:在用户态下,CPU只能执行非特权指令,例如算术运算、逻辑运算等。而在内核态下,CPU可以执行特权指令,例如访问设备、修改系统状态等。
- 中断和异常处理:在用户态下,当发生中断或异常时,操作系统会进行中断处理,将控制权转移到内核态下的中断处理程序中。而在内核态下,操作系统可以直接处理中断和异常,并进行相应的处理操作。
- 内存保护:在用户态下,应用程序只能访问自己的内存空间,无法访问其他应用程序的内存空间和操作系统的内存空间。而在内核态下,操作系统可以访问所有的内存空间,包括应用程序的内存空间。
- 安全性:由于用户态的应用程序受到限制,操作系统可以对其进行隔离和保护,防止恶意代码对系统造成损害。而内核态下的操作系统具有更高的权限,需要对其进行严格的安全管理,以防止非法访问和恶意操作。
fork创建子进程的特点
- 父子进程:fork调用后,会创建一个新的子进程,该子进程与父进程几乎完全相同,包括代码、数据和打开文件等。子进程从fork调用的位置开始执行,父进程和子进程在fork调用之后的代码处继续执行。
- 资源继承:子进程继承了父进程的大部分资源,包括打开的文件、文件描述符、信号处理器等。但是有些资源(如互斥锁和定时器)可能需要进行特殊处理,以避免竞争条件或资源泄漏。
- 内存:父进程和子进程拥有独立的虚拟内存空间,每个进程都有自己的内存映射表。子进程通过写时复制(copy-on-write)机制与父进程共享物理内存,只有在需要修改内存内容时才会进行复制。
- 父子关系:父进程可以通过fork的返回值判断是否为子进程。父进程的fork返回子进程的PID,而子进程的fork返回0。这样可以根据返回值的不同,在父子进程中执行不同的逻辑。
epoll具体工作流程
-
通过epoll_create创建epoll对象epfd,此时epoll对象的内核结构包含就绪链表和红黑树,就绪队列是用于保存所有读写事件到来的socket。红黑树用于保存所有待检测的socket。
当某一进程调用 epoll_create 方法时,Linux 内核会创建一个 eventpoll 结构体。
struct eventpoll { /* sys_poll-wait用到的等待队列,软中断数据就绪的时候会通过wq来找到阻塞在epoll对象上的用户进程 */ wait_queue_head_t wq; /* 红黑树的根节点,这棵树中存储着所有添加到epoll中的事件,也就是这个epoll监控的事件 */ struct rb_root rbr; /* 双向链表rdllist保存着将要通过epoll_wait返回给用户的、满足条件的事件 */ struct list_head rdllist; ... };
在调用 epoll_create 时,内核除了在 epoll 文件系统里建了个 file 结点,在内核 cache 里建了个红黑树用于存储以后 epoll_ctl 传来的 socket 外,还会再建立一个 rdllist 双向链表,用于存储准备就绪的事件,当 epoll_wait 调用时,仅仅观察这个 rdllist 双向链表里有没有数据即可。有数据就返回,没有数据就sleep,等到 timeout 时间到后即使链表没数据也返回。所以epoll_wait 非常高效。
-
通过epoll_crt将待检测的socket,加入到红黑树中,并注册一个事件回调函数,当有事件到来的之后,会调用这个回调函数,进而通知到epoll对象。
所有添加到epoll中的事件都会与设备(如网卡)驱动程序建立回调关系,也就是说相应事件的发生时会调用这里的回调方法。这个回调方法在内核中叫做ep_poll_callback,它会把这样的事件放到上面的rdllist双向链表中。
-
调用epoll_wait等待事件的发生,当内核检测到事件发生后,调用该socket注册的回调函数,执行回调函数就能找到socket对应的epoll对象,然后会将事件加入到epoll对象的绪队列中,最后将就绪队列返回给应用层。
// 先用epoll_create创建一个epoll对象epfd,
// 再通过epoll_ctl将需要监视的socket添加到epfd中,
// 最后调用epoll_wait等待数据,当epoll_wait返回后,就可以遍历它返回的事件列表,然后根据事件类型做出相应的处理。
int s = socket(AF_INET, SOCK_STREAM, 0);
bind(s, ...);
listen(s, ...)
int epfd = epoll_create(...);
epoll_ctl(epfd, ...); //将所有需要监听的socket添加到epfd中
while(1) {
int n = epoll_wait(...);
for(接收到数据的socket){
//处理
}
}
epoll有EPOLLLT和EPOLLET两种触发模式,LT是默认的模式,ET是“高速”模式。
-
LT(水平触发(level-triggered))模式下,只要这个文件描述符还有数据可读,每次 epoll_wait都会返回它的事件,提醒用户程序去操作;使用水平触发模式时,当被监控的 Socket 上有可读事件发生时,服务器端不断地从 epoll_wait 中苏醒,直到内核缓冲区数据被 read 函数读完才结束,目的是告诉我们有数据需要读取;
-
ET(边缘触发 (edge-triggered))模式下,在它检测到有 I/O 事件时,通过 epoll_wait 调用会得到有事件通知的文件描述符,对于每一个被通知的文件描述符,如可读,则必须将该文件描述符一直读到空,让 errno 返回 EAGAIN 为止,否则下次的 epoll_wait 不会返回余下的数据,会丢掉事件。使用边缘触发模式时,当被监控的 Socket 描述符上有可读事件发生时,服务器端只会从 epoll_wait 中苏醒一次,即使进程没有调用 read 函数从内核读取数据,也依然只苏醒一次,因此我们程序要保证一次性将内核缓冲区的数据读取完;
如果ET模式不是非阻塞的,那这个一直读或一直写势必会在最后一次阻塞。
epoll、select、poll的区别
-
select实现多路复用的方式是,将已连接的Socket都放到一个文件描述符集合,通常是使用fd_set数据结构来表示,该集合包含要监视的文件描述符。然后将该文件描述符集合作为参数传递给select函数,调用select函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生,就是通过遍历文件描述符集合(Socket集合)的方式,当检查到有事件产生后,将此Socket标记为可读或可写,接着再把整个文件描述符集合拷贝回用户态里,然后用户态还需要再通过遍历整个Socket集合的方法找到可读或可写的Socket,然后再对其处理。
所以,对于select这种方式,需要进行2次「遍历」文件描述符集合,一次是在内核态里,一个次是在用户态里,而且还会发生2次「拷贝」文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。
select使用固定长度的BitsMap,表示文件描述符集合,而且所支持的文件描述符的个数是有限制的,在Linux系统中,由内核中的FD_SETSIZE限制,默认最大值为1024,只能监听0~1023的文件描述符。
-
poll不再用BitsMap来存储所关注的文件描述符,取而代之用动态数组,以链表形式来组织,突破了select的文件描述符个数限制,当然还会受到系统文件描述符限制。
但是poll和select并没有太大的本质区别,都是使用“线性结构”存储进程关注的Socket集合,因此都需要遍历文件描述符集合(Socket集合)来找到可读或可写的Socket,时间复杂度为O(n),而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上来(Socket集合增大),性能的损耗会呈指数级增长。 -
epoll通过两个方面,很好解决了select/poll的问题。
-
epoll在内核里使用红黑树来跟踪进程所有待检测的Socket文件描述符,把需要监控的socket通过epoll_ctl()函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删改一般时间复杂度是O(logn)。而select/poll内核里没有类似epoll红黑树这种保存所有待检测的socket的数据结构,所以select/poll每次操作时都传入整个socket集合给内核,而epoll因为在内核维护了红黑树,可以保存所有待检测的socket,所以只需要传入一个待检测的socket,减少了内核和用户空间大量的数据拷贝和内存分配。
-
epoll使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个socket有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用epoll_wait()函数时,只会返回有事件发生的文件描述符的个数,只将有事件发生的 Socket 集合传递给应用程序,不需要像select/poll那样轮询扫描整个socket集合,大大提高了检测的效率。
-
epoll的方式即使监听的Socket数量越多的时候,效率不会大幅度降低,能够同时监听的Socket的数目也非常的多了,上限就为系统定义的进程打开的最大文件描述符个数。因而,epoll被称为解决C10K问题(web servers to handle ten thousand clients simultaneously)的利器。
IO特别密集时epoll效率还高吗
可以考虑select/poll,这种情况轮询也很高效,且结构简单。
- 连接密集(短连接特别多),使用epoll的话,每一次连接需要发生epoll_wait->accpet->epoll_ctl调用,而使用select只需要select->accpet,减少了一次系统调用。
- 读写密集的话,如果收到数据,我们需要响应数据的话,使用epoll的情况下, read 完后也需要epoll_ctl 加入写事件,相比select多了一次系统调用。
零拷贝
sendfile系统调用实现了零拷贝技术,零拷贝技术的文件传输方式相比传统文件传输的方式,减少了2次上下文切换和数据拷贝次数,只需要2次上下文切换和数据拷贝次数,就可以完成文件的传输,而且2次的数据拷贝过程,都不需要通过CPU,2次都是由DMA来搬运,使用零拷贝的项目有nginx、kafka。
BIO、NIO和AIO
IO(Blocking IO)、NIO(Non-Blocking IO)和AIO(Asynchronous IO)是Java中常用的IO模式。它们之间的主要区别在于IO的处理方式和效率。
- BIO是同步阻塞IO,在进行IO操作时,必须等待IO操作完成后才能进行下一步操作,这时线程会被阻塞。BIO适用于连接数比较小且固定的架构,由于线程阻塞等待IO操作,所以并发处理能力不强。
- NIO是同步非阻塞IO,可以支持多个连接同时进行读写操作,因此可以用较少的线程来处理大量的连接。NIO通过Selector来监听多个Channel的状态,当Channel中有数据可读或可写时,Selector会通知程序进行读写操作。NIO适用于连接数多且连接时间较短的场景。
- AIO是异步非阻塞IO,与NIO不同的是,AIO不需要用户线程等待IO操作完成,而是由操作系统来完成IO操作,操作系统完成IO操作后会通知用户线程处理。AIO适用于连接数较多且连接时间较长的场景,如高性能网络服务器等。
IO模型
- 阻塞I/O模型:应用程序发起I/O操作后会被阻塞,直到操作完成才返回结果。适用于对实时性要求不高的场景。
- 非阻塞I/O模型:应用程序发起I/O操作后立即返回,不会被阻塞,但需要不断轮询或者使用select/poll/epoll等系统调用来检查I/O操作是否完成。适合于需要进行多路复用的场景,例如需要同时处理多个socket连接的服务器程序。
- I/O复用模型:通过select、poll、epoll等系统调用,应用程序可以同时等待多个I/O操作,当其中任何一个I/O操作准备就绪时,应用程序会被通知。适合于需要同时处理多个I/O操作的场景,比如高并发的服务端程序。
- 信号驱动I/O模型:应用程序发起I/O操作后,可以继续做其他事情,当I/O操作完成时,操作系统会向应用程序发送信号来通知其完成。适合于需要异步I/O通知的场景,可以提高系统的并发能力。
- 异步I/O模型:应用程序发起I/O操作后可以立即做其他事情,当I/O操作完成时,应用程序会得到通知。异步I/O模型由操作系统内核完成I/O操作,应用程序只需等待通知即可。适合于需要大量并发连接和高性能的场景,能够减少系统调用次数,提高系统效率。
消息队列上的消息堆压
- 自身场景下,消息堆压是暂时的,消息堆压只是突发状况,就算不额外处理,随着时间流逝也可消费完毕。
- 假如存在持续性消息堆压,可以考虑临时增加消费者的数量,提升消费者的消费能力。
如果是线上突发问题,要临时扩容,增加消费端的数量,与此同时,降级一些非核心的业务。通过扩容和降级承担流量,这是为了表明你对应急问题的处理能力。其次,才是排查解决异常问题,如通过监控,日志等手段分析是否消费端的业务逻辑代码出现了问题,优化消费端的业务处理逻辑。
工具
-
使用 Valgrind 检测内存使用情况:
valgrind --tool=memcheck --leak-check=full ./main -
内核调试工具:
printk, systemtap, kprobe, packetdrill -
使用ps命令:通过在终端中运行ps -eLf命令,可以列出所有进程及其对应的线程信息。每个线程都会显示线程ID(TID)、进程ID(PID)、线程优先级(PRI)、CPU占用率(%CPU)、内存占用(%MEM)等信息。
参考
图解系统介绍!