来聊聊IO阻塞与CPU任务调度
关于IO阻塞的几个提问
使用多线程时大家是否有下面这些疑问:
- 为什么并发的IO任务使用多线程效率更高?
- CPU在任务IO阻塞时发生了什么?
- CPU切换线程的依据是什么?
- 线程休眠有什么用?
- 线程休眠1秒后是否会立刻拿到CPU执行权。
- 为什么有人代码会用到
Thread.sleep(0);
它的作用是什么?
操作系统任务调度详解
操作系统的任务调度
我们都知道CPU处理速度很快,多线程情况下CPU会按照操作系统的调度算法有序快速有序的执行任务,使得我们即使开个十几个程序,看上去所有程序仍像是在同时运行一样。
上文提到CPU执行线程时会按照任务优先级进行处理,一般而言,对于硬件产生的信号优先级都是最高的,当收到中断信号时,CPU理应中断手头的任务去处理硬件中断程序。例如:用户键盘打字输入、收取网络数据包。以用户键盘打字输入为例,从键盘输入到CPU处理的流程为:
- 用户在键盘键入一个字母。
- 键盘给CPU发送一个中断引脚发送一个高电平。
- CPU执行键盘的中断程序,获取键盘的数据。
同理,获取网络数据包的执行流程为:
- 网卡收到网线传来的数据。
- 通过硬件电路完成传输。
- 将数据写入到内存中的某个地址中。
- 网卡发送一个中断信号给CPU。
- CPU响应网卡中断程序,从内存中读取数据。
了解网络数据包获取流程整个流程后,不知道读者是否发现,网卡读取数据期间CPU似乎无需参与工作的,那么操作系统是如何处理这期间的任务调度呢?
IO阻塞的线程会如何避免CPU资源占用
操作系统为了支持多任务,将任务分为了运行、等待、就绪等几种状态,对于运行状态的任务,操作系统会将其放到工作队列中。CPU按照操作系统的调度算法按需执行工作队列中的任务。
需要注意的是,这些任务能够被CPU时间片完整执行的前提是任务不会发生阻塞。一旦任务或是读取本地文件或者发起网络IO等原因发起阻塞,这些线程任务就会被放到等待队列中,就如图上面所有的收取网络数据包,在网卡读取数据并写入到内存这期间,该任务就是在等待队列中完成的。
只有这些IO任务接受到了完整的数据并通过中断程序发送信号给CPU,操作系统才会将其放到工作队列中,让CPU读取数据。
这也就是IO阻塞避免CPU资源消耗的原因,以及为什么IO任务使用多线程高效的原因所在。
用一个实例了解网络收包的过程
对于上述问题,我们不妨看一段这样的代码,功能很简单,服务端开启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();
}
}
客户端代码示例如下,执行步骤为:
- 连接服务端。
- 输入要发送的数据。
- 发送数据。
- 获取响应。
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对应做法如下:
new ServerSocket(9009)
新建由文件系统管理的Socket
对象,并绑定9009
端口。
serverSocket.accept();
阻塞监听等待客户端连接,此时CPU就会将其放到等待队列中,去处理其他线程任务。
- 客户端发起连接后,服务端网卡收到客户端请求连接,通过中断程序发出信号,CPU收到中断信号后挂起当前执行的线程去响应连接请求。
- 服务端建立连接成功,输出Connection successful!
in.readLine()
阻塞获取用户发送数据,CPU再次将其放到等待队列中,处理其他非阻塞的线程任务。- 客户端发送数据,网卡接收并将其存放到内存中,通过中断程序发出信号,CPU收到中断信号后挂起当前执行的线程去读取响应数据。
- 重复5、6两步。
Thread.sleep()详解
CPU如何处理任务优先级分配
调度算法
上文我们提到过CPU会按照某种调度算法执行进程任务,这里的算法大致分为两种:
- 抢占式。
- 非抢占式。
抢占式
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程序的操作系统采用抢占式调度算法,可能会出现以下的一个流程:
- 大循环长时间霸占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);
}
}
}
那为什么设计者们不使用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执行权。
参考文献
操作系统面试题:进程如何阻塞?进程阻塞为什么不占用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
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。 如若内容造成侵权/违法违规/事实不符,请联系我的编程经验分享网邮箱:veading@qq.com进行投诉反馈,一经查实,立即删除!