互斥
互斥的必要性:
- 数据一致性和完整性:在并发环境中,多个线程可能会同时尝试读取或修改同一个数据项。如果没有适当的互斥机制,这种并发访问可能导致数据损坏或不一致性。例如,一个线程可能在另一个线程完成更新之前读取了部分更新的数据,从而导致数据错误。
- 避免竞争条件:竞争条件是指两个或多个线程在尝试执行一系列操作时,由于执行顺序的不可预测性而导致的错误输出。这通常发生在多个线程试图同时更新共享资源时。通过实现互斥,可以确保在任何给定时间内只有一个线程能够访问该资源,从而避免竞争条件。
- 保护临界区:临界区是指访问共享资源的那部分代码,这些代码的执行需要互斥保护以防止数据竞争。通过互斥机制,可以确保在任何给定时间内,只有一个线程能够执行临界区代码,从而保护共享资源免受并发访问的干扰。
- 提高程序的稳定性和可靠性:通过实施线程互斥,可以减少并发程序中的错误和异常。这有助于确保程序即使在高负载或异常情况下也能稳定运行,从而提高整体的系统稳定性和可靠性。
- 简化并发编程:虽然互斥可能会增加程序的复杂性(因为需要管理锁和其他同步机制),但它也简化了并发编程的某些方面。通过提供明确的同步点,互斥使得开发人员能够更容易地理解和控制线程之间的交互,从而编写出更加健壮和可靠的并发程序。
首先利用多线程模拟购买火车票
假设有5个线程,模拟五个用户,抢购火车票,一共有1000张火车票,直到抢完为止:
cpp#include<unistd.h> #include<iostream> #include<pthread.h> #include<string> using namespace std; #define NUM 5 int tickets=1000; class Threadd { public: Threadd(pthread_t id){ name="pthread "+to_string(id); } string name; }; void* Buy(void* args){ Threadd* da=static_cast<Threadd*>(args); while(tickets>0){ usleep(1000); cout<<"线程 "<<da->name<<" 买到了票; 余票:"<<--tickets<<endl; } } int main() { pthread_t tids[NUM]; Threadd* Date[NUM]; for(int i=0;i<NUM;i++){ pthread_t tid; Threadd* da=new Threadd(i); pthread_create(&tid,nullptr,Buy,(void*)da); tids[i]=tid; Date[i]=da; } for(auto e:tids) pthread_join(e,nullptr); for(auto e:Date) delete e; }但是结果并非如我们所期望的:
,这是为什么呢?
因为多线程同时执行 tickets>0 与 --tickets 时,这两个操作并不是原子的,它会被拆成"读取值→减一→写回"三个步骤以及"读取值,进行比较"。举个例子:
互斥锁接口
1.设置锁:pthread_mutex_init
- mutex 指向要初始化的互斥锁对象的指针。
- attr 是指向互斥锁属性的指针,如果设置为 NULL,则使用默认属性。互斥锁属性允许你定制互斥锁的行为,比如设置互斥锁的类型为递归锁(recursive mutex),但在大多数情况下,使用默认属性就足够了。
如果 pthread_mutex_init 函数调用成功,它会返回 0。如果发生错误,它会返回一个非零的错误码。
phread_mutex_t是互斥锁类型:任何时刻只允许一个线程进行资源访问。
如果你是定义的全局的或者静态的(即它在编译时就已经分配了内存空间),你也可以考虑使用PTHREAD_MUTEX_INITIALIZER宏来静态初始化它,这样可以避免在运行时调用pthread_mutex_init,在这种情况下不需要destroy,进程结束后会自动释放。但是,请注意,静态初始化通常只适用于静态分配的互斥锁,并且不能用于动态分配的互斥锁。
但是,如果你使用的是动态分配的互斥锁(例如,通过malloc或calloc分配的),或者出于某种原因你希望在运行时控制互斥锁的初始化(例如,基于某些条件决定是否初始化),那么你就必须调用pthread_mutex_init。
2. pthread_mutex_destroy释放锁
- mutex 指向要销毁的互斥锁对象的指针。
如果 pthread_mutex_destroy 函数调用成功,它会返回 0。如果发生错误(例如,如果传入的互斥锁未初始化),它会返回一个非零的错误码。
3.加锁:pthread_mutex_lock
用于对互斥锁(mutex)进行加锁操作。当一个线程调用 pthread_mutex_lock 并成功获取锁时,它就可以安全地访问或修改被该锁保护的共享数据,而不用担心其他线程同时访问这些数据。mutex 指向要锁定的互斥锁对象的指针。
如果 pthread_mutex_lock 调用成功,它会返回 0,表示线程已经成功获取了锁。如果调用失败(例如,因为另一个线程已经持有了该锁,并且调用了线程在等待锁时被中断或取消了),它会返回一个错误码。
4.解锁:pthread_mutex_unlock
当一个线程完成对共享数据的访问或修改后,它应该调用 pthread_mutex_unlock 来释放锁,以便其他线程可以获取该锁并访问共享数据。
mutex 指向要解锁的互斥锁对象的指针。
如果 pthread_mutex_unlock 调用成功,它会返回 0,表示锁已经被成功释放。如果调用失败(例如,因为传入的互斥锁未初始化或当前线程并未持有该锁),它会返回一个错误码。
5.pthread_mutex_trylock
不阻塞,不等待,能拿到锁就拿,拿不到就立刻返回。
返回值:
0 → 成功加锁(你获得锁了)
EBUSY → 失败(别人持有锁),但不会阻塞你
其他错误码 → 错误
关键点:
它不会像
pthread_mutex_lock那样睡眠等待。
加入互斥后的购买火车票:
为了解决上面的情况,加入全局锁进行互斥。
cpp#include <unistd.h> #include <iostream> #include <pthread.h> #include <string> using namespace std; #define NUM 5 pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; int tickets = 1000; class Threadd { public: Threadd(pthread_t id) { name = "pthread " + to_string(id); } string name; }; void *Buy(void *args) { Threadd *da = static_cast<Threadd *>(args); while (true) { pthread_mutex_lock(&mutex); if (tickets > 0) { cout << "线程 " << da->name << " 买到了票; 余票:" << --tickets << endl; usleep(1000); pthread_mutex_unlock(&mutex); } else{ pthread_mutex_unlock(&mutex); break; } } } int main() { pthread_t tids[NUM]; Threadd *Date[NUM]; for (int i = 0; i < NUM; i++) { pthread_t tid; Threadd *da = new Threadd(i); pthread_create(&tid, nullptr, Buy, (void *)da); tids[i] = tid; Date[i] = da; } for (auto e : tids) pthread_join(e, nullptr); for (auto e : Date) delete e; }
但是我们发现,虽然避免了产生负数的问题,但是都是一个线程买到票了,显然不合理,因为**线程对于锁的竞争能力可能会不同,显然线程1的竞争力远远大于其他的线程。**所以需要控制顺序。
理解互斥
如何理解申请锁成功,允许你进入临界区。
申请锁成功,执行完pthread_mutex_lock。
如何理解申请锁失败,不允许你进入临界区。
申请锁失败,pthread_mutex_lock函数不返回,线程就阻塞了。(锁没有就绪)
pthread_mutex_lock函数和线程都属于pthread库,函数内部实现时就是一个判断,没有申请成功那么就将线程设置成阻塞状态。如果有线程pthread_mutex_unlock了,那么被阻塞的线程在pthread_mutex_lock内部就会被重新唤醒,重新申请锁,申请成功走上面逻辑,申请失败继续阻塞等待。
锁也是临界资源,如何保证访问锁的原子性?
大多数系统都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性, 即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。
al可看成一个寄存器,把0movb到al中,xchgb将内存中的变量与寄存器中的做了直接的交换,不需要中间变量。
1.CPU的寄存器只有一套,被所有的线程共享。但是寄存器中的数据,属于执行流上下文,属于执行流私有的数据!
2.CUP在执行代码的时候,一定要有对应的执行载体 --线程&&进程。
3.数据在内存中,是被所有线程所共享的
据1,2,3可知:把数据从内存移动到寄存器,本质是把数据从共享,变成线程私有!!!
线程执行判断,如果al中的内容>0,则申请锁成功然后返回,否则挂起等待,等待完成被唤醒,goto lock重新申请锁。
同步
线程同步同样是需要互斥锁来实现的,但不同的是:线程同步,程之间所具有的一种制约关系,一个线程的执行依赖另一个线程的消息或状态。当某个线程需要等待另一个线程完成某项任务后才能继续执行时,就需要进行线程同步。同步的目的是确保线程之间按照一定的顺序或规则来访问共享资源或执行特定操作。
线程同步是要强调顺序性的,而非单一的竞争,这就为访问临界资源提供了一定的合理性,不会让同一个线程连续多次的访问临界资源。这就与线程互斥有区别了,线程互斥只完成了排他性,但同步在排他的基础上,完成了线程访问资源的顺序性。同步的概念更为广泛,它不仅包括互斥,还包括其他形式的线程间协作和顺序控制。可以说互斥是同步的一种特殊形式。
条件变量
条件变量是一种同步原语,它提供了一种线程间通信的方式。当线程需要等待某个条件成立时,它可以使用条件变量将自己挂起并进入等待状态。一旦条件成立,另一个线程会通知条件变量,从而唤醒等待的线程。
条件变量必须与互斥锁结合使用,以确保线程在检查条件和等待条件变量时的原子性。这意味着,在调用条件变量的等待函数之前,线程必须已经持有与条件变量关联的互斥锁。当线程被条件变量唤醒后,它会重新获取互斥锁,并再次检查条件是否真正满足。
条件变量提供了两种基本操作:
等待(wait):线程调用条件变量的等待函数时,会释放已持有的互斥锁并进入等待状态。此时,线程不再消耗CPU资源,直到被其他线程唤醒。唤醒后,线程会重新获取互斥锁,并继续执行后续操作。
通知(notify/notifyAll):当条件满足时,另一个线程会调用条件变量的通知函数来唤醒一个或所有等待的线程。通知操作必须在持有互斥锁的情况下进行,以确保线程同步的正确性。
条件变量通常是与线程队列相关联的,因为可能有多个线程等待同一个条件,条件满足时,条件变量会从队列中唤醒一个或多个线程,使它们能够继续执行。
同步接口
接口:pthread_cond_init函数是用于初始化一个条件变量
cond 是指向条件变量对象的指针,该对象将被初始化。
attr 是指向条件变量属性的指针,用于指定条件变量的属性。如果此参数为 NULL,则使用默认属性。在大多数应用中,通常传递 NULL。
函数成功时返回 0;出错时返回错误码。
在实际使用中,静态初始化的方式(如示例中 PTHREAD_COND_INITIALIZER 和 PTHREAD_MUTEX_INITIALIZER)通常用于全局或静态的条件变量和互斥锁。对于局部变量或需要动态配置属性的情况,应使用 pthread_cond_init 和 pthread_mutex_init 函数进行动态初始化。
pthread_cond_t,该类型用于表示条件变量。pthread_cond_t 的使用中,通常需要与互斥锁(pthread_mutex_t)一起工作,以确保对共享数据的访问是同步的。条件变量本身不直接管理或保护任何数据;它们必须与互斥锁结合使用,以确保在检查条件(即"等待"条件)和修改条件(即"通知"或"广播"条件)时,数据的完整性得到保护。
接口:pthread_cond_wait,用于使线程在条件变量上等待,直到该条件变量被另一个线程的信号(pthread_cond_signal)或广播(pthread_cond_broadcast)唤醒。
cond 是指向条件变量对象的指针。
mutex 是指向互斥锁对象的指针,该互斥锁必须在调用 pthread_cond_wait 之前被当前线程持有(即锁定状态)。
接口:pthread_cond_signal(唤醒一个线程)和pthread_cond_broadcast(唤醒所有线程),是用于唤醒等待条件变量的线程的两个函数。
这两个函数通常与 pthread_cond_wait 或 pthread_cond_timedwait 一起使用,以实现线程间的同步。cond 是指向要唤醒线程的条件变量的指针。
成功时返回 0;失败时返回错误码。
加入同步后的购买火车票:
cpp#include <unistd.h> #include <iostream> #include <pthread.h> #include <string> using namespace std; #define NUM 5 pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; pthread_cond_t cond=PTHREAD_COND_INITIALIZER; int tickets = 1000; class Threadd { public: Threadd(pthread_t id) { name = "pthread " + to_string(id); } string name; }; void *Buy(void *args) { Threadd *da = static_cast<Threadd *>(args); while (true) { pthread_mutex_lock(&mutex); pthread_cond_wait(&cond,&mutex); if (tickets > 0) { cout << "线程 " << da->name << " 买到了票; 余票:" << --tickets << endl; pthread_mutex_unlock(&mutex); } else{ pthread_mutex_unlock(&mutex); break; } } } int main() { pthread_t tids[NUM]; Threadd *Date[NUM]; for (int i = 0; i < NUM; i++) { pthread_t tid; Threadd *da = new Threadd(i); pthread_create(&tid, nullptr, Buy, (void *)da); tids[i] = tid; Date[i] = da; usleep(1000); } while(true) { sleep(1); //pthread_cond_signal(&cond); //唤醒在cond的等待队列中等待的一个线程,默认都是第一个 pthread_cond_broadcast(&cond); } for (auto e : tids) pthread_join(e, nullptr); for (auto e : Date) delete e; }打印结果:
,顺序变成了一样的。
我还发现了一个事,当在唤醒循环中,将sleep(1)换成usleep(1000)时,他打印的结果就不是按顺序的了,怎么回事呢?当你把
usleep(10000)改成sleep(1)时,线程唤醒频率变低,不再频繁同时争抢互斥锁,调度器会按照等待队列和线程的虚拟运行时间依次安排它们运行,因此每次被唤醒后都呈现出相对稳定、几乎固定的执行顺序;而唤醒太频繁时锁竞争激烈,调度高度随机,线程输出顺序就会混乱。
生产者与消费者模型
概念
生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。这个阻塞队列就是用来给生产者和消费者解耦的。
1.基本概念
- 生产者:负责生成数据或任务的实体。它执行某些操作,如读取文件、计算数据或接收输入,然后将生成的数据放入某个中间存储位置(如队列、缓冲区等)。
- 消费者:负责处理数据的实体。它从中间存储位置取出数据,进行进一步处理,如显示、存储到数据库或发送给另一个系统。
- 缓冲区(阻塞队列):用于存储生产者生成而消费者尚未处理的数据的临时存储区。它解决了生产者和消费者之间速度不匹配的问题。
2.主要问题
- 同步:确保生产者和消费者不会同时访问缓冲区,以避免数据竞争和不一致。
- 互斥:确保在任一时刻只有一个生产者或消费者能够访问缓冲区。
- 死锁:防止两个或多个线程永久阻塞,每个线程都在等待其他线程释放资源。
- 饥饿:确保每个消费者都有机会从缓冲区中获取数据,防止某些消费者因为某些原因(如优先级、调度策略等)而得不到服务。
3.优点
- 解耦:生产者和消费者之间的解耦是模型的核心优势之一。生产者不需要知道消费者的具体实现细节,同样,消费者也不需要知道生产者的具体实现。它们之间通过共享的缓冲区进行通信,这种解耦使得系统的各个部分可以独立地发展和优化。
- 提高效率和吞吐量:生产者可以在没有消费者立即处理数据的情况下继续生产,而消费者也可以在生产者没有新数据时继续处理已有数据。这种并行处理能力可以显著提高系统的整体效率和吞吐量。
- 平衡负载:在生产者-消费者模型中,可以通过调整生产者和消费者的数量来平衡系统的负载。如果生产者生成数据的速度超过了消费者的处理能力,可以添加更多的消费者来分担负载。反之,如果消费者的处理能力超过生产者,则可以减少消费者的数量以节省资源。
- 灵活性:模型具有很高的灵活性,可以根据需要轻松地添加或删除生产者和消费者。此外,缓冲区的大小也可以根据需要进行调整,以适应不同的工作负载和数据流量。
- 简化并发控制:通过使用锁、条件变量或其他同步机制,生产者消费者模型可以简化并发控制。这些机制确保了生产者和消费者之间的正确同步,并防止了数据竞争和条件竞争等并发问题。
- 增强系统的可扩展性:由于生产者和消费者之间的解耦和并行处理能力,生产者消费者模型可以很容易地扩展到处理更多的数据或更复杂的任务。通过增加生产者和消费者的数量,系统可以处理更高的负载并保持高效的性能。
4.思考切入点(321原则)
1.一个仓库(一段内存空间,如队列)
2.两种角色(生产者,消费者)
3.三种关系(生产和生产(互斥关系),消费和消费(互斥关系),生产和消费(互斥关系&&同步关系))
编写基于BlockingQueue的生产者消费者模型
cppblockqueue.hpp #include <iostream> #include <queue> #include <pthread.h> using namespace std; template<class T> class Blockqueue { public: Blockqueue(int capacity):max_capacity(capacity){ pthread_mutex_init(&mutex,nullptr); pthread_cond_init(&c_cond,nullptr); pthread_cond_init(&p_cond,nullptr); } T pop(){ pthread_mutex_lock(&mutex); while(que.size() == 0){ pthread_cond_wait(&c_cond,&mutex);//等待 } T a=que.front(); que.pop(); pthread_mutex_unlock(&mutex); pthread_cond_broadcast(&p_cond); return a; } void push(const T& data){ pthread_mutex_lock(&mutex); while(que.size() == max_capacity){ pthread_cond_wait(&p_cond,&mutex);//等待 } que.push(data); pthread_mutex_unlock(&mutex); pthread_cond_broadcast(&c_cond); } ~Blockqueue(){ pthread_mutex_destroy(&mutex); pthread_cond_destroy(&c_cond); pthread_cond_destroy(&p_cond); } private: queue<T> que; int max_capacity; pthread_mutex_t mutex; pthread_cond_t c_cond; pthread_cond_t p_cond; };
- 构造函数 (BlockQueue): 初始化队列的最大容量、互斥锁和两个条件变量(一个用于生产者,一个用于消费者)。
- Pop 方法: 供消费者线程调用,从队列中移除并返回队列前端的元素。如果队列为空,则消费者线程将阻塞在pthread_cond_wait调用上,直到生产者向队列中添加了元素并通知了消费者。
- Equeue 方法: 供生产者线程调用,向队列中添加一个新元素。如果队列已满,则生产者线程将阻塞在pthread_cond_wait调用上,直到消费者从队列中移除了元素并通知了生产者。
- 析构函数 (~BlockQueue): 清理资源,销毁互斥锁和条件变量
在pop与push中判断临界资源是否满足条件时的时候不用if而用while,为了避免误唤醒!! 如果有两个消费者A和B,A竞争锁成功了,他就会执行临界区代码,先判断商品是否为空,如果空了就要wait;此时B可以持有锁,B也可以执行临界区代码,因为商品空了,所有B也要等待。此时AB都在消费者的等待队列中睡眠(阻塞)。
此时生产者生产了一个商品,使用 pthread_cond_broadcast把A和B都唤醒(AB由阻塞态变成就绪态),AB竞争锁资源,如果A竞争到了锁资源,A变成运行态,但是B没有竞争到锁,那么B变成阻塞态,到mutex的阻塞队列中等待,不会再返回到消费者的等待队列中了。A消费了一个商品,此时这个队列中已经没有商品了,当A执行完成后释放锁,同时唤醒生产者进程, 此时消费者B要与刚被唤醒的生产者竞争同一把锁mutex,消费者B肯定有优势,因为他已经是被唤醒的了,所以B会拿到锁 ,执行下面代码,B去拿商品的时候却没有商品:添加尚未满足,但是线程被异常唤醒的情况,叫做伪唤醒!为了避免这种情况的发生,使用while判断商品是否为空,如果为空那么重新等待,对于生产者同样如此!
cpp#include "blockqueue.hpp" #include <iostream> #include<unistd.h> using namespace std; void *Producer(void *args) { Blockqueue<int> *bq = static_cast<Blockqueue<int> *>(args); while (true) { cout << pthread_self()<<" Produce 666" << endl; bq->push(666); sleep(1); } } void *Consumer(void *args) { Blockqueue<int> *bq = static_cast<Blockqueue<int> *>(args); while (true) { int da = bq->pop(); cout << pthread_self() <<" consume " << da << endl; sleep(1); } } int main() { Blockqueue<int> *bq = new Blockqueue<int>(2); pthread_t p[3], c[3]; for (int i = 0; i < 3; i++) { pthread_create(p+i, nullptr, Producer, (void *)bq); } for (int i = 0; i < 3; i++) { pthread_create(c+i, nullptr, Consumer, (void *)bq); } for (int i = 0; i < 3; i++) { pthread_join(c[i], nullptr); } for (int i = 0; i < 3; i++) { pthread_join(p[i], nullptr); } }
,这是为什么呢?


但是我们发现,虽然避免了产生负数的问题,但是都是一个线程买到票了,显然不合理,因为**线程对于锁的竞争能力可能会不同,显然线程1的竞争力远远大于其他的线程。**所以需要控制顺序。



,顺序变成了一样的。
