1. 程式人生 > >POSIX執行緒程式設計

POSIX執行緒程式設計

 前言

        在UNIX主機上,執行緒常常又被稱為“輕量級程序”,這種稱呼很簡單同時也便於理解,事實上,UNIX執行緒是從程序演變而來的。與程序相比,執行緒相當小,建立執行緒引起的CPU開銷也相對較小。不僅如此,由於執行緒可以共享記憶體資源,而不像程序那樣擁有獨立的記憶體空間,所以使用執行緒也很節省記憶體。以後的幾篇文章,將重點講述POSIX 執行緒標準最常用的部分(主要基於其在DEC OSF/1 OS, V3.0上的實現)。

1. Hello World

       建立程序所用的函式是pthread_create()。它的四個引數包括了:一個執行緒(pthread_t)變數的指標、一個執行緒屬性(pthread_attr_t)變數的指標、執行緒啟動時所要執行的函式指標以及傳遞給該函式的一個引數(void *)。

  pthread_t         a_thread;
  pthread_attr_t    a_thread_attribute;
  
void*             thread_function(void*argument);
  
char*some_argument;
  
  pthread_create( 
&a_thread, a_thread_attribute, thread_function, (void*)some_argument);

       很多時候,執行緒屬性變數僅僅指定執行緒使用的最小棧。其實它有更豐富的含義,但現在的情況是,大多數應用程式建立新執行緒時值不過傳遞了一個 PTHREAD_ATTR_DEFAULT,有時甚至只是NULL。用pthread_create()創建出的新執行緒,從指定的函式入口開始執行,這與建立程序不同:所有的程序都具有相同的執行序列。這樣設計的原因很簡單:如果所有的程序都從同一程序空間的同一處開始執行,那麼就會有多個程序對相同的共享資源執行相同的指令。

       現在我們已經知道如何建立新執行緒,就讓我們來開始第一個多執行緒程式:用多個執行緒在螢幕上輸出“Hello World”。為了顯示執行緒的作用,我們將用到兩個執行緒:一個用來輸出“Hello”、另一個用來輸出“World”。為此,我們首先需要一個用於螢幕輸出的函式,新執行緒將從此函式開始執行。此外,我們還需要兩個執行緒(pthread_t)變數,用來建立新執行緒。當然,我們需要在 pthread_create()的引數中指明每個新執行緒應該輸出的字串。請看以下程式碼:

void* print_message_function( void*ptr ); 
  
  main()
  
{
     pthread_t thread1, thread2;
     
char*message1 ="Hello";
     
char*message2 ="World";
     
     pthread_create( 
&thread1, pthread_attr_default,
                    print_message_function, (
void*) message1);
     pthread_create(
&thread2, pthread_attr_default, 
                    print_message_function, (
void*) message2);
  
     exit(
0);
  }

  
  
void* print_message_function( void*ptr )
  
{
     
char*message;
     message 
= (char*) ptr;
     printf(
"%s ", message);
  }

        這裡需要注意的是print_message_function()函式的原型,以及建立新執行緒時對引數型別的轉換。程式首先建立第一個新執行緒並將 “Hello”作為引數傳遞,接著建立了另一個執行緒並傳遞“World”作為起始引數。我們希望第一個執行緒從 printf_message_function()開始執行,在輸出“Hello”後結束,接著第二個執行緒在輸出“World”之後也同樣地結束。這樣的過程看起來似乎很合理,然而其中有兩處嚴重缺陷。

        首先,不同的執行緒是並行執行的,並無先後次序。因此我們無法保證第一個新執行緒在第二執行緒之前輸出字串。其結果是,螢幕輸出可能是“Hello World”,也可能是“World Hello”。其次,與上述原因類似,父執行緒(姑且如此稱呼)有可能在兩個子執行緒輸出之前就執行了exit(0),這將導致整個程序結束——當然兩個子程序也就因此而結束了。其後果是螢幕上可能根本沒有輸出。為了解決第二個問題,我們可以用pthread_exit()來代替exit(),這樣兩個子程序就不會結束(因為該函式不會終止整個程序的執行)。

        目前我們的小程式有兩個競爭條件,現在讓我們試著用比較笨的辦法來解決它們。首先,為了讓兩個子執行緒按照我們需要的順序執行,我們在建立第二個執行緒之前插入一個延遲。接著,為了保證在子執行緒結束之前父執行緒不退出,我們在父執行緒的尾部也插入一個延遲。請看下面的程式碼: 

void* print_message_function( void*ptr ); 
  
  main()
  
{
     pthread_t thread1, thread2;
     
char*message1 ="Hello";
     
char*message2 ="World";
     
     pthread_create( 
&thread1, pthread_attr_default,
                    print_message_function, (
void*) message1);
     sleep(
10);
     pthread_create(
&thread2, pthread_attr_default, 
                    print_message_function, (
void*) message2);
  
     sleep(
10);
     exit(
0);
  }

  
  
void* print_message_function( void*ptr )
  
{
     
char*message;
     message 
= (char*) ptr;
     printf(
"%s", message);
     pthread_exit(
0);
  }

        令人遺憾的是,以上程式碼仍然不能達到我們的目的。利用延遲來進行執行緒同步是很不可靠的。我們目前遇到的同步問題本質上與分散式程式設計中的同步問題相同:我們永遠無法確知某一個執行緒將會在何時結束。
 
        以上程式碼的缺陷不只是不可靠,事實上sleep()函式執行時,整個程序都在睡覺而不僅僅是父執行緒,這一點和exit()很像。當sleep()返回時,我們的程式仍然面對著相同的條件競爭。我們的新程式碼不僅沒有解決競爭問題,反而讓我們多花了20秒來等待程式結束。順便應該指出,如果想要對某一執行緒進行延遲,應該呼叫pthread_delay_np()函式(np意指non portable,不可移植),如下:

struct timespec delay;
     delay.tv_sec 
=2;
     delay.tv_nsec 
=0;
     pthread_delay_np( 
&delay );

2. 執行緒同步

        POSIX提供了兩種用於執行緒同步的原語,這兩種操作分別是互斥以及條件變數。互斥是一種簡單的進行鎖定的原語,其主要作用是控制對共享資源的訪問,防止衝突。關於多執行緒程式設計,有一點值得大家注意,那就是整個程式的地址空間有所有的執行緒共享。其結果是幾乎所有的資源都可以被共享——比如全域性變數、檔案描述符等。另一方面,在每個執行緒的入口函式(由pthread_create呼叫)內,以及由該函式呼叫的其他函式內,我們都會定義一些私有的區域性變數。在多執行緒程式中,全域性變數與區域性變數總是被混合使用,要想使多執行緒程式順利的執行,各執行緒對共享資源的訪問必須得到控制。

      以下是一個生產者/消費者程式。生產者與消費者對共享緩衝區的訪問由互斥進行控制。


  
void* reader_function(void*);
  
void* writer_function(void*);
  
  
char buffer;
  
int buffer_has_item =0;
  pthread_mutex_t mutex;
  
struct timespec delay;
  
  main()
  
{
     pthread_t reader;
  
     delay.tv_sec 
=2;
     delay.tv_nsec 
=0;
  
     pthread_mutex_init(
&mutex, pthread_mutexattr_default);
     pthread_create( 
&reader, pthread_attr_default, reader_function,
                    NULL);
     writer_function();
  }

  
  
void* writer_function(void*)
  
{
     
while(1)
     
{
          pthread_mutex_lock( 
&mutex );
          
if ( buffer_has_item ==0 )
          
{
               buffer 
= make_new_item();
               buffer_has_item 
=1;
          }

          pthread_mutex_unlock( 
&mutex );
          pthread_delay_np( 
&delay );
     }

  }

  
  
void* reader_function(void*)
  
{
     
while(1)
     
{
          pthread_mutex_lock( 
&mutex );
          
if ( buffer_has_item ==1)
          
{
               consume_item( buffer );
               buffer_has_item 
=0;
          }

          pthread_mutex_unlock( 
&mutex );
          pthread_delay_np( 
&delay );
     }

  }

        上邊這個簡單的例子程式中,共享緩衝區只能儲存一個共享資料項。因此該緩衝區只有兩個狀態:“有”/“無”。生產者在向緩衝區寫入資料前,首先會將互斥上鎖,如果該互斥已被鎖定,則生產者將阻塞直到互斥被解鎖。生產者鎖定了互斥以後,將會檢查緩衝區是否為空(通過標誌變數 buffer_has_item)。如果緩衝區沒有資料,生產者就會產生新資料項放入緩衝區,並設定標誌變數以使得消費者可以知道是否能進行消費。接下來生產者解除對互斥的鎖定並等待,這樣消費者應該有充足的時間來訪問緩衝區。

        消費者採取了相似的過程來訪問緩衝區。它首先鎖定互斥,檢查標誌變數,如果可能則消費掉僅有的資料項。接著消費者解鎖互斥並等待一小會兒好讓生產者有時間寫入新的資料項。

        上例中,生產者和消費者將會持續不斷的執行,不斷的生產、消費。事實上,在通常的程式中,如果確定不再使用某個互斥,則應該用 pthread_mutex_destroy(&mutex)將其摧毀。順便提一句,在使用某個互斥之前,應該使用 pthread_mutex_init()將其初始化。在我們的例子中,初始化時使用了兩個引數,第一個用來指定被初始化的互斥,第二個則是該互斥的屬性。(在DEC OSF/1上,互斥的屬性沒有實際意義,通常使用(THREAD_MUTEXATTR_DEFAULT)。

        對互斥的正確使用可以有效地減少競爭條件。其實互斥本身是非常簡單的,只有兩個狀態:鎖定、未鎖定。它能實現的功能也是有限的。POSIX還提供了條件變數這一有力工具來補充互斥的不足。使用條件變數,一個執行緒可以在已經鎖定互斥的情況下被阻塞並等待喚醒訊號,而其他執行緒仍能訪問被鎖定的共享資源。當另外的某一個執行緒發出訊號後,被阻塞的執行緒將被喚醒並依然可以訪問阻塞前自己鎖定的共享資源。由此,互斥和條件變數的聯合使用可以幫助我們避免迴圈死鎖的情況出現。我們利用互斥和條件變數設計了一個僅有單一整數訊號燈的庫。