自学内容网 自学内容网

【Linux】线程


什么是线程

在一个程序里的一条执行路线,就叫做线程。
比如一个函数从执行入口开始往后执行,这条执行路线就叫做线程。
线程在进程内部运行,本质是在进程地址空间内运行。

线程的创建——pthread_create

1.pthread_create(&tid,NULL,rout,NULL)

第一个参数是输入输出型参数,返回的是线程ID,第二个参数是设置线程的属性,为空表示设置线程为默认属性,第三个参数是线程要执行的函数,第四个参数是为该函数传递的参数。

在这里插入图片描述

注意:创建线程时传递的参数和返回值,不仅仅可以传递一般参数,还可以直接传递对象!

线程等待——pthread_join

线程等待:等待线程结束。
为什么要有线程等待?
已经退出的线程,其空间没有被释放,仍然在进程的地址空间内。
创建新的线程不会复用刚才退出线程的地址空间。

> int pthread_join(pthread_t thread,void** value_ptr);

成功返回0,失败返回错误码
第一个参数是等待线程结束的ID,第二个参数是该线程为什么结束,状态码会放在第二个参数。总结如下:
1.如果thread线程通过return返回,value_ ptr所指向的单元里存放的是thread线程函数的返回值。

  1. 如果thread线程被别的线程调用pthread_ cancel异常终掉,value_ ptr所指向的单元里存放的是常PTHREAD_CANCELED。

  2. 如果thread线程是自己调用pthread_exit终止的,value_ptr所指向的单元存放的是传给pthread_exit的参数。

  3. 如果对thread线程的终止状态不感兴趣,可以传NULL给value_ ptr参数。

线程分离——pthread_detach

默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏。
如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。——pthread_detach(pthread_t thread);
pthread_detach(pthread_self());
joinable和分离是冲突的,一个线程不能既是joinable又是分离的。

获取线程自己的id——pthread_self

pthread_t pthread_self(void);

但打印时默认是打印十进制的id,是一串长数字,现在想看到十六进制。

执行pthread_join时,该线程流默认是阻塞等待。
也就是主线程会阻塞等待子线程的退出。

并且,在pthread_join这里,主线程等待子线程退出的时候,不用考虑异常问题,因为它根本没办法做到。线程出现异常,操作系统就会将进程整体全部干掉,资源全部回收,那主线程也会被干掉,此时子线程返回异常信息给主线程,就没意义了。

在子线程的执行流中,直接exit()终止线程可以吗?

通过这个例子可以发现,子线程调用exit函数退出后,不止退出的是子线程本身,还退出了主线程。

在这里插入图片描述

所以,exit函数相当于终止了整个进程!

所以不能用exit函数来终止线程。

线程如何共享进程资源?

其实我们可以反过来想,线程哪些资源是私有的?把私有的资源分清楚后,剩下的都是资源共享的了。

线程的私有资源

由于线程的本质是函数的执行流,所以函数有的栈区,局部变量等执行过程中私有的信息,都会保存在函数自己的栈中,所以,每个线程都有自己独立的栈区。这些栈区都在进程地址空间中的共享区中。

每个线程都有自己独立的栈结构。这是线程私有的。
但这些所谓的线程私有的栈结构,本质上都是在进程地址空间中的共享区存储着。

同时函数运行时需要额外的寄存器来保存一些信息,像部分局部变量之类,这些寄存器也是线程私有的,一个线程不可能访问到另一个线程的这类寄存器信息。

在这里插入图片描述

总结:到目前为止,所知道的线程的私有信息有:线程的栈区,栈指针,寄存器的信息,程序计数器等这些都是线程私有的。
与进程上下文类似,这些线程信息也叫做线程上下文。

线程共有资源

除了线程的私有栈资源外,其他的都是线程之间的共享资源,可以互相访问。
比如一个全局变量,所有其他线程都可以访问到。

关于共享资源中的栈区和线程的局部存储问题

从线程这个抽象的概念上来说,栈区是线程私有的,然而从实际的实现上看,栈区属于线程私有这一规则并没有严格遵守,这句话是什么意思?

通常来说,注意这里的用词是通常,通常来说栈区是线程私有,既然有通常就有不通常的时候。不通常是因为不像进程地址空间之间的严格隔离,线程的栈区没有严格的隔离机制来保护,因此如果一个线程能拿到来自另一个线程栈帧上的指针,那么该线程就可以改变另一个线程的栈区,也就是说这些线程可以任意修改本属于另一个线程栈区中的变量。

其实从名字上也可以看出,所谓线程局部存储,是指存放在该区域中的变量有两个含义:

  • 1.存放在该区域中的变量是全局变量,所有线程都可以访问
  • 2.虽然看上去所有线程访问的都是同一个变量,但该全局变量独属于一个线程,一个线程对此变量的修改对其他线程不可见。
int a = 1; // 全局变量

void print_a() {
    cout<<a<<endl;
}

void run() {
    ++a;
    print_a();
}

//使用C++语言提供的线程库实现的
void main() {
    thread t1(run);
    t1.join();

    thread t2(run);
    t2.join();
}

很明显,结果就是2和3。

接下来我们对变量a的定义稍作修改,其它代码不做改动:

__thread int a = 1; // 线程局部存储

运行结果如下:

2
2

原来,这就是线程局部存储的作用所在,线程t1对变量a的修改不会影响到线程t2,线程t1在将变量a加到1后变为2,但对于线程t2来说此时变量a依然是1,因此加1后依然是2。

因此,线程局部存储可以让你使用一个独属于线程的全局变量。也就是说,虽然该变量可以被所有线程访问,但该变量在每个线程中都有一个副本,一个线程对改变量的修改不会影响到其它线程。

总结:因此,线程局部存储可以让你使用一个独属于线程的全局变量。也就是说,虽然该变量可以被所有线程访问,但该变量在每个线程中都有一个副本,一个线程对改变量的修改不会影响到其它线程。

Linux线程互斥

大部分情况下,线程使用的变量都是私有的,意味着该变量只有一个线程拥有,其他线程无法访问。
但很多时候,许多变量都需要在线程之间共享,这样的变量称为共享变量,比如买票系统中,票剩余数量这个变量,就是需要线程之间共享。

但是如果多线程并发地修改一个共享变量,就可能出现问题。

因为一条 count++/–语句,实际上是由三条汇编语句组成的:

  • 1.先将count变量的内容拷贝到寄存器中。
  • 2.在寄存器中将count变量的内容++。
  • 3.将寄存器的内容拷贝回内存。

这三条汇编语句,每一条都是原子的。
但是在三条汇编语句执行过程中,极有可能因为线程的时间片到了,导致该线程被CPU强行带走,所以线程只能将它自己的上下文保存起来,再次排队到下一次轮到它时再将它自己的线程上下文重新恢复到CPU上运行。

但是在这个过程中,其他线程也可能对该变量修改,所以当原来的线程重新恢复上下文去访问这个全局变量时,它以为是100,实际上已经被修改成10了,这时候就出现不一致问题。

案例

// 操作共享变量会有问题的售票系统代码
//模拟多人抢票——没上锁版本,出现线程并发修改数据问题
//记录线程name
struct threadData
{
    threadData(int num)
    {
        name = "thread-" + to_string(num);
    }

    std::string name;
};

int ticket = 1000;

void* gettickets(void* args)
{
    threadData* td = static_cast<threadData*>(args);
    const char* name = td->name.c_str();

    while(true)
    {
        if(ticket > 0)
        {
            usleep(1000);
            printf("i am %s,i get a ticket:%d\n",name,ticket);
            --ticket;
        }
        else
            break;
    }

}

int main()
{
    vector<pthread_t>tids; //记录线程们的tid
    vector<threadData*> threads; //记录线程们的数据

    
    for(int i = 1; i <= 10;i++)
    {
        pthread_t tid;

        threadData* th = new threadData(i);
        threads.push_back(th);

        pthread_create(&tid,nullptr,gettickets,threads[i-1]);
        tids.push_back(tid);
    }

    for(int i = 0;i<10;i++)
    {
        pthread_join(tids[i],nullptr);
    }

    for(int i = 0 ;i<10;i++)
    {
        delete threads[i];
    }

    return 0;
}

为了解决上面的问题,就有一个锁的概念。

互斥量(锁)

pthread_mutex_t lock;

这是一个系统提供的数据类型——pthread_mutex_t。

对锁初始化:

pthread_mutex_t lock;//这个锁必须是全局的/静态的,不能是局部的锁。

pthread_mutex_init(&lock,nullptr);
//参数一就是给哪个锁初始化,参数二就是锁属性设置成什么什么。
pthread_mutex_destroy(&lock);
//对锁资源进行释放

(但是不能对局部锁初始化,只能对全局锁初始化)

多个执行流共享的资源,就叫做临界资源。
在每个线程内部,访问临界资源的代码,就叫做临界区。

pthread_mutex_lock(&lock); //上锁
pthread_mutex_unlock(&lock); //解锁

在上锁和解锁这段区间内的代码,就叫做临界区。

该如何用锁解决上面的多线程并发访问变量的问题呢?

 while(true)
    {
    pthread_mutex_lock(&lock); //申请锁成功,才能往后执行,不成功,阻塞等待
        if(ticket > 0)
        {
            usleep(1000);
            printf("i am %s,i get a ticket:%d\n",name,ticket);
            --ticket;
        pthread_mutex_unlock(&lock);
        }
        else
        {
        pthread_mutex_unlock(&lock);
            break;
        }
    }

在这里插入图片描述

只需要像上面那样,在这段区间加锁解锁即可。

为什么不直接在while循环上一行代码加锁,在结束循环下一行代码解锁呢?

  • 1.不符合逻辑,这样做就是让一个线程把票都抢完了。
  • 2.加锁解锁之间的临界区代码量越少越好。

并且,解锁的地方也是有学问的,如果unlock放在break的后面。
就会出现有一个线程过来,先上锁,然后判断ticket是否大于0,如果不大于,就会走else,直接break,此时该线程手上的锁还没解锁,该线程就带着锁退出了。
这样就会导致其他申请锁的线程无法申请成功,就会出现其他问题。

但是加锁也不是一件好事情,
加锁的本质是用时间来换安全。

  • 问题1:

持有锁的这部分区域,叫做临界区,在临界区执行代码期间,线程可以切换吗?

答案是可以切换!但是,在线程被切出去的时候,是持有锁被切走的。
该线程被切走期间,其他任何线程都进不来。没人能进入该临界区访问资源。

在这里插入图片描述
我们怎么知道我们要让一个线程去等待队列等待了呢?一定是临界资源没有就绪,所以,临
界资源也是有就绪和没就绪两个状态的。

我们怎么知道临界资源是否就绪?判断出来,判断的过程,本质上就是在访问临界资源。所以,判断必须放在加锁之后!

所以被切走的线程压根不担心被切走,因为锁在自己这里。

对其他线程而言,我竞争锁的目的很简单,就是想要得到锁这个资源,但现在既然我没竞争过别人,我就不会关心别人拿到锁之后具体干了什么。我没拿到锁,目前只关心两件事:

  • 1.被调度走的线程要么没有锁
  • 2.被调度走的线程要么释放锁

除此之外,我不会关心其他事情。

上面这段话就是从没有拿到锁的线程的角度思考问题。没有完美的解决方法,但有符合实际的解决方法。
所以,当一个问题被解决后,这个解决问题的方案又会引发另一个问题。

  • 问题2:解释一下:“单纯的互斥锁,可能会引发饥饿问题。”

回到这段代码,现在注释掉了usleep那一行代码。

在这里插入图片描述
此时结果如下:
在这里插入图片描述
会发现一个问题,有大量的票是同一个线程抢的。

这个就是因为大量线程都在同时申请互斥锁,而锁只有一个,前面这个线程,可能刚释放锁,又立刻申请锁。由于操作系统复杂的调度规则等原因,并且每个线程的申请资源的能力是不一样的,就可能导致一个锁在一段时间内,频繁地被一个线程申请了释放,释放了又申请,最后会导致其他线程的饥饿问题。——其他线程也想申请锁但申请不到!

为了解决这个问题,下面会引入线程同步这个概念解决。

锁的原理

  • 问题3:前面说了申请锁和释放锁都是原子的,那怎么做到原子呢?
    锁本身其实并不复杂,只不过是一个变量。

为了方便理解,假如这个锁就是一个int类型的变量。

int mutex;

前面讲过线程共享变量,此时这个锁是共享的。
怎么让共享的锁变成线程私有的锁?
其实就是当一个线程读取到内存中的这个锁变量的内容时,会调用swap/exchange指令,将该物理内存中锁所在单元数据和寄存器的数据进行交换!!
而这个寄存器数据,必然属于线程的硬件上下文!
也就是该寄存器在执行时属于线程私有的!!
此时这个共享锁,就成了线程的私有锁。
在这里插入图片描述

锁的简单应用

锁代码

可重入和线程安全

  • 结论:
  • 1.线程安全描述的是线程并发的问题,可重入描述的是函数特点的问题。
  • 2.不可重入函数在多线程访问时可能会出现线程安全问题,但是一个函数如果是可重入的,那它不会有线程安全问题。

死锁

举一个简单的死锁的例子。
当前你拥有一个锁,我也拥有一个锁,但是你还想要申请我的锁,我不释放,同时我想申请你的锁,但是你也不释放,这样我们双方都处于永久等待对方释放锁的状态中,这样就产生了死锁。

死锁产生的四个必要条件:

  • 互斥条件:一个资源每次只能被一个执行流使用
  • 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
  • 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
  • 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系

避免死锁的方式也很简单:
破坏死锁产生的条件即可。

同步

让所有线程获取资源,按照一定的顺序,就叫做同步。
可以理解成,同步就是排队获取一个资源。

多个线程也会并发抢锁,所以
锁本身也是临界资源!!
所以在申请和释放锁这个动作,都被设计成原子性操作。

所以线程之间同步和互斥是同时设置的。

锁和条件变量同时使用案例

条件变量

在这里插入图片描述
条件变量的作用相当于一个窗口,当每一个线程直接去申请锁失败后,就会来到条件变量这个窗口,等待锁资源就绪,一旦锁资源就绪,条件变量这个窗口就会唤醒在它这个队列下等待的线程。

pthread_cond_wait(&cond,&mutex); //让当前线程进入cond这个条件变量中等待mutex这个锁资源
pthread_cond_signal(&cond);//唤醒在cond这个条件变量下的第一个线程,默认是第一个
pthread_cond_boardcast(&cond); //唤醒在cond这个条件变量下的所有线程

int ticket = 1000;
void* Count(void* args)
{
    pthread_detach(pthread_self()); //线程分离
    uint64_t number = (uint64_t)args;
    std::cout << "pthread-" << number << " create success" << std::endl;
    while(ticket)
    {
        pthread_mutex_lock(&mutex);
        //我们怎么知道我们要让一个线程去等待队列等待了呢?一定是临界资源没有就绪,所以,临界资源也是有就绪和没就绪两个状态的。
        //我怎么知道临界资源是否就绪?判断出来,判断的过程,本质上就是在访问临界资源。所以,判断必须放在加锁之后!

        pthread_cond_wait(&cond,&mutex);  // 为什么这条代码要放在这里? 1.线程进入条件变量队列中,会自动释放锁
        //在这个队列中等待是阻塞等待的,如果没有被唤醒会一直阻塞地等。
        printf("i am a pthread,id: %d , i get a ticket : %d\n",number,ticket--);
        pthread_mutex_unlock(&mutex);
        usleep(10);
    }
}

生产消费者模型

生产消费者模型是为了通过一个阻塞队列,来解决生产消费者之间的强耦合问题。
比如一个生产者,固定与5名消费者进行关系绑定,该生产者生产的数据只由这五名消费者进行消费。
但是只要其中一方更换,另一方也必须更换,因为他们之间是强耦合的。

但是有了一个阻塞队列为中介后,生产者生产数据就不必要给特定的消费者,而是直接放进阻塞队列中,而消费者也不必要去找生产者给数据,而是通过读取阻塞队列,就可获取是否有数据需要处理。

这样就解决了生产者消费者之间的强耦合关系。

对于生产者,一个容器,消费者这样三者组成的关系,叫做生产消费者模型。

生产消费者模型实例

在这里插入图片描述

生产消费者模型中的问题
1.伪唤醒情况

在这里插入图片描述
假设此时队列中刚好满了,正常情况下,会唤醒消费者去调用pop消费。
如果是使用

pthread_cond_boardcast();

该函数会将消费者 c_cond 条件变量下的所有线程全部唤醒,这就造成了多个线程竞争一个锁的情况,而最终一定只有一个线程能申请到锁资源。而当该消费者线程消费完后,会唤醒生产者来生产,但是,该生产者不一定能申请锁资源成功,因为刚才有多个消费者线程同时被唤醒,如果生产者线程竞争锁的能力比其他两个消费者线程竞争锁能力差,就可能出现这样的情况:容器队列中的资源已经全部被处理,现在一个消费者拿到了锁要去处理数据,这样就可能出现其他较大问题。

为了解决该问题,只需要把if的判断条件改成while即可。

这样当申请到锁的消费者,在往下走的时候,会重新回到while循环判断容器队列是否为空,如果为空,就会重新进入c_cond条件变量下继续休眠等待。
这样就解决了问题。

POSIX信号量

POSIX信号量和SystemV信号量作用相同,都是用于同步操作,达到无冲突的访问共享资源目的。

#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);
参数:
pshared:0表示线程间共享,非零表示进程间共享
value:信号量初始值

销毁信号量
int sem_destroy(sem_t *sem);

等待信号量
功能:等待信号量,会将信号量的值减1
int sem_wait(sem_t *sem); //P()

发布信号量
功能:发布信号量,表示资源使用完毕,可以归还资源了。将信号量值加1int sem_post(sem_t *sem);//V()

注意:信号量的本质是一把计数器。
表示临界资源数量的多少。

基于环形队列的生产消费者模型代码分析

  • 1.在为空/为满的情况下:只能有一个线程进入并执行push/pop行为,这个过程在局部上就表现为互斥
  • 2.在为空时,整个代码就一定是要求生产者先运行,在为满时,整个代码就一定是要求消费者先运行,这个过程同样在局部上表现为同步
  • 3.在不为空也不为满的情况下,生产者消费者各玩各的,没有表现出明显的互斥性,这个过程就是并发运行

2)先申请锁再申请信号量还是先申请信号量再申请锁?

1.
Lock(p_mutex_);
P(pspace_sem_); //让空间临界资源数量-1
2.
P(pspace_sem_); //让空间临界资源数量-1
Lock(p_mutex_);

选择2.
解释:

  • 1)信号量的申请是原子的,不需要加锁。
  • 2)在多个线程并发申请信号量之后,只有一个线程可以成功申请锁,其他线程都申请成功信号量并等待锁资源,这样就能让线程申请信号量和申请锁资源是并行的。
  • 3)降低同一个线程多次申请锁的概率,因为在申请锁之前,要申请信号量,而一开始申请信号量成功并竞争锁成功的线程在释放锁后,想要再次申请锁,就要先申请信号量,而其他在等待锁释放的线程已经申请信号量了, 所以就算一个线程竞争能力很强,也能很大概率减少锁资源被同一个线程长时间占用问题。

原文地址:https://blog.csdn.net/w2915w/article/details/143053392

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!