UNIX 共享內存應用中的問題及解決方法
簡介
共享內存是一種非常重要且常用的進程間通信方式,相對于其它IPC機制,因其速度最快、效率最高,被廣泛應用于各類軟件產品及應用開發中。System V IPC 為Unix平臺上的共享內存應用制定了統一的API標準,從而為在UNIX/Linux平臺上進行跨平臺開發提供了極大的便利;開發人員基于一套基本相同的源代碼,便可開發出同時支持AIX、Solaris、HP-UX、Linux等平臺的產品。
然而,各個平臺對System V 標準的API在實現上各有差異,由此對相關應用開發帶來影響,甚至引入難以調試的問題。本文將結合作者在Tivoli產品開發中的實際經驗,對這些平臺相關的問題,以及具有共性的問題,逐一進行分析,并提出解決方法。
1. System V共享內存概述
System V 進程間通信(IPC)包括3種機制:消息隊列、信號量、共享內存。消息隊列和信號量均是內核空間的系統對象,經由它們的數據需要在內核和用戶空間進行額外的數據拷貝;而共享內存和訪問它的所有應用程序均同處于用戶空間,應用進程可以通過地址映射的方式直接讀寫內存,從而獲得非常高的通信效率。
System V 為共享內存定義了下列API接口函數:
# include <sys/types.h># include <sys/ipc.h># include <sys/shm.h>key_t ftok(const char *pathname, int proj_id);int shmget(key_t key, int size, int shmflg);void* shmat(int shmid, const void *shmaddr, int shmflg);int shmdt(void *shmaddr);int shmctl(int shmid, int cmd, struct shmid_ds *buf);
ftok函數用于生成一個鍵值:key_t key,該鍵值將作為共享內存對象的唯一性標識符,并提供給為shmget函數作為其輸入參數;ftok 函數的輸入參數包括一個文件(或目錄)路徑名:pathname,以及一個額外的數字:proj_id,其中pathname所指定的文件(或目錄)要求必須已經存在,且proj_id不可為0;shmget函數用于創建(或者獲取)一個由key鍵值指定的共享內存對象,返回該對象的系統標識符:shmid;shmat函數用于建立調用進程與由標識符shmid指定的共享內存對象之間的連接;shmdt函數用于斷開調用進程與共享內存對象之間的連接;shmctl函數用于對已創建的共享內存對象進行查詢、設值、刪除等操作;
2. ftok的陷阱
根據pathname指定的文件(或目錄)名稱,以及proj_id參數指定的數字,ftok函數為IPC對象生成一個唯一性的鍵值。在實際應用中,很容易產生的一個理解是,在proj_id相同的情況下,只要文件(或目錄)名稱不變,就可以確保ftok返回始終一致的鍵值。然而,這個理解并非完全正確,有可能給應用開發埋下很隱晦的陷阱。因為ftok的實現存在這樣的風險,即在訪問同一共享內存的多個進程先后調用ftok函數的時間段中,如果pathname指定的文件(或目錄)被刪除且重新創建,則文件系統會賦予這個同名文件(或目錄)新的i節點信息,于是這些進程所調用的ftok雖然都能正常返回,但得到的鍵值卻并不能保證相同。由此可能造成的后果是,原本這些進程意圖訪問一個相同的共享內存對象,然而由于它們各自得到的鍵值不同,實際上進程指向的共享內存不再一致;如果這些共享內存都得到創建,則在整個應用運行的過程中表面上不會報出任何錯誤,然而通過一個共享內存對象進行數據傳輸的目的將無法實現。
AIX、Solaris、HP-UX均明確指出,key文件被刪除并重建后,不保證通過ftok得到的鍵值不變,比如AIX上ftok的man幫助信息即聲明:
Attention: If the Path parameter of the ftok subroutine names a file that has been removed while keys still refer to it, the ftok subroutine returns an error. If that file is then re-created, the ftok subroutine will probably return a key different from the original one.
Linux沒有提供類似的明確聲明,但我們可以通過下面的簡單例程test01.c,得到相同的印證:
#include <stdio.h>#include <sys/ipc.h>void main(int argc, char* argv[]){if (argc !=2 ) {printf("Usage: %s KeyFilen e.g. %s /tmp/mykeyfilen", argv[0], argv[0]);return;}printf("Key generated by ftok: 0x%xn", ftok(argv[1], 1));}
將上述例程在Red Hat Enterprise Linux AS release 4平臺上編程成可執行程序test01,并且通過touch命令在 /tmp目錄下創建一個新文件mykeyfile,然后為該文件生成鍵值:
# touch /tmp/mykeyfile# ./test01 /tmp/mykeyfileKey generated by ftok: 0x101000b
然后,將/tmp/mykeyfile刪除,并且通過vi命令重新創建該文件,再次生成鍵值:
# ./test01 /tmp/mykeyfileKey generated by ftok: 0x1010017
我們可以看到,雖然文件名稱都是 /tmp/mykeyfile,并未改變,但由于中間發生了文件刪除并重新創建的操作,前后兩次所得到的鍵值已經不再相同。
避免此類問題最根本的方法,就是采取措施保證pathname所指定的文件(或目錄)在共享內存的使用期間不被刪除,不要使用有可能被刪除的文件;或者干脆直接指定鍵值,而不借助ftok來獲取鍵值。
3. AIX中shmat的問題
AIX系統中,System V各類進程間通信機制在使用中均存在限制。區別于其它UNIX操作系統對IPC機制的資源配置方式,AIX使用了不同的方法;在AIX中定義了 IPC 機制的上限, 且是不可配置的。就共享內存機制而言,在4.2.1及以上版本的AIX系統上,存在下列限制:
對于64位進程,同一進程可連接最多268435456個共享內存段;
對于32位進程,同一進程可連接最多11個共享內存段,除非使用擴展的shmat;
上述限制對于64位應用不會帶來麻煩,因為可供連接的數量已經足夠大了;但對于32位應用,卻很容易帶來意外的問題,因為最大的連接數量只有11個。在某些事件觸發的多線程應用中,新的線程不斷地為進行事件處理而被創建,這些線程如果都需要去連接特定的共享內存,則極有可能造成該進程連接的共享內存數量超過11個,事實上同時擁有幾十個甚至上百個處理線程的應用并不少見。一旦超個這個限制值,則所有后續的處理線程都將無法正常工作,從而導致應用運行失敗。
下面的例程test02.c演示了這個問題,為了精簡代碼,它反復連接的是同一個共享內存對象;實際上,無論所連接的共享內存對象是否相同,該限制制約的是連接次數:
#include <stdio.h>#include <errno.h>#include <sys/types.h>#include <sys/ipc.h>#include <sys/shm.h>#define MAX_ATTACH_NUM 15void main(int argc, char* argv[]){key_t mem_key;longmem_id;void* mem_addr[MAX_ATTACH_NUM];int i;if ( ( mem_key = ftok("/tmp/mykeyfile", 1) ) == (key_t)(-1) ) {printf("Failed to generate shared memory access key, ERRNO=%dn",errno);goto MOD_EXIT;}if ( ( mem_id = shmget(mem_key, 256, IPC_CREAT) ) == (-1) ) {printf("Failed to obtain shared memory ID, ERRNO=%dn", errno);goto MOD_EXIT;}for ( i=1; i<=MAX_ATTACH_NUM; i++ ) {if ( ( mem_addr[i] = (void *)shmat(mem_id, 0, 0) ) == (void *)(-1) )printf("Failed to attach shared memory, times [%02d], errno:%dn", i,errno);elseprintf("Successfully attached shared memory, times [%02d]n", i);}MOD_EXIT:shmctl(mem_id, IPC_RMID, NULL);}
在AIX系統上,我們將其編譯為test02,并運行,可以看到如下輸出:
Successfully attached shared memory, times [01]Successfully attached shared memory, times [02]Successfully attached shared memory, times [03]Successfully attached shared memory, times [04]Successfully attached shared memory, times [05]Successfully attached shared memory, times [06]Successfully attached shared memory, times [07]Successfully attached shared memory, times [08]Successfully attached shared memory, times [09]Successfully attached shared memory, times [10]Successfully attached shared memory, times [11]Failed to attach shared memory, times [12], errno:24Failed to attach shared memory, times [13], errno:24Failed to attach shared memory, times [14], errno:24Failed to attach shared memory, times [15], errno:24
說明超出11個連接之后,所有后續的共享內存連接都將無法建立。錯誤碼24的定義是EMFILE,AIX給予的解釋是:
The number of shared memory segments attached to the calling process exceeds the system-imposed limit。
解決這個問題的方法是,使用擴展的shmat;具體而言就是,在運行相關應用之前(確切地說,是在共享內存被創建之前),首先在shell中設置EXTSHM環境變量,通過它擴展shmat,對于源代碼本身無需作任何修改:
export EXTSHM=ON
值得注意的是,雖然設置環境變量,在程序中也可通過setenv函數來做到,比如在程序的開始,加入下列代碼:
setenv("EXTSHM", "ON", 1);
但實踐證明這樣的方法在解決這個問題上是無效的;也就是說唯一可行的辦法,就是在shell中設置EXTSHM環境變量,而非在程序中。
在AIX上配置32位DB2實例時,也要求確保將環境變量 EXTSHM 設為 ON,這是運行 Warehouse Manager 和 Query Patroller 之前必需的操作:
export EXTSHM=ONdb2set DB2ENVLIST=EXTSHMdb2start
其原因即來自我們剛剛介紹的AIX中32位應用連接共享內存時,存在最大連接數限制。這個問題同樣普遍存在于AIX平臺上Oracle等軟件產品中。
4. HP-UX中shmget和shmat的問題
4.1 32位和64位應用兼容問題
在HP-UX平臺上,如果同時運行32位應用和64位應用,而且它們訪問的是一個相同的共享內存區,則會遇到兼容性問題。
在HP-UX中,應用程序設置IPC_CREAT標志調用shmget,所創建的共享內存區,只可被同類型的應用所訪問;即32位應用程序所創建的共享內存區只可被其它的32位應用程序訪問,同樣地,64位應用程序所創建的共享內存區只可被其它的64位應用程序訪問。
如果,32位應用企圖訪問一個由64位應用創建的共享內存區,則會在調用shmget時失敗,得到EINVAL錯誤碼,其解釋是:
A shared memory identifIEr exists for key but is in 64-bit address space and the process performing the request has been compiled as a 32-bit executable.
解決這一問題的方法是,當64位應用創建共享內存時,合并IPC_CREAT標志,同時給定IPC_SHARE32標志:
shmget(mem_key, size, 0666 | IPC_CREAT | IPC_SHARE32)
對于32位應用,沒有設定IPC_SHARE32標志的要求,但設置該標志并不會帶來任何問題,也就是說無論應用程序將被編譯為32位還是64位模式,都可采用如上相同的代碼;并且由此解決32位應用和64位應用在共享內存訪問上的兼容性問題。
4.2 對同一共享內存的連接數限制
在HP-UX上,應用進程對同一個共享內存區的連接次數被限制為最多1次;區別于上面第3節所介紹的AIX上的連接數限制,HP-UX并未對指向不同共享內存區的連接數設置上限,也就是說,運行在HP-UX上的應用進程可以同時連接很多個不同的共享內存區,但對于同一個共享內存區,最多只允許連接1次;否則,shmat調用將失敗,返回錯誤碼EINVAL,在shmat的man幫助中,對該錯誤碼有下列解釋:
shmid is not a valid shared memory identifier, (possibly because the shared memory segment was already removed using shmctl(2) with IPC_RMID), or the calling process is already attached to shmid.
這個限制會對多線程應用帶來無法避免的問題,只要一個應用進程中有超過1個以上的線程企圖連接同一個共享內存區,則都將以失敗而告終。
解決這個問題,需要修改應用程序設計,使應用進程具備對同一共享內存的多線程訪問能力。相對于前述問題的解決方法,解決這個問題的方法要復雜一些。
作為可供參考的方法之一,以下介紹的邏輯可以很好地解決這個問題:
基本思路是,對于每一個共享內存區,應用進程首次連接上之后,將其鍵值(ftok的返回值)、系統標識符(shmid,shmget調用的返回值)和訪問地址(即shmat調用的返回值)保存下來,以這個進程的全局數組或者鏈表的形式留下記錄。在任何對共享內存的連接操作之前,程序都將先行檢索這個記錄列表,根據鍵值和標志符去匹配希望訪問的共享內存,如果找到匹配記錄,則從記錄中直接讀取訪問地址,而無需再次調用shmat函數,從而解決這一問題;如果沒有找到匹配目標,則調用shmat建立連接,并且為新連接上來的共享內存添加一個新記錄。
記錄條目的數據結構,可定義為如下形式:
typedef struct _Shared_Memory_Record{key_tmem_key;// key generated by ftok()intmem_id;// id returned by shmget()void*mem_addr;// access address returned by shmat()intnattach;// times of attachment} Shared_Memory_Record;
其中,nattach成員的作用是,記錄當前對該共享內存區的連接數目;每一次打開共享內存的操作都將對其進行遞增,而每一次關閉共享內存的操作將其遞減,直到nattach的數值降到0,則對該共享內存區調用shmdt進行真正的斷開連接。
打開共享內存的邏輯流程可參考如下圖一:
圖一
關閉共享內存的邏輯流程可參考如下圖二:
圖二
5. Solaris中的shmdt函數原型問題
Solaris系統中的shmdt調用,在原型上與System V標準有所不同,
Default int shmdt(char *shmaddr);
即形參shmaddr的數據類型在Solaris上是char *,而System V定義的是void * 類型;實際上Solaris上shmdt調用遵循的函數原型規范是SVID-v4之前的標準;以Linux系統為例,libc4和libc5 采用的是char * 類型的形參,而遵循SVID-v4及后續標準的glibc2及其更新版本,均改為采用void * 類型的形參。
如果仍在代碼中采用System V的標準原型,就會在Solaris上編譯代碼時造成編譯錯誤;比如:
Error: Formal argument 1 of type char* in call to shmdt(char*)is being passed void*.
解決方法是,引入一個條件編譯宏,在編譯平臺是Solaris時,采用char * 類型的形參,而對其它平臺,均仍采用System V標準的void * 類型形參,比如:
#ifdef _SOLARIS_SHARED_MEMORYshmdt((char *)mem_addr);#else shmdt((void *)mem_addr);#endif
6. 通過shmctl刪除共享內存的風險
當進程斷開與共享內存區的連接后,一般通過如下代碼刪除該共享內存:
shmctl(mem_id, IPC_RMID, NULL);
從HP-UX上shmctl函數的man幫助,我們可以看到對IPC_RMID操作的說明:
IPC_RMID Remove the shared memory identifier specified by shmid from the system and destroy the shared memory segment and data structure associated with it. If the segment is attached to one or more processes, then the segment key is changed to IPC_PRIVATE and the segment is marked removed. The segment disappears when the last attached process detaches it.
其它UNIX平臺也有類似的說明。關于shmctl的IPC_RMID操作,其使用特點可簡述如下:
如果共享內存已經與所有訪問它的進程斷開了連接,則調用IPC_RMID子命令后,系統將立即刪除共享內存的標識符,并刪除該共享內存區,以及所有相關的數據結構;
如果仍有別的進程與該共享內存保持連接,則調用IPC_RMID子命令后,該共享內存并不會被立即從系統中刪除,而是被設置為IPC_PRIVATE狀態,并被標記為"已被刪除";直到已有連接全部斷開,該共享內存才會最終從系統中消失。
于是,存在這樣的一種狀態:
N個進程(進程1至進程N)已經與某共享內存區連接;
進程1已完成對此共享內存的操作,斷開連接后,調用shmctl的IPC_RMID子命令,企圖刪除該共享內存;
由于進程2至進程N仍保持與該共享內存的連接,因此在它們全部斷開連接之前,這個共享內存區毫無疑問地會依然存在。
此時,如果有其它的進程(比如第N+1號進程)想建立對這個共享內存的連接,是否能夠成功呢?
類似的狀態,在Windows上同樣存在,只是程序借助的API有所不同,比如通過CreateFileMapping函數創建共享內存,通過MapViewOfFile函數建立連接,通過UnmapViewOfFile函數斷開連接,通過CloseHandle函數刪除共享內存等。在Windows上,對此問題的回答是肯定的;也就是說,只要共享內存依然存在,則進程總是可以建立對它的連接,而無論之前是否有進程對其執行過刪除操作。
然而,對于包括AIX、Solaris、HP-UX等在內的UNIX平臺,答案卻是否定的!這也正是本節所討論的使用shmctl中的風險所在;通過以下test03.P1.c和test03.P2.c兩個例程,我們可以很直觀地得到答案:
test03.P1.c: 創建共享內存,并建立連接,保持10秒后(在此期間,test03.P2將反復連接、并刪除該共享內存),斷開連接,并最后再次嘗試連接以驗證該共享內存是否已被真正刪除;
test03.P2.c: 反復連接由test03.P1創建的共享內存,并在期間通過shmctl的IPC_RMID 子命令刪除該共享內存,以觀察共享內存被執行刪除操作之后,在被徹底銷毀之前是否還能接受連接;
/******* test03.P1.c ********/#include <stdio.h>#include <unistd.h>#include <errno.h>#include <sys/types.h>#include <sys/ipc.h>#include <sys/shm.h>int main(int argc, char* argv[]){key_t mem_key;long mem_id;void* mem_addr;intisAttached = 0;mem_key = ftok("/tmp/mykeyfile", 1);mem_id = shmget(mem_key, 256, IPC_CREAT);if ( ( mem_addr = (void *)shmat(mem_id, 0, 0) ) == (void *)(-1) ) printf("%s, Failed to attach shared memory, errno:%dn", argv[0], errno);else {isAttached = 1;printf("%s, +.Successfully attached shared memoryn", argv[0]); }/* sleep 10 seconds, to wait test03.P2 to run */sleep(10);if (isAttached) {// Attention: the following line should be "shmdt((char *)mem_addr);" ifon Solarisshmdt((void *)mem_addr);printf("%s, -.Successfully detached shared memoryn", argv[0]);}/* try to attach the shared memory which has been removed! */if ( ( mem_addr = (void *)shmat(mem_id, 0, 0) ) == (void *)(-1) )printf("%s, Failed to attach the removed shared memory, errno:%dn",argv[0], errno); return 0;}/******* test03.P2.c ********/#include <stdio.h>#include <errno.h>#include <sys/types.h>#include <sys/ipc.h>#include <sys/shm.h>int main(int argc, char* argv[]){key_t mem_key; long mem_id;void* mem_addr;inti, isAttached;mem_key = ftok("/tmp/mykeyfile", 1);mem_id = shmget(mem_key, 0, 0);// repeated attaching & detachingfor (i=1; i<10; i++) {isAttached = 0;if ( ( mem_addr = (void *)shmat(mem_id, 0, 0) ) == (void *)(-1) )printf("%s, Failed to attach shared memory, times [%02d],errno:%dn", argv[0], i, errno); else { isAttached = 1; printf("%s, +.Successfully attached shared memory, times[%02d]n",argv[0], i);} if (isAttached) { // Attention: the following line should be "shmdt((char*)mem_addr);", if on Solarisshmdt((void *)mem_addr); printf("%s, -.Successfully detached, times [%02d]n", argv[0], i); }// purposely remove the shared memory at times [5]if (i==5) {shmctl(mem_id, IPC_RMID, NULL);printf("%s, *.Remove executed, times [%02d], errno=%dn",argv[0], i, errno);}}return 0;}
上述程序均可在AIX、HP-UX、Linux平臺上編譯通過;在Solaris平臺上只需按注釋提示的要求,將shmdt的參數強制為char *類型也可編譯通過(第5節中已介紹過)。
將test03.P1.c、test03.P2.c各自編譯為可執行程序test03.P1、test03.P2,并通過下面的shell腳本:runtest,運行它們:
#!/bin/sh./test03.P1&sleep 2./test03.P2
在Linux平臺(Red Hat 8.0)上的運行結果如下:
[root@localhost tmp]# ./runtest./test03.P1, +.Successfully attached shared memory./test03.P2, +.Successfully attached shared memory, times [01]./test03.P2, -.Successfully detached, times [01]./test03.P2, +.Successfully attached shared memory, times [02]./test03.P2, -.Successfully detached, times [02]./test03.P2, +.Successfully attached shared memory, times [03]./test03.P2, -.Successfully detached, times [03]./test03.P2, +.Successfully attached shared memory, times [04]./test03.P2, -.Successfully detached, times [04]./test03.P2, +.Successfully attached shared memory, times [05]./test03.P2, -.Successfully detached, times [05]./test03.P2, *.Remove executed, times [05], errno=0./test03.P2, +.Successfully attached shared memory, times [06]./test03.P2, -.Successfully detached, times [06]./test03.P2, +.Successfully attached shared memory, times [07]./test03.P2, -.Successfully detached, times [07]./test03.P2, +.Successfully attached shared memory, times [08]./test03.P2, -.Successfully detached, times [08]./test03.P2, +.Successfully attached shared memory, times [09]./test03.P2, -.Successfully detached, times [09][root@localhost tmp]# ./test03.P1, -.Successfully detached shared memory./test03.P1, Failed to attach the removed shared memory, errno:22
根據運行結果,我們可以看到,在Linux平臺上,即便對共享內存執行了刪除操作(在第5次連接之后,test03.P2進程調用了shmctl的IPC_RMID刪除操作),只要該共享內存依然存在(test03.P1進程保持著連接,因此共享內存不會被立即刪除),則它仍然是可連接的(test03.P2進程的第6到第9次連接均是成功的)。
然而,在AIX、HP-UX、Solaris平臺上的運行結果卻不同于Linux:
# ./runtest./test03.P1, +.Successfully attached shared memory./test03.P2, +.Successfully attached shared memory, times [01]./test03.P2, -.Successfully detached, times [01]./test03.P2, +.Successfully attached shared memory, times [02]./test03.P2, -.Successfully detached, times [02]./test03.P2, +.Successfully attached shared memory, times [03]./test03.P2, -.Successfully detached, times [03]./test03.P2, +.Successfully attached shared memory, times [04]./test03.P2, -.Successfully detached, times [04]./test03.P2, +.Successfully attached shared memory, times [05]./test03.P2, -.Successfully detached, times [05]./test03.P2, *.Remove executed, times [05], errno=0./test03.P2, Failed to attach shared memory, times [06], errno:22./test03.P2, Failed to attach shared memory, times [07], errno:22./test03.P2, Failed to attach shared memory, times [08], errno:22./test03.P2, Failed to attach shared memory, times [09], errno:22# ./test03.P1, -.Successfully detached shared memory./test03.P1, Failed to attach the removed shared memory, errno:22
根據結果,可以發現,test03.P2進程的第6到第9次連接都是失敗的,也就說明,在AIX、HP-UX、Solaris平臺上一旦通過shmctl對共享內存進行了刪除操作,則該共享內存將不能再接受任何新的連接,即使它依然存在于系統中!
而且,上面的運行結果,也證明了,對共享內存進行了刪除操作之后,當已有的連接全部斷開,該共享內存將被系統自動銷毀(運行結果的最后一行,說明該共享內存已經不存在了)。
本節的目的在于說明,在AIX、HP-UX、Solaris平臺上調用shmctl的IPC_RMID刪除操作,是存在潛在風險的,需要足夠的謹慎。
如果,可以確知,在刪除之后不可能再有新的連接,則執行刪除操作是安全的;
否則,在刪除操作之后如仍有新的連接發生,則這些連接都將失??!
7. 結論
對共享內存的操作,往往是產品或者應用中數據傳輸的基礎,對其可靠性和性能至關重要;而且作為底層的IPC機制,相關代碼具有不易調試的特點,由其造成的問題往往關鍵卻不容易解決。
本文從應用實現的角度上,對在UNIX/Linux平臺上使用共享內存可能會遇到的問題,進行了全面的介紹和分析,并給出了解決方法或建議,可供相關的應用開發人員參考。
