进程调度算法
先来先服务
根据到达先后顺序,考虑等待时间
非抢占式
优点:公平、简单、不会饥饿
缺点:对短进程不利
短进程优先
短进程优先得到服务,考虑运行时间
包括非抢占式和抢占式(最短剩余时间优先)
缺点:不公平,对长进程不利,可能产生饥饿
高响应比优先
根据响应比从高到低进行调度,兼顾了等待和运行时间
响应比 = (等待时间 + 要求服务时间) / 要求服务时间
非抢占式
不会饥饿
时间片轮转法
公平、轮流地为各个进程服务,让每个进程在一定时间间隔内都能得到响应
抢占式
若时间片太大会退化为先来先服务
若时间片太小会导致进程频繁切换,消耗资源
优点:公平、响应快、不会饥饿
缺点:进程切换造成开销、无法区分任务紧急程度
优先级调度
根据进程优先级从高到低进行调度
分非抢占式和抢占式(可能会导致饥饿)
多级反馈队列
设置多级就绪队列
抢占式
可能会饥饿
各级队列优先级从高到低,时间片从小到大
详见:https://www.bilibili.com/video/BV1YE411D7nH?p=17&spm_id_from=pageDriver
常用 IO 模型
一个输入操作通常包括两个阶段:
1.等待数据准备好
2.从内核向进程复制数据
对于一个套接字上的输入操作,第一步通常涉及等待数据从网络中到达
当所等待数据到达时,它被复制到内核中的某个缓冲区
第二步就是把数据从内核缓冲区复制到应用进程缓冲区
1.同步IO
调用一个功能,在功能结果未返回前,一直等待结果返回
a.阻塞式IO
调用一个函数,当调用结果返回之前,当前线程会被挂起,只有得到结果之后才会返回
使用系统调用,并一直阻塞直到内核将数据准备好,之后再由内核缓冲区复制到用户态,在等待内核准备的这段时间什么也干不了
阻塞式IO是最流行的IO模型
下图函数调用期间,一直被阻塞,直到数据准备好且从内核复制到用户程序才返回,这种IO模型为阻塞式IO
优点
简单
在阻塞等待期间,用户线程挂起,在挂起期间不会占用CPU资源
缺点
一个线程维护一个IO,不适合高并发
在并发量大的时候需要创建大量的线程来维护网络连接,内存、线程开销非常大
b.非阻塞式IO
调用一个函数,不能立刻得到结果之前,调用不能阻塞当前线程,应用进程可以继续执行,但是需要不断的执行系统调用来获知 I/O 是否完成,如果内核缓冲区有数据,内核就会把数据返回进程
内核在没有准备好数据的时候会返回错误码,而调用程序不会休眠,而是不断轮询内核数据是否准备好
下图函数调用时,如果数据没有准备好,不像阻塞式IO那样一直被阻塞,而是返回一个错误码,当数据准备好时,函数成功返回
优点
每次发起IO调用,在内核等待数据的过程中可以立即返回,用户线程不会阻塞,实时性较好。
缺点
应用进程不断轮询内核是否有数据,占用CPU资源,效率不高
一般Web服务器不会采用此模式
c.多路复用IO
类似与非阻塞,只不过轮询不是由用户线程去执行,而是由内核去轮询,内核监听程序监听到数据准备好后,调用内核函数复制数据到用户态
下图中select这个系统调用,充当代理类的角色,不断轮询注册到它这里的所有需要IO的文件描述符,有结果时,把结果告诉被代理的recvfrom函数,它本尊再亲自出马去拿数据
IO多路复用至少有两次系统调用,如果只有一个代理对象,性能上是不如前面的IO模型的,但是由于它可以同时监听很多套接字,所以性能比前两者高
使用 select 或者 poll 等待数据,可以等待多个套接字中的任何一个变为可读
这一过程会被阻塞,当某一个套接字可读时返回,之后把数据从内核复制到进程中
在多路复用 IO 模型中,会有一个线程不断去轮询多个 socket 的状态
只有当 socket 真正有读写事件时,才真正调用实际的 IO 读写操作。因为在多路复用 IO 模型中,只需要使用一个线程就可以管理多个 socket,并且只有在真正有 socket 读写事件进行时,才会使用 IO 资源,所以它大大减少了资源占用
优点
系统不必创建维护大量线程,只使用一个线程
一个选择器即可同时处理成千上万个连接,大大减少了系统开销
多路复用底层
select、poll 和 epoll 允许应用程序监视一组文件描述符,等待一个或者多个描述符成为就绪状态,从而完成 I/O 操作
select 和 poll 的功能基本相同,不过在一些实现细节上有所不同
如果一个线程对某个描述符调用了 select 或者 poll,另一个线程关闭了该描述符,会导致调用结果不确定。
select 和 poll 速度都比较慢,每次调用都需要将全部描述符从应用进程缓冲区复制到内核缓冲区。
select
线性扫描所有监听的文件描述符,不管他们是否活跃的
有最大数量限制:32位系统1024,64位系统2048
select 的描述符类型使用数组实现,FD_SETSIZE 大小默认为 1024,因此默认只能监听少于 1024 个描述符
如果要监听更多描述符的话,需要修改 FD_SETSIZE 之后重新编译
poll
同select,不过数据结构不同,需要分配一个pollfd结构数组,维护在内核中
它没有大小限制,不过需要很多复制操作
poll 提供了更多的事件类型,并且对描述符的重复利用上比 select 高
epoll
用于代替poll和select,没有大小限制
使用一个文件描述符管理多个文件描述符,使用红黑树存储
用事件驱动代替了轮询
epoll_ctl中注册的文件描述符在事件触发的时候会通过回调机制激活该文件描述符,epoll_wait便会收到通知
当某个进程调用 epoll_create() 方法时,内核会创建一个 eventpoll 对象。
创建 epoll 对象后,可以用 epoll_ctl() 向内核注册新的描述符或者是改变某个文件描述符的状态
已注册的描述符在内核中会被维护在一棵红黑树上,通过回调函数内核会将 I/O 准备好的描述符加入到一个链表中管理,进程调用 epoll_wait() 便可以得到事件完成的描述符
就绪列表:epoll 使用双向链表来实现就绪队列,是一种能够快速插入和删除的数据结构。索引结构:epoll 使用红黑树去监听并维护所有文件描述符。
epoll 的描述符事件有两种触发模式:LT(水平触发)和 ET(边沿触发)。
LT:当 epoll_wait() 检测到描述符事件到达时,将此事件通知进程,进程可以不立即处理该事件,下次调用 epoll_wait()会再次通知进程。
ET:和 LT 模式不同的是,通知之后进程必须立即处理事件,下次再调用 epoll_wait() 时不会再得到事件到达的通知。
边沿触发仅触发一次,水平触发会一直触发
d.信号驱动IO
使用信号,内核在数据准备就绪时通过信号来进行通知
首先开启信号驱动io套接字,并使用sigaction系统调用来安装信号处理程序,内核直接返回,不会阻塞用户态
数据准备好时,内核会发送SIGIO信号,收到信号后开始进行io操作
当进程发起一个 IO 操作,会向内核注册一个信号处理函数,然后进程返回不阻塞;当内核数据就绪时会发送一个信号给进程,进程便在信号处理函数中调用 IO 读取数据。
2.异步IO
调用一个功能,调用立刻返回,但调用者未必立刻得到结果
调用者可进行后续的操作,异步调用结果一般通过状态、回调函数方式通知调用者
异步IO依赖信号处理程序来进行通知
不过异步IO与前面IO模型不同的是:前面的都是数据准备阶段的阻塞与非阻塞,异步IO模型通知的是IO操作已经完成,而不是数据准备完成
异步IO才是真正的“非阻塞”,主进程只负责做自己的事情
等IO操作完成(数据成功从内核缓存区复制到应用程序缓冲区)时通过回调函数对数据进行处理
unix中异步io函数以aio_或lio_开头
优点
真正实现了异步非阻塞,吞吐量在这几种模式中是最高的
缺点
应用程序只需要进行事件的注册与接收,其余工作都交给了操作系统内核,所以需要内核提供支持
在Linux系统中,异步IO在其2.6才引入,目前也还不是非常完善,其底层实现仍使用epoll,与IO多路复用相同,因此在性能上没有明显占优
总结
前面四种IO模型的主要区别在第一阶段,他们第二阶段是一样的(数据从内核缓冲区复制到调用者缓冲区期间会被阻塞)
同步IO的IO操作导致请求进程阻塞,直到IO操作完成
异步IO的IO操作不会导致请求进程阻塞
进程通信方式
a) 管道/匿名管道(Pipes)
用于具有亲缘关系的父子进程间或者兄弟进程之间的通信
工作于内存中
b) 有名管道(Names Pipes)
匿名管道由于没有名字,只能用于亲缘关系的进程间通信
为了克服这个缺点,提出了有名管道
有名管道严格遵循先进先出
有名管道以磁盘文件的方式存在,可以实现本机任意两个进程通信
c)消息队列(Message Queuing)
消息队列是消息的链表,具有特定的格式,存放在内存中并由消息队列标识符标识
管道和消息队列的通信数据都遵循先进先出原则
与管道不同的是消息队列存放在内核中,只有在内核重启(即操作系统重启)或者显式地删除一个消息队列时,该消息队列才会被真正的删除
消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取,也可以按消息的类型读取
消息队列克服了信号承载信息量少,管道只能承载无格式字节流以及缓冲区大小受限等缺陷
d) 信号(Signal)
信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生
对于异常情况下的工作模式,就需要用此方式来通知进程
信号事件的来源主要有硬件来源(如键盘 Cltr+C )和软件来源(如 kill 命令)
比如,Ctrl+C 产生 SIGINT 信号,表示终止该进程
Ctrl+Z 产生 SIGSTP,表示停止该进程,但还未结束
e) 信号量(Semaphores)
信号量是一个计数器,用于多进程对共享数据的访问,信号量的意图在于进程间同步
这种通信方式主要用于解决与同步相关的问题并避免竞争条件
信号量其实是一个整型的计数器,主要用于实现进程间的互斥与同步,而不是用于缓存进程间通信的数据
f) 共享内存(Shared memory)
使得多个进程可以访问同一块内存空间,不同进程可以及时看到对方进程中对共享内存中数据的更新
这种方式需要依靠某种同步操作,如互斥锁和信号量等
可以说这是最有用的进程间通信方式
共享内存的机制,就是拿出一块虚拟地址空间来,映射到相同的物理内存中
g) 套接字(Sockets)
此方法主要用于在客户端和服务器之间通过网络进行通信
套接字是支持 TCP/IP 的网络通信的基本操作单元,可以看作是不同主机之间的进程进行双向通信的端点
简单来说就是通信双方的一种约定,用套接字中的相关函数来完成通信过程
int socket(int domain, int type, int protocal)
线程通信方式
a) 互斥量(Mutex)
采用互斥对象机制,只有拥有互斥对象的线程才有访问公共资源的权限
比如 Java 中的 synchronized 关键词和各种 Lock 都是这种机制
b) 信号量(Semphares)
它允许同一时刻多个线程访问同一资源,但是需要控制同一时刻访问此资源的最大线程数量
c) 事件(Event)
Wait/Notify
通过通知操作的方式来保持多线程同步,还可以方便地实现多线程优先级的比较操作
fork 函数
在 Linux 中 fork 函数是非常重要的函数
它的作用是从已经存在的进程中创建一个子进程,而原进程称为父进程
fock 函数调用一次却返回两次:向父进程返回子进程的 ID,向子进程中返回 0
fork 调用失败的原因:实际进程数超过限制
过程
调用 fork(),当控制转移到内核中的 fork 代码后,内核开始:
1.分配新的内存块和内核数据结构给子进程
2.将父进程部分数据结构内容拷贝至子进程
3.将子进程添加到系统进程列表
4.fork返回开始调度器,调度
协程
协程是一种用户态的轻量级线程,协程的调度完全由用户控制
协程拥有自己的寄存器上下文和栈
协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁地访问全局变量,所以上下文的切换非常快
对操作系统而言,线程是最小的执行单元,进程是最小的资源管理单元,无论是进程还是线程,都是由操作系统所管理的
协程不是被操作系统内核所管理的,而是完全由程序所控制,也就是在用户态执行
这样带来的好处是性能大幅度的提升,因为不会像线程切换那样消耗资源
协程既不是进程也不是线程,协程仅仅是一个特殊的函数
一个进程可以包含多个线程,一个线程可以包含多个协程
一个线程内的多个协程虽然可以切换,但是多个协程是串行执行的,只能在一个线程内运行,没法利用 CPU 多核能力
协程与进程一样,存在上下文切换问题
linux 创建进程和线程
进程通过 fork()创建
线程通过 pthread_create() 创建
Linux命令
通过进程id查看占用的端口
netstat -nap | grep 进程id
通过端口号查看占用的进程id
netstat -nap | grep 端口号
查看占用比较多的进程
僵尸进程
在每个进程退出的时候,内核释放该进程所有的资源,包括打开的文件,占用的内存,但是仍然保留了一些信息(如进程号、退出状态、运行时间等)
一个进程使用fork创建子进程,若子进程退出,而父进程并未调用wait或waitpid获取子进程的状态信息,那么子进程的进程控制块(PCB)仍然保存在系统中,这种进程称之为僵尸进程
带来的问题
这些保留的信息直到进程通过调用 wait/waitpid 时才会释放,这样就导致了一个问题,如果没有调用 wait/waitpid 的话,那么保留的信息就不会释放
比如进程号就会被一直占用了。但系统所能使用的进程号的有限的,如果产生大量的僵尸进程,将导致系统没有可用的进程号而导致系统不能创建进程
所以我们应该避免僵尸进程
孤儿进程
一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程
孤儿进程将被 init 进程(进程号为1)所收养,并由 init 进程对它们完成状态收集工作
孤儿进程是没有父进程的进程,管理孤儿进程这个重任就落到了 init 进程身上,因此孤儿进程并不会有什么危害
虚拟内存
虚拟内存使得应用程序认为它拥有一个连续的地址空间,而实际上,它通常是被分隔成多个物理内存碎片,还有一部分存储在外部磁盘存储器上,在需要时进行数据交换
虚拟内存可以让程序可以拥有超过系统物理内存大小的可用内存空间
虚拟内存让每个进程拥有一片连续完整的内存空间
局部性理论
1)时间局部性
程序中的某条指令一旦执行,不久以后该指令可能会再次执行
若某数据被访问过,不久以后该数据可能会再次被访问
2)空间局部性
一旦程序访问了某个存储单元,在不久之后,其附近的存储单元也将被访问
操作系统将内存抽象成地址空间。每个程序拥有自己的地址空间,这个地址空间被分割成多个块,每一块称为一页。这些页被映射到物理内存,但不需要映射到连续的物理内存,也不需要所有页都必须在物理内存中。当程序引用到不在物理内存中的页时,会将缺失的部分从磁盘装入物理内存
页面置换算法
OPT 页面置换算法(最佳页面置换算法):所选择的被换出的页面将是最长时间内不再被访问, 通常可以保证获得最低的缺页率。
FIFO(First In First Out) 页面置换算法(先进先出页面置换算法) : 总是淘汰最先进入内存的页面,即选择在内存中驻留时间最久的页面进行淘汰。
LRU (Least Currently Used)页面置换算法(最近最久未使用页面置换算法):将最近最久未使用的页面换出。需要在内存中维护一个所有页面的链表。当一个页面被访问时,将这个页面移到 链表表头。这样就能保证链表表尾的页面是最近最久未访问的。力扣-实现LRU
LFU (Least Frequently Used)页面置换算法(最少使用页面置换算法):该置换算法选择在之前时期使用最少的页面作为淘汰页。力扣-实现LFU
2.15 分段和分页讲一下?以及对应的场景?
操作系统的内存管理机制了解吗?内存管理有哪几种方式?
块式管理 : 将内存分为几个固定大小的块,每个块中只包含一个进程。
页式管理 :把主存分为大小相等且固定的一页一页的形式,页较小,相对相比于块式管理的划分力度更大,提高了内存利用率,减少了碎片。页式管理通过页表对应逻辑地址 和物理地址。
段式管理 : 页式管理虽然提高了内存利用率,但是页式管理其中的页实际并无任何实际意义。 段式管理把主存分为一段段的,最重要的是段是有实际意义的,每个段定义了一组逻辑信息。 段式管理通过段表对应逻辑地址和物理地址。例如,有主程序段 MAIN、子程序段 X、数据段 D 及栈段 S 等。 段式管理通过段表对应逻辑地址和物理地址。
段页式管理:段页式管理机制结合了段式管理和页式管理的优点。段页式管理机制就是 把主存先分成若干段,每个段又分成若干页。
分段和分页:
共同点
分页机制和分段机制都是为了提高内存利用率,较少内存碎片。
页和段都是离散存储的,所以两者都是离散分配内存的方式。但是,每个页和段中 的内存是连续的。
区别
页的大小是固定的,由操作系统决定;而段的大小不固定,取决于我们当前运行的 程序。
分页仅仅是为了满足操作系统内存管理的需求,而段是逻辑信息的单位,在程序中 可以体现为代码段,数据段,能够更好满足用户的需要。
2.16 讲一下用户态和内核态?所有的系统调用都会进入到内核态吗?
操作系统(Operating System,简称 OS)是管理计算机硬件与软件资源的程序。根据进程访问资源的特点,我们可以把进程在系统上的运行分为两个级别:
用户态(user mode) : 用户态运行的进程或可以直接读取用户程序的数据。
内核态(kernel mode):可以简单的理解系统态运行的进程或程序几乎可以访问计算机的任何资源,不受限制。
运行的程序基本都是运行在用户态。如果我们调用操作系统提供的内核态级别的子功能那就需要系统调用了。
系统调用:与系统态级别的资源有关的操作(如文件管理、进程控制、内存管理等),都 必须通过系统调用方式向操作系统提出服务请求,并由操作系统代为完成。
系统调用是操作系统为应用程序提供能够访问到内核态的资源的接口。补充:
用户态切换到内核态的几种方式
系统调用: 系统调用是用户态主动要求切换到内核态的一种方式, 用户应用程序通过操作系统调用内核为上层应用程序开放的接口来执行程序。
异常:当 cpu 在执行用户态的应用程序时,发生了某些不可知的异常。 于是当前用户态的应用进程切换到处理此异常的内核的程序中去。
硬件设备的中断: 当硬件设备完成用户请求后,会向 cpu 发出相应的中断信号,这时 cpu 会暂停执行下一条即将要执行的指令,转而去执行与中断信号对应的应用程序, 如果先前执行的指令是用户态下程序的指令,那么这个转换过程也是用户态到内核态的转换。
参考资料
[1]五种IO模型及Epoll