前言
使用文件進行進程間通信應該是最先學會的一種IPC方式。任何編程語言中,文件IO都是很重要的知識,所以使用文件進行進程間通信就成了很自然被學會的一種手段。考慮到系統對文件本身存在緩存機制,使用文件進行IPC的效率在某些多讀少寫的情況下並不低下。但是大家似乎經常忘記IPC的機制可以包括“文件”這一選項。
我們首先引入文件進行IPC,試圖先使用文件進行通信引入一個競爭條件的概念,然后使用文件鎖解決這個問題,從而先從文件的角度來管中窺豹的看一下后續相關IPC機制的總體要解決的問題。閱讀本文可以幫你解決以下問題:
- 什么是競爭條件(racing)?。
- flock和lockf有什么區別?
- flockfile函數和flock與lockf有什么區別?
- 如何使用命令查看文件鎖?
競爭條件(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的例子,其作用也很簡單:
- 打開/tmp/lock文件。
- 使用flock對其加互斥鎖。
- 打印“PID:locked!”表示加鎖成功。
- 打開一個子進程,在子進程中使用flock對同一個文件加互斥鎖。
- 子進程打印“PID:locked!”表示加鎖成功。如果沒加鎖成功子進程會退出,不顯示相關內容。
- 父進程回收子進程並退出。
這個程序直接編譯執行的結果是:
[zorro@zorrozou-pc0 locktest]$ ./flock 23279: locked! 23280: locked!
- 1
- 2
- 3
父子進程都加鎖成功了。這個結果似乎並不符合我們對文件加鎖的本意。按照我們對互斥鎖的理解,子進程對父進程已經加鎖過的文件應該加鎖失敗才對。我們可以稍微修改一下上面程序讓它達到預期效果,將子進程代碼段中的注釋取消掉重新編譯即可:
... /* 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!
- 1
- 2
- 3
- 4
此時子進程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!
- 1
- 2
在子進程不用open重新打開文件的情況下,進程執行仍然被阻塞在子進程lockf加鎖的操作上。關於fcntl對文件實現記錄鎖的詳細內容,大家可以參考《UNIX環境高級編程》中關於記錄鎖的14.3章節。
標准IO庫文件鎖
C語言的標准IO庫中還提供了一套文件鎖,它們的原型如下:
#include <stdio.h> void flockfile(FILE *filehandle); int ftrylockfile(FILE *filehandle); void funlockfile(FILE *filehandle);
- 1
- 2
- 3
- 4
- 5
從實現角度來說,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。
最后
本文通過文件盒文件鎖的例子,引出了競爭條件這樣在進程間通信中需要解決的問題。並深入探討了系統編程中常用的文件鎖的實現和應用特點。希望大家對進程間通信和文件鎖的使用有更深入的理解。
轉自
Linux的進程間通信-文件和文件鎖
作者:zorro
微博ID:orroz
微信公眾號:Linux系統技術