来聊聊IO阻塞与CPU任务调度

2023-12-14 11:21:13

关于IO阻塞的几个提问

使用多线程时大家是否有下面这些疑问:

  1. 为什么并发的IO任务使用多线程效率更高?
  2. CPU在任务IO阻塞时发生了什么?
  3. CPU切换线程的依据是什么?
  4. 线程休眠有什么用?
  5. 线程休眠1秒后是否会立刻拿到CPU执行权。
  6. 为什么有人代码会用到Thread.sleep(0);它的作用是什么?

操作系统任务调度详解

操作系统的任务调度

我们都知道CPU处理速度很快,多线程情况下CPU会按照操作系统的调度算法有序快速有序的执行任务,使得我们即使开个十几个程序,看上去所有程序仍像是在同时运行一样。

在这里插入图片描述

上文提到CPU执行线程时会按照任务优先级进行处理,一般而言,对于硬件产生的信号优先级都是最高的,当收到中断信号时,CPU理应中断手头的任务去处理硬件中断程序。例如:用户键盘打字输入、收取网络数据包。以用户键盘打字输入为例,从键盘输入到CPU处理的流程为:

  1. 用户在键盘键入一个字母。
  2. 键盘给CPU发送一个中断引脚发送一个高电平。
  3. CPU执行键盘的中断程序,获取键盘的数据。

在这里插入图片描述

同理,获取网络数据包的执行流程为:

  1. 网卡收到网线传来的数据。
  2. 通过硬件电路完成传输。
  3. 将数据写入到内存中的某个地址中。
  4. 网卡发送一个中断信号给CPU。
  5. CPU响应网卡中断程序,从内存中读取数据。

在这里插入图片描述

了解网络数据包获取流程整个流程后,不知道读者是否发现,网卡读取数据期间CPU似乎无需参与工作的,那么操作系统是如何处理这期间的任务调度呢?

IO阻塞的线程会如何避免CPU资源占用

操作系统为了支持多任务,将任务分为了运行、等待、就绪等几种状态,对于运行状态的任务,操作系统会将其放到工作队列中。CPU按照操作系统的调度算法按需执行工作队列中的任务。

在这里插入图片描述

需要注意的是,这些任务能够被CPU时间片完整执行的前提是任务不会发生阻塞。一旦任务或是读取本地文件或者发起网络IO等原因发起阻塞,这些线程任务就会被放到等待队列中,就如图上面所有的收取网络数据包,在网卡读取数据并写入到内存这期间,该任务就是在等待队列中完成的。
只有这些IO任务接受到了完整的数据并通过中断程序发送信号给CPU,操作系统才会将其放到工作队列中,让CPU读取数据。
这也就是IO阻塞避免CPU资源消耗的原因,以及为什么IO任务使用多线程高效的原因所在。

用一个实例了解网络收包的过程

对于上述问题,我们不妨看一段这样的代码,功能很简单,服务端开启9009端口获取客户端输入的信息。

服务端代码如下,逻辑也很清晰,执行步骤为:

  1. 创建ServerSocket 服务器。
  2. 绑定端口。
  3. 阻塞监听等待客户端连接。
  4. 处理客户端发送的数据。
  5. 回复数据给客户端。
public class Server {
    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = null;

        try {
            // 创建服务器 Socket 并绑定 9009 端口
            serverSocket = new ServerSocket(9009);
        } catch (IOException e) {
            System.err.println("Could not listen on port: 9009.");
            System.exit(1);
        }

        Socket clientSocket = null;
        System.out.println("Waiting for connection...");

        try {
            // 等待客户端连接
            clientSocket = serverSocket.accept();
            System.out.println("Connection successful!");
        } catch (IOException e) {
            System.err.println("Accept failed.");
            System.exit(1);
        }

        //输出流
        PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true);

        //输入流
        BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));

        String inputLine;

        while ((inputLine = in.readLine()) != null) { // 不断读取客户端发送的消息
            System.out.println("Client: " + inputLine);
            out.println("Server: Welcome to the server!"); // 向客户端发送欢迎消息
        }

        out.close();
        in.close();
        clientSocket.close();
        serverSocket.close();
    }
}

客户端代码示例如下,执行步骤为:

  1. 连接服务端。
  2. 输入要发送的数据。
  3. 发送数据。
  4. 获取响应。
public class Client {
    public static void main(String[] args) throws IOException {
        Socket socket = null;
        PrintWriter out = null;
        BufferedReader in = null;

        try {
            socket = new Socket("localhost", 8080); // 连接到服务器
            out = new PrintWriter(socket.getOutputStream(), true);
            in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
        } catch (UnknownHostException e) {
            System.err.println("Unknown host: localhost.");
            System.exit(1);
        } catch (IOException e) {
            System.err.println("Couldn't get I/O for the connection to: localhost.");
            System.exit(1);
        }

        BufferedReader stdIn = new BufferedReader(new InputStreamReader(System.in));
        String userInput;

        while ((userInput = stdIn.readLine()) != null) { // 不断从控制台读取用户输入
            out.println(userInput); // 向服务器发送消息
            System.out.println("Server: " + in.readLine()); // 从服务器读取消息并打印到控制台
        }

        out.close();
        in.close();
        stdIn.close();
        socket.close();
    }
}

启动服务端,我们会看到这样一段输出:

Waiting for connection...

并通过客户端发送字符串hello world,服务端的输出结果如下:

Waiting for connection...
Connection successful!
Client: hello world

了解整个流程之后,我们再对细节进行分析。对于服务端的每一个步骤,CPU对应做法如下:

  1. new ServerSocket(9009) 新建由文件系统管理的Socket对象,并绑定9009端口。

在这里插入图片描述

  1. serverSocket.accept();阻塞监听等待客户端连接,此时CPU就会将其放到等待队列中,去处理其他线程任务。

在这里插入图片描述

  1. 客户端发起连接后,服务端网卡收到客户端请求连接,通过中断程序发出信号,CPU收到中断信号后挂起当前执行的线程去响应连接请求。
  2. 服务端建立连接成功,输出Connection successful!
  3. in.readLine()阻塞获取用户发送数据,CPU再次将其放到等待队列中,处理其他非阻塞的线程任务。
  4. 客户端发送数据,网卡接收并将其存放到内存中,通过中断程序发出信号,CPU收到中断信号后挂起当前执行的线程去读取响应数据。
  5. 重复5、6两步。

Thread.sleep()详解

CPU如何处理任务优先级分配

调度算法

上文我们提到过CPU会按照某种调度算法执行进程任务,这里的算法大致分为两种:

  1. 抢占式。
  2. 非抢占式。
抢占式

Windows用的调度算法就是抢占算法,它会在调度前计算每一个线程的优先级,然后按照优先级执行任务,执行任务直到执行到线程主动挂起释放执行权或者CPU察觉到该线程霸占CPU执行时间过长将其强行挂起。
此后会再次重新计算一次优先级,在这期间,那些等待很久的线程优先级就会被大大提高,然后CPU再次找出优先级最高的线程任务执行。
之所以我们称这种算法为抢占式,是因为每次进行重新分配时不一定是公平的。假设线程1第一次执行到期后,CPU重新计算优先级,结果发现还是线程1优先级最高,那么线程1依然会再次获得CPU执行权,这就导致其他线程一直没有执行的机会,极可能出现线程饥饿的情况。

非抢占式

Unix操作系统用的就是非抢占式调度算法,即时间分片算法,它会将时间平均切片,每一个进程都会得到一个平均的执行时间,只有任务执行完分片算法分配的时间或者在执行期间发生阻塞,CPU才会切换到下一个线程执行。因为时间分片是平均的,所以分片算法可以保证尽可能的公平。

Thread.sleep()的作用

上文提到抢占式算法可能导致线程饥饿的问题,所以我们是否有什么办法让长时间霸占CPU的线程主动让CPU重新计算一次优先级呢?
答案就是Thread.sleep()方法,通过该方法让当前线程休眠,进入等待队列,此时CPU就会重新计算任务优先级。这样一来那些因为长时间等待使得优先级被拔高的线程就会被CPU优先处理了。

在这里插入图片描述

RocketMQ中关于Thread.sleep(0)的经典案例

对应代码如下可以看到在RocketMQ这个大循环中,处理一些刷盘的操作,该因为是大循环,且涉及数据来回传输等操作,所以循环期间势必会创建大量的垃圾对象。

所以代码中有个if判断调用了Thread.sleep(0),作用如上所说,假设运行Java程序的操作系统采用抢占式调度算法,可能会出现以下的一个流程:

  1. 大循环长时间霸占CPU导致处理GC任务的线程迟迟无法工作。
  2. 循环结束后堆内存中出现大量因为刷盘等业务操作留下的垃圾对象。
  3. 操作系统重新进行一次CPU竞争,假设此时等待已久的处理GC任务的线程优先级最高,于是执行权分配给了GC线程。
  4. 因为堆内存垃圾太多,导致长时间的GC。

所以设计者们考虑到这一点,这在循环内部每一个小节点时调用Thread.sleep(),确保每执行一小段时间执行让操作系统进行一次CPU竞争,让GC线程尽可能多执行,做到垃圾回收的削峰填谷,避免后续出现一次长时间的GC时间导致STW进而阻塞业务线程的运行。

for (int i = 0, j = 0; i < this.fileSize; i += MappedFile.OS_PAGE_SIZE, j++) {
        byteBuffer.put(i, (byte) 0);
        // force flush when flush disk type is sync
        if (type == FlushDiskType.SYNC_FLUSH) {
            if ((i / OS_PAGE_SIZE) - (flush / OS_PAGE_SIZE) >= pages) {
                flush = i;
                mappedByteBuffer.force();
            }
        }

        // prevent gc
        if (j % 1000 == 0) {
            log.info("j={}, costTime={}", j, System.currentTimeMillis() - time);
            time = System.currentTimeMillis();
            try {
                Thread.sleep(0);
            } catch (InterruptedException e) {
                log.error("Interrupted", e);
            }
        }
 }

那为什么设计者们不使用Thread.sleep()而是调用Thread.sleep(0)方法呢?原因如下:

  1. 调用sleep方法仅仅是为了让操作系统重新进行一次CPU竞争,并不是为了挂起当前线程。
  2. 并不是每次sleep都需要垃圾回收,设置为0可以确保当前大循环的线程让出CPU执行权并休眠0s,即一让出CPU时间片就参与CPU下一次执行权的竞争。

不能不说RocketMQ的设计们对于编码的功力是非常深厚的。

小结

到此为止,我们了解的操作系统对于CPU执行线程任务的调度流程,回到我们文章开头提出的几个问题:

1. 为什么并发的IO任务使用多线程效率更高?
答:IO阻塞的任务会让出CPU时间片,自行处理IO请求,确保操作系统尽可能榨取CPU利用率。

2. CPU在任务IO阻塞时发生了什么?
答:将任务放入等待队列,并切换到下一个要执行的线程中。

3. CPU切换线程的依据是什么?
答:有可能是分配给线程的时间片到期了,有可能是因为线程阻塞,还有可能因为线程霸占CPU太久了(针对抢占式算法)

4. 线程休眠有什么用?
答:以抢占式算法为例,线程休眠会将当前任务存入等待队列,并让CPU重新计算任务优先级,选出当前最高优先级的任务。

5. 线程休眠1秒后是否会立刻拿到CPU执行权。
答:不一定,CPU会按照调度算法执行任务,这个不能一概而论。

6. 为什么有人代码会用到`Thread.sleep(0);`它的作用是什么?
答:让当前线程让出CPU执行权,所有线程重新进行一次CPU竞争,优先级高的获取CPU执行权。

参考文献

操作系统面试题:进程如何阻塞?进程阻塞为什么不占用CPU?:https://blog.csdn.net/weixin_44844089/article/details/115655642

Thread.sleep(0)并不是写错了,而是有妙用!:https://mp.weixin.qq.com/s/Zt8gnddY1fxFJpWT8mhWvA

面试题-Thread.sleep(0)的作用是什么:https://www.cnblogs.com/east7/p/14502400.html

没有二十年功力,写不出Thread.sleep(0)这一行“看似无用”的代码!:https://segmentfault.com/a/1190000042432589

文章来源:https://blog.csdn.net/shark_chili3007/article/details/109842669
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。