赞
踩
1.组件式的封装出一个线程类(像C++11线程库那样去管理线程)
//hpp文件 #pragma once #include<iostream> #include<pthread.h> #include<string> #include<functional> using namespace std; template<class T> using fun_c = std::function<void(T)>; template<class T> class Thread { public: Thread(const string& threadname, fun_c<T> func, T data) :_threadname(threadname), _func(func), _data(data) {} ~Thread() { ; } //线程执行的函数 static void* ThreadRoutine(void* args) //成员函数默认里面有一个this指针,而静态成员函数中没有 { Thread* t = static_cast<Thread*>(args); t->_func(t->_data); return nullptr; } //线程创建-》running bool Start() { int n = pthread_create(&_tid, nullptr, ThreadRoutine, this/*?*/); if(n == 0) { _isrunning = true; return true; } else return false; } bool Join() { if(!_isrunning) return true; int n = pthread_join(_tid, nullptr); if(n == 0) { _isrunning = false; return true; } return false; } std::string ThreadName() { return _threadname; } bool IsRunning() { return _isrunning; } private: string _threadname; pthread_t _tid = 0; bool _isrunning = false; fun_c<T> _func; T _data; };
//.cc文件 #include <iostream> #include "mythread.hpp" #include <cstdio> #include<unistd.h> string GetThreadName() { static int num = 1; char buffer[64]; snprintf(buffer, sizeof(buffer), "thread-%d", num++); return buffer; } void Print(int number) { while (number) { std::cout << "hello world: " << number-- << std::endl; sleep(1); } } int main() { Thread<int> t(GetThreadName(), Print, 10); std::cout << "is thread running? " << t.IsRunning() << std::endl; t.Start(); std::cout << "is thread running? " << t.IsRunning() << std::endl; t.Join(); //线程阻塞等待 // Thread t(GetThreadName(), Print); // std::cout << "is thread running? " << t.IsRunning() << std::endl; // t.Start(); // std::cout << "is thread running? " << t.IsRunning() << std::endl; // t.Join(); //线程阻塞等待 return 0; }
假设现在有一份共享资源tickets,如果我们想让多个线程都对这个资源进行操作,也就是tickets- -的操作,但下面两份代码分别出现了不同的结果,上面代码并没有出现问题,而下面代码却出现了票为负数的情况,这是怎么回事呢?
其实问题产生就是由于多线程被调度器调度的特性导致的。
int ticket = 1000; //票数 void route(string arg) { while (1) { if (ticket > 0) { usleep(1000); printf("%s sells ticket:%d\n", arg.c_str(), ticket); ticket--; } else { break; } } } int main() { Thread<string> t(GetThreadName(), route, GetThreadName()); Thread<string> t2(GetThreadName(), route, GetThreadName()); Thread<string> t3(GetThreadName(), route, GetThreadName()); Thread<string> t4(GetThreadName(), route, GetThreadName()); t.Start(); t2.Start(); t3.Start(); t4.Start(); t.Join(); t2.Join(); t3.Join(); t4.Join(); return 0; }
在知道上面的原理之后,还需要知道usleep的作用,当usleep放到if分支语句的第一行时,票数就出现了问题,出现了负数,主要是因为usleep可以将线程暂时阻塞,那么CPU就会把他切换下去,转而执行其他线程,但需要注意的是,如果被切换的线程重新调度上来时,还会从上次他执行后的语句继续向下运行。
所以会出现多个线程同时进入到分支判断语句,然后去阻塞等待的情况,假设tickets已经变成了1,然后其余的线程此时都被调度上来了,他们都开始执行tickets- -,- -之后不满足循环条件线程才会退出,那么如果我们创建出了4个线程,就会有3个线程在票数已经为0的情况下继续减减,所以就会出现票数为负数的情况。
那该如何解决上面的问题呢?多个执行流操作共享资源时,发生了数据不一致问题。
解决上面的问题实际要通过加锁来实现,但在谈论加锁的话题之前,我们需要来重新看待几个概念。
有了上面四组概念的铺垫之后,我们来谈谈如何对共享资源进行加锁和解锁?
首先锁实际就是一种数据类型,这个锁就像我们平常定义出来的变量或是对象一样,只不过这个锁的类型是系统给我们封装好的一种类型,进行重定义后为pthread_mutex_t。
变量或对象在定义的时候也是可以初始化的,变量初始化后,就是变量的定义,而不是声明了。变量和对象也都有自己的销毁方案,内置类型的变量或者对象销毁时,操作系统会自动回收其资源或者自动调用析构函数,而自定义对象销毁时,操作系统会调用其析构函数进行资源的回收。
锁同样也是如此,锁也有自己的初始化和销毁方案,如果你定义的是一把局部锁,就需要用pthread_mutex_init()和pthread_mutex_destroy()来进行初始化和销毁,如果你定义的是一把全局锁或静态所,则不需要用init初始化和destroy销毁,直接PTHREAD_MUTEX_INITIALIZER进行初始化即可,它有自己的初始化和销毁方案,我们无须关心静态或全局锁如何销毁。
定义好锁之后,我们就可以对某一段代码进行加锁和解锁,加锁与解锁意味着,这段代码不是一般的代码,只有申请到锁,持有锁的线程才能访问这段代码,加锁和解锁之间的代码可以称为临界区,因为想要访问这段空间必须有锁才可以访问。pthread_mutex_lock实际就是申请锁的代码和临界区的入口,如果你申请锁成功了,那么你就可以进入临界区访问临界资源,如果你并没有申请成功,比如当前这把锁已经被别的线程申请到并持有了,其他线程正持有锁在临界区访问着呢,那么你就无法进入临界区,因为你并没有持有锁,必须得在pthread_mutex_lock阻塞等待,直到你申请到锁之后,你才能进入临界区访问临界资源,这样的线程访问实际就是互斥,指的是当一个线程正在持有锁访问临界区的时候,其他线程无法进入临界区,直到持有锁的线程释放锁之后才会有可能进入临界区,注意是有可能,因为当线程释放锁之后,这把锁还需要被竞争,哪个线程竞争到这把锁,哪个线程才能持有锁的访问临界资源!
上面谈论完锁的初始化和销毁,以及如何加锁和解锁之后,我们来利用锁解决上面出现的共享资源访问不安全的问题。你不是由于多线程再进行临界资源访问时,可能由于线程切换什么的,导致非原子性式的访问临界资源吗?那我不让你这么干,我对这段临界资源进行加锁,让你当前申请到锁正在访问临界资源的线程,必须给我以原子性的访问来访问临界资源,换句话说,你必须把访问临界资源的工作做完了,才可以,要么你不要访问临界资源,要么你访问了临界资源,就必须把临界资源全部访问完了,中间不能访问一半就不访问了!所以只要对临界资源进行加锁后,临界资源就变得安全了,因为无论什么线程想要访问临界资源,都必须以原子性的方式访问完,这样的话,就不会出现在访问一半的时候,线程被切换下去了,其他线程被切换上来继续访问临界资源了,而是说如果持有锁的线程被切换下去了,这个线程会抱着他申请到的锁被切换下去,此时其他线程如果被切换上来,想要访问临界资源,那也没用,因为你没有锁啊!持有锁的线程被切换时,是抱着锁被切换的,那你现在既然访问不了临界区,CPU无法继续执行代码,那就只能等持有锁的线程重新被切换上来时,才能继续开展临界资源的访问工作,这个工作必须且只能由申请到锁的线程来完成,其他任何线程都无法完成这个工作!反过来说,这不就是原子性吗?访问临界资源的工作只要被持有锁的线程开始做了,哪怕他在做的过程中被切换下去了,也无须担心,因为别的线程做不了这个工作,所以还是得等持有锁的线程被切换上来的时候才能继续做这个工作,那是不是这个工作只要开始做了,就一定会被做完呢?会不会出现做一半,停下来了不做了,让别的线程在去访问临界资源的情况呢?当然不会!这就是锁带来的作用。
如果在加锁之后运行代码,实际可以发现他抢票的速度是要比没加锁之前慢的,原因也很简单。我来给大家解释一下,没加锁之前,线程之间是可以并发或并行执行的,我先大概说一下并发和并行是什么,后面会详细介绍这两者的区别和概念,并发你可以简单理解为,当线程运行一半被切换下去的时候,此时CPU还可以调度运行其他线程,也就是说,如果多个线程在运行的时候,每个线程都会被CPU跑一跑,那在一段时间内,所有的线程都可以被执行到,并且推进每个线程的执行过程。而并行就是在多个核心上面同一时刻跑不同的线程,比如两个同时访问临界资源的线程,在未加锁的时候,可能出现多个核心同时执行两个线程的代码,同时在访问临界资源,但实际这种情况并不常见,因为我们写出来的代码优先级并没有那么高,所以基本上都是在按照并发执行的。
然后加锁前是并发执行的,也就是说在一个线程被切换下去的时候,其他- -tickets的线程还能够被重新调度上来进行票数的- -,那么总体上来说,票数就会被一直- -。
而加锁之后就不是并发执行的了,因为我们上面说过,加锁之后即使持有锁的线程被切换下去,其他被调度到CPU上的线程也是无法进行票数- -的,因为他们没有锁,所以在持有锁的线程被切换下去的这段时间里,票数不会改变,因为线程在串行的访问临界资源,什么是串行呢?就是一个线程访问完之后,才能轮到另一个线程,就是我们前面说的,一个线程在完成他的工作之后,释放完锁之后,其他线程才有可能竞争到锁,才有可能访问临界资源,这样就是串行。
串行的执行效率肯定要比并发执行的效率底嘛,因为当多线程在执行任务的时候,我们进行并发执行,为的就是当前线程如果被切换下去了,那也没啥事,因为其他被调度上来的线程依旧可以执行这个任务。你现在加锁之后就会变成串行执行了,那当前持有锁的线程被切换下去时,其他被调度上来的线程是无法继续执行任务的,效率自然就会底一些。(效率底一点就底一点吧,毕竟现在共享资源就安全了嘛,下面运行结果你也可以看到,没有锁的时候,票数就为负数了,这种情况用户怎么可能容忍。)
如果定义局部锁的话,我们肯定是想要将这把锁传给每个线程的,让每个线程都用这把锁来互斥式的访问共享资源,以此来保证共享资源的安全性。
//pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; //初始化一把全局的锁 int ticket = 1000; //票数 void route(pthread_mutex_t* mutex) { while (1) { pthread_mutex_lock(mutex); //上锁 if (ticket > 0) { usleep(1000); printf("sells ticket:%d\n", ticket); ticket--; pthread_mutex_unlock(mutex); } else { pthread_mutex_unlock(mutex); break; } } } int main() { pthread_mutex_t mutex; pthread_mutex_init(&mutex, nullptr); //初始化一把局部的锁 Thread<pthread_mutex_t*> t(GetThreadName(), route, &mutex); Thread<pthread_mutex_t*> t2(GetThreadName(), route, &mutex); Thread<pthread_mutex_t*> t3(GetThreadName(), route, &mutex); Thread<pthread_mutex_t*> t4(GetThreadName(), route, &mutex); t.Start(); t2.Start(); t3.Start(); t4.Start(); t.Join(); t2.Join(); t3.Join(); t4.Join(); pthread_mutex_destroy(&mutex); //释放锁 return 0; }
全局的锁定义并且初始化以后,每个线程都可以看得见,因此也是共享资源
完成上面对于共享资源访问不安全问题的解决之后,我们来深入的理解一下锁。
我们知道,共享资源在被多线程访问时,是不安全的,所以我们需要加锁来保护共享资源。但是我们回过头来想一想,锁本身是不是共享资源呢?所有的线程都需要申请锁和释放锁,那不就是在共同的访问锁这个资源嘛?所以锁本身不就是共享资源吗?那多个线程在访问锁这个共享资源的时候,锁本身是不是需要被保护呢?当然需要!其他的共享资源可以通过加锁来进行保护,那锁怎么办呢?
实际上,加锁和解锁的过程是原子的!也就是说只要你申请了锁,并且竞争能力恰好足够,那么你就一定能够拿到这个锁,否则你就不会拿到这个锁,不会说在申请锁申请一半的时候,线程被切换下去了,其他线程去申请锁了,不会出现这种中间态的情况!既然加锁和解锁的过程是原子的,那其实访问锁就是安全的!(但加锁解锁的过程为什么是原子的呢?我该如何理解呢?这个后面会说。)
如果我们想简单的封装使用锁,那我们该如何设计呢?我们也想像之前封装设计线程那样搞出来C++式的面向对象版的创建线程和销毁线程。
实际实现起来也很简单,无非就是对原生的申请锁,加锁,解锁接口的封装!我们先定义一个互斥量的类,类中实现构造函数将锁的地址进行初始化,然后定义出加锁和解锁的两个接口,这样就可以定义出来一个内部能够进行加锁和解锁的类。
然后我们再加一层封装,实现出RAII( Resource Acquisition Is Initialization)风格的加锁,即为构造函数处进行加锁,析构函数处进行解锁!
至于锁的初始化和销毁方案,是类外面的事情,使用时需要自己先初始化好一把锁,确定初始化和销毁的方案,然后利用LockGuard.hpp这个小组件来进行加锁和解锁的过程!
在多线程并发执行代码,同时访问共享资源的时候,如果某一个共享资源由于多线程访问,发生了数据不一致,共享资源不安全,并且导致其他线程运行出问题了,那么这种情况就是线程不安全的。尤其对于没有锁保护的共享资源的多线程访问的代码,很大概率出现线程不安全的情况。
而什么是可重入呢?这个话题并不陌生,我们之前谈论进程信号的时候,进程可能由于收到信号,并且在陷入内核时检测到信号,跳转到handler方法执行信号处理函数,信号处理函数中可能会出现和main执行流中执行相同的函数体,例如当时我们所说的链表的push_back在main和handler中同时执行,可能会导致某些未知错误的产生,如果出现了问题,那么我们称这个函数是不可重入函数,如果没有出现问题这个函数就是可重入函数。值得注意的是,不可重入函数说的是这个函数的属性,而不是说这个函数叫做不可重入函数,那么他就一定不能被执行流所重入,只是说,他如果被执行流重入,极大概率是要出问题的。
死锁是指一个进程中的各个线程,都持有着锁,但同时又去申请其他线程的锁,而每个线程持有的锁都是占有不会释放的,所以大家都会等着,等对方先释放锁,但是呢,大家又都不释放锁,全都占有着锁,所以大家就会处于一种永久等待的状态,也就是永久性的阻塞状态,所有执行流都不会被运行,这样的问题就是死锁!
之前抢票的代码中,多个线程使用的是同一把锁,未来有些场景一定是要使用多把锁的,在多把锁的情况下,如果某些线程持有锁不释放,还要去申请其他线程正持有的锁,而每个线程都是这样的状态,那就是死锁问题。
产生死锁的四个必要条件
1.互斥条件:一个资源每次只能被一个执行流使用,互斥其实就是加锁之后线程的串行执行。
2.请求与保持条件:一个执行流由于请求资源而阻塞时,对自己已经获得的资源保持不放。说白了就是我自己的东西不释放,我还要你的东西,你不给我就一直等,等到你给我为止。
3.不剥夺条件:一个线程在未使用完自己获得的资源之前,是不能够强行剥夺其他线程的资源的。说白了就是你先在还有资源呢,你想要别人的自由你就得等,不能强行剥夺!当你使用完自己的资源后,你可以去等待申请别人的资源。总之就是不能强行剥夺其他线程的资源,想要就必须阻塞等待别人释放资源才可以。
4.循环等待条件:若干个执行流之间,形成一种头尾相接的互相等待对方资源的关系。我们也称这样的现象为环路等待。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。