【Linux】深入探索多线程编程:从互斥锁到高性能线程池实战
目录
一、线程互斥
1. 进程线程间的互斥相关背景概念
2. 互斥量mutex
(1)线程安全问题模拟
(2)错误原因分析
(3)解决方法
(4)互斥量的接口
① 初始化互斥量
② 销毁互斥量
③ 互斥量的加锁和解锁
④ 改进购票系统
3. 互斥锁实现原理探究
(1)汇编实现原理
(2)流程分析总结
(3)核心要点总结
4. 互斥量的封装
(1)C++互斥锁的简单封装
(2)RAII风格互斥锁的封装
二、线程同步
1. 条件变量
2. 条件变量函数
(1)初始化条件变量
(2)销毁条件变量
(3)等待条件满足
(4)唤醒一个等待进程
(5)唤醒所有等待进程
(6)使用实例
4. 生产者消费者模型
(1)321原则(便于记忆)
(2)为何要使用生产者消费者模型?
(3)生产者消费者模型的优点
5. 基于BlockingQueue的生产者消费者模型
(1)BlockingQueue的特点
(2)C++queue模拟阻塞队列的生产消费模型
① BlockQueue.hpp 阻塞队列实现
② Task.hpp 任务定义
③ Main.cc 主程序
6. 为什么pthread_cond_wait需要互斥量?
7. 条件变量使用规范
(1)等待条件代码
(2)给条件发送信号代码
8. 条件变量的封装
9. POSIX信号量
(1)POSIX信号量接口
① 初始化信号量
② 销毁信号量
③ 等待信号量
④ 发布信号量
(2)基于环形队列的生产消费模型
① 特性分析
② 单生产单消费模型
③ 多生产多消费模型
三、线程池
1. 日志与策略模式
(1)日志
(2)模拟实现日志
(3)策略模式
2. 线程池设计
(1)线程池
(2)线程池的应用场景
(3)线程池的种类
(4)线程池实现
3. 线程安全的单例模式
(1)什么是单例模式?
(2)饿汉实现方式和懒汉实现方式
(3)饿汉方式实现单例模式
(4)懒汉方式实现单例模式
(5)懒汉方式实现单例模式(线程安全版本)
4. 单例模式的线程池
四、线程安全与重入问题
1. 线程安全
2. 可重入
3. 分类对比
(1)线程不安全的情况
(2)不可重入的情况
(3)线程安全的情况
(4)可重入的情况
4. 可重入与线程安全的区别与联系
(1)联系
(2)区别
(3)注意事项
五、常见锁概念
1. 死锁
2. 死锁的四个必要条件
3. 避免死锁
六、STL、智能指针和线程安全
1. STL中的容器是否是线程安全的?
2. 智能指针是否是线程安全的?
七、其他常见的锁
一、线程互斥
1. 进程线程间的互斥相关背景概念
• 互斥的背景事实:并发实体(线程或进程)对共享资源的访问可能引发竞争条件(Race Condition),从而导致数据不一致、程序崩溃等不可预知的错误。
• 竞争条件:当两个或多个线程/进程同时读写某些共享数据,且最终结果取决于线程/进程执行的精确时序时,就发生了竞争条件。
• 临界资源:多线程执行流共享的资源就叫做临界资源
• 临界区:每个线程内部,访问临界资源的代码,就叫做临界区
• 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
• 原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成
2. 互斥量mutex
大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。
(1)线程安全问题模拟
多个线程并发的操作共享变量,会带来一些线程安全问题。下面代码模拟了4个售票窗口(4个线程)同时售卖10张票(共享变量 ticket
)的场景:
// 操作共享变量会有问题的售票系统代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>int ticket = 10;
void *route(void *arg)
{char *id = (char *)arg;while (1){if (ticket > 0) // 1. 判断{usleep(1000); // 模拟抢票花的时间printf("%s sells ticket:%d\n", id, ticket); // 2. 抢到了ticket--;}else{break;}}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);
}
$ make
g++ -o mutex TestMutex.cc
$ ./mutex
thread 3 sells ticket:10
thread 1 sells ticket:10
thread 4 sells ticket:8
thread 2 sells ticket:7
thread 3 sells ticket:6
thread 1 sells ticket:5
thread 2 sells ticket:4
thread 4 sells ticket:6
thread 4 sells ticket:2
thread 1 sells ticket:2
thread 3 sells ticket:2
thread 2 sells ticket:-1
thread 4 sells ticket:-2
我们发现,票数居然减到负数了,这显然不符合逻辑。同一张票被卖给了两个不同的顾客(线程1和线程2),这在现实中是无法接受的。
(2)错误原因分析
① 判断后并发切换:if 语句判断条件为真后,代码可能立即切换到其他线程执行。
场景模拟: 假设此时 ticket 的值为 1,这是最后一张票。线程1和线程2同时执行到了 if (ticket > 0) 的判断。
• 时刻 A: 线程1执行 if (ticket > 0),发现 ticket=1>0,条件为真。但它还没来得及执行 ticket--,就被操作系统的调度器中断了(时间片用完了)。
• 时刻 B: 调度器切换到线程2。线程2也执行 if (ticket > 0),此时 ticket 在内存中的值仍然是 1(因为线程1还没修改它)。所以线程2也成功通过了判断。
• 时刻 C: 线程2顺利地执行了 usleep, printf,然后执行 ticket--。现在 ticket 的值在内存中变成了 0。线程2完成任务。
• 时刻 D: 调度器再次切换回线程1。线程1从它上次被中断的地方继续执行(它已经通过了判断)。它执行 usleep 和 printf,然后执行 ticket--。此时,ticket 的值从 0 减为了 -1。
② 并发进入临界区:usleep 模拟的漫长业务过程期间,可能让多个线程进入该代码段。
• usleep(1000) 的作用:它极大地放大了这个问题发生的概率。因为休眠会让线程主动让出CPU,使得其他线程有极大的机会在关键步骤(判断之后,修改之前)介入执行。即使没有 usleep,在多核CPU上并行执行时,这个问题也必然会发生,只是概率较低,更难复现。
③ 非原子操作:--ticket 操作本身不是原子操作,其对应三条底层汇编指令:
取出ticket--部分的汇编代码
40064b: 8b 05 e3 04 20 00 mov 0x2004e3(%rip), %eax # 从内存加载 ticket 到 eax 寄存器
400651: 83 e8 01 sub $0x1, %eax # 将 eax 寄存器中的值减1
400654: 89 05 da 04 20 00 mov %eax, 0x2004da(%rip) # 将计算结果存回内存中的 ticket
• LOAD:将共享变量 ticket 从内存加载到寄存器中:(mov 0x2004e3(%rip), %eax)
• UPDATE:更新寄存器中的值,执行 -1 操作:(sub $0x1, %eax)
• STORE:将计算后的新值,从寄存器写回 ticket 的内存地址:(mov %eax, 0x2004da(%rip))
全局资源没有加保护,可能会在多线程情况下产生并发问题!---> 线程安全问题
(3)解决方法
① 互斥执行:一旦某个线程进入临界区执行,则不允许其他线程进入该临界区。
② 公平竞争:当多个线程同时请求执行临界区代码且临界区空闲时,只能允许一个线程进入。
③ 非阻执行:不允许不在临界区内执行的线程阻止其他线程进入临界区。
满足以上三点要求的本质,就是需要一把锁。Linux pthread 库提供的这把锁就叫做互斥量(Mutex)。
使用pthread的互斥接口来保护数据,确保同一时间只有一个线程访问数据。互斥量从本质上说是一个锁,在访问共享资源前对互斥量进行设置(加锁),在访问完成后释放(解锁)互斥量。对互斥量进行加锁后,任何其他试图再次对互斥量加锁的线程都会被阻塞直到当前进程释放该互斥锁。如果释放互斥量时有一个以上的线程阻塞,那么所有该锁上的阻塞线程都会变成可运行状态,第一个变为运行的线程就可以对互斥量加锁,其他线程就会看到互斥量依然是锁着的,只能回去再次等待它重新变为可用。在这种方式下,每次只有一个线程可以向前执行。
只有将所有线程都设计成遵守相同数据访问规则的,互斥规则才能正常工作。操作系统并不会为我们做数据访问的串行化。如果允许其中的某个线程在没有得到锁的情况下也可以访问共享资源,那么即使其他的线程在使用共享资源前都申请锁,也还是会出现数据不一致的问题。
(4)互斥量的接口
互斥变量是用 pthread_mutex_t 数据类型表示的,在使用互斥变量以前,首先对它进行初始化。
① 初始化互斥量
方法1:静态分配
通常用于全局或静态互斥量,使用预定义的宏进行初始化。
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
方法2:动态分配
通常用于动态分配(如堆内存)的互斥量。
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
mutex:要初始化的互斥量指针;attr:设置互斥量属性,通常传入 NULL 表示默认属性。
返回值:成功返回 0,失败返回错误号。
② 销毁互斥量
int pthread_mutex_destroy(pthread_mutex_t *mutex);
// 返回值:成功返回 0,失败返回错误号。
注意事项:
• 使用 PTHREAD_MUTEX_INITIALIZER 初始化的互斥量不需要销毁。
• 不要销毁一个已经加锁的互斥量。
• 已经销毁的互斥量,要确保后续不会有线程再尝试加锁。
③ 互斥量的加锁和解锁
// 加锁函数
int pthread_mutex_lock(pthread_mutex_t *mutex);
// 解锁函数
int pthread_mutex_unlock(pthread_mutex_t *mutex);
// 返回值:成功返回 0,失败返回错误号。
调用 pthread_mutex_lock 时可能遇到的情况:
• 互斥量处于未锁状态,该函数会将互斥量锁定,并立即返回成功。
• 互斥量已由其他线程锁定:函数调用会阻塞(执行流被挂起),直到互斥量被解锁后,该线程才有可能竞争到锁并继续执行。
• 存在多个线程同时竞争互斥量:只有一个线程能成功竞争到锁,其余线程则会陷入阻塞等待。
④ 改进购票系统
方法1:静态加锁
#include <iostream>
#include <string>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>int ticket = 100;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; // 静态加锁void *route(void *arg)
{char *id = (char *)arg;while (1){pthread_mutex_lock(&lock); // 加锁if (ticket > 0) // 逻辑计算->也要加锁{usleep(1000);printf("%s sells ticket:%d\n", id, ticket);ticket--;pthread_mutex_unlock(&lock); // 解锁}else{pthread_mutex_unlock(&lock); // 解锁break;}}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);
}
方法2:动态加锁
#include <iostream>
#include <string>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>int ticket = 10;class ThreadData
{
public:ThreadData(const std::string &n, pthread_mutex_t &lock) : name(n),lockp(&lock){}std::string name;pthread_mutex_t *lockp;
};// 加锁: 加锁范围的粒度要比较细, 尽可能的不要包含太多的非临界区代码
void *route(void *arg)
{ThreadData *td = static_cast<ThreadData*>(arg);while (1){pthread_mutex_lock(td->lockp); // 加锁if (ticket > 0) // 逻辑计算->也要加锁{usleep(1000);printf("%s sells ticket:%d\n", td->name.c_str(), ticket);ticket--;pthread_mutex_unlock(td->lockp); // 解锁}else{pthread_mutex_unlock(td->lockp); // 解锁break;}}return nullptr;
}
int main(void)
{pthread_mutex_t lock;pthread_mutex_init(&lock, nullptr); // 初始化锁(动态分配)pthread_t t1, t2, t3, t4;ThreadData *td1 = new ThreadData("thread-1", lock);pthread_create(&t1, NULL, route, td1);ThreadData *td2 = new ThreadData("thread-2", lock);pthread_create(&t2, NULL, route, td2);ThreadData *td3 = new ThreadData("thread-3", lock);pthread_create(&t3, NULL, route, td3);ThreadData *td4 = new ThreadData("thread-4", lock);pthread_create(&t4, NULL, route, td4);pthread_join(t1, NULL);pthread_join(t2, NULL);pthread_join(t3, NULL);pthread_join(t4, NULL);pthread_mutex_destroy(&lock); // 销毁锁
}
3. 互斥锁实现原理探究
(1)汇编实现原理
为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。现在我们把lock和unlock的伪代码改一下。
我们使用两个线程A和B,并逐步分析这段基于 xchgb 指令的汇编代码如何实现互斥锁:
1. 初始状态:互斥量 mutex 在内存中的初始值为 1。
2. 关键指令:xchgb %al, mutex
(1)将 %al 寄存器的当前值存入 mutex 所在的内存地址。
(2)将 mutex 内存地址中的原始值读入 %al 寄存器。3. 线程A和B的竞争过程
(1) 线程A先执行【movb $0, %al】, 线程A将自己的AL寄存器设置为0。【xchgb %al, mutex】执行原子交换,该指令执行后,将AL的值(0)存入 mutex (锁被占据了)。再将 mutex 的旧值(1)读入AL(%al_A = 1)。检查 if(al寄存器的内容 > 0),条件为真则return 0,线程A成功获取锁,开始执行临界区代码。(2) 线程B开始执行【movb $0, %al】,程B将自己的AL寄存器也设置为0(%al_B = 0)。【xchgb %al, mutex】原子交换,此时 mutex 的值已经是线程A设置的 0。该指令执行后,将AL的值(0)存入 mutex,mutex 从 0 又被设置为 0 (值不变,锁依然被占据)。将 mutex 的当前值(0)读入AL(%al_B = 0)。检查 if(al寄存器的内容 > 0),%al_B = 0 > 0,条件为假则挂起等待,线程B未能获取锁,被操作系统挂起,放入等待队列。
(3)线程A执行解锁。线程A执行完临界区代码,调用 unlock,【movb $1, mutex】 将 mutex 的值设置回 1(释放锁)。操作系统唤醒正在等待此锁的线程B(或其他线程)。
(4)线程B被唤醒后再次尝试,会再次跳转到 lock 标签处重试,线程B成功获取锁,可以进入临界区。
(2)流程分析总结
步骤 | 线程 | 操作 | AL寄存器值 | Mutex内存值 | 结果 |
---|---|---|---|---|---|
1 | A | xchgb | 1 (成功) | 0 (被占用) | A获得锁 |
2 | B | xchgb | 0 (失败) | 0 (仍占用) | B挂起 |
3 | A | movb $1, mutex | - | 1 (释放) | A释放锁并唤醒B |
4 | B | xchgb (重试) | 1 (成功) | 0 (被占用) | B获得锁 |
(3)核心要点总结
① 原子性是关键:xchgb 指令是硬件保证的原子操作,确保在交换过程中不会被中断。这使得“检查锁状态”和“设置锁状态”两个动作合并为一个不可分割的操作。
② 锁的本质:锁本身就是一个共享变量(mutex),通过硬件提供的原子指令来管理它的状态,再配合挂起和唤醒机制,就实现了互斥。
这种利用硬件原子指令实现锁的方式是很多互斥锁(Mutex)的底层基础。
③ 要彻底理解锁(Lock)或互斥量(Mutex)是如何工作的,必须从四个最根本的前提谈起。
1. 硬件资源的唯一性:计算机的中央处理器(CPU)内部只有一套物理寄存器。这意味着,在任何一瞬间,只能有一个执行流(线程或进程)的指令和数据占据这些寄存器并被执行。
2. 执行流上下文的私有性:虽然硬件寄存器只有一套,但操作系统通过上下文切换机制,创造了每个执行流“独享”CPU的假象。当一个线程/进程被切换出去时,它当前所有寄存器的状态(即硬件上下文)都会被保存到它私有的内存区域(如进程控制块PCB或线程控制块TCB中)。当它被再次调度执行时,这些保存的状态会被精确地恢复回CPU寄存器。
3. “交换”操作的决定性意义:我们用swap,exchange将内存中的变量,交换到CPU的寄存器中的本质是:当前线程/进程获取锁,因为是交换(原子指令),不是拷贝!!!
4. 锁竞争的胜负判定:锁变量 mutex 在内存中只有一份。判断胜负的依据,就是看哪个线程的 xchgb 指令能交换到 mutex 的初始值 1,哪个线程就成功获得锁。另一个线程只能交换到 0,从而必须挂起等待。
4. 互斥量的封装
(1)C++互斥锁的简单封装
#include <iostream>
#include <string>
#include <stdio.h>
#include <mutex>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>// C++实现互斥量的简单封装
int ticket = 100;
std::mutex cpp_lock; // 定义锁void *route(void *arg)
{char *id = (char *)arg;while (1){cpp_lock.lock();// pthread_mutex_lock(&lock); // 加锁if (ticket > 0) // 逻辑计算->也要加锁{usleep(1000);printf("%s sells ticket:%d\n", id, ticket);ticket--; // 解锁cpp_lock.unlock();}else{cpp_lock.unlock(); // 解锁break;}}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);
}
(2)RAII风格互斥锁的封装
// Mutex.hpp
#pragma once
#include <iostream>
#include <pthread.h>namespace MutexModule
{class Mutex{public:Mutex(){pthread_mutex_init(&_mutex, nullptr);}void Lock(){int n = pthread_mutex_lock(&_mutex);(void)n;}void Unlock(){int n = pthread_mutex_unlock(&_mutex);(void)n;}~Mutex(){pthread_mutex_destroy(&_mutex);}private:pthread_mutex_t _mutex;};class LockGuard{public:LockGuard(Mutex &mutex):_mutex(mutex){_mutex.Lock();}~LockGuard(){_mutex.Unlock();}private:Mutex &_mutex;};
}
//////////////////////////////////////////////////////////////////
// TestMutex.cpp#include <iostream>
#include <string>
#include <stdio.h>
#include <mutex>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include "Mutex.hpp"using namespace MutexModule;int ticket = 10;class ThreadData
{
public:ThreadData(const std::string &n, Mutex &lock): name(n),lockp(&lock){}std::string name;// pthread_mutex_t *lockp;Mutex *lockp;
};void *route(void *arg)
{ThreadData *td = static_cast<ThreadData *>(arg);while (1){{ // 封装代码块// 加锁完成, 出代码块时自动解锁LockGuard guard(*td->lockp); // RAII风格的互斥锁的实现if (ticket > 0){usleep(1000);printf("%s sells ticket:%d\n", td->name.c_str(), ticket);ticket--;}else{break;}}}return nullptr;
}
int main(void)
{Mutex lock;pthread_t t1, t2, t3, t4;ThreadData *td1 = new ThreadData("thread-1", lock);pthread_create(&t1, NULL, route, td1);ThreadData *td2 = new ThreadData("thread-2", lock);pthread_create(&t2, NULL, route, td2);ThreadData *td3 = new ThreadData("thread-3", lock);pthread_create(&t3, NULL, route, td3);ThreadData *td4 = new ThreadData("thread-4", lock);pthread_create(&t4, NULL, route, td4);pthread_join(t1, NULL);pthread_join(t2, NULL);pthread_join(t3, NULL);pthread_join(t4, NULL);
}
二、线程同步
• 同步:同步是指在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题。
1. 条件变量
• 当一个线程互斥的访问某个变量时,他可能发现在其他线程改变状态之前,它什么也做不了。例如一个线程访问队列时,发现队列为空,他只能等待,直到其他线程将一个节点添加到队列中,这种情况就需要用到条件变量。
• 条件变量是线程可用的另一种同步机制(让执行具有一定顺序性),允许线程在某个条件不满足时挂起(等待),直到其他线程改变该条件并通知等待的线程。
• 条件变量给多个线程提供了一个回合的场所。条件变量和互斥量一起使用时,允许线程以无竞争的方式等待特定线程的条件发生。
• 条件变量是由互斥量保护的。线程在改变条件状态之前必须首先锁住互斥量。其他线程在获得互斥量之前不会察觉到这种改变,因为互斥量必须在锁定以后才能计算条件。
2. 条件变量函数
(1)初始化条件变量
在使用条件变量之前,必须先对他进行初始化。由 pthread_cond_t 数据类型表示的条件变量可以用两种方式初始化。
① 方法1:静态分配
把常量【PTHREAD_COND_INITIALIZER】赋给静态分配的条件变量。
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
② 方法2:动态分配
int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);
cond:指向要初始化的条件变量的指针
attr:条件变量属性,通常设置为NULL表示使用默认属性。
返回值: 成功返回0,失败返回错误码。
(2)销毁条件变量
使用 PTHREAD_COND_INITIALIZER 初始化的条件变量会自动释放。
int pthread_cond_destroy(pthread_cond_t *cond);
cond:要销毁的条件变量
返回值: 成功返回0,失败返回错误码
(3)等待条件满足
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
cond:要等待的条件变量
mutex:与条件变量配合使用的互斥锁
返回值: 成功返回0,失败返回错误码
pthread_cond_wait 的内部操作:
① 调用成功后,挂起当前进程之前要先自动释放指定的互斥锁。
② 将线程加入条件变量的等待队列,使线程进入阻塞等待状态。
③ 当被唤醒时,重新获取互斥锁。
④ 如果线程被唤醒,但申请锁失败了,线程就会在锁上阻塞等待。
等待条件的正确模式:
pthread_mutex_lock(&mutex);
while (condition_is_false) {pthread_cond_wait(&cond, &mutex);
}
// 执行需要保护的操作
pthread_mutex_unlock(&mutex);
使用 while 循环而不是 if 的原因:
① 防止虚假唤醒(伪唤醒)。
② 确保条件真正满足后再继续执行
③ 多个线程可能同时被唤醒
(4)唤醒一个等待进程
不保证哪个线程会被唤醒。效率较高,适合只有一个线程需要被唤醒的场景。
int pthread_cond_signal(pthread_cond_t *cond);
(5)唤醒所有等待进程
int pthread_cond_broadcast(pthread_cond_t *cond);
cond:要广播信号的条件变量
(6)使用实例
#include <iostream>
#include <pthread.h>
#include <string>
#include <vector>
#include <unistd.h>#define NUM 5
int cnt = 10000;
pthread_mutex_t glock = PTHREAD_MUTEX_INITIALIZER; // 定义锁
pthread_cond_t gcond = PTHREAD_COND_INITIALIZER; // 定义条件变量// 等待之前是需要对资源进行判定的, 判定本身就要访问临界资源,
// 判定结果也一定是在临界区内部
// 所以条件不满足要休眠,一定是在临界区内部休眠
// 条件变量允许线程等待, 允许一个线程唤醒在cond等待的其他线程, 实现同步过程void* threadrun(void* args)
{std::string name = static_cast<const char*>(args);while(true){pthread_mutex_lock(&glock);// 直接让对应的线程进行等待, 临界资源不满足导致我们等待的!// glock在pthread_cond_wait之前, 会被自动释放掉pthread_cond_wait(&gcond, &glock);std::cout << name << " 计算cnt: " << cnt << std::endl;cnt++;pthread_mutex_unlock(&glock);}return nullptr;
}int main()
{std::vector<pthread_t> threads;for (int i = 0; i < NUM; i++){pthread_t tid;char* name = new char[64];snprintf(name, 64, "thread-%d", i);int n = pthread_create(&tid, nullptr, threadrun, name);if(n != 0)continue;threads.push_back(tid);sleep(1);}sleep(2); // 2秒之后开始运行程序// 每隔一秒唤醒一个线程while (true){// std::cout << "唤醒一个线程..." << std::endl;// pthread_cond_signal(&gcond);std::cout << "唤醒所有线程..." << std::endl;pthread_cond_broadcast(&gcond);sleep(1);}for(auto &id : threads){int m = pthread_join(id, nullptr);(void)m;}return 0;
}
$ make
g++ -o cond TestCond.cpp
$ ./cond
唤醒所有线程...
thread-3 计算cnt: 10000
thread-0 计算cnt: 10001
thread-2 计算cnt: 10002
thread-1 计算cnt: 10003
thread-4 计算cnt: 10004
唤醒所有线程...
thread-1 计算cnt: 10005
thread-3 计算cnt: 10006
thread-2 计算cnt: 10007
thread-0 计算cnt: 10008
thread-4 计算cnt: 10009
...
4. 生产者消费者模型
(1)321原则(便于记忆)
① 3种关系:生产者之间是竞争和互斥关系;消费者之间是互斥关系;生产者和消费者之间是互斥和同步关系。
② 2种角色:生产者和消费者(线程承担)。
③ 1个交易场所:以特定结构构成的一种“内存”空间。
(2)为何要使用生产者消费者模型?
生产者消费者模式通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通讯,而是通过阻塞队列来进行通讯。所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里面取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。这个阻塞队列就是用来给生产者和消费者解耦的。
(3)生产者消费者模型的优点
① 解耦:可以独立修改和扩展生产者和消费者的实现
② 支持并发
③ 支持忙闲不均:当生产者速度快时,数据暂存于队列中;当消费者速度快时,从队列中获取积压的数据。
5. 基于BlockingQueue的生产者消费者模型
(1)BlockingQueue的特点
阻塞队列是一种特殊的数据结构,是一个有容量上限的队列,当不满足读写条件时,就要阻塞对应的线程。与普通队列的区别在于:
① 队列为空时:从队列获取元素的操作会被阻塞,直到队列中被放入了元素。
② 队列满时:往队列里存放元素的操作也会被阻塞,直到有元素被从队列中取出。
③ 线程安全:所有操作都是线程安全的。
(2)C++queue模拟阻塞队列的生产消费模型
采用模板设计使得BlockQueue可以处理多种数据类型
① BlockQueue.hpp 阻塞队列实现
// BlockQueue.hpp// 阻塞队列的实现
#pragma once
#include <iostream>
#include <string>
#include <queue>
#include <pthread.h>const int defaultcap = 5;template <typename T>
class BlockQueue
{
private:bool IsFull() { return _q.size() >= _cap; } // 队列满了bool IsEmpty() { return _q.empty(); }public:BlockQueue(int cap = defaultcap): _cap(cap),_csleep_num(0),_psleep_num(0){pthread_mutex_init(&_mutex, nullptr);pthread_cond_init(&_full_cond, nullptr);pthread_cond_init(&_empty_cond, nullptr);}void Equeue(const T &in){pthread_mutex_lock(&_mutex);// 生产者调用while (IsFull()) // 判满也是临界资源{// 生产者线程等待// point 1: pthread_cond_wait调用成功, 挂起当前线程之前,要先自动释放锁!// point 2: 当线程被唤醒的时候, 默认就在临界区内唤醒!// 要想从该函数成功返回, 需要当前线程重新申请mutex锁!// point 3: 如果线程被唤醒了, 但申请锁失败了? 线程就会在锁上阻塞等待!_psleep_num++;std::cout << "生产者进入休眠了: _psleep_num " << _psleep_num << std::endl;// question 1: pthread_cond_wait是函数, 可能会失败? pthread_cond_wait()会立即返回?// question 2: pthread_cond_wait可能会因为条件不满足,被伪唤醒// 条件语句 ---> while循环就能解决这两个问题pthread_cond_wait(&_full_cond, &_mutex);_psleep_num--;}// 没满_q.push(in);// 唤醒消费者if (_csleep_num > 0){pthread_cond_signal(&_empty_cond);std::cout << "唤醒消费者..." << std::endl;}// pthread_cond_signal(&_empty_cond); // 在解锁之前是可以的, 在mutex上阻塞pthread_mutex_unlock(&_mutex);// pthread_cond_signal(&_empty_cond); // 在解锁之后是可以的, 在mutex上阻塞}T Pop(){// 消费者调用pthread_mutex_lock(&_mutex);while (IsEmpty()){_csleep_num++;pthread_cond_wait(&_empty_cond, &_mutex);_csleep_num--;}// 非空T data = _q.front();_q.pop();// 唤醒生产者if (_psleep_num > 0){pthread_cond_signal(&_full_cond);std::cout << "唤醒生产者..." << std::endl;}// pthread_cond_signal(&_full_cond); // 可以pthread_mutex_unlock(&_mutex);// pthread_cond_signal(&_full_cond); // 可以return data;}~BlockQueue(){pthread_mutex_destroy(&_mutex);pthread_cond_destroy(&_full_cond);pthread_cond_destroy(&_empty_cond);}private:std::queue<T> _q; // 临界资源int _cap; // 容量pthread_mutex_t _mutex;pthread_cond_t _full_cond; // 满条件pthread_cond_t _empty_cond; // 空条件int _csleep_num; // 消费者休眠个数int _psleep_num; // 生产者休眠个数
};
双条件变量设计:
_full_cond:队列满时生产者等待
_empty_cond:队列空时消费者等待
等待计数器:
_csleep_num; 记录等待的消费者个数
_psleep_num; 记录等待的生产者个数
② Task.hpp 任务定义
函数指针类型:使用std::function<void()>
类对象类型:自定义Task类,包含执行逻辑
// Task.hpp#pragma once
#include <functional>
#include <iostream>
#include <unistd.h>// 定义一个任务类型: 返回值void, 参数为空
using task_t = std::function<void()>;void Download()
{std::cout << "我是一个下载任务..." << std::endl;sleep(3); // 假设处理任务比较耗时
}class Task
{
public:Task() {}Task(int x, int y):_x(x), _y(y) {}void Execute(){_result = _x + _y;}int X() {return _x;}int Y() {return _y;}int Result(){return _result;}~Task() {}private:int _x;int _y;int _result;
};
③ Main.cc 主程序
代码支持多种配置:单生产单消费,单生产多消费,多生产单消费,多生产多消费。
// Main.cc#include "BlockQueue.hpp"
#include "Task.hpp"
#include <iostream>
#include <pthread.h>
#include <unistd.h>void *consumer(void *args)
{BlockQueue<task_t> *bq = static_cast<BlockQueue<task_t> *>(args);while (true){sleep(10);// 1. 消费任务task_t t = bq->Pop();// 2. 处理任务 —— 处理任务时, 该任务已经被拿到线程的上下文中了, 不属于队列了t();}
}void *productor(void *args)
{// 1. 获得任务BlockQueue<task_t> *bq = static_cast<BlockQueue<task_t> *>(args);while (true){sleep(1);std::cout << "生产了一个任务! " << std::endl;// 2. 生产任务bq->Equeue(Download);}
}int main()
{// BlockQueue<int> *bq = new BlockQueue<int>();// // 构建生产和消费者// pthread_t c, p;// pthread_create(&c, nullptr, consumer, bq);// pthread_create(&c, nullptr, productor, bq);// pthread_join(c, nullptr);// pthread_join(p, nullptr);/////////////////////////////////////////////////////// 扩展认识: 阻塞队列可以放任务对象吗?// 申请阻塞队列// BlockQueue<task_t> *bq = new BlockQueue<task_t>();// // 构建生产和消费者// pthread_t c, p;// pthread_create(&c, nullptr, consumer, bq);// pthread_create(&c, nullptr, productor, bq);// pthread_join(c, nullptr);// pthread_join(p, nullptr);/////////////////////////////////////////////////////// 多生产多消费模型BlockQueue<task_t> *bq = new BlockQueue<task_t>();// 构建生产和消费者pthread_t c[2], p[3];pthread_create(c, nullptr, consumer, bq);pthread_create(c+1, nullptr, productor, bq);pthread_create(p, nullptr, productor, bq);pthread_create(p+1, nullptr, productor, bq);pthread_create(p+2, nullptr, productor, bq);pthread_join(c[0], nullptr);pthread_join(c[1], nullptr);pthread_join(p[0], nullptr);pthread_join(p[1], nullptr);pthread_join(p[2], nullptr);return 0;
}
6. 为什么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_mutex_unlock)和等待(pthread_cond_wait)之间存在一个危险的时间窗口:
① 线程A检查条件,发现不满足
② 线程A解锁互斥锁
③ 此时线程B获得锁,改变条件,发送信号
④ 线程A调用pthread_cond_wait开始等待
结果:线程A错过了线程B发出的信号,可能永远阻塞
7. 条件变量使用规范
(1)等待条件代码
正确的工作流程:
int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex) 是一个原子操作,它完成了三个关键步骤:
① 自动释放锁:在进入等待状态前自动释放互斥锁
② 进入等待:将线程加入条件变量的等待队列
③ 重新获取锁:被唤醒后自动重新获取互斥锁
pthread_mutex_lock(&mutex);
while (condition_is_false) {// 原子操作:释放锁 + 等待 + 重新获取锁pthread_cond_wait(&cond, &mutex);// 被唤醒后,自动重新持有锁
}
// 条件满足,执行操作
pthread_mutex_unlock(&mutex);
(2)给条件发送信号代码
pthread_mutex_lock(&mutex);
设置条件为真
pthread_cond_signal(cond);
pthread_mutex_unlock(&mutex);
8. 条件变量的封装
为了让条件变量更具有通用性,建议封装的时候,不要再Cond类内部引用对应的封装互斥量,不然后面组合的时候,会因为代码耦合的问题难以初始化,因为一般而言 Mutex 和 Cond 基本是一起创建的。
// Cond.hpp
#pragma once
// 对条件变量的封装
#include <iostream>
#include <pthread.h>
#include "Mutex.hpp"using namespace MutexModule;namespace CondModule
{class Cond{public:Cond(){pthread_cond_init(&_cond, nullptr);}void Wait(Mutex &mutex){// 释放曾经持有的锁并等待int n = pthread_cond_wait(&_cond, mutex.Get());(void)n;}// 唤醒在条件变量下等待的一个线程void Signal(){int n = pthread_cond_signal(&_cond);(void)n;}// 唤醒在条件变量下等待的所有线程void Broadcast(){int n = pthread_cond_broadcast(&_cond);}~Cond(){pthread_cond_destroy(&_cond);}private:pthread_cond_t _cond;};
}
// Cond.hpp#pragma once
#include <iostream>
#include <pthread.h>namespace MutexModule
{class Mutex{public:Mutex(){pthread_mutex_init(&_mutex, nullptr);}void Lock(){int n = pthread_mutex_lock(&_mutex);(void)n;}void Unlock(){int n = pthread_mutex_unlock(&_mutex);(void)n;}~Mutex(){pthread_mutex_destroy(&_mutex);}// 获取锁的接口pthread_mutex_t *Get(){return &_mutex;}private:pthread_mutex_t _mutex;};class LockGuard{public:LockGuard(Mutex &mutex):_mutex(mutex){_mutex.Lock();}~LockGuard(){_mutex.Unlock();}private:Mutex &_mutex;};
}
9. POSIX信号量
(1)POSIX信号量接口
POSIX信号量和SystemV信号量作用相同,都是用于同步操作,达到无冲突的访问共享资源的目的。但POSIX可以用于线程间同步。
POSIX信号量有两种形式:命名的和未命名的。我们这里暂时先只介绍未命名的信号量。未命名的信号量只存在于内存中,并要求能使用信号量的进程必须可以访问内存。这意味着它们只能应用在同一进程中的线程,或者不同进程中已经映射相同内存内容到它们的地址空间中的线程。
① 初始化信号量
#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);
sem:指向信号量对象的指针。如果要在两个进程之间使用信号量,需要确保sem参数指向两个进程之间共享的内存范围。
pshared:
• 0:线程间共享(同一进程内的线程)
• 非零:进程间共享(需要放在共享内存中)
value:信号量的初始值
② 销毁信号量
调用sem_destroy后,不能再使用任何带有sem的信号量函数,除非通过调用sem_init重新初始化它。
#include <semaphore.h>
int sem_destroy(sem_t *sem);
③ 等待信号量
#include <semaphore.h>
int sem_wait(sem_t *sem); // P操作
如果信号量值 > 0,则减1并立即返回
如果信号量值 = 0,则阻塞直到信号量值 > 0
④ 发布信号量
#include <semaphore.h>
int sem_post(sem_t *sem); // V操作
将信号量值加1;如果有线程在等待该信号量,会唤醒其中一个
(2)基于环形队列的生产消费模型
① 特性分析
• 环形队列采用数组模拟,用模运算来模拟环状特性。
• 环形结构其实状态和结束状态都是一样的,不好判断为空或者为满,所以可能通过加计时器或者标记位来判断满或者空。另外可以预留一个空的位置,作为满的状态。
• 但是有了信号量这个计数器,就很简单的进行多线程间的同步过程。
② 单生产单消费模型
#pragma once
// POSIX信号量封装
#include <iostream>
#include <pthread.h>
#include <semaphore.h> // POSIX信号量namespace SemMoudle
{const int defaultvalue = 1;class Sem{public:Sem(unsigned int sem_value = defaultvalue){sem_init(&_sem, 0, sem_value);}void P(){int n = sem_wait(&_sem); // 原子的(void)n;}void V(){int n = sem_post(&_sem); // 原子的}~Sem(){sem_destroy(&_sem);}private:sem_t _sem;};
}
// 基于环形队列的生产消费模型
#pragma once#include <iostream>
#include <vector>
#include "Sem.hpp"const static int gcap = 5;using namespace SemMoudle;template<typename T>
class RingQueue
{
public:RingQueue(int cap = gcap): _rq(cap), _cap(cap),_black_sem(cap),_p_step(0),_data_sem(0),_c_step(0){}void Equeue(const T &in){// 生产者// 1. 申请空位置信号量_black_sem.P();// 2. 生产_rq[_p_step] = in;// 3. 更新下标++_p_step;// 4. 维持环形特性_p_step %= _cap;// 5. 通知数据信号量+1_data_sem.V();}void Pop(T *out){// 消费者// 1. 申请信号量_data_sem.P();// 2. 消费*out = _rq[_c_step];// 3. 更新下标++_c_step;// 4. 维持环状特性_c_step %= _cap;// 通知空格信号_black_sem.V();}~RingQueue(){}
private:std::vector<T> _rq;int _cap; // 容量// 生产者Sem _black_sem; // 空位置int _p_step;// 消费者Sem _data_sem; // 数据int _c_step;
};
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include "RingQueue.hpp"void *consumer(void *args)
{RingQueue<int> *rq = static_cast<RingQueue<int> *>(args);while (true){sleep(1);// 1. 消费任务int t = 0;rq->Pop(&t);// 2. 处理任务 —— 处理任务时, 该任务已经被拿到线程的上下文中了, 不属于队列了std::cout << "消费者拿到了一个数据: " << t << std::endl;}
}void *productor(void *args)
{// 1. 获得任务RingQueue<int> *rq = static_cast<RingQueue<int> *>(args);int data = 1;while (true){std::cout << "生产了一个任务: " << data << std::endl;// 2. 生产任务rq->Equeue(data);data++;}
}int main()
{RingQueue<int> *rq = new RingQueue<int>();// 构建生产和消费者pthread_t c, p;pthread_create(&c, nullptr, consumer, rq);pthread_create(&c, nullptr, productor, rq);pthread_join(c, nullptr);pthread_join(p, nullptr);return 0;
}
我们设置的是让生产者跑得快,消费之每隔一秒消费一次。执行后的现象是:一瞬间生产者就把整个环形队列打满了,而后每隔一秒消费者消费一次,生产者也生产一次。
$ make
g++ -o ring_cp Main.cc -std=c++11 -lpthread
$ ./ring_cp
生产了一个任务: 1
生产了一个任务: 2
生产了一个任务: 3
生产了一个任务: 4
生产了一个任务: 5
生产了一个任务: 6
消费者拿到了一个数据: 1
生产了一个任务: 7
消费者拿到了一个数据: 2
生产了一个任务: 8
消费者拿到了一个数据: 3
生产了一个任务: 9
消费者拿到了一个数据: 4
生产了一个任务: 10
重新设置生产者生产的慢一点,消费者跑得快。执行后消费者就会阻塞,每当生产者生产一个数据,消费者就消费一个。
make
g++ -o ring_cp Main.cc -std=c++11 -lpthread
$ ./ring_cp
生产了一个任务: 1
消费者拿到了一个数据: 1
生产了一个任务: 2
消费者拿到了一个数据: 2
生产了一个任务: 3
消费者拿到了一个数据: 3
单生产单消费模型中,生产者与生产者和消费者与消费者之间的互斥关系不需要维护,我们刚刚实现的环形队列,只是通过信号量来实现生产者消费者两个线程之间的互斥和同步!两个线程执行时有先后就是互斥,访问同一个资源时,只能由一个线程访问就是同步。
③ 多生产多消费模型
多生产多消费模型中,cc与pp之间的互斥关系就需要维护了。—> 加锁
进一步理解信号量:为什么在使用信号量的条件下加锁时,不用像我们之前使用条件变量一样使用while循环来重复检查条件?
对于条件变量,当一个线程被唤醒时,资源可能已经被其他线程取走,所以必须重新检查条件;信号量本身就是用来描述临界资源数量的多少的。在访问临界资源之前,信号量就对临界资源是否存在和是否就绪等条件,以原子的形式判断过了。所以信号量的 sem_wait() 在返回成功时,已经保证线程获得了对资源的访问权,这种保证是内置在信号量的原子操作语义中的。
// RingQueue.hpp// 基于环形队列的生产消费模型
#pragma once#include <iostream>
#include <vector>
#include "Sem.hpp"
#include "Mutex.hpp"const static int gcap = 5;using namespace SemMoudle;
using namespace MutexModule;template <typename T>
class RingQueue
{
public:RingQueue(int cap = gcap): _rq(cap),_cap(cap),_black_sem(cap),_p_step(0),_data_sem(0),_c_step(0){}void Equeue(const T &in){// 生产者// 1. 申请空位置信号量(资源的预定机制)_black_sem.P();{ // 先把资源瓜分了, 再串型式的申请锁LockGuard lockguard(_pmutex);// 2. 生产_rq[_p_step] = in;// 3. 更新下标++_p_step;// 4. 维持环形特性_p_step %= _cap;}// 5. 通知数据信号量+1_data_sem.V();}void Pop(T *out){// 消费者// 1. 申请信号量_data_sem.P();{LockGuard lockguard(_cmutex);// 2. 消费*out = _rq[_c_step];// 3. 更新下标++_c_step;// 4. 维持环状特性_c_step %= _cap;}// 通知空格信号_black_sem.V();}~RingQueue() {}private:std::vector<T> _rq;int _cap; // 容量// 生产者Sem _black_sem; // 空位置int _p_step;// 消费者Sem _data_sem; // 数据int _c_step;// 维护多生产多消费 -> 锁Mutex _cmutex;Mutex _pmutex;
};
// Sem.hpp
#pragma once
// POSIX信号量封装
#include <iostream>
#include <pthread.h>
#include <semaphore.h> // POSIX信号量namespace SemMoudle
{const int defaultvalue = 1;class Sem{public:Sem(unsigned int sem_value = defaultvalue){sem_init(&_sem, 0, sem_value);}void P(){int n = sem_wait(&_sem); // 原子的(void)n;}void V(){int n = sem_post(&_sem); // 原子的}~Sem(){sem_destroy(&_sem);}private:sem_t _sem;};
}
// Mutex.hpp#pragma once
#include <iostream>
#include <pthread.h>namespace MutexModule
{class Mutex{public:Mutex(){pthread_mutex_init(&_mutex, nullptr);}void Lock(){int n = pthread_mutex_lock(&_mutex);(void)n;}void Unlock(){int n = pthread_mutex_unlock(&_mutex);(void)n;}~Mutex(){pthread_mutex_destroy(&_mutex);}private:pthread_mutex_t _mutex;};class LockGuard{public:LockGuard(Mutex &mutex):_mutex(mutex){_mutex.Lock();}~LockGuard(){_mutex.Unlock();}private:Mutex &_mutex;};
}
// Main.cc
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include "RingQueue.hpp"struct threaddata
{RingQueue<int> *rq;std::string name;
};void *consumer(void *args)
{threaddata *td = static_cast<threaddata *>(args);// RingQueue<int> *rq = static_cast<RingQueue<int> *>(args);while (true){sleep(1);// 1. 消费任务int t = 0;td->rq->Pop(&t);// 2. 处理任务 —— 处理任务时, 该任务已经被拿到线程的上下文中了, 不属于队列了std::cout << td->name << " 消费者拿到了一个数据: " << t << std::endl;}
}
int data = 1;void *productor(void *args)
{// 1. 获得任务threaddata *td = static_cast<threaddata *>(args);// RingQueue<int> *rq = static_cast<RingQueue<int> *>(args);while (true){sleep(3);std::cout << td->name << " 生产了一个任务: " << data << std::endl;// 2. 生产任务td->rq->Equeue(data);data++;}
}int main()
{// RingQueue<int> *rq = new RingQueue<int>();// // 构建生产和消费者// pthread_t c, p;// pthread_create(&c, nullptr, consumer, rq);// pthread_create(&c, nullptr, productor, rq);// pthread_join(c, nullptr);// pthread_join(p, nullptr);/////////////////////////////////////////////////////// 多生产多消费模型RingQueue<int> *rq = new RingQueue<int>();// 构建生产和消费者pthread_t c[2], p[3];threaddata *td1 = new threaddata();td1->name = "cthread-1";td1->rq = rq;pthread_create(c, nullptr, consumer, td1);threaddata *td2 = new threaddata();td2->name = "cthread-2";td2->rq = rq;pthread_create(c + 1, nullptr, productor, td2);threaddata *td3 = new threaddata();td3->name = "pthread-3";td3->rq = rq;pthread_create(p, nullptr, productor, td3);threaddata *td4 = new threaddata();td4->name = "pthread-4";td4->rq = rq;pthread_create(p + 1, nullptr, productor, td4);threaddata *td5 = new threaddata();td5->name = "pthread-5";td5->rq = rq;pthread_create(p + 2, nullptr, productor, td5);pthread_join(c[0], nullptr);pthread_join(c[1], nullptr);pthread_join(p[0], nullptr);pthread_join(p[1], nullptr);pthread_join(p[2], nullptr);return 0;
}
总结:
在多生产多消费场景中,信号量负责处理资源计数的同步(有多少空位、有多少数据),而互斥锁则负责保护索引操作的互斥(防止多个生产者同时修改in索引,或多个消费者同时修改out索引)。信号量确保资源可用性,互斥锁确保操作原子性,两者各司其职,共同构成了完整的多线程同步方案。
选择:
如果资源可以划分,就使用信号量(环形队列资源是固定的);如果资源是整体使用的,就用互斥锁(阻塞队列)。
三、线程池
1. 日志与策略模式
(1)日志
计算机中的日志是记录系统和软件运行中发生事件的文件。主要作用是监控运行状态、记录异常信息,帮助快速定位问题并支持程序员进行问题修复。他是系统维护、故障排查和安全管理的重要工具。
日志格式以下几个指标是必须得有的:时间戳、日志等级、日志内容
以下几个指标是可选的:文件名行号,进程,进程相关id信息等。
日志有现成的解决方案,如:spdlog、glog、Boost.Log、Log4cxx等等,我们依旧喜爱用自定义日志的方式。
(2)模拟实现日志
我们想要的日志格式:
[可读性很好的时间] [日志等级] [进程pid] [打印对应日志的稳件名][行号] - 消息内容,支持可变参数
// Mutex.hpp
#pragma once
#include <iostream>
#include <pthread.h>namespace MutexModule
{class Mutex{public:Mutex(){pthread_mutex_init(&_mutex, nullptr);}void Lock(){int n = pthread_mutex_lock(&_mutex);(void)n;}void Unlock(){int n = pthread_mutex_unlock(&_mutex);(void)n;}~Mutex(){pthread_mutex_destroy(&_mutex);}private:pthread_mutex_t _mutex;};class LockGuard{public:LockGuard(Mutex &mutex):_mutex(mutex){_mutex.Lock();}~LockGuard(){_mutex.Unlock();}private:Mutex &_mutex;};
}
// Log.hpp
#pragma once
#include <iostream>
// #include <pthread.h>
#include <string>
#include <cstdio>
#include <filesystem> // C++17
#include <fstream> // 文件流
#include <memory>
#include <unistd.h>
#include <strstream> // 流式的格式化控制
#include <ctime>
#include "Mutex.hpp"namespace LogMoudle
{using namespace MutexModule;const std::string gsep = "\r\n"; // 分隔符// 策略模式: 多态!// 2. 刷新策略: ① 显示器打印 ② 向指定文件打印// 刷新策略基类class LogStrategy{public:~LogStrategy() = default;virtual void SyncLog(const std::string &message) = 0; // 刷新(虚函数)};// 显示器打印日志的策略: 子类class ConsoleLogStrategy : public LogStrategy{public:ConsoleLogStrategy(){}void SyncLog(const std::string &message) override // 重写基类虚函数{LockGuard lockguard(_mutex);std::cout << message << gsep;}~ConsoleLogStrategy(){}private:Mutex _mutex;};// 文件打印日志的策略: 子类const std::string defaultpath = "./log";const std::string defaultfile = "my.log";class FileLogStrategy : public LogStrategy{public:FileLogStrategy(const std::string &path = defaultpath, const std::string &file = defaultfile): _path(path),_file(file){LockGuard lockguard(_mutex);if (std::filesystem::exists(_path)) // 判断该路径是否存在{return; // 存在}try{std::filesystem::create_directories(_path); // 路径不存在, 创建该路径}catch (const std::filesystem::filesystem_error &e){std::cerr << e.what() << '\n';}}void SyncLog(const std::string &message) override // 重写基类虚函数{LockGuard lockguard(_mutex);std::string filename = _path + (_path.back() == '/' ? "" : "/") + _file; // eg: "./log/my.log"std::ofstream out(filename, std::ios::app); // 输出文件流, 以追加写入的方式打开if (!out.is_open()){return;}// 打开成功了out << message << gsep; // 文件信息和分隔符写到文件流里面out.close(); // 关闭}~FileLogStrategy(){}private:std::string _path; // 日志文件路径std::string _file; // 日子文件本身Mutex _mutex;};// 1. 形成完整的日志 && 根据上面不同的策略,选择不同的刷新方式// (1) 形成日志等级enum class LogLevel{DEBUG, // 调试INFO, // 常规消息WARNING, // 警告ERROR, // 错误FATAL // 致命};std::string Level2Str(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";}}// 获取时间std::string GetTimeStep(){time_t curr = time(nullptr);struct tm curr_tm;localtime_r(&curr, &curr_tm); // 将时间戳转化成 struct tm (表示日期和时间的结构体)char timebuffer[128];snprintf(timebuffer, sizeof(timebuffer), "%4d-%02d-%02d %02d:%02d:%02d", curr_tm.tm_year+1900, curr_tm.tm_mon+1, curr_tm.tm_mday, curr_tm.tm_hour, curr_tm.tm_min, curr_tm.tm_sec); // 时间格式化return timebuffer;}// (2) 形成日志 && 根据不同的策略完成刷新class Logger{public:Logger(){EnableConsoleLogStrategy(); // 默认向显示器刷新}// 以文件策略刷新void EnableFileLogStrategy(){_fflush_strategy = std::make_unique<FileLogStrategy>();}// 以显示器策略进行刷新void EnableConsoleLogStrategy(){_fflush_strategy = std::make_unique<ConsoleLogStrategy>();}// 内部类: 表示的是未来的一条日志class LogMessage // 为了支持重载和可变参数!{public:LogMessage(LogLevel &level, std::string &src_name, int line_number, Logger &logger): _curr_time(GetTimeStep()),_level(level),_pid(getpid()),_src_name(src_name),_line_number(line_number),_logger(logger){// 日志的左半部分,合并起来std::stringstream ss; // string流ss << "[" << _curr_time << "]"<< "[" << Level2Str(_level) << "]"<< "[" << _pid << "]"<< "[" << _src_name << "]"<< "[" << _line_number << "]"<< "- ";_loginfo = ss.str(); // 完整信息}// 用模版支持重载template <typename T>LogMessage &operator<<(const T &info){// 日志右半部分,可变的std::stringstream ss;ss << info;_loginfo += ss.str();return *this; // 返回当前的LogMessage对象的引用, 链式调用, 方便下一次输入// 支持不定个数的参数进行日志更新}~LogMessage(){ // 析构的时候执行刷新策略if (_logger._fflush_strategy){_logger._fflush_strategy->SyncLog(_loginfo);}}private:std::string _curr_time; // 刷新时间LogLevel _level; // 日志等级pid_t _pid; // 进程std::string _src_name; // 源文件名int _line_number; // 行号std::string _loginfo; // 合并之后,一条完整的信息Logger &_logger; // 引用的是外部类的对象};// 重载// 仿函数形式: 我们想实现直接以Logger写入日志的形式LogMessage operator()(LogLevel level, std::string name, int line){return LogMessage(level, name, line, *this); // 返回的是临时对象!与<<符号进行了关联, 当函数重载<<结束才会返回临时对象!}~Logger(){}private:std::unique_ptr<LogStrategy> _fflush_strategy; // 刷新策略};// 全局日志对象Logger logger;// 使用宏, 简化用户操作, 获得文件名和行号#define LOG(level) logger(level, __FILE__, __LINE__) // 预处理符:表示的是替换完成之后的目标文件的文件名和行号// 预处理器读取 Main.cc,它内部维护着"当前文件名"和"当前行号"的计数器。// 当看到 __FILE__ 时,插入当前文件名字符串,当看到 __LINE__ 时,插入当前行号数字。#define Enable_Console_Log_Strategy() logger.EnableConsoleLogStrategy()#define Enable_File_LogStrategy() logger.EnableFileLogStrategy()
}
预处理器读取 Main.cc,它内部维护着"当前文件名"和"当前行号"的计数器。当看到 __FILE__ 时,插入当前文件名字符串,当看到 __LINE__ 时,插入当前行号数字。
#include "Log.hpp"
#include <memory>using namespace LogMoudle;int main()
{// std::unique_ptr<LogStrategy> strategy = std::make_unique<ConsoleLogStrategy>(); // C++14 智能指针,拥有其所指向对象的独占所有权。// std::unique_ptr<LogStrategy> strategy = std::make_unique<FileLogStrategy>(); // C++14 智能指针,拥有其所指向对象的独占所有权。// strategy->SyncLog("Hello log!");/////////////////////////////////////////////////////////////////////////////////////////////////// logger.EnableFileLogStrategy();// logger(LogLevel::DEBUG, "main.cc", 10) << "hello world!" << 3.14;// logger(LogLevel::DEBUG, "main.cc", 10) << "hello world!";// logger(LogLevel::DEBUG, "main.cc", 10) << "hello world!";// logger(LogLevel::DEBUG, "main.cc", 10) << "hello world!";// logger(LogLevel::DEBUG, "main.cc", 10) << "hello world!";/////////////////////////////////////////////////////////////////////////////////////////////////Enable_Console_Log_Strategy();LOG(LogLevel::DEBUG) << "hello world! " << 3.14;// 编译器看到的:// logger(LogLevel::DEBUG, "Main.cc", 25) << "hello world!";LOG(LogLevel::DEBUG) << "hello world! " << 3.14;LOG(LogLevel::DEBUG) << "hello world! " << 3.14;Enable_File_LogStrategy();LOG(LogLevel::DEBUG) << "hello world! " << 3.14;LOG(LogLevel::DEBUG) << "hello world! " << 3.14;return 0;
}
(3)策略模式
策略模式定义了一系列算法,将每个算法封装起来,使它们可以相互替换,让算法的变化独立于使用算法的客户。
工作流程:
业务代码
↓
Logger类(上下文)
↓
LogStrategy接口(策略抽象)
↓
ConsoleLogStrategy / FileLogStrategy(具体策略)
↓
控制台输出 / 文件输出(具体执行)
2. 线程池设计
(1)线程池
一种线程使用模式。线程过多会带来调度开销,进而影响缓存局部性和整体性能。而线程池维护着多个线程,等待着监督管理者分配可并发执行的任务。这避免了在处理短时间任务时创建与销毁线程的代价。线程池不仅能够保证内核的充分利用,还能防止过分调度。可用线程数量应该取决于可用的并发处理器、处理器内核、内存、网络sockets等的数量。
(2)线程池的应用场景
• 需要大量的线程来完成任务,且完成任务的时间比较短。比如WEB服务器完成网页请求这样的任务,使用线程池技术是非常合适的。因为单个任务小,而任务数量巨大,你可以想象一个热门网站的点击次数。但对于长时间的任务,比如一个Telnet连接请求,线程池的优点就不明显了。因为Telnet会话时间比线程的创建时间大多了。
• 对性能要求苛刻的应用,比如要求服务器迅速响应客户请求。
• 接受突发性的大量请求,但不至于使服务器因此产生大量线程的应用。突发性大量客户请求,在没有线程池情况下,将产生大量线程,虽然理论上大部分操作系统线程数目最大值不是问题,短时间内产生大量线程可能使内存到达极限,出现错误。
(3)线程池的种类
a. 创建固定数量线程池,循环从任务队列中获取任务对象,获取到任务对象后,执行任务对象中的任务接口
b. 浮动线程池,其他同上
此处,我们选择固定线程个数的线程池。
(4)线程池实现
// ThreadPool.hpp#pragma once
#include <iostream>
#include <string>
#include <pthread.h>
#include <vector>
#include <queue>
#include "Log.hpp"
#include "Thread.hpp"
#include "Cond.hpp"
#include "Mutex.hpp"namespace ThraedPoolMoudule
{using namespace ThreadModlue;using namespace LogMoudle;using namespace CondModule;using namespace MutexModule;static const int gnum = 5; // 缺省线程个数template <typename T>class ThreadPool{private:// 唤醒所有线程void WakeupAllThread(){LockGuard lockguard(_mutex);if (_sleepernum > 0){_cond.Broadcast();LOG(LogLevel::INFO) << "唤醒所有的休眠线程";}}void WakeUpOne(){_cond.Signal();LOG(LogLevel::INFO) << "唤醒一个休眠线程";}public:ThreadPool(int num = gnum) : _num(num), _isrunning(false), _sleepernum(0){// std::cout << "ThreadPool 构造函数调用,线程数: " << _num << std::endl;for (int i = 0; i < num; i++){_threads.emplace_back([this, i](){// std::cout << "线程 " << i << " 启动" << std::endl;HandlerTask(); // 回调执行// std::cout << "线程 " << i << " 结束" << std::endl;});LOG(LogLevel::INFO) << "create new thread success";}}// 启动所有线程void Start(){if (_isrunning)return;_isrunning = true;for (auto &thread : _threads){thread.Start();LOG(LogLevel::INFO) << "start new thread success : " << thread.Name();}}void Stop(){if (!_isrunning)return;_isrunning = false;// 唤醒所有线程WakeupAllThread();}void Join(){for (auto &thread : _threads){thread.Join();}}void HandlerTask(){char name[128];pthread_getname_np(pthread_self(), name, sizeof(name));while (true){T t;// sleep(1);// LOG(LogLevel::DEBUG) << name << " is running ";{LockGuard LockGuard(_mutex);// 1. a.队列是否为空 b.线程池没有退出-> 才去休眠!while (_taskq.empty() && _isrunning){_sleepernum++;_cond.Wait(_mutex);_sleepernum--;}// 2. 内部的线程被唤醒if (!_isrunning && _taskq.empty()){LOG(LogLevel::INFO) << name << "退出了,线程池退出&&任务队列为空";break;}// 有任务t = _taskq.front(); // 从q取任务, 任务已经是线程私有的了!_taskq.pop();}t(); // 处理任务, 不用再临界区内部}}bool Enqueue(const T &in){if (_isrunning){LockGuard lockguard(_mutex);_taskq.push(in);if (_threads.size() == _sleepernum)WakeUpOne();return true;}return false;}~ThreadPool(){}private:std::vector<Thread> _threads;int _num; // 线程池中线程个数std::queue<T> _taskq; // 任务队列Cond _cond;Mutex _mutex;bool _isrunning;int _sleepernum; // 休眠中的线程个数};
}
// Thread.hpp 线程封装#ifndef _THREAD_H_
#define _THREAD_H_#include <iostream>
#include <string>
#include <functional>
#include <pthread.h>
#include <cstring>
#include <cstdio>
#include "Log.hpp"namespace ThreadModlue
{using namespace LogMoudle;// 原子计数器 —— bugstatic uint32_t number = 1;class Thread{// 线程要执行的外部方法,我们不考虑传参,后续有std::bind来进行类间耦合using func_t = std::function<void()>;private:void EnableDetach() // 启用分离操作{// std::cout << _name << " 线程被分离了" << std::endl;_isdetach = true;}void EnableRunning(){_isrunning = true;}// Routine属于类内的成员函数,默认传参this指针// 设置static就不传this指针了static void *Routine(void *args){Thread *self = static_cast<Thread *>(args);self->EnableRunning();if (self->_isdetach)self->Detach(); // 分离线程pthread_setname_np(self->_tid, self->_name.c_str()); // 为线程设计名字self->_func(); // 回调处理return nullptr;}public:Thread(func_t func): _tid(0),_isdetach(false), // 默认不是分离状态_isrunning(false),_res(nullptr),_func(func){_name = "thread-" + std::to_string(number++);}void Detach(){if (_isdetach)return;if (_isrunning) // 运行中调用detach进行分离pthread_detach(_tid);EnableDetach(); // 如果没有运行直接对标志位置1}bool Start(){if (_isrunning)return false;int n = pthread_create(&_tid, nullptr, Routine, this); // 传入this指针实现回调机制if (n != 0){std::cerr << "Create thread error!" << std::endl;return false;}else{std::cout << _name << " create success!" << std::endl;}return true;}bool Stop(){if (_isrunning){int n = pthread_cancel(_tid);if (n != 0){// std::cerr << "Cancel thread error!" << std::endl;return false;}else{_isrunning = false;// std::cout << _name << " stop success!" << std::endl;}}return true;}void Join(){if (_isdetach){// std::cout << _name << " 线程已经是分离得了, 不能进行join!" << std::endl;return;}int n = pthread_join(_tid, &_res);if (n != 0){// std::cerr << "Join thread error!" << std::endl;LOG(LogLevel::DEBUG) << "Join线程失败!";}else{// std::cout << _name << " join success!" << std::endl;LOG(LogLevel::DEBUG) << "Join线程成功!";}}std::string Name(){return _name;}~Thread(){}private:pthread_t _tid;std::string _name;bool _isdetach;bool _isrunning;void *_res;func_t _func;};
}#endif
// Mutex.hpp#pragma once
#include <iostream>
#include <pthread.h>namespace MutexModule
{class Mutex{public:Mutex(){pthread_mutex_init(&_mutex, nullptr);}void Lock(){int n = pthread_mutex_lock(&_mutex);(void)n;}void Unlock(){int n = pthread_mutex_unlock(&_mutex);(void)n;}// 获取锁的接口pthread_mutex_t *Get(){return &_mutex;}~Mutex(){pthread_mutex_destroy(&_mutex);}private:pthread_mutex_t _mutex;};class LockGuard{public:LockGuard(Mutex &mutex):_mutex(mutex){_mutex.Lock();}~LockGuard(){_mutex.Unlock();}private:Mutex &_mutex;};
}
#pragma once
#include <iostream>
#include <pthread.h>
// Log.hpp
#pragma once
#include <iostream>
#include <string>
#include <cstdio>
#include <filesystem> // C++17
#include <fstream> // 文件流
#include <memory>
#include <unistd.h>
#include <strstream> // 流式的格式化控制
#include <ctime>
#include "Mutex.hpp"namespace LogMoudle
{using namespace MutexModule;const std::string gsep = "\r\n"; // 分隔符// 策略模式: 多态!// 2. 刷新策略: ① 显示器打印 ② 向指定文件打印// 刷新策略基类class LogStrategy{public:~LogStrategy() = default;virtual void SyncLog(const std::string &message) = 0; // 刷新(虚函数)};// 显示器打印日志的策略: 子类class ConsoleLogStrategy : public LogStrategy{public:ConsoleLogStrategy(){}void SyncLog(const std::string &message) override // 重写基类虚函数{LockGuard lockguard(_mutex);std::cout << message << gsep;}~ConsoleLogStrategy(){}private:Mutex _mutex;};// 文件打印日志的策略: 子类const std::string defaultpath = "./log";const std::string defaultfile = "my.log";class FileLogStrategy : public LogStrategy{public:FileLogStrategy(const std::string &path = defaultpath, const std::string &file = defaultfile): _path(path),_file(file){LockGuard lockguard(_mutex);if (std::filesystem::exists(_path)) // 判断该路径是否存在{return; // 存在}try{std::filesystem::create_directories(_path); // 路径不存在, 创建该路径}catch (const std::filesystem::filesystem_error &e){std::cerr << e.what() << '\n';}}void SyncLog(const std::string &message) override // 重写基类虚函数{LockGuard lockguard(_mutex);std::string filename = _path + (_path.back() == '/' ? "" : "/") + _file; // eg: "./log/my.log"std::ofstream out(filename, std::ios::app); // 输出文件流, 以追加写入的方式打开if (!out.is_open()){return;}// 打开成功了out << message << gsep; // 文件信息和分隔符写到文件流里面out.close(); // 关闭}~FileLogStrategy(){}private:std::string _path; // 日志文件路径std::string _file; // 日子文件本身Mutex _mutex;};// 1. 形成完整的日志 && 根据上面不同的策略,选择不同的刷新方式// (1) 形成日志等级enum class LogLevel{DEBUG, // 调试INFO, // 常规消息WARNING, // 警告ERROR, // 错误FATAL // 致命};std::string Level2Str(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";}}// 获取时间std::string GetTimeStep(){time_t curr = time(nullptr);struct tm curr_tm;localtime_r(&curr, &curr_tm); // 将时间戳转化成 struct tm (表示日期和时间的结构体)char timebuffer[128];snprintf(timebuffer, sizeof(timebuffer), "%4d-%02d-%02d %02d:%02d:%02d", curr_tm.tm_year+1900, curr_tm.tm_mon+1, curr_tm.tm_mday, curr_tm.tm_hour, curr_tm.tm_min, curr_tm.tm_sec); // 时间格式化return timebuffer;}// (2) 形成日志 && 根据不同的策略完成刷新class Logger{public:Logger(){EnableConsoleLogStrategy(); // 默认向显示器刷新}// 以文件策略刷新void EnableFileLogStrategy(){_fflush_strategy = std::make_unique<FileLogStrategy>();}// 以显示器策略进行刷新void EnableConsoleLogStrategy(){_fflush_strategy = std::make_unique<ConsoleLogStrategy>();}// 内部类: 表示的是未来的一条日志class LogMessage // 为了支持重载和可变参数!{public:LogMessage(LogLevel &level, std::string &src_name, int line_number, Logger &logger): _curr_time(GetTimeStep()),_level(level),_pid(getpid()),_src_name(src_name),_line_number(line_number),_logger(logger){// 日志的左半部分,合并起来std::stringstream ss; // string流ss << "[" << _curr_time << "]"<< "[" << Level2Str(_level) << "]"<< "[" << _pid << "]"<< "[" << _src_name << "]"<< "[" << _line_number << "]"<< "- ";_loginfo = ss.str(); // 完整信息}// 用模版支持重载template <typename T>LogMessage &operator<<(const T &info){// 日志右半部分,可变的std::stringstream ss;ss << info;_loginfo += ss.str();return *this; // 返回当前的LogMessage对象的引用, 链式调用, 方便下一次输入// 支持不定个数的参数进行日志更新}~LogMessage(){ // 析构的时候执行刷新策略if (_logger._fflush_strategy){_logger._fflush_strategy->SyncLog(_loginfo);}}private:std::string _curr_time; // 刷新时间LogLevel _level; // 日志等级pid_t _pid; // 进程std::string _src_name; // 源文件名int _line_number; // 行号std::string _loginfo; // 合并之后,一条完整的信息Logger &_logger; // 引用的是外部类的对象};// 重载// 仿函数形式: 我们想实现直接以Logger写入日志的形式LogMessage operator()(LogLevel level, std::string name, int line){return LogMessage(level, name, line, *this); // 返回的是临时对象!与<<符号进行了关联, 当函数重载<<结束才会返回临时对象!}~Logger(){}private:std::unique_ptr<LogStrategy> _fflush_strategy; // 刷新策略};// 全局日志对象Logger logger;// 使用宏, 简化用户操作, 获得文件名和行号#define LOG(level) logger(level, __FILE__, __LINE__) // 预处理符:表示的是替换完成之后的目标文件的文件名和行号// 预处理器读取 Main.cc,它内部维护着"当前文件名"和"当前行号"的计数器。// 当看到 __FILE__ 时,插入当前文件名字符串,当看到 __LINE__ 时,插入当前行号数字。#define Enable_Console_Log_Strategy() logger.EnableConsoleLogStrategy()#define Enable_File_LogStrategy() logger.EnableFileLogStrategy()
}
// Cond.hpp
#pragma once
// 对条件变量的封装
#include <iostream>
#include <pthread.h>
#include "Mutex.hpp"using namespace MutexModule;namespace CondModule
{class Cond{public:Cond(){pthread_cond_init(&_cond, nullptr);}void Wait(Mutex &mutex){// 释放曾经持有的锁并等待int n = pthread_cond_wait(&_cond, mutex.Get());(void)n;}// 唤醒在条件变量下等待的一个线程void Signal(){int n = pthread_cond_signal(&_cond);(void)n;}// 唤醒在条件变量下等待的所有线程void Broadcast(){int n = pthread_cond_broadcast(&_cond);}~Cond(){pthread_cond_destroy(&_cond);}private:pthread_cond_t _cond;};
}
// Task.hpp#pragma once
#include <functional>
#include <iostream>
#include <unistd.h>
#include "Log.hpp"using namespace LogMoudle;// 定义一个任务类型: 返回值void, 参数为空
using task_t = std::function<void()>;void Download()
{LOG(LogLevel::DEBUG) << "我是一个下载任务...";// sleep(3); // 假设处理任务比较耗时
}class Task
{
public:Task() {}Task(int x, int y):_x(x), _y(y) {}void Execute(){_result = _x + _y;}int X() {return _x;}int Y() {return _y;}int Result(){return _result;}~Task() {}private:int _x;int _y;int _result;
};
// Main.cc#include "Log.hpp"
#include <memory>
#include "ThreadPool.hpp"
#include "Task.hpp"using namespace LogMoudle;
using namespace ThraedPoolMoudule;int main()
{Enable_Console_Log_Strategy();ThreadPool<task_t> *tp = new ThreadPool<task_t>();tp->Start();int count = 10;while (count--){tp->Enqueue(Download);sleep(1);}tp->Stop();tp->Join();return 0;
}
$ make
g++ -o threadpool Main.cc -Wno-deprecated -std=c++17 -lpthread
$ ./threadpool
[2025-09-26 12:35:11][INFO][2762456][ThreadPool.hpp][55]- create new thread success
[2025-09-26 12:35:11][INFO][2762456][ThreadPool.hpp][55]- create new thread success
[2025-09-26 12:35:11][INFO][2762456][ThreadPool.hpp][55]- create new thread success
[2025-09-26 12:35:11][INFO][2762456][ThreadPool.hpp][55]- create new thread success
[2025-09-26 12:35:11][INFO][2762456][ThreadPool.hpp][55]- create new thread success
thread-1 create success!
[2025-09-26 12:35:11][INFO][2762456][ThreadPool.hpp][69]- start new thread success : thread-1
thread-2 create success!
[2025-09-26 12:35:11][INFO][2762456][ThreadPool.hpp][69]- start new thread success : thread-2
thread-3 create success!
[2025-09-26 12:35:11][INFO][2762456][ThreadPool.hpp][69]- start new thread success : thread-3
thread-4 create success!
[2025-09-26 12:35:11][INFO][2762456][ThreadPool.hpp][69]- start new thread success : thread-4
thread-5 create success!
[2025-09-26 12:35:11][INFO][2762456][ThreadPool.hpp][69]- start new thread success : thread-5
[2025-09-26 12:35:11][DEBUG][2762456][Task.hpp][14]- 我是一个下载任务...
[2025-09-26 12:35:12][INFO][2762456][ThreadPool.hpp][39]- 唤醒一个休眠线程
[2025-09-26 12:35:12][DEBUG][2762456][Task.hpp][14]- 我是一个下载任务...
[2025-09-26 12:35:13][INFO][2762456][ThreadPool.hpp][39]- 唤醒一个休眠线程
[2025-09-26 12:35:13][DEBUG][2762456][Task.hpp][14]- 我是一个下载任务...
[2025-09-26 12:35:14][INFO][2762456][ThreadPool.hpp][39]- 唤醒一个休眠线程
[2025-09-26 12:35:14][DEBUG][2762456][Task.hpp][14]- 我是一个下载任务...
[2025-09-26 12:35:15][INFO][2762456][ThreadPool.hpp][39]- 唤醒一个休眠线程
[2025-09-26 12:35:15][DEBUG][2762456][Task.hpp][14]- 我是一个下载任务...
[2025-09-26 12:35:16][INFO][2762456][ThreadPool.hpp][39]- 唤醒一个休眠线程
[2025-09-26 12:35:16][DEBUG][2762456][Task.hpp][14]- 我是一个下载任务...
[2025-09-26 12:35:17][INFO][2762456][ThreadPool.hpp][39]- 唤醒一个休眠线程
[2025-09-26 12:35:17][DEBUG][2762456][Task.hpp][14]- 我是一个下载任务...
[2025-09-26 12:35:18][INFO][2762456][ThreadPool.hpp][39]- 唤醒一个休眠线程
[2025-09-26 12:35:18][DEBUG][2762456][Task.hpp][14]- 我是一个下载任务...
[2025-09-26 12:35:19][INFO][2762456][ThreadPool.hpp][39]- 唤醒一个休眠线程
[2025-09-26 12:35:19][DEBUG][2762456][Task.hpp][14]- 我是一个下载任务...
[2025-09-26 12:35:20][INFO][2762456][ThreadPool.hpp][39]- 唤醒一个休眠线程
[2025-09-26 12:35:20][DEBUG][2762456][Task.hpp][14]- 我是一个下载任务...
[2025-09-26 12:35:21][INFO][2762456][ThreadPool.hpp][32]- 唤醒所有的休眠线程
[2025-09-26 12:35:21][INFO][2762456][ThreadPool.hpp][112]- thread-4退出了,线程池退出&&任务队列为空
[2025-09-26 12:35:21][INFO][2762456][ThreadPool.hpp][112]- thread-1退出了,线程池退出&&任务队列为空
[2025-09-26 12:35:21][INFO][2762456][ThreadPool.hpp][112]- thread-2退出了,线程池退出&&任务队列为空
[2025-09-26 12:35:21][INFO][2762456][ThreadPool.hpp][112]- thread-3退出了,线程池退出&&任务队列为空
[2025-09-26 12:35:21][INFO][2762456][ThreadPool.hpp][112]- thread-5退出了,线程池退出&&任务队列为空
[2025-09-26 12:35:21][DEBUG][2762456][Thread.hpp][121]- Join线程成功!
[2025-09-26 12:35:21][DEBUG][2762456][Thread.hpp][121]- Join线程成功!
[2025-09-26 12:35:21][DEBUG][2762456][Thread.hpp][121]- Join线程成功!
[2025-09-26 12:35:21][DEBUG][2762456][Thread.hpp][121]- Join线程成功!
[2025-09-26 12:35:21][DEBUG][2762456][Thread.hpp][121]- Join线程成功!
① 程序启动阶段的调用分析:
Main::main()
↓
ThreadPool构造函数
↓ 创建5个Thread对象(默认gnum=5)
↓ 每个Thread对象构造时:
- 生成唯一名称:thread-1, thread-2...
- 绑定回调函数:HandlerTask(线程池方法)
↓ 但此时线程尚未启动
② 线程池启动调用分析:
ThreadPool::Start()
↓ 遍历_threads向量
↓ 对每个Thread对象调用Start()
↓
Thread::Start()
↓ pthread_create(&_tid, nullptr, Routine, this)
↓ 创建POSIX线程,入口函数为Routine
↓
Thread::Routine(void* args) // 静态成员函数
↓ Thread* self = static_cast<Thread*>(args)
↓ self->EnableRunning() // 设置运行标志
↓ pthread_setname_np() // 设置线程名
↓ self->_func() // 执行回调函数 ← 这里指向ThreadPool::HandlerTask()
③ 任务提交调用分析:
ThreadPool::Enqueue(Download)
↓ 检查_isrunning状态
↓ 加锁保护任务队列
↓ _taskq.push(Download) // 任务入队
↓ 检查是否需要唤醒线程
↓ 如果所有线程都在休眠(_threads.size() == _sleepernum)
↓ 调用WakeUpOne()唤醒一个线程
↓ Cond::Signal() → pthread_cond_signal()
3. 线程安全的单例模式
(1)什么是单例模式?
单例模式是⼀种创建型设计模式,确保⼀个类只有⼀个实例,并提供⼀个全局访问点来获取这个实例。
核心思想:
控制实例数量:防止创建多个实例
全局访问:方便在任何地方获取实例
资源共⽤:避免重复创建消耗资源的对象
(2)饿汉实现方式和懒汉实现方式
饿汉实现方式:在程序启动时或类加载时就创建实例(提前创建),但如果实例一直未被使用,会造成资源浪费。
懒汉实现方式:只有在第一次使用时才创建实例(延迟创建),并且多线程环境下需要线程安全措施,优化服务器的启动速度。
(3)饿汉方式实现单例模式
template <typename T>
class Singleton
{static T data;public:static T *GetInstance(){return &data;}
};
(4)懒汉方式实现单例模式
template <typename T>
class Singleton
{static T *inst;public:static T *GetInstance(){if (inst == NULL){inst = new T();}return inst;}
};
该实现方式有一个严重的问题:线程安全。第一次调用GetInstance时,如果两个线程同时调用,可能会创建出两份T对象的实例。但是后续再次调用,就没有问题了。
(5)懒汉方式实现单例模式(线程安全版本)
// 懒汉模式, 线程安全
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;}
};
1. 第一次检查(无锁):快速检查实例是否已存在
2. 加锁:确保只有一个线程能进入创建阶段
3. 第二次检查(有锁):防止多个线程同时通过第一次检查
4. 创建实例:只有第一个进入的线程会执行创建
5. 释放锁:其他线程可以安全获取实例
4. 单例模式的线程池
// ThreadPool.hpp
#pragma once
#include <iostream>
#include <string>
#include <pthread.h>
#include <vector>
#include <queue>
#include "Log.hpp"
#include "Thread.hpp"
#include "Cond.hpp"
#include "Mutex.hpp"namespace ThraedPoolMoudule
{using namespace ThreadModlue;using namespace LogMoudle;using namespace CondModule;using namespace MutexModule;static const int gnum = 5; // 缺省线程个数template <typename T>class ThreadPool{private:// 唤醒所有线程void WakeupAllThread(){LockGuard lockguard(_mutex);if (_sleepernum > 0){_cond.Broadcast();LOG(LogLevel::INFO) << "唤醒所有的休眠线程";}}void WakeUpOne(){_cond.Signal();LOG(LogLevel::INFO) << "唤醒一个休眠线程";}ThreadPool(int num = gnum) : _num(num), _isrunning(false), _sleepernum(0){// std::cout << "ThreadPool 构造函数调用,线程数: " << _num << std::endl;for (int i = 0; i < num; i++){_threads.emplace_back([this, i](){// std::cout << "线程 " << i << " 启动" << std::endl;HandlerTask(); // 回调执行// std::cout << "线程 " << i << " 结束" << std::endl;});LOG(LogLevel::INFO) << "create new thread success";}}// 启动所有线程void Start(){if (_isrunning)return;_isrunning = true;for (auto &thread : _threads){thread.Start();LOG(LogLevel::INFO) << "start new thread success : " << thread.Name();}}// 单例模式: 禁用拷贝构造和赋值语句ThreadPool(const ThreadPool<T> &) = delete;ThreadPool<T> &operator=(const ThreadPool<T> &) = delete;public:// 获取当前线程的单例// 静态化, 没有对象也可以调用static ThreadPool<T> *GetInstance(){if (inc == nullptr) // 双层判断,提高获取单例的效率{LockGuard lockguard(_lock); // 线程安全版本LOG(LogLevel::DEBUG) << "获取单例...";if (inc == nullptr){ // 没有单例就创建一个单例LOG(LogLevel::DEBUG) << "首次使用单例, 创建之";inc = new ThreadPool<T>();inc->Start();}}return inc;}void Stop(){if (!_isrunning)return;_isrunning = false;// 唤醒所有线程WakeupAllThread();}void Join(){for (auto &thread : _threads){thread.Join();}}void HandlerTask(){char name[128];pthread_getname_np(pthread_self(), name, sizeof(name));while (true){T t;// sleep(1);// LOG(LogLevel::DEBUG) << name << " is running ";{LockGuard LockGuard(_mutex);// 1. a.队列是否为空 b.线程池没有退出-> 才去休眠!while (_taskq.empty() && _isrunning){_sleepernum++;_cond.Wait(_mutex);_sleepernum--;}// 2. 内部的线程被唤醒if (!_isrunning && _taskq.empty()){LOG(LogLevel::INFO) << name << "退出了,线程池退出&&任务队列为空";break;}// 有任务t = _taskq.front(); // 从q取任务, 任务已经是线程私有的了!_taskq.pop();}t(); // 处理任务, 不用再临界区内部}}bool Enqueue(const T &in){if (_isrunning){LockGuard lockguard(_mutex);_taskq.push(in);if (_threads.size() == _sleepernum)WakeUpOne();return true;}return false;}~ThreadPool(){}private:std::vector<Thread> _threads;int _num; // 线程池中线程个数std::queue<T> _taskq; // 任务队列Cond _cond;Mutex _mutex;bool _isrunning;int _sleepernum; // 休眠中的线程个数static ThreadPool<T> *inc; // 单例指针static Mutex _lock; // 保护单例的锁};template <typename T>ThreadPool<T> *ThreadPool<T>::inc = nullptr; // static变量在类外初始化template <typename T>Mutex ThreadPool<T>::_lock;
}
#include "Log.hpp"
#include <memory>
#include "ThreadPool.hpp"
#include "Task.hpp"using namespace LogMoudle;
using namespace ThraedPoolMoudule;int main()
{// 单例模式Enable_Console_Log_Strategy();int count = 10;while (count--){sleep(1);ThreadPool<task_t>::GetInstance()->Enqueue(Download);}ThreadPool<task_t>::GetInstance()->Stop();ThreadPool<task_t>::GetInstance()->Join();return 0;
}
$ make
g++ -o threadpool Main.cc -Wno-deprecated -std=c++17 -lpthread
$ ./threadpool
[2025-09-26 13:30:08][DEBUG][2780071][ThreadPool.hpp][84]- 获取单例...
[2025-09-26 13:30:08][DEBUG][2780071][ThreadPool.hpp][87]- 首次使用单例, 创建之
[2025-09-26 13:30:08][INFO][2780071][ThreadPool.hpp][54]- create new thread success
[2025-09-26 13:30:08][INFO][2780071][ThreadPool.hpp][54]- create new thread success
[2025-09-26 13:30:08][INFO][2780071][ThreadPool.hpp][54]- create new thread success
[2025-09-26 13:30:08][INFO][2780071][ThreadPool.hpp][54]- create new thread success
[2025-09-26 13:30:08][INFO][2780071][ThreadPool.hpp][54]- create new thread success
thread-1 create success!
[2025-09-26 13:30:08][INFO][2780071][ThreadPool.hpp][68]- start new thread success : thread-1
thread-2 create success!
[2025-09-26 13:30:08][INFO][2780071][ThreadPool.hpp][68]- start new thread success : thread-2
thread-3 create success!
[2025-09-26 13:30:08][INFO][2780071][ThreadPool.hpp][68]- start new thread success : thread-3
thread-4 create success!
[2025-09-26 13:30:08][INFO][2780071][ThreadPool.hpp][68]- start new thread success : thread-4
thread-5 create success!
[2025-09-26 13:30:08][INFO][2780071][ThreadPool.hpp][68]- start new thread success : thread-5
[2025-09-26 13:30:08][DEBUG][2780071][Task.hpp][14]- 我是一个下载任务...
[2025-09-26 13:30:09][INFO][2780071][ThreadPool.hpp][39]- 唤醒一个休眠线程
[2025-09-26 13:30:09][DEBUG][2780071][Task.hpp][14]- 我是一个下载任务...
[2025-09-26 13:30:10][INFO][2780071][ThreadPool.hpp][39]- 唤醒一个休眠线程
[2025-09-26 13:30:10][DEBUG][2780071][Task.hpp][14]- 我是一个下载任务...
[2025-09-26 13:30:11][INFO][2780071][ThreadPool.hpp][39]- 唤醒一个休眠线程
[2025-09-26 13:30:11][DEBUG][2780071][Task.hpp][14]- 我是一个下载任务...
[2025-09-26 13:30:12][INFO][2780071][ThreadPool.hpp][39]- 唤醒一个休眠线程
[2025-09-26 13:30:12][DEBUG][2780071][Task.hpp][14]- 我是一个下载任务...
[2025-09-26 13:30:13][INFO][2780071][ThreadPool.hpp][39]- 唤醒一个休眠线程
[2025-09-26 13:30:13][DEBUG][2780071][Task.hpp][14]- 我是一个下载任务...
[2025-09-26 13:30:14][INFO][2780071][ThreadPool.hpp][39]- 唤醒一个休眠线程
[2025-09-26 13:30:14][DEBUG][2780071][Task.hpp][14]- 我是一个下载任务...
[2025-09-26 13:30:15][INFO][2780071][ThreadPool.hpp][39]- 唤醒一个休眠线程
[2025-09-26 13:30:15][DEBUG][2780071][Task.hpp][14]- 我是一个下载任务...
[2025-09-26 13:30:16][INFO][2780071][ThreadPool.hpp][39]- 唤醒一个休眠线程
[2025-09-26 13:30:16][DEBUG][2780071][Task.hpp][14]- 我是一个下载任务...
[2025-09-26 13:30:17][INFO][2780071][ThreadPool.hpp][39]- 唤醒一个休眠线程
[2025-09-26 13:30:17][INFO][2780071][ThreadPool.hpp][32]- 唤醒所有的休眠线程
[2025-09-26 13:30:17][INFO][2780071][ThreadPool.hpp][135]- thread-5退出了,线程池退出&&任务队列为空
[2025-09-26 13:30:17][DEBUG][2780071][Task.hpp][14]- 我是一个下载任务...
[2025-09-26 13:30:17][INFO][2780071][ThreadPool.hpp][135]- thread-1退出了,线程池退出&&任务队列为空
[2025-09-26 13:30:17][INFO][2780071][ThreadPool.hpp][135]- thread-2退出了,线程池退出&&任务队列为空
[2025-09-26 13:30:17][INFO][2780071][ThreadPool.hpp][135]- thread-3退出了,线程池退出&&任务队列为空
[2025-09-26 13:30:17][DEBUG][2780071][Thread.hpp][121]- Join线程成功!
[2025-09-26 13:30:17][DEBUG][2780071][Thread.hpp][121]- Join线程成功!
[2025-09-26 13:30:17][INFO][2780071][ThreadPool.hpp][135]- thread-4退出了,线程池退出&&任务队列为空
[2025-09-26 13:30:17][DEBUG][2780071][Thread.hpp][121]- Join线程成功!
[2025-09-26 13:30:17][DEBUG][2780071][Thread.hpp][121]- Join线程成功!
[2025-09-26 13:30:17][DEBUG][2780071][Thread.hpp][121]- Join线程成功!
单例模式的关键设计:
① 私有构造函数 - 禁止外部创建实例:只能通过特定的静态方法获取实例,不能直接new ThreadPool()。
private:ThreadPool(int num = gnum) : _num(num), _isrunning(false), _sleepernum(0){// 构造函数是私有的!外部无法直接创建ThreadPool对象for (int i = 0; i < num; i++) {_threads.emplace_back([this, i](){ HandlerTask(); });}}
② 禁用拷贝构造和赋值 - 防止通过拷贝创建新实例。
// 单例模式: 禁用拷贝构造和赋值语句
ThreadPool(const ThreadPool<T> &) = delete;
ThreadPool<T> &operator=(const ThreadPool<T> &) = delete;
③ 静态实例指针 - 全局唯一实例存储,所有对象共享这一个实例。
private:static ThreadPool<T> *inc; // 单例指针// 类外初始化
template <typename T>
ThreadPool<T> *ThreadPool<T>::inc = nullptr; // 初始为空
④ 静态获取方法 - 全局访问点
静态方法,无需对象即可调用;控制实例的创建时机,第一次调用 GetInstance() 时才创建
;保证返回的都是同一个实例。
static ThreadPool<T> *GetInstance(){//...}
四、线程安全与重入问题
1. 线程安全
多个线程在访问共享资源时,能够正确地执行,不会相互干扰或破坏彼此的执行结果。
特点:
多个线程并发执行只有局部变量的代码时,不会出现不同的结果
对全局变量或静态变量进行操作时,需要适当的同步机制保护
2. 可重入
定义:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入。
重入的两种情况:
多线程重入函数:多个线程同时调用同一函数
信号导致的重入:信号处理函数中断原执行流,再次进入同一函数
可重入函数:在重入的情况下,运行结果不会出现任何不同或问题的函数
3. 分类对比
(1)线程不安全的情况
• 不保护共享变量的函数
• 函数状态随着被调用,状态发生变化的函数
• 返回指向静态变量指针的函数
• 调用线程不安全函数的函数
(2)不可重入的情况
•调用了malloc/free函数(使用全局链表管理堆)
•调用了标准I/O库函数(很多实现以不可重入方式使用全局数据结构)
•可重入函数体内使用了静态的数据结构
(3)线程安全的情况
• 每个线程对全局变量或静态变量只有读取权限,没有写入权限
• 类或接口对于线程来说都是原子操作
• 多个线程之间的切换不会导致该接口的执行结果存在二义性
(4)可重入的情况
• 不使用全局变量或静态变量
• 不使用malloc或new开辟的空间
• 不调用不可重入函数
• 不返回静态或全局数据,所有数据由函数的调用者提供
• 使用本地数据,或通过制作全局数据的本地拷贝来保护全局数据
4. 可重入与线程安全的区别与联系
(1)联系
• 函数是可重入的 → 那就是线程安全的(核心结论)
• 函数是不可重入的 → 不能由多个线程使用,可能引发线程安全问题
• 如果函数中有全局变量 → 既不是线程安全也不是可重入的
(2)区别
特性 | 线程安全 | 可重入 |
---|---|---|
范畴 | 线程安全函数的⼀种 | 更广泛的函数特性 |
关系 | 不一定是可重入的 | 一定是线程安全的 |
关注点 | 线程访问公共资源的安全 | 函数是否能被重复进入 |
典型例子 | 加锁保护的函数 | 无状态函数 |
重要区别示例:
• 如果对临界资源的访问加上锁,这个函数是线程安全的
• 但如果这个重入函数在锁还未释放时被重入,会产生死锁,因此是不可重入的
(3)注意事项
• 不考虑信号重入时:线程安全和重入在安全角度可以不做严格区分。
• 侧重点不同:
线程安全:侧重说明线程访问公共资源的安全情况,表现并发线程的特点
可重入:描述一个函数是否能被重复进入,表示函数的特点
• 实践建议:在编写多线程程序时,优先考虑使用可重入函数。
五、常见锁概念
1. 死锁
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用的不会释放的资源而处于的一种永久等待状态。
关键点:
• 占有不释放:每个线程都持有一部分资源且不释放。
• 互相申请:每个线程都在请求对方已占有的资源。
• 永久等待:没有外力干预,就一直等下去。
流程图演示:
申请一把锁是原子的,但是申请两把锁就不一定了。
造成的结果就是:线程A,B都互相申请对方的锁,但不释放自己的锁,导致死锁。
2. 死锁的四个必要条件
• 互斥条件:资源一次只能被一个线程使用。
• 持有并等待:线程已持有一些资源,同时等待其他资源。
• 不可剥夺:资源只能由持有者释放,在末使用完之前,不能被强制抢占。
• 循环等待:存在一个线程等待链,每个线程都在等待下一个线程所占有的资源。
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 同时锁定两个互斥锁// std::lock(lock1, lock2);//////////////////////////////////////////////////////////////// 现在两个互斥锁都已锁定,可以安全地访问共享资源int cnt = 10000;while (cnt){++shared_resource1;++shared_resource2;cnt--;}// 当离开 access_shared_resources 的作用域时,lock1 和 lock2 的析构函数会被自动调用// 这会导致它们各自的互斥量被⾃动解锁
}
// 模拟多线程同时访问共享资源的场景
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;
}
(1)不加锁运行:数据竞争
因为:多个线程同时执行 ++shared_resource1 和 ++shared_resource2,而++ 操作不是原子的:包含读取、计算、写入三个步骤,线程A读取值后,线程B也读取相同的值,然后分别写入,导致部分增加操作丢失。
$ g++ Main.cc
$ ./a.out # 不一次申请
Shared Resource 1: 93018
Shared Resource 2: 92687
(2)一次性加锁(正确)
std::lock(lock1, lock2) 原子性地同时获取两把锁,如果无法同时获取所有锁,会释放已获得的锁并重试。按固定顺序获取锁,避免了死锁的可能性。
# 取消加锁的注释后运行
$ g++ Main.cc
$ ./a.out # 一次申请
Shared Resource 1: 100000
Shared Resource 2: 100000
六、STL、智能指针和线程安全
1. STL中的容器是否是线程安全的?
STL的设计初衷是将性能挖掘到极致,而一旦涉及到加锁保证线程安全,会对性能造成巨大的影响。而且对于不同的容器,加锁方式不同,性能可能也不同。
序列容器(vector、list、deque):需要锁整个容器
关联容器(map、set):可能需要锁整个树结构
哈希容器(unordered_map):可以只锁单个桶(更细粒度)
因此 STL 默认不是线程安全,如果需要在多线程环境下使用,往往需要调用者自行保证线程安全。多线程环境下使用STL的正确方式:
// 1. 外部加锁
std::mutex mtx;
std::vector<int> vec;void safe_push(int value) {std::lock_guard<std::mutex> lock(mtx);vec.push_back(value);
}// 2. 每个线程使用独立的容器,最后再合并
std::vector<int> process_data() {std::vector<int> local_vec;// ... 本地操作return local_vec; // 最后合并
}
2. 智能指针是否是线程安全的?
(1)对于unique_ptr,由于只是在当前代码块范围内生效,因此不涉及线程安全问题。
// unique_ptr是线程安全的(在特定意义上)
void transfer_ownership() {std::unique_ptr<int> ptr = std::make_unique<int>(42);// 因为unique_ptr不能拷贝,所有权转移限于当前作用域// 或通过move语义明确转移,不会出现多线程同时访问
}
(2)对于shared_ptr,多个对象需要共用一个引用计数变量,所有会存在线程安全问题。但是标准库实现的时候考虑到了这个问题,基于原子操作(CAS)的方式似乎保证 share_ptr 能够高效,原子的操作引用计数。引用计数操作是线程安全的,但对象访问和同一变量写操作需要同步。
// 引用计数是原子的(安全)
std::shared_ptr<int> global_ptr;void thread_func() {// 多个线程同时操作同一个shared_ptr需要同步auto local_copy = global_ptr; // 引用计数原子增加
}
// 指向对象的数据访问不是线程安全的
std::shared_ptr<Data> data_ptr = std::make_shared<Data>();void unsafe_access() {// 即使shared_ptr本身安全,对象访问仍需同步data_ptr->modify(); // 需要额外的互斥锁保护
}
// 同一个shared_ptr实例的写操作需要同步
std::shared_ptr<Data> ptr;void race_condition() {// 多个线程同时写同一个shared_ptr变量需要同步ptr = std::make_shared<Data>(); // 需要加锁
}
// 正确使用模式
class ThreadSafeContainer {
private:std::shared_ptr<std::vector<int>> data_ptr;mutable std::mutex mtx;public:void update() {auto new_ptr = std::make_shared<std::vector<int>>();// 在新指针上准备数据...{std::lock_guard<std::mutex> lock(mtx);data_ptr = new_ptr; // 原子性替换}}std::shared_ptr<std::vector<int>> get() {std::lock_guard<std::mutex> lock(mtx);return data_ptr; // 引用计数原子增加}
};
七、其他常见的锁
• 悲观锁:在每次访问数据时,总是担心数据会被其他线程修改,所以在访问数据前先加锁,当其他线程想要访问数据时,会被阻塞挂起。
• 乐观锁:每次访问数据时,总是乐观地认为数据不会被其他线程修改,因此不上锁。但是在更新数据前,会判断其他线程在更新前有没有对数据进行修改。
• CAS操作:当需要更新数据时,判断当前内存值和之前取得的值是否相等。如果相等则用新值更新,如果不相等则操作失败,失败后一般会进行重试(自旋过程)。
• 自旋锁:线程在获取锁时,如果锁已被占用,不会立即阻塞,而是循环检查锁是否被释放(忙等待)。
• 读写锁:允许多个线程同时读,但只允许一个线程写。读锁是共享的,写锁是排他的。