在这里要引入几个概念
样例中的ticket是主线程的全局数据,在这里属于临界资源
ticket是临界资源,那么后续的判断ticket>0 和ticket–等操作就是访问临界资源的代码,属于临界区
for循环内的代码,即临界区其中执行到一半会被进行线程切换,那么该临界区并不具有原子性,也正是这个原因导致的bug
多核 CPU 真正实现了“同时执行多个任务”,多核 CPU 的每个核心都可以独立地执行一个任务,而且多个核心之间不会相互干扰。在不同核心上执行的多个任务,是真正地同时运行,这种状态就叫做并行。
下图展示了两个任务并行执行的过程:
就是通过一种算法将 CPU 资源合理地分配给多个任务,当一个任务执行 I/O 操作时,CPU 可以转而执行其它的任务,等到 I/O 操作完成以后,或者新的任务遇到 I/O 操作时,CPU 再回到原来的任务继续执行。
下图展示了两个任务并发执行的过程:
互斥能使多线程串行访问临界资源,对临界资源进行保护,有效避免出现多线程并发出现错误情况
大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。
但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。
多个线程并发的操作共享变量,会带来一些问题。
在这里只针对单CPU进行分析
先模拟一个多线程抢取火车票的程序。每个线程进到循环先判断ticket是否大于0,大于就进入循环,然后休眠,之后再模拟一个火车票数量减少。否则退出循环。
thread.cc
#include
#include
#include
#include
#include
#include
#include
#include
#include"mythread.hpp"
using namespace std;
int ticket=1000;
void* getticket(void*args)
{
string username=static_cast<const char*> (args);
while(true)
{
if(ticket>0)
{
usleep(1234);
cout<<"User name:"<<username<<"get tickets ing..."<<"ticket num: "<<ticket<<endl;
ticket--;
}else
{
break;//没票了退出循环
}
}
return nullptr;
}
int main()
{
unique_ptr<thread> thread1(new thread(getticket,(void*)"user1",1));
unique_ptr<thread> thread2(new thread(getticket,(void*)"user2",2));
unique_ptr<thread> thread3(new thread(getticket,(void*)"user3",3));
thread1->join();
thread2->join();
thread3->join();
return 0;
}
mythread.hpp
#include
#include
#include
#include
using namespace std;
class thread;//声明
class Context
{
public:
thread* _this;//this指针
void* _args;//函数参数
public:
Context()
:_this(nullptr)
,_args(nullptr)
{}
~Context()
{}
};
class thread
{
public:
typedef function<void* (void*)> func_t;//包装器构建返回值类型为void* 参数类型为void* 的函数类型
const int num=1024;
thread(func_t func,void* args,int number=0)//构造函数
: fun_(func)
,args_(args)
{
char namebuffer[num];
snprintf(namebuffer,sizeof namebuffer,"threa--%d",number);//缓冲区内保存线程的名字即几号线程
Context* ctx=new Context();//
ctx->_this=this;
ctx->_args=args_;
int n=pthread_create(&pid_,nullptr,start_rontine,ctx);//因为调用函数start_rontine是类内函数,具有缺省参数this指针,在后续解包参数包会出问题,所以需要一个类来直接获取函数参数
assert(n==0);
(void)n;
}
static void* start_rontine(void* args)
{
Context* ctx=static_cast<Context*>(args);
void *ret= ctx->_this->run(ctx->_args);//调用外部函数
delete ctx;
return ret;
}
void* run(void* args)
{
return fun_(args);//调用外部函数
}
void join()
{
int n= pthread_join(pid_,nullptr);
assert(n==0);
(void)n;
}
~thread()
{
//
}
private:
string name_;//线程的名字
pthread_t pid_;//线程id
func_t fun_;//线程调用的函数对象
void* args_;//线程调用的函数的参数
};
原因如下:
- if 语句判断条件为真以后,代码可以并发的切换到其他线程
- usleep 这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段
- ticket–操作本身就不是一个原子操作,而是对应三条汇编指令:
load :将共享变量ticket从内存加载到寄存器中
update : 更新寄存器里面的值,执行-1操作
store :将新值,从寄存器写回共享变量ticket的内存地址
线程一再次被切换到,先把上下文加载回CPU中,然后做ticket–。再次把内存中的ticket数据加载到CPU中,然后做–,再把ticket数据放回到内存中,此时ticket=0。到线程二,也做着与线程一同样的工作,ticket–后将数据放回到内存中,此时ticket=-1,同样的退出线程三操作后ticket=-2。这样就ticket为负数的情况。
要解决以上问题,需要做到以下:
这三点本质上就是互斥量的概念,Linux上提供这的这把锁叫互斥量
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;//静态分配,一般用于在全局定义
函数原型
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
const pthread_mutexattr_t *restrict attr);
函数原型
int pthread_mutex_destroy(pthread_mutex_t *mutex);
注意一下:
- 使用
PTHREAD_ MUTEX_ INITIALIZER
初始化的互斥量不需要销毁- 不要销毁一个已经加锁的互斥量
- 已经销毁的互斥量,要确保后面不会有线程再尝试加锁
函数原型
int pthread_mutex_lock(pthread_mutex_t *mutex);
函数原型
int pthread_mutex_trylock(pthread_mutex_t *mutex);
mutex是需要加锁的互斥量,需要传互斥量的地址
申请成功返回0,申请失败立刻返回错误码
函数原型
int pthread_mutex_unlock(pthread_mutex_t *mutex);
mutex是需要加解锁的互斥量,需要传互斥量的地址
申请成功返回0,申请失败返回错误码
对上面出错的代码进行修改
int ticket=1000;
pthread_mutex_t mut;//定义全局的互斥量
void* getticket(void*args)
{
string username=static_cast<const char*> (args);
while(true)
{
{
pthread_mutex_lock(&mut);//阻塞式加锁
if(ticket>0)
{
usleep(1234);
cout<<"User name:"<<username<<"get tickets ing..."<<"ticket num: "<<ticket<<endl;
ticket--;
pthread_mutex_unlock(&mut);//解锁
}else
{
pthread_mutex_unlock(&mut);//解锁
break;//没票了退出循环
}
}//将临界区放进代码块内
usleep(1000);//模拟产生订单
}
return nullptr;
}
int main()
{
pthread_mutex_init(&mut,nullptr);//初始化互斥量
unique_ptr<thread> thread1(new thread(getticket,(void*)"user1",1));
unique_ptr<thread> thread2(new thread(getticket,(void*)"user2",2));
unique_ptr<thread> thread3(new thread(getticket,(void*)"user3",3));
thread1->join();
thread2->join();
thread3->join();
pthread_mutex_destroy(&mut);//互斥量的销毁
return 0;
}
有关锁的概念:
- 给临界资源加锁后,多个执行流是串行访问,那么程序的执行速度相比于并发执行的速度是要慢的
- 加锁只规定线程串行执行临界区,而线程执行优先级由竞争结果决定
- 加锁的过程是安全的,即加锁的过程具有原子性
- 当先申请到锁的线程被切换时,锁也随之切换,其余线程也无法申请成功,共享区也就无法继续执行下去,直到该线程释放锁
- 使用锁的时候,保持共享区的粒度尽量小
- 对于访问临界资源的线程,尽量做到加锁一致性,要么给全部线程加锁,要么都不加
需要注意的是:
lock:
mob $0,%al //第一条指令
xchgb,%al,mutex //第二条指令
if(al寄存器的内容>0)
{
return 0;
}else
挂起等待;
goto lock;
unlock:
movb $1,mutex
唤醒等待Mutex的线程;
return 0;
然后判断al寄存器中的值是否大于0,若大于0则返回0,即加锁成功,此时线程一就已经对指定临界资源加锁成功;若为其他结果,则线程挂起等待。等待占用锁的线程释放锁
若此时线程一完成了一、二条指令即al寄存器中的值是1,mutex含的值是0。OS将线程一切换为线程二,线程一被切换的同时,在CPU的上下文也随之被切换。线程二被切换进来后,一样的执行第一、二条指令,先将0设置进al寄存器,然后将al寄存器的值与mutex含的值进行交换,此时mutex的值为1,那么进而判断mutex的值不大于0,判断为假,进而线程二需要挂起等待。
mutex.hpp
#include
using namespace std;
class Mutex{
public:
Mutex(pthread_mutex_t * mutex=nullptr):mutex_(mutex){}
void Lock()
{
if(mutex_)
{
pthread_mutex_lock(mutex_);//加锁
}
}
void UnLock()
{
if(mutex_)
{
pthread_mutex_unlock(mutex_);//解锁
}
}
private:
pthread_mutex_t *mutex_;
};
class LockReady
{
public:
LockReady(pthread_mutex_t* mutex)
:mutex_(mutex)
{
mutex_.Lock();//加锁
}
~LockReady()
{
mutex_.UnLock();//解锁
}
public:
Mutex mutex_;
};
- 线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作, 并且没有锁保护的情况下,会出现该问题。
- 重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
常见的线程不安全的情况
不保护共享变量的函数 |
---|
函数状态随着被调用,状态发生变化的函数 |
返回指向静态变量指针的函数 |
调用线程不安全函数的函数 |
而常见的线程安全的情况
每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限 |
---|
类或者接口对于线程来说都是原子操作 |
多个线程之间的切换不会导致该接口的执行结果存在二义性 |
常见不可重入的情况
调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的 |
---|
调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构 |
可重入函数体内使用了静态的数据结构 |
常见可重入的情况
不使用全局变量或静态变量 |
---|
不使用用malloc或者new开辟出的空间 |
不调用不可重入函数 |
不返回静态或全局数据,所有数据都有函数的调用者提供 |
使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据 |
那么可重入与线程安全之间有什么关系呢?
而可重入函数和线程安全有什么关系呢?
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源而处于的一种永久等待状态。换言之,一个线程持有资源不释放,向申请不到的资源进行申请,而申请失败处于阻塞等待状态,那么持有的资源将无法释放或者让别的线程使用,该线程就处于死锁状态。
这个概念多数应用于多线程场景,单执行流线程会造成死锁吗?答案是会的。一个线程连续对同一份资源申请加锁两次,那么该线程会被挂起。第一次申请成功,对资源成功加锁,第二次申请失败被挂起直到该锁被释放。然而此时该资源已经被线程自己加锁了,没有释放,那么线程就处于永久等待状态。
下面是一个单执行流引发死锁的案例
#include
#include
using namespace std;
void* start_routine(void* args)
{
long long * num=static_cast<long long*>(args);
pthread_mutex_t mut;
pthread_mutex_init(&mut,nullptr);//初始化锁
pthread_mutex_lock(&mut);//加锁
pthread_mutex_lock(&mut);//二次加锁
cout<<"加锁的数据是num: "<<*num<<endl;
pthread_mutex_unlock(&mut);//解锁
return nullptr;
}
int main()
{
pthread_t t1;
long long num=199;
pthread_create(&t1,nullptr,start_routine,(void*)num);
pthread_join(t1,nullptr);
return 0;
}
综上:
互斥条件:一个资源每次只能被一个执行流使用 |
---|
请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放 |
不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺 |
循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系 |
造成死锁的四个条件至少不具备一个
加锁顺序应当一致。即多个线程同时竞争同个资源,那么在一个资源竞争到还未释放时,其他线程应当阻塞等待,这样该线程申请别的资源时就能释放该资源,对其他资源加锁
避免锁未释放
资源一次性分配。避免一个线程多次对不同资源进行加锁解锁
可以运用避免死锁的算法,如死锁检测法,银行家算法等
线程同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步
竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。
需要明确的是:
- 条件变量函数的返回值都是:调用成功返回0,调用失败返回错误码
- 条件变量是
pthread_cond_t
类型,其本质是一个结构体。为简化理解,应用时可忽略其实现细节,简单当成整数看待。
函数原型
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict attr);
pthread_cond_t cond=PTHREAD_COND_INITIALIZER;//静态初始化条件变量—通常用在全局作用域
函数原型
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
- 先对调用函数的线程进行解锁(mutex),然后自动在条件变量cond队列尾部挂起等待
- 当接收到其他线程发来的信号时,唤醒该线程,并在函数返回时解除阻塞并重新申请获取互斥锁即加锁(mutex)
唤醒等待有两个函数
int pthread_cond_signal(pthread_cond_t *cond);
int pthread_cond_broadcast(pthread_cond_t *cond);
函数原型
int pthread_cond_destroy(pthread_cond_t *cond);
这里我用抢票系统这段来作演示,在没有条件变量时
#include
#include
#include
#include
#include
#include
using namespace std;
#define NUM 5
pthread_mutex_t mut=PTHREAD_MUTEX_INITIALIZER;//全局初始化互斥量
int ticket=1000;
void* start_routine(void* args)
{
string str=static_cast<const char*>(args);
while(true)
{
pthread_mutex_lock(&mut);//加锁
if(ticket>0)
{
cout<<str<<"-> ticket: "<<ticket<<endl;
ticket--;
}else
{
pthread_mutex_unlock(&mut);//解锁
break;
}
pthread_mutex_unlock(&mut);//解锁
}
}
int main()
{
pthread_t dt[NUM];//数组存储多个线程id
for(int i=0;i<NUM;i++)
{
char * name=new char[64];
snprintf(name,64,"thread:%d",i);//设置线程名字
int n= pthread_create(&dt[i],nullptr,start_routine,(void*)name);
assert(n==0);
}
while(true)
{
usleep(500000);//1-1000-1000000
cout<<"main thread wake up one thread"<<endl;
}
for(int i=0;i<NUM;i++)
{
pthread_join(dt[i],nullptr);//回收线程
}
return 0;
}
加上条件变量
#include
#include
#include
#include
#include
#include
using namespace std;
#define NUM 5
pthread_mutex_t mut=PTHREAD_MUTEX_INITIALIZER;//全局初始化互斥量
pthread_cond_t cond=PTHREAD_COND_INITIALIZER;//全局初始化条件变量
int ticket=1000;
void* start_routine(void* args)
{
string str=static_cast<const char*>(args);
while(true)
{
pthread_mutex_lock(&mut);//加锁
if(ticket>0)
{
pthread_cond_wait(&cond,&mut);//让调用函数线程在条件变量出阻塞等待
//到这里说明在条件变量处阻塞等待的线程已经被唤醒
cout<<str<<"-> ticket: "<<ticket<<endl;
ticket--;
}else
{
pthread_mutex_unlock(&mut);//解锁
break;
}
pthread_mutex_unlock(&mut);//解锁
}
}
int main()
{
pthread_t dt[NUM];//数组存储多个线程id
for(int i=0;i<NUM;i++)
{
char * name=new char[64];
snprintf(name,64,"thread:%d",i);//设置线程名字
int n= pthread_create(&dt[i],nullptr,start_routine,(void*)name);
assert(n==0);
}
while(true)
{
usleep(500000);//1-1000-1000000
pthread_cond_signal(&cond);//给条件变量发信号,唤醒处在条件变量处的阻塞的一个线程
pthread_cond_broadcast(&cond);给条件变量发信号,唤醒处在条件变量处的阻塞的全部线程
cout<<"main thread wake up one thread"<<endl;
}
for(int i=0;i<NUM;i++)
{
pthread_join(dt[i],nullptr);//回收线程
}
return 0;
}
pthread_cond_wait
函数,新线程先是解锁然后在条件变量等待主线程发送信号,主线程发送信号给新线程,新线程接收到信号后,先加锁,然后执行临界区的代码。之后解锁。再次竞争式加锁,进入临界区后,由于pthread_cond_wait
函数,新线程先是解锁然后自动在条件变量队列尾部阻塞等待。pthread_cond_wait
函数需要传入互斥量,将线程挂起时自动把锁释放掉,避免了死锁问题,而满足条件后唤醒线程,能够往后执行临界区的代码,这时候就需要给线程重新加锁错误的执行逻辑
前面提到pthread_cond_wait
函数一是会将线程挂起并把锁释放,二是线程被唤醒后自动给线程加锁,那么我们也可以让线程进入临界区后先将锁释放,然后再让线程通过pthread_cond_wait
函数挂起等待。
// 错误的设计
pthread_mutex_lock(&mutex);
while (condition_is_false) {
pthread_mutex_unlock(&mutex);
//解锁之后,等待之前,条件可能已经满足,信号已经发出,但是该信号可能被错过
pthread_cond_wait(&cond);
pthread_mutex_lock(&mutex);
}
pthread_mutex_unlock(&mutex);
phread_cond_signal
函数给该线程发送了信号,而此时挂起的线程将错过这个信号,最终可能会导致线程永远不会被唤醒,因此解锁和等待必须是一个原子操作。pthread_cond_wait
函数后,会先判断条件变量是否等于0,若等于0则说明不满足,此时会先将对应的互斥锁解锁,直到pthread_cond_wait
函数返回时再将条件变量改为1,并将对应的互斥锁加锁。条件变量使用规范
pthread_mutex_lock(&mutex);
while (条件为假)
pthread_cond_wait(cond, mutex);
修改条件
pthread_mutex_unlock(&mutex);
pthread_mutex_lock(&mutex);
设置条件为真
pthread_cond_signal(cond);
pthread_mutex_unlock(&mutex);