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


目录

一、线程冲突

二、重入与线程安全

1、线程不安全的情况

2、线程安全的情况

3、不可重入的情况

4、可重入的情况

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

三、互斥锁

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销毁信号量 


一、线程冲突

        一共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、可重入和线程安全的联系

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

三、互斥锁

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

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

        锁通过对临界区加锁以保护共享资源。多个线程需要看到同一把锁,锁,本身就是一种共享资源,那么谁来保护锁的安全呢?为了实现互斥锁的操作,大多数体系结构提供了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 4
    pthread_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函数会返回一个错误码并且不会销毁该信号量。在销毁信号量时,需要保证所有使用该信号量的线程都已经结束。

猜你喜欢

转载自blog.csdn.net/gfdxx/article/details/130164939