IO 任务与 CPU 调度艺术

近期和同行谈及一些操作系统下关于性能指标评估的话题,涉及一些计算机基础的核心知识点,遂以此文针对如下几个话题进行深入分析:

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

一、详解操作系统对于线程的调度

1. 操作系统的任务调度

近代CPU运算速度是非常快的,即使在多线程情况下CPU会按照操作系统的调度算法有序快速有序的执行任务,使得我们即使开个十几个进程,肉眼上所有的进程几乎的是并行运行的,这本质上就是CPU对应ns级别的线程任务调度切换以及人眼200ms下不会直观感受到停顿的共同作用:

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

以用户键盘打字输入为例,从键盘输入到CPU处理的流程为:

用户在键盘键入一个按键指令键盘给CPU发送一个中断引脚发送一个高电平。CPU执行键盘的中断程序,获取键盘的数据。

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

网卡收到网线传输的网络数据通过硬件电路完成数据传输将数据写入到内存中的某个地址中网卡发送一个中断信号给CPUCPU响应网卡中断程序,从内存中读取数据

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

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

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

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

这也就是IO阻塞避免CPU资源消耗的原因,即在IO阻塞态时,CPU会将这些任务挂起切换执行其它任务,等其IO数据准备就绪并发起中断信号时,再回头处理这些任务。

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

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

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

创建ServerSocket 服务器。绑定端口。阻塞监听等待客户端连接。处理客户端发送的数据。回复数据给客户端。
复制
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.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43.

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

连接服务端。输入要发送的数据。发送数据。获取响应。
复制
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("Couldnt 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(); } }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.

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

复制
Waiting for connection...1.

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

复制
Waiting for connection... Connection successful! Client: hello world1.2.3.

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

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

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

(3) 客户端发起连接后,服务端网卡收到客户端请求连接,通过中断程序发出信号,CPU收到中断信号后挂起当前执行的线程去响应连接请求。

(4) 服务端建立连接成功,输出Connection successful!

(5) in.readLine()阻塞获取用户发送数据,CPU再次将其放到等待队列中,处理其他非阻塞的线程任务。

(6) 客户端发送数据,网卡接收并将其存放到内存中,通过中断程序发出信号,CPU收到中断信号后挂起当前执行的线程去读取响应数据。

(7) 重复5、6两步。

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

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

抢占式非抢占式

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

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

三、详解Java中的阻塞方法Thread.sleep()

1. Thread.sleep()如何优化抢占式调度的饥饿问题

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

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

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

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

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

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

调用sleep方法仅仅是为了让操作系统重新进行一次CPU竞争,并不是为了挂起当前线程。并不是每次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执行权。

THE END