• Linux之多线程


    首先我们来讲线程的概念!

    首先我们偏官方的解释

    线程:是进程内的一个执行分支。线程的执行粒度,要比进程要细。

    回忆之前的地址空间,每一个进程都有单独的地址空间,也就是有独立的资源!

    如果我们想让多个执行流执行,我们就要开辟父子进程了!

    但是如果我们创建新的进程还要创建很多管理数据结构!还要重新开辟相关的地址空间,申请资源等,创建起来非常费时费力!

    所以我们就创造了线程的概念,也即在一个进程之内将任务进行分割!(资源和代码都分割)

    具体如何分割我们后面将会演示,这里只是粗粒度的看一下!

    这样就可以让线程执行更加轻便的任务了。

    然后我们再来回忆一下Linux中是如何调度进程的!

    对CPU来说只看task_struct然后执行就可以了。

    那么我们Linux就可以利用这一点,每次创建新的线程只需要创建新的task_struct,不开辟新的地址空间,就可以完成新的任务创建!!!

    并且这样可以充分的复用进程的task_struct,而不用重新为线程写一套,大幅度的提高了代码的可维护性和复用度!!!

    也即Linux没有真正的线程,而是用进程模拟实现线程!!!

    这也就是为什么Linux的线程我们一般称为轻量级进程

    (不同操作系统的具体实现方式不一样,这里是Linux的实现方式,非常优雅)

    并且从上面的介绍中,我们先推出两个结论!

    1.进程是资源管理的基本单位!

    2.线程是操作系统调度的基本单位!

    然后我们再回忆一下以前对进程的定义

    那现在加入了线程,我们又如何理解呢?

    那我们先思考一下线程(执行流)是不是资源?

    答案是肯定的——是!

    所以线程(轻量级进程)对进程来说也是资源!

    就像对老板来说,原材料(其他资源)和工人(线程)都是的资源!!!

    而我们以前的时候没有引入线程的概念的时候,其实就是进程只有一个线程的时候,反而才是特殊情况!(光杆司令)

    接下来我们再来重新深入的讲一下地址空间

    首先我们回忆一下,其实物理内存也是被4kb,4kb划分出来的!

    (类似于磁盘中的扇区)被称为页框

    首先我们想一下可能是一一映射的关系吗?

    页表中包含虚拟地址,物理地址,状态的信息,哪怕保守估计一个映射关系是10个字节,

    那总共2^32个映射关系(32位系统)

    我们要花接近40G的内存!!!显然根本不可能是这样进行映射的!!!

    接下来我们来研究真正的页表构建方法!!!

    真正的页表其实是被拆成两级的!

    32位地址被转化为10+10+12位

    也就是前十位地址被存到一级页表,然后第二个十位存在二级页表,二级页表就可以支持我们找到页框了!然后再通过后十二位我们就可以偏移找到我们的地址了!

    这个时候我们再算一下一个页表最大为

    相比于之前的40G已经非常少了!

    并且我们每个进程并不会用完所有的页表,每个进程都只会创建一部分页表

    所以这样页表要的资源对我们来说就不算什么了!

    但是这仍然是个比较重的工作,这也就是我们为什么要诞生线程!!!

    接下来我们来学习一些周边的小问题

    首先为什么线程比进程更轻量化

    针对第一个:我们创建线程不用像创建进程那样还要创建页表等,更加轻量化,不用多说。

    针对第二点:我们稍微细谈一下!
    首先在我们的CPU其实有一个cathe

    (一个比较大的缓存空间,相比于内存来说小,但是比cpu的空间大)

    比如我们要运行一个代码的第10行我们可能把10-50行的代码和数据储存起来,等下运行的时候就可以直接从cache中调用而不用从内存中去读取这些内容了。这样可以大幅度提高效率,但是大家可能会有疑问,万一我不执行呢,比如函数跳转等!那么我们再想一下在一份代码里面是代码往下执行的概率大,还是函数跳转的概率大?显然函数跳转应该才是少数!

    所以这个优化其实是一种基于概率的优化!

    所以如果是进程间更替,我们的cache数据(也称热数据)不用被更换(或者需要更新的数据较少,不用整个替换),这样也可以大幅度提高效率,如果进程替换,那么这个数据也就无效了,必须要进行数据的替换,也拖慢运行效率!!!!!!

    由此基于上面所说的线程之所以在运行期间更加轻量化于=与cache有非常密切的关系!!!

    接下来我们来总结一下线程的优缺点

    很多资源线程之间是共享的

    再就是线程之间虽然共享资源但也有单独的数据!

    一般我们就简化为上下文数据和栈!

    接下来我们开始学习使用Linux的线程!

    但是Linux并没有关于线程的系统调用!只有轻量级进程的系统调用!

    所以为了方便我们的使用,无数程序员就对轻量级进程进行封装写了一个pthread线程库!

    (几乎所有的平台都默认自带这个库!!!)

    所有接下来我们学习pthread线程库的使用!

    我们先来看创建线程的系统调用接口

    第一个参数:这是一个输出形参数,线程的管理id

    第二个参数:线程的属性,现在我们设置为NULL就可以了

    第三个参数:是一个函数指针,返回值和参数类型都为void*,也就是我们要让新线程执行的函数

    接下来我们看一下我们的使用实例!

    然后我们编译运行就会发现出现了编译报错(链接时报错)!

     

    原因就是因为我们用的pthread库不是c++标准库或者系统调用,而是第三方库!

    所以我们根据前面学习动静态库的知识,我们必须要链接pthread库!

    然后就可以正常执行了!

    接下来我们可以通过ps -aL查看我们的轻量级进程!

    这里的LWP就是线程的id!并且主线程的PID=LWP!并且子线程会依次以主线程的LWP向后延伸创造!!!

    所以我们以前的调度看似使用的是PID其实是LWP!

     (并且只要有一个线程被杀掉,整个进程都会崩溃,所以多线程的健壮性较差)

    并且一个函数可以被多个线程所执行,也就在这里我们又看到了函数被重入的实例了!

    接下来我们来深入了解一下tid!

    我们先打印看一看(先直接打印整形会出现非常大的数,然后猜测是地址)

    所以这个并不是简单的返回tid给我们,之后我们再来深入探究!!!

    现在我们再思考一个问题,多进程需要等待,那多线程呢?

    答案是也要等待,否则也会出现类似僵尸进程一样的东西!

    接下来我们直接看等待的函数(进程是wait())

    而多线程则是pthread_join!

    第一个参数:pthread,不多赘述

    第二个参数:为了要获取一个void*的返回值,所以我们要一个void**的参数去接收变量。

    这个retval就被接收到了!

    然后这个地方提一下,如果线程是被取消的,就会返回默认值-1

    我们在这里稍微提一下语言的可移植性

    首先我们c++11之后便加入了线程库,但是你会发现仍然需要链接-pthread库

    所以也就是c++的线程库其实也是对pthread的封装!

    由此移植性好,其本质就是外面的封装库不变,而内部调用的函数(系统调用)方便替换就可以算作可移植性好。

    比如我们的phtread_creat,join....都是对clone的封装。

    由此,我们就要明确线程的概念是库给我们维护的(不是维护线程的执行流),也即所谓的独立栈什么的,其实都是线程库里面维护的数据结构而已,所以才能在同一地址空间共享空间。

    于是我们的库就要维护这些线程,那么如何维护了——先描述再组织!于是就存在线程控制块!

    所以我们就可以来看看tcb(线程控制块)了!

    1.每一个线程的tcb起始地址就是tid!

    2:除了主线程,所有其他线程的独立栈,都在共享区具体来讲是在pthread库中,tid指向的用户tcb中!

    3:线程局部存储

    可以将全局变量给每个线程保存一份!

    只要加上__thread就可以启动线程局部存储了。

    这样就可以有一份线程级别的全局变量了!(比如在线程里面调用函数要查看当前线程的ID,如果不这样就要一直传,如果这样函数就可以直接访问这个全局变量了,编译器会自动调用访问该线程的该全局变量)

    看完了tcp,我们再来了解一下pthread_detach

    正常一个线程是需要join的,但是如果自己对自己detach之后,线程结束以后就会自动分离,而不需要等待。

    学完上面的基础概念之后

    我们来写一个多线程抢票的程序

    然后我们就发现出现了意料之外的结果,我们明明判断了tickets>0才能抢,现在居然把票抢到负数了!!!

    而产生的原因就是多执行流访问共享数据造成的数据不一致问题!!!

    所以我们就产生了问题——对一个全局变量进行多线程并发--/++操作是否是安全的?

    答案很明显——不安全!为什么呢?因为多线程在进行访问的时候必须是线性的,所以在执行if判断的时候,必须先把ticket变量的数据从内存中先放到cpu寄存器,但是也就是在寄存器的过程中,这时时间片可能结束了,于是线程就暂时停止运行了,其他线程也可能就进行了操作覆盖了内存中的数据,此时线程再恢复运行的时候执行ticket--时,重新读取新的数据进行--就会导致这种意料之外的问题发生!

    所以就有可重入函数和不可重入函数(也即能被多执行流并发访问没有问题的函数和有问题函数)

    于是为了防止不可重入函数被重入由此我们就引出了————线程锁!

    我们首先来看看线程锁的接口!

    实际用法如下!就是创建锁,初始化锁,销毁锁的过程。

    然后就是加锁和解锁的函数调用。 

    大家如果自己去尝试这个例子,如果把usleep删去,大部分票都会被一个线程抢走,所以其实也就说明了线程之间的竞争能力是不同的!!!(有强有弱)

    但是一般我们解锁以后一般会有动作,否则就要加以限制,否则会导致其他线程抢不到锁

    也叫锁饥饿问题!所以我们在使用锁的时候一定要注意这个问题!

    而比饥饿问题危害性更大的问题就是死锁问题!

    首先我们来看产生死锁的四个必要条件(产生死锁一定有这些条件,有这些条件不一定有锁)

    所以就像防止饥饿问题一样,我们现在如果想避免死锁,就可以尝试破坏其中的条件!!!

    所以我们就有一种方法去解决这个问题——条件变量!

    用法几乎一样,但是却可以增加条件变量!

    其中braodcast是唤醒所有,signal是唤醒一个!

    可能大家不是很理解,我们接下来通过实例来理解!

    当在等待的时候就会放开锁直到通知唤醒!比如这个程序就可以让每个线程轮流唤醒再等待!

    但是这个地方我们暂时没有例子,不容易理解,由此我们就可以引入我们的生产消费者模型了!

    CP问题————consumer produecer(生产者消费者模型)

    我们在日常生活中,我们买东西从来都是在超市而不是去找工厂买!为什么呢?

    效率高!!!

    同样,我们在计算机当中也是这样,如果有一个类似超市的缓冲区,那么生产和消费就可以不同步了!可以达到忙闲不均!这样工厂不用等待消费者购买的时候才去生产,消费者购买的时候不用供货商的生产,从而提高效率!

    在计算机里面也是一样的!如果我们一部分线程充当工厂,一部分用来充当消费者!

    比如在网络部分,一部分线程用来接受数据,一部分用来处理数据,就是cp模型的体现!!!

    整个来说就是3种关系,2种角色,1个中间场所!简称321原则!

    在这里我们就可以将以前的信号量进一步讲解了!

    之前我们就知道信号量可以控制线程对相应资源的访问数量

    信号量概念
    什么是信号量?

    只要我们对资源进行整体加锁就默认了我们对这个资源整体使用,实际情况可能存在一份公共资源,但是允许同时访问不同的区域!(程序员编码保证不同的线程可以并发访问公共资源的不同区域!)

    信号量本质是一把计数器,衡量临界资源中资源数量多少的计数器

    只要拥有信号量,就在未来一定能够拥有临界资源的一部分,申请信号量的本质:对临界资源中特定小块资源的预定机制。比如电影院买票预定座位

    只要申请成功,就一定有你的资源,只要申请失败,就说明条件不就绪,你只能等,就不需要判断

    线程要进行访问临界资源中的某一区域——得先申请信号量——前提是所有人必须先看到信号量——所以信号量本身必须是:公共资源。

    这是我们之前学习的相关知识,大家可以先复习了一下。

     今天我们就可以来学习和了解一下他的接口了!

     信号量PV操作

    P操作sem–,申请操作,必须保证操作的原子性

    V操作:sem++,归还资源,必须保证操作的原子性

    1. #include <semaphore.h>
    2. //信号量初始化
    3. int sem_init(sem_t *sem, int pshared, unsigned int value)
    4. //sem:自己定义的信号量变量
    5. //pshared:0表示线程间共享,非零表示进程间共享。
    6. //value:信号量初始值(资源数目)。
    7. //信号量销毁
    8. int sem_destroy(sem_t *sem)
    9. //信号量等待
    10. int sem_wait(sem_t *sem):p操作,--
    11. //信号量发布
    12. int sem_pos(sem_t *sem):V操作,++

    访问环形队列

    生产者和消费者访问同一个位置的情况:空的时候,满的时候;其他情况下生产者与消费者访问的就是不同的区域了。

    为了完成环形队列的生产消费,我们的核心工作就是

    1.消费者不能超过生产者

    2.生产者不能套消费者一个圈以上

    3.生产者和消费者指向同一个位置时,如果此时满了就让消费者先走,如果此时为空就让生产者先走

    大部分情况下生产者与消费者是并发执行的,但是当环形队列为空或为满的时候就会存在着同步与互斥问题。

    如何去进行保证:信号量维护,信号量是衡量临界资源中资源数量的

    资源是什么:

    1.对于生产者,看中的是队列中的剩余空间,空间资源定义成一个信号量

    2.对于消费者,看中的是队列中的数据资源,数据资源定义成一个信号量

    比如我们一共有10个位置,消费者初始信号量是0,生产者初始信号量是10,如果生产者线程生产数据,申请信号量,进行P操作,信号量变为9,申请失败则阻塞;申请成功后消费者线程看到了多一个数据资源,消费者信号量进行V操作.所以我们并不需要进行判空判满:当生产者生产满了,信号量申请不到,进行阻塞,只能让消费者先走;当消费者消费完了,信号量申请不到,只能让生产者先走。

    代码实现

    单生产单消费的环形队列生产者消费者模型,利用随机数生成数据资源,通过生产线程与消费线程进行数据的生成与数据的消费 

    实现如下

    Task.hpp:

    1. #pragma once
    2. #include
    3. #include
    4. #include
    5. #include
    6. class Task
    7. {
    8. using func_t = std::function<int(int,int,char)>;
    9. public:
    10. Task()
    11. {}
    12. Task(int x,int y,char op,func_t func)
    13. :_x(x),_y(y),_op(op),_callback(func)
    14. {}
    15. std::string operator()()
    16. {
    17. int result = _callback(_x,_y,_op);
    18. char buffer[1024];
    19. snprintf(buffer,sizeof buffer,"%d %c %d = %d",_x,_op,_y,result);
    20. return buffer;
    21. }
    22. std::string toTaskString()
    23. {
    24. char buffer[1024];
    25. snprintf(buffer,sizeof buffer,"%d %c %d = ?",_x,_op,_y);
    26. return buffer;
    27. }
    28. private:
    29. int _x;
    30. int _y;
    31. char _op;
    32. func_t _callback;
    33. };
    34. const std::string oper = "+-*/%";
    35. int mymath(int x,int y,char op)
    36. {
    37. int result = 0;
    38. switch (op)
    39. {
    40. case '+':
    41. result = x + y;
    42. break;
    43. case '-':
    44. result = x - y;
    45. break;
    46. case '*':
    47. result = x * y;
    48. break;
    49. case '/':
    50. {
    51. if (y == 0)
    52. {
    53. std::cerr << "div zero error!" << std::endl;
    54. result = -1;
    55. }
    56. else
    57. result = x / y;
    58. }
    59. break;
    60. case '%':
    61. {
    62. if (y == 0)
    63. {
    64. std::cerr << "mod zero error!" << std::endl;
    65. result = -1;
    66. }
    67. else
    68. result = x % y;
    69. }
    70. break;
    71. default:
    72. break;
    73. }
    74. return result;
    75. }

    Main.cc

    1. #include "RingQueue.hpp"
    2. #include "Task.hpp"
    3. #include <pthread.h>
    4. #include <ctime>
    5. #include <cstdlib>
    6. #include <sys/types.h>
    7. #include <unistd.h>
    8. void*ProductorRoutine(void*rq)
    9. {
    10. RingQueue<Task>*ringqueue = static_cast<RingQueue<Task>*>(rq);
    11. while(true)
    12. {
    13. //构建任务
    14. int x = rand()%10;
    15. int y = rand()%5;
    16. char op = oper[rand()%oper.size()];
    17. Task t(x,y,op,mymath);
    18. //生产任务
    19. ringqueue->Push(t);
    20. std::cout<<"生产者派发了一个任务:"<<t.toTaskString()<<std::endl;
    21. //sleep(1);
    22. }
    23. }
    24. void*ConsumerRoutine(void*rq)
    25. {
    26. RingQueue<Task>*ringqueue = static_cast<RingQueue<Task>*>(rq);
    27. while(true)
    28. {
    29. //构建任务
    30. Task t;
    31. //消费任务
    32. ringqueue->Pop(&t);
    33. std::string result = t();
    34. std::cout<<"消费者消费了一个任务:"<<result<<std::endl;
    35. sleep(1);
    36. }
    37. }
    38. int main()
    39. {
    40. srand((unsigned int) time(nullptr)^getpid()^pthread_self()^0x7432);
    41. RingQueue<Task>*rq = new RingQueue<Task>();
    42. pthread_t p,c;
    43. pthread_create(&p,nullptr,ConsumerRoutine,rq);
    44. pthread_create(&c,nullptr,ProductorRoutine,rq);
    45. pthread_join(p,nullptr);
    46. pthread_join(c,nullptr);
    47. delete rq;
    48. return 0;
    49. }

    RingQueue.hpp

    1. #pragma once
    2. #include <iostream>
    3. #include <vector>
    4. #include <cassert>
    5. #include <semaphore.h>
    6. #include <pthread.h>
    7. static const int gcap = 5;
    8. template<class T>
    9. class RingQueue
    10. {
    11. private:
    12. void P(sem_t&sem)
    13. {
    14. int n =sem_wait(&sem);
    15. assert(n==0);
    16. (void)n;
    17. }
    18. void V(sem_t&sem)
    19. {
    20. int n = sem_post(&sem);
    21. assert(n==0);
    22. (void)n;
    23. }
    24. public:
    25. RingQueue(const int&cap = gcap):_queue(cap),_cap(cap)
    26. {
    27. int n = sem_init(&_spaceSem,0,_cap);
    28. assert(n == 0);
    29. n = sem_init(&_dataSem, 0, 0);
    30. assert(n==0);
    31. _productorStep = _consumerStep = 0;
    32. pthread_mutex_init(&_pmutex,nullptr);
    33. pthread_mutex_init(&_cmutex,nullptr);
    34. }
    35. void Push(const T&in)
    36. {
    37. P(_spaceSem);//申请到了空间信号量,意味着我们一定能进行正常的生产
    38. pthread_mutex_lock(&_pmutex);
    39. _queue[_productorStep++] = in;
    40. _productorStep%=_cap;
    41. pthread_mutex_unlock(&_pmutex);
    42. V(_dataSem);
    43. }
    44. void Pop(T *out)
    45. {
    46. P(_dataSem);
    47. pthread_mutex_lock(&_cmutex);
    48. *out = _queue[_consumerStep++];
    49. _consumerStep%=_cap;
    50. pthread_mutex_unlock(&_cmutex);
    51. V(_spaceSem);
    52. }
    53. ~RingQueue()
    54. {
    55. sem_destroy(&_spaceSem);
    56. sem_destroy(&_dataSem);
    57. pthread_mutex_destroy(&_pmutex);
    58. pthread_mutex_destroy(&_cmutex);
    59. }
    60. private:
    61. std::vector<T> _queue;
    62. int _cap;
    63. sem_t _spaceSem;
    64. sem_t _dataSem;
    65. int _productorStep;
    66. int _consumerStep;
    67. pthread_mutex_t _pmutex;
    68. pthread_mutex_t _cmutex;
    69. };

    Main.cc

    1. #include "RingQueue.hpp"
    2. #include "Task.hpp"
    3. #include <pthread.h>
    4. #include <ctime>
    5. #include <cstdlib>
    6. #include <sys/types.h>
    7. #include <unistd.h>
    8. std::string SelfName()
    9. {
    10. char name[128];
    11. snprintf(name,sizeof(name),"thread[%0x%x]",pthread_self());
    12. return name;
    13. }
    14. void*ProductorRoutine(void*rq)
    15. {
    16. RingQueue<Task>*ringqueue = static_cast<RingQueue<Task>*>(rq);
    17. while(true)
    18. {
    19. int x = rand()%10;
    20. int y = rand()%5;
    21. char op = oper[rand()%oper.size()];
    22. Task t(x,y,op,mymath);
    23. //生产任务
    24. ringqueue->Push(t);
    25. std::cout<<SelfName()<<",生产者派发了一个任务:"<<t.toTaskString()<<std::endl;
    26. //sleep(1);
    27. }
    28. }
    29. void*ConsumerRoutine(void*rq)
    30. {
    31. RingQueue<Task>*ringqueue = static_cast<RingQueue<Task>*>(rq);
    32. while(true)
    33. {
    34. Task t;
    35. //消费任务
    36. ringqueue->Pop(&t);
    37. std::string result = t();
    38. std::cout<<SelfName()<<",消费者消费了一个任务:"<<result<<std::endl;
    39. sleep(1);
    40. }
    41. }
    42. int main()
    43. {
    44. srand((unsigned int) time(nullptr)^getpid()^pthread_self()^0x7432);
    45. RingQueue<Task>*rq = new RingQueue<Task>();
    46. pthread_t p[4],c[8];
    47. for(int i = 0;i<4;i++) pthread_create(p+i,nullptr,ProductorRoutine,rq);
    48. for(int i = 0 ;i<8;i++) pthread_create(c+i,nullptr,ConsumerRoutine,rq);
    49. for(int i = 0;i<4;i++) pthread_join(p[i],nullptr);
    50. for(int i = 0 ;i<8;i++) pthread_join(c[i],nullptr);
    51. return 0;
    52. }

    总结
    多生产多消费的意义:不管是环形队列还是阻塞队列,多线程的意义在于构建or获取任务是要花时间的,效率比较低,当消费的时候也是要花时间的,不单单只是拿出来就行了,所以多生产多消费的时候的意义在于生产之前,消费之后,处理任务获取任务的时候本身也是要花费时间的,可以在生产之前与消费之后让线程并行执行。

    条件变量是一种同步机制,它允许线程等待某个条件的发生,通常与互斥锁一起使用。而信号量是一种计数器,它可以用于控制对共享资源的访问;如果想让每一刻只有一个线程访问共享资源,可以使用条件变量。但如果需要允许多个线程并发访问共享资源的不同区域,则可以使用信号量。

  • 相关阅读:
    Linux (一) 基础复习
    Dom4J解析XML、Xpath检索XML
    [iOS]-消息传递和消息转发机制
    RR有幻读问题吗?MVCC能否解决幻读?
    LLM文章阅读:Baichuan 2 干货
    ReentrantLock的功能详解与理解
    电子机械节拍器芯片-DLT5F03ATF-杰力科创
    面向对象实验二友元
    包装印刷行业万界星空科技云MES解决方案
    如何使用MATLAB绘制极坐标堆叠柱状图
  • 原文地址:https://blog.csdn.net/qq_35639854/article/details/136487186