真正的知識是深入淺出的,碼農翻身” 公共號將苦澀難懂的計算機知識,用形象有趣的生活中實例呈現給我們,讓我們更好地理解。
本文源地址:那些煩人的同步和互斥問題
1、批處理和脫機打印
打印機程序,准確的說是打印機進程,在這個批處理系統中生活得非常自在,它所在的機器叫做IBM1401,除了打印之外什么也不干,每天大部分時間都是歇着。
這個系統還有兩台機器,一台還是IBM1401,它專門收集程序員寫出來的穿孔卡片,然后轉成磁帶。 然后,操作員把磁帶輸入到IBM7094這個昂貴又強大的計算機上執行,執行結果也會輸出到磁帶上。 最后磁帶被拿到1401上進行打印, 這叫做脫機打印(不和7094相連接)。如下圖:
圖一、脫機打印
在沒有磁帶來的時候,打印機程序無所事事,這就是脫機打印的好處。更大的好處是,磁帶上需要打印的東西都是順序的,一個接一個打印就可以了,完全沒有沖突的問題。這是沒辦法的事情,那時候的計算機,尤其是IBM 7094太過昂貴,要充分的利用它的每一分每一秒,然后就想出了這樣一個收集程序,然后成批處理的點子。
2、假脫機打印
隨着計算機系統的發展,打印程序的好日子很快就結束了,電腦越來越便宜,最后每個人的桌子上都有一台電腦了。個人電腦的計算能力更是強大的驚人,打印程序也被集成進了個人電腦里,和其他各種各樣的程序生活在一起。打印的需求仍然很強烈,像Word、WPS、Excel 、 IE、Chrome...這些程序時不時都要打印,這時候沖突就會產生。 因為只有一個打印機,到底先打印誰的文檔就是個大問題。最后操作系統老大想了個辦法,專門開辟了一塊空間,誰要想打印的話就按照先來后到的次序排隊放在那,原來的打印進程變成了一個打印守護進程,會周期性的檢查是否有文件打印,如果有則取出隊伍排頭的,打印出來,然后刪除隊列中文件。打印進程覺得這和原來的脫機打印很像,只不過用一個隊列替換了原來的磁帶,所以就叫做假脫機打印。
圖二、假脫機打印
3、沖突
但是這個隊列可不是原來的磁帶了,它完全是個動態變化的東西,試運行還不到20秒,沖突就出現了。
WPS氣沖沖的來着打印機進程:“打印機,你怎么搞的?我的放假通知.wps 為什么沒有打印?”
打印機:“我沒看到什么放假通知.wps啊!”
WPS:“我明明放在了編號為3的槽里,怎么可能沒有了?”
在操作系統老大的協助下,大家查了半天,才知道是Word引起的:
當時Word 插了一腳,也進來打印,讀到了in = 3,就是說隊列中編號為3的槽是空着的,他把3這個值放到了自己的局部變量free_slot中,這時候發生了一次時鍾中斷,操作系統老大認為Word已經運行了足夠長的時間,決定切換到WPS進程。
WPS也讀到了in = 3,把3 也存到自己的局部變量free_slot中,現在Word和WPS都認為下一個空的槽是3!
WPS接着干活,他把文件放到了第3號槽里,並且把in 改為4,然后離開了。接下來又輪到Word運行了,它發現free_slot 為3,就把文件也放到了第3號槽里,把free_slot 加1,得到4,存入in 中。可憐的WPS,他的文件被覆蓋掉了。
但是打印機程序啥也察覺不出來,照樣打印不誤。
圖三、沖突
4、臨界區
很明顯,Word和WPS 這兩個進程甚至多個進程在讀寫in這個共享變量的時候,最后的結果嚴重依賴於進程運行的精確次序,這次是WPS的文件被覆蓋掉了,下次可能就是Word了。
這種對共享變量,共享內存,共享資源進行訪問的程序片段叫做臨界區。代碼在進入臨界區之前一定要做好同步或者互斥的操作。
WPS說:“老大,當時你切換Word的時候是不是發生了一次時鍾中斷 ?”
操作系統:“是啊,有了時鍾中斷我才能計算時間,然后做進程切換啊!”
“那在訪問這個in共享變量的時候,我們自己能不能把這個中斷給屏蔽?這樣就不會有進程切換,肯定沒問題了。” Word問道。
“你想的美,時鍾中斷是最基本的東西,我把這個權限給了你們應用程序,到時候那個家伙屏蔽以后忘記開中斷,我們整個系統就要完蛋了!” 操作系統狠狠的瞪了Word 一眼,Word趕緊噤聲。
“不過我聽說有些機器提供了一個特別的指令,這個指令能檢查並且設置內存的值,而不會被打斷,叫做TestAndSet,如果用C語言描述的話,類似這樣:”
bool TestAndSet(bool *lock){ bool rv = *lock; *lock = true; return tv; }
“需要注意的是,這個函數中的三條指令是“原子”執行的,也就是說不會被打斷。你們要是想用的話可以這樣用:”
bool lock = false; while(TestAndSet(&lock)){ ;//什么也不做 } 臨界區; lock = false; 剩余區;
WPS說:“看起來有點復雜,讓我想想啊,我和Word 的臨界區代碼,就是‘訪問in變量,放入待打印文件,然后把in 加1’ 。那在進入臨界區之前,我們倆都會調用TestAndSet。如果是我先調用,lock會被置為true,函數就會返回false。我就跳出了循環,可以進行后續臨界區操作了,而Word 在調用 TestAndSet的時候,函數一直返回true,他只好不停的在這里循環了。”
“是啊!”,Word 接着說,“我會不停的循環,直到WPS 離開臨界區,然后把lock置為false。 ”
“這個方法看起來很簡單啊,只要一個變量加上一個函數就能讓我和Word 進行互斥操作。”
操作系統說: “是的,實現了你們兩個的互斥,但是並不是所有的機器都會提供這樣的指令,所以也不通用。”
5、生產者-消費者
打印機進程說:“你們討論了半天,只是解決了兩個進程往隊列里放文件的沖突問題,現在也得考慮考慮我了。”
“有你啥事?”,Word和WPS 都不以為然。
“你們想想,那個打印隊列對5個‘槽’,要是滿了就沒法往里邊放了,你們都得等;要是空了,我就得等你們往里邊放東西,所以咱們之間是不是也得同步?”
“這就是所謂的生產者和消費者問題,也是個老大難問題了”,老大總結道。
“那用剛才那個鎖好像不行啊,它能搞定互斥,但是做多個進程的同步就有點力不從心了。”
操作系統老大說:“聽說荷蘭有個叫Dijkstra的,發明了一個信號量(semaphore)的東西,能解決這個問題。”
圖四、科學家Dijkstra.
“信號量是什么鬼?信號燈嗎? ”
"所謂信號量,說白了其實就是一個整數,基於這個整數有兩個操作:wait 和 signa。”
int s; wait(s){ while(s <= 0){ ;//什么也不做 } s--; } signal(s){ s++; }
“這....這....這是啥玩意兒,這么簡單,能解決啥問題?再說了你看看這s++、s--和我們隊列中的in、out不是一樣嗎?在多進程切換下自身正確性都難保,還能解決別人的問題?” ,WPS吃驚的問。
“WPS 問的好啊,說明他思考了,實際上這個東西必須得我出馬來實現”,操作系統老大說,“ 我會在內核實現wait 和signal,讓你們調用,比如我在做s++、s-- 時,我可以屏蔽中斷。”
Word說:“這個簡單的小東西有點意思,比如我們倆可以用它做互斥:”
int lock = 1; wait(lock); 臨界區; signal(lock); 剩余區;
打印進程說:“既然信號量是個整數,也許可以解決我們消費者-生產者直接的同步問題。”
int lock = 1; int empty = 5; int full = 0; 生產者: while(true){ //如果empty的值小於等於0,生產者只好等待 wait(empty); //加鎖(因為要操作隊列,和其它生產者互斥) wait(lock); 把新產生的文件加入隊列; //釋放鎖 signal(lock); //通知消費者隊列中已經產生了新的文件 signal(full); } 消費者: while(true){ wait(full); wait(lock); 把隊列頭的文件打印,刪除; signal(lock); signal(empty); }
Word說:“我的天,真是復雜啊,容我想想,我和WPS都是生產者。假設我們倆都開始執行生產者代碼,先去wait(empty),發現沒有問題,因為empty的初始值為5。接下來都去執行wait(lock),這時候就看誰先搶到了。如果我先搶到,我就可以往隊列里加文件,然后釋放鎖,WPS就可以接着放文件了。最后我還要把full這個值加一,目的是打印機進程可能在等待。恩,看起來不錯!”
操作系統老大說:“是啊!在多進程下,由於進程的執行隨時都有可能被打斷,還要保證正確性,不能出一點閃失。這對程序員的挑戰很大,出現了疏漏,很難定位。”
打印進程說:“老大,我注意到wait函數中,如果s 的值 為0或小於0 ,那個while 循環會一直執行,CPU豈不是一直在忙等?”
“確實是這樣,我們改進下,讓忙等的進程進入休眠吧,很明顯,這件事還得我做啊”, 操作系統說道。
//把整數型信號量封裝成一個結構體 typedef struct{ int value; struct process *list;//process進程就緒隊列 }semaphore; wait(semaphore *s){ s->value--; if(s->value < 0){ 把當前進程加到s->list中; block();//把進程休眠,放棄cpu } } signal(semaphore *s){ s->value++; if(s->value <= 0){ 從s->list中取出一個進程p wakup(p);//喚醒進程p } }
WPS說:“唉,真是好復雜!不過我想起一個問題,這些wait、signal 能用到我們內部的線程的同步上嗎?”
“當然可以,概念上是一致的,都是訪問共享資源的程序,需要做同步和互斥操作,可能表現形式不同。”
“難道那些程序員們真的要使用這些wait、signal 編程嗎?多容易出錯啊!”
“一般來說,程序員們所使用的工具和平台會做抽象和封裝,例如在Java JDK中,已經對線程的同步做了封裝了,對於生產者-消費者問題,可以直接使用BlockingQueue。非常簡單,完全不用你去考慮這些wait、signal、full、empty。”
//建立一個隊列,其中隊列中已經對wait和signal操作做了封裝 BlockingQueue queues = new LinkedBlockingQueue(10); 生產者: //如果隊列滿,線程自動阻塞,直到有空閑位置 queues.put(xxx); 消費者: //如果隊列空,線程自動阻塞,直到有數據到來 queues.take();
WPS說:“果然是抽象大法好,這多簡單啊。”
操作系統說:“是啊!無論是什么東西,抽象以后用起來好多了。但是還是要了解底層,這樣出現了類似於BlockingQueue這樣的新概念, 你能迅速搞明白。”
(完)
“碼農翻身” 公共號:由工作15年的前IBM架構師創建,分享編程和職場的經驗教訓。
長按二維碼, 關注碼農翻身