操作系统之网络系统篇

目录

一、DMA技术

1.1、为什么要有DMA技术

1.2、传统的文件传输

1.3、如何优化文件传输的性能

1.4、PageCache 有什么用

1.5、大文件的传输方式

二、I / O 多路复用

2.1、select/poll

2.2、epoll

三、高性能网络模式:Reactor 和 Proactor

3.1、Reactor

3.1.1、单 Reactor 单进程 / 线程

3.1.2、单 Reactor 多线程 / 进程

3.1.3、多 Reactor 多进程 / 线程模式

3.2、Proactor


一、DMA技术

1.1、为什么要有DMA技术

在没有 DMA 技术之前,I/O 的过程是这样的:

  1. CPU 发出对应的指令给磁盘控制器,然后返回
  2. 磁盘控制器收到指令后,于是开始准备数据,会把数据放入磁盘控制器的内部缓冲区中,然后产生一个中断
  3. CPU 收到中断信号之后,停下手头的工作,接着把磁盘控制器的缓冲区的数据一次一个字节地读进自己的寄存器,然后再把寄存器里的数据写入到内存中,而在数据传输的期间 CPU 是无法执行其他任务的

DMA技术就是在进行 IO 设备和内存的数据传输的时候,数据搬运的工作全部交给 DMA 控制器,而 CPU 不再参与任何与数据搬运相关的事情,这样 CPU 就可以去处理别的事物。

使用 DMA 控制器进行数据传输的过程究竟是怎么样的呢?

  1. 用户进程调用 read 方法,向操作系统发出 IO 请求,请求读取数据到自己的内存缓冲区中,进程进入阻塞状态
  2. 操作系统收到请求后,进一步将 IO 请求发送 DMA ,然后让 CPU 执行其他任务
  3. DMA 进一步将 IO 请求发送给硬盘
  4. 磁盘收到 DMA 的 IO 请求,把数据从磁盘读取到磁盘控制器的缓冲区中,当磁盘控制器的缓冲区被读满后,向 DMA 发起中断信号,告知自己缓冲区已满
  5. DMA 读取了足够多的数据,就会发送中断信号给 CPU
  6. CPU 收到 DMA 信号,知道数据已经准备好,于是将数据从内核拷贝到用户空间,系统调用返回

可以看到 CPU 不再参与【将数据从磁盘控制器缓冲区搬运到内核空间】的工作,这部分工作全程由 DMA 完成。

1.2、传统的文件传输

传统的 IO 的工作方式是,数据读取和写入是从用户空间到内核空间来回复制,而内核空间的数据是通过操作系统层面的 IO 接口从磁盘读取或写入。如图:

操作系统之网络系统篇_第1张图片

首先此期间共发生了 4 次用户态与内核态的上下文切换,因为发生了两次系统调用,一次是 read()一次是 write() ,每次系统调用都得从用户态切换到内核态,等内核完成任务后,再从内核态切换到用户态。上下文切换的成本并不小,一次切换就需要耗时几十纳秒到几微秒,虽然时间看上去短,但是在高并发的场景下,这类时间容易被积累,从而影响系统性能。

在传输期间共发生了四次拷贝,其中两次是 DMA 拷贝,另外两次是通过 CPU 访问:

  1. 第一次拷贝:把磁盘上的数据拷贝到操作系统内核的缓冲区,这个拷贝的过程是通过 DMA 搬运
  2. 第二次拷贝:把内核缓冲区的数据拷贝到用户的缓冲区里,于是我们应用程序就可以使用这部分数据了,这个拷贝的过程是由 CPU 完成的
  3. 第三次拷贝:把刚才拷贝到用户的缓冲区里的数据,再拷贝到内核的 socket 的缓冲区里,这个过程依然还是由 CPU 搬运
  4. 第四次拷贝:把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区,这个过程又是由 DMA 搬运

所以想要提高文件传输的性能,就需要减少【用户态与内核态的上下文切换】和【内存拷贝】的次数

1.3、如何优化文件传输的性能

  1. 第一步,通过 DMA 将磁盘上的数据拷贝到内核缓冲区中
  2. 第二布,缓冲区描述符和数据长度传到 socket 缓冲区,这样网卡的 SG-DMA 控制器就可以直接将内核缓存的数据拷贝到网卡的缓冲区,此过程不需要将数据从操作系统内核缓冲区拷贝到 socket 缓冲区中,这样就减少了一个数据拷贝

这就是所谓的零拷贝技术,因为我们没有在内存的层面去拷贝数据,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行运输的。

零拷贝技术的文件传输相比传统文件传输的方式,减少了两次上下文切换和数据拷贝次数,只需要两次上下文切换和数据拷贝次数,就可完成文件的传输,而且两次的数据拷贝过程都不需要通过 CPU ,两次都是由 DMA 来搬运,总体上看,零拷贝技术可以把文件传输的性能提高至少一倍以上。

1.4、PageCache 有什么用

回顾前面说到文件传输过程,其中第一步都是先需要把磁盘文件数据拷贝【内核缓冲区】里,这个【内核缓冲区】实际上是磁盘告诉缓存。

如果零拷贝使用了 PageCache 技术,可以使得零拷贝进一步提升性能。

我们都知道读写磁盘相比读写内存速度慢太多了,所以我们会通过 DMA 把磁盘里的数据搬运到内存里,这样就可以用读内存替换读磁盘,但是内核空间远比磁盘要小,内存只能拷贝磁盘里的一小部分数据。程序在运行的时候具有【局部性】,所以通常刚被访问的数据在短时间内再次被访问的概率很高,于是我们可以用 PageCache 来缓存最近被访问的数据,当空间不足时淘汰最久未访问的缓存,所以读取磁盘数据的时候,优先在PageCache 找,如果数据存在则直接返回,没有则从磁盘中读取,然后缓存到 PageCache 中。并且 PageCache还用了预读功能进一步提高读写磁盘的效率。

如果是传输大文件呢?

例如在 GB 级别的文件,Pagecache 会不起作用,那就会白白浪费 DMA 多做出来的一次数据拷贝,造成性能下降,即使使用了 PageCache 的零拷贝也会损失性能。

由于文件太大,某些部分的文件数据被再次访问的概率比较低,这会带来两个问题:

  • PageCache 由于长时间被大量文件占据,其他【热点】的小文件可能就无法充分使用到 PageCache ,于是磁盘读写的性能就会下降了
  • PageCache 中的大文件数据,由于没有享受到缓存带来的好处,但却耗费 DMA 多拷贝到 PageCache 一次

所以针对大文件的传输,不应该使用 PageCache ,也就是说不应该使用零拷贝技术,因为可能由于 PageCache 被大文件占据,而导致【热点】小文件无法利用到 PageCache ,这样在高并发的环境下,会带来严重的性能问题。

1.5、大文件的传输方式

将读操作分为两部分:

  • 前半部分,内核向磁盘发起读操作,但是可以不等待数据就位就可以返回,于是进程此时可以处理其他任务
  • 后半部分,当内核将磁盘中的数据拷贝到进程缓冲区后,进程将接收到内核的通知,再去处理数据

而且,异步 IO 并没有涉及到 PageCache ,所有使用异步 IO 就意味着要绕开 PageCache。

绕开 PageCache 的 IO 叫直接 IO ,使用 PageCache 的 IO 则叫缓存 IO ,通常,对于磁盘,异步 IO 只支持直接 IO。前面也提到大文件传输不能使用 PageCache,于是在高并发的场景下,针对大文件的传输的方式,应该使用【异步 IO + 直接 IO】来替代零拷贝技术。

直接 IO 的应用场景:

  • 应用程序已经实现了磁盘数据的缓存,那么可以不需要 PageCache 再次缓存,减少额外的性能损耗,在 MySQL 数据库中,可以通过参数设置开启直接 IO,默认是不开启的
  • 传输大文件的时候,由于大文件难以命中 PageCache 缓存,而且会占满 PageCache 导致【热点】文件无法充分利用缓存,从而增大了性能开销,因此此时应该使用直接 IO

另外,由于直接 IO 绕过了 PageCache ,就无法享受到内核的两点优化:

  • 内核的 IO 调度算法会缓存尽可能多的 IO 请求在 PageCache 中,最后【合并】成一个更大的 IO 请求再发给磁盘,这样做是为了减少磁盘的寻址操作
  • 内核也会【预读】后续的 IO 请求放在 PagCache 中,一样是为了减少对磁盘的操作

二、I / O 多路复用

在 TCP 连接的过程中,服务器的内核实际上未每个 Socket 维护了两个队列:

  • 一个是【还没完全建立】连接的队列,称为 TCP 半连接队列,这个队列都是没有完成三次握手的连接,此时服务端处于 syn_rcvd 的状态
  • 一个是【已经建立】连接的队列,称为 TCP 全连接队列,这个队列都是为了完成三次握手的连接,此时服务端处于 established 状态

只使用一个进程来维护多个 socket ,这个就是 IO 多路复用

IO多路复用适用场景:

  1. 当客户处理多个描述符时(一般是交互式输入和网络套接口),必须使用 IO 复用
  2. 当一个客户同时处理多个套接口时,这个情况是可能的,但是很少见
  3. 如果一个 TCP 服务器既要处理监听套接口,又要处理已连接套接口,一般也要用到 IO 复用
  4. 如果一个服务器既要处理 TCP 又要处理 UDP 一般要用到 IO 复用
  5. 如果一个服务器要处理多个服务或者多个协议,一般要用 IO 复用

2.1、select/poll

select 实现多路复用的方式是,将已连接的 Socket 都放到一个文件描述符集合,然后调用 select 函数将文件描述符集合拷贝到内核中,让内核检查是否有网络事件产生,检查的方式很粗暴,就是通过遍历文件描述符集合的方式,当检查到有事件产生后,将此 Socket 标记为可读或者可写,接着再把整个文件描述符集合拷贝回用户态里。然后用户态还需要通过遍历的方式找到可读或者可写的 Socket ,然后再对其处理。

所以对于 select 这种方式,需要进行 2 次【遍历】文件描述符集合,一次是在内核态里,一次是在用户态里,而且还会发生 2 次【拷贝】文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间。

select 使用固定长度的 BitsMap ,表示文件描述符集合,而且所支持的文件描述符的个数是有限制的,在 Linux 系统中,由内核的 FD_SETSIZE 限制,默认最大值为 1024,只能监听 0~1023 个文件描述符。

poll 不再用 BitsMap 来存储所关注的文件描述符,以链表形式来组织,突破了 select 的文件描述符个数限制,当然还会受到系统文件描述符限制。但是 poll 和 select 并没有太大的区别,都是使用 【线性结构】存储进程关注的 Socket 集合,因此都需要遍历文件描述符集合来找到可读或者可写的 Socket ,时间复杂度为 O(n),而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上来之后,性能的损耗会呈指数级增长。

2.2、epoll

epoll 通过两个方面很好的解决了 select/poll 的问题

①、epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述符,把需要监控的 socket 通过 epoll_ctl() 函数加入到内核中的红黑树里,红黑树是一个高效的数据结构,增删改一般时间复杂度是 O(logn)。而 select/poll 内核里没有类似 epoll 红黑树这种保存所有待检测的 socket 的数据结构,所以 select/poll 每次操作时都需要传入整个 socket 集合给内核,而 epoll 因为在内核维护了红黑树,可以保存所有待检测的 socket ,所以只需要传入待检测的 socket ,减少内核与用户空间大量的数据拷贝和内存分配。

它在内核中维护了一个时间表,并提供了一个独立的系统调用函数 epoll_ctl 来控制住该内核表往该内核表中添加,删除,修改事件。这样,每次调用 epoll_wait() 函数时,都是直接从内核事件表中取得用户注册的事件,而无需反复从用户空间将这些注册事件读取到内核区中,节省了复制的系统开销。epoll_wait() 系统调用中的 events 指针参数仅用来返回就绪的事件,这使得应用程序索引就绪文件描述符的时间复杂度为O(1)。需要注意的是,epoll 与 poll 一样,也是将文件描述符和与其关联的事件是绑定在一起的,好处是程序接口变得简洁。

②、epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪队列中,当用户调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。

边缘触发和水平触发:

epoll 分别支持两种事件触发模式,分别是边缘触发(dege-triggered,ET)和水平触发(level-triggered,LT)。

  • 使用边缘触发模式时,当没被监控的 Socket 描述符上有可读事件发生时,服务端只会从 epoll_wait() 中苏醒一次,即使进程没有调用 read 函数从内核读取数据,也依然只苏醒一次,因此我们程序要保证一次性将内核缓冲区的数据读取完毕。
  • 使用水平触发模式,当被监控的 Socket 上有可读事件发生时,服务端不断地从 epoll_wait 中苏醒,直到内核缓冲区数据被 read 函数读完才结束,目的是告诉我们有数据需要读取

如果使用水平触发模式,当内核通知文件描述符可读写时,接下来还可以继续去检测它的状态,看它是否依然可读或可写。所以在收到通知之后,没必要一次执行尽可能多的读写操作。

如果使用边缘触发模式,IO 事件发生时只会通知一次,而且我们不知道到底能读写多少数据,所以在收到通知后尽可能的读写数据,以免错失读写的机会。因此,我们会循环从文件描述符读写数据,那么如果文件描述符是阻塞的,没有数据可读写时,进程会阻塞在读写函数里,程序就没办法往下执行,所以边缘触发模式一般和非阻塞 IO 搭配使用,程序会一直执行 IO 操作,直到系统调用返回错误,错误类型为 EAGAIN 或 EWOULDBLOCK。

一般来说,边缘触发的效率比水平触发的效率要高,因为边缘触发可以减少 epoll_wait() 的系统调用次数,系统调用也是有一定的开销的,毕竟也存在上下文的切换。

select/poll 只有水平触发模式,epoll 默认的是水平触发,但是可以减少 epoll_wait() 的系统调用次数,系统调用也是由一定的开销的,毕竟也存在上下文切换。

使用 IO 多路复用的时候,最好和非阻塞 IO 一起使用,因为多路复用 API 返回的时间并不一定是可读写的,如果使用阻塞 IO,那么在调用 read/write 时则会发生程序阻塞,因此最好搭配非阻塞 IO,以便应对极少数的特殊情况。

三、高性能网络模式:Reactor 和 Proactor

3.1、Reactor

Reactor 模式是灵活多变的,可以应对不同的应用场景,灵活在于:

Reactor 的数量可以只有一个,也可以有多个

处理资源池可以是单个进程 / 线程,也可以是多个进程 / 线程,将此两种情况排列组合一下,理论上就有 4 种方案:

  • 单 Reactor 单进程 / 线程
  • 单 Reactor 多进程 / 线程
  • 多 Reactor 单进程 / 线程
  • 多 Reactor 多进程 / 线程

其中,【多 Reactor 单进程 / 线程】实现方案相比【单 Reactor 单进程 / 线程】方案,不仅复杂而且也没有性能优势,因此在实际中并没有应用。

3.1.1、单 Reactor 单进程 / 线程

直接上图:

操作系统之网络系统篇_第2张图片

进程里面有 Reactor、Acceptor、Handler 这三个对象:

  • Reactor 对象的作用是监听和分发事件
  • Acceptor 对象的作用是获取连接
  • Handler 对象的作用是处理业务

简单介绍一下【单 Reactor 单进程】这个方案:

  1. Reactor 对象通过 select(IO 多路复用接口)监听事件,收到事件后通过 dispatch 进行分发,具体分发给 Acceptor 对象还是 Handler 对象,还要看收到的事件类型
  2. 如果是连接建立的事件,则交由 Acceptor 对象进行处理,Acceptor 对象会通过 accept 方法获取连接,并创建一个 Handler 对象来处理后续的响应事件
  3. 如果不是连接建立事件,则交由当前连接对应的 Handler 对象来进行响应
  4. Handler 对象通过 read -> 业务处理 -> send 的流程来完成完整的业务流程

单 Reactor 单进程的方案会因为全部工作都在同一进程内完成,所以实现起来比较简单,不需要考虑进程间的通信,也不用担心多进程竞争。但是存在两个缺点:

  1. 因为只有一个进程,无法充分利用多核 CPU 的性能
  2. Handler 对象在业务处理时,整个进程是无法处理其他连接事件的,如果业务处理耗时较长,那么就造成响应的延迟

所以,单 Reactor 单进程的方案不适用于计算机密集型的场景,只适用于业务处理非常快速的场景。

3.1.2、单 Reactor 多线程 / 进程

操作系统之网络系统篇_第3张图片

简单说一下这个方案:

  1. Reactor 对象通过 select (IO 多路复用接口)监听事件,收到事件后通过 dispatch 进行分发,具体分发给 Acceptor 对象还是 Handler 对象,还要看收到的事件类型。
  2. 如果是建立连接的事件,则交由 Acceptor 对象进行处理,Acceptor 对象会通过 accept 方法获取丽娜姐,并创建一个 Handler 对象来处理后续的响应事件
  3. 如果不是连接建立事件,则交由当前连接对应的 Handler 对象来进行响应

上三个步骤与 单 Reactor 单线程的方案是一致的,接下来的就不同了:

  1. Handler 对象不再负责业务处理,只负责数据的接受和发送,Handler 对象通过 read 读取到数据后,会将数据发给子线程里的 Processor 对象进行业务处理
  2. 子线程的 Processor 对象就进行业务处理,处理完毕后,将结果发给主线程中的 Handler 对象,接着由 Handler 通过 send 方法将响应的结果发送给 client

单 Reactor 多线程的方案优势在于能够充分利用多核 CPU ,但是也带来了多线程竞争的资源的问题。

例如:子线程完成业务处理后,要把结果传递给主线程的 Handler 进行发送,这里涉及共享数据的竞争。要避免多线程由于竞争共享资源而导致数据错乱的问题,就需要在操作共享资源前加上互斥锁,以保证任意时间里只有一个线程在操作共享资源,待该线程操作完释放互斥锁后,其他线程才有机会操作共享数据。

3.1.3、多 Reactor 多进程 / 线程模式

操作系统之网络系统篇_第4张图片

方案如下:

主线程中的 MainReactor 对象通过 select 监控连接建立事件,收到事件后通过 Acceptor 对象中的 accept 获取连接,将新的连接分配给某个子线程

子线程中的 SubReactor 对象将 MainReactor 对象分配的连接加入 select 继续进行监听,并创建一个 Handler 用于处理连接的响应事件

如果有新的事件发生时,SubReactor 对象会调用当前连接对应的 Handler 对象来进行响应

Handler 对象通过 read -> 业务处理 -> send 的流程完成完整的业务流程

多 Reactor 多线程的方案虽然看起来是复杂的,但是实际实现时比单 Reactor 多线程的方案要简单得多,原因如下:

主线程和子线程分工明确,主线程只负责接受新连接,子线程负责完成后续的业务处理

主线程和子线程的交互很简单,主线程只需要把新连接传给子线程,子线程无法返回数据,直接就可以在子线程将处理结果发送给客户端

3.2、Proactor

Reactor 是非阻塞同步网络模式,而 Proactor 是异步网络模式

Reactor 是非阻塞同步网络模式,感知的是就绪可读写事件。在每次感知到有事件发生(比如可都就绪事件)后,就需要应用进程主动调用 read 方法来完成数据的读取,也就是要应用进程主动将 socket 接收缓存中的数据读到应用进程内存中,这个过程是同步的,读取完数据后应用进程才能处理数据

Proactor 是异步网络模式,感知的是已完成的读写事件。在发起异步读写请求的时候,需要传入数据缓冲区的地址(用来存放结果数据)等信息,这样系统内核才可以自动帮我们把数据的读写工作完成,这里的读写工作全程由操作系统来做,并不需要像 Reactor 那样还需要应用进程主动发起 read / write 来读写数据,操作系统完成读写工作后,就会通知应用进程直接处理数据

因此,Reactor 可以理解为【来了事件操作系统通知应用进程,让应用进程来处理】,而 Proactor 可以理解为【来了事件操作系统来处理,处理完再通知应用进程】。这里的【事件】就是有新连接、有数据可读、有数据可写的这些 IO 事件这里的【处理】包含从驱动读取到内核以及从内核读取到用户空间。

举个生动的:Reactor 模式就是快递员在楼下,给你打电话告诉你快递到你家小区了,你需要自己下楼来拿快递。而在 Proactor 模式下,快递员直接将快递送到你家门口,然后通知你。

无论是 Reactor 模式还是 Proactor 模式,都是一种基于【事件分发】的网络编程模式,区别在于 Reactor 模式是基于【待完成】的 IO 事件,而 Proactor 模式则是基于【已完成】的 IO 事件

示意图如下:

操作系统之网络系统篇_第5张图片

介绍一下 Proactor 模式的工作流程:

  1. Proactor Initiator 负责创建 Proactor 和 Handler 对象,并将 Proactor 和 Handler 都通过 Asynchronous Operation Processor 注册到内核
  2. Asynchronous Operation Processor 负责处理注册请求,并处理 IO 操作
  3. Asynchronous Operation Processor 完成 IO 操作之后通知 Proactor
  4. Proactor 根据不同的事件类型回调不同的 Handler 进行业务处理
  5. Handler 完成业务处理

可惜的是,在 Linux 下的异步 IO 是不完善的,aio 系列函数是由 POSIX 定义的异步操作接口,不是真正的操作系统级别支持的,而是用户空间模拟出来的异步,并且仅仅支持基于本地文件的 aio 异步操作,网络编程中的 socket 是不支持的,这也使得基于 Linux 的高性能网络程序都是使用 Reactor 方案。

而 Windows 里实现了一套完整的支持 socket 的异步编程接口,这套接口就是 IOCP ,是由操作系统级别实现的异步 IO,真正意义上异步 IO,因此在 Windows 里实现高性能网络程序可以使用效率更高的 Proactor 方案。

你可能感兴趣的:(linux,ubuntu,windows,centos)