目录
首先,我们需要了解一些概念:
共享资源:同一进程内的各个线程都能访问的资源叫共享资源
临界资源:把共享资源保护起来后就叫做临界资源(保护就是说一个只允许一个线程访问该资源)
临界区:访问临界资源的代码就叫做临界区,除了这临界区,就是非临界区
互斥:任何时刻,只有一个线程进入临界区访问临界资源这个就叫线程间互斥
原子性:一个操作要么没干,要么干完,此时就称该操作是原子的
抢票逻辑出现的问题
那么为什么要求线程间互斥呢?因为访问共享资源时,共享资源不加保护,此时如果多个进程同时访问就会导致数据不一致问题,我们可以简单写个抢票逻辑来看看问题
int g_tickets = 10000;//共享资源,没有保护 void Grabtickets(int &tickets) { while (1) { if (tickets > 0) { usleep(1000); cout << "get ticket:" << tickets << endl; tickets--; } else break; } } int main() { vector<Thread<int>> threads; for (int i = 1; i <= 4; i++) { string name = "thread-" + to_string(i); threads.emplace_back(Grabtickets, g_tickets, name); } for (auto &e : threads) { e.start(); } sleep(10); for (auto &e : threads) { e.join(); cout << "wait thread done,thread is:" << e.name() << endl; } return 0; }
我们看到,竟然抢到了负数的票,这显然是不合理的,这就是因为共享数据没有做保护,不同线程访问临界区不是互斥的
上面的代码有些简陋,其实我们可以给线程对象不只传int,我们可以传任意类型的对象,那我们下面就写一个对象,这个对象中可以存这个线程一共抢了多少张票等等更多内容
int g_tickets = 10000; // 共享资源,没有保护 class ThreadData { public: ThreadData(const string &name, int &tickets) : _name(name), _tickets(tickets), _total(0) {} const string _name; int &_tickets; int _total; }; void Grabtickets(ThreadData&td) { while (1) { if (td._tickets > 0) { usleep(1000); cout <<td._name<< "get ticket:" << td._tickets << endl; td._tickets--; td._total++; } else break; } } int main() { vector<Thread<ThreadData>> threads; for (int i = 1; i <= 4; i++) { string name = "thread-" + to_string(i); ThreadData td(name,g_tickets); threads.emplace_back(Grabtickets, td); } for (auto &e : threads) { e.start(); } for (auto &e : threads) { e.join(); cout << "wait thread done,thread is:" << e.dataaddress()->_name <<" get total: "<<e.dataaddress()->_total<<endl; } return 0; }
解释出现的问题
那么我们来解释一下为什么会抢到负数,我们看下面这段抢票的代码
这段代码进程间并不是互斥的,也就是说,这段代码可能同时有多个线程进入,我们想象下面这种场景:
如果内存中g_tickets为1,此时线程1执行到了if那行,它判断结果为真,进入循环,在usleep时被切换到等待队列里,此时线程2也执行if那行,判断结果也为真,进入循环,在usleep时被切换到等待队列里,此时线程1回来了,它要执行tickets--,从内存中拿值为1,减完后为0,放回内存,可能在某一时刻线程2也回来了,它就要把tickets从0减到-1,这样,它就抢到了0号票,这显然是不合理的,如果再加一个线程,就可能抢到负数的票。
总之,其实问题就是在于不同的线程并发访问了这一段不可以并发访问的代码,这一段代码必须是串行执行才能保证不出错。
锁
那我们的解决办法就是把这一段代码加锁,同一时刻只允许一个线程访问这段代码,把这段代码变成临界区
加锁我们可以简单理解为这有一把锁,只有拿到锁的线程才可以访问这段代码,你如果没有锁,就只能在外面等待,直到别的线程访问完并释放锁后你就可以去申请锁了
我们先来简单认识一些接口
下面是初始化一个锁,就是先定义一个锁
man pthread_mutex_init
如果定义的锁是局部的,我们需要用前两个去对锁初始化和释放
如果锁是静态的或全局的,我们只需要用第三个宏去初始化一下即可,不需要释放
下面是加锁和解锁,就是封住一段代码
man pthread_mutex_lock
我们一般用的就是lock和unlock,lock是如果这个锁正在被别人用,那么此线程就会阻塞等待在这里;trylock是如果别人用,它就会返回一个错误码表示当前锁不可用
所以这个lock就会有三种状态,申请锁成功就会继续向后执行代码,申请锁失败就会阻塞等待锁,如果锁根本就不存在,那么就会函数调用失败,返回一个错误码
加锁解决抢票问题
那下面用锁来解决一下上面的抢票出现的问题
我们可以先定义一把全局的锁,然后加锁解锁
我们可以看到运行速度变慢了,这就是因为线程会在申请锁的时候会等待,并且运行结果中已经没有负数的票了
当然我们也可以把锁定义成局部的,通过对象传过去
#include <vector> #include "MyThread.hpp" #include <unistd.h> using namespace MyThread; int g_tickets = 10000; // 共享资源,没有保护 // pthread_mutex_t gmutex = PTHREAD_MUTEX_INITIALIZER; class ThreadData { public: ThreadData(const string &name, int &tickets, pthread_mutex_t &mutex) : _name(name), _tickets(tickets), _total(0), _mutex(mutex) {} const string _name; int &_tickets; int _total; pthread_mutex_t &_mutex; }; void Grabtickets(ThreadData &td) { while (1) { // pthread_mutex_lock(&gmutex);//加锁 pthread_mutex_lock(&td._mutex); if (td._tickets > 0) { usleep(1000); cout << td._name << "get ticket:" << td._tickets << endl; td._tickets--; td._total++; // pthread_mutex_unlock(&gmutex);//解锁 pthread_mutex_unlock(&td._mutex); } else { // pthread_mutex_unlock(&gmutex);//解锁 pthread_mutex_unlock(&td._mutex); break; } } } int main() { vector<Thread<ThreadData>> threads; pthread_mutex_t mutex; pthread_mutex_init(&mutex, nullptr); for (int i = 1; i <= 4; i++) { string name = "thread-" + to_string(i); ThreadData td(name, g_tickets, mutex); threads.emplace_back(Grabtickets, td); } for (auto &e : threads) { e.start(); } for (auto &e : threads) { e.join(); cout << "wait thread done,thread is:" << e.dataaddress()->_name << " get total: " << e.dataaddress()->_total << endl; } pthread_mutex_destroy(&mutex); return 0; } //"MyThread.hpp" namespace MyThread { template<class T> using fun_t = function<void(T &)>; template <class T> class Thread { private: // using fun_t = function<void(const T &)>;//如果写里边是这样的 // 等价于 typedef function<void(const T &)> fun_t; void excute() { _func(_data); } public: Thread(fun_t<T> func, T &data) : _func(func), _data(data), _stop(true){} static void *threadrun(void *args)//如果不是静态,会有this指针 { Thread<T> *ptr = reinterpret_cast<Thread<T> *>(args); ptr->excute(); return nullptr; } bool start() { int n = pthread_create(&_id, nullptr, threadrun, this);//把this当参数传过去 if (n == 0) { _stop = false; return true; } else { return false; } } void join() { if (!_stop) { pthread_join(_id, nullptr); } } void detach() { if (!_stop) { pthread_detach(_id); } } void stop() { _stop = true; } T* dataaddress() { return &_data; } private: pthread_t _id; bool _stop; fun_t<T> _func; T _data;//ThreadData内包含名字 }; }
这个代码我们还需要写两个unlock,就是怕锁万一没被解锁,如何让它自动解锁呢?可以用智能指针的特性,类构造对象就让它加锁,析构对象就让它释放锁,因为局部对象出了作用域会自动调用析构,我们可以创建一个这样的类
之后加锁解锁就只需要创建一个局部对象即可
锁的底层机制
其实我们已经看出来了像tickets--这样的代码并不是原子的,有可能会有数据不一致的问题,那么什么样的代码是原子的呢?其实只要生成一条汇编就是原子的,比如a=10,汇编代码只有一条mov指令
为了实现互斥锁的操作,大部分CPU都提供了swap或exchange指令,这个命令是把寄存器和内存中的数据做交换,因为只有一条指令,这个就是原子的,并且一个CPU在执行这个命令是另一个CPU要等待,就是因为这个指令才实现了互斥锁
简单原理就是内存中有一把锁,我们可以看成数字1,如果一个线程申请锁,那么这个1就会通过上面的指令到线程内部,此时其他线程就看不到锁了,就需要等待;线程释放锁就是把数字1放回内存中,这样其他线程就可以申请锁了
其实就是将内存中的共享数据放到CPU内部寄存器中,变成线程的上下文,变成线程的私有数据