线程 概念、特性及常见问题分析

18 篇文章 0 订阅
17 篇文章 0 订阅

线程概念

在一个程序里的一个执行路线就叫做线程,更准确的定义是:线程是“一个进程内部的控制序列”,一切进程至少都有一个执行线程,线程在进程内部运行,本质是在进程地址空间内运行

线程在 Linux 操作系统中就是一个执行流,不同的执行流可以拥有不同的 CPU 来进行运算,即不同的的执行流之间可能会有并行的情况产生

在Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化

透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流

线程就是创建一个执行流,在内核当中创建一个 PCB,其实就是创建一个 task_struct 结构体对象,这个 PCB 当中的内存指针,指向进程的虚拟地址空间
在
当进程当中只有一个执行流时,意味着只有一个主线程,pid == tgid (pid:线程 id,tpid:线程组 id)
当进程当中不止一个执行流时
主线程,pid == tgid
工作线程(新创建线程) tgid (即进程号,线程组id,表示是一个进程),pid(相当于线程 id,每一个线程 pid 都是不同的)

创建线程的接口是调用的库函数,引申含义,即操作系统内核当中没有线程概念,内核认为为我们创建的线程,是在内核当中创建了一个轻量级进程(LWP)

轻量级进程(LWP)是计算机操作系统中一种实现多任轻量级进程(LWP)务的方法,与普通进程相比,LWP与其他进程共享所有(或大部分)它的逻辑地址空间和系统资源;与线程相比,LWP有它自己的进程标识符,优先级,状态,以及栈和局部存储区,并和其他进程有着父子关系;这是和类Unix操作系统的系统调用vfork()生成的进程一样的。

另外,线程既可由应用程序管理,又可由内核管理,而LWP只能由内核管理并像普通进程一样被调度。Linux内核是支持LWP的典型例子
LWP与普通进程的区别也在于它只有一个最小的执行上下文和调度程序所需的统计信息,而这也是它之所以被称为轻量级的原因。

一般来说,一个进程代表程序的一个实例,而LWP代表程序的执行线程(其实,在内核不支持线程的时候,LWP可以很方便地提供线程的实现)。因为一个执行线程不像进程那样需要那么多状态信息,所以LWP也不带有这样的信息。

创建出来的线程的 PCB,也是需要挂在内核当中的双向链表当中去,也就是意味着操作系统在管理线程(轻量级进程)时,也是通过双向链表来获取到线程的PCB,从而调度该线程获取到CPU资源,这也是同一个程序当中不同的执行流(线程),可以并行运行的原因

线程共享和独有

共享:
共享了虚拟地址空间,代码段数据段
文件描述符
信号处理器
当前进程的工作路径
进程用户id(Uid)和组id(Pid)

独有:

  1. tid:每个线程都有自己的线程ID,这个ID在本进程中是唯一的。进程用此来标识线程
  2. 栈:线程函数可以调用函数,而被调用函数中又是可以层层嵌套的,所以线程必须拥有自己的函数堆栈,使得函数调用可以正常执行,不受其他线程的影响
    堆栈是保证线程独立运行所必须的
  3. 信号屏蔽字:由于每个线程所感兴趣的信号不同,所以线程的信号屏蔽码应该由线程自己管理。但所有的线程都共享同样的信号处理器
    调度优先级:由于线程需要像进程那样能够被调度,那么就必须要有可供调度使用的参数,这个参数就是线程的优先级
  4. 一组寄存器:由于线程间是并发运行的,每个线程有自己不同的运行线索,当从一个线程切换到另一个线程上时,必须将原有的线程的寄存器集合的状态保存,以便将来该线程在被重新切换到时能得以恢复
  5. errno:由于同一个进程中有很多个线程在同时运行,可能某个线程进行系统调用后设置了errno值,而在该线程还没有处理这个错误,另外一个线程就在此时被调度器投入运行,这样错误值就有可能被修改,所以,不同的线程应该拥有自己的错误返回码变量。

线程优缺点

优点:

  1. 创建一个新线程的代价要比创建一个新进程小得多
  2. 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
  3. 线程占用的资源要比进程少很多
  4. 能充分利用多处理器的可并行数量
  5. 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
  6. 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
  7. I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作

缺点:

  1. 性能损失
    一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变
  2. 健壮性降低
    编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享
    了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的,当一个线程意外退出,可能会拖累整个进程
  3. 缺乏访问控制
    进程是访问控制的基本力度,在一个线程中调用某些OS函数会对整个进程造成影响,无法确认哪个执行流先进行,不能对执行顺序进行保证
  4. 编程难度提高
    编写与调试一个多线程程序比单线程程序困难得多
命令:
top 可以查看进程CPU的使用率
top -H -p[pid] 可以查看每一个线程CPU的使用率
gcore:可以使一个进程强制产生 core 文件
pstack:可以查看一个进程的堆栈

在gdb调试过程中,可以使用thread apply all bt 查看所有线程的调用堆栈

线程异常

  1. 单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃
  2. 线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出

线程用途

合理的使用多线程,能提高CPU密集型程序的执行效率
合理的使用多线程,能提高IO密集型程序的用户体验(如我们一边写代码一边下载开发工具,就是多线程运行的一种表现)

线程控制

前提:线程控制当中使用的函数都是库函数,使用线程控制函数时,需要增加链接线程库 -libpthread.so

线程创建

int pthread_create(pthread_t* thread, const phread_attr_t* attr, void*(*start_routine)(void*), void* arg);
    thread:出参,返回的时候线程的标识符,和线程 id 不一样,pthread_t 是线程空间的首地址,通过这个标识符可以对当前线程进行操作
    attr:类型是 pthread_attr_t 是一个结构体,这个结构体完成对新创建线程属性的设置,如果说在创建线程的时候,该参数设置为NULL,则表示采用默认的属性
        线程栈的大小
        线程栈的起始位置
        线程的分离属性
        线程的优先级调度属性
    start_routine:函数指针,接收一个函数的地址,在创建线程的时候,指定该线程要从哪个函数开始执行,线程的入口函数
        函数是一个void*返回值
    void* arg:表示的是给入口函数传的参数(void*)
        可以传递默认类型,也可以传递自定义类型(结构体,类的实例化指针)
        传递参数时,尤其需要注意临时变量的生命周期

在这里插入图片描述

线程终止

1. 从线程入口函数的return返回
2. pthread_exit(void* retval)
    谁调用,谁退出--》自杀行为
    retval:当前线程的退出信息,也可以传入NULL
3. int pthread_cancel(pthread_t)
        取消线程标识符的进程
        thread:想结束哪个线程就传入哪个线程的标识符
        这个函数可以结束任一个线程,可以结束任一个执行流

线程等待

前提:线程有默认属性,一般默认属性当中都有一个 joinable 属性,当该线程退出的时候,需要别人来回收退出线程的资源,意味着如果不去回收该线程的资源,该线程的资源就泄漏掉了,共享区当中关于该线程的资源就不会释放或重用

  1. 等待的必要性
    为了释放退出线程的资源,防止内存泄漏
2. pthread_join(pthread_t, void**)
        void*(*thread_start)(void*)
        pthread_exit(void*)
            void**:其实是一个出参,需要接收一个 void* 的地址
        三种情况:
            从线程入口函数的 return 返回,则可以使用 pthread_join 获取入口函数的返回值(void*)
            调用 pthread_exit(void*) 终止,则可以使用 pthread_join 获取 void* 的值
            如果线程是被 pthread_cancel 所终止的,则 pthread_join 获取的值是一个常数 PTHREAD_CANCELED

线程分离

为什么会分离?
线程退出的时候,由于线程的默认属性是 joinable 属性,资源需要被其他线程所释放,为了解决这种情况,将线程的属性设置为分离属性,好处是:当线程终止时,不需要别人来释放资源,操作系统直接回收
int pthread_detach(pthread_t thread)
thread:需要分离哪一个线程,也可以传入自己线程的标识符
获取自己线程的标识符
pthread_self():返回当前调用执行流的线程标识符,谁调用返回谁
两种方法

  1. 在创建线程的时候,调用 pthread_detach(tid)
  2. 在线程的入口函数当中,调用 pthread_detach( pthread_self()),第二种用得更多

线程安全

多个线程(执行流)同时进行访问临界资源,不会导致程序产生二义性,程序结果是唯一的,我们把这种情况称为线程安全

访问临界资源:在临界区当中对临界资源非原子操作(修改数据),带来的问题是:有可能一个执行流正在访问这个临界资源,但是由于操作是非原子性的,所以有可能被其他执行流打断,让出CPU,其他执行流有可能也访问了同样的临界资源,这样的话,就有可能会导致程序的二义性

原子操作:操作是一步完成的,只有两种结果:要么完成,要么未完成

正常的自加自减不是一个原子性操作,过程为先将变量的值加载到寄存器当中,然后 CPU 进行运算,写入寄存器,回写到内存,这个过程中,每一个步骤都有可能被打断,即让出 CPU 资源,当前执行等待重新获取时间片,这个时间段可能会有其他执行流对该变量进行访问,就会产生数据的二义性

保证线程安全
互斥:保证不同的执行流对临界资源的原子访问,即每一次只有一个执行流可以访问临界资源
不能造成同一个资源同时被不同线程拿到,保证拿到资源都合法

 实现方法:
 互斥锁:互斥锁底层其实是一个互斥量,互斥量是一个计数器,使用该计数器保证该计数器的取值只有两个 0 或 1
    0:表示当前资源不可以被访问,无法获取资资源,执行流被挂起
    1:表示当前资源不可以被访问,可以获取锁资源,加锁访问,当访问临界资源完成,需要对互斥锁进行释放锁的操作,也采用交换操作,相当于给互斥锁加 1

关于互斥锁的使用,请见下一篇博客.
在这里插入图片描述
计数器本身也是一个变量,变量在修改时(从 0 变 1,或 1 变 0),修改操作是否是原子操作?
计数器(从 0 变 1,或 1 变 0)不是直接对计数器变量进行加或减,而是通过 xchgd 与寄存器中的值进行交换,如果寄存器当中的值为 1,表示可以加锁,如果小于 1 ,不可以加锁,执行流挂起等待,等待锁资源

同步:保证了程序对临界资源访问的合理性
有资源通知来抢资源,没资源即等待,不陷入死循环

条件变量 = 两个接口(线程等待及唤醒线程)+ PCB 等待队列
消费线程、生产线程

条件变量的接口

  1. 定义条件变量
pthread_cond_t
  1. 初始化条件变量
int pthread_cond_init(pthread_cond_t*, pthread_condattr_t*)
    pthread_cond_t*:需要传入条件变量的地址
    pthread_condattr_t*:条件变量的
 pthread_cond_t cond = PTHREAD_COND_INITIALIZED;
     通过宏来进行初始化
  1. 等待接口
int pthread_cond_wait(pthread_cond_t* cond, pthread_mutex_t* mutex)
    cond:条件变量
    mutex:互斥锁变量

要在线程放到等待队列之后再解锁,防止在消费者线程解锁后,进入等待队列前,发生生产者获得互斥锁,生产出临界资源,并通知等待队列里的进程,此时,信号先给出,再进行入队,可能导致生产出的临界资源永远都无法被访问

  1. 唤醒接口
    pthread_cond_signal(pthread_cond_t*)
    传入的是条件变量的地址,唤醒 PCB 等待队列当中至少一个等待的执行流

  2. 销毁条件变量
    pthread_cond_destroy(pthread_cond_t*)

判断资源数量要使用 while 进行判断

需要两个条件变量来完成消费者和生产者两类不同的线程对程序运行的控制

生产者与消费者模型优点:

  1. 可以解耦合,生产者和消费者通过队列进行交互
  2. 支持忙闲不均,队列当中的节点就起到一个缓存的作用,生产者自己生产直到队列满
  3. 支持并发,因为消费者和生产者只需要关心在队列当中消费或生产

线程池

线程池的实现 = 线程安全的队列 + 一大堆线程

  1. 严格上区分,线程池当中的线程都是等价的,没有角色之分
  2. 从程序角度分析,线程池当中的线程都是处理线程池当中数据的,可以认为线程池当中的线程都是消费线程

线程池当中的线程都是统一的线程入口函数,跑同一份代码,现在需要处理不同的请求,就会产生问题

实际场景中,一台服务器可能在同一时间有大量不同的请求,程序不崩溃的情况下,尽可能多的处理请求,如何让相同入口的函数,处理不同的请求?
向线程池抛入数据时,将处理数据的函数一起抛入(函数地址),线程池当中的线程只需要调用传入的函数进行处理数据即可

线程与进程区别与联系

  1. 线程是操作系统调度的最小单位,进程是操作系统分配资源的最小单位
  2. 线程共享进程数据,但也拥有自己的一部分数据
  3. 一个进程至少有一个线程
  4. 线程的划分尺度小于进程,多个线程并发性高
  5. 进程在指定的过程中,拥有独立的内存单元,而多个线程共享进程的内存空间,从而提高了程序的运行效率
  6. 线程在执行的过程中,与进程的区别在于每个独立的线程有一个程序的运行入口顺序执行序列和程序出口,但线程不能独立运行,必须依存于进程,应用程序中由应用程序提供线程的控制

常见问题

问题:都指向了同一个虚拟地址空间,这个和 vfork 创建子进程有什么区别?
进程虚拟地址空间当中有线程独有的栈

问题:线程数量越多越好?
不是,大量的线程都需要占有CPU,增加操作系统的开销,操作系统忙于调度线程

问题:多进程和多线程的区别?
多进程:由于每一个进程都是拥有自己独立的虚拟地址空间,所以一个进程的异常不会导致其他进程退出。多进程也会提高程序的运行效率,但会带来进程间通信的问题
多线程:由于进程当中的每一个执行流使用的同一个虚拟地址空间,所以,一个执行流的异常可能会导致整个进程退出,多线程的程序也会提高程序的运行效率,但是会造成程序的健壮性低,代码编写的复杂性增高

问题:当前主进=线程如果创建了一个线程之后,调用 pthread_exit,进程是否后退出?
进程不会退出,但是主线程的状态变成了僵尸线程,工作线程的状态还是 S 或 R(即工作线程正常工作)

问题:为什么不用循环判断是否有临界资源而是加入等待队列?
大量的轮询会造成 CPU 的压力,为了节省 CPU 资源

问题:为什么需要互斥锁?
条件变量只是保证了同步,需要使用到互斥锁来保证互斥,保证消费者和消费者之间没有冲突,保证生产者和生产者之间没有矛盾,还要保证生产者和消费者之间的互斥,条件变量增加互斥锁,是为了通知消费者或者生产者,保证只有一个线程可以获得消费或生产的信号

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值