內(nèi)核如何阻塞與喚醒進程?
進程和線程
我們先從 Linux 的進程談起,操作系統(tǒng)要運行一個可執(zhí)行程序,首先要將程序文件加載到內(nèi)存,然后 CPU 去讀取和執(zhí)行程序指令,而一個進程就是“一次程序的運行過程”,內(nèi)核會給每一個進程創(chuàng)建一個名為task_struct的數(shù)據(jù)結構,而內(nèi)核也是一段程序,系統(tǒng)啟動時就被加載到內(nèi)存中了。
進程在運行過程中要訪問內(nèi)存,而物理內(nèi)存是有限的,比如 16GB,那怎么把有限的內(nèi)存分給不同的進程使用呢?跟 CPU 的分時共享一樣,內(nèi)存也是共享的,Linux 給每個進程虛擬出一塊很大的地址空間,比如 32 位機器上進程的虛擬內(nèi)存地址空間是 4GB,從 0x00000000 到 0xFFFFFFFF。但這 4GB 并不是真實的物理內(nèi)存,而是進程訪問到了某個虛擬地址,如果這個地址還沒有對應的物理內(nèi)存頁,就會產(chǎn)生缺頁中斷,分配物理內(nèi)存,MMU(內(nèi)存管理單元)會將虛擬地址與物理內(nèi)存頁的映射關系保存在頁表中,再次訪問這個虛擬地址,就能找到相應的物理內(nèi)存頁。每個進程的這 4GB 虛擬地址空間分布如下圖所示:
進程的虛擬地址空間總體分為用戶空間和內(nèi)核空間,低地址上的 3GB 屬于用戶空間,高地址的 1GB 是內(nèi)核空間,這是基于安全上的考慮,用戶程序只能訪問用戶空間,內(nèi)核程序可以訪問整個進程空間,并且只有內(nèi)核可以直接訪問各種硬件資源,比如磁盤和網(wǎng)卡。那用戶程序需要訪問這些硬件資源該怎么辦呢?答案是通過系統(tǒng)調(diào)用,系統(tǒng)調(diào)用可以理解為內(nèi)核實現(xiàn)的函數(shù),比如應用程序要通過網(wǎng)卡接收數(shù)據(jù),會調(diào)用 Socket 的 read 函數(shù):
- ssize_t read(int fd,void *buf,size_t nbyte)
CPU 在執(zhí)行系統(tǒng)調(diào)用的過程中會從用戶態(tài)切換到內(nèi)核態(tài),CPU 在用戶態(tài)下執(zhí)行用戶程序,使用的是用戶空間的棧,訪問用戶空間的內(nèi)存;當 CPU 切換到內(nèi)核態(tài)后,執(zhí)行內(nèi)核代碼,使用的是內(nèi)核空間上的棧。
從上面這張圖我們看到,用戶空間從低到高依次是代碼區(qū)、數(shù)據(jù)區(qū)、堆、共享庫與 mmap 內(nèi)存映射區(qū)、棧、環(huán)境變量。其中堆向高地址增長,棧向低地址增長。
請注意用戶空間上還有一個共享庫和 mmap 映射區(qū),Linux 提供了內(nèi)存映射函數(shù) mmap, 它可將文件內(nèi)容映射到這個內(nèi)存區(qū)域,用戶通過讀寫這段內(nèi)存,從而實現(xiàn)對文件的讀取和修改,無需通過 read/write 系統(tǒng)調(diào)用來讀寫文件,省去了用戶空間和內(nèi)核空間之間的數(shù)據(jù)拷貝,Java 的 MappedByteBuffer 就是通過它來實現(xiàn)的;用戶程序用到的系統(tǒng)共享庫也是通過 mmap 映射到了這個區(qū)域。
我在開始提到的task_struct結構體本身是分配在內(nèi)核空間,它的vm_struct成員變量保存了各內(nèi)存區(qū)域的起始和終止地址,此外task_struct中還保存了進程的其他信息,比如進程號、打開的文件、創(chuàng)建的 Socket 以及 CPU 運行上下文等。
在 Linux 中,線程是一個輕量級的進程,輕量級說的是線程只是一個 CPU 調(diào)度單元,因此線程有自己的task_struct結構體和運行棧區(qū),但是線程的其他資源都是跟父進程共用的,比如虛擬地址空間、打開的文件和 Socket 等。
阻塞與喚醒
我們知道當用戶線程發(fā)起一個阻塞式的 read 調(diào)用,數(shù)據(jù)未就緒時,線程就會阻塞,那阻塞具體是如何實現(xiàn)的呢?
Linux 內(nèi)核將線程當作一個進程進行 CPU 調(diào)度,內(nèi)核維護了一個可運行的進程隊列,所有處于TASK_RUNNING狀態(tài)的進程都會被放入運行隊列中,本質是用雙向鏈表將task_struct鏈接起來,排隊使用 CPU 時間片,時間片用完重新調(diào)度 CPU。所謂調(diào)度就是在可運行進程列表中選擇一個進程,再從 CPU 列表中選擇一個可用的 CPU,將進程的上下文恢復到這個 CPU 的寄存器中,然后執(zhí)行進程上下文指定的下一條指令。
而阻塞的本質就是將進程的task_struct移出運行隊列,添加到等待隊列,并且將進程的狀態(tài)的置為TASK_UNINTERRUPTIBLE或者TASK_INTERRUPTIBLE,重新觸發(fā)一次 CPU 調(diào)度讓出 CPU。
那線程怎么喚醒呢?線程在加入到等待隊列的同時向內(nèi)核注冊了一個回調(diào)函數(shù),告訴內(nèi)核我在等待這個 Socket 上的數(shù)據(jù),如果數(shù)據(jù)到了就喚醒我。這樣當網(wǎng)卡接收到數(shù)據(jù)時,產(chǎn)生硬件中斷,內(nèi)核再通過調(diào)用回調(diào)函數(shù)喚醒進程。喚醒的過程就是將進程的task_struct從等待隊列移到運行隊列,并且將task_struct的狀態(tài)置為TASK_RUNNING,這樣進程就有機會重新獲得 CPU 時間片。
這個過程中,內(nèi)核還會將數(shù)據(jù)從內(nèi)核空間拷貝到用戶空間的堆上。
當 read 系統(tǒng)調(diào)用返回時,CPU 又從內(nèi)核態(tài)切換到用戶態(tài),繼續(xù)執(zhí)行 read 調(diào)用的下一行代碼,并且能從用戶空間上的 Buffer 讀到數(shù)據(jù)了。
小結
今天我們談到了一次 Socket read 系統(tǒng)調(diào)用的過程:首先 CPU 在用戶態(tài)執(zhí)行應用程序的代碼,訪問進程虛擬地址空間的用戶空間;read 系統(tǒng)調(diào)用時 CPU 從用戶態(tài)切換到內(nèi)核態(tài),執(zhí)行內(nèi)核代碼,內(nèi)核檢測到 Socket 上的數(shù)據(jù)未就緒時,將進程的task_struct結構體從運行隊列中移到等待隊列,并觸發(fā)一次 CPU 調(diào)度,這時進程會讓出 CPU;當網(wǎng)卡數(shù)據(jù)到達時,內(nèi)核將數(shù)據(jù)從內(nèi)核空間拷貝到用戶空間的 Buffer,接著將進程的task_struct結構體重新移到運行隊列,這樣進程就有機會重新獲得 CPU 時間片,系統(tǒng)調(diào)用返回,CPU 又從內(nèi)核態(tài)切換到用戶態(tài),訪問用戶空間的數(shù)據(jù)。