TCP/IP网络编程 第十八章:多线程服务器端的实现

理解线程的概念

引入线程的背景

第10章介绍了多进程服务器端的实现方法。多进程模型与select或epoll相比的确有自身的优点,但同时也有问题。如前所述,创建进程(复制)的工作本身会给操作系统带来相当沉重的负担。而且,每个进程具有独立的内存空间,所以进程间通信的实现难度也会随之提高(参考第11章)。换言之,多进程模型的缺点可概括如下。

□创建进程的过程会带来一定的开销。
□为了完成进程间数据交换,需要特殊的IPC技术。

但相比于下面的缺点,上述2个缺点不算什么。
“每秒少则数十次、多则数千次的‘上下文切换’(Context Switching)是创建进程
时最大的开销。”
对于上下文切换的理解,简单来说,就是切换进程时,操作系统需要为下一个切换进程做出的准备。而这个准备会耗费大量的代码量。所以上下文切换需要很长时间。即使通过优化加快速度,也会存在一定的局限。

为了保持多进程的优点,同时在一定程度上克服其缺点,人们引人了线程(Thread)。这是
为了将进程的各种劣势降至最低限度而设计的一种“轻量级进程”。线程相比于进程具有如下优点。
□线程的创建和上下文切换比进程的创建和上下文切换更快。
□线程间交换数据时无需特殊技术。

线程和进程的差异

线程是为了解决如下问题登场的:“嘿!为了得到多条代码执行流而复制整个内存区域的负担太重了!"
每个进程的内存空间都由保存全局变量的“数据区”、向mallco等函数的动态分配提供空间的堆(Heap)、函数运行时使用的栈(Stack)构成。每个进程都拥有这种独立空间,但如果只是获取多个代码执行流为目的,则不应该完全分离内存结构,而只需分离栈区域。通过这种方式可以获得如下优势。

□上下文切换时不需要切换数据区和堆。
□可以利用数据区和堆交换数据。

实际上这就是线程。线程为了保持多条代码执行流而隔开了栈区域。多个线程将共享数据区和堆。为了保持这种结构,线程将在进程内创建并运行。也就是说,进程和线程可以定义为如下形式。
□进程:在操作系统构成单独执行流的单位。
□线程:在进程构成单独执行流的单位。
以上就是线程的所有理论说明,下面将用具体的代码实践来加强记忆。

线程的创建及运行

POSIX是Portable Operating System Interface for Computer Environment(适用于计算机环境的可移植操作系统接口)的简写,是为了提高UNIX系列操作系统间的移植性而制定的API规范。下面要介绍的线程创建方法也是以POSIX标准为依据的。因此,它不仅适用于Linux,也适用于大部分UNIX系列的操作系统。

线程的创建和执行流程

线程具有单独的执行流,因此需要单独定义线程的main函数,还需要请求操作系统在单独的
执行源中执行该函数,完成该功能的函数如下。

#include
int pthread_create(pthread_t *restrict thread,const pthread_attr_t * restrict attr,void *(( start_routine)(void *),void * restrict arg);
//成功时返回0,失败时返回其他值
     thread        //保存新创建进程ID的变量地址值。用以区分不同线程。
     attr          //用于传递线程属性的参数,传递NULL时,创建默认属性的线程
     start_routine //相当于新线程的main函数的,在单独执行流中执行的函数地址值(函数指针)
     arg           //传递第三个参数所需的参数

要想理解好上述函数的参数,需要熟练掌握restrict关键字和函数指针相关语法。但如果只关注使用方法,那么该函数的使用比想象中要简单。下面通过简单示例了解该函数的功能。

#include
#include
#include
void* thread_main(void *arg);

int main(int argc,char *argv[]){
    pthread_t t_id;
    int thread_param=5;

    if(pthread_create(&t_id,NULL,thread_main,(void *)&thread_param)!=0){
         puts("pthread_create() error");
         return -1;
    }
    sleep(10);
    puts("end of main");
    return 0;
}

void* thread_main(void *arg){
    int i;
    int cnt=*((int*)arg);
    for(i=0;i

特别注意的是,线程相关代码在编译时需要添加-lpthread选项声明需要连接线程库,只有这样才可以调用头文件pthread.h中声明的函数。

接下来将上述示例的第15行sleep函数的调用语句改成如下形式:

sleep(2);

各位运行后可以看到,此时不会像代码中写的那样输出5次“running thread”字符串。因为
main函数返回后整个进程将被销毁。正因如此,我们在之前的示例中通过调用sleep函数向线程提供了充足的执行时间。但实际上通过调用sleep函数控制线程的执行相当于预测程序的执行流程,但实际上这是不可能完成的事情。而且稍有不慎,很可能干扰程序的正常执行流。例如,怎么可能在上述示例中准确预测thread_main函数的运行时间,并让main函数恰好等待这么长时间呢?因此,我们不用sleep函数,而是通常利用下面的函数控制线程的执行流。

#include
int pthread_join(pthread_t thread,void **status);//成功时返回0.失败时返回其他值
    thread    //该ID的线程只有在终止后此函数才会返回
    status    //保存线程的main函数返回值的指针变量的地址值

简而言之,调用该函数的进程(线程)将进入等待状态,直到第一个参数为ID的线程终止为止。下面通过实例来了解该函数的功能。

#include
#include
#include
#include
void *thread_main(void *arg);

int main(int argc,char *argv[]){
    pthread_t t_id;
    int thread_param=5;
    void *thr_ret;

    if(pthread_create(&t_id,NULL,thread_main,(void*)&thread_param)!=0){
        puts("pthread_create() error");
        return -1;
    }
    
    if(pthread_join(t_id,&thr_ret)!=0){
        puts("pthread_join() error");
        return -1;
    }

    printf("Thread return message: %s \n",(char*)thr_ret);
    free(thr_ret);
    return 0;
}

void* thread_main(void *arg){
    int i;
    int cnt=*((int*)arg);
    char *msg=(char *)malloc(sizeof(char)*50);
    strcpy(msg,"Hello, I'am thread~ \n");

    for(i=0;i

可在临界区内调用的函数

上述的示例中,我们都只是调用了一个线程。各位想想看,如果调用多个线程时会发生什么事情。这类函数内部存在临界区,也就是说,多个线程同时执行这部分代码时可能引起问题。

稍后将讨论哪些代码可能成为临界区,多个线程同时执行临界区代码时会产生哪些问题等内容。现阶段只需理解临界区的概念即可。根据临界区是否引起问题,函数可分为以下2类。

□线程安全函数
□非线程安全函数
线程安全函数被多个线程同时调用时也不会引发问题。反之,非线程安全函数被同时调用时会引发问题。

幸运的是,大多数标准函数都是线程安全的函数。更幸运的是,我们不用自己区分线程安全的函数和非线程安全的函数。因为这些平台在定义非线程安全函数的同时,提供了具有相同功能的线程安全的函数。比如,第8章介绍过的如下函数就不是线程安全的函数:

struct hostent * gethostbyname(const char * hostname);

同时提供线程安全的同一功能的函数。

struct hostent * gethostbyname_r(const char * name, struct hostent * result,,char * buffer, intbuflen,int * h_errnop);

线程安全函数的名称后缀通常为r。当然!但这种方法会给程序员带来"沉重"的负担。幸好可以通过如下方法自动将gethostbyname函数调用改为gethostbyname_r函数调用!
“声明头文件前定义RBENTRANT宏"
gethostbyname函数和gethostbyname_r函数的函数名和参数声明都不同,因此,这种宏声明方式拥有巨大的吸引力。另外,无需为了上述宏定义特意添加#define语句,可以在编译时通过添加-D_REENTRANT选项定义宏。

线程存在的问题和临界区

现在有一个情景,在一个进程中,有两个被新创建出来的线程,这两个线程一个对一个全局变量num自增,一个对一个全局变量num自减。这两个线程将分别执行1000次。这个全局变量num一开始被初始化为0。对于以上伪代码的描述的问题是num最后的值到底是多少?从表面上看,一个线程增,一个线程减,最后的结果num应该是0。但最后的运行结果并不是0!而且每次运行的结果均不同!这就是我们接下来要试着分析的问题。

多个线程访问同一变量的问题

在详细解释问题之前,先了解一个概念———寄存器。这个寄存器就和它的名字一样,是存放值的地方。当线程从进程中获取值进行运算的时候,线程实际上会将这个值放入自己的寄存器在计算完成后才会重新更新进程内部的数据区。这种机制看似没啥问题,但是如果一但一个线程在将寄存器中的值更新回数据区之前它被操作系统中断掉,转而运行第二个线程,那么此时第二个线程实际上使用的是一个过期的值!这个过程发生几次最后的误差就会有多大。

所以这个问题就是:"两个线程正在同时访问全局变量num"。

临界区位置

临界区定义为如下形式:"函数内同时运行多个线程时引起问题的多条构成的代码块。"那么上述的伪代码描述中,不就是两个线程中中,一条负责num的自增,一条负责num的自减的两条语句是临界区。

线程同步

同步的两面性

需要的同步的情况可以从如下两个方面考虑:

□同时访问同一内存空间时发生的情况。
□需要指定访问同一内存空间的线程执行顺序的情况。

之前已解释过前一种情况,因此重点讨论第二种情况。这是“控制线程执行顺序”的相关内容。假设有A、B两个线程,线程A负责向指定内存空间写入(保存)数据,线程B负责取走该数据。这种情况下,线程A首先应该访问约定的内存空间并保存数据。万一线程B先访向并取走数据,将导致错误结果。像这种需要控制执行顺序的情况也需要使用同步技术。

稍后将介绍“互斥量”(Mutex)和“信号量”(Semaphore)这2种同步技术。二者概念上十分接近,只要理解了互斥量就很容易掌握信号量。

互斥量

互斥量是“Mutual Exclusion”的简写,表示不允许多个线程同时访问。互斥量主要用于解决线程同步访问的问题。现实世界中的临界区就是洗手间。洗手间无法同时容纳多人(比作线程),因此可以将临界区比喻为洗手间。而且这里发生的所有事情几乎可以全部套用到临界区同步过程。洗手间使用规则如下。

□为了保护个人隐私,进洗手间时锁上门,出来时再打开。
□如果有人使用洗手间,其他人需要在外面等待。
□等待的人数可能很多,这些人需排队进入洗手间。

这就是洗手间的使用规则。同样,线程中为了保护临界区也需要套用上述规则。洗手间中存在,但之前的线程示例中缺少的是什么呢?就是锁机制。线程同步中同样需要锁,就像洗手间示例中使用的那样。互斥量就是一把优秀的锁,接下来介绍互斥量的创建及销毁函数。

#include
int pthread_mutex_init(pthread_mutex_t *mutex,const pthread_mutexattr_t* attr);
int pthread_mutex_destroy(pthread_mutex_t *mutex);
//成功时返回0,失败时返回其他值
    mutex    //创建互斥量时传递互斥量的变量地址,销毁互斥量时传递互斥量的变量地址
    attr     //传递即将创建的互斥量属性

从上述函数声明中也可看出,为了创建相当于锁系统的互斥量,需要声明如下pthread_
mutex_t型变量:

pthread_mutex_t mutex;

该变量的地址将传递给pthread_mutex_init函数,用来保存操作系统创建的互斥量(锁系统)。调用pthread_mutex_destroy函数时同样需要该信息。如果不需要配置特殊的互斥量属性,则向第一个参数传递NULL时,可以利用PTHREAD_MUTEX_INITIALIZER宏进行如下声明:

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

但推荐各位尽可能使用pthread_mutex_init函数进行初始化,因为通过宏进行初始化时很难发
现发生的错误。接下来介绍利用互斥量锁住或释放临界区时使用的函数。

#include
int pthread_mutex_lock(pthread_mutex_t* mutex);
int pthread_mutex_unlock(pthread_mutex_t* mutex);
//成功时返回0,失败时返回其他值。

函数名本身含有lock、unlock等词汇,很容易理解其含义。进入临界区前调用的函数就是pthread_mutex_lock。调用该函数时,发现有其他线程已进入临界区,则pthread_mutex_lock函数不会返回(一直阻塞),直到里面的线程调用pthread_mutex_unlock函数退出临界区为止。也就是说,其他线程让出临界区之前,当前线程将一直处于阻塞状态。接下来整理一下保护临界区的代码块编写方法。创建好互斥量的前提下,可以通过如下结构保护临界区。

pthread_mutex_lock(&mutex);
//临界区的开始
//.........
//临界区的结束
pthread_mutex_unlock(&mutex);

简言之,就是利用lock和unlock函数围住临界区的两端。此时互斥量相当于一把锁,阻止多个线程同时访问。还有一点需要注意,线程退出临界区时,如果忘了调用pthread_mutex_unlock函数,那么其他为了进入临界区而调用pthread_mutex_lock函数的线程就无法摆脱阻塞状态。这种情况被称为"死锁"(Dead-lock)。接下来利用互斥量解决之前伪代码中遇到的问题。

#include
#include
#include
#include
#define NUM_THREAD 100
void * thread_inc(void *arg);
void * thread_des(void *arg);

long long num=0;
pthread_mutex_t mutex;
int main(int argc,char *argv[]){
    pthread_t thread_id[NUM_TREAD];
    int i;
   
    pthread_mutex_init(&mutex,NULL);
    for(int i=0;i

信号量

下面介绍信号量。信号量与互斥量极为相似,在互斥量的基础上很容易理解信号量。此处只
涉及利用“二进制信号量”(只用0和1)完成“控制线程顺序”为中心的同步方法。下面给出信
号量创建及销毁方法。

#include
int sem_init(sem_t*sem,int pshared,unsigned int value);
int sem_destroy(sem_t*sem);
//成功时返回0,失败时返回其他值。
    sem     //创建信号量时传递保存信号量的变量地址值,销毁时传递需要销毁的信号量变量地址值。
    pshared //传递其他值时,创建可由多个进程共享的信号具;传递0时,创建只允许1个进程内部使用的信 
            //号量。我们只需要完成同一进程内的线程同步,故传递0。
    value   //指定新创建的信号量初始值。

上述函数的pshared参数超出了我们关注的范围,默认向其传递0。稍后讲解通过value参数初始化的信号量值究竟是多少。接下来介绍信号量中相当于互斥量lock、unlock的函数。

#include
int sem_post(sem_t *sem);
int sem_wait(sem_t *sem);
//成功时返回0,失败时返回其他值
    sem  //传递保存信号量读取值的变量地址值,传递给sem_post时信号量增1,传递给sem_wait时
         //信号量减1。

调用sem_init函数时,操作系统将创建信号量对象,此对象中记录着“信号量值”整数。该值在调用sem_post函数时增1,调用sem_wait函数时减1。但信号量的值不能小于0,因此,在信号量为0的情况下调用sem_wait函数时,调用函数的线程将进入阻塞状态。当然,此时如果有其他线程调用sem_post函数,信号量的值将变为1,而原本阻塞的线程可以将该信号量重新减为0并跳出阻塞状态。实际上就是通过这种特性完成临界区的同步操作,可以通过如下形式同步临界区(假设信号量的初始值为1)。

sem_wait(&sem);//信号量变为0
//临界区的开始
//.......
//临界区的结束
sem_post(&sem);//信号量变为1

上述代码结构中,调用sem_wait函数进入临界区的线程在调用sem_post函数前不允许其他线程进入临界区。信号量的值在0和1之间跳转,因此,具有这种特性的机制称为“二进制信号量”。接下来给出信号量相关示例。该示例的场景如下:“线程A从用户输入得到值后存入全局变量num,此时线程B将取走该值并累加。该过程共进行5次,完成后输出总和并退出程序。”

为了按照上述要求构建程序,应按照线程A、线程B的顺序访问变量num,且需要线程同步。

#include
#include
#include

void* read(void* arg);
void* accu(void* arg);
static sem_t sem_one;
static sem_t sem_two;
static int num;

int main(int argc,char *argv[]){
    pthread_t id_t1,id_t2;
    sem_init(&sem_one,0,0);
    sem_init(&sem_two,0,1);

    pthread_create(&id_t1,NULL,read,NULL);
    pthread_create(&id_t2,NULL,accu,NULL);

    pthread_join(id_t1,NULL);
    pthread_join(id_t2,NULL);

    sem_destroy(&sem_one);
    sem_destroy(&sem_two);
    return 0;
}

void* read(void* arg){
    int i;
    for(i=0;i<5;++i){
        fputs("Input num:",stdout);
        sem_wait(&sem_two);
        scanf("%d",&num);
        sem_post(&sem_two);
    }
    return NULL;
}

void* accu(void* arg){
    int i;
    for(i=0;i<5;++i){
        sem_wait(&sem_one);
        sum+=num;
        sem_post(&sem_one);
    }
    printf("Result: %d \n",sum);
    return NULL;
}

以上就是线程相关的全部理论知识,下面在此基础上编写服务器。

线程的销毁和多线程并发服务器端的实现

下面先介绍线程的销毁,再实现多线程服务器。

销毁线程的2种方法

Linux线程并不是在首次调用的线程main函数返回时自动销毁,所以用如下2种方法之一加以
明确
。否则由线程创建的内存空间将一直存在。
□调用pthread_join函数。
□调用pthread_detach函数。

之前调用过pthread_join函数。调用该函数时,不仅会等待线程终止,还会引导线程销毁。但该函数的问题是,线程终止前,调用该函数的线程将进入阻塞状态。因此,通常通过如下函数调用引导线程销毁。

#include
int pthread_detach(pthread_t thread);//成功时返回0,失败时返回其他值。
    thread   //终止时返回需要销毁的线程ID

调用上述函数不会引起线程终止或进入阻塞状态,可以通过该函数在线程(被创建的)结束时引导销毁线程创建的内存空间。调用该函数后不能再针对相应线程调用pthread_join函数,这需要格外注意。虽然还有方法在创建线程时可以指定销毁时机,但与pthread_detach方式相比,结果上没有太大差异,故省略其说明。

多线程并发服务器端的实现

本节并不打算介绍回声服务器端,而是介绍多个客户端之间可以交换信息的简单的聊天程
序。无论服务器端还是客户端,代码量都不少,故省略可以从其他示例中得到或从源代码中复制
的头文件声明。同时最大程度地减少异常处理的代码。

#include<"头文件请参考之前的实例">
#define BUF_SIZE 100
#define MAX_CLNT 256

void* handle_clnt(void*arg);
void  send_msg(char *msg,int len);
void  error_handling(char* msg);
int clnt_cnt=0;
int clnt_socks[MAX_CLNT];
pthread_mutex_t mutx;

int main(int argc,char *argv[]){
    int serv_sock,clnt_sock;
    struct sockaddr_in serv_adr,clnt_adr;
    int clnt_adr_sz;
    pthread_t t_id;
    if(argc!=2){
        printf("Usage : %s \n",argv[0]);
        exit(1);
    }

    pthread_mutex_init(&mutx,NULL);
    serv_sock=socket(PF_INET,SOCK_STREAM,0);

    memset(&serv_adr,0,sizeof(serv_adr));
    serv_adr.sin_family=AF_INET;
    serv_adr.sin_addr.s_addr=htonl(INADDR_ANY);
    serv_adr.sin_port=htons(atoi(argv[1]));

    if(bind(serv_sock,(struct sockaddr*)&serv_adr,sizeof(serv_adr))==-1)
        error_handling("bind() error");
    if(listen(serv_sock,5)==-1)
        error_handling("listen() error");

    while(1){
       clnt_adr_sz=sizeof(clnt_adr);
       clnt_sock=accept(serv_sock,(struct sockaddr*)&clnt_adr,&clnt_adr_sz);
       
       pthread_mutex_lock(&mutx);
       clnt_socks[clnt_cnt++]=clnt_sock;
       pthread_mutex_unlock(&mutx);

       pthread_create(&t_id,NULL,handle_cnt,(void*)&clnt_sock);
       pthread_detach(t_id);
       printf("Connected client IP: %s \n",inet_ntoa(clnt_adr.sin_addr));
    }
    close(serv_sock);
    return 0;
}

void * handle_clnt(void *arg){
     int clnt_sock=*((int *)arg);
     int str_len=0,i;
     char msg[BUF_SIZE];

     while((str_len=read(clnt_sock,msg,sizeof(msg)))!=0)
          send_msg(msg,str_len);

     pthread_mutex_lock(&mutx);
     for(i=0;i

上述示例中,各位必须掌握的并不是聊天服务器端的实现方式,而是临界区的构成形式。上
述示例中的临界区具有如下特点:“访问全局变量clnt_cnt和数组clnt_socks的代码将构成临界区!”

添加或删除客户端时,变量cInt_cnt和数组clnt_socks同时发生变化。因此,会导致数据不一致,从而引发严重错误。

□线程A从数组clnt_socks中删除套接字信息,同时线程B读取cInt_cnt变量。
□线程A读取变量clnt_cnt,同时线程B将套接字信息添加到clnt_socks数组。

因此,如上述示例所示,访问变量cInt_cnt和数组clnt_socks的代码应组织一起并构成临界
区。

接下来介绍聊天客户端,客户端示例为了分离输入和输出过程而创建了线程。

#include <“头文件声明请参考源文件。“>
#define BUF_SIZE 100
#define NAME_SIZE 20
void *send_msg(void * arg);
void *recv_msg(void * arg);
void error _handling(char * msg);

char name[NAME_SIZE]="[DEFAULT]";
char msg[BUF_SIZE];、

int main(int argc, char *argv[]){
    int sock;
    struct sockaddr_in serv_addr;
    pthread_t snd_thread, rcv_thread;
    void * thread_return;
    if(argc!=4) {
       printf("Usage : %s   \n",argv[0]);
       exit(1);
    }

    sprintf(name,"[%s]", argv[3]);
    sock=socket(PF_INET,SOCK_STREAM, 0);
    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family=AF_INET;
    serv_addr.sin_addr.s_addr=inet_addr(argv[1]);
    serv_addr.sin_port=htons(atoi(argv[2]));

    if(connect(sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr))==-1)
         error_handling("connect() error");

    pthread_create(&snd_thread, NULL, send_msg, (void*)&sock);
    pthread_create(&rcv_thread, NULL, recv_msg,(void*)&sock);
    pthread_join(snd_thread, &thread_return);
    pthread_join(rcv_thread, &thread_return);
    close(sock);
    return 0;
}
void * send_msg(void * arg){
    int sock=*((int*)arg);
    char name_msg[NAME_SIZE+BUF_SIZE];
    while(1){
       fgets(msg, BUF_SIZE, stdin);
       if(!strcmp(msg,"q\n")||!strcmp(msg,"Q\n")){
          close(sock);
          exit(0);
       }
       sprintf(name_msg,"%s %s", name, msg);
       write(sock, name_msg, strlen(name_msg));
    }
    return NULL;
}

void * recv_msg(void * arg)
    int sock=*((int*)arg);
    char name_msg[NAME_SIZE+BUF_SIZE];
    int str_len;
    while(1){
       str_len=read(sock, name_msg, NAME_SIZE+BUF_SIZE-1);
       if(str_len==-1)return (void*)-1;
       name_msg[str_len]=0;
       fputs(name_msg, stdout);
     }
     return NULL;
}

void error_handling(char *msg){
//与之前示例的error_handling函数一致。
}

你可能感兴趣的:(TCP/IP网络编程,网络,tcp/ip,网络协议,服务器,运维)