進程 (未完結)

本章節目標:

  • 認識馮諾依曼系統
  • 作業系統概念與定位,理解"管理"
  • 深⼊理解進程概念,瞭解PCB
  • 學習進程狀態,學會創建進程,掌握僵⼫進程和孤⼉進程,及其形成原因和危害
  • 瞭解進程調度,Linux進程優先級,理解進程競爭性與獨⽴性,理解並⾏與併發
  • 理解進程切換,以及Linux2.6 kernel,O(1)調度算法架構
  • 理解環境變量,熟悉常見環境變量及相關指令, getenv/setenv函數
  • 理解C記憶體空間分配規律,瞭解進程記憶體映像和應⽤程序區別, 認識虛擬地址空間

馮諾伊曼系統結構

現實生活中常見的計算機大部分都遵循馮諾伊曼系統體系
馮諾伊曼結構.png
輸入設備包含:鍵盤、鼠標、掃描儀、攝像頭、網卡、硬盤(ROM)等
輸出設備包含:顯示器、影印機、喇叭、網卡、硬盤
而在這裡的存儲器指的就是記憶體。輸入設備與輸出設備統稱為外部設備。由於它們的速度遠慢於CPU,因此通常不會直接與 CPU持續交互,而是透過I/O控制器、緩衝區或DMA先將資料傳入記憶體,再由CPU與記憶體進行交互。

為什麼要有記憶體?

存儲分級的問題:
存儲分層.png
為了提高效率,輸入設備的資料通常會透過緩衝或快取的方式預先載入到記憶體。由於程式在處理資料時往往具有時間局部性與空間局部性,因此大部分情況下 CPU 可以直接從記憶體中存取所需資料,而不必頻繁等待輸入設備。

  • 時間局部性(Temporal Locality):如果某個記憶體位置被訪問過,那麼在不久之後很可能會再次被訪問。
  • 空間局部性(Spatial Locality):如果某個記憶體位置被訪問,那麼它附近的記憶體位置很可能會在不久之後被訪問。

計算機中,資料流動的本質就是拷貝!!! -> 因此可以知道計算機效率的問題是由設備的拷貝效率決定的
從你登錄上某通訊軟體開始和某位朋友聊天開始,資料的流動過程
訊息傳送的流動過程.png


作業系統

任何計算機系統都包含⼀個基本的程序集合,稱為作業系統(OS)。籠統的理解,作業系統包括

  • 內核(進程管理,記憶體管理,檔案管理,驅動管理)-> 任何計算機都需要這四個功能
  • 其他程序(例如函數庫,shell程序等等)
    廣義、狹義作業系統.png

設計OS的目的

  • 對下,與硬體交互,管理所有的軟硬體資源 -> 手段
  • 對上,為⽤⼾程序(應⽤程序)提供⼀個良好的執⾏環境(穩定、高效、安全) -> 目的
    設置OS的目的.png

OS 內部,核心最重要的是什麼?── 資料結構
作業系統(OS)本質上就是一個軟體,一款負責管理軟硬體資源的軟體。它遵循一個基本的思路:先進行描述,再進行組織
回想C++的「類別」與STL的關係

  • 類別的設計是 先描述(定義它的功能與特性)
  • 然後再透過STL等工具進行 組織與運用。這樣的做法,目的都是為了把現實世界的問題轉換成計算機世界中可操作的規律。
    從開發角度來看,作業系統對外表現為一個整體,但它會暴露部分介面給上層使用,這些介面就是系統呼叫(System Call)
    系統呼叫本身功能比較基礎,對使用者的要求也相對較高。為了方便更多開發者使用,部分開發者會對系統呼叫進行 封裝,形成各種函式庫。這些庫的出現,進一步降低了使用難度,也有利於更高層的應用程式開發。
    那麼,作業系統是如何管理進程的呢?其核心思路其實很簡單:先將進程進行描述,再把它們組織起來。
    因為在作業系統中,可能同時需要運行許多程式:
  • 每一個程式都必須被加載到記憶體
  • 當多個進程並存時,作業系統必須對它們進行有效管理
    而要做到「管理」,前提就是「先描述」。換句話說,要讓作業系統認識一個進程,就必須先有一個用來描述它的結構體物件
    在作業系統中,這個結構體就是 PCB(Process Control Block,進程控制塊)
    當一個程式被加載到記憶體,真正成為「進程」時,系統會為它建立對應的PCB,進而透過PCB進行後續的管理與調度。

在還沒有啟動進程前的第一個軟體是什麼? --- 作業系統(作業系統也在記憶體中)
所以什麼是進程?進程就是內核的資料結構 + 自己程序的代碼和資料


進程

PCB 的概念

  • PCB(Process Control Block,進程控制塊)是作業系統中用來描述和管理進程的一個資料結構
  • 它保存了進程執行所需的所有資訊,例如:進程狀態、程式計數器、寄存器內容、記憶體管理資訊、開啟的檔案、排程相關資訊等等
  • 這是一個抽象概念,不同作業系統的實作方式和名稱可能不同
    可以理解成,當創建一個新的進程時,就會建立一個對應的PCB,而Linux中,新的進程往往是通過父進程(bash -- 命令行解釋器)的方式創建出來。

Linux 的實作

  • 在 Linux 內核中,進程控制塊的具體實作就是一個名為 task_struct 的結構體
  • task_struct 中包含了 Linux 內核用來管理進程(以及輕量級執行緒,因為 Linux 沒有嚴格區分 process/thread)的所有資訊

例如其中包含:

  • 進程識別符(pid)
  • 父進程/子進程關係
  • 記憶體描述符(mm_struct)
  • 檔案描述符表
  • 調度優先級與排程策略
  • 狀態資訊(running、sleeping 等)
  • 上下文數據(程式計數器、CPU 暫存器、堆疊指標等)

    • 程式計數器 -- 下一個即將要執行的指令的地址
    • 暫存器是共享的,但暫存器中的數據是進程私有的

一個進程執行代碼,佔有CPU,要一直等到該代碼執行完畢後才讓出CPU? --- 不是(如果是while(1)就卡死了)
-> 當代計算機,都會給每一個進程分配一個時間片,時間片執行完畢就會讓出CPU,讓另一個進程執行 -- 基於時間片的輪轉調度
-> 一個進程還沒執行完可能就會讓出CPU --- 因爲時間片到達,就存在進程切換和調度的動作

一個進程如何獲取自己的識別符? -> 要獲取task_struct 自己內部的屬性OS就需要提供給我們一個系統調用 -- getpid()、getppid()

About fork() -- fork()後會共享fork後的程式碼
如何創建一個進程(子進程)?通過程式碼的方式 -- fork() -- 本質是OS內部多了一個子進程 -> task_struct + 程式碼和數據 ; 進程會即時的顯示在 /proc中

  1. fork()返回值有兩個?是的,但為什麼給子進程的是0,而給父進程的是子進程的PID

    • 因爲父進程和子進程的關係通常是 1 : n -- 返回給父進程子進程的PID是為了標識指定的子進程,以便未來可以控制特定的子進程
  2. fork()函數怎麼可能返回兩次阿? -- 上面說了fork後會共享fork後的程式碼,其實就是執行了兩次return (return的本質就是寫入)
  3. 一個id怎麼既能接受 == 0 又接受 > 0 ? (後面談) -- 關聯寫實拷貝、虛擬地址部分

進程狀態

  1. 作業系統教材中對於狀態(運行、阻塞、掛起)的說明
    進程狀態1.png
  2. 什麼叫阻塞?阻塞和運行的本質是看你的task_struct在誰的佇列中(runqueue or waitqueue)!!!!

    • 凡是在runqueue中的都是運行狀態!!!
  3. 什麼是掛起?

    • task_struct 在記憶體中,由於記憶體不足,而導致進程對應的數據或代碼被交換到的swap分區(磁盤)

      • 但是過度的swap會導致系統變慢
    • 如果進程本身就處於阻塞狀態,又被掛起,就稱為阻塞掛起
    • 如果阻塞掛起後記憶體還是不足,作業系統實在沒辦法只能將運行狀態的進程也掛起,就稱為運行掛起
    • 如果還是不行呢?那麼Linux系統就會選擇性殺掉特定的進程

Linux中具體進程狀態表示
進程狀態.png

  • R運⾏狀態(running): 並不意味著進程⼀定在運⾏中,它表明進程要麼是在運⾏中要麼在運⾏佇列⾥
  • S睡眠狀態(sleeping): 意味著進程在等待事件完成(這⾥的睡眠有時候也叫做可中斷睡眠(interruptible sleep))
  • D磁盤休眠狀態(Disk sleep)有時候也叫不可中斷睡眠狀態(uninterruptible sleep),在這個狀態的進程通常會等待IO的結束
  • T停⽌狀態(stopped): 可以通過發送 SIGSTOP 信號給進程來停⽌(T)進程。這個被暫停的進程可以通過發送 SIGCONT 信號讓進程繼續運⾏
  • X死亡狀態(dead):這個狀態只是⼀個返回狀態,你不會在任務列表⾥看到這個狀態

    • 進程不會直接進入X狀態,會先進入Z狀態,之後再變成X狀態
  • 僵死狀態(Zombies):是⼀個⽐較特殊的狀態。當進程退出並且⽗進程(使⽤wait()系統調⽤)沒有讀取到⼦進程退出的返回代碼時就會產⽣僵死(屍)進程

    • 僵死進程會以終⽌狀態保持在進程表中,並且會⼀直在等待⽗進程讀取退出狀態代碼。
    • 只要⼦進程退出,⽗進程還在運⾏,但⽗進程沒有讀取⼦進程狀態,⼦進程進⼊Z狀態
    • Z狀態指的就是,只保留進程的task_struct,未來讓父進程或OS幫我們獲取子進程的退出數據
  1. 進程退出了,退出信息是什麼?
    main函數的返回值或收到的信號值
  2. 進程退出了,退出信息保存在哪?
    進程自己的task_struct結構中
  3. 檢測Z狀態進程、回收Z狀態進程本質是在做什麼?
    本質是在獲取task_struct中exit_state、exit_code、exit_signal這部分的數據,獲取完後讓task_struct處於X釋放掉
  4. 怎麼回收?誰來回收?
    通常是父進程來回收,透過wait函數來獲取到子進程的status,這也是為什麼要返回子進程的pid給父進程

進程狀態查看

  • ps axj / ps aux

    • a:顯⽰⼀個終端所有的進程,包括其他⽤⼾的進程。
    • x:顯⽰沒有控制終端的進程,例如後臺運⾏的守護進程。
    • j:顯⽰進程歸屬的進程組ID、會話ID、⽗進程ID,以及與作業控制相關的信息
    • u:以⽤⼾為中⼼的格式顯⽰進程信息,提供進程的詳細信息,如⽤⼾、CPU和記憶體使⽤情況等
  • 在此先拋出的概念

    • 前後臺進程 --- TODO
    • kill -9 --- 發送信號
    • stat + --- 帶加號表示前臺進程,否則為後臺進程

細談殭屍進程(僵死進程)

殭屍進程的危害

當一個進程退出時,其退出的狀態需要維持,因爲要告訴父進程說你交給我的任務我完成的怎麼樣。但如果父進程一直不讀取,那子進程就會一直維持在殭屍狀態!而維護退出狀態本⾝就是要⽤資料維護,也屬於進程基本信息,所以保存在task_struct(PCB)中,換句話說,Z狀態⼀直不退出,PCB⼀直都要維護!

那我們知道一個父進程可能會創建許多子進程,如果父進程一直不回收退出的子進程,就可能導致記憶體資源浪費的問題 -- 也就是記憶體洩漏

孤兒進程

這裡討論的是,如果父進程比子進程早退出呢?那子進程退出時進入殭屍狀態由誰來管理?
如果父進程比子進程早退出,那麼這些子進程就稱為孤兒進程
孤兒進程會被系統(1號進程 -- init/systemd)「領養」,1號進程處理孤兒進程的善後,也就是說,孤兒進程會被系統自動回收
-> 1號進程接收孤兒進程後,孤兒進程會轉變成後台進程!
*關於前後台進程,只要是能從鍵盤上獲取數據的,就是前台進程,反之則為後台進程。

  • 前台進程無論何時都只能有一個!
  • 後台進程可以有無數個

進程優先級(理論重要性 >>>>> 實操)

  1. 何謂優先級?

    權限說明了「能不能」的問題,而優先級就是來說明能的情況下的先後問題 --- 進程能得到某種資源的前提下,獲取該資源的先後順序!

  2. 為什麼要有優先級?
    因爲資源有限!優先級就是用來分配資源的!
  3. Linux作業系統下是如何做的? --- 進程調度 --- task_struct中的兩個整形變量
    優先級.png

    • UID : 代表執⾏者的⾝份
    • PID : 代表這個進程的代號
    • PPID :代表這個進程是由哪個進程發展衍⽣⽽來的,亦即⽗進程的代號
    • PRI :代表這個進程可被執⾏的優先級,其值越⼩越早被執⾏
    • NI :代表這個行程的nice值

    PRI是優先級,那NI呢?NI是一個NICE值,其用來修正進程的優先級數值,在Linux下要調整進程優先級,就是調整NI值
    (1) NICE的範圍?[-20,19]
    (2) 為什麼是這個範圍?
    這是 Linux(源自 UNIX)調度器設計時的約定,為了提供一個平衡範圍

    • 負值代表「提高優先級」
    • 正值代表「降低優先級」
    • 總共 40 個級別,這樣設計是為了 1. 給系統管理員足夠的微調空間 2. 避免讓範圍過大導致難以管理

    除此之外,因爲目前的作業系統都是分時系統 -- 為了要確保給進程分配的時間片是相對公平、公正的,讓每一個進程都能較均 衡的在一段時間內獲取CPU的資源。
    (3) 進程優先級變化範圍是多少?[60,99]

查看進程優先級的命令

  1. top : 進⼊top後按「r」‒>輸⼊進程PID‒>輸⼊nice值
  2. nice : nice [選項] 命令 [參數] nice -n 10 ./myprocess

    • 啟動時設定進程的 NI 值
    • 只能在創建新進程時使用

      # 以 NI=10 啟動程式(較低優先級)
      nice -n 10 ./myprocess
      
      # 以 NI=-5 啟動程式(較高優先級,需要 root 權限)
      sudo nice -n -5 ./myprocess
      
      # 預設情況下,nice 會降低優先級
      nice ./myprocess  # 相當於 nice -n 10 ./myprocess
  3. renice [選項] 優先級 [選項] 進程ID
    選項:

    • -n <數值>:設定新的 NI 值
    • -p :指定進程 ID
    • -u <使用者>:指定使用者的所有進程
    • -g <群組>:指定群組的所有進程
      和nice的不同之處
    • 修改執行中進程的 NI 值
    • 可以修改已存在的進程

      # 修改指定 PID 的進程 NI 值
      renice 10 1234  # 將 PID 1234 的進程 NI 值設為 10
      
      # 使用 -n 選項(更明確)
      renice -n 10 -p 1234
      
      # 修改使用者的所有進程
      renice -n 5 -u username
      
      # 修改多個進程
      renice -n 10 -p 1234 5678 9012
      
      # 提高優先級(需要 root 權限)
      sudo renice -n -5 -p 1234

補充概念-競爭、獨⽴、並⾏、並發

  • 競爭性: 系統進程數⽬眾多,⽽CPU資源只有少量,甚⾄1個,所以進程之間是具有競爭屬性的。為了⾼效完成任務,更合理競爭相關資源,便具備了優先級
  • 獨⽴性: 多進程運⾏,需獨享各種資源,多進程運⾏期間互不⼲擾
  • 並⾏: 多個進程在多個CPU下分別,同時進⾏運⾏,這稱之為並⾏
  • 並發: 多個進程在⼀個CPU下採⽤進程切換的⽅式,在⼀一段時間之內,讓多個進程都得以推進,稱之為並發

Linux2.6內核進程O(1)調度佇列

O(1)調度佇列.png

活動佇列

時間⽚還沒結束的所有行程都依照優先權放在該佇列
• nr_active: 總共有多少運⾏狀態的進程
• queue[140]: ⼀個元素就是⼀個進程佇列,相同優先權的進程依照FIFO規則進⾏排隊調度,所以,數組下標就是優先權!

從這個結構中,選擇⼀個最適合的進程,過程是如何做的呢?

  1. 從0下表開始遍歷queue[140]
  2. 找出第⼀個⾮空佇列,該佇列必定為優先權最⾼的佇列
  3. 拿到選取佇列的第⼀個進程,開始運⾏,調度完成!
  4. 遍歷queue[140]時間複雜度是常數!但還是太低效了!
    bitmap[5]:⼀共140個優先權,⼀共140個進程佇列,為了提⾼找出⾮空佇列的效率,就可以⽤5*32個⽐特位表⽰佇列是否為空,這樣,便可以⼤⼤提⾼查找效率!

小結論:

  • 優先級數字,本身就是數組的下標(優先級+40)
  • 根據優先級選擇的過程,本身是一個hash的過程
  • 一旦確定是哪個佇列的,接下來就是FIFO
  • 進程飢餓問題:長時間內不斷有進程進入,導致其他進程無法被調度

過期佇列

過期佇列與活動佇列結構⼀模⼀樣!
過期佇列上放置的進程,都是時間⽚耗盡的進程,當活動佇列上的進程都被處理完畢之後,對過期佇列的進程進⾏時間⽚重新計算。

active指標和expired指標

active指標永遠指向活動佇列;expired指標永遠指向過期佇列。
可是活動佇列上的進程會越來越少,過期佇列上的進程會越來越多,因為進程時間⽚到期時⼀直都存在的。
-> 沒關係,在適當的時候,只要能夠交換active指針和expired指針的內容,就相當於有具有了⼀批新的活動進程。

結論:
在系統當中找出⼀個最適合調度的進程的時間複雜度是⼀個常數,不隨著進程增加⽽導致時間成本增加,我們稱之為進程調度O(1)演算法!