linux:“多线程”基础理解


一、概念

线程是进程中的一条执行流程,

但是因为linux下执行流是通过pcb实现的,因此linux下的线程实际上就是一个pcb,一个进程中可以有多个执行流,也就是有多个pcb,且这些pcb还共享很多程序运行所需的资源;

因此linux下的pcb就叫—轻量级进程

(一般说:linux下无真正的线程,线程实际上是一个轻量级进程)

1.进程与线程的区别

  1. 进程是操作系统进行资源分配的基本单元;
  2. 线程是操作系统进行cpu调度的基本单元。

2.线程之间的独有与共享

共享:虚拟地址空间,信号处理方式,IO信息,工作路径…
独有:栈,寄存器,上下文数据,errno,信号屏蔽字,线程ID…

3.多进程与多线程在多任务处理中的优缺点

3.1多线程

  1. 线程间通信非常灵活(包含进程间通信方式在内,全局,传参);
  2. 线程的创建与销毁成本更低(线程资源大多共享);
  3. 线程间的切换调度成本稍低。

3.2多进程

独立性高,稳定性强

共同优点多任务使用多执行流处理的优点。

(1)cpu密集型程序:
程序中几乎都是cpu数据运算;
多核cpu:更加充分利用cpu资源
执行流并不是越多越好,多了反而增加切换调度成本。

(2)IO密集型程序:
程序中几乎都是IO操作。
IO操作:等待IO就绪,数据拷贝

二、线程控制

在linux中没有直接提供线程的操作接口,
因此在上层封装实现了一套线程库

在用户态创建了一个线程,
但是linux下程序调度只能通过pcb完成,
则在内核中也创建了一个新的pcb

用户态线程:上层创建的线程。
轻量级进程:内核中的pcb。

1.创建

int pthread_create(
  pthread_t *tid, 用于获取上层线程id
  pthread_attr_t *attr, 用于设置线程属性,通常置NULL
  void *(*thread_routine)(void* arg), 线程入口函数
  void *arg  传递给线程入口函数的参数
)

返回值:成功返回0, 失败返回非0值。

2.终止

如果一个线程的入口函数运行完毕,则该线程就会退出

(1)在线程入口函数中return(main中return退出的是进程)(2)void pthread_exit(void *retval);任意位置调用退出调用线程
   函数没有返回值,但是参数retval这是线程的退出返回值。
   
(3)int pthread_cancel(pthread_t tid);在任意位置退出指定线程。

3.等待

等待一个指定的线程退出,获取这个线程的**退出返回值,**释放资源

默认情况下,如果一个线程退出,如果不等待也会造成资源泄露

int pthread_join(pathread_t tid, void **retval);
    tid:表示要等待哪个进程退出
    retval:是一个void*空间的地址,用于接收线程返回值
    void *(thr_entry)(void *arg)

默认情况下线程退出,为了保存自己的退出返回值,因此线程占用的资源在退出后也不会完全被释放,需要被其他线程等待。

线程等待不仅仅为了释放资源,避免资源泄露而等待;还有:

  1. 必须等到某个线程处理完成后得到结果才能继续往下处理;
  2. 等到某个或所有线程退出后再继续运行。

4.分离

  int pthread_detach(pthread_t tid); 

由来:

当不关心一个线程的返回值时,又不需要等待线程退出才能往下运行,
这时候等待会导致性能降低,
在该场景下,等待就不合适了,但是不等待又会资源泄露;
一个线程,既不关心返回值,也不想等待
基于该需求就有了线程分离

注意:
因为线程一旦设置了分离属性,则退出后自动释放资源,则等待将无意义,所以设置了分离属性的线程不能被等待。

线程分离:

将线程的分离属性设置为detach状态。

等待与分离:

  1. 等待:线程默认有个分离属性,默认值为joinable
    表示线程退出后不会自动释放资源,需要被等待,获取返回值之后释放资源。
  2. 分离:将线程的分离属性设置为detach
    线程退出之后将不再被等待,而是自动释放资源。

应用场景:
创建线程之后,根据是否有需要等待的需求而定;
本质上等待与分离,在实际使用中只会用其中一个!

三、线程安全

1.概念

多线程之间对同一个临界资源的访问操作是安全的
多线程同时修改同一个临界资源有可能会造成数据二义!

2.实现

如何实现线程操作是安全的?

  1. 同步:通过一些条件判断实现对资源获取的合理操作
    同步的实现:条件变量,信号量
  2. 互斥:保证执行流在同一时间对临界资源的唯一访问
    互斥的实现:互斥锁

2.1 同步的实现

概念:

通过一些条件判断,保证执行流对资源获取的合理。

实现:

(1)条件变量
(2)信号量

实现同步原理:

当线程访问资源的条件不合理时则调用阻塞接口阻塞线程,
将线程pcb加入到条件变量等待队列中,
等到其他线程促使条件满足之后,唤醒等待队列上的pcb。

2.1.1 条件变量

pcb等待队列+两个接口(阻塞线程、唤醒线程)
在这里插入图片描述

2.1.2 条件变量接口
1.定义条件变量: 
    pthread_cond_t cond;
2.初始化条件变量:
    int pthread_cons_init(pthread_cond_t *cond,pthread_condattr_t *attr); //属性通常置空
3.使线程阻塞:
    int pthread_cond_wait(pthread_cond_t *cond,pthread_mutex_t *mutex);
    该接口中涉及三个操作:解锁,阻塞,被唤醒后加锁;
    且解锁和陷入阻塞是原子操作,一步完成不会被打断;
    int pthread_cond_timedwait() //限制时长的阻塞等待
  如何阻塞一个线程?
      将pcb的状态置位可中断休眠,置一个唤醒条件。
      pthread_cond_wait 使线程陷入阻塞。
     (修改线程状态,将线程pcb加入到cond的pcb队列中)
4.唤醒阻塞的线程
    int pthread_cond_signal(pthread_cond_t *cond);
    将cond的pcb队列中的线程至少唤醒一个;
    int pthread_cond_broadcast(pthread_cond_t *cond);
    将cond的pcb队列中的线程全部唤醒。
5.释放销毁
    int pthread_cond_destroy(pthread_cond_t *cond);

注意:

信号只提供了使线程阻塞和唤醒线程接口
至于什么时候阻塞,什么时候唤醒,条件变量本身并不关心,全部由用户自己控制

条件变量使用注意事项:

  1. 是否满足条件的判断语句应该使用循环操作。
  2. 多种角色线程使用多个条件变量,分开等待,分开唤醒,防止唤醒角色错误。
2.1.3 信号量(posix)

本质:

就是一个计数器,用于实现进程或线程之间的同步与互斥。

操作:

  • P操作:计数-1,判断计数是否大于等于0,成立则返回否则阻塞。
  • V操作:计数+1,唤醒一个阻塞的进程或线程。

同步的实现:

  • 通过计数器对资源数量进行计数,获取资源之前进行P操作,产生资源之后进行V操作,
    通过这种方式实现对资源的合理获取。

互斥的实现:

  • 计数器初始值为1(资源只有一个),访问资源前进行P操作,访问完毕进行V操作,
    实现类似于加锁和解锁的操作。
2.1.4 信号量接口
(1)定义: sem_t sem;
(2)初始化: int sem_init(sem_t *sem,int pshared,int value)
           sem:定义的信号量;
           pshared: 0-线程间 ; 1-进程间
           value: 信号量初值—有多少资源初值就设置多少.
(3)P操作: int sem_wait(sem_t *sem); --阻塞
         int sem_trywait(sem_t *sem);--非阻塞
         int sem_timedwait(sem_t *sem,struct timespec*);
(4)V操作: int sem_post(sem_t *sem);
(5)销毁: int sme_destory(sem_t *sem);
2.1.5 使用信号量实现一个生产者与消费者模型

实现线程安全的数据队列:

class RingQueue{
     std::vector<int> _arry;
     int _capacity;
     int _write_step;
     int _read_step;
     sem_t _sem_idle;//空闲空间计数
     sem_t _sem_data; //数据资源计数
     sem_t _sem_lock;//用于实现互斥
}

条件变量与信号量实现同步上的区别:

  1. 本质上的不同:信号量是个计数器,条件变量没有计数器;
    因此条件变量的资源访问合理性需要用户自己进行,但是信号量可以通过自身计数完成。
  2. 条件变量需要搭配互斥锁一起使用,而信号量不需要。

2.2 互斥的实现–互斥锁

本质:

就是一个0/1的计数器,主要用于标记资源的访问状态;
0-不可访问, 1-可访问

操作:加锁解锁

  1. 加锁:将状态置为不可访问状态

  2. 解锁:将状态置为可访问状态

    一个执行流在访问资源之前进行加锁操作,
    如果不能加锁则阻塞,在访问资源完毕后解锁。

  • 互斥锁实现互斥,本质上自己也是个临界资源
    (同一个资源所有线程在访问的时候必须加同一把锁)。

因此互斥锁必须先保证自己是安全的–

  • 互斥锁的操作是一个原子操作!

在这里插入图片描述

2.2.1 接口
(1)定义互斥锁变量
   pthread_mutex_t mutex;
(2)初始化互斥锁 
   int pthread_mutex_init(pthread_mutex_t *mutex,
                          pthread_mutexattr_t *attr);
      mutex:定义的互斥锁变量; 
      attr:互斥锁属性,通常置NULL
   返回值:成功返回0; 失败返回错误编号。
(3)加锁
   int pthread_mutex_lock(pthread_mutex_t *mutex);-阻塞
   int pthread_mutex_trylock(pthread_mutex_t *mutex)-非阻塞
(4)解锁
   int pthread_mutex_unlock(pthread_mutex_t *mutex)
(5)释放销毁
   int pthread_mutex_destroy(pthread_mutex_t *mutex)

注意点:

  1. 使用锁过程中,加锁后,在任意有可能退出的位置都要解锁;
  2. 锁只能保证安全操作,无法保证操作合理。

2.3 死锁

2.3.1 概念

死锁程序流程无法继续推进,卡死。

产生:由于对锁资源的争抢不当所导致。

  1. 加解锁顺序不一致。
  2. 阻塞加锁。
2.3.2 四个必要条件
  1. 互斥条件:自己加的锁,别人不能继续加。
  2. 不可剥夺条件:自己加的锁别人不能解。
  3. 请求与保持条件:加A锁后请求B锁,B锁请求不到不释放A。
  4. 环路等待条件:加A请求B,对方加B请求A。
2.3.3 预防
  1. 加解锁顺序保持一致。
  2. 使用非阻塞加锁,加不了锁释放已有。
2.3.4 避免死锁—银行家算法
  1. 定义系统运行状态:安全,非安全。
  2. 定义表:所有资源表,已分配资源,资源请求表。
  3. 思想:查看资源请求表,哪个线程要请求哪个锁,根据前两张判断,
    这个锁分配给线程是否有可能造成环路等待,有可能则不予分配。

四、线程的应用

4.1 生产者与消费者模型:一种特殊的设计模式

4.1.1 设计模式

针对典型应用场景设计的解决方案。

4.1.2 针对场景

有大量数据的产生以及处理的场景。

4.1.3 思想

将产生与处理两个模块分开,通过线程安全的缓冲区进行交互。

4.1.4 优点

  1. 解耦合(减少模块之间的依赖性,提高程序的独立性)
  2. 支持忙闲不均(数据缓存队列)
  3. 支持并发(缓存队列必须线程安全)

4.1.5 实现

生产者与消费者—两种角色的线程—入队和出队
封装代码通常都是一个功能一个模块
线程安全的任务队列:

 class BlockQueue{
     std::queue<int> _queue;
     int _capacity;//容量
     pthread_mutex_t_mutex;
     pthread_cond_t_cond_pro;
     pthread_cond_t_cond_con; 
     public:   
      BlockQueue(){}   
       bool Push(const int data);
        return ture;    
       bool Pop(int *data); 
        return ture;
}

良好代码风格:

1)输入参数:在函数内使用传入的数据--const2)输出参数:通过参数返回处理结果数据--使用指针
(3)输入输出参数:既要使用传入的数据,也要通过参数返回数据--使用引用

4.2 读写锁

4.2.1 应用场景–读者写者模型

4.2.2 读者写者模型:

读共享,写互斥的场景。
(用互斥锁,串行化效率低)

4.2.3 读写锁

  • 加读锁:当前只要没有被加写锁
  • 加写锁:既没有读,也没有写的时候才能加写锁

实现:用两个计数器—读者计数+写者计数

当加锁不成功时,则要阻塞进程/xainc
读写锁的阻塞是通过自旋锁来实现的

4.2.4 自旋锁

一直占用cpu不释放,循环进行条件判断(即时性更强)

适用场景

适用于等待时间确定较短的场景

4.2.5 其他锁的种类

  • 无锁编程, 悲观锁,乐观锁(CAS锁实现),可重入锁,不可重入锁
  • 悲观锁:总是认为访问期间会有冲突,因此总是加锁保护。
  • 乐观锁:总是认为访问期间大概率无冲突。
  • 可重入锁:同一个线程可以重复加锁。
  • 不可重入锁:同一个线程不可重复加锁,一个锁只能加一次。

4.3 线程池的简单实现

4.3.1 线程池

一个或多个线程+线程安全的任务队列,对任务进行处理。

4.3.2 主要针对

有大量任务需要处理的场景

使用多执行流可以提高处理效率,
若一个任务到来就创建一个线程进行处理,处理完后销毁有缺陷:

  1. 成本:一个任务处理的成本
    总耗时=线程创建时间+任务处理时间+线程销毁时间
    若任务处理时间较短,则大量时间被线程的创建与销毁消耗了。
  2. 风险:若线程无限制,则在峰值压力下会有资源耗尽系统崩溃风险。

4.3.3 思想

线程池其实是一堆创建好的线程+任务队列
有任务来了抛入线程池中,分配一个线程进行处理

4.3.4 优势

  • 并发处理,效率更高。
  • 节省了任务处理过程中线程的创建与销毁时间成本。
  • 线程池中的线程与任务节点数量都有最大限制,避免资源耗尽风险。

4.3.5 实现

  • 定义一个任务类:需要处理的数据,处理数据的方法

  • 定义一个线程池类:多个提前创建好的线程+线程安全的任务队列

class ThreadPool{
private:
     int _thr_max;//线程数量
     int _qmax;//任务节点数量
     BlockQueue _queue;
public:
     ThreadPool(){创建线程 pthread_create(thr_entry())};
     void *thr_entry(void *arg){出队任务,进行处理}
}  
  • 若线程入口函数固定,则线程的任务处理方法固定,
  • 若任务处理也加入线程池中,则模块之间的耦合度较高;

因此线程池模块,就只是一个线程池,主管处理任务,
但是不管如何处理,要求外部在抛入数据的同时,顺便将处理方法也给定。

typedef void(*handler_t)(int data);
class ThreadTask{
private:
    int _data;//要处理的数据
    handler_t_handler;//数据的处理方法
public:
    bool Run(){_handler(_data);}
}

4.4 线程安全的单例模式:

4.4.1 单例模式

一种典型的设计模式-针对典型场景设计的解决方案

4.4.2 针对场景

一个类只能实例化一个对象,提供一个访问接口
(一个资源在内存中只能有一份);

4.4.3 目的

  1. 节省内存空间。
  2. 防止数据二义混淆(多份数据在同一时刻的不同体现)。

4.4.4 实现

  • 饿汉:资源提前全部加载初始化完毕,用的时候直接用。
    (以空间换时间的思想)
  • 懒汉:资源用的时候再去加载初始化,不用就不需要。
    (延迟加载的思想)

4.4.5 饿汉

template<class T>  
class Singleton{
private:   
   static T data;//全局共享,程序运行前初始化加载
   Singleton(){} //构造函数私有化
public:
   T* Getlnstance(){
   return &data;
  }
};
  1. 构造函数私有化,无法在类外实例化对象。
  2. 成员变量(资源数据)静态化,资源单独一份共享,运行前初始化,
    初始化过程不用考虑线程安全问题。

4.5.6 懒汉

template<class T>
class Singleton{
private:
   volatile static T *data
   static std::mutex _mutex;
public:
   volatile static T* Getlnstance(){
       if(data == NULL){
        _mutex.lock();
       if(data == NULL)
          data = new T();
        _mutex.unlock(); }
         return data;
   }  
}
  1. 构造函数私有化
  2. 定义静态指针对象(为了在用的时候申请加载)
  3. 在访问接口中加锁保护资源初始化加载过程
  4. 在加锁之外进行二次探测,提高效率
  5. 防止编译器过度优化,使用volatile修饰指针成员变量
  • 6
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 8
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值