]淺談幾種伺服器端模型——多執行緒併發式(執行緒池)

jxh_123發表於2015-05-21


【說明】本文轉自 http://www.cnblogs.com/Bozh/archive/2012/04/22/2464690.html

(如果不加以說明,我們都是考慮開發是基於GNU/Linux的)在Linux下建立一個執行緒的方式很簡單,pthread_create() 函式來建立執行緒,其中的一個引數的回撥函式,也就是執行緒本身的執行體函式。

1
void *thread_entry( void * args );

這裡不過多的強調怎樣利用執行緒等來建立執行體以及其他的系統呼叫怎樣使用的。

那麼,在服務端的執行緒使用方式一般為三種種:

(1)按需生成(來一個連線生成一個執行緒)

(2)執行緒池(預先生成很多執行緒)

(3)Leader follower(LF)

主要講解第一種和第二種,第三種暫時手上沒有例項程式碼,最近也沒寫、

第一種方式的正規化大概是這樣:

回撥函式:

1
2
3
4
5
void *thread_entry( void *args )
{
        int fd = *(int *)args ;
        do_handler_fd( fd );
}

程式主體:

1
2
3
4
5
for(;;){
    fd = accept();
    pthread_create(...,thread_entry,&fd);
}
    

這裡所展示的只是一個最簡單的方式,但是可以代表多執行緒的伺服器端模型。

大體服務端分為主執行緒和工作執行緒,主執行緒負責accept()連線,而工作執行緒負責處理業務邏輯和流的讀取等。這樣,即使在工作執行緒阻塞的情況下,也只是阻塞線上程範圍內,關於這部分內容,可以參考《C++網路程式設計》第一卷的第五章。在應用層和核心之間的執行緒比例為1:1的作業系統執行緒機制中,一個執行緒在核心中會有一個核心執行緒例項,那麼就是說,如果這個執行緒阻塞,不會引起在同一個程式裡面的執行緒也阻塞。現在大多是的作業系統採用的都是 1:1的模型,但是這個比傳統的N:1模型更消耗資源。 N:1模型就是,在應用層級別的多個執行緒在作業系統中只有一個例項,可以看做一個組,一旦一個執行緒阻塞,這個工作組的其他執行緒都會阻塞。

故上述程式碼的 do_handler_fd( fd ) 裡面的系統呼叫如果阻塞,不會引起整個程式阻塞,執行緒的阻塞只是線上程範圍內。所以,主執行緒可以一直等待客戶連線,而把工作處理過程放到執行緒中去。

這個是傳統的執行緒方式,這種方式也會帶來一些問題:

(1)工作開銷過大,執行緒的頻繁建立的銷燬也是一個很消耗資源的過程,雖然較程式小很多。 

(2)對於臨界資源的訪問需要控制加鎖等操作,加大了程式設計的複雜性。

(3)一個執行緒的崩潰會導致整個程式的崩潰,比如呼叫了exit() 函式等,雖然阻塞操作只阻塞一個執行緒,但是其他一些系統呼叫的失敗或崩潰將導致伺服器整個down機。後果不堪設想。

但是在很多地方也提到了,多執行緒的方式適合IO密集型的程式,比如大檔案傳輸等,這樣可以在使用者看來所有的操作都是並行的。

 

下面來說說執行緒池的方式,它改進了上述的問題的第一個,頻繁的建立執行緒。

執行緒池的基本思想就是預先建立一部分執行緒,然後等到任務來的時候,通過條件變數或者其他的機制來喚醒一個工作執行緒。

下面詳細的講述一下前段時間寫的一個簡單的執行緒池方案。

 

執行緒池有一個任務佇列,即由任務物件組成的一組佇列。

我們為這個任務佇列提供兩個介面:

1
void mc_thread_pool_add_task(void *task , size_t tasksize )

解釋一下這個介面的含義和引數, task 是一個指向任務例項的指標,tasksize 一般取 sizeof( instance_task ) 為的是在加入任務佇列的時候佇列的一些其他操作。為了簡單化,這裡沒有提供任務優先順序的考慮。

1
void *mc_thread_pool_get_task()

這個函式用來取得一個指向任務例項的指標,然後可以操作這個任務。

一般情況下,由主執行緒呼叫第一個函式,而工作執行緒呼叫第二個函式。

我們來看看執行緒池的結構:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
typedef struct _thread_pool_t
{
    pthread_mutex_t  queue_lock ;
    pthread_cond_t   task_cond  ;
    list_t         * tasks       // treat it as queue thread_task_t type
    pthread_t      * pthreads   ;
    int              isdestoried;
    int              workersnum ;
    char             ready      ;
    thread_task_handler  thread_pool_task_handler;
}thread_pool_t;
    /*
     *  this structure is a global control block of threads poll
     *  as you can see , queue_lock and task_cond is define to protecte access of this whole poll
     *  and task_cond is used to signal to threads that the task queue is ready
     *  tasks is a queue of tasks , each task should posted to this queue and threads
     *  in this pool can get it , we defined this task as void * to use wildly
     *  isdestoried is a boolean flag as his/her name
     *  workersnum is the total number of threads
     *  ready is a flag also and used to judge if the tasks queue is ready
     *  thread_pool_task_handler is a function point which points to the task handler you defined
     */

線上程池的結構中,我們定義了兩個變數, queue_lock 和 task_cond

一個是鎖,用來控制執行緒對於 task 任務佇列的訪問,另一個 task_cond 用來喚醒工作執行緒。

 

說說基本原理:工作執行緒預設情況下是阻塞在 pthread_cond_wait() 系統呼叫下的,如果有任務到來,我們可用使用 pthread_cond_singal() 來喚醒一個處於阻塞狀態的執行緒,這樣這個執行緒就可以執行 mc_thread_pool_get_task() 來取得一個任務,並呼叫相應的回撥函式。

 

tasks就是上面所說的任務佇列,pthreads是一個pthread_t 的陣列,也就是用來標示執行緒id 的陣列。每一次建立執行緒的時候都會返回執行緒id,所以我們需要記錄。

ready 是一個flag , 標示是否任務佇列可用。thread_task_handler   是一個函式指標,定義是這樣的:

typedef void ( *thread_task_handler )( void * args ) ;

結構體裡的 thread_pool_task_handler 就是在初始化的時候設定的執行緒的執行體。

下面看看初始化函式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
void mc_thread_pool_ini( mc_thread_pool_t * par_tp , int workersnum ,thread_task_handler par_handler )
{
    int err ;
    //par_tp = ( thread_pool_t *)malloc( sizeof(thread_pool_t) );
     
    if( par_tp == NULL )
    {
        fprintf( stderr , "thread_pool_t malloc\n");
        return  ;
    }
    par_tp->workersnum = workersnum ;
     
    pthread_mutex_init( &par_tp->queue_lock ,NULL );
    pthread_cond_init(&par_tp->task_cond , NULL );
     
    /*
    par_tp->queue_lock = PTHREAD_MUTEX_INITIALIZER ;
    par_tp->task_cond  = PTHREAD_COND_INITIALIZER  ;
    */
    par_tp->tasks = mc_listcreate() ;
    if( par_tp->tasks == NULL )
    {
        fprintf( stderr , "listcreate() error\n");
        //free( par_tp ) ;
        return  ;
    }
     
    par_tp->pthreads = ( pthread_t *)malloc( sizeof( pthread_t )*workersnum );
     
    if( par_tp->pthreads == NULL )
    {
        fprintf( stderr , "pthreads malloc\n");
        //free( par_tp );
        mc_freelist( par_tp->tasks ) ;
        return NULL ;
    }
     
    int i = 0 ;
    for( ; i < workersnum ; i++ )
    {
        fprintf(stderr,"start to create threads\n");
        err = pthread_create(&(par_tp->pthreads[i]),NULL,mc_thread_entry,NULL) ;
        if( err == -1 )
        {
            fprintf( stderr , "pthread_create error\n");
            //free( par_tp );
            mc_freelist( par_tp->tasks ) ;
            free(par_tp->pthreads) ;
        }
    }
     
    par_tp->thread_pool_task_handler = par_handler ;
    par_tp->ready = 0 ;
    fprintf(stderr,"successed to create threads\n");
}

在初始化函式中,我們傳遞了一個函式執行體的入口點,也就是函式指標給執行緒池,當我們有任務的時候,一個執行緒被喚醒,執行相應的回撥函式。

其他需要注意的地方是使用 for迴圈來建立很多的執行緒,並利用陣列方式記錄了執行緒的id 。

建立執行緒時候的回撥函式並不是我們的引數傳遞的回撥函式地址。因為在建立執行緒好執行緒的時候,我們需要一個阻塞操作,使得執行緒處於睡眠狀態,不然函式執行完畢後執行緒就退出了。所以,建立執行緒時候的回撥函式是這樣的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static void *mc_thread_entry( void *args )
{
    void * task ;
    for(;;)
    {
        pthread_mutex_lock( &mc_global_threads_pool.queue_lock ) ;
        fprintf(stderr, " locked to wait task\n");
        while( mc_global_threads_pool.ready == 0 )
        {
            pthread_cond_wait( &mc_global_threads_pool.task_cond , &mc_global_threads_pool.queue_lock ) ;
        }
        task = mc_thread_pool_get_task() ;
        fprintf(stderr, "get a task and ready to unlock \n");
        pthread_mutex_unlock( &mc_global_threads_pool.queue_lock ) ;
        mc_global_threads_pool.thread_pool_task_handler( task ) ;
    }
}

需要注意的一點是,我們要用兩個變數來判斷一個佇列是否就緒,ready 和條件變數本身。

判斷條件是 while() 而不是 if,這樣可以使得執行緒在沒有工作任務的時候,也就是工作佇列為空的時候阻塞在 pthread_cond_wait 上,關於pthread_cond_wait 的工作機制可以參考IBM developerworks上的很多好文章。

pthread_cond_wait 在發現沒有任務的時候,條件不成立的時候,是會有一個預設的操作的,就是釋放鎖,第二個引數的鎖,使得其他執行緒可以得到condition 的競爭權利。所以我們在函式體內 pthread_cond_wait 的呼叫上下有一個加鎖和釋放鎖的操作。

在函式內部有一個  mc_global_threads_pool.thread_pool_task_handler( task ) 這個操作就是執行緒內部得到了任務後呼叫回撥函式過程。

將任務佇列加入的函式例項如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void mc_thread_pool_add_task(void *task , size_t tasksize )
{
    pthread_mutex_lock( &mc_global_threads_pool.queue_lock );
     
    fprintf( stderr ,"thread locked and append to list\n");
     
    mc_list_append( mc_global_threads_pool.tasks , task , tasksize ) ;
     
    pthread_mutex_unlock( &mc_global_threads_pool.queue_lock );
     
    fprintf( stderr ,"thread unlocked and successed append to list\n");
     
    mc_global_threads_pool.ready = 1 ;
     
    if( mc_global_threads_pool.ready == 1 )
    {
        fprintf( stderr ,"signal to threads\n");
        pthread_cond_signal( &mc_global_threads_pool.task_cond ) ;
    }
}

  

這裡使用了 ready 來判斷是有任務,如果有,使用 pthread_cond_signal 來喚醒一個等待的執行緒。

取得一個佇列的任務方式很簡單,直接返回佇列的第一個任務:

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void *mc_thread_pool_get_task()
{
    void * ret_task ;
    ret_task = mc_getnode_del( mc_global_threads_pool.tasks , 0 );
    if( ret_task == NULL )
    {
        fprintf(stderr,"get node_del error\n");
    }
    fprintf( stderr ," got a task\n");
    mc_global_threads_pool.ready = 0 ;
    if( ret_task == NULL )
    {
        fprintf(stderr, "getnode_del error\n");
        return NULL ;
    }
    else
        return ret_task ;
}<br><br>

 主體框架是這樣的:

定義一個自己的task結構體比如:

1
2
3
4
typedef struct _thread_task_t
{
    int     task_num ;
}mc_thread_task_t ;

定義自己的回撥函式:

1
2
3
4
5
6
7
8
9
10
11
void my_thread_task_handler( void * task )
{
 
    fprintf(stderr,"task->tasknum %d\n",((mc_thread_task_t *)task)->task_num );
     
    /*
     *  if the task is a event we can like this demo:
     *  (event_t *)task->handler( (event_t *)task );
     *  so in event_t structure there should be a callback called handler
     */
}

  

函式主體就是這樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int main()
{
    mc_thread_task_t ltask;
    ltask.task_num = 1 ;
    fprintf(stderr,"begin to ini pool\n");
    mc_thread_pool_ini( &mc_global_threads_pool , 20 , my_thread_task_handler );
    mc_thread_pool_add_task( &ltask , sizeof(mc_thread_task_t) );
    int i = 0 ;
    for(;i < 10000; i++)
    {
        ltask.task_num = i ;
        mc_thread_pool_add_task( &ltask , sizeof(mc_thread_task_t) );
        sleep(1);
    }
    return 0;
}

執行緒池初始化的時候所傳入的結構體就是自己定義的 task 的回撥函式。

上述所說的是執行緒池一個方案。回到我們的服務端模型上來看。

我們的服務端的改寫方式可以換成這樣:

 

定義只的一個任務結構,比如說,我們定義為:

struct task
{
    int fd ;
}
 
void *task_handler( void *task )
{
        int fd = *(int *)task ;
        do_handler_fd( fd );
}

好了,我們的伺服器主體框架可以是這樣:

1
2
3
4
5
6
7
8
9
mc_thread_pool_ini( &mc_global_threads_pool , N , task_handler );  // 第二個引數為執行緒池工作執行緒數
 
for(;;)
{
    fd = accept();
    struct task * newtask = ( struct task *)malloc( sizeof(struct task) );
    newtask->fd = fd ;
    mc_thread_pool_add_task( &newtask,sizeof(struct task*) ); //將newtask 指標加入佇列,而不是例項,可以減少佇列的儲存空間
}  

總結:

  執行緒池的方案能夠減少執行緒建立時候帶來的開銷,但是對於臨界資源的訪問控制等變得更加的複雜,考慮的因素更多。這裡沒有完整的貼出執行緒池的程式碼。上述模型在平常使用的過程中適合併發連線數目不大的情況,IO密集型。對於CPU 密集型的服務端,執行緒池返回會加大資源消耗。下一篇文章我們來看看反應堆模型,非同步事件驅動,非阻塞IO,並貼出一個簡單的 epoll 的反應堆。

相關文章