• 【Linux操作系统】--多线程(一)


    目录

    Linux线程概念

    什么是线程

    Linux线程与接口关系的认识

    线程的用途

    线程和进程共享和私有

    线程的优点

    线程的缺点

    线程异常

    进程与线程间的关系

    线程控制

    创建线程

    线程等待

    线程终止

    分离线程

    线程互斥

    线程需要互斥原因:

    进程间互斥相关背景概念:

    模拟线程抢占

    对临界区进行加锁:

    互斥量实现原理探究

    常见的线程安全的情况

    可重入和线程安全

    线程安全问题

    常见重入的情况:

    可重入与线程安全联系

    可重入与线程安全区别

    常见锁概念

    死锁

    死锁四个必要条件

    避免死锁


    Linux线程概念

    什么是线程

    笼统的讲:线程是在进程内部运行的一个执行分支(执行流),属于进程的一部分,粒度要比进程更加细致和轻量化。

    • 在一个程序里的一个执行路线叫做线程,更准确的定义是:线程是”一个进程内部的控制序列“
    • 一切进程至少都有一个执行线程
    • 线程在进程内部运行,本质是在进程地址空间内运行
    • 在Linux系统中,在CPU眼里,看到的PCB都要比传统的进程更加轻量化
    • 透过进程虚拟地址空间,可以看到进程大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流。

    Linux线程与接口关系的认识

    一个进程内存在多个线程,进程:线程=1:n,那么操作系统需要对这些线程进行管理,根据以往经验,需要先描述再组织。所以线程也需要有自己的控制块TCB,因为Linux操作系统是C语言为基础写的,所以管理TCB控制块是一个结构体struct tcb{},以上是常规OS的做法。

    但是实际上因为再写一个进程控制块来管理进程是相当麻烦的,所以只创建进程的task_struct,线程和进程共享一个地址空间,当前进程的资源(包括代码+数据),划分为若干份,让每个PCB使用。

    CPU此时看到PCB<=以前所了解的PCB概念,一个PCB就是一个需要被调度的执行流,Linux中没有专门为线程设计TCB,而是用进程的PCB来模拟线程。此时,我们不用维护复杂的进程和线程的关系,不用单独为线程设计任何算法,直接俄使用进程的一套相关的方法。OS只需要聚焦在线程间的资源分配上就可以了。

    所以最开始提到的线程在进程内部运行,内部指的是线程在进程的地址空间内运行;线程是一个执行分支:指的是CPU调度的时候,只看PCB,每个PCB曾经被指派过的方法和数据,CPU可以直接调度。

    之前讲到的进程,内部只有一个执行流的进程;今天讲到的进程,内部可以具有多个执行流。众所周知,创建进程的”成本“非常高,成本包括时间+空间。创建进程要使用的资源也非常多。从内核的视角来看:进程是承担分配系统资源的基本实体,线程是CPU调度的基本单位,承担进程资源一部分的基本实体。也就是说进程划分资源给线程。

    Linux因为是用进程模拟的,所以Linux下不会直接给我们直接提供线程的接口,而是给我们提供,在同一个地址空间内创建PCB的方法,分配资源给指定的PCB接口。这样不直接提供接口的方式对程序员用户非常不友好,那么用户得自己实现,来创建线程的接口,释放线程,等待线程等等这些接口。所以系统级别的工程师,在用户层对linux轻量级进程接口进行封装,给我们打包成库,让用户直接使用库接口,也就是原生线程库(用户层)。这个库接口就是我么接下来要学习的库接口(pthread)。

    线程的用途

    线程和进程共享和私有

    所有的轻量级进程(也可能是”线程“)都是在同一个进程地址空间内运行的。

    进程是具有独立性的,但也可以有部分共享资源(通过管道,ipc资源实现)。线程,大部分资源是共享的,可以有部分资源是”私有“的(如:pcb,栈和上下文)。

    栈的私有性体现在,一个线程的产生的临时数据需要压栈入栈的,如果共享栈,栈中数据混淆,线程之间的数据混在一块,非常麻烦,所以栈是有独立性的。因为一个线程是cpu调度的基本单位,这样的话每个线程pcb块需要有自己调度的上下文。

    私有:细化分一下进程私有资源:

    • 线程ID
    • 一组寄存器
    • errno
    • 信号屏蔽字
    • 调度优先级

    共享:进程的多个线程共享同一个地址空间,因此TEST segment,Data Segment都是共享的,如果定义一个函数,在各线程中都可以调用;或者定义一个全局变量,在各个线程中都可以访问到;除此之外,各线程还共享一下进程资源和环境:

    • 文件描述符表
    • 每种信号处理方式(SIG,IGN,SIG_DFL或者自定义信号处理函数)
    • 当前工作目录
    • 用户id和组id

    线程的优点

    1. 从创建线程来看,创建一个新线程的代价要比拆功能键一个新进程小的多。不用创建PCB,不用创建地址空间,不用加载代码和数据,只需要分配资源就可以了,就是进程创建PCB分配给你就可以了。
    2. 与进程切换相比,线程之间切换需要OS做的工作相对比较少。进程间切换直接切换上下文,不需要切换页表,不需要更新各种缓存,因为线程之间数据都是有效的。
    3. 线程占用的资源比较少,因为线程不是主要申请资源的角色,只需要用别人创建好的即可
    4. 能充分利用多处理器的可并行数量。
    5. 在等待慢速I/O操作结束的过程中,程序可执行其他的计算任务。
    6. 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现。计算密集型应用最常见的情况有:加密,大数据运算等---主要使用的是CPU资源。
      1. 那么计算密集型是否是线程越多越好呢?不一定!如果线程太多,会导致线程被过度调度切换,调度切换是有成本的。假如有200个线程来操作一个200G进程,一个进程1G,这样的话本来一个线程直接就能完成的操作,200个进程中有大量时间来进行切换调度,使得运行成本变高,反而导致运行效率降低。
    7. I/O密集型应用,为了提高性能,将I/O操作重叠,线程可以同时等待不同的I/O操作。这种应用具体实现有:网络下载,云盘,ssh,在线直播,看电影----需要内存和外设的IO资源。

            cpu+IO密集型这样两方面都用到的应用,最常见的有网络游戏。农药,撸啊撸这些的。

            IO密集型是否线程越多越好呢?IO密集型线程多几个是好的,因为IO操作大部分时间是用来等待的,比如果QQ发送信息,IO大部分时间都是来等待用户输入的。在这等待的过程中,多个线程一起等待,导致了IO操作重叠。但是也并不是IO越多越好,线程太多,线程间来回切换调度,OS是忙不过来的。

    线程的缺点

    1. 性能损失
      • 如计算密集型线程的数量比可用的处理器多,那么可能有较大的性能损失,这里的性能损失指的是增加了额外的调度和同步数据的开销,而可用资源不变。
    2. 健壮性降低
      • 编写多线程需要更全面更深入的考虑,在一个多线程程序里,因为时间分配上的细微偏差,或者因为共享了不该共享的变量造成不良影响的可能性很大,换句话说线程之间是缺乏保护的。
    3. 缺乏访问控制
      • 进程是访问的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响
    4. 变成难度提高
      • 编写与调试一个多线程程序比单线程程序难得多。

    线程异常

    • 单线程如果出现除零或野指针问题导致线程崩溃,进程也会跟着崩溃。
    • 因为进程具有独立性,导致其他进程最多只是对该进程只读但是不能写。而线程共用的是一个进程的地址空间,线程与线程之间的数据可以互相访问,当一个线程数据出错了,操作系统对该线程发信号,发信号只能发送给该线程对应的进程,进程跟着崩溃了,导致进程内的所有数据被释放,该进程内的其他线程也跟着销毁了(因为线程的数据是进程给的)。所以一个线程崩溃就会导致整个进程崩溃,这也造成了线程的健壮性降低的原因。

    进程与线程间的关系

    线程控制

    创建线程

    创建线程使用的接口时pthread_create,用man手册来查看一下,man pthread_create,发现pthread_create使用的是三号手册,头文件是pthread.h,在编译的时候需要引入链接-pthread这个库,这个就是引入第三方库。

    pthread_create各个参数的意义:

    返回值:创建成功返回0,创建失败返回错误码,告诉我们为什么失败。

    pthread_create这个函数的参数:pthread_t是一个无符号整数,第一个叫线程id,用来返回新创建线程的线程id;

    第二个线程属性设置成NULL,这里一般的设置有:栈,优先级等线程的属性,一般我们不设置线程属性,第一是我们不懂,第二我们不会轻易设置线程属性,只有OS是最懂得,直接交给OS即可;

    第三个是回调函数,这个回调函数意味着你要执行线程中代码的哪一部分;

    第四个参数就是要给这个回调函数传入的参数。这个回调函数的返回值和参数的返回值都是void*,所以我们在传入函数和参数的时候需要将它们都强转为void*类型。

     我们来写一个简单的线程函数来验证一下:

    main函数中执行主线程,创建了一个新线程thread_run函数,传入自己创建的线程id:tid,线程属性设置为NULL,创建从线程执行函数thread_run,传入函数参数为thread 1,因为thread1 是字符串,需要将它转换为void*类型。在主线程执行循环体的同时,从线程也执行循环体。

    1. [wjy@VM-24-9-centos thread]$ cat mythread.c
    2. #include
    3. #include
    4. #include
    5. void* thread_run(void* args)
    6. {
    7. const char* id=(const char*)args;
    8. while(1)
    9. {
    10. printf("我是%s线程,%d\n",id,getpid());
    11. sleep(1);
    12. }
    13. }
    14. int main()
    15. {
    16. pthread_t tid;
    17. pthread_create(&tid,NULL,thread_run,(void*)"thread 1");
    18. while(1)
    19. {
    20. printf("我是main线程,%d\n",getpid());
    21. sleep(1);
    22. }
    23. }

    我们进行编译测试,当正常编译gcc mythread.c -o mythread的时候会编译报错,告诉我们没有加入第三方库,那么在后面加入-lpthread,gcc mythread.c -o mythread -lpthread,编译后用ldd mythread查看,发现已经链接上这个库了。

    1. [wjy@VM-24-9-centos thread]$ gcc mythread.c -o mythread
    2. /tmp/ccsd8SAf.o: In function `main':
    3. mythread.c:(.text+0x5c): undefined reference to `pthread_create'
    4. collect2: error: ld returned 1 exit status
    5. [wjy@VM-24-9-centos thread]$ gcc mythread.c -o mythread -lpthread
    6. [wjy@VM-24-9-centos thread]$ ldd mythread
    7. linux-vdso.so.1 => (0x00007ffd8ab07000)
    8. libpthread.so.0 => /lib64/libpthread.so.0 (0x00007f800d6c6000)
    9. libc.so.6 => /lib64/libc.so.6 (0x00007f800d2f8000)
    10. /lib64/ld-linux-x86-64.so.2 (0x00007f800d8e2000)

    ./mythread执行以下代码:

    发现两个执行流用的是同一个进程id,说明,此时依旧是只有一个进程,但是进程内部一定具有两个执行流!当我们用kill -9 进程号,两个线程都会被干掉。所以信号发送,不是给进程发送,而是给线程发送。

    通过-L选项可以查看轻量级进程,pid是它们的进程号,LWP是他们的线程号,这也就证明了确实有两个执行流来公用一个进程地址空间。那么PID==LWP这个情况一定是主线程。

    所以Linux OS调度的时候,看的是pid还是LWP呢?毫无疑问,看的是LWP。那么如何理解我们之前单独一个进程的情况?以前只有一个执行流的时候PID == LWP。


    上面我们获取的是进程id,那么我们验证一下pthread_create创建线程获取的id和新线程id是否相同。那么新线程如果获取自己的线程id呢?这里要引入一个新的函数,pthread_self()函数,用来获取当前线程的线程id。

    1. [wjy@VM-24-9-centos thread]$ cat mythread2.c
    2. #include
    3. #include
    4. #include
    5. void* thread_run(void* args)
    6. {
    7. while(1)
    8. {
    9. printf("我是新线程[%s],我的线程id是:%lu\n",(const char*)args, pthread_self());
    10. sleep(1);
    11. }
    12. }
    13. int main()
    14. {
    15. pthread_t tid;
    16. pthread_create(&tid,NULL,thread_run,(void*)"new thread");
    17. while(1)
    18. {
    19. printf("我是主线程,我创建的新线程id是:%lu\n",tid);
    20. sleep(1);
    21. }
    22. }

    编写makefile文件,需要在编译语句后面加上-lpthread,链接第三方库。

    1. [wjy@VM-24-9-centos thread]$ cat makefile
    2. mythread2:mythread2.c
    3. gcc -o $@ $^ -lpthread
    4. .PHONY:clean
    5. clean:
    6. rm -f mythread2

    我们发现打印出来的结果,线程id是一样的,但是显示的线程id和用-L选项查看的线程id不同。

    为什么LWP和我们查看道德线程id不同呢?

    我们查看编译文件用到了哪些库,发现libpthread是一个LSB的共享库, 我们用ps -aL查看的LWP是从内核级看到的线程id,但是上面那一大串数字查看到的线程id是pthread库的线程id,不是Linux内核中的LWP,pthread库的线程id是一个内存地址。

    既然线程库它是一个库,那么它存在磁盘中,因为线程是用进程来模拟的,每个线程都要有运行时的临时数据,每个线程都要有自己的私有栈结构。这样每个线程都有一个类似于进程的结构,那么线程库需要将磁盘中的库加载到内存中,通过页表映射,将物理地址映射成虚拟地址,加载到内存空间中。因为进程库也是一个库,所以它会被加载到堆区和栈区之间的共享内存中。每个线程都要访问这个内存,并且每个线程都会有线程库中的某些属性,这些属性都是一部分内存,那么想要访问这些属性,就要拿到这个属性的起始空间。当要查找线程属性的时候,直接从线程库中找到对应线程的虚拟地址即可。

    那么我们现在就知道了,线程id的本质,就是在动态库加载到内存之后,动态库中,某个线程属性的起点地址。


    我们对以上代码进行改造,上面理论知识我们提到,一个进程崩溃,整个进程崩溃导致其它进程也会退出,所以我们模拟实现一下一个线程崩溃的场景。

    我们循环创建五个进程,当进程数是3的时候,也就是第三个进程,我们产生野指针问题,看看有什么不一样的结果。

    1. [wjy@VM-24-9-centos thread]$ cat mythread2.c
    2. #include
    3. #include
    4. #include
    5. void* thread_run(void* args)
    6. {
    7. int num=*(int*)args;
    8. while(1)
    9. {
    10. printf("我是新线程[%d],我创建的线程id:%lu\n",num,pthread_self());
    11. sleep(3);
    12. if(num==3)
    13. {
    14. printf("thread number:%d quit\n",num);
    15. int* flag=NULL;
    16. *flag=300;
    17. }
    18. }
    19. }
    20. int main()
    21. {
    22. pthread_t tid[5];
    23. //循环创建线程,每sleep一秒,就执行新线程执行流代码。
    24. for(int i=0;i<5;i++)
    25. {
    26. pthread_create(tid+i,NULL,thread_run,(void*)&i);
    27. sleep(1);
    28. }
    29. while(1)
    30. {
    31. printf("我是主线程,我创建的线程id:%lu\n",pthread_self());
    32. //主线程将五个新线程的线程id一组打印
    33. printf("############begin#############\n");
    34. for(int i=0;i<5;i++)
    35. {
    36. printf("我是主线程,我创建的新线程的线程id:%lu\n",tid[i]);
    37. }
    38. printf("#############end################\n");
    39. sleep(1);
    40. }
    41. }

    发现在执行第三个线程之后出错了,崩溃打印了错误码和错误原因,这种就叫线程的健壮性不强。

    线程等待

    一个线程创建后也需要被等待,类似于父子进程,如果不等待有可能变成僵尸线程,当一个进程创建出来,它就是为了主线程服务的,主线程需要知道新线程的运行状态,那么就需要等待线程,线程等待涉及一个函数pthread_join。

    第一个参数是线程id,也就是上面我们看到的一大长串数字。void** retval是一个二级指针参数,他是一个输出型参数,用来获取新线程退出的时候函数的返回值,为什么是二级指针?因为新线程执行函数的返回值是一个一级指针void*,需要将函数的返回值拿出来,获取地址变量需要传二级指针。就好比,如果一个函数返回值是int整数,想要通过输出型参数拿到这个返回值,需要获取它的地址,也就是这个变量的指针,才能拿到这个输出型参数。

    用一段代码测试一下等待线程结果:

    pthread_join中第一个参数是新线程的线程id,第二个参数需要获取新线程的返回值(输出型参数)。我们设置一个void*变量,用来接收新线程执行函数返回值,void*是一个指针,32位下4字节,64位下8字节,指针变量本身就可以充当某种容器来保存数据。

    最后我们打印这个新线程返回值,直接输出status,它本身是void*的,要把它强转成int类型来输出。

    1. [wjy@VM-24-9-centos thread]$ cat mythread2.c
    2. #include
    3. #include
    4. #include
    5. void* thread_run(void* args)
    6. {
    7. int num=*(int*)args;
    8. while(1)
    9. {
    10. printf("我是新线程[%d],我创建的线程id:%lu\n",num,pthread_self());
    11. sleep(3);
    12. break;
    13. }
    14. return (void*)111;
    15. }
    16. #define NUM 1
    17. int main()
    18. {
    19. pthread_t tid[NUM];
    20. for(int i=0;i
    21. {
    22. pthread_create(tid+i,NULL,thread_run,(void*)&i);
    23. sleep(1);
    24. }
    25. void* status=NULL;
    26. pthread_join(tid[0],&status);
    27. printf("ret:%d\n",(int)status)
    28. }

    这里会有一个告警,我们直接忽略,直接编译。最后打印出我们想要的结果。

     线程可以处理代码跑完结果对,代码跑完结果不对返回错误码的情况,第三种情况代码异常了进程是不需要管的,代码异常导致程序奔溃,如果一个线程崩溃,那么整个进程都会崩溃,这个时候,主线程等待从线程已经没有必要。

    线程终止

    如果需要只终止某个线程而不终止整个进程,可以有三种方法:

    1. 从线程函数return。这种方法对主线程不适用,从main函数return相当于调用exit。

    2. 线程可以调用pthread_ exit终止自己。

    3. 一个线程可以调用pthread_ cancel终止同一进程中的另一个线程。

    线程终止方案:

    1.函数返回return,main函数退出return的时候代表(主线程和从线程一起都退出了),其它线程函数退出return,只代表当前线程退出。

    2.pthead_exit设置退出码进行退出,终止自己。

    我们并不能直接设置exit来终止线程,因为exit会将整个进程终止,而我们只是想终止新线程,主线程运行。

    3.pthread_cancel主线程取消新线程

    有时候我们发现,线程退出后的退出码是-1,这是因为-1就代表线程退出,我们可以查找一下pthread_cancel的头文件,可以发现-1就代表线程退出。

    分离线程

    当我们想要创建线程,不用等待他,它做完自己的事情直接释放,主线程也不用等待。我们就可以用到一个函数pthread_detach()。这个操作类似于信号中的sigchld。子进程退出会对父进程发送一个sigchld信号,且默认处理动作是忽略,这样就不用阻塞式等待子进程退出。

    而线程分类与之类似,主线程不关心从线程的返回值,从线程执行完直接释放。主线程不退出,新线程执行完直接退出。

    man pthread_detach查看

    SYNOPSIS
           #include

           int pthread_detach(pthread_t thread);

           Compile and link with -pthread.

    我们创建一个新线程后,让这个新线程函数直接分离pthread_detach,最后设置退出码为123。当我们在主线程测试等待新线程后,时候能拿到新线程的退出码,以及等待新线程的返回值。

    pthread_join等待线程函数中,如果等待成功返回0,等待失败返回错误码。最后我们看到结果,pthread_join的返回值不是0,是22,说明等待出错了,并且获取的新线程返回码status不是我们设置的123。也就证明了,一个线程如果被分离了,那么其它线程等待也是白等,会等待失败。

    线程互斥

    线程需要互斥原因:

    因为多个线程是共享地址的,也就是多个资源在进程内部的线程之间都是共享的。优点是:通信方便,缺点是:缺乏访问控制。

    什么是缺乏访问控制:假如定义一个全局变量,一个线程要对其做判断,另一个线程不小心将这个全局变量做了修改,导致第一个线程判断有误。像这样因为一个线程的错误问题,给其它线程造成了不可控,或者引起崩溃,异常,逻辑不正确这种现象,叫做线程安全

    所以创建一个函数没有线程安全的问题的话,不要使用全局,stl,malloc,new等会在全局内有效的数据。上面我们可以使用线程,并且没有线程安全问题,是因为上面用的都是局部变量,线程有自己独立栈结构。

    进程间互斥相关背景概念:

    1.临界资源:凡是被线程共享访问的资源都是临界资源(比如多线程,多进程打印数据到显示器上,显示器就是临界资源)。

    2.临界区:线程代码段中的访问临界资源的代码(在我的代码中,不是所有的代码都是进行访问临界资源的。而访问临界资源的代码区域我们称之为临界区)。

    3.对临界区保护功能,本质:就是对临界资源的保护。保护方式:互斥或同步

    4.互斥:在任意时刻,只允许一个执行流访问某段代码(访问某部分资源),就可以称之为互斥。

    5.原子性:这里简单提一下,后面还会提到。一个事情,要么全做完,要么不做,只有这两种状态。比如执行printf("hello world"):lock()->printf()->unlock().执行printf时加入锁,执行完printf才能解锁。如果在printf没执行完期间执行其它动作,那么加入锁失败。

    6.同步:一般而言,让访问临界资源的过程在安全的前提下(一般都是互斥and原子性的),让各个访问资源执行具有顺序性。举个例子一个线程因为优先级过高,当执行完之后,又抢占了资源,又执行这个优先级高的线程。执行完之后又抢占了资源,这样其它资源排不上队,使得其它线程一直抢占不到资源。所以同步就是为了让各个资源具有顺序性。当这个线程执行完了,还想继续抢占执行,不好意思,你要到队列后面排队。

    模拟线程抢占

    我们循环创建五个线程,每个线程中要对临界资源ticket进行抢票操作。循环抢票,当票数大于0时,就继续抢票,票数小于0时,循环结束,跳出循环。我们先不加锁,模拟抢票的操作来看看运行结果是怎样:

    1. #include
    2. #include
    3. #include
    4. #include
    5. using namespace std;
    6. int tickets=1000;//临界资源
    7. void* ThreadRoutine(void* args)
    8. {
    9. int id=*(int*)args;
    10. delete (int*)args;
    11. while(true)
    12. {
    13. if(tickets>0)
    14. {
    15. usleep(1000);
    16. cout<<"我是["<"]我要强的票是:"<
    17. tickets--;
    18. }
    19. else
    20. {
    21. //没有票
    22. break;
    23. }
    24. }
    25. }
    26. int main()
    27. {
    28. pthread_t tid[5];
    29. for(int i=0;i<5;i++)
    30. {
    31. int* id=new int(i);
    32. pthread_create(tid+i,nullptr,ThreadRoutine,id);
    33. }
    34. for(int i=0;i<5;i++)
    35. {
    36. pthread_join(tid[i],nullptr);
    37. }
    38. return 0;
    39. }

    g++ -o tickets tickets.cc -std=c++11 -lpthread编译一下文件

    运行结果:

    我们发现最后结果居然抢到了负数的票,这时为什么?

    tickets就是我们所说的临界资源,那么tickets--操作是原子的吗?并不是。为了让多个线程进行切换,线程可能在时间片到了的时候切换,也有可能是从内核转换为用户态的时候,被切换的。

    tickets作为全局变量,保存在内存中,当A线程进行ticket--操作进行运算,会将tickets数据从内存加载到CPU中,在CPU中进行tickets--运算,然后将运算结果返回给内存中保存。然而A线程将tickets数据加载到CPU的过程中,还没有运算的时候,B进程抢占到资源了,此时A线程需要将CPU中资源保存上下文,A线程将1000保存到上下文后,B进程将内存中还是1000的数据拿到,加载到CPU中开始运算,这期间没有其它线程打扰B线程,B线程一直运算到tickets为10的时候,也就是说它一个线程就抢走了900多张票。此时A线程开始抢占资源,要对tickets继续进行运算,A将它的上下文继续加载到CPU中,此时tickets又变成了1000,B抢的票全白抢了。这就解释了为什么上面示例中出现负数的情况。就是一因为一个线程还没有运算完,另一个线程拿到还没有运算完的资源再进行tickets--。这使得tickets--并非原子的,且很不安全。这也证明了在汇编级它是多行的代码。

    所以tickets--就是临界资源,if语句就是访问临界资源的代码,叫做临界区。

    下面我们来介绍锁:

    对临界区进行加锁:

    我们来认识一下线程库的原生锁,这个锁是偏底层的。用到底层的锁,需要对锁进行初始化,不需要用锁的时候,将锁销毁。

    初始化锁:

    • 通过pthread_mutex_init();这个函数pthread_mutex_t是一个全局可见的锁结构体,第一个参数要传入一个锁指针,第二个参数是互斥锁属性,一般我们设置为NULL即可。返回值:初始成功返回0,初始化错误返回错误码。
    • 当有static类型锁的时候,使用PTHREAD_MUTEX_INITIALIAER宏来进行初始化也是可以的

    销毁锁:pthread_mutex_destroy()函数,参数传入锁指针即可。

    将锁和临界资源封装成一个类:

    原生线程库中加锁和解锁函数是:pthread_mutex_lock()和pthead_mutex_unlock(),传入的参数都是锁指针

    我们将ticket和加锁解锁封装成一个类,将临界区资源封装成一个获取票资源的成员函数,多线程调用函数直接调用这个类成员函数即可。

    1. class Ticket
    2. {
    3. private:
    4. int tickets;
    5. pthread_mutex_t mtx;
    6. public:
    7. Ticket()
    8. :tickets(1000)//初始化票数1000
    9. {
    10. pthread_mutex_init(&mtx,nullptr);
    11. }
    12. bool GetTicket()
    13. {
    14. bool res=true;
    15. pthread_mutex_lock(&mtx);
    16. //临界区
    17. if(tickets>0)
    18. {
    19. usleep(1000);
    20. cout<<"["<<pthread_self()<<"]抢到的票是:"<
    21. tickets--;
    22. }
    23. else
    24. {
    25. printf("没有票了\n");
    26. res=false;
    27. }
    28. //因为tickets<=0的时候也加锁了一次,也需要解锁,所以我们将解锁函数放在外面。
    29. pthread_mutex_unlock(&mtx);
    30. return res;
    31. }
    32. ~Ticket()
    33. {
    34. pthread_mutex_destroy(&mtx);
    35. }
    36. };
    37. void* ThreadRoutine(void* args)
    38. {
    39. Ticket* t=(Ticket*)args;
    40. while(true)
    41. {
    42. if(!t->GetTicket())
    43. {
    44. break;
    45. }
    46. }
    47. }
    48. int main()
    49. {
    50. Ticket* t=new Ticket();
    51. //建立五个线程抢票
    52. pthread_t tid[5];
    53. for(int i=0;i<5;i++)
    54. {
    55. pthread_create(tid+i,nullptr,ThreadRoutine,(void*)t);
    56. }
    57. for(int i=0;i<5;i++)
    58. {
    59. pthread_join(tid[i],nullptr);
    60. }
    61. return 0;
    62. }

     运行结果:

    这里有几个问题:

            1.bool变量res是否被所有线程共享呢?不是的,因为每个线程都有自己的私有栈区,当调用了一个成员函数,已经形成了自己的栈区,和别的线程区分开来。

            2.当我要访问临界资源tickets的时候,需要先访问mtx互斥锁,那么锁本身也是一个临界资源,被所有的线程去抢占,那么如何保证锁也是安全的?   锁的原理:lock()/unlock()-->锁是原子的。只有在汇编代码只有一行的时候,才是原子的。

            3.当加了锁之后,if语句那部分代码执行流就是互斥的,是穿行执行的!

    上面用的是原生互斥锁,是系统界别的。那么在C++中也有互斥锁,C++级别的互斥锁是在原生互斥锁的基础上将它封装,需要包含头文件#include .C++加锁解锁,不需要初始化锁和销毁锁,因为已经封装在库里面了。

    1. class Ticket
    2. {
    3. private:
    4. int tickets;
    5. std::mutex mymtx;
    6. public:
    7. Ticket()
    8. :tickets(1000)
    9. {
    10. }
    11. bool GetTicket()
    12. {
    13. bool res=true;
    14. mymtx.lock();
    15. //临界区
    16. if(tickets>0)
    17. {
    18. usleep(1000);
    19. cout<<"["<<pthread_self()<<"]抢到的票是:"<
    20. tickets--;
    21. }
    22. else
    23. {
    24. printf("没有票了\n");
    25. res=false;
    26. }
    27. mymtx.unlock();
    28. return res;
    29. }
    30. };

    互斥量实现原理探究

    当我们定义一个锁变量,那么就有一个锁了,申请锁会将锁变量lock--,这时候锁被拿走了,锁变成0.当锁释放,lock++,又变成一个。好了知道这个我们继续来看,为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令是用来将寄存器和内存单元的数据交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待上一个总线周期执行完。我们来看一下下面lock和unlock的伪代码:

    我们申请了一个锁变量存储在内存中,此时内存中的mutex==1,lock中第一行指令用来初始化寄存器的,也就是说假如说有两个线程:线程A和线程B来竞争锁,当线程A竞争锁的时候,需要将寄存器初始化为0,申请的锁变量存储在内存中,lock的第二句指令:将寄存器%al中的数据和内存mutex做交换,CPU寄存器就有了一个锁变量,内存中的锁变量变为0。

    当CPU执行线程A代码的时候,CPU内寄存器内的数据是线程A私有的,当A线程被切换走的时候,寄存器中数据被保存到A的上下文中,锁变量也被保存到上下文中了。也就好比说,当我走进一间教室,我有钥匙,当我走的时候我将钥匙拿走,别人想进也没有钥匙进不去。所以当B进程竞争锁的时候,B进程虽然会先执行lock第一句汇编代码,将寄存器初始化为0,A就算没有把所有寄存器内容保存到上下文,在寄存器中有所残留,第一句汇编代码将以前的内容都覆盖,还是初始化为0,但是当执行到第二句汇编代码,内存中的锁变量已经被A进程拿走,执行exchange它抢不到锁,从而被阻塞。在下面的if/else语句中我们也看到,当寄存器中有内容的时候,也就是有锁的时候,虽然会返回,但是返回后只有A进程抢到锁,A进程返回后再继续执行,再执行goto lock继续执行临界区代码;B进程因为寄存器内容是0,所以被挂起。

    mutex的本质:通过一条汇编,将数据交换到自己的上下文数据中。(执行到return 0的时候,就会交换到自己的上下文)

    在上面我们写的代码实例中,通过if/else语句实现抢票功能,这个临界区代码完全是有可能被切换的,线程被切走的时候我们需要做的就是,保护上下文,而锁数据也是在上下文中的!拥有锁的线程,被切走后,是抱着锁走的。在此期间,其它线程休想申请锁成功,休想进入临界区!

    站在其它线程的视角,A线程要么没申请锁,要么线程A使用完锁--线程A访问的临界区是具有原子性的。

    代码是程序员写的,为了保证临界区的安全,必须保证每个线程都遵守相同的编码规范(A申请锁,其它线程也要申请锁,但是申请不到,会一直阻塞)

    常见的线程安全的情况

    可重入和线程安全

    线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或静态变量进行操作,并且没有锁的保护下,会出现该问题。

    重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们 称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。

    线程安全问题

    常见线程不安全情况

    • 不保护共享变量的函数
    • 函数状态随着被调用,状态发生变化的函数
    • 返回指向静态变量指针的函数
    • 调用线程不安全函数的函数

    常见线程安全情况

    • 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
    • 类或者接口对于线程来说都是原子操作
    • 多个线程之间的切换不会导致该接口的执行结果存在二义性

    常见重入的情况:

    常见不可重入情况

    • 用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
    • 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
    • 可重入函数体内使用了静态的数据结构

    常见可重入情况

    • 不使用全局变量或静态变量
    • 不使用用malloc或者new开辟出的空间
    • 不调用不可重入函数
    • 不返回静态或全局数据,所有数据都有函数的调用者提供
    • 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据

    可重入与线程安全联系

    • 函数是可重入的,那就是线程安全的
    • 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
    • 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的

    可重入与线程安全区别

    • 可重入函数是线程安全函数的一种
    • 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
    • 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生 死锁,因此是不可重入的

    常见锁概念

    死锁

    死锁是指一组进程中各个进程均占有不会释放的资源,但因为互相申请其它进程所占用不会释放的资源而导致的一种永久等待状态。

    死锁四个必要条件

    • 互斥条件:一个资源每次只能被一个执行流使用
    • 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
    • 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
    • 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系,形成了环路等待的状态。假设有三个线程,A等待B还未释放的紫云啊,B等待C还未释放的资源,C等待A还未释放的资源。

    避免死锁

    • 破坏死锁的四个必要条件
    • 加锁顺序一致
    • 避免锁未释放的场景
    • 资源一次性分配
  • 相关阅读:
    编写程序将一个子串插入到主串中
    【数据结构】单链表OJ题(二)
    Edge被2345浏览器劫持 解决方法
    中国高级测试经理对敏捷测试的理解
    Python+OpenCV实用案例应用教程:处理文件、摄像头和GUI
    大数据毕业设计可视化大屏前后端项目分享
    植物大战 string——C++
    echarts图从隐藏到显示以后大小有问题的解决方法
    【Leetcode】202. 两数之和
    猿创征文|浅谈ES-关于ES你了解多少?
  • 原文地址:https://blog.csdn.net/qq_53413129/article/details/125927159