网络面试题深究——从多路复用到Reactor

Socket编程

基本流程复习

对于非科班出身的同学,可能仅仅从面经入手,对TCP,HTTP问题倒背如流,结果一问抓包没干过,一问socket编程三不知,这样会给面试官留下「只会背八股、没有真实践」的现象。想要深入了解传输层相关问题,肯定还是要理解到socket编程层面才足够。

socket这个名词可能大家都听说过,直接搜谷歌socket,会弹出来一堆插头的照片;如果你在港区(doge),你还会发现旁边直接就有「网络插座」几个大字。这个解释可以说是很形象了,socket就好比插座,分别在两端负责接收传输来的数据。

TCP和UDP的网络编程步骤是不一样的,先上两张图,有个大致的体感:

TCP:

 UDP:

可见TCP要建立一个网络连接,相比UDP有更多的步骤,这种区别可以和它们的协议内容很好地对应上:

UDP利用IP协议提供「无连接」的通信服务,一个UDP连接只会维护源端口号&目标端口号,而TCP需要维护「四元组」(源IP地址、源端口、目标IP地址、目标端口),因为这样才能唯一地确定一个连接。(TCP连接由socket、序列号、窗口大小三部分组成,其中socket指的就是IP地址+端口号)。UDP仅仅需要端口号就可能进行传输,也为它在QUIC协议中可以通过「连接ID」进行连接迁移,不需要重新握手做了铺垫。

可以看到UDP Server在开始准备接收UDP数据也有bind这个步骤,声明自己要接受来自哪个端口的信息;然后直接就开始传输数据了。

对于TCP而言,从开启一个socket到开始传输数据主要有以下步骤:

  • 服务端和客户端初始化监听socket,得到文件描述符;
  • 服务端调用bind,将监听socket绑定在指定的IP地址和端口(给监听socket里面特定地址赋值);
  • 服务端调用listen,给监听socket创建半连接队列(哈希表)和全连接队列(链表);
  • 「当客户端调用connect建立连接、握手完成&连接建立之后」,服务端调用accept将连接socket取出来;
  • 客户端向指定socket write数据然后发送,服务端拿着accept取出来的socket进行read读取,之后的数据交流就是双方分别调用read/write进行交流
  • 最后四次挥手、close关闭连接

大致流程为:

落到C语言socket编程的层面可以直接google「linux socket基本操作」,有很多文章介绍得很详细。其中要特别注意的一点就是:服务端在客户端插手之前所建立起来的socket是监听socket,服务器会给每一个监听socket分配半连接队列和全连接队列,来管理即将要到来的客户端连接请求;当请求被服务端收到之后,服务端会给每一个创建好的请求分配一个socket,我们称为连接socket,来代表四元组的真正连接,然后让这个连接信息在半连接队列和全连接队列中转移。

socket常见面试题

到此为止,我们可以引出超多的TCP面试题,我们简单过一遍:

  • TCP和UDP可以绑定同一个端口吗?
    • 不同层根据不同的数据来定位目标,数据链路层是mac地址、网络层是IP地址、传输层是端口号;所以传输层的端口号作用是为了区分同一个主机上不同程序的数据包;而TCP和UDP在内核中是两个完全独立的软件模块;通俗来讲,虽然TCP和UDP可能终点名字一样,但它们本身赛道都不一样,根本不会影响
    • 细致一点讲,当主机收到数据包后,可以在IP头的「协议号」字段知道数据包是TCP/UDP,根据这个信息送给对应的模块处理,送给了对应模块的报文根据「端口号」确定送给哪个应用程序处理。

  • TCP里,只bind没listen,客户端能发送信息吗?能建立TCP连接吗?没有accept呢?
    • 首先我们要知道bind和listen到底做了什么:bind是指定了TCP里传输到底用哪个端口,而listen创建了这个端口对应的半连接队列和全连接队列,而且listen过程会把该socket放入一个hash表中进行维护,客户端调用connect发起连接的时候,服务端会到这个hash表里面寻找,如果找不到就会发送RST报文来中止这个连接了;只bind没listen代表没有建立相应的半连接队列和全连接队列,也没有把对应的socket放入hash,客户端自然无法建立连接。
    • 但是TCP连接还是可以建立的,方法是:客户端自己连接自己,或者两个客户端同时向对方发出请求建立连接(TCP同时打开)
    • 因为服务端不调用listen没法建立连接,本质上就是listen_hash中没保存对应端口的监听socket;而在客户端,不需要调用listen,调用connect的时候客户端会将自己的连接信息放入一个全局hash,然后将消息发出,消息在经过回环地址重新回到TCP传输层的时候,就会根据IP + 端口信息,再一次从这个全局hash中取出消息。于是握手包一来一回,最后成功建立连接
    • 没有accept则不太相同。accept的作用是从全连接队列的头部取一个socket进行处理(数据读写),所以只是一个pop的行为,与连接建立本身没有关系;当socket进入全连接队列,也就是进入ESTABLISHED状态,连接过程就已经结束了。

  • TCP里的半连接队列和全连接队列大小怎样决定?怎么观察大小?满了会怎么样?
    • 首先我们要知道两个队列本质是什么:半连接队列收留状态在SYN_RCVD的sock,本质上是一个哈希表;而全连接队列收留状态刚刚进入ESTABLISHED的sock,本质上是一个链表。

  • 至于为什么这样设计:全连接链表中放的都是已建立完成的连接,这些连接正在等待被取走。而服务端取走连接的过程中,并不关心具体是哪个连接,直接从队列头取就行;而半连接队列里面都是不完整的连接,第三次握手的到来的时候,要找到对应客户端IP端口的sock找出来才行,为了不遍历我们就用hash。
  • 半连接队列的最大值是max_qlen_log变量,max_qlen_log是这样得来的:

  • 全连接队列的最大值是sk_max_ack_backlog变量,sk_max_ack_backlog实际上是在listen()源码里指定的,也就是min(somaxconn, backlog)
  • 全连接队列或半连接队列满了都可能存在丢弃数据包的情况,由于队列长度而丢包的情况有以下几种:
    • 如果半连接队列满了,并且没有开启tcp_syncookies,则会丢弃;
    • 如果没有开启tcp_syncookies,并且max_syn_backlog减去 当前半连接队列长度小于(max_syn_backlog>>2),则会丢弃
    • 若全连接队列满了,且没有重传SYN + ACK包的连接请求多于1个,则会丢弃;
  • 观察队列大小:
    • 全连接队列大小用ss -lnt,其中Send-Q指全连接队列的最大值,Recv-Q指当前全连接队列的使用值。当上面两个值很相近的时候代表可能溢出了,可以使用「netstat -s | grep overflowed」,是历史发生溢出的次数;配合watch -d ,每两秒自动执行,可以看到正在发生的溢出操作
    • 半连接队列没有命令可以直接查看到,但因为半连接队列里,放的都是SYN_RECV状态的连接,那可以通过统计处于这个状态的连接的数量,间接获得半连接队列的长度。
# netstat -nt | grep -i '127.0.0.1:8080' | grep -i 'SYN_RECV' | wc -l
0
  • 当队列里的半连接不断增多,最终也是会发生溢出,可以通过下面的命令查看。
# netstat -s | grep -i "SYNs to LISTEN sockets dropped" 
    26395 SYNs to LISTEN sockets dropped
  • 同样建议配合watch -d 命令使用。
# watch -d 'netstat -s | grep -i "SYNs to LISTEN sockets dropped"'
Every 2.0s: netstat -s | grep -i "SYNs to LISTEN sockets dropped"       
Fri Sep 17 08:36:38 2021

    26395 SYNs to LISTEN sockets dropped

面试题过完,我们回到socket编程的主线上:

看完socket编程的过程后,有没有感觉读写socket的方式好像读写文件?对于linux而言:一切皆文件,在内核中socket也是以文件形式存在的,也有对应的文件描述符(这在刚才的面试题中已经反复提过了)

多路复用

为什么多路复用?

背过计网八股的同学都知道:TCP是面向连接的、可靠的、面向字节流的,其中面向连接就代表着,使用TCP传输数据要先建立连接,并且不能广播,只能一对一连接。又因为我们使用的是同步阻塞的方式,当服务端还没处理完一个客户端的网络I/O时,或者读写操作发生阻塞时,其他客户端是无法与服务端连接的。这样太浪费资源了,我们可不可以想办法让其不阻塞呢?

很容易想到的就是用并发来解决:

  • 多进程:我们可以考虑让父进程来accept,然后fork出子进程来read&write;fork这个过程会创建出来一个子进程,把父进程所有相关的东西都复制一份,包括文件描述符、内存地址空间、程序计数器、执行的代码等等。既然子进程已经有了对应的文件描述符,自然可以控制「连接socket」。这样子进程只关心「已连接socket」,父进程只关心「监听socket」,各司其职,只要注意处理好僵尸进程(子进程退出后记得调用wait&waitpid)。这样的方法缺点太明显了:太重了,fork重,产生新进程很耗资源,进程上下文切换也很重,连接的客户端一多很快就吃不消了。
  • 多线程:主线程负责accept,子线程负责read&write,嫌弃线程创建销毁频繁还可以维护一个线程池,加快速度、节省资源、方便管理。这样是比多进程好,但是连接太多的话也遭不住。

接下来就是我们的主角登场了:I/O多路复用。

有没有可能用一个进程来维护多个socket呢?一个进程虽然任一时刻只能处理一个进程,但是处理每个请求的时间控制在1毫秒内,这样一秒可以处理上千个请求,把时间拉长来看,就是多个请求复用了同一个进程,是不是很熟悉?就是工程上的并发,只是在这个场景中叫「时分多路复用」。

真要八股,马上就进行到select/poll/epoll了,不够透彻,我们学院派一点,先把多路复用讲透。

多路复用的英文是:Multiplexing over a physical trunk,这个技术就是把许多信号在单一的传输线路和用单一的传输设备来进行传输的技术。

主要有「时分多路复用」、「频分多路复用」、「波分多路复用」、「码分多路复用」四种:

  • 时分多路复用:1ms一个请求,1s能处理好多个请求
  • 频分多路复用:将不同波升频到不同的频率段,可以同时在传输线路上传递,最后用滤波器将三种信号分离
  • 波分多路复用:将不同的波根据波段和能量同时传输,在滤波器处进行区分,与频分类似
  • 码分多路复用:用不同的调制方法、选定不同的抽样、量化、编码标准来对波进行不同的解释

我们在编程中可能聊到的更多也叫「时分多路复用」,但是是linux处理TCP连接socket时候的“多路复用”,并不是传输时候的多路复用,所以本质上还是有所区别的。linux中的时分多路复用有三种:

select/poll

将已连接的socket都放到一个文件描述符集合,然后调用select函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生;检查也很粗暴,就是遍历整个文件描述符集合;遍历过程中如果检查到有事件产生,就将此socket标记为可读或者可写,接着再把整个文件描述符集合「拷贝」(你没听错,就是拷贝到内核里然后又拷贝回用户态了)回用户态里,然后用户态还要再通过遍历的方法找到可读或者可写的socket,然后再对其进行处理。

所以,对于select这种方法,我们要进行「2次文件描述符集合的遍历」和「2次文件描述符集合的拷贝」

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

poll不再用位图来存储所关注的文件描述符,取而代之用动态数组,以链表的形式来阻止,突破了select的文件描述符个数限制,当然还会收到系统文件描述符限制

但是poll和select没有太大的本质区别,都是使用「线性结构」存储进程关注的socket集合,因此都需要遍历文件描述符结合来找到可读或者可写的socket,时间复杂度为O(n),而且也需要在用户态与内核态之间拷贝文件描述符集合

epoll

先简单说一下epoll的用法:先用epoll_create创建一个epoll对象epfd,再通过epoll_ctl将需要监视的socket添加到epfd中,最后调用epoll_wait等待数据

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

  • epoll在内核里使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的socket通过epoll_ctl函数加入内核中的红黑树里;检测的时候只需要传入一个待检测的socket(从红黑树取一个),大大减少了内核和用户空间大量的数据拷贝和内存分配
  • epoll使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个socket有事件法僧呢时,通过回调函数内核会将其加入到这个就绪事件列表中;当用户调用epoll_wait函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。

epoll 的方式即使监听的 Socket 数量越多的时候,效率不会大幅度降低,能够同时监听的 Socket 的数目也非常的多了,上限就为系统定义的进程打开的最大文件描述符个数。因而,epoll 被称为解决 C10K 问题的利器。

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

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

这就是两者的区别,水平触发的意思是只要满足事件的条件,比如内核中有数据需要读,就一直不断地把这个事件传递给用户;而边缘触发的意思是只有第一次满足条件的时候才触发,之后就不会再传递同样的事件了。

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

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

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

select/poll 只有水平触发模式,epoll 默认的触发模式是水平触发,但是可以根据应用场景设置为边缘触发模式。

需要注意的是,不仅是epoll的边缘需要搭配非阻塞I/O来使用,linux手册推荐我们I/O多路复用也最好搭配非阻塞I/O来使用;原因也很简单:多路复用api返回的事件不一定是可读写的,有可能是检测有错误然后丢弃的(这种情况下文件描述符也会报告为就绪),这样在调用read/write时则会发生阻塞,因此最好搭配非阻塞 I/O,以便应对极少数的特殊情况。

我们终于聊到非阻塞I/O了,接下来我们看看非阻塞I/O:

IO模型

I/O相关基础知识

谈java网络编程,一个非常非常重要的议题就是nio,意为非阻塞IO。我们从I/O多路复用直接跳到I/O模型未免有点突兀,我们一点点来。

我们刚刚谈到的I/O多路复用和现在谈到的I/O模型其实讲的并不是同一个阶段,「多路复用」谈得更多是连接的管理,一秒可以处理好多好多连接,但是内核把一个socket标记为可读之后,不管是poll还是epoll,都需要在用户态(应用进程)发起对内核的数据请求(这里还涉及零拷贝的相关内容,后续介绍),这时就需要调用linux的read或write的I/O操作。这种I/O操作就不仅仅是网络编程里面有了,对磁盘和其他硬件的输入输出也是一样。

我们不妨再往前探索一点:直接从计算机结构的角度来解读一下I/O:

根据冯.诺依曼结构,计算机结构分为 5 大部分:运算器、控制器、存储器、输入设备、输出设备。

输入设备(比如键盘)和输出设备(比如显示器)都属于外部设备。网卡、硬盘这种既可以属于输入设备,也可以属于输出设备。

输入设备向计算机输入数据,输出设备接收计算机输出的数据。

从计算机结构的视角来看的话, I/O 描述了计算机系统与外部设备之间通信的过程。

我们再从应用程序的角度来解读一下 I/O。

根据大学里学到的操作系统相关的知识:为了保证操作系统的稳定性和安全性,一个进程的地址空间划分为 用户空间(User space) 和 内核空间(Kernel space ) 。

像我们平常运行的应用程序都是运行在用户空间,只有内核空间才能进行系统态级别的资源有关的操作,比如文件管理、进程通信、内存管理等等。也就是说,我们想要进行 IO 操作,一定是要依赖内核空间的能力。

并且,用户空间的程序不能直接访问内核空间。

当想要执行 IO 操作时,由于没有执行这些操作的权限,只能发起系统调用请求操作系统帮忙完成。

因此,用户进程想要执行 IO 操作的话,必须通过 系统调用 来间接访问内核空间

我们在平常开发过程中接触最多的就是 磁盘 IO(读写文件) 和 网络 IO(网络请求和响应)。

从应用程序的视角来看的话,我们的应用程序对操作系统的内核发起 IO 调用(系统调用),操作系统负责的内核执行具体的 IO 操作。也就是说,我们的应用程序实际上只是发起了 IO 操作的调用而已,具体 IO 的执行是由操作系统的内核来完成的。

当应用程序发起 I/O 调用后,会经历两个步骤:

  • 内核等待 I/O 设备准备好数据
  • 内核将数据从内核空间拷贝到用户空间。

根据这两个步骤怎么处理,也就是I/O调用具体怎么实现,可以分为三种I/O模型:

阻塞I/O

等待缓冲区中的数据准备好过后才处理其他的事情,否则一直等待在那里

非阻塞I/O

当我们的进程访问我们的数据缓冲区的时候,如果数据没有准备好则直接返回,不会等待。如果数据已经准备好,也直接返回。这样可以让I/O即使出错(假就绪)也能理解返回,不会浪费等待时间。

异步I/O

同步:应用程序要直接参与 IO 读写,要主动拉取结束状态。

异步:所有的 IO 读写交给操作系统去处理,应用程序只需要等待通知。

Java的NIO

终于聊到java的nio了,我摆一段常用的java nio网络连接的代码在这里,大家其实看看就懂了;现在框架基本都netty起步,没人手写nio了,如果真要用的话,就以看示例代码 + Buffer缓冲区使用规则为主,实践出真知(意思是错的多了你就会了/doge)

public static void polling() {
    ServerSocketChannel serverSocketChannel;
    Selector selector;
    try{
        serverSocketChannel = ServerSocketChannel.open();
        serverSocketChannel.bind(new InetSocketAddress(DEFAULT_PORT));
        serverSocketChannel.configureBlocking(false);
        selector = Selector.open();
        serverSocketChannel.register(selector,SelectionKey.OP_ACCEPT);
    } catch (IOException e) {
        e.printStackTrace();
        return ;
    }

    System.out.println("Listening for connections on port 8888");

    while(true) {
        try{
            selector.selectNow();
        } catch (IOException e) {
            e.printStackTrace();
            break;
        }

        Set<SelectionKey> readyKeys = selector.selectedKeys();
        Iterator<SelectionKey> iterator = readyKeys.iterator();
        while(iterator.hasNext()) {

            SelectionKey key = iterator.next();
            iterator.remove();
            try {
                if(key.isAcceptable()) {
                    ServerSocketChannel server = (ServerSocketChannel) key.channel();
                    SocketChannel client = server.accept();
                    System.out.println("Accepted connection from " + client);
                    client.configureBlocking(false);
                    SelectionKey key2 = client.register(selector,SelectionKey.OP_WRITE | SelectionKey.OP_READ);
                    ByteBuffer input = ByteBuffer.allocate(10240);
                    input.put(ShuaiConstants.WELCOME);
                    input.flip();
                    key2.attach(input);
                    //                        while(!client.finishConnect());
                    client.write(input);
                    input.clear();
                }else if(key.isReadable()) {
                    executor.submit(new ShuaiTask(key));
                    key.interestOps(SelectionKey.OP_WRITE);
                }
            } catch (IOException e) {
                key.cancel();
                try{
                    key.channel().close();
                }catch (IOException ioe) {
                    ioe.printStackTrace();
                }
            }
        }
    }
}

这基本就是用javaNIO维护一个tcp服务端的基本代码了,可见其连接的过程基本就是对之前讲的C语言TCPsocket编程的一层包装:

  • SeverSocketChannel就是服务端的「监听socket」,上来open就是socket()方法,圈一块fd当socket;然后bind一个端口(里面应该也包含了listen方法);
  • Selector是java nio中多路复用对象化的结果,其底层实现可以是select/poll/epoll,每次selectNow就是一次取出声明为就绪的socket给我们的应用程序的过程
  • SelectionKey负责存储这个连接的一些信息以及缓冲区,channel代表与客户端沟通的通道,可以通过channel做出rpc的感觉;
  • 给SelectionKey进行attch的是buffer,java的非阻塞io是面向缓冲区编程的,纯用java nio进行操作你会感觉你光捣鼓缓冲区了

(看水印就知道从哪偷来的图,很好的网站)

零拷贝

我们在上文中提到过:不管什么模型的I/O,都逃不过等待「应用程序(用户态)等待内核将数据复制到应用缓冲区」这一步,是不得不等,那我们能不能让他加快呢?

为什么要有 DMA 技术?

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

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

为了方便你理解,我画了一副图:

可以看到,整个数据的传输过程,都要需要 CPU 亲自参与搬运数据的过程,而且这个过程,CPU 是不能做其他事情的。

简单的搬运几个字符数据那没问题,但是如果我们用千兆网卡或者硬盘传输大量数据的时候,都用 CPU 来搬运的话,肯定忙不过来。

计算机科学家们发现了事情的严重性后,于是就发明了 DMA 技术,也就是直接内存访问(Direct Memory Access) 技术。

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

那使用 DMA 控制器进行数据传输的过程究竟是什么样的呢?下面我们来具体看看。

具体过程:

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

可以看到, CPU 不再参与「将数据从磁盘控制器缓冲区搬运到内核空间」的工作,这部分工作全程由 DMA 完成。但是 CPU 在这个过程中也是必不可少的,因为传输什么数据,从哪里传输到哪里,都需要 CPU 来告诉 DMA 控制器。

早期 DMA 只存在在主板上,如今由于 I/O 设备越来越多,数据传输的需求也不尽相同,所以每个 I/O 设备里面都有自己的 DMA 控制器。

零拷贝技术种类

对于传统的数据传输而言(传统IO),底层通过调用read()和write()来实现:通过read()把数据从硬盘读取到内核缓冲区,再复制到用户缓冲区;然后再通过write()写入socket缓冲区,最后写入网卡设备。

整个过程发生了4次用户态和内核态的上下文切换和4次拷贝,具体流程如下:

  • 用户进程通过read()方法向操作系统发起系统调用,此时陷入内核态
  • DMA控制器把数据从硬盘中拷贝到读缓冲区
  • CPU把读缓冲区数据拷贝到应用缓冲区,上下文从内核态转为用户态,read()返回
  • 用户进程通过write()方法发起系统调用,上下文从用户态转为内核态
  • CPU将应用缓冲区中数据拷贝到socket缓冲区
  • DMA控制器把数据从socket缓冲区拷贝到网卡,上下文从内核态切换回用户态,write()返回

所谓零拷贝技术就是:CPU不需要先将数据从某处内存复制到另一个特定区域,这种技术通常用于通过网络传输文件节省CPU周期和内存宽带。以下是几种零拷贝技术:

  • mmap + write:简单来说就是使用mmap替换了read+write中的read操作,减少了一次CPU的拷贝。mmap主要实现方式是将读缓冲区的地址和用户缓冲区的地址进行映射,内核缓冲区和应用缓冲区共享,从而减少了从读缓冲区到用户缓冲区的一次CPU拷贝。具体发送流程如下:
    • 用户进程通过mmap()方法向操作系统发起调用,上下文从用户态转向内核态
    • DMA控制器把数据从硬盘中拷贝到读缓冲区
    • 上下文从内核态转为用户态,mmap调用返回
    • 用户进程通过write()方法发起调用,上下文从用户态转为内核态
    • CPU将读缓冲区中数据拷贝到socket缓冲区
    • DMA控制器把数据从socket缓冲区拷贝到网卡,上下文从内核态切换回用户态,write()返回
    • mmap的方式节省了一次CPU拷贝,同时由于用户进程中的内存是虚拟的,只是映射到内核的读缓冲区,所以可以节省一半的内存空间,比较适合大文件的传输
  • sendfile:相比mmap来说,sendfile同样减少了一次CPU拷贝,而且还减少了2次上下文切换。
    • sendfile是Linux2.1内核版本后引入的一个系统调用函数,通过使用sendfile数据可以直接在内核空间进行传输,因此避免了用户空间和内核空间的拷贝,同时由于使用sendfile替代了read+write从而节省了一次系统调用,也就是2次上下文切换。具体发送流程如下:
      • 用户进程通过sendfile()方法向操作系统发起调用,上下文从用户态转向内核态
      • DMA控制器把数据从硬盘中拷贝到读缓冲区
      • CPU将读缓冲区中数据拷贝到socket缓冲区
      • DMA控制器把数据从socket缓冲区拷贝到网卡,上下文从内核态切换回用户态,sendfile调用返回
    • sendfile方法IO数据对用户空间完全不可见,所以只能适用于完全不需要用户空间处理的情况,比如静态文件服务器。
  • sendfile+DMA Scatter/Gather:Linux2.4内核版本之后对sendfile做了进一步优化,通过引入新的硬件支持,这个方式叫做DMA Scatter/Gather 分散/收集功能。它将读缓冲区中的数据描述信息--内存地址和偏移量记录到socket缓冲区,由 DMA 根据这些将数据从读缓冲区拷贝到网卡,相比之前版本减少了一次CPU拷贝的过程。具体流程如下:
    • 用户进程通过sendfile()方法向操作系统发起调用,上下文从用户态转向内核态
    • DMA控制器利用scatter把数据从硬盘中拷贝到读缓冲区离散存储
    • CPU把读缓冲区中的文件描述符和数据长度发送到socket缓冲区
    • DMA控制器根据文件描述符和数据长度,使用scatter/gather把数据从内核缓冲区拷贝到网卡
    • sendfile()调用返回,上下文从内核态切换回用户态
    • DMA gather和sendfile一样数据对用户空间不可见,而且需要硬件支持,同时输入文件描述符只能是文件,但是过程中完全没有CPU拷贝过程,极大提升了性能。

PageCache 有什么作用?

回顾前面说道文件传输过程,其中第一步都是先需要先把磁盘文件数据拷贝「内核缓冲区」里,这个「内核缓冲区」实际上是磁盘高速缓存(PageCache)。

由于零拷贝使用了 PageCache 技术,可以使得零拷贝进一步提升了性能,我们接下来看看 PageCache 是如何做到这一点的。

读写磁盘相比读写内存的速度慢太多了,所以我们应该想办法把「读写磁盘」替换成「读写内存」。于是,我们会通过 DMA 把磁盘里的数据搬运到内存里,这样就可以用读内存替换读磁盘。

但是,内存空间远比磁盘要小,内存注定只能拷贝磁盘里的一小部分数据。

那问题来了,选择哪些磁盘数据拷贝到内存呢?

我们都知道程序运行的时候,具有「局部性」,所以通常,刚被访问的数据在短时间内再次被访问的概率很高,于是我们可以用 PageCache 来缓存最近被访问的数据,当空间不足时淘汰最久未被访问的缓存。

所以,读磁盘数据的时候,优先在 PageCache 找,如果数据存在则可以直接返回;如果没有,则从磁盘中读取,然后缓存 PageCache 中。

还有一点,读取磁盘数据的时候,需要找到数据所在的位置,但是对于机械磁盘来说,就是通过磁头旋转到数据所在的扇区,再开始「顺序」读取数据,但是旋转磁头这个物理动作是非常耗时的,为了降低它的影响,PageCache 使用了「预读功能」。

比如,假设 read 方法每次只会读 32 KB 的字节,虽然 read 刚开始只会读 0 ~ 32 KB 的字节,但内核会把其后面的 32~64 KB 也读取到 PageCache,这样后面读取 32~64 KB 的成本就很低,如果在 32~64 KB 淘汰出 PageCache 前,进程读取到它了,收益就非常大。

所以,PageCache 的优点主要是两个:

  • 缓存最近被访问的数据;
  • 预读功能;

这两个做法,将大大提高读写磁盘的性能。

但是,在传输大文件(GB 级别的文件)的时候,PageCache 会不起作用,那就白白浪费 DMA 多做的一次数据拷贝,造成性能的降低,即使使用了 PageCache 的零拷贝也会损失性能

这是因为如果你有很多 GB 级别文件需要传输,每当用户访问这些大文件的时候,内核就会把它们载入 PageCache 中,于是 PageCache 空间很快被这些大文件占满。

另外,由于文件太大,可能某些部分的文件数据被再次访问的概率比较低,这样就会带来 2 个问题:

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

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

大文件传输用什么方式实现?

那针对大文件的传输,我们应该使用什么方式呢?

我们先来看看最初的例子,当调用 read 方法读取文件时,进程实际上会阻塞在 read 方法调用,因为要等待磁盘数据的返回,如下图:

具体过程:

  • 当调用 read 方法时,会阻塞着,此时内核会向磁盘发起 I/O 请求,磁盘收到请求后,便会寻址,当磁盘数据准备好后,就会向内核发起 I/O 中断,告知内核磁盘数据已经准备好;
  • 内核收到 I/O 中断后,就将数据从磁盘控制器缓冲区拷贝到 PageCache 里;
  • 最后,内核再把 PageCache 中的数据拷贝到用户缓冲区,于是 read 调用就正常返回了。

对于阻塞的问题,可以用异步 I/O 来解决,它工作方式如下图:

它把读操作分为两部分:

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

而且,我们可以发现,异步 I/O 并没有涉及到 PageCache,所以使用异步 I/O 就意味着要绕开 PageCache。

绕开 PageCache 的 I/O 叫直接 I/O,使用 PageCache 的 I/O 则叫缓存 I/O。通常,对于磁盘,异步 I/O 只支持直接 I/O。

前面也提到,大文件的传输不应该使用 PageCache,因为可能由于 PageCache 被大文件占据,而导致「热点」小文件无法利用到 PageCache。

于是,在高并发的场景下,针对大文件的传输的方式,应该使用「异步 I/O + 直接 I/O」来替代零拷贝技术。

直接 I/O 应用场景常见的两种:

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

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

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

于是,传输大文件的时候,使用「异步 I/O + 直接 I/O」了,就可以无阻塞地读取文件了。

所以,传输文件的时候,我们要根据文件的大小来使用不同的方式:

  • 传输大文件的时候,使用「异步 I/O + 直接 I/O」;
  • 传输小文件的时候,则使用「零拷贝技术」;

在 nginx 中,我们可以用如下配置,来根据文件的大小来使用不同的方式:

location /video/ { 
    sendfile on; 
    aio on; 
    directio 1024m; 
}

当文件大小大于 directio 值后,使用「异步 I/O + 直接 I/O」,否则使用「零拷贝技术」。

Reactor&Proactor

我们之前讨论的都在Socket编程的范畴中,只有更底层的内容,却没有往上走的内容。你可能觉得你整个spring/dubbo也看不到socket。但是这些框架的网络传输效率都很高,这是归因于I/O模型和多路复用吗?基本是的,但是光用这些,全是面向过程的,编码和维护的效率太低了。这些软件底层的socket,经过面向对象的包装后才进入框架,否则不能称为有很好的扩展性和可读性。这样有人就提出了用面向对象的方法给它包一层,就引出了Reactor模式。

Reactor 模式也叫 Dispatcher 模式,我觉得这个名字更贴合该模式的含义,即 I/O 多路复用监听事件,收到事件后,根据事件类型分配(Dispatch)给某个进程 / 线程。

Reactor 模式主要由 Reactor 和处理资源池这两个核心部分组成,它俩负责的事情如下:

  • Reactor 负责监听和分发事件,事件类型包含连接事件、读写事件;
  • 处理资源池负责处理事件,如 read -> 业务逻辑 -> send;

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

  • Reactor 的数量可以只有一个,也可以有多个;
  • 处理资源池可以是单个进程 / 线程,也可以是多个进程 /线程;

将上面的两个因素排列组设一下,理论上就可以有 4 种方案选择:

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

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

剩下的 3 个方案都是比较经典的,且都有应用在实际的项目中:

方案具体使用进程还是线程,要看使用的编程语言以及平台有关:

  • Java 语言一般使用线程,比如 Netty;
  • C 语言使用进程和线程都可以,例如 Nginx 使用的是进程,Memcache 使用的是线程。

接下来,分别介绍这三个经典的 Reactor 方案。

单 Reactor 单进程 / 线程

一般来说,C 语言实现的是「单 Reactor 单进程」的方案,因为 C 语编写完的程序,运行后就是一个独立的进程,不需要在进程中再创建线程。

而 Java 语言实现的是「单 Reactor 单线程」的方案,因为 Java 程序是跑在 Java 虚拟机这个进程上面的,虚拟机中有很多线程,我们写的 Java 程序只是其中的一个线程而已。

我们来看看「单 Reactor 单进程」的方案示意图:

可以看到进程里有 Reactor、Acceptor、Handler 这三个对象:

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

对象里的 select、accept、read、send 是系统调用函数,dispatch 和 「业务处理」是需要完成的操作,其中 dispatch 是分发事件操作。

接下来,介绍下「单 Reactor 单进程」这个方案:

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

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

但是,这种方案存在 2 个缺点:

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

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

Redis 是由 C 语言实现的,在 Redis 6.0 版本之前采用的正是「单 Reactor 单进程」的方案,因为 Redis 业务处理主要是在内存中完成,操作的速度是很快的,性能瓶颈不在 CPU 上,所以 Redis 对于命令的处理是单进程的方案。

单 Reactor 多线程 / 多进程

如果要克服「单 Reactor 单线程 / 进程」方案的缺点,那么就需要引入多线程 / 多进程,这样就产生了单 Reactor 多线程 / 多进程的方案。

闻其名不如看其图,先来看看「单 Reactor 多线程」方案的示意图如下:

详细说一下这个方案:

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

上面的三个步骤和单 Reactor 单线程方案是一样的,接下来的步骤就开始不一样了:

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

单 Reator 多线程的方案优势在于能够充分利用多核 CPU 的能,那既然引入多线程,那么自然就带来了多线程竞争资源的问题。

例如,子线程完成业务处理后,要把结果传递给主线程的 Handler 进行发送,这里涉及共享数据的竞争。

要避免多线程由于竞争共享资源而导致数据错乱的问题,就需要在操作共享资源前加上互斥锁,以保证任意时间里只有一个线程在操作共享资源,待该线程操作完释放互斥锁后,其他线程才有机会操作共享数据。

聊完单 Reactor 多线程的方案,接着来看看单 Reactor 多进程的方案。

事实上,单 Reactor 多进程相比单 Reactor 多线程实现起来很麻烦,主要因为要考虑子进程 <-> 父进程的双向通信,并且父进程还得知道子进程要将数据发送给哪个客户端。

而多线程间可以共享数据,虽然要额外考虑并发问题,但是这远比进程间通信的复杂度低得多,因此实际应用中也看不到单 Reactor 多进程的模式。

另外,「单 Reactor」的模式还有个问题,因为一个 Reactor 对象承担所有事件的监听和响应,而且只在主线程中运行,在面对瞬间高并发的场景时,容易成为性能的瓶颈的地方。

多 Reactor 多进程 / 线程

要解决「单 Reactor」的问题,就是将「单 Reactor」实现成「多 Reactor」,这样就产生了第 多 Reactor 多进程 / 线程的方案。

老规矩,闻其名不如看其图。多 Reactor 多进程 / 线程方案的示意图如下(以线程为例):

方案详细说明如下:

  • 主线程中的 MainReactor 对象通过 select 监控连接建立事件,收到事件后通过 Acceptor 对象中的 accept 获取连接,将新的连接分配给某个子线程;
  • 子线程中的 SubReactor 对象将 MainReactor 对象分配的连接加入 select 继续进行监听,并创建一个 Handler 用于处理连接的响应事件。
  • 如果有新的事件发生时,SubReactor 对象会调用当前连接对应的 Handler 对象来进行响应。
  • Handler 对象通过 read -> 业务处理 -> send 的流程来完成完整的业务流程。

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

  • 主线程和子线程分工明确,主线程只负责接收新连接,子线程负责完成后续的业务处理。
  • 主线程和子线程的交互很简单,主线程只需要把新连接传给子线程,子线程无须返回数据,直接就可以在子线程将处理结果发送给客户端。

大名鼎鼎的两个开源软件 Netty 和 Memcache 都采用了「多 Reactor 多线程」的方案。

采用了「多 Reactor 多进程」方案的开源软件是 Nginx,不过方案与标准的多 Reactor 多进程有些差异。

具体差异表现在主进程中仅仅用来初始化 socket,并没有创建 mainReactor 来 accept 连接,而是由子进程的 Reactor 来 accept 连接,通过锁来控制一次只有一个子进程进行 accept(防止出现惊群现象),子进程 accept 新连接后就放到自己的 Reactor 进行处理,不会再分配给其他子进程。

Proactor

前面提到的 Reactor 是非阻塞同步网络模式,而 Proactor 是异步网络模式。

  • Reactor 是非阻塞同步网络模式,感知的是就绪可读写事件。在每次感知到有事件发生(比如可读就绪事件)后,就需要应用进程主动调用 read 方法来完成数据的读取,也就是要应用进程主动将 socket 接收缓存中的数据读到应用进程内存中,这个过程是同步的,读取完数据后应用进程才能处理数据。
  • Proactor 是异步网络模式, 感知的是已完成的读写事件。在发起异步读写请求时,需要传入数据缓冲区的地址(用来存放结果数据)等信息,这样系统内核才可以自动帮我们把数据的读写工作完成,这里的读写工作全程由操作系统来做,并不需要像 Reactor 那样还需要应用进程主动发起 read/write 来读写数据,操作系统完成读写工作后,就会通知应用进程直接处理数据。

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

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

无论是 Reactor,还是 Proactor,都是一种基于「事件分发」的网络编程模式,区别在于 Reactor 模式是基于「待完成」的 I/O 事件,而 Proactor 模式则是基于「已完成」的 I/O 事件。

接下来,一起看看 Proactor 模式的示意图:

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

  • Proactor Initiator 负责创建 Proactor 和 Handler 对象,并将 Proactor 和 Handler 都通过 Asynchronous Operation Processor 注册到内核;
  • Asynchronous Operation Processor 负责处理注册请求,并处理 I/O 操作;
  • Asynchronous Operation Processor 完成 I/O 操作后通知 Proactor;
  • Proactor 根据不同的事件类型回调不同的 Handler 进行业务处理;
  • Handler 完成业务处理;

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

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

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值