IO 任務與 CPU 調度藝術
近期和同行談及一些操作系統下關于性能指標評估的話題,涉及一些計算機基礎的核心知識點,遂以此文針對如下幾個話題進行深入分析:
- 為什么并發的IO任務使用多線程效率更高?
- CPU在任務IO阻塞時發生了什么?
- CPU切換線程的依據是什么?
- 線程休眠有什么用?
- 線程休眠1秒后是否會立刻拿到CPU執行權。
- 為什么有人代碼會用到Thread.sleep(0);它的作用是什么?
一、詳解操作系統對于線程的調度
1. 操作系統的任務調度
近代CPU運算速度是非常快的,即使在多線程情況下CPU會按照操作系統的調度算法有序快速有序的執行任務,使得我們即使開個十幾個進程,肉眼上所有的進程幾乎的是并行運行的,這本質上就是CPU對應ns級別的線程任務調度切換以及人眼200ms下不會直觀感受到停頓的共同作用:
CPU執行線程時會按照任務優先級進行處理,一般而言,對于硬件產生的信號優先級都是最高的,當收到中斷信號時,CPU理應中斷手頭的任務去處理硬件中斷程序。例如:用戶鍵盤打字輸入、收取網絡數據包。
以用戶鍵盤打字輸入為例,從鍵盤輸入到CPU處理的流程為:
- 用戶在鍵盤鍵入一個按鍵指令
- 鍵盤給CPU發送一個中斷引腳發送一個高電平。
- CPU執行鍵盤的中斷程序,獲取鍵盤的數據。
同理,獲取網絡數據包的執行流程為:
- 網卡收到網線傳輸的網絡數據
- 通過硬件電路完成數據傳輸
- 將數據寫入到內存中的某個地址中
- 網卡發送一個中斷信號給CPU
- CPU響應網卡中斷程序,從內存中讀取數據
了解網絡數據包獲取流程整個流程后,不知道讀者是否發現,網卡讀取數據期間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();
}
}
客戶端代碼示例如下,執行步驟為:
- 連接服務端。
- 輸入要發送的數據。
- 發送數據。
- 獲取響應。
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端口。
(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);
}
}
}
那為什么設計者們不使用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執行權。