Linux网络编程 - 多线程服务器端的实现(2)

一  线程同步

上一篇文章【Linux网络编程 - 多线程服务器端的实现(1)】探讨了多线程编程中存在的问题,接下来就是要讨论解决方法——线程同步。

1.1 同步的两面性

线程同步用于解决线程访问共享资源顺序所引发的问题。需要同步的情况可以从如下两方面考虑。

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

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

        下面将介绍 “互斥量(Mutex)” 和 “信号量(Semaphore)” 这两种同步技术。二者概念上十分接近,只要理解了互斥量就很容易掌握信号量。而且大部分线程同步技术的原理都大同小异。

1.1 互斥量

        互斥量是 "Mutual Exclusion" 的简写,也称为互斥锁。表示不允许多个线程同时访问。互斥量主要用于解决线程同步访问的问题。为了理解好互斥量,我们以上洗手间这个生活例子来解释互斥量的作用。

        等待上洗手间的人可以看做是线程,而洗手间可以看做是临界区。洗手间每次只能容纳一个人。洗手间使用规则如下:

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

        洗手间的使用规则,同样适用于线程。多线程环境中为了保护临界区资源,也需要套用上述规则。之前的线程示例中缺少的是什么呢?就是锁机制,就像洗手间中使用的那样。互斥量就是线程所需的那把锁,接下来介绍互斥量的创建及销毁函数。

  • 线程的初始化及销毁函数
#include <pthread.h>

int pthread_mutex_init(pthread_mutex_t *restrict mutex,
              const pthread_mutexattr_t *restrict attr);

int pthread_mutex_destroy(pthread_mutex_t *mutex);

参数说明

  • mutex:创建互斥量时传递保存互斥量变量的地址值,销毁时传递需要销毁的互斥量地址值。
  • attr:传递即将创建的互斥量属性,没有特别需要指定的属性时传递 NULL。

为了创建相当于锁系统的互斥量,需要声明如下 pthread_mutex_t 类型变量:

pthread_mutex_t mutex;

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

//声明静态互斥量
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

        但还是推荐使用 pthread_mutex_init 函数进行初始化,因为通过宏名进行初始化时很难发现发生的错误。接下来介绍利用互斥量加锁和解锁临界区时使用的函数。

  • 互斥量加和锁解锁函数
#include <pthread.h>

//互斥量加锁函数
int pthread_mutex_lock(pthread_mutex_t *mutex);

//互斥量加锁函数,但不阻塞
int pthread_mutex_trylock(pthread_mutex_t *mutex);

//互斥量解锁函数
int pthread_mutex_unlock(pthread_mutex_t *mutex);

参数说明

  • mutex:保存互斥量变量的地址值。

返回值】成功时返回0,失败时返回错误编号。

函数说明

1、线程访问临界区前需要调用 pthread_mutex_lock 函数对其进行加锁操作,如果互斥量mutex已被锁定,那么 pthread_mutex_lock 函数将被阻塞而不会返回,直到互斥量被其他线程释放(即调用 pthread_mutex_unlock 函数退出临界区)为止。也就是说,其他线程让出临界区之前,当前线程将一直处于阻塞状态。

2、当 pthread_mutex_lock 成功返回时,说明互斥量被当前线程成功加锁。

3、pthread_mutex_trylock 函数也是对互斥量进行加锁操作,但是与 pthread_mutex_lock 函数不同的是,如果互斥量已经被锁定,该函数不会阻塞,而是立即返回一个 EBUSY 错误,也就是说,当前线程并不会被阻塞,而是可以继续往下执行其他操作。

4、pthread_mutex_unlock 函数用于解锁操作,即线程让出临界区的使用权,而其他因为等待互斥量的释放而被阻塞的线程将按照线程的优先级顺序对互斥量重新进行加锁操作,从而获得临界区的使用权。

接下来整理一下保护临界区的代码块编写方法。创建好互斥量的前提下,可以通过如下结构保护临界区。

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

        简言之,就是利用 lock 和 unlock 函数围住临界区的两端。此时互斥量相当于一把锁,组织多个线程同时访问。还有一点需要注意,线程退出临界区时,如果忘了调用 pthread_mutex_unlock 函数,那么其他为了进入临界区而调用 pthread_mutex_lock 函数的线程就无法摆脱阻塞状态。这种情况称为 “死锁(Dead-lock)”,需要格外注意。

编程实例:接下来利用互斥量解决示例 thread4.c 中遇到的线程同步问题。

  • mutex.c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <pthread.h>

#define THREAD_NUM 100

void* thread_inc(void *arg);
void* thread_des(void *arg);
long long num = 0;            //long long 数据类型是8字节整型
pthread_mutex_t mutex;        //声明互斥量变量

int main(int argc, char *argv[])
{
    pthread_t tid[THREAD_NUM];
    int i;
    
    pthread_mutex_init(&mutex, NULL);  //初始化互斥量
    
    printf("sizeof(long long): %d(bytes)\n", sizeof(long long));  //查看long long的大小
    for(i=0; i<THREAD_NUM; i++)
    {
        if(i % 2 != 0)
            pthread_create(&tid[i], NULL, thread_inc, NULL);
        else
            pthread_create(&tid[i], NULL, thread_des, NULL);
    }
    
    for(i=0; i<THREAD_NUM; i++)
        pthread_join(tid[i], NULL);

    printf("result: %lld\n", num);
    pthread_mutex_destroy(&mutex);  //销毁互斥量
    return 0;
}

void* thread_inc(void *arg)
{
    int i;
    pthread_mutex_lock(&mutex);    //加锁操作
    for(i=0; i<50000000; i++)
        num += 1;
    pthread_mutex_unlock(&mutex);  //解锁操作
    return NULL;
}

void* thread_des(void *arg)
{
    int i;
    pthread_mutex_lock(&mutex);    //加锁操作
    for(i=0; i<50000000; i++)
        num -= 1;
    pthread_mutex_unlock(&mutex);  //解锁操作
    return NULL;
}
  • 代码说明
  • 第11行:声明了一个保存互斥量读取值的变量。之所以声明为全局变量,是因为 thread_inc 和 thread_des 函数都需要访问互斥量。
  • 第33行:销毁互斥量。不再需要互斥量时应该销毁,就会释放在内核中创建的对应数据结构存储空间。
  • 第40、43行:实际临界区只有第42行。但此处连同第41行的for循环语句一起作用在临界区,调用了 lock、unlock 函数。关于这一点稍后再讨论。
  • 第50、53行:通过 lock、unlock 函数围住对应于临界区的第52行语句。

可以看到,lock、unlock 函数总是成对出现的,这样做是为了避免线程死锁的发生。

  • 运行结果
$ gcc mutex.c -D_REENTRANT -o mutex -lpthread
$ ./mutex
sizeof(long long): 8(bytes)
result: 0

        从运行结果可以看到,已解决了示例 thread4.c 中的问题。但确认运行结果需要等待较长时间。因为互斥量 lock、unlock 函数的调用过程要比想象中花费更长的时间。首先分析一下 thread_inc 线程函数的同步过程。

void* thread_inc(void *arg)
{
    int i;
    pthread_mutex_lock(&mutex);    //加锁操作
    for(i=0; i<50000000; i++)
        num += 1;
    pthread_mutex_unlock(&mutex);  //解锁操作
    return NULL;
}

以上临界区划分范围较大,但这时考虑到如下优点所做的决定:

最大限度减少互斥量 lock、unlock 函数的调用次数。

如果不太关注线程的阻塞等待时间,可以适当扩展临界区的范围。但当变量num的值增加到 50,000,000 前不允许其他线程访问,这反而成了缺点。其实这里没有正确答案,需要根据不同程序酌情考虑究竟扩大还是缩小临界区。此处没有公式可言,需要根据编程经验培养自己的判断能力。

知识扩展》互斥量的升级版 — 读写锁

        读写锁(reader-writer lock)与互斥量类似,不过读写锁允许更高的并行性。互斥量要么是锁住状态,要么就是不加锁状态,而且一次只有一个线程可以对其加锁。而读写锁可以有3种状态:读模式加锁状态、写模式加锁状态和不加锁状态。一次只有一个线程可以占有写模式的读写锁,但是多个线程可以同时占有读模式的读写锁。

  当读写锁是写加锁状态时,在这个锁被释放前,所有试图对这个锁加锁的线程都会被阻塞。当读写锁在读加锁状态时,所有试图以读模式对它进行加锁的线程都可以得到访问权,但是任何希望以写模式对此锁进行加锁的线程都会阻塞,直到所有的线程释放它们的读锁为止。虽然各操作系统对读写锁的实现各不相同,但当读写锁处于读模式锁住的状态时,而这时有一个线程试图以写模式获取锁,读写锁通常会阻塞随后的读模式锁请求。这样可以避免读模式锁长期占用,而等待的写模式锁请求一直得不到满足。

读写锁参考博文链接

线程同步—读写锁

1.3 信号量

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

  • 信号量的创建及销毁函数
#include <semaphore.h>

int sem_init(sem_t *sem, int pshared, unsigned int value);

int sem_destroy(sem_t *sem);

参数说明

  • sem:创建信号量时保存信号量的变量地址值,销毁时传递需要销毁的信号量变量地址值。
  • pshared:传递其他值时,创建可由多个进程共享的信号量;传递0时,创建只允许一个进程内部使用的信号量。我们需要完成同一进程内的线程同步,故传递0。
  • value:指定新创建的信号量初始值。

返回值】成功时返回0,失败时返回错误编号。

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

  • 信号量的加锁和解锁函数
#include <semaphore.h>

int sem_wait(sem_t *sem);

int sem_trywait(sem_t *sem);

int sem_timedwait(sem_t *sem, const struct timespec *abs_timeout);

int sem_post(sem_t *sem);

//struct timespec 时间结构体定义
struct timespec {
   time_t tv_sec;      /* Seconds */
   long   tv_nsec;     /* Nanoseconds [0 .. 999999999] */
};

参数说明

  • sem:传递保存信号量读取值的变量地址值,传递给 sem_post 时信号量增1,传递给 sem_wait 时信号量减1。

返回值】成功时返回0,失败时返回错误编号。

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

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

        上述代码结构中,调用 sem_wait 函数进入临界区的线程在调用 sem_post 函数前不允许其他线程进入临界区。信号量的值在 0 和 1 之间跳转。因此,具有这种特性的机制称为 “二进制信号量”。接下来给出信号量相关示例。即将介绍的示例并非关于同时访问的同步,而是关于控制线程访问顺序的同步。该示例的场景如下:

“线程A从用户输入得到值后存入全局变量num,此时线程B将取走该值并累加。该过程共执行5次,完成后输出总和并退出程序。”

为了按照上述要求构建程序,应按照线程A、线程B 的顺序访问全局变量num,则需要线程同步,我们使用信号量方式实现线程同步。

编程实例】使用 信号量 来实现控制线程访问顺序的同步问题。

  • semaphore.c
#include <stdio.h>
#include <pthread.h>
#include <semaphore.h>

#define N 5

void* read(void *arg);
void* accu(void *arg);

static int num;         //声明静态全局变量
static sem_t sem_one;   //声明静态信号量变量
static sem_t sem_two;   //声明静态信号量变量

int main(int argc, char *argv[])
{
    pthread_t tid1, tid2;
    
    sem_init(&sem_one, 0, 1);  //sem_one信号量初始值为1
    sem_init(&sem_two, 0, 0);  //sem_two信号量初始值为0
    
    pthread_create(&tid1, NULL, read, NULL);  //创建线程A
    pthread_create(&tid2, NULL, accu, NULL);  //创建线程B
    
    pthread_join(tid1, NULL);
    pthread_join(tid2, NULL);
    
    sem_destroy(&sem_one);
    sem_destroy(&sem_two);
    return 0;
}

void* read(void *arg)
{
    int i;
    for(i=0; i<N; i++)
    {
        fputs("Input num: ", stdout);
        sem_wait(&sem_one);  //sem_one信号量值变为0
        scanf("%d", &num);
        sem_post(&sem_two);  //sem_two信号量值变为1
    }
    return NULL;
}

void* accu(void *arg)
{
    int sum, i;
    for(sum=i=0; i<N; i++)
    {
        sem_wait(&sem_two);  //sem_two信号量值变为0
        sum += num;
        sem_post(&sem_one);  //sem_one信号量值变为1
    }
    printf("Result: %d\n", sum);
    return NULL;
}
  • 代码说明
  • 第18、19行:生成2个信号量,一个信号量的初始值为1,另一个为0。之所以要设置两个信号量,是为了保证始终是线程A先输入数据,然后线程B读取数据这一线程执行顺序的同步。一定要掌握需要两个信号量的原因。
  • 第38、52行:利用信号量变量 sem_one 调用 wait 函数和 post 函数。这是为了防止在调用 accu 函数的线程B还未取走数据的情况下,调用 read 函数的线程A覆盖了原值。
  • 第40、50行:利用信号量变量 sem_two 调用 wait 函数和 post 函数。这是为了防止调用 read 函数的线程A还未写入新值前,accu 函数取走(再取走旧值)数据。
  • 运行结果

$ gcc semaphore.c -D_REENTRANT -o semaphore -lpthread
$ ./semaphore
Input num: 1
Input num: 2
Input num: 3
Input num: 4
Input num: 5
Result: 15

 《知识补充线程同步 — 条件变量

        上面讲的两种线程同步技术,虽然可以解决一些资源竞争的问题,但是 互斥量、二进制信号量 都只有两种状态,这使得它们的用途非常有限。除了互斥锁、信号量之外,还可以使用条件变量来解决线程的同步问题。条件变量是对互斥量的补充,它允许线程阻塞并等待另一个线程发送的信号。当收到信号时,阻塞线程就被唤醒并试图锁定与之关联的互斥量。条件变量一般与互斥量结合起来使用,以实现线程同步的目的。经典的 “生产者-消费者” 问题模型就是使用条件变量的方式实现线程同步的。

【关于条件变量的相关内容,请参见下面的博文链接】

线程同步—条件变量

二  基于多线程的聊天服务器端/客户端的实现

        本节并不打算介绍回声服务器端/客户端,而是介绍多个客户端之间可以进行数据交换的简单的聊天程序。通过该示例复习线程的使用方法以及线程同步问题的处理方法。

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

  • 聊天服务器端:chat_server.c
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <pthread.h>

#define BUF_SIZE 1024
#define MAX_CLNT 256

typedef struct sockaddr SA;

void error_handling(char *message);
void* handle_clnt(void *arg);
void send_msg(char *msg, int len);

int clnt_cnt = 0;            //记录客户端的连接数
int clnt_socks[MAX_CLNT];    //存放与所有客户端数据交互的套接字文件描述符数组
pthread_mutex_t mutex;       //声明互斥量变量

int main(int argc, char *argv[])
{
    int serv_sock, clnt_sock;
    struct sockaddr_in serv_adr, clnt_adr;
    int clnt_adr_sz;
    pthread_t tid;
    
    if(argc != 2){
        printf("Usage: %s <Port>\n", argv[0]);
        exit(1);
    }
    
    pthread_mutex_init(&mutex, 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, (SA*)&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, (SA*)&clnt_adr, &clnt_adr_sz);
        if(clnt_sock < 0)
        {
            error_handling("epoll_wait() error!");
            continue;
        }
        printf("New client connected from address[%s:%d], clnt_sock=%d\n", 
                inet_ntoa(clnt_adr.sin_addr), ntohs(clnt_adr.sin_port), clnt_sock);

        pthread_mutex_lock(&mutex);
        clnt_socks[clnt_cnt++] = clnt_sock;
        pthread_mutex_unlock(&mutex);
        
        pthread_create(&tid, NULL, handle_clnt, &clnt_sock); //创建于客户端数据交互的线程
        pthread_detach(tid);  //分离线程
    }
    close(serv_sock);
    return 0;
}

//处理与客户端的数据交互过程
void* handle_clnt(void *arg)
{
    int clnt_sock = *(int*)arg;
    int str_len=0, i;
    char msg[BUF_SIZE] = {0};
    
    while((str_len=read(clnt_sock, msg, sizeof(msg))) != 0)
        send_msg(msg, str_len);
    
    pthread_mutex_lock(&mutex);
    for(i=0; i<clnt_cnt; i++)    //删除已断链的客户端套接字描述符
    {
        if(clnt_sock == clnt_socks[i])
        {
            while(i++ < clnt_cnt-1)
                clnt_socks[i] = clnt_socks[i+1];
            break;
        }
    }
    clnt_cnt--;     //客户端连接数减1
    pthread_mutex_unlock(&mutex);
    printf("closed client, clnt_sock=%d\n", clnt_sock);
    close(clnt_sock);
    return NULL;
}

//向所有连接客户端转发收到的消息
void send_msg(char *msg, int len)
{
    int i;
    pthread_mutex_lock(&mutex);
    for(i=0; i<clnt_cnt; i++)
        write(clnt_socks[i], msg, len);
    pthread_mutex_unlock(&mutex);
}

void error_handling(char *message)
{
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}
  • 代码说明
  • 第19、20行:用于管理接入连接的客户端套接字的变量和数组。这两个变量都是全局变量,因此访问这两个变量的代码将构成临界区,而对临界区的访问使用互斥量同步技术实现。
  • 第62行:每当有新连接时,将相关信息写入变量 clnt_cnt 和 clnt_socks。
  • 第65行:创建线程向新接入的客户端提供服务。由该线程执行第73行定义的线程函数 handle_clnt。
  • 第66行:调用 pthread_detach 函数从内存中完全销毁已终止运行的线程。

上述示例中,我们必须掌握的并不是服务器端的实现方式,而是临界区的构成形式。上述示例中的临界区具有如下特点:

访问全局变量 clnt_cnt 和 数组 clnt_socks 的代码将构成临界区!

 在 chat_server.c 示例中,一共有三个线程,分别是:

  • 主线程:在 main 函数中执行。
  • 线程A:在 handle_clnt 函数中执行。
  • 线程B:在 send_msg 函数中执行。

添加或删除客户端时,变量 clnt_cnt 和 数组 clnt_socks 同时发生变化。因此,在如下情形中均会导致数据不一致,从而引发严重问题。

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

        因此,在上述示例中,访问全局变量 clnt_cnt 和 全局数组 clnt_socks 的代码应组织在一起并构成临界区,而对临界区的访问使用互斥量方式互斥访问。

2.2 多线程并发客户端的实现

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

  • 聊天客户端:chat_clnt.c
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <pthread.h>

#define BUF_SIZE  1024
#define NAME_SIZE 30

typedef struct sockaddr SA;

void error_handling(char *message);
void* send_msg(void *arg);
void* recv_msg(void *arg);

char name[NAME_SIZE]="[DEFAULT]";
char msg[BUF_SIZE] = {0};

int main(int argc, char *argv[])
{
    int sockfd;
    struct sockaddr_in serv_adr;
    pthread_t snd_thread, rcv_thread;
    void *retval;
    
    if(argc != 4){
        printf("Usage: %s <IP> <Port> <Name>\n", argv[0]);
        exit(1);
    }
    sprintf(name, "[%s]", argv[3]);
    
    sockfd = socket(PF_INET, SOCK_STREAM, 0);
    serv_adr.sin_family = AF_INET;
    serv_adr.sin_addr.s_addr = inet_addr(argv[1]);
    serv_adr.sin_port = htons(atoi(argv[2]));
    
    if(connect(sockfd, (SA*)&serv_adr, sizeof(serv_adr)) == -1)
        error_handling("connect() error!");
    
    pthread_create(&snd_thread, NULL, send_msg, &sockfd);  //创建发送消息线程
    pthread_create(&snd_thread, NULL, recv_msg, &sockfd);  //创建接收消息函数
    pthread_join(snd_thread, &retval);                     //等待发送消息线程结束
    pthread_join(rcv_thread, &retval);                     //等待接收消息线程结束
    close(sockfd);
    return 0;
}

//处理发送消息的线程函数
void* send_msg(void *arg)
{
    int sockfd = *(int*)arg;
    char name_msg[NAME_SIZE+BUF_SIZE] = {0};
    while(1)
    {
        fgets(msg, BUF_SIZE, stdin);  //控制台(终端)输入一行字符串
        if(!strcmp(msg, "q\n") || !strcmp(msg, "Q\n"))  //退出条件
        {
            close(sockfd);
            exit(0);
        }
        sprintf(name_msg, "%s %s", name, msg);
        write(sockfd, name_msg, strlen(name_msg));  //向服务器端发送字符串消息
    }
    return NULL;
}

//处理接收消息的线程函数
void* recv_msg(void *arg)
{
    int sockfd = *(int*)arg;
    char name_msg[NAME_SIZE+BUF_SIZE] = {0};
    int str_len;
    
    while(1)
    {
        str_len = read(sockfd, name_msg, NAME_SIZE+BUF_SIZE-1);
        if(str_len <= 0)
            pthread_exit((void*)2);   //线程主动退出
        name_msg[str_len] = 0;
        fputs(name_msg, stdout);      //控制台(终端)输出收到的字符串消息
    }
    return NULL;
}

void error_handling(char *message)
{
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}
  • 运行结果
  • 聊天服务器端:chat_server.c

$ gcc chat_server.c -DREENTRANT -o chatserv -lpthread
$ ./chatserv 9190
New client connected from address[127.0.0.1:48636], clnt_sock=4
New client connected from address[127.0.0.1:48638], clnt_sock=5
closed client, clnt_sock=4
closed client, clnt_sock=5

  • 聊天客户端1:chat_clnt.c

$ gcc chat_clnt.c -DREENTRANT -o chatclnt -lpthread
$ ./chatclnt 127.0.0.1 9190 Yoon
Hi, everyone~
[Yoon] Hi, everyone~
[Choi] Hi Yoon
Q

  • 聊天客户端2:chat_clnt.c

$ ./chatclnt 127.0.0.1 9190 Choi
[Yoon] Hi, everyone~
Hi Yoon
[Choi] Hi Yoon
Q

三  习题

1、单CPU系统中如何同时执行多个进程?请解释该过程中发生的上下文切换。

:单CPU系统中,CPU通过分时方式轮流执行多个进程。而为了实现CPU分时使用,需要经过进程的上下文切换过程。上下文切换是指,在CPU改变运行对象的准备过程中,将之前执行的进程相关信息暂时保存起来,并读入待执行的进程相关信息,然后CPU开始执行切换后的进程。

2、为何线程的上下文切换速度相对更快?线程间数据交换为何不需要类似IPC的特别技术?

:因为线程进行上下文切换时不需要切换数据区和堆区。同时,可以利用数据区和堆区进行数据交换。

3、请从执行流角度说明进程和线程的区别。

  • 进程:在操作系统中构成单独执行流的单位。
  • 线程:在进程内构成单独执行流的单位。

4、下面关于临界区的说法错误的是?

a. 临界区是多个线程同时访问时的发生问题的区域。

b. 线程安全的函数中不存在临界区,即便多个线程同时调用也不会发生问题。

c. 1个临界区只能有一个代码块,而非多个代码块构成。换言之,线程A执行的代码块A和线程B执行的代码块B之间绝对不会构成临界区。

d. 临界区由访问全局变量的代码构成。其他变量中不会发生问题。

:b、c、d。分析如下:

  • b:线程安全函数被多个线程同时调用时不会引发问题,但是不代表线程安全函数中不存在临界区,线程安全函数中同样可能存在临界区的。故b的描述有误。
  • c:当线程A执行的代码块A和线程B执行的代码块B访问的都是同一内存空间时,那么由这两个代码块共同构成了临界区,所以,一个临界区时有可能由多个不同地方的代码块共同构成的。故 c 的描述有误。
  • d:当其他变量与全局变量有关联时,如赋值、大小比较等关系时,全局变量发生问题也会导致与其关联的其他变量发生问题。故 d 的描述有误。

5、下面关于线程同步的描述错误的是?

a. 线程同步就是限制访问临界区。

b. 线程同步也具有控制线程执行顺序的含义。

c. 互斥量和信号量时典型的同步技术。

d. 线程同步是代替进程IPC的技术。

:d。IPC 技术是为了解决进程间数据交换的问题,而线程同步是为了解决线程访问临界区的问题,不属于同一类技术。

6、请说明完全销毁Linux线程的两种方法。

  • 方法一:调用 pthread_join 函数等待线程的结束。
  • 方法二:调用 pthread_detach 函数,当线程运行终止的同时,也将自动释放线程所占用的各种资源。

7、利用多线程技术实现回声服务器端,但要让所有线程共享保存客户端消息的内存空间(char数组)。这么做是为了应用线程同步技术,其实不符合常理。

  • 多线程实现回声服务器端:echo_thrserv.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <pthread.h>
#include <errno.h>

#define BUF_SIZE 1024

typedef struct sockaddr SA;

void * handle_clnt(void * arg);
void error_handling(char *message);

char buf[BUF_SIZE];
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 tid;

    if(argc!=2) {
        printf("Usage : %s <port>\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, (SA*)&clnt_adr, &clnt_adr_sz);
        if(clnt_sock == -1)
        {
            printf("accept() error! %d:%s\n", errno, strerror(errno));
            continue;
        }
        printf("New client connected from address[%s:%d], conn_fd=%d\n", 
                inet_ntoa(clnt_adr.sin_addr), ntohs(clnt_adr.sin_port), clnt_sock);
        
        //创建与客户端进行数据交换的线程
        pthread_create(&tid, NULL, handle_clnt, &clnt_sock);
        pthread_detach(tid);    //分离线程
    }

    close(serv_sock);
    return 0;
}

void * handle_clnt(void * arg)
{
    int clnt_sock = *((int*)arg);
    int str_len=0;
    
    while(1)
    {
        pthread_mutex_lock(&mutx);     //加锁操作
        str_len=read(clnt_sock, buf, sizeof(buf));
        if(str_len<=0)
            break;
        else
            write(clnt_sock, buf, str_len);
        pthread_mutex_unlock(&mutx);   //解锁操作
    }
    
    close(clnt_sock);
    return NULL;
}

void error_handling(char *buf)
{
    fputs(buf, stderr);
    fputc('\n', stderr);
    exit(1);
}

程序编译:$ gcc echo_thrserv.c -D_REENTRANT -o thrserv -lpthread

程序运行:$ ./thrserv 9190

  • 获取回声客户端程序代码,请参见如下博文链接(第3.2节:echo_client.c)

Linux网络编程 - 基于TCP的服务器端/客户端(1)

8、上一题要求所有线程共享保存回声消息的内存空间,如果这种方式,无论是否同步都会产生问题。请说明每种情况各产生哪些问题。

:如果不同步,两个以上客户端线程会同时访问全局数组 buf 的内存空间,从而引发问题。相反,如果使用同步技术,当其中一个线程正在访问临界区时,可能会导致服务器端无法及时接收到其他客户端发送过来的字符串消息而必须等待的问题。

参考

《TCP-IP网络编程(尹圣雨)》第18章 - 多线程服务器端的实现

《Linux高性能服务器编程》第14章 - 多线程编程

《Linux典藏大系:Linux环境C程序设计(第2版)》第17章 - 线程控制

《TCP/IP网络编程》课后练习答案第二部分15~18章 尹圣雨

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值