【Linux】Linux系統編程入門

做者:不洗碗工做室 - Marklux
出處:marklux.cn/blog/56
版權歸做者全部,轉載請註明出處java

文件和文件系統

文件是linux系統中最重要的抽象,大多數狀況下你能夠把linux系統中的任何東西都理解爲文件,不少的交互操做其實都是經過文件的讀寫來實現的。linux

文件描述符

在linux內核中,文件是用一個整數來表示的,稱爲 文件描述符,通俗的來講,你能夠理解它是文件的id(惟一標識符)算法

普通文件

  • 普通文件就是字節流組織的數據。
  • 文件並非經過和文件名關聯來實現的,而是經過關聯索引節點來實現的,文件節點擁有文件系統爲普通文件分配的惟一整數值(ino),而且存放着一些文件的相關元數據。

目錄與連接

  • 正常狀況下文件是經過文件名來打開的。
  • 目錄是可讀名稱到索引編號之間的映射,名稱和索引節點之間的配對稱爲連接
  • 能夠把目錄看作普通文件,只是它包含着文件名稱到索引節點的映射(連接)

進程

進程是僅次於文件的抽象概念,簡單的理解,進程就是正在執行的目標代碼,活動的,正在運行的程序。不過在複雜狀況下,進程還會包含着各類各樣的數據,資源,狀態甚至虛擬計算機。shell

你能夠這麼理解進程:它是競爭計算機資源的基本單位。編程

進程、程序與線程

  1. 程序設計模式

    程序,簡單的來講就是存在磁盤上的二進制文件,是能夠內核所執行的代碼 api

  2. 進程數組

    當一個用戶啓動一個程序,將會在內存中開啓一塊空間,這就創造了一個進程,一個進程包含一個獨一無二的PID,和執行者的權限屬性參數,以及程序所需代碼與相關的資料。bash

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

    一個進程能夠衍生出其餘的子進程,子進程的相關權限將會沿用父進程的相關權限。

  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\n");

    fpid=fork();

    if (fpid < 0)
        printf("error in fork!");
    else if (fpid == 0) {
        printf("i am the child process, my process id is %d\n",getpid());
        printf("我是爹的兒子\n");
        count++;
    }
    else {
        printf("i am the parent process, my process id is %d\n",getpid());
        printf("我是孩子他爹\n");
        count++;
    }
    printf("統計結果是: %d\n",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())來把本身標記成分離的。

相關文章
相關標籤/搜索