TCP IP网络编程(14) 多线程服务端

多线程服务器端实现

1.引入线程

  在《基于Linux的多进程服务器 》中介绍了Linux下多进程服务端实现的原理,在文章《Linux下epoll》中,介绍了epoll的实现原理。多进程服务端与基于select或者epoll实现的服务端相比具有一定的优势,但是也有一定的问题:创建(复制)进程会给操作系统带来沉重的负担,且每个进程具有独立的内存空间,进程间通讯的复杂度也会随之上升。可以总结为:

  • 创建进程的过程会带来一定的开销
  • 需要特殊的ICP技术,实现进程间数据交换

上述所说的操作系统开销主要是上下文切换(Context Switching),这是创建进程过程中主要的开销。

上下文切换(Context Switching):

  • 即使是单核的CPU,也可以运行多进程程序,这是因为操作系统将CPU时间分成多个微小的块以后,分配给了多个进程,为了实现“同时运行”多个进程,CPU就需要在每个进程分配的时间运行结束后,及时切换到其他的进程继续运行,运行进程的时候需要将相应的进程信息读入内存,如果运行完进程A后需要运行进程B,则操作系统需要将进程A相关的信息移出内存放到硬盘,并读入进程B相关的信息,这就是上下文切换。因为需要将内存数据移出放到硬盘,因此此过程需要较长时间,即使通过优化,也会存在一定的局限性。基于时间片轮转的任务调度,是非实时操作系统的特点,与此相对应的有实时操作系统,其特点任务的调度基于任务优先级,优先级高的任务可以抢占优先级低的任务的CPU资源

为了保证多进程的优点,且克服其缺点,引入了线程(Thread)的概念,这是为了将进程的劣势降到最低限度而设计的一种轻量级进程(再Linux下称为LWP Light-weight process),具备如下的优点:

  • 与进程相比,线程的创建合上下文切换速度更快
  • 线程间数据交换无需特殊技术
2.线程与进程之间的差异

每个进程的内存空间,主要有以下几个区构成:

  • 数据区 :  保存进程内的全局变量
  • 堆(heap) :  存储进程中动态分配的资源
  • 栈(stack) :  函数运行时,存储函数中的临时变量
  • 代码区 :  进程运行的程序机器语言指令

如果以获得多个执行流为主要目的,则可以将上面的进程结构进行改进,仅仅将栈区域进行分离,这种改进方式形成的就是线程,通过这种改进,可以实现如下的优点:

  • 上下文切换时不需要切换数据区和堆区
  • 可以利用数据区和堆区进行数据交换

线程为了保证多条代码执行流,将栈区进行了隔离,而多个线程将共享数据区和堆区,为了保证上图的这种结构,线程必须在进程内创建并运行:

  • 进程: 操作系统构成单独执行流的基本单位
  • 线程:进程构成单独执行流的基本单位
3.线程的创建及运行

  POSIX标准,全称为Portable Operating System Interface For Computer Environment,即适用于计算机环境的可移植操作系统接口,是为了提高Unix系统间程序可以执行而制定的API规范,下面介绍的线程的创建方法也是基于POSIX标准。

#include <pthread.h>

int pthread_create(pthread_t * restrict thread, pthread_attr * restrict attr, 
                   void* (*start_routine)(void * ), void * restrict arg);

// thread : 保存新创建线程id
// attr   : 用于传递线程属性的参数,NULL表示创建默认属性的线程
// arg    : 线程函数的参数
// start_routine: 线程函数
// 返回值 : 0 成功
#include <pthread.h>

int thread_join(pthread_t thread, void ** status);

// thread 线程id
// 保存线程函数的返回值的地址
// 返回值 0 成功

调用thread_join()的进程会进入等待状态,直到调用的线程函数执行完毕,且可以获取到线程的返回值,避免应为线程未执行完毕,而主程序执行结束,导致线程被销毁。

代码实例:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>
#include <unistd.h>

void* threadMain(void* arg);

int main(int argc, char *argv[])
{
    pthread_t tid;

    int tpara = 5;    // 线程参数

    void* tRet;

    if (pthread_create(&tid, NULL, threadMain, (void*)&tpara) != 0)
    {
        puts("pthread_create() error.");
        return -1;
    }

    if (pthread_join(tid, &tRet) != 0)
    {
        puts("pthread_join() error.");
        return -2;
    }

    printf("Thread return value : %s\n", (char*)tRet);

    return 0;
}

void* threadMain(void* arg)
{
    int cnt = *(int*)(arg);

    char* msg = (char*)malloc(sizeof(char) * 50);

    strcpy(msg, "This is in the thread.\n");

    for (size_t i = 0; i < cnt; i++)
    {
        sleep(1);
        puts("Thread is running.");
    }

    // 释放threadMain内部分配的内存
    free(tRet);

    return (void*)msg;
    
}

在编译的时候,需要连接上pthread库,才可以进行编译

CMAKE_MINIMUM_REQUIRED(VERSION 3.1)      # cmake version

PROJECT(threadDemon VERSION 1.2.1 LANGUAGES CXX)   # projectName version languages

SET(CMAKE_BUILD_TYPE "Debug")    # 使得生成的程序包含调试信息

SET(CMAKE_CXX_FLAGS_DEBUG "$ENV{CXXFLAGS} -O0 -Wall -g -ggdb")

SET(CMAKE_CXX_FLAGS_RELEASE "$ENV{CXXFLAGS} -O3 -Wall")

AUX_SOURCE_DIRECTORY(. DIR_SRCS)

ADD_EXECUTABLE(${PROJECT_NAME} ${DIR_SRCS})

# 需要链接线程库
target_link_libraries(threadDemon pthread)

MESSAGE(NOTICE, "Finished build the project.")

运行结果:

img

4.可在临界区内调用的函数

  在创建多个线程运行,同时调用函数(执行)时可能会产生问题,则是因为这部分函数内部存在着临界区,也就是说,多个线程同时执行这部分代码的时候,可能引起问题。

根据函数临界区是否引起问题,函数可分为以下两类:

  • 线程安全函数 (Thread-safe Function)
  • 非线程安全函数 (Thread-unsafe Function)

但是线程安全的函数中并不是没有临界区,而实可以通过一些措施进行避免。大多数标准函数都是线程安全的,而且平台在定义非线程安全的函数时,也会定义具有相同功能的线程安全的函数。线程安全的函数后缀名通常是_r

  通常可以声明头文件前定义宏_REENTRANT,告诉编译器用户需要可重入功能,这个宏的定义必须出现在所有#include语句之前。可重入代码可以被多个线程同时调用,且任然能正常工作,为用户提供线程安全的标准函数。

例如,在多线程程序里,只有一个errno供所有线程共享,在一个线程正在获取错误代码时,该变量很容易被其他线程修改,还有类似的fputs函数,这类函数通常只有一个单独的全局性区域来缓存数据。

_REENTRANT作用:

  • 对部分函数重新定义可重入的安全版本,通常这些安全版本的函数名,只是在原函数名的基础上加上_r
  • stdio.h中原来以宏的形式实现的一些函数,将变成可安全重入函数
  • error.h中定义的errno将成为一个函数调用,能够以一种线程安全的方式来获取errno的值。
4.1 线程存在的问题和临界区

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

例如thread1对全局变量执行加1,thread2对全局变量执行减1,此时全局变量的值是初始值吗?

不一定,首先需要明确全局变量值增加的方式,值的增加需要CPU运算完成,全局变量的值不会自己增加,需要CPU将全局变量的值读取到CPU内部,进行运算完成后,将计算结果写入全局变量,这一过程结束,才相当于改变了全局变量的值。

因此,如果在thread1读取变量后完成了加1运算,但是还未将运算结果写入全局变量,thread2通过切换获得了CPU资源,此时thread2读取全局变量进行运算,此时无论两个线程的写入次序如何,最终全局变量中存储的结果都和预想的不同。

4.2 线程同步

线程同步用于解决线程访问次序引发的问题,同步可分为如下两种情况:

  1. 同时访问同一内存空间
  2. 需要指定访问统一内存空间的线程的执行顺序

控制线程的执行顺序: 假设有threadA和threadB,threadA负责向指定的内存区域写入数据,threadB负责从统一内存区域读取数据,这种情况下,threadA应该先访问约定的内存区域并存入数据,然后threadB负责取走数据,如果线程AB的访问次序不对,将导致错误的结果。

互斥量

互斥量是Mutual Exclusion的缩写,表示不允许多个线程同时访问,主要用于解决线程同步访问的问题。互斥量提供了一种优秀的锁机制,互斥量的创建和销毁如下所示

#include <pthread.h>

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

int pthread_mutex_destory(pthread_mutex_t * mutex);

// mutex : 需要创建或者销毁的互斥量的地址
// attr  : 创建的互斥量的属性,如果没有特别需要指定的属性,则传递NULL
// 返回值 : 0 成功  其他值 失败

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

pthread_mutex_t mutex;

在创建互斥量之后,需要将互斥量的地址传递给互斥量初始化函数:

// 推荐pthread_mutex_init()方法初始化互斥量
pthread_mutex_init(pthread_mutex_t* mutex);

//还可以通过宏的形式初始化互斥量
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

互斥量的作用是用于锁住或者释放临界区:

#include <pthread.h>

int pthread_mutex_lock(pthread_mutex_t * mutex);
int pthread_mutex_unlock(pthread_mutex_t * mutex);

// 返回值  0 成功  其他值 失败

进入临界区前,调用pthread_mutex_lock函数,当发现有其他线程已经进入临界区的时候,则pthread_mutex_lock函数会处于阻塞状态,直到其他线程调用pthread_mutex_unlock函数退出临界区时为止。如果线程在退出临界区的时候,忘记调用pthread_mutex_unlock,则其他线程在调用pthread_mutex_lock函数后就会一直处于阻塞状态。这种情况称为“死锁”,在实际应用中应该避免。

实例代码:

#include<pthread.h>
#include<stdio.h>

#define PTHREAD_NUM  2
// 全局变量 main函数和其他线程函数都需要调用,
// 定义在main函数内的变量,其他函数无法访问
long long num = 100;

// 全局互斥量
pthread_mutex_t mutex;

int loopCount = 20;    // 因为要以指针的形式传递给线程函数,所以需要定义在main函数之外,作为全局变量

void* thread_inc(void* arg);
void* thread_des(void* arg);

int main(int argc, char* argv[])
{
    pthread_t arrayThread[PTHREAD_NUM];

    pthread_mutex_init(&mutex, NULL);

    for (size_t i = 0; i < PTHREAD_NUM; i++)
    {
        /* code */
        if (i % 2 == 0)
        {
            pthread_create(&arrayThread[i], NULL, thread_inc, (void*)&loopCount);
        }
        else
        {
            pthread_create(&arrayThread[i], NULL, thread_des, (void*)&loopCount);
        }
    }

    for (size_t i = 0; i < PTHREAD_NUM; i++)
    {
        /* code */
        pthread_join(arrayThread[i], NULL);
        //pthread_detach(arrayThread[i]);

        printf("Join thread %d.\n", i);
    }

    printf("The result is %d.\n", num);

    // 销毁互斥量
    pthread_mutex_destroy(&mutex);
    
    return 0;
}


void* thread_inc(void* arg)
{
    size_t count = *(size_t*)arg;     // 获取参数

     pthread_mutex_lock(&mutex);      // 进入临界区上锁

    for (size_t i = 0; i < count; i++)
    {
        /* code */
        num++;
        printf("Num value is: %d.\n", num);
    }

    pthread_mutex_unlock(&mutex);   // 推出临界区解锁
    
    return NULL;
}

void* thread_des(void* arg)
{
    size_t count = *(size_t*)arg;    // 获取参数

    for (size_t i = 0; i < count; i++)
    {
        /* code */
        pthread_mutex_lock(&mutex);  // 进入临界区上锁

        num--;

        printf("Num value is: %d.\n", num);

        pthread_mutex_unlock(&mutex); // 推出临界区解锁
    }
    
    return NULL;
}

上述两个线程中,划分的临界区大小不同,在具体实践中,需要具体判断,最大限度的减少lockunlock的操作。

信号量

信号量与互斥量类似,以利用二进制信号量控制线程执行顺序为例.信号量的创建和销毁方法如下所示:

#include <semaphore.h>

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

// sem: 信号量的地址
// pshared: 传递其他值的时候,初始化的是由多个进程可共享的信号量,传递0的时候,创建的是仅在一个进程内可使用的信号量
// value: 指定新创建的信号量的初始值
// 返回值: 0 成功  其他值 失败

信号量中相当于lockunlock的函数:

#include <semaphore.h>

int sem_post(sem_t* sem);
int sem_wait(sem_t* sem);

// 返回值: 0 成功  其他值 失败

在调用sem_init函数的时候,系统将初始化信号量对象,此对象中记载着信号量值(semaphore value),在调用sem_post函数的时候,信号量值会加1,在调用sem_wait函数的时候,信号量的值会减1,但是信号量的值不能小于0,在信号量为0的情况下调用调用sem_wait函数,调用的线程将进入阻塞状态。此时如果其他线程调用sem_post函数,信号量的值将变为1,而原本阻塞的线程可以将信号量变为0,跳出阻塞状态。线程之间就是通过这种方式完成同步操作。

代码示例:假设线程A将数据写入全局变量,线程B将数据读出全局变量值并作累加,在这个例子中,需要做的并非同时访问的同步,而是线程间访问次序的同步,必须保证每次线程A将数据写入后,线程B再去进行读出

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

void* writeNum(void* arg);
void* accuNum(void* arg);

static sem_t semAccu;
static sem_t semWrite;

static int g_num;

int main(int argc, char* argv[])
{
    pthread_t writeThread, accuThread;

    // 初始化信号量
    sem_init(&semWrite, 0, 1);
    sem_init(&semAccu,  0, 0);


    pthread_create(&writeThread, NULL, writeNum, NULL);
    pthread_create(&accuThread, NULL, accuNum, NULL);

    pthread_join(writeThread, NULL);
    pthread_join(accuThread,  NULL);

    sem_destroy(&semAccu);
    sem_destroy(&semWrite);

    return 0;
}

void* writeNum(void* arg)
{
    fputs("Entering Write threrad.\n", stdout);
    for(int i=1; i<=100; i++)
    {
        sem_wait(&semWrite);

        g_num = i;        // 全局变量写入

        sem_post(&semAccu);
    }

    return NULL;
}

void* accuNum(void* arg)
{
    fputs("Entering Write threrad.\n", stdout);

    int result = 0;

    for(int i=1; i<=100; i++)
    {
        sem_wait(&semAccu);

        result += g_num;

        sem_post(&semWrite);
    }

    printf("The total result is %d.\n", result);

    return NULL;
}

上述示例代码中,在执行写入和读出的过程中,两个信号量在0和1之间不断切换,完成线程访问次序的控制,因此称之为“二进制信号量”。

4.3线程的创建和销毁

Linux线程并不是在首次调用的线程main函数返回时自动销毁,所以需要用以下方法加以明确,否则由线程创建的内存空间将一直存在:

  • 调用pthread_join()函数
  • 调用pthread_detach()函数

pthread_join()函数会等待线程终止,且会引导线程销毁,但是在线程终止之前,调用该函数的线程将处于阻塞状态
pthread_detach()函数不会等待线程终止,也不会引起调用该函数的线程阻塞的,且可以通过该函数引导销毁线程创建的内存空间。调用该方法后,不能再对线程进行join操作。

5.基于多线程的并发服务器首先

基于多线程服务端的聊天程序实现,多个用户可同时加入,一个用户发送的信息到服务器,服务器将收到的信息转发给其他的所有用户:

服务端:

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/socket.h>
#include <arpa/inet.h>

#define BUFF_SIZE     100
#define CLIENT_MAX    256
#define SERVER_PORT   25520

void* handleClient(void* arg);

void sendMsg(int clisock, char* msg, int len);

void error_handler(const char* msg);

bool pushClientSocket(int sock);

int clientSocketArray[CLIENT_MAX] = { 0 };

pthread_mutex_t mutex;

int main(int argc, char* argv[])
{
    int serverSocket, clientSocket;
    sockaddr_in serverAddr, clientAddr;
    
    socklen_t socketLen = sizeof(serverSocket);
    socklen_t addrSize = sizeof(serverAddr);        

    pthread_t thrID;

    pthread_mutex_init(&mutex, NULL);

    serverSocket = socket(PF_INET, SOCK_STREAM, 0);

    // 初始化服务端地址
    memset(&serverAddr, 0, sizeof(serverAddr));
    serverAddr.sin_family = AF_INET;
    serverAddr.sin_addr.s_addr = htonl(INADDR_ANY);
    serverAddr.sin_port = htons(SERVER_PORT);

    if (bind(serverSocket, (sockaddr*)&serverAddr, addrSize) != 0)
    {
        error_handler("bind() error.");
    }

    printf("Successfully bind the socket.\n");
    
    if (listen(serverSocket, 5) != 0)
    {
        error_handler("listen() error.");
    }

    printf("Start listening...\n");

    while (true)
    {
        clientSocket = accept(serverSocket, (sockaddr*)&clientAddr, &addrSize);

        if (clientSocket <= 0)
        {
            continue;
        }

        // while (!pushClientSocket(clientSocket)) {}

        bool bres = pushClientSocket(clientSocket);

        while (!bres)
        {
            bres = pushClientSocket(clientSocket);
        }

        // 创建线程,client socket在线程中处理消息接受和发送
        pthread_create(&thrID, NULL, handleClient, (void*)&clientSocket);
        pthread_detach(thrID);
        printf("Recv connect from %s : %d.\n", inet_ntoa(clientAddr.sin_addr), ntohs(clientAddr.sin_port));
    }
    

    return 0;
}

void* handleClient(void* arg)
{
    int clientSock = *(int*)arg;

    if (clientSock <= 0)
        return NULL;

    char msg[BUFF_SIZE];

    int strLen;

    while ((strLen = read(clientSock, msg, BUFF_SIZE)) > 0)
    {
        sendMsg(clientSock, (char*)msg, strLen);
    }

    // 接收到EOF,则需要关闭客户端的socket
    pthread_mutex_lock(&mutex);

    for(int i=0; i<CLIENT_MAX; i++)
    {
        if (clientSock == clientSocketArray[i])
        {
            clientSocketArray[i] = 0;
            break;
        }
    }

    pthread_mutex_unlock(&mutex);

    close(clientSock);

    return NULL;
}

void sendMsg(int clisock, char* msg, int len)
{
    // 向所有的客户端发布消息
    pthread_mutex_lock(&mutex);
    for(int i=0; i<CLIENT_MAX; i++)
    {
        if (clientSocketArray[i] !=0 && clientSocketArray[i] != clisock)
        {
            write(clientSocketArray[i], msg, len);
        }
    }
    pthread_mutex_unlock(&mutex);
}

void error_handler(const char* msg)
{
    printf("%s\n", msg);
    exit(-1);
}

bool pushClientSocket(int sock)
{
    pthread_mutex_lock(&mutex);

    int index;

    for(index = 0; index < CLIENT_MAX; ++index)
    {
        if (clientSocketArray[index] == 0)
            break;
    }

    if (index >= CLIENT_MAX)
    {
        pthread_mutex_unlock(&mutex);
        return false;
    }

    clientSocketArray[index] = sock;

    pthread_mutex_unlock(&mutex);

    return true;
}

客户端:

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <errno.h>

#define BUFF_SIZE     100
#define NAME_SIZE     20
#define CLIENT_MAX    256
#define SERVER_PORT   25520
#define SERVER_ADDR   "127.0.0.1"

char buffer[BUFF_SIZE];
char name[NAME_SIZE];

void* sendMsg(void* msg);
void* recvMsg(void* msg);

void error_handler(const char* msg);

int main(int argc, char* argv[])
{
    int sock;

    struct sockaddr_in servAddr;

    pthread_t sendThread, recvThread;

    void* threadRet;   // 线程返回

    if (argc != 2)
    {
        printf("Usage %s <IP> <Port> <Name>\n", argv[0]);\
        return -1;
    }

    sprintf(name, "[%s]", argv[1]);

    sock = socket(PF_INET, SOCK_STREAM, 0);

    if (sock == -1)
    {
        char info[50];
        sprintf(info, "socket() error. code: %d", errno);   // 添加错误码
        error_handler((char*)info);
    }

    memset(&servAddr, 0 ,sizeof(servAddr));
    servAddr.sin_family = AF_INET;
    servAddr.sin_addr.s_addr = inet_addr(SERVER_ADDR);
    servAddr.sin_port = htons(SERVER_PORT);

    if(connect(sock, (struct sockaddr*)&servAddr, sizeof(servAddr)) == -1)
    {       
        // 在调用系统函数的时候,可以加入errno变量,获取出错的错误玛,有利于问题定位
        char info[50];
        sprintf(info, "connect() error. code: %d", errno);       
        error_handler((char*)info);
    }

    pthread_create(&sendThread, NULL, sendMsg, (void*)&sock);
    pthread_create(&recvThread, NULL, recvMsg, (void*)&sock);
    pthread_join(sendThread, &threadRet);
    pthread_join(recvThread, &threadRet);
    close(sock);

    return 0;
}

void* sendMsg(void* sock)
{
    int sockfd = *(int*)(sock);

    char name_msg[NAME_SIZE + BUFF_SIZE];

    while (true)
    {
        memset(buffer, 0, BUFF_SIZE);
        memset(name_msg, 0, NAME_SIZE + BUFF_SIZE);
        
        fgets(buffer, BUFF_SIZE, stdin);    // 从标准输入读取数据

        if (strcmp(buffer, "q\n") && strcmp(buffer, "Q\n"))
        {
            sprintf(name_msg, "%s %s", name, buffer);
            write(sockfd, name_msg, strlen(name_msg));
            continue;
        }

        break;
    }
    
    return NULL;
}

void* recvMsg(void* sock)
{
    int sockfd = *(int*)(sock);

    char name_msg[NAME_SIZE + BUFF_SIZE];

    while (true)
    {
        memset(name_msg, 0, NAME_SIZE+BUFF_SIZE);

        int readLen = read(sockfd, name_msg, NAME_SIZE+BUFF_SIZE);

        if (readLen > 0)
        {
            fputs(name_msg, stdout);
            continue;
        }

        break;
    }
    
    return NULL;
}

void error_handler(const char* msg)
{
    printf("%s\n", msg);
    exit(-1);
}

posted @ 2023-02-05 21:44  Alpha205  阅读(100)  评论(0编辑  收藏  举报