线程认识

Posted

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了线程认识相关的知识,希望对你有一定的参考价值。

线程是为了让程序更好的利用cpu资源,在并行/并发处理下比进程切换cpu使用所要的花销要小。在一个程序里的一个执行路线就叫做线程(thread)。更准确的定义是:线程是“一个进程内部的控制序列”。一切进程至少都有一个执行线程。线程在进程内部运行,本质是在进程地址空间内运行。在Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化(Linux中可以称为轻量级进程(LWP))。透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流。
结合生活中的例子,创建一个进程就相当于工厂新建了一个厂子,而创建一个线程就相当于在原厂的基础上增加一条生产线,在这方面也能看出创建新进程会分配新的虚拟地址空间,而创建新的线程则会共用原来的虚拟地址空间(创建进程会拷贝原有的PCB并指向原有的虚拟地址空间)。那么进程和线程的区别是什么呢?进程的作用更多是资源的管理(管理内存、文件),而线程的作用更多是负责资源的调度和执行(也是抢占式的调度)。
------>线程之间共用虚拟地址空间和文件描述符表
------>线程之间不共用的资源:①栈(函数调用栈、局部变量),每个线程的栈是不共用的但不是私有的,别的线程也可以用②上下文信息(cpu中寄存器数据保存在内存中,方便下次执行)③errno错误码
技术图片
这张图也就说明每个线程在共享内存区拥有自己独立的信息


线程相比于进程的优缺点
优点(线程间共享同一虚拟地址空间)

  • 创建和销毁一个新线程的代价比创建新进程的代价小(没有创建/销毁虚拟地址空间的开销)
  • 线程间切换(调度)的开销更小----->cpu少所以要切换并发式的执行
  • 线程占用的资源更小

缺点

  • 健壮性降低:一个线程异常终止就会导致整个进程异常终止
  • 编程、调试难度增加:对线程的可靠性要求很高,也要线程安全问题
  • 线程不是越多越好,当达到一定数量时效率就无法提升了
  • 线程过多时当多个线程尝试访问同一个资源可能会有冲突(互斥解决)
  • 线程过多可能会导致某个线程一直得不到执行的机会,导致线程饥饿(同步解决)

多线程/进程的应用场景:cpu密集型------>cpu进行大量的计算,IO密集型-------->进行大量的输入输出(如 ①利用多线程通过网络输入输出②响应UI界面,借助多线程来防止由于数据计算太久导致界面卡死)


线程控制

这些函数不是系统调用函数,而是基于posix库的库函数
线程创建

int pthread_create(pthread_t * thread, const pthread_attr_t *attr,void *(*start_routine) (void *), void *arg);
thread:返回线程ID,输出型参数,本质就是一个进程地址空间上的一个地址
attr:设置线程的属性,attr为NULL表示使用默认属性
start_routine:是个函数地址,新线程启动后要执行的函数,要执行什么代码
arg:传给线程启动函数的参数
在编译时有可能报错提示对pthread_create未定义的使用,说明没有找到这个函数的链接库,只需要在编译命令中加上-lphread即可
使用ps -eLf(L来的意思是LWP轻量级进程) 查看所有的线程信息,也可以通过管道|grep test来筛选,此时得到的线程tid是站在内核角度给PCB加的编号,使用pthread_self()得到的线程tid是站在posix线程库的角度(无符号长整型)
技术图片
pstack [进程id]就可以查看这个进程中有多少个线程以及线程信息(调用栈),由上图可以看出新线程并不是由主线程调用的,也就是说并不是系统调用,而是clone()函数调用而来。
也可以通过gdb attach 【pid】将gdb附加到进程上,然后使用info thread查看当前的线程信息

线程终止
正常退出:
1.从线程入口函数执行结束(最常用、最优的线程退出方式)

  1. void pthread_exit(void * retval);参数时线程的返回结果,通常传NULL
  2. int pthread_cancel(pthread_t thread);让任何一个线程结束(本进程的线程)
    这个函数带来了一个问题:让代码不具备事务性,也就是说有可能代码在执行过程中由于抢占式被这个函数执行后就会终止,可能还有一些事情没有做完、一些数据没有被更新,所以并不太推荐使用这个函数
    线程等待
    目的和进程等待类似,防止出现类似僵尸进程的内存泄露
    int pthread_join(pthread_t thread, void ** retval) ;也是阻塞函数
    thread:等待的线程tid
    retval:输出型参数,保存的是退出线程的退出状态,不关心时可以传NULL
    线程分离
    默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏。
    如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。就不需要显示回收了,就相当于进程中的SIGCHLD信号的是使用类似。

int pthread_detach(pthread_t thread);
在哪里detach都是ok的,就是让这个线程分离。


线程中的同步和互斥(互斥锁+条件变量或信号量实现)

互斥:
线程不安全:在多线程环境下程序的执行结果出现预期之外的结果,线程抢占式执行,这是“万恶之源”。模拟场景:两个线程对同一个全局变量进行自增操作时分三步首先将这个全局变量从内存中拿到CPU中,然后进行自增操作,最后从CPU中放回内存,而在多线程环境下,由于两个线程操作的是同一个变量,所以当第一个线程将这个变量计算结束放回内存后第二个线程也有可能进行了相同的操作,这就导致这个变量的值再一次被更新,就不是我们预期的结果,这也就产生了bug。
临界资源:多个线程访问的公共资源叫做临界资源
临界区:访问临界区的代码
互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
原子性(后面讨论如何实现):不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成
而解决线程不安全的方法就是在临界区中使用“互斥机制”,互斥机制的使用就是加锁解锁的过程,同一时刻只有一个线程获取到锁,只有这个获取到锁的线程才能执行临界区代码,其他线程只能等待锁的释放
可重入函数和线程安全函数的理解:
可重入函数指一个函数在任意执行流中调用都不会出现问题
线程安全函数指一个函数在任意线程中调用都不会出现问题
所以就可以看出来可重入这个概念覆盖到了线程安全,也就是说一个函数是可重入函数那么一定是线程安全的,反之一个函数线程安全则不一定可重入。

通过引入互斥量之后的代码:
```#include<stdio.h>
#include<pthread.h>
#include<unistd.h>

#define THREAD_NUM 2

int g_count=0;

pthread_mutex_t mutex;//定义全局变量,互斥量

void ThreadEnter(void arg){
(void)arg;
int i=0;
for(i=0;i<50000;++i){
pthread_mutex_lock(&mutex);//在临界区之前上锁
g_count++;
pthread_mutex_unlock(&mutex);//在临界区之后解锁
}
return NULL;
}

int main()
{
int i=0;
pthread_mutex_init(&mutex,NULL);//对这个互斥量初始化
pthread_t tid[THREAD_NUM];
for(i=0;i<THREAD_NUM;++i){
pthread_create(&tid[i],NULL,ThreadEnter,NULL);
}
for(i=0;i<THREAD_NUM;++i){
pthread_join(tid[i],NULL);
}
printf("%d ",g_count);
pthread_mutex_destroy(&mutex);//释放这个互斥量
return 0;
}


互斥锁的特点:互斥锁能够保证线程安全,由并行执行强行变为串行执行,降低了程序的执行效率,也有可能变成**死锁**。
**死锁**是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源而处于的一种永久等待状态。
**死锁四个必要条件**
互斥条件:一个资源每次只能被一个执行流使用
请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
**避免死锁**
破坏死锁的四个必要条件
加锁顺序一致
避免锁未释放的场景
资源一次性分配

死锁最常见的场景:①一个线程加锁一次之后再尝试加锁(连续加两次锁,加锁是阻塞的)②两个线程1、2,有两把锁A、B,线程1先获取到锁A再想去获取锁B,线程2先获取到锁B,再想去获取锁A,这个时候这两个锁都没释放,也就引起了死锁。③多个线程多把锁(哲学家就餐问题,五个哲学家五根筷子,解决方案是对筷子编号并规定先后顺序或增加信号量来计数)。**比较实用的死锁解决方案**->①短:临界区代码尽量短②平:临界区代码尽量不要调用其他复杂函数③:快:临界区代码执行速度尽量快,避免太耗时的操作

-----

***同步:***
在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步
简单示例:

pthread_mutex_t mutex;//定义全局变量,互斥量
pthread_cond_t cond;//定义全局变量,同步变量

//让传球和扣篮的顺序匹配
void ThreadEnter1(void arg)
{
(void)arg;
while(1)
{
pthread_cond_signal(&cond);
printf("传球 ");
usleep(879789);
}
return NULL;
}

void ThreadEnter2(void arg)
{
(void)arg;
while(1)
{
pthread_cond_wait(&cond,&mutex);//让快的线程等待,等到慢的发出信号结束阻塞
printf("扣篮 ");
usleep(211234);
}
return NULL;
}
int main()
{
pthread_cond_init(&cond,NULL);
pthread_mutex_init(&mutex,NULL);
pthread_t tid1,tid2;
pthread_create(&tid1,NULL,ThreadEnter1,NULL);
pthread_create(&tid2,NULL,ThreadEnter2,NULL);
pthread_join(tid1,NULL);
pthread_mutex_destroy(&mutex);
pthread_cond_destroy(&cond);
pthread_join(tid2,NULL);
return 0;
}


在同步中一些概念的理解:
* 静态条件:因为时序问题导致的程序异常,比如上面由usleep所模拟的场景
* 在pthread_cond_wait()函数中搭配互斥锁来使用是由于条件等待大部分情况下是由两个线程完成的,一个线程进行等待,另一个线程友好的通知它什么时候等待结束,所以一定要用互斥锁来保护,没有互斥锁就无法安全的获取和修改共享数据。解锁和等待必须都是原子操作,所以这个函数两个参数分别是条件变量和互斥锁。

-----

# 生产者消费者模型
什么是生产者消费者模型?
当我们完成某些操作的时候需要一些数据,而这些数据可能由专门的线程进程产生再有专门的线程进程去使用,类比生活中的包饺子过程,有人擀面,有人包饺子。
![](https://s1.51cto.com/images/blog/202001/13/3bf9d92651a3d98e22344df7f8df86f5.png?x-oss-process=image/watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_100,g_se,x_10,y_10,shadow_90,type_ZmFuZ3poZW5naGVpdGk=)
生产者负责产生数据,然后将数据放入仓库中(通常用队列管理),消费者负责消费数据把数据从仓库中拿走。
消费者和消费者之间、生产者和生产者之间、生产者和消费者之间都是互斥关系,生产者和消费者之间还具有同步关系。

    下面就是一个简单的生产者消费者的实例:

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

//仓库
std::vector<int> data;

//定义互斥量,互斥使消费者和生产者之间实现互斥关系
pthread_mutex_t mutex;

//定义条件变量,使消费者在vector有数据时才消费,没有数据时啥都不做
//提高代码效率
pthread_cond_t cond;

//还需要生产者和消费者->线程
void Product(void arg)
{
(void)arg;
int count=0;
//生产者负责往vector中写数据
while(1)
{
pthread_mutex_lock(&mutex);
data.push_back(++count);
pthread_mutex_unlock(&mutex);
pthread_cond_signal(&cond);
usleep(898778);
}
return NULL;
}
void Consume(void arg)
{
(void)arg;
//消费者负责读数据
while(1){
pthread_mutex_lock(&mutex);
if(data.empty()){
//如果当前的vector是空的,消费者没有必要去一直读取其中的数据
//所以让消费者线程进行等待,不必进行空转也就节省了资源
pthread_cond_wait(&cond,&mutex);
//此函数的执行
//1.释放锁
//2.等待其他线程pthread_cond_signal()函数返回
//3.重新获取锁
}
std::cout<<"result:"<<data.back()<<std::endl;
data.pop_back();
pthread_mutex_unlock(&mutex);
usleep(124242);//让消费者执行速度快
}
return NULL;
}

int main()
{
pthread_t tid1,tid2,tid3,tid4;
pthread_mutex_init(&mutex,NULL);
pthread_cond_init(&cond,NULL);
//创建线程
pthread_create(&tid1,NULL,Product,NULL);
pthread_create(&tid3,NULL,Product,NULL);
pthread_create(&tid2,NULL,Consume,NULL);
pthread_create(&tid4,NULL,Consume,NULL);
//线程等待
pthread_join(tid1,NULL);
pthread_join(tid2,NULL);
pthread_join(tid3,NULL);
pthread_join(tid4,NULL);
pthread_mutex_destroy(&mutex);
pthread_cond_destroy(&cond);
return 0;
}


C++中STL所提供的容器和算法都是线程不安全的,但为什么设计成这样子呢?因为C++要和C语言兼容、并且追求性极致,而加锁解锁都是影响性能的。所以我们在多线程编程时使用STL必须手动进行限制

-----
**信号量**
信号量就相当于是一个计数器,当申请资源时计数器加一(P操作),当释放资源时计数器减一(V操作),而且这个方式是原子的,和普通的全局变量不同,所以是可以用来完成同步和互斥的。
直观上来看,当用信号量表示互斥时,P和V在同一个函数中,用信号量表示同步时P和V在不同的函数。

用信号量来实现一个阻塞队列:

#pragma once
#include<vector>
#include<pthread.h>
#include<stdio.h>
#include<iostream>
#include<unistd.h>
#include<semaphore.h>

//用信号量模拟实现阻塞队列的生产消费模型
//阻塞队列的长度一般是有上限的
//如果队列为空pop就会阻塞
//如果队列为满,push就会阻塞

//信号量表示可用资源的个数
//用一个来表示队列中元素的个数
//用另一个来表示队列中空格的个数

template<class T>
class BlockingQueue
{
public:
BlockingQueue(int max_size)
:_max_size(max_size)
,_head(0)
,_tail(0)
,_size(0)
,_queue(max_size)
{
sem_init(&_lock,0,1);
sem_init(&_elem,0,0);//元素个数为0
sem_init(&_block,0,_max_size);//空格个数为队列大小
}
void push(const T &data){
//Push操作要申请一个空格资源
//如果没有空格资源就会陷入阻塞
sem_wait(&_block);

        sem_wait(&_lock);//申请资源,对信号量进行P操作
        _queue[_tail]=data;
        _tail++;
        _size++;
        sem_post(&_lock);//释放资源,对信号量进行V操作

        //Push结束后要释放一个元素资源
        sem_post(&_elem);
    }

    //data是出队列的元素,
    //指针表示输出型参数,引用表示出入型参数,const类型的引用表示输入参数
    void pop(T*data){
        //Pop操作先申请一个元素资源
        //如果队列中没有元素就陷入阻塞
        sem_wait(&_elem);
        sem_wait(&_lock);
        *data=_queue[_head];
        _head++;
        _size--;
        sem_post(&_lock);

        //Pop结束后要释放一个空格资源
        sem_post(&_block);
    }

    ~BlockingQueue()
    {
        sem_close(&_lock);
        sem_close(&_elem);
        sem_close(&_block);
    }
private:
    int _max_size;
    int _head;
    int _tail;
    int _size;
    std::vector<T> _queue;
    sem_t _lock;//用一个二元信号量表示互斥锁
    sem_t _elem;
    sem_t _block;

};


模拟实现一个简单的线程池:
线程池就是一种线程使用模式。线程过多会带来调度开销,进而影响缓存局部性和整体性能。而线程池维护着多个线程,等待着监督管理者分配可并发执行的任务。这避免了在处理短时间任务时创建与销毁线程的代价。线程池不仅能够保证内核的充分利用,还能防止过分调度。可用线程数量应该取决于可用的并发处理器、处理器内核、内存、网络sockets等的数量。

首先是借助阻塞队列管理线程:

#pragma once
#include"blockingqueue.hpp"

class Task
{
public:
virtual ~Task()
{}
virtual void Run()
{
printf("base ");
}
};

//线程池对象是用来干嘛的?
//它启动的时候创建一组线程并去完成特定的任务(去执行一定的代码逻辑)
//这个逻辑是由调用者来决定
class Threadpool
{
public:
//n代表创建的线程个数
Threadpool(int n)
:_queue(100)
,_worker_count(n)//设定阻塞队列的起始大小
{
//创建出n个线程
int i=0;
for(i=0;i<_worker_count;++i){
//创建线程
pthread_t tid;
//由于在启动函数中要对类的成员进行操作,所以第四个参数传this指针
pthread_create(&tid,NULL,ThreadEnter,this);
_workers.push_back(tid);
}
}
~Threadpool()
{
size_t i=0;
//先让线程退出再回收
for(i=0;i<_workers.size();++i){
pthread_cancel(_workers[i]);
}
for(i=0;i<_workers.size();++i){
pthread_join(_workers[i],NULL);
}
}
//使用线程池的时候就需要由调用者加入一些任务让线程去执行
void AddTask(Task task)
{
_queue.push(task);
}
private:
BlockingQueue<Task
> _queue;
int _worker_count;
std::vector<pthread_t> _workers;

    static void* ThreadEnter(void *arg)
    {
        Threadpool*pool=(Threadpool*)arg;
        while(1)
        {
            //循环中从阻塞队列中获取任务并执行任务
            Task* task=NULL;
            pool->_queue.pop(&task);
            //task是Mytask类型,因为Run是虚函数
            //执行的是子类,用户自定制的逻辑
            task->Run();
            delete task;
        }
    }

};


然后就是用户可以随意将想要完成的功能添加到线程池中,通过MyTask类实现:

#include"threadpool.hpp"

//这个类由用户自己定制,想干什么都是由用户自己决定
class MyTask :public Task
{
public:
MyTask(int id)
:_id(id)
{}
//重写Run函数去执行用户自定制的逻辑
void Run()
{
printf("%d ",_id);
}
private:
int _id;
};

int main()
{
Threadpool pool(10);//创建十个线程
int i=0;
for(i=0;i<30;++i){
//将自己定义的类交给线程池去进行创建、管理、调度
pool.AddTask(new MyTask(i));
usleep(789789);
}
return 0;
}


另一种常见的模式是读者写者模型,和生产者消费者模型类似,但是区别在于读者写者模型中读操作是不需要互斥的,也就是这种模型适用于“一写多读”的操作,可以提高服务器的响应效率。也有自身的加锁和解锁函数

初始化和销毁锁:int pthread_rwlock_destroy(pthread_rwlock_t rwlock);
int pthread_rwlock_init(pthread_rwlock_t
restrict rwlock,const pthread_rwlockattr_t *restrict attr);

加读锁:int pthread_rwlock_rdlock(pthread_rwlock_t rwlock);
加写锁:int pthread_rwlock_wrlock(pthread_rwlock_t
rwlock);



-----

C++中自带的std::thread,从C++11开始把线程纳入标准库中
包含在std::thread类中-----------------------》查看手册后信号量和读写锁还未提供,但是提供了很厉害的**原子操作(相当于在线程安全中上锁,将临界资源改为 std::atomic_int count(0);就由普通变量变为原子变量,将之前的内存cpu之间的三步操作合为一步)**原子操作本质上也是锁,但是是cpu指令级别的锁,与之前的操作系统提供的锁不同,所以开销要小很多。

-----

**
注意::
* STL不是线程安全的,原因是STL诞生1998年而线程操作是C++11加入的,而且STL追求性能,如果考虑到线程安全就会对性能有所影响
* 智能指针①unique_ptr一般只在函数内部使用,并且是函数的局部变量,所以在栈上,不涉及线程安全问题②shared_ptr是线程安全的,本质上是使用原子操作维护引用计数
* 
**

以上是关于线程认识的主要内容,如果未能解决你的问题,请参考以下文章

详细讲解 —— 多线程初阶认识线程(Java EE初阶)

多线程 Thread 线程同步 synchronized

活动到片段方法调用带有进度条的线程

多个用户访问同一段代码

JAVA-初步认识-第十四章-线程间通信-等待唤醒机制-代码优化

线程学习知识点总结