首頁 > 軟體

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

2020-06-16 17:35:30

前言

使用檔案進行進程間通訊應該是最先學會的一種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] = '';
    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] = '';
    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則沒有這個問題。

為了對比flock的行為,我們在此列出使用lockf的相同例子,來演示一下它們的不同:

[zorro@zorrozou-pc0 locktest]$ cat lockf.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 (lockf(fd, F_LOCK, 0) < 0) {
        perror("lockf()");
        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 (lockf(fd, F_LOCK, 0) < 0) {
            perror("lockf()");
            exit(1);
        }
        printf("%d: locked!n", getpid());
        exit(0);
    }
    wait(NULL);
    unlink(PATH);   
    exit(0);
}

編譯執行的結果是:

[zorro@zorrozou-pc0 locktest]$ ./lockf 
27262: locked!

在子進程不用open重新開啟檔案的情況下,進程執行仍然被阻塞在子進程lockf加鎖的操作上。關於fcntl對檔案實現記錄鎖的詳細內容,大家可以參考《UNIX環境高階程式設計》中關於記錄鎖的14.3章節。

標準IO庫檔案鎖

C語言的標準IO庫中還提供了一套檔案鎖,它們的原型如下:

#include <stdio.h>

void flockfile(FILE *filehandle);
int ftrylockfile(FILE *filehandle);
void funlockfile(FILE *filehandle);

從實現角度來說,stdio庫中實現的檔案鎖與flock或lockf有本質區別。作為一種標準庫,其實現的鎖必然要考慮跨平台的特性,所以其結構都是在使用者態的FILE結構體中實現的,而非核心中的資料結構來實現。這直接導致的結果就是,標準IO的鎖在多進程環境中使用是有問題的。進程在fork的時候會複製一整套父進程的地址空間,這將導致子進程中的FILE結構與父進程完全一致。就是說,父進程如果加鎖了,子進程也將持有這把鎖,父進程沒加鎖,子進程由於地址空間跟父進程是獨立的,所以也無法通過FILE結構體檢查別的進程的使用者態空間是否家了標準IO庫提供的檔案鎖。這種限制導致這套檔案鎖只能處理一個進程中的多個執行緒之間共用的FILE 的進行檔案操作。就是說,多個執行緒必須同時操作一個用fopen開啟的FILE 變數,如果內部自己使用fopen重新開啟檔案,那麼返回的FILE *地址不同,也起不到執行緒的互斥作用。

我們分別將兩種使用執行緒的狀態的例子分別列出來,第一種是執行緒之間共用同一個FILE *的情況,這種情況互斥是沒問題的:

[zorro@zorro-pc locktest]$ cat racing_pthread_sharefp.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>
#include <pthread.h>

#define COUNT 100
#define NUM 64
#define FILEPATH "/tmp/count"
static FILE *filep;

void *do_child(void *p)
{
    int fd;
    int ret, count;
    char buf[NUM];

    flockfile(filep);

    if (fseek(filep, 0L, SEEK_SET) == -1) {
        perror("fseek()");
    }
    ret = fread(buf, NUM, 1, filep);

    count = atoi(buf);
    ++count;
    sprintf(buf, "%d", count);
    if (fseek(filep, 0L, SEEK_SET) == -1) {
        perror("fseek()");
    }
    ret = fwrite(buf, strlen(buf), 1, filep);

    funlockfile(filep);

    return NULL;
}

int main()
{
    pthread_t tid[COUNT];
    int count;

    filep = fopen(FILEPATH, "r+");
    if (filep == NULL) {
        perror("fopen()");
        exit(1);
    }

    for (count=0;count<COUNT;count++) {
        if (pthread_create(tid+count, NULL, do_child, NULL) != 0) {
            perror("pthread_create()");
            exit(1);
        }
    }

    for (count=0;count<COUNT;count++) {
        if (pthread_join(tid[count], NULL) != 0) {
            perror("pthread_join()");
            exit(1);
        }
    }

    fclose(filep);

    exit(0);
}

另一種情況是每個執行緒都fopen重新開啟一個描述符,此時執行緒是不能互斥的:

[zorro@zorro-pc locktest]$ cat racing_pthread_threadfp.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>
#include <pthread.h>

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

void *do_child(void *p)
{
    int fd;
    int ret, count;
    char buf[NUM];
    FILE *filep;

    filep = fopen(FILEPATH, "r+");
    if (filep == NULL) {
        perror("fopen()");
        exit(1);
    }

    flockfile(filep);

    if (fseek(filep, 0L, SEEK_SET) == -1) {
        perror("fseek()");
    }
    ret = fread(buf, NUM, 1, filep);

    count = atoi(buf);
    ++count;
    sprintf(buf, "%d", count);
    if (fseek(filep, 0L, SEEK_SET) == -1) {
        perror("fseek()");
    }
    ret = fwrite(buf, strlen(buf), 1, filep);

    funlockfile(filep);

    fclose(filep);
    return NULL;
}

int main()
{
    pthread_t tid[COUNT];
    int count;


    for (count=0;count<COUNT;count++) {
        if (pthread_create(tid+count, NULL, do_child, NULL) != 0) {
            perror("pthread_create()");
            exit(1);
        }
    }

    for (count=0;count<COUNT;count++) {
        if (pthread_join(tid[count], NULL) != 0) {
            perror("pthread_join()");
            exit(1);
        }
    }


    exit(0);
}

以上程式大家可以自行編譯執行看看效果。

檔案鎖相關命令

系統為我們提供了flock命令,可以方便我們在命令列和shell指令碼中使用檔案鎖。需要注意的是,flock命令是使用flock系統呼叫實現的,所以在使用這個命令的時候請注意進程關係對檔案鎖的影響。flock命令的使用方法和在指令碼程式設計中的使用可以參見我的另一篇文章《shell程式設計之常用技巧》中的bash並行程式設計和flock這部分內容,在此不在贅述。

我們還可以使用lslocks命令來檢視當前系統中的檔案鎖使用情況。一個常見的現實如下:

[root@zorrozou-pc0 ~]# lslocks 
COMMAND           PID   TYPE  SIZE MODE  M      START        END PATH
firefox         16280  POSIX    0B WRITE 0          0          0 /home/zorro/.mozilla/firefox/bk2bfsto.default/.parentlock
dmeventd          344  POSIX    4B WRITE 0          0          0 /run/dmeventd.pid
gnome-shell       472  FLOCK    0B WRITE 0          0          0 /run/user/120/wayland-0.lock
flock           27452  FLOCK    0B WRITE 0          0          0 /tmp/lock
lvmetad           248  POSIX    4B WRITE 0          0          0 /run/lvmetad.pid

這其中,TYPE主要表示鎖型別,就是上文我們描述的flock和lockf。lockf和fcntl實現的鎖事POSIX型別。M表示是否事強制鎖,0表示不是。如果是記錄鎖的話,START和END表示鎖住檔案的記錄位置,0表示目前鎖住的是整個檔案。MODE主要用來表示鎖的許可權,實際上這也說明了鎖的共用屬性。在系統底層,互斥鎖表示為WRITE,而共用鎖表示為READ,如果這段出現*則表示有其他進程正在等待這個鎖。其餘引數可以參考man lslocks。

最後

本文通過檔案盒檔案鎖的例子,引出了競爭條件這樣在進程間通訊中需要解決的問題。並深入探討了系統程式設計中常用的檔案鎖的實現和應用特點。希望大家對進程間通訊和檔案鎖的使用有更深入的理解。

本文永久更新連結地址http://www.linuxidc.com/Linux/2016-08/134230.htm


IT145.com E-mail:sddin#qq.com