MFC线程安全案例
作者:小蜗牛向前冲
名言:我可以接受失败,但我不能接受放弃
如果觉的博主的文章还不错的话,还请
点赞,收藏,关注👀支持博主。如果发现有问题的地方欢迎❀大家在评论区指正
目录
一、项目解析
二、多线程安全机制
2.1、互斥锁
2.2、临界区
三、项目实现
3.1、在Dialg资源UI控制好界界面
3.2、通过类封装 线程操作
3.3、通过消息映射机制处理点击事件
3.4、项目演示
一、项目解析
这里我们就要控制线程对临界资源的访问,通过UI界面进行测试。
二、多线程安全机制
在多线程中,当多个线程访问同一份临界资源的时候,可能会出现线程之间竞争的操作,比如有二个线程A和B,他们都要进行抢票操作, int ticket=5000,线程每次抢到一份票就进行ticket--操作。
每个线程中都有下面的判断
while (1)
{if (ticket <= 0){std::cout << "停止抢票"<<std::endl;break;}else{--ticket;}
}
当没有票的时候,就停止抢票。但是我们发现ticket会出现负数的情况。按理说是这是不可能的,为什么呢?这是因为--操作是非原子的。
原子操作(Atomic Operation)
原子操作是指一组操作在执行过程中不可被中断的操作。无论在多线程或并发环境中,原子操作都是一个不可分割的单位,即使在操作执行过程中,其他线程也无法干扰该操作。这保证了操作的完整性和一致性。
非原子操作(Non-Atomic Operation)
非原子操作是指在执行过程中可能会被其他线程或进程中断的操作。在多线程或并发环境下,非原子操作可能会导致数据竞争、冲突或不一致的结果。
那为什么会出现负数呢?
首先,我们来分析一下非原子操作:
--ticket; // 非原子操作
这个操作实际上包含了三个步骤:
- 读取
ticket
的当前值。 - 对该值进行减 1 操作。
- 将新的值写回
ticket
。
在多线程环境中,当多个线程(比如线程A和线程B)同时执行这段代码时,可能会出现以下情况:
场景示例:
假设 ticket
的值为 1,线程 A 和线程 B 同时开始抢票。
- 线程A 读取了
ticket
的值为1,刚刚执行完--操作,但是还没有来的急,将信息写会回内存变量,OS(操作系统就调度线程B进行抢票) - 线程B此时发现
ticket
的值为1,可以进行抢票,线程B也执行了--操作。 - 这个时候线程A的时间片到了,继续执行后面的操作把结果写会变量为0
- 这个线程B继续往后执行,也讲--的结果写会,最终ticketw为服数
这样,ticket
同一份资源被用了二个线程同时使用,造成了资源不一致,当多个线程都在进行类似的操作时,ticket
的值有可能被多次修改,最终可能导致 ticket
为负数。
为了解决在多线程中,那些线程不安全的问题,我们要使用下面的安全操作
2.1、互斥锁
互斥锁(Mutex,全称 Mutual Exclusion Lock)是一种用于 多线程同步 的机制,主要用于保护共享资源,确保同一时刻只有一个线程能够访问共享数据,从而避免数据竞争和不一致性。互斥锁通过加锁和解锁操作来控制对临界区的访问。
C++11 引入了 std::mutex
类来提供互斥锁的功能。它位于 <mutex>
头文件中。
#include <iostream>
#include <thread>
#include <mutex>std::mutex mtx; // 创建一个互斥锁void printHello() {mtx.lock(); // 加锁std::cout << "Hello from thread!" << std::endl;mtx.unlock(); // 解锁
}int main() {std::thread t1(printHello);std::thread t2(printHello);t1.join();t2.join();return 0;
}
使用 std::lock_guard
自动管理锁
std::lock_guard
是一个 RAII(Resource Acquisition Is Initialization)类,它用于自动加锁和解锁。std::lock_guard
在创建时会加锁,在销毁时会自动解锁。这种方式更安全,避免了手动解锁时可能出现的错误(例如忘记解锁
#include <iostream>
#include <thread>
#include <mutex>std::mutex mtx;void printHello() {std::lock_guard<std::mutex> lock(mtx); // 自动加锁std::cout << "Hello from thread!" << std::endl;
} // 自动解锁int main() {std::thread t1(printHello);std::thread t2(printHello);t1.join();t2.join();return 0;
}
std::unique_lock
:更灵活的锁管理
std::unique_lock
是另一种锁管理类,它提供了更多的功能,如可以手动解锁、延迟加锁、以及可以在多个锁上进行组合。
#include <iostream>
#include <thread>
#include <mutex>std::mutex mtx;void printHello() {std::unique_lock<std::mutex> lock(mtx); // 加锁std::cout << "Hello from thread!" << std::endl;// lock 会在作用域结束时自动解锁
}int main() {std::thread t1(printHello);std::thread t2(printHello);t1.join();t2.join();return 0;
}
锁的死锁(Deadlock)
死锁是指两个或多个线程因相互等待而无法继续执行的情况。例如,线程 A 持有锁 1,等待锁 2;线程 B 持有锁 2,等待锁 1。这样,两个线程相互等待对方释放锁,导致程序进入死锁状态。
2.2、临界区
临界区是指一段在多线程程序中访问共享资源的代码区域,多个线程必须互斥地访问这个区域,以避免出现数据竞争和资源冲突。简单来说,临界区是多个线程共享的数据被操作的区域,这些数据需要保护起来,以确保在任何时刻只有一个线程能够访问和修改这些共享数据。
Windows 操作系统中,创建和使用临界区的 API 提供了多线程同步的机制。Windows 提供了 CRITICAL_SECTION
类型,它是操作系统用于线程同步的基本工具。CRITICAL_SECTION
用于保护共享资源,确保同一时刻只有一个线程能够进入临界区。
1. 初始化临界区
void InitializeCriticalSection(LPCRITICAL_SECTION lpCriticalSection);
lpCriticalSection:指向一个 CRITICAL_SECTION
结构的指针,系统会根据这个结构初始化临界区。
2. 删除临界区
当不再需要临界区时,应该调用 DeleteCriticalSection
来销毁它。这样可以释放与临界区相关的资源。
void DeleteCriticalSection(LPCRITICAL_SECTION lpCriticalSection);
3. 加锁临界区
使用 EnterCriticalSection
函数来加锁临界区。当一个线程试图访问临界区时,它必须首先请求锁。如果锁已经被其他线程持有,则该线程将会被阻塞,直到锁变得可用。
void EnterCriticalSection(LPCRITICAL_SECTION lpCriticalSection);
4. 解锁临界区
使用 LeaveCriticalSection
来解锁临界区,这样其他线程可以访问临界区中的资源。
void LeaveCriticalSection(LPCRITICAL_SECTION lpCriticalSection);
要使用临界资源,我们要先定义应该临界区,比如: CRITICAL_SECTION m_critical_section对象
,然后调用相应的函数就好了。如EnterCriticalSection(&m_CriticalSection);
2.3、条件变量
在了解条件变量时,我们先了解这样的场景。在多线程中,一个线程负责消费,一个线程负责生产资源。但是这里会存在一个问题。消费的线程怎么知道,还有没有资源给他消息。生产资源的线程,怎么知道资源是不是生产足够了。为了解决这个问题,引入了条件变量(本质就是多线程间的通信)。
条件变量:它让线程可以在某些特定的条件下被“挂起”(阻塞),并在条件发生变化时被其他线程“唤醒”或“通知”继续执行。
条件变量,通常与互斥锁(std::mutex
)配合使用,用于保护共享资源并避免数据竞争。
C++ 标准库提供了 std::condition_variable
类来实现这一机制。下面是一个简单的示例,展示了如何使用条件变量来实现线程之间的同步。
.h文件
#pragma once#include<iostream>
#include<queue>
#include<thread>
#include<condition_variable>
#include<mutex>namespace pjb
{class ProductConsumer{public:void producter();//生产者void consumer();//消费者private:std::queue<int> m_qdataQueue;std::mutex m_mutex;std::condition_variable m_cv;};
}
.cpp文件
#include"thread_scure.h"void pjb::ProductConsumer::producter()
{//生产者生产数据,生产好了,才会通知消费者去消费for (int i = 0; i < 5; ++i){//用休眠,模拟生产过程std::this_thread::sleep_for(std::chrono::seconds(1));{//这个区间要进行加锁保护std::lock_guard<std::mutex> lock(m_mutex);m_qdataQueue.push(i);//生产数据std::cout << "data: " << i << std::endl;}//数据生产好了,就可以通知消费者,进行消费了m_cv.notify_all();//通知消费者}
}void pjb::ProductConsumer::consumer()
{while (true){//加锁//注意我们这里加锁使用的是unique_lock,因为wait需要//wait() 会在等待时释放互斥锁,并且能够在被唤醒后重新获得锁。std::unique_lock<std::mutex> lock(m_mutex);//设置一个条件变量,有数据就消费,没有数据就阻塞等待//当wait的第二个参数返回true才会继续执行m_cv.wait(lock, [this] {return !m_qdataQueue.empty();});//消费数据int data = m_qdataQueue.front();m_qdataQueue.pop();std::cout << "Consumed: " << data << std::endl;if (m_qdataQueue.empty()){break; // 如果队列为空,则退出}}
}
mian.cpp文件
#include"thread_scure.h"int main()
{pjb::ProductConsumer pc;// 启动生产者和消费者线程std::thread prod(&pjb::ProductConsumer::producter, &pc);std::thread cons(&pjb::ProductConsumer::consumer, &pc);// 等待线程完成prod.join();cons.join();return 0;
}
1、在这里例子中,我定义了m_mutex锁和m_cv的条件变量
2、在生产者线程中,我们让他进行生产,将成产的数据写入到 队列中 m_qdataQueue
3、在消费者线程中,我们让他通过条件变量 m_cv.wait(lock, [this] {return !m_qdataQueue.empty();});去等待资源,如果队里不为空就往下执行,否则阻塞等待资源。
4、通过上面的例子,我们就能使用条件变量可以在线程间协调协作,使得线程可以根据某些条件的改变而被唤醒或等待。
运用场景
1. 生产者-消费者问题
这是使用条件变量的经典场景,生产者线程负责生产数据并放入队列,消费者线程负责从队列中取数据进行消费。消费者线程在队列为空时需要等待,直到生产者线程将数据放入队列中并通知消费者线程继续工作。
示例:
- 生产者线程生产数据,放入队列。
- 消费者线程等待队列非空条件,直到生产者通知它有数据可以消费。
2. 线程池中的任务调度
在多线程编程中,线程池通常用来管理并发任务。条件变量可以用于控制线程池中的线程何时开始执行任务以及何时停止。线程池中的工作线程会等待条件变量通知,直到任务队列中有新的任务时,它们才会开始执行。
示例:
- 线程池中的线程在没有任务时等待。
- 主线程将任务加入任务队列,并通过条件变量通知等待的线程开始工作。
3. 读写锁模式
在某些场景中,多个线程需要读取共享数据,少数线程需要写入数据。在这种情况下,条件变量可以用于实现线程之间的协调,允许多个线程同时读取共享数据,而在有线程写入时,阻塞其他线程的读取操作,直到写入完成。
示例:
- 读者线程等待写者线程完成写入操作。
- 写者线程完成写入后,通过条件变量通知等待的读者线程继续读取。
4. 限制并发数
有时需要限制并发执行的线程数,例如限制最大并发的数据库查询线程数。在这种情况下,条件变量可以用于协调多个线程的启动和停止,确保不会超过最大并发数。
示例:
- 启动多个线程执行任务,但限制同时执行的最大线程数。
- 如果超过最大线程数,新的线程等待直到有线程完成并释放资源。
5. 定时任务或超时等待
条件变量还可以用于处理定时任务或设置超时等待。例如,一个线程等待某个条件的满足,但如果条件在一定时间内未满足,则超时退出。条件变量与
std::chrono
配合使用可以实现这一功能。示例:
- 线程在等待某个条件时设置超时时间,如果超时则执行其他操作。
- 线程等待指定时间,直到收到条件变量的通知。
6. 事件通知
在某些场景中,一个线程可能会等待另一个线程的某个事件发生,典型的例子如状态机或事件驱动的程序。在这种情况下,条件变量用于实现线程间的通知机制。
示例:
- 主线程等待子线程完成某个操作(如处理某个事件或任务)。
- 一旦子线程完成,主线程通过条件变量通知其继续执行。
2.4、信号量
信号量是一种用于进程间或线程间同步的机制。它是一种计数器,通常用于控制访问共享资源的线程数量
号量的主要作用就是 控制线程对临界资源的访问,并通过限制线程数量来 同步并发访问。它允许多个线程按一定规则同时访问有限数量的资源,或者确保只有一定数量的线程可以同时访问共享资源,从而避免数据竞争或资源冲突。
在C++11中是没有信号量这个概念的,但是我们可以通过互斥锁mutex和条件变量conitation_variable来实现。
下面是信号量的简单模拟实现
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>class Semaphore {
public:explicit Semaphore(int count = 0) : count(count) {}void wait() {std::unique_lock<std::mutex> lock(mtx);// 等待直到 count 大于 0cond.wait(lock, [this]() { return count > 0; });--count;}void signal() {std::lock_guard<std::mutex> lock(mtx);++count;cond.notify_one(); // 唤醒一个等待的线程}private:int count;std::mutex mtx;std::condition_variable cond;
};void worker(int id, Semaphore& sem) {sem.wait(); // 等待信号量std::cout << "Thread " << id << " is working\n";std::this_thread::sleep_for(std::chrono::seconds(1)); // 模拟工作std::cout << "Thread " << id << " is done\n";sem.signal(); // 完成后释放信号量
}int main() {Semaphore sem(2); // 最多允许 2 个线程同时执行std::thread t1(worker, 1, std::ref(sem));std::thread t2(worker, 2, std::ref(sem));std::thread t3(worker, 3, std::ref(sem));std::thread t4(worker, 4, std::ref(sem));t1.join();t2.join();t3.join();t4.join();return 0;
}
信号量通常有两个操作:
- P 操作(Wait/Decrement):将信号量的值减 1,如果信号量的值为 0,则调用线程会被阻塞,直到信号量大于 0。
- V 操作(Signal/Increment):将信号量的值加 1,可能会唤醒一个等待的线程。
三、项目实现
为了实现这个项目,我们通过MFC创建应该基于对话框的项目。
3.1、在Dialg资源UI控制好界界面
这里要注意为对话框添加变量名,因为我要显示线程运行的数据在上面。
这里我们在对话框的.h头文件中可以看到
3.2、通过类封装 线程操作
ThreadMagager.h在这个头文件中实现
#pragma once#include<functional>
#include<thread>
#include<atomic>
#include <windows.h>
#include <memory>
namespace pjb
{class ThreadManager{public:ThreadManager();~ThreadManager();void StartThread(int step, unsigned int interval, std::function<void(int)> callback);void StopThread();bool IsRunning()const { return m_Running; };private:std::thread m_Thread;//std::unique_ptr<std::thread> m_Thread;std::atomic<bool> m_Running;//创建原子对象,保证线程安全int m_iCount;//计数器int m_iStep;//步长unsigned int m_uiInterval;//时间间隔std::function<void(int)> m_UpdateCallback;//更新回调函数CRITICAL_SECTION m_CriticalSection; // 临界区,用于线程同步void _ThreadProc();};
}
在 ThreadMagager.cpp中实现细节操作
#include "pch.h"//这个头我文件记得包含
#include"ThreadMagager.h"
#include<chrono>// 构造函数:初始化成员变量和临界区
pjb::ThreadManager::ThreadManager(): m_Running(false), m_iCount(0), m_iStep(0), m_uiInterval(0)
{InitializeCriticalSection(&m_CriticalSection); // 初始化临界区
}// 析构函数:停止线程并销毁临界区
pjb::ThreadManager::~ThreadManager()
{StopThread();DeleteCriticalSection(&m_CriticalSection); // 销毁临界区
}
void pjb::ThreadManager::StartThread(int step, unsigned int interval, std::function<void(int)> callback)
{//判断线程是否已经启动if (m_Running) return;//设置线程的参数m_iStep = step;m_uiInterval = interval;m_UpdateCallback = callback;m_Running = true;//启动线程// 使用智能指针来管理资源,防止内存泄漏//m_Thread = std::make_unique<std::thread>(&ThreadManager::ThreadProc, this);//std::thread t(function, args...);的参数//function为可调用对象,将会做为线程的主体函数。传this指针,是为了告诉线程在哪个 ThreadManager 对象上调用这个成员函数m_Thread = std::thread(&pjb::ThreadManager::_ThreadProc, this);}
void pjb::ThreadManager::StopThread()
{if (!m_Running)return;m_Running = false;//判断线程是否能被join()。if (m_Thread.joinable()){//m_Thread.join();//等待线程完成,这种方式会阻塞UI线程,导致用户无法操作m_Thread.detach(); // 分离线程,让它在后台执行,UI线程可以继续操作,当程序结束后,操作系统会回收资源}
}
//线程处理
void pjb::ThreadManager::_ThreadProc()
{while (m_Running){// 使用临界区保护共享资源EnterCriticalSection(&m_CriticalSection);// 检查计数器是否已经达到上限if (m_iCount >= 10000){m_Running = false; // 停止线程LeaveCriticalSection(&m_CriticalSection); // 离开临界区break; // 退出循环}//更新计数器m_iCount += m_iStep;LeaveCriticalSection(&m_CriticalSection); // 离开临界区//调用回调函数,更新计数值if (m_UpdateCallback){m_UpdateCallback(m_iCount);}//休眠指定时间,然后继续进行更新std::this_thread::sleep_for(std::chrono::milliseconds(m_uiInterval));//毫秒}
}
这里我们在 线程处理函数_ThreadProc()中创建临界区,限制了访问临界资源时候只运行一个线程访问。
3.3、通过消息映射机制处理点击事件
对于对话框的头文件没有什么好说的,大家看一下就可以了。
// ThreadOperateDlg.h: 头文件
//#pragma once
#include"ThreadMagager.h"// CThreadOperateDlg 对话框
class CThreadOperateDlg : public CDialogEx
{
// 构造
public:CThreadOperateDlg(CWnd* pParent = nullptr); // 标准构造函数// 对话框数据
#ifdef AFX_DESIGN_TIMEenum { IDD = IDD_THREADOPERATE_DIALOG };
#endifprotected:virtual void DoDataExchange(CDataExchange* pDX); // DDX/DDV 支持// 实现
protected:HICON m_hIcon;// 生成的消息映射函数virtual BOOL OnInitDialog();afx_msg void OnSysCommand(UINT nID, LPARAM lParam);afx_msg void OnPaint();afx_msg HCURSOR OnQueryDragIcon();DECLARE_MESSAGE_MAP()
private:pjb::ThreadManager m_Thread1;pjb::ThreadManager m_Thread2;CEdit m_show1;CEdit m_show2;
public:afx_msg void OnBnClickedButton1();afx_msg void OnBnClickedButton2();
在对话框.cpp文件中处理点击事情,就是通过前面我们封装好的接口去处理即可
//线程启动
void CThreadOperateDlg::OnBnClickedButton1()
{// TODO: 在此添加控件通知处理程序代码//启动线程//情况2使用共享计数变量:m_Thread1.StartThread(1, 10, [this](int value) {CString str;//格式转换,将整形转换为字符串str.Format(_T("%d"), value);GetDlgItem(IDC_EDIT1)->SetWindowTextW(str);if (_ttoi(str) >= 10000){//将启动线程的按键禁用GetDlgItem(IDC_BUTTON1)->EnableWindow(FALSE);}});//线程2m_Thread2.StartThread(2, 20, [this](int value) {CString str;//格式转换,将整形转换为字符串str.Format(_T("%d"), value);GetDlgItem(IDC_EDIT2)->SetWindowTextW(str);if (_ttoi(str) >= 10000){//将启动线程的按键禁用GetDlgItem(IDC_BUTTON1)->EnableWindow(FALSE);}});}void CThreadOperateDlg::OnBnClickedButton2()
{// TODO: 在此添加控件通知处理程序代码m_Thread1.StopThread();m_Thread2.StopThread();
}
3.4、项目演示
这里我们点击启动线程,就可以观察到对话框的变化。