多線程 or 多進程?

 在Unix上編程採用多線程還是多進程的爭執由來已久,這種爭執最常見到在C/S通訊中服務端併發技術的選型上,比如WEB服務器技術中,Apache是採用多進程的(perfork模式,每客戶連接對應一個進程,每進程中只存在唯一一個執行線程),Java的Web容器Tomcat、Websphere等都是多線程的(每客戶連接對應一個線程,所有線程都在一個進程中)。

      從Unix發展歷史看,伴隨着Unix的誕生進程就出現了,而線程很晚才被系統支持,例如Linux直到內核2.6,才支持符合Posix規範的NPTL線程庫。進程和線程的特點,也就是各自的優缺點如下:

進程優點:編程、調試簡單,可靠性較高。
進程缺點:創建、銷燬、切換速度慢,內存、資源佔用大。
線程優點:創建、銷燬、切換速度快,內存、資源佔用小。
線程缺點:編程、調試複雜,可靠性較差。

      上面的對比可以歸結爲一句話:“線程快而進程可靠性高”。線程有個別名叫“輕量級進程”,在有的書籍資料上介紹線程可以十倍、百倍的效率快於進程;而進程之間不共享數據,沒有鎖問題,結構簡單,一個進程崩潰不像線程那樣影響全局,因此比較可靠。我相信這個觀點可以被大部分人所接受,因爲和我們所接受的知識概念是相符的。

在寫這篇文章前,我也屬於這“大部分人”,這兩年在用C語言編寫的幾個C/S通訊程序中,因時間緊總是採用多進程併發技術,而且是比較簡單的現場爲每客戶fork()一個進程,當時總是擔心併發量增大時負荷能否承受,盤算着等時間充裕了將它改爲多線程形式,或者改爲預先創建進程的形式,直到最近在網上看到了一篇論文《Linux系統下多線程與多進程性能分析》作者“周麗 焦程波 蘭巨龍”,才認真思考這個問題,我自己也做了實驗,結論和論文作者的相似,但對大部分人可以說是顛覆性的。

下面是得出結論的實驗步驟和過程,結論究竟是怎樣的? 感興趣就一起看看吧。

 

      實驗代碼使用周麗論文中的代碼樣例,我做了少量修改,值得注意的是這樣的區別:

論文實驗和我的實驗時間不同,論文所處的年代linux內核是2.4,我的實驗linux內核是2.6,2.6使用的線程庫是NPTL,2.4使用的是老的Linux線程庫(用進程模擬線程的那個LinuxThread)。

論文實驗和我用的機器不同,論文描述了使用的環境:單 cpu 機器基本配置爲:celeron 2.0 GZ, 256M, Linux 9.2,內核 2.4.8。我的環境是我的工作本本:單cpu單核celeron(R) M 1.5 GZ,1.5G內存,ubuntu10.04 desktop,內核2.6.32。

      進程實驗代碼(fork.c):

  1. #include <stdlib.h>  
  2. #include <stdio.h>  
  3. #include <signal.h>  
  4.    
  5. #define P_NUMBER 255    /* 併發進程數量 */  
  6. #define COUNT 100       /* 每進程打印字符串次數 */  
  7. #define TEST_LOGFILE "logFile.log"  
  8. FILE *logFile = NULL;  
  9.    
  10. char *s = "hello linux/0";  
  11.    
  12. int main()  
  13. {  
  14.     int i = 0,j = 0;   
  15.     logFile = fopen(TEST_LOGFILE, "a+"); /* 打開日誌文件 */  
  16.     for(i = 0; i < P_NUMBER; i++)  
  17.     {  
  18.         if(fork() == 0) /* 創建子進程,if(fork() == 0){}這段代碼是子進程運行區間 */  
  19.         {  
  20.             for(j = 0;j < COUNT; j++)  
  21.             {  
  22.                 printf("[%d]%s/n", j, s); /* 向控制檯輸出 */  
  23.                 fprintf(logFile,"[%d]%s/n", j, s); /* 向日志文件輸出 */  
  24.             }  
  25.             exit(0); /* 子進程結束 */  
  26.         }  
  27.     }  
  28.    
  29.     for(i = 0; i < P_NUMBER; i++) /* 回收子進程 */  
  30.     {  
  31.         wait(0);  
  32.     }  
  33.    
  34.     printf("OK/n");  
  35.     return 0;  
  36. }  

      進程實驗代碼(thread.c):

  1. #include <pthread.h>   
  2. #include <unistd.h>   
  3. #include <stdlib.h>   
  4. #include <stdio.h>   
  5.    
  6. #define P_NUMBER 255    /* 併發線程數量 */  
  7. #define COUNT 100       /* 每線程打印字符串次數 */  
  8. #define Test_Log "logFIle.log"   
  9. FILE *logFile = NULL;   
  10.    
  11. char *s = "hello linux/0";  
  12.    
  13. print_hello_linux() /* 線程執行的函數 */  
  14. {  
  15.     int i = 0;  
  16.     for(i = 0; i < COUNT; i++)  
  17.     {  
  18.         printf("[%d]%s/n", i, s); /* 向控制檯輸出 */  
  19.         fprintf(logFile, "[%d]%s/n", i, s); /* 向日志文件輸出 */  
  20.     }  
  21.     pthread_exit(0); /* 線程結束 */  
  22. }  
  23.    
  24. int main()  
  25. {  
  26.     int i = 0;  
  27.     pthread_t pid[P_NUMBER]; /* 線程數組 */  
  28.     logFile = fopen(Test_Log, "a+"); /* 打開日誌文件 */  
  29.    
  30.     for(i = 0; i < P_NUMBER; i++)  
  31.         pthread_create(&pid[i], NULL, (void *)print_hello_linux, NULL); /* 創建線程 */  
  32.    
  33.     for(i = 0; i < P_NUMBER; i++)  
  34.         pthread_join(pid[i],NULL); /* 回收線程 */  
  35.    
  36.     printf("OK/n");  
  37.     return 0;  
  38. }  

      兩段程序做的事情是一樣的,都是創建“若干”個進程/線程,每個創建出的進程/線程打印“若干”條“hello linux”字符串到控制檯和日誌文件,兩個“若干”由兩個宏 P_NUMBER和COUNT分別定義,程序編譯指令如下:

diaoyf@ali:~/tmp1$ gcc -o fork fork.c
diaoyf@ali:~/tmp1$ gcc -lpthread -o thread thread.c

      實驗通過time指令執行兩個程序,抄錄time輸出的掛鐘時間(real時間):

time ./fork
time ./thread

      每批次的實驗通過改動宏 P_NUMBER和COUNT來調整進程/線程數量和打印次數,每批次測試五輪,得到的結果如下:

一、重複周麗論文實驗步驟

  第1次 第2次 第3次 第4次 第5次 平均
多進程 0m1.277s 0m1.175s 0m1.227s 0m1.245s 0m1.228s 0m1.230s
多線程 0m1.150s 0m1.192s 0m1.095s 0m1.128s 0m1.177s 0m1.148s

      進程線程數:255 / 打印次數:100

  第1次 第2次 第3次 第4次 第5次 平均
多進程 0m6.341s 0m6.121s 0m5.966s 0m6.005s 0m6.143s 0m6.115s
多線程 0m6.082s 0m6.144s 0m6.026s 0m5.979s 0m6.012s 0m6.048s

進程線程數:255 / 打印次數:500

  第1次 第2次 第3次 第4次 第5次 平均
多進程 0m12.155s 0m12.057s 0m12.433s 0m12.327s 0m11.986s 0m12.184s
多線程 0m12.241s 0m11.956s 0m11.829s 0m12.103s 0m11.928s 0m12.011s

進程線程數:255 / 打印次數:1000

  第1次 第2次 第3次 第4次 第5次 平均
多進程 1m2.182s 1m2.635s 1m2.683s 1m2.751s 1m2.694s 1m2.589s
多線程 1m2.622s 1m2.384s 1m2.442s 1m2.458s 1m3.263s 1m2.614s

      進程線程數:255 / 打印次數:5000

      本輪實驗是爲了和周麗論文作對比,因此將進程/線程數量限制在255個,論文也是測試了255個進程/線程分別進行10 次,50 次,100 次,200 次……900 次打印的用時,論文得出的結果是:任務量較大時,多進程比多線程效率高;而完成的任務量較小時,多線程比多進程要快,重複打印 600 次時,多進程與多線程所耗費的時間相同。

      雖然我的實驗直到5000打印次數時,多進程纔開始領先,但考慮到使用的是NPTL線程庫的緣故,從而可以證實了論文的觀點。從我的實驗數據看,多線程和多進程兩組數據非常接近,考慮到數據的提取具有瞬間性,因此可以認爲他們的速度是相同的。

      當前的網絡環境中,我們更看中高併發、高負荷下的性能,縱觀前面的實驗步驟,最長的實驗週期不過1分鐘多一點,因此下面的實驗將向兩個方向延伸,第一,增加併發數量,第二,增加每進程/線程的工作強度。

二、增加併發數量的實驗

      下面的實驗打印次數不變,而進程/線程數量逐漸增加。在實驗過程中多線程程序在後三組(線程數500,800,1000)的測試中都出現了“段錯誤”,出現錯誤的原因和線程棧的大小有關。

      實驗中的計算機CPU是32位的賽揚,尋址最大範圍是4GB(2的32次方),Linux是按照3GB/1GB的方式來分配內存,其中1GB屬於所有進程共享的內核空間,3GB屬於用戶空間(進程虛擬內存空間),對於進程而言只有一個棧,這個棧可以用盡這3GB空間(計算時需要排除程序文本、數據、共享庫等佔用的空間),所以它的大小通常不是問題。但對線程而言每個線程有一個線程棧,這3GB空間會被所有線程棧攤分,線程數量太多時,線程棧累計的大小將超過進程虛擬內存空間大小,這就是實驗中出現的“段錯誤”的原因。

      Linux2.6的默認線程棧大小是8M,可以通過 ulimit -s 命令查看或修改,我們可以計算出線程數的最大上線: (1024*1024*1024*3) / (1024*1024*8) = 384,實際數字應該略小與384,因爲還要計算程序文本、數據、共享庫等佔用的空間。在當今的稍顯繁忙的WEB服務器上,突破384的併發訪問並不是稀罕的事情,要繼續下面的實驗需要將默認線程棧的大小減小,但這樣做有一定的風險,比如線程中的函數分配了大量的自動變量或者函數涉及很深的棧幀(典型的是遞歸調用),線程棧就可能不夠用了。可以配合使用POSIX.1規定的兩個線程屬性guardsize和stackaddr來解決線程棧溢出問題,guardsize控制着線程棧末尾之後的一篇內存區域,一旦線程棧在使用中溢出併到達了這片內存,程序可以捕獲系統內核發出的告警信號,然後使用malloc獲取另外的內存,並通過stackaddr改變線程棧的位置,以獲得額外的棧空間,這個動態擴展棧空間辦法需要手工編程,而且非常麻煩。

      有兩種方法可以改變線程棧的大小,使用 ulimit -s 命令改變系統默認線程棧的大小,或者在代碼中創建線程時通過pthread_attr_setstacksize函數改變棧尺寸,在實驗中使用的是第一種,在程序運行前先執行ulimit指令將默認線程棧大小改爲1M:

diaoyf@ali:~/tmp1$ ulimit -s 1024
diaoyf@ali:~/tmp1$ time ./thread

  第1次 第2次 第3次 第4次 第5次 平均
多進程 0m4.958s 0m5.032s 0m5.181s 0m4.951s 0m5.032s 0m5.031s
多線程 0m4.994s 0m5.040s 0m5.071s 0m5.113s 0m5.079s 0m5.059s

      進程線程數:100 / 打印次數:1000

  第1次 第2次 第3次 第4次 第5次 平均
多進程 0m12.155s 0m12.057s 0m12.433s 0m12.327s 0m11.986s 0m12.184s
多線程 0m12.241s 0m11.956s 0m11.829s 0m12.103s 0m11.928s 0m12.011s

      進程線程數:255 / 打印次數:1000 (這裏使用了第一次的實驗數據)

  第1次 第2次 第3次 第4次 第5次 平均
多進程 0m17.686s 0m17.569s 0m17.609s 0m17.663s 0m17.784s 0m17.662s
多線程 0m17.694s 0m17.976s 0m17.884s 0m17.785s 0m18.261s 0m17.920s

      進程線程數:350 / 打印次數:1000

  第1次 第2次 第3次 第4次 第5次 平均
多進程 0m23.638s 0m23.543s 0m24.135s 0m23.981s 0m23.507s 0m23.761s
多線程 0m23.634s 0m23.326s 0m23.408s 0m23.294s 0m23.980s 0m23.528s

      進程線程數:500 / 打印次數:1000 (線程棧大小更改爲1M)

  第1次 第2次 第3次 第4次 第5次 平均
多進程 0m38.517s 0m38.133s 0m38.872s 0m37.971s 0m38.005s 0m38.230s
多線程 0m38.011s 0m38.049s 0m37.635s 0m38.219s 0m37.861s 0m37.995s

      進程線程數:800 / 打印次數:1000 (線程棧大小更改爲1M)

  第1次 第2次 第3次 第4次 第5次 平均
多進程 0m48.157s 0m47.921s 0m48.124s 0m48.081s 0m48.126s 0m48.082s
多線程 0m47.513s 0m47.914s 0m48.073s 0m47.920s 0m48.652s 0m48.014s

      進程線程數:1000 / 打印次數:1000 (線程棧大小更改爲1M)

      出現了線程棧的問題,讓我特別關心Java線程是怎樣處理的,因此用Java語言寫了同樣的實驗程序,Java程序加載虛擬機環境比較耗時,所以沒有用time提取測試時間,而直接將測時寫入代碼。對Linux上的C編程不熟悉的Java程序員也可以用這個程序去對比理解上面的C語言試驗程序。

 

  1. import java.io.File;  
  2. import java.io.FileNotFoundException;  
  3. import java.io.FileOutputStream;  
  4. import java.io.IOException;  
  5.    
  6. public class MyThread extends Thread  
  7. {  
  8.     static int P_NUMBER = 1000;     /* 併發線程數量 */  
  9.     static int COUNT = 1000;        /* 每線程打印字符串次數 */  
  10.    
  11.     static String s = "hello linux/n";  
  12.           
  13.     static FileOutputStream out = null; /* 文件輸出流 */  
  14.     @Override  
  15.     public void run()  
  16.     {  
  17.         for (int i = 0; i < COUNT; i++)  
  18.         {  
  19.             System.out.printf("[%d]%s", i, s); /* 向控制檯輸出 */  
  20.               
  21.             StringBuilder sb = new StringBuilder(16);  
  22.             sb.append("[").append(i).append("]").append(s);  
  23.             try  
  24.             {  
  25.                 out.write(sb.toString().getBytes());/* 向日志文件輸出 */  
  26.             }  
  27.             catch (IOException e)  
  28.             {  
  29.                 e.printStackTrace();  
  30.             }  
  31.         }  
  32.     }  
  33.    
  34.     public static void main(String[] args) throws FileNotFoundException, InterruptedException  
  35.     {  
  36.         MyThread[] threads = new MyThread[P_NUMBER]; /* 線程數組 */  
  37.           
  38.         File file = new File("Javalogfile.log");  
  39.         out = new FileOutputStream(file, true);  /* 日誌文件輸出流 */  
  40.           
  41.         System.out.println("開始運行");  
  42.         long start = System.currentTimeMillis();  
  43.    
  44.         for (int i = 0; i < P_NUMBER; i++) //創建線程  
  45.         {  
  46.             threads[i] = new MyThread();   
  47.             threads[i].start();  
  48.         }  
  49.    
  50.         for (int i = 0; i < P_NUMBER; i++) //回收線程  
  51.         {  
  52.             threads[i].join();  
  53.         }  
  54.           
  55.         System.out.println("用時:" + (System.currentTimeMillis() – start) + " 毫秒");  
  56.         return;  
  57.     }  
  58.    
  59. }  

  第1次 第2次 第3次 第4次 第5次 平均
Java 65664 毫秒 66269 毫秒 65546 毫秒 65931 毫秒 66540 毫秒 65990 毫秒

      線程數:1000 / 打印次數:1000

      Java程序比C程序慢一些在情理之中,但Java程序並沒有出現線程棧問題,5次測試都平穩完成,可以用下面的ps指令獲得java進程中線程的數量:

diaoyf@ali:~$ ps -eLf | grep MyThread | wc -l
1010

      用ps測試線程數在1010上維持了很長時間,多出的10個線程應該是jvm內部的管理線程,比如用於GC。我不知道Java創建線程時默認棧的大小是多少,很多資料說法不統一,於是下載了Java的源碼jdk-6u21-fcs-src-b07-jrl-17_jul_2010.jar(實驗環境安裝的是 SUN jdk 1.6.0_20-b02),但沒能從中找到需要的信息。對於jvm的運行,java提供了控制參數,因此再次測試時,通過下面的參數將Java線程棧大小定義在8192k,和Linux的默認大小一致:

diaoyf@ali:~/tmp1$ java -Xss8192k MyThread

      出乎意料的是並沒有出現想象中的異常,但用ps偵測線程數最高到達337,我判斷程序在創建線程時在棧到達可用內存的上線時就停止繼續創建了,程序運行的時間遠小於估計值也證明了這個判斷。程序雖然沒有拋出異常,但運行的並不正常,另一個問題是最後並沒有打印出“用時 xxx毫秒”信息。

      這次測試更加深了我的一個長期的猜測:Java的Web容器不穩定。因爲我是多年編寫B/S的Java程序員,WEB服務不穩定常常掛掉也是司空見慣的,除了自己或項目組成員水平不高,代碼編寫太爛的原因之外,我一直猜測還有更深層的原因,如果就是線程原因的話,這顛覆性可比本篇文章的多進程性能顛覆性要大得多,想想世界上有多少Tomcat、Jboss、Websphere、weblogic在跑着,嘿嘿。

      這次測試還打破了以前的一個說法:單CPU上併發超過6、7百,線程或進程間的切換就會佔用大量CPU時間,造成服務器效率會急劇下降。但從上面的實驗來看,進程/線程數到1000時(這差不多是非常繁忙的WEB服務器了),仍具有很好的線性。

三、增加每進程/線程的工作強度的實驗

      這次將程序打印數據增大,原來打印字符串爲:

[java] view plaincopy
  1. char *s = "hello linux/0";  

      現在修改爲每次打印256個字節數據:

  1. char *s = "1234567890abcdef/  
  2. 1234567890abcdef/  
  3. 1234567890abcdef/  
  4. 1234567890abcdef/  
  5. 1234567890abcdef/  
  6. 1234567890abcdef/  
  7. 1234567890abcdef/  
  8. 1234567890abcdef/  
  9. 1234567890abcdef/  
  10. 1234567890abcdef/  
  11. 1234567890abcdef/  
  12. 1234567890abcdef/  
  13. 1234567890abcdef/  
  14. 1234567890abcdef/  
  15. 1234567890abcdef/  
  16. 1234567890abcdef/0";  

  第1次 第2次 第3次 第4次 第5次 平均
多進程 0m28.149s 0m27.993s 0m28.094s 0m27.657s 0m28.016s 0m27.982s
多線程 0m28.171s 0m27.764s 0m27.865s 0m28.041s 0m27.780s 0m27.924s

      進程線程數:255 / 打印次數:100

  第1次 第2次 第3次 第4次 第5次 平均
多進程 2m20.357s 2m19.740s 2m19.965s 2m19.788s 2m19.796s 2m19.929s
多線程 2m20.061s 2m20.462s 2m19.789s 2m19.514s 2m19.479s 2m19.861s

      進程線程數:255 / 打印次數:500

  第1次 第2次
多進程 9m39s 9m17s
多線程 9m31s 9m22s

      進程線程數:255 / 打印次數:2000 (實驗太耗時,因此只進行了2輪比對)

【實驗結論】

      從上面的實驗比對結果看,即使Linux2.6使用了新的NPTL線程庫(據說比原線程庫性能提高了很多,唉,又是據說!),多線程比較多進程在效率上沒有任何的優勢,在線程數增大時多線程程序還出現了運行錯誤,實驗可以得出下面的結論:

在Linux2.6上,多線程並不比多進程速度快,考慮到線程棧的問題,多進程在併發上有優勢。

四、多進程和多線程在創建和銷燬上的效率比較

      預先創建進程或線程可以節省進程或線程的創建、銷燬時間,在實際的應用中很多程序使用了這樣的策略,比如Apapche預先創建進程、Tomcat預先創建線程,通常叫做進程池或線程池。在大部分人的概念中,進程或線程的創建、銷燬是比較耗時的,在stevesn的著作《Unix網絡編程》中有這樣的對比圖(第一卷 第三版 30章 客戶/服務器程序設計範式):

行號 服務器描述 進程控制CPU時間(秒,與基準之差)
Solaris2.5.1 Digital Unix4.0b BSD/OS3.0
0 迭代服務器(基準測試,無進程控制) 0.0 0.0 0.0
1 簡單併發服務,爲每個客戶請求fork一個進程 504.2 168.9 29.6
2 預先派生子進程,每個子進程調用accept   6.2 1.8
3 預先派生子進程,用文件鎖保護accept 25.2 10.0 2.7
4 預先派生子進程,用線程互斥鎖保護accept 21.5    
5 預先派生子進程,由父進程向子進程傳遞套接字 36.7 10.9 6.1
6 併發服務,爲每個客戶請求創建一個線程 18.7 4.7  
7 預先創建線程,用互斥鎖保護accept 8.6 3.5  
8 預先創建線程,由主線程調用accept 14.5 5.0  

      stevens已駕鶴西去多年,但《Unix網絡編程》一書仍具有巨大的影響力,上表中stevens比較了三種服務器上多進程和多線程的執行效率,因爲三種服務器所用計算機不同,表中數據只能縱向比較,而橫向無可比性,stevens在書中提供了這些測試程序的源碼(也可以在網上下載)。書中介紹了測試環境,兩臺與服務器處於同一子網的客戶機,每個客戶併發5個進程(服務器同一時間最多10個連接),每個客戶請求從服務器獲取4000字節數據,預先派生子進程或線程的數量是15個。

      第0行是迭代模式的基準測試程序,服務器程序只有一個進程在運行(同一時間只能處理一個客戶請求),因爲沒有進程或線程的調度切換,因此它的速度是最快的,表中其他服務模式的運行數值是比迭代模式多出的差值。迭代模式很少用到,在現有的互聯網服務中,DNS、NTP服務有它的影子。第1~5行是多進程服務模式,期中第1行使用現場fork子進程,2~5行都是預先創建15個子進程模式,在多進程程序中套接字傳遞不太容易(相對於多線程),stevens在這裏提供了4個不同的處理accept的方法。6~8行是多線程服務模式,第6行是現場爲客戶請求創建子線程,7~8行是預先創建15個線程。表中有的格子是空白的,是因爲這個系統不支持此種模式,比如當年的BSD不支持線程,因此BSD上多線程的數據都是空白的。

      從數據的比對看,現場爲每客戶fork一個進程的方式是最慢的,差不多有20倍的速度差異,Solaris上的現場fork和預先創建子進程的最大差別是504.2 :21.5,但我們不能理解爲預先創建模式比現場fork快20倍,原因有兩個:

      1. stevens的測試已是十幾年前的了,現在的OS和CPU已起了翻天覆地的變化,表中的數值需要重新測試。

      2. stevens沒有提供服務器程序整體的運行計時,我們無法理解504.2 :21.5的實際運行效率,有可能是1504.2 : 1021.5,也可能是100503.2 : 100021.5,20倍的差異可能很大,也可能可以忽略。

      因此我寫了下面的實驗程序,來計算在Linux2.6上創建、銷燬10萬個進程/線程的絕對用時。

      創建10萬個進程(forkcreat.c):

 

  1. #include <stdlib.h>  
  2. #include <signal.h>  
  3. #include <stdio.h>  
  4. #include <unistd.h>  
  5. #include <sys/stat.h>  
  6. #include <fcntl.h>  
  7. #include <sys/types.h>  
  8. #include <sys/wait.h>  
  9.    
  10. int count;  /* 子進程創建成功數量 */  
  11. int fcount; /* 子進程創建失敗數量 */  
  12. int scount; /* 子進程回收數量 */  
  13.    
  14. /* 信號處理函數–子進程關閉收集 */  
  15. void sig_chld(int signo)  
  16. {  
  17.       
  18.     pid_t chldpid; /* 子進程id */  
  19.     int stat; /* 子進程的終止狀態 */  
  20.    
  21.     /* 子進程回收,避免出現殭屍進程 */  
  22.     while ((chldpid = wait(&stat)) > 0)  
  23.     {  
  24.         scount++;  
  25.     }  
  26. }  
  27.    
  28. int main()  
  29. {  
  30.     /* 註冊子進程回收信號處理函數 */  
  31.     signal(SIGCHLD, sig_chld);  
  32.    
  33.     int i;  
  34.     for (i = 0; i < 100000; i++) //fork()10萬個子進程  
  35.     {  
  36.         pid_t pid = fork();  
  37.         if (pid == -1) //子進程創建失敗  
  38.         {  
  39.             fcount++;  
  40.         }  
  41.         else if (pid > 0) //子進程創建成功  
  42.         {  
  43.             count++;  
  44.         }  
  45.         else if (pid == 0) //子進程執行過程  
  46.         {  
  47.             exit(0);  
  48.         }  
  49.     }  
  50.    
  51.     printf("count: %d fcount: %d scount: %d/n", count, fcount, scount);  
  52. }  

      創建10萬個線程(pthreadcreat.c):

  1. #include <stdio.h>  
  2. #include <pthread.h>  
  3.    
  4. int count = 0; /* 成功創建線程數量 */  
  5.    
  6. void thread(void)  
  7. {  
  8.     /* 線程啥也不做 */  
  9. }  
  10.    
  11. int main(void)  
  12. {  
  13.     pthread_t id; /* 線程id */  
  14.     int i,ret;  
  15.    
  16.     for (i = 0; i < 100000; i++) /* 創建10萬個線程 */  
  17.     {  
  18.         ret = pthread_create(&id, NULL, (void *)thread, NULL);  
  19.         if(ret != 0)  
  20.         {  
  21.             printf ("Create pthread error!/n");  
  22.             return (1);  
  23.         }  
  24.    
  25.         count++;  
  26.    
  27.         pthread_join(id, NULL);   
  28.     }  
  29.       
  30.     printf("count: %d/n", count);  
  31.    
  32. }  

      創建10萬個線程的Java程序:

[java] view plaincopy
  1. public class ThreadTest  
  2. {  
  3.     public static void main(String[] ags) throws InterruptedException  
  4.     {  
  5.         System.out.println("開始運行");  
  6.         long start = System.currentTimeMillis();  
  7.         for(int i = 0; i < 100000; i++) //創建10萬個線程  
  8.         {  
  9.             Thread athread = new Thread();  //創建線程對象  
  10.             athread.start();                //啓動線程  
  11.             athread.join();                 //等待該線程停止  
  12.         }  
  13.           
  14.         System.out.println("用時:" + (System.currentTimeMillis() – start) + " 毫秒");  
  15.     }  
  16. }  

      在我的賽揚1.5G的CPU上測試結果如下(仍採用測試5次後計算平均值):

創建銷燬10萬個進程 創建銷燬10萬個線程 創建銷燬10萬個線程(Java)
0m18.201s 0m3.159s 12286毫秒

      從數據可以看出,多線程比多進程在效率上有5~6倍的優勢,但不能讓我們在使用那種併發模式上定性,這讓我想起多年前政治課上的一個場景:在講到優越性時,面對着幾個對此發表質疑評論的調皮男生,我們的政治老師發表了高見,“不能只橫向地和當今的發達國家比,你應該縱向地和過去中國幾十年的發展歷史比”。政治老師的話套用在當前簡直就是真理,我們看看,即使是在賽揚CPU上,創建、銷燬進程/線程的速度都是空前的,可以說是有質的飛躍的,平均創建銷燬一個進程的速度是0.18毫秒,對於當前服務器幾百、幾千的併發量,還有預先派生子進程/線程的必要嗎?

      預先派生子進程/線程比現場創建子進程/線程要複雜很多,不僅要對池中進程/線程數量進行動態管理,還要解決多進程/多線程對accept的“搶”問題,在stevens的測試程序中,使用了“驚羣”和“鎖”技術。即使stevens的數據表格中,預先派生線程也不見得比現場創建線程快,在《Unix網絡編程》第三版中,新作者參照stevens的測試也提供了一組數據,在這組數據中,現場創建線程模式比預先派生線程模式已有了效率上的優勢。因此我對這一節實驗下的結論是:

預先派生進程/線程的模式(進程池、線程池)技術,不僅複雜,在效率上也無優勢,在新的應用中可以放心大膽地爲客戶連接請求去現場創建進程和線程。

     我想,這是fork迷們最願意看到的結論了。

五、併發服務的不可測性

      看到這裏,你會感覺到我有挺進程、貶線程的論調,實際上對於現實中的併發服務具有不可測性,前面的實驗和結論只可做參考,而不可定性。對於不可測性,我舉個生活中的例子。

      這幾年在大都市生活的朋友都感覺城市交通狀況越來越差,到處堵車,從好的方面想這不正反應了我國GDP的高速發展。如果你7、8年前來到西安市,穿過南二環上的一些十字路口時,會發現一個奇怪的U型彎的交通管制,爲了更好的說明,我畫了兩張圖來說明,第一張圖是採用U型彎之前的,第二張是採用U型彎之後的。

南二環交通圖一

南二環交通圖一

南二環交通圖二

南二環交通圖二

      爲了講述的方便,我們不考慮十字路口左拐的情況,在圖一中東西向和南北向的車輛交匯在十字路口,用紅綠燈控制同一時間只能東西向或南北向通行,一般的十字路口都是這樣管控的。隨着車輛的增多,十字路口的堵塞越來越嚴重,尤其是上下班時間經常出現堵死現象。於是交通部門在不動用過多經費的情況下而採用了圖二的交通管制,東西向車輛行進方式不變,而南北向車輛不能直行,需要右拐到下一個路口拐一個超大的U型彎,這樣的措施避免了因車輛交錯而引發堵死的次數,從而提高了車輛的通過效率。我曾經問一個每天上下班乘公交經過此路口的同事,他說這樣的改動不一定每次上下班時間都能縮短,但上班時間有保障了,從而遲到次數減少了。如果今天你去西安市的南二環已經見不到U型彎了,東西向建設了高架橋,車輛分流後下層的十字路口已恢復爲圖一方式。

      從效率的角度分析,在圖一中等一個紅燈45秒,遠遠小於圖二拐那個U型彎用去的時間,但實際情況正好相反。我們可以設想一下,如果路上的所有運行車輛都是同一型號(比如說全是QQ3微型車),所有的司機都遵守交規,具有同樣的心情和性格,那麼圖一的通行效率肯定比圖二高。現實中就不一樣了,首先車輛不統一,有大車、小車、快車、慢車,其次司機的品行不一,有特別遵守交規的,有想耍點小聰明的,有性子慢的,也有的性子急,時不時還有三輪摩托逆行一下,十字路口的“死鎖”也就難免了。

      那麼在什麼情況下圖二優於圖一,是否能拿出一個科學分析數據來呢?以現在的科學技術水平是拿不出來的,就像長期的天氣預報不可預測一樣,西安市的交管部門肯定不是分析各種車輛的運行規律、速度,再進行復雜的社會學、心理學分析做出U型彎的決定的,這就是要說的不可測性。

      現實中的程序亦然如此,比如WEB服務器,有的客戶在快車道(寬帶),有的在慢車道(窄帶),有的性子慢(等待半分鐘也無所謂),有的性子急(拼命的進行瀏覽器刷新),時不時還有一兩個黑客混入其中,這種情況每個服務器都不一樣,既是是同一服務器每時每刻的變化也不一樣,因此說不具有可測性。開發者和維護者能做的,不論是前面的這種實驗測試,還是對具體網站進行的壓力測試,最多也就能模擬相當於QQ3通過十字路口的場景。

結束語

      本篇文章比較了Linux系統上多線程和多進程的運行效率,在實際應用時還有其他因素的影響,比如網絡通訊時採用長連接還是短連接,是否採用select、poll,java中稱爲nio的機制,還有使用的編程語言,例如Java不能使用多進程,PHP不能使用多線程,這些都可能影響到併發模式的選型。

      最後還有兩點提醒:

1. 文章中的所有實驗數據有環境約束:Linux2.6,單核單U的i386處理器。
2. 由於並行服務的不可測性,文章中的觀點應該只做參考,而不要去定性。


【參考資料】

1. 《Linux系統下多線程與多進程性能分析》作者“周麗 焦程波 蘭巨龍”,這是我寫這篇文章的誘因之一,只是不知道引用原作的程序代碼是否屬於侵權行爲。

2. stevens著作的《Unix網絡編程(第一卷)》和《Unix高級環境編程》,這兩本書應該收集入IT的四書五經。

3. Robert Love著作的《Linux內核設計與實現》。

4. John Fusco 著作的《Linux開發工具箱》,這本書不太出名,但卻是我讀過的對內存和進程調度講解最清晰明瞭的,第5章“開發者必備內核知識”和第6章“進程”是這本書的精華。

發佈了128 篇原創文章 · 獲贊 14 · 訪問量 12萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章