進程間的通信IPC

進程間的通信


進程間的通信(Inter Process Communication, IPC)問題主要有3個:
(1) 一個進程如何把信息傳遞給另一個進程;
(2) 確保兩個或更多進程在關鍵活動中不會出現交叉;
(3) 有協作關係的進程的時序問題。

兩個或多個進程讀寫某些共享數據,而最後的結果取決於進程運行的精確時序,稱爲競爭條件(race condition)。我們把對共享內存進行訪問的程序片段稱作臨界區域(critical region)或臨界區(critical section),如果我們能夠保證兩個進程不可能同時處於臨界區中,就能避免競爭條件。爲了避免競爭條件,以某種手段確保當前一個進程在使用一個共享變量或文件時,其他進程不能做同樣的操作,稱爲互斥(mutual exclusion)。
一個好的互斥方案需要滿足下面4個條件:
(1) 任何兩個進程不能同時處於其臨界區;
(2) 臨界區外運行的進程不得阻塞其他進程;
(3) 不得使進程無限期等待進入臨界區;
(4) 不應對CPU的速度和數量做任何假設。
下面將討論幾種實現互斥的方案。

1、忙等等(busy waiting)

1.1 屏蔽中斷

在單處理器系統中,最簡單的辦法是使每個進程在剛剛進入臨界區域後立即屏蔽所有中斷,並在就要離開之前打開中斷。在屏蔽中斷之後,CPU將不會切換到其他進程。

但這個方案並不好,把屏蔽中斷的權利交給用戶進程會引發風險,如果進程中斷屏蔽後不再打開,將使得整個系統終止。此外,在多CPU系統中,屏蔽中斷僅僅對執行disable指令的那個CPU有效,其他CPU仍將繼續運行,並可以訪問共享內存。

1.2 鎖變量

設想有一個共享鎖變量,其初始值爲0。0表示臨界區沒有進程,1表示已經有某個進程進入臨界區。
當一個進程要進入臨界區時,先測試這把鎖,如果該鎖的值爲0,則該進程將其設置爲1並進入臨界區,若這把鎖的值爲1,則該進程將等待直到其值變爲0。
鎖變量的缺陷:如果一個進程讀出鎖變量的值爲0,但在將它設置爲1之前,另一個進程被調度運行,將該鎖變量設置爲1。當第一個進程再次能運行時,它同樣也將該鎖設置爲1,則此時有兩個進程進入臨界區中。

1.3 嚴格輪轉法

//進程0
while(True){
	while(turn != 0);	//等待turn等於0
	critical_region();
	turn = 1;			//離開臨界區
	noncritical_region();
}
//進程1
while(True){
	while(turn != 1);	//等待turn等於1
	critical_region();
	turn = 0;			//離開臨界區
	noncritical_region();
}

嚴格輪轉法採用忙等待,即連續測試一個變量直到某個值出現爲止,用於忙等待的鎖稱爲自旋鎖(spin lock),這種方式比較浪費CPU時間,通常應該避免。
代碼說明:進程0離開臨界區,將turn設置爲1,以便允許進程1進入其臨界區。假設進程1很快便離開臨界區,則此時兩個進程都處於臨界區之外,turn的值又被設置爲0。如果此時進程1突然結束了非臨界區並且返回循環的開始,但是,這時它不能進入臨界區,因爲turn的值爲0,而此時進程0還在忙於非臨界區的操作,進程1只有繼續while循環,直到進程0把turn的值改爲1。這實際上違反了前面敘述的互斥條件(2),即臨界區外運行的進程不得阻塞其他進程。

1.4 Peterson解法

#define N 2	//進程數量
int turn;	//鎖變量
int interested[N];

void enter_region(int process){
	int other;
	other = 1 - process;	//其他進程
	interested[process] = True;
	turn = process;
	while(turn==process && interested[other]==True);	//等待other離開臨界區
}

void leave_region(int process){
	interested[process] = False;
}

代碼說明:一開始,沒有任何進程處於臨界區,現在進程0調用enter_region,它通過設置數組元素和將turn置爲0來表示它希望進入臨界區。由於進程1並不處於臨界區,enter_region很快便返回。如果此時進程1調用enter_region,進程1將在此處掛起直到interested[0]變成False,該事件只有在進程0調用leave_region退出臨界區時纔會發生。

1.5 TSL指令/XCHG指令

在某些計算機上,有下面這樣的指令:
TSL RX, LOCK
TSL(Test and Set Lock)指令將一個內存字lock讀入寄存器RX中,然後將該內存地址上存一個非零值。讀字和寫字操作保證是不可分割的,即該指令結束之前其他處理器均不允許訪問該內存字。執行TSL指令的CPU將鎖住內存總線,以禁止其他CPU在本指令結束之前訪問內存。

//用TSL指令進入和離開臨界區
enter_region:
	TSL REGISTER, LOCK	;複製鎖變量到寄存器,並將鎖設爲1
	CMP REGISTER, #0	
	JNE enter_region	;若鎖不等於0,繼續循環等待
	RET

leave_region:
	MOVE LOCK, #0
	RET

從代碼可以看出,進程在進入臨界區之前先調用enter_region,這將導致忙等待,直到鎖空閒爲止,隨和它獲得該鎖並返回。在進程從臨界區返回時它調用leave_region,這將把鎖設置爲0。
一個可替代TSL指令的是XCHG,它原子性地交換兩個位置的內容。

//用XCHG指令進入和離開臨界區
enter_region:
	MOVE REGISTER, #1	
	XCHG REGISTER, LOCK	;交換寄存器與鎖變量的內容
	CMP REGISTER, #0	;測試鎖
	JNE enter_region	;若鎖不等於0,繼續循環等待
	RET

leave_region:
	MOVE LOCK, #0
	RET


2、睡眠和喚醒(sleep & wake up)

忙等待的缺點:當一個進程要進入臨界區時,先檢查是否允許進入,若不允許,則該進程將原地等待,直到允許爲止。
考慮一種情況:現在有兩個進程H和L,H的優先級更高,L處於臨界區,H處於就緒態。由於H的優先級高於L,L將不會被調度,因此也無法離開臨界區,這時H將始終處於忙等待,這種情況稱爲優先級反轉問題(priority inversion problem)。
睡眠是將一個無法進入臨界區的進程阻塞,而不是忙等待,該進程被掛起,直到另外一個進程將其喚醒。

#define N 100	//緩衝區大小
int count = 0;

//數據生產者
void producer(void){
	int item;
	while(True){
		item = produce_item();	//產生下一新數據項
		if(count==N)sleep();	//如果緩衝區滿,就進入休眠狀態
		insert_item(item);		//將新數據項放入緩衝區
		count++;
		if(count==1)wakeup(consumer);//喚醒消費者
	}
}

//數據消費者
void consumer(void){
	int item;
	while(True){
		if(count==0)sleep();	//緩衝區爲空,就進入休眠狀態
		item = remove_item();	//從緩衝區取走一個數據項
		count--;
		if(count==N-1)wakeup(producer);//喚醒生產者
		consume_item(item);
	}
}

代碼說明:在生產者-消費者(producer-consumer)問題中,兩個進程共享一個公共的固定大小的緩衝區(bounded-buffer),其中一個是生產者,將信息放入緩衝區;另一個是消費者,從緩衝區取走信息。當緩衝區滿時,讓生產者睡眠,待消費者從緩衝區取出一個或多個數據項時再喚醒它。同樣地,當消費者試圖從空緩衝區取數據時,消費者就睡眠,直到生產者向其中放入一些數據項時再喚醒它。
不過上面的代碼仍存在一個問題,其原因是對count的訪問未加限制。這種情況是:當緩衝區爲空時,消費者剛剛讀取count的值爲0,而此時調度程序恰好將消費者掛起,並啓動生產者,生產者向緩衝區加入一個數據項,count加1。現在count的值爲1,它推斷認爲由於count剛纔爲0,所以消費者一定在睡眠,於是生產者調用wakeup來喚醒消費者。但是,消費者此時在邏輯上並未睡眠,所以wakeup信號丟失。當消費者再次運行時,它將測試先前讀到的count值,發現它爲0,於是睡眠。這樣生產者遲早會填滿整個緩衝區,兩個進程都將永遠睡眠下去。
上面這個問題的實質在於給一個清醒的進程發送的wakeup信號被丟失了。我們可以設置一個喚醒等待位,當一個清醒的進程收到wakeup信號時,將喚醒等待爲置爲1,隨後,如果該進程收到sleep信號,先檢測喚醒等待位,如果喚醒等待位爲1,則不睡眠,而只是將喚醒等待位清0。

3、信號量(semaphore)

信號量是設置一個整型變量來累計喚醒次數。對信號量有兩種操作:down和up(一般化後的sleep和wankeup)。
對信號量執行down操作,則是先檢查其值是否大於0,若該值大於0,則將其值減1並繼續,若該值爲0,則進程將睡眠。這裏,檢查數值、修改變量值以及可能發生的睡眠操作是一個原子操作(不會被中間打斷)。
up操作對信號量加1,信號量的增值1和喚醒操作同樣是不可分割的。


#define N 100	//緩衝區大小
typedef int semaphore;
semaphore full = 0;		//緩衝區已用數目	
semaphore empty = N;	//緩衝區剩餘數目
semaphore mutex = 1;	//控制對臨界區的訪問

//數據生產者
void producer(void){
	int item;
	while(True){
		item = produce_item();	//產生下一新數據項
		down(&empty);			
		down(&mutex);			//進入臨界區
		insert_item(item);
		up(&mutex);				//離開臨界區
		up(&full);
	}
}

//數據消費者
void consumer(void){
	int item;
	while(True){
		down(&full);
		down(&mutex);
		item = remove_item();
		up(&mutex);
		up(&empty);
		consume_item(item);
	}
}

代碼說明:mutex是一個二元信號量,每個進程在進入臨界區前都對它執行一個down操作,離開臨界區後執行一個up操作,就能夠實現互斥。信號量的另一個作用是實現同步(synchronization),信號量full和empty用來保證當緩衝區滿地時候生產者停止運行,以及當緩衝區空的時候消費者停止運行。

4、互斥量(mutex)


互斥量是一種退化的信號量,它只有兩種狀態:加鎖和解鎖,這樣只要一個二進制位即可表示。
互斥量在實現用戶級線程包時非常有用。當一個線程需要訪問臨界區時,調用mutex_lock,如果該互斥量當前是解鎖的,此調用成功,調用線程可以自由進入臨界區。如果該互斥量已經加鎖,調用線程被阻塞,直到在臨界區中的線程完成並調用mutex_unlock。如果多個線程被阻塞在該互斥量上,將隨機選擇一個線程並允許它獲得鎖。

mutex_lock:
	TSL REGISTER, MUTEX
	CMP REGISTER, #0	;測試互斥量
	JZE ok				;解鎖
	CALL thread_yield	;互斥量忙,調度另一個線程
	JMP mutex_lock		;稍後再試
ok:	RET

mutex_unlock:
	MOVE MUTEX, #0
	RET

可以看出,線程互斥量與1.5節中的TSL指令實現的進程互斥類似,但有一個關鍵區別:
當enter_region進入臨界區失敗時,它始終重複測試鎖(忙等待)。實際上,由於時鐘超時作用,會調度其他進程運行,這樣遲早擁有鎖的進程會進入運行並釋放鎖。
當mutex_lock進入臨界區失敗時,它調用thread_yield主動釋放CPU給另外一個線程,這樣就沒有忙等待。這是因爲線程沒有時鐘中斷,通過忙等待的方式來獲取鎖的線程將永遠循環下去,絕不會得到鎖,而其他線程也沒有機會運行了。

下面列出了幾個與線程互斥量相關的調用

pthread_mutex_init

創建一個互斥量

pthread_mutex_destroy

撤銷一個已經存在的互斥量

pthread_mutex_lock

獲得一個鎖或阻塞

pthread_mutex_unlock

解鎖

pthread_mutex_trylock

獲得一個鎖或失敗


除互斥量外,pthread提供了另外一種同步機制——條件變量(condition variable)。互斥量允許或阻塞對臨界區的訪問,條件變量則允許線程由於一些未達到的條件而阻塞。條件變量和互斥量經常一起使用,這種模式用於讓一個線程鎖住一個互斥量,然後當它不能獲得它期待的結果時等待一個條件變量。最後另一個線程會向他發信號,使它可以繼續執行。注意:條件變量不會存在內存中,如果將一個信號量傳遞給一個沒有線程在等待的條件變量,那麼這個信號就會丟失。

下面列出了幾個與條件變量相關的調用

pthread_cond_init

創建一個條件變量

pthread_cond_destroy

撤銷一個條件變量

pthread_cond_wait

阻塞調用線程直到另一個線程給它發信號

pthread_cond_signal

向另一個線程發信號來喚醒它

pthread_cond_broadcast

向多個線程發信號來讓它們全部喚醒





5、管程(monitor)




6、消息傳遞(message passing)


消息傳遞使用的兩條原語:send和receive,前一個調用向目標發送一條消息,後一個調用從一個給定的源接收一條消息。如果沒有消息可用,則接收者可能被阻塞,直到下一條消息到達,或者帶着一個錯誤碼立即返回。
消息傳遞過程中可能發生消息丟失的現象,因此,一旦接收到消息,接收方應該回送一條確認消息(acknowledge),如果發送方在一段時間間隔內沒有收到確認,則重發消息。而如果消息本身被正確接收,但返回給發送方的確認消息丟失,發送者將重發消息,這樣將導致接收者接收到兩次相同的消息。通常採用在每條原始消息中嵌入一個連續的序號來解決此問題,如果接收者接收到一條消息,並且它具有與前面某條消息一樣的序號,就知道這條消息是重複的。

#define N 100

void producer(void){
	int item;
	message m;
	while(True){
		item = produce_item();
		receive(consumer, &m);	//等待消費者發送空緩衝區
		build_message(&m, item);//建立一個待發送的信號
		send(consumer, &m);		//發送數據項給消費者
	}
}

void consumer(void){
	int item;
	message m;
	for(int i=0; i<N; ++i)
		send(producer, &m);			//發送N個空緩衝區
		while(True){
			receive(produce, &m);	//接收包含數據項的消息
			item = extract_item(&m);//將數據項從消息中提取出來
			send(producer, &m);		//將空緩衝區發送回生產者
			consume_item(item);
		}
}

代碼說明:消費者先將N條空消息發送給生產者,當生產者向消費者傳遞一個數據項時,它取走一條空消息並送回一條填充了內容的消息。通過這種方式,系統中的消息總數保持不變。如果生產者的速度比消費者快,所有消息都將被填滿,等待消費者;相反,如果消費者速度比生產者快,所有消息均爲空,等待生產者來填充它們,消費者被阻塞,以等待一條填充過的消息。


7、屏障(barrier)


屏障機制適用於進程組。在有些應用中劃分了若干階段,並且規定,除非所有進程都就緒準備着手下一個階段,否則任何進程都不能進入下一個階段。可以通過在每個階段的結尾設置屏障來實現這種行爲。如下圖所示,在所有進程到達屏障前,先到達的進程會被掛起,只有當所有進程都就緒後,所有進程一起被釋放。










發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章