1、電腦一通電,先運行主板上ROM(只讀存儲器)里寫死的程序BIOS,BIOS去找要運行什么操作系統,運行操作系統的第一段代碼,創建0號進程,它是這次開機所有進程的爹,
2、然后操作系統代碼里先初始化中斷門Interrupt Gate處理包括系統調用在內的各種中斷,再初始化內存管理模塊,然后運行一個函數初始化其他,這時會創建第二個進程,即1號進程,這是爹的大兒子,它以后會管理用戶態的所有進程。
用戶態和內核態的區別,兩個態是針對代碼權限而言的,物理上,就是兩類代碼訪問的物理地址不一樣,兩者不能越界訪問對方的物理地址。
用戶態,處於應用層,是用戶寫的代碼;內核態,是操作系統訪問關鍵資源的代碼。用戶寫代碼(比如C語言)進行網絡請求時,代碼里具體函數會經過Glibc庫(它封裝了系統調用函數,使系統調用更方便)轉化為系統調用函數,系統調用函數再去調內核代碼,內核代碼再去調設備驅動(這里是網卡驅動),網卡發完數據包,系統調用結束,返回用戶態。

3、然后創建第三個進程,負責所有內核態線程的調度和管理。(對於內核而言,進程和線程都是task,一樣的)
4、系統啟動之后,init 進程(即1號進程)會啟動很多的 daemon 進程,為系統運行提供服務,然后就是啟動 getty,讓用戶登錄,登錄后運行 shell,用戶啟動的進程都是通過 shell 運行的,從而形成了一棵進程樹。
5、線程和進程區別:進程默認有一個主線程的。線程是負責執行二進制指令的,它會根據項目執行計划書,一行一行執行下去。進程要比線程管的寬多了,除了執行指令之外,內存、文件系統等等都要它來管。進程相當於一個項目,而線程就是為了完成項目需求,而建立的一個個開發任務。默認情況下,你可以建一個大的任務,就是完成某某功能,然后交給一個人讓它從頭做到尾,這就是主線程。但是有時候,你發現任務是可以拆解的,如果相關性沒有非常大前后關聯關系,就可以並行執行。
線程的數據:
1)線程棧上本地數據,線程私有
2)在整個進程里共享的全局數據:例如全局變量,雖然在不同進程中是隔離的,但是在一個進程中是共享的。
Mutex,全稱 Mutual Exclusion,中文叫互斥。誰拿到鎖了誰訪問,沒搶到的阻塞,不想阻塞的返回錯誤碼,用完的人釋放鎖,
3)線程私有數據:key 一旦被創建,所有線程都可以訪問它,但各線程可根據自己的需要往 key 中填入不同的值,這就相當於提供了一個同名而不同值的全局變量。
進程的權限:
1)通過chmod u+s program 命令,設置set-user-ID 的標識位,以用戶和用戶組控制權限
2)capabilities,這個解決了非 root 用戶進程使用 exec 執行一個程序的時候,如何保留權限的問題。
進程的上下文切換主要干兩件事情,一是切換進程空間,也即虛擬內存;二是切換寄存器和 CPU 上下文。所謂的進程切換,就是將某個進程的 thread_struct 里面的寄存器的值,寫入到 CPU 的 TR 指向的 tss_struct,對於 CPU 來講,這就算是完成了切換。
CPU調度不同進程的切換,是通過主動調度或搶占式調度進行的:進程調度第一定律—— 一定要等待正在運行的進程調用 __schedule 才能搶占。調度主要發生在中斷、調用返回的時候。
進程的創建:fork函數最后會走到系統調用 sys_fork,此函數先復制進程結構,如任務ID、任務狀態、運行統計、親緣關系、權限、調度相關、信號處理、內存管理等內容,然后調用wake_up_new_task函數喚醒新進程。
線程的創建:它由內核態和用戶態合作完成,用戶態先調用glibc庫中的函數allocate_stack,此函數中觸發內核態系統調用,進行標志位設定、親緣關系和信號處理,內核調用完成,返回系統調用,返回用戶態,在用戶態調用start_thread函數,開始運行線程,運行完成后,調用函數deallocate_stack釋放線程棧,至此線程生命周期完成。
6、內存空間怎么分配
為了防止一個物理地址面對多個值得沖突,計算機使用虛擬地址,對於一個進程來說,如果有32位,那它就擁有一個2^32=4G的空間,64位則更大,這個大虛擬地址一分為二,一份用來放內核的東西,稱為內核空間,一部分用來放進程的東西,稱為用戶空間。
用戶空間在下,在低地址;內核空間在上,在高地址。
從低位開始,往上走,分別放了可執行代碼、靜態常量、靜態變量、堆、文件依賴的動態鏈接庫、棧(主線程的棧空間就在這兒),用戶態觸發系統調用的話,就會用到內核空間,內核空間里的地址,對於每個進程來說,是公有的(對於每個進程來說,用戶空間是是它一個人的),別的進程用了那塊地址,我就不能用了,所以內核空間要用鎖。
物理內存根據 NUMA 架構分節點。每個節點里面再分區域。每個區域里面再分頁。物理頁面通過伙伴系統(如果對應的頁塊鏈表中沒有空閑頁塊,那我們就在更大的頁塊鏈表中去找。當分配的頁塊中有多余的頁時,伙伴系統會根據多余的頁塊大小插入到對應的空閑頁塊鏈表中。例如,要請求一個 128 個頁的頁塊時,先檢查 128 個頁的頁塊鏈表是否有空閑塊。如果沒有,則查 256 個頁的頁塊鏈表;如果有空閑塊的話,則將 256 個頁的頁塊分成兩份,一份使用,一份插入 128 個頁的頁塊鏈表中。如果還是沒有,就查 512 個頁的頁塊鏈表;如果有的話,就分裂為 128、128、256 三個頁塊,一個 128 的使用,剩余兩個插入對應頁塊鏈表。)進行分配。分配的物理頁面要變成虛擬地址讓上層可以訪問,kswapd 可以根據物理頁面的使用情況對頁面進行換入換出。對於內存的分配需求,可能來自內核態,也可能來自用戶態。
對於內核態,kmalloc 在分配大內存的時候,以及 vmalloc 分配不連續物理頁的時候,直接使用伙伴系統,分配后轉換為虛擬地址,訪問的時候需要通過內核頁表進行映射。對於 kmem_cache 以及 kmalloc 分配小內存,則使用 slub 分配器,將伙伴系統分配出來的大塊內存切成一小塊一小塊進行分配。kmem_cache 和 kmalloc 的部分不會被換出,因為用這兩個函數分配的內存多用於保持內核關鍵的數據結構。內核態中 vmalloc 分配的部分會被換出,因而當訪問的時候,發現不在,就會調用 do_page_fault。
對於用戶態的內存分配,或者直接調用 mmap 系統調用分配,或者調用 malloc。調用 malloc 的時候,如果分配小的內存,就用 sys_brk 系統調用;如果分配大的內存,還是用 sys_mmap 系統調用。正常情況下,用戶態的內存都是可以換出的,因而一旦發現內存中不存在,就會調用 do_page_fault。
7、文件系統如何存放文件:
linux主流用的是ext格式,新買的應該要格式化(將一塊盤使用命令組織成一定格式的文件系統的過程),要格式化了才能放文件。使用 Windows 的時候,咱們常格式化的格式為 NTFS(New Technology File System)。在 Linux 下面,常用的是 ext3 或者 ext4。硬盤分成大小相同的塊,一個塊默認大小是4K個扇區。一個文件可以分布在多個塊上, 每個文件都有大小、權限等基本信息,放在一個叫inode的結構里,inode有12個值,每個值放數據塊的位置,對於數據塊過大的情況,12個不夠用,ext2、3,用的是間接塊方法,即在第12個值里,不放數據塊的位置,而放下一個inode[1]的位置信息,ext4用的是Extents方法,inode里放的都是樹的根結點位置,每個根結點有多個子樹,子樹上的每個結點,指向一個連續塊的地址。

硬鏈接與軟鏈接的區別:硬鏈接與原始文件共用一個 inode 的,但是 inode 是不跨文件系統的,每個文件系統都有自己的 inode 列表,因而硬鏈接是沒有辦法跨文件系統的。而軟鏈接不同,軟鏈接相當於重新創建了一個文件。這個文件也有獨立的 inode,只不過打開這個文件看里面內容的時候,內容指向另外的一個文件。這就很靈活了。我們可以跨文件系統,甚至目標文件被刪除了,鏈接文件還是在的,只不過指向的文件找不到了而已。
8、CPU給設備發了一個指令,讓它讀取一些數據,它讀完的時候,怎么通知CPU呢?
有一個硬件的中斷控制器,當設備完成任務后觸發中斷到中斷控制器,中斷控制器就通知 CPU,一個中斷產生了,CPU 需要停下當前手里的事情來處理中斷。
中斷有兩種,一種軟中斷,例如代碼調用 INT 指令觸發,一種是硬件中斷,就是硬件通過中斷控制器觸發的。
一個設備驅動程序初始化的時候,要先注冊一個該設備的中斷處理函數,中斷返回的那一刻是進程切換的時機,中斷的時候,觸發的函數是 do_IRQ。這個函數是中斷處理的統一入口。在這個函數里面,我們可以找到設備驅動程序注冊的中斷處理函數 Handler,然后執行它進行中斷處理。

信號也是類似的,當信號來的時候,我們並不直接處理這個信號,而是設置一個標識位 TIF_SIGPENDING,來表示已經有信號等待處理。同樣等待系統調用結束,或者中斷處理結束,從內核態返回用戶態的時候,再進行信號的處理。
信號的發送與處理是一個復雜的過程,這里來總結一下:
假設我們有一個進程 A,main 函數里面調用系統調用進入內核。按照系統調用的原理,會將用戶態棧的信息保存在 pt_regs 里面,也即記住原來用戶態是運行到了 line A 的地方。在內核中執行系統調用讀取數據。當發現沒有什么數據可讀取的時候,只好進入睡眠狀態,並且調用 schedule 讓出 CPU,這是進程調度第一定律。將進程狀態設置為 TASK_INTERRUPTIBLE,可中斷的睡眠狀態,也即如果有信號來的話,是可以喚醒它的。其他的進程或者 shell 發送一個信號,有四個函數可以調用 kill、tkill、tgkill、rt_sigqueueinfo。四個發送信號的函數,在內核中最終都是調用 do_send_sig_info。do_send_sig_info 調用 send_signal 給進程 A 發送一個信號,其實就是找到進程 A 的 task_struct,或者加入信號集合,為不可靠信號,或者加入信號鏈表,為可靠信號。do_send_sig_info 調用 signal_wake_up 喚醒進程 A。進程 A 重新進入運行狀態 TASK_RUNNING,根據進程調度第一定律,一定會接着 schedule 運行。進程 A 被喚醒后,檢查是否有信號到來,如果沒有,重新循環到一開始,嘗試再次讀取數據,如果還是沒有數據,再次進入 TASK_INTERRUPTIBLE,即可中斷的睡眠狀態。當發現有信號到來的時候,就返回當前正在執行的系統調用,並返回一個錯誤表示系統調用被中斷了。系統調用返回的時候,會調用 exit_to_usermode_loop。這是一個處理信號的時機。調用 do_signal 開始處理信號。根據信號,得到信號處理函數 sa_handler,然后修改 pt_regs 中的用戶態棧的信息,讓 pt_regs 指向 sa_handler。同時修改用戶態的棧,插入一個棧幀 sa_restorer,里面保存了原來的指向 line A 的 pt_regs,並且設置讓 sa_handler 運行完畢后,跳到 sa_restorer 運行。返回用戶態,由於 pt_regs 已經設置為 sa_handler,則返回用戶態執行 sa_handler。sa_handler 執行完畢后,信號處理函數就執行完了,接着根據第 15 步對於用戶態棧幀的修改,會跳到 sa_restorer 運行。sa_restorer 會調用系統調用 rt_sigreturn 再次進入內核。在內核中,rt_sigreturn 恢復原來的 pt_regs,重新指向 line A。從 rt_sigreturn 返回用戶態,還是調用 exit_to_usermode_loop。這次因為 pt_regs 已經指向 line A 了,於是就到了進程 A 中,接着系統調用之后運行,當然這個系統調用返回的是它被中斷了,沒有執行完的錯誤。
9、管道的原理:所謂的匿名管道,其實就是內核里面的一串緩存。所謂的命名管道,其實是也是內核里面的一串緩存。
10、進程間通信機制:
對共享的內存進行保護,就需要信號量這樣的同步協調機制。
無論是共享內存還是信號量,創建與初始化都遵循同樣流程,通過 ftok 得到 key,通過 xxxget 創建對象並生成 id;生產者和消費者都通過 shmat 將共享內存映射到各自的內存空間,在不同的進程里面映射的位置不同;為了訪問共享內存,需要信號量進行保護,信號量需要通過 semctl 初始化為某個值;接下來生產者和消費者要通過 semop(-1) 來競爭信號量,如果生產者搶到信號量則寫入,然后通過 semop(+1) 釋放信號量,如果消費者搶到信號量則讀出,然后通過 semop(+1) 釋放信號量;共享內存使用完畢,可以通過 shmdt 來解除映射。
semop 會調用 semtimedop,這是一個非常復雜的函數。semtimedop 做的第一件事情,就是將用戶的參數,例如,對於信號量的操作 struct sembuf,拷貝到內核里面來。另外,如果是 P 操作,很可能讓進程進入等待狀態,是否要為這個等待狀態設置一個超時,timeout 也是一個參數,會把它變成時鍾的滴答數目。semtimedop 做的第二件事情,是通過 sem_obtain_object_check,根據信號量集合的 id,獲得 struct sem_array,然后,創建一個 struct sem_queue 表示當前的信號量操作(進程並發底層仍然是靠隊列處理)。為什么叫 queue 呢?因為這個操作可能馬上就能完成,也可能因為無法獲取信號量不能完成,不能完成的話就只好排列到隊列上,等待信號量滿足條件的時候。semtimedop 會調用 perform_atomic_semop 在實施信號量操作。
信號量是一個整個 Linux 可見的全局資源,而不像咱們在線程同步那一節講過的都是某個進程獨占的資源,好處是可以跨進程通信,壞處就是如果一個進程通過 P 操作拿到了一個信號量,但是不幸異常退出了,如果沒有來得及歸還這個信號量,可能所有其他的進程都阻塞了。那怎么辦呢?Linux 有一種機制叫 SEM_UNDO,也即每一個 semop 操作都會保存一個反向 struct sem_undo 操作,當因為某個進程異常退出的時候,這個進程做的所有的操作都會回退,從而保證其他進程可以正常工作。
等待隊列上的每一個 struct sem_queue,都有一個 struct sem_undo,以此來表示這次操作的反向操作。
上面的講解,讓我想起,如果是多核CPU,所謂原子操作,究竟如何實現呢?
總之,cas的核心,就是在最底層處,硬件級別,要么鎖內存,要么鎖總線的前提下CPU每個核再去執行TS原子指令。所以並發的根源,其實還是某一刻只做一件事。
11、網絡通信
操作系統選擇對於網絡協議的實現模式是,二到四層的處理代碼在內核里面,七層的處理代碼讓應用自己去做,兩者需要跨內核態和用戶態通信,就需要一個系統調用完成這個銜接,這就是 Socket。
三次握手,是 TCP 層的動作,是在內核完成的,應用層不需要參與。
當一些網絡包到來觸發了中斷,內核處理完這些網絡包之后,我們可以先進入主動輪詢 poll 網卡的方式,主動去接收到來的網絡包。如果一直有,就一直處理,等處理告一段落,就返回干其他的事情。當再有下一批網絡包到來的時候,再中斷,再輪詢 poll。這樣就會大大減少中斷的數量,提升網絡處理的效率,這種處理方式我們稱為 NAPI。
我們將請求封裝為 HTTP 協議,通過 Socket發送到內核。內核的網絡協議棧里面,在 TCP 層創建用於維護連接、序列號、重傳、擁塞控制的數據結構,將 HTTP 包加上 TCP 頭,發送給 IP 層,IP 層加上 IP 頭,發送給 MAC 層,MAC 層加上 MAC 頭,從硬件網卡發出去。
最終網絡包會被轉發到目標服務器,它發現 MAC 地址匹配,就將 MAC 頭取下來,交給上一層。IP 層發現 IP 地址匹配,將 IP 頭取下來,交給上一層。TCP 層會根據 TCP 頭中的序列號等信息,發現它是一個正確的網絡包,就會將網絡包緩存起來,等待應用層的讀取。應用層通過 Socket 監聽某個端口,因而讀取的時候,內核會根據 TCP 頭中的端口號,將網絡包發給相應的應用。
12、虛擬化
13、容器
容器實現封閉的環境主要要靠兩種技術,一種是看起來是隔離的技術,稱為 namespace(命名空間)。在每個 namespace 中的應用看到的,都是不同的 IP 地址、用戶空間、進程 ID 等。另一種是用起來是隔離的技術,稱為 cgroup(網絡資源限制),即明明整台機器有很多的 CPU、內存,但是一個應用只能用其中的一部分。
在每一個進程的 task_struct 里面,有一個指向 namespace 結構體的指針 nsproxy,nsproxy中含有6個結構指針,分別描述pid、網絡、用戶組等信息,如果要創建一個namespace,內核會調用函數逐一復制這些值。namespace有三個常用的函數 clone、setns 和 unshare。要直接操作 Namespace,有兩個常用的命令 nsenter 和 unshare。
在cgroup文件夾下,有cpu、內存、io等不同的文件夾,以cpu文件夾為例,里面是docker id的文件夾,此文件夾里是各種cpu相關參數,如設置cpu核數,cpu使用率等。系統初始化時,cgroup也會初始化,初始化 cgroup 的各個子系統的操作函數,分配各個子系統的數據結構,cgroup 初始化完畢之后,接下來就是創建一個 cgroup 的文件系統,用於配置和操作 cgroup。文件系統的操作函數會調用到 cgroup 子系統的操作函數寫入 cgroup 文件,設置 cpu 或者 memory 的相關參數。文件系統再寫入 tasks 文件,將進程交給某個 cgroup 進行管理。每一個 cgroup 子系統會調用相應的attach函數,
例如 CPU 調用順序是 cpu_cgroup_attach-> sched_move_task-> sched_change_group,在 sched_change_group 中設置這個進程以這個 task_group 的方式參與調度。最終對於CPU而言,修改了 scheduled entity,也放入相應的隊列里,從而下次調度的時候就起作用了。
對於內存,寫入內存的限制使用函數 mem_cgroup_write->mem_cgroup_resize_limit 來設置 struct mem_cgroup 的 memory.limit 成員。在進程執行過程中,申請內存的時候,我們會調用 handle_pte_fault->do_anonymous_page()->mem_cgroup_try_charge()。在 mem_cgroup_try_charge 中,先是調用 get_mem_cgroup_from_mm 獲得這個進程對應的 mem_cgroup 結構,然后在 try_charge 中,根據 mem_cgroup 的限制,看是否可以申請分配內存。所以,對於內存的 cgroup 設定,只有在申請內存的時候才起作用。
