> 文章列表 > 【Linux】多线程的互斥与同步

【Linux】多线程的互斥与同步

【Linux】多线程的互斥与同步


目录

一、线程冲突

二、重入与线程安全

1、线程不安全的情况

2、线程安全的情况

3、不可重入的情况

4、可重入的情况

5、可重入和线程安全的联系

6、STL是否线程安全

7、智能指针是否线程安全

三、互斥锁

1、互斥锁的使用

2、基于RAII风格的互斥锁的封装

2.1Mutex.hpp

2.2mythread.cc 

四、死锁

1、死锁的概念

2、发生死锁的四个必要条件

3、避免死锁的条件

五、线程同步

1、线程同步的概念

2、条件变量

3、信号量

3.1信号量的概念

3.2信号量的pv原语(原子,语句)

3.3初始化信号量

3.4等待信号量(P操作)

3.5发布信号量(V操作) 

3.6销毁信号量 

3、自旋锁(轮询)

4、读写锁(只允许一个写者写入,支持多个读者读取)

六、其他常见锁


一、线程冲突

        一共10000张票,三个线程理轮流抢票啊,抢到最后,剩余票数为-1。是因为单核CPU只有一套寄存器。

        比如现在两个新线程都被卡在usleep休眠。在usleep发生线程切换的过程中,线程会保存寄存器中的上下文数据,当该线程再次被调度,1、恢复上下文数据2、执行tickets--语句3、去内存中获取tickets数据再--4、将新数据写回内存。这时票数为0,但目前还有一个线程正准备执行tickets--的运算,所以票数会出现负数现象。

二、重入与线程安全

1、线程不安全的情况

  • 不保护共享变量的函数
  • 函数状态随着被调用,状态发生变化的函数;
  • 返回指向静态变量指针的函数 ;
  • 调用线程不安全函数的函数。

2、线程安全的情况

  • 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的 ;
  • 类或者接口对于线程来说都是原子操作 ;
  • 多个线程之间的切换不会导致该接口的执行结果存在二义性。

3、不可重入的情况

  • 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
  • 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
  • 可重入函数体内使用了静态的数据结构

4、可重入的情况

  • 不使用全局变量或静态变量
  • 不使用malloc或者new开辟出的空间
  • 不调用不可重入函数
  • 不返回静态或全局数据,所有数据都有函数的调用者提供
  • 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据

5、可重入和线程安全的联系

  • 函数是可重入的,那就是线程安全的
  • 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
  • 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
  • 可重入函数是线程安全函数的一种
  • 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
  • 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。

6、STL是否线程安全

        不是。STL为了将性能提升到极致,并没有做线程安全的保护,如果后续自己在多线程编程的时候,涉及线程安全需自己加锁。对于不同的容器, 加锁方式的不同, 性能可能也不同(例如hash表的锁表和锁桶).

7、智能指针是否线程安全

        对于 unique_ptr, 由于只是在当前代码块范围内生效, 因此不涉及线程安全问题;

        对于 shared_ptr, 多个对象需要共用一个引用计数变量, 所以会存在线程安全问题. 但是标准库实现的时候考虑到了这个问题, 基于原子操作(CAS)的方式保证 shared_ptr 能够高效, 原子的操作引用计数。

三、互斥锁

互斥想让多个线程串行访问共享资源;

原子性:对一个资源进行访问的时候,要么不做,要么做完。比如使用一条汇编就能完成对资源的操作,则称为原子性。

        锁通过对临界区加锁以保护共享资源。多个线程需要看到同一把锁,锁,本身就是一种共享资源,那么谁来保护锁的安全呢?为了实现互斥锁的操作,大多数体系结构提供了swap会exchange指令,该指令是把寄存器和内存单元的数据进行交换,相当于只有一条指令,保证了原子性。即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。这保证了pthread_mutex_lock加锁过程的安全。加锁的过程是原子的。

        如果线程对锁申请成功,就获得了向后执行的权利;如果线程因为锁被别的线程拿走了,从而导致本次申请锁失败,自身将会被休眠阻塞挂起进行等待,直到某位“ 揣”着锁的线程把锁还回去才解除休眠。

        当然,某位线程“揣”着锁在临界区运行,这个时候时间片到了,该线程是会被切出CPU的,但是它是“抱”着锁走的,其他线程因为拿不到这个锁,这个期间是绝对无法访问临界区的。

        1、我们在使用锁的时候,一定要保证锁的范围最小化,刚好卡在临界区两端的代码即可,减小临界区跨度,提高线程在临界区的运行效率。

        2、某段代码要加锁,就要把这把锁让所有线程看到,不能给某个线程搞特殊,给他运行中断代码不用锁。。。。

1、互斥锁的使用

        为了防止线程出现并行访问导致结果出现异常,使用互斥锁让线程串行访问。

PTHREAD_MUTEX_DESTROY(3P)  
#include <pthread.h>
销毁:int pthread_mutex_destroy(pthread_mutex_t *mutex);
初始化:int pthread_mutex_init(pthread_mutex_t *restrict mutex,const pthread_mutexattr_t *restrict attr);
初始化:pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;//相当于使用默认属性调用 pthread_mutex_init()。
返回值:如果成功,pthread_mutex_delete()和pthread_mutex_init()函数将返回零; 
否则,将返回一个错误号来指示错误。
如果实现了[EBUSY]和[EINVAL]错误检查,它们的行为就好像是在函数处理开始时立即执行的,
并且应该在修改由互斥对象指定的互斥对象的状态之前导致错误返回
PTHREAD_MUTEX_LOCK(3P)   
#include <pthread.h>
加锁:int pthread_mutex_lock(pthread_mutex_t *mutex);
//尝试拿到指定的互斥锁。如果互斥锁已经被另一个线程锁定,则该函数立即返回一个返回值为EBUSY的错误。
//如果互斥锁可用,则函数锁定它并返回0
尝试锁定互斥锁:int pthread_mutex_trylock(pthread_mutex_t *mutex);
解锁:int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:如果成功,pthread_mutex_lock ()和pthread_mutex_unlock()函数将返回零;
否则,将返回一个错误号来指示错误。
如果在互斥对象引用的互斥对象上获得了一个锁,那么pthread_mutex_trylock()函数将返回零。
否则,错误号返回以指示错误。
除了把锁定义成局部的方式,还可以把锁定义成static或全局
这样就可以无需pthread_mutex_init()来初始化锁,可直接使用PTHREAD_MUTEX_INITIALIZER宏来初始化锁
//pthread_mutex_t lock=PTHREAD_MUTEX_INITIALIZER;
struct ThreadData
{ThreadData(const std::string& threadname,pthread_mutex_t* mutex_p):_threadname(threadname),_mutex_p(mutex_p){}~ThreadData(){}std::string _threadname;//线程名pthread_mutex_t* _mutex_p;//锁的指针
};
int tickets=10000; 
void* getTicket(void* args)
{ //string threadName=static_cast<const char*>(args);ThreadData* td=static_cast<ThreadData*>(args);//取出参数while(1){//加锁pthread_mutex_lock(td->_mutex_p);if(tickets>0){usleep(1000);cout<<"新线程"<<td->_threadname<<"->"<<tickets--<<endl;pthread_mutex_unlock(td->_mutex_p);//解锁}else{pthread_mutex_unlock(td->_mutex_p);//解锁break;}}return nullptr;
}int main()
{
#define NUM 4pthread_mutex_t lock;//定义互斥锁pthread_mutex_init(&lock,nullptr);//初始化互斥锁std::vector<pthread_t> tids(NUM);//线程ID数组//创建线程for(int i=0;i<NUM;++i){char buffer[64];snprintf(buffer,sizeof(buffer),"thread %d",i+1);ThreadData* td=new ThreadData(buffer,&lock);//传入的是同一把锁pthread_create(&tids[i],nullptr,getTicket,(void*)td);}//主线程循环等待新线程for(const auto& tid:tids){pthread_join(tid,nullptr);}pthread_mutex_destroy(&lock);//释放锁资源return 0; 
}

        使用了互斥锁之后,票数抢到0就正常结束了,完美解决了上述问题。通过控制台输出的打印结果可以看到:

        1、程序执行变慢了。这是因为加锁之后新线程由原先的并行执行变为串行执行;

        2、会出现用一个线程连续多次抢到票。这是因为锁只规定互斥访问,并没有规定让哪个线程优先执行,这完全是各线程竞争的结果。对于同一个线程连续多次抢到票,这可能是当前线程抢到锁之后,其他进程被挂起,当前线程一释放锁又立马竞争到了锁。这里可以让线程抢到票后去sleep一下,模拟抢到票后线程去干其他事情,让别的线程也有竞争机会。

2、基于RAII风格的互斥锁的封装

2.1Mutex.hpp

#pragma once
#include <iostream>
#include <pthread.h>
class Mutex//锁的对象
{
public:Mutex(pthread_mutex_t* lock_p=nullptr):_lock_p(lock_p){}~Mutex(){}void lock(){if(_lock_p){pthread_mutex_lock(_lock_p);}}void unlock(){if(_lock_p){pthread_mutex_unlock(_lock_p);}}
private:pthread_mutex_t* _lock_p;//锁的指针
};
class LockGuard
{
public:LockGuard(pthread_mutex_t* mutex):_mutex(mutex){_mutex.lock();//在构造函数进行加锁}~LockGuard(){_mutex.unlock();//在析构函数进行解锁}private:Mutex _mutex;
};

2.2mythread.cc 

#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include "Mutex.hpp"
using namespace std;   
pthread_mutex_t lock=PTHREAD_MUTEX_INITIALIZER;
int tickets=10000; 
void* getTicket(void* args)
{ string threadName=static_cast<const char*>(args);while(1){{   //RAII风格的加锁LockGuard lockgrand(&lock);//在作用域中自动加锁if(tickets>0){usleep(1000);std::cout<<threadName<<"正在进行抢票"<<tickets<<std::endl;--tickets;}else{break;}}//出了作用域自动解锁usleep(1000);}return nullptr;
}
int main()
{pthread_t t1, t2, t3, t4;pthread_create(&t1, nullptr, getTicket, (void *)"thread 1");pthread_create(&t2, nullptr, getTicket, (void *)"thread 2");pthread_create(&t3, nullptr, getTicket, (void *)"thread 3");pthread_create(&t4, nullptr, getTicket, (void *)"thread 4");pthread_join(t1, nullptr);pthread_join(t2, nullptr);pthread_join(t3, nullptr);pthread_join(t4, nullptr);return 0; 
}

四、死锁

1、死锁的概念

        多个进程或线程因为互相等待对方释放资源而陷入了无限等待的状态。例如一个线程持有自己的锁不释放,还想要拿对方手里的那把锁,对方也是如此,这就形成了死锁。

一把锁也会出现死锁:重复申请一把锁

pthread_mutex_t lock;
pthread_mutex_lock(&lock);//线程A申请了锁
pthread_mutex_lock(&lock);//线程A再次申请这把锁,毫无疑问,本次申请失败,线程A被挂起阻塞

2、发生死锁的四个必要条件

1、互斥:锁的特性

2、请求与保持:我要你的资源,但我保持我的资源。

3、不剥夺:不考虑优先级等条件抢夺别人的锁

4、环路等待条件:例如A->B->C->A形成一个环状资源索要圈子,互相等待对方释放资源。

3、避免死锁的条件

1、能不用锁就不要用锁

2、保证加锁顺序一致,破坏环路等待;

3、避免锁未释放的场景;

4、资源一次性分配。

5、死锁检测算法:

  1. 银行家算法:该算法用于预防和避免死锁,也可以用于检测死锁。银行家算法通过分配资源和回收资源的方式,预测和避免系统进入死 锁状态。
  2. 图论算法:该算法将系统中的进程和资源看作节点,将它们之间的关系看作边,构成一个图。通过检测图中是否存在环,来判断系统是否处于死锁状态。
  3. 等待图算法:该算法将系统中的进程和资源看作节点,将它们之间的等待关系看作边,构成一个等待图。通过检测图中是否存在环,来判断系统是否处于死锁状态。
  4. 时间片轮询算法:该算法通过轮询系统中的进程,检测它们是否处于阻塞状态。如果一个进程处于阻塞状态,并且它所等待的资源已被其他进程占用,那么这个进程就可能处于死锁状态

五、线程同步

1、线程同步的概念

        在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效的避免饥饿问题。

2、条件变量

        条件变量是一种变量:

PTHREAD_COND_DESTROY(3P)  
#include <pthread.h>
int pthread_cond_destroy(pthread_cond_t *cond);
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict attr);//cond:条件变量的指针;attr:条件变量的属性,不需要则设为NULL
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
返回值:如果成功,pthread_cond _delete()和pthread_cond_init()函数将返回零; 否则,错误号将返回以指示错误如果实现了[EBUSY]和[EINVAL]错误检查,那么它们的行为应该像在函数处理开始时立即执行的一样
并在修改由 cond 指定的条件变量的状态之前导致错误返回。

        条件变量提供了线程等待和线程唤醒的接口:

PTHREAD_COND_TIMEDWAIT(3P) #include <pthread.h>int pthread_cond_timedwait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex,const struct timespec *restrict abstime);
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);
返回值:如果在函数执行过程中发生了错误,则会立即返回错误号,
不会修改mutex或cond的状态。如果函数执行成功,则返回0。
PTHREAD_COND_BROADCAST(3P)
#include <pthread.h>
唤醒一批线程:
int pthread_cond_broadcast(pthread_cond_t *cond);
唤醒一个线程:
int pthread_cond_signal(pthread_cond_t *cond);//cond:条件变量,用于唤醒一个等待在该条件变量上的线程
返回值:如果成功,pthread_cond_Broadcasting()和pthread_cond_information()函数将返回零;
否则,将返回错误号指示错误。

        当条件变量不满足的时候,对应线程必须去某些定义好的条件变量上进行等待。

void push(const T& in)//输入型参数:const &
{pthread_mutex_lock(&_mutex);//细节2:充当条件的判断必须是while,不能用if//这是因为唤醒的时候存在唤醒异常或伪唤醒的情况//需要让线程重新使用IsFull对空间就行判断,确保100%唤醒while(IsFull()){//细节1://该线程被pthread_cond_wait函数挂起后,会自动释放锁。//该线程被pthread_cond_signal函数唤醒后,会自动重新获取原来那把锁pthread_cond_wait(&_pcond,&_mutex);//因为生产条件不满足,无法生产,此时我们的生产者进行等待}//走到这里一定没有满_q.push(in);//刚push了一个数据,可以试着消费者把他取出来(唤醒消费者)//细节3:pthread_cond_signal()这个函数,可以放在临界区内部,也可以放在临界区外部pthread_cond_signal(&_ccond);//可以设置水位线,满多少就唤醒消费者pthread_mutex_unlock(&_mutex);//pthread_cond_signal(&_ccond);//也可以放在解锁之后
}

        1、pthread_cond_wait()接口的第二个参数必须是我们当前上下文正在使用的那把互斥锁。这是因为当我们调用该函数时,这个线程必然拿着这把锁;如果这个线程被pthread_cond_wait()挂起了,那这把锁不是谁都拿不到了吗?为了解决这一问题,该函数会以原子性的方式,将锁释放,并将当前线程挂起。线程挂起后,会一直在临界区卡着,当pthread_cond_signal()函数唤醒该线程后,该线程会重新自动获得这把锁,继续执行下方代码。

        2、充当的条件判断必须是while不能是if

        3、pthread_cond_signal()这个函数,可以放在临界区内部,也可以放在临界区外部。

3、信号量

        之前讲的多个线程为了访问临界资源,必须频繁锁的就绪情况。信号量的意义在于它提供了一种有效的方式来避免多个进程或线程同时访问某个共享资源时的竞争条件,从而避免了死锁和饥饿等问题。同时,信号量还可以用于实现进程间通信和线程间通信,例如生产者-消费者模型中的缓冲区就可以使用信号量来协调生产者和消费者的访问。

3.1信号量的概念

        只要拥有信号量,线程在未来就一定可以拥有临界资源的一部分,申请信号量的本质就是对临界资源中特定的小块资源的预定。(临界资源可能被分成一块块的小块资源。)(类似电影院买票,只要买了票,未来这场电影一定有我的位置)

        既然我买票成功了,那我不用打电话去问电影院也能知道这场电影是否有我的位置。同理,一个线程只要成功申请了信号量,未来就一定可以成功访问到临界资源。那么就可以摒弃之前访问临界资源需要先加锁,再检测锁的操作,转而通过申请信号量的形式确定未来是否可以访问临界资源。

3.2信号量的pv原语(原子,语句)

信号量也是一个共享资源,也需要访问的安全,信号量的pv原语保证了信号量++--的原子性:

  • 信号量--:代表申请资源——P操作
  • 信号量++:代表归还资源 。——V操作

3.3初始化信号量

SEM_INIT(3) 
#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);
Link with -pthread.

用途:sem_init是一个信号量初始化函数,用于初始化一个未命名的信号量。

参数:

  • sem:指向要初始化的信号量的指针。
  • pshared:指定信号量的类型。如果pshared为0,表示信号量只能用于同一进程内的线程之间的同步;如果pshared为非零值,表示信号量可用于多个进程之间的同步。
  • value:指定信号量的初始值。

返回值:

  • 如果sem_init函数调用成功,返回值为0。
  • 如果调用失败,返回值为-1,并设置errno为相应的错误代码。

3.4等待信号量(P操作)

SEM_WAIT(3)   
#include <semaphore.h>
int sem_wait(sem_t *sem);
int sem_trywait(sem_t *sem);
int sem_timedwait(sem_t *sem, const struct timespec *abs_timeout);
Link with -pthread.

用途:sem_wait是一个信号量P操作函数,用于将信号量的值减1。如果信号量的值为0,调用sem_wait函数的线程将被阻塞,直到信号量的值大于0为止。

参数:

  • sem:指向要操作的信号量的指针。

返回值:

  • 如果sem_wait函数调用成功,返回值为0。
  • 如果调用失败,返回值为-1,并设置errno为相应的错误代码。

3.5发布信号量(V操作) 

SEM_POST(3)   
#include <semaphore.h>
int sem_post(sem_t *sem);
Link with -pthread.

用途:sem_post是一个信号量V操作函数,用于将信号量的值加1。如果有线程因为等待信号量的值为0而被阻塞,调用sem_post函数后,其中的一个线程将被唤醒。

参数:

  • sem:指向要操作的信号量的指针。

返回值:

  • 调用成功时,返回值为0。
  • 调用失败时,返回值为-1,错误代码存储在errno中。常见的错误代码包括:EINVAL(无效的参数)、EPERM(权限不足)等。

3.6销毁信号量 

#include <semaphore.h>
int sem_destroy(sem_t *sem);
Link with -pthread.
#include <semaphore.h>
int sem_destroy(sem_t *sem);
Link with -pthread.
用途:sem_destroy是一个信号量的销毁函数,用于释放已经创建的信号量所占用的资源。
参数:
● sem为需要销毁的信号量的指针。
返回值:sem_destroy函数的返回值为0表示销毁成功,否则表示销毁失败。如果当前有线程在等待该信号量,则sem_destroy函数会返回一个错误码并且不会销毁该信号量。在销毁信号量时,需要保证所有使用该信号量的线程都已经结束。

3、自旋锁(轮询)

        线程申请锁失败,会疯狂的轮询这把锁是否就绪,自旋锁相较于挂起等待锁的优势在于,当线程处理临界区代码很快时,使用自旋锁可以很快的检测出锁已就绪,提升代码执行效率。但如果处理临界区代码消耗时间过久,自旋的线程疯狂轮询会极大降低CPU的执行效率。

        那么在自旋锁和挂起等待锁的选择上该如何选择?程序员可以分别测试两种锁的效率,再进行选用。

PTHREAD_SPIN_LOCK(3P)
#include <pthread.h>
int pthread_spin_lock(pthread_spinlock_t *lock);
int pthread_spin_trylock(pthread_spinlock_t *lock);

4、读写锁(只允许一个写者写入,支持多个读者读取)

三种读者、写者之间的关系:

  • 1、写者和写者之间的互斥关系;
  • 2、读者和读者之间的没有关系,谁都可以读;
  • 3、读者和写者之间的互斥关系与同步关系。(互斥:需要保证读写安全;同步:当缓冲区数据满了或空了,能够互相等待和通知)

        读者写者模型与生产者消费者模型最大的区别在于读者写者模型的读者不会拿走数据,而生产者消费者模型的消费者会拿走数据。

        读者写者模型的应用场景:适用于一次发布,很长时间不做修改,大部分时间都是在被读取。

PTHREAD_RWLOCK_DESTROY(3P)    
#include <pthread.h>
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock,const pthread_rwlockattr_t *restrict attr);

写者:

PTHREAD_RWLOCK_TRYWRLOCK(3P) 
#include <pthread.h>
int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);

读者:

PTHREAD_RWLOCK_RDLOCK(3P)  
#include <pthread.h>
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);

读写锁的行为

当前锁状态

读锁请求

写锁请求

无锁

可以

可以

读锁

可以

不可以

写锁

不可以

可以

        在同一时间,读写模型只允许一个写者进行写入,但支持多个读者进行读取(写者写入时读者阻塞, 读者读取时写者阻塞)

        如果有大量的读线程势必会造成写者饥饿,读者优先符合该模型。

        如果想让写者优先,可以考虑写者就绪时(可以用一个标志位,标志位反转了说明写者准备申请写锁了),此时不再让读者申请读取,等内部所有读者读完并归还写锁后,写者马上申请写锁进行写入。

六、其他常见锁

        悲观锁在每次取数据时,总是担心数据会被其他线程修改,所以会在取数据前先加锁(读锁,写锁,行锁等),当其他线程想要访问数据时,被阻塞挂起。

        乐观锁:每次取数据时候,总是乐观的认为数据不会被其他线程修改,因此不上锁。但是在更新数据前,会判断其他线程在更新前有没有对数据进行修改。主要采用两种方式:版本号机制和CAS操作。

        CAS操作:比较并交换。当需要更新数据时,判断当前内存值和之前取得的值是否相等。如果相等则用新值更新。若不等则失败,失败则重试,一般是一个自旋的过程,即不断重试。

        公平锁:多个线程按照请求锁的顺序来获取锁,先到先得,遵循"先来后到"的原则。当锁释放后,等待时间最长的线程会获得锁,保证每个线程都有公平竞争的机会。公平锁是一种比较安全的锁,但是会降低系统的吞吐量。

        非公平锁:多个线程获取锁的顺序是不确定的,有可能后请求锁的线程先获取锁,不遵循"先来后到"的原则。当锁释放后,系统会任意挑选一个等待的线程获取锁。非公平锁的优点是提高了系统的吞吐量,但是容易导致某些线程一直获取不到锁,导致饥饿现象。