线程的互斥与同步

Linux中的线程与进程

线程的互斥与同步_第1张图片

一、线程栈

1、线程自己的栈空间

线程的互斥与同步_第2张图片

线程库会被加载到进程地址空间中(共享区),tid为线程对象的起始地址。

多线程情况下测试局部变量test_i

#define NUM 5
struct threadData
{
    string threadname;
};
string toHex(pthread_t tid)
{
    char buffer[128];
    snprintf(buffer,sizeof(buffer),"0x%x",tid);
    return buffer;
}
void* threadRoutine(void* args)
{
    threadData* td = static_cast(args);
    int cnt=10;
    int test_i=0;
    while(cnt--)
    {
        cout<<"pid:"<threadname<threadname = "thread-" + to_string(number);
}
int main()
{
    vector tids;
    for(int i=0;i

        线程的互斥与同步_第3张图片

每个线程的test_i都是独立的,有自己的地址,是在线程各自的栈空间上开辟的。

堆空间是共享的,每个线程分配一块。

int *p = nullptr;定义一个全局的p变量

线程的互斥与同步_第4张图片

线程的互斥与同步_第5张图片

主线程中可以获取子线程的栈区上的局部变量。

也就是说,线程之间虽然有独立的栈区,但线程之间也是可以做到互相访问的。(在地址空间中

但实际使用时,规定不能这样使用。

2、线程局部存储

int g_val1=0;

线程的互斥与同步_第6张图片

线程的互斥与同步_第7张图片

__thread编译选项,运用线程局部存储原理,在共享区上创建一个私有全局变量

只能创建内置类型

应用:可以保存一些需要系统调用的值(获取一些基本属性),提高效率。

__thread int g_val2=0;

线程的互斥与同步_第8张图片

二、线程分离

默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏。
如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。
int pthread_detach(pthread_t thread);
可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离。
pthread_detach(pthread_self());
joinable 和分离是冲突的,一个线程不能既是 joinable 又是分离的。
主线程分离
  for (auto i : tids)
    {
        pthread_detach(i);//主线程分离
    }
   
    for (int i = 0; i < tids.size(); i++)
    {
        int n = pthread_join(tids[i], nullptr);
        printf("n = %d, who = 0x%x, why: %s\n", n, tids[i], strerror(n));
    }
其它线程自己分离
线程的互斥与同步_第9张图片
线程的互斥与同步_第10张图片
注:进程分离后,必须保证进程最后退出。否则分离后,进程join时不再阻塞等待,进程结束,进程退出,所有线程都会退出,该做的任务就没有完成。
线程的互斥与同步_第11张图片
主线程调用pthread_exit只是退出主线程,并不会导致进程的退出
是否被分离,是看线程tcb中自身的属性是joinable还是分离的(0还是1)

三、线程互斥

进程线程间的互斥相关背景概念
临界资源:多线程执行流共享的资源就叫做临界资源
临界区:每个线程内部,访问临界资源的代码,就叫做临界区
互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成
互斥量mutex
大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。
但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之 间的交互。
多个线程并发的操作共享变量,会带来一些问题。

多线程并发抢票:

// -----------多线程并发抢票,互斥
#define NUM 4

int tickets = 1000;
class threadData
{
public:
    threadData(int number)
    {
        threadName = "thread-" + to_string(number);
    }
public:
    string threadName;
};
string toHex(pthread_t tid)
{
    char buffer[128];
    snprintf(buffer, sizeof(buffer), "0x%x", tid);
    return buffer;
}
void *getTicket(void *args)
{
    threadData* td = static_cast(args);
    while(tickets>0)
    {
        usleep(10000);
        --tickets;
        cout<<"threadname:"<threadName<<" tid:"
<threadName< tids;
    vector thread_Datas;
    for (int i = 1; i <= NUM; ++i)
    {
        pthread_t tid;
        threadData* td = new threadData(i);
        thread_Datas.push_back(td);
        pthread_create(&tid, nullptr, getTicket, thread_Datas[i-1]);
        tids.push_back(tid);
    }

    for(auto tid:tids)
    {
        pthread_join(tid,nullptr);
    }
    for(auto td:thread_Datas)
    {
        delete td;
    }
    return 0;
}

线程的互斥与同步_第12张图片

对一个全局变量多线程并发--

tickets--的操作不是原子性的,而是分为三个步骤:

  1. 将共享变量tickets从内存加载到寄存器中
  2. 更新寄存器里面的值 执行-1操作
  3. 将新值从寄存器写回共享变量tickets的内存地址

线程的互斥与同步_第13张图片

例子:

假设现在有2个线程thread-1 和 thread-2进行抢票工作,分为上面的1,2,3步。

对于thread-1:执行第一步,从内存中读取到1000,1步完成。此时时间片到了,切换为thread-2

对于thread-2:时间片剩余较多,假设可以完整完成100次抢票工作。最后一次完成3步,此时eax中保存的值是900,最终将其写回内存中。然后切换回thread-1.

对于thread-1:继续第2步,先恢复上下文数据,将1000写到eax中,计算后为999,再进行第三步写回内存,此时内存中的值就变为999了,也就是多了100张票。

即对于thread2来说,tickets值前后不一致,即数据不一致问题。

tips:寄存器的内容!=寄存器

保存上下文到线程的对象内部,每次轮转到时恢复到CPU内的寄存器中

也就是thread-1认为自己一直在正确地--,实际上保存在上下文的那一份,拷贝回内存时,导致了最终的数据不一致问题。

线程的互斥与同步_第14张图片

数据不一致的原因:

tickets--的操作不是原子性的,即允许多个执行流同时进入,会互相干扰。

为避免该问题,则需要加锁操作。

为什么票数<=0时还能抢票呢?

判断tickets时,成立进入。

但设置了usleep,为了让多线程都停留在判断进入,但没有--操作,就被切换走了。

此时就会出现tickets为1,但有>1个线程判断成立。

后续的--操作就不判断tickets的值是否>0了

每次--都需要重新读取tickets的值,在这之前tickets可能已经被其它线程修改了。

四、互斥锁

1、init/destroy

线程的互斥与同步_第15张图片

pthread_mutex是库提供的一种数据类型

全局的mutex不用手动init和destroy

线程的互斥与同步_第16张图片

在main函数中创建并init一个lock。

在threadData中加入一个锁的指针,多线程对于一个临界区,共用一把锁。

线程的互斥与同步_第17张图片

2、lock/trylock/unlock

线程的互斥与同步_第18张图片

多线程共享的资源是临界资源,访问临界资源的代码叫临界区。

加锁本质:是用时间换安全。

加锁表现:原来多线程并发执行,对于加锁的临界区的代码变为串行执行。(并发度下降)

加锁原则:临界区的代码越少越好。

场景1:

线程的互斥与同步_第19张图片

lock和unlock之间的代码就是临界区。

问题1:

把lock和unlock放在while(1)外部会怎么样?

一直是一个线程在抢票(执行while(1)),与逻辑不符。

问题2:

lock失败则会阻塞等待,直到申请成功。

问题3:

tickets为0时,break跳出,不会unlock

在if和else中都要加unlock

线程的互斥与同步_第20张图片

锁的竞争性与饥饿问题:

线程的互斥与同步_第21张图片

上述代码加锁后,不会出现负数情况,但为什么还是一个线程在抢票?

线程的互斥与同步_第22张图片

这是由于多个线程对于锁的竞争性不同导致的。

对于lock的线程,unlock后可以直接继续lock,中间间隔很短,lock的概率大

只要lock成功,即使轮转到别的线程,其它线程也只能阻塞等待。

对于其它线程,线程的切换需要的时间很长,lock的概率就很小。

在抢票完成后usleep,此时持有锁的进程不会立刻下一次lock,而是和其它线程一样进行时间片轮转,多线程直接lock的概率差距就变小了,即对锁的竞争性就差不多了。

饥饿问题:

线程的互斥与同步_第23张图片

解决方案--同步:

线程的互斥与同步_第24张图片

锁的申请和释放是原子性的:

联系信号量,PV操作的设计也是原子性不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成)

锁是为了保护临界资源的,各线程申请一把锁,锁本身也是临界资源

 线程切换时,是持有锁一起切换走的,这期间其它线程不能进入临界区访问临界资源。该持有锁的线程访问临界区的过程,对其它线程来说是原子的。

实现原理:

为了实现互斥锁的操作,大多数体系结构都提供了swapexchange指令,该指令的作用就是把寄存器和内存单元的数据相交换(一条汇编指令完成,即原子性的

线程的互斥与同步_第25张图片


以加锁示例,这是由多条汇编语句执行的,上述%al是寄存器mutex就是内存中的一个变量。每个线程申请锁时都要执行上述语句,执行步骤如下:

  • movb $0%al先将al寄存器中的值清0。该动作可以被多个线程同时执行,因为每个线程都有自己的一组寄存器(上下文信息),执行该动作本质上是将自己的al寄存器清0。注意:凡是在寄存器中的数据,全部都是线程的内部上下文!多个线程看起来同时在访问寄存器,但是互不影响。
  • xchgb %almutex然后用此一条指令交换al寄存器和内存中mutex的值,xchgb是体系结构提供的交换指令,该指令可以完成寄存器和内存单元之间数据的交换
  • 最后判断al寄存器中的值是否大于0。若大于0则申请锁成功,此时就可以进入临界区访问对应的临界资源;否则申请锁失败需要被挂起等待,直到锁被释放后再次竞争申请锁。

例子1:A-lock

现在线程A要开始加锁,执行上述语句。首先(movb $0%al),线程A把0读进al寄存器(清0寄存器)

线程的互斥与同步_第26张图片

然后执行第二条语句(xchgb %almutex)将al寄存器中的值与内存中mutex的值进行交换。

线程的互斥与同步_第27张图片

  • 当线程A争议执行第三条语句if判断时,发生了线程切换(切至线程B),但是线程A要把自己的上下文(1)带走。线程B也要执行加锁动作,同样是第一条语句把0加载到寄存器,清0寄存器。

线程的互斥与同步_第28张图片

  • 随后线程B执行第二条语句交换动作,可是mutex的数据先前已经被线程A交换至寄存器,然后保存到线程A的上下文了,现在的mutex为0,而线程B执行交换动作,拿寄存器al的0去换内存中mutex的0。

最终A进行lock成功,B被挂起等待。

例子2:B-lock

线程A在执行第一条语句把寄存器清0后就发生了线程切换(切至线程B),线程A保存上下文数据(0),此时线程B执行第一条语句把0写进寄存器,随后线程B执行第二条语句xchgb交换:

线程的互斥与同步_第29张图片

此时线程A执行第三条语句if判断失败,只能被挂起等待,线程A只能把自己的上下文数据保存,重新切换至线程B,也就是说线程B只要不运行,你们其它所有线程都无法申请成功。线程B恢复上下文数据(1)到寄存器,然后执行第三条语句if成功,返回结果。

交换的本质上述xchgb就是申请锁的过程。申请锁是将数据从内存交换到寄存器,本质就是将数据从共享内存变成线程私有。

  • mutex就是内存里的全局变量,被所有线程共享,但是一旦用一条汇编语句将内存的mutex值交换到寄存器,寄存器内部是哪个线程使用,那么此mutex就是哪个线程的上下文数据,那么就意味着交换成功后,其它任何一个线程都不可能再申请锁成功了,因为mutex已经独属于某线程私有了。
  • 这个mutex = 1就如同令牌一般,哪个线程先交换拿到1,那么哪个线程就能申请锁成功,所以加锁是原子的。

unlock

进行unlock的一般都是是lock成功的那个线程,因此天然具有原子性。

某些情况下,也可以让其它线程进行unlock。

当线程释放锁时,需要执行以下步骤:

  1. 将内存中的mutex置回1。使得下一个申请锁的线程在执行交换指令后能够得到1,形象地说就是“将锁的钥匙放回去”。
  2. 唤醒等待Mutex的线程。唤醒这些因为申请锁失败而被挂起的线程,让它们继续竞争申请锁。

总结:

  • 在申请锁时本质上就是哪一个线程先执行了交换指令,那么该线程就申请锁成功,因为此时该线程的al寄存器中的值就是1了。而交换指令就只是一条汇编指令,一个线程要么执行了交换指令,要么没有执行交换指令,所以申请锁的过程是原子的。
  • 线程释放锁没有将当前线程al寄存器中的值清0,这不会造成影响,因为每次线程在申请锁时都会先将自己al寄存器中的值清0,再执行交换指令。
  • CPU内的寄存器不是被所有的线程共享的,每个线程都有自己的一组寄存器,但内存中的数据是各个线程共享的。申请锁实际就是,把内存中的mutex通过交换指令,原子性的交换到自己的al寄存器中。

五、锁的应用

简单封装

class Mutex//封装Lock和Unlock接口
{
public:
    Mutex(pthread_mutex_t* lock)
    :_lock(lock){}

    ~Mutex(){}

    void Lock()
    {
        pthread_mutex_lock(_lock);
    }
    void Unlock()
    {
        pthread_mutex_unlock(_lock);
    }

private:
    pthread_mutex_t* _lock;
};

class LockGuard
{
public:
    LockGuard(pthread_mutex_t* lock)
    :_mutex(lock)
    {
        _mutex.Lock();
    }
    ~LockGuard()
    {
        _mutex.Unlock();
    }
private:
    Mutex _mutex;
};

使用一个全局的锁,且创建一个LockGuard设计RAII风格的锁

线程的互斥与同步_第30张图片

加一个代码块,让LockGuard完成RAII功能。

线程的互斥与同步_第31张图片

六、线程安全与可重入

线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数

七、死锁

2个线程2把锁的死锁场景

// int 票数计数器
int tickets = 1000; // 临界资源,可能会因为共同访问,造成数据不一致的问题
pthread_mutex_t Mutex=PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t Mutex1=PTHREAD_MUTEX_INITIALIZER;
void *getTickets(void *args)
{
    const char *name = static_cast(args);
    while (true)
    {
        pthread_mutex_lock(&Mutex);
        sleep(1);
        pthread_mutex_lock(&Mutex1);
        // 临界区
        if (tickets > 0)
        {
            usleep(100);
            cout << name << " 抢到了票, 票的编号: " << tickets << endl;
            tickets--;
        }else
        {
            break;
        }
        pthread_mutex_unlock(&Mutex1);
        pthread_mutex_unlock(&Mutex);
    }
    return nullptr;
}

void *getTickets1(void *args)
{
    const char *name = static_cast(args);
    while (true)
    {
        pthread_mutex_lock(&Mutex1);
        sleep(1);
        pthread_mutex_lock(&Mutex);
        // 临界区
        if (tickets > 0)
        {
            usleep(100);
            cout << name << " 抢到了票, 票的编号: " << tickets << endl;
            tickets--;
        }else
        {
            break;
        }
        pthread_mutex_unlock(&Mutex);
        pthread_mutex_unlock(&Mutex1);
    }
    return nullptr;
}

死锁四个必要条件:

互斥条件:一个资源每次只能被一个执行流使用
请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
对于1把锁:连续调用lock函数就会死锁
对于>=2把锁:死锁时一定满足上面4个必要条件

解决死锁问题:

破坏4个必要条件中的一个即可。

1、互斥条件(编码):也就是不再使用锁,一般不会改变。

2、变为请求与不保持条件(接口)

例如A和B两个线程,A申请到了a锁,B申请到了b锁,然后A又要申请b锁,B又要申请a锁。

使用pthread_mutex_trylock接口,A申请b锁时,由于B持有锁,所以申请失败直接返回,if成功执行临界区代码,else失败会把a锁释放,然后再重新申请2把锁。

释放a锁后,B线程就可以正常申请到第二把a锁,使用完后释放a锁和b锁。

3、变为剥夺条件(接口)

线程A会因为没申请到b锁而阻塞,此时如果可以让B线程把b锁释放,A线程就可以申请成功了。

4、循环等待条件(编码)

2个线程需要2把锁,按照顺序申请锁,都是先申请a锁,再申请b锁

如:线程A申请到a锁,线程B申请a锁时会阻塞,此时b锁不在线程B上,线程A可以正常拿到b锁,然后执行完代码后依次释放a锁和b锁。

之后线程B才能申请这两把锁。

编码上避免死锁的原则

破坏死锁的四个必要条件
加锁顺序一致
避免锁未释放的场景
资源一次性分配

八、线程同步

同步概念

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

如果没有锁,新来的线程都会直接访问临界资源,失败了才会排队,但访问期间就会导致线程不安全。

如果没有饥饿问题,只用互斥即可。

有饥饿问题,则需要同步。

简单实现同步(条件变量)

让阻塞的线程去排队(按一定顺序)

线程的互斥与同步_第32张图片

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
int cnt=0;
void* Cond(void* args)//condition
{
    pthread_detach(pthread_self());
    uint64_t number = (uint64_t)args;
    cout<<"thread-"<

线程的互斥与同步_第33张图片

九、生产消费者模型

线程的互斥与同步_第34张图片

线程的互斥与同步_第35张图片

BlockQueue-1

template 
class BlockQueue
{
    const static int defaultNum = 20;

private:
    queue q_;
    int maxcap_;
    // 保证生产者和消费者之间是互斥+同步的
    pthread_mutex_t mutex_;// 互斥
    pthread_cond_t c_cond_;// 同步
    pthread_cond_t p_cond_;//分别放在2个不同的条件变量的队列中
    int _high_water_ = maxcap_*2/3;//水位线控制策略
    int _low_water_ = maxcap_/3;
public:
    BlockQueue(int maxcap = defaultNum) : maxcap_(maxcap)
    {
        pthread_mutex_init(&mutex_, nullptr);
        pthread_cond_init(&c_cond_, nullptr);
        pthread_cond_init(&p_cond_, nullptr);
    }
    ~BlockQueue()
    {
        pthread_mutex_destroy(&mutex_);
        pthread_cond_destroy(&c_cond_);
        pthread_cond_destroy(&p_cond_);
    }
    void push(const T &in)
    {
        pthread_mutex_lock(&mutex_);
        // 判断保证可以生产
        if (q_.size() == maxcap_) // 判断时也要访问临界资源,需要放在加锁之后
        {
            pthread_cond_wait(&p_cond_, &mutex_); // 1、调度的时候自动释放锁
        }
        // 1、队列不满 2、满了后被唤醒,唤醒是因为已经有消费了
        q_.push(in);
        if(q_.size()>_high_water_)pthread_cond_signal(&c_cond_);
        pthread_mutex_unlock(&mutex_);
    }
    const T pop()
    {
        pthread_mutex_lock(&mutex_);
        // 判断保证可以消费
        if (q_.size() == 0) 
        {
            pthread_cond_wait(&c_cond_, &mutex_); 
        }
        T out = q_.front();
        q_.pop();
        if(q_.size()<_low_water_)pthread_cond_signal(&p_cond_);
        pthread_mutex_unlock(&mutex_);
        return out;
    }
};

demo-1

#include"BlockQueue.hpp"

void* Productor(void* args)
{
    BlockQueue* bq = static_cast*>(args);
    int data = 0;
    while(1)
    {
        data++;
        bq->push(data);
        cout<<"生产了一个数据 "<* bq = static_cast*>(args);
    while (1)
    {
        int data = bq->pop();
        cout<<"消费了一个数据 "<* bq = new BlockQueue();
    pthread_t c,p;
    pthread_create(&c,nullptr,Consumer,bq);
    pthread_create(&p,nullptr,Productor,bq);

    pthread_join(c,nullptr);
    pthread_join(p,nullptr);
    delete bq;

    return 0;
}

你可能感兴趣的:(Linux,is,not,unix,--,系统,开发语言,linux)