浅谈几种服务器端模型——多线程并发式(线程池)

来源:互联网 发布:怎么禁止软件启动 编辑:程序博客网 时间:2024/06/06 05:44

引言:上篇文章说到多进程并发式的服务端模型,正如上章所述,进程的频繁创建会导致服务器不堪负载,本章主要讲:线程模型和线程池的方式来提高服务端的负载能力。同时比较一下不同的模型的好处与坏处。



这里不过多的强调怎样利用线程等来创建执行体以及其他的系统调用怎样使用的。

服务端的线程使用方式一般分为三种:

(1)按需求生成(来一个连接生成一个线程)

(2)线程池(先储备生成很多线程)

(3)Leader follower(LF)

本章主要讲解第一种和第二种。

第一种方式:

回调函数:

1
2
3
4
5
void *thread_callback( void *arg )
{
        intfd = (int)(intptr_t)arg ;
        handler_fd(fd);
}

程序主体:

1
2
3
4
5
while(1){
    fd = accept();
    pthread_create(...,thread_callback,(void*)(intptr_t)fd);
}
    

这只是一个最简单的方式,但可以代表多线程的服务器端模型。

大体服务端分为主线程和业务线程,主线程负责accept()连接,而业务线程负责处理业务逻辑和流的读取等。即使在业务线程阻塞的情况下,也只是阻塞在线程范围内。在应用层和内核之间的线程比例为1:1的操作系统线程机制中,一个线程在内核中会有一个内核线程实例,也就是说,如果这个线程阻塞,不会引起在同一个进程里面的线程也阻塞。现在大多的操作系统采用的都是 1:1的模型,但这个比传统的N:1模型更消耗资源。所谓N:1模型就是:在应用层级别的多个线程,在操作系统中只有一个实例,可以比作一个组,一旦某一个线程阻塞,那么工作组中的其他线程都会受影响而阻塞。

故上述代码handler_fd( fd ) 里面的系统调用如果阻塞,不会引起整个进程阻塞,线程的阻塞只是在线程范围内。所以,主线程可以一直等待客户连接,而把业务逻辑处理放到子线程中去。

此为传统的线程方式,其也会带来一些问题:

(1)工作开销过大,线程的频繁创建和销毁也是一个很消耗资源的过程,虽然较进程小很多。 

(2)对于临界资源的访问需要控制,如加锁等操作,因此加大了程序设计的复杂度。

(3)一个线程的崩溃会导致整个进程的崩溃,比如调用了exit() 函数等,虽然阻塞操作只阻塞一个线程,但是其他一些系统调用的失败或崩溃将导致整个服务器down机。后果是不堪设想的。

总体来说,多线程的方式适合IO密集型的程序,比如大文件传输等

 

再来说下线程池的方式:

线程池的基本思想:预先创建一部分线程,等到任务来的时候,通过条件变量或者其他的机制来唤醒一个业务线程。

一般来说实现一个线程池主要包括以下几个组成部分:
(1)线程管理器:用于创建并管理线程池。
(2)业务线程:线程池中实际执行任务的线程。在初始化线程时会预先创建好固定数目的线程在池中,这些初始化的线程一般处于空闲状态,并且一般不占用 CPU,只占用较小的内存空间。
(3)任务接口:每个任务必须实现的接口,当线程池的任务队列中有可执行任务时,被空闲的业务线程调去执行(线程的闲与忙是通过互斥量实现的),把任务抽象出来形成接口,可以做到线程池与具体的任务无关。
(4)任务队列:用来存放没有处理的任务,提供一种缓冲机制,实现这种结构有好几种方法,常用的是队列,主要运用先进先出原理,另外一种是链表之类的数据结构,可以动态的为它分配内存空间,应用中比较灵活。


首先讲述任务队列提供的两个接口:

//参数一:void *task(指向实例的指针)

//参数二:size_t tasksize(一般取 sizeof( instance_task ) 为的是在加入任务队列的时候队列的一些其他操作。)

1
void thread_pool_add_task(void *task , size_t tasksize )

为了简单化,这里没有提供任务优先级的考虑。

1
void *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() 来唤醒一个处于阻塞状态的线程,这样这个线程就可以执行thread_pool_get_task() 来取得一个任务,并调用相应的回调函数。

 

tasks就是上面所说的任务队列,pthreads是一个pthread_t 的数组,用来标示线程id 的数组。每一次创建线程的时候都会返回线程id,用数组来记录。

ready 是一个flag , 标示是否任务队列可用。thread_task_handler   是一个函数指针,其定义:

typedefvoid( *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( thread_pool_t * par_tp , intworkersnum ,thread_task_handler par_handler )
{
    interr ;
    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 = 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 );
        freelist( par_tp->tasks ) ;
        returnNULL ;
    }
     
 
    for(int i=0; i < workersnum ; i++ )
    {
        fprintf(stderr,"start to create threads\n");
        err = pthread_create(&(par_tp->pthreads[i]),NULL,thread_callback,NULL) ;
        if( err == -1 )
        {
            fprintf( stderr ,"pthread_create error\n");
            //free( par_tp );
            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 *thread_callback(void*arg)
{
    void* task ;
    while(1)
    {
        pthread_mutex_lock( &global_threads_pool.queue_lock ) ;
        fprintf(stderr," locked to wait task\n");
        while( global_threads_pool.ready == 0 )
        {
            pthread_cond_wait( &global_threads_pool.task_cond , &global_threads_pool.queue_lock ) ;
        }
        task = thread_pool_get_task() ;
        fprintf(stderr,"get a task and ready to unlock \n");
        pthread_mutex_unlock( &global_threads_pool.queue_lock ) ;
        global_threads_pool.thread_pool_task_handler( task ) ;
    }
}

需要注意的是,需要用两个变量来判断一个队列是否就绪,ready 和条件变量本身。

判断条件是 while() 而不是 if,这样可以使得线程在没有业务逻辑的时候,也就是队列为空的时候,阻塞在 pthread_cond_wait 上,pthread_cond_wait 在发现没有任务的时候,也就是条件不成立的时候,是会有一个默认的操作的,就是释放锁,第二个参数的锁,使得其他线程可以得到condition 的竞争权利。所以在函数体内 pthread_cond_wait 的调用上下有一个加锁和释放锁的操作。

在函数内部有一个  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 thread_pool_add_task(void *task , size_t tasksize )
{
    pthread_mutex_lock( &global_threads_pool.queue_lock );
     
    fprintf( stderr ,"thread locked and append to list\n");
     
    list_append( mc_global_threads_pool.tasks , task , tasksize ) ;
     
    pthread_mutex_unlock( &global_threads_pool.queue_lock );
     
    fprintf( stderr ,"thread unlocked and successed append to list\n");
     
    global_threads_pool.ready = 1 ;
     
    if( global_threads_pool.ready == 1 )
    {
        fprintf( stderr ,"signal to threads\n");
        pthread_cond_signal( &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 *thread_pool_get_task()
{
    void* ret_task ;
    ret_task = mc_getnode_del( global_threads_pool.tasks , 0 );
    if( ret_task == NULL )
    {
        fprintf(stderr,"get node_del error\n");
    }
    fprintf( stderr ," got a task\n");
    global_threads_pool.ready = 0 ;
    if( ret_task == NULL )
    {
        fprintf(stderr,"getnode_del error\n");
        returnNULL ;
    }
    else
        returnret_task ;
}

 主体框架是这样的:

自定义的task结构体:

1
2
3
4
typedef struct_thread_task_t
{
    int    task_num ;
}thread_task_t ;

自定义的回调函数:

1
2
3
4
5
6
7
8
9
10
11
void thread_task_handler( void * task )
{
 
    fprintf(stderr,"task->tasknum %d\n",((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()
{
    thread_task_t ltask;
    ltask.task_num = 1 ;
    fprintf(stderr,"begin to ini pool\n");
    thread_pool_ini( &global_threads_pool , 20 , thread_task_handler );
    thread_pool_add_task( &ltask ,sizeof(thread_task_t) );
 
    for(int i=0;i < 10000; i++)
    {
        ltask.task_num = i ;
        thread_pool_add_task( &ltask ,sizeof(thread_task_t) );
        sleep(1);
    }
    return0;
}

线程池初始化的时候所传入的结构体就是自定义task 的回调函数。

上述所说的是线程池一个方案。回到服务端模型上来。

服务端的改写方式可以换成这样:

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

服务器主体框架大概如此:

1
2
3
4
5
6
7
8
9
thread_pool_ini( &global_threads_pool , N , task_handler ); // 第二个参数为线程池的业务线程数
 
while(1)
{
    fd = accept();
    structtask * newtask = (structtask *)malloc(sizeof(structtask) );
    newtask->fd = fd ;
    thread_pool_add_task( &newtask,sizeof(structtask*) ); //将newtask 指针加入队列,而不是实例,可以减少队列的存储空间
}  


补充一份完整的线程池代码:线程池代码

总结:

  线程池的方案能够减少线程创建时带来的开销,但是对于临界资源的访问控制等变得更加的复杂,考虑的因素更多。上述模型在平常使用的过程中适合并发连接数目不大的情况,IO密集型。对于CPU 密集型的服务端,线程池返回会加大资源消耗。

阅读全文
0 0
原创粉丝点击