赞
踩
在现代软件开发中,多线程编程已成为提高程序性能和资源利用率的关键技术。然而,多线程环境下的线程同步问题一直是开发者需要面对的挑战。本文旨在深入探讨多线程同步的基本概念、机制和实际应用,帮助读者理解并掌握如何在多线程环境中实现有效的线程间协作。
本文首先介绍了同步的基本概念,解释了同步在多线程编程中的重要性。随后,详细讨论了条件变量这一实现线程间同步的重要工具,并通过一个生动的果农与猴子的比喻,形象地说明了条件变量的工作原理和应用场景。接着,文章通过实际的测试代码示例,展示了如何使用条件变量和互斥锁来实现线程间的同步。
文章进一步探讨了生产消费模型、线程池、可重入与线程安全、死锁等多线程编程中的核心问题,并提供了相应的解决方案和最佳实践。特别地,对于线程安全的单例模式、自旋与阻塞挂起、以及读者写者问题等高级主题,文章不仅提供了深入的理论分析,还给出了具体的代码实现和应用示例。
同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步。
举个例子:
场景设定:
- 盘子:用来存放苹果的容器,初始时盘子里没有苹果。
- 果农:负责往盘子里放苹果的人。
- 猴子:等待盘子里有苹果后,才会从盘子里拿苹果吃。
- 铃铛:一个信号装置,果农放完苹果后会摇铃铛,以此通知猴子
行为流程:
- 猴子等待: 猴子检查盘子里的苹果数量。 如果盘子里没有苹果,猴子就等待。
- 果农放苹果: 果农检查盘子里的苹果数量。 如果盘子里的苹果少于设定的数量(比如5个),果农就往盘子里放一些苹果。 放完苹果后,果农摇铃,发出声音信号。
- 猴子被唤醒: 猴子听到铃铛声后,知道盘子里已经有苹果了。 猴子再次检查盘子里的苹果数量,确认后开始吃苹果。
- 猴子吃苹果: 猴子从盘子里拿一个苹果吃。 吃完后,猴子继续等待,重复之前的检查和等待过程。
条件变量的应用:
在编程实现中,铃铛可以类比为条件变量的通知机制。当果农(一个线程)放完苹果并摇铃铛后,条件变量会被触发,这相当于通知所有等待的猴子(其他线程)可以开始执行它们的任务(吃苹果)。
使用条件变量的好处是,猴子线程不需要不断地检查盘子里是否有苹果,这会浪费CPU资源。相反,猴子线程可以在条件不满足时挂起,直到被果农线程通过摇铃铛(条件变量通知)唤醒。
在实际的多线程编程中,我们会使用互斥锁来保护对盘子里苹果数量的访问,并使用条件变量来同步果农和猴子的行为。当果农放苹果时,它会锁定互斥锁,更新苹果数量,然后摇铃铛(使用条件变量通知等待的猴子)。猴子在等待时会锁定互斥锁,检查条件,如果不满足就挂起等待,直到听到铃铛声(被通知)后再次检查条件并执行任务。
条件变量 = 铃铛 + 队列
pthread_cond_t
:
pthread_cond_t cond = PTHREAD_COND_INITIALIZER; // 全局,静态
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrictattr);
参数:
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); // 唤醒所在cond等待的线程
int pthread_cond_signal(pthread_cond_t *cond); // 唤醒一个线程
#include <iostream> #include <string> #include <vector> #include <pthread.h> #include <unistd.h> pthread_cond_t gcond = PTHREAD_COND_INITIALIZER; // 条件变量 pthread_mutex_t gmutex = PTHREAD_MUTEX_INITIALIZER; // 互斥锁 void* SlaverCore(void* args) { std::string name = static_cast<const char*>(args); while (true) { // 1. 加锁 pthread_mutex_lock(&gmutex); // 2. 一般条件变量是在加锁和解锁之间使用 pthread_cond_wait(&gcond, &gmutex); // gmutex:这个是,用来被释放的! std::cout << "当前被叫醒的线程是:" << name << std::endl; // 3. 解锁 pthread_mutex_unlock(&gmutex); } } void* MasterCore(void* args) { sleep(3); std::cout << "master 开始工作了" << std::endl; std::string name = static_cast<const char*>(args); while (true) { pthread_cond_signal(&gcond); // 唤醒其中一个队列首部的线程 // pthread_cond_broadcast(&gcond); // 唤醒队列中所有的 线程 sleep(1); std::cout << "master 唤醒一个线程" << std::endl; } } void StartMaster(std::vector<pthread_t> *tidsptr) { pthread_t tid; int n = pthread_create(&tid, nullptr, MasterCore, (void *)"Master Thread"); if (n == 0) { std::cout << "create master success" << std::endl; } tidsptr->emplace_back(tid); } void StartSlaver(std::vector<pthread_t> *tidsptr, int threadnum = 3) { for (int i = 0; i < threadnum; ++i) { char *name = new char[64]; snprintf(name, 64, "slaver-%d", i + 1); // thread-1 pthread_t tid; int n = pthread_create(&tid, nullptr, SlaverCore, name); if (n == 0) { std::cout << "create success" << name << std::endl; tidsptr->emplace_back(tid); } } } void WaitThread(std::vector<pthread_t>& tids) { for (auto &tid : tids) { pthread_join(tid, nullptr); } } int main() { std::vector<pthread_t> tids; StartMaster(&tids); StartSlaver(&tids, 5); WaitThread(tids); return 0; }
讨论:并发数据的传递问题。
我们可以将超市的仓库看作是缓冲区,超市的供应商(生产者)负责提供货物(生产数据),而顾客(消费者)则负责购买这些货物(消费数据)。以下是这个模型在超市中的具体应用:
缓冲区(仓库):超市的仓库是一个有限的空间,用来存储供应商送来的货物。
生产者(供应商):供应商定期向超市仓库提供货物,比如新鲜的水果、蔬菜、肉类等。
消费者(顾客):顾客到超市来购买他们需要的商品。
同步机制:为了防止生产者在仓库已满时继续生产货物,或者消费者在仓库为空时尝试购买商品,需要有一套同步机制来控制生产者和消费者的行为。在超市中,这可以通过库存管理系统来实现,确保货物的供应和销售是协调的。
互斥锁:当一个供应商正在向仓库添加货物时,其他供应商需要等待,直到当前供应商完成添加。同样,当一个顾客正在购买商品时,其他顾客需要等待,直到该顾客完成购买。
条件变量:当仓库为空时,消费者(顾客)可以等待,直到生产者(供应商)提供新的货物。相反,当仓库已满时,生产者(供应商)可以等待,直到消费者(顾客)购买一些商品,为新货物腾出空间。
死锁避免:超市需要确保不会发生死锁,即供应商和顾客都在等待对方采取行动,导致双方都无法前进。这可以通过合理的库存管理和顾客流控制来避免。
超市:共享资源——临界资源
厂商s, 用户s: 多个线程
超市是什么?临时保存数据的"内存空间"——某种数据结构对象。是数据"交易"的场所。
商品是什么?数据
并发问题:
生产者 vs 生产者 —— 互斥
消费者 vs 消费者 —— 互斥
生产者 vs 消费者 —— 互斥 && 同步
“321”: 3种关系,2种角色,1个交易场所
为什么?生产消费模型,可以提供比较好的并发度
使用生产和消费数据,进行解耦
支持忙闲不均
BlockingQueue
在多线程编程中阻塞队列(Blocking Queue)是一种常用于实现生产者和消费者模型的数据结构。其与普通的队列区别在于,当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中被放入了元素;当队列满时,往队列里存放元素的操作也会被阻塞,直到有元素被从队列中取出(以上的操作都是基于不同的线程来说的,线程在对阻塞队列进程操作时会被阻塞)
基于环形队列的生产消费模型 :
p c index 是同一个位置:
a. 队列满 :访问临界资源,让消费者跑。
b.队列空:访问临界资源,生产者先跑。
生产者不能把消费者套一个圈,消费者不能超过生产者!
p c index 不是同一个位置:一定不为空 && 一定不为满
生产和消费动作,可以真正的并发吗? 可以的
怎么做到呢? 信号量
sem_t room(10); // 空间信号量
sem_t data(0); // 资源信号量
生产者:
P(room); // 申请空间资源(空间信号量--)
// 信号量申请成功
ringbuffer[p_index] = data;
p_index++;
p_index %= 10;
V(data); // 资源信号量++
消费者:
P(data); // 申请数据资源
// 信号量申请成功——数据资源一定有
out = ringbuffer[c_index];
c_index++;
c_index %= 10;
V(room);
信号量接口:
#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);
int sem_destroy(sem_t *sem);
int sem_wait(sem_t *sem); // P操作
int sem_post(sem_t *sem); // V操作
如果是多生产者,多消费者呢?
解决方法:加锁。
加几把锁? 2把
如何加锁?
#include <iostream> #include <string> #include <vector> #include <semaphore.h> #include <pthread.h> template<typename T> class RingQueue { private: void P(sem_t &sem) { sem_wait(&sem); } void V(sem_t &sem) { sem_post(&sem); } void Lock(pthread_mutex_t &mutex) { pthread_mutex_lock(&mutex); } void Unlock(pthread_mutex_t &mutex) { pthread_mutex_unlock(&mutex); } public: RingQueue(int cap): _ring_queue(cap), _cap(cap), _productor_step(0), _consumer_step(0) { sem_init(&_room_sem, 0, _cap); sem_init(&_data_sem, 0, 0); pthread_mutex_init(&_productor_mutex, nullptr); pthread_mutex_init(&_consumer_mutex, nullptr); } void Enqueue(const T &in) { // 生产行为 P(_room_sem); Lock(_productor_mutex); // 一定有空间!!! _ring_queue[_productor_step++] = in; // 生产 _productor_step %= _cap; Unlock(_productor_mutex); V(_data_sem); } void Pop(T *out) { // 消费行为 P(_data_sem); Lock(_consumer_mutex); *out = _ring_queue[_consumer_step++]; _consumer_step %= _cap; Unlock(_consumer_mutex); V(_room_sem); } ~RingQueue() { sem_destroy(&_room_sem); sem_destroy(&_data_sem); 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_t _room_sem; // 生产者关心 sem_t _data_sem; // 消费者关心 // 4. 定义锁,维护多生产多消费之间的互斥关系 pthread_mutex_t _productor_mutex; pthread_mutex_t _consumer_mutex; };
一种线程使用模式。线程过多会带来调度开销,进而影响缓存局部性和整体性能。而线程池维护着多个线程,等待着监督管理者分配可并发执行的任务。这避免了在处理短时间任务时创建与销毁线程的代价。线程池不仅能够保证内核的充分利用,还能防止过分调度。可用线程数量应该取决于可用的并发处理器、处理器内核、内存、网络sockets等的数量。
死锁是多线程把锁不合理的使用, 导致代码不会继续向后推进的情况。
有时候访问一块临界资源的时候需要同时持有两把锁才能访问,但是有两把锁互相持有一把锁,它们两互相申请对方的锁,就造成死锁了!
死锁的四个必要条件:
解决、避免死锁条件必定是破坏4个条件之一。建议大家如果申请多把锁,每个线程申请锁的顺序一致。
不是,
原因是,STL的设计初衷是将性能挖掘打极致,而一旦涉及到加锁保证线程安全,会对性能造成巨大的影响。且对不同的容器,加锁的方式不同性能也可能不同,性能可能也不同(例如hash表的锁表和锁桶)。
因此 STL 默认不是线程安全,如果需要在多线程环境下使用,往往需要调用者自行保证线程安全。
对于 unique_ptr
, 由于只是当前代码块范围内生效,因此不涉及线程安全问题。
对于shared_ptr
, 多个对象需要共用一个引用计数变量,所以会存在线程安全问题,但是标准库实现的时候考虑了这个问题,基于原子操作(CAS)的方式保证 shared_ptr
能够高效,原子的操作引用计数。
单例模式是一种“经典的,常用的,常考的” 设计模式。
IT行业这么火, 涌入的人很多. 俗话说林子大了啥鸟都有. 大佬和菜鸡们两极分化的越来越严重. 为了让菜鸡们不太拖大佬的后腿, 于是大佬们针对一些经典的常见的场景, 给定了一些对应的解决方案, 这个就是设计模式。
某些类, 只应该具有一个对象(实例), 就称之为单例。
例如一个男人只能有一个媳妇。
在很多服务器开发场景中,经常需要让服务器加载很多的数据 (上百G) 到内存中。此时往往要用一个单例的类来管理这些数据。
[洗完的例子]
吃完饭, 立刻洗碗, 这种就是饿汉方式. 因为下一顿吃的时候可以立刻拿着碗就能吃饭.吃完饭, 先把碗放下, 然后下一顿饭用到这个碗了再洗碗,就是懒汉方式.
懒汉方式最核心的思想是 “延时加载”. 从而能够优化服务器的启动速度.
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
关键字防止过度优化无论是是自旋,还是挂起等待,都是等待检测就绪的策略!
#include <pthread.h>
int pthread_spin_lock(pthread_spinlock_t *lock);
int pthread_spin_trylock(pthread_spinlock_t *lock);
int pthread_spin_unlock(pthread_spinlock_t *lock);
int pthread_spin_init(pthread_spinlock_t *lock, int pshared);
int pthread_spin_destroy(pthread_spinlock_t *lock);
例子:csdn发文章,印刷报纸,杂志,出黑板报…
读者众多,写者较少——读者问题最常见的情况。
有线程向公共资源写入,其他线程从公共资源中读数据——读者问题。
读者 vs 消费者 区别?
消费者:会把数据拿走!
读者:不会,只会拷贝!
原理上,我们得理解一下——伪代码——模拟实现一下读者的加锁逻辑
int reader_count = 0;
pthread_mutex_t wlock;
pthread_mutex_t rlock;
读者:
lock(&rlock);
if (reader_count == 0)
lock(&wlock); // 申请成功:继续运行,不会有任何读者进来!
// 申请失败:阻塞
++reader _count;
unlock(&rlock);
// 可以常规的read了
lock(&rlock);
--reader_count;
if(reader_count == 0)
unlock(&wlock);
unlock(&rlock);
写者:
lock(&wlock);
// 写入操作
unlock(&wlock);
pthread_rwlock_t
:默认就是读者优先的,会有写者饥饿问题。
本文全面而深入地探讨了多线程同步的多个方面,从基础概念到高级应用,从理论分析到代码实践,为读者提供了一份详尽的多线程编程指南。通过阅读本文,读者不仅能够理解多线程同步的基本原理和方法,还能够学习到如何在实际编程中应用这些知识,解决多线程环境下的各种同步问题。
在多线程编程中,合理地使用同步机制是确保程序正确性和性能的关键。无论是通过条件变量实现线程间的协调,还是通过生产消费模型、线程池等模式优化资源利用,都需要开发者对同步机制有深刻的理解和熟练的运用能力。同时,避免死锁、确保线程安全和可重入性,也是编写高质量多线程程序的重要考虑因素。
最后,本文还特别强调了在多线程环境下,对于单例模式、自旋锁、读者写者问题等特定场景的处理方法,这些都是多线程编程中不可或缺的知识点。希望通过本文的学习和实践,读者能够在面对复杂的多线程编程挑战时,更加从容不迫,游刃有余。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。