Linux程式間通訊中的檔案和檔案鎖

窮佐羅的Linux書發表於2016-08-07

前言

使用檔案進行程式間通訊應該是最先學會的一種IPC方式。任何程式語言中,檔案IO都是很重要的知識,所以使用檔案進行程式間通訊就成了很自然被學會的一種手段。考慮到系統對檔案本身存在快取機制,使用檔案進行IPC的效率在某些多讀少寫的情況下並不低下。但是大家似乎經常忘記IPC的機制可以包括“檔案”這一選項。

我們首先引入檔案進行IPC,試圖先使用檔案進行通訊引入一個競爭條件的概念,然後使用檔案鎖解決這個問題,從而先從檔案的角度來管中窺豹的看一下後續相關IPC機制的總體要解決的問題。閱讀本文可以幫你解決以下問題:

  1. 什麼是競爭條件(racing)?。
  2. flock和lockf有什麼區別?
  3. flockfile函式和flock與lockf有什麼區別?
  4. 如何使用命令檢視檔案鎖?

競爭條件(racing)

我們的第一個例子是多個程式寫檔案的例子,雖然還沒做到通訊,但是這比較方便的說明一個通訊時經常出現的情況:競爭條件。假設我們要併發100個程式,這些程式約定好一個檔案,這個檔案初始值內容寫0,每一個程式都要開啟這個檔案讀出當前的數字,加一之後將結果寫回去。在理想狀態下,這個檔案最後寫的數字應該是100,因為有100個程式開啟、讀數、加1、寫回,自然是有多少個程式最後檔案中的數字結果就應該是多少。但是實際上並非如此,可以看一下這個例子:

[zorro@zorrozou-pc0 process]$ cat racing.c
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <fcntl.h>
#include <string.h>
#include <sys/file.h>
#include <wait.h>

#define COUNT 100
#define NUM 64
#define FILEPATH "/tmp/count"

int do_child(const char *path)
{
    /* 這個函式是每個子程式要做的事情
    每個子程式都會按照這個步驟進行操作:
    1. 開啟FILEPATH路徑的檔案
    2. 讀出檔案中的當前數字
    3. 將字串轉成整數
    4. 整數自增加1
    5. 將證書轉成字串
    6. lseek調整檔案當前的偏移量到檔案頭
    7. 將字串寫會檔案
    當多個程式同時執行這個過程的時候,就會出現racing:競爭條件,
    多個程式可能同時從檔案獨到同一個數字,並且分別對同一個數字加1並寫回,
    導致多次寫回的結果並不是我們最終想要的累積結果。 */
    int fd;
    int ret, count;
    char buf[NUM];
    fd = open(path, O_RDWR);
    if (fd < 0) {
        perror("open()");
        exit(1);
    }
    /*  */
    ret = read(fd, buf, NUM);
    if (ret < 0) {
        perror("read()");
        exit(1);
    }
    buf[ret] = '\0';
    count = atoi(buf);
    ++count;
    sprintf(buf, "%d", count);
    lseek(fd, 0, SEEK_SET);
    ret = write(fd, buf, strlen(buf));
    /*  */
    close(fd);
    exit(0);
}

int main()
{
    pid_t pid;
    int count;

    for (count=0;count<COUNT;count++) {
        pid = fork();
        if (pid < 0) {
            perror("fork()");
            exit(1);
        }

        if (pid == 0) {
            do_child(FILEPATH);
        }
    }

    for (count=0;count<COUNT;count++) {
        wait(NULL);
    }
}

這個程式做後執行的效果如下:

[zorro@zorrozou-pc0 process]$ make racing
cc     racing.c   -o racing
[zorro@zorrozou-pc0 process]$ echo 0 > /tmp/count
[zorro@zorrozou-pc0 process]$ ./racing 
[zorro@zorrozou-pc0 process]$ cat /tmp/count 
71[zorro@zorrozou-pc0 process]$ 
[zorro@zorrozou-pc0 process]$ echo 0 > /tmp/count
[zorro@zorrozou-pc0 process]$ ./racing 
[zorro@zorrozou-pc0 process]$ cat /tmp/count 
61[zorro@zorrozou-pc0 process]$ 
[zorro@zorrozou-pc0 process]$ echo 0 > /tmp/count
[zorro@zorrozou-pc0 process]$ ./racing 
[zorro@zorrozou-pc0 process]$ cat /tmp/count 
64[zorro@zorrozou-pc0 process]$

我們執行了三次這個程式,每次結果都不太一樣,第一次是71,第二次是61,第三次是64,全都沒有得到預期結果,這就是競爭條件(racing)引入的問題。仔細分析這個程式我們可以發現這個競爭條件是如何發生的:

最開始檔案內容是0,假設此時同時開啟了3個程式,那麼他們分別讀檔案的時候,這個過程是可能併發的,於是每個程式讀到的陣列可能都是0,因為他們都在別的程式沒寫入1之前就開始讀了檔案。於是三個程式都是給0加1,然後寫了個1回到檔案。其他程式以此類推,每次100個程式的執行順序可能不一樣,於是結果是每次得到的值都可能不太一樣,但是一定都少於產生的實際程式個數。於是我們把這種多個執行過程(如程式或執行緒)中訪問同一個共享資源,而這些共享資源又有無法被多個執行過程存取的的程式片段,叫做臨界區程式碼。

那麼該如何解決這個racing的問題呢?對於這個例子來說,可以用檔案鎖的方式解決這個問題。就是說,對臨界區程式碼進行加鎖,來解決競爭條件的問題。哪段是臨界區程式碼?在這個例子中,兩端/ /之間的部分就是臨界區程式碼。一個正確的例子是:

...
    ret = flock(fd, LOCK_EX);
    if (ret == -1) {
        perror("flock()");
        exit(1);
    }

    ret = read(fd, buf, NUM);
    if (ret < 0) {
        perror("read()");
        exit(1);
    }
    buf[ret] = '\0';
    count = atoi(buf);
    ++count;
    sprintf(buf, "%d", count);
    lseek(fd, 0, SEEK_SET);
    ret = write(fd, buf, strlen(buf));
    ret = flock(fd, LOCK_UN);
    if (ret == -1) {
        perror("flock()");
        exit(1);
    }
...

我們將臨界區部分程式碼前後都使用了flock的互斥鎖,防止了臨界區的racing。這個例子雖然並沒有真正達到讓多個程式通過檔案進行通訊,解決某種協同工作問題的目的,但是足以表現出程式間通訊機制的一些問題了。當涉及到資料在多個程式間進行共享的時候,僅僅只實現資料通訊或共享機制本身是不夠的,還需要實現相關的同步或非同步機制來控制多個程式,達到保護臨界區或其他讓程式可以處理同步或非同步事件的能力。我們可以認為檔案鎖是可以實現這樣一種多程式的協調同步能力的機制,而除了檔案鎖以外,還有其他機制可以達到相同或者不同的功能,我們會在下文中繼續詳細解釋。

再次,我們並不對flock這個方法本身進行功能性講解。這種功能性講解大家可以很輕易的在網上或者通過別的書籍得到相關內容。本文更加偏重的是Linux環境提供了多少種檔案鎖以及他們的區別是什麼?

flock和lockf

從底層的實現來說,Linux的檔案鎖主要有兩種:flock和lockf。需要額外對lockf說明的是,它只是fcntl系統呼叫的一個封裝。從使用角度講,lockf或fcntl實現了更細粒度檔案鎖,即:記錄鎖。我們可以使用lockf或fcntl對檔案的部分位元組上鎖,而flock只能對整個檔案加鎖。這兩種檔案鎖是從歷史上不同的標準中起源的,flock來自BSD而lockf來自POSIX,所以lockf或fcntl實現的鎖在型別上又叫做POSIX鎖。

除了這個區別外,fcntl系統呼叫還可以支援強制鎖(Mandatory locking)。強制鎖的概念是傳統UNIX為了強制應用程式遵守鎖規則而引入的一個概念,與之對應的概念就是建議鎖(Advisory locking)。我們日常使用的基本都是建議鎖,它並不強制生效。這裡的不強制生效的意思是,如果某一個程式對一個檔案持有一把鎖之後,其他程式仍然可以直接對檔案進行各種操作的,比如open、read、write。只有當多個程式在操作檔案前都去檢查和對相關鎖進行鎖操作的時候,檔案鎖的規則才會生效。這就是一般建議鎖的行為。而強制性鎖試圖實現一套核心級的鎖操作。當有程式對某個檔案上鎖之後,其他程式即使不在操作檔案之前檢查鎖,也會在open、read或write等檔案操作時發生錯誤。核心將對有鎖的檔案在任何情況下的鎖規則都生效,這就是強制鎖的行為。由此可以理解,如果核心想要支援強制鎖,將需要在核心實現open、read、write等系統呼叫內部進行支援。

從應用的角度來說,Linux核心雖然號稱具備了強制鎖的能力,但其對強制性鎖的實現是不可靠的,建議大家還是不要在Linux下使用強制鎖。事實上,在我目前手頭正在使用的Linux環境上,一個系統在mount -o mand分割槽的時候報錯(archlinux kernel 4.5),而另一個系統雖然可以以強制鎖方式mount上分割槽,但是功能實現卻不完整,主要表現在只有在加鎖後產生的子程式中open才會報錯,如果直接write是沒問題的,而且其他程式無論open還是read、write都沒問題(Centos 7 kernel 3.10)。鑑於此,我們就不在此介紹如何在Linux環境中開啟所謂的強制鎖支援了。我們只需知道,在Linux環境下的應用程式,flock和lockf在是鎖型別方面沒有本質差別,他們都是建議鎖,而非強制鎖。

flock和lockf另外一個差別是它們實現鎖的方式不同。這在應用的時候表現在flock的語義是針對檔案的鎖,而lockf是針對檔案描述符(fd)的鎖。我們用一個例子來觀察這個區別:

[zorro@zorrozou-pc0 locktest]$ cat flock.c
#include <stdlib.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/file.h>
#include <wait.h>

#define PATH "/tmp/lock"

int main()
{
    int fd;
    pid_t pid;

    fd = open(PATH, O_RDWR|O_CREAT|O_TRUNC, 0644);
    if (fd < 0) {
        perror("open()");
        exit(1);
    }

    if (flock(fd, LOCK_EX) < 0) {
        perror("flock()");
        exit(1);
    }
    printf("%d: locked!\n", getpid());

    pid = fork();
    if (pid < 0) {
        perror("fork()");
        exit(1);
    }

    if (pid == 0) {
/*
        fd = open(PATH, O_RDWR|O_CREAT|O_TRUNC, 0644);
        if (fd < 0) {
                perror("open()");
                exit(1);
        }
*/
        if (flock(fd, LOCK_EX) < 0) {
            perror("flock()");
            exit(1);
        }
        printf("%d: locked!\n", getpid());
        exit(0);
    }
    wait(NULL);
    unlink(PATH);
    exit(0);
}

上面程式碼是一個flock的例子,其作用也很簡單:

  1. 開啟/tmp/lock檔案。
  2. 使用flock對其加互斥鎖。
  3. 列印“PID:locked!”表示加鎖成功。
  4. 開啟一個子程式,在子程式中使用flock對同一個檔案加互斥鎖。
  5. 子程式列印“PID:locked!”表示加鎖成功。如果沒加鎖成功子程式會推出,不顯示相關內容。
  6. 父程式回收子程式並推出。

這個程式直接編譯執行的結果是:

[zorro@zorrozou-pc0 locktest]$ ./flock 
23279: locked!
23280: locked!

父子程式都加鎖成功了。這個結果似乎並不符合我們對檔案加鎖的本意。按照我們對互斥鎖的理解,子程式對父程式已經加鎖過的檔案應該加鎖失敗才對。我們可以稍微修改一下上面程式讓它達到預期效果,將子程式程式碼段中的註釋取消掉重新編譯即可:

...
/*
        fd = open(PATH, O_RDWR|O_CREAT|O_TRUNC, 0644);
        if (fd < 0) {
                perror("open()");
                exit(1);
        }
*/
...

將這段程式碼上下的/ /刪除重新編譯。之後執行的效果如下:

[zorro@zorrozou-pc0 locktest]$ make flock
cc     flock.c   -o flock
[zorro@zorrozou-pc0 locktest]$ ./flock 
23437: locked!

此時子程式flock的時候會阻塞,讓程式的執行一直停在這。這才是我們使用檔案鎖之後預期該有的效果。而相同的程式使用lockf卻不會這樣。這個原因在於flock和lockf的語義是不同的。使用lockf或fcntl的鎖,在實現上關聯到檔案結構體,這樣的實現導致鎖不會在fork之後被子程式繼承。而flock在實現上關聯到的是檔案描述符,這就意味著如果我們在程式中複製了一個檔案描述符,那麼使用flock對這個描述符加的鎖也會在新複製出的描述符中繼續引用。在程式fork的時候,新產生的子程式的描述符也是從父程式繼承(複製)來的。在子程式剛開始執行的時候,父子程式的描述符關係實際上跟在一個程式中使用dup複製檔案描述符的狀態一樣(參見《UNIX環境高階程式設計》8.3節的檔案共享部分)。這就可能造成上述例子的情況,通過fork產生的多個程式,因為子程式的檔案描述符是複製的父程式的檔案描述符,所以導致父子程式同時持有對同一個檔案的互斥鎖,導致第一個例子中的子程式仍然可以加鎖成功。這個檔案共享的現象在子程式使用open重新開啟檔案之後就不再存在了,所以重新對同一檔案open之後,子程式再使用flock進行加鎖的時候會阻塞。另外要注意:除非檔案描述符被標記了close-on-exec標記,flock鎖和lockf鎖都可以穿越exec,在當前程式變成另一個執行映象之後仍然保留。

上面的例子中只演示了fork所產生的檔案共享對flock互斥鎖的影響,同樣原因也會導致dup或dup2所產生的檔案描述符對flock在一個程式內產生相同的影響。dup造成的鎖問題一般只有在多執行緒情況下才會產生影響,所以應該避免在多執行緒場景下使用flock對檔案加鎖,而lockf/fcntl則沒有這個問題。

相關文章