操作系统 : 线程同步与互斥
操作系统 : 线程同步与互斥
目录
- 操作系统 : 线程同步与互斥
- 引言
- 1. 线程互斥
- 1.1 进程线程间互斥的相关概念
- 1.2 互斥量`mutex`
- 1.3 互斥量实现原理探究
- 1.4 互斥量的封装
- 2. 线程同步
- 2.1 同步概念与竞态条件
- 2.2 条件变量
- 2.3 生产者消费者模型
- 2.4 为什么`Pthread_cond_wait`需要互斥量
- 2.5 条件变量使用规范
- 2.6 条件变量的封装
- 2.7 POSIX信号量
- 3. 线程池
- 3.1 设计模式
- 3.2 日志和策略模式
- 3.2.1 日志概念
- 3.2.2 日志的实现——策略模式
- 3.3 线程池设计
- 3.4 线程安全的单例模式
- 3.5 单例式线程池实现
- 4. 线程安全和重入问题
- 5. 常见锁概念
- 5.1 死锁
- 5.2 死锁的四个必要条件
- 5.3 避免死锁
- 6. STL容器和智能指针的线程安全
引言
在多线程编程中,线程同步与互斥是确保程序正确性和高效性的核心机制。当多个线程并发访问共享资源时,如果没有适当的同步控制,可能会导致数据竞争、死锁、资源不一致等问题。理解并掌握线程同步与互斥的技术,是编写高性能、高可靠性多线程程序的关键。
本文将系统性地介绍线程同步与互斥的基本概念、实现原理及实际应用。内容涵盖以下方面:
- 线程互斥:深入讲解临界资源、临界区的概念,以及互斥量(
mutex
)的实现与使用。 - 线程同步:探讨条件变量、生产者消费者模型及其应用场景。
- 线程池设计:分析线程池的工作原理、实现方式及其性能优化。
- 线程安全与重入问题:讨论线程安全与可重入函数的区别与联系。
- 常见锁概念:解析死锁的成因及避免策略。
- STL容器与智能指针的线程安全性:评估标准库在多线程环境下的表现。
1. 线程互斥
1.1 进程线程间互斥的相关概念
- 临界资源:多线程执行流共享的资源就叫做临界资源。
- 临界区:每个线程内部,访问临界资源的代码,就叫做临界区。
- 互斥:任何时刻,互斥保证有且只有一个执行流进⼊临界区,访问临界资源,通常对临界资源起保护作⽤。
- 原⼦性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。
1.2 互斥量mutex
-
大部分情况下,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况变量归属单个线程,其他线程无法获得这种变量。
-
但有时候很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享完成线程之间的交互。
-
多个线程并发的操作共享变量,会带来一些问题。
// 操作共享变量会有问题的售票系统代码 #include <stdio.h> // 标准输入输出库 #include <stdlib.h> // 标准库函数 #include <string.h> // 字符串操作函数 #include <unistd.h> // UNIX标准函数,如usleep() #include <pthread.h> // 线程库int ticket = 100; // 全局共享变量,表示剩余的票数// 线程函数,每个线程执行这个函数 void *route(void *arg) {char *id = (char*)arg; // 将参数转换为字符串,表示线程IDwhile (1) { // 无限循环,直到票卖完if (ticket > 0) { // 检查是否还有票usleep(1000); // 模拟售票过程中的耗时操作(1000微秒=1毫秒)// 打印售票信息(这里存在竞态条件)printf("%s sells ticket:%d\n", id, ticket);ticket--; // 票数减1(这里存在竞态条件)} else {break; // 没有票了,退出循环}} }int main(void) {pthread_t t1, t2, t3, t4; // 定义4个线程标识符// 创建4个线程,都执行route函数,并传入不同的线程IDpthread_create(&t1, NULL, route, "thread 1");pthread_create(&t2, NULL, route, "thread 2");pthread_create(&t3, NULL, route, "thread 3");pthread_create(&t4, NULL, route, "thread 4");// 等待所有线程结束pthread_join(t1, NULL);pthread_join(t2, NULL);pthread_join(t3, NULL);pthread_join(t4, NULL);return 0; }
输出结果
⼀次执⾏结果: thread 4 sells ticket:100 ... thread 4 sells ticket:1 thread 2 sells ticket:0 thread 1 sells ticket:-1 thread 3 sells ticket:-2
-
为什么无法获得正确结果?
if
语句判断条件为真以后,代码可以并发的切换到其他线程。- **
usleep
**这个模拟漫长业务的过程,在该过程中可能有很多线程会进入该代码段。 - **
--ticket
**操作本身就不是一个原子操作。
-
取出
ticket--
部分的汇编代码objdump -d a.out > test.objdump 152 40064b: 8b 05 e3 04 20 00 mov 0x2004e3(%rip),%eax # 600b34 <ticket> 153 400651: 83 e8 01 sub $0x1,%eax 154 400654: 89 05 da 04 20 00 mov %eax,0x2004da(%rip) # 600b34 <ticket>
--
操作并不是原子操作,而是对应三条汇编指令:load
:将共享变量ticket
从内存加载到寄存器中。update
:更新寄存器里面的值,执行-1
操作。store
:将新值从寄存器写回共享变量ticket
的内存地址。
-
要解决上述问题,需要做到三点:
- 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
-
要做到这三点,本质上就是需要一把锁,Linux上提供的这把锁叫互斥量。
-
-
互斥量接口
-
初始互斥量
方法一:静态分配
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
方法二:动态分配
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr); 参数: mutex:要初始化的互斥量 attr:NULL
-
销毁互斥量
销毁互斥量需要注意:
- 使用
PTHREAD_MUTEX_INITIALIZER
初始化的互斥量不需要销毁; - 不要销毁一个已经加锁的互斥量;
- 已经销毁的互斥量,要确保后面不会有线程再尝试加锁。
int pthread_mutex_destroy(pthread_mutex_t *mutex);
- 使用
-
互斥量加锁和解锁
int pthread_mutex_lock(pthread_mutex_t *mutex); int pthread_mutex_unlock(pthread_mutex_t *mutex); 返回值:成功返回0,失败返回错误号
调用
pthread_lock
时,可能会遇到以下情况:- 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功;
- 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量但没有竞争到互斥量,那么
pthread_lock
调用会陷入阻塞(执行流被挂起),等待互斥量解锁。 - 总结:多线程竞争申请锁,都得先看到锁,锁本身就是临界资源,所以申请锁的过程必须是原子的;当线程申请成功锁,继续往后运行,访问临界区代码,访问临界资源;如果失败,则阻塞挂起申请执行流。锁提供的能力是执行临界区代码由并行转换成为串行(在我执行期间,不会被打扰,也是一种变相的原子性的表现)。
加锁之后,在临界区内部允许线程进行切换,但是因为当前线程并没有释放锁,是持有锁被切换的,所以即使不在,其他线程也得等此线程回来执行完代码,释放了锁,其他线程才能开始展开锁的竞争,进入临界区。
-
-
改进售票系统
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <pthread.h> #include <sched.h>int ticket = 100; // 共享资源:剩余票数 pthread_mutex_t mutex; // 互斥锁,用于保护对共享资源的访问// 线程函数,模拟售票过程 void *route(void *arg) {char *id = (char*)arg; // 线程标识符while (1) {// 加锁,保护临界区pthread_mutex_lock(&mutex);if (ticket > 0) { // 检查是否还有票usleep(1000); // 模拟售票耗时(1毫秒)printf("%s sells ticket:%d\n", id, ticket); // 打印售票信息ticket--; // 票数减1// 解锁,退出临界区pthread_mutex_unlock(&mutex);// sched_yield(); // 可选的:主动放弃CPU,让其他线程运行} else {// 没有票了,解锁并退出循环pthread_mutex_unlock(&mutex);break;}}return NULL; }int main(void) {pthread_t t1, t2, t3, t4; // 定义4个线程// 初始化互斥锁pthread_mutex_init(&mutex, NULL);// 创建4个售票线程pthread_create(&t1, NULL, route, "thread 1");pthread_create(&t2, NULL, route, "thread 2");pthread_create(&t3, NULL, route, "thread 3");pthread_create(&t4, NULL, route, "thread 4");// 等待所有线程结束pthread_join(t1, NULL);pthread_join(t2, NULL);pthread_join(t3, NULL);pthread_join(t4, NULL);// 销毁互斥锁pthread_mutex_destroy(&mutex);return 0; }
1.3 互斥量实现原理探究
- 经过上面的例子,大家已经意识到单纯的
i++
或者++i
都不是原子的,有可能会有数据一致性问题。 - 为了实现互斥锁操作,大多数体系结构都提供了
swap
或exchange
指令,该指令的作用是把寄存器和内存单元的数据相交换(本质是当前线程/进程在获取锁,因为是交换不是拷贝,所以内存单元的锁只有一份),由于只有一条指令,保证了原子性,谁申请到锁谁就持有锁。即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。现在我们把lock
和unlock
的伪代码改一下。
1.4 互斥量的封装
-
Lock.hpp
#pragma once // 防止头文件被重复包含#include <iostream> #include <string> #include <pthread.h> // POSIX线程库,用于互斥锁操作namespace LockModule // 定义锁模块的命名空间 {/*** @brief 对pthread_mutex_t进行封装,提供加锁/解锁接口* @note 禁止拷贝构造和赋值操作,确保锁的唯一性*/class Mutex{public:// 删除拷贝构造函数和赋值运算符,防止锁被意外复制Mutex(const Mutex &) = delete;const Mutex &operator=(const Mutex &) = delete;/*** @brief 构造函数:初始化互斥锁*/Mutex(){int n = pthread_mutex_init(&_mutex, nullptr); // 初始化互斥锁(void)n; // 显式忽略返回值(实际工程中应检查错误)}/*** @brief 加锁操作(阻塞式)*/void Lock(){int n = pthread_mutex_lock(&_mutex); // 获取锁,失败时会阻塞(void)n;}/*** @brief 解锁操作*/void Unlock(){int n = pthread_mutex_unlock(&_mutex); // 释放锁(void)n;}/*** @brief 获取底层原始互斥锁指针(用于需要原生API的场景)*/pthread_mutex_t *GetMutexOriginal(){return &_mutex;}/*** @brief 析构函数:销毁互斥锁*/~Mutex(){int n = pthread_mutex_destroy(&_mutex); // 销毁锁资源(void)n;}private:pthread_mutex_t _mutex; // 底层POSIX互斥锁对象};/*** @brief RAII风格的锁守卫(自动管理锁的生命周期)* @note 构造时加锁,析构时自动解锁,避免忘记解锁导致死锁*/class LockGuard{public:/*** @brief 构造函数:获取锁资源* @param mutex 需要管理的互斥锁引用*/explicit LockGuard(Mutex &mutex) : _mutex(mutex){_mutex.Lock(); // 构造时自动加锁}/*** @brief 析构函数:释放锁资源*/~LockGuard(){_mutex.Unlock(); // 析构时自动解锁}private:Mutex &_mutex; // 持有的互斥锁引用(不可拷贝)}; } // namespace LockModule
-
多线程售票系统(RAII风格互斥锁)
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <pthread.h> #include "Lock.hpp"using namespace LockModule;int ticket = 1000; Mutex mutex;void *route(void *arg) {char *id = (char *)arg;while (1) {{ // 显式标示 LockGuard 的作用域开始LockGuard lockguard(mutex); // RAII锁:进入作用域时加锁,离开时自动解锁if (ticket > 0) {usleep(1000);printf("%s sells ticket:%d\n", id, ticket);ticket--;}else {break;}} // lockguard 在此处析构并自动解锁}return nullptr; }int main(void) {pthread_t t1, t2, t3, t4;pthread_create(&t1, NULL, route, (void*)"thread 1");pthread_create(&t2, NULL, route, (void*)"thread 2");pthread_create(&t3, NULL, route, (void*)"thread 3");pthread_create(&t4, NULL, route, (void*)"thread 4");pthread_join(t1, NULL);pthread_join(t2, NULL);pthread_join(t3, NULL);pthread_join(t4, NULL);return 0; }
2. 线程同步
2.1 同步概念与竞态条件
- 同步是指在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题。
- 竞态条件是因为时序问题而导致程序异常,在线程场景下,这种问题也不难理解。
2.2 条件变量
-
概念
- 当⼀个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。
- 例如⼀个线程访问队列时,发现队列为空,它只能等待,直到其它线程将⼀个节点添加到队列中,这种情况就需要用到条件变量。
-
函数
-
初始化
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict attr); 参数: cond:要初始化的条件变量 attr:NULL
-
销毁
int pthread_cond_destroy(pthread_cond_t *cond)
-
等待条件满足
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex); 参数: cond:要在这个条件变量上等待 mutex:互斥量,后⾯详细解释
-
唤醒等待
int pthread_cond_broadcast(pthread_cond_t *cond); int pthread_cond_signal(pthread_cond_t *cond);
-
2.3 生产者消费者模型
-
为何要使用生产者消费者模型
生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。这个阻塞队列就是用来给生产者和消费者解耦的。
-
生产者消费者模型优点
解耦;支持并发;支持忙闲不均
-
生产者消费者模型
1个交易场所:以特定结构构成的一个“内存”空间
2个角色:生产者角色和消费者角色(线程)
3种关系:生产者与生产者->互斥关系;消费者和消费者->互斥关系;生产者和消费者->互斥和同步。
-
基于
BlockingQueue
的生产者消费者模型-
BlockingQueue
在多线程编程中,阻塞队列(
Blocking Queue
)是一种常用于实现生产者和消费者模型的数据结构。其与普通队列的区别在于:当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中被放入了元素;当队列满时,往队列里存放元素的操作也会被阻塞,直到有元素被从队列中取出(以上操作都是基于不同线程而言的,线程在对阻塞队列进行操作时会被阻塞)。 -
C++ queue模拟阻塞队列的⽣产消费模型
BlockingQueue
#ifndef __BLOCK_QUEUE_HPP__ // 防止头文件重复包含 #define __BLOCK_QUEUE_HPP__#include <iostream> #include <string> #include <queue> #include <pthread.h> // POSIX线程库// 阻塞队列模板类 template <typename T> class BlockQueue { private:// 判断队列是否已满bool IsFull() {return _block_queue.size() == _cap;}// 判断队列是否为空bool IsEmpty() {return _block_queue.empty();}public:// 构造函数,初始化容量和各种同步原语BlockQueue(int cap) : _cap(cap) {_productor_wait_num = 0; // 生产者等待数量初始化为0_consumer_wait_num = 0; // 消费者等待数量初始化为0// 初始化互斥锁pthread_mutex_init(&_mutex, nullptr);// 初始化生产者条件变量pthread_cond_init(&_product_cond, nullptr);// 初始化消费者条件变量pthread_cond_init(&_consum_cond, nullptr);}// 生产者接口:向队列中添加元素void Enqueue(T &in) {pthread_mutex_lock(&_mutex); // 加锁// 如果队列已满,生产者需要等待while(IsFull()) {// 增加等待的生产者计数_productor_wait_num++;// 等待条件变量(会自动释放锁)pthread_cond_wait(&_product_cond, &_mutex);// 被唤醒后减少等待计数_productor_wait_num--;}// 将元素加入队列_block_queue.push(in);// 如果有消费者在等待,则通知一个消费者if(_consumer_wait_num > 0) {pthread_cond_signal(&_consum_cond);}pthread_mutex_unlock(&_mutex); // 解锁}// 消费者接口:从队列中取出元素void Pop(T *out) {pthread_mutex_lock(&_mutex); // 加锁// 如果队列为空,消费者需要等待while(IsEmpty()) {// 增加等待的消费者计数_consumer_wait_num++;// 等待条件变量(会自动释放锁)pthread_cond_wait(&_consum_cond, &_mutex);// 被唤醒后减少等待计数_consumer_wait_num--;}// 取出队列头部的元素*out = _block_queue.front();_block_queue.pop();// 如果有生产者在等待,则通知一个生产者if(_productor_wait_num > 0) {pthread_cond_signal(&_product_cond);}pthread_mutex_unlock(&_mutex); // 解锁}// 析构函数,清理资源~BlockQueue() {pthread_mutex_destroy(&_mutex); // 销毁互斥锁pthread_cond_destroy(&_product_cond); // 销毁生产者条件变量pthread_cond_destroy(&_consum_cond); // 销毁消费者条件变量}private:std::queue<T> _block_queue; // 底层队列容器int _cap; // 队列容量上限pthread_mutex_t _mutex; // 保护队列的互斥锁pthread_cond_t _product_cond; // 生产者条件变量pthread_cond_t _consum_cond; // 消费者条件变量int _productor_wait_num; // 等待的生产者数量int _consumer_wait_num; // 等待的消费者数量 };#endif // __BLOCK_QUEUE_HPP__
-
2.4 为什么Pthread_cond_wait
需要互斥量
-
条件等待是线程间同步的一种手段。如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有另一个线程通过某些操作改变共享变量,使原先不满足的条件变得满足,并通知等待在条件变量上的线程。
-
条件不会无缘无故地突然满足,必然会涉及共享数据的变化,因此必须用互斥锁来保护。没有互斥锁就无法安全地获取和修改共享数据。
-
按照上述逻辑,可能会设计出如下代码:先加锁,发现条件不满足后解锁,然后等待在条件变量上。但这样的实现存在潜在问题,如下列代码:
// 错误的设计 pthread_mutex_lock(&mutex); while (condition_is_false) {pthread_mutex_unlock(&mutex);//解锁之后,等待之前,条件可能已经满⾜,信号已经发出,但是该信号可能被错过pthread_cond_wait(&cond);pthread_mutex_lock(&mutex); } pthread_mutex_unlock(&mutex);
- 由于解锁和等待不是原子操作,调用解锁之后、
pthread_cond_wait
之前,如果已经有其他线程获取到互斥量,并且条件满足,发送了信号,那么pthread_cond_wait
将错过这个信号,可能会导致线程永远阻塞在pthread_cond_wait
。因此,解锁和等待必须是一个原子操作。 int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex)
进入该函数后,会检查条件量是否等于 0;如果等于 0,就将互斥量解锁(置为 1),直到pthread_cond_wait
返回时,重新将条件量设为 1,并恢复互斥量原来的状态。
- 由于解锁和等待不是原子操作,调用解锁之后、
2.5 条件变量使用规范
-
等待条件代码
pthread_mutex_lock(&mutex); while (条件为假)pthread_cond_wait(cond, mutex); 修改条件 pthread_mutex_unlock(&mutex);
-
给条件发送信号代码
pthread_mutex_lock(&mutex); 设置条件为真 pthread_cond_signal(cond); pthread_mutex_unlock(&mutex);
2.6 条件变量的封装
Cond.hpp
#pragma once // 防止头文件被重复包含#include <iostream> // 标准输入输出库
#include <string> // 字符串库
#include <pthread.h> // POSIX线程库
#include "Lock.hpp" // 自定义的锁相关头文件namespace CondModule // 条件变量模块命名空间
{using namespace LockModule; // 使用锁模块的命名空间// 条件变量类封装class Cond{public:// 构造函数:初始化条件变量Cond(){// 调用pthread_cond_init初始化条件变量// _cond: 要初始化的条件变量// nullptr: 使用默认属性int n = pthread_cond_init(&_cond, nullptr);// 忽略返回值(实际开发中应检查并处理错误)(void)n; // 可以在此处添加日志或错误判断逻辑}// 等待条件变量// mutex: 互斥锁,调用时会自动释放锁,并在条件满足时重新获取void Wait(Mutex &mutex){// 调用pthread_cond_wait等待条件变量// _cond: 要等待的条件变量// mutex.GetMutexOriginal(): 获取底层pthread_mutex_t对象int n = pthread_cond_wait(&_cond, mutex.GetMutexOriginal());// 忽略返回值(实际开发中应检查并处理错误)(void)n;}// 唤醒一个等待该条件变量的线程void Notify(){// 调用pthread_cond_signal唤醒一个线程int n = pthread_cond_signal(&_cond);// 忽略返回值(实际开发中应检查并处理错误)(void)n;}// 唤醒所有等待该条件变量的线程void NotifyAll(){// 调用pthread_cond_broadcast唤醒所有线程int n = pthread_cond_broadcast(&_cond);// 忽略返回值(实际开发中应检查并处理错误)(void)n;}// 析构函数:销毁条件变量~Cond(){// 调用pthread_cond_destroy销毁条件变量int n = pthread_cond_destroy(&_cond);// 忽略返回值(实际开发中应检查并处理错误)(void)n; // 可以在此处添加日志或错误判断逻辑}private:pthread_cond_t _cond; // POSIX条件变量};
} // namespace CondModule
注意:为了让条件变量更具有通用性,建议封装的时候不要在 Cond
类内部引用对应的封装互斥量,否则后续组合时可能会因为代码耦合问题导致难以初始化,因为一般而言 Mutex
和 Cond
基本是一起创建的。
2.7 POSIX信号量
POSIX
信号量和SystemV
信号量作用相同,都是用于同步操作,达到无冲突的访问共享资源目的,但POSIX
信号量可以用于线程间同步。
多线程使用资源,有两种场景:
- 将目标资源整体使用【
mutex
+ 二元信号量mutex】- 将目标资源按照不同的“块”,分批使用【信号量sem】
-
函数
-
初始化信号量
#include <semaphore.h> int sem_init(sem_t *sem, int pshared, unsigned int value); 参数: pshared:0表⽰线程间共享,⾮零表⽰进程间共享 value:信号量初始值
-
销毁信号量
int sem_destroy(sem_t *sem);
-
等待信号量
功能:等待信号量,会将信号量的值减1 int sem_wait(sem_t *sem); //P()
-
发布信号量
功能:发布信号量,表⽰资源使⽤完毕,可以归还资源了。将信号量值加1。 int sem_post(sem_t *sem);//V()
-
-
基于环形队列的生产消费模型
环形队列采用数组模拟,用模运算来模拟环状特性
环状结构起始状态和结束状态都是一样的,不好判断为空或者为满,所以可以通过加计数器或者标记位来判断满或者空。另外也可以预留一个空的位置,作为满的状态。
但是我们现在有信号量这个计数器,就很简单的进行多线程间的同步过程。
- 结论一:环形队列,不为空&&不为满,生产消费可以同时进行。
- 结论二:环形队列,为空||为满,生产和消费需要同步互斥。
- 约定一:队列为空,生产者先运行
- 约定二:队列为满,消费者先运行
- 约定三:生产者不能把消费者套一个圈以上
- 约定四:消费者,不能超过生产者
#pragma once // 防止头文件被重复包含#include <iostream> // 标准输入输出库(虽然本例未使用,但通常保留) #include <semaphore.h> // POSIX信号量库// 信号量封装类 // 对POSIX信号量进行了简单的面向对象封装,提供更易用的接口 class Sem { public:// 构造函数// 参数:n - 信号量的初始值// 功能:初始化一个计数信号量Sem(int n) {// sem_init参数说明:// 1. &_sem - 要初始化的信号量对象// 2. 0 - 表示信号量在当前进程的线程间共享(非0表示进程间共享)// 3. n - 信号量的初始计数值sem_init(&_sem, 0, n);}// P操作(荷兰语"proberen"的缩写,意为尝试)// 功能:申请资源(信号量值减1),如果信号量值为0则阻塞等待// 相当于传统信号量操作中的wait()void P() {// sem_wait会原子性地:// 1. 检查信号量值是否>0// 2. 如果是,减1并立即返回// 3. 如果为0,阻塞直到信号量值变为>0sem_wait(&_sem);}// V操作(荷兰语"verhogen"的缩写,意为增加)// 功能:释放资源(信号量值加1),唤醒等待的线程// 相当于传统信号量操作中的post()void V() {// sem_post会原子性地:// 1. 将信号量值加1// 2. 如果有线程正在等待此信号量,唤醒其中一个sem_post(&_sem);}// 析构函数// 功能:销毁信号量,释放系统资源~Sem() {// 销毁信号量对象// 注意:确保没有线程在等待此信号量时才能安全销毁sem_destroy(&_sem);}private:sem_t _sem; // POSIX信号量对象// 注意:这个类遵循RAII原则,在构造时初始化资源,在析构时释放资源// 但默认的拷贝构造函数和赋值运算符被隐式删除,因为sem_t通常不支持拷贝 };
RingQueue.hpp
#pragma once#include <iostream> #include <string> #include <vector> #include <semaphore.h> #include <pthread.h>/** 环形队列(Ring Queue)实现,支持多生产者多消费者模型* * 核心思想:* 1. 三种关系处理:* a. 生产者和消费者:互斥和同步关系* b. 生产者之间:互斥关系* c. 消费者之间:互斥关系* 2. 解决方案:使用两把锁分别保护生产者和消费者* * 模板参数T:队列中存储的数据类型*/ template<typename T> class RingQueue { private:// 加锁函数封装void Lock(pthread_mutex_t &mutex) {pthread_mutex_lock(&mutex);}// 解锁函数封装void Unlock(pthread_mutex_t &mutex) {pthread_mutex_unlock(&mutex);}public:/** 构造函数* @param cap 环形队列的容量*/RingQueue(int cap): _ring_queue(cap), // 初始化vector容量_cap(cap), // 设置队列容量_room_sem(cap), // 初始化空间信号量(初始值为容量)_data_sem(0), // 初始化数据信号量(初始值为0)_productor_step(0), // 生产者起始位置_consumer_step(0) // 消费者起始位置{// 初始化生产者互斥锁pthread_mutex_init(&_productor_mutex, nullptr);// 初始化消费者互斥锁pthread_mutex_init(&_consumer_mutex, nullptr);}/** 生产数据(入队操作)* @param in 要生产的数据*/void Enqueue(const T &in) {// 1. 申请空间信号量(P操作),如果没有空间会阻塞_room_sem.P();// 2. 加生产者锁(保护多个生产者之间的互斥)Lock(_productor_mutex);// 3. 生产数据(此时保证有空间)_ring_queue[_productor_step++] = in;_productor_step %= _cap; // 环形处理// 4. 释放生产者锁Unlock(_productor_mutex);// 5. 释放数据信号量(V操作),通知消费者有新数据_data_sem.V();}/** 消费数据(出队操作)* @param out 用于接收消费数据的指针*/void Pop(T *out) {// 1. 申请数据信号量(P操作),如果没有数据会阻塞_data_sem.P();// 2. 加消费者锁(保护多个消费者之间的互斥)Lock(_consumer_mutex);// 3. 消费数据*out = _ring_queue[_consumer_step++];_consumer_step %= _cap; // 环形处理// 4. 释放消费者锁Unlock(_consumer_mutex);// 5. 释放空间信号量(V操作),通知生产者有新空间_room_sem.V();}// 析构函数~RingQueue() {// 销毁生产者锁pthread_mutex_destroy(&_productor_mutex);// 销毁消费者锁pthread_mutex_destroy(&_consumer_mutex);}private:// 1. 环形队列存储容器std::vector<T> _ring_queue;int _cap; // 环形队列的容量上限// 2. 生产和消费的位置指针int _productor_step; // 生产者位置(下一个要生产的位置)int _consumer_step; // 消费者位置(下一个要消费的位置)// 3. 信号量(假设Sem类已实现基本的P/V操作)Sem _room_sem; // 空间信号量(生产者关心,表示剩余空间)Sem _data_sem; // 数据信号量(消费者关心,表示已有数据)// 4. 互斥锁(解决多生产者/多消费者之间的竞争)pthread_mutex_t _productor_mutex; // 生产者互斥锁pthread_mutex_t _consumer_mutex; // 消费者互斥锁 };
3. 线程池
3.1 设计模式
在 IT 行业(尤其是 软件开发 领域),设计模式(Design Patterns) 是 可复用的解决方案,用于解决在软件设计中 反复出现 的常见问题。它们不是具体的代码实现,而是 最佳实践的总结,帮助开发者编写更 可维护、可扩展、高效 的代码。
-
设计模式的三大分类
1. 创建型模式(Creational Patterns)
作用:控制对象的创建方式,避免直接
new
对象,提高灵活性。
常见模式:- 单例模式(Singleton):确保一个类只有一个实例(如数据库连接池)。
- 工厂模式(Factory):通过工厂类创建对象,隐藏具体实现(如
PaymentFactory
生成不同支付方式)。 - 建造者模式(Builder):分步构建复杂对象(如
StringBuilder
)。 - 原型模式(Prototype):通过克隆现有对象来创建新对象(如 JavaScript 的
Object.create()
)。
2. 结构型模式(Structural Patterns)
作用:处理类或对象的组合,优化代码结构。
常见模式:- 适配器模式(Adapter):让不兼容的接口协同工作(如充电器转接头)。
- 装饰器模式(Decorator):动态扩展对象功能(如 Java I/O 流)。
- 代理模式(Proxy):控制对象访问(如网络请求代理、缓存代理)。
- 组合模式(Composite):用树形结构处理部分-整体关系(如文件系统)。
3. 行为型模式(Behavioral Patterns)
作用:优化对象间的通信与职责分配。
常见模式:- 观察者模式(Observer):一对多的依赖通知(如事件监听)。
- 策略模式(Strategy):封装算法,灵活切换(如排序算法选择)。
- 命令模式(Command):将请求封装为对象(如撤销/重做功能)。
- 状态模式(State):让对象根据状态改变行为(如订单状态流转)。
-
💡 为什么需要设计模式?
- 提高代码复用性:避免重复造轮子。
- 增强可维护性:代码结构清晰,易于修改。
- 降低耦合度:模块间依赖更少,灵活性更高。
- 团队协作标准化:通用模式让开发者更容易理解彼此代码。
3.2 日志和策略模式
3.2.1 日志概念
-
日志认识
计算机中的日志是记录系统和软件运行中发生事件的文件,主要作用是监控运行状态、记录异常信息,帮助快速定位问题并支持程序员进行问题修复。它是系统维护、故障排查和安全管理的重要工具。
-
日志格式
日志通常包括结构化字段,且必须得有时间戳、日志等级、日志内容。常见格式如下:
-
基础文字格式
[2024-03-15 14:30:45] [ERROR] [MainThread] Connection failed: timeout after 30s (retry=3)
字段说明:
[时间]
:事件发生时间(YYYY-MM-DD HH:MM:SS
)。[日志级别]
:DEBUG
/INFO
/WARN
/ERROR
/FATAL
。[线程/模块名]
:记录日志的代码位置。[消息]
:具体事件描述(可含错误码、参数等)。
-
JSON
格式(结构化日志){"timestamp": "2024-03-15T14:30:45Z","level": "ERROR","service": "payment-gateway","message": "Connection failed","details": {"error": "timeout","retry": 3,"ip": "192.168.1.1"} }
- 优点:机器可读,方便集成到日志分析系统(如 ELK、Splunk)。
-
Syslog
格式(Linux 标准)Mar 15 14:30:45 server01 payment-service[1234]: ERROR: Transaction failed (ID: tx-789)
- 字段顺序:
时间 主机名 服务名[进程ID]: 级别: 消息
。
- 字段顺序:
-
CSV
/TSV
格式2024-03-15,14:30:45,ERROR,payment-service,"Connection timeout",192.168.1.1
适用场景:导入数据库或 Excel 分析。
-
3.2.2 日志的实现——策略模式
策略模式(Strategy):封装算法,灵活切换(如排序算法选择)。
Log.hpp
#pragma once // 防止头文件被重复包含// 包含必要的标准库头文件
#include <iostream> // 标准输入输出流
#include <string> // 字符串处理
#include <fstream> // 文件流操作
#include <memory> // 智能指针
#include <ctime> // 时间处理
#include <sstream> // 字符串流
#include <filesystem> // 文件系统操作(C++17)
#include <unistd.h> // POSIX操作系统API
#include "Lock.hpp" // 自定义锁实现namespace LogModule
{// 使用我们自己封装的锁模块,也可以替换为C++标准库的锁using namespace LockModule;/********************** 常量定义 **********************/const std::string defaultpath = "./log/"; // 默认日志文件存储路径const std::string defaultname = "log.txt"; // 默认日志文件名/********************** 日志等级枚举 **********************/// 定义日志级别,用于区分日志的重要程度enum class LogLevel{DEBUG, // 调试信息,用于开发阶段调试程序INFO, // 普通信息,记录程序运行状态WARNING, // 警告信息,表示可能出现问题但不影响程序运行ERROR, // 错误信息,表示程序出现错误但可以继续运行FATAL // 致命错误,表示程序无法继续运行};/********************** 工具函数 **********************//*** @brief 将日志等级枚举转换为可读字符串* @param level 日志等级枚举值* @return 对应的字符串描述*/std::string LogLevelToString(LogLevel level){switch (level){case LogLevel::DEBUG: return "DEBUG"; // 返回调试级别字符串case LogLevel::INFO: return "INFO"; // 返回信息级别字符串case LogLevel::WARNING: return "WARNING"; // 返回警告级别字符串case LogLevel::ERROR: return "ERROR"; // 返回错误级别字符串case LogLevel::FATAL: return "FATAL"; // 返回致命错误字符串default: return "UNKNOWN"; // 未知级别处理}}/*** @brief 获取当前格式化的时间字符串* @return 格式为"YYYY-MM-DD HH:MM:SS"的时间字符串*/std::string GetCurrTime(){time_t tm = time(nullptr); // 获取当前时间戳struct tm curr; // 定义tm结构体localtime_r(&tm, &curr); // 转换为本地时间(线程安全版本)// 使用snprintf格式化时间字符串,保证缓冲区安全char timebuffer[64];snprintf(timebuffer, sizeof(timebuffer), "%4d-%02d-%02d %02d:%02d:%02d",curr.tm_year + 1900, // 年份(需要加1900)curr.tm_mon, // 月份(0-11)curr.tm_mday, // 日(1-31)curr.tm_hour, // 时(0-23)curr.tm_min, // 分(0-59)curr.tm_sec); // 秒(0-59)return timebuffer;}/********************** 策略模式接口 **********************//*** @brief 日志策略抽象基类* 定义日志输出的通用接口,具体实现由派生类完成*/class LogStrategy{public:// 虚析构函数,确保派生类对象能正确释放资源virtual ~LogStrategy() = default;/*** @brief 同步日志接口* @param message 需要输出的日志消息*/virtual void SyncLog(const std::string &message) = 0;};/********************** 具体策略实现 **********************//*** @brief 控制台日志策略* 将日志输出到标准错误流(std::cerr)*/class ConsoleLogStrategy : public LogStrategy{public:/*** @brief 实现日志同步输出到控制台* @param message 需要输出的日志消息*/void SyncLog(const std::string &message) override{// 使用锁保护控制台输出,防止多线程竞争LockGuard LockGuard(_mutex);std::cerr << message << std::endl; // 输出到标准错误流}// 析构函数(调试时可取消注释查看对象生命周期)~ConsoleLogStrategy(){// std::cout << "~ConsoleLogStrategy" << std::endl;}private:Mutex _mutex; // 互斥锁,保证控制台输出的线程安全};/*** @brief 文件日志策略* 将日志输出到指定文件中*/class FileLogStrategy : public LogStrategy{public:/*** @brief 构造函数,初始化日志文件路径* @param logpath 日志文件存储路径* @param logfilename 日志文件名*/FileLogStrategy(const std::string logpath = defaultpath, std::string logfilename = defaultname): _logpath(logpath), _logfilename(logfilename){// 使用锁保护目录创建操作LockGuard lockguard(_mutex);// 检查目录是否已存在if (std::filesystem::exists(_logpath))return;try{// 递归创建目录结构std::filesystem::create_directories(_logpath);}catch (const std::filesystem::filesystem_error &e){// 捕获并输出文件系统异常std::cerr << e.what() << '\n';}}/*** @brief 实现日志同步输出到文件* @param message 需要输出的日志消息*/void SyncLog(const std::string &message) override{// 使用锁保护文件写入操作LockGuard lockguard(_mutex);// 拼接完整文件路径std::string log = _logpath + _logfilename;// 以追加模式打开文件std::ofstream out(log.c_str(), std::ios::app);if (!out.is_open())return; // 文件打开失败直接返回out << message << "\n"; // 写入日志内容out.close(); // 关闭文件}// 析构函数(调试时可取消注释查看对象生命周期)~FileLogStrategy(){// std::cout << "~FileLogStrategy" << std::endl;}public:std::string _logpath; // 日志文件存储路径std::string _logfilename; // 日志文件名Mutex _mutex; // 互斥锁,保证文件写入的线程安全};/********************** 日志器主类 **********************//*** @brief 日志器主类* 提供统一的日志接口,内部使用策略模式实现不同输出方式*/class Logger{public:/*** @brief 默认构造函数* 初始化时默认使用控制台输出策略*/Logger(){UseConsoleStrategy(); // 默认使用控制台策略}// 默认析构函数~Logger() = default;/*** @brief 切换到控制台输出策略*/void UseConsoleStrategy(){_strategy = std::make_unique<ConsoleLogStrategy>();}/*** @brief 切换到文件输出策略*/void UseFileStrategy(){_strategy = std::make_unique<FileLogStrategy>();}/********************** 日志消息内部类 **********************//*** @brief 日志消息内部类* 采用RAII技术管理单条日志的生命周期*/class LogMessage{private:LogLevel _type; // 日志等级std::string _curr_time; // 日志时间戳pid_t _pid; // 进程IDstd::string _filename; // 源文件名int _line; // 源代码行号Logger &_logger; // 引用外部Logger对象std::string _loginfo; // 完整的日志信息public:/*** @brief 构造函数,初始化日志头部信息* @param type 日志等级* @param filename 源文件名* @param line 源代码行号* @param logger 外部Logger引用*/LogMessage(LogLevel type, std::string &filename, int line, Logger &logger): _type(type),_curr_time(GetCurrTime()),_pid(getpid()),_filename(filename),_line(line),_logger(logger){// 使用字符串流格式化日志头部信息std::stringstream ssbuffer;ssbuffer << "[" << _curr_time << "] " // 时间<< "[" << LogLevelToString(type) << "] " // 等级<< "[" << _pid << "] " // 进程ID<< "[" << _filename << "] " // 文件名<< "[" << _line << "]" // 行号<< " - "; // 分隔符_loginfo = ssbuffer.str(); // 保存头部信息}/*** @brief 重载<<运算符,支持链式日志输入* @tparam T 任意可输出类型* @param info 需要输出的信息* @return 当前LogMessage对象的引用*/template <typename T>LogMessage &operator<<(const T &info){std::stringstream ssbuffer;ssbuffer << info; // 格式化用户数据_loginfo += ssbuffer.str(); // 追加到日志信息return *this; // 返回自身支持链式调用}/*** @brief 析构函数,在对象销毁时输出完整日志*/~LogMessage(){// 如果策略存在,则使用策略输出日志if (_logger._strategy){_logger._strategy->SyncLog(_loginfo);}}};/*** @brief 重载函数调用运算符,创建LogMessage临时对象* @param type 日志等级* @param filename 源文件名* @param line 源代码行号* @return 构造的LogMessage临时对象*/LogMessage operator()(LogLevel type, std::string filename, int line){return LogMessage(type, filename, line, *this);}private:std::unique_ptr<LogStrategy> _strategy; // 日志输出策略智能指针};/********************** 全局对象和宏定义 **********************/Logger logger; // 全局日志器对象// 定义日志宏,自动填充文件名和行号// 使用示例: LOG(LogLevel::INFO) << "This is a message";#define LOG(type) logger(type, __FILE__, __LINE__)// 定义策略切换宏#define ENABLE_CONSOLE_LOG_STRATEGY() logger.UseConsoleStrategy() // 切换到控制台输出#define ENABLE_FILE_LOG_STRATEGY() logger.UseFileStrategy() // 切换到文件输出
}
使用样例
#include <iostream>
#include "Log.hpp"
using namespace LogModule;void fun()
{int a = 10;LOG(LogLevel::FATAL) << "hello world" << 1234 << ", 3.14" << 'c' << a;
}
int main()
{// ENABLE_CONSOLE_LOG_STRATEGY();LOG(LogLevel::DEBUG) << "hello world";LOG(LogLevel::DEBUG) << "hello world";LOG(LogLevel::DEBUG) << "hello world";// ENABLE_FILE_LOG_STRATEGY();LOG(LogLevel::DEBUG) << "hello world";LOG(LogLevel::DEBUG) << "hello world";LOG(LogLevel::WARNING) << "hello world";fun();return 0;
}
输出结果
[可读性很好的时间] [⽇志等级] [进程pid] [打印对应⽇志的⽂件名][⾏号] - 消息内容,⽀持可变参数
[2024-08-04 12:27:03] [DEBUG] [202938] [main.cc] [16] - hello world
[2024-08-04 12:27:03] [DEBUG] [202938] [main.cc] [17] - hello world
[2024-08-04 12:27:03] [DEBUG] [202938] [main.cc] [18] - hello world
[2024-08-04 12:27:03] [DEBUG] [202938] [main.cc] [20] - hello world
[2024-08-04 12:27:03] [DEBUG] [202938] [main.cc] [21] - hello world
[2024-08-04 12:27:03] [WARNING] [202938] [main.cc] [23] - hello world
3.3 线程池设计
-
线程池是一种线程使用模式。线程过多会带来调度开销,进而影响缓存局部性和整体性能。而线程池维护着多个线程,等待着监督管理者分配可并发执行的任务,这避免了在处理短时间任务时创建与销毁线程的代价。线程池不仅能够保证内核的充分利用,还能防止过分调度。可用线程数量应该取决于可用的并发处理器、处理器内核、内存、网络sockets等的数量。
-
线程池的应用场景:需要大量的线程来完成任务,且完成任务的时间比较短。比如WEB服务器完成网页请求这样的任务,使用线程池技术是非常合适的,因为单个任务小,而任务数量巨大(可以想象一个热门网站的点击次数)。但对于长时间的任务(比如一个Telnet连接请求),线程池的优点就不明显了,因为Telnet会话时间比线程的创建时间大得多。此外,线程池适用于对性能要求苛刻的应用(比如要求服务器迅速响应客户请求),以及需要接受突发性大量请求但不至于使服务器产生大量线程的应用(在没有线程池情况下,短时间内产生大量线程可能使内存到达极限,出现错误)。
-
线程池的种类:
a. 固定数量线程池:创建固定数量的线程,循环从任务队列中获取任务对象并执行任务接口。
b. 浮动线程池:线程数量可动态调整,其余机制与固定线程池相同。
在具体实现时,我们通常选择固定线程个数的线程池,以平衡资源利用和稳定性。
ThreadPool.hpp
#pragma once#include <iostream>
#include <vector>
#include <queue>
#include <memory>
#include <pthread.h>
#include "Log.hpp" // 自定义日志模块
#include "Thread.hpp" // 自定义线程模块
#include "Lock.hpp" // 自定义锁模块
#include "Cond.hpp" // 自定义条件变量模块using namespace ThreadModule;
using namespace CondModule;
using namespace LockModule;
using namespace LogModule;const static int gdefaultthreadnum = 10; // 默认线程池线程数量// 线程池模板类
template <typename T>
class ThreadPool
{
private:// 线程任务处理函数(线程执行的核心逻辑)void HandlerTask(){std::string name = GetThreadNameFromNptl(); // 获取当前线程名称LOG(LogLevel::INFO) << name << " is running..."; // 记录线程启动日志while (true){// 1. 加锁保证任务队列的线程安全_mutex.Lock();// 2. 检查任务队列是否为空(使用while防止虚假唤醒)while (_task_queue.empty() && _isrunning){_waitnum++; // 增加等待线程计数_cond.Wait(_mutex); // 等待条件变量通知_waitnum--; // 减少等待线程计数}// 2.1 检查线程池是否已停止且任务队列为空if (_task_queue.empty() && !_isrunning){_mutex.Unlock(); // 解锁break; // 退出线程循环}// 2.2 线程池运行中且任务队列不为空// 2.3 线程池已停止但任务队列不为空(处理剩余任务后再退出)// 3. 获取任务(此时队列中一定有任务)T t = _task_queue.front(); // 获取队首任务_task_queue.pop(); // 弹出任务_mutex.Unlock(); // 解锁LOG(LogLevel::DEBUG) << name << " get a task"; // 记录获取任务日志// 4. 执行任务(此时已释放锁,任务执行不会阻塞其他线程)t(); // 调用任务的函数运算符}}public:// 构造函数(初始化线程数量)ThreadPool(int threadnum = gdefaultthreadnum) : _threadnum(threadnum), _waitnum(0), _isrunning(false){LOG(LogLevel::INFO) << "ThreadPool Construct()"; // 记录构造日志}// 初始化线程池(创建线程但不启动)void InitThreadPool(){for (int num = 0; num < _threadnum; num++){// 创建线程并绑定HandlerTask成员函数_threads.emplace_back(std::bind(&ThreadPool::HandlerTask, this));LOG(LogLevel::INFO) << "init thread " << _threads.back().Name() << " done"; // 记录线程初始化日志}}// 启动线程池void Start(){_isrunning = true; // 设置运行标志// 启动所有线程for (auto &thread : _threads){thread.Start();LOG(LogLevel::INFO) << "start thread " << thread.Name() << " done";}}// 停止线程池void Stop(){_mutex.Lock(); // 加锁_isrunning = false; // 设置停止标志_cond.NotifyAll(); // 唤醒所有等待线程_mutex.Unlock(); // 解锁LOG(LogLevel::DEBUG) << "线程池退出中..."; // 记录停止日志}// 等待所有线程退出void Wait(){for (auto &thread : _threads){thread.Join(); // 等待线程结束LOG(LogLevel::INFO) << thread.Name() << " 退出..."; // 记录线程退出日志}}// 向任务队列添加任务bool Enqueue(const T &t){bool ret = false;_mutex.Lock(); // 加锁if (_isrunning) // 检查线程池是否在运行{_task_queue.push(t); // 添加任务到队列// 如果有线程在等待,则通知一个线程if (_waitnum > 0){_cond.Notify();}LOG(LogLevel::DEBUG) << "任务入队列成功"; // 记录任务添加日志ret = true;}_mutex.Unlock(); // 解锁return ret; // 返回添加结果}// 析构函数~ThreadPool(){// 注意:应在外部先调用Stop()和Wait()确保线程池正确关闭}private:int _threadnum; // 线程数量std::vector<Thread> _threads; // 线程数组std::queue<T> _task_queue; // 任务队列Mutex _mutex; // 互斥锁(保护任务队列)Cond _cond; // 条件变量(线程等待/通知)int _waitnum; // 当前等待任务的线程数bool _isrunning; // 线程池运行标志
};
输出结果
$ ./a.out
[2024-08-04 15:09:29] [INFO] [206342] [ThreadPool.hpp] [62] - ThreadPool
Construct()
[2024-08-04 15:09:29] [INFO] [206342] [ThreadPool.hpp] [70] - init thread
Thread-0 done
[2024-08-04 15:09:29] [INFO] [206342] [ThreadPool.hpp] [70] - init thread
Thread-1 done
[2024-08-04 15:09:29] [INFO] [206342] [ThreadPool.hpp] [70] - init thread
Thread-2 done
[2024-08-04 15:09:29] [INFO] [206342] [ThreadPool.hpp] [70] - init thread
Thread-3 done
[2024-08-04 15:09:29] [INFO] [206342] [ThreadPool.hpp] [70] - init thread
Thread-4 done
[2024-08-04 15:09:29] [INFO] [206342] [ThreadPool.hpp] [79] - start thread
Thread-0done
[2024-08-04 15:09:29] [INFO] [206342] [ThreadPool.hpp] [79] - start thread
Thread-1done
[2024-08-04 15:09:29] [INFO] [206342] [ThreadPool.hpp] [28] - Thread-0 is
running...
[2024-08-04 15:09:29] [INFO] [206342] [ThreadPool.hpp] [79] - start thread
Thread-2done
[2024-08-04 15:09:29] [INFO] [206342] [ThreadPool.hpp] [79] - start thread
Thread-3done
[2024-08-04 15:09:29] [INFO] [206342] [ThreadPool.hpp] [28] - Thread-3 is
running...
[2024-08-04 15:09:29] [INFO] [206342] [ThreadPool.hpp] [28] - Thread-2 is
running...
[2024-08-04 15:09:29] [INFO] [206342] [ThreadPool.hpp] [79] - start thread
Thread-4done
[2024-08-04 15:09:29] [DEBUG] [206342] [ThreadPool.hpp] [109] - 任务⼊队列成功
[2024-08-04 15:09:29] [DEBUG] [206342] [ThreadPool.hpp] [52] - Thread-0 get a
task
this is a task
[2024-08-04 15:09:29] [INFO] [206342] [ThreadPool.hpp] [28] - Thread-1 is
running...
[2024-08-04 15:09:29] [INFO] [206342] [ThreadPool.hpp] [28] - Thread-4 is
running...
[2024-08-04 15:09:30] [DEBUG] [206342] [ThreadPool.hpp] [109] - 任务⼊队列成功
[2024-08-04 15:09:30] [DEBUG] [206342] [ThreadPool.hpp] [52] - Thread-3 get a
task
this is a task
...
this is a task
[2024-08-04 15:09:39] [DEBUG] [206342] [ThreadPool.hpp] [88] - 线程池退出中...
[2024-08-04 15:09:44] [INFO] [206342] [ThreadPool.hpp] [95] - Thread-0 退出...
[2024-08-04 15:09:44] [INFO] [206342] [ThreadPool.hpp] [95] - Thread-1 退出...
[2024-08-04 15:09:44] [INFO] [206342] [ThreadPool.hpp] [95] - Thread-2 退出...
[2024-08-04 15:09:44] [INFO] [206342] [ThreadPool.hpp] [95] - Thread-3 退出...
[2024-08-04 15:09:44] [INFO] [206342] [ThreadPool.hpp] [95] - Thread-4 退出...
3.4 线程安全的单例模式
单例模式(Singleton):确保一个类只有一个实例(如数据库连接池)。
-
饿汉方式实现单例模式
template <typename T> class Singleton {static T data; public:static T* GetInstance() {return &data;} };
只要通过
Singleton
这个包装类来使用 T 对象, 则一个进程中只有⼀个 T 对象的实例。 -
懒汉方式实现单例模式
template <typename T> class Singleton {static T* inst; public:static T* GetInstance() {if (inst == NULL) {inst = new T();}return inst;} };
存在⼀个严重的问题, 线程不安全。第⼀次调⽤
GetInstance
的时候, 如果两个线程同时调用,可能会创建出两份 T 对象的实例。但是后续再次调用,就没有问题了。 -
懒汉方式实现单例模式(线程安全版本)
懒汉⽅式最核心的思想是 “延时加载”,从而能够优化服务器的启动速度。
// 懒汉模式, 线程安全 template <typename T> class Singleton {volatile static T* inst; // 需要设置 volatile 关键字, 否则可能被编译器优化.static std::mutex lock; public:static T* GetInstance() {if (inst == NULL) { // 双重判定空指针, 降低锁冲突的概率, 提⾼性能.lock.lock(); // 使⽤互斥锁, 保证多线程情况下也只调⽤⼀次 new.if (inst == NULL) {inst = new T();}lock.unlock();}return inst;} };
注意:加锁解锁的位置;双重
if
判定,避免不必要的锁竞争 ;volatile
关键字防止过度优化
3.5 单例式线程池实现
ThreadPool.hpp
#pragma once
#include <iostream>
#include <vector>
#include <queue>
#include <memory>
#include <pthread.h>
#include "Log.hpp" // 日志系统:提供分级日志输出功能
#include "Thread.hpp" // 线程封装:封装了POSIX线程的创建和管理
#include "Lock.hpp" // 互斥锁封装:基于pthread_mutex_t的RAII封装
#include "Cond.hpp" // 条件变量封装:基于pthread_cond_t的RAII封装// 使用自定义模块的命名空间
using namespace ThreadModule;
using namespace CondModule;
using namespace LockModule;
using namespace LogModule;const static int gdefaultthreadnum = 10; // 默认线程数量(CPU核心数×2是常见设置)/*** @brief 线程池模板类* @tparam T 任务类型,必须是可调用对象(仿函数/lambda/函数指针等)* @note 采用单例模式设计,确保全局唯一线程池实例* @note 线程安全设计:* 1. 任务队列使用互斥锁保护* 2. 双检查锁单例初始化* 3. 条件变量实现任务等待/通知机制*/
template <typename T>
class ThreadPool
{
private:/*** @brief 私有构造函数(单例模式)* @param threadnum 初始线程数量* @note 初始化列表:* _threadnum - 记录线程数量* _waitnum - 当前等待任务的线程数(初始为0)* _isrunning - 线程池运行状态标志(初始为false)*/ThreadPool(int threadnum = gdefaultthreadnum) : _threadnum(threadnum), _waitnum(0), _isrunning(false){LOG(LogLevel::INFO) << "ThreadPool Constructor Invoked";}/*** @brief 初始化线程池(创建线程对象)* @note 1. 预先创建指定数量的线程对象* 2. 使用std::bind绑定成员函数作为线程执行体* 3. 线程对象创建后处于挂起状态(未调用Start)*/void InitThreadPool(){for (int num = 0; num < _threadnum; num++){// emplace_back直接构造Thread对象,避免拷贝_threads.emplace_back(std::bind(&ThreadPool::HandlerTask, this) // 绑定成员函数);LOG(LogLevel::INFO) << "Thread Initialized: " << _threads.back().Name();}}/*** @brief 启动所有工作线程* @note 设置运行标志后逐个启动线程*/void Start(){_isrunning = true;for (auto &thread : _threads){thread.Start(); // 实际调用pthread_createLOG(LogLevel::INFO) << "Thread Started: " << thread.Name();}}/*** @brief 线程执行函数(核心逻辑)* @note 工作流程:* 1. 获取线程名称并打印启动日志* 2. 进入无限循环:* a. 加锁访问任务队列* b. 等待条件满足(有任务或线程池停止)* c. 获取并执行任务* d. 解锁并处理任务* 3. 退出条件:线程池停止且任务队列为空*/void HandlerTask(){const std::string name = GetThreadNameFromNptl(); // 获取系统级线程名LOG(LogLevel::INFO) << name << " Entered Task Handling Loop";while (true){/*--- 临界区开始 ---*/_mutex.Lock(); // RAII锁也可用,但需要控制作用域// 等待条件:队列非空或线程池停止while (_task_queue.empty() && _isrunning){_waitnum++; // 原子计数器递增_cond.Wait(_mutex); // 自动释放锁并等待(避免忙等待)_waitnum--; // 被唤醒后计数器递减}// 退出条件检查(线程池停止且队列为空)if (_task_queue.empty() && !_isrunning){_mutex.Unlock();LOG(LogLevel::DEBUG) << name << " Exiting Gracefully";break; // 跳出循环,线程结束}// 任务提取流程(此时保证队列非空)T task = _task_queue.front(); // 取队首任务_task_queue.pop(); // 移除已取任务_mutex.Unlock(); // 尽早释放锁/*--- 临界区结束 ---*/LOG(LogLevel::DEBUG) << name << " Processing Task";task(); // 执行任务(可能抛出异常,建议外部捕获)}}// 明确禁用拷贝构造和赋值操作ThreadPool<T> &operator=(const ThreadPool<T> &) = delete;ThreadPool(const ThreadPool<T> &) = delete;public:/*** @brief 获取线程池单例(线程安全)* @return ThreadPool<T>* 单例指针* @note 双检查锁模式优势:* 1. 第一次检查避免不必要的锁竞争* 2. 第二次检查保证创建唯一实例* 3. 内存屏障确保指令不重排序*/static ThreadPool<T> *GetInstance(){// 第一次检查(无锁快速路径)if (nullptr == _instance){LockGuard lockguard(_lock); // RAII锁// 第二次检查(防止竞态条件)if (nullptr == _instance){_instance = new ThreadPool<T>();_instance->InitThreadPool();_instance->Start();LOG(LogLevel::DEBUG) << "ThreadPool Instance Created";}}return _instance;}/*** @brief 停止线程池* @note 1. 设置运行标志为false* 2. 唤醒所有等待线程* 3. 需要配合Wait()等待线程退出*/void Stop(){_mutex.Lock();_isrunning = false;_cond.NotifyAll(); // 广播通知所有等待线程_mutex.Unlock();LOG(LogLevel::DEBUG) << "ThreadPool Shutdown Initiated";}/*** @brief 等待所有工作线程退出* @note 必须在线程池Stop()后调用*/void Wait(){for (auto &thread : _threads){thread.Join(); // 阻塞等待线程结束LOG(LogLevel::INFO) << thread.Name() << " Thread Exited";}}/*** @brief 提交任务到线程池* @param t 任务对象(必须可调用)* @return bool 是否提交成功* @note 线程池停止运行时拒绝新任务*/bool Enqueue(const T &t){bool ret = false;_mutex.Lock();if (_isrunning){_task_queue.push(t);// 有等待线程时才通知(避免无效唤醒)if (_waitnum > 0){_cond.Notify(); // 通知一个等待线程}LOG(LogLevel::DEBUG) << "Task Enqueued Successfully";ret = true;}_mutex.Unlock();return ret;}// 析构函数(单例对象通常需要显式销毁)~ThreadPool() {}private:// 线程池配置int _threadnum; // 线程数量(建议设置为CPU核心数的1-2倍)// 线程管理std::vector<Thread> _threads; // 线程对象容器(使用vector避免动态分配)// 任务队列std::queue<T> _task_queue; // 任务队列(FIFO)// 同步机制Mutex _mutex; // 互斥锁(保护任务队列)Cond _cond; // 条件变量(任务通知)int _waitnum; // 等待线程计数器(优化通知效率)bool _isrunning; // 运行状态标志(原子变量更优)// 单例模式静态成员static ThreadPool<T> *_instance; // 单例指针static Mutex _lock; // 单例初始化锁
};// 静态成员初始化(模板类需在头文件中初始化)
template <typename T>
ThreadPool<T> *ThreadPool<T>::_instance = nullptr;template <typename T>
Mutex ThreadPool<T>::_lock;
测试样例代码
#include <iostream> // 标准输入输出流库
#include <functional> // 函数对象库,用于定义task_t类型
#include <unistd.h> // POSIX操作系统API,提供sleep()函数
#include "ThreadPool.hpp" // 自定义线程池头文件// 定义任务类型为返回void的无参函数对象
using task_t = std::function<void()>;// 示例任务函数:下载任务
void DownLoad() {std::cout << "this is a task" << std::endl;
}int main() {// 启用控制台日志策略(假设是自定义的日志宏)ENABLE_CONSOLE_LOG_STRATEGY();int cnt = 10; // 任务计数器// 循环提交10个任务到线程池while (cnt) {// 获取线程池单例并将下载任务加入队列ThreadPool<task_t>::GetInstance()->Enqueue(DownLoad);sleep(1); // 每次提交后暂停1秒(模拟任务间隔)cnt--; // 计数器递减}// 停止线程池(不再接受新任务)ThreadPool<task_t>::GetInstance()->Stop();sleep(5); // 主线程等待5秒(给线程池时间处理剩余任务)// 等待线程池中所有任务完成ThreadPool<task_t>::GetInstance()->Wait();return 0; // 程序正常退出
}
测试结果
$ ./a.out
[2024-08-04 15:03:37] [INFO] [206234] [ThreadPool.hpp] [28] - ThreadPool
Construct()
[2024-08-04 15:03:37] [INFO] [206234] [ThreadPool.hpp] [36] - init thread
Thread-0 done
[2024-08-04 15:03:37] [INFO] [206234] [ThreadPool.hpp] [36] - init thread
Thread-1 done
[2024-08-04 15:03:37] [INFO] [206234] [ThreadPool.hpp] [36] - init thread
Thread-2 done
[2024-08-04 15:03:37] [INFO] [206234] [ThreadPool.hpp] [36] - init thread
Thread-3 done
[2024-08-04 15:03:37] [INFO] [206234] [ThreadPool.hpp] [36] - init thread
Thread-4 done
[2024-08-04 15:03:37] [INFO] [206234] [ThreadPool.hpp] [36] - init thread
Thread-5 done
[2024-08-04 15:03:37] [INFO] [206234] [ThreadPool.hpp] [36] - init thread
Thread-6 done
[2024-08-04 15:03:37] [INFO] [206234] [ThreadPool.hpp] [36] - init thread
Thread-7 done
[2024-08-04 15:03:37] [INFO] [206234] [ThreadPool.hpp] [36] - init thread
Thread-8 done
[2024-08-04 15:03:37] [INFO] [206234] [ThreadPool.hpp] [36] - init thread
Thread-9 done
[2024-08-04 15:03:37] [INFO] [206234] [ThreadPool.hpp] [45] - start thread
Thread-0done
[2024-08-04 15:03:37] [INFO] [206234] [ThreadPool.hpp] [45] - start thread
Thread-1done
[2024-08-04 15:03:37] [INFO] [206234] [ThreadPool.hpp] [51] - Thread-0 is
running...
[2024-08-04 15:03:37] [INFO] [206234] [ThreadPool.hpp] [45] - start thread
Thread-2done
[2024-08-04 15:03:37] [INFO] [206234] [ThreadPool.hpp] [45] - start thread
Thread-3done
[2024-08-04 15:03:37] [INFO] [206234] [ThreadPool.hpp] [51] - Thread-2 is
running...
[2024-08-04 15:03:37] [INFO] [206234] [ThreadPool.hpp] [45] - start thread
Thread-4done
[2024-08-04 15:03:37] [INFO] [206234] [ThreadPool.hpp] [51] - Thread-3 is
running...
[2024-08-04 15:03:37] [INFO] [206234] [ThreadPool.hpp] [45] - start thread
Thread-5done
[2024-08-04 15:03:37] [INFO] [206234] [ThreadPool.hpp] [51] - Thread-4 is
running...
[2024-08-04 15:03:37] [INFO] [206234] [ThreadPool.hpp] [51] - Thread-5 is
running...
[2024-08-04 15:03:37] [INFO] [206234] [ThreadPool.hpp] [45] - start thread
Thread-6done
[2024-08-04 15:03:37] [INFO] [206234] [ThreadPool.hpp] [51] - Thread-6 is
running...
[2024-08-04 15:03:37] [INFO] [206234] [ThreadPool.hpp] [45] - start thread
Thread-7done
[2024-08-04 15:03:37] [INFO] [206234] [ThreadPool.hpp] [51] - Thread-7 is
running...
[2024-08-04 15:03:37] [INFO] [206234] [ThreadPool.hpp] [45] - start thread
Thread-8done
[2024-08-04 15:03:37] [INFO] [206234] [ThreadPool.hpp] [45] - start thread
Thread-9done
[2024-08-04 15:03:37] [DEBUG] [206234] [ThreadPool.hpp] [98] - 创建线程池单例
[2024-08-04 15:03:37] [DEBUG] [206234] [ThreadPool.hpp] [133] - 任务⼊队列成功
[2024-08-04 15:03:37] [INFO] [206234] [ThreadPool.hpp] [51] - Thread-1 is
running...
[2024-08-04 15:03:37] [DEBUG] [206234] [ThreadPool.hpp] [75] - Thread-0 get a
task
this is a task
....
[2024-08-04 15:03:47] [DEBUG] [206234] [ThreadPool.hpp] [102] - 获取线程池单例
[2024-08-04 15:03:47] [DEBUG] [206234] [ThreadPool.hpp] [112] - 线程池退出中...
[2024-08-04 15:03:52] [DEBUG] [206234] [ThreadPool.hpp] [102] - 获取线程池单例
[2024-08-04 15:03:52] [INFO] [206234] [ThreadPool.hpp] [119] - Thread-0 退出...
[2024-08-04 15:03:52] [INFO] [206234] [ThreadPool.hpp] [119] - Thread-1 退出...
[2024-08-04 15:03:52] [INFO] [206234] [ThreadPool.hpp] [119] - Thread-2 退出...
[2024-08-04 15:03:52] [INFO] [206234] [ThreadPool.hpp] [119] - Thread-3 退出...
[2024-08-04 15:03:52] [INFO] [206234] [ThreadPool.hpp] [119] - Thread-4 退出...
[2024-08-04 15:03:52] [INFO] [206234] [ThreadPool.hpp] [119] - Thread-5 退出...
[2024-08-04 15:03:52] [INFO] [206234] [ThreadPool.hpp] [119] - Thread-6 退出...
[2024-08-04 15:03:52] [INFO] [206234] [ThreadPool.hpp] [119] - Thread-7 退出...
[2024-08-04 15:03:52] [INFO] [206234] [ThreadPool.hpp] [119] - Thread-8 退出...
[2024-08-04 15:03:52] [INFO] [206234] [ThreadPool.hpp] [119] - Thread-9 退出...
4. 线程安全和重入问题
-
概念
-
线程安全
线程安全是指多个线程在访问共享资源时,能够正确地执行,不会相互干扰或破坏彼此的执行结果。
- 一般情况下:多个线程并发执行同一段只有局部变量的代码时,不会出现不同的结果。
- 问题场景:对全局变量或者静态变量进行操作,并且没有锁保护的情况下,容易出现线程安全问题。
-
重入
重入是指同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入。
-
可重入函数:一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题。
-
不可重入函数:不符合上述条件的函数。
-
重入的两种情况
- 多线程重入函数
- 信号导致一个执行流重复进入函数
-
-
常见线程不安全的情况
- 不保护共享变量的函数
- 函数状态随着被调用,状态发生变化的函数
- 返回指向静态变量指针的函数
- 调用线程不安全函数的函数
-
常见不可重入的情况
- 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
- 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
- 可重入函数体内使用了静态的数据结构
-
常见线程安全的情况
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限
- 类或者接口对于线程来说都是原子操作
- 多个线程之间的切换不会导致该接口的执行结果存在二义性
-
常见可重入的情况
- 不使用全局变量或静态变量
- 不使用malloc或者new开辟出的空间
- 不调用不可重入函数
- 不返回静态或全局数据,所有数据都有函数的调用者提供
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
-
-
结论
-
可重⼊与线程安全联系:
- 函数是可重⼊的,那就是线程安全的;
- 函数是不可重⼊的,那就不能由多个线程使用,有可能引发线程安全问题;
- 如果⼀个函数中有全局变量,那么这个函数既不是线程安全也不是可重⼊的。
-
可重⼊与线程安全区别:
- 可重⼊函数是线程安全函数的⼀种
- 线程安全不⼀定是可重⼊的,而可重⼊函数则⼀定是线程安全的。
- 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重⼊函数若锁还未释放则会产生死锁,因此是不可重⼊的。
-
注意:
- 如果不考虑信号导致⼀个执行流重复进⼊函数这种重⼊情况,线程安全和重⼊在安全⻆度不做区分;
- 但是线程安全侧重说明线程访问公共资源的安全情况,表现的是并发线程的特点;
- 可重⼊描述的是⼀个函数是否能被重复进⼊,表示的是函数的特点。
-
5. 常见锁概念
5.1 死锁
- 死锁是指在⼀组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源而处于的⼀种永久等待状态。
- 为了⽅便表述,假设现在线程A、线程B必须同时持有锁1和锁2,才能进行后续资源的访问。
申请一把锁是原子的,但是申请两把锁就不一定了。
造成的结果是:
5.2 死锁的四个必要条件
-
互斥条件:一个资源每次只能被一个执行流使用
-
请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
-
不剥夺条件:一个执行流已获得的资源,在未使用完之前,不能强行剥夺
-
循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
5.3 避免死锁
-
避免死锁其实只需要破坏死锁的四个必要条件即可
-
破坏循环等待条件问题:资源一次性分配,使用超时机制,加锁顺序一致。
#include <iostream> #include <mutex> #include <thread> #include <vector> #include <unistd.h>// 定义两个共享资源(整数变量) int shared_resource1 = 0; int shared_resource2 = 0;// 定义两个互斥锁,分别用于保护对应的共享资源 std::mutex mtx1, mtx2;// 一个函数,同时访问两个共享资源 void access_shared_resources() {// 注释掉的代码展示了更安全的锁定方式(同时锁定多个互斥锁以避免死锁)// std::unique_lock<std::mutex> lock1(mtx1, std::defer_lock);// std::unique_lock<std::mutex> lock2(mtx2, std::defer_lock);// std::lock(lock1, lock2); // 原子性地同时锁定两个锁// 当前实现存在潜在的死锁风险(如果其他线程以不同顺序获取锁)mtx1.lock();mtx2.lock();// 现在两个互斥锁都已锁定,可以安全地访问共享资源int cnt = 10000;while (cnt){++shared_resource1; // 修改共享资源1++shared_resource2; // 修改共享资源2cnt--;}// 释放锁(如果用unique_lock则不需要手动解锁)mtx2.unlock();mtx1.unlock(); }// 模拟多线程同时访问共享资源的场景 void simulate_concurrent_access() {std::vector<std::thread> threads;// 创建多个线程来模拟并发访问for (int i = 0; i < 10; ++i){threads.emplace_back(access_shared_resources); // 创建并启动线程}// 等待所有线程完成for (auto &thread : threads){thread.join(); // 阻塞主线程直到所有子线程完成}// 输出共享资源的最终状态std::cout << "Shared Resource 1: " << shared_resource1 << std::endl;std::cout << "Shared Resource 2: " << shared_resource2 << std::endl; }int main() {simulate_concurrent_access(); // 执行并发访问模拟return 0; }
$ ./a.out // 不⼀次申请 Shared Resource 1: 94416 Shared Resource 2: 94536
$ ./a.out // ⼀次申请 Shared Resource 1: 100000 Shared Resource 2: 100000
-
-
避免锁未释放的场景
6. STL容器和智能指针的线程安全
-
STL容器是否是线程安全的
不是。原因是STL的设计初衷是将性能挖掘到极致,而一旦涉及到加锁保证线程安全,会对性能造成巨大的影响。而且对于不同的容器,加锁方式的不同,性能可能也不同(例如hash表的锁表和锁桶)。因此STL默认不是线程安全。如果需要在多线程环境下使用,往往需要调用者自行保证线程安全。
-
智能指针是否是线程安全的
对于
unique_ptr
,由于只是在当前代码块范围内生效,因此不涉及线程安全问题;对于shared_ptr
,多个对象需要共用一个引用计数变量,所以会存在线程安全问题,但是标准库实现的时候考虑到了这个问题,基于原子操作(CAS)的方式保证shared_ptr
能够高效、原子地操作引用计数。