【Linux】线程分离和线程互斥

终于到线程互斥了~

文章目录


前言

在上一篇文章中我们学习了线程控制,比如创建一个线程,取消一个线程以及等待线程,这篇文章我们讲两个非常重要的概念,一个是线程分离,另一个是线程互斥


一、线程分离

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

下面我们先写一个测试代码,让程序跑起来然后我们再测试线程分离接口:

#include <iostream>
#include <cstdio>
#include <cstring>
#include <string>
#include <pthread.h>
#include <unistd.h>
using namespace std;

void *threadRoutine(void* args)
{
    string name = static_cast<const char*>(args);
    int cnt = 5;
    while (cnt)
    {
        cout<<name<<" : "<<cnt--<<endl;
        sleep(1);
    }
    return nullptr;
}
int main()
{
    pthread_t tid;
    pthread_create(&tid,nullptr,threadRoutine,(void*)"thread 1");
    int n = pthread_join(tid,nullptr);
    if (n!=0)
    {
        cerr<<"error: "<<n<<" : "<<strerror(n)<<endl;
    }
    return 0;
}

 可以看到线程运行后5秒都退出了,下面下面我们加入分离接口:

 这段代码的意思是我们刚创建一个新线程就将新线程分离了,而我们将线程分离后如果还是正常的去join线程是会出错的,下面我们运行起来看看:

果然出错了,Invalid argument说明我们的参数是错误的,这是因为我们刚刚pthread_detach的参数是不合法的,所以:一个线程被设置为分离状态后,是不需要join的!

那么如果我们让这个线程自己分离自己呢?

 通过运行我们发现并没有什么问题,我们让主线程sleep(1)再看一下:

 可以看到又报参数错误了,出现这种错误的原因是:我们调度哪个线程是不确定的,像刚才的代码如果我们直接调度主线程导致新线程一行代码也没跑直接主线程就join了,就又会像之前那样出现参数错误。

下面我们总结一下线程分离:当我们想join一个线程的时候那就不要进行分离,当我们不想去join一个线程那就直接将这个线程分离即可。

如何理解线程库和线程ID:

线程库:

 首先我们学动静态库的时候知道,库是在磁盘中存放的,从磁盘映射到了物理内存然后经过页表的转化映射到进程地址空间的共享区当中,又因为我们的线程是共享进程的进程地址空间的,所以我们的线程是可以随时随地访问共享区中的库的。

那么线程库是如何管理线程的呢?先描述再组织。先给线程创建类似的管理线程的TCB(类似于PCB),下面我们看一张图:

 在这张图中,mmap区域就是我们的共享区,右边的动态库等信息就是我们的线程库,里面有管理线程的结构体等,而要找我们的线程ID该怎么去找呢?我们可以看到pthread_t tid的小箭头指向结构体,实际上pthread_t 就是一个地址数据,用来标识线程相关属性集合的起始地址。所以我们之前打印线程id的时候是很长的数据,为什么长呢因为那是地址!!下面我们将代码修改一下演示出id:

 可以看到确实打印出来的ID是很长,下面我们将这个ID转换为16进制的地址:

string hexAddr(pthread_t tid)
{
    char buffer[64];
    snprintf(buffer,sizeof(buffer),"0x%x",tid);
    return buffer;
}

 下面我们运行起来:

 这一次我们可以看到地址变的正常了。我们在上面线程的图中可以看到线程局部存储和线程栈,其实学过线程的都知道线程是有自己的私有栈的,只不过不知道这个栈在哪里,从图中我们可以看到这个栈是在线程自己的地址当中,每个线程有struct,线程局部存储和线程栈,通过地址找到这些内容。

总结:线程库的作用是给用户提供操作线程的接口,在我们创建线程的时候会在线程库里面给我们创建一个描述线程相关的struct,然后还会创建一个轻量级进程,线程结构体里会包含线程自己的栈结构,局部存储等信息。线程的ID就是描述线程结构体TCB的起始地址,每个线程都有自己的栈在库当中存放。

下面我们编写代码验证一下每个线程中的私有栈:

int main()
{
    pthread_t t1,t2,t3;
    pthread_create(&t1,nullptr,threadRoutine,(void*)"thread 1");
    pthread_create(&t2,nullptr,threadRoutine,(void*)"thread 2");
    pthread_create(&t3,nullptr,threadRoutine,(void*)"thread 3");
    pthread_join(t1,nullptr);
    pthread_join(t2,nullptr);
    pthread_join(t3,nullptr);
    return 0;
}

 我们开3个线程,然后每个线程进入routine函数的时候都打印一下cnt这个变量的地址,如果不一样则说明他们有自己独立的栈:

仔细观察可以看到地址是不一样的,地址很相似是因为他们都在共享区。下面我们再看看全局变量的地址:

 我们可以看到全局变量的地址一样说明3个线程都是同一个全局变量,这就证明了线程共享进程的地址空间。当然我们也可以在全局变量前面加上__thread让全局变量变成每个线程的局部存储:

 运行后我们可以看到地址确实不一样了。下面我们进入互斥的内容

二、线程互斥

在多线程中,有一个全局的变量,是被所有执行流共享的,而线程中大部分资源都会直接或者间接共享,而这就可能会存在并发访问的问题,如下图:

 当我们要对一个全局变量进行--操作时,先将内存中的代码加载到CPU的寄存器当中,计算后将结果再写会内存中,这样内存中的100就变成了99:

 这个时候另一个线程过来了,这个线程是将100减到10所以在寄存器中减到10后将10写入内存中,然后线程B的时间片到了就重新调度线程A:

 本来线程B好不容易将数减到10了结果A线程回来后数据又变成了99。所以当我们对全局变量做--操作时,如果没有保护的话,会存在并发访问的问题,进而导致数据不一致问题。所以为了解决这样的问题,引入了互斥这个概念。

进程线程间的互斥相关背景概念:
临界资源:多线程执行流共享的资源就叫做临界资源。
临界区:每个线程内部,访问临界资源的代码,就叫做临界区。 
互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用。
原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完 成。
下面我们用一个多线程并发抢票的代码来演示互斥问题:
int tickets = 10000;

void *threadRoutine(void* name)
{
    string tname = static_cast<const char*>(name);
    while (true)
    {
        if (tickets>0)
        {
            usleep(2000);  //模拟抢票花费的时间
            cout<<tname<<" get a ticket: "<<tickets--<<endl;
        }
        else 
        {
            break;
        }
    }
    return nullptr;
}
int main()
{
    pthread_t t[4];
    int n = sizeof(t)/sizeof(t[0]);
    for (int i = 0;i<n;i++)
    {
        char* data = new char[64];
        snprintf(data,64,"thread-%d",i+1);
        pthread_create(t+i,nullptr,threadRoutine,data);
    }
    for (int i = 0;i<n;i++)
    {
        pthread_join(t[i],nullptr);
    }
    return 0;
}

上面代码中我们创建线程第一个参数是t+i是因为这个参数是指针类型,t是首元素地址所以这样写,join中第一个参数是线程id所以直接用t[i]即可。usleep可以让线程休眠:

 usleep休眠的时间是微秒为单位,所以我们相当于休眠0.002秒。下面我们运行起来:

 运行后经过多次抢票我们发现最后票数变成了负数,为什么是负数呢?这就是我们提到的并发问题了,和我们之前说的那个全局变量一样,由于没有对全局变量的上下问进行保护,所以会减到负数去,(比如说我们现在的票数是1,四个线程都进入到这个判断逻辑tickets>0,然后四个线程都进行--操作,这样票数就变成了-3 )要解决这个问题需要三点:

代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。

下面我们演示如何完成互斥:

在我们对临界资源进行加锁前需要学习一下互斥锁的概念:

互斥量的接口:
初始化互斥量
初始化互斥量有两种方法:
方法 1 ,静态分配: pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
方法 2 ,动态分配:
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict
attr);
参数: mutex:要初始化的互斥量          attr: NULL
销毁互斥量
销毁互斥量需要注意:
使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁
不要销毁一个已经加锁的互斥量
已经销毁的互斥量,要确保后面不会有线程再尝试加锁
int pthread_mutex_destroy(pthread_mutex_t *mutex)
互斥量加锁和解锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值 : 成功返回0,失败返回错误号
调用 pthread_ lock 时,可能会遇到以下情况:
互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功。
发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_ lock 调用会陷入阻塞 ( 执行流被挂起 ) ,等待互斥量解锁。

我们可以看到参数是pthread_mutex_t类型,这个类型被称为互斥锁。每一把锁用完后都要用pthread_mutex_destroy进行销毁。

 然后我们有了锁后还需要知道加锁的接口:pthread_mutex_lock:

 对于加锁这个接口如果加锁成功就会对临界资源进行加锁,失败就会将当前线程阻塞住。当我们用完临界资源需要对这个资源进行解锁操作,我们解锁的时候必须保证一定能解锁,所以修改代码如下:

下面我们将代码运行起来:

这次我们可以看到没有并发访问的情况了,但是为什么只有一个线程在抢票呢?这是因为我们抢完票还要将票放入用户的数据库当中,但是我们的代码并没有这个场景,下面我们用usleep模拟一下:

 这样就让多个线程一起抢票了,以上就是我们对互斥锁的接口的使用,下面我们补充一些互斥锁的细节,我们先把代码修改一下:

 我们先创建一个局部的锁,然后对这个锁进行初始化,在结束前将这个锁销毁,那么局部的锁该如何被所有线程看到呢?我们用类解决这个问题:

class TData
{
public:
    TData(const string& name,pthread_mutex_t* mutex)
        :_name(name)
        ,_pmutex(mutex)
    {

    }
public:
    string _name;
    pthread_mutex_t* _pmutex;
};

 我们的思想很简单,就是让所有的线程都能看到我们的局部锁,所以我们定义了一个对象,对象中有线程的名字和锁,每个线程进入回调函数后都会给自己进行加锁解锁操作,下面我们运行起来看:

 运行起来后我们可以看到和我们一开始的局部变量的效果一模一样,对于加锁我们总结四点:

1.凡是访问同一个临界资源的线程,都要进行加锁保护,而且必须加同一把锁,这是一个都遵守的规则,不能有例外。

2.每一个线程访问临界区之前,得加锁,加锁本质是给临界区加锁,加锁的粒度尽量要细一些。

3.线程访问临界区的时候,需要先加锁。所有线程都必须要先看到同一把锁,锁本身就是公共资源,锁如何保证自己的安全呢?因为锁是原子性的,所以无需保证。

4.临界区可以是一行代码,可以是一批代码。当一个线程已经申请到锁了,那么这个线程有可能被切换吗?当然是可能的,加锁只是保护这个线程的上下文数据。那么切换会有影响吗?不会。因为在我不在期间任何人都没有办法进入临界区,并且他无法成功的申请到锁,因为锁被原先申请到的那个进程拿走了。

5.加锁解锁正是体现互斥带来的串行化表现,站在其他线程的角度,对其他线程有意义的状态就是:锁被我申请(持有锁),锁被我释放了(不持有锁),原子性就体现在这里,要不有锁,要不没锁。


总结

以上就是线程分离和线程互斥的全部知识,本篇文章重点在于:

1.线程共享进程的地址空间。

2.线程有自己独立的栈结构(其实不只是栈,还有寄存器等)

3.线程分离后不需要join,如果不想join某个线程可以将它分离

还有就是我们加锁所总结的5点内容。

  • 47
    点赞
  • 45
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 88
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 88
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

一朵猫猫菇

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值