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.")
运行结果:
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 线程同步
线程同步用于解决线程访问次序引发的问题,同步可分为如下两种情况:
- 同时访问同一内存空间
- 需要指定访问统一内存空间的线程的执行顺序
控制线程的执行顺序: 假设有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; }
上述两个线程中,划分的临界区大小不同,在具体实践中,需要具体判断,最大限度的减少lock
和unlock
的操作。
信号量
信号量与互斥量类似,以利用二进制信号量控制线程执行顺序为例.信号量的创建和销毁方法如下所示:
#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 成功 其他值 失败
信号量中相当于lock
和unlock
的函数:
#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); }
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET10 - 预览版1新功能体验(一)