目录
[2.2.1 解决抢票问题](#2.2.1 解决抢票问题)
0.对原生线程封装的代码
方便后续对锁的理解:Thread.hpp
详情请看: Linux--线程ID&&封装管理原生线程-CSDN博客
cpp#pragma once #include <iostream> #include <string> #include <pthread.h> namespace ThreadMoudle { // 线程要执行的方法,后面我们随时调整 typedef void (*func_t)(const std::string &name); // 函数指针类型 //执行函数时,名字带出来,方便打印测试结果 class Thread { public: //成员方法调用_func执行任务 void Excute() { std::cout << _name << " is running" << std::endl; _isrunning = true;//开始回调了,就表示线程跑起来了 _func(_name); _isrunning = false; } public: //构造 Thread(const std::string &name, func_t func):_name(name), _func(func) { std::cout << "create " << name << " done" << std::endl; } // 线程的固定历程,新线程都会执行该方法! static void *ThreadRoutine(void *args) { //为了匹配类型,加static属于类而不属于对象,就没有this指针了 //this指针从creat函数传递过来 Thread *self = static_cast<Thread*>(args); // 获得了当前对象 self->Excute();//直接调用成员方法 return nullptr;//简单的演示,没有设置返回值 } //线程启动 bool Start() { //使用标准库中的方法创建进程 int n = ::pthread_create(&_tid, nullptr, ThreadRoutine, this); if(n != 0) return false; return true; } //表示状态 std::string Status() { if(_isrunning) return "running"; else return "sleep"; } void Stop() { //表示有线程在running才需要stop if(_isrunning) { ::pthread_cancel(_tid);//取消 _isrunning = false;//状态变为停止 std::cout << _name << " Stop" << std::endl; } } void Join() { //线程退出后等待回收。 if(!_isrunning) { ::pthread_join(_tid, nullptr); std::cout << _name << " Joined" << std::endl; } } //知道是哪个线程 std::string Name() { return _name; } ~Thread() { } private: std::string _name;//线程名字 pthread_t _tid;//ID bool _isrunning;//是否在运行 func_t _func; // 线程要执行的回调函数(任务) }; }
1.为什么需要线程互斥
数据一致性和完整性 :
在并发环境中,多个线程可能会同时尝试读取或修改同一个数据项。如果没有适当的互斥机制,这种并发访问可能导致数据损坏或不一致性。例如,一个线程可能在另一个线程完成更新之前读取了部分更新的数据,从而导致数据错误。
避免竞争条件 :
竞争条件是指两个或多个线程在尝试执行一系列操作时,由于执行顺序的不可预测性而导致的错误输出。这通常发生在多个线程试图同时更新共享资源时。通过实现互斥,可以确保在任何给定时间内只有一个线程能够访问该资源,从而避免竞争条件。
保护临界区 :
临界区是指访问共享资源的那部分代码,这些代码的执行需要互斥保护以防止数据竞争。通过互斥机制,可以确保在任何给定时间内,只有一个线程能够执行临界区代码,从而保护共享资源免受并发访问的干扰。
提高程序的稳定性和可靠性 :
通过实施线程互斥,可以减少并发程序中的错误和异常。这有助于确保程序即使在高负载或异常情况下也能稳定运行,从而提高整体的系统稳定性和可靠性。
简化并发编程 :
虽然互斥可能会增加程序的复杂性(因为需要管理锁和其他同步机制),但它也简化了并发编程的某些方面。通过提供明确的同步点,互斥使得开发人员能够更容易地理解和控制线程之间的交互,从而编写出更加健壮和可靠的并发程序。
见一见多线程访问的问题 --抢票的代码:
我们发现会有抢到负数的情况。
cpp#include <iostream> #include <vector> #include <cstdio> #include <unistd.h> #include "Thread.hpp" using namespace ThreadMoudle; int tickets = 10000; void route(const std::string &name) { while(true) { if(tickets > 0) { // 抢票过程 usleep(1000); // 1ms -> 抢票花费的时间 printf("who: %s, get a ticket: %d\n", name.c_str(), tickets); tickets--; } else { break; } } } int main() { Thread t1("thread-1", route); Thread t2("thread-2", route); Thread t3("thread-3", route); Thread t4("thread-4", route); t1.Start(); t2.Start(); t3.Start(); t4.Start(); t1.Join(); t2.Join(); t3.Join(); t4.Join(); }
这是为什么?
首先判断的过程就是一种计算,属于逻辑运算,由CPU来做。tickets变量是在内存中的变量, 要把数据移动到CPU的eax寄存器中用于逻辑计算
现实情况是,每个线程都要这样执行上面的逻辑,但cpu的寄存器只有一套,但是寄存器中的数据有多套。(由于
ticket
的读取和修改操作不是原子的(即,它们被分成了两步:读取和写入)有一种情况,当一个线程正走到以上逻辑的第二步时,正准备判断,此时这个线程被切换了,一旦被切换了当前线程的数据都会被带走,回来的时候,会恢复!当票数为1时,a线程会做判断,符合逻辑进入if,走到usleep语句;此时b线程也进来来,a将寄存器中的数据带走,此时b线程见到的票数也是1,b线程也符合逻辑,进入if,也会走到usleep;同样的c和d线程都会做以上线程的动作,都会进入if。当a过了usleep时间,会执行--操作(1.重读数据2.--数据3.写回数据),此时票数为0了,同样的b,c,d线程也会做--,因为它们已经进入了if中。最后就导致票数为-2的情况了。
如何解决这样的问题?
加锁!
2.加锁
2.1.认识加锁和它的接口
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来释放锁
cpp#include <pthread.h> int pthread_mutex_destroy(pthread_mutex_t *mutex);
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
,表示锁已经被成功释放。如果调用失败(例如,因为传入的互斥锁未初始化或当前线程并未持有该锁),它会返回一个错误码。
2.2用一下接口
2.2.1 解决抢票问题
从临界区的角度来看,上面的问题涉及到多线程程序中对共享资源(即
ticket
变量)的访问冲突。临界区是指访问共享资源并执行某些操作的代码段,这些操作在并发执行时可能会相互干扰,导致数据竞争或不一致。每个线程都有一个循环,该循环试图检查
ticket
变量的值,如果大于0,则减少它并打印。这个检查和减少的操作组合起来就构成了一个临界区,因为多个线程可能同时试图执行这个操作。没有同步机制(如互斥锁)的情况下,临界区内的操作可能会以不可预测的方式交错执行,导致数据竞争(不一致性)。
为了解决这个问题,我们需要确保在任何给定时间,只有一个线程可以进入临界区并执行其中的操作。这可以通过使用互斥锁(mutex)来实现。(由并行操作转为串行操作)
所谓对临界资源进行保护,本质是对临界区进行保护!我们对所有资源进行访问吗,本质是通过代码进行访问!保护资源,本质就是想办法把访问资源的代码保护起来!
1.加锁是将并行改为串行的一种方法,所以一定要搞清楚临界区在哪里,枷锁的范围(代码行数),粒度一定要尽量的小(串行跨度过长是会导致多线程情况下的效率降低)
这一步就完成了加锁和解锁
cppint tickets = 10000; //设置锁 pthread_mutex_t gmutex=PTHREAD_MUTEX_INITIALIZER; void route(const std::string &name) { while(true) { //加锁 pthread_mutex_lock(&gmutex); if(tickets > 0) { // 抢票过程 usleep(1000); // 1ms -> 抢票花费的时间 printf("who: %s, get a ticket: %d\n", name.c_str(), tickets); tickets--; //执行完抢票逻辑后解锁 pthread_mutex_unlock(&gmutex); } else { pthread_mutex_unlock(&gmutex); break; } } }
运行结果:减到1后停止抢票,完成了抢票逻辑的加锁和解锁
2.至此任何线程,要进行抢票,都得先申请锁,不应该有例外
3.所有线程申请锁,前提是所有线程都得看到这把锁,锁本身也是共享资源---加锁的过程必须是原子的!(不能是我在申请锁的时候,别人也在申请)
4.原子性:指的是一个操作(或一组操作)在执行过程中不可分割,即要么全部执行成功,要么全部不执行,中间不会被其他操作或事件打断。(对于任何一个线程在抢票的时候,要么抢到了,要么没抢到,不存在正在抢票的状态)
5.如果线程申请锁失败了,我的线程就要被阻塞。
6.如果线程申请锁成功了,继续向后运行。(申请成功的锁先进行串行操作,完成之后,其它线程才从阻塞态被唤醒,执行该操作,这样并行就变成串行了)
7.如果一个线程申请成功了,执行临界区的代码了,在执行临界区代码期间,该线程可以被切换走吗?可以切换,但是其它线程无法进入临界区,因为该线程并没有释放锁!!!该线程可以放心的执行完毕临界区的代码,没有人能打扰!
结论:所以对于其它线程来说,要么我没申请锁,要么我释放了锁,对其他线程才有意义!我访问临界区,对于其它线程是原子的!
2.2.2设置局部锁
我们使用ThreadData接收参数,包括锁的接收,这样每一个线程都能看到同一个锁了
cpp#pragma once #include <iostream> #include <string> #include <pthread.h> namespace ThreadMoudle { //传递的参数 class ThreadData { public: ThreadData(const std::string &name, pthread_mutex_t *lock):_name(name), _lock(lock) {} public: std::string _name; pthread_mutex_t *_lock; }; // 线程要执行的方法,后面我们随时调整 typedef void (*func_t)(ThreadData *td); // 函数指针类型 class Thread { public: void Excute() { std::cout << _name << " is running" << std::endl; _isrunning = true; _func(_td); _isrunning = false; } public: Thread(const std::string &name, func_t func, ThreadData *td):_name(name), _func(func), _td(td) { std::cout << "create " << name << " done" << std::endl; } static void *ThreadRoutine(void *args) // 新线程都会执行该方法! { Thread *self = static_cast<Thread*>(args); // 获得了当前对象 self->Excute(); return nullptr; } bool Start() { int n = ::pthread_create(&_tid, nullptr, ThreadRoutine, this); if(n != 0) return false; return true; } std::string Status() { if(_isrunning) return "running"; else return "sleep"; } void Stop() { if(_isrunning) { ::pthread_cancel(_tid); _isrunning = false; std::cout << _name << " Stop" << std::endl; } } void Join() { ::pthread_join(_tid, nullptr); std::cout << _name << " Joined" << std::endl; delete _td; } std::string Name() { return _name; } ~Thread() { } private: std::string _name; pthread_t _tid; bool _isrunning; func_t _func; // 线程要执行的回调函数 ThreadData *_td;//线程参数 }; }
这样,每个线程都获取了局部锁的地址,在每个线程在执行抢票逻辑的时候,将锁的地址传给加锁函数,就能实现加锁了。
cpp#include <iostream> #include <vector> #include <cstdio> #include <unistd.h> #include "Thread.hpp" using namespace ThreadMoudle; int tickets = 10000; void route(ThreadData *td) { while (true) { pthread_mutex_lock(td->_lock); if (tickets > 0) { // 抢票过程 usleep(1000); // 1ms -> 抢票花费的时间 printf("who: %s, get a ticket: %d\n", td->_name.c_str(), tickets); tickets--; pthread_mutex_unlock(td->_lock); } else { pthread_mutex_unlock(td->_lock); break; } } } static int threadnum =4; int main() { //创建局部锁 pthread_mutex_t mutex; pthread_mutex_init(&mutex, nullptr); std::vector<Thread> threads; for(int i = 0; i < threadnum; i++) { std::string name = "thread-" + std::to_string(i+1); //创建锁后,把锁的地址给到td对象,再将td给到Thread ThreadData *td = new ThreadData(name, &mutex); threads.emplace_back(name, route, td); } for(auto &thread : threads) { thread.Start(); } for(auto &thread : threads) { thread.Join(); } //释放锁 pthread_mutex_destroy(&mutex); }
进一步对锁进行封装:
cpp#pragma once #include <pthread.h> class LockGuard { public: LockGuard(pthread_mutex_t *mutex):_mutex(mutex) { pthread_mutex_lock(_mutex); } ~LockGuard() { pthread_mutex_unlock(_mutex); } private: pthread_mutex_t *_mutex; };
route函数就可以这样写:由于while区域是一个代码块,进入时调用构造,当条件判断结束时:自动调用析构
cppvoid route(ThreadData *td) { while (true) { LockGuard lockguard(td->_lock); // RAII风格的锁 if (tickets > 0) { // 抢票过程 usleep(1000); // 1ms -> 抢票花费的时间 printf("who: %s, get a ticket: %d\n", td->_name.c_str(), tickets); tickets--; } else { break; } } }
2.3从原理角度理解锁
如何理解申请锁成功,允许你进入临界区。
申请锁成功,pthread_mutex_lock函数会返回。
如何理解申请锁失败,不允许你进入临界区。
申请锁失败,pthread_mutex_lock函数不返回,线程就阻塞了。(锁没有就绪)
pthread_mutex_lock函数和线程都属于pthread库,函数内部实现时就是一个判断,没有申请成功那么就将线程设置成阻塞状态。如果有线程pthread_mutex_unlock了,那么被阻塞的线程在pthread_mutex_lock内部就会被重新唤醒,重新申请锁,申请成功走上面逻辑,申请失败继续阻塞等待。
2.4锁是如何实现的****
经过上面的例子,大家已经意识到单纯的 i++ 或者 ++i 都不是原子的,有可能会有数据一致性问题
为了实现互斥锁操作,大多数体系结构都提供了**swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,**即使是多处理器平台,访问内存的 总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。 现在我们把lock和unlock的伪代码改一下
al可看成一个寄存器,把0movb到al中,xchgb将内存中的变量与寄存器中的做了直接的交换,不需要中间变量。(比如说lock一开始的数据是1表示锁没有被申请,0表示锁被申请了)
1.CPU的寄存器只有一套,被所有的线程共享。但是寄存器中的数据,属于执行流上下文,属于执行流私有的数据!
2.CUP在执行代码的时候,一定要有对应的执行载体 --线程&&进程。
3.数据在内存中,是被所有线程所共享的
据1,2,3可知:把数据从内存移动到寄存器,本质是吧数据从共享,变成线程私有!!!
线程执行判断,如果al中的内容>0,则申请锁成功然后返回,否则挂起等待,等待完成被唤醒,goto lock重新申请锁。
基于以上代码我们来理解一下加锁:
线程 A执行第一行代码,此时%al寄存器中为0;执行第二条代码此时内存中lock中的数据与%al继续交换,所有%al中值为1,lock的值为0;当线程A执行第三行代码的时候被切换走,因此线程A会保存上下文,带走%al中的数据,此时线程A处在第三行。
此时线程B走第一行和第二行代码,由于内存中lock的值为0,交换之后%al的值还是0。所以当线程B执行到第3行代码的时候只能跳到第6行,线程B被挂起等待,线程B下次被唤醒的时候将执行第7行代码,goto lock重新申请锁。
**线程B被挂起,线程A被切回,继续从第三行开始执行,进入if,调用接口pthread_mutex_lock,return 0表示加锁成功,进入临界区。所以此时线程A称之为:申请锁成功。在这个过程中加锁就是执行第二行代码:xchgb,只有一条汇编代码,交换不是拷贝,只有一个"1",持有1的,就表示持有锁!**当线程下次想申请锁的时候就要把%al清空了。
解锁就是把"1"还回去,内存中的lock由0变为1.