7.1说说为什么要有DMA技术?
在没有DMA技术前,IO的过程是这样的?
(1)应用程序read()调用,向操作系统发出IO请求,请求读取数据到自己的用户缓冲区中,进程进入阻塞状态,用户态切换为内核态;
(2)操作系统收到请求后,发送IO请求给磁盘,然后返回;
(3)磁盘控制器收到指令后,开始准备数据,将数据放入磁盘控制器的内部缓冲区,然后发起IO中断信号;
(4)CPU收到IO中断信号后,停下手头的工作,把磁盘控制器的缓冲区的数据拷贝到自己的PageCache中(寄存器);
(5)然后把PageCache中的数据拷贝到用户缓冲区中,系统调用返回,内核态切换回用户态;
整个过程都需要CPU亲自搬运数据,于是发明了DMA技术(直接内存访问)。DMA技术就是在进行IO设备和内存数据传输的时候,数据搬运的工作全部交给DMA控制器,CPU不再参与任何数据搬运相关的事件,这样CPU就可以去处理别的事务。
(1)应用程序read()调用,向操作系统发出IO请求,请求读取数据到自己的用户缓冲区中,进程进入阻塞状态,用户态切换为内核态;
(2)操作系统收到请求后,进一步发起IO请求给DMA,然后让CPU执行其他任务;
(3)DMA进一步将IO请求发送给磁盘;
(4)磁盘收到DMA的IO请求后,把数据从磁盘读入到磁盘控制器的缓冲区中,当磁盘控制器缓冲区被读满后,向DMA发出中断信号;
(5)DMA收到磁盘的中断信号后,将磁盘控制器缓冲区中的数据拷贝到内核缓冲区,这个过程是不占用CPU的,CPU可以执行其他任务;
(6)当DMA拷贝完数据后,发出中断信号给CPU;
(7)CPU收到DMA的中断信号,将内核缓冲区中的数据拷贝到用户空间,系统调用返回;
CPU虽然不再参与数据搬运的工作,但CPU在这个过程中依然是必不可少的,因为传输什么数据,从哪里传输到哪里,都需要CPU来告诉DMA控制器。
7.2说说文件传输的过程
传统文件传输的整个期间,会发生4次用户态、内核态的切换,以及4次数据拷贝的过程;
(1)第一次拷贝:把磁盘上的数据拷贝到内核缓冲区里,这个拷贝的过程是由DMA搬运的;
(2)第二次拷贝:把内核缓冲区中的数据拷贝到用户的缓冲区里,于是应用程序就可以使用这部分数据了,这个拷贝过程是由CPU来完成的;
(3)第三次拷贝:把用户缓冲区中的数据拷贝到内核中的socket缓冲区里,这个过程也是CPU来完成的;
(4)第四次拷贝:把socket缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程是由DMA搬运的;
每次系统调用都会发生一次从用户态到内核态,等内核完成任务后,再从内核态切换回用户态的过程;
7.3如何优化文件传输的性能?
读取磁盘数据的时候,之所以要发生上下文切换,是因为用户空间没有权限操作磁盘或网卡,内核的权限最高,这些操作设备的过程都需要交由操作系统内核来完成,所以一般要通过内核去完成某些任务的时候,就需要使用操作系统提供的系统调用函数。
而传输的文件传输方式会经历4次数据拷贝的过程,但是这里面,从内核缓冲区拷贝数据到用户的缓冲区里,再从用户缓冲区拷贝数据到socket缓冲区里,这个过程其实是没有必要的。因为在文件传输的应用场景中, 在我们用户空间我们通常并不会对数据进行再加工处理,所以数据实际上可以不用搬运到用户空间,因此用户的缓冲区是没有必要存在的。
【零拷贝】
零拷贝的方式通常有两种:1.mmap+write 2.sendfile
【mmap+write】
mmap系统调用函数会直接把内核缓冲区中的数据映射到用户空间,这样操作系统内核和用户空间之间不需要数据拷贝操作;
(1)应用程序调用了mmap()后,DMA会把磁盘的数据拷贝到内核缓冲区的,随后操作系统和用户空间都可以访问这个共享的缓冲区;
(2)应用进程再调用write(),操作系统把内核缓冲区的数据拷贝到socket缓冲区中,这一切都发生在内核态,由CPU来搬运数据;
(3)最后,把内核的socket缓冲区中的数据,拷贝到网卡的缓冲区里,这个过程是由DMA搬运的;
但这还不是最理想的零拷贝,因为依然需要进行三次数据拷贝的过程,相比于read()+wrte(),减少了一次从内核缓冲区拷贝到用户缓冲区的过程;
【sendfile】
sendfile()可以替代read()+write(),这样能减少一次系统调用,减少2次上下文切换的开销;
(1)应用程序调用sendfile()函数后,DMA把磁盘控制器中缓冲区中的数据搬运到内核缓冲区中;
(2)然后不切换到用户态,也不需要再把内核缓冲区中的数据拷贝到用户缓冲区,而是直接让CPU拷贝数据到内核的socket缓冲区中;
(3)最后把内核的socket缓冲区中的数据拷贝到网卡的缓冲区里;
【SG-DMA】
如果网卡支持SG-DMA技术,可以进一步减少CPU把内核缓冲区里的数据拷贝到socket缓冲区的过程;
(1)通过DMA将磁盘控制器的缓冲区中的数据拷贝到内核缓冲区;
(2)缓冲区描述符和数据长度传到socket缓冲区,这样网卡SG-DMA控制器就可以直接将内存缓冲区中的数据拷贝到网卡的缓冲区里,这个过程不需要再将操作系统的内核缓冲区拷贝到socket缓冲区中,减少一次数据拷贝的过程;
零拷贝技术,指的是没有在内存层面去拷贝数据,就是说全程没有CPU来搬运数据,所有的数据都是通过DMA来进行传输的。零拷贝技术的文件传输方式相比于传统文件传输的方式,减少了2次上下文切换和数据拷贝次数,只需要2次上下文切换和数据拷贝次数,就可以完成传输,而且2次的数据拷贝过程,都不需要通过CPU,2次都是由DMA来搬运。
7.4说说PageCache有什么作用?
第一步把磁盘控制器的缓冲区中的数据拷贝到内核缓冲区,这个内核缓冲区其实就是磁盘高速缓存(PageCache)。因为读写磁盘的速度比读写内存慢太多了,所以我们可以通过DMA把磁盘里的数据搬运到内存中,用读内存的方式来替换读磁盘。
所以,读磁盘数据的时候,会优先在PageCache中找,如果数据存在就可以直接返回;如果没有,则从磁盘中读取,然后缓存到PageCache中。另外对于机械硬盘来说,就是通过磁头旋转到数据所在的扇区,然后顺序读取数据,寻址时间+旋转磁头的物理动作是非常耗时的,为了降低它的影响,PageCache使用了预读功能。打个比方,假设每次read函数只会读取32KB的字节,刚开始读取0-32KB字节的数据,但内核会把其后面的32KB也读取到PageCache中,这样后面读取32-64KB的成本就会降低,如果在32KB-64KB淘汰出PageCache之前,读取命中了PageCache,收益就非常大了。
但是PageCache不太适合用于某些大文件的传输,因为PageCache会因为长时间被大文件占据,其他热点的小文件数据就可能无法充分使用到PageCache,这样读写磁盘的性能就下降了;另外PageCache中的大文件数据,由于没有享受到缓存的好处,但却耗费DMA多拷贝到PageCache一次。
7.5说说大文件应该用什么方式传输
传统方式,进程调用read()时会阻塞等待磁盘数据的返回;
异步IO的方式:
(1)前半部分:应用进程向内核发起异步IO调用,不等待数据就位就返回,进程此时可以处理其他任务;内核收到用户进程的请求后,发送IO请求给磁盘。
(2)后半部分:磁盘将数据准备好放入磁盘控制器缓冲区中后,发起IO中断信号给内核,内核直接将磁盘缓冲区里的数据拷贝到用户缓冲区中,然后通知进程;
在高并发的场景下,针对大文件传输的方式,应该使用异步IO+直接IO来代替零拷贝技术;
7.6说说网络通信的多进程模型
基于最原始的阻塞网络IO,如果服务端要支持多个客户端访问,比较传统的方式是多进程模型,也就是为每个客户端分配一个进程来处理请求。
服务端的主进程负责监听客户的连接,一旦与客户端连接完成,accept()函数就会返回一个已连接Socket,这时就可以通过fork函数创建一个子进程,实际上就是把父进程所有的东西都复制一份,包括文件描述符、内存地址空间、程序计数器、执行的代码等。
因此子进程会复制父进程的文件描述符,所以就可以直接使用已连接Socket和客户端通信了;子进程不需要关心监听Socket,只需要关心已连接Socket;而父进程不需要关心已连接Socket,只需要关心监听Socket。
当子进程退出时,实际上内核里还是会保留该进程的一些信息,也是会占用内存的,如果不做好回收工作,就会变成僵尸进程,随着僵尸进程越多,会耗尽系统资源。当子进程退出后,需要回收它们的系统资源,分别是调用wait()和waitpad()函数。
这种方式用多个进程来应对多个客户端的方式,在应对100个客户端还是可行的,但是如果客户端的数量多起来是肯定扛不住的,因为每一次的fork创建子进程,必定有消耗系统资源,然后进程的上下文切换的成本是很高的,性能会大打折扣。进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。
7.7说说网络通信的多线程模型
因为进程的上下文切换是重量级操作,所以可以使用多线程模型来代替多进程模型。一个进程可以运行多个线程, 同一个进程里的线程可以共享进程的部分资源,比如文件描述符列表、进程空间、代码、全局数据、堆等,这些共享数据在上下文切换时是不需要切换的,只需要切换线程的私有数据,因此同一个进程下的线程上下文切换的开销比进程要小得多。
当服务端与客户端TCP完成连接后,通过pthread_create()函数创建线程,然后将已连接Socket的文件描述符传递给线程函数,接着在线程里和客户端进程通信,从而达到并发处理的目的。
如果每来一个连接就创建一个线程,线程运行完后,还得操作系统销毁线程,虽然线程切换的成本开销不大,但如果需要频繁创建和销毁线程,系统开销也是不小的。那么,我们可以使用线程池来避免线程的频繁创建和销毁,提前创建若干个线程,将已连接Socket放入一个队列里,然后线程池里的线程负责从队列中取出已连接Socket处理。另外,需要注意的是,这个已连接Socket队列是全局的,每个线程都会操作,为了避免多线程竞争,线程在操作这个队列前需要加锁。
7.8说说高性能网络模式Reactor
如果要让服务端服务多个客户端,那么最直接的方式就是为每一条连接创建线程。其实创建进程也可以,但是线程比进程更加轻量一些。提前创建好线程池,将已连接Socket分配给线程,让一个线程可以处理多个业务。
【阻塞模式】:但是当一个连接对应一个线程时,线程一般采用read->业务处理->send的处理流程,如果当前连接没有事件可读,那线程就会一直阻塞在read事件上,不过这种阻塞方式倒是不会影响其他线程。但如果引入了线程池后,一个线程要处理多个连接的业务,线程在处理某个连接的read操作时,如果遇到了没有数据可读,就会发生阻塞,那么线程就没办法继续处理其他连接的业务。
【非阻塞模式】:要解决这个问题,最简单的方式就是将socket改成非阻塞的模式,然后线程不断轮询调用read来判断是否有数据,虽然这种方式能够解决阻塞的问题,但是解决的方式比较粗暴,导致CPU空转,而且一个线程处理的连接越多,轮询的效率就越低。
【IO多路复用技术】:就是想办法,当连接上有数据的时候线程才会去调用read()函数去处理这个连接,这个技术就是IO多路复用。IO多路复用技术就是会用系统调用函数来监听我们所关心的连接,也就是说可以在一个监控线程里面监控很多的连接。
select/poll/epoll就是内核提供给用户态的多路复用系统调用,线程可以通过一个系统调用函数从内核中获取多个事件。
(1)如果没有事件发生,线程只需阻塞在这个系统调用上,无需向非阻塞那样轮询调用read操作来判断是否有数据;
(2)如果有事件发生,内核会返回产生了事件的连接,线程就从阻塞状态返回,然后在用户态中再处理这些连接对应的业务即可。
【Reactor模式】:主要由Reactor和处理资源池这个核心部分组成;
Reactor:负责监听和分发事件,事件包括连接事件和读写事件;
处理资源池:负责处理事件,比如read->业务逻辑->send。
Reactor模式是灵活多变的,可以应对不同的业务场景,灵活在于:Reactor的数量可以只有一个,也可以有多个;处理资源池可以是单个进程/线程,也可以是多个进程/线程。通常有三种方式应用在实际的项目中,分别是:1.单Reactor单进程/线程模式 2.单Reactor多进程/线程模式 3.多Reactor多进程/线程模式;具体是使用进程还是线程,要看编程语言和平台,java语言一般使用的是线程,比如Netty;C语言使用进程或线程都行,Nginx使用的是进程,Memcache使用的是线程;
【单Reactor单进程/线程模式】
进程里有Reactor、Acceptor、Handler这三个对象;Reactor对象的作用是监听和分发事件;Acceptor对象的作用是获取连接;Handler对象的作用是处理业务;
对象中对应的select、dispatch、read、send是系统调用函数,
(1)Reactor对象通过select(IO多路复用)监听事件,收到事件后通过dispatch进行分发,具体分发给Acceptor对象还是Handler对象,还要看收到的事件类型;
(2)如果是连接建立的事件,则交给Acceptor对象进行处理,Acceptor对象会通常accep()函数获取连接,并创建一个Handler对象来处理后续的响应时间;
(3)如果不是连接建立事件,则交给当前连接对应的Handler对象来进行处理;
(4)Handler对象通过read->业务处理->send的流程来完成完整的业务流程。
单Reactor单进程的方案因为全部工作都在同一个进程内完成,所以实现起来比较简单,不需要考虑进程间的通信,也不用担心多进程竞争,但它的缺点是:1.只有一个进程,无法充分发挥CPU多核的性能 2.Handler对象在业务处理时,整个进程是无法处理其他连接的事件的,如果业务处理耗时较长,就会造成响应的延迟。
所以单Reactor单进程的方案不适用于计算密集型的场景,只适用于处理非常快速的场景。Redis就是采用的单Reactor单进程的方案,因为Redis业务处理主要是在内存中完成,操作的速度是非常快的,性能瓶颈不在CPU上。
【单Reactor多进程/线程模式】
(1)Reactor对象通过select(多路复用接口)监听事件,收到事件后通过dispatch进行分发,具体分发给Acceptor对象还是Handler对象,还要看收到的事件类型;
(2)Handler对象不再负责业务处理,只负责数据的接收和发送,Handler对象通过read函数读取到数据后,会把数据发给子线程的Processor对象进行业务处理;
(3)子线程里的Processor对象进程业务处理完毕后,将结果发送给主线程的Handler对象,接着由Handler对象通过send方法将相应结果发送给客户端。
单Reactor多线程的方案优势在于能够充分利用多核CPU的功能,既然引入了多线程就自然会带来多线程竞争资源的问题;要避免多线程由于竞争共享资源而导致数据错乱的问题,就需要在操作共享资源前加上互斥锁,保证任意时刻只有一个线程在操作共享资源,待该线程操作完释放互斥锁后,其他线程才有机会操作共享数据;
另外虽然单Reactor多线程的方案需要避免多线程操作共享资源而需要加锁,但是开销也比单Reactor多进程的方案开销要低很多,因为进程之间的通信要复杂得多,在实际应用中也很难看到单Reactor多进程的模式。
【多Reactor多进程/线程模式】
因为只有一个Reactor对象承担所有事件的监听和响应,而且只在主线程中运行,在面对瞬间高并发的场景下,容易产生性能瓶颈。所以就有了多Reactor多进程/线程模式。
(1)主线程中的MainReactor对象通过select监控建立连接事件,收到连接事件后交给Acceptor对象来处理,Acceptor对象获取连接,将新的连接分配给某个子线程;
(2)子线程中的SubReactor对象将分配的连接加入select继续监听,并创建一个Handler用于处理连接的响应事件;
(3)如果有新的事件发生,SubReactor对象会调用当前连接对应的Handler对象来响应;
(4)Handler对象通过read->业务处理->send的流程来完成完整的业务流程。
多Reactor多线程的方案的优点在于:1.主线程和子线程分工明确,主线程只负责接收新连接,子线程负责完成后续的业务处理。 2.主线程和子线程的交互很就简单,主线程只需要把新连接传给子线程,子线程无须返回数据,直接就可以在子线程中将处理结果返回给客户端。
7.9说说什么是阻塞、非阻塞、同步、异步IO?
(1)阻塞IO:当用户程序调用read()函数时,线程会被阻塞,一直要等到内核将数据准备好然后拷贝到用户缓冲区,read才会返回;阻塞等待的是【内核缓冲区中的数据准备好】和【数据从内核态拷贝到用户态】这两个过程;
(2)非阻塞IO:非阻塞的read请求在数据未准备好的情况也会立刻返回,此时可以继续往下运行;然后应用程序不断轮询内核,直到数据准备好,内核将数据拷贝到应用程序缓冲区中,read调用才会获取到结果;
(3)同步IO:非阻塞IO的最后一次read函数调用读取到内核中有数据时,这个获取数据的过程是一个同步的过程,需要等待内核完成从内核缓冲区将数据拷贝到用户缓冲区的这个过程。因此不管是阻塞IO还是非阻塞IO都是同步调用,因为在read调用时,内核拷贝数据到用户缓冲区的这个过程是需要时间的,用户进程需要等待,如果内核实现的拷贝效率比较低的话,read调用就会在这个同步过程中等待比较长的时间;
(4)异步IO:真正的异步IO是【等待内核数据准备好】和【等待内核将数据拷贝到用户缓冲区】这两个过程都是不需要等待的。
当用户进程发起aio_read函数调用后,就会立刻返回,当内核完成将数据拷贝到用户缓冲区后,会通知用户进程。
7.8说说高性能网络模式Proactor
(1)Reactor是非阻塞同步网络模式,感知的是就绪可读写事件。在每次感知到有事件发生后,就需要应用进程主动调用read函数来完成数据的读取,也就是应用进程要主动等待socket缓冲区中的数据读到用户缓冲区的这个过程,这个过程是同步的,读取完数据后应用进程才能处理数据;
(2)Proactor是异步网络模式,感知的是已完成的读写事件。在发起异步读写请求时,需要传入用户缓冲区的地址等信息,这样内核才可以自动帮我们完成数据的数据工作,这里的读写工作全部交给操作系统来做,不需要像Reactor那样同步等待,操作系统完成数据读写的操作后,就会通知应用进程直接处理数据。
Reactor模式是基于待完成的IO事件,而Proactor模式是基于已完成的IO事件。
(1)Proactor Initiator负责创建Proactor和Handler对象,并将这两个对象注册到内核;
(2)Asynchronous Operation Processor负责处理注册请求,并处理IO操作;
(3)Asynchronous Opetation Processor完成IO操作后通知Proactor;
(4)Proactor根据不同的事件类型回调不同的Handler进行业务处理;
(5)Handler完成业务处理;
7.9说说IO多路复用机制
如果每个请求分配一个进程/线程去处理的方式不合适,那IO多路复用技术可以使用一个进程来维护多个Socket。一个进程虽然任一时刻只能处理一个请求,但是处理每个请求的事件时,耗时控制在1毫秒以内,这样一秒内就可以处理上千个请求,把时间拉长来看,多个请求复用了一个进程,这就是多路复用。这种思想也叫时分多路复用。
select/poll/epoll就是内核提供给用户态的多路复用系统调用,进程可以通过一个系统调用函数从内核中获取多个事件。在获取事件前,先把所有连接(文件描述符)传给内核,再由内核返回产生了事件的连接,然后在用户态中再处理这些连接对应的请求即可。
【select/poll】
select实现多路复用的方式是,将已连接的Socket都放到一个文件描述符集合,然后调用select函数拷贝到内核中,让内核来检查是否有网络事件产生,检查的方式就是通过遍历文件描述符数组,当检查到有事件产生后,将此Socket标记为可读或可写,接着再把整个文件描述符拷贝回用户态,然后用户态需要再通过遍历的方法找到可读或可写的Socket,然后再对其进行处理。所以select这种方式,需要进行2次遍历文件描述符集合,一次在内核里,一次是在用户态里;而且还会发生2次拷贝文件描述符集合,先从用户空间传入到内核空间,由内核空间修改后,再传入到用户空间。
select使用固定长度的BitsMap,表示文件描述符的集合,而且所支持的文件描述符的个数是有限制的。在Linux系统中,由内核的fd_setsize限制,默认最大值为1024,只能监听0-1023的文件描述符。
poll不再使用BitsMap来存储所关注的文件描述符,而是用的动态数组,以链表形式来组织,突破select的文件描述符集合容量的限制,但是当然还是会受到系统文件描述符限制。
select和poll并没有太大的本质区别,都是使用的线程结构存储进程关注的Socket集合,因此都需要遍历文件描述符集合来找到可读或可写的Socket,时间复杂度是O(N),而且也需要在用户态与内核态之间拷贝文件描述符集合。随着并发数上来,性能的损耗会呈指数级增长。
【epoll】
epoll通过两个方面,很好得解决了select/poll的问题。
(1)epoll在内核里使用红黑树来跟踪进程所有待检测的文件描述符,把需要监控的socket通过epoll_ctl()函数加入到内核中的红黑树中,红黑树是个高效的数据结构,增删查的时间复杂度是O(logN),通过对这颗红黑树进行操作,就不需要像select/poll那样每次都传入整个socket集合,只需要传入一个待检测的socket,减少内核与用户空间大量的数据拷贝和内存分配。
(2)epoll使用了事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个socket有事件发生时,通过回调函数内核会将其加入到这个就绪事件队列中,当用户调用epoll_wait()函数时,只会返回有事件发生的文件描述符的个数,不需要像select/poll那样轮询描扫描整个socket集合,大大提高了监测的效率。
epoll的方式即使监听的Socket数量多的时候,效率也不会大幅度降低,能够同时监听的Socket的数目也非常多了,上限是系统定义的为进程开启的最大文件描述符个数。
epoll支持两种事件触发模式,分别是边缘触发和水平触发;
(1)边缘触发模式:当被监听的Socket上有可读等事件发生时,服务端只会从epoll_wait中苏醒一次,即使进程没有调用read函数从内核读取数据,也依然只苏醒一次,因此我们程序要保证一次性将内核缓冲区的数据读取完;
(2)水平触发模式:当被监听的Sokcet上有可读等事件触发时,服务端不停地从epoll_wait中苏醒,直到内核缓冲区数据被read函数读完才结束,目的就是告诉我们有数据需要读取。
如果使用边缘触发的方式,IO事件发生时只会通知一次,所以在收到通知后应该尽可能地读写数据,以避免错失读写的机会。因此,我们会循环从内核缓冲区读数据,直到读取完。因为如果没有读取完这些数据的话,只能等到下一次的边缘触发时才能继续读数据了。如果文件描述符是阻塞的,没有数据可读写时,进程会阻塞在读写函数那里,程序就没办法继续往下执行。所以,边缘触发模式一般和非阻塞IO搭配使用,程序会一直执行IO操作,直到系统调用返回错误。
另外,边缘触发的效率比水平触发的效率要高,因为边缘触发可以减少epoll_wait的系统调用次数,系统调用也是有一定开销的,毕竟也存在上下文切换。