跟清华大佬,从内核的角度看IO!

2,992 阅读7分钟

先上下大佬的公开课视频

b23.tv/4XHhPT

准备知识

看我如何把NIO拉下神坛

如何构建可伸缩的高性能网络服务(Reactor编程模式详解)

Hermes

用户态和内核态

在讲IO之前我们需要先了解一下Linux的系统架构。Linux的设计哲学之一就是对不同的操作赋予不同的操作权限,也就是特权模式。即与系统相关的一些关键操作必须由最高特权级别的程序来操作。例如:CPU资源,存储资源,IO资源等。

Intel的X86架构的CPU提供了0到3四个特权级,数字越小,特权越高。Linux操作系统中主要采用了0和3两个特权级,分别对应的就是内核态(Kernel Mode)与用户态(User Mode)。

计算机内核

用户态:只能受限的访问内存,不能访问外围设备(磁盘,网卡),不允许进程切换。

内核态:CPU可以访问内存所有数据,包括外围设备(硬盘、网卡),允许切换进程。

Linux中任何一个用户进程被创建时都包含2个栈:内核栈、用户栈,并且是进程私有的,进程从用户态开始运行。当进程在执行用户自己的代码时,则称其处于用户运行态(用户态)。即此时处理器在特权级最低的(3 级)用户代码中运行。当一个任务(进程)执行系统调用而陷入内核代码中执行时,我们就称进程处于内核运行态(或简称为内核态)。此时处理器处于特权级最高的(0 级)内核代码中执行。当进程处于内核态时,执行的内核代码会使用当前进程的内核栈。

用户态到内核态的切换

  • 系统调用(软中断)

    所有用户程序都是运行在用户态的,但是有时候程序确实需要做一些内核态的事情,例如从硬盘读取数据等。而唯一可以做这些事情的就是操作系统,所以此时程序就需要先操作系统请求以程序的名义来执行这 操作。这时需要一个这样的机制:用户态程序切换到内核态,但是不能控制在内核态中执行的指令。这种机制叫系统调用,在CPU中的实现称之为陷阱指令(Trap Instruction)。

  • 外围设备的中断(硬中断)

    当外围设备完成用户的请求操作后,会像CPU发出中断信号,此时,CPU就会暂停执行下一条即将要执行的指令,转而去执行中断信号对应的处理程序,如果先前执行的指令是在用户态下,则自然就发生从用户态到内核态的转换。

  • 异常事件

  • 当CPU正在执行运行在用户态的程序时,突然发生某些预先不可知的异常事件,这个时候就会触发从当前用户态执行的进程转向内核态执行相关的异常事件,典型的如缺页异常。

用户空间的应用程序,通过系统调用,进入内核空间。这个时候用户空间的进程要传递很多变量、参数的值给内核,内核态运行的时候也要保存用户进程的一些寄存器值、变量等。所谓的“进程上下文”,可以看作是用户进程传递给内核的这些参数以及内核要保存的那一整套的变量和寄存器值和当时的环境等。

硬件通过触发信号,导致内核调用中断处理程序,进入内核空间。这个过程中,硬件的一些变量和参数也要传递给内核,内核通过这些参数进行中断处理。所谓的“中断上下文”,其实也可以看作就是硬件传递过来的这些参数和内核需要保存的一些其他环境(主要是当前被打断执行的进程环境)。

可以看出用户态到内核态的切换是有相当大的开销的。

回到文章的开头,如果我们的应用程序要通过socket读取网络中的数据,必然涉及到了通过系统调用(read函数),由用户态切换到内核态,由内核程序读取网卡中的数据,再回到用户态的过程。

从内核看IO

step1

使用strace -ff -o ./log java BioServer命令启动java程序,strace命令可以帮助我们跟踪程序运行期间的所有系统调用。

public class BioServer {


    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket(8091);
        System.out.println("step1: bind 8091");
        while (true) {
            Socket socket = serverSocket.accept();
            System.out.println("step2: accept " + socket.getPort());
            new Thread(() -> {
                try (BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
                     PrintWriter out = new PrintWriter(socket.getOutputStream(), true)) {
                    String line;
                    while ((line = reader.readLine()) != null) {
                        System.out.println(line);
                        out.println("Server recv:" + line);
                    }
                } catch (Exception e) {

                }
            }).start();
        }

    }
}

查看strace打印的日志,socket 5 调用了内核函数bind和listen,然后执行了标准输出 1,最终执行poll函数阻塞。

step2

  1. jps查看BioServer的pid

  1. /proc/pid/fd目录下存放了该进程的所有fd(文件描述符)

  • 0:标准输入
  • 1:标准输出
  • 2:错误输出
  • 3:jdk库
  • 4、5:ipv4、ipv6的socket
  1. /proc/pid/task目录存放了该进程下所有的线程

  1. 使用nc ip port建立两个连接

再次查看pid=27290的进程的文件描述符,确实多了一个为6和7的socket。

  1. 使用netstat -natp命令查看8091端口也建立一个可用的tcp连接。

  1. 查看strace日志

先调用了accept函数建立一个新的socket fd=7,然后调用clone函数,创建一个新的线程,这个线程的pid=7616。

  1. 查看pid=7616的日志

线程7616阻塞在recvfrom(fd=7)函数上。

IO的演变之路

所以BIO的本质是因为系统调用的recvfrom(fd)函数是阻塞的,所以必须使用多线程才能进行socket的连接和读取。

这个模型的问题是什么?创建的线程太多了,本质是因为①内核的recvfrom函数是blocking的

如果recvfrom函数是no blocking的那么就能解决一个线程处理N个客户端的问题。

但是如果只是recvfrom函数非阻塞,如果有百万以上的客户端,每循环一次,每个soekct都会调用一次recvfrom函数,②不管这个socket有没有数据可读,都会发生用户态到内核态的切换(system call),时间复杂度是O(n)

如果能只把发生了可读可写的socket选出来,就可以把时间复杂度降到常数级别。

于是内核提供了一个select的系统调用(多路复用),它可以把发生了可读的socket的文件描述符返回,然后程序再调用recvfrom函数,这样就大大减少了系统调用的次数。

这个模型有什么问题?那就是③每次循环select都会把所有socket的文件描述符传给内核,然后内核会遍历所有的文件描述符

怎么解决这个问题那就是epoll。

epoll模型

应用程序先通过epoll_create系统调用创建一个内存区域,然后通过epoll_ctl将文件描述符添加到开辟的区域中,并且是个accpet事件。客户端连的时候通过网卡向内核发出硬中断。可读的socket将会被应用程序处理。所以epoll的核心就是基于事件驱动。对应在java中就是selectKey。

epoll的两种触发模式

  • 水平触发(LT)

  • 边缘触发(ET)

关于ET和LT的讨论

www.zhihu.com/question/20…

Linux零拷贝技术

blog.csdn.net/linsongbin1…

www.ibm.com/developerwo…

参考:

segmentfault.com/a/119000001…

b23.tv/4XHhPT