Linux--多线程

1. 什么是线程

Linux中没有专门为线程设计TCB,而是用进程的PCB来模拟进程。 这也是为什么有种观点会说Linux下没有真正意义上的线程。

对于线程来说,只创建task_struct,共享同一个地址空间,当前进程的资源(代码+数据),划分为若干份,让每个PCB使用。

线程可以认为是进程里的一个执行流(每一个PCB),由于线程的创建并不需要像进程那样创建数据结构、地址空间等,而是复用进程的PCB等资源,所以线程也被看作是进程的一部分。

有两个要点:

  1. 线程在进程的地址空间内运行
  2. CPU调度的时候,只看PCB,每一个PCB已经被分配指向的数据和方法,CPU可以直接调度

在这里插入图片描述

这样的设计有一个很大的优点:不用维护复杂的进程和线程的关系,不用单独为线程设计任何算法,直接使用进程的一套相关的方法。OS只需要聚焦在线程间的资源分配上就行了。

所以很明显,创建进程的成本比创建线程大的多。

在windows系统中,采用的是和进程类似的方法,也就是线程自己重新创建一份TCB等结构,可以知道其源码肯定非常复杂。

2. 创建线程

线程创建函数:pthread_create

在这里插入图片描述

参数:
thread:返回线程ID
attr:设置线程的属性,attr为NULL表示使用默认属性
start_routine:是个函数地址,线程启动后要执行的函数
arg:传给线程启动函数的参数

返回值:成功返回0;失败返回错误码

编写一个函数来验证它们是否处于同一个进程(由于使用的是第三方库,编译时需要-lpthread链接):

#include <stdio.h>
#include <unistd.h>
#include <pthread.h>

void* thread_run(void* args)
{
    const char* id = (const char*)args;
    while(1){
      printf("我是%s线程:%d\n",id,getpid());
      sleep(1);
    }
}
int main()
{
    pthread_t tid;
    pthread_create(&tid,NULL,thread_run,(void*)"thread 1");
    while(1){
      printf("我是main线程:%d\n",getpid());
      sleep(1);
    }
    return 0;
}

运行结果:

在这里插入图片描述

首先,两个死循环都能跑起来,如果仅仅只有一个线程,这是根本不可能实现的,说明此时创建了新的线程

其次,可以看见他们的pid都是一样的,说明它们是同一个进程

最后,当收到二号信号的时候,两个线程都直接退出,说明确实是只有一个进程,并且两个线程是同一个进程。

ps -aL是查看线程资源的指令:

在这里插入图片描述

这里的LWP其实就是线程的标识符,相当于进程的PID。6548其实就是此时的主线程,6549就是另一个线程。

操作系统调度线程的时候,看的其实是LWP,而不是PID。

也可以批量创建线程:

void* thread_run(void* args)
{}
int main()
{
    pthread_t tid[5];
    for(int i = 0;i < 5;i++){
       pthread_create(tid+i,NULL,thread_run,(void*)"thread 1");
    }
    return 0;
}

运行结果:

在这里插入图片描述

可以看见LWP刚好有六个属于test程序的线程,第一个LWP=PID的是主线程,其他的五个都是按照顺序生成的子线程。

线程健壮性不强,在一个进程下的线程,如果有一个崩溃了,其他的也会随之崩溃。

3. 轻量级进程(LWP)

3.1 LWP是什么

前面所说的线程,其实就是轻量级进程(Light Weight Process)。

轻量级进程(LWP)通常被实现为线程。在某些操作系统中,轻量级进程就是指线程。

更准确地说:LWP(Lightweight Process)不是直接等同于线程,但可以看作是线程的一种实现方式。LWP提供了线程的基本执行环境,包括堆栈、寄存器状态和调度信息等。每个LWP都有自己的执行上下文,并且可以被操作系统调度和管理。多个LWP可以共享同一个地址空间,这意味着它们可以访问相同的内存区域。

线程是操作系统中最小的执行单元,它由线程库在用户空间进行管理和调度。线程共享同一进程的地址空间、文件描述符等资源,因此线程之间的切换开销较小。这种共享使得线程之间的通信和同步更加简单高效。

在Linux操作系统中,轻量级进程就是指线程。而在其他一些操作系统中,轻量级进程可能是通过特定的机制和数据结构来实现的,但本质上仍然是线程的概念。

3.2 LWP与传统进程的区别

轻量级进程(LWP)和传统进程之间存在一些区别,主要包括以下几个方面:

  1. 资源开销:传统进程在操作系统中拥有独立的地址空间、文件描述符等资源,因此创建和切换进程的开销较大。而轻量级进程共享相同的地址空间和文件描述符,减少了资源的创建和切换开销。也就是第一节说的共享一个进程的mm_struct

  2. 上下文切换:由于轻量级进程共享相同的地址空间,所以在轻量级进程之间的上下文切换速度更快。这是因为不需要切换整个地址空间,只需切换轻量级进程的上下文即可。

  3. 通信和同步:轻量级进程之间的通信和同步更加简单高效。由于它们共享相同的地址空间,可以通过共享内存或者直接读写共享变量来进行通信,无需使用复杂的进程间通信机制。

  4. 调度方式:传统进程的调度和管理是由操作系统内核负责的,而轻量级进程的调度和管理是由用户空间的线程库负责的。这使得轻量级进程的调度策略可以根据应用程序的需求进行优化,提高系统的性能和响应能力。

我们所打印的线程ID其实是pthread库在地址空间当中映射之后的地址,该地址能快速地找到对应线程的相关属性,该地址与LWP是一一对应的关系。

多线程的进程,又被称为线程组,线程组内的每一个线程在内核之中都存在一个进程描述符(task_struct)与之对应。

查看线程ID:

ps -eLf |head -1 && ps -eLf |grep a.out |grep -v grep

ps命令中的-L选项,会显示如下信息:

  1. LWP:线程ID,既gettid()系统调用的返回值。
  2. NLWP:线程组内线程的个数

4. 线程等待

4.1 pthread_join函数

和进程一样,线程也是需要等待的,否则就会造成类似于“僵尸进程”的问题。

线程等待函数pthread_join:

在这里插入图片描述

参数:

thread表示需要等待的线程id;

retval是一个输出型参数,用来获取新线程退出的时候,函数的返回值。由于新线程创建的时候函数返回值是void*,所以要将该返回值输出,就要用二级指针void**。

返回值:成功返回0,失败返回错误码。

下列程序可以显示出,主线程确实是可以等待其他线程,并拿到其返回值:

void* thread_run(void* args)
{
    const char* id = (const char*)args;
    while(1){
      printf("我是%s线程:%d\n",id,getpid());
      sleep(1);
      break;
    }
    return (void*)11;//随意设置返回值
}
int main()
{
    pthread_t tid;
    pthread_create(&tid,NULL,thread_run,(void*)"thread 1");
    等待
    void* status = NULL;
    pthread_join(tid,&status);
    printf("返回值:%d\n",(int)status);
    return 0;
}

运行结果:

在这里插入图片描述

可以看到status中确实保存了该新线程的返回值。

值得注意的是,当线程异常的时候,该函数并不会进行处理,因为线程异常已经是属于进程层面的问题了。

4.2 线程分离

如果一个线程不需要被等待,可以将其分离:

在这里插入图片描述

分离后的线程不能被等待,主线程v不退出,新线程运行完毕之后会自动释放资源。

4.3 线程终止的方案

  1. return:main函数中return代表主线程退出,在其他线程中return代表当前线程退出。

  2. 新线程通过pthread_exit终止自己,但是不能用exit,因为那是用来终止进程的,除非你想直接终止该进程。

在这里插入图片描述
参数是退出码,强转成void*。

  1. 取消目标线程,直接通过线程id取消线程

在这里插入图片描述

5. 抢票程序

5.1 加锁抢票和不加锁抢票的区别

线程进行切换的时间:

  1. 时间片耗尽
  2. 检测的时间点,从内核态返回用户态的时候

假设现在有1000张票,有五个线程同时在抢:

#include <iostream>
#include <cstdio>
#include <string>
#include <unistd.h>
#include <pthread.h>

//抢票逻辑,1000票,5线程同时在抢
int tickets = 1000;

void* ThreadRoutine(void* args)
{
    int id = *(int*)args;
    delete (int*)args;
    while(true){
        if(tickets > 0){
            usleep(1000);
            std::cout << "我是["<< id << "]我要抢的票是:" << tickets<< std::endl;
            tickets--;
            printf(" ");
        }
        else{
            //无票
            break;
        }
    }
}
int main()
{
    //5个线程同时抢票
    pthread_t tid[5];
    for(int i = 0;i < 5;i++){
        int* id = new int(i);
        pthread_create(tid+i,nullptr,ThreadRoutine,id);
    }
    //线程等待
    for(int i = 0;i < 5;i++){
        pthread_join(tid[i],nullptr);
    }
    
    return 0;
}

运行结果:

在这里插入图片描述

可以看到最后票数被抢成了负数,很明显已经造成了错误。

本质原因:tickets减少的这个操作并不是原子的,在汇编级别它是多行的代码:从内存被CPU读取,然后CPU处理减法,最后再回到内存中。

这里的每一步都可能会被其他线程打断,造成数据错误。

解决方法就是加锁,把锁加在临界区,就能实现不同线程资源的独立。

线程锁的初始化与销毁:

在这里插入图片描述

上述函数分别有锁的释放、锁的初始化(全局和局部锁)

  • 锁本身也是临界资源,而且具有原子性

其实这里的锁也是个临界资源,因为每个线程都需要进行加锁,就需要看到同一个锁资源。

由于加锁解锁本就是用来保护线程安全的,所以这个过程(申请锁和释放锁)其实就是原子的,锁自身保证了线程安全,才能去保证其他的线程安全。

这个过程的实现是通过汇编语句完成的:

申请mutex的本质,就是通过一条汇编语句(exchange、swap语句),将锁数据交换到自己的上下文中。这样做的好处是:每个线程来申请锁时,看到的都是自己的上下文数据,而不会和其他线程的上下文交错。

而不是像未加锁的时候的多线程抢票,自己的语句还没执行完,其他线程就来插一脚,造成混乱。

比如A线程的寄存器里的值为0,B线程寄存器里的值为0,竞争锁的要求是寄存器内容大于0,否则挂起;那么A和B在这个过程中如果A增加了,B没增加那么A将获得锁资源,但是B不会从A的寄存器数据开始计算,而是从它自己的寄存器开始计算,也就是依旧是0;那么它将被挂起无法获得锁资源。

我们说线程共享寄存器,但是并不共享寄存器里的内容,这是实现这个过程的基础。

这就是申请锁的原理实现,是原子性的、是通过公平竞争的方式申请的。

抢票程序加锁的构造和析构、类封装:

class Ticket
{
public:
    Ticket()
        :tickets(1000)
    {
    	//初始化锁
        pthread_mutex_init(&mtx,nullptr);
    }
    bool GetTicket()
    {
        bool res = true;
        //加锁
        pthread_mutex_lock(&mtx);
        //临界区
        if(tickets > 0){
            usleep(1000);
            std::cout << "我是["<< pthread_self() << "]我要抢的票是:" << tickets<< std::endl;
            tickets--;
            printf(" ");
        }
        else{
            //无票
            std::cout<<"票抢光了"<<std::endl;
            res = false;
        }
        //临界区结束
        //解锁
        pthread_mutex_unlock(&mtx);
        return res;
    }
    ~Ticket()
    {
        pthread_mutex_destroy(&mtx);
    }
private:
    int tickets;
    pthread_mutex_t mtx;
};
void* ThreadRoutine(void* args)
{
    Ticket* t = (Ticket*)args;
    while(true){
        if(!t->GetTicket())
        {
            break;
        }
    }
}
int main()
{
    Ticket* t = new Ticket();
    pthread_t tid[5];
    for(int i = 0;i < 5;i++){
        pthread_create(tid+i,nullptr,ThreadRoutine,(void*)t);
    }
    //线程等待
    for(int i = 0;i < 5;i++){
        pthread_join(tid[i],nullptr);
    }
    
    return 0;
}

加锁以后正常运行,未加锁会造成负数抢票。

5.2 加锁的意义

加锁以后直到释放锁之前的区域就变成了临界区;但是访问这个临界区的过程中,有可能会被其他线程切换掉吗?

是有可能的。

刚才不是已经加了锁吗?为什么我自己线程访问我的临界资源,还有被打断并切换成其他线程的风险呢?

加锁是为了保护临界资源,而不是为了保护线程在访问临界资源的时候不被切换,这是两个不同的概念。

线程在被切换的时候,要进行上下文保护,而这里面的数据就包括了锁资源,所以该线程的锁数据也会被一并带走。 在这之前,其他线程都无法以各种操作进入它的临界区。

相当于被赶出家门的人,是抱着钥匙走的,即使有后来人,也进不去它的家门,直到这个人将自己的钥匙和锁头带走。

站在其他线程的角度来看,这是一种很有意义的状态。因为某个线程要么就不申请锁,要么就使用完锁别的线程才可以继续申请,这体现了线程申请锁的原子性。

很明显,这样的锁只有一把,而且在进入临界区之前,每个线程都要执行一样的申请锁资源,要么就都不申请。

6. 死锁

死锁:死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。

死锁四个必要条件:

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

避免死锁:

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

7. 常见问题

  1. 常见不可重入的情况

调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的

调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构

可重入函数体内使用了静态的数据结构

  1. 常见可重入的情况

不使用全局变量或静态变量

不使用用malloc或者new开辟出的空间

不调用不可重入函数

不返回静态或全局数据,所有数据都有函数的调用者提供

使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据

  1. 可重入与线程安全联系

函数是可重入的,那就是线程安全的

函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题

如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。

  1. 可重入与线程安全区别

可重入函数是线程安全函数的一种

线程安全不一定是可重入的,而可重入函数则一定是线程安全的。 因为有一种情况是骑驴找驴;相当于自己把锁申请了,但是再次重入该函数进行申请锁,而此时的锁就在自己手里,一直等待自己释放锁,自己才能继续进行下去,但是这个过程永远无法完成,因为锁就在自己手上,这是个自相矛盾的问题,这就相当于死锁。所以说线程安全不一定可重入。

如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

久菜

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

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

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

打赏作者

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

抵扣说明:

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

余额充值