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 @   Alpha205  阅读(98)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET10 - 预览版1新功能体验(一)
点击右上角即可分享
微信分享提示