12 [虛擬化] 進程抽象;fork,execve,exit
南京大學操作系統課蔣炎巖老師網絡課程筆記。
視頻:https://www.bilibili.com/video/BV1N741177F5?p=12
講義:http://jyywiki.cn/OS/2021/slides/8.slides#/
本講概述
回到“操作系統是管理程序運行的軟件”
- 操作系統中的進程
- 程序 = 狀態機 (M,R)
- 操作系統 = 多個狀態機
- 進程管理API
- fork:狀態機的復制
- execve:狀態機的重置
- exit
再次強調,一定要深入理解:程序(進程)就是一個狀態機。
操作系統中的進程
復習:應用程序
應用程序 = 代碼 + 數據(文件) = 狀態機
- a.out, bash, ls ,grep
- gcc(cc1, as, collect2, ld)
- xedit, vscode
復習:操作系統
操作系統是管理多個應用程序執行的軟件。
- 應用視角:操作系統就是一組系統調用
- 硬件視角:操作系統就是個狀態機(C程序)
理解“最小”操作系統:
如果硬件提供一些機制(如虛擬存儲來虛擬化內存M和寄存器R,即(M,R))使得各個“線程”不能訪問其他“線程”、操作系統的內存,就得到了虛擬化的“進程”,仿佛獨占CPU運行。注意:這里是將運行在操作系統上的各個程序(進程)看做了是運行在操作系統這個大程序(進程)上的一些”線程“。
操作系統:狀態機的虛擬化
操作系統“模擬”了其中所有程序的狀態機
- 這就是“虛擬化”
- 程序仿佛自己獨占CPU運行,但它獨占的只是CPU的一部分,其他部分它“看不見”。
進程:運行的程序。任意時刻,進程都可以看做是狀態機的狀態。
操作系統在終端以后,可以選擇將進程(狀態機)調度到CPU上運行。而進程執行系統調用,會使用指令(syscall)等回到操作系統。
“操作系統是一個中斷處理程序”
- 被動的中斷:硬件(時鐘、I/O設備、NMI,)
- 主動的中斷:系統調用
操作系統運行的兩種模式
- 用戶態(ring 3):應用程序運行在用戶態
- 內核態(ring 0):操作系統運行在內核態
二者的切換如上面所述:
- 中斷、系統調用:用戶態 -> 內核態
- 操作系統調度:內核態 -> 用戶態
就是這樣的切換,使得我們的應用程序(在用戶態)實現了虛擬化,同時操作系統仿佛就是一個中斷處理程序。
操作系統課的三種調用
-
進程(狀態機)管理
fork, execve, exit:進程(狀態機)的創建、改變和刪除
-
存儲(地址空間)管理
mmap:對進程虛擬地址空間的一部分進行映射
brk:虛擬地址空間管理
-
文件(數據對象)管理
open, close:文件訪問管理
read, write:數據管理
mkdir, link, unling:目錄管理
fork() 狀態機管理:創建狀態機
如果需要創建狀態機,我們需要什么樣的API?
UNIX的答案:fork()
- 做一份狀態機的完整的復制(內存M,寄存器現場R)
- 父進程返回子進程的PID,子進程返回0
fork bomb
fork bomb代碼解析:
:(){:|:&};: # 一行版本的fork bomb:(){ # 格式化一下: | : &
};:fork(){ # 這其實在bash中定義了一個函數,bash允許以冒號作為標識符fork | fork &
}; fork
父子進程、進程樹
因為狀態機是復制的,因此總能找到”父子關系“。
因此有了進程樹(pstree),如下:
systemd─┬─ModemManager───2*[{ModemManager}]├─NetworkManager─┬─dhclient│ └─2*[{NetworkManager}]├─accounts-daemon───2*[{accounts-daemon}]├─acpid├─avahi-daemon───avahi-daemon├─boltd───2*[{boltd}]├─colord───2*[{colord}]├─cron├─cups-browsed───2*[{cups-browsed}]├─cupsd───dbus├─dbus-daemon├─gdm3─┬─gdm-session-wor─┬─gdm-x-session─┬─Xorg───{Xorg}│ │ │ ├─gnome-session-b─┬─gnome-shell─┬─ibus-daemon─┬─ibus-dconf───3*[{ibus-dconf}]│ │ │ │ │ │ ├─ibus-engine-sim───2*[{ibus-engine-
...
進程樹的存在,是我們用fork()復制創造子進程,所得到的一個很自然的結果。
例程1
猜猜會打印出什么呢?(提示:可以試著畫一下狀態機,線程樹)
#include <unistd.h>
#include <stdio.h>int main(){pid_t pid1 = fork();pid_t pid2 = fork();pid_t pid3 = fork();printf("Hello World from (%d, %d, %d)\n", pid1, pid2, pid3);
}
例程2
猜猜會打印出什么呢?
#include <unistd.h>
#include <stdio.h>#define N 2
int main(){for (int i=0; i<N; i++){fork();printf("Hello\n");}
}
還是可以逐步畫一下程序的狀態機,
gcc test.c
./a.out
輸出結果應該是6。
有趣的是,如果我們將輸出重定向到管道,再通過wc -l
命令打印出行數,這時會輸出8,這不禁令我們大為驚奇。
為什么會這樣呢?這其實是因為printf函數將內容直接輸出到標準輸出stdout時是直接輸出的,會按照我們的理解打印出6個Hello。但是如果要重定向到管道(或文本文件等),printf則會將要輸出的內容先放置到緩沖區,到最后一起打印,在本例中,由于我們fork()創建進程的時候,會將全部的內存M和寄存器現場R復制一份,導致每次fork()時,緩沖區也被完整地復制,最后我們每個進程的緩沖區有2個Hello,最后共有四個進程,故會有8個Hello。(也可以重定向到文本文件中試一下,確實是有8個Hello)。這恰好進一步驗證了我們所說的fork()是對整個(M,R)的完整復制。
可以嘗試理解一下N=3時正常打印和重定向打印會有多少個Hello。筆者認為分別是 ∑i=1N2i\sum_{i=1}^N2^i∑i=1N?2i 和 2N×N2^N\times N2N×N。
機器永遠是對的,計算機系統的世界沒有魔法,一切都是按部就班地進行的
execve() 狀態機管理:替換狀態機(執行)
只有fork新建還不夠,我們還需要能夠執行別的程序。
UNIX的答案:execve
execve(filename, argv, enpv)
- 執行名為filename的程序
- 分別傳入參數argv(v)和環境變量enpv(e)
這剛好對應了main函數的參數:
int main(int argc, char** argv, char** enpv){// ...
}
關于main函數的參數:Linux中 C++ main函數參數argc和argv含義及用法
execve可以看作狀態機的重置。
環境變量
環境變量即應用程序執行的環境。
- 使用env命令來查看
- PATH:可執行文件的搜索路徑
- PWD:當前路徑
- HOME:home目錄
- DISPLAY:圖形輸出
- PS1:shell的提示符
- export:告訴shell在創建子進程時設置環境變量
PATH環境變量
PATH環境變量是可執行文件的搜索路徑
還記得gcc的strace結果嗎
[pid 28369] execve("/usr/local/sbin/as", [“as”, “–64”, …
[pid 28369] execve("/usr/local/bin/as", [“as”, “–64”, …
[pid 28369] execve("/usr/sbin/as", [“as”, “–64”, …
[pid 28369] execve("/usr/bin/as", [“as”, “–64”, …
這個搜索順序恰好是PATH環境變量中指定的順序:
$ PATH="" /usr/bin/gcc fork-demo.c
gcc: error trying to exec ‘as’: execvp: No such file or directory
$ PATH="/usr/bin/" gcc fork-demo.c
在gcc被execve時,將環境變量PATH傳給gcc,它就會按照其順序來搜索可執行文件的路徑。
計算機系統里沒有魔法,機器永遠是對的
exit() 狀態機管理:終止狀態機
有了fork,execve,我們可以自由地創建、執行程序(狀態機)了,還缺一個銷毀狀態機的函數
UNIX的答案:exit
- 銷毀當前狀態機,并允許有一個返回值
- 子進程終止會通知父進程(之后會講)
問題是一個進程(狀態機)中有多個線程啊。
結束程序執行的三種方法
exit的幾種寫法,它們是不同的:
- exit(0) - stdlib.h中聲明的libc函數
- 它會調用atexit
- _exit(0) - glibc中的syscall wrapper
- 執行exit_group系統調用,終止整個進程(所有線程)
- 不會調用atexit
- syscall(SYS_exit, 0)
- 執行exit系統調用終止當前線程
- 不會調用atexit
最起碼要區分好庫函數(應用程序的一部分)和系統調用。
可以用strace觀察各種結束方式的執行。
Fork-Exec vs. Spawn
我們既然fork創建了一個子進程,那我們絕大多情況下肯定是要execve執行這個進程的,也就是說fork后面幾乎一定會跟著execve,那為什么不直接把它們合成一個系統調用 spawn(path, argv, enpv) 呢?即spawn = fork + execve
實際上,fork + execve是一個非常優雅的實現,因為要考慮到進程可以持有操作系統中的對象,這使fork、execve、exit還要涉及到操作系統的對象的管理。
例如,在上面用到過的管道技術中:
./a.out | wc -l
其中./a.out
持有了操作系統中的對象——管道的寫口,而wc -l
則持有了管道的讀口,從而能夠將前者的輸出作為后者的輸入。而如果./a.out
這個進程(狀態機)需要fork一個子進程,那么這個子進程就可以自然地復制拿到父進程的全部(M,R)。這樣,對于操作系統中的對象——管道,子進程就持有了其寫口。
Take aways and Wrap-up
虛擬化
- 程序 = 狀態機
- 操作系統 = 狀態機的管理者
- 用硬件(物理狀態機)實現多個并發執行的虛擬狀態機
- API:fork,execve,exit
Ref:
http://jyywiki.cn/OS/2021/slides/8.slides#/
https://www.bilibili.com/video/BV1N741177F5?p=12