Linux进程间通信中的文件和文件锁
Linux進程間通信中的文件和文件鎖
前言
使用文件進行進程間通信應該是最先學會的一種IPC方式。任何編程語言中,文件IO都是很重要的知識,所以使用文件進行進程間通信就成了很自然被學會的一種手段。考慮到系統對文件本身存在緩存機制,使用文件進行IPC的效率在某些多讀少寫的情況下并不低下。但是大家似乎經常忘記IPC的機制可以包括“文件”這一選項。
我們首先引入文件進行IPC,試圖先使用文件進行通信引入一個競爭條件的概念,然后使用文件鎖解決這個問題,從而先從文件的角度來管中窺豹的看一下后續相關IPC機制的總體要解決的問題。閱讀本文可以幫你解決以下問題:
競爭條件(racing)
我們的第一個例子是多個進程寫文件的例子,雖然還沒做到通信,但是這比較方便的說明一個通信時經常出現的情況:競爭條件。假設我們要并發100個進程,這些進程約定好一個文件,這個文件初始值內容寫0,每一個進程都要打開這個文件讀出當前的數字,加一之后將結果寫回去。在理想狀態下,這個文件最后寫的數字應該是100,因為有100個進程打開、讀數、加1、寫回,自然是有多少個進程最后文件中的數字結果就應該是多少。但是實際上并非如此,可以看一下這個例子:
[zorro@zorrozou-pc0 process]$ cat racing.c int do_child(const char *path) {/* 這個函數是每個子進程要做的事情每個子進程都會按照這個步驟進行操作:1. 打開FILEPATH路徑的文件2. 讀出文件中的當前數字3. 將字符串轉成整數4. 整數自增加15. 將證書轉成字符串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 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的例子,其作用也很簡單:
這個程序直接編譯執行的結果是:
[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則沒有這個問題。
總結
以上是生活随笔為你收集整理的Linux进程间通信中的文件和文件锁的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Linux中sort,uniq,cut,
- 下一篇: Linux 下的五种 IO 模型