目录

线程概念:

线程之间的独有与共享:

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

线程控制:

线程创建:

线程终止:

线程等待:

线程分离:

线程安全(的问题):

*互斥锁:

*死锁: 

*同步:

*条件变量:

设计模式 (多线程的应用): 生产者与消费者模型

*信号量(POSIX):posix标准信号量:

*线程池的简单实现:

*线程安全的单例模式:


线程概念:

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

在linux之前学习进程的时候 ,进程就是一个pcb, 但是在现在学习线程的时候,  发现线程是进程中的一条执行流,而因为linux下执行流是通过pcb来完成的,所以理解pcb是linux下的执行流,反推得到了一个结论,linux下的一个pcb是一个线程。只不过人家linux下通常不谈线程,而叫做轻量级进程. ( 有些地方认为Linux没有真正的线程的说法, 线程实际上是一个轻量级进程. )

从另一个角度来说:

线程是cpu调度的基本单位, 进程是资源分配的基本单位.

线程之间的独有与共享:

共享(每个线程相同的): 

虚拟地址空间(使线程间可以直接通信).

信号处理方式(信号是针对进程的, 当给一个进程发送一个信号时, 所有的线程都能收到这个信号,  处于cpu时间片上运行的线程会处理这个信号, 某个地方修改了这个信号的处理方式则其他线程对这个信号的处理方式也跟着修改了.)

io信息(共享文件描述信息, 可以操作同一文件, 而且不同的线程的文件读写位置是一致的, 常见的设计思路是 有一个线程专门负责打开文件,  后续其他线程负责文件的各种操作).

工作路径( 比如在三级目录下运行一个open(./text, O_CREATE,664)的test程序则text文件生成在当前三记录下. 如果在二级目录下运行这个test程序: ./三级目录名/test 运行则text文件生成在当前的二级目录下)等...

独有(线程之间不同的):

( 局部变量存放在栈中, 但是把局部变量的地址给其他线程, 其他线程也能访问到该变量,因为虚拟地址空间是同一套的.).

上下文数据(即寄存器独有pcb是不断在切换运行的, 为了保存不同线程自己每次运行到哪了,下次时间片运行时接着运行,所以每个线程有自己的上下文数据.).

errno(不同线程用接口操作时某个可能失败了, 某个成功了, errno独有则保证了不发生冲突).

信号屏蔽字(即信号阻塞集合, 线程信号会打断当前操作, 为了保护某些线程正常运行, 就算此线程拿到时间片也不去处理信号. 所以线程之间信号阻塞集合是独有的).

线程id等...

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


多线程优点:

1. 线程间通信非常灵活(可以通过全局数据, 函数传参, 包括进程间通信方式实现线程间通信)

2. 线程创建与销毁成本更低(资源大多共享的, 除了独立的信息之外, 共享的信息不需要重新另创建)

3. 线程间切换调度成本稍低(多数数据共享不需要切换调度)

多进程优点:

1. 独立性高,稳定性强 (比如某个线程收到异常退出信号(或者调用了exit接口), 此时这个进程的所有线程都将退出, 信号是所有线程共享的, 但是如果是多进程就只退出异常的进程其他进程照常运行. 所以稳定性要求高的场景使用多进程, 比如大型的网络通信服务器, 除此之外为了便捷使用多线程)

共同优点:

cpu密集型程序(程序中大部分是cpu数据运算)和io密集型程序(程序中大部分是io操作)使用多进程或多线程的多执行流处理充分利用资源效率更高.

线程控制:

Linux通过线程库中的各种库函数进行线程控制.

线程创建:

int pthread_create(  pthread_t* tid,  pthread_attr_t* atrr,  void*(*thread_routine)(void* arg),  void* arg)

pthread_t* tid  :  使传入的tid实参获取线程id. 后续通过这个tid操作线程.(线程的操作句柄)

pthread_attr_t* atrr  :  用于设置线程属性,通常置NULL.

void*(*thread_routine)(void* arg) :  线程入口函数,线程要进行的函数.

void* arg: 传递给线程入口函数的参数(若要传多个参数, 可以组成一个结构体把结构体传入.)

返回值: 成功返回0  ,  失败返回非零值.

编译链接的时候需要加: -l+库名(为了跨平台性也可以不加-l)   如下

实现:

运行这个程序后可以通过下面的指令观察两个线程的信息(也可以在-L前加个l查看状态):   5423就是主线程main的tid也是所有线程的pid.  5424就是创造的第二个线程的tid.

线程终止:

线程进行的函数运行结束了, 这个线程就终止(退出)了.

1. 线程入口函数 return  ( main主函数return则退出了进程,  所有线程都终止,和下面不同 )

2. void pthread_exit(void* retval)  接口 , 没有返回值, 通过传入的参数获取线程退出的返回值.(不需要则置NULL).  哪个线程调用了这个接口该线程就退出  (和return不同的是, 如果线程入口函数调用了另一个函数, 那个函数中有这个exit接口则线程直接退出了. 如果没有,运行完调用函数返回到入口函数再运行到入口函数的return才退出.)

3.int pthread_cancel(pthread_t tid)  任意位置退出指定线程.  主线程调用pthread_cancel(pthread_self() )函数, 或pthread_exit(NULL)  则主线程的状态变更成为Z, 其他线程不受影响

线程等待:

默认情况下, 一个线程退出如果不等待也会造成资源泄露, 所以需要等待指定线程的退出, 获取这个线程的退出返回值, 从而释放资源.  有时候不仅仅是为了防止资源泄漏等待, 是必须等到某个线程处理完得到结果或者是必须等某个线程退出才能往下运行时等待.

线程等待接口: int pthread_join(pthread_t tid , void** retval)  是个阻塞等待接口.

tid: 等待指定的线程退出.

retval:  用于接收线程退出返回值. 通常定义一个void* retval 然后传入 &retval 作实参.(如果定义void** retval直接传retval会发成解引用野指针的问题. )不需要则置NULL

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

线程分离:

线程有很多属性, 其中有一个叫做分离属性, 分离属性默认值-JOINABLE, 表示线程退出之后不会自动释放资源 , 需要被等待, 如果将线程的分离属性设置为其他值-DETACH,这时候则线程退出后之后将不需要被等待,而是直接释放资源, 因为线程一旦设置了分离属性,则退出后自动释放资源,则等待将毫无意义,所以设置了分离属性的线程是不能被等待. 需要等待的线程则不会设置线程分离
int pthread_detach( pthread_t tid) 接口将指定线程分离属性设置为detach. ( 通常在一个线程接口自己内部刚开始第一行就使用pthread_detach( pthread_self() ))

不想等待某个线程且不需要它的返回值则将这个线程分离.

线程安全(的问题):

多线程同时修改同一个临界资源可能会造成数据的二义性. 所以需要实现线程安全保证多线程对同一个临界资源的的访问操作是安全的.

实现线程安全的方法: 同步与互斥.

互斥(通过 互斥锁 实现): 保证执行流在同一时间对临界资源的唯一访问.

同步(通过 条件变量, 信号量 实现): 通过一些规则(判断条件)实现线程对资源获取的秩序合理.

*互斥锁:

互斥锁的本质是一个 0/1 计数器, 主要用于标记临界资源的访问状态. 0不可访问,1可访问.

互斥锁操作: 访问资源之前加锁(加不上锁则阻塞,因为资源还没有解锁), 访问资源完毕则解锁.  

互斥锁实现互斥, 本质上自己也是个临界资源

同一个资源所有线程访问的时候加的是同一把锁. 不同的锁则加了没有意义.

为了保证互斥锁自身的操作是安全的, 互斥锁内部的操作是原子操作.

接口流程:

在互斥锁变量mutex加解锁之间的代码是受保护的安全是临界区.

 火车站买票示例:

上述示例中出问题的原因在于, 没有互斥锁保护临界资源就会:在抢票操作的1ms时间内, 时间片给到另外的黄牛, 此时第一个进入抢最后一场票的黄牛还没完成抢票操作,票数还为1, 第二第三个黄牛运行一看还有票就也进行了抢票操作. 就导致了票数不正常的情况.  则此时我们需要对票数这个资源进行互斥锁操作:

上述程序中, 发现都是同一个黄牛在抢票, 这是因为互斥锁只能保证安全操作,无法保证合理.

因为在加完锁之后, 第一个进入抢票的黄牛抢完票再解锁, 此时因为时间还在他手上他又马上运行到加锁抢票的过程. 然后如此往复其他黄牛每次有时间片想加锁都失败然后阻塞了.解锁的时候时间片还在原来抢票的黄牛手上,就导致了抢票不合理.  (只是互斥的不合理,不是下面说的死锁哈) 下面的同学吃饭初始做饭加入了条件变量的同步操作就可以合理的不同的同学吃饭不同的厨师做饭.

*死锁: 

程序流程流程无法继续运行, 卡死的情况叫死锁.

产生原因: 由于对锁资源争抢顺序不当所致.

导致死锁的四个必要条件 ( 四个条件都发生则产生死锁 ): 

1.互斥条件: 一个线程加了锁, 别人不能再加.

2.不可剥夺条件: 我加的锁别人不能解.

(前两个条件是是加互斥锁之后必然的,所以看是否死锁得看后两个是否发生)

3.请求与保持条件: 加A锁后请求B锁,B锁请求不到(因为B锁已被加锁)而不释放A锁.

4.环路等待条件: 加A锁请求B锁,对方已经加B锁请求A锁. 

综上理解: 因为规则一个资源只能被一个线程(1号线程)加锁, 且自己加的锁别人不能解. 然后1号线程加了一个资源A的锁后想给另一个资源B加自己的锁, 而另一个资源B已经被另一个线程(2号线程)加了2号自己的锁 1号线程就加不了也解不了, 而且2号线程同时也想给1号线程加了锁的A资源加锁. 因为1号线程得不到B锁就不释放A锁二号就得不到A锁, 2号线程得不到A锁也不释放B锁. 就导致了死锁( 例子: 哲学家吃饭问题, 哲学家坐一圈圆桌吃饭, 每个哲学家只有一只筷子, 每个都想要旁边的人的另一只筷子 ,  而每个人得不到另一只筷子吃不到饭就不把自己的筷子给别人导致了死锁.所以条件1,2理解就是一个筷子只能同时被一个人用, 且不能抢被人的筷子)

对同一资源加(解)锁顺序不一致导致了环路等待条件, 阻塞加锁导致了请求与保持条件. 所以预防死锁就得保证加解锁顺序一致, 使用非阻塞加锁.

避免死锁方案: 银行家算法, 银行家算法的思想在于将系统运行分为两种状态:安全/非安全,有可能出现风险的都属于非安全, 安全状态则系统中一定无死锁进程(思想:查看资源请求表,哪个线程要请求哪个锁,根据所有资源表和已分配资源表判断,这个锁分配给线程是否有可能造成环路等待(可能造成则不安全),不安全则不予分配.) 等算法...

死锁的处理办法:

鸵鸟策略  对可能出现的问题采取无视态度,前提是出现概率很低

预防策略 破坏死锁产生的必要条件

避免策略 银行家算法,分配资源前进行风险判断,避免风险的发生

检测与解除死锁 分配资源时不采取措施,但是必须提供死锁的检测与解除手段

*同步:

概念: 通过一些条件判断保证执行流对资源获取的秩序合理. 

即a线程达到某些条件时唤醒b线程. 然后自己再陷入加锁阻塞状态,等b线程达到唤醒自己的条件又b又唤醒a线程,如此往复,就实现了同步.

实现方式: 条件变量, 信号量. (信号量也能实现互斥)

*条件变量:

例子:

除了等待join直接传mutex.

加解锁, 初始化, 等待, 唤醒, 销毁传 &mutex, &cond, 初始化和等待加个NULL.

条件变量和互斥锁实现同步与互斥的学生吃饭厨师做饭问题:

*所以注意事项就是:

1.是否满足需要阻塞条件的判断应该使用循环操作!!!!

2.多种角色线程等待应该分开等待,分开唤醒防止唤醒角色错误多种角色定义多个条件变量.
 

设计模式 (多线程的应用): 生产者与消费者模型

设计模式是大佬们针对典型应用场景设计的解决方案. 生产者与消费者模型就是针对有大量数据产生及处理的场景的设计模式, 下面说的单例模式也是一种设计模式(针对的是一个类只能实例化一个对象,提供一个访问接口,一个资源在内存中只能有一份的场景), 以后遇到某些典型的场景就可以使用大佬们搞好的特定设计模式解.

生产者与消费者模型特点: 1. 解耦合(生产和处理分开,生产线程负责生产处理线程负责处理, 处理需更多时间和资源, 多创建几个处理线程) 2. 支持忙先不均(生产线程与处理线程并不直接交互, 生产线程生产的要处理的数据先放入一个数据缓冲队列, 处理线程空闲的话就查看这个缓冲任务队列, 有任务则取出处理.) 3.支持并发(多个生产处理线程访问同一个任务队列, 所以这个数据缓冲队列必须保证线程安全同一时间只有一个线程对队列操作.)

条件变量和互斥锁实现生产者与消费者模型: 两种角色的线程负责入队(生产)和出队(处理),  和一个线程提供入队出队的安全的队列. 

注意: 运行时出现打印的入队 (出队) 数据个数比定义的最大的数据个数MAXQ多的原因是因为入对数据 打印, 出队数据 打印这两个地方的两步操作不是原子操作, 运行了_push之后数据最大了, 然后线程阻塞, 时间片轮转到别的线程, 等待下一次时间片抢到空余位置插入数据再打印然后继续运行插入数据,这就是打印多出数据的原因, 打印多了不代表队列中的数据多了. 如果将生产者或消费者中的某一角色线程个数比另一角色线程个数多好多的时候就会出现一对一如队即出队交互了的假象

*信号量(POSIX):
posix标准信号量:

计数器用于线程可以是局部变量通过传参使用同一个,或者使用全局变量

计数器用于进程间,这个计数器是通过共享内存实现的

systemV标准信号量: linux内核提供的一个计数器

本质: 一个计数器, 用于实现进程或线程之间的同步与互斥.

p操作: 计数减一, 且判断技术是否大于等于0 , 大于等于0则返回, 小于0则阻塞.

v操作: 计数加一, 且唤醒一个阻塞的进程或线程 (其实sem_post唤醒多个阻塞进程或线程,但是真正获取到资源的只有一个, 其他的没有资源又会陷入阻塞).

信号量实现同步: 通过队资源数量进行计数, 获取资源之前进行p操作, 产生资源之后进行v操作. 通过这种方式实现对资源的合理获取.

信号量实现互斥: 计数器初始值为1 ,访问资源前进行p操作, 访问完毕进行v操作, 实现类似加解锁的操作.(真正使用中不需要用信号实现锁, 都是用定义好的mutex互斥锁)

信号量实现的生产者与消费者模型:

这里运行结果和上面条件变量与互斥锁实现的一样, 打印的数据个数有误,原因也是因为_push (_pop)操作和打印操作不是原子操作.

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

其他一些锁:

*线程池的简单实现:

线程池其实就是一堆(一个或多个)线程进行任务处理. 针对有大量任务需要处理的场景.

上面的生产者消费者模型思想其实就是多线程进行任务处理的思想 , 线程池则可以说是多线程任务处理的具体应用. 所以类似的, 线程池的实现思想就是一堆创建好的线程和线程安全的任务队列. 有任务进入线程池中,就会分配一个线程处理.  线程池和来一个任务就创建一个线程处理比的优点是: 1. 节省了任务处理过程中线程创建和销毁的时间成本    2. 线程池中的线程和任务节点数量都有最大限制, 避免资源耗尽风险.

为了降低线程池的耦合度, 在给出任务进入线程池时应同时给出任务的处理方法.( 通过函数指针 ),所以给进任务队列里的任务就不只是要处理的数据了, 得加上数据对应的解决方法, 两者合并为一个taskfun类传入任务队列里. 线程池负责将任务入队和从队中取出任务并处理.

实现: 

*线程安全的单例模式:

单例模式也是一种设计模式, 针对一个类只能实例化一个对象, 提供一个访问接口的场景(一个资源在内存中只能有一份的场景)

目的是: 1.节省空间 2.防止数据二义性

两种实现方式:

饿汉(资源全部提前加载完毕, 用的时候可以直接用, 以空间换时间)

template<class T>
class singleton{
public:
    //单例模式只有一个类外能访问的接口,其他构造,拷贝构造,赋值重载都是私有的
    steatic T* Getlenstance(){
        return &data;
    }
private:
    static T data;//静态成员属于全局, 程序运行前就加载好了.
    singleton(){} //构造函数私有化, 无法在类外实例化对象.
    static singleton _mysingleton;//类内初始化.
};

懒汉(资源用的时候才加载,不用不需要加载. 延迟加载,用的地方更多) 

template<class T>
class singleton{
public:
    //volatile关键字防止编译器过度优化
    //类外初始化静态成员时:T* singleton::data=NULL
    //不使用volatile被编译器优化后会一直使用寄存器中的NULL.
    volatile static T* getlenstance(){
        if(data==NULL){ //加锁前二次检查,提高效率.
            _mutex.lock();//多线程可能同时访问,为了线程安全加锁
            if(data==NULL){
                data=new T();//申请调用时才加载
                _mutex.unlock();
            }
        }
        return data;
    }
private:
    volatile static T* data; //静态指针, 申请使用时才加载变量.
    static std::mutex _mutex;
    singleton(){} //构造函数初始化
};

Logo

更多推荐