【Linux】Linux系統程式設計入門

不洗碗工作室發表於2019-03-04

作者:不洗碗工作室 – Marklux
出處:marklux.cn/blog/56
版權歸作者所有,轉載請註明出處

檔案和檔案系統

檔案是linux系統中最重要的抽象,大多數情況下你可以把linux系統中的任何東西都理解為檔案,很多的互動操作其實都是通過檔案的讀寫來實現的。

檔案描述符

在linux核心中,檔案是用一個整數來表示的,稱為 檔案描述符,通俗的來說,你可以理解它是檔案的id(唯一識別符號)

普通檔案

  • 普通檔案就是位元組流組織的資料。
  • 檔案並不是通過和檔名關聯來實現的,而是通過關聯索引節點來實現的,檔案節點擁有檔案系統為普通檔案分配的唯一整數值(ino),並且存放著一些檔案的相關後設資料。

目錄與連結

  • 正常情況下檔案是通過檔名來開啟的。
  • 目錄是可讀名稱到索引編號之間的對映,名稱和索引節點之間的配對稱為連結
  • 可以把目錄看做普通檔案,只是它包含著檔名稱到索引節點的對映(連結)

程式

程式是僅次於檔案的抽象概念,簡單的理解,程式就是正在執行的目的碼,活動的,正在執行的程式。不過在複雜情況下,程式還會包含著各種各樣的資料,資源,狀態甚至虛擬計算機。

你可以這麼理解程式:它是競爭計算機資源的基本單位。

程式、程式與執行緒

  1. 程式

    程式,簡單的來說就是存在磁碟上的二進位制檔案,是可以核心所執行的程式碼

  2. 程式

    當一個使用者啟動一個程式,將會在記憶體中開啟一塊空間,這就創造了一個程式,一個程式包含一個獨一無二的PID,和執行者的許可權屬性引數,以及程式所需程式碼與相關的資料。

    程式是系統分配資源的基本單位。

    一個程式可以衍生出其他的子程式,子程式的相關許可權將會沿用父程式的相關許可權。

  3. 執行緒

    每個程式包含一個或多個執行緒,執行緒是程式內的活動單元,是負責執行程式碼和管理程式執行狀態的抽象。

    執行緒是獨立執行和排程的基本單位。

程式的層次結構(父程式與子程式)

在程式執行的過程中可能會衍生出其他的程式,稱之為子程式,子程式擁有一個指明其父程式PID的PPID。子程式可以繼承父程式的環境變數和許可權引數。

於是,linux系統中就誕生了程式的層次結構——程式樹。

程式樹的根是第一個程式(init程式)。

過程呼叫的流程: fork & exec

一個程式生成子程式的過程是,系統首先複製(fork)一份父程式,生成一個暫存程式,這個暫存程式和父程式的區別是pid不一樣,而且擁有一個ppid,這時候系統再去執行(exec)這個暫存程式,讓他載入實際要執行的程式,最終成為一個子程式的存在。

程式的結束

當一個程式終止時,並不會立即從系統中刪除,核心將在記憶體中儲存該程式的部分內容,允許父程式查詢其狀態(這個被稱為等待終止程式)。

當父程式確定子程式已經終止,該子程式將會被徹底刪除。

但是如果一個子程式已經終止,但父程式卻不知道它的狀態,這個程式將會成為 殭屍程式

服務與程式

簡單的說服務(daemon)就是常駐記憶體的程式,通常服務會在開機時通過init.d中的一段指令碼被啟動。

程式通訊

程式通訊的幾種基本方式:管道,訊號量,訊息佇列,共享記憶體,快速使用者控制元件互斥。

程式,程式和執行緒

現在我們再次詳細的討論這三個概念

程式(program)

程式是指編譯過的、可執行的二進位制程式碼,儲存在儲存介質上,不執行

程式(process)

程式是指正在執行的程式。

程式包括了很多資源,擁有自己獨立的記憶體空間。

執行緒

執行緒是程式內的活動單元。

包括自己的虛擬儲存器,如棧、程式狀態如暫存器,以及指令指標。

  • 在單執行緒的程式中,執行緒即程式。而在多執行緒的程式中,多個執行緒將會共享同一個記憶體地址空間

  • 參考閱讀

PID

可以參考之前的基礎概念部分。

在C語言中,PID是由資料型別pid_t來表示的。

執行一個程式

建立一個程式,在unix系統中被分為了兩個流程。

  1. 把程式載入記憶體並執行程式映像的操作:exec
  2. 建立一個新程式:fork

exec

最簡單的exec系統呼叫函式:execl()

  • 函式原型:
int execl(const char * path,const chr * arg,...)複製程式碼

execl()呼叫將會把path所指的路徑的映像載入記憶體,替換當前程式的映像。

引數arg是以第一個引數,引數內容是可變的,但最後必須以NULL結尾。

  • 舉例:
int ret;

ret = execl("/bin/vi","vi",NULL);

if (ret == -1) {
    perror("execl");
}複製程式碼

上面的程式碼將會通過/bin/vi替換當前執行的程式

注意這裡的第一個引數vi,是unix系統的預設慣例,當建立、執行程式時,shell會把路徑中的最後部分放入新程式的第一個引數,這樣可以使得程式解析出二進位制映像檔案的名字。

int ret;

ret = execl("/bin/vi","vi","/home/mark/a.txt",NULL);

if (ret == -1) {
    perror("execl");
}複製程式碼

上面的程式碼是一個非常有代表性的操作,這相當於你在終端執行以下命令:

vi /home/mark/a.txt複製程式碼
  • 返回值:

正常情況下其實execl()不會返回,呼叫成功後會跳轉到新的程式入口點。

成功的execl()呼叫,將改變地址空間和程式映像,還改變了很多程式的其他屬性。

不過程式的PID,PPID,優先順序等引數將會被保留下來,甚至會保留下所開啟的檔案描述符(這就意味著它可以訪問所有這些原本程式開啟的檔案)。

失敗後將會返回-1,並更新errno。

其他exec系函式

略,使用時查詢

fork

通過fork()系統呼叫,可以建立一個和當前程式映像一模一樣的子程式。

  • 函式原型
pid_t fork(void)複製程式碼

呼叫成功後,會建立一個新的程式(子程式),這兩個程式都會繼續執行。

  • 返回值

如果呼叫成功,
父程式中,fork()會返回子程式的pid,在子程式中返回0;
如果失敗,返回-1,並更新errno,不會建立子程式。

  • 舉例

我們看下面這段程式碼

#include <unistd.h>
#include <stdio.h>
int main ()
{
    pid_t fpid; //fpid表示fork函式返回的值
    int count=0;

    printf("this is a process
");

    fpid=fork();

    if (fpid < 0)
        printf("error in fork!");
    else if (fpid == 0) {
        printf("i am the child process, my process id is %d
",getpid());
        printf("我是爹的兒子
");
        count++;
    }
    else {
        printf("i am the parent process, my process id is %d
",getpid());
        printf("我是孩子他爹
");
        count++;
    }
    printf("統計結果是: %d
",count);
    return 0;
}複製程式碼

這段程式碼的執行結果比較神奇,是這樣的:

this is a process
i am the parent process, my process id is 21448
我是孩子他爹
統計結果是: 1
i am the child process, my process id is 21449
我是爹的兒子
統計結果是: 1複製程式碼

在執行了fork()之後,這個程式就擁有了兩個程式,父程式和子程式分別往下繼續執行程式碼,進入了不同的if分支。

如何理解pid在父子程式中不同?

其實就相當於連結串列,程式形成了連結串列,父程式的pid指向了子程式的pid,因為子程式沒有子程式,所以pid為0。

寫時複製

傳統的fork機制是,呼叫fork時,核心會複製所有的內部資料結構,複製程式的頁表項,然後把父程式的地址空間按頁複製給子程式(非常耗時)。

現代的fork機制採用了一種惰性演算法的優化策略。

為了避免複製時系統開銷,就儘可能的減少“複製”操作,當多個程式需要讀取他們自己那部分資源的副本時,並不複製多個副本出來,而是為每個程式設定一個檔案指標,讓它們讀取同一個實際檔案。

顯然這樣的方式會在寫入時產生衝突(類似併發),於是當某個程式想要修改自己的那個副本時,再去複製該資源,(只有寫入時才複製,所以叫寫時複製)這樣就減少了複製的頻率。

聯合例項

在程式中建立一個子程式,開啟另一個應用。

pid_t pid;

pid = fork();

if (pid == -1)
    perror("fork");

//子程式
if (!pid) {
    const char * args[] = {"windlass",NULL};

    int ret;

    // 引數以陣列方式傳入
    ret = execv("/bin/windlass",args);

    if (ret == -1) {
        perror("execv");
        exit(EXIT_FAILURE);
    }
}複製程式碼

上面的程式建立了一個子程式,並且使子程式執行了/bin/windlas程式。

終止程式

exit()

  • 函式原型
void exit (int status)複製程式碼

該函式用於終止當前的程式,引數status只用於標識程式的退出狀態,這個值將會被傳送給當前程式的父程式用於判斷。

還有一些其他的終止呼叫函式,在此不贅述。

等待子程式終止

如何通知父程式子程式終止?可以通過訊號機制來實現這一點。但是在很多情況下,父程式需要知道有關子程式的更詳細的資訊(比如返回值),這時候簡單的訊號通知就顯得無能為力了。

如果終止時,子程式已經完全被銷燬,父程式就無法獲取關於子程式的任何資訊。

於是unix最初做了這樣的設計,如果一個子程式在父程式之前結束,核心就把這個子程式設定成一種特殊的執行狀態,這種狀態下的程式被稱為殭屍程式,它只保留最小的概要資訊,等待父程式獲取到了這些資訊之後,才會被銷燬。

wait()

  • 函式原型
pid_t wait(int * status);複製程式碼

這個函式可以用於獲取已經終止的子程式的資訊。

呼叫成功時,會返回已終止的子程式的pid,出錯時返回-1。如果沒有子程式終止會導致呼叫的阻塞直到有一個子程式終止。

waitpid()

  • 函式原型
pid_t waitpid(pid_t pid,int * status,int options);複製程式碼

waitpid()是一個更為強大的系統呼叫,支援更細粒度的管控。

一些其他可能會遇到的等待函式

  • wait3()

  • wait4()

簡單的說,wait3等待任意一個子程式的終止,wait4等待一個指定子程式的終止。

建立並等待新程式

很多時候我們會遇到下面這種情景:

你建立了一個新程式,你想等待它呼叫完之後再繼續執行你自己的程式,也就是說,建立一個新程式並立即開始等待它的終止。

一個合適的選擇是system():

int system(const char * command);複製程式碼

system()函式將會呼叫command提供的命令,一般用於執行簡單的工具和shell指令碼。

成功時,返回的是執行command命令所得到的返回狀態。

你可以使用fork(),exec(),waitpid()來實現一個system()。

下面給出一個簡單的實現:

int my_system(const char * cmd)
{
    int status;
    pid_t pid;

    pid = fork();

    if (pid == -1) {
        return -1;
    }

    else if (pid == 0) {
        const char * argv[4];

        argv[0] = "sh";
        argv[1] = "-c";
        argv[2] = cmd;
        argv[3] = NULL;

        execv("bin/sh",argv);
        // 這傳參呼叫好像有型別轉換問題

        exit(-1);

    }//子程式

    //父程式
    if (waitpid(pid,&status,0) == -1)
        return -1;
    else if (WIFEXITED(status))
        return WEXITSTATUS(status);

    return -1;
}複製程式碼

幽靈程式

上面我們談論到殭屍程式,但是如果父程式沒有等待子程式的操作,那麼它所有的子程式都將成為幽靈程式,幽靈程式將會一直存在(因為等不到父程式呼叫,就一直不終止),導致系統執行速度的拖慢。

正常情況下我們不該讓這種情況發生,然而如果父程式在子程式結束之前就結束了,或者父程式還沒有機會等待其殭屍程式的子程式,就先結束了,這樣就不可避免的產生了幽靈程式。

linux核心有一個機制來避免這樣的情況發生。

無論何時,只要有程式結束,核心就會遍歷它的所有子程式,並且把他們的父程式重新設定為init,而init會週期性的等待所有的子程式,以確保沒有長時間存在的幽靈程式。

程式與許可權

略,待補充

會話和程式組

程式組

每個程式都屬於某個程式組,程式組就是由一個或者多個為了實現作業控制而相互關聯的程式組成的。

一個程式組的id是程式組首程式的pid(如果一個程式組只有一個程式,那程式組和程式其實沒啥區別)。

程式組的意義在於,訊號可以傳送給程式組中的所有程式。這樣可以實現對多個程式的同時操作。

會話

會話是一個或者多個程式組的集合。

一般來說,會話(session)和shell沒有什麼本質上的區別。

我們通常使用使用者登入一個終端進行一系列操作這樣的例子來描述一次會話。

  • 舉例
$cat ship-inventory.txt | grep booty|sort複製程式碼

上面就是在某次會話中的一個shell命令,它會產生一個由3個程式組成的程式組。

守護程式(服務)

守護程式(daemon)執行在後臺,不與任何控制終端相關聯。通常在系統啟動時通過init指令碼被呼叫而開始執行。

在linux系統中,守護程式和服務沒有什麼區別。

對於一個守護程式,有兩個基本的要求:其一:必須作為init程式的子程式執行,其二:不與任何控制終端互動。

產生一個守護程式的流程

  1. 呼叫fork()來建立一個子程式(它即將成為守護程式)
  2. 在該程式的父程式中呼叫exit(),這保證了父程式的父程式在其子程式結束時會退出,保證了守護程式的父程式不再繼續執行,而且守護程式不是首程式。(它繼承了父程式的程式組id,而且一定不是leader)
  3. 呼叫setsid(),給守護程式建立一個新的程式組和新的會話,並作為兩者的首程式。這可以保證不存在和守護程式相關聯的控制終端。
  4. 呼叫chdir(),將當前工作目錄改為根目錄。這是為了避免守護程式執行在原來fork的父程式開啟的隨機目錄下,便於管理。
  5. 關閉所有的檔案描述符。
  6. 開啟檔案描述符0,1,2(stdin,stdout,err),並把它們重定向到/dev/null

daemon()

用於實現上面的操作來產生一個守護程式

  • 函式原型
int daemon(int nochdir,int noclose);複製程式碼

如果引數nochdir是非0值,就不會將工作目錄定向到根目錄。
如果引數noclose是非0值,就不會關閉所有開啟的檔案描述符。

成功時返回0,失敗返回-1。

注意呼叫這個函式生成的函式是父程式的副本(fork),所以最終生成的守護程式的樣子就是父程式的樣子,一般來說,就是在父程式中寫好要執行在後臺的功能程式碼,然後呼叫daemon()來把這些功能包裝成一個守護程式。

這樣子看上去好像是把當前執行的程式包裝成了一個守護程式,但其實包裝的是它派生出的一個副本。

執行緒

基礎概念

執行緒是程式內的執行單元(比程式更低一層的概念),具體包括 虛擬處理器,堆疊,程式狀態等。

可以認為 執行緒是作業系統排程的最小執行單元。

現代作業系統對使用者空間做兩個基礎抽象:虛擬記憶體和虛擬處理器。這使得程式內部“感覺”自己獨佔機器資源。

虛擬記憶體

系統會為每個程式分配獨立的記憶體空間,這會讓程式以為自己獨享全部的RAM。

但是同一個程式內的所有執行緒共享該程式的記憶體空間。

虛擬處理器

這是一個針對執行緒的概念,它讓每個執行緒都“感覺”自己獨享CPU。實際上對於程式也是一樣的。

多執行緒

多執行緒的好處

  • 程式設計抽象

    模組化的設計模式

  • 併發

    在多核處理器上可以實現真正的併發,提高系統吞吐量

  • 提高響應能力

    防止序列運算僵死

  • 防止i/o阻塞

    避免單執行緒下,i/o操作導致整個程式阻塞的情況。此外也可以通過非同步i/o和非阻塞i/o解決。

  • 減少上下文切換

    多執行緒的切換消耗的效能遠比程式間的上下文切換小的多

  • 記憶體共享

    因為同一程式內的執行緒可以共享記憶體,在某些場景下可以利用這些特性,用多執行緒取代多程式。

多執行緒的代價

除錯難度極大。

在同一個記憶體空間內併發性的讀寫操作會引發多種問題(如髒資料),對多程式情景下的資源同步變得困難,而且多個獨立執行的執行緒其時間和順序具有不可預測性,會導致各種各樣奇怪的問題。

這一點可以參考併發帶來的問題。

執行緒模型

執行緒的概念同時存在於核心和使用者空間中。

核心級執行緒模型

每個核心執行緒直接轉換成使用者空間的執行緒。即核心執行緒:使用者空間執行緒=1:1

使用者級執行緒模型

這種模型下,一個保護了n個執行緒的使用者程式只會對映到一個核心程式。即n:1。

可以減少上下文切換的成本,但在linux下沒什麼意義,因為linux下程式間的上下文切換本身就沒什麼消耗,所以很少使用。

混合式執行緒模型

上述兩種模型的混合,即n:m型。

很難實現。

*協同程式

‌提供了比執行緒更輕量級的執行單位。

執行緒模式

每個連線對應一個執行緒

也就是阻塞式的I/O,實際就是單執行緒模式

執行緒以序列的方式執行,一個執行緒遇到I/O時執行緒必須被掛起等待直到操作完成後,才能再繼續執行。

事件驅動的執行緒模式

單執行緒的操作模型中,大部分的系統負荷在於等待(尤其是I/O操作),因此在事件驅動的模式下,把這些等待操作從執行緒的執行過程中剝離掉,通過傳送非同步I/O請求或者是I/O多路複用,引入事件迴圈和回撥來處理執行緒和I/O之間的關係。

有關I/O的幾種模式,參考這裡

簡要概括一下,分為四種:

  • 阻塞IO:序列處理,單執行緒,同步等待
  • 非阻塞IO:執行緒發起IO請求後將立即得到結果而不是等待,如果IO沒有處理完將返回ERROR,需要執行緒自己主動去向Kernel不斷請求來判斷IO是否完成
  • 非同步IO:執行緒發起IO請求後,立即得到結果,Kernel執行完IO後會主動傳送SIGNAL去通知執行緒
  • 事件驅動IO:屬於非阻塞IO的一個升級,主要用於連線較多的情況,讓Kernel去監視多個socket(每個socket都是非阻塞式的IO),哪個socket有結果了就繼續執行哪個socket。

併發,並行,競爭!

併發和並行

併發,是指同一時間週期內需要執行(處理)多個執行緒。

並行,是指同一時刻有多個執行緒在執行。

本質上,併發是一種程式設計概念,而並行是一種硬體屬性,併發可以通過並行的方式實現,也可以不通過並行的方式實現(單cpu)。

競爭

併發程式設計帶來的最大挑戰就是競爭,這主要是因為多個執行緒同時執行時,執行結果的順序存在不可預料性

  • 一個最簡單的示範,可以參考java併發程式設計中的基本例子。

    請看下面這行程式碼:

      x++;複製程式碼

    假設x的初始值為5,我們使用兩個執行緒同時執行這行程式碼,會出現很多不一樣的結果,即執行完成後,x的值可能為6,也可能為7。(這個是併發最基本的示範,自己理解一下很容易明白。)

    原因簡要描述為下:

    一個執行緒執行x++的過程大概分為3步:

    1. 把x載入到暫存器
    2. 把暫存器的值+1
    3. 把暫存器的值寫回到x中

      當兩個執行緒出現競爭的時候,就是這3步執行的過程在時間上出現了不可預料性,假設執行緒1,2將x載入到暫存器的時候x都是5,但當執行緒1寫回x時,x成為6,執行緒2寫回x時,x還是6,這就與初衷相悖。

      如果有更多的執行緒結果將變得更加難以預料。

解決競爭的手段:同步

簡要的說,就是在會發生競爭的資源上,取消併發,而是採用同步的方式訪問和操作。

最常見的,處理併發的機制,就是鎖機制了,當然系統層面的鎖比DBMS等其他一些複雜系統的鎖要簡單一些(不存在共享鎖,排他鎖等一些較為複雜的概念)。

但是鎖會帶來兩個問題:死鎖餓死

解決這兩個問題需要一些機制以及設計理念。具體有關鎖的部分可以參考DBMS的併發筆記。

關於鎖,有一點要記住。

鎖住的是資源,而不是程式碼

編寫程式碼時應該切記這個原則。

系統執行緒實現:PThreads

原始的linux系統呼叫中,沒有像C++11或者是Java那樣完整的執行緒庫。

整體看來pthread的api比較冗餘和複雜,但是基本操作也主要是 建立、退出等。

需要留意的一點是linux機制下,執行緒存在一個被稱為joinable的狀態。下面簡要了解一下:

Join和Detach

這塊的概念,非常類似於之前父子程式那部分,等待子程式退出的內容(一系列的wait函式)。

linux機制下,執行緒存在兩種不同的狀態:joinableunjoinable

如果一個執行緒被標記為joinable時,即便它的執行緒函式執行完了,或者使用了pthread_exit()結束了該執行緒,它所佔用的堆疊資源和程式描述符都不會被釋放(類似殭屍程式),這種情況應該由執行緒的建立者呼叫pthread_join()來等待執行緒的結束並回收其資源(類似wait系函式)。預設情況下建立的執行緒都是這種狀態。

如果一個執行緒被標記成unjoinable,稱它被分離(detach)了,這時候如果該執行緒結束,所有它的資源都會被自動回收。省去了給它擦屁股的麻煩。

因為建立的執行緒預設都是joinable的,所以要麼在父執行緒呼叫pthread_detach(thread_id)將其分離,要麼線上程內部,呼叫pthread_detach(pthread_self())來把自己標記成分離的。

相關文章