协程和多路复用IO都是解决大量线程进行IO操作带来的性能问题!
协程出现的原因
线程太多会出现的问题
一是系统线程会占用非常多的内存空间,二是过多的线程切换需要CPU用户态和核心态的切换会消耗大量系统时间。
协程成为线程中运行的实体
- 协程由用户程序按需创建
- 我们把任务分发给协程,协程有自己的执行环境(执行现场)存在于线程中
- 当一个协程执行完成后,可以选择主动让出,让另一个协程运行在当前线程之上。
- 协程并没有增加线程数量,只是在线程的基础之上通过分时复用的方式运行多个协程。
- 而且协程的切换在用户态完成,切换的代价比线程从用户态到内核态的代价小很多。
解决的问题
以前需要开启10000个线程执行的任务,现在只需要启动100个线程,每个线程上运行100个协程。
这样不仅减少了线程切换、内存开销,而且还能够同时处理10000个任务。
协程执行过程
但对于操作系统,仍然面对着线程进行调度
线程切换与协程切换对比
线程切换:
- 线程在进行切换的时候,需要将CPU中的寄存器的信息存储起来,然后读入另外一个线程的数据,这个会花费一些时间
- CPU的高速缓存中的数据,也可能失效,需要重新加载
- 线程的切换会涉及到用户模式到内核模式的切换,据说每次模式切换都需要执行上千条指令,很耗时。
协程切换:
- 在切换的时候,寄存器需要保存和加载的数据量比较小。
- 高速缓存可以有效利用
- 没有用户模式到内核模式的切换操作。
- 更有效率的调度,因为协程是非抢占式的,前一个协程执行完毕或者堵塞,才会让出CPU,而线程则一般使用了时间片的算法,会进行很多没有必要的切换(为了尽量让用户感知不到某个线程卡)。
总结
在有大量IO操作业务的情况下,我们采用协程替换线程,可以到达很好的效果,一是降低了系统内存,二是减少了系统切换开销,因此系统的性能也会提升。
在协程中尽量不要调用阻塞IO的方法,比如打印,读取文件,Socket接口等,除非改为异步调用的方式,并且协程只有在IO密集型的任务中才会发挥作用。(因为操作系统不知道协程的存在,如果协程调用阻塞IO,会让整个线程进入阻塞。。。。这玩了个锤子呦)
协程只有和异步IO结合起来才能发挥出最大的威力。
什么是IO?
I/O就是数据的输入(Input)、输出(Output),且IO操作是系统调用(数据要在用户空间与内核空间之间拷贝)
针对不同的操作对象,可以划分为磁盘I/O模型,网络I/O模型,内存映射I/O, Direct I/O、数据库I/O等。
如由南桥的DMA进行IO操作,将数据从外存中进行读取、存储:(网络IO:DMA读取网卡中数据到网卡缓冲区内存 ===网卡硬件中断=> CPU将缓存区数据根据socket放到不同的socket缓冲区)
文件描述符fd:系统中有好多输入、输出流,,每个流使用文件描述符fd标识(fd就是个整数),对流的操作就是对这个整数进行操作!!
IO模型分类
网络IO为例: 线程读取 / 写入网卡缓冲区数据
阻塞IO
当一个线程发起IO请求,这个请求(系统调用)就会一直阻塞,直至可用时(允许读 / 写)才会返回,阻塞期间线程干不了其他事。
非阻塞IO
当一个线程发起IO请求,系统调用会立即返回是否可用(允许读 / 写),如果返回不可用,然后线程会循环进行请求,直至请求返回可用。
IO多路复用
面试:什么是IO多路复用?
答:IO多路复用是为解决大量线程进行IO请求,导致CPU主要处理了IO线程的切换(用户态和核心态转换)而损耗的大量性能问题。
首先,先引入一个中间件,也就是select、epoll等。这些中间件会有一个专门的IO线程监听所有连接,替所有线程查看数据状态,当有数据准备就绪之后再分配对应的线程去读取数据。
出现的问题:好多线程进行IO请求,那么CPU主要处理了大量线程的IO请求,而且线程切换需要CPU用户态和核心态的切换,这就会消耗大量系统资源。
注:在网络连接中,一个连接就是一个流啊!就是一个fd啊!
解决的办法:IO多路复用,引入一个中间代理,由一个线程专门监控多个网络连接(后面将称为fd文件描述符),这样就可以只需要一个或几个线程就可以完成数据状态询问的操作,当有数据准备就绪之后再分配对应的线程去读取数据。
多路IO的实现(代理:其实就是系统提供的一种函数)
复用IO的基本思路就是通过select或poll、epoll(系统调用函数) 来监控多fd ,来达到不必为每个fd创建一个对应的监控线程,从而减少线程资源创建的目的。 (这个代理是一个进程一个select或poll、epoll,是将一个进程中的IO请求由一个IO线程进行处理)【Redis、Nginx 都是epoll】
select
select | |
---|---|
实现过程 | 1. 连接集合fd集合保存着需要监听的fd 2. 将fd集合转换为rset集合(底层是bitmap标识位数据结构 大小1024) 3. 调用select()函数传入rset集合参数(将rset从用户态拷贝到内核态进行遍历监听是否有数据(fd的poll()函数)===> 如果没有数据监控线程则会睡眠一会,然后又继续遍历 ===> 如果有数据,则将有数据的fd标志(置位),然后将rset从内核空间拷贝到用户空间) select函数返回(select是个阻塞函数) 4. 遍历查看rset集合哪个fd有数据(被置位的),并进行读操作(结束后从2又头开始) |
时间复杂度 | 线性遍历返回的fd集合:O(n) |
最大连接数 | 32位机默认是1024个,64位机默认是2048。 |
性能 | 1. 每次select都需要用户态与内核态拷贝fd集合 2. rset集合不能重用,要根据fd集合重新赋值 |
poll
poll | |
---|---|
实现过程 | 调用过程和select类似,只是采用链表的方式替换原有fd_set数据结构,而使其没有连接数的限制。 |
时间复杂度 | 线性遍历返回的结果集合:O(n) |
最大连接数 | 无限制 |
性能 | 1. 每次poll都需要用户态与内核态拷贝rset集合 2. rset集合不能重用,要根据fd集合重新赋值 3. 水平触发:若是报告了fd后,没有被处理,那么下次poll时会再次报告该fd。 |
epoll
epoll结构就在内核态中:
socket等待队列都指向了epoll:
会将自己的fd加入到就绪队列中:
然后,就绪队列中的fd会被拷贝到epoll_wait()函数传入的数组中并返回,然后我们遍历数组中的fd就ok
epoll | |
---|---|
工作方式 | 两种工作方式:水平触发(LT)与边缘触发(ET) 水平触发模式:会将没有处理完的事件继续放回到就绪队列之中(即那个内核中的链表),一直进行处理。 边缘触发模式:就绪的事件只能处理一次,若没有处理完会在下次的其它事件就绪时再进行处理。而若以后再也没有就绪的事件,那么剩余的那部分数据也会随之而丢失。 ET模式只支持非阻塞的读写:为了保证数据的完整性。 由此可见:边缘触发模式效率要高。只是如果使用边缘触发模式,就要保证每次进行数据处理时,要将其处理完,不能造成数据丢失。【边缘模式的优点:系统不会充斥大量你不关心的就绪文件描述符 虽然有数据,但你并不想处理。】 |
时间复杂度 | 直接从就绪链表中拿数据:O(1) |
最大连接数 | 有上限 很大,1G内存的机器上可以打开10万左右的连接 |
性能 | 1. select和poll在“醒着”的时候要遍历整个fd集合;而epoll在只要判断一下就绪链表是否为空就好了 ,节省了大量的CPU时间 2. epoll不会因为连接数增加而效率递减,因为epoll不会去遍历所有的fd 3. fd就绪后,回调函数(fd上的callback函数)会被调用,于是fd会被加入到就绪队列中 4. epoll使用mmap实现内核空间与用户空间共享,避免了fd拷贝 |